fix(cli): emit the JSON error envelope for transport failures - #106
fix(cli): emit the JSON error envelope for transport failures#106N-45div wants to merge 2 commits into
Conversation
runCli rethrew every error that was not an InvalidArgumentsError, so an HTTP or transport failure escaped to main() and printed a bare message on stderr with nothing on stdout. The skill instructs agent hosts to treat all command output as JSON, so a failed `auth login` gave them an empty stdout and no error.code to branch on. Route every failure through writeCommandError, and give HttpStatusError its own payload branch carrying status_code, the parsed upstream remote_error, and a bounded fallback for non-JSON bodies. A 5xx from the brokered-login registration endpoint now also says the failure is server-side and that the Developer API path does not depend on it, instead of leaving users to guess that reinstalling the CLI might help. Adds coverage for both the JSON and the non-JSON upstream error body.
Ray-56
left a comment
There was a problem hiding this comment.
[P1] Do not promote an untrusted upstream error body directly into the CLI's trusted error contract or terminal output.
parseRemoteErrorBody bounds only non-JSON text. A JSON response can supply arbitrarily long code and message strings; the upstream code becomes the top-level error.code, while the message is duplicated in the JSON envelope and written raw to stderr. A compromised, misconfigured, or user-selected broker can therefore spoof stable local codes such as auth_required/invalid_arguments, inject CR/LF or ANSI terminal controls into logs, amplify output, and expose internal response details that the CLI previously withheld.
Please keep the top-level code locally owned (broker_unavailable or http_error) and retain a normalized upstream code only under remote_error. Reuse one remote-string sanitizer for both JSON and non-JSON bodies: allowlist fields, cap code/message lengths, constrain machine codes to a safe character set, strip or escape terminal controls before stderr, and avoid surfacing token-like/internal fields. Add hostile JSON tests covering a long flat and nested message, forged local code, CR/LF/ANSI content, and a secret-like field that must not appear in stdout or stderr.
[P2] The central transport guarantee and documentation are incomplete. Both new tests return HTTP responses; neither covers fetch rejecting with TypeError/DNS/connection failure. Add a genuine rejected-fetch regression asserting nonzero exit, parseable stdout JSON, no help_command, and bounded/safe stderr. Update the canonical packages/cli/docs/cli-reference.md and synchronized packages/cli/README.md, which still state that some top-level failures may print plain stderr, and explicitly document the stable envelope fields.
CI for 359ee27 is action_required with zero jobs. The exact head passes pnpm check, pnpm test, and pnpm pack:dry-run in an isolated checkout, but this touches authentication/error handling and cannot be merged without a real green required CI run after the fix.
Release decision: Patch release recommended for @call-e/cli; the changeset targets the correct package and bump, but its claim that upstream bodies are safely bounded must be corrected with the implementation.
Address review on the JSON error envelope:
- error.code is never taken from an upstream response. HTTP failures use
broker_unavailable or http_error; a rejected or timed-out fetch uses
transport_error. An upstream body can no longer impersonate a stable
local code such as auth_required.
- Upstream detail lives only under error.remote_error, reduced to code and
message (top-level or nested under `error`); every other field is
dropped unread so token-like values cannot reach stdout or stderr.
- One sanitizer for all remote-derived strings: safeRemoteString now strips
ANSI CSI/OSC/ESC sequences and C0/C1 controls before bounding, and
safeRemoteCode constrains machine codes to [A-Za-z0-9_.:-]{1,64}.
writeCommandError strips controls again before writing stderr.
- Add regressions: forged auth_required, 20 KB message, nested error
object with internal fields, CR/LF/ANSI content, secret-like fields
absent from stdout and stderr, unsafe code dropped, fetch rejecting
with TypeError/ENOTFOUND, and a request timeout.
- Document the error envelope and its stable fields in cli-reference.md
and README.md, replacing the statement that some failures print plain
stderr. Correct the changeset to describe the sanitization.
|
Thanks for the review — both points were right, and P1 was a real hole: I had promoted whatever P1 — trusted contract
P2 — transport coverage and docs
Testing
On CI: I can't trigger the workflow from the fork side ( |
Summary
A failing
calle auth logincurrently prints a bare string to stderr and writes nothing tostdout, so an agent host has no JSON to parse and no
error.codeto branch on.runClirethrows every error that is not anInvalidArgumentsError, so HTTP and transportfailures skip
writeCommandErrorentirely and land inmain(), which only printserror.message. That contradicts the contract the skill sets for agent hosts, inskills/calle/references/commands.md:An agent following that instruction gets an empty stdout on any network-layer failure.
Separately,
HttpStatusErroralready capturesstatusCodeandresponseText, but nothingreads them, so the upstream error body is discarded.
How I hit this
Fresh install on Windows, Node v24.11.1,
@call-e/cli0.5.0, clean token cache:npm install -g @call-e/cli calle auth status --json # usable: false, no cache calle auth login --start-only --no-browser-open --jsonBefore
Nothing on stdout. The reason is only visible if you curl the broker by hand:
{"error":"oauth_register_failed", "message":"Failed to register an OAuth client. err_type=HTTPStatusError"}The rest of the estate was healthy at that moment — the MCP endpoint returned 401 and
api.heycall-e.comreturned 405 — so the natural reading of a bare 502 is "my install isbroken", and the documented recovery path (reinstall, re-auth) cannot help.
After, against the same live 502:
{ "ok": false, "server_url": "https://seleven-mcp-sg.airudder.com/mcp/openagent_oauth", "error": { "code": "oauth_register_failed", "message": "Client error '502 Bad Gateway' for url '.../sessions' Failed to register an OAuth client. err_type=HTTPStatusError The CALL-E login service is unavailable. This is not a local configuration problem, so reinstalling the CLI will not help. Retry later, or use the Developer API with a dashboard API key, which does not depend on brokered login.", "status_code": 502, "remote_error": { "code": "oauth_register_failed", "message": "Failed to register an OAuth client. err_type=HTTPStatusError" } } }Changes
runCliroutes every failure throughwriteCommandError, not justInvalidArgumentsError. Thehelp_commandhint stays exclusive to argument errors, so thatpath is byte-identical to before.
errorPayloadgains anHttpStatusErrorbranch carryingstatus_code, the parsed upstreamremote_error, and a 500-character bounded fallback when the body is not JSON (gatewaysoften return HTML).
failure is server-side and that the Developer API path does not depend on it.
Testing
packages/cli: 55 unit + 15 e2e passing, including two new cases — a JSON upstream errorbody, and a long non-JSON body asserting the bound holds.
core,claude-plugin,codex-plugin,cursor-plugin,openclaw-cli-skill,skills-sh-skill: 142 tests total across the repo, 0 failures.node scripts/check-runtime-syntax.mjspasses for all 8 CLI files.pnpm pack:dry-runin my environment; the change touches no packagemetadata.
Notes
Changeset included. Happy to split the "login service is unavailable" wording into a separate
commit if you would rather keep the envelope fix purely mechanical.