From 9be871245b56ab8bcabb72ee5285eb1eaeb1e741 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 20 Aug 2026 21:12:33 -0700 Subject: [PATCH] fix(channels): answer the channel question, and stop calling ECONNREFUSED an auth failure (#126984) Two defects in one command, both on the path a brand-new operator is on immediately after `openclaw onboard`. `channels status` never mentioned channels when none were configured. With the gateway up it printed `Gateway reachable.` and a tip about `status --deep`; without it, two blank lines where the channel list belongs. The operator asked for the status of their channels and got gateway reachability. Its siblings already handle this -- `channels list` prints `- no configured chat channels (run \`openclaw channels list --all\` to see installable channels)` and `openclaw status` prints `No channels configured` -- so `channels status` was the lone holdout. Both renderers now emit that same line, moved to a shared constant so the three surfaces cannot drift apart again. The second is worse because it sends the operator somewhere wrong. The fallback computed `gatewayAuthUnavailable = expectedError || isGatewaySecretRefUnavailableError(err)`, and `isExpectedCliError` returns true for `isGatewayTransportError` -- a plain ECONNREFUSED. So a gateway that simply was not running reported `Gateway auth unavailable; showing config-only status.`, contradicting the `Gateway not reachable at ws://... (ECONNREFUSED)` line printed three lines above it. Someone who runs `channels status` before starting the gateway went hunting for a token problem that did not exist. The flag now consults only the two genuinely auth-related predicates; `expectedError` keeps its separate job of selecting the canonical CLI failure output. `isGatewayCredentialsCliError` becomes exported for that check. The JSON shape is unchanged; only the truth of `gatewayAuthUnavailable` changes, and no test or documented contract depended on transport errors setting it. Production +27/-9. --- src/cli/failure-output.ts | 2 +- ...channels.config-only-status-output.test.ts | 10 ++++++++ .../channels.status.command-flow.test.ts | 24 +++++++++++++++++-- ...time-errors-channels-status-output.test.ts | 8 +++++++ src/commands/channels/list.ts | 12 +++++----- src/commands/channels/shared.ts | 3 +++ src/commands/channels/status-config-format.ts | 5 ++++ src/commands/channels/status.runtime.ts | 5 ++++ src/commands/channels/status.ts | 9 +++++-- 9 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/cli/failure-output.ts b/src/cli/failure-output.ts index abc5c02b4f12..9f80140afc3f 100644 --- a/src/cli/failure-output.ts +++ b/src/cli/failure-output.ts @@ -41,7 +41,7 @@ export class ExpectedCliError extends Error { } } -function isGatewayCredentialsCliError( +export function isGatewayCredentialsCliError( error: unknown, ): error is Error & { method: string; configPath: string } { // Keep the root failure renderer lean; importing gateway/call would pull the diff --git a/src/commands/channels.config-only-status-output.test.ts b/src/commands/channels.config-only-status-output.test.ts index 80fc1641f987..c7f875c085d6 100644 --- a/src/commands/channels.config-only-status-output.test.ts +++ b/src/commands/channels.config-only-status-output.test.ts @@ -203,6 +203,16 @@ function requireReadOnlyPluginListCall(): unknown[] { } describe("config-only channels status output", () => { + it("guides operators when no channels are configured", async () => { + activeChannelPlugins.splice(0); + + const output = await formatLocalStatusSummary({ channels: {} }); + + expect(output).toContain( + "- no configured chat channels (run `openclaw channels list --all` to see installable channels)", + ); + }); + it("sanitizes channel and account display names in terminal output", async () => { const control = "\u001B]0;channels-status-injection\u0007"; registerSingleTestPlugin( diff --git a/src/commands/channels.status.command-flow.test.ts b/src/commands/channels.status.command-flow.test.ts index fff7adb3b319..afac9fae2c1a 100644 --- a/src/commands/channels.status.command-flow.test.ts +++ b/src/commands/channels.status.command-flow.test.ts @@ -1,6 +1,7 @@ // Channels status command-flow tests cover gateway calls, config fallback, and timeout validation. import { beforeEach, describe, expect, it, vi } from "vitest"; import { GatewaySecretRefUnavailableError } from "../gateway/credentials.js"; +import { GatewayTransportError } from "../gateway/transport-error.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { channelsStatusCommand } from "./channels/status.js"; import { createCapturingTestRuntime } from "./test-runtime-config-helpers.js"; @@ -214,6 +215,18 @@ function createTokenOnlyPlugin() { }; } +function createGatewayTransportError(message = "Gateway not reachable (ECONNREFUSED).") { + return new GatewayTransportError({ + kind: "closed", + message, + connectionDetails: { + url: "ws://127.0.0.1:18997", + urlSource: "local loopback", + message: "Gateway target: ws://127.0.0.1:18997", + }, + }); +} + describe("channelsStatusCommand SecretRef fallback flow", () => { beforeEach(() => { mocks.callGateway.mockReset(); @@ -270,7 +283,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { }); it("keeps read-only fallback output when SecretRefs are unresolved", async () => { - mocks.callGateway.mockRejectedValue(new Error("gateway closed")); + mocks.callGateway.mockRejectedValue(createGatewayTransportError()); mocks.requireValidConfig.mockResolvedValue({ secretResolved: false, channels: {} }); mocks.resolveCommandConfigWithSecrets.mockResolvedValue({ resolvedConfig: { secretResolved: false, channels: {} }, @@ -284,6 +297,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { await channelsStatusCommand({ probe: false }, runtime as never); expect(errors.join("\n")).toContain("Gateway not reachable"); + expect(errors.join("\n")).not.toContain("Gateway auth unavailable"); expect(mocks.resolveCommandConfigWithSecrets).toHaveBeenCalledOnce(); const configResolutionRequest = mocks.resolveCommandConfigWithSecrets.mock.calls[0]?.[0]; expect(configResolutionRequest?.commandName).toBe("channels status"); @@ -294,6 +308,8 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { ), ).toBe(true); const joined = logs.join("\n"); + expect(joined).toContain("Gateway not reachable; showing config-only status."); + expect(joined).not.toContain("Gateway auth unavailable; showing config-only status."); expect(joined).toContain("configured, secret unavailable in this command path"); expect(joined).toContain("token:config (unavailable)"); }); @@ -319,6 +335,10 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { expect(joined).toContain("Gateway auth unavailable; showing config-only status."); expect(joined).not.toContain("Gateway not reachable; showing config-only status."); expect(joined).toContain("configured, secret unavailable in this command path"); + + const { runtime: jsonRuntime, logs: jsonLogs } = createCapturingTestRuntime(); + await channelsStatusCommand({ json: true, probe: false }, jsonRuntime as never); + expect(JSON.parse(jsonLogs.at(-1) ?? "{}").gatewayAuthUnavailable).toBe(true); }); it("renders missing gateway credentials canonically before config-only status", async () => { @@ -434,7 +454,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { it("keeps JSON fallback structured without rendering config-only text", async () => { mocks.callGateway.mockRejectedValue( - new Error( + createGatewayTransportError( [ "gateway timeout after 3000ms", "Gateway target: wss://user:pass@gateway.example.com/socket?token=secret-token&keep=visible", diff --git a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts index a3deeb5fcd38..1a5f4b4375ce 100644 --- a/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts +++ b/src/commands/channels.surfaces-signal-runtime-errors-channels-status-output.test.ts @@ -37,6 +37,14 @@ describe("channels command", () => { setActivePluginRegistry(createTestRegistry([])); }); + it("guides operators when no channels are configured", () => { + const lines = formatGatewayChannelsStatusLines({ channelAccounts: {} }); + + expect(lines).toContain( + "- no configured chat channels (run `openclaw channels list --all` to see installable channels)", + ); + }); + it("surfaces Signal runtime errors in channels status output", () => { const lines = formatGatewayChannelsStatusLines({ channelLabels: { diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index fc363a9e9556..9014de61377a 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -19,7 +19,11 @@ import { resolvePluginMetadataSnapshot } from "../../plugins/plugin-metadata-sna import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { listManifestInstalledChannelIds } from "../channel-setup/discovery.js"; import { listTrustedChannelPluginCatalogEntries } from "../channel-setup/trusted-catalog.js"; -import { formatChannelAccountLabel, requireValidChannelConfig } from "./shared.js"; +import { + formatChannelAccountLabel, + NO_CONFIGURED_CHAT_CHANNELS_LINE, + requireValidChannelConfig, +} from "./shared.js"; export type ChannelsListOptions = { json?: boolean; @@ -341,11 +345,7 @@ export async function channelsListCommand( } if (accountLines.length === 0 && catalogOnlyLines.length === 0) { lines.push( - theme.muted( - showAll - ? "- no chat channels found" - : "- no configured chat channels (run `openclaw channels list --all` to see installable channels)", - ), + theme.muted(showAll ? "- no chat channels found" : NO_CONFIGURED_CHAT_CHANNELS_LINE), ); } else { for (const line of accountLines) { diff --git a/src/commands/channels/shared.ts b/src/commands/channels/shared.ts index 3f1d33bc965f..424390ee101c 100644 --- a/src/commands/channels/shared.ts +++ b/src/commands/channels/shared.ts @@ -12,6 +12,9 @@ import { requireValidConfig, requireValidConfigFileSnapshot } from "../config-va export type ChatChannel = ChannelId; +export const NO_CONFIGURED_CHAT_CHANNELS_LINE = + "- no configured chat channels (run `openclaw channels list --all` to see installable channels)"; + export { requireValidConfigFileSnapshot }; /** Load valid channel command config with read-only secret resolution applied. */ diff --git a/src/commands/channels/status-config-format.ts b/src/commands/channels/status-config-format.ts index eb74a7dd9c44..0a4b2e9bd327 100644 --- a/src/commands/channels/status-config-format.ts +++ b/src/commands/channels/status-config-format.ts @@ -23,6 +23,7 @@ import { appendTokenSourceBits, buildChannelAccountLine, type ChatChannel, + NO_CONFIGURED_CHAT_CHANNELS_LINE, } from "./shared.js"; type ChannelStatusPluginLabel = { @@ -74,6 +75,7 @@ export async function formatConfigChannelsStatusLines( includeSetupFallbackPlugins: true, }).filter((plugin) => !requestedChannel || plugin.id === requestedChannel); const visibleChannelIds = new Set(); + const statusLinesStart = lines.length; for (const plugin of plugins) { visibleChannelIds.add(plugin.id); const accountIds = plugin.config.listAccountIds(cfg); @@ -127,6 +129,9 @@ export async function formatConfigChannelsStatusLines( lines.push(`- ${hint.label}: ${hint.repairHint}`); } } + if (lines.length === statusLinesStart) { + lines.push(theme.muted(NO_CONFIGURED_CHAT_CHANNELS_LINE)); + } lines.push(""); lines.push( diff --git a/src/commands/channels/status.runtime.ts b/src/commands/channels/status.runtime.ts index 22609e8d5b4e..9a23829c8d45 100644 --- a/src/commands/channels/status.runtime.ts +++ b/src/commands/channels/status.runtime.ts @@ -21,6 +21,7 @@ import { appendTokenSourceBits, buildChannelAccountLine, type ChatChannel, + NO_CONFIGURED_CHAT_CHANNELS_LINE, } from "./shared.js"; import { formatConfigChannelsStatusLines } from "./status-config-format.js"; import type { ChannelsStatusOptions } from "./status.js"; @@ -193,12 +194,16 @@ export function formatGatewayChannelsStatusLines(payload: Record>; } } + const accountLinesStart = lines.length; for (const channelId of Object.keys(accountPayloads).toSorted()) { const accounts = accountPayloads[channelId]; if (accounts && accounts.length > 0) { lines.push(...accountLines(channelId, accounts)); } } + if (lines.length === accountLinesStart) { + lines.push(theme.muted(NO_CONFIGURED_CHAT_CHANNELS_LINE)); + } lines.push(""); const issues = collectChannelStatusIssues(payload); diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index f8e8c8ecf3c7..919cf631fe11 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -1,7 +1,11 @@ // 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 { + formatCliFailureLines, + isExpectedCliError, + isGatewayCredentialsCliError, +} from "../../cli/failure-output.js"; import { parseTimeoutMsWithFallback } from "../../cli/parse-timeout.js"; import { withProgress } from "../../cli/progress.js"; import { callGateway } from "../../gateway/call.js"; @@ -77,7 +81,8 @@ export async function channelsStatusCommand( } catch (err) { const safeError = formatChannelsStatusError(err); const expectedError = isExpectedCliError(err); - const gatewayAuthUnavailable = expectedError || isGatewaySecretRefUnavailableError(err); + const gatewayAuthUnavailable = + isGatewayCredentialsCliError(err) || isGatewaySecretRefUnavailableError(err); const expectedErrorOutput = expectedError ? formatCliFailureLines({ title: "", error: err }).join("\n") : undefined;