Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
## [Unreleased]

### 修复

- 插件工具使用中文等非 ASCII 名称时 LLM 调用失败:主流 API 要求工具名匹配 `^[a-zA-Z0-9_-]{1,64}$`,现自动将非法名称转写为合法拼音名(`pypinyin` 缺失时退回下划线替换),冲突追加 `_2`/`_3` 后缀,并在工具描述前缀 `[原名: …]` 保留原名映射;`config_json.plugins` 配置键与插件内部仍使用原始名称,路由不受影响
- 修复聊天页在"生成中"时于输入框持续打字导致消息列表上下轻微抖动的问题:输入框高度测量改为在离屏克隆节点上进行,不再瞬态改变页面布局
- 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.24] - 2026-08-15

Expand Down
8 changes: 8 additions & 0 deletions src/octop/infra/backend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
75 changes: 51 additions & 24 deletions src/octop/infra/backend/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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


Expand Down
185 changes: 185 additions & 0 deletions src/octop/infra/backend/windows_execute.py
Original file line number Diff line number Diff line change
@@ -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 ``<no output>`` 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:'<root>'\\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 "<no output>"

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"(?<![\w/.])"
new = r"(?<![\w/:.\\])"
if new in pattern:
return True
if old not in pattern:
# Unexpected harness_agent version: leave the regex untouched rather
# than risk a wrong rewrite.
return False
bwrap_shell._ABS_TOKEN_RE = re.compile(pattern.replace(old, new))
return True


def apply() -> 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"]
9 changes: 8 additions & 1 deletion tests/unit/agents/test_agent_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,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,
}


Expand All @@ -523,7 +524,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}})),
)
assert cfg.backend == {"type": "local_shell", "virtual_mode": 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)
Expand Down
Loading
Loading