mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
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:
committed by
GitHub
parent
b485f0c627
commit
7a507d8e66
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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: {} });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
Reference in New Issue
Block a user