From 7a507d8e668079d339b6a628cb197dfd8eccb25a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 17 Aug 2026 19:07:18 -0700 Subject: [PATCH] fix(cli): render missing gateway credentials consistently (#125007) * fix(cli): unify missing gateway credential output * test(cli): cover workshop credential rendering --- src/cli/cli-utils.ts | 4 +- src/cli/cron-cli/shared.ts | 4 +- src/cli/daemon-cli/status.test.ts | 33 +++++++ src/cli/exec-approvals-cli.ts | 2 + src/cli/failure-output.test.ts | 81 +++++++++++++-- src/cli/failure-output.ts | 42 +++++++- src/cli/gateway-backed-exit.process.test.ts | 98 +++++++++++++++++++ src/cli/gateway-cli/register.ts | 4 + src/cli/hooks-cli.ts | 2 + ...program.nodes-diagnostics-auth.e2e.test.ts | 42 ++++++++ src/cli/secrets-cli.ts | 2 + src/cli/skills-cli.ts | 5 +- src/cli/skills-cli.workshop-cache.test.ts | 7 +- src/cli/system-cli.ts | 3 +- src/commands/audit.ts | 4 + .../channels.status.command-flow.test.ts | 31 ++++++ src/commands/channels/status.runtime.ts | 6 +- src/commands/channels/status.ts | 15 ++- src/commands/sessions-compact.ts | 2 + src/commands/sessions-lifecycle.ts | 3 +- src/commands/tasks.ts | 3 +- 21 files changed, 368 insertions(+), 25 deletions(-) diff --git a/src/cli/cli-utils.ts b/src/cli/cli-utils.ts index 11beb828db89..69f770195933 100644 --- a/src/cli/cli-utils.ts +++ b/src/cli/cli-utils.ts @@ -1,7 +1,7 @@ // Shared CLI execution wrappers and inherited Commander option lookup. import type { Command } from "commander"; import { formatErrorMessage } from "../infra/errors.js"; -import { formatCliOperatorError } from "./failure-output.js"; +import { formatCliOperatorError, isExpectedCliError } from "./failure-output.js"; import { isJsonOutputModeActive } from "./json-output-mode.js"; export { formatErrorMessage }; @@ -42,7 +42,7 @@ export async function runCommandWithRuntime( try { await action(); } catch (err) { - if (isJsonOutputModeActive(process.argv)) { + if (isJsonOutputModeActive(process.argv) || isExpectedCliError(err)) { throw err; } if (onError) { diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index cd8d41fd8dbe..fef22e97793a 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -23,6 +23,7 @@ import { import { formatTimestamp } from "../../logging/timestamps.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { formatLookupMiss } from "../error-format.js"; +import { rethrowExpectedCliError } from "../failure-output.js"; import type { GatewayRpcOpts } from "../gateway-rpc.js"; import { callGatewayFromCli } from "../gateway-rpc.js"; import { isJsonOutputModeActive } from "../json-output-mode.js"; @@ -205,10 +206,11 @@ function formatCronStatusForDisplay(job: CronJob): string { } export function handleCronCliError(err: unknown) { + rethrowExpectedCliError(err); const missingJob = readCronJobNotFoundError(err); const message = missingJob ? formatCronLookupMiss(missingJob.jobId) : formatErrorMessage(err); if (isJsonOutputModeActive(process.argv)) { - throw new Error(message); + throw missingJob ? new Error(message) : err; } defaultRuntime.error(danger(message)); defaultRuntime.exit(1); diff --git a/src/cli/daemon-cli/status.test.ts b/src/cli/daemon-cli/status.test.ts index 297e99c09b6a..0b7427ed0a0d 100644 --- a/src/cli/daemon-cli/status.test.ts +++ b/src/cli/daemon-cli/status.test.ts @@ -85,6 +85,39 @@ describe("runDaemonStatus", () => { expect(defaultRuntime.exit).toHaveBeenCalledTimes(1); }); + it.each([false, true])( + "does not exit after reporting a failed non-required RPC probe in json=%s mode", + async (json) => { + gatherDaemonStatus.mockResolvedValueOnce({ + service: { + label: "LaunchAgent", + loaded: true, + loadedText: "loaded", + notLoadedText: "not loaded", + }, + rpc: { + ok: false, + url: "ws://127.0.0.1:18789", + error: "connect ECONNREFUSED 127.0.0.1:18789", + }, + extraServices: [], + }); + + await runDaemonStatus({ + rpc: {}, + probe: true, + requireRpc: false, + json, + }); + + expect(printDaemonStatus).toHaveBeenCalledWith(expect.any(Object), { + json, + deep: false, + }); + expect(defaultRuntime.exit).not.toHaveBeenCalled(); + }, + ); + it("forwards require-rpc to daemon status gathering", async () => { await runDaemonStatus({ rpc: {}, diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts index 5d3c9cf19add..c2fd289b13b5 100644 --- a/src/cli/exec-approvals-cli.ts +++ b/src/cli/exec-approvals-cli.ts @@ -42,6 +42,7 @@ import { } from "../infra/exec-approvals.js"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import { defaultRuntime } from "../runtime.js"; +import { rethrowExpectedCliError } from "./failure-output.js"; import { callGatewayFromCli } from "./gateway-rpc.js"; import { nodesCallOpts, resolveCliNodeId } from "./nodes-cli/rpc.js"; import type { NodesRpcOpts } from "./nodes-cli/types.js"; @@ -381,6 +382,7 @@ function formatCliError(err: unknown): string { } function failApprovalsCommand(err: unknown, opts: ExecApprovalsCliOpts): void { + rethrowExpectedCliError(err); const message = formatCliError(err); if (opts.json) { throw new Error(message); diff --git a/src/cli/failure-output.test.ts b/src/cli/failure-output.test.ts index 9c04a073dd6b..c2f9120b4df9 100644 --- a/src/cli/failure-output.test.ts +++ b/src/cli/failure-output.test.ts @@ -1,6 +1,15 @@ // Failure output tests cover CLI error formatting and failure summaries. import { describe, expect, it } from "vitest"; -import { ExpectedCliError, formatCliFailureLines, formatCliJsonFailure } from "./failure-output.js"; +import { GatewayCredentialsRequiredError } from "../gateway/call.js"; +import { + ExpectedCliError, + formatCliFailureLines, + formatCliJsonFailure, + isExpectedCliError, +} from "./failure-output.js"; + +const PLUGIN_POLICY_MESSAGE = + 'The `openclaw workboard` command is provided by the "workboard" plugin, but that bundled plugin is disabled by default. Run `openclaw plugins enable workboard` to enable that CLI surface.'; describe("formatCliJsonFailure", () => { it("uses the canonical typed envelope and redacts the message", () => { @@ -55,20 +64,34 @@ describe("formatCliJsonFailure", () => { }); expect(payload.error.message).not.toContain("internal parse cause"); }); - it("keeps plugin policy messages in the canonical JSON envelope", () => { - const message = - 'The `openclaw workboard` command is provided by the "workboard" plugin, but that bundled plugin is disabled by default. Run `openclaw plugins enable workboard` to enable that CLI surface.'; - const error = new ExpectedCliError({ - message, - humanOutput: message, - machineOutput: message, + message: PLUGIN_POLICY_MESSAGE, + humanOutput: PLUGIN_POLICY_MESSAGE, + machineOutput: PLUGIN_POLICY_MESSAGE, }); expect(formatCliJsonFailure(error)).toEqual({ ok: false, - error: { type: "cli_error", message }, + error: { type: "cli_error", message: PLUGIN_POLICY_MESSAGE }, + }); + }); + + it.each([ + { label: "default output", env: {} }, + { label: "debug output", env: { OPENCLAW_DEBUG: "1" } }, + ])("keeps gateway credential guidance unchanged in $label", ({ env }) => { + const error = new GatewayCredentialsRequiredError({ + method: "device.pair.list", + configPath: "/tmp/openclaw.json", + }); + + expect(formatCliJsonFailure(error, { env })).toEqual({ + ok: false, + error: { + type: "cli_error", + message: error.message, + }, }); }); }); @@ -116,6 +139,46 @@ describe("formatCliFailureLines", () => { ]); }); + it.each([ + { + label: "plugin policy refusal", + createError: () => + new ExpectedCliError({ + message: PLUGIN_POLICY_MESSAGE, + humanOutput: PLUGIN_POLICY_MESSAGE, + machineOutput: PLUGIN_POLICY_MESSAGE, + }), + }, + { + label: "missing gateway credentials", + createError: () => + new GatewayCredentialsRequiredError({ + method: "device.pair.list", + configPath: "/tmp/openclaw.json", + }), + }, + ])( + "routes $label through the shared expected-condition predicate without crash framing", + ({ createError }) => { + const error = createError(); + + expect(isExpectedCliError(error)).toBe(true); + const lines = formatCliFailureLines({ + title: "The CLI command failed.", + error, + env: { OPENCLAW_DEBUG: "1" }, + }); + + expect(lines).toEqual(error.message.split("\n")); + const output = lines.join("\n"); + expect(output).not.toContain("[openclaw] The CLI command failed."); + expect(output).not.toContain("[openclaw] Reason:"); + expect(output).not.toContain("OPENCLAW_DEBUG"); + expect(output).not.toContain("Stack:"); + expect(output).not.toContain("openclaw doctor"); + }, + ); + it("prints stack details when debug output is requested", () => { const lines = formatCliFailureLines({ title: "The CLI command failed.", diff --git a/src/cli/failure-output.ts b/src/cli/failure-output.ts index b799033d94bf..97aefa57a2be 100644 --- a/src/cli/failure-output.ts +++ b/src/cli/failure-output.ts @@ -40,8 +40,41 @@ export class ExpectedCliError extends Error { } } -export function isExpectedCliError(error: unknown): error is ExpectedCliError { - return error instanceof ExpectedCliError; +function isGatewayCredentialsCliError( + error: unknown, +): error is Error & { method: string; configPath: string } { + // Keep the root failure renderer lean; importing gateway/call would pull the + // transport and config stack into every CLI startup path. + if (!(error instanceof Error)) { + return false; + } + return ( + error.name === "GatewayCredentialsRequiredError" && + "method" in error && + typeof error.method === "string" && + "configPath" in error && + typeof error.configPath === "string" + ); +} + +export function isExpectedCliError(error: unknown): error is Error { + return error instanceof ExpectedCliError || isGatewayCredentialsCliError(error); +} + +export function rethrowExpectedCliError(error: unknown): void { + if (isExpectedCliError(error)) { + throw error; + } +} + +function resolveExpectedCliOutput(error: Error) { + return error instanceof ExpectedCliError + ? error + : { + humanOutput: error.message, + humanOutputWritten: false, + machineOutput: error.message, + }; } /** Canonical machine-readable failure envelope for CLI-owned errors. */ @@ -50,7 +83,7 @@ export function formatCliJsonFailure( options: CliFailureDebugOptions = {}, ): CliJsonFailure { const message = isExpectedCliError(error) - ? formatErrorMessage(error.machineOutput.trimEnd()) + ? formatErrorMessage(resolveExpectedCliOutput(error).machineOutput.trimEnd()) : formatCliOperatorError(error, options); return { ok: false, @@ -101,7 +134,8 @@ function pushPrefixed(out: string[], value: string): void { export function formatCliFailureLines(options: FormatCliFailureOptions): string[] { if (isExpectedCliError(options.error)) { - return options.error.humanOutputWritten ? [] : options.error.humanOutput.trimEnd().split("\n"); + const output = resolveExpectedCliOutput(options.error); + return output.humanOutputWritten ? [] : output.humanOutput.trimEnd().split("\n"); } // Default output stays terse; causes and stack traces require explicit debug intent. diff --git a/src/cli/gateway-backed-exit.process.test.ts b/src/cli/gateway-backed-exit.process.test.ts index 722f445940aa..e791b36c841b 100644 --- a/src/cli/gateway-backed-exit.process.test.ts +++ b/src/cli/gateway-backed-exit.process.test.ts @@ -24,6 +24,7 @@ import { storeOriginDeviceToken, } from "../infra/device-auth-store.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; +import { acquireGatewayLock } from "../infra/gateway-lock.js"; import { pickMatchingExternalInterfaceAddress, readNetworkInterfaces, @@ -380,6 +381,7 @@ async function runIsolatedGatewayCli(params: { NODE_ENV: undefined, NODE_OPTIONS: undefined, OPENCLAW_CONFIG_PATH: params.configPath, + OPENCLAW_SKIP_CHANNELS: "1", OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1", OPENCLAW_GATEWAY_PASSWORD: undefined, OPENCLAW_GATEWAY_TOKEN: undefined, @@ -387,6 +389,10 @@ async function runIsolatedGatewayCli(params: { OPENCLAW_HOME: params.root, OPENCLAW_NO_RESPAWN: "1", OPENCLAW_STATE_DIR: params.stateDir, + DISCORD_BOT_TOKEN: undefined, + TWILIO_ACCOUNT_SID: undefined, + TWILIO_AUTH_TOKEN: undefined, + TWILIO_FROM_NUMBER: undefined, VITEST: undefined, ...params.env, }, @@ -870,6 +876,98 @@ describe("gateway-backed CLI process exit", () => { }); }, 30_000); + it.each([ + { + label: "device list", + args: ["devices", "list"], + gatewayOwnsLock: false, + method: "device.pair.list", + }, + { + label: "skills workshop apply", + args: ["skills", "workshop", "apply", "proposal-missing-credentials"], + gatewayOwnsLock: true, + method: "health", + }, + ])( + "renders missing $label credentials as expected guidance, not a crash", + async ({ label, args, gatewayOwnsLock, method }) => { + const root = tempDirs.make(`openclaw-${label.replaceAll(" ", "-")}-credentials-human-`); + const stateDir = path.join(root, "state"); + const configPath = path.join(stateDir, "openclaw.json"); + const port = await getFreePort(); + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ gateway: { mode: "local", port } })}\n`, + "utf8", + ); + + const lock = gatewayOwnsLock + ? await acquireGatewayLock({ + allowInTests: true, + env: { + ...process.env, + HOME: root, + OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_HOME: root, + OPENCLAW_STATE_DIR: stateDir, + }, + port, + role: "gateway", + timeoutMs: 1_000, + }) + : null; + if (gatewayOwnsLock) { + expect(lock).not.toBeNull(); + } + try { + const result = await runIsolatedGatewayCli({ args, root, stateDir, configPath }); + + expect(result).toMatchObject({ code: 1, signal: null, stdout: "" }); + expect(result.stderr).toContain( + `gateway ${method} requires credentials before opening a websocket`, + ); + expect(result.stderr).toContain( + "Fix: configure gateway.auth token/password, pair this device, or pass --token/--password.", + ); + expect(result.stderr).toContain(`Config: ${configPath}`); + expect(result.stderr).not.toContain("The CLI command failed"); + expect(result.stderr).not.toContain("Could not start the CLI"); + expect(result.stderr).not.toContain("OPENCLAW_DEBUG"); + expect(result.stderr).not.toContain("Stack:"); + expect(result.stderr).not.toContain("openclaw doctor"); + } finally { + await lock?.release(); + } + }, + 30_000, + ); + + it.each([ + { label: "channels config-only status", args: ["channels", "status"] }, + { label: "gateway reachability status", args: ["gateway", "status"] }, + ])( + "returns success after delivering $label", + async ({ args }) => { + const root = tempDirs.make("openclaw-degraded-status-"); + const stateDir = path.join(root, "state"); + const configPath = path.join(stateDir, "openclaw.json"); + const port = await getFreePort(); + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ gateway: { mode: "local", port } })}\n`, + "utf8", + ); + + const result = await runIsolatedGatewayCli({ args, root, stateDir, configPath }); + + expect(result.code, result.stderr).toBe(0); + }, + 30_000, + ); + it("preserves pre-hello rate-limit details through the real health entry point", async () => { const root = tempDirs.make("openclaw-gateway-rate-limit-json-"); const stateDir = path.join(root, "state"); diff --git a/src/cli/gateway-cli/register.ts b/src/cli/gateway-cli/register.ts index 1183fbd17d6a..7bc2afc62cce 100644 --- a/src/cli/gateway-cli/register.ts +++ b/src/cli/gateway-cli/register.ts @@ -20,6 +20,7 @@ import { defaultRuntime } from "../../runtime.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { inheritOptionFromParent } from "../command-options.js"; import { addGatewayServiceCommands } from "../daemon-cli/register-service-commands.js"; +import { rethrowExpectedCliError } from "../failure-output.js"; import { parseGatewayPortOption } from "../gateway-port-option.js"; import { callGatewayFromCliWithTransport } from "../gateway-rpc.js"; import { formatHelpExamples } from "../help-format.js"; @@ -138,6 +139,9 @@ async function runGatewayCommand( try { await action(); } catch (err) { + if (!opts?.json) { + rethrowExpectedCliError(err); + } if (opts?.json) { const { formatGatewayAuthErrorJson, diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index 36f03dd3afc8..474fa08b2056 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -33,6 +33,7 @@ import { defaultRuntime } from "../runtime.js"; import { shortenHomePath } from "../utils.js"; import { resolveOptionFromCommand } from "./cli-utils.js"; import { formatCliCommand } from "./command-format.js"; +import { rethrowExpectedCliError } from "./failure-output.js"; import { runNativeHookRelayCli, type NativeHookRelayCliOptions } from "./native-hook-relay-cli.js"; import { requestExitAfterOneShotOutput } from "./one-shot-exit.js"; import { runPluginInstallCommand } from "./plugins-install-command.js"; @@ -261,6 +262,7 @@ function formatHookMissingSummary(hook: HookStatusEntry): string { } function exitHooksCliWithError(err: unknown): never { + rethrowExpectedCliError(err); defaultRuntime.error(`${theme.error("Error:")} ${formatErrorMessage(err)}`); defaultRuntime.exit(1); throw new Error("unreachable"); diff --git a/src/cli/program.nodes-diagnostics-auth.e2e.test.ts b/src/cli/program.nodes-diagnostics-auth.e2e.test.ts index 478c0df71c7d..6f5ee4a4fe35 100644 --- a/src/cli/program.nodes-diagnostics-auth.e2e.test.ts +++ b/src/cli/program.nodes-diagnostics-auth.e2e.test.ts @@ -157,6 +157,48 @@ describe("cli program (nodes diagnostics auth)", () => { expect(requests[0]?.useStoredDeviceAuth).toBe(true); }); + it("lets missing credentials reach the shared renderer without a nodes status prefix", async () => { + const error = Object.assign( + new Error( + [ + "gateway node.list requires credentials before opening a websocket", + "Fix: configure gateway.auth token/password, pair this device, or pass --token/--password.", + "Config: /tmp/openclaw.json", + ].join("\n"), + ), + { + name: "GatewayCredentialsRequiredError", + method: "node.list", + configPath: "/tmp/openclaw.json", + }, + ); + programGatewayCallMock.mockRejectedValue(error); + + await expect(runProgram(["nodes", "status"])).rejects.toBe(error); + + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + + it.each([ + new Error("connect ECONNREFUSED 127.0.0.1:4242"), + Object.assign(new Error("unauthorized: token mismatch"), { + name: "GatewayClientRequestError", + gatewayCode: "INVALID_REQUEST", + details: { code: "AUTH_TOKEN_MISMATCH" }, + }), + ])("keeps non-credential node failures distinct: $message", async (error) => { + programGatewayCallMock.mockRejectedValue(error); + + await expect(runProgram(["nodes", "status"])).rejects.toThrow("exit"); + + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining(error.message)); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("nodes status failed:")); + expect(runtime.error).not.toHaveBeenCalledWith( + expect.stringContaining("configure gateway.auth"), + ); + }); + it("falls back to configured auth after stored device auth is rejected", async () => { programGatewayCallMock.mockImplementation(async (...args: unknown[]) => { const opts = (args[0] ?? {}) as { method?: string; useStoredDeviceAuth?: boolean }; diff --git a/src/cli/secrets-cli.ts b/src/cli/secrets-cli.ts index 37ac5c7e98ae..30816458d77e 100644 --- a/src/cli/secrets-cli.ts +++ b/src/cli/secrets-cli.ts @@ -9,6 +9,7 @@ import type { SecretsApplyPlan } from "../secrets/plan.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { formatCliCommand } from "./command-format.js"; import { formatGatewayCommandFailure } from "./error-format.js"; +import { rethrowExpectedCliError } from "./failure-output.js"; import { addGatewayClientOptions, callGatewayFromCli, type GatewayRpcOpts } from "./gateway-rpc.js"; import { registerSecretStoreCli } from "./secrets-store-cli.js"; @@ -143,6 +144,7 @@ export function registerSecretsCli(program: Command): void { } defaultRuntime.log("Secrets reloaded."); } catch (err) { + rethrowExpectedCliError(err); defaultRuntime.error( danger( formatGatewayCommandFailure({ diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index 547133643c43..94901896e8f9 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -69,7 +69,7 @@ import { CONFIG_DIR } from "../utils.js"; import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js"; import { resolveOptionFromCommand } from "./cli-utils.js"; import { inheritOptionFromParent } from "./command-options.js"; -import { formatCliJsonFailure } from "./failure-output.js"; +import { formatCliJsonFailure, rethrowExpectedCliError } from "./failure-output.js"; import { resolveInstallPolicyWarningAcknowledgementCliOptions } from "./install-policy-warning-acknowledgement.js"; import { parseStrictPositiveIntOption } from "./program/helpers.js"; import { setCommandJsonMode } from "./program/json-mode.js"; @@ -951,6 +951,7 @@ export function registerSkillsCli(program: Command) { } defaultRuntime.writeStdout(formatSkillCuratorStatus(status)); } catch (err) { + rethrowExpectedCliError(err); defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); } @@ -977,6 +978,7 @@ export function registerSkillsCli(program: Command) { `${action[0]?.toUpperCase()}${action.slice(1)} ${result.skillKey}\n`, ); } catch (err) { + rethrowExpectedCliError(err); defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); } @@ -1010,6 +1012,7 @@ export function registerSkillsCli(program: Command) { } defaultRuntime.writeStdout(format(result)); } catch (err) { + rethrowExpectedCliError(err); defaultRuntime.error(formatErrorMessage(err)); defaultRuntime.exit(1); } diff --git a/src/cli/skills-cli.workshop-cache.test.ts b/src/cli/skills-cli.workshop-cache.test.ts index b0e999085bfd..65e3e9dc7889 100644 --- a/src/cli/skills-cli.workshop-cache.test.ts +++ b/src/cli/skills-cli.workshop-cache.test.ts @@ -234,7 +234,12 @@ describe("skills workshop CLI gateway snapshot invalidation", () => { registerSkillsCli(program); await expect( program.parseAsync(["skills", "workshop", "apply", proposal.record.id], { from: "user" }), - ).rejects.toThrow("__exit__:1"); + ).rejects.toMatchObject({ + name: "GatewayCredentialsRequiredError", + message: "gateway health requires credentials", + method: "health", + configPath: "/tmp/openclaw.json", + }); expect(mocks.callGateway).toHaveBeenCalledTimes(1); expect(mocks.acquireGatewayLock).toHaveBeenCalledTimes(1); diff --git a/src/cli/system-cli.ts b/src/cli/system-cli.ts index c66aeb9ed44a..39fee0d67803 100644 --- a/src/cli/system-cli.ts +++ b/src/cli/system-cli.ts @@ -7,7 +7,7 @@ import { danger } from "../globals.js"; import { formatErrorMessage } from "../infra/errors.js"; import { defaultRuntime } from "../runtime.js"; import { formatCliCommand } from "./command-format.js"; -import { formatCliJsonFailure } from "./failure-output.js"; +import { formatCliJsonFailure, rethrowExpectedCliError } from "./failure-output.js"; import type { GatewayRpcOpts } from "./gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js"; import { setCommandJsonMode } from "./program/json-mode.js"; @@ -46,6 +46,7 @@ async function runSystemGatewayCommand( defaultRuntime.log(successText); } } catch (err) { + rethrowExpectedCliError(err); const message = formatErrorMessage(err); if (machineOutput) { defaultRuntime.writeJson(formatCliJsonFailure(message)); diff --git a/src/commands/audit.ts b/src/commands/audit.ts index 9cce5f385c53..c2df736c2f72 100644 --- a/src/commands/audit.ts +++ b/src/commands/audit.ts @@ -23,6 +23,7 @@ import { } from "../../packages/gateway-protocol/src/schema/audit-activity.js"; import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; import { parsePositiveAuditCursor } from "../audit/audit-cursor.js"; +import { isExpectedCliError } from "../cli/failure-output.js"; import { parseAbsoluteTimeMs } from "../cron/parse.js"; import { callGateway } from "../gateway/call.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -200,6 +201,9 @@ function validateAuditFilterCombination(options: AuditListCommandOptions): void } function formatAuditGatewayError(error: unknown): Error { + if (isExpectedCliError(error)) { + return error; + } const message = formatErrorMessage(error); const operatorMessage = message === "invalid audit.activity.list range or cursor" diff --git a/src/commands/channels.status.command-flow.test.ts b/src/commands/channels.status.command-flow.test.ts index 5c541e87a3f3..fff7adb3b319 100644 --- a/src/commands/channels.status.command-flow.test.ts +++ b/src/commands/channels.status.command-flow.test.ts @@ -321,6 +321,37 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { expect(joined).toContain("configured, secret unavailable in this command path"); }); + it("renders missing gateway credentials canonically before config-only status", async () => { + const error = Object.assign( + new Error( + [ + "gateway channels.status requires credentials before opening a websocket", + "Fix: configure gateway.auth token/password, pair this device, or pass --token/--password.", + "Config: /tmp/openclaw.json", + ].join("\n"), + ), + { + name: "GatewayCredentialsRequiredError", + method: "channels.status", + configPath: "/tmp/openclaw.json", + }, + ); + mocks.callGateway.mockRejectedValue(error); + mocks.requireValidConfig.mockResolvedValue({ channels: {} }); + mocks.resolveCommandConfigWithSecrets.mockResolvedValue({ + resolvedConfig: { channels: {} }, + effectiveConfig: { channels: {} }, + diagnostics: [], + }); + const { runtime, logs, errors } = createCapturingTestRuntime(); + + await channelsStatusCommand({ probe: false }, runtime as never); + + expect(errors).toEqual([error.message]); + expect(errors.join("\n")).not.toContain("Gateway not reachable:"); + expect(logs.join("\n")).toContain("Gateway auth unavailable; showing config-only status."); + }); + it("prefers resolved snapshots when command-local SecretRef resolution succeeds", async () => { mocks.callGateway.mockRejectedValue(new Error("gateway closed")); mocks.requireValidConfig.mockResolvedValue({ secretResolved: false, channels: {} }); diff --git a/src/commands/channels/status.runtime.ts b/src/commands/channels/status.runtime.ts index 0d423c455a8c..22609e8d5b4e 100644 --- a/src/commands/channels/status.runtime.ts +++ b/src/commands/channels/status.runtime.ts @@ -223,14 +223,16 @@ export async function renderChannelsStatusFallback(params: { runtime: RuntimeEnv; safeError: string; gatewayAuthUnavailable: boolean; + expectedErrorOutput?: string; }): Promise { - const { opts, runtime, safeError, gatewayAuthUnavailable } = params; + const { opts, runtime, safeError, gatewayAuthUnavailable, expectedErrorOutput } = params; const fallbackReason = gatewayAuthUnavailable ? "Gateway auth unavailable; showing config-only status." : "Gateway not reachable; showing config-only status."; if (!opts.json) { runtime.error( - `${gatewayAuthUnavailable ? "Gateway auth unavailable" : "Gateway not reachable"}: ${safeError}`, + expectedErrorOutput ?? + `${gatewayAuthUnavailable ? "Gateway auth unavailable" : "Gateway not reachable"}: ${safeError}`, ); } const cfg = await requireValidConfig(runtime, { observe: false }); diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index e186e4f4d434..f8e8c8ecf3c7 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -1,6 +1,7 @@ // Implements `openclaw channels status` with gateway status and config-only fallback. import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { formatCliFailureLines, isExpectedCliError } from "../../cli/failure-output.js"; import { parseTimeoutMsWithFallback } from "../../cli/parse-timeout.js"; import { withProgress } from "../../cli/progress.js"; import { callGateway } from "../../gateway/call.js"; @@ -75,8 +76,18 @@ export async function channelsStatusCommand( runtime.log(formatGatewayChannelsStatusLines(payload).join("\n")); } catch (err) { const safeError = formatChannelsStatusError(err); - const gatewayAuthUnavailable = isGatewaySecretRefUnavailableError(err); + const expectedError = isExpectedCliError(err); + const gatewayAuthUnavailable = expectedError || isGatewaySecretRefUnavailableError(err); + const expectedErrorOutput = expectedError + ? formatCliFailureLines({ title: "", error: err }).join("\n") + : undefined; const { renderChannelsStatusFallback } = await loadChannelsStatusRuntime(); - await renderChannelsStatusFallback({ opts: args, runtime, safeError, gatewayAuthUnavailable }); + await renderChannelsStatusFallback({ + opts: args, + runtime, + safeError, + gatewayAuthUnavailable, + expectedErrorOutput, + }); } } diff --git a/src/commands/sessions-compact.ts b/src/commands/sessions-compact.ts index 5f8053110935..1734df6c2f26 100644 --- a/src/commands/sessions-compact.ts +++ b/src/commands/sessions-compact.ts @@ -7,6 +7,7 @@ * (transport error or an `ok:false` payload) so automation never mistakes a * silent no-op for success. */ +import { rethrowExpectedCliError } from "../cli/failure-output.js"; import { callGatewayFromCliWithTransport } from "../cli/gateway-rpc.js"; import { formatErrorMessage } from "../infra/errors.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; @@ -93,6 +94,7 @@ export async function sessionsCompactCommand( defaultTimeoutMs: 10_000, })) as SessionsCompactResult; } catch (err) { + rethrowExpectedCliError(err); const message = formatErrorMessage(err); if (opts.json) { writeRuntimeJson(runtime, { ok: false, key: opts.key, error: message }); diff --git a/src/commands/sessions-lifecycle.ts b/src/commands/sessions-lifecycle.ts index 41b6a13a8e40..aa48a085898f 100644 --- a/src/commands/sessions-lifecycle.ts +++ b/src/commands/sessions-lifecycle.ts @@ -1,6 +1,6 @@ /** Gateway-backed archive and delete commands for stored sessions. */ import { formatCliCommand } from "../cli/command-format.js"; -import { formatCliJsonFailure } from "../cli/failure-output.js"; +import { formatCliJsonFailure, rethrowExpectedCliError } from "../cli/failure-output.js"; import { callGatewayFromCliWithTransport } from "../cli/gateway-rpc.js"; import { formatErrorMessage } from "../infra/errors.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; @@ -207,6 +207,7 @@ async function runSessionsLifecycleCommand( try { sessions = await listRequestedSessions(keys.filter(Boolean), opts.agent, rpcOptions); } catch (error) { + rethrowExpectedCliError(error); const message = formatErrorMessage(error); outputLifecycleResults( operation, diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index 129f60027443..477440e7d060 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -9,7 +9,7 @@ import { isRich, theme } from "../../packages/terminal-core/src/theme.js"; import { formatCliCommand } from "../cli/command-format.js"; import { parseCliEnumFilter } from "../cli/enum-filter.js"; import { formatLookupMiss } from "../cli/error-format.js"; -import { formatCliJsonFailure } from "../cli/failure-output.js"; +import { formatCliJsonFailure, rethrowExpectedCliError } from "../cli/failure-output.js"; import { getRuntimeConfig } from "../config/config.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { getTaskById, updateTaskNotifyPolicyById } from "../tasks/runtime-internal.js"; @@ -497,6 +497,7 @@ async function runTaskRecoveryCommand( ), ); } catch (error) { + rethrowExpectedCliError(error); runtime.error( sanitizeTerminalText( `Task delivery ${action} requires a live Gateway: ${error instanceof Error ? error.message : String(error)}`,