diff --git a/CHANGELOG.md b/CHANGELOG.md index dcef07bd..1b557620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ ## [Unreleased] +### 修复 +- Windows 下新建 agent 时,本地后端 `root_dir:"/"` 被解析为当前盘根目录,导致读取工作区(通常位于另一盘符)时抛 `Path ... outside root directory`;现在后端规格解析会在 Windows 上将主机根 `/` 归一化为工作区作用域默认值 + ## [0.9.19] - 2026-08-05 ### 新增 diff --git a/src/octop/infra/agents/manager.py b/src/octop/infra/agents/manager.py index 41c7a734..a9d16492 100644 --- a/src/octop/infra/agents/manager.py +++ b/src/octop/infra/agents/manager.py @@ -23,6 +23,7 @@ backend_spec_supports_execution, default_agent_backend_spec, resolve_agent_backend_spec, + windows_neutralize_host_root, ) from octop.infra.connectors.builder import ( build_mcp_server_configs_for_user, @@ -1515,13 +1516,16 @@ def _agent_config_dict(self, row: AgentRow) -> dict[str, Any]: def _backend_spec_for_row(self, row: AgentRow) -> Any: cfg = self._agent_config_dict(row) backend_spec = cfg.get("backend") + workspace_dir = self._paths.ensure_agent_workspace(row.agent_id) if backend_spec is None: - workspace_dir = self._paths.ensure_agent_workspace(row.agent_id) return default_agent_backend_spec(workspace_dir) - return resolve_agent_backend_spec( + resolved = resolve_agent_backend_spec( backend_spec, repo=self._repos.storage_backend_repo, ) + # Windows: the dashboard defaults local backends to root_dir "/", which + # resolves to a drive other than the workspace and breaks path checks. + return windows_neutralize_host_root(resolved, workspace_dir=workspace_dir) def _backend_workspace_for_row(self, row: AgentRow) -> Any: """Resolve :class:`BackendWorkspace` for *row* without a running harness agent.""" diff --git a/src/octop/infra/backend/resolver.py b/src/octop/infra/backend/resolver.py index bbba06c3..65f2c55e 100644 --- a/src/octop/infra/backend/resolver.py +++ b/src/octop/infra/backend/resolver.py @@ -31,6 +31,47 @@ def default_agent_backend_spec(workspace_dir: Path) -> dict[str, Any]: return dict(DEFAULT_BACKEND_SPEC) +def windows_neutralize_host_root(spec: Any, *, workspace_dir: Path) -> Any: + """Windows: replace local backends rooted at ``/`` with the workspace default. + + ``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``. Rewriting to the + workspace-scoped default keeps Windows agents functional out of the box. + + 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. + """ + 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 default_agent_backend_spec(workspace_dir) + if ( + kind == "composite" + and isinstance(spec.get("default"), dict) + and _is_host_root(spec["default"].get("root_dir")) + ): + return {**spec, "default": default_agent_backend_spec(workspace_dir)} + return spec + + +def _is_host_root(root: Any) -> bool: + """True when *root* is an explicitly host-rooted local backend path. + + Only an *explicit* ``/``, ``\\`` or empty string counts (the dashboard's + default). A missing ``root_dir`` is left alone — harness already falls back + to ``workspace_dir`` for local backends, which is workspace-scoped by + design. + """ + return root is not None and str(root).strip() in ("/", "\\", "") + + def resolve_agent_backend_spec( spec: Any, *, diff --git a/tests/unit/agents/test_agent_manager.py b/tests/unit/agents/test_agent_manager.py index a8511859..5a9881e4 100644 --- a/tests/unit/agents/test_agent_manager.py +++ b/tests/unit/agents/test_agent_manager.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os from collections.abc import AsyncIterator from pathlib import Path from typing import Any @@ -438,6 +439,22 @@ def test_build_harness_config_respects_config_json_backend(manager: AgentManager assert cfg.backend == custom +def test_backend_spec_for_row_neutralizes_host_root_on_windows( + manager: AgentManager, monkeypatch: Any +) -> None: + # The dashboard persists local backends with root_dir "/" (host-root sentinel). + # On Windows that resolves to the current-drive root, breaking cross-drive reads + # of the workspace; the resolver must scope it to the workspace default. + monkeypatch.setattr(os, "name", "nt") + row = _row( + config_json=json.dumps( + {"backend": {"type": "local_shell", "root_dir": "/", "virtual_mode": True}} + ) + ) + ws = manager._paths.ensure_agent_workspace(row.agent_id) + assert manager._backend_spec_for_row(row) == default_agent_backend_spec(ws) + + def test_build_harness_config_omits_fs_permissions_for_local_shell(manager: AgentManager) -> None: cfg = manager._build_harness_config( _row(config_json=json.dumps({"backend": {"type": "local_shell", "virtual_mode": True}})), diff --git a/tests/unit/backend/test_resolver.py b/tests/unit/backend/test_resolver.py index 18a08a69..1b99bfd9 100644 --- a/tests/unit/backend/test_resolver.py +++ b/tests/unit/backend/test_resolver.py @@ -5,6 +5,7 @@ import os from pathlib import Path from types import SimpleNamespace +from typing import Any from unittest.mock import patch import pytest @@ -13,8 +14,10 @@ from harness_agent.backends.workspace import BackendWorkspace from octop.infra.backend.resolver import ( + _is_host_root, backend_spec_supports_execution, default_agent_backend_spec, + windows_neutralize_host_root, ) @@ -61,3 +64,99 @@ def test_default_agent_backend_spec_windows_scopes_to_workspace(tmp_path: Path) "root_dir": str(ws.resolve()), "virtual_mode": True, } + + +def _windows_default(ws: Path) -> dict[str, Any]: + with patch("octop.infra.backend.resolver.os", SimpleNamespace(name="nt")): + return default_agent_backend_spec(ws) + + +def test_is_host_root_matches_explicit_host_roots() -> None: + for root in ("/", "\\", "", " / "): + assert _is_host_root(root), root + + +def test_is_host_root_ignores_drive_root_and_missing() -> None: + # An explicit drive root is a deliberate user choice, not the dashboard sentinel. + assert not _is_host_root("D:\\") + assert not _is_host_root("C:/") + # harness falls back to workspace_dir on its own; do not rewrite absent config. + assert not _is_host_root(None) + + +def test_windows_neutralize_is_passthrough_on_posix(tmp_path: Path) -> None: + spec = {"type": "local_shell", "root_dir": "/", "virtual_mode": True} + with patch("octop.infra.backend.resolver.os", SimpleNamespace(name="posix")): + out = windows_neutralize_host_root(spec, workspace_dir=tmp_path) + assert out == spec + + +def test_windows_neutralize_local_shell_host_root_scopes_to_workspace( + tmp_path: Path, +) -> None: + ws = tmp_path / "agents" / "AGT001" + ws.mkdir(parents=True) + with patch("octop.infra.backend.resolver.os", SimpleNamespace(name="nt")): + out = windows_neutralize_host_root( + {"type": "local_shell", "root_dir": "/", "virtual_mode": True}, + workspace_dir=ws, + ) + assert out == _windows_default(ws) + + +def test_windows_neutralize_filesystem_empty_root_scopes_to_workspace( + tmp_path: Path, +) -> None: + ws = tmp_path / "agents" / "AGT001" + ws.mkdir(parents=True) + with patch("octop.infra.backend.resolver.os", SimpleNamespace(name="nt")): + out = windows_neutralize_host_root( + {"type": "filesystem", "root_dir": "", "virtual_mode": True}, + workspace_dir=ws, + ) + assert out == _windows_default(ws) + + +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 + + +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 + + +def test_windows_neutralize_composite_default_host_root_scoped(tmp_path: Path) -> None: + ws = tmp_path / "agents" / "AGT001" + ws.mkdir(parents=True) + spec: dict[str, Any] = { + "type": "composite", + "default": {"type": "local_shell", "root_dir": "/", "virtual_mode": True}, + "routes": {}, + } + with patch("octop.infra.backend.resolver.os", SimpleNamespace(name="nt")): + out = windows_neutralize_host_root(spec, workspace_dir=ws) + assert out["default"] == _windows_default(ws) + assert out["routes"] == {} + + +def test_windows_neutralize_composite_healthy_default_kept(tmp_path: Path) -> None: + spec: dict[str, Any] = { + "type": "composite", + "default": {"type": "local_shell", "virtual_mode": True}, + "routes": { + "/project/": { + "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