CLI: make read-only SecretRef status flows degrade safely (#37023)

* CLI: add read-only SecretRef inspection

* CLI: fix read-only SecretRef status regressions

* CLI: preserve read-only SecretRef status fallbacks

* Docs: document read-only channel inspection hook

* CLI: preserve audit coverage for read-only SecretRefs

* CLI: fix read-only status account selection

* CLI: fix targeted gateway fallback analysis

* CLI: fix Slack HTTP read-only inspection

* CLI: align audit credential status checks

* CLI: restore Telegram read-only fallback semantics
This commit is contained in:
Josh Avant
2026-03-05 23:07:13 -06:00
committed by GitHub
parent 8d4a2f2c59
commit 0e4245063f
58 changed files with 3422 additions and 215 deletions

View File

@@ -50,6 +50,12 @@ function makeSlackPlugin(params?: { botToken?: string; appToken?: string }): Cha
config: {
listAccountIds: () => ["primary"],
defaultAccountId: () => "primary",
inspectAccount: () => ({
name: "Primary",
enabled: true,
botToken: params?.botToken ?? "bot-token",
appToken: params?.appToken ?? "app-token",
}),
resolveAccount: () => ({
name: "Primary",
enabled: true,
@@ -65,6 +71,196 @@ function makeSlackPlugin(params?: { botToken?: string; appToken?: string }): Cha
};
}
function makeUnavailableSlackPlugin(): ChannelPlugin {
return {
id: "slack",
meta: {
id: "slack",
label: "Slack",
selectionLabel: "Slack",
docsPath: "/channels/slack",
blurb: "test",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["primary"],
defaultAccountId: () => "primary",
inspectAccount: () => ({
name: "Primary",
enabled: true,
configured: true,
botToken: "",
appToken: "",
botTokenSource: "config",
appTokenSource: "config",
botTokenStatus: "configured_unavailable",
appTokenStatus: "configured_unavailable",
}),
resolveAccount: () => ({
name: "Primary",
enabled: true,
configured: true,
botToken: "",
appToken: "",
botTokenSource: "config",
appTokenSource: "config",
botTokenStatus: "configured_unavailable",
appTokenStatus: "configured_unavailable",
}),
isConfigured: () => true,
isEnabled: () => true,
},
actions: {
listActions: () => ["send"],
},
};
}
function makeSourceAwareUnavailablePlugin(): ChannelPlugin {
return {
id: "slack",
meta: {
id: "slack",
label: "Slack",
selectionLabel: "Slack",
docsPath: "/channels/slack",
blurb: "test",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["primary"],
defaultAccountId: () => "primary",
inspectAccount: (cfg) =>
(cfg as { marker?: string }).marker === "source"
? {
name: "Primary",
enabled: true,
configured: true,
botToken: "",
appToken: "",
botTokenSource: "config",
appTokenSource: "config",
botTokenStatus: "configured_unavailable",
appTokenStatus: "configured_unavailable",
}
: {
name: "Primary",
enabled: true,
configured: false,
botToken: "",
appToken: "",
botTokenSource: "none",
appTokenSource: "none",
},
resolveAccount: () => ({
name: "Primary",
enabled: true,
botToken: "",
appToken: "",
}),
isConfigured: (account) => Boolean((account as { configured?: boolean }).configured),
isEnabled: () => true,
},
actions: {
listActions: () => ["send"],
},
};
}
function makeSourceUnavailableResolvedAvailablePlugin(): ChannelPlugin {
return {
id: "discord",
meta: {
id: "discord",
label: "Discord",
selectionLabel: "Discord",
docsPath: "/channels/discord",
blurb: "test",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["primary"],
defaultAccountId: () => "primary",
inspectAccount: (cfg) =>
(cfg as { marker?: string }).marker === "source"
? {
name: "Primary",
enabled: true,
configured: true,
tokenSource: "config",
tokenStatus: "configured_unavailable",
}
: {
name: "Primary",
enabled: true,
configured: true,
tokenSource: "config",
tokenStatus: "available",
},
resolveAccount: () => ({
name: "Primary",
enabled: true,
configured: true,
tokenSource: "config",
tokenStatus: "available",
}),
isConfigured: (account) => Boolean((account as { configured?: boolean }).configured),
isEnabled: () => true,
},
actions: {
listActions: () => ["send"],
},
};
}
function makeHttpSlackUnavailablePlugin(): ChannelPlugin {
return {
id: "slack",
meta: {
id: "slack",
label: "Slack",
selectionLabel: "Slack",
docsPath: "/channels/slack",
blurb: "test",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["primary"],
defaultAccountId: () => "primary",
inspectAccount: () => ({
accountId: "primary",
name: "Primary",
enabled: true,
configured: true,
mode: "http",
botToken: "xoxb-http",
signingSecret: "",
botTokenSource: "config",
signingSecretSource: "config",
botTokenStatus: "available",
signingSecretStatus: "configured_unavailable",
}),
resolveAccount: () => ({
name: "Primary",
enabled: true,
configured: true,
mode: "http",
botToken: "xoxb-http",
signingSecret: "",
botTokenSource: "config",
signingSecretSource: "config",
botTokenStatus: "available",
signingSecretStatus: "configured_unavailable",
}),
isConfigured: () => true,
isEnabled: () => true,
},
actions: {
listActions: () => ["send"],
},
};
}
function makeTokenPlugin(): ChannelPlugin {
return {
id: "token-only",
@@ -122,6 +318,90 @@ describe("buildChannelsTable - mattermost token summary", () => {
expect(slackRow?.detail).toContain("need bot+app");
});
it("reports configured-but-unavailable Slack credentials as warn", async () => {
vi.mocked(listChannelPlugins).mockReturnValue([makeUnavailableSlackPlugin()]);
const table = await buildChannelsTable({ channels: {} } as never, {
showSecrets: false,
});
const slackRow = table.rows.find((row) => row.id === "slack");
expect(slackRow).toBeDefined();
expect(slackRow?.state).toBe("warn");
expect(slackRow?.detail).toContain("unavailable in this command path");
});
it("preserves unavailable credential state from the source config snapshot", async () => {
vi.mocked(listChannelPlugins).mockReturnValue([makeSourceAwareUnavailablePlugin()]);
const table = await buildChannelsTable({ marker: "resolved", channels: {} } as never, {
showSecrets: false,
sourceConfig: { marker: "source", channels: {} } as never,
});
const slackRow = table.rows.find((row) => row.id === "slack");
expect(slackRow).toBeDefined();
expect(slackRow?.state).toBe("warn");
expect(slackRow?.detail).toContain("unavailable in this command path");
const slackDetails = table.details.find((detail) => detail.title === "Slack accounts");
expect(slackDetails).toBeDefined();
expect(slackDetails?.rows).toEqual([
{
Account: "primary (Primary)",
Notes: "bot:config · app:config · secret unavailable in this command path",
Status: "WARN",
},
]);
});
it("treats status-only available credentials as resolved", async () => {
vi.mocked(listChannelPlugins).mockReturnValue([makeSourceUnavailableResolvedAvailablePlugin()]);
const table = await buildChannelsTable({ marker: "resolved", channels: {} } as never, {
showSecrets: false,
sourceConfig: { marker: "source", channels: {} } as never,
});
const discordRow = table.rows.find((row) => row.id === "discord");
expect(discordRow).toBeDefined();
expect(discordRow?.state).toBe("ok");
expect(discordRow?.detail).toBe("configured");
const discordDetails = table.details.find((detail) => detail.title === "Discord accounts");
expect(discordDetails).toBeDefined();
expect(discordDetails?.rows).toEqual([
{
Account: "primary (Primary)",
Notes: "token:config",
Status: "OK",
},
]);
});
it("treats Slack HTTP signing-secret availability as required config", async () => {
vi.mocked(listChannelPlugins).mockReturnValue([makeHttpSlackUnavailablePlugin()]);
const table = await buildChannelsTable({ channels: {} } as never, {
showSecrets: false,
});
const slackRow = table.rows.find((row) => row.id === "slack");
expect(slackRow).toBeDefined();
expect(slackRow?.state).toBe("warn");
expect(slackRow?.detail).toContain("configured http credentials unavailable");
const slackDetails = table.details.find((detail) => detail.title === "Slack accounts");
expect(slackDetails).toBeDefined();
expect(slackDetails?.rows).toEqual([
{
Account: "primary (Primary)",
Notes: "bot:config · signing:config · secret unavailable in this command path",
Status: "WARN",
},
]);
});
it("still reports single-token channels as ok", async () => {
vi.mocked(listChannelPlugins).mockReturnValue([makeTokenPlugin()]);

View File

@@ -1,4 +1,8 @@
import fs from "node:fs";
import {
hasConfiguredUnavailableCredentialStatus,
hasResolvedCredentialValue,
} from "../../channels/account-snapshot-fields.js";
import {
buildChannelAccountSnapshot,
formatChannelAllowFrom,
@@ -12,6 +16,7 @@ import type {
ChannelId,
ChannelPlugin,
} from "../../channels/plugins/types.js";
import { inspectReadOnlyChannelAccount } from "../../channels/read-only-account-inspect.js";
import type { OpenClawConfig } from "../../config/config.js";
import { sha256HexPrefix } from "../../logging/redact-identifier.js";
import { formatTimeAgo } from "./format.js";
@@ -32,6 +37,13 @@ type ChannelAccountRow = {
snapshot: ChannelAccountSnapshot;
};
type ResolvedChannelAccountRowParams = {
plugin: ChannelPlugin;
cfg: OpenClawConfig;
sourceConfig: OpenClawConfig;
accountId: string;
};
const asRecord = (value: unknown): Record<string, unknown> =>
value && typeof value === "object" ? (value as Record<string, unknown>) : {};
@@ -79,6 +91,61 @@ function formatTokenHint(token: string, opts: { showSecrets: boolean }): string
return `${head}${tail} · len ${t.length}`;
}
function inspectChannelAccount(plugin: ChannelPlugin, cfg: OpenClawConfig, accountId: string) {
return (
plugin.config.inspectAccount?.(cfg, accountId) ??
inspectReadOnlyChannelAccount({
channelId: plugin.id,
cfg,
accountId,
})
);
}
async function resolveChannelAccountRow(
params: ResolvedChannelAccountRowParams,
): Promise<ChannelAccountRow> {
const { plugin, cfg, sourceConfig, accountId } = params;
const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId);
const resolvedInspectedAccount = inspectChannelAccount(plugin, cfg, accountId);
const resolvedInspection = resolvedInspectedAccount as {
enabled?: boolean;
configured?: boolean;
} | null;
const sourceInspection = sourceInspectedAccount as {
enabled?: boolean;
configured?: boolean;
} | null;
const resolvedAccount = resolvedInspectedAccount ?? plugin.config.resolveAccount(cfg, accountId);
const useSourceUnavailableAccount = Boolean(
sourceInspectedAccount &&
hasConfiguredUnavailableCredentialStatus(sourceInspectedAccount) &&
(!hasResolvedCredentialValue(resolvedAccount) ||
(sourceInspection?.configured === true && resolvedInspection?.configured === false)),
);
const account = useSourceUnavailableAccount ? sourceInspectedAccount : resolvedAccount;
const selectedInspection = useSourceUnavailableAccount ? sourceInspection : resolvedInspection;
const enabled =
selectedInspection?.enabled ?? resolveChannelAccountEnabled({ plugin, account, cfg });
const configured =
selectedInspection?.configured ??
(await resolveChannelAccountConfigured({
plugin,
account,
cfg,
readAccountConfiguredField: true,
}));
const snapshot = buildChannelAccountSnapshot({
plugin,
cfg,
accountId,
account,
enabled,
configured,
});
return { accountId, account, enabled, configured, snapshot };
}
const formatAccountLabel = (params: { accountId: string; name?: string }) => {
const base = params.accountId || "default";
if (params.name?.trim()) {
@@ -110,6 +177,12 @@ const buildAccountNotes = (params: {
if (snapshot.appTokenSource && snapshot.appTokenSource !== "none") {
notes.push(`app:${snapshot.appTokenSource}`);
}
if (snapshot.signingSecretSource && snapshot.signingSecretSource !== "none") {
notes.push(`signing:${snapshot.signingSecretSource}`);
}
if (hasConfiguredUnavailableCredentialStatus(entry.account)) {
notes.push("secret unavailable in this command path");
}
if (snapshot.baseUrl) {
notes.push(snapshot.baseUrl);
}
@@ -191,13 +264,90 @@ function summarizeTokenConfig(params: {
const accountRecs = enabled.map((a) => asRecord(a.account));
const hasBotTokenField = accountRecs.some((r) => "botToken" in r);
const hasAppTokenField = accountRecs.some((r) => "appToken" in r);
const hasSigningSecretField = accountRecs.some(
(r) => "signingSecret" in r || "signingSecretSource" in r || "signingSecretStatus" in r,
);
const hasTokenField = accountRecs.some((r) => "token" in r);
if (!hasBotTokenField && !hasAppTokenField && !hasTokenField) {
if (!hasBotTokenField && !hasAppTokenField && !hasSigningSecretField && !hasTokenField) {
return { state: null, detail: null };
}
const accountIsHttpMode = (rec: Record<string, unknown>) =>
typeof rec.mode === "string" && rec.mode.trim() === "http";
const hasCredentialAvailable = (
rec: Record<string, unknown>,
valueKey: string,
statusKey: string,
) => {
const value = rec[valueKey];
if (typeof value === "string" && value.trim()) {
return true;
}
return rec[statusKey] === "available";
};
if (
hasBotTokenField &&
hasSigningSecretField &&
enabled.every((a) => accountIsHttpMode(asRecord(a.account)))
) {
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
const ready = enabled.filter((a) => {
const rec = asRecord(a.account);
return (
hasCredentialAvailable(rec, "botToken", "botTokenStatus") &&
hasCredentialAvailable(rec, "signingSecret", "signingSecretStatus")
);
});
const partial = enabled.filter((a) => {
const rec = asRecord(a.account);
const hasBot = hasCredentialAvailable(rec, "botToken", "botTokenStatus");
const hasSigning = hasCredentialAvailable(rec, "signingSecret", "signingSecretStatus");
return (hasBot && !hasSigning) || (!hasBot && hasSigning);
});
if (unavailable.length > 0) {
return {
state: "warn",
detail: `configured http credentials unavailable in this command path · accounts ${unavailable.length}`,
};
}
if (partial.length > 0) {
return {
state: "warn",
detail: `partial credentials (need bot+signing) · accounts ${partial.length}`,
};
}
if (ready.length === 0) {
return { state: "setup", detail: "no credentials (need bot+signing)" };
}
const botSources = summarizeSources(ready.map((a) => a.snapshot.botTokenSource ?? "none"));
const signingSources = summarizeSources(
ready.map((a) => a.snapshot.signingSecretSource ?? "none"),
);
const sample = ready[0]?.account ? asRecord(ready[0].account) : {};
const botToken = typeof sample.botToken === "string" ? sample.botToken : "";
const signingSecret = typeof sample.signingSecret === "string" ? sample.signingSecret : "";
const botHint = botToken.trim()
? formatTokenHint(botToken, { showSecrets: params.showSecrets })
: "";
const signingHint = signingSecret.trim()
? formatTokenHint(signingSecret, { showSecrets: params.showSecrets })
: "";
const hint =
botHint || signingHint ? ` (bot ${botHint || "?"}, signing ${signingHint || "?"})` : "";
return {
state: "ok",
detail: `credentials ok (bot ${botSources.label}, signing ${signingSources.label})${hint} · accounts ${ready.length}/${enabled.length || 1}`,
};
}
if (hasBotTokenField && hasAppTokenField) {
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
const ready = enabled.filter((a) => {
const rec = asRecord(a.account);
const bot = typeof rec.botToken === "string" ? rec.botToken.trim() : "";
@@ -220,6 +370,13 @@ function summarizeTokenConfig(params: {
};
}
if (unavailable.length > 0) {
return {
state: "warn",
detail: `configured tokens unavailable in this command path · accounts ${unavailable.length}`,
};
}
if (ready.length === 0) {
return { state: "setup", detail: "no tokens (need bot+app)" };
}
@@ -245,12 +402,20 @@ function summarizeTokenConfig(params: {
}
if (hasBotTokenField) {
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
const ready = enabled.filter((a) => {
const rec = asRecord(a.account);
const bot = typeof rec.botToken === "string" ? rec.botToken.trim() : "";
return Boolean(bot);
});
if (unavailable.length > 0) {
return {
state: "warn",
detail: `configured bot token unavailable in this command path · accounts ${unavailable.length}`,
};
}
if (ready.length === 0) {
return { state: "setup", detail: "no bot token" };
}
@@ -268,10 +433,17 @@ function summarizeTokenConfig(params: {
};
}
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
const ready = enabled.filter((a) => {
const rec = asRecord(a.account);
return typeof rec.token === "string" ? Boolean(rec.token.trim()) : false;
});
if (unavailable.length > 0) {
return {
state: "warn",
detail: `configured token unavailable in this command path · accounts ${unavailable.length}`,
};
}
if (ready.length === 0) {
return { state: "setup", detail: "no token" };
}
@@ -292,7 +464,7 @@ function summarizeTokenConfig(params: {
// Keep this generic: channel-specific rules belong in the channel plugin.
export async function buildChannelsTable(
cfg: OpenClawConfig,
opts?: { showSecrets?: boolean },
opts?: { showSecrets?: boolean; sourceConfig?: OpenClawConfig },
): Promise<{
rows: ChannelRow[];
details: Array<{
@@ -319,29 +491,24 @@ export async function buildChannelsTable(
const resolvedAccountIds = accountIds.length > 0 ? accountIds : [defaultAccountId];
const accounts: ChannelAccountRow[] = [];
const sourceConfig = opts?.sourceConfig ?? cfg;
for (const accountId of resolvedAccountIds) {
const account = plugin.config.resolveAccount(cfg, accountId);
const enabled = resolveChannelAccountEnabled({ plugin, account, cfg });
const configured = await resolveChannelAccountConfigured({
plugin,
account,
cfg,
readAccountConfiguredField: true,
});
const snapshot = buildChannelAccountSnapshot({
plugin,
cfg,
accountId,
account,
enabled,
configured,
});
accounts.push({ accountId, account, enabled, configured, snapshot });
accounts.push(
await resolveChannelAccountRow({
plugin,
cfg,
sourceConfig,
accountId,
}),
);
}
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 defaultEntry = accounts.find((a) => a.accountId === defaultAccountId) ?? accounts[0];
const summary = plugin.status?.buildChannelSummary
@@ -379,6 +546,9 @@ export async function buildChannelsTable(
if (issues.length > 0) {
return "warn";
}
if (unavailableConfiguredAccounts.length > 0) {
return "warn";
}
if (link.linked === false) {
return "setup";
}
@@ -423,6 +593,13 @@ export async function buildChannelsTable(
return extra.length > 0 ? `${base} · ${extra.join(" · ")}` : base;
}
if (unavailableConfiguredAccounts.length > 0) {
if (tokenSummary.detail?.includes("unavailable")) {
return tokenSummary.detail;
}
return `configured credentials unavailable in this command path · accounts ${unavailableConfiguredAccounts.length}`;
}
if (tokenSummary.detail) {
return tokenSummary.detail;
}
@@ -461,7 +638,10 @@ export async function buildChannelsTable(
accountId: entry.accountId,
name: entry.snapshot.name,
}),
Status: entry.enabled ? "OK" : "WARN",
Status:
entry.enabled && !hasConfiguredUnavailableCredentialStatus(entry.account)
? "OK"
: "WARN",
Notes: notes.join(" · "),
};
}),