From b672be59ae46159bf06f6a4be23290edbc0d7755 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 15 May 2026 05:42:10 +0100 Subject: [PATCH] fix(channels): prefer runtime status in channel list (#82016) --- CHANGELOG.md | 1 + src/commands/channels.list.test.ts | 113 +++++++++++++++++++++++++++++ src/commands/channels/list.ts | 83 ++++++++++++++++++--- 3 files changed, 186 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3039921ccba..651f7a0557c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord/channels: make `openclaw channels list --all` prefer reachable Gateway runtime account status and mark configured-but-unavailable credentials, avoiding false `not configured` output when Discord is running from service-only env. Fixes #79343. Thanks @EricY019. - Installer: handle noninteractive git installs from moving refs without tag-fetch conflicts, while keeping immutable refs on frozen lockfile installs. (#81875) Thanks @keshavbotagent. - Codex app-server: inject native client factories per run and compaction attempt instead of using module-scope test state, avoiding temporal-dead-zone reads during cyclic startup. (#81148) Thanks @bdjben. - Plugin skills: replace generated Windows plugin-skill directories before publishing the current skill link, avoiding repeated `EINVAL` warnings from stale non-symlink entries. Fixes #81432. (#81446) Thanks @hclsys and @vincentkoc. diff --git a/src/commands/channels.list.test.ts b/src/commands/channels.list.test.ts index 78c80491b35..6ae81d12e00 100644 --- a/src/commands/channels.list.test.ts +++ b/src/commands/channels.list.test.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ isCatalogChannelInstalled: vi.fn<(params: { entry: ChannelPluginCatalogEntry }) => boolean>( () => true, ), + callGateway: vi.fn(), resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), resolveDefaultAgentId: vi.fn(() => "main"), })); @@ -29,6 +30,10 @@ vi.mock("../cli/command-config-resolution.js", () => ({ resolveCommandConfigWithSecrets: mocks.resolveCommandConfigWithSecrets, })); +vi.mock("../gateway/call.js", () => ({ + callGateway: mocks.callGateway, +})); + vi.mock("../cli/command-secret-targets.js", () => ({ getChannelsCommandSecretTargetIds: () => new Set(), })); @@ -115,6 +120,8 @@ describe("channels list", () => { mocks.listTrustedChannelPluginCatalogEntries.mockReturnValue([]); mocks.isCatalogChannelInstalled.mockReset(); mocks.isCatalogChannelInstalled.mockReturnValue(true); + mocks.callGateway.mockReset(); + mocks.callGateway.mockRejectedValue(new Error("gateway unavailable")); }); it("does not include auth providers in JSON output (auth section was removed)", async () => { @@ -221,6 +228,112 @@ describe("channels list", () => { expect(output).not.toContain("Auth providers"); }); + it("prefers reachable gateway account snapshots over command-local token state", async () => { + const runtime = createTestRuntime(); + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([ + createMockChannelPlugin({ id: "discord", label: "Discord", accountIds: ["default"] }), + ]); + mocks.buildChannelAccountSnapshot.mockResolvedValue({ + accountId: "default", + configured: false, + tokenSource: "none", + enabled: true, + }); + mocks.callGateway.mockResolvedValue({ + channelAccounts: { + discord: [ + { + accountId: "default", + name: "clawsweeper", + configured: true, + tokenSource: "env", + tokenStatus: "available", + enabled: true, + }, + ], + }, + }); + mocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + channels: { + discord: { enabled: true }, + }, + }, + }); + + await channelsListCommand({ all: true }, runtime); + + expect(mocks.callGateway).toHaveBeenCalledWith({ + method: "channels.status", + params: { probe: false, timeoutMs: 5000 }, + timeoutMs: 5000, + }); + const output = stripAnsi(loggedText(runtime)); + expect(output).toContain("Discord default (clawsweeper):"); + expect(output).toContain("configured"); + expect(output).toContain("token=env"); + expect(output).not.toContain("not configured"); + expect(output).not.toContain("token=none"); + }); + + it("falls back to command-local account snapshots when gateway status is unavailable", async () => { + const runtime = createTestRuntime(); + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([ + createMockChannelPlugin({ id: "discord", label: "Discord", accountIds: ["default"] }), + ]); + mocks.buildChannelAccountSnapshot.mockResolvedValue({ + accountId: "default", + configured: false, + tokenSource: "none", + enabled: true, + }); + mocks.callGateway.mockRejectedValue(new Error("gateway unavailable")); + mocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + channels: { + discord: { enabled: true }, + }, + }, + }); + + await channelsListCommand({ all: true }, runtime); + + const output = stripAnsi(loggedText(runtime)); + expect(output).toContain("Discord default:"); + expect(output).toContain("not configured"); + expect(output).toContain("token=none"); + }); + + it("marks configured-but-unavailable credential sources in text output", async () => { + const runtime = createTestRuntime(); + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([ + createMockChannelPlugin({ id: "discord", label: "Discord", accountIds: ["default"] }), + ]); + mocks.buildChannelAccountSnapshot.mockResolvedValue({ + accountId: "default", + configured: true, + tokenSource: "config", + tokenStatus: "configured_unavailable", + enabled: true, + }); + mocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + channels: { + discord: { enabled: true }, + }, + }, + }); + + await channelsListCommand({ all: true }, runtime); + + const output = stripAnsi(loggedText(runtime)); + expect(output).toContain("configured"); + expect(output).toContain("token=config-unavailable"); + }); + it("default output does NOT show installable catalog channels (only configured ones)", async () => { const runtime = createTestRuntime(); mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]); diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index f3ee045c89c..d4fdcab12aa 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -5,6 +5,7 @@ import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js"; import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { ChannelAccountSnapshot } from "../../channels/plugins/types.public.js"; +import { callGateway } from "../../gateway/call.js"; import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; @@ -17,6 +18,47 @@ export type ChannelsListOptions = { all?: boolean; }; +type RuntimeChannelStatus = { + channelAccounts?: Record; +}; + +function normalizeRuntimeAccounts( + payload: RuntimeChannelStatus | null, +): Map { + const out = new Map(); + const rawAccounts = payload?.channelAccounts; + if (!rawAccounts || typeof rawAccounts !== "object") { + return out; + } + for (const [channelId, accounts] of Object.entries(rawAccounts)) { + if (!Array.isArray(accounts)) { + continue; + } + const normalized = accounts.filter( + (account): account is ChannelAccountSnapshot => + Boolean(account) && + typeof account === "object" && + typeof (account as { accountId?: unknown }).accountId === "string", + ); + if (normalized.length > 0) { + out.set(channelId, normalized); + } + } + return out; +} + +async function readGatewayChannelStatus(): Promise { + try { + return (await callGateway({ + method: "channels.status", + params: { probe: false, timeoutMs: 5_000 }, + timeoutMs: 5_000, + })) as RuntimeChannelStatus; + } catch { + return null; + } +} + const colorValue = (value: string) => { if (value === "none") { return theme.error(value); @@ -39,14 +81,20 @@ function formatInstalled(value: boolean): string { return value ? theme.success("installed") : theme.warn("not installed"); } -function formatTokenSource(source?: string): string { +function formatCredentialSource(source?: string, status?: string): string { const value = source || "none"; - return `token=${colorValue(value)}`; + if (status === "configured_unavailable" && value !== "none") { + return theme.warn(`${value}-unavailable`); + } + return colorValue(value); } -function formatSource(label: string, source?: string): string { - const value = source || "none"; - return `${label}=${colorValue(value)}`; +function formatTokenSource(source?: string, status?: string): string { + return `token=${formatCredentialSource(source, status)}`; +} + +function formatSource(label: string, source?: string, status?: string): string { + return `${label}=${formatCredentialSource(source, status)}`; } function formatLinked(value: boolean): string { @@ -83,13 +131,13 @@ function formatAccountLine(params: { bits.push(formatLinked(snapshot.linked)); } if (snapshot.tokenSource) { - bits.push(formatTokenSource(snapshot.tokenSource)); + bits.push(formatTokenSource(snapshot.tokenSource, snapshot.tokenStatus)); } if (snapshot.botTokenSource) { - bits.push(formatSource("bot", snapshot.botTokenSource)); + bits.push(formatSource("bot", snapshot.botTokenSource, snapshot.botTokenStatus)); } if (snapshot.appTokenSource) { - bits.push(formatSource("app", snapshot.appTokenSource)); + bits.push(formatSource("app", snapshot.appTokenSource, snapshot.appTokenStatus)); } if (snapshot.baseUrl) { bits.push(`base=${theme.muted(snapshot.baseUrl)}`); @@ -129,6 +177,10 @@ export async function channelsListCommand( cfg, ...(workspaceDir ? { workspaceDir } : {}), }); + const runtimeAccountsByChannel = + opts.json === true + ? new Map() + : normalizeRuntimeAccounts(await readGatewayChannelStatus()); const installedByChannelId = new Map(); for (const entry of catalogEntries) { installedByChannelId.set( @@ -158,8 +210,14 @@ export async function channelsListCommand( const accountIds = plugin.config.listAccountIds(cfg); if (accountIds && accountIds.length > 0) { renderedChannelIds.add(plugin.id); - for (const accountId of accountIds) { - const snapshot = await buildChannelAccountSnapshot({ plugin, cfg, accountId }); + const runtimeAccounts = runtimeAccountsByChannel.get(plugin.id) ?? []; + const mergedAccountIds = [ + ...new Set([...accountIds, ...runtimeAccounts.map((account) => account.accountId)]), + ]; + for (const accountId of mergedAccountIds) { + const runtimeSnapshot = runtimeAccounts.find((account) => account.accountId === accountId); + const snapshot = + runtimeSnapshot ?? (await buildChannelAccountSnapshot({ plugin, cfg, accountId })); accountLines.push({ plugin, snapshot, @@ -184,10 +242,13 @@ export async function channelsListCommand( cfg, accountId: "default", }); + const runtimeSnapshot = runtimeAccountsByChannel + .get(plugin.id) + ?.find((account) => account.accountId === "default"); renderedChannelIds.add(plugin.id); accountLines.push({ plugin, - snapshot, + snapshot: runtimeSnapshot ?? snapshot, installed: isInstalled(plugin.id), }); }