fix(status): trust live channel credential state

This commit is contained in:
Vincent Koc
2026-05-02 14:33:23 -07:00
parent 0fbf4636f5
commit ce04ad83fa
3 changed files with 170 additions and 6 deletions

View File

@@ -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"),
}),
);
});
});

View File

@@ -42,6 +42,63 @@ type ResolvedChannelAccountRowParams = {
accountId: string;
};
function getLiveChannelAccounts(params: {
liveChannelStatus: unknown;
channelId: string;
}): Array<Record<string, unknown>> {
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, unknown>): string {
return (
normalizeOptionalString(account.accountId) ??
normalizeOptionalString(account.id) ??
normalizeOptionalString(account.name) ??
"default"
);
}
function findLiveChannelAccount(params: {
liveAccounts: Array<Record<string, unknown>>;
accountId: string;
}): Record<string, unknown> | 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<Record<string, unknown>>;
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<string, unknown> {
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(" · "),

View File

@@ -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 };