diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cee634c8..c4eef5630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A CLI command that fails with a non-`Error` value now reports it. A protocol-shaped rejection such as `{ status: 401 }` from a broker client printed `[object Object]`, and an `Error` with an empty message printed nothing at all; both now surface the message, status, and code, with credentials redacted. Applies to the top-level failure handler, `node agent attach`, and broker request failures. - `agent-relay integration webhook create` now works. It took a `` argument and sent `{ url, event }`, but `POST /v1/webhooks` accepts `{ channel, name? }` and returns the URL — so every invocation failed with `channel is required`. It now takes `` with an optional `--name`, matching `create-inbound`, which posts to the same endpoint. - `@agent-relay/sdk` `RelayCreateWebhookInput` declared a required `url` and an `event`, neither of which the endpoint accepts. It is now `{ channel, name? }`. Code passing `url`/`event` was already failing at runtime. diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index f88ac5b05..eefafbfa8 100644 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; import { runAiSdkSidecarMain } from '@agent-relay/harnesses'; import { runCli } from './bootstrap.js'; +import { describeError } from './lib/describe-error.js'; export * from './bootstrap.js'; @@ -25,10 +26,9 @@ if (isEntrypoint()) { main.catch((err) => { // Commander will have already printed a helpful message for parse errors. // For other top-level failures, surface them to stderr and exit non-zero. - const message = err instanceof Error ? err.message : String(err); - if (message) { - process.stderr.write(`${message}\n`); - } + // `describeError` keeps a non-Error rejection (e.g. `{ status: 401 }` from + // a broker client) readable instead of collapsing it to `[object Object]`. + process.stderr.write(`${describeError(err)}\n`); process.exit(1); }); } diff --git a/packages/cli/src/cli/lib/attach-broker.ts b/packages/cli/src/cli/lib/attach-broker.ts index acb923b67..5fa74009c 100644 --- a/packages/cli/src/cli/lib/attach-broker.ts +++ b/packages/cli/src/cli/lib/attach-broker.ts @@ -1,6 +1,7 @@ import { HarnessDriverClient } from '@agent-relay/harness-driver'; import type { BrokerConnection } from './broker-connection.js'; +import { describeError } from './describe-error.js'; export type { InboundDeliveryMode, @@ -40,5 +41,5 @@ export function mapBrokerSdkFailure(error: unknown): BrokerSdkFailure { typeof (error as { status: unknown }).status === 'number' ? (error as { status: number }).status : 0; - return { status, message: error instanceof Error ? error.message : String(error) }; + return { status, message: describeError(error) }; } diff --git a/packages/cli/src/cli/lib/attach-drive.ts b/packages/cli/src/cli/lib/attach-drive.ts index 48dd4e20a..1be5ffa27 100644 --- a/packages/cli/src/cli/lib/attach-drive.ts +++ b/packages/cli/src/cli/lib/attach-drive.ts @@ -86,6 +86,7 @@ import { type PtyInputStreamOptions, type PtyInputWriteResult, } from '../lib/attach-broker.js'; +import { describeError } from './describe-error.js'; import { createPredictiveEcho, type CreatePredictiveEchoOptions } from './predictive-echo-screen.js'; import type { PredictiveEcho } from '@agent-relay/harness-driver'; @@ -926,7 +927,7 @@ function runDriveSessionLoop(state: DriveSessionState, deps: DriveDependencies): // event loop on every keystroke. void stream.send(decoded).catch((err: unknown) => { if (settled) return; - const message = err instanceof Error ? err.message : String(err); + const message = describeError(err); deps.log(`[drive] input stream send failed: ${message}`); // The keystroke never reached the PTY — drop any optimistic echo // for it so the screen doesn't show input the agent didn't get. @@ -1126,7 +1127,7 @@ function runDriveSessionLoop(state: DriveSessionState, deps: DriveDependencies): } } catch (err: unknown) { if (settled) return; - const message = err instanceof Error ? err.message : String(err); + const message = describeError(err); deps.error(`[drive] could not open PTY input stream: ${message}`); finish(1); } @@ -1163,7 +1164,7 @@ function runDriveSessionLoop(state: DriveSessionState, deps: DriveDependencies): } } catch (err: unknown) { if (settled) return; - const message = err instanceof Error ? err.message : String(err); + const message = describeError(err); deps.error(`[drive] could not take terminal input: ${message}`); finish(1); } @@ -1214,7 +1215,7 @@ function runDriveSessionLoop(state: DriveSessionState, deps: DriveDependencies): try { await predictiveEcho.seed(snapshotBytes); } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); + const message = describeError(err); deps.log(`[drive] could not seed predictive echo: ${message}`); finish(1); return; diff --git a/packages/cli/src/cli/lib/attach-native.ts b/packages/cli/src/cli/lib/attach-native.ts index a67f04e90..7a00d2e47 100644 --- a/packages/cli/src/cli/lib/attach-native.ts +++ b/packages/cli/src/cli/lib/attach-native.ts @@ -16,6 +16,7 @@ import { type BrokerConnection, } from './broker-connection.js'; import { createBrokerClient } from './attach-broker.js'; +import { describeError } from './describe-error.js'; export type NativeAttachMode = 'view' | 'drive' | 'passthrough'; @@ -146,7 +147,7 @@ export async function attachNative( try { brokerCursor = await client.currentEventSeq(); } catch (error) { - deps.output.stderr(`Error: ${error instanceof Error ? error.message : String(error)}\n`); + deps.output.stderr(`Error: ${describeError(error)}\n`); client.disconnect(); return 1; } @@ -255,7 +256,7 @@ export async function attachNative( } }) .catch((error: unknown) => { - deps.output.stderr(`Input failed: ${error instanceof Error ? error.message : String(error)}\n`); + deps.output.stderr(`Input failed: ${describeError(error)}\n`); }) .then(() => undefined); commandChain = pendingCommand.catch(() => undefined); @@ -279,7 +280,7 @@ export async function attachNative( await Promise.allSettled(pendingCommands); return 0; } catch (error) { - deps.output.stderr(`Error: ${error instanceof Error ? error.message : String(error)}\n`); + deps.output.stderr(`Error: ${describeError(error)}\n`); return 1; } finally { stopped = true; diff --git a/packages/cli/src/cli/lib/attach-passthrough.ts b/packages/cli/src/cli/lib/attach-passthrough.ts index 39baeccf0..765f34d58 100644 --- a/packages/cli/src/cli/lib/attach-passthrough.ts +++ b/packages/cli/src/cli/lib/attach-passthrough.ts @@ -64,6 +64,7 @@ import { resizeWorker, type InboundDeliveryMode, } from './attach-drive.js'; +import { describeError } from './describe-error.js'; import { createPredictiveEcho, type CreatePredictiveEchoOptions } from './predictive-echo-screen.js'; import type { PredictiveEcho } from '@agent-relay/harness-driver'; @@ -560,7 +561,7 @@ export async function runPassthroughSession( if (decoded.length > 0) { void stream.send(decoded).catch((err: unknown) => { if (settled) return; - const message = err instanceof Error ? err.message : String(err); + const message = describeError(err); deps.log(`[passthrough] input stream send failed: ${message}`); // The keystroke never reached the PTY — drop any optimistic echo // for it so the screen doesn't show input the agent didn't get. @@ -740,7 +741,7 @@ export async function runPassthroughSession( } } catch (err: unknown) { if (settled) return; - const message = err instanceof Error ? err.message : String(err); + const message = describeError(err); deps.error(`[passthrough] could not open PTY input stream: ${message}`); finish(1); } @@ -775,7 +776,7 @@ export async function runPassthroughSession( } } catch (err: unknown) { if (settled) return; - const message = err instanceof Error ? err.message : String(err); + const message = describeError(err); deps.error(`[passthrough] could not take terminal input: ${message}`); finish(1); } @@ -825,7 +826,7 @@ export async function runPassthroughSession( try { await predictiveEcho.seed(snapshotBytes); } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); + const message = describeError(err); deps.log(`[passthrough] could not seed predictive echo: ${message}`); finish(1); return; diff --git a/packages/cli/src/cli/lib/broker-lifecycle.test.ts b/packages/cli/src/cli/lib/broker-lifecycle.test.ts index 20952f546..299d796cd 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.test.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.test.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { classifyBrokerStartError, classifyBrokerStartStage, - describeError, + describeErrorWithCause, isBundledBunExecutableEntrypoint, readNodeDeliveryStatus, resolveNodeIdentityFromSession, @@ -34,9 +34,9 @@ describe('isBundledBunExecutableEntrypoint', () => { }); }); -describe('describeError', () => { +describe('describeErrorWithCause', () => { it('returns plain message for a bare Error', () => { - expect(describeError(new Error('boom'))).toBe('boom'); + expect(describeErrorWithCause(new Error('boom'))).toBe('boom'); }); it('unwraps the Node fetch failed cause and surfaces the network code', () => { @@ -46,7 +46,7 @@ describe('describeError', () => { }); const err = new TypeError('fetch failed', { cause }); - const result = describeError(err); + const result = describeErrorWithCause(err); expect(result).toContain('fetch failed'); expect(result).toContain('ECONNREFUSED'); expect(result).toContain('127.0.0.1'); @@ -58,15 +58,15 @@ describe('describeError', () => { }); const err = new TypeError('fetch failed', { cause }); - const result = describeError(err); + const result = describeErrorWithCause(err); expect(result).toContain('ENOTFOUND'); expect(result).toContain('agentrelay.com'); }); it('handles non-Error values without throwing', () => { - expect(describeError('something went wrong')).toBe('something went wrong'); - expect(describeError(undefined)).toBe('undefined'); - expect(describeError(null)).toBe('null'); + expect(describeErrorWithCause('something went wrong')).toBe('something went wrong'); + expect(describeErrorWithCause(undefined)).toBe('undefined'); + expect(describeErrorWithCause(null)).toBe('null'); }); it('caps the cause-chain walk so a cycle cannot loop forever', () => { @@ -75,7 +75,7 @@ describe('describeError', () => { a.cause = b; b.cause = a; // Just needs to terminate — the assertion is the absence of a hang. - expect(typeof describeError(a)).toBe('string'); + expect(typeof describeErrorWithCause(a)).toBe('string'); }); }); diff --git a/packages/cli/src/cli/lib/broker-lifecycle.ts b/packages/cli/src/cli/lib/broker-lifecycle.ts index d598694e9..520adb93b 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.ts @@ -21,6 +21,7 @@ import { type NodeDefinitionDescriptor, type RunningNodeProviderChild, } from './node-provider-child.js'; +import { describeError } from './describe-error.js'; import { maskSecret } from './redact.js'; import { startReflexCapture, type RunningReflexCapture } from './reflex-capture.js'; import { projectWorkspaceKeyPath, writeProjectWorkspaceKey } from './project-workspace-key.js'; @@ -152,7 +153,7 @@ export function readBrokerConnection(dataDir: string): BrokerConnection | null { } function toErrorMessage(err: unknown): string { - return err instanceof Error ? err.message : String(err); + return describeError(err); } /** Emit a `[verbose]`-prefixed step marker via `deps.log` when `--verbose` is set. */ @@ -258,6 +259,9 @@ function errorCode(err: unknown): string | undefined { /** * Extract a human-meaningful detail string from an error, walking `err.cause`. * + * The broker-start specialization of {@link describeError}: it starts from the + * shared description and appends the cause chain's detail and error codes. + * * Node's native `fetch()` throws `TypeError: fetch failed` for any network * problem and stuffs the real reason (ECONNREFUSED, ENOTFOUND, AbortError, * UND_ERR_CONNECT_TIMEOUT, …) into `err.cause`. Without unwrapping, every @@ -265,7 +269,7 @@ function errorCode(err: unknown): string | undefined { * * Exported for testing. */ -export function describeError(err: unknown): string { +export function describeErrorWithCause(err: unknown): string { const top = toErrorMessage(err); if (!(err instanceof Error) || !err.cause) return top; @@ -1392,7 +1396,7 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): env: deps.env, }); } catch (err: unknown) { - deps.error(`Failed to start broker in background: ${describeError(err)}`); + deps.error(`Failed to start broker in background: ${describeErrorWithCause(err)}`); deps.exit(1); return; } @@ -1666,7 +1670,7 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): if (isBrokerAlreadyRunningError(message)) { reportAlreadyRunningError(message, paths.dataDir, deps); } else { - deps.error(`Failed to start broker: ${describeError(err)}`); + deps.error(`Failed to start broker: ${describeErrorWithCause(err)}`); } deps.exit(1); } diff --git a/packages/cli/src/cli/lib/core-maintenance.ts b/packages/cli/src/cli/lib/core-maintenance.ts index b38d1ece3..85e1502da 100644 --- a/packages/cli/src/cli/lib/core-maintenance.ts +++ b/packages/cli/src/cli/lib/core-maintenance.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import type { CoreDependencies, CoreFileSystem } from '../commands/core.js'; import { track } from '../telemetry/index.js'; import { readBrokerConnection } from './broker-lifecycle.js'; +import { describeError } from './describe-error.js'; import { errorClassName } from './telemetry-helpers.js'; const SNIPPET_MARKER_START_PREFIX = '