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

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { projectSafeChannelAccountSnapshotFields } from "./account-snapshot-fields.js";
describe("projectSafeChannelAccountSnapshotFields", () => {
it("omits webhook and public-key style fields from generic snapshots", () => {
const snapshot = projectSafeChannelAccountSnapshotFields({
name: "Primary",
tokenSource: "config",
tokenStatus: "configured_unavailable",
signingSecretSource: "config",
signingSecretStatus: "configured_unavailable",
webhookUrl: "https://example.com/webhook",
webhookPath: "/webhook",
audienceType: "project-number",
audience: "1234567890",
publicKey: "pk_live_123",
});
expect(snapshot).toEqual({
name: "Primary",
tokenSource: "config",
tokenStatus: "configured_unavailable",
signingSecretSource: "config",
signingSecretStatus: "configured_unavailable",
});
});
});

View File

@@ -0,0 +1,217 @@
import type { ChannelAccountSnapshot } from "./plugins/types.core.js";
// Read-only status commands project a safe subset of account fields into snapshots
// so renderers can preserve "configured but unavailable" state without touching
// strict runtime-only credential helpers.
const CREDENTIAL_STATUS_KEYS = [
"tokenStatus",
"botTokenStatus",
"appTokenStatus",
"signingSecretStatus",
"userTokenStatus",
] as const;
type CredentialStatusKey = (typeof CREDENTIAL_STATUS_KEYS)[number];
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function readTrimmedString(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function readBoolean(record: Record<string, unknown>, key: string): boolean | undefined {
return typeof record[key] === "boolean" ? record[key] : undefined;
}
function readNumber(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function readStringArray(record: Record<string, unknown>, key: string): string[] | undefined {
const value = record[key];
if (!Array.isArray(value)) {
return undefined;
}
const normalized = value
.map((entry) => (typeof entry === "string" || typeof entry === "number" ? String(entry) : ""))
.map((entry) => entry.trim())
.filter(Boolean);
return normalized.length > 0 ? normalized : undefined;
}
function readCredentialStatus(record: Record<string, unknown>, key: CredentialStatusKey) {
const value = record[key];
return value === "available" || value === "configured_unavailable" || value === "missing"
? value
: undefined;
}
export function resolveConfiguredFromCredentialStatuses(account: unknown): boolean | undefined {
const record = asRecord(account);
if (!record) {
return undefined;
}
let sawCredentialStatus = false;
for (const key of CREDENTIAL_STATUS_KEYS) {
const status = readCredentialStatus(record, key);
if (!status) {
continue;
}
sawCredentialStatus = true;
if (status !== "missing") {
return true;
}
}
return sawCredentialStatus ? false : undefined;
}
export function resolveConfiguredFromRequiredCredentialStatuses(
account: unknown,
requiredKeys: CredentialStatusKey[],
): boolean | undefined {
const record = asRecord(account);
if (!record) {
return undefined;
}
let sawCredentialStatus = false;
for (const key of requiredKeys) {
const status = readCredentialStatus(record, key);
if (!status) {
continue;
}
sawCredentialStatus = true;
if (status === "missing") {
return false;
}
}
return sawCredentialStatus ? true : undefined;
}
export function hasConfiguredUnavailableCredentialStatus(account: unknown): boolean {
const record = asRecord(account);
if (!record) {
return false;
}
return CREDENTIAL_STATUS_KEYS.some(
(key) => readCredentialStatus(record, key) === "configured_unavailable",
);
}
export function hasResolvedCredentialValue(account: unknown): boolean {
const record = asRecord(account);
if (!record) {
return false;
}
return (
["token", "botToken", "appToken", "signingSecret", "userToken"].some((key) => {
const value = record[key];
return typeof value === "string" && value.trim().length > 0;
}) || CREDENTIAL_STATUS_KEYS.some((key) => readCredentialStatus(record, key) === "available")
);
}
export function projectCredentialSnapshotFields(
account: unknown,
): Pick<
Partial<ChannelAccountSnapshot>,
| "tokenSource"
| "botTokenSource"
| "appTokenSource"
| "signingSecretSource"
| "tokenStatus"
| "botTokenStatus"
| "appTokenStatus"
| "signingSecretStatus"
| "userTokenStatus"
> {
const record = asRecord(account);
if (!record) {
return {};
}
return {
...(readTrimmedString(record, "tokenSource")
? { tokenSource: readTrimmedString(record, "tokenSource") }
: {}),
...(readTrimmedString(record, "botTokenSource")
? { botTokenSource: readTrimmedString(record, "botTokenSource") }
: {}),
...(readTrimmedString(record, "appTokenSource")
? { appTokenSource: readTrimmedString(record, "appTokenSource") }
: {}),
...(readTrimmedString(record, "signingSecretSource")
? { signingSecretSource: readTrimmedString(record, "signingSecretSource") }
: {}),
...(readCredentialStatus(record, "tokenStatus")
? { tokenStatus: readCredentialStatus(record, "tokenStatus") }
: {}),
...(readCredentialStatus(record, "botTokenStatus")
? { botTokenStatus: readCredentialStatus(record, "botTokenStatus") }
: {}),
...(readCredentialStatus(record, "appTokenStatus")
? { appTokenStatus: readCredentialStatus(record, "appTokenStatus") }
: {}),
...(readCredentialStatus(record, "signingSecretStatus")
? { signingSecretStatus: readCredentialStatus(record, "signingSecretStatus") }
: {}),
...(readCredentialStatus(record, "userTokenStatus")
? { userTokenStatus: readCredentialStatus(record, "userTokenStatus") }
: {}),
};
}
export function projectSafeChannelAccountSnapshotFields(
account: unknown,
): Partial<ChannelAccountSnapshot> {
const record = asRecord(account);
if (!record) {
return {};
}
return {
...(readTrimmedString(record, "name") ? { name: readTrimmedString(record, "name") } : {}),
...(readBoolean(record, "linked") !== undefined
? { linked: readBoolean(record, "linked") }
: {}),
...(readBoolean(record, "running") !== undefined
? { running: readBoolean(record, "running") }
: {}),
...(readBoolean(record, "connected") !== undefined
? { connected: readBoolean(record, "connected") }
: {}),
...(readNumber(record, "reconnectAttempts") !== undefined
? { reconnectAttempts: readNumber(record, "reconnectAttempts") }
: {}),
...(readTrimmedString(record, "mode") ? { mode: readTrimmedString(record, "mode") } : {}),
...(readTrimmedString(record, "dmPolicy")
? { dmPolicy: readTrimmedString(record, "dmPolicy") }
: {}),
...(readStringArray(record, "allowFrom")
? { allowFrom: readStringArray(record, "allowFrom") }
: {}),
...projectCredentialSnapshotFields(account),
...(readTrimmedString(record, "baseUrl")
? { baseUrl: readTrimmedString(record, "baseUrl") }
: {}),
...(readBoolean(record, "allowUnmentionedGroups") !== undefined
? { allowUnmentionedGroups: readBoolean(record, "allowUnmentionedGroups") }
: {}),
...(readTrimmedString(record, "cliPath")
? { cliPath: readTrimmedString(record, "cliPath") }
: {}),
...(readTrimmedString(record, "dbPath") ? { dbPath: readTrimmedString(record, "dbPath") } : {}),
...(readNumber(record, "port") !== undefined ? { port: readNumber(record, "port") } : {}),
};
}

View File

@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "../config/config.js";
import { projectSafeChannelAccountSnapshotFields } from "./account-snapshot-fields.js";
import type { ChannelAccountSnapshot } from "./plugins/types.core.js";
import type { ChannelPlugin } from "./plugins/types.plugin.js";
@@ -14,6 +15,7 @@ export function buildChannelAccountSnapshot(params: {
return {
enabled: params.enabled,
configured: params.configured,
...projectSafeChannelAccountSnapshotFields(params.account),
...described,
accountId: params.accountId,
};

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { withEnv } from "../test-utils/env.js";
import { getChannelDock } from "./dock.js";
function emptyConfig(): OpenClawConfig {
@@ -69,7 +70,7 @@ describe("channels dock", () => {
},
},
},
} as OpenClawConfig;
} as unknown as OpenClawConfig;
const accountDefault = ircDock?.config?.resolveDefaultTo?.({ cfg, accountId: "work" });
const rootDefault = ircDock?.config?.resolveDefaultTo?.({ cfg, accountId: "missing" });
@@ -99,4 +100,73 @@ describe("channels dock", () => {
expect(formatted).toEqual(["user", "foo", "plain"]);
});
it("telegram dock config readers preserve omitted-account fallback semantics", () => {
withEnv({ TELEGRAM_BOT_TOKEN: "tok-env" }, () => {
const telegramDock = getChannelDock("telegram");
const cfg = {
channels: {
telegram: {
allowFrom: ["top-owner"],
defaultTo: "@top-target",
accounts: {
work: {
botToken: "tok-work",
allowFrom: ["work-owner"],
defaultTo: "@work-target",
},
},
},
},
} as unknown as OpenClawConfig;
expect(telegramDock?.config?.resolveAllowFrom?.({ cfg })).toEqual(["top-owner"]);
expect(telegramDock?.config?.resolveDefaultTo?.({ cfg })).toBe("@top-target");
});
});
it("slack dock config readers stay read-only when tokens are unresolved SecretRefs", () => {
const slackDock = getChannelDock("slack");
const cfg = {
channels: {
slack: {
botToken: {
source: "env",
provider: "default",
id: "SLACK_BOT_TOKEN",
},
appToken: {
source: "env",
provider: "default",
id: "SLACK_APP_TOKEN",
},
defaultTo: "channel:C111",
dm: { allowFrom: ["U123"] },
channels: {
C111: { requireMention: false },
},
replyToMode: "all",
},
},
} as unknown as OpenClawConfig;
expect(slackDock?.config?.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual(["U123"]);
expect(slackDock?.config?.resolveDefaultTo?.({ cfg, accountId: "default" })).toBe(
"channel:C111",
);
expect(
slackDock?.threading?.resolveReplyToMode?.({
cfg,
accountId: "default",
chatType: "channel",
}),
).toBe("all");
expect(
slackDock?.groups?.resolveRequireMention?.({
cfg,
accountId: "default",
groupId: "C111",
}),
).toBe(false);
});
});

View File

@@ -2,7 +2,7 @@ import {
resolveChannelGroupRequireMention,
resolveChannelGroupToolsPolicy,
} from "../config/group-policy.js";
import { resolveDiscordAccount } from "../discord/accounts.js";
import { inspectDiscordAccount } from "../discord/account-inspect.js";
import {
formatTrimmedAllowFromEntries,
formatWhatsAppConfigAllowFromEntries,
@@ -14,9 +14,10 @@ import {
import { requireActivePluginRegistry } from "../plugins/runtime.js";
import { normalizeAccountId } from "../routing/session-key.js";
import { resolveSignalAccount } from "../signal/accounts.js";
import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts.js";
import { inspectSlackAccount } from "../slack/account-inspect.js";
import { resolveSlackReplyToMode } from "../slack/accounts.js";
import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
import { resolveTelegramAccount } from "../telegram/accounts.js";
import { inspectTelegramAccount } from "../telegram/account-inspect.js";
import { normalizeE164 } from "../utils.js";
import {
resolveDiscordGroupRequireMention,
@@ -246,13 +247,13 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000,
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
stringifyAllowFrom(resolveTelegramAccount({ cfg, accountId }).config.allowFrom ?? []),
stringifyAllowFrom(inspectTelegramAccount({ cfg, accountId }).config.allowFrom ?? []),
formatAllowFrom: ({ allowFrom }) =>
trimAllowFromEntries(allowFrom)
.map((entry) => entry.replace(/^(telegram|tg):/i, ""))
.map((entry) => entry.toLowerCase()),
resolveDefaultTo: ({ cfg, accountId }) => {
const val = resolveTelegramAccount({ cfg, accountId }).config.defaultTo;
const val = inspectTelegramAccount({ cfg, accountId }).config.defaultTo;
return val != null ? String(val) : undefined;
},
},
@@ -335,14 +336,14 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
},
config: {
resolveAllowFrom: ({ cfg, accountId }) => {
const account = resolveDiscordAccount({ cfg, accountId });
const account = inspectDiscordAccount({ cfg, accountId });
return (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map((entry) =>
String(entry),
);
},
formatAllowFrom: ({ allowFrom }) => formatDiscordAllowFrom(allowFrom),
resolveDefaultTo: ({ cfg, accountId }) =>
resolveDiscordAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
inspectDiscordAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
},
groups: {
resolveRequireMention: resolveDiscordGroupRequireMention,
@@ -477,14 +478,14 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
streaming: DEFAULT_BLOCK_STREAMING_COALESCE,
config: {
resolveAllowFrom: ({ cfg, accountId }) => {
const account = resolveSlackAccount({ cfg, accountId });
const account = inspectSlackAccount({ cfg, accountId });
return (account.config.allowFrom ?? account.dm?.allowFrom ?? []).map((entry) =>
String(entry),
);
},
formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom),
resolveDefaultTo: ({ cfg, accountId }) =>
resolveSlackAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
inspectSlackAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
},
groups: {
resolveRequireMention: resolveSlackGroupRequireMention,
@@ -495,7 +496,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
},
threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
resolveSlackReplyToMode(inspectSlackAccount({ cfg, accountId }), chatType),
allowExplicitReplyTagsWhenOff: false,
buildToolContext: (params) => buildSlackThreadingToolContext(params),
},

View File

@@ -1,7 +1,7 @@
import type { OpenClawConfig } from "../../config/types.js";
import { resolveDiscordAccount } from "../../discord/accounts.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
import { resolveTelegramAccount } from "../../telegram/accounts.js";
import { inspectDiscordAccount } from "../../discord/account-inspect.js";
import { inspectSlackAccount } from "../../slack/account-inspect.js";
import { inspectTelegramAccount } from "../../telegram/account-inspect.js";
import { resolveWhatsAppAccount } from "../../web/accounts.js";
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
import { normalizeSlackMessagingTarget } from "./normalize/slack.js";
@@ -75,7 +75,7 @@ function toDirectoryEntries(kind: "user" | "group", ids: string[]): ChannelDirec
export async function listSlackDirectoryPeersFromConfig(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const account = inspectSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const ids = new Set<string>();
addAllowFromAndDmsIds(ids, account.config.allowFrom ?? account.dm?.allowFrom, account.config.dms);
@@ -98,7 +98,7 @@ export async function listSlackDirectoryPeersFromConfig(
export async function listSlackDirectoryGroupsFromConfig(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const account = inspectSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const ids = Object.keys(account.config.channels ?? {})
.map((raw) => raw.trim())
.filter(Boolean)
@@ -110,7 +110,7 @@ export async function listSlackDirectoryGroupsFromConfig(
export async function listDiscordDirectoryPeersFromConfig(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
const ids = new Set<string>();
addAllowFromAndDmsIds(
@@ -139,7 +139,7 @@ export async function listDiscordDirectoryPeersFromConfig(
export async function listDiscordDirectoryGroupsFromConfig(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
const ids = new Set<string>();
for (const guild of Object.values(account.config.guilds ?? {})) {
addTrimmedEntries(ids, Object.keys(guild.channels ?? {}));
@@ -159,7 +159,7 @@ export async function listDiscordDirectoryGroupsFromConfig(
export async function listTelegramDirectoryPeersFromConfig(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const account = resolveTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
const account = inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
const raw = [
...(account.config.allowFrom ?? []).map((entry) => String(entry)),
...Object.keys(account.config.dms ?? {}),
@@ -190,7 +190,7 @@ export async function listTelegramDirectoryPeersFromConfig(
export async function listTelegramDirectoryGroupsFromConfig(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const account = resolveTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
const account = inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
const ids = Object.keys(account.config.groups ?? {})
.map((id) => id.trim())
.filter((id) => Boolean(id) && id !== "*");

View File

@@ -10,7 +10,7 @@ import type {
GroupToolPolicyConfig,
} from "../../config/types.tools.js";
import { normalizeAtHashSlug, normalizeHyphenSlug } from "../../shared/string-normalization.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
import { inspectSlackAccount } from "../../slack/account-inspect.js";
import type { ChannelGroupContext } from "./types.js";
type GroupMentionParams = ChannelGroupContext;
@@ -130,7 +130,7 @@ type ChannelGroupPolicyChannel =
function resolveSlackChannelPolicyEntry(
params: GroupMentionParams,
): SlackChannelPolicyEntry | undefined {
const account = resolveSlackAccount({
const account = inspectSlackAccount({
cfg: params.cfg,
accountId: params.accountId,
});

View File

@@ -1,6 +1,7 @@
import type { OpenClawConfig } from "../../../config/config.js";
import type { DiscordGuildEntry } from "../../../config/types.discord.js";
import { hasConfiguredSecretInput } from "../../../config/types.secrets.js";
import { inspectDiscordAccount } from "../../../discord/account-inspect.js";
import {
listDiscordAccountIds,
resolveDefaultDiscordAccountId,
@@ -148,8 +149,8 @@ export const discordOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listDiscordAccountIds(cfg).some((accountId) => {
const account = resolveDiscordAccount({ cfg, accountId });
return Boolean(account.token) || hasConfiguredSecretInput(account.config.token);
const account = inspectDiscordAccount({ cfg, accountId });
return account.configured;
});
return {
channel,

View File

@@ -1,6 +1,7 @@
import type { OpenClawConfig } from "../../../config/config.js";
import { hasConfiguredSecretInput } from "../../../config/types.secrets.js";
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
import { inspectSlackAccount } from "../../../slack/account-inspect.js";
import {
listSlackAccountIds,
resolveDefaultSlackAccountId,
@@ -199,12 +200,8 @@ export const slackOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listSlackAccountIds(cfg).some((accountId) => {
const account = resolveSlackAccount({ cfg, accountId });
const hasBotToken =
Boolean(account.botToken) || hasConfiguredSecretInput(account.config.botToken);
const hasAppToken =
Boolean(account.appToken) || hasConfiguredSecretInput(account.config.appToken);
return hasBotToken && hasAppToken;
const account = inspectSlackAccount({ cfg, accountId });
return account.configured;
});
return {
channel,

View File

@@ -2,6 +2,7 @@ import { formatCliCommand } from "../../../cli/command-format.js";
import type { OpenClawConfig } from "../../../config/config.js";
import { hasConfiguredSecretInput } from "../../../config/types.secrets.js";
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
import { inspectTelegramAccount } from "../../../telegram/account-inspect.js";
import {
listTelegramAccountIds,
resolveDefaultTelegramAccountId,
@@ -153,12 +154,8 @@ export const telegramOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listTelegramAccountIds(cfg).some((accountId) => {
const account = resolveTelegramAccount({ cfg, accountId });
return (
Boolean(account.token) ||
Boolean(account.config.tokenFile?.trim()) ||
hasConfiguredSecretInput(account.config.botToken)
);
const account = inspectTelegramAccount({ cfg, accountId });
return account.configured;
});
return {
channel,

View File

@@ -18,6 +18,7 @@ import {
createOutboundTestPlugin,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js";
import { resolveChannelConfigWrites } from "./config-writes.js";
import {
@@ -409,6 +410,72 @@ describe("directory (config-backed)", () => {
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
});
it("keeps Telegram config-backed directory fallback semantics when accountId is omitted", async () => {
await withEnvAsync({ TELEGRAM_BOT_TOKEN: "tok-env" }, async () => {
const cfg = {
channels: {
telegram: {
allowFrom: ["alice"],
groups: { "-1001": {} },
accounts: {
work: {
botToken: "tok-work",
allowFrom: ["bob"],
groups: { "-2002": {} },
},
},
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]);
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
});
});
it("keeps config-backed directories readable when channel tokens are unresolved SecretRefs", async () => {
const envSecret = {
source: "env",
provider: "default",
id: "MISSING_TEST_SECRET",
} as const;
const cfg = {
channels: {
slack: {
botToken: envSecret,
appToken: envSecret,
dm: { allowFrom: ["U123"] },
channels: { C111: {} },
},
discord: {
token: envSecret,
dm: { allowFrom: ["<@111>"] },
guilds: {
"123": {
channels: {
"555": {},
},
},
},
},
telegram: {
botToken: envSecret,
allowFrom: ["alice"],
groups: { "-1001": {} },
},
},
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
await expectDirectoryIds(listSlackDirectoryPeersFromConfig, cfg, ["user:u123"]);
await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]);
await expectDirectoryIds(listDiscordDirectoryPeersFromConfig, cfg, ["user:111"]);
await expectDirectoryIds(listDiscordDirectoryGroupsFromConfig, cfg, ["channel:555"]);
await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]);
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
});
it("lists WhatsApp peers/groups from config", async () => {
const cfg = {
channels: {

View File

@@ -1,7 +1,70 @@
import type { OpenClawConfig } from "../../config/config.js";
import { projectSafeChannelAccountSnapshotFields } from "../account-snapshot-fields.js";
import { inspectReadOnlyChannelAccount } from "../read-only-account-inspect.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "./types.js";
// Channel docking: status snapshots flow through plugin.status hooks here.
async function buildSnapshotFromAccount<ResolvedAccount>(params: {
plugin: ChannelPlugin<ResolvedAccount>;
cfg: OpenClawConfig;
accountId: string;
account: ResolvedAccount;
runtime?: ChannelAccountSnapshot;
probe?: unknown;
audit?: unknown;
}): Promise<ChannelAccountSnapshot> {
if (params.plugin.status?.buildAccountSnapshot) {
return await params.plugin.status.buildAccountSnapshot({
account: params.account,
cfg: params.cfg,
runtime: params.runtime,
probe: params.probe,
audit: params.audit,
});
}
const enabled = params.plugin.config.isEnabled
? params.plugin.config.isEnabled(params.account, params.cfg)
: params.account && typeof params.account === "object"
? (params.account as { enabled?: boolean }).enabled
: undefined;
const configured =
params.account && typeof params.account === "object" && "configured" in params.account
? (params.account as { configured?: boolean }).configured
: params.plugin.config.isConfigured
? await params.plugin.config.isConfigured(params.account, params.cfg)
: undefined;
return {
accountId: params.accountId,
enabled,
configured,
...projectSafeChannelAccountSnapshotFields(params.account),
};
}
export async function buildReadOnlySourceChannelAccountSnapshot<ResolvedAccount>(params: {
plugin: ChannelPlugin<ResolvedAccount>;
cfg: OpenClawConfig;
accountId: string;
runtime?: ChannelAccountSnapshot;
probe?: unknown;
audit?: unknown;
}): Promise<ChannelAccountSnapshot | null> {
const inspectedAccount =
params.plugin.config.inspectAccount?.(params.cfg, params.accountId) ??
inspectReadOnlyChannelAccount({
channelId: params.plugin.id,
cfg: params.cfg,
accountId: params.accountId,
});
if (!inspectedAccount) {
return null;
}
return await buildSnapshotFromAccount({
...params,
account: inspectedAccount as ResolvedAccount,
});
}
export async function buildChannelAccountSnapshot<ResolvedAccount>(params: {
plugin: ChannelPlugin<ResolvedAccount>;
cfg: OpenClawConfig;
@@ -10,27 +73,17 @@ export async function buildChannelAccountSnapshot<ResolvedAccount>(params: {
probe?: unknown;
audit?: unknown;
}): Promise<ChannelAccountSnapshot> {
const account = params.plugin.config.resolveAccount(params.cfg, params.accountId);
if (params.plugin.status?.buildAccountSnapshot) {
return await params.plugin.status.buildAccountSnapshot({
account,
const inspectedAccount =
params.plugin.config.inspectAccount?.(params.cfg, params.accountId) ??
inspectReadOnlyChannelAccount({
channelId: params.plugin.id,
cfg: params.cfg,
runtime: params.runtime,
probe: params.probe,
audit: params.audit,
accountId: params.accountId,
});
}
const enabled = params.plugin.config.isEnabled
? params.plugin.config.isEnabled(account, params.cfg)
: account && typeof account === "object"
? (account as { enabled?: boolean }).enabled
: undefined;
const configured = params.plugin.config.isConfigured
? await params.plugin.config.isConfigured(account, params.cfg)
: undefined;
return {
accountId: params.accountId,
enabled,
configured,
};
const account = (inspectedAccount ??
params.plugin.config.resolveAccount(params.cfg, params.accountId)) as ResolvedAccount;
return await buildSnapshotFromAccount({
...params,
account,
});
}

View File

@@ -52,6 +52,7 @@ export type ChannelSetupAdapter = {
export type ChannelConfigAdapter<ResolvedAccount> = {
listAccountIds: (cfg: OpenClawConfig) => string[];
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount;
inspectAccount?: (cfg: OpenClawConfig, accountId?: string | null) => unknown;
defaultAccountId?: (cfg: OpenClawConfig) => string;
setAccountEnabled?: (params: {
cfg: OpenClawConfig;

View File

@@ -129,6 +129,12 @@ export type ChannelAccountSnapshot = {
tokenSource?: string;
botTokenSource?: string;
appTokenSource?: string;
signingSecretSource?: string;
tokenStatus?: string;
botTokenStatus?: string;
appTokenStatus?: string;
signingSecretStatus?: string;
userTokenStatus?: string;
credentialSource?: string;
secretSource?: string;
audienceType?: string;

View File

@@ -0,0 +1,39 @@
import type { OpenClawConfig } from "../config/config.js";
import { inspectDiscordAccount, type InspectedDiscordAccount } from "../discord/account-inspect.js";
import { inspectSlackAccount, type InspectedSlackAccount } from "../slack/account-inspect.js";
import {
inspectTelegramAccount,
type InspectedTelegramAccount,
} from "../telegram/account-inspect.js";
import type { ChannelId } from "./plugins/types.js";
export type ReadOnlyInspectedAccount =
| InspectedDiscordAccount
| InspectedSlackAccount
| InspectedTelegramAccount;
export function inspectReadOnlyChannelAccount(params: {
channelId: ChannelId;
cfg: OpenClawConfig;
accountId?: string | null;
}): ReadOnlyInspectedAccount | null {
if (params.channelId === "discord") {
return inspectDiscordAccount({
cfg: params.cfg,
accountId: params.accountId,
});
}
if (params.channelId === "slack") {
return inspectSlackAccount({
cfg: params.cfg,
accountId: params.accountId,
});
}
if (params.channelId === "telegram") {
return inspectTelegramAccount({
cfg: params.cfg,
accountId: params.accountId,
});
}
return null;
}