Skip to content

Commit ce04ad8

Browse files
committed
fix(status): trust live channel credential state
1 parent 0fbf463 commit ce04ad8

3 files changed

Lines changed: 170 additions & 6 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { buildChannelsTable } from "./channels.js";
3+
4+
const mocks = vi.hoisted(() => ({
5+
resolveInspectedChannelAccount: vi.fn(),
6+
}));
7+
8+
const discordPlugin = {
9+
id: "discord",
10+
meta: { label: "Discord" },
11+
config: {
12+
listAccountIds: () => ["default"],
13+
},
14+
};
15+
16+
vi.mock("../../channels/account-inspection.js", () => ({
17+
resolveInspectedChannelAccount: mocks.resolveInspectedChannelAccount,
18+
}));
19+
20+
vi.mock("../../channels/plugins/read-only.js", () => ({
21+
listReadOnlyChannelPluginsForConfig: () => [discordPlugin],
22+
}));
23+
24+
describe("buildChannelsTable", () => {
25+
beforeEach(() => {
26+
vi.clearAllMocks();
27+
mocks.resolveInspectedChannelAccount.mockResolvedValue({
28+
account: {
29+
tokenStatus: "configured_unavailable",
30+
tokenSource: "secretref",
31+
},
32+
enabled: true,
33+
configured: true,
34+
});
35+
});
36+
37+
it("keeps a live gateway-backed account OK when local status cannot resolve the token", async () => {
38+
const table = await buildChannelsTable(
39+
{ channels: { discord: { enabled: true } } },
40+
{
41+
liveChannelStatus: {
42+
channelAccounts: {
43+
discord: [
44+
{
45+
accountId: "default",
46+
running: true,
47+
connected: true,
48+
tokenStatus: "available",
49+
},
50+
],
51+
},
52+
},
53+
},
54+
);
55+
56+
expect(table.rows).toContainEqual(
57+
expect.objectContaining({
58+
id: "discord",
59+
state: "ok",
60+
detail: expect.not.stringContaining("unavailable"),
61+
}),
62+
);
63+
expect(table.details[0]?.rows[0]).toEqual(
64+
expect.objectContaining({
65+
Status: "OK",
66+
Notes: expect.stringContaining("credential available in gateway runtime"),
67+
}),
68+
);
69+
});
70+
71+
it("warns when a configured token is unavailable and there is no live account proof", async () => {
72+
const table = await buildChannelsTable({ channels: { discord: { enabled: true } } });
73+
74+
expect(table.rows).toContainEqual(
75+
expect.objectContaining({
76+
id: "discord",
77+
state: "warn",
78+
detail: expect.stringContaining("unavailable"),
79+
}),
80+
);
81+
});
82+
});

src/commands/status-all/channels.ts

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,63 @@ type ResolvedChannelAccountRowParams = {
4242
accountId: string;
4343
};
4444

