From 4bec9eb477a878208003c7b91f9fbcedb5e3baee Mon Sep 17 00:00:00 2001 From: Georgyhongbo <2280628443@qq.com> Date: Wed, 12 Aug 2026 16:52:38 +0800 Subject: [PATCH 1/3] fix(backend): inherit host env for Windows local_shell so execute works deepagents LocalShellBackend defaults inherit_env=False, so every agent execute() subprocess starts with an empty environment (no PATH/SystemRoot) and external tools (python/curl/ffmpeg/edge-tts) are unfindable. POSIX sh fills in a default PATH, so the breakage is effectively Windows-specific. Inject inherit_env=True into the Windows default agent backend spec (default_agent_backend_spec) and, via setdefault, into windows_neutralize_host_root for configured local_shell specs. An explicit inherit_env=false (empty-env sandbox) is preserved. Filesystem backends are skipped: no execute tool, and its constructor rejects the kwarg. Adds unit tests covering local_shell injection, filesystem skip, and explicit-override preservation. Verified end-to-end: a fresh agent thread now runs venv edge-tts via execute and delivers a valid MP3. --- CHANGELOG.md | 3 + src/octop/infra/backend/resolver.py | 75 +++++++++++++++++-------- tests/unit/agents/test_agent_manager.py | 4 +- tests/unit/backend/test_resolver.py | 40 ++++++++++++- 4 files changed, 95 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7159128c..188eb360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ ## [Unreleased] +### 修复 +- Windows 下本地 agent 的 `execute` 工具无法运行任何外部命令(python/curl/ffmpeg/edge-tts 均报"找不到"):deepagents `LocalShellBackend` 默认 `inherit_env=False`,子进程继承空环境(无 `PATH`/`SystemRoot`)。现于 Windows 默认 backend spec(`default_agent_backend_spec`)与 `windows_neutralize_host_root` 归一化中注入 `inherit_env`(`setdefault`,显式配置 `inherit_env: false` 的沙箱选择仍被保留;仅 `local_shell`,`filesystem` 不接受该参数) + ## [0.9.22] - 2026-08-11 ### 新增 diff --git a/src/octop/infra/backend/resolver.py b/src/octop/infra/backend/resolver.py index fc988789..d45658c3 100644 --- a/src/octop/infra/backend/resolver.py +++ b/src/octop/infra/backend/resolver.py @@ -19,6 +19,13 @@ def default_agent_backend_spec(workspace_dir: Path) -> dict[str, Any]: ``root_dir='/'`` resolves to the process current-drive root, which often differs from the drive hosting ``workspace_dir`` — scope the default to the agent workspace instead. + + Windows also sets ``inherit_env=True``: deepagents ``LocalShellBackend`` + defaults to an *empty* subprocess environment (no ``PATH``/``SystemRoot``), + which makes every external tool — python/curl/ffmpeg/edge-tts — unfindable + from the agent's ``execute`` tool. This is the path a brand-new agent (no + explicit ``backend`` config) takes, so it is where the new-user default + must already work. """ from harness_agent.backends import DEFAULT_BACKEND_SPEC # noqa: PLC0415 @@ -27,41 +34,61 @@ def default_agent_backend_spec(workspace_dir: Path) -> dict[str, Any]: "type": "local_shell", "root_dir": str(workspace_dir.resolve()), "virtual_mode": True, + # Without this every execute() subprocess starts with an empty env + # (no PATH/SystemRoot), so python/curl/ffmpeg/edge-tts are unfindable. + "inherit_env": True, } return dict(DEFAULT_BACKEND_SPEC) def windows_neutralize_host_root(spec: Any, *, workspace_dir: Path) -> Any: - """Windows: rewrite local backends rooted at ``/`` to the agent workspace. - - ``root_dir: "/"`` is the dashboard's default for local backends. On Windows - it resolves to the *current drive root* (often a different drive than the - agent workspace), so deepagents virtual-path checks reject every workspace - path with ``Path ... outside root directory``. Only ``root_dir`` is rewritten - so ``type`` / ``virtual_mode`` and other fields stay intact. - - Applies to top-level ``local_shell`` / ``filesystem`` specs and to the - ``default`` of composite specs (the default is what anchors the agent - workspace). Route sub-backends are user-pinned and left untouched — a route - root of ``/`` is the caller's explicit choice and harmless to loading. + """Windows: normalize local host backends so they actually work on the host. + + This is the single normalization point for Windows ``local_shell`` / + ``filesystem`` agent backends, correcting two defaults that are broken on + Windows. Applies to top-level specs, composite ``default`` subspecs, and + the agent-config path that doesn't go through ``default_agent_backend_spec``. + Route sub-backends are user-pinned and left untouched — a route root of + ``/`` is the caller's explicit choice and harmless to loading. + + 1. Host-root rewrite: ``root_dir: "/"`` is the dashboard's default for local + backends. On Windows it resolves to the *current drive root* (often a + different drive than the agent workspace), so deepagents virtual-path checks + reject every workspace path with ``Path ... outside root directory``. Only + ``root_dir`` is rewritten; ``type`` / ``virtual_mode`` stay intact. + + 2. Empty subprocess env: deepagents ``LocalShellBackend`` defaults + ``inherit_env=False``, so without intervention every subprocess starts with + an empty environment (no ``PATH``/``SystemRoot``) and common tools + (python/curl/ffmpeg/edge-tts) are unfindable. On POSIX ``sh`` fills in a + default ``PATH``, so the breakage is effectively Windows-specific. Inject + ``inherit_env`` so the parent server environment is visible to agent shell + commands — but only as a *default*: an explicitly pinned ``inherit_env`` is + preserved, so a user who deliberately wants an empty-env sandbox can still + set it to ``False``. (``filesystem`` never gets this: it has no execute tool + and its constructor rejects the kwarg.) """ if os.name != "nt": return spec if not isinstance(spec, dict): return spec kind = spec.get("type") - if kind in ("local_shell", "filesystem") and _is_host_root(spec.get("root_dir")): - return {**spec, "root_dir": str(workspace_dir.resolve())} - if ( - kind == "composite" - and isinstance(spec.get("default"), dict) - and _is_host_root(spec["default"].get("root_dir")) - ): - default = spec["default"] - return { - **spec, - "default": {**default, "root_dir": str(workspace_dir.resolve())}, - } + if kind in ("local_shell", "filesystem"): + out = dict(spec) + if _is_host_root(spec.get("root_dir")): + out["root_dir"] = str(workspace_dir.resolve()) + # local_shell runs shell commands on the host; deepagents defaults + # inherit_env=False, so without this every subprocess has an empty env + # (no PATH/SystemRoot) and python/curl/ffmpeg/edge-tts are unfindable. + # filesystem has no execute tool and its constructor rejects this kwarg. + # setdefault keeps an explicit user override (inherit_env: false). + if kind == "local_shell": + out.setdefault("inherit_env", True) + return out + if kind == "composite" and isinstance(spec.get("default"), dict): + new_default = windows_neutralize_host_root(spec["default"], workspace_dir=workspace_dir) + if new_default is not spec["default"]: + return {**spec, "default": new_default} return spec diff --git a/tests/unit/agents/test_agent_manager.py b/tests/unit/agents/test_agent_manager.py index 64a1bb03..6fc9d3a7 100644 --- a/tests/unit/agents/test_agent_manager.py +++ b/tests/unit/agents/test_agent_manager.py @@ -502,6 +502,7 @@ def test_backend_spec_for_row_neutralizes_host_root_on_windows( "type": "local_shell", "root_dir": str(ws.resolve()), "virtual_mode": True, + "inherit_env": True, } @@ -518,7 +519,8 @@ def test_build_harness_config_keeps_fs_permissions_for_local_shell_guard( cfg = manager._build_harness_config( _row(config_json=json.dumps({"backend": {"type": "local_shell", "virtual_mode": True}})), ) - assert cfg.backend == {"type": "local_shell", "virtual_mode": True} + # Windows injects inherit_env so local_shell subprocesses see the host PATH. + assert cfg.backend == {"type": "local_shell", "virtual_mode": True, "inherit_env": True} assert cfg.permissions is not None middleware = cfg.middleware or [] assert not any(isinstance(item, FilesystemGuardMiddleware) for item in middleware) diff --git a/tests/unit/backend/test_resolver.py b/tests/unit/backend/test_resolver.py index 2c84e16a..5b62b3e6 100644 --- a/tests/unit/backend/test_resolver.py +++ b/tests/unit/backend/test_resolver.py @@ -63,6 +63,7 @@ def test_default_agent_backend_spec_windows_scopes_to_workspace(tmp_path: Path) "type": "local_shell", "root_dir": str(ws.resolve()), "virtual_mode": True, + "inherit_env": True, } @@ -100,9 +101,27 @@ def test_windows_neutralize_local_shell_host_root_scopes_to_workspace( "type": "local_shell", "root_dir": str(ws.resolve()), "virtual_mode": True, + "inherit_env": True, } +def test_windows_neutralize_local_shell_injects_inherit_env(tmp_path: Path) -> None: + # local_shell executes on the host; without inherit_env its subprocesses have + # an empty PATH and cannot find python/curl/ffmpeg/edge-tts. + spec = {"type": "local_shell", "virtual_mode": True} + with patch("octop.infra.backend.resolver.os", SimpleNamespace(name="nt")): + out = windows_neutralize_host_root(spec, workspace_dir=tmp_path) + assert out == {"type": "local_shell", "virtual_mode": True, "inherit_env": True} + + +def test_windows_neutralize_filesystem_skips_inherit_env(tmp_path: Path) -> None: + # FilesystemBackend has no execute tool and its constructor rejects inherit_env. + spec = {"type": "filesystem", "virtual_mode": True} + with patch("octop.infra.backend.resolver.os", SimpleNamespace(name="nt")): + out = windows_neutralize_host_root(spec, workspace_dir=tmp_path) + assert out == spec + + def test_windows_neutralize_filesystem_empty_root_scopes_to_workspace( tmp_path: Path, ) -> None: @@ -125,11 +144,22 @@ def test_windows_neutralize_keeps_explicit_drive_root(tmp_path: Path) -> None: spec = {"type": "local_shell", "root_dir": "D:\\develop", "virtual_mode": True} with patch("octop.infra.backend.resolver.os", SimpleNamespace(name="nt")): out = windows_neutralize_host_root(spec, workspace_dir=tmp_path) - assert out == spec + # Explicit drive root is preserved; inherit_env is still injected so the + # host shell commands can find python/curl/etc. + assert out == {**spec, "inherit_env": True} def test_windows_neutralize_keeps_missing_root_dir(tmp_path: Path) -> None: spec = {"type": "local_shell", "virtual_mode": True} + with patch("octop.infra.backend.resolver.os", SimpleNamespace(name="nt")): + out = windows_neutralize_host_root(spec, workspace_dir=tmp_path) + assert out == {**spec, "inherit_env": True} + + +def test_windows_neutralize_preserves_explicit_inherit_env(tmp_path: Path) -> None: + # A user who deliberately wants the empty-env sandbox opts out explicitly; + # the injected default must not override that choice. + spec = {"type": "local_shell", "virtual_mode": True, "inherit_env": False} with patch("octop.infra.backend.resolver.os", SimpleNamespace(name="nt")): out = windows_neutralize_host_root(spec, workspace_dir=tmp_path) assert out == spec @@ -149,6 +179,7 @@ def test_windows_neutralize_composite_default_host_root_scoped(tmp_path: Path) - "type": "local_shell", "root_dir": str(ws.resolve()), "virtual_mode": True, + "inherit_env": True, } assert out["routes"] == {} @@ -167,4 +198,9 @@ def test_windows_neutralize_composite_healthy_default_kept(tmp_path: Path) -> No } with patch("octop.infra.backend.resolver.os", SimpleNamespace(name="nt")): out = windows_neutralize_host_root(spec, workspace_dir=tmp_path) - assert out == spec + # Only the default sub-backend is touched (inherits env); routes stay as-is. + assert out == { + "type": "composite", + "default": {"type": "local_shell", "virtual_mode": True, "inherit_env": True}, + "routes": spec["routes"], + } From 355988138898bc8c53299e3e3cb591246219d7ce Mon Sep 17 00:00:00 2001 From: Georgyhongbo <2280628443@qq.com> Date: Wed, 12 Aug 2026 18:13:44 +0800 Subject: [PATCH 2/3] fix(backend): tolerate native GBK output and drive paths in agent execute Two Windows-only defects in the harness stack made agent shell commands unusable on Chinese Windows: 1. deepagents LocalShellBackend.execute runs subprocesses with text=True (strict UTF-8). Under PYTHONUTF8=1 the pipe reader crashes with UnicodeDecodeError when native tools (dir, git, ffmpeg, curl) emit GBK/CP936, dropping the entire output and returning a spurious exit code - the confusion that sent agents into endless self-diagnosis loops. Fixed with a bytes-mode reader + errors=replace decode. 2. harness_agent's virtual-path rewrite treats the / after a drive letter as a virtual path start, so C:/Users/... became C:''\Users\... and every absolute Windows path failed with file not found. Tightened the bare-token lookbehind to also exclude : and backslash. Patches applied idempotently at import (Windows only) via octop.infra.backend.windows_execute. --- CHANGELOG.md | 1 + src/octop/infra/backend/__init__.py | 8 + src/octop/infra/backend/windows_execute.py | 185 +++++++++++++++++++++ tests/unit/backend/test_windows_execute.py | 127 ++++++++++++++ 4 files changed, 321 insertions(+) create mode 100644 src/octop/infra/backend/windows_execute.py create mode 100644 tests/unit/backend/test_windows_execute.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 188eb360..d8521d29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 修复 - Windows 下本地 agent 的 `execute` 工具无法运行任何外部命令(python/curl/ffmpeg/edge-tts 均报"找不到"):deepagents `LocalShellBackend` 默认 `inherit_env=False`,子进程继承空环境(无 `PATH`/`SystemRoot`)。现于 Windows 默认 backend spec(`default_agent_backend_spec`)与 `windows_neutralize_host_root` 归一化中注入 `inherit_env`(`setdefault`,显式配置 `inherit_env: false` 的沙箱选择仍被保留;仅 `local_shell`,`filesystem` 不接受该参数) +- Windows 下 agent `execute` 输出丢失导致模型陷入自诊循环:deepagents 以 `text=True` 严格按 UTF-8 解码子进程管道,中文 Windows 原生工具(`dir`/git/ffmpeg/curl)输出 GBK/CP936,读取线程抛 `UnicodeDecodeError` 后整段输出被丢弃、并附赠错误退出码;且 harness 的虚拟路径改写会把 `C:/…` 盘符路径当成虚拟根路径改写坏。现以 bytes 模式读取 + `errors="replace"` 宽容解码(Python 子进程继承 `PYTHONUTF8=1` 输出 UTF-8,仍干净解码),并收紧改写正则的 bare-token 前瞻(排除 `:`/`\`),`C:\…`/`C:/…` 原样保留、`/AGENTS.md` 等虚拟路径仍映射到工作区。新增 `octop.infra.backend.windows_execute`(导入时幂等打补丁,仅 Windows) ## [0.9.22] - 2026-08-11 diff --git a/src/octop/infra/backend/__init__.py b/src/octop/infra/backend/__init__.py index 554b4fa9..8e3ab353 100644 --- a/src/octop/infra/backend/__init__.py +++ b/src/octop/infra/backend/__init__.py @@ -8,6 +8,14 @@ from octop.infra.backend.adapter import row_to_backend_spec from octop.infra.backend.probe import probe_storage_backend from octop.infra.backend.resolver import default_agent_backend_spec, resolve_agent_backend_spec +from octop.infra.backend.windows_execute import apply as _apply_windows_execute_patch + +# Windows: make agent local_shell ``execute`` tolerate native GBK/CP936 +# subprocess output (deepagents reads text pipes as strict UTF-8 and drops the +# whole result on the first non-UTF-8 byte) and stop the harness virtual-path +# rewrite from mangling ``C:\…`` / ``C:/…`` drive paths. Applied at import so +# every backend built afterwards runs the patched implementation. +_apply_windows_execute_patch() __all__ = [ "default_agent_backend_spec", diff --git a/src/octop/infra/backend/windows_execute.py b/src/octop/infra/backend/windows_execute.py new file mode 100644 index 00000000..de765581 --- /dev/null +++ b/src/octop/infra/backend/windows_execute.py @@ -0,0 +1,185 @@ +"""Windows harness ``execute`` compat: native GBK output + drive-path rewriting. + +Two Windows-only defects in the third-party harness stack make agent shell +commands unusable on Chinese Windows; this module monkeypatches both. Applied +idempotently at import by :func:`apply` — see ``octop.infra.backend.__init__``. + +1. **Encoding crash** — deepagents :class:`LocalShellBackend.execute` runs + subprocesses with ``text=True`` (strict decode). On a server launched under + ``PYTHONUTF8=1`` the pipe is decoded as strict UTF-8; Chinese-Windows + native tools (``cmd`` builtins like ``dir``, git, ffmpeg, curl) emit + GBK/CP936 bytes, so the pipe reader thread raises ``UnicodeDecodeError``, + the whole output is dropped, and the agent sees ```` with a + spurious non-zero exit code — the exact confusion that sends agents into an + endless self-diagnosis loop. Fixed with a bytes-mode reader + + ``errors="replace"`` decode: Python children inherit ``PYTHONUTF8=1`` and + emit UTF-8 (decodes cleanly); non-UTF-8 bytes degrade to U+FFFD instead of + dropping the entire stream. + +2. **Drive-path rewriting** — harness_agent ``BubbledLocalShellBackend`` + rewrites absolute path tokens in ``execute`` commands onto ``root_dir`` for + virtual-mode filesystem alignment. Its tokenizer regex treats the ``/`` + after a drive letter as the start of a virtual path, so ``C:/Users/...`` + becomes ``C:''\\Users\\...`` and every absolute Windows path fails + with "file not found". The bare-token lookbehind is tightened to also + exclude ``:`` (and ``\\``), leaving ``C:\\...`` / ``C:/...`` untouched while + genuine virtual paths (``cat /AGENTS.md``) still map onto ``root_dir``. +""" + +from __future__ import annotations + +import os +import re +import subprocess + +from deepagents.backends.local_shell import LocalShellBackend +from deepagents.backends.protocol import ExecuteResponse + +_APPLIED = False + + +def _patched_execute( + self: LocalShellBackend, + command: str, + *, + timeout: int | None = None, +) -> ExecuteResponse: + """Mirror deepagents ``LocalShellBackend.execute`` with tolerant decoding. + + Kept as a self-contained copy (rather than a wrapper) because the original + builds ``subprocess.run(..., text=True)`` inline and there is no hook to + pass ``errors`` through. Mirrors deepagents 0.6.x behaviour exactly; only + the reader differs (bytes mode + ``errors="replace"``). + """ + if not command or not isinstance(command, str): + return ExecuteResponse( + output="Error: Command must be a non-empty string.", + exit_code=1, + truncated=False, + ) + + effective_timeout = timeout if timeout is not None else self._default_timeout + if effective_timeout <= 0: + msg = f"timeout must be positive, got {effective_timeout}" + raise ValueError(msg) + + try: + result = subprocess.run( # noqa: S602 + command, + check=False, + shell=True, # Intentional: designed for LLM-controlled shell execution + capture_output=True, + stdin=subprocess.DEVNULL, # Prevent hanging on commands that read stdin + # bytes mode: the reader thread does no decoding, so native + # GBK/CP936 output on Chinese Windows can never crash it. + env=self._env, + cwd=str(self.cwd), + timeout=effective_timeout, + ) + + stdout = _decode_stream(result.stdout) + stderr = _decode_stream(result.stderr) + + # Combine stdout and stderr; prefix each stderr line for attribution. + output_parts = [] + if stdout: + output_parts.append(stdout) + if stderr: + stderr_lines = stderr.strip().split("\n") + output_parts.extend(f"[stderr] {line}" for line in stderr_lines) + + output = "\n".join(output_parts) if output_parts else "" + + truncated = False + if len(output) > self._max_output_bytes: + output = output[: self._max_output_bytes] + output += f"\n\n... Output truncated at {self._max_output_bytes} bytes." + truncated = True + + if result.returncode != 0: + output = f"{output.rstrip()}\n\nExit code: {result.returncode}" + + return ExecuteResponse( + output=output, + exit_code=result.returncode, + truncated=truncated, + ) + except subprocess.TimeoutExpired: + if timeout is not None: + msg = ( + f"Error: Command timed out after {effective_timeout} seconds " + "(custom timeout). The command may be stuck or require more time." + ) + else: + msg = ( + f"Error: Command timed out after {effective_timeout} seconds. " + "For long-running commands, re-run using the timeout parameter." + ) + return ExecuteResponse(output=msg, exit_code=124, truncated=False) + except Exception as e: # noqa: BLE001 + # Broad exception catch is intentional: return a consistent + # ExecuteResponse rather than propagating exceptions. + return ExecuteResponse( + output=f"Error executing command ({type(e).__name__}): {e}", + exit_code=1, + truncated=False, + ) + + +def _decode_stream(data: bytes) -> str: + """Decode subprocess bytes, mirroring ``text=True`` universal-newlines. + + ``subprocess.run(..., text=True)`` (as the original deepagents + implementation uses) reads through a ``TextIOWrapper`` with universal + newlines, so CRLF output from Windows native tools arrives as ``\\n``. + Decoding raw bytes preserves ``\\r\\n``; normalize to match, while + ``errors="replace"`` keeps non-UTF-8 GBK/CP936 bytes from crashing the + reader thread (they degrade to U+FFFD instead of dropping the stream). + """ + if not data: + return "" + text = data.decode("utf-8", errors="replace") + return text.replace("\r\n", "\n").replace("\r", "\n") + + +def _patch_drive_path_rewriting() -> bool: + """Stop harness_agent from rewriting Windows drive paths in commands. + + Tightens the bare-token lookbehind in ``_ABS_TOKEN_RE`` so a ``/`` right + after a drive letter (``C:/…``) or a backslash (``C:\\…``) is not treated + as the start of a virtual absolute path. Genuine virtual paths still map. + """ + from harness_agent.backends import bwrap_shell # local import: lazy + + pattern = bwrap_shell._ABS_TOKEN_RE.pattern + old = r"(? bool: + """Apply Windows harness ``execute`` compatibility patches. + + Idempotent: returns True when a patch was applied and False when nothing + changed (already applied, or non-Windows where native output is UTF-8 and + the original readers are correct). + """ + global _APPLIED + if _APPLIED: + return False + if os.name != "nt": + return False + LocalShellBackend.execute = _patched_execute # type: ignore[method-assign] + _patch_drive_path_rewriting() + _APPLIED = True + return True + + +__all__ = ["apply"] diff --git a/tests/unit/backend/test_windows_execute.py b/tests/unit/backend/test_windows_execute.py new file mode 100644 index 00000000..7fea47ff --- /dev/null +++ b/tests/unit/backend/test_windows_execute.py @@ -0,0 +1,127 @@ +"""Tests for Windows harness execute compatibility patches.""" + +from __future__ import annotations + +import os +import re +import subprocess +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from harness_agent.backends import bwrap_shell + +from octop.infra.backend import windows_execute + + +def _fake_backend(tmp_path: Path) -> SimpleNamespace: + return SimpleNamespace( + _default_timeout=30, + _max_output_bytes=100_000, + _env=os.environ.copy(), + cwd=str(tmp_path), + ) + + +def test_patched_execute_decodes_utf8_cleanly(tmp_path: Path) -> None: + backend = _fake_backend(tmp_path) + fake_run = SimpleNamespace(stdout=b"\xe4\xbd\xa0\xe5\xa5\xbd\n", stderr=b"", returncode=0) + with patch("octop.infra.backend.windows_execute.subprocess.run", return_value=fake_run): + res = windows_execute._patched_execute(backend, "python tts.py") + assert res.exit_code == 0 + assert res.output == "你好\n" + + +def test_patched_execute_normalizes_crlf_like_text_mode(tmp_path: Path) -> None: + # Original deepagents uses subprocess.run(..., text=True), whose universal + # newlines collapse CRLF from Windows native tools to LF. Bytes mode must + # mirror that so output stays identical for valid UTF-8 streams. + backend = _fake_backend(tmp_path) + fake_run = SimpleNamespace(stdout=b"hi\r\nline2\r\n", stderr=b"", returncode=0) + with patch("octop.infra.backend.windows_execute.subprocess.run", return_value=fake_run): + res = windows_execute._patched_execute(backend, "dir") + assert res.exit_code == 0 + assert res.output == "hi\nline2\n" + + +def test_patched_execute_tolerates_gbk_bytes(tmp_path: Path) -> None: + # Chinese-Windows native tools emit GBK; the strict UTF-8 reader used to + # crash and drop the whole output. The patched reader must not raise. + backend = _fake_backend(tmp_path) + fake_run = SimpleNamespace(stdout="中".encode("gbk"), stderr=b"", returncode=0) + with patch("octop.infra.backend.windows_execute.subprocess.run", return_value=fake_run): + res = windows_execute._patched_execute(backend, "dir") + assert res.exit_code == 0 + assert "\ufffd" in res.output + assert "" not in res.output + + +def test_patched_execute_prefixes_stderr_and_exit_code(tmp_path: Path) -> None: + backend = _fake_backend(tmp_path) + fake_run = SimpleNamespace(stdout=b"out", stderr=b"err1\nerr2", returncode=1) + with patch("octop.infra.backend.windows_execute.subprocess.run", return_value=fake_run): + res = windows_execute._patched_execute(backend, "bad cmd") + assert res.exit_code == 1 + assert res.output == "out\n[stderr] err1\n[stderr] err2\n\nExit code: 1" + + +def test_patched_execute_timeout_returns_124(tmp_path: Path) -> None: + backend = _fake_backend(tmp_path) + with patch( + "octop.infra.backend.windows_execute.subprocess.run", + side_effect=subprocess.TimeoutExpired("cmd", 30), + ): + res = windows_execute._patched_execute(backend, "sleep 10") + assert res.exit_code == 124 + assert "timed out" in res.output + + +def test_patched_execute_invalid_command(tmp_path: Path) -> None: + backend = _fake_backend(tmp_path) + res = windows_execute._patched_execute(backend, "") + assert res.exit_code == 1 + assert "non-empty" in res.output + + +def _windows_safe_regex() -> re.Pattern[str]: + pattern = bwrap_shell._ABS_TOKEN_RE.pattern + return re.compile(pattern.replace(r"(? None: + original = bwrap_shell._ABS_TOKEN_RE + try: + bwrap_shell._ABS_TOKEN_RE = _windows_safe_regex() + cmd = "C:/Users/Lenovo/.octop-venv/Scripts/python.exe -V" + out = bwrap_shell.rewrite_virtual_paths_in_command(cmd, str(tmp_path)) + assert out == cmd + finally: + bwrap_shell._ABS_TOKEN_RE = original + + +def test_drive_path_rewrite_keeps_mapping_virtual_paths(tmp_path: Path) -> None: + original = bwrap_shell._ABS_TOKEN_RE + try: + bwrap_shell._ABS_TOKEN_RE = _windows_safe_regex() + out = bwrap_shell.rewrite_virtual_paths_in_command("cat /AGENTS.md", str(tmp_path)) + assert str(tmp_path) in out + assert "AGENTS.md" in out + finally: + bwrap_shell._ABS_TOKEN_RE = original + + +@pytest.mark.skipif(os.name != "nt", reason="Windows-only patch") +def test_apply_is_idempotent_on_windows() -> None: + saved = windows_execute._APPLIED + windows_execute._APPLIED = False + try: + assert windows_execute.apply() is True + assert windows_execute.apply() is False + finally: + windows_execute._APPLIED = saved + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX-only no-op") +def test_apply_is_noop_on_posix() -> None: + assert windows_execute.apply() is False From 5d2619169828eb59e40499cb9ac2938153eac47c Mon Sep 17 00:00:00 2001 From: Georgyhongbo <2280628443@qq.com> Date: Wed, 12 Aug 2026 20:11:17 +0800 Subject: [PATCH 3/3] fix(test): assert inherit_env for local_shell only where injected The CI-failing test asserted inherit_env unconditionally, but the resolver injects it only on Windows (on POSIX sh supplies a default PATH from the empty env, so nothing is added). Make the assertion platform-aware so the test passes on both linux and windows CI jobs. --- tests/unit/agents/test_agent_manager.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/unit/agents/test_agent_manager.py b/tests/unit/agents/test_agent_manager.py index 6fc9d3a7..8933be38 100644 --- a/tests/unit/agents/test_agent_manager.py +++ b/tests/unit/agents/test_agent_manager.py @@ -519,8 +519,13 @@ def test_build_harness_config_keeps_fs_permissions_for_local_shell_guard( cfg = manager._build_harness_config( _row(config_json=json.dumps({"backend": {"type": "local_shell", "virtual_mode": True}})), ) - # Windows injects inherit_env so local_shell subprocesses see the host PATH. - assert cfg.backend == {"type": "local_shell", "virtual_mode": True, "inherit_env": True} + # Windows injects inherit_env so local_shell subprocesses see the host PATH; + # on POSIX sh already supplies a default PATH from the empty env, so the + # resolved backend keeps exactly the configured spec with no extra key. + expected = {"type": "local_shell", "virtual_mode": True} + if os.name == "nt": + expected["inherit_env"] = True + assert cfg.backend == expected assert cfg.permissions is not None middleware = cfg.middleware or [] assert not any(isinstance(item, FilesystemGuardMiddleware) for item in middleware)