fix(agent): capture Windows Bash output and pass the subprocess env

The Windows branch of `_create_bash_subprocess` spawned Git Bash with
neither pipes nor the env it was handed. `proc.stdout` and `proc.stderr`
came back `None`, so `_run_subprocess_streaming`'s reader returned
immediately and the Bash tool reported `"(no output)"` alongside the real
exit code — while the child inherited the server's own stdout/stderr and
wrote agent command output into the console and the launchd/Docker logs.

The `env` parameter was accepted and never used, so `PATH`, `VIRTUAL_ENV`,
`HOME`, `TMPDIR` and the configured import paths carried in
`ctx["subproc_env"]` never reached the child on Windows, even though every
POSIX path applies them.

Spawn it the way the POSIX path at `:688` already does: `stdin=DEVNULL`,
`stdout=PIPE`, `stderr=PIPE`, `env=env`.

`website/configuration-reference.md` is generated from source line numbers,
so the four added lines shift one entry; regenerated with
`scripts/generate_env_reference.py`.
This commit is contained in:
Léo
2026-10-01 16:48:05 +02:00
parent 05c6cfcfa1
commit 34f01c0b58
3 changed files with 83 additions and 1 deletions
+4
View File
@@ -117,6 +117,10 @@ async def _create_bash_subprocess(
bash,
"-c",
str(command or ""),
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
cwd=cwd,
)
kwargs = {"cwd": cwd} if cwd is not None else {}
+78
View File
@@ -1,5 +1,7 @@
"""Windows execution contract for the agent Bash tool."""
import asyncio
import pytest
from types import SimpleNamespace
@@ -38,6 +40,82 @@ async def test_windows_bash_uses_git_bash_with_structural_cwd(monkeypatch):
assert captured["kwargs"]["cwd"] == workspace
@pytest.mark.asyncio
async def test_windows_bash_captures_output_instead_of_inheriting_server_handles(monkeypatch):
captured = {}
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
monkeypatch.setattr(
subprocess_tools, "find_bash", lambda: r"C:\Program Files\Git\bin\bash.exe"
)
async def fake_exec(*_argv, **kwargs):
captured.update(kwargs)
return object()
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fake_exec)
await subprocess_tools._create_bash_subprocess("pwd", cwd=r"C:\Work")
assert captured["stdout"] == asyncio.subprocess.PIPE
assert captured["stderr"] == asyncio.subprocess.PIPE
assert captured["stdin"] == asyncio.subprocess.DEVNULL
@pytest.mark.asyncio
async def test_windows_bash_applies_the_subprocess_env(monkeypatch):
captured = {}
env = {"PATH": r"C:\Odysseus\venv\Scripts", "HOME": r"C:\Odysseus\data"}
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
monkeypatch.setattr(
subprocess_tools, "find_bash", lambda: r"C:\Program Files\Git\bin\bash.exe"
)
async def fake_exec(*_argv, **kwargs):
captured.update(kwargs)
return object()
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fake_exec)
await subprocess_tools._create_bash_subprocess("pwd", cwd=r"C:\Work", env=env)
assert captured["env"] == env
@pytest.mark.asyncio
async def test_windows_bash_tool_passes_ctx_env_through_to_the_child(monkeypatch):
captured = {}
env = {"PATH": r"C:\Odysseus\venv\Scripts", "VIRTUAL_ENV": r"C:\Odysseus\venv"}
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
monkeypatch.setattr(
subprocess_tools, "find_bash", lambda: r"C:\Program Files\Git\bin\bash.exe"
)
monkeypatch.setattr("src.tool_execution.agent_cwd", lambda: r"D:\Workspaces\Project")
async def fake_exec(*argv, **kwargs):
captured["argv"] = argv
captured["kwargs"] = kwargs
return SimpleNamespace(pid=4242)
async def fake_stream(_process, **_kwargs):
return "ok", "", 0, False
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fake_exec)
monkeypatch.setattr(subprocess_tools, "_run_subprocess_streaming", fake_stream)
result = await subprocess_tools.BashTool().execute(
"pwd",
{"subproc_env": env, "session_id": "chat-1"},
)
assert result == {"output": "ok", "exit_code": 0}
assert captured["kwargs"]["env"] == env
assert captured["kwargs"]["stdout"] == asyncio.subprocess.PIPE
assert captured["kwargs"]["stderr"] == asyncio.subprocess.PIPE
@pytest.mark.asyncio
async def test_windows_bash_without_git_bash_fails_clearly(monkeypatch):
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
+1 -1
View File
@@ -75,7 +75,7 @@ The source tree reads **108** `ODYSSEUS_*` variables: 78 an operator may want to
| `ODYSSEUS_MAX_VISUAL_EVIDENCE_FRAMES` | `'3'` | `src/agent_loop.py:15361` | How many video frames one tool result may contribute. Clamped to 1-8. |
| `ODYSSEUS_MAX_VISUAL_EVIDENCE_IMAGES` | `'1'` | `src/agent_loop.py:15329` | How many images one tool result may contribute to the model turn. Clamped to 1-8. |
| `ODYSSEUS_MCP_ALLOWED_COMMANDS` | `''` | `src/agent_tools/admin_tools.py:140` | Security-relevant. Comma-separated allowlist of MCP launcher basenames the agent may start. Empty by default, and the deny list still wins. |
| `ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES` | `''` | `src/agent_tools/subprocess_tools.py:927` | Security-relevant. Absolute package roots, separated by the platform path separator, exposed to the sandboxed Python tool. Empty exposes none. |
| `ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES` | `''` | `src/agent_tools/subprocess_tools.py:931` | Security-relevant. Absolute package roots, separated by the platform path separator, exposed to the sandboxed Python tool. Empty exposes none. |
| `ODYSSEUS_SCRIPT_HOST` | `'localhost'` | `src/builtin_actions.py:919` | Default host for the run-script action. `localhost`, `127.0.0.1`, `local` and empty run locally; any other value runs over SSH. |
| `ODYSSEUS_TOOL_APPROVAL_GATE` | `'0'` | `src/tool_capabilities.py:645` | Security-relevant. Truthy makes tool calls pass through the approval gate. Off by default. |