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.
This commit is contained in:
Peter Steinberger
2026-08-20 21:12:33 -07:00
committed by GitHub
parent 2acfc47b7f
commit 9be871245b
9 changed files with 67 additions and 11 deletions
+1 -1
View File
@@ -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
@@ -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(
@@ -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",
@@ -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: {
+6 -6
View File
@@ -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) {
+3
View File
@@ -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. */
@@ -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<string>();
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(
+5
View File
@@ -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<string, unknown
accountPayloads[channelId] = raw as Array<Record<string, unknown>>;
}
}
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);
+7 -2
View File
@@ -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;