Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/json-error-envelope-for-transport-failures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
"@call-e/cli": patch
---

Always emit the documented JSON error envelope, and surface upstream error detail safely.

`runCli` previously rethrew every error that was not an `InvalidArgumentsError`, so any
transport or upstream HTTP failure escaped to `main()` and printed a bare message to stderr
with nothing on stdout. Agent hosts are instructed to treat all command output as JSON, so a
failed `auth login` left them with an empty stdout and no `error.code` to branch on.

Every failure now leaves through `writeCommandError`. `error.code` is always CLI-owned:
`broker_unavailable` when the brokered-login service returns a 5xx, `http_error` for other
non-success statuses, and `transport_error` when `fetch` rejects or times out before a
response arrives. Upstream detail is exposed only under `error.remote_error` after passing
through the same sanitizer used for MCP call errors: only `code` and `message` are read
(top-level or nested under `error`), all other fields are dropped unread, codes are
constrained to `[A-Za-z0-9_.:-]` and 64 characters, messages are capped at 500 characters,
and ANSI/C0/C1 terminal control sequences are stripped before anything reaches stdout or
stderr. An upstream body cannot set the top-level code, so it cannot impersonate stable
local codes such as `auth_required`.

Before, against a broker returning 502:

```text
Client error '502 Bad Gateway' for url '.../api/v1/openagent-auth/sessions'
```

After:

```json
{
"ok": false,
"error": {
"code": "broker_unavailable",
"status_code": 502,
"remote_error": {
"code": "oauth_register_failed",
"message": "Failed to register an OAuth client. err_type=HTTPStatusError"
}
}
}
```

The CLI reference and README now document the error envelope and its stable fields.
9 changes: 6 additions & 3 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,12 @@ original confirmation context without printing it. If only the initial status
query fails, the command still returns the accepted `run_id` and a `call status`
`next_command`.

