Skip to content

fix(server): answer hosted agent relay refusals as agent-device errors - #2575

Merged
janicduplessis merged 2 commits into
mainfrom
fix/2574-agent-refusal-json
Oct 6, 2026
Merged

janicduplessis merged 2 commits into
mainfrom
fix/2574-agent-refusal-json

Conversation

@janicduplessis

Copy link
Copy Markdown
Collaborator

Description

When a hosted macOS app's agent-device client sends a request the pinned relay refuses (for example agent-device doctor --platform macos), relayPinned answered 400 text/plain "Unsupported request.". The client parses every response body as JSON-RPC, so the agent saw COMMAND_FAILED "Invalid daemon response" with Unexpected token 'U' instead of a refusal.

Solution

relayPinned now answers the refusal in the shape the agent-device daemon uses for its own typed errors: a JSON-RPC 2.0 error envelope with data.code: UNAUTHORIZED, a hint, retriable: false and details.reason: STIM_AGENT_REQUEST_REFUSED. details.rule names what was refused (command, method or request) and the message names the command or method and lists the allowed commands. The request's JSON-RPC id is echoed when it is a string or number. HTTP status stays 400 and pinLease refuses exactly the inputs it refused before; device selectors are still stripped and the platform forced to macOS, not refused. stim guide macos and website/docs/macos.md describe the refusal.

Code written by Codex gpt-6.1-sol.

Test plan

  • pnpm test packages/server/__tests__/agent-device-driver.test.ts: the existing pinning test now asserts the envelope (status 400, application/json, id echo, details.reason/rule/command/method, retriable: false) for a disallowed command, a disallowed batch step, a disallowed method, non-JSON, a malformed body and an oversized body.
  • Live, with the real agent-device client and daemon from feat(remote): add a host-allocated macos-app lease backend callstack/agent-device#3236 against this branch's AgentDeviceDriver (script run on the MacBook with a lease for a fake app; not run through the Mac mini's stim-server, which runs main): doctor --platform macos and devices now fail with UNAUTHORIZED, details.reason: STIM_AGENT_REQUEST_REFUSED, rule: command, message Refused command "doctor". Allowed commands: .... open com.apple.TextEdit still gets the daemon's own MACOS_APP_LEASE_DENIED.

Fixes #2574

@janicduplessis janicduplessis left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fresh review (Claude). No blocking issues found.

Refusal boundary: unchanged. I compared pinLease input by input against origin/main:

  • body === null (oversized) was refused before calling pinLease. It now reaches pinLease, goes to rpc = undefined, and is refused with rule request.
  • JSON parse failure, a non-object root, a non-string method or non-object params are all refused with rule request, the same set as before.
  • Command methods: a top-level command that fails isAllowedCommand is refused. The batch for loop uses exactly the old .some() predicate (!isJsonObject(step) || !isAllowedCommand(step.command)), and its early return happens before any rewrite.
  • Lease methods are rewritten exactly as before, and any other method is refused.
  • The forwarded bytes (Buffer.from(JSON.stringify(rpc))) and relay() are untouched. Status stays 400.

Envelope vs the agent-device client. Checked against the installed client (0.21.12, daemon-client-lifecycle.js). Xt reads data.code (normalized; UNAUTHORIZED is a known code), data.message, data.hint, data.details (object) and data.retriable (boolean). Qt spreads details into the thrown error's details next to hint/diagnosticId/logPath. reason/rule/command/method don't collide with those keys. The JSON-RPC error.code number is ignored by the client, so -32000 is fine. The daemon itself uses -32001 for its proxy-token UNAUTHORIZED, so you could match that, but it's optional. Because baseUrl is set, the client will add a logPathUnavailable: ... the daemon named no diagnostics record detail. That's harmless.

Leaks and injection. None that cross a principal. The only echoed values are the requester's own command/method strings and its id, and they go back on the same connection. JSON.stringify keeps the body valid JSON whatever the input. Nothing is logged server-side. The echo is unbounded, though: a ~1 MB method string comes back three times (error.message, data.message, details.method), so up to ~3 MB to the sender. That only amplifies against the sender, and the sender's own terminal is the only place escape sequences could land. Optional: truncate the echoed name (e.g. 128 chars) to keep refusal bodies small.

Tests

  1. The non-object batch-step branch (refuseCommand(undefined) for e.g. batchSteps: ['doctor'] or [null]) has no test, either before or after this PR. It is the branch that matters most here: pinStep returns a non-object step unchanged, so if this check regressed, the step would be forwarded upstream un-pinned. Consider adding one row to the new table (rule: 'command', no command in details).
  2. The new table repeats cases the pre-existing status-only loops already cover: lease.allocate, 'not json', { method: 'agent_device.command' } with no params, and a disallowed batch step. Per CLAUDE.md ("extend a relevant case instead of repeating it"), consider folding the old status-only loops into the table, or dropping the duplicates there.
  3. Nit: the id: true row says details: {}, but the refusal does include details.method. toMatchObject hides that, so the row reads as if there are no details. Use { method: 'agent_device.lease.allocate' } or rename the row.
  4. The 1 MB+1 row is not flaky as far as I can see. readBody drains the whole request before answering, so the client never sees EPIPE/ECONNRESET mid-write.

Docs / style

  • The hint always says the connection "allows only ". For rule: method refusals that is slightly off, since lease heartbeat/release methods are also forwarded. Minor.
  • The guide and website text is accurate for command/method refusals. Malformed requests get rule request and a generic message, which the docs don't claim to name, so that's fine.
  • STIM_AGENT_REQUEST_REFUSED is a new STIM_* token that isn't indexed in stim guide errors. The guide code-scan test only reads commands/macos.ts, not guide/macos.ts, so nothing fails. Your call whether a details.reason belongs in the errors index.
  • The diff is ASCII-only, adds no new comments, and the empty catch {} matches existing usage in this file.

@janicduplessis
janicduplessis marked this pull request as ready for review October 6, 2026 01:07
@janicduplessis
janicduplessis merged commit 06c68a5 into main Oct 6, 2026
11 checks passed
@janicduplessis
janicduplessis deleted the fix/2574-agent-refusal-json branch October 6, 2026 01:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hosted agent relay refusals reach agent-device as "Invalid daemon response"

1 participant