From f623e64e07d584ed9ffd87c1a9b0ab704664fd0a Mon Sep 17 00:00:00 2001 From: Jason Cat Date: Sat, 10 Oct 2026 19:23:39 +0800 Subject: [PATCH] fix(acp): resolve a bare agent command through PATH before spawning (#6598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(acp): resolve a bare agent command through PATH before spawning `invoke_acp_agent` handed `acp_agents..command` straight to the ACP SDK, which spawns it with `asyncio.create_subprocess_exec`. That API applies no `PATHEXT` lookup on Windows, so a bare npm launcher (`npx`, `mcode`) raised `FileNotFoundError` although its `.cmd` shim was on `PATH`. The documented `command: npx` and `command: mcode` shapes could not start, and the tool reported "was not found on PATH" for an installed launcher. Resolve the configured command with `shutil.which` before spawning — the way the MCP Python SDK normalizes stdio server commands — and keep the configured value when nothing matches, so the existing install guidance still fires. Fixes #6597 * fix(acp): resolve the ACP agent command off the event loop `_resolve_agent_command` calls `shutil.which`, which stats the filesystem. Calling it inline in the async `_invoke` tripped the strict BlockBuster gate (`BlockingError: Blocking call to os.access`) for every command that resolves, and would block the Gateway event loop in production. Offload the lookup with `asyncio.to_thread`, matching the neighbouring `_get_work_dir`, `_build_acp_mcp_servers`, and `_format_invocation_error` calls. The blocking-io anchor configured `command="test-agent"`, a name that resolves nowhere, so `shutil.which` short-circuited before `os.access` and the gate never covered the new call. Point it at `sys.executable` so the lookup is really exercised, and cover the unresolvable-guidance branch end-to-end with a new test. Refs #6597 * fix(acp): keep the configured command in ACP remediation text Review follow-ups on the PATH resolution: - Only bare names are resolved. A configured path is interpreted by the spawn against the per-thread ACP workspace, so resolving it against the Gateway's working directory could pick a different file or hand the spawn a path that then fails. - `_format_invocation_error` receives `agent_config.command` again. A command can resolve on PATH and still fail to launch (a broken shebang, for example), and reporting the resolved path made "was not found on PATH" false and dropped the `codex-acp` hint. - The blocking-io anchor uses a bare name backed by a shim on a controlled PATH instead of `sys.executable`, which the bare-name scoping no longer resolves. Tests: the pass-through assertion in `test_invoke_acp_agent_uses_fixed_acp_workspace` pins PATH, and new coverage covers the bare-name scoping and the spawn-failure message. Refs #6597 * docs(acp): correct the path-resolution rationale A configured path is resolved by the spawn in its own process, so the pass-through reason must not claim it is resolved against the ACP workspace: that only holds on POSIX, while Windows resolves a relative executable against the calling process's directory. State the platform-neutral reason instead. Refs #6597 * fix(acp): resolve the launcher against the agent's own PATH The ACP SDK merges acp_agents..env over its inherited environment at spawn time, so a configured PATH, not the Gateway's, decides which launcher the agent would find. Resolve a bare command against that effective PATH, and use the Gateway's only when the agent does not override it. Return a successful lookup as an absolute path: a relative PATH entry makes shutil.which yield a result relative to the Gateway's cwd, while the spawn runs with cwd set to the ACP workspace, where that path resolves elsewhere or not at all. Regressions: a real-subprocess test with same-named launchers in the Gateway and agent PATHs (asserting which one ran), and a real-subprocess test whose PATH holds a relative directory while the ACP workspace is elsewhere. Both are red on the pre-fix code. --- README.md | 1 + .../packages/harness/deerflow/tools/AGENTS.md | 1 + .../tools/builtins/invoke_acp_agent_tool.py | 64 +++- .../blocking_io/test_invoke_acp_agent_tool.py | 16 +- backend/tests/test_invoke_acp_agent_tool.py | 279 ++++++++++++++++++ 5 files changed, 355 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b73152cb03..55be0c9cac 100644 --- a/README.md +++ b/README.md @@ -367,6 +367,7 @@ streams simulate chunks from a non-streaming response, while no-tool streams sta - `CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR` accepts a UTF-8 token handoff and reuses it for later model instances in the same process. Undecodable handoffs are skipped so Claude Code can still try its override or default credentials file. - CLI credential JSON files accept UTF-8 with or without a BOM, independently of the host locale. `make doctor` accepts the same files when checking CLI authentication. Invalid text encoding is treated as an unreadable source; Claude Code can still try its default file after an invalid override. - ACP agent entries are separate from model providers — if you configure `acp_agents.codex`, point it at a Codex ACP adapter such as `npx -y @zed-industries/codex-acp` + - A bare ACP agent `command` is resolved before the agent starts, so npm-installed launchers work on Windows too: `npx` or `mcode` is spawned as its `npx.cmd`/`mcode.cmd` shim instead of failing with a command-not-found error. The lookup uses the `PATH` the agent subprocess will actually see (`acp_agents..env.PATH` when set, otherwise the Gateway's) and hands the spawn an absolute path, so a relative `PATH` entry cannot be re-interpreted inside the agent's own workspace. A `command` containing a path separator is used as configured, and a bare name that cannot be resolved is still reported with the install guidance below. - Each ACP agent's `timeout_seconds` (default: 1800) is one shared budget for initialization, session creation, and the prompt, starting after the subprocess launches. On timeout, DeerFlow aborts the invocation and closes the subprocess before returning an error. Workspace/MCP preparation and subprocess cleanup are outside this budget. A `TimeoutError` raised by the SDK before this deadline expires retains its own error message. - MiniMax Code speaks ACP directly. Install and authenticate it, then add it as an ACP agent: diff --git a/backend/packages/harness/deerflow/tools/AGENTS.md b/backend/packages/harness/deerflow/tools/AGENTS.md index fe83a28852..5d2720dfc4 100644 --- a/backend/packages/harness/deerflow/tools/AGENTS.md +++ b/backend/packages/harness/deerflow/tools/AGENTS.md @@ -93,6 +93,7 @@ E2B output sync records remote file versions and actual host file metadata in a - `timeout_seconds` is one deadline shared by initialization, session creation, and prompt after process launch. Keep the SDK process context outside the timeout so teardown finishes before the tool returns; workspace/MCP preparation and teardown are outside the protocol budget. Real-process regressions in `tests/test_invoke_acp_agent_tool.py` cover every hung phase, the shared budget, success, and caller cancellation. Only report budget expiry when the timeout context's `expired()` is true; otherwise propagate SDK `TimeoutError` to `_format_invocation_error`. - ACP results collect only `agent_message_chunk` text. Thought chunks remain internal and must not be concatenated into the tool result - Missing ACP executables now return an actionable error message instead of a raw `[Errno 2]` +- A bare configured `command` is resolved before the spawn (`_resolve_agent_command`), off the event loop because the lookup stats the filesystem. The ACP SDK's stdio transport calls `asyncio.create_subprocess_exec`, which performs no `PATHEXT` lookup on Windows, so a bare npm launcher (`npx`, `mcode`) would raise `FileNotFoundError` although its `.cmd` shim is on `PATH`. Resolution consults the `PATH` the agent subprocess will actually see (`acp_agents..env.PATH` when the agent config sets one, the Gateway's otherwise) and returns an absolute path, so a relative `PATH` entry cannot be re-interpreted inside the ACP workspace. A `command` containing a path separator goes to the spawn unchanged, so the spawn — not `shutil.which` — decides what it refers to, and `_format_invocation_error` always receives the configured value so its remediation still matches `config.yaml` - Each ACP agent uses a per-thread workspace at `{base_dir}/users/{user_id}/threads/{thread_id}/acp-workspace/`. The workspace is accessible to the lead agent via the virtual path `/mnt/acp-workspace/` (read-only). In docker sandbox mode, the directory is volume-mounted into the container at `/mnt/acp-workspace` (read-only); in local sandbox mode, path translation is handled by `tools.py` Ordinary `task` results forward bounded `artifact.knowledge_sources` records diff --git a/backend/packages/harness/deerflow/tools/builtins/invoke_acp_agent_tool.py b/backend/packages/harness/deerflow/tools/builtins/invoke_acp_agent_tool.py index 472a0f32da..571368c3a8 100644 --- a/backend/packages/harness/deerflow/tools/builtins/invoke_acp_agent_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/invoke_acp_agent_tool.py @@ -125,6 +125,56 @@ def _build_permission_response(options: list[Any], *, auto_approve: bool) -> Any return RequestPermissionResponse(outcome=DeniedOutcome(outcome="cancelled")) +def _agent_path(env: dict[str, str] | None) -> str | None: + """Return the ``PATH`` the ACP SDK will give the agent subprocess. + + The SDK builds the child environment by inheriting a trimmed copy of the + Gateway's environment and then applying ``acp_agents..env`` on top, so + a configured ``PATH`` -- not the Gateway's -- decides which launcher the + agent actually finds. ``None`` means the agent does not override ``PATH``, + which lets ``shutil.which`` fall back to the Gateway's own. + + The lookup is case-insensitive because environment variable names are on + Windows, where ``Path`` and ``PATH`` name the same variable. + """ + if not env: + return None + return next((value for key, value in env.items() if key.upper() == "PATH"), None) + + +def _resolve_agent_command(command: str, path: str | None = None) -> str: + """Return the host path to spawn for a configured ACP agent command. + + ``asyncio.create_subprocess_exec``, which the ACP SDK's stdio transport + uses, does not apply ``PATHEXT`` on Windows: a bare ``npx`` or ``mcode`` + raises ``FileNotFoundError`` even though the npm shim (``npx.cmd``) is on + ``PATH``. Resolve the name the way the MCP Python SDK normalizes stdio + commands, and keep the configured value when nothing matches so the + not-found remediation still fires. + + Only bare names are resolved. A configured path is handed to the spawn + unchanged, so the spawn -- not ``shutil.which`` -- decides what it refers + to. + + ``path`` is the ``PATH`` the agent subprocess will actually see (see + ``_agent_path``); the Gateway's own is only the fallback. A successful + lookup is returned absolute: for a relative ``PATH`` entry ``shutil.which`` + yields a result relative to the Gateway's cwd, while the spawn runs with + ``cwd`` set to the ACP workspace, where that path resolves elsewhere -- or + not at all. + + The lookup stats the filesystem (``shutil.which`` -> ``os.access``), so + callers must run it off the event loop. + """ + if os.path.dirname(command): + return command + try: + resolved = shutil.which(command, path=path) + except OSError: + return command + return os.path.abspath(resolved) if resolved else command + + def _format_invocation_error(agent: str, cmd: str, exc: Exception) -> str: """Return a user-facing ACP invocation error with actionable remediation.""" if not isinstance(exc, FileNotFoundError): @@ -213,7 +263,12 @@ def build_invoke_acp_agent_tool(agents: dict) -> BaseTool: return response client = _CollectingClient() - cmd = agent_config.command + agent_env: dict[str, str] | None = None + if agent_config.env: + agent_env = {k: (os.environ.get(v[1:], "") if v.startswith("$") else v) for k, v in agent_config.env.items()} + # Resolve against the PATH the agent subprocess will see, not the Gateway's: + # the SDK merges agent_env over its inherited environment at spawn time. + cmd = await asyncio.to_thread(_resolve_agent_command, agent_config.command, _agent_path(agent_env)) args = agent_config.args or [] physical_cwd = await asyncio.to_thread(_get_work_dir, thread_id) try: @@ -225,9 +280,6 @@ def build_invoke_acp_agent_tool(agents: dict) -> BaseTool: exc, ) mcp_servers = [] - agent_env: dict[str, str] | None = None - if agent_config.env: - agent_env = {k: (os.environ.get(v[1:], "") if v.startswith("$") else v) for k, v in agent_config.env.items()} try: from acp import spawn_agent_process @@ -268,7 +320,9 @@ def build_invoke_acp_agent_tool(agents: dict) -> BaseTool: return result or "(no response)" except Exception as e: logger.error("ACP agent '%s' invocation failed: %s", agent, e) - return await asyncio.to_thread(_format_invocation_error, agent, cmd, e) + # Report the configured command, not the resolved path: remediation + # text has to match what the operator wrote in config.yaml. + return await asyncio.to_thread(_format_invocation_error, agent, agent_config.command, e) return StructuredTool.from_function( name="invoke_acp_agent", diff --git a/backend/tests/blocking_io/test_invoke_acp_agent_tool.py b/backend/tests/blocking_io/test_invoke_acp_agent_tool.py index 97ef268cdb..b949de1f1e 100644 --- a/backend/tests/blocking_io/test_invoke_acp_agent_tool.py +++ b/backend/tests/blocking_io/test_invoke_acp_agent_tool.py @@ -1,9 +1,15 @@ -"""Regression test for ACP invocation setup on the event loop.""" +"""Regression test for ACP invocation setup on the event loop. + +The configured command is a bare name backed by a launcher shim on a controlled +``PATH``, so the gate really runs the ``shutil.which`` lookup (``os.access``) +instead of short-circuiting on a name that resolves nowhere. +""" from __future__ import annotations import asyncio import contextlib +import sys from types import SimpleNamespace from typing import Any @@ -32,6 +38,14 @@ async def test_invoke_acp_agent_setup_does_not_block_event_loop(monkeypatch, tmp ) monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(config_path)) + shim_dir = tmp_path / "bin" + shim_dir.mkdir() + shim = shim_dir / ("test-agent.cmd" if sys.platform == "win32" else "test-agent") + shim.write_text("@echo off\n" if sys.platform == "win32" else "#!/bin/sh\n", encoding="utf-8") + if sys.platform != "win32": + shim.chmod(0o755) + monkeypatch.setenv("PATH", str(shim_dir)) + captured: dict[str, Any] = {} class _Connection: diff --git a/backend/tests/test_invoke_acp_agent_tool.py b/backend/tests/test_invoke_acp_agent_tool.py index 2f0fbd2e90..6d1595d110 100644 --- a/backend/tests/test_invoke_acp_agent_tool.py +++ b/backend/tests/test_invoke_acp_agent_tool.py @@ -2,8 +2,10 @@ import asyncio import contextlib +import os import sys import time +from pathlib import Path from types import SimpleNamespace import pytest @@ -11,11 +13,13 @@ import pytest from deerflow.config.acp_config import ACPAgentConfig from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig, reset_extensions_config, set_extensions_config from deerflow.tools.builtins.invoke_acp_agent_tool import ( + _agent_path, _build_acp_mcp_servers, _build_mcp_servers, _build_permission_response, _format_invocation_error, _get_work_dir, + _resolve_agent_command, build_invoke_acp_agent_tool, ) from deerflow.tools.tools import get_available_tools @@ -190,6 +194,9 @@ async def test_invoke_acp_agent_uses_fixed_acp_workspace(monkeypatch, tmp_path): """ACP agent uses {base_dir}/acp-workspace/ when no thread_id is available (no config).""" from deerflow.config import paths as paths_module + # An empty PATH directory keeps the configured command unresolvable, so the + # spawn assertion below checks the configuration rather than the host PATH. + monkeypatch.setenv("PATH", str(tmp_path / "empty-bin")) monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path)) monkeypatch.setattr( @@ -992,3 +999,275 @@ async def test_invoke_acp_agent_preserves_sdk_timeout_errors(acp_subprocess_tool assert result == "Error invoking ACP agent 'test': SDK transport timed out" assert "timeout_seconds" not in result assert acp_subprocess_tool.captured["proc"].returncode is not None + + +def _write_launcher_shim(directory: Path, name: str, command: str) -> Path: + """Write an executable launcher shim for ``name`` under ``directory``.""" + directory.mkdir(parents=True, exist_ok=True) + if sys.platform == "win32": + shim = directory / f"{name}.cmd" + shim.write_text(f"@echo off\n{command}\n", encoding="utf-8") + else: + shim = directory / name + shim.write_text(f"#!/bin/sh\nexec {command}\n", encoding="utf-8") + shim.chmod(0o755) + return shim + + +@pytest.mark.anyio +async def test_invoke_acp_agent_spawns_the_path_resolved_command(monkeypatch, tmp_path): + """A bare configured command reaches the ACP SDK as its PATH-resolved path. + + Windows resolves npm-installed launchers through ``PATHEXT`` (``npx`` -> + ``npx.cmd``) and ``asyncio.create_subprocess_exec`` does not, so the + resolved path must be handed to the SDK instead of the bare name. + """ + import acp as acp_module + + from deerflow.config import paths as paths_module + + monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path)) + monkeypatch.setattr( + "deerflow.config.extensions_config.ExtensionsConfig.from_file", + classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})), + ) + script_path = tmp_path / "test_acp_agent.py" + script_path.write_text(_ACP_AGENT_SCRIPT, encoding="utf-8") + phase_path = tmp_path / "phase.txt" + + shim_name = "deerflow-acp-probe" + shim = _write_launcher_shim(tmp_path / "bin", shim_name, f'"{sys.executable}" "{script_path}" "" 0 "{phase_path}"') + monkeypatch.setenv("PATH", str(shim.parent)) + + captured: dict[str, object] = {} + real_spawn_agent_process = acp_module.spawn_agent_process + + @contextlib.asynccontextmanager + async def _spying_spawn_agent_process(client, cmd, *args, env=None, cwd=None): + captured["cmd"] = cmd + async with real_spawn_agent_process(client, cmd, *args, env=env, cwd=cwd) as (conn, proc): + yield conn, proc + + monkeypatch.setattr(acp_module, "spawn_agent_process", _spying_spawn_agent_process) + + tool = build_invoke_acp_agent_tool({"probe": ACPAgentConfig(command=shim_name, description="Probe", timeout_seconds=30)}) + result = await asyncio.wait_for(tool.coroutine(agent="probe", prompt="do work"), timeout=20) + + # Windows PATHEXT matching reports the shim with its extension case + # normalized, so compare paths rather than raw strings. + assert Path(captured["cmd"]) == shim + assert result == "(no response)" + + +@pytest.mark.anyio +async def test_invoke_acp_agent_reports_guidance_for_an_unresolvable_command(monkeypatch, tmp_path): + """A command PATH cannot resolve still reports the configured name. + + ``_resolve_agent_command`` falls back to the configured value when nothing + matches, so the not-found guidance must name what the user configured. + """ + from deerflow.config import paths as paths_module + + monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path)) + monkeypatch.setattr( + "deerflow.config.extensions_config.ExtensionsConfig.from_file", + classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})), + ) + + tool = build_invoke_acp_agent_tool({"probe": ACPAgentConfig(command="deerflow-missing-acp-agent", description="Probe", timeout_seconds=30)}) + result = await asyncio.wait_for(tool.coroutine(agent="probe", prompt="do work"), timeout=20) + + assert "Command 'deerflow-missing-acp-agent' was not found on PATH" in result + assert "acp_agents.probe.command" in result + + +def test_agent_path_reads_the_configured_override_case_insensitively(): + """The env the SDK will apply decides which PATH resolves the launcher.""" + assert _agent_path(None) is None + assert _agent_path({}) is None + assert _agent_path({"FOO": "bar"}) is None + assert _agent_path({"PATH": "/agent-bin"}) == "/agent-bin" + assert _agent_path({"Path": "/agent-bin"}) == "/agent-bin" + + +def test_resolve_agent_command_only_resolves_bare_names(monkeypatch, tmp_path): + """A configured path is left to the spawn, which runs in the ACP workspace.""" + resolved_dir = tmp_path / "resolved" + monkeypatch.setattr( + "deerflow.tools.builtins.invoke_acp_agent_tool.shutil.which", + lambda command, path=None: str(resolved_dir / command), + ) + + assert _resolve_agent_command("npx") == str(resolved_dir / "npx") + assert _resolve_agent_command("bin/agent") == "bin/agent" + assert _resolve_agent_command("./bin/agent") == "./bin/agent" + + +def test_resolve_agent_command_hands_the_effective_path_to_the_lookup(monkeypatch, tmp_path): + """The agent's own PATH, not the Gateway's, is what the lookup consults.""" + seen: list[str | None] = [] + + def _which(command, path=None): + seen.append(path) + return str(tmp_path / (path or "gateway-bin") / command) + + monkeypatch.setattr("deerflow.tools.builtins.invoke_acp_agent_tool.shutil.which", _which) + + assert _resolve_agent_command("npx", str(tmp_path / "agent-bin")) == str(tmp_path / "agent-bin" / "npx") + assert _resolve_agent_command("npx") == str(tmp_path / "gateway-bin" / "npx") + assert seen == [str(tmp_path / "agent-bin"), None] + + +@pytest.mark.anyio +async def test_invoke_acp_agent_reports_the_configured_command_when_spawn_fails(monkeypatch, tmp_path): + """A command PATH resolves can still fail to launch. + + The remediation text has to stay anchored to the configured command, so the + ``codex-acp`` hint below survives the resolution. + """ + import acp as acp_module + + from deerflow.config import paths as paths_module + + monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path)) + monkeypatch.setattr( + "deerflow.config.extensions_config.ExtensionsConfig.from_file", + classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})), + ) + shim_dir = tmp_path / "bin" + _write_launcher_shim(shim_dir, "codex-acp", "echo") + _write_launcher_shim(shim_dir, "codex", "echo") + monkeypatch.setenv("PATH", str(shim_dir)) + + def _raise_missing(*args, **kwargs): + raise FileNotFoundError(2, "No such file or directory") + + monkeypatch.setattr(acp_module, "spawn_agent_process", _raise_missing) + + tool = build_invoke_acp_agent_tool({"codex": ACPAgentConfig(command="codex-acp", description="Codex CLI", timeout_seconds=30)}) + result = await asyncio.wait_for(tool.coroutine(agent="codex", prompt="do work"), timeout=20) + + assert "Command 'codex-acp' was not found on PATH" in result + assert "does not speak ACP directly" in result + + +def _write_marking_shim(directory: Path, name: str, marker: Path, command: str) -> Path: + """Write a launcher shim for ``name`` that records that this copy ran.""" + directory.mkdir(parents=True, exist_ok=True) + body = f'\necho ran > "{marker}"\n{command}\n' + if sys.platform == "win32": + shim = directory / f"{name}.cmd" + shim.write_text(f"@echo off{body}", encoding="utf-8") + else: + shim = directory / name + shim.write_text(f"#!/bin/sh{body}", encoding="utf-8") + shim.chmod(0o755) + return shim + + +@pytest.mark.anyio +async def test_invoke_acp_agent_resolves_against_the_agent_configured_path(monkeypatch, tmp_path): + """The agent's ``env.PATH`` wins when both PATHs hold the same launcher name. + + The ACP SDK merges ``acp_agents..env`` over the inherited environment + at spawn time, so resolving the bare name against the Gateway's ``PATH`` + would launch a different same-named launcher than the agent was configured + for. + """ + import acp as acp_module + + from deerflow.config import paths as paths_module + + monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path)) + monkeypatch.setattr( + "deerflow.config.extensions_config.ExtensionsConfig.from_file", + classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})), + ) + script_path = tmp_path / "test_acp_agent.py" + script_path.write_text(_ACP_AGENT_SCRIPT, encoding="utf-8") + phase_path = tmp_path / "phase.txt" + shim_name = "deerflow-acp-probe" + launch = f'"{sys.executable}" "{script_path}" "" 0 "{phase_path}"' + + gateway_marker = tmp_path / "gateway-ran.txt" + agent_marker = tmp_path / "agent-ran.txt" + _write_marking_shim(tmp_path / "gateway-bin", shim_name, gateway_marker, launch) + agent_shim = _write_marking_shim(tmp_path / "agent-bin", shim_name, agent_marker, launch) + monkeypatch.setenv("PATH", str(tmp_path / "gateway-bin")) + + captured: dict[str, object] = {} + real_spawn_agent_process = acp_module.spawn_agent_process + + @contextlib.asynccontextmanager + async def _spying_spawn_agent_process(client, cmd, *args, env=None, cwd=None): + captured["cmd"] = cmd + async with real_spawn_agent_process(client, cmd, *args, env=env, cwd=cwd) as (conn, proc): + yield conn, proc + + monkeypatch.setattr(acp_module, "spawn_agent_process", _spying_spawn_agent_process) + + tool = build_invoke_acp_agent_tool( + { + "probe": ACPAgentConfig( + command=shim_name, + env={"PATH": str(tmp_path / "agent-bin")}, + description="Probe", + timeout_seconds=30, + ) + } + ) + result = await asyncio.wait_for(tool.coroutine(agent="probe", prompt="do work"), timeout=20) + + assert Path(captured["cmd"]) == agent_shim + assert agent_marker.read_text(encoding="utf-8").strip() == "ran" + assert not gateway_marker.exists() + assert result == "(no response)" + + +@pytest.mark.anyio +async def test_invoke_acp_agent_spawns_an_absolute_path_for_a_relative_path_entry(monkeypatch, tmp_path): + """A relative PATH entry must not be re-interpreted inside the ACP workspace. + + ``shutil.which`` yields a result relative to the Gateway's cwd when a + relative directory is on ``PATH``, but the SDK spawns with ``cwd`` set to + the per-thread ACP workspace, where that relative path does not exist. + """ + import acp as acp_module + + from deerflow.config import paths as paths_module + + monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path)) + monkeypatch.setattr( + "deerflow.config.extensions_config.ExtensionsConfig.from_file", + classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})), + ) + script_path = tmp_path / "test_acp_agent.py" + script_path.write_text(_ACP_AGENT_SCRIPT, encoding="utf-8") + phase_path = tmp_path / "phase.txt" + + # Pin the process cwd so the relative PATH entry below points at a shim + # beside the ACP workspace rather than inside it. + monkeypatch.chdir(tmp_path) + shim_name = "deerflow-acp-probe" + shim = _write_launcher_shim(tmp_path / "bin", shim_name, f'"{sys.executable}" "{script_path}" "" 0 "{phase_path}"') + monkeypatch.setenv("PATH", "bin") + + captured: dict[str, object] = {} + real_spawn_agent_process = acp_module.spawn_agent_process + + @contextlib.asynccontextmanager + async def _spying_spawn_agent_process(client, cmd, *args, env=None, cwd=None): + captured["cmd"] = cmd + captured["cwd"] = cwd + async with real_spawn_agent_process(client, cmd, *args, env=env, cwd=cwd) as (conn, proc): + yield conn, proc + + monkeypatch.setattr(acp_module, "spawn_agent_process", _spying_spawn_agent_process) + + tool = build_invoke_acp_agent_tool({"probe": ACPAgentConfig(command=shim_name, description="Probe", timeout_seconds=30)}) + result = await asyncio.wait_for(tool.coroutine(agent="probe", prompt="do work"), timeout=20) + + assert os.path.isabs(captured["cmd"]) + assert Path(captured["cmd"]) == shim + assert Path(captured["cwd"]) != shim.parent + assert result == "(no response)"