fix(cli): redact machine-readable CLI error payloads (#124075)

This commit is contained in:
Peter Steinberger
2026-08-15 00:44:18 -07:00
committed by GitHub
parent f2c721b0ac
commit 49e1ea2c9f
19 changed files with 189 additions and 64 deletions
+2 -1
View File
@@ -3,6 +3,7 @@ import { Option, type Command } from "commander";
import { formatDocsLink } from "../../packages/terminal-core/src/links.js";
import { theme } from "../../packages/terminal-core/src/theme.js";
import { danger } from "../globals.js";
import { formatErrorMessage } from "../infra/errors.js";
import { defaultRuntime } from "../runtime.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
import { resolveCliArgvInvocation } from "./argv-invocation.js";
@@ -70,7 +71,7 @@ function runChannelsCommand(action: () => Promise<void>) {
function runChannelsCommandWithDanger(action: () => Promise<void>, label: string) {
return runCommandWithRuntime(defaultRuntime, action, (err) => {
defaultRuntime.error(danger(`${label}: ${String(err)}`));
defaultRuntime.error(danger(`${label}: ${formatErrorMessage(err)}`));
defaultRuntime.exit(1);
});
}
+4 -3
View File
@@ -10,6 +10,7 @@ import { diffConfigPaths } from "../gateway/config-diff.js";
import { buildGatewayReloadPlan } from "../gateway/config-reload-plan.js";
import { resolveGatewayReloadSettings } from "../gateway/config-reload-settings.js";
import { danger, info } from "../globals.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { RuntimeEnv } from "../runtime.js";
import { writeRuntimeJson } from "../runtime.js";
import { toDotPath } from "../shared/dot-path.js";
@@ -495,13 +496,13 @@ export function handleConfigMutationError(params: {
runtime: RuntimeEnv;
options: ConfigMutationOptions;
}) {
const message = formatErrorMessage(params.err);
if (params.options.dryRun && params.options.json) {
if (params.err instanceof ConfigSetDryRunValidationError) {
writeRuntimeJson(params.runtime, params.err.result);
params.runtime.exit(1);
return;
}
const message = params.err instanceof Error ? params.err.message : String(params.err);
const result: ConfigSetDryRunResult = {
ok: false,
operations: 0,
@@ -513,10 +514,10 @@ export function handleConfigMutationError(params: {
errors: [{ kind: "schema", message }],
};
writeRuntimeJson(params.runtime, result);
params.runtime.error(danger(String(params.err)));
params.runtime.error(danger(message));
params.runtime.exit(1);
return;
}
params.runtime.error(danger(String(params.err)));
params.runtime.error(danger(message));
params.runtime.exit(1);
}
+2 -1
View File
@@ -12,6 +12,7 @@ import {
type SecretRef,
} from "../config/types.secrets.js";
import { validateConfigObjectRawWithPlugins } from "../config/validation.js";
import { formatErrorMessage } from "../infra/errors.js";
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import { type RuntimeEnv, defaultRuntime, writeRuntimeJson } from "../runtime.js";
import {
@@ -155,7 +156,7 @@ export async function collectDryRunResolvabilityErrors(params: {
} catch (err) {
failures.push({
kind: "resolvability",
message: String(err),
message: formatErrorMessage(err),
ref: `${ref.source}:${ref.provider}:${ref.id}`,
});
}
+7 -2
View File
@@ -3361,7 +3361,7 @@ describe("config cli", () => {
},
refsChecked: 0,
skippedExecRefs: 0,
errors: [{ kind: "schema", message }],
errors: [{ kind: "schema", message: expect.stringContaining(message) }],
});
expectErrorIncludes(message);
});
@@ -3398,7 +3398,10 @@ describe("config cli", () => {
it("aggregates schema and resolvability failures in --dry-run --json mode", async () => {
setGatewaySnapshot({ providers: { default: { source: "env" } } });
mockResolveSecretRefValue.mockRejectedValue(new Error("missing env var"));
const secret = "sk-abcdefghijklmnopqrstuv";
const error = new Error(`missing env var: Authorization: Bearer ${secret}`);
error.name = "SecretResolutionError";
mockResolveSecretRefValue.mockRejectedValue(error);
await expect(
runConfigCommand([
@@ -3421,6 +3424,8 @@ describe("config cli", () => {
expect(errorKinds).toContain("resolvability");
const errorRefs = (payload.errors ?? []).map((entry) => entry.ref ?? "");
expect(errorRefs).toContain("env:default:DISCORD_BOT_TOKEN");
expect(JSON.stringify(payload)).not.toContain(error.name);
expect(JSON.stringify(payload)).not.toContain(secret);
});
it("fails dry-run when provider updates make existing refs unresolvable", async () => {
+11 -6
View File
@@ -10,6 +10,7 @@ import { redactConfigObject } from "../config/redact-snapshot.js";
import { readBestEffortRuntimeConfigSchema } from "../config/runtime-schema.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { danger, info, success, warn } from "../globals.js";
import { formatErrorMessage } from "../infra/errors.js";
import {
ExitError,
type RuntimeEnv,
@@ -182,11 +183,11 @@ export async function runConfigGet(opts: { path: string; json?: boolean; runtime
throw err;
}
if (opts.json) {
writeRuntimeJson(runtime, { error: String(err) });
writeRuntimeJson(runtime, { error: formatErrorMessage(err) });
runtime.exit(1);
return;
}
runtime.error(danger(String(err)));
runtime.error(danger(formatErrorMessage(err)));
runtime.exit(1);
}
}
@@ -288,7 +289,7 @@ async function runConfigFile(opts: { json?: boolean; runtime?: RuntimeEnv }) {
}
writeRuntimeStdout(runtime, `${path}\n`);
} catch (err) {
runtime.error(danger(String(err)));
runtime.error(danger(formatErrorMessage(err)));
runtime.exit(1);
}
}
@@ -302,7 +303,7 @@ async function runConfigSchema(opts: { runtime?: RuntimeEnv } = {}) {
schema.properties = { $schema: { type: "string" }, ...schema.properties };
writeRuntimeJson(runtime, schema);
} catch (err) {
runtime.error(danger(`Config schema error: ${String(err)}`));
runtime.error(danger(`Config schema error: ${formatErrorMessage(err)}`));
runtime.exit(1);
}
}
@@ -368,9 +369,13 @@ async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv }
}
} catch (err) {
if (opts.json) {
writeRuntimeJson(runtime, { valid: false, path: outputPath, error: String(err) }, 0);
writeRuntimeJson(
runtime,
{ valid: false, path: outputPath, error: formatErrorMessage(err) },
0,
);
} else {
runtime.error(danger(`Config validation error: ${String(err)}`));
runtime.error(danger(`Config validation error: ${formatErrorMessage(err)}`));
}
runtime.exit(1);
}
+2 -1
View File
@@ -8,6 +8,7 @@ import type { Command } from "commander";
import { THINKING_LEVELS_HELP } from "../../auto-reply/thinking.shared.js";
import type { CronJob } from "../../cron/types.js";
import { danger } from "../../globals.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { sanitizeAgentId } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import {
@@ -496,7 +497,7 @@ export function registerCronEditCommand(cron: Command) {
defaultRuntime.writeJson(res);
await warnIfCronSchedulerDisabled(opts);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.error(danger(formatErrorMessage(err)));
defaultRuntime.exit(1);
}
}),
+2 -1
View File
@@ -12,6 +12,7 @@ import { parseAbsoluteTimeMs } from "../../cron/parse.js";
import { resolveCronStaggerMs } from "../../cron/stagger.js";
import type { CronDeliveryPreview, CronJob, CronSchedule } from "../../cron/types.js";
import { danger } from "../../globals.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { formatDurationHuman } from "../../infra/format-time/format-duration.ts";
import {
isOffsetlessIsoDateTime,
@@ -196,7 +197,7 @@ function formatCronStatusForDisplay(job: CronJob): string {
}
export function handleCronCliError(err: unknown) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.error(danger(formatErrorMessage(err)));
defaultRuntime.exit(1);
}
+7 -2
View File
@@ -139,7 +139,10 @@ describe("runDaemonStatus", () => {
});
it("renders service-inspection failures as JSON in JSON mode", async () => {
gatherDaemonStatus.mockRejectedValueOnce(new Error("service manager unavailable"));
const secret = "sk-abcdefghijklmnopqrstuv";
const error = new Error(`service manager unavailable: Authorization: Bearer ${secret}`);
error.name = "ServiceManagerError";
gatherDaemonStatus.mockRejectedValueOnce(error);
await expect(
runDaemonStatus({
@@ -153,8 +156,10 @@ describe("runDaemonStatus", () => {
expect(printDaemonStatus).not.toHaveBeenCalled();
expect(defaultRuntime.writeJson).toHaveBeenCalledWith({
ok: false,
error: "Gateway status failed: Error: service manager unavailable",
error: expect.stringContaining("Gateway status failed: service manager unavailable"),
});
expect(JSON.stringify(defaultRuntime.writeJson.mock.calls)).not.toContain(error.name);
expect(JSON.stringify(defaultRuntime.writeJson.mock.calls)).not.toContain(secret);
expect(defaultRuntime.error).not.toHaveBeenCalled();
expect(defaultRuntime.exit).toHaveBeenCalledTimes(1);
});
+2 -1
View File
@@ -1,5 +1,6 @@
// Gateway service status command entrypoint: gathers status, prints it, and handles probe failures.
import { colorize, isRich, theme } from "../../../packages/terminal-core/src/theme.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { defaultRuntime } from "../../runtime.js";
import { gatherDaemonStatus } from "./status.gather.js";
import { printDaemonStatus } from "./status.print.js";
@@ -34,7 +35,7 @@ export async function runDaemonStatus(opts: DaemonStatusOptions) {
});
printDaemonStatus(status, { json: opts.json, deep: opts.deep === true });
} catch (err) {
failDaemonStatus(opts, `Gateway status failed: ${String(err)}`);
failDaemonStatus(opts, `Gateway status failed: ${formatErrorMessage(err)}`);
return;
}
+39 -4
View File
@@ -316,17 +316,17 @@ describe("registerDirectoryCli", () => {
[
"self",
["directory", "self", "--channel", "demo-directory", "--json"],
"Error: Channel demo-directory does not support directory self",
"Channel demo-directory does not support directory self",
],
[
"peers",
["directory", "peers", "list", "--channel", "demo-directory", "--json"],
"Error: Channel demo-directory does not support directory peers",
"Channel demo-directory does not support directory peers",
],
[
"groups",
["directory", "groups", "list", "--channel", "demo-directory", "--json"],
"Error: Channel demo-directory does not support directory groups",
"Channel demo-directory does not support directory groups",
],
[
"group members",
@@ -340,7 +340,7 @@ describe("registerDirectoryCli", () => {
"group-1",
"--json",
],
"Error: Channel demo-directory does not support group members listing",
"Channel demo-directory does not support group members listing",
],
])("writes JSON errors for unsupported directory %s", async (_label, args, expectedError) => {
mocks.resolveInstallableChannelPlugin.mockResolvedValue({
@@ -364,6 +364,41 @@ describe("registerDirectoryCli", () => {
expect(runtimeState.defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it.each([
{ mode: "human", args: ["directory", "self", "--channel", "demo-directory"] },
{
mode: "JSON",
args: ["directory", "self", "--channel", "demo-directory", "--json"],
},
])("renders named errors without class names in $mode mode", async ({ mode, args }) => {
const error = new Error("Multiple agents are configured, but this operation has no owner.");
error.name = "AgentSelectionRequiredError";
const self = vi.fn().mockRejectedValue(error);
mocks.resolveInstallableChannelPlugin.mockResolvedValue({
cfg: { channels: { "demo-directory": {} } },
channelId: "demo-directory",
plugin: { id: "demo-directory", directory: { self } },
configChanged: false,
});
const program = new Command().name("openclaw");
registerDirectoryCli(program);
await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow("exit:1");
if (mode === "JSON") {
const payload = JSON.parse(runtimeState.runtimeLogs.at(-1) ?? "");
expect(payload).toEqual({ error: error.message });
expect(Object.keys(payload)).toEqual(["error"]);
expect(runtimeState.defaultRuntime.error).not.toHaveBeenCalled();
} else {
expect(runtimeErrors()).toEqual([error.message]);
expect(runtimeState.defaultRuntime.writeJson).not.toHaveBeenCalled();
}
expect([...runtimeState.runtimeLogs, ...runtimeErrors()].join("\n")).not.toContain(error.name);
expect(runtimeState.defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it.each([
["peers list", ["directory", "peers", "list", "--channel", "slack", "--limit", "5x"]],
["groups list", ["directory", "groups", "list", "--channel", "slack", "--limit", "5x"]],
+4 -2
View File
@@ -16,6 +16,7 @@ import { resolveInstallableChannelPlugin } from "../commands/channel-setup/chann
import { getRuntimeConfig, readConfigFileSnapshot, replaceConfigFile } from "../config/config.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { danger } from "../globals.js";
import { formatErrorMessage } from "../infra/errors.js";
import { resolveMessageChannelSelection } from "../infra/outbound/channel-selection.js";
import { commitConfigWithPendingPluginInstalls } from "../plugins/install-record-commit.js";
import { defaultRuntime } from "../runtime.js";
@@ -192,10 +193,11 @@ export function registerDirectoryCli(program: Command) {
try {
await action();
} catch (err) {
const message = formatErrorMessage(err);
if (opts.json) {
defaultRuntime.writeJson({ error: String(err) });
defaultRuntime.writeJson({ error: message });
} else {
defaultRuntime.error(danger(String(err)));
defaultRuntime.error(danger(message));
}
defaultRuntime.exit(1);
}
+13 -5
View File
@@ -427,7 +427,7 @@ describe("runNodeDaemonStatus", () => {
await runNodeDaemonStatus();
expect(mocks.runtime.error).toHaveBeenCalledWith(
"Node service check failed: Error: systemd unavailable",
"Node service check failed: systemd unavailable",
);
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
expect(stdout()).not.toContain("not loaded");
@@ -435,27 +435,35 @@ describe("runNodeDaemonStatus", () => {
});
it("reports a failed service check as JSON without inventing node status", async () => {
mocks.service.isLoaded.mockRejectedValue(new Error("systemd unavailable"));
const secret = "sk-abcdefghijklmnopqrstuv";
const error = new Error(`systemd unavailable: Authorization: Bearer ${secret}`);
error.name = "ServiceManagerError";
mocks.service.isLoaded.mockRejectedValue(error);
await runNodeDaemonStatus({ json: true });
expect(mocks.runtime.writeJson).toHaveBeenCalledWith({
error: "Node service check failed: Error: systemd unavailable",
error: expect.stringContaining("Node service check failed: systemd unavailable"),
});
expect(JSON.stringify(mocks.runtime.writeJson.mock.calls)).not.toContain(error.name);
expect(JSON.stringify(mocks.runtime.writeJson.mock.calls)).not.toContain(secret);
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.runtime.error).not.toHaveBeenCalled();
});
it("reports an unknown runtime when runtime inspection fails", async () => {
mocks.service.readRuntime.mockRejectedValue(new Error("permission denied"));
const error = new Error("permission denied");
error.name = "RuntimeInspectionError";
mocks.service.readRuntime.mockRejectedValue(error);
await runNodeDaemonStatus({ json: true });
expect(mocks.runtime.writeJson).toHaveBeenCalledWith({
service: expect.objectContaining({
runtime: { status: "unknown", detail: "Error: permission denied" },
runtime: { status: "unknown", detail: "permission denied" },
}),
});
expect(JSON.stringify(mocks.runtime.writeJson.mock.calls)).not.toContain(error.name);
});
it("keeps missing service-unit status on stderr and prints recovery hints on stdout", async () => {
+9 -5
View File
@@ -21,6 +21,7 @@ import {
readSystemdUserLingerStatus,
resolveSystemdUserServiceAccount,
} from "../../daemon/systemd.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { loadNodeHostConfig } from "../../node-host/config.js";
import { defaultRuntime } from "../../runtime.js";
import { formatCliCommand } from "../command-format.js";
@@ -147,7 +148,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
try {
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
fail(`Node service check failed: ${String(err)}`);
fail(`Node service check failed: ${formatErrorMessage(err)}`);
return;
}
if (loaded && !opts.force) {
@@ -259,7 +260,7 @@ export async function runNodeDaemonStatus(opts: NodeDaemonStatusOptions = {}) {
try {
loaded = await service.isLoaded({ env: process.env });
} catch (error) {
const message = `Node service check failed: ${String(error)}`;
const message = `Node service check failed: ${formatErrorMessage(error)}`;
if (json) {
defaultRuntime.writeJson({ error: message });
} else {
@@ -270,9 +271,12 @@ export async function runNodeDaemonStatus(opts: NodeDaemonStatusOptions = {}) {
}
const [command, runtime] = await Promise.all([
service.readCommand(process.env).catch(() => null),
service
.readRuntime(process.env)
.catch((err: unknown): GatewayServiceRuntime => ({ status: "unknown", detail: String(err) })),
service.readRuntime(process.env).catch(
(err: unknown): GatewayServiceRuntime => ({
status: "unknown",
detail: formatErrorMessage(err),
}),
),
]);
const payload = {
+9 -9
View File
@@ -289,7 +289,7 @@ describe("runMessageAction", () => {
await runSendAction();
expect(messageCommandMock).not.toHaveBeenCalled();
expect(errorMock).toHaveBeenCalledWith("Error: plugin load failed");
expect(errorMock).toHaveBeenCalledWith("plugin load failed");
expect(exitMock).toHaveBeenCalledOnce();
expect(exitMock).toHaveBeenCalledWith(1);
expect(exitMock).not.toHaveBeenCalledWith(0);
@@ -310,7 +310,7 @@ describe("runMessageAction", () => {
).rejects.toThrow("exit");
expect(errorMock).toHaveBeenCalledWith(
"Error: --poll-anonymous and --poll-public are mutually exclusive.",
"--poll-anonymous and --poll-public are mutually exclusive.",
);
expect(loadPluginRegistryHandleMock).not.toHaveBeenCalled();
expect(messageCommandMock).not.toHaveBeenCalled();
@@ -377,7 +377,7 @@ describe("runMessageAction", () => {
await expect(runMessageAction(action, opts)).rejects.toThrow("exit");
const kind = NON_NEGATIVE_INTEGER_FLAGS.has(flag) ? "non-negative" : "positive";
expect(errorMock).toHaveBeenCalledWith(`Error: ${flag} must be a ${kind} integer.`);
expect(errorMock).toHaveBeenCalledWith(`${flag} must be a ${kind} integer.`);
expect(loadPluginRegistryHandleMock).not.toHaveBeenCalled();
expect(messageCommandMock).not.toHaveBeenCalled();
expect(exitMock).toHaveBeenCalledWith(1);
@@ -402,7 +402,7 @@ describe("runMessageAction", () => {
).rejects.toThrow("exit");
const kind = NON_NEGATIVE_INTEGER_FLAGS.has(flag) ? "non-negative" : "positive";
expect(errorMock).toHaveBeenCalledWith(`Error: ${flag} must be a ${kind} integer.`);
expect(errorMock).toHaveBeenCalledWith(`${flag} must be a ${kind} integer.`);
expect(messageCommandMock).not.toHaveBeenCalled();
expect(exitMock).toHaveBeenCalledWith(1);
});
@@ -496,7 +496,7 @@ describe("runMessageAction", () => {
messageCommandMock.mockRejectedValueOnce(new Error("send failed"));
await runSendAction();
expect(errorMock).toHaveBeenCalledWith("Error: send failed");
expect(errorMock).toHaveBeenCalledWith("send failed");
expect(exitMock).toHaveBeenCalledOnce();
expect(exitMock).toHaveBeenCalledWith(1);
});
@@ -515,7 +515,7 @@ describe("runMessageAction", () => {
runGatewayStopMock.mockRejectedValueOnce(new Error("hook failed"));
await runSendAction();
expect(errorMock).toHaveBeenCalledWith("gateway_stop hook failed: Error: hook failed");
expect(errorMock).toHaveBeenCalledWith("gateway_stop hook failed: hook failed");
expect(exitMock).toHaveBeenCalledWith(0);
});
@@ -525,8 +525,8 @@ describe("runMessageAction", () => {
runGatewayStopMock.mockRejectedValueOnce(new Error("hook failed"));
await runSendAction();
expect(errorMock).toHaveBeenNthCalledWith(1, "Error: send failed");
expect(errorMock).toHaveBeenNthCalledWith(2, "gateway_stop hook failed: Error: hook failed");
expect(errorMock).toHaveBeenNthCalledWith(1, "send failed");
expect(errorMock).toHaveBeenNthCalledWith(2, "gateway_stop hook failed: hook failed");
expect(exitMock).toHaveBeenCalledWith(1);
});
@@ -545,7 +545,7 @@ describe("runMessageAction", () => {
const runMessageAction = createRunMessageAction();
await expect(runMessageAction("send", baseSendOptions)).resolves.toBeUndefined();
expect(errorMock).toHaveBeenCalledWith("Error: boom");
expect(errorMock).toHaveBeenCalledWith("boom");
expect(exitMock).toHaveBeenCalledOnce();
expect(exitMock).toHaveBeenCalledWith(1);
expect(exitMock).not.toHaveBeenCalledWith(0);
+4 -2
View File
@@ -13,6 +13,7 @@ import { resolveMessageSecretScope } from "../../../cli/message-secret-scope.js"
import { messageCommand } from "../../../commands/message.js";
import { getRuntimeConfig } from "../../../config/config.js";
import { danger, setVerbose } from "../../../globals.js";
import { formatErrorMessage } from "../../../infra/errors.js";
import { CHANNEL_TARGET_DESCRIPTION } from "../../../infra/outbound/channel-target.js";
import { withActivatedPluginIds } from "../../../plugins/activation-context.js";
import {
@@ -84,7 +85,8 @@ async function runPluginStopHooks(): Promise<void> {
const hookRun = runGlobalGatewayStopSafely({
event: { reason: "cli message action complete" },
ctx: {},
onError: (err) => defaultRuntime.error(danger(`gateway_stop hook failed: ${String(err)}`)),
onError: (err) =>
defaultRuntime.error(danger(`gateway_stop hook failed: ${formatErrorMessage(err)}`)),
});
const bounded = new Promise<"timeout">((resolve) => {
timeout = setTimeout(() => resolve("timeout"), GATEWAY_STOP_TIMEOUT_MS);
@@ -210,7 +212,7 @@ export function createMessageCliHelpers(
},
(err) => {
failed = true;
defaultRuntime.error(danger(String(err)));
defaultRuntime.error(danger(formatErrorMessage(err)));
},
);
// Outbound actions may start plugin-side resources; run bounded stop hooks even after failure.
+26 -3
View File
@@ -95,14 +95,14 @@ describe("system-cli", () => {
name: "invalid wake mode",
args: ["system", "event", "--text", "hello", "--mode", "later", "--json"],
gatewayResult: undefined,
expectedError: "Error: --mode must be now or next-heartbeat",
expectedError: "--mode must be now or next-heartbeat",
gatewayCalls: 0,
},
{
name: "rejected Gateway call",
args: ["system", "event", "--text", "hello", "--json"],
gatewayResult: { ok: false, reason: "unwakeable-session-key" },
expectedError: "Error: unwakeable-session-key",
expectedError: "unwakeable-session-key",
gatewayCalls: 1,
},
])(
@@ -122,6 +122,29 @@ describe("system-cli", () => {
},
);
it.each([
{ mode: "human", args: ["system", "event", "--text", "hello"] },
{ mode: "JSON", args: ["system", "event", "--text", "hello", "--json"] },
])("renders named errors without class names in $mode mode", async ({ mode, args }) => {
const error = new Error("Multiple agents are configured, but this operation has no owner.");
error.name = "AgentSelectionRequiredError";
callGatewayFromCli.mockRejectedValueOnce(error);
await runCli(args);
if (mode === "JSON") {
const payload = JSON.parse(runtimeLogs.at(-1) ?? "");
expect(payload).toEqual({ error: error.message });
expect(Object.keys(payload)).toEqual(["error"]);
expect(runtimeErrors).toEqual([]);
} else {
expect(runtimeErrors).toEqual([error.message]);
expect(defaultRuntime.writeJson).not.toHaveBeenCalled();
}
expect([...runtimeLogs, ...runtimeErrors].join("\n")).not.toContain(error.name);
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it("forwards --session-key on system event", async () => {
await runCli([
"system",
@@ -171,7 +194,7 @@ describe("system-cli", () => {
expect(typeof gatewayOptions).toBe("object");
expect(params).toBeUndefined();
expect(requestOptions).toEqual({ expectFinal: false });
const expectedError = "Error: Gateway unavailable";
const expectedError = "Gateway unavailable";
expect(runtimeLogs).toEqual([JSON.stringify({ error: expectedError }, null, 2)]);
expect(runtimeErrors).toEqual([]);
expect(defaultRuntime.writeJson).toHaveBeenCalledWith({ error: expectedError });
+4 -2
View File
@@ -4,6 +4,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 { danger } from "../globals.js";
import { formatErrorMessage } from "../infra/errors.js";
import { defaultRuntime } from "../runtime.js";
import { formatCliCommand } from "./command-format.js";
import type { GatewayRpcOpts } from "./gateway-rpc.js";
@@ -44,10 +45,11 @@ async function runSystemGatewayCommand(
defaultRuntime.log(successText);
}
} catch (err) {
const message = formatErrorMessage(err);
if (machineOutput) {
defaultRuntime.writeJson({ error: String(err) });
defaultRuntime.writeJson({ error: message });
} else {
defaultRuntime.error(danger(String(err)));
defaultRuntime.error(danger(message));
}
defaultRuntime.exit(1);
}
+37 -11
View File
@@ -34,6 +34,7 @@ function runtimeErrors(): string[] {
describe("webhooks cli", () => {
beforeEach(() => {
mocks.runtimeLogs.length = 0;
mocks.runtimeErrors.length = 0;
mocks.defaultRuntime.error.mockClear();
mocks.defaultRuntime.writeJson.mockClear();
@@ -63,7 +64,7 @@ describe("webhooks cli", () => {
if (json) {
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith({
error: `Error: ${flag} must be a positive integer.`,
error: `${flag} must be a positive integer.`,
});
expect(mocks.defaultRuntime.error).not.toHaveBeenCalled();
} else {
@@ -73,19 +74,44 @@ describe("webhooks cli", () => {
expect(mocks.runGmailService).not.toHaveBeenCalled();
});
it("writes JSON when gmail setup rejects", async () => {
mocks.runGmailSetup.mockRejectedValueOnce(new Error("setup failed"));
it.each([
{ command: "setup", mode: "human", json: false },
{ command: "setup", mode: "JSON", json: true },
{ command: "run", mode: "human", json: false },
])("renders named gmail $command errors safely in $mode mode", async ({ command, json }) => {
const secret = "sk-abcdefghijklmnopqrstuv";
const error = new Error(`Gmail failed: Authorization: Bearer ${secret}`);
error.name = "GmailCredentialError";
const runner = command === "setup" ? mocks.runGmailSetup : mocks.runGmailService;
runner.mockRejectedValueOnce(error);
const program = createProgram();
const args =
command === "setup"
? ["webhooks", "gmail", "setup", "--account", "default"]
: ["webhooks", "gmail", "run"];
if (json) {
args.push("--json");
}
await expect(
program.parseAsync(["webhooks", "gmail", "setup", "--account", "default", "--json"], {
from: "user",
}),
).rejects.toThrow("__exit__:1");
await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow("__exit__:1");
expect(mocks.runGmailSetup).toHaveBeenCalledWith(expect.objectContaining({ json: true }));
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith({ error: "Error: setup failed" });
expect(mocks.defaultRuntime.error).not.toHaveBeenCalled();
expect(runner).toHaveBeenCalledOnce();
if (json) {
const payload = JSON.parse(mocks.runtimeLogs.at(-1) ?? "");
expect(payload).toEqual({
error: expect.stringContaining("Gmail failed: Authorization: Bearer"),
});
expect(Object.keys(payload)).toEqual(["error"]);
expect(mocks.defaultRuntime.error).not.toHaveBeenCalled();
} else {
expect(runtimeErrors()).toEqual([
expect.stringContaining("Gmail failed: Authorization: Bearer"),
]);
expect(mocks.defaultRuntime.writeJson).not.toHaveBeenCalled();
}
expect([...mocks.runtimeLogs, ...runtimeErrors()].join("\n")).not.toContain(error.name);
expect([...mocks.runtimeLogs, ...runtimeErrors()].join("\n")).not.toContain(secret);
expect(mocks.defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it.each([
+5 -3
View File
@@ -21,6 +21,7 @@ import {
DEFAULT_GMAIL_SUBSCRIPTION,
DEFAULT_GMAIL_TOPIC,
} from "../hooks/gmail.js";
import { formatErrorMessage } from "../infra/errors.js";
import { defaultRuntime } from "../runtime.js";
import { formatCliCommand } from "./command-format.js";
@@ -71,10 +72,11 @@ export function registerWebhooksCli(program: Command) {
const parsed = parseGmailSetupOptions(opts);
await runGmailSetup(parsed);
} catch (err) {
const message = formatErrorMessage(err);
if (opts.json) {
defaultRuntime.writeJson({ error: String(err) });
defaultRuntime.writeJson({ error: message });
} else {
defaultRuntime.error(danger(String(err)));
defaultRuntime.error(danger(message));
}
defaultRuntime.exit(1);
}
@@ -107,7 +109,7 @@ export function registerWebhooksCli(program: Command) {
const parsed = parseGmailRunOptions(opts);
await runGmailService(parsed);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.error(danger(formatErrorMessage(err)));
defaultRuntime.exit(1);
}
});