fix(cli): render missing gateway credentials consistently (#125007)

* fix(cli): unify missing gateway credential output

* test(cli): cover workshop credential rendering
This commit is contained in:
Peter Steinberger
2026-08-17 19:07:18 -07:00
committed by GitHub
parent b485f0c627
commit 7a507d8e66
21 changed files with 368 additions and 25 deletions
+2 -2
View File
@@ -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) {
+3 -1
View File
@@ -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);
+33
View File
@@ -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: {},
+2
View File
@@ -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);
+72 -9
View File
@@ -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.",
+38 -4
View File
@@ -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.
@@ -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");
+4
View File
@@ -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,
+2
View File
@@ -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");
@@ -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 };
+2
View File
@@ -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({
+4 -1
View File
@@ -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);
}
+6 -1
View File
@@ -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);
+2 -1
View File
@@ -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));
+4
View File
@@ -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"
@@ -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: {} });
+4 -2
View File
@@ -223,14 +223,16 @@ export async function renderChannelsStatusFallback(params: {
runtime: RuntimeEnv;
safeError: string;
gatewayAuthUnavailable: boolean;
expectedErrorOutput?: string;
}): Promise<void> {
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 });
+13 -2
View File
@@ -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,
});
}
}
+2
View File
@@ -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 });
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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)}`,