fix(delegate): reject acp_command values the transport can't drive - #39462
fix(delegate): reject acp_command values the transport can't drive#39462ssiweifnag wants to merge 7 commits into
Conversation
…esent _check_via_local_git() hard-coded 'origin' for both the fetch and the rev-list ref, so users on a fork (where 'origin' is their own clone) saw a 'behind' count measured against their fork's main rather than the canonical source. The fork itself was always ahead of local by 0-3 commits, so the message was permanently misleading. Add a small _detect_canonical_remote() helper that returns 'upstream' when that remote exists, else 'origin', and use it in _check_via_local_git. This mirrors the convention already used by cmd_update (see _has_upstream_remote, _sync_with_upstream_if_needed) so the update-check banner and the 'hermes update' flow now agree on what 'behind' means for fork users. Also update the docstring of check_for_updates() to say 'canonical remote' instead of 'origin/main'.
Adds regression coverage for the canonical-remote detection in _check_via_local_git. Without these tests, a future refactor that re-hardcodes "origin" would silently break fork users (the "behind" banner would measure against the fork's main, which is normally 0-3 commits ahead, producing permanent false positives). Two test groups: - TestDetectCanonicalRemote: exercises the detector against real git repos in tmp_path (4 scenarios: upstream present, no upstream, not a git repo, only upstream). - TestCheckViaLocalGitUsesCanonicalRemote: pins the contract that the checker fetches+rev-lists whatever the detector returns, by mocking _detect_canonical_remote directly so subprocess.run only sees the intended fetch/rev-list calls.
The error returned by _validate_cron_script_path for absolute / ~ / Windows paths told users to "Place scripts in ~/.hermes/scripts/" — but the actual resolution uses get_hermes_home() / "scripts", so on Docker or any non-default HERMES_HOME setup the error message points users (and the agent) at the wrong directory. The fix computes scripts_dir once and uses it in both the rejection message and the (unchanged) containment check. Closes NousResearch#38693
…Exit The memory plugin loader used to glob every *.py in a user-installed provider directory and exec_module it, with a bare `except Exception` around the call. Two compounding problems: 1. Files that are not real submodules — `setup.py`, `conftest.py`, `pyproject.py`, `test_*.py`, `*_test.py` — get executed. A `setup.py` next to a plugin will call setuptools, which parses sys.argv and `sys.exit()`s on bad subcommand. 2. `sys.exit()` raises SystemExit, which inherits from BaseException — not Exception. The bare `except Exception` does not catch it, so SystemExit propagates and crashes the whole Hermes process. Fix: skip the known non-submodule files in the glob, and explicitly re-raise KeyboardInterrupt/SystemExit in the load path so they cannot be swallowed by future `except` clauses either. Closes NousResearch#38674
The conversation loop's recovery path uses parse_available_output_tokens_from_error() to detect when the request failed because max_tokens (the *output* cap) is too large relative to the context window, vs. the *input* itself being too long. The two require different recovery strategies — output-cap errors reduce max_tokens for the next call, while input-overflow errors compress history. The parser previously recognized only Anthropic's "... = available_tokens: N" shape. OpenRouter and Nous Research return the same condition in a different shape: "maximum context length is 256000 tokens. However, you requested about 281093 tokens (5683 of text input, 13410 of tool input, 262000 in the output)." For OpenRouter/Nous users, the parser returned None, so Hermes classified the error as a prompt-overflow and tried to compress history. On a fresh session with 1 message there's nothing to compress, so the gateway auto-reset the session — and on the next message the same max_tokens config value produced the same error, looping forever. The new guard recognizes the OpenRouter shape by its three structural anchors (text input, tool input, "in the output") and extracts the available output as context_length - text_input - tool_input. All existing Anthropic-format tests still pass. Closes NousResearch#38652
Two related bugs in NousResearch#38650: 1. ``hermes dump`` reported ``mcp_servers: 0`` even when servers were configured. ``_count_mcp_servers`` read the wrong config key (``config["mcp"]["servers"]`` — a path that never existed in real configs). The real Hermes config uses a top-level ``mcp_servers`` key. Fix reads the right key, and additionally prefers the live ``_servers`` registry over the config snapshot — the registry is the reliable truth at dump time because MCP discovery runs asynchronously in the background. 2. The welcome banner labeled every not-yet-connected server as "failed" (red text) on fresh launch. The MCP registry is empty for a few seconds while background discovery is in flight, so every server showed as failed. ``get_mcp_status()`` now distinguishes "pending" (discovery hasn't reached this server yet) from "failed" (the genuine case), and the banner renders pending as a neutral "connecting…". The original "failed" rendering is preserved for the case where discovery has actually attempted and given up on a server — the "pending" flag is only set when the live registry has no record of that server *and* discovery either hasn't started or hasn't reached it yet. Closes NousResearch#38650
…ousResearch#38662) The gateway's /claude command was setting acp_command='claude' with args=['--acp', '--stdio']. Claude Code CLI doesn't implement --acp, so every child agent died with "unknown option '--acp'" before any prompt was sent, retried three times, then fell back to a different model and surfaced a confusing 401. The ACP subprocess transport in agent/copilot_acp_client.py is wired specifically to GitHub Copilot CLI. Validate at the delegate_task entry point so a bad acp_command fails fast with a clear message that names the supported CLI and points at the claude-code skill for Claude users. Update the existing test that pinned the broken behavior and add coverage for the new allowlist. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…orted ACP transport delegate_task exposes acp_command / acp_args as free-form, model-controllable tool-call arguments. They propagate unvalidated through child construction into subprocess.Popen([acp_command] + acp_args, ...) (agent/copilot_acp_client.py), so a prompt-injected delegate_task call with acp_command="python" (or powershell/bash + inline code in acp_args) is arbitrary host-process execution that bypasses the terminal backend's sandboxing and command approvals — a model-reachable RCE crossing the OS boundary. Add a copilot-only allowlist (_validate_acp_command / _SUPPORTED_ACP_COMMANDS) enforced on BOTH the top-level and per-task model-supplied overrides, before any child agent is built. A bare `copilot` or an absolute path ending in `/copilot` (custom install location) is accepted; everything else returns a clear tool error. Operator-configured `delegation.command` (creds["command"]) is trusted and is not routed through the check. Schema descriptions updated to state the restriction. Supersedes NousResearch#39462 (stale / merge-conflicted), which first proposed the copilot-only allowlist; this carries that approach forward, rebased on current main, with top-level + per-task enforcement and regression tests. Credit also to @nikshepsvn (NousResearch#27426) for the earlier acp_command crash-hardening attempt. The companion credential-inheritance half of this finding (the ACP subprocess inheriting os.environ secrets) is addressed separately in the "sanitize agent subprocess env" change. Co-authored-by: ssiweifnag <120658181+ssiweifnag@users.noreply.github.com> Co-authored-by: nikshepsvn <23241247+nikshepsvn@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for surfacing this — you were first to propose restricting the model-supplied We're closing it along with the later carry-forward (#52346), because the fix direction we want isn't an allowlist. Filtering the value keeps Your work here is credited as the earliest proposal of this restriction. Thanks! |
Source: #52346 Related prior work: #39462 Related prior work: #27426 Maintainer direction: #52346 (comment) Remove acp_command and acp_args from the model-facing delegate_task schema and dispatch paths. Child agents can still use ACP subprocess transport when it comes from trusted delegation config or parent inheritance, but a model tool call can no longer choose the command or arguments that reach child construction. This is salvageable because the risky boundary is model control over child ACP transport, not ACP itself. The patch follows the maintainer direction from the source discussion by preserving trusted ACP configuration and prior integration work while removing the untrusted tool-call fields from both top-level and per-task delegate inputs. Reproduced on main by passing acp_command through delegate_task and observing it reach _build_child_agent. Verified after the fix that model dispatch strips the hidden top-level fields and per-task hidden fields are ignored before child construction. Co-authored-by: Carlosian <claudlos@agentmail.to> Co-authored-by: ssiweifnag <120658181+ssiweifnag@users.noreply.github.com> Co-authored-by: nikshepsvn <23241247+nikshepsvn@users.noreply.github.com>
Source: NousResearch#52346 Related prior work: NousResearch#39462 Related prior work: NousResearch#27426 Maintainer direction: NousResearch#52346 (comment) Remove acp_command and acp_args from the model-facing delegate_task schema and dispatch paths. Child agents can still use ACP subprocess transport when it comes from trusted delegation config or parent inheritance, but a model tool call can no longer choose the command or arguments that reach child construction. This is salvageable because the risky boundary is model control over child ACP transport, not ACP itself. The patch follows the maintainer direction from the source discussion by preserving trusted ACP configuration and prior integration work while removing the untrusted tool-call fields from both top-level and per-task delegate inputs. Reproduced on main by passing acp_command through delegate_task and observing it reach _build_child_agent. Verified after the fix that model dispatch strips the hidden top-level fields and per-task hidden fields are ignored before child construction. Co-authored-by: Carlosian <claudlos@agentmail.to> Co-authored-by: ssiweifnag <120658181+ssiweifnag@users.noreply.github.com> Co-authored-by: nikshepsvn <23241247+nikshepsvn@users.noreply.github.com>
Source: NousResearch#52346 Related prior work: NousResearch#39462 Related prior work: NousResearch#27426 Maintainer direction: NousResearch#52346 (comment) Remove acp_command and acp_args from the model-facing delegate_task schema and dispatch paths. Child agents can still use ACP subprocess transport when it comes from trusted delegation config or parent inheritance, but a model tool call can no longer choose the command or arguments that reach child construction. This is salvageable because the risky boundary is model control over child ACP transport, not ACP itself. The patch follows the maintainer direction from the source discussion by preserving trusted ACP configuration and prior integration work while removing the untrusted tool-call fields from both top-level and per-task delegate inputs. Reproduced on main by passing acp_command through delegate_task and observing it reach _build_child_agent. Verified after the fix that model dispatch strips the hidden top-level fields and per-task hidden fields are ignored before child construction. Co-authored-by: Carlosian <claudlos@agentmail.to> Co-authored-by: ssiweifnag <120658181+ssiweifnag@users.noreply.github.com> Co-authored-by: nikshepsvn <23241247+nikshepsvn@users.noreply.github.com>
Source: NousResearch#52346 Related prior work: NousResearch#39462 Related prior work: NousResearch#27426 Maintainer direction: NousResearch#52346 (comment) Remove acp_command and acp_args from the model-facing delegate_task schema and dispatch paths. Child agents can still use ACP subprocess transport when it comes from trusted delegation config or parent inheritance, but a model tool call can no longer choose the command or arguments that reach child construction. This is salvageable because the risky boundary is model control over child ACP transport, not ACP itself. The patch follows the maintainer direction from the source discussion by preserving trusted ACP configuration and prior integration work while removing the untrusted tool-call fields from both top-level and per-task delegate inputs. Reproduced on main by passing acp_command through delegate_task and observing it reach _build_child_agent. Verified after the fix that model dispatch strips the hidden top-level fields and per-task hidden fields are ignored before child construction. Co-authored-by: Carlosian <claudlos@agentmail.to> Co-authored-by: ssiweifnag <120658181+ssiweifnag@users.noreply.github.com> Co-authored-by: nikshepsvn <23241247+nikshepsvn@users.noreply.github.com>
Source: NousResearch#52346 Related prior work: NousResearch#39462 Related prior work: NousResearch#27426 Maintainer direction: NousResearch#52346 (comment) Remove acp_command and acp_args from the model-facing delegate_task schema and dispatch paths. Child agents can still use ACP subprocess transport when it comes from trusted delegation config or parent inheritance, but a model tool call can no longer choose the command or arguments that reach child construction. This is salvageable because the risky boundary is model control over child ACP transport, not ACP itself. The patch follows the maintainer direction from the source discussion by preserving trusted ACP configuration and prior integration work while removing the untrusted tool-call fields from both top-level and per-task delegate inputs. Reproduced on main by passing acp_command through delegate_task and observing it reach _build_child_agent. Verified after the fix that model dispatch strips the hidden top-level fields and per-task hidden fields are ignored before child construction. Co-authored-by: Carlosian <claudlos@agentmail.to> Co-authored-by: ssiweifnag <120658181+ssiweifnag@users.noreply.github.com> Co-authored-by: nikshepsvn <23241247+nikshepsvn@users.noreply.github.com>
Summary
Fixes #38662 — the gateway's
/claudecommand setacp_command='claude'withargs=['--acp', '--stdio']. Claude Code CLI doesn't implement--acp, so every child agent died withunknown option '--acp'before sending any prompt, retried three times, then fell back to a different model and surfaced a confusing 401 to the user.The ACP subprocess transport in
agent/copilot_acp_client.pyis wired specifically to GitHub Copilot CLI. This PR validatesacp_commandat thedelegate_taskentry point so a bad value fails fast with a clear message that names the supported CLI and points at theclaude-codeskill for Claude users.Changes
tools/delegate_tool.py— add_validate_acp_commandallowlist helper, call it indelegate_taskfor the top-level kwarg and inside the per-task loop. Allowlist currently contains onlycopilot(matching whatagent/copilot_acp_client.pysupports); absolute paths ending in/copilotare accepted so users with the CLI in a non-default location (nvm, asdf) aren't tripped up.tests/tools/test_delegate.py— update the existingtest_acp_args_forwardedtest (it was pinning the broken behavior withacp_command="claude"), and add 12 new tests covering the allowlist unit (TestValidateAcpCommand) and the end-to-end rejection indelegate_task(TestDelegateAcpCommandRejection).Why not a Claude → ACP bridge?
The issue's "Proposed Fix" section suggests building a lightweight ACP bridge that translates
claude -p "prompt"into ACP JSON-RPC. That's a much larger surface — the user would be responsible for keeping the bridge in sync with the ACP spec, and we'd be adding a new code path to maintain. The transport's docstring explicitly states it is built forcopilot --acp --stdio, so failing fast on the unsupported value is the more conservative fix and matches what the existing tool schema description already promises ("Requires an ACP-compatible CLI (currently GitHub Copilot CLI via 'copilot --acp --stdio')"). If a real Claude→ACP bridge gets built later, expanding the allowlist is a one-line change.Test plan
pytest tests/tools/test_delegate.py -v— 147 passedTestValidateAcpCommandcoversNone,"","copilot","/opt/copilot/bin/copilot","claude","claude-code","aider", etc.TestDelegateAcpCommandRejectioncovers top-level rejection, per-task rejection, and the positive path (copilot still works, unset still works)TestDispatchDelegateTask::test_acp_args_forwardednow usesacp_command="copilot"so it asserts the supported path🤖 Generated with Claude Code