45+
function getLiveChannelAccounts(params: {
46+
liveChannelStatus: unknown;
47+
channelId: string;
48+
}): Array<Record<string, unknown>> {
49+
const payload = asRecord(params.liveChannelStatus);
50+
const accountsByChannel = asRecord(payload.channelAccounts);
51+
const raw = accountsByChannel[params.channelId];
52+
return Array.isArray(raw) ? raw.map(asRecord) : [];
53+
}
54+
55+
function getLiveAccountId(account: Record<string, unknown>): string {
56+
return (
57+
normalizeOptionalString(account.accountId) ??
58+
normalizeOptionalString(account.id) ??
59+
normalizeOptionalString(account.name) ??
60+
"default"
61+
);
62+
}
63+
64+
function findLiveChannelAccount(params: {
65+
liveAccounts: Array<Record<string, unknown>>;
66+
accountId: string;
67+
}): Record<string, unknown> | null {
68+
return (
69+
params.liveAccounts.find((account) => getLiveAccountId(account) === params.accountId) ??
70+
(params.accountId === "default" && params.liveAccounts.length === 1
71+
? (params.liveAccounts[0] ?? null)
72+
: null)
73+
);
74+
}
75+
76+
function hasLiveCredentialAvailable(params: {
77+
liveAccounts: Array<Record<string, unknown>>;
78+
accountId: string;
79+
}): boolean {
80+
const account = findLiveChannelAccount(params);
81+
if (!account) {
82+
return false;
83+
}
84+
if (hasConfiguredUnavailableCredentialStatus(account)) {
85+
return false;
86+
}
87+
return account.running === true || account.connected === true;
88+
}
89+
90+
function markConfiguredUnavailableCredentialStatusesAvailable(
91+
account: unknown,
92+
): Record<string, unknown> {
93+
const record = { ...asRecord(account) };
94+
for (const key of ["tokenStatus", "botTokenStatus", "appTokenStatus", "signingSecretStatus"]) {
95+
if (record[key] === "configured_unavailable") {
96+
record[key] = "available";
97+
}
98+
}
99+
return record;
100+
}
101+
45102
function existsSyncMaybe(p: string | undefined): boolean | null {
46103
const path = normalizeOptionalString(p) ?? "";
47104
if (!path) {
@@ -87,6 +144,7 @@ const buildAccountNotes = (params: {
87144
plugin: ChannelPlugin;
88145
cfg: OpenClawConfig;
89146
entry: ChannelAccountRow;
147+
liveCredentialAvailable?: boolean;
90148
}) => {
91149
const { plugin, cfg, entry } = params;
92150
const notes: string[] = [];
@@ -112,7 +170,9 @@ const buildAccountNotes = (params: {
112170
) {
113171
notes.push(`signing:${snapshot.signingSecretSource}`);
114172
}
115-
if (hasConfiguredUnavailableCredentialStatus(entry.account)) {
173+
if (params.liveCredentialAvailable) {
174+
notes.push("credential available in gateway runtime");
175+
} else if (hasConfiguredUnavailableCredentialStatus(entry.account)) {
116176
notes.push("secret unavailable in this command path");
117177
}
118178
if (snapshot.baseUrl) {
@@ -192,6 +252,7 @@ export async function buildChannelsTable(
192252
showSecrets?: boolean;
193253
sourceConfig?: OpenClawConfig;
194254
includeSetupFallbackPlugins?: boolean;
255+
liveChannelStatus?: unknown;
195256
},
196257
): Promise<{
197258
rows: ChannelRow[];
@@ -234,12 +295,27 @@ export async function buildChannelsTable(
234295
}),
235296
);
236297
}
298+
const liveAccounts = getLiveChannelAccounts({
299+
liveChannelStatus: opts?.liveChannelStatus,
300+
channelId: plugin.id,
301+
});
237302

238303
const anyEnabled = accounts.some((a) => a.enabled);
239304
const enabledAccounts = accounts.filter((a) => a.enabled);
240305
const configuredAccounts = enabledAccounts.filter((a) => a.configured);
241-
const unavailableConfiguredAccounts = enabledAccounts.filter((a) =>
242-
hasConfiguredUnavailableCredentialStatus(a.account),
306+
const unavailableConfiguredAccounts = enabledAccounts.filter(
307+
(a) =>
308+
hasConfiguredUnavailableCredentialStatus(a.account) &&
309+
!hasLiveCredentialAvailable({ liveAccounts, accountId: a.accountId }),
310+
);
311+
const accountsForTokenSummary = accounts.map((entry) =>
312+
hasConfiguredUnavailableCredentialStatus(entry.account) &&
313+
hasLiveCredentialAvailable({ liveAccounts, accountId: entry.accountId })
314+
? {
315+
...entry,
316+
account: markConfiguredUnavailableCredentialStatusesAvailable(entry.account),
317+
}
318+
: entry,
243319
);
244320
const defaultEntry = accounts.find((a) => a.accountId === defaultAccountId) ?? accounts[0];
245321

@@ -256,7 +332,7 @@ export async function buildChannelsTable(
256332
const link = resolveLinkFields(summary);
257333
const missingPaths = collectMissingPaths(enabledAccounts);
258334
const tokenSummary = summarizeTokenConfig({
259-
accounts,
335+
accounts: accountsForTokenSummary,
260336
showSecrets,
261337
});
262338

@@ -383,14 +459,19 @@ export async function buildChannelsTable(
383459
title: `${label} accounts`,
384460
columns: ["Account", "Status", "Notes"],
385461
rows: configuredAccounts.map((entry) => {
386-
const notes = buildAccountNotes({ plugin, cfg, entry });
462+
const liveCredentialAvailable = hasLiveCredentialAvailable({
463+
liveAccounts,
464+
accountId: entry.accountId,
465+
});
466+
const notes = buildAccountNotes({ plugin, cfg, entry, liveCredentialAvailable });
387467
return {
388468
Account: formatAccountLabel({
389469
accountId: entry.accountId,
390470
name: entry.snapshot.name,
391471
}),
392472
Status:
393-
entry.enabled && !hasConfiguredUnavailableCredentialStatus(entry.account)
473+
entry.enabled &&
474+
(!hasConfiguredUnavailableCredentialStatus(entry.account) || liveCredentialAvailable)
394475
? "OK"
395476
: "WARN",
396477
Notes: notes.join(" · "),

src/commands/status.scan-overview.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ export async function collectStatusScanOverview(params: {
251251
showSecrets: params.showSecrets,
252252
sourceConfig,
253253
includeSetupFallbackPlugins: params.includeChannelSetupRuntimeFallback !== false,
254+
liveChannelStatus: channelsStatus,
254255
});
255256
params.progress?.tick();
256257
return { channelsStatus, channelIssues, channels };

0 commit comments

Comments
 (0)