From 2463ea0a5e64f8026ad725ad2a6d0bbd2dfd6b32 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 14 Aug 2026 19:18:58 -0700 Subject: [PATCH] fix(cli): render errors through redacting formatter (#123929) --- src/acp/server.startup.test.ts | 2 +- src/acp/server.ts | 11 +++++--- src/cli/gateway-cli/health-route.test.ts | 4 +-- src/cli/gateway-cli/health-route.ts | 3 ++- src/cli/program.smoke.test.ts | 6 ++--- src/cli/qr-cli.test.ts | 4 +-- src/cli/qr-cli.ts | 3 ++- src/cli/resume-cli.ts | 3 ++- src/cli/sandbox-cli.ts | 3 ++- src/cli/skills-cli.commands.test.ts | 27 +++++++++++++++++++ src/cli/skills-cli.curator.test.ts | 2 +- src/cli/skills-cli.ts | 19 ++++++------- src/cli/skills-cli.verify.test.ts | 2 +- src/cli/tui-cli.ts | 3 ++- src/cli/update-cli.ts | 9 ++++--- .../update-cli/update-command-post-update.ts | 3 ++- src/cli/update-cli/wizard.ts | 3 ++- src/commands/agents.commands.identity.ts | 3 ++- src/commands/agents.identity.test.ts | 5 +++- 19 files changed, 77 insertions(+), 38 deletions(-) diff --git a/src/acp/server.startup.test.ts b/src/acp/server.startup.test.ts index 87d30bc2a2fc..80c380586461 100644 --- a/src/acp/server.startup.test.ts +++ b/src/acp/server.startup.test.ts @@ -475,7 +475,7 @@ describe("serveAcpGateway startup", () => { opts: { verbose: true }, expected: [ "openclaw acp: gateway event chat failed\n", - "openclaw acp: gateway event chat error: Error: handler boom\n", + "openclaw acp: gateway event chat error: handler boom\n", ], }, ])("contains rejected gateway event handling with $name", async ({ opts, expected }) => { diff --git a/src/acp/server.ts b/src/acp/server.ts index 979b2f458cff..c6518b7cb5c2 100644 --- a/src/acp/server.ts +++ b/src/acp/server.ts @@ -21,6 +21,7 @@ import { getRuntimeConfig } from "../config/config.js"; import { resolveGatewayClientBootstrap } from "../gateway/client-bootstrap.js"; import { startGatewayClientWhenEventLoopReady } from "../gateway/client-start-readiness.js"; import { GatewayClient } from "../gateway/client.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { isMainModule } from "../infra/is-main.js"; import { routeLogsToStderr } from "../logging/console.js"; import { closeOpenClawStateDatabase } from "../state/openclaw-state-db.js"; @@ -137,7 +138,7 @@ export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise { process.stderr.write(`openclaw acp: gateway event ${evt.event} failed\n`); if (opts.verbose) { - process.stderr.write(`openclaw acp: gateway event ${evt.event} error: ${String(err)}\n`); + process.stderr.write( + `openclaw acp: gateway event ${evt.event} error: ${formatErrorMessage(err)}\n`, + ); } }); }, @@ -202,7 +205,7 @@ export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise { - console.warn(`acp: gateway stop failed during shutdown: ${String(err)}`); + console.warn(`acp: gateway stop failed during shutdown: ${formatErrorMessage(err)}`); }); await gatewayStop; closeStateDatabase(); @@ -420,7 +423,7 @@ if (isMainModule({ currentFile: fileURLToPath(import.meta.url) })) { } const opts = parseArgs(argv); serveAcpGateway(opts).catch((err: unknown) => { - console.error(String(err)); + console.error(formatErrorMessage(err)); process.exit(1); }); } diff --git a/src/cli/gateway-cli/health-route.test.ts b/src/cli/gateway-cli/health-route.test.ts index 5a346bf58830..6106bc6a6bbf 100644 --- a/src/cli/gateway-cli/health-route.test.ts +++ b/src/cli/gateway-cli/health-route.test.ts @@ -80,7 +80,7 @@ describe("runGatewayHealthJsonRoute", () => { ); }); - it("preserves the existing error contract when local config resolution fails", async () => { + it("formats local config resolution failures", async () => { const runtime = createRuntime(); const error = new Error("config unavailable"); const callGateway = vi.fn(); @@ -101,7 +101,7 @@ describe("runGatewayHealthJsonRoute", () => { expect(callGateway).not.toHaveBeenCalled(); expect(runtime.writeJson).not.toHaveBeenCalled(); - expect(runtime.error).toHaveBeenCalledWith(String(error)); + expect(runtime.error).toHaveBeenCalledWith(error.message); expect(runtime.exit).toHaveBeenCalledWith(1); }); diff --git a/src/cli/gateway-cli/health-route.ts b/src/cli/gateway-cli/health-route.ts index a94d75b8b121..576603b616b4 100644 --- a/src/cli/gateway-cli/health-route.ts +++ b/src/cli/gateway-cli/health-route.ts @@ -1,5 +1,6 @@ // Route-first machine-readable Gateway health command. import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { formatErrorMessage } from "../../infra/errors.js"; import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; type GatewayHealthRpcOpts = Parameters< @@ -62,7 +63,7 @@ export async function runGatewayHealthJsonRoute( ); } catch (error) { if (!rpc) { - runtime.error(String(error)); + runtime.error(formatErrorMessage(error)); runtime.exit(1); return; } diff --git a/src/cli/program.smoke.test.ts b/src/cli/program.smoke.test.ts index 518deef7092d..8a5a447614c0 100644 --- a/src/cli/program.smoke.test.ts +++ b/src/cli/program.smoke.test.ts @@ -149,9 +149,7 @@ describe("cli program (smoke)", () => { it("rejects partial tui history limits", async () => { await expect(runProgram(["tui", "--history-limit", "10x"])).rejects.toThrow("exit"); - expect(runtime.error).toHaveBeenCalledWith( - "Error: --history-limit must be a positive integer.", - ); + expect(runtime.error).toHaveBeenCalledWith("--history-limit must be a positive integer."); expect(tuiRunMock).not.toHaveBeenCalled(); }); @@ -175,7 +173,7 @@ describe("cli program (smoke)", () => { it("rejects tui history limits above the Gateway maximum", async () => { await expect(runProgram(["tui", "--history-limit", "1001"])).rejects.toThrow("exit"); - expect(runtime.error).toHaveBeenCalledWith("Error: --history-limit must be at most 1000."); + expect(runtime.error).toHaveBeenCalledWith("--history-limit must be at most 1000."); expect(tuiRunMock).not.toHaveBeenCalled(); }); diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 90c26779cbb1..05e937e31279 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -274,9 +274,7 @@ describe("registerQrCli", () => { }); await expect(runQr(["--setup-code-only", "--limited", "--voice-node"])).rejects.toThrow("exit"); - expect(runtime.error).toHaveBeenCalledWith( - "Error: Use either --limited or --voice-node, not both.", - ); + expect(runtime.error).toHaveBeenCalledWith("Use either --limited or --voice-node, not both."); }); it("renders ASCII QR by default", async () => { diff --git a/src/cli/qr-cli.ts b/src/cli/qr-cli.ts index 29b808c82184..55b69873e0b3 100644 --- a/src/cli/qr-cli.ts +++ b/src/cli/qr-cli.ts @@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { hasConfiguredSecretInput } from "../config/types.secrets.js"; import { trimToUndefined } from "../gateway/credentials.js"; import { resolveRequiredConfiguredSecretRefInputString } from "../gateway/resolve-configured-secret-input-string.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js"; import { renderQrTerminal } from "../media/qr-terminal.ts"; import { resolvePairingSetupFromConfig, encodePairingSetupCode } from "../pairing/setup-code.js"; @@ -282,7 +283,7 @@ export function registerQrCli(program: Command) { defaultRuntime.log(lines.join("\n")); } catch (err) { - defaultRuntime.error(String(err)); + defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); } }); diff --git a/src/cli/resume-cli.ts b/src/cli/resume-cli.ts index 160e2c9d7b7b..2a66f014607a 100644 --- a/src/cli/resume-cli.ts +++ b/src/cli/resume-cli.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import { formatDocsLink } from "../../packages/terminal-core/src/links.js"; import { theme } from "../../packages/terminal-core/src/theme.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { defaultRuntime } from "../runtime.js"; import { addTuiOptions } from "./tui-cli-options.js"; @@ -31,7 +32,7 @@ export function registerResumeCli(program: Command) { const { runResumeCommand } = await import("./resume-cli.runtime.js"); await runResumeCommand(query, opts); } catch (error) { - defaultRuntime.error(String(error)); + defaultRuntime.error(formatErrorMessage(error)); defaultRuntime.exit(1); } }); diff --git a/src/cli/sandbox-cli.ts b/src/cli/sandbox-cli.ts index 461780138520..94019ccdc771 100644 --- a/src/cli/sandbox-cli.ts +++ b/src/cli/sandbox-cli.ts @@ -4,6 +4,7 @@ import { formatDocsLink } from "../../packages/terminal-core/src/links.js"; import { theme } from "../../packages/terminal-core/src/theme.js"; import { sandboxExplainCommand } from "../commands/sandbox-explain.js"; import { sandboxListCommand, sandboxRecreateCommand } from "../commands/sandbox.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { defaultRuntime } from "../runtime.js"; import { formatHelpExamples } from "./help-format.js"; @@ -50,7 +51,7 @@ function createRunner( try { await commandFn(opts, defaultRuntime); } catch (err) { - defaultRuntime.error(String(err)); + defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); } }; diff --git a/src/cli/skills-cli.commands.test.ts b/src/cli/skills-cli.commands.test.ts index 9c58a4578c68..3a845fac262c 100644 --- a/src/cli/skills-cli.commands.test.ts +++ b/src/cli/skills-cli.commands.test.ts @@ -1477,6 +1477,33 @@ describe("skills cli commands", () => { expectStatusWorkspaceCall("/tmp/workspace-main"); }); + it("renders named agent-selection errors without the internal class name", async () => { + const error = new Error( + "Multiple agents are configured, but this operation has no explicit owner.", + ); + error.name = "AgentSelectionRequiredError"; + resolveDefaultAgentIdMock.mockImplementationOnce(() => { + throw error; + }); + + await expect(runCommand(["skills", "list"])).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors).toStrictEqual([error.message]); + }); + + it("redacts secrets from rendered skills CLI errors", async () => { + const secret = "sk-abcdefghijklmnopqrstuv"; + resolveDefaultAgentIdMock.mockImplementationOnce(() => { + throw new Error(`Skill lookup failed with token=${secret}`); + }); + + await expect(runCommand(["skills", "list"])).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors).toHaveLength(1); + expect(runtimeErrors[0]).toContain("Skill lookup failed"); + expect(runtimeErrors[0]).not.toContain(secret); + }); + it("keeps non-JSON skills list output on stdout with human-readable formatting", async () => { await runCommand(["skills", "list"]); diff --git a/src/cli/skills-cli.curator.test.ts b/src/cli/skills-cli.curator.test.ts index 1e5c79f2a19f..8968a1267864 100644 --- a/src/cli/skills-cli.curator.test.ts +++ b/src/cli/skills-cli.curator.test.ts @@ -110,7 +110,7 @@ describe("skills curator cli", () => { from: "user", }), ).rejects.toThrow("__exit__:1"); - expect(mocks.defaultRuntime.error).toHaveBeenCalledWith("Error: remote unavailable"); + expect(mocks.defaultRuntime.error).toHaveBeenCalledWith("remote unavailable"); }); it("disambiguates duplicate skill keys in text status", async () => { diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index 4eb06be70946..ad0ca7bf3379 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -23,6 +23,7 @@ import { fetchClawHubSkillVerification, type ClawHubSkillVerificationResponse, } from "../infra/clawhub-skills.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { defaultRuntime } from "../runtime.js"; import { installSkillFromClawHub, @@ -209,7 +210,7 @@ async function runSkillsAction( const report = await loadSkillsStatusReport(options); defaultRuntime.writeStdout(render(report)); } catch (err) { - defaultRuntime.error(String(err)); + defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); } } @@ -601,7 +602,7 @@ export function registerSkillsCli(program: Command) { defaultRuntime.log(`${skillRef}${version} ${displayName}${summary}${trust}`); } } catch (err) { - defaultRuntime.error(String(err)); + defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); } }); @@ -734,7 +735,7 @@ export function registerSkillsCli(program: Command) { } defaultRuntime.log(`Installed ${result.slug}@${result.version} -> ${result.targetDir}`); } catch (err) { - defaultRuntime.error(String(err)); + defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); } }, @@ -834,7 +835,7 @@ export function registerSkillsCli(program: Command) { defaultRuntime.exit(1); } } catch (err) { - defaultRuntime.error(String(err)); + defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); } }, @@ -917,7 +918,7 @@ export function registerSkillsCli(program: Command) { } } } catch (err) { - reportError(String(err)); + reportError(formatErrorMessage(err)); defaultRuntime.exit(1); return; } @@ -941,7 +942,7 @@ export function registerSkillsCli(program: Command) { } defaultRuntime.writeStdout(formatSkillCuratorStatus(status)); } catch (err) { - defaultRuntime.error(String(err)); + defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); } }; @@ -967,7 +968,7 @@ export function registerSkillsCli(program: Command) { `${action[0]?.toUpperCase()}${action.slice(1)} ${result.skillKey}\n`, ); } catch (err) { - defaultRuntime.error(String(err)); + defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); } }); @@ -996,7 +997,7 @@ export function registerSkillsCli(program: Command) { } defaultRuntime.writeStdout(format(result)); } catch (err) { - defaultRuntime.error(String(err)); + defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); } }; @@ -1061,7 +1062,7 @@ export function registerSkillsCli(program: Command) { } defaultRuntime.writeStdout(formatSkillProposalInspect(proposal)); } catch (err) { - defaultRuntime.error(String(err)); + defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); } }); diff --git a/src/cli/skills-cli.verify.test.ts b/src/cli/skills-cli.verify.test.ts index 8e7041f8724d..3efe96708982 100644 --- a/src/cli/skills-cli.verify.test.ts +++ b/src/cli/skills-cli.verify.test.ts @@ -284,7 +284,7 @@ describe("skills verify CLI", () => { ).rejects.toThrow("__exit__:1"); expect(JSON.parse(mocks.runtimeStdout.at(-1) ?? "{}")).toEqual({ - error: "Error: ClawHub verification unavailable", + error: "ClawHub verification unavailable", }); expect(mocks.runtimeErrors).toStrictEqual([]); }); diff --git a/src/cli/tui-cli.ts b/src/cli/tui-cli.ts index 692e5e9fcda1..d8ded136dc92 100644 --- a/src/cli/tui-cli.ts +++ b/src/cli/tui-cli.ts @@ -4,6 +4,7 @@ import type { Command } from "commander"; import { CHAT_HISTORY_MAX_ENTRIES } from "../../packages/gateway-protocol/src/schema/chat-history-constants.js"; import { formatDocsLink } from "../../packages/terminal-core/src/links.js"; import { theme } from "../../packages/terminal-core/src/theme.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { defaultRuntime } from "../runtime.js"; import { parseTimeoutMs } from "./parse-timeout.js"; import { resolveSessionTarget } from "./session-target.js"; @@ -123,7 +124,7 @@ export function registerTuiCli(program: Command) { const invokedSubcommand = cmd.parent?.args[0]; await runTuiCliAction(target, opts, invokedSubcommand); } catch (err) { - defaultRuntime.error(String(err)); + defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); } }); diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 8c1a72207501..672bfe7093b7 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import { formatDocsLink } from "../../packages/terminal-core/src/links.js"; import { theme } from "../../packages/terminal-core/src/theme.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { defaultRuntime } from "../runtime.js"; import { inheritOptionFromParent } from "./command-options.js"; import { formatHelpExamples } from "./help-format.js"; @@ -118,7 +119,7 @@ function registerUpdateFinalizationCommand(update: Command, name: string, hidden normalizeCommanderClawHubRiskOption(opts) || inheritedUpdateClawHubRisk(actionCommand), }); } catch (err) { - defaultRuntime.error(String(err)); + defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); } }); @@ -209,7 +210,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/up acknowledgeClawHubRisk: normalizeCommanderClawHubRiskOption(opts), }); } catch (err) { - defaultRuntime.error(String(err)); + defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); } }); @@ -235,7 +236,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/up timeout: inheritedUpdateTimeout(opts, command), }); } catch (err) { - defaultRuntime.error(String(err)); + defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); } }); @@ -265,7 +266,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/up timeout: inheritedUpdateTimeout(opts, command), }); } catch (err) { - defaultRuntime.error(String(err)); + defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); } }); diff --git a/src/cli/update-cli/update-command-post-update.ts b/src/cli/update-cli/update-command-post-update.ts index e467c58b05b9..4663f745093d 100644 --- a/src/cli/update-cli/update-command-post-update.ts +++ b/src/cli/update-cli/update-command-post-update.ts @@ -3,6 +3,7 @@ import { theme } from "../../../packages/terminal-core/src/theme.js"; import { readConfigFileSnapshot } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { readGatewayServiceState, resolveGatewayService } from "../../daemon/service.js"; +import { formatErrorMessage } from "../../infra/errors.js"; import type { UpdateChannel } from "../../infra/update-channels.js"; import { compareSemverStrings } from "../../infra/update-check.js"; import { @@ -437,7 +438,7 @@ export async function finishUpdate(params: { } } catch (err) { if (err instanceof GatewayServiceUpdateOwnershipError) { - defaultRuntime.error(err.message); + defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); return; } diff --git a/src/cli/update-cli/wizard.ts b/src/cli/update-cli/wizard.ts index 99612aa59b4b..6dace8136e40 100644 --- a/src/cli/update-cli/wizard.ts +++ b/src/cli/update-cli/wizard.ts @@ -5,6 +5,7 @@ import { selectStyled } from "../../../packages/terminal-core/src/prompt-select- import { stylePromptMessage } from "../../../packages/terminal-core/src/prompt-style.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; import { readConfigFileSnapshot } from "../../config/config.js"; +import { formatErrorMessage } from "../../infra/errors.js"; import { formatUpdateChannelLabel, normalizeUpdateChannel, @@ -155,7 +156,7 @@ export async function updateWizardCommand(opts: UpdateWizardOptions = {}): Promi timeout: opts.timeout, }); } catch (err) { - defaultRuntime.error(String(err)); + defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); } } diff --git a/src/commands/agents.commands.identity.ts b/src/commands/agents.commands.identity.ts index 12fd7f852e48..24a9b851cbf1 100644 --- a/src/commands/agents.commands.identity.ts +++ b/src/commands/agents.commands.identity.ts @@ -15,6 +15,7 @@ import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js"; import { logConfigUpdated } from "../config/logging.js"; import type { AgentConfig, IdentityConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; @@ -136,7 +137,7 @@ export async function agentsSetIdentityCommand( try { identityFromFile = await loadAgentIdentityFromFile(identityFilePath); } catch (error) { - runtime.error(String(error instanceof Error ? error.message : error)); + runtime.error(formatErrorMessage(error)); runtime.exit(1); return; } diff --git a/src/commands/agents.identity.test.ts b/src/commands/agents.identity.test.ts index 0147d1082930..3338542b72ec 100644 --- a/src/commands/agents.identity.test.ts +++ b/src/commands/agents.identity.test.ts @@ -352,9 +352,12 @@ describe("agents set-identity command", () => { await agentsSetIdentityCommand({ agent: "main", identityFile: identityPath }, runtime); - expect(runtime.error).toHaveBeenCalledWith( + const renderedError = String(runtime.error.mock.calls[0]?.[0]); + expect(renderedError).toContain( `Identity file ${identityPath} exceeds the maximum size of ${TEST_MAX_IDENTITY_FILE_BYTES} bytes`, ); + expect(renderedError).toContain(`File exceeds ${TEST_MAX_IDENTITY_FILE_BYTES} bytes:`); + expect(renderedError).toContain("too-large"); expect(runtime.exit).toHaveBeenCalledWith(1); expect(configMocks.writeConfigFile).not.toHaveBeenCalled(); });