Skip to content
Merged
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: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

## [Unreleased]

### 修复
- Windows 下新建 agent 时,本地后端 `root_dir:"/"` 被解析为当前盘根目录,导致读取工作区(通常位于另一盘符)时抛 `Path ... outside root directory`;现在后端规格解析会在 Windows 上将主机根 `/` 归一化为工作区作用域默认值

## [0.9.19] - 2026-08-05

### 新增
Expand Down
8 changes: 6 additions & 2 deletions src/octop/infra/agents/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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."""
Expand Down
41 changes: 41 additions & 0 deletions src/octop/infra/backend/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand Down
17 changes: 17 additions & 0 deletions tests/unit/agents/test_agent_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}})),
Expand Down
99 changes: 99 additions & 0 deletions tests/unit/backend/test_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)


Expand Down Expand Up @@ -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