Successful command stdout is JSON except help and version output. Some
top-level or local failures may print plain stderr. Access tokens are read from
the local cache and are never printed.
Command stdout is JSON except help and version output, for failures as well as
successes: every error writes a JSON envelope with a CLI-owned `error.code` to
stdout, a one-line summary to stderr, and exits non-zero. Upstream error details
appear only under `error.remote_error`, sanitized and bounded. See
[Error Envelopes](./docs/cli-reference.md#error-envelopes). Access tokens are
read from the local cache and are never printed.

## Options

Expand Down
57 changes: 55 additions & 2 deletions packages/cli/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ This is the canonical reference for `calle` commands, options, defaults, and
parameter examples. When changing CLI commands or options, update this document
and any synchronized command guidance in the same change.

Successful command stdout is JSON except `--help`, `-h`, `--version`, and `-V`.
Some top-level or local failures may print plain stderr.
Command stdout is JSON except `--help`, `-h`, `--version`, and `-V`. This holds
for failures too: every error leaves through the same JSON envelope on stdout,
with a one-line summary on stderr and a non-zero exit code. See
[Error Envelopes](#error-envelopes).

## JSON Result Envelopes

Expand Down Expand Up @@ -45,6 +47,57 @@ than the latest call state. See the
[MCP tool result envelope](../../../docs/mcp/openagent-oauth.md#tool-result-envelope)
for the direct protocol shape and SDK field-name differences.

## Error Envelopes

Every failure, including argument errors, transport failures, and upstream HTTP
errors, writes one JSON object to stdout and exits non-zero:

```json
{
"ok": false,
"server_url": "https://example.test/mcp/openagent_oauth",
"error": {
"code": "broker_unavailable",
"message": "Client error '502 Bad Gateway' for url '...' Failed to register an OAuth client. The CALL-E login service is unavailable. ...",
"status_code": 502,
"remote_error": {
"code": "oauth_register_failed",
"message": "Failed to register an OAuth client."
}
}
}
```

Stable fields:

| Field | Always present | Meaning |
| --- | --- | --- |
| `ok` | yes | `false` for every error envelope. |
| `server_url` | yes | Configured MCP server URL, or `null` when configuration could not be resolved. |
| `error.code` | yes | A code owned by the CLI. Branch on this. |
| `error.message` | yes | Human-readable summary; the same text is written to stderr. |
| `error.status_code` | HTTP errors | Upstream HTTP status. |
| `error.remote_error` | when an upstream body was readable | `{ code?, message? }` extracted from the upstream response, sanitized and bounded. Informational only. |
| `error.cause_code` | transport errors | Node.js error code such as `ENOTFOUND` or `ECONNREFUSED`, when known. |
| `help_command` | argument errors only | A directly runnable `--help` command. |

`error.code` values:

| Code | Exit | When |
| --- | --- | --- |
| `invalid_arguments` | 2 | Unknown command, missing or invalid option. `help_command` is set. |
| `auth_required` | 1 | No usable token, or the server rejected the token. Run `auth login`. |
| `broker_unavailable` | 1 | The brokered-login service returned a 5xx. Not a local problem. |
| `http_error` | 1 | Any other non-success HTTP status from a CLI-side request. |
| `transport_error` | 1 | The request never received a response: DNS, connection, TLS, or timeout. |
| `mcp_error` | 1 | An MCP-level failure, including stage failures from `call` commands, which add `stage`, `call_started`, `retry_safe`, and recovery fields. |

`error.code` is never taken from an upstream response. Upstream error codes and
messages appear only under `error.remote_error`, after sanitization: unknown
fields are dropped unread, codes are limited to `[A-Za-z0-9_.:-]` and 64
characters, messages are limited to 500 characters, and terminal control
sequences are removed before anything reaches stdout or stderr.

## Finding Command Help

Help is available at the root, command-group, and subcommand levels:
Expand Down
162 changes: 153 additions & 9 deletions packages/cli/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
resolveRuntimeConfig,
} from "./config.js";
import { ensurePendingLogin, loginWithBroker } from "./broker-client.js";
import { HttpStatusError } from "./http.js";
import {
AuthRequiredError,
McpHttpError,
Expand Down Expand Up @@ -882,24 +883,138 @@ function errorPayload(error, config, helpCommand = null) {
};
}

if (error instanceof HttpStatusError) {
const remoteError = sanitizedRemoteError(error.responseText);
const brokerUnavailable = isBrokerRegistrationFailure(error);
// error.message embeds the upstream status text, so it is remote-influenced too.
const messageParts = [safeRemoteString(error.message, LOCAL_MESSAGE_LIMIT) ?? "HTTP request failed."];
if (remoteError?.message) {
messageParts.push(remoteError.message);
}
if (brokerUnavailable) {
messageParts.push(BROKER_UNAVAILABLE_HINT);
}
return {
exitCode: 1,
body: {
ok: false,
server_url: config?.serverUrl ?? null,
error: {
// The top-level code is always locally owned. An upstream body must never be
// able to impersonate a stable local code such as `auth_required`.
code: brokerUnavailable ? "broker_unavailable" : "http_error",
message: messageParts.join(" "),
status_code: error.statusCode,
...(remoteError ? { remote_error: remoteError } : {}),
},
},
};
}

if (isTransportFailure(error)) {
const causeCode = safeRemoteCode(error?.cause?.code);
const detail = safeRemoteString(error?.message, LOCAL_MESSAGE_LIMIT) ?? "Request failed before a response was received.";
return {
exitCode: 1,
body: {
ok: false,
server_url: config?.serverUrl ?? null,
error: {
code: "transport_error",
message: causeCode ? `${detail} (${causeCode})` : detail,
...(causeCode ? { cause_code: causeCode } : {}),
},
},
};
}

return {
exitCode: 1,
body: {
ok: false,
server_url: config?.serverUrl ?? null,
error: {
code: "mcp_error",
message: error?.message || String(error),
message: safeRemoteString(error?.message ?? String(error), LOCAL_MESSAGE_LIMIT) ?? "Unknown error.",
},
},
};
}

const LOCAL_MESSAGE_LIMIT = 300;
const REMOTE_MESSAGE_LIMIT = 500;

const BROKER_UNAVAILABLE_HINT =
"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.";

/**
* Reduce an upstream HTTP error body to at most two sanitized strings.
*
* Allowlist only: `code` (or a string-valued `error`) and `message`, read from the top level
* or from a nested `error` object. Every other field is dropped unread, so token-like or
* internal fields in a response can never reach stdout or stderr. Both strings pass through
* the same sanitizer as MCP call errors; codes are additionally constrained to a machine-safe
* character set and length.
*/
function sanitizedRemoteError(responseText) {
const raw = typeof responseText === "string" ? responseText : "";
if (!raw.trim()) {
return null;
}

let parsed;
try {
parsed = JSON.parse(raw);
} catch {
parsed = undefined;
}

const record = recordObject(parsed);
if (!record) {
// Non-JSON (typically a gateway's HTML page) or a JSON array/scalar: keep a bounded,
// control-stripped excerpt and nothing else.
const message = safeRemoteString(raw, REMOTE_MESSAGE_LIMIT);
return message ? { message } : null;
}

const nested = recordObject(record.error) || {};
const codeValue = nested.code ?? record.code ?? (typeof record.error === "string" ? record.error : undefined);
const code = safeRemoteCode(codeValue);
const message = safeRemoteString(nested.message ?? record.message, REMOTE_MESSAGE_LIMIT);

if (code === undefined && message === undefined) {
return null;
}
return {
...(code !== undefined ? { code } : {}),
...(message !== undefined ? { message } : {}),
};
}

function isBrokerRegistrationFailure(error) {
return Number(error?.statusCode) >= 500
&& /\/api\/v1\/openagent-auth\/sessions/u.test(String(error?.message ?? ""));
}

// A rejected fetch (DNS failure, connection refused, TLS error) surfaces as a TypeError with
// a `cause`; the core HTTP layer turns an aborted request into a plain timeout Error.
function isTransportFailure(error) {
if (error instanceof HttpStatusError || error instanceof McpHttpError) {
return false;
}
if (error instanceof TypeError) {
return true;
}
return /^Request timed out for /u.test(String(error?.message ?? ""));
}

function writeCommandError(stdout, stderr, error, config, helpCommand = null) {
const formatted = errorPayload(error, config, helpCommand);
writeJson(stdout, formatted.body);
// Remote-derived strings are sanitized at the source; this is the last line of defence
// for the one channel that goes straight to a terminal.
stderr([
formatted.body.error.message,
stripTerminalControls(formatted.body.error.message),
...(formatted.body.help_command ? [`Run '${formatted.body.help_command}' for usage.`] : []),
].join("\n"));
return formatted.exitCode;
Expand Down Expand Up @@ -1018,11 +1133,38 @@ function structuredPayload(result) {
return result?.structuredContent || result?.structured_content || result || {};
}

// ANSI/VT escape sequences (CSI, OSC, and single-character ESC forms) plus C0/C1 control
// characters. Anything remote-supplied that reaches a log line or a terminal goes through
// this, so a hostile or misconfigured upstream cannot inject cursor movement, colour, line
// breaks, or hidden text into agent-visible output.
const TERMINAL_CONTROL_RE =
/\u001b\[[0-?]*[ -\/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-_]|[\u0000-\u001f\u007f-\u009f]/gu;

function stripTerminalControls(value) {
return String(value).replace(TERMINAL_CONTROL_RE, " ");
}

function safeRemoteString(value, maxLength = 1000) {
if (typeof value !== "string" || !value.trim()) {
if (typeof value !== "string") {
return undefined;
}
const cleaned = stripTerminalControls(value).trim();
if (!cleaned) {
return undefined;
}
return cleaned.slice(0, maxLength);
}

// Machine codes from upstream are kept only as an opaque, normalized token under
// `remote_error`; they never become the CLI's own `error.code`, which agent hosts branch on.
const REMOTE_CODE_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/u;

function safeRemoteCode(value) {
if (typeof value !== "string") {
return undefined;
}
return value.trim().slice(0, maxLength);
const cleaned = stripTerminalControls(value).trim();
return REMOTE_CODE_RE.test(cleaned) ? cleaned : undefined;
}

function safeRemoteCallError(result) {
Expand Down Expand Up @@ -1716,10 +1858,6 @@ export async function runCli(argv, deps = {}) {
try {
return await runCliCommand(argv, deps);
} catch (error) {
if (!(error instanceof InvalidArgumentsError)) {
throw error;
}

const stdout = deps.stdout || ((text) => process.stdout.write(text));
const stderr = deps.stderr || ((text) => process.stderr.write(`${text}\n`));
const [group, command, ...rest] = argv;
Expand All @@ -1730,7 +1868,13 @@ export async function runCli(argv, deps = {}) {
} catch {
// Invalid option syntax may prevent runtime configuration from being resolved.
}
return writeCommandError(stdout, stderr, error, config, helpCommandFor(group, command));
// Every failure leaves through the documented JSON envelope, not just argument errors.
// Agent hosts are instructed to treat all command output as JSON, so a transport or
// upstream failure that printed a bare string left them with nothing to parse.
const helpCommand = error instanceof InvalidArgumentsError
? helpCommandFor(group, command)
: null;
return writeCommandError(stdout, stderr, error, config, helpCommand);
}
}

Expand Down
Loading