fix(acp): let a generic-ACP agent declare the env vars it authenticates with - #4392
Conversation
…es with
A generic-ACP agent configured the documented way (an `acp.agents:` row, or
`omnigent setup` -> Custom ACP agent) was spawned with no provider credentials
and no way to be given any, so it started unauthenticated, stalled during the
handshake, and every turn failed.
The spawn env is deny-by-default with an empty prefix family: the executor
drives an arbitrary agent, so it cannot know which vendor family that agent
authenticates with, and guessing would re-widen the leak that filtering closed.
That part is right. The gap was the escape hatch: `env_passthrough` only existed
on a full agent spec's `os_env.sandbox`, which a user configuring an agent
through `acp.agents:` never authors. Measured against a realistic environment,
only HOME/PATH/TERM survived.
Keep deny-by-default and make the hatch reachable per agent:
acp:
agents:
- name: Grok Build
command: grok agent stdio
env_passthrough: [XAI_API_KEY]
Names only, never values: the variable is read from the host environment at
spawn, so no secret lands in config.yaml. A `NAME=value` entry is rejected
rather than accepted-and-ignored, since that mistake would write a plaintext
credential and still not reach the agent. Threaded through the existing
plumbing (AcpAgentEntry -> HARNESS_ACP_ENV_PASSTHROUGH -> AcpAgentConfig ->
_build_spawn_env), unioned with any spec-declared names, and also honored for a
spec-embedded one-shot agent.
Also stop the handshake timeout reporting itself as a blank failure.
`asyncio.TimeoutError` carries no message, so a caller reporting it by
`str(exc)` produced `inner executor error: ` with nothing to act on. `_rpc` now
raises a TimeoutError naming the agent, the stalled method and the deadline, at
the one place every handshake RPC routes through.
Before: `inner executor error: `
After: `inner executor error: ACP agent 'Grok Build' did not answer
session/new within 30s (command: 'grok agent stdio')`
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
|
…llowlist The canary drives the real `_build_spawn_env` on an executor built via `object.__new__` carrying only the attributes the builder reads, so reading `self._config` unconditionally raised AttributeError there. Read the agent config defensively, matching the duck-typed style `declared_passthrough` already uses for the spec chain. Also extend the canary to the new field: a declared name is an allowlist, not a bypass, so the declared variable arrives and every planted canary secret still stays out. Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
|
🏷️ Doc impact: Adds an optional Auto-classified on merge. Set the label manually before merging to override. · run |
Related issue
Closes #4390
(Root cause behind #4281, whose visible symptom — the blank error — is
addressed separately by #4362. Also unblocks #3460, where the ACP model list
never arrives because the turn dies before
session/newreturns.)Summary
A generic-ACP agent configured the documented way — an
acp.agents:row, oromnigent setup→ Custom ACP agent — was spawned with no providercredentials and no way to be given any, so it started unauthenticated, stalled
during the handshake, and every turn failed.
The spawn env is deny-by-default with an empty prefix family, and that part is
correct: the executor drives an arbitrary agent, so it cannot know which
vendor family the agent authenticates with, and guessing would re-widen the leak
that #3479 closed. The gap was the escape hatch.
env_passthroughonly existedon a full agent spec's
os_env.sandbox, which a user configuring an agentthrough
acp.agents:never authors — so in practice there was no hatch at all.Measured against a realistic environment, only
HOME/PATH/TERMsurvived;
XAI_API_KEY,OPENAI_API_KEY,ANTHROPIC_API_KEY,GOOSE_*wereall stripped.
Keep deny-by-default; make the hatch reachable per agent:
at spawn, so no secret lands in
config.yaml. ANAME=valueentry isrejected rather than accepted-and-ignored — that mistake would write a
plaintext credential and silently not reach the agent, so it has to be loud.
AcpAgentEntry→HARNESS_ACP_ENV_PASSTHROUGH→AcpAgentConfig→_build_spawn_env'sextra_allowed, unioned with any spec-declaredos_env.sandbox.env_passthrough(neither shadows the other). A spec-embeddedone-shot
acp_agenthonors it too.Second, no more blank turn errors from a stalled handshake.
asyncio.TimeoutErrorcarries no message, so a caller reporting it viastr(exc)producedinner executor error:with nothing to act on._rpcnowraises a
TimeoutErrornaming the agent, the stalled method and the deadline —fixed once at the single point every handshake RPC routes through, rather than at
each caller:
ELI5 + flow
Omnigent deliberately hands an agent a near-empty environment so one agent can't
read another provider's API key. For built-in agents it then adds back that
agent's own family (
GOOSE_*for goose,QWEN_*for qwen). For a custom ACPagent it can't know what to add back — and there was no way for you to say. So
the agent got no key, hung waiting on its provider, and the turn died without a
message. Now you name the variable you use, and only that one is passed through.
Test Plan
New tests (all three layers of the path, so a future break in any link fails):
tests/onboarding/test_acp_auth.py— parse → persist round-trip, bare-stringform, de-dup/trim, and the
NAME=value+ non-name rejections.tests/runtime/test_acp_spawn_env.py— names forwarded toHARNESS_ACP_ENV_PASSTHROUGH; var absent when undeclared (deny-by-defaultpreserved); embedded-agent path.
tests/inner/test_acp_executor.py— the credential lands in the real spawnenv from the agent config, from the spec, and from both unioned; an undeclared
secret still does not; the wrap decodes the forwarded names; and a stalled
handshake yields a non-blank error naming
session/new.tests/test_agent_spawn_env_canary.py— extends the Host-secret env leak fixed in pi/codex executors is still present in qwen_executor and acp_executor #3445 canary: a declaredname is an allowlist, not a bypass (the named variable arrives; every planted
canary secret still stays out).
The handshake test fails on
main(ExecutorError(message='')) and passes here.Regression sweep over the executors/config this touches — 1082 passed:
13 failures in
tests/onboarding/sandboxes/test_base.py(host-config rendering)and a
json5import error intest_openclaw_config.pyreproduce identicallywith these changes stashed — pre-existing, unrelated to this PR.
ruff check+ruff format --checkclean;pyreflyreports 0 errors on allfour changed modules.
End-to-end through the real config path (a temp
OMNIGENT_CONFIG_HOMEwithan
acp.agents:row →_build_acp_spawn_env→ the harness wrap → a fake ACPagent that answers
initializeoffline but contacts its provider onsession/new, so it stalls without a key). One variable changed:Demo
N/A — backend/config change, no visual surface. The before/after operator-facing
strings are quoted in the Summary.
Type of change
Test coverage
Coverage notes
Automated tests cover each link of the chain (config parse → spawn-env
forwarding → wrap decode → the env the subprocess actually receives), plus the
negative case that an undeclared variable is still withheld. Manual verification
was the end-to-end run in the Test Plan: a real
acp.agents:config resolvedthrough
_build_acp_spawn_envinto the harness wrap against a fake ACP agentthat hangs without a key — reproducing the original blank failure and then
succeeding with the variable declared. Not exercised: a real vendor CLI
(
grok agent stdio/goose acp), which needs live credentials.Changelog
A custom ACP agent can declare the environment variables it authenticates with via
env_passthrough, and a stalled ACP handshake now reports which call timed out instead of failing with an empty message.