Skip to content

fix(cli): emit the JSON error envelope for transport failures - #106

Open
N-45div wants to merge 2 commits into
CALLE-AI:mainfrom
N-45div:fix/cli-json-error-envelope
Open

fix(cli): emit the JSON error envelope for transport failures#106
N-45div wants to merge 2 commits into
CALLE-AI:mainfrom
N-45div:fix/cli-json-error-envelope

Conversation

@N-45div

@N-45div N-45div commented Sep 4, 2026

Copy link
Copy Markdown

Summary

A failing calle auth login currently prints a bare string to stderr and writes nothing to
stdout
, so an agent host has no JSON to parse and no error.code to branch on.

runCli rethrows every error that is not an InvalidArgumentsError, so HTTP and transport
failures skip writeCommandError entirely and land in main(), which only prints
error.message. That contradicts the contract the skill sets for agent hosts, in
skills/calle/references/commands.md:

Treat all command output as JSON except --help.
If ok is false and error.code is auth_required, run or suggest auth login, then retry.

An agent following that instruction gets an empty stdout on any network-layer failure.

Separately, HttpStatusError already captures statusCode and responseText, but nothing
reads them, so the upstream error body is discarded.

How I hit this

Fresh install on Windows, Node v24.11.1, @call-e/cli 0.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 --json

Before

Client error '502 Bad Gateway' for url 'https://seleven-mcp-sg.airudder.com/api/v1/openagent-auth/sessions'

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.com returned 405 — so the natural reading of a bare 502 is "my install is
broken", 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

  • runCli routes every failure through writeCommandError, not just
    InvalidArgumentsError. The help_command hint stays exclusive to argument errors, so that
    path is byte-identical to before.
  • errorPayload gains an HttpStatusError branch carrying status_code, the parsed upstream
    remote_error, and a 500-character bounded fallback when the body is not JSON (gateways
    often return HTML).
  • A 5xx specifically from the brokered-login registration endpoint adds one sentence saying the
    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 error
    body, and a long non-JSON body asserting the bound holds.
  • Also ran the suites for 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.mjs passes for all 8 CLI files.
  • Verified by hand against the live broker while it was returning 502.
  • I could not run pnpm pack:dry-run in my environment; the change touches no package
    metadata.

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.

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 Ray-56 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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.
@N-45div

N-45div commented Sep 5, 2026

Copy link
Copy Markdown
Author

Thanks for the review — both points were right, and P1 was a real hole: I had promoted whatever code the upstream body sent straight into the CLI's own error.code. Addressed in the follow-up commit.

P1 — trusted contract

  • error.code is now always CLI-owned: broker_unavailable (5xx from the brokered-login registration endpoint), http_error (any other non-success status), or transport_error (fetch rejected / timed out before a response). Nothing from an upstream body can reach the top-level code, so auth_required and invalid_arguments cannot be spoofed.
  • Upstream detail lives only under error.remote_error, allowlisted to code and message (top-level or nested under error). Every other field is dropped unread, so token-like values never touch stdout or stderr.
  • One sanitizer for both JSON and non-JSON bodies: I hardened the existing safeRemoteString rather than adding a second helper, so MCP call errors get the same treatment. It strips ANSI CSI/OSC/ESC sequences and C0/C1 controls, then bounds (500 chars for messages, 300 for the local status line). safeRemoteCode constrains machine codes to [A-Za-z0-9_.:-]{1,64} and drops anything else rather than "cleaning" it into something plausible. writeCommandError strips controls once more before stderr as a last line of defence.
  • Hostile tests added: forged auth_required (asserts no login_url/assistant_hint leak through), 20 KB flat message, nested error object with internal details/request_id fields, CR/LF/ANSI content, access_token/token/refresh_token fields absent from stdout and stderr, and an unsafe code (bad code�[31m) dropped.

P2 — transport coverage and docs

  • Added a genuine rejected-fetch regression (TypeError("fetch failed") with cause.code = ENOTFOUND): exit 1, parseable stdout, error.code = transport_error, cause_code = ENOTFOUND, no help_command, stderr bounded and control-free. Plus a request-timeout case classified the same way.
  • cli-reference.md gains an Error Envelopes section documenting ok, server_url, error.code (with the full list of CLI-owned values and exit codes), error.message, error.status_code, error.remote_error, error.cause_code, and help_command. Both it and README.md no longer say some failures print plain stderr.
  • Changeset corrected to describe the sanitization instead of claiming bodies are "bounded".

Testing

  • packages/cli: 67 unit + 15 e2e green (12 new). Other packages: 72 green. check-runtime-syntax, branch-name, manifest-version and install-doc sync all pass locally.
  • Also ran the patched CLI against the live broker again while writing this; it is answering normally now, so I could not re-reproduce the 502 end to end — the hostile-body tests cover that path.

On CI: I can't trigger the workflow from the fork side (action_required, first-time contributor), so it needs an approval click from a maintainer whenever you're ready. Happy to split the docs into a separate commit if that's easier to review.

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.

2 participants