From ce04ad83fa673347ed19150a0e141d0591676b8d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 2 May 2026 14:33:23 -0700 Subject: [PATCH] fix(status): trust live channel credential state --- src/commands/status-all/channels.test.ts | 82 +++++++++++++++++++++ src/commands/status-all/channels.ts | 93 ++++++++++++++++++++++-- src/commands/status.scan-overview.ts | 1 + 3 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 src/commands/status-all/channels.test.ts diff --git a/src/commands/status-all/channels.test.ts b/src/commands/status-all/channels.test.ts new file mode 100644 index 00000000000..37b72c6fe59 --- /dev/null +++ b/src/commands/status-all/channels.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildChannelsTable } from "./channels.js"; + +const mocks = vi.hoisted(() => ({ + resolveInspectedChannelAccount: vi.fn(), +})); + +const discordPlugin = { + id: "discord", + meta: { label: "Discord" }, + config: { + listAccountIds: () => ["default"], + }, +}; + +vi.mock("../../channels/account-inspection.js", () => ({ + resolveInspectedChannelAccount: mocks.resolveInspectedChannelAccount, +})); + +vi.mock("../../channels/plugins/read-only.js", () => ({ + listReadOnlyChannelPluginsForConfig: () => [discordPlugin], +})); + +describe("buildChannelsTable", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveInspectedChannelAccount.mockResolvedValue({ + account: { + tokenStatus: "configured_unavailable", + tokenSource: "secretref", + }, + enabled: true, + configured: true, + }); + }); + + it("keeps a live gateway-backed account OK when local status cannot resolve the token", async () => { + const table = await buildChannelsTable( + { channels: { discord: { enabled: true } } }, + { + liveChannelStatus: { + channelAccounts: { + discord: [ + { + accountId: "default", + running: true, + connected: true, + tokenStatus: "available", + }, + ], + }, + }, + }, + ); + + expect(table.rows).toContainEqual( + expect.objectContaining({ + id: "discord", + state: "ok", + detail: expect.not.stringContaining("unavailable"), + }), + ); + expect(table.details[0]?.rows[0]).toEqual( + expect.objectContaining({ + Status: "OK", + Notes: expect.stringContaining("credential available in gateway runtime"), + }), + ); + }); + + it("warns when a configured token is unavailable and there is no live account proof", async () => { + const table = await buildChannelsTable({ channels: { discord: { enabled: true } } }); + + expect(table.rows).toContainEqual( + expect.objectContaining({ + id: "discord", + state: "warn", + detail: expect.stringContaining("unavailable"), + }), + ); + }); +}); diff --git a/src/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index f5fcd14a89b..a91b3eed236 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -42,6 +42,63 @@ type ResolvedChannelAccountRowParams = { accountId: string; }; +function getLiveChannelAccounts(params: { + liveChannelStatus: unknown; + channelId: string; +}): Array> { + const payload = asRecord(params.liveChannelStatus); + const accountsByChannel = asRecord(payload.channelAccounts); + const raw = accountsByChannel[params.channelId]; + return Array.isArray(raw) ? raw.map(asRecord) : []; +} + +function getLiveAccountId(account: Record): string { + return ( + normalizeOptionalString(account.accountId) ?? + normalizeOptionalString(account.id) ?? + normalizeOptionalString(account.name) ?? + "default" + ); +} + +function findLiveChannelAccount(params: { + liveAccounts: Array>; + accountId: string; +}): Record | null { + return ( + params.liveAccounts.find((account) => getLiveAccountId(account) === params.accountId) ?? + (params.accountId === "default" && params.liveAccounts.length === 1 + ? (params.liveAccounts[0] ?? null) + : null) + ); +} + +function hasLiveCredentialAvailable(params: { + liveAccounts: Array>; + accountId: string; +}): boolean { + const account = findLiveChannelAccount(params); + if (!account) { + return false; + } + if (hasConfiguredUnavailableCredentialStatus(account)) { + return false; + } + return account.running === true || account.connected === true; +} + +function markConfiguredUnavailableCredentialStatusesAvailable( + account: unknown, +): Record { + const record = { ...asRecord(account) }; + for (const key of ["tokenStatus", "botTokenStatus", "appTokenStatus", "signingSecretStatus"]) { + if (record[key] === "configured_unavailable") { + record[key] = "available"; + } + } + return record; +} + function existsSyncMaybe(p: string | undefined): boolean | null { const path = normalizeOptionalString(p) ?? ""; if (!path) { @@ -87,6 +144,7 @@ const buildAccountNotes = (params: { plugin: ChannelPlugin; cfg: OpenClawConfig; entry: ChannelAccountRow; + liveCredentialAvailable?: boolean; }) => { const { plugin, cfg, entry } = params; const notes: string[] = []; @@ -112,7 +170,9 @@ const buildAccountNotes = (params: { ) { notes.push(`signing:${snapshot.signingSecretSource}`); } - if (hasConfiguredUnavailableCredentialStatus(entry.account)) { + if (params.liveCredentialAvailable) { + notes.push("credential available in gateway runtime"); + } else if (hasConfiguredUnavailableCredentialStatus(entry.account)) { notes.push("secret unavailable in this command path"); } if (snapshot.baseUrl) { @@ -192,6 +252,7 @@ export async function buildChannelsTable( showSecrets?: boolean; sourceConfig?: OpenClawConfig; includeSetupFallbackPlugins?: boolean; + liveChannelStatus?: unknown; }, ): Promise<{ rows: ChannelRow[]; @@ -234,12 +295,27 @@ export async function buildChannelsTable( }), ); } + const liveAccounts = getLiveChannelAccounts({ + liveChannelStatus: opts?.liveChannelStatus, + channelId: plugin.id, + }); const anyEnabled = accounts.some((a) => a.enabled); const enabledAccounts = accounts.filter((a) => a.enabled); const configuredAccounts = enabledAccounts.filter((a) => a.configured); - const unavailableConfiguredAccounts = enabledAccounts.filter((a) => - hasConfiguredUnavailableCredentialStatus(a.account), + const unavailableConfiguredAccounts = enabledAccounts.filter( + (a) => + hasConfiguredUnavailableCredentialStatus(a.account) && + !hasLiveCredentialAvailable({ liveAccounts, accountId: a.accountId }), + ); + const accountsForTokenSummary = accounts.map((entry) => + hasConfiguredUnavailableCredentialStatus(entry.account) && + hasLiveCredentialAvailable({ liveAccounts, accountId: entry.accountId }) + ? { + ...entry, + account: markConfiguredUnavailableCredentialStatusesAvailable(entry.account), + } + : entry, ); const defaultEntry = accounts.find((a) => a.accountId === defaultAccountId) ?? accounts[0]; @@ -256,7 +332,7 @@ export async function buildChannelsTable( const link = resolveLinkFields(summary); const missingPaths = collectMissingPaths(enabledAccounts); const tokenSummary = summarizeTokenConfig({ - accounts, + accounts: accountsForTokenSummary, showSecrets, }); @@ -383,14 +459,19 @@ export async function buildChannelsTable( title: `${label} accounts`, columns: ["Account", "Status", "Notes"], rows: configuredAccounts.map((entry) => { - const notes = buildAccountNotes({ plugin, cfg, entry }); + const liveCredentialAvailable = hasLiveCredentialAvailable({ + liveAccounts, + accountId: entry.accountId, + }); + const notes = buildAccountNotes({ plugin, cfg, entry, liveCredentialAvailable }); return { Account: formatAccountLabel({ accountId: entry.accountId, name: entry.snapshot.name, }), Status: - entry.enabled && !hasConfiguredUnavailableCredentialStatus(entry.account) + entry.enabled && + (!hasConfiguredUnavailableCredentialStatus(entry.account) || liveCredentialAvailable) ? "OK" : "WARN", Notes: notes.join(" ยท "), diff --git a/src/commands/status.scan-overview.ts b/src/commands/status.scan-overview.ts index e84ef4883e6..eff89740ba7 100644 --- a/src/commands/status.scan-overview.ts +++ b/src/commands/status.scan-overview.ts @@ -251,6 +251,7 @@ export async function collectStatusScanOverview(params: { showSecrets: params.showSecrets, sourceConfig, includeSetupFallbackPlugins: params.includeChannelSetupRuntimeFallback !== false, + liveChannelStatus: channelsStatus, }); params.progress?.tick(); return { channelsStatus, channelIssues, channels };