From 0e4245063f200b84854b2513224068a7d9be7c26 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:07:13 -0600 Subject: [PATCH] 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 --- docs/cli/channels.md | 2 + docs/cli/status.md | 2 + docs/gateway/doctor.md | 8 + docs/gateway/secrets.md | 18 +- docs/tools/plugin.md | 32 ++ extensions/discord/src/channel.ts | 9 +- extensions/slack/src/channel.test.ts | 49 +++ extensions/slack/src/channel.ts | 19 +- extensions/telegram/src/channel.ts | 12 +- src/channels/account-snapshot-fields.test.ts | 27 ++ src/channels/account-snapshot-fields.ts | 217 +++++++++++++ src/channels/account-summary.ts | 2 + src/channels/dock.test.ts | 72 ++++- src/channels/dock.ts | 21 +- src/channels/plugins/directory-config.ts | 18 +- src/channels/plugins/group-mentions.ts | 4 +- src/channels/plugins/onboarding/discord.ts | 5 +- src/channels/plugins/onboarding/slack.ts | 9 +- src/channels/plugins/onboarding/telegram.ts | 9 +- src/channels/plugins/plugins-core.test.ts | 67 ++++ src/channels/plugins/status.ts | 95 ++++-- src/channels/plugins/types.adapters.ts | 1 + src/channels/plugins/types.core.ts | 6 + src/channels/read-only-account-inspect.ts | 39 +++ src/cli/command-secret-gateway.test.ts | 212 +++++++++++++ src/cli/command-secret-gateway.ts | 293 ++++++++++++++++-- src/cli/program/preaction.test.ts | 2 +- src/cli/program/preaction.ts | 2 + ...channels.config-only-status-output.test.ts | 271 ++++++++++++++++ src/commands/channels/resolve.ts | 1 + src/commands/channels/shared.ts | 14 +- src/commands/channels/status.ts | 92 ++++-- src/commands/doctor-config-flow.test.ts | 48 +++ src/commands/doctor-config-flow.ts | 21 +- src/commands/status-all.ts | 6 +- .../channels.mattermost-token-summary.test.ts | 280 +++++++++++++++++ src/commands/status-all/channels.ts | 220 +++++++++++-- src/commands/status.command.ts | 66 ++-- src/commands/status.scan.test.ts | 138 +++++++++ src/commands/status.scan.ts | 37 ++- src/commands/status.summary.ts | 7 +- src/discord/account-inspect.ts | 141 +++++++++ src/discord/audit.test.ts | 29 ++ src/discord/audit.ts | 4 +- src/discord/ui.ts | 4 +- src/infra/channel-summary.test.ts | 84 +++++ src/infra/channel-summary.ts | 66 +++- src/plugin-sdk/discord.ts | 6 + src/plugin-sdk/index.ts | 6 + src/plugin-sdk/slack.ts | 7 + src/plugin-sdk/subpaths.test.ts | 9 + src/plugin-sdk/telegram.ts | 6 + src/secrets/command-config.ts | 63 +++- src/security/audit-channel.ts | 80 ++++- src/security/audit.test.ts | 249 ++++++++++++++- src/security/audit.ts | 12 +- src/slack/account-inspect.ts | 205 ++++++++++++ src/telegram/account-inspect.ts | 213 +++++++++++++ 58 files changed, 3422 insertions(+), 215 deletions(-) create mode 100644 src/channels/account-snapshot-fields.test.ts create mode 100644 src/channels/account-snapshot-fields.ts create mode 100644 src/channels/read-only-account-inspect.ts create mode 100644 src/commands/channels.config-only-status-output.test.ts create mode 100644 src/commands/status.scan.test.ts create mode 100644 src/discord/account-inspect.ts create mode 100644 src/infra/channel-summary.test.ts create mode 100644 src/slack/account-inspect.ts create mode 100644 src/telegram/account-inspect.ts diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 23e0b2cfd4b..654fbef5fa9 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -67,6 +67,7 @@ openclaw channels logout --channel whatsapp - Run `openclaw status --deep` for a broad probe. - Use `openclaw doctor` for guided fixes. - `openclaw channels list` prints `Claude: HTTP 403 ... user:profile` → usage snapshot needs the `user:profile` scope. Use `--no-usage`, or provide a claude.ai session key (`CLAUDE_WEB_SESSION_KEY` / `CLAUDE_WEB_COOKIE`), or re-auth via Claude Code CLI. +- `openclaw channels status` falls back to config-only summaries when the gateway is unreachable. If a supported channel credential is configured via SecretRef but unavailable in the current command path, it reports that account as configured with degraded notes instead of showing it as not configured. ## Capabilities probe @@ -97,3 +98,4 @@ Notes: - Use `--kind user|group|auto` to force the target type. - Resolution prefers active matches when multiple entries share the same name. +- `channels resolve` is read-only. If a selected account is configured via SecretRef but that credential is unavailable in the current command path, the command returns degraded unresolved results with notes instead of aborting the entire run. diff --git a/docs/cli/status.md b/docs/cli/status.md index a76c99d1ee6..856c341b036 100644 --- a/docs/cli/status.md +++ b/docs/cli/status.md @@ -24,3 +24,5 @@ Notes: - Overview includes Gateway + node host service install/runtime status when available. - Overview includes update channel + git SHA (for source checkouts). - Update info surfaces in the Overview; if an update is available, status prints a hint to run `openclaw update` (see [Updating](/install/updating)). +- Read-only status surfaces (`status`, `status --json`, `status --all`) resolve supported SecretRefs for their targeted config paths when possible. +- If a supported channel SecretRef is configured but unavailable in the current command path, status stays read-only and reports degraded output instead of crashing. Human output shows warnings such as “configured token unavailable in this command path”, and JSON output includes `secretDiagnostics`. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 73264b255c9..2e7b7df68ba 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -244,6 +244,14 @@ Doctor checks local gateway token auth readiness. - If `gateway.auth.token` is SecretRef-managed but unavailable, doctor warns and does not overwrite it with plaintext. - `openclaw doctor --generate-gateway-token` forces generation only when no token SecretRef is configured. +### 12b) Read-only SecretRef-aware repairs + +Some repair flows need to inspect configured credentials without weakening runtime fail-fast behavior. + +- `openclaw doctor --fix` now uses the same read-only SecretRef summary model as status-family commands for targeted config repairs. +- Example: Telegram `allowFrom` / `groupAllowFrom` `@username` repair tries to use configured bot credentials when available. +- If the Telegram bot token is configured via SecretRef but unavailable in the current command path, doctor reports that the credential is configured-but-unavailable and skips auto-resolution instead of crashing or misreporting the token as missing. + ### 13) Gateway health check + restart Doctor runs a health check and offers to restart the gateway when it looks diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 4c286f67ef1..db4be160cd7 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -339,10 +339,22 @@ Behavior: ## Command-path resolution -Credential-sensitive command paths that opt in (for example `openclaw memory` remote-memory paths and `openclaw qr --remote`) can resolve supported SecretRefs via gateway snapshot RPC. +Command paths can opt into supported SecretRef resolution via gateway snapshot RPC. + +There are two broad behaviors: + +- Strict command paths (for example `openclaw memory` remote-memory paths and `openclaw qr --remote`) read from the active snapshot and fail fast when a required SecretRef is unavailable. +- Read-only command paths (for example `openclaw status`, `openclaw status --all`, `openclaw channels status`, `openclaw channels resolve`, and read-only doctor/config repair flows) also prefer the active snapshot, but degrade instead of aborting when a targeted SecretRef is unavailable in that command path. + +Read-only behavior: + +- When the gateway is running, these commands read from the active snapshot first. +- If gateway resolution is incomplete or the gateway is unavailable, they attempt targeted local fallback for the specific command surface. +- If a targeted SecretRef is still unavailable, the command continues with degraded read-only output and explicit diagnostics such as “configured but unavailable in this command path”. +- This degraded behavior is command-local only. It does not weaken runtime startup, reload, or send/auth paths. + +Other notes: -- When gateway is running, those command paths read from the active snapshot. -- If a configured SecretRef is required and gateway is unavailable, command resolution fails fast with actionable diagnostics. - Snapshot refresh after backend secret rotation is handled by `openclaw secrets reload`. - Gateway RPC method used by these command paths: `secrets.resolve`. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index e7b84cfd815..4a20ec0c37c 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -178,6 +178,38 @@ Compatibility note: subpaths; use `core` for generic surfaces and `compat` only when broader shared helpers are required. +## Read-only channel inspection + +If your plugin registers a channel, prefer implementing +`plugin.config.inspectAccount(cfg, accountId)` alongside `resolveAccount(...)`. + +Why: + +- `resolveAccount(...)` is the runtime path. It is allowed to assume credentials + are fully materialized and can fail fast when required secrets are missing. +- Read-only command paths such as `openclaw status`, `openclaw status --all`, + `openclaw channels status`, `openclaw channels resolve`, and doctor/config + repair flows should not need to materialize runtime credentials just to + describe configuration. + +Recommended `inspectAccount(...)` behavior: + +- Return descriptive account state only. +- Preserve `enabled` and `configured`. +- Include credential source/status fields when relevant, such as: + - `tokenSource`, `tokenStatus` + - `botTokenSource`, `botTokenStatus` + - `appTokenSource`, `appTokenStatus` + - `signingSecretSource`, `signingSecretStatus` +- You do not need to return raw token values just to report read-only + availability. Returning `tokenStatus: "available"` (and the matching source + field) is enough for status-style commands. +- Use `configured_unavailable` when a credential is configured via SecretRef but + unavailable in the current command path. + +This lets read-only commands report “configured but unavailable in this command +path” instead of crashing or misreporting the account as not configured. + Performance note: - Plugin discovery and manifest metadata use short in-process caches to reduce diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 3abaa82a956..04f8b5ab3a8 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -10,6 +10,7 @@ import { DiscordConfigSchema, formatPairingApproveHint, getChatChannelMeta, + inspectDiscordAccount, listDiscordAccountIds, listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, @@ -19,6 +20,8 @@ import { normalizeDiscordMessagingTarget, normalizeDiscordOutboundTarget, PAIRING_APPROVED_MESSAGE, + projectCredentialSnapshotFields, + resolveConfiguredFromCredentialStatuses, resolveDiscordAccount, resolveDefaultDiscordAccountId, resolveDiscordGroupRequireMention, @@ -80,6 +83,7 @@ export const discordPlugin: ChannelPlugin = { config: { listAccountIds: (cfg) => listDiscordAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }), defaultAccountId: (cfg) => resolveDefaultDiscordAccountId(cfg), setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabledInConfigSection({ @@ -390,7 +394,8 @@ export const discordPlugin: ChannelPlugin = { return { ...audit, unresolvedChannels }; }, buildAccountSnapshot: ({ account, runtime, probe, audit }) => { - const configured = Boolean(account.token?.trim()); + const configured = + resolveConfiguredFromCredentialStatuses(account) ?? Boolean(account.token?.trim()); const app = runtime?.application ?? (probe as { application?: unknown })?.application; const bot = runtime?.bot ?? (probe as { bot?: unknown })?.bot; return { @@ -398,7 +403,7 @@ export const discordPlugin: ChannelPlugin = { name: account.name, enabled: account.enabled, configured, - tokenSource: account.tokenSource, + ...projectCredentialSnapshotFields(account), running: runtime?.running ?? false, lastStartAt: runtime?.lastStartAt ?? null, lastStopAt: runtime?.lastStopAt ?? null, diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index 204c016a6dc..2d4efa3f956 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -182,4 +182,53 @@ describe("slackPlugin config", () => { expect(configured).toBe(false); expect(snapshot?.configured).toBe(false); }); + + it("does not mark partial configured-unavailable token status as configured", async () => { + const snapshot = await slackPlugin.status?.buildAccountSnapshot?.({ + account: { + accountId: "default", + name: "Default", + enabled: true, + configured: false, + botTokenStatus: "configured_unavailable", + appTokenStatus: "missing", + botTokenSource: "config", + appTokenSource: "none", + config: {}, + } as never, + cfg: {} as OpenClawConfig, + runtime: undefined, + }); + + expect(snapshot?.configured).toBe(false); + expect(snapshot?.botTokenStatus).toBe("configured_unavailable"); + expect(snapshot?.appTokenStatus).toBe("missing"); + }); + + it("keeps HTTP mode signing-secret unavailable accounts configured in snapshots", async () => { + const snapshot = await slackPlugin.status?.buildAccountSnapshot?.({ + account: { + accountId: "default", + name: "Default", + enabled: true, + configured: true, + mode: "http", + botTokenStatus: "available", + signingSecretStatus: "configured_unavailable", + botTokenSource: "config", + signingSecretSource: "config", + config: { + mode: "http", + botToken: "xoxb-http", + signingSecret: { source: "env", provider: "default", id: "SLACK_SIGNING_SECRET" }, + }, + } as never, + cfg: {} as OpenClawConfig, + runtime: undefined, + }); + + expect(snapshot?.configured).toBe(true); + expect(snapshot?.botTokenStatus).toBe("available"); + expect(snapshot?.signingSecretStatus).toBe("configured_unavailable"); + }); }); diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 82e29e95b99..2589a577689 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -7,6 +7,7 @@ import { formatPairingApproveHint, getChatChannelMeta, handleSlackMessageAction, + inspectSlackAccount, listSlackMessageActions, listSlackAccountIds, listSlackDirectoryGroupsFromConfig, @@ -16,6 +17,8 @@ import { normalizeAccountId, normalizeSlackMessagingTarget, PAIRING_APPROVED_MESSAGE, + projectCredentialSnapshotFields, + resolveConfiguredFromRequiredCredentialStatuses, resolveDefaultSlackAccountId, resolveSlackAccount, resolveSlackReplyToMode, @@ -131,6 +134,7 @@ export const slackPlugin: ChannelPlugin = { config: { listAccountIds: (cfg) => listSlackAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }), defaultAccountId: (cfg) => resolveDefaultSlackAccountId(cfg), setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabledInConfigSection({ @@ -428,14 +432,23 @@ export const slackPlugin: ChannelPlugin = { return await getSlackRuntime().channel.slack.probeSlack(token, timeoutMs); }, buildAccountSnapshot: ({ account, runtime, probe }) => { - const configured = isSlackAccountConfigured(account); + const mode = account.config.mode ?? "socket"; + const configured = + (mode === "http" + ? resolveConfiguredFromRequiredCredentialStatuses(account, [ + "botTokenStatus", + "signingSecretStatus", + ]) + : resolveConfiguredFromRequiredCredentialStatuses(account, [ + "botTokenStatus", + "appTokenStatus", + ])) ?? isSlackAccountConfigured(account); return { accountId: account.accountId, name: account.name, enabled: account.enabled, configured, - botTokenSource: account.botTokenSource, - appTokenSource: account.appTokenSource, + ...projectCredentialSnapshotFields(account), running: runtime?.running ?? false, lastStartAt: runtime?.lastStartAt ?? null, lastStopAt: runtime?.lastStopAt ?? null, diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index bc8b7e1fcaf..f7c2ad16328 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -7,6 +7,7 @@ import { deleteAccountFromConfigSection, formatPairingApproveHint, getChatChannelMeta, + inspectTelegramAccount, listTelegramAccountIds, listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig, @@ -17,6 +18,8 @@ import { PAIRING_APPROVED_MESSAGE, parseTelegramReplyToMessageId, parseTelegramThreadId, + projectCredentialSnapshotFields, + resolveConfiguredFromCredentialStatuses, resolveDefaultTelegramAccountId, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, @@ -43,7 +46,7 @@ function findTelegramTokenOwnerAccountId(params: { const normalizedAccountId = normalizeAccountId(params.accountId); const tokenOwners = new Map(); for (const id of listTelegramAccountIds(params.cfg)) { - const account = resolveTelegramAccount({ cfg: params.cfg, accountId: id }); + const account = inspectTelegramAccount({ cfg: params.cfg, accountId: id }); const token = (account.token ?? "").trim(); if (!token) { continue; @@ -122,6 +125,7 @@ export const telegramPlugin: ChannelPlugin listTelegramAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }), defaultAccountId: (cfg) => resolveDefaultTelegramAccountId(cfg), setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabledInConfigSection({ @@ -416,6 +420,7 @@ export const telegramPlugin: ChannelPlugin { + const configuredFromStatus = resolveConfiguredFromCredentialStatuses(account); const ownerAccountId = findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId, @@ -426,7 +431,8 @@ export const telegramPlugin: ChannelPlugin { + 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", + }); + }); +}); diff --git a/src/channels/account-snapshot-fields.ts b/src/channels/account-snapshot-fields.ts new file mode 100644 index 00000000000..72d745beac0 --- /dev/null +++ b/src/channels/account-snapshot-fields.ts @@ -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 | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function readTrimmedString(record: Record, 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, key: string): boolean | undefined { + return typeof record[key] === "boolean" ? record[key] : undefined; +} + +function readNumber(record: Record, key: string): number | undefined { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function readStringArray(record: Record, 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, 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, + | "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 { + 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") } : {}), + }; +} diff --git a/src/channels/account-summary.ts b/src/channels/account-summary.ts index 3e6db86c615..a36a45d678f 100644 --- a/src/channels/account-summary.ts +++ b/src/channels/account-summary.ts @@ -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, }; diff --git a/src/channels/dock.test.ts b/src/channels/dock.test.ts index bfb544a3721..e3d00824c3b 100644 --- a/src/channels/dock.test.ts +++ b/src/channels/dock.test.ts @@ -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); + }); }); diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 98db2a2cf49..3cabb919f51 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -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 = { 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 = { }, 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 = { 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 = { }, threading: { resolveReplyToMode: ({ cfg, accountId, chatType }) => - resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), + resolveSlackReplyToMode(inspectSlackAccount({ cfg, accountId }), chatType), allowExplicitReplyTagsWhenOff: false, buildToolContext: (params) => buildSlackThreadingToolContext(params), }, diff --git a/src/channels/plugins/directory-config.ts b/src/channels/plugins/directory-config.ts index 66620e4427b..2d308eccda3 100644 --- a/src/channels/plugins/directory-config.ts +++ b/src/channels/plugins/directory-config.ts @@ -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 { - const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); + const account = inspectSlackAccount({ cfg: params.cfg, accountId: params.accountId }); const ids = new Set(); 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 { - 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 { - const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); + const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); const ids = new Set(); addAllowFromAndDmsIds( @@ -139,7 +139,7 @@ export async function listDiscordDirectoryPeersFromConfig( export async function listDiscordDirectoryGroupsFromConfig( params: DirectoryConfigParams, ): Promise { - const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); + const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); const ids = new Set(); 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 { - 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 { - 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 !== "*"); diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts index 0988e2e66ce..551f0d52985 100644 --- a/src/channels/plugins/group-mentions.ts +++ b/src/channels/plugins/group-mentions.ts @@ -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, }); diff --git a/src/channels/plugins/onboarding/discord.ts b/src/channels/plugins/onboarding/discord.ts index eb9405e8f4e..85592b7810e 100644 --- a/src/channels/plugins/onboarding/discord.ts +++ b/src/channels/plugins/onboarding/discord.ts @@ -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, diff --git a/src/channels/plugins/onboarding/slack.ts b/src/channels/plugins/onboarding/slack.ts index eaadbe483ab..ee054a851eb 100644 --- a/src/channels/plugins/onboarding/slack.ts +++ b/src/channels/plugins/onboarding/slack.ts @@ -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, diff --git a/src/channels/plugins/onboarding/telegram.ts b/src/channels/plugins/onboarding/telegram.ts index 91342e1fa95..6a65d324d27 100644 --- a/src/channels/plugins/onboarding/telegram.ts +++ b/src/channels/plugins/onboarding/telegram.ts @@ -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, diff --git a/src/channels/plugins/plugins-core.test.ts b/src/channels/plugins/plugins-core.test.ts index cbc4c9e4da6..49012222982 100644 --- a/src/channels/plugins/plugins-core.test.ts +++ b/src/channels/plugins/plugins-core.test.ts @@ -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: { diff --git a/src/channels/plugins/status.ts b/src/channels/plugins/status.ts index 1fc831285f1..cc7de671a3a 100644 --- a/src/channels/plugins/status.ts +++ b/src/channels/plugins/status.ts @@ -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(params: { + plugin: ChannelPlugin; + cfg: OpenClawConfig; + accountId: string; + account: ResolvedAccount; + runtime?: ChannelAccountSnapshot; + probe?: unknown; + audit?: unknown; +}): Promise { + 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(params: { + plugin: ChannelPlugin; + cfg: OpenClawConfig; + accountId: string; + runtime?: ChannelAccountSnapshot; + probe?: unknown; + audit?: unknown; +}): Promise { + 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(params: { plugin: ChannelPlugin; cfg: OpenClawConfig; @@ -10,27 +73,17 @@ export async function buildChannelAccountSnapshot(params: { probe?: unknown; audit?: unknown; }): Promise { - 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, + }); } diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index f31f3b20284..df84ee4d3d2 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -52,6 +52,7 @@ export type ChannelSetupAdapter = { export type ChannelConfigAdapter = { 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; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index af8a2eca955..6cd5173e13b 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -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; diff --git a/src/channels/read-only-account-inspect.ts b/src/channels/read-only-account-inspect.ts new file mode 100644 index 00000000000..535fe05c473 --- /dev/null +++ b/src/channels/read-only-account-inspect.ts @@ -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; +} diff --git a/src/cli/command-secret-gateway.test.ts b/src/cli/command-secret-gateway.test.ts index c37501d7390..e825be990f7 100644 --- a/src/cli/command-secret-gateway.test.ts +++ b/src/cli/command-secret-gateway.test.ts @@ -139,6 +139,9 @@ describe("resolveCommandSecretRefsViaGateway", () => { expect( result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")), ).toBe(true); + expect( + result.diagnostics.some((entry) => entry.includes("resolved command secrets locally")), + ).toBe(true); } finally { if (priorValue === undefined) { delete process.env.TALK_API_KEY; @@ -353,4 +356,213 @@ describe("resolveCommandSecretRefsViaGateway", () => { }); expect(result.diagnostics).toEqual(["memory search ref inactive"]); }); + + it("degrades unresolved refs in summary mode instead of throwing", async () => { + const envKey = "TALK_API_KEY_SUMMARY_MISSING"; + const priorValue = process.env[envKey]; + delete process.env[envKey]; + callGateway.mockResolvedValueOnce({ + assignments: [], + diagnostics: [], + }); + + try { + const result = await resolveCommandSecretRefsViaGateway({ + config: { + talk: { + apiKey: { source: "env", provider: "default", id: envKey }, + }, + } as OpenClawConfig, + commandName: "status", + targetIds: new Set(["talk.apiKey"]), + mode: "summary", + }); + + expect(result.resolvedConfig.talk?.apiKey).toBeUndefined(); + expect(result.hadUnresolvedTargets).toBe(true); + expect(result.targetStatesByPath["talk.apiKey"]).toBe("unresolved"); + expect( + result.diagnostics.some((entry) => + entry.includes("talk.apiKey is unavailable in this command path"), + ), + ).toBe(true); + } finally { + if (priorValue === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = priorValue; + } + } + }); + + it("uses targeted local fallback after an incomplete gateway snapshot", async () => { + const envKey = "TALK_API_KEY_PARTIAL_GATEWAY"; + const priorValue = process.env[envKey]; + process.env[envKey] = "recovered-locally"; + callGateway.mockResolvedValueOnce({ + assignments: [], + diagnostics: [], + }); + + try { + const result = await resolveCommandSecretRefsViaGateway({ + config: { + talk: { + apiKey: { source: "env", provider: "default", id: envKey }, + }, + } as OpenClawConfig, + commandName: "status", + targetIds: new Set(["talk.apiKey"]), + mode: "summary", + }); + + expect(result.resolvedConfig.talk?.apiKey).toBe("recovered-locally"); + expect(result.hadUnresolvedTargets).toBe(false); + expect(result.targetStatesByPath["talk.apiKey"]).toBe("resolved_local"); + expect( + result.diagnostics.some((entry) => + entry.includes( + "resolved 1 secret path locally after the gateway snapshot was incomplete", + ), + ), + ).toBe(true); + } finally { + if (priorValue === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = priorValue; + } + } + }); + + it("limits strict local fallback analysis to unresolved gateway paths", async () => { + const gatewayResolvedKey = "TALK_API_KEY_PARTIAL_GATEWAY_RESOLVED"; + const locallyRecoveredKey = "TALK_API_KEY_PARTIAL_GATEWAY_LOCAL"; + const priorGatewayResolvedValue = process.env[gatewayResolvedKey]; + const priorLocallyRecoveredValue = process.env[locallyRecoveredKey]; + delete process.env[gatewayResolvedKey]; + process.env[locallyRecoveredKey] = "recovered-locally"; + callGateway.mockResolvedValueOnce({ + assignments: [ + { + path: "talk.apiKey", + pathSegments: ["talk", "apiKey"], + value: "resolved-by-gateway", + }, + ], + diagnostics: [], + }); + + try { + const result = await resolveCommandSecretRefsViaGateway({ + config: { + talk: { + apiKey: { source: "env", provider: "default", id: gatewayResolvedKey }, + providers: { + elevenlabs: { + apiKey: { source: "env", provider: "default", id: locallyRecoveredKey }, + }, + }, + }, + } as OpenClawConfig, + commandName: "message send", + targetIds: new Set(["talk.apiKey", "talk.providers.*.apiKey"]), + }); + + expect(result.resolvedConfig.talk?.apiKey).toBe("resolved-by-gateway"); + expect(result.resolvedConfig.talk?.providers?.elevenlabs?.apiKey).toBe("recovered-locally"); + expect(result.hadUnresolvedTargets).toBe(false); + expect(result.targetStatesByPath["talk.apiKey"]).toBe("resolved_gateway"); + expect(result.targetStatesByPath["talk.providers.elevenlabs.apiKey"]).toBe("resolved_local"); + } finally { + if (priorGatewayResolvedValue === undefined) { + delete process.env[gatewayResolvedKey]; + } else { + process.env[gatewayResolvedKey] = priorGatewayResolvedValue; + } + if (priorLocallyRecoveredValue === undefined) { + delete process.env[locallyRecoveredKey]; + } else { + process.env[locallyRecoveredKey] = priorLocallyRecoveredValue; + } + } + }); + + it("limits local fallback to targeted refs in read-only modes", async () => { + const talkEnvKey = "TALK_API_KEY_TARGET_ONLY"; + const gatewayEnvKey = "GATEWAY_PASSWORD_UNRELATED"; + const priorTalkValue = process.env[talkEnvKey]; + const priorGatewayValue = process.env[gatewayEnvKey]; + process.env[talkEnvKey] = "target-only"; + delete process.env[gatewayEnvKey]; + callGateway.mockRejectedValueOnce(new Error("gateway closed")); + + try { + const result = await resolveCommandSecretRefsViaGateway({ + config: { + talk: { + apiKey: { source: "env", provider: "default", id: talkEnvKey }, + }, + gateway: { + auth: { + password: { source: "env", provider: "default", id: gatewayEnvKey }, + }, + }, + } as OpenClawConfig, + commandName: "status", + targetIds: new Set(["talk.apiKey"]), + mode: "summary", + }); + + expect(result.resolvedConfig.talk?.apiKey).toBe("target-only"); + expect(result.hadUnresolvedTargets).toBe(false); + expect(result.targetStatesByPath["talk.apiKey"]).toBe("resolved_local"); + } finally { + if (priorTalkValue === undefined) { + delete process.env[talkEnvKey]; + } else { + process.env[talkEnvKey] = priorTalkValue; + } + if (priorGatewayValue === undefined) { + delete process.env[gatewayEnvKey]; + } else { + process.env[gatewayEnvKey] = priorGatewayValue; + } + } + }); + + it("degrades unresolved refs in operational read-only mode", async () => { + const envKey = "TALK_API_KEY_OPERATIONAL_MISSING"; + const priorValue = process.env[envKey]; + delete process.env[envKey]; + callGateway.mockRejectedValueOnce(new Error("gateway closed")); + + try { + const result = await resolveCommandSecretRefsViaGateway({ + config: { + talk: { + apiKey: { source: "env", provider: "default", id: envKey }, + }, + } as OpenClawConfig, + commandName: "channels resolve", + targetIds: new Set(["talk.apiKey"]), + mode: "operational_readonly", + }); + + expect(result.resolvedConfig.talk?.apiKey).toBeUndefined(); + expect(result.hadUnresolvedTargets).toBe(true); + expect(result.targetStatesByPath["talk.apiKey"]).toBe("unresolved"); + expect( + result.diagnostics.some((entry) => + entry.includes("attempted local command-secret resolution"), + ), + ).toBe(true); + } finally { + if (priorValue === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = priorValue; + } + } + }); }); diff --git a/src/cli/command-secret-gateway.ts b/src/cli/command-secret-gateway.ts index 1333667d6c4..dfbb425a49d 100644 --- a/src/cli/command-secret-gateway.ts +++ b/src/cli/command-secret-gateway.ts @@ -2,20 +2,37 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveSecretInputRef } from "../config/types.secrets.js"; import { callGateway } from "../gateway/call.js"; import { validateSecretsResolveResult } from "../gateway/protocol/index.js"; -import { collectCommandSecretAssignmentsFromSnapshot } from "../secrets/command-config.js"; -import { setPathExistingStrict } from "../secrets/path-utils.js"; -import { resolveSecretRefValues } from "../secrets/resolve.js"; +import { + analyzeCommandSecretAssignmentsFromSnapshot, + type UnresolvedCommandSecretAssignment, +} from "../secrets/command-config.js"; +import { getPath, setPathExistingStrict } from "../secrets/path-utils.js"; +import { resolveSecretRefValue } from "../secrets/resolve.js"; import { collectConfigAssignments } from "../secrets/runtime-config-collectors.js"; -import { applyResolvedAssignments, createResolverContext } from "../secrets/runtime-shared.js"; +import { createResolverContext } from "../secrets/runtime-shared.js"; +import { assertExpectedResolvedSecretValue } from "../secrets/secret-value.js"; import { describeUnknownError } from "../secrets/shared.js"; -import { discoverConfigSecretTargetsByIds } from "../secrets/target-registry.js"; +import { + discoverConfigSecretTargetsByIds, + type DiscoveredConfigSecretTarget, +} from "../secrets/target-registry.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; type ResolveCommandSecretsResult = { resolvedConfig: OpenClawConfig; diagnostics: string[]; + targetStatesByPath: Record; + hadUnresolvedTargets: boolean; }; +export type CommandSecretResolutionMode = "strict" | "summary" | "operational_readonly"; + +export type CommandSecretTargetState = + | "resolved_gateway" + | "resolved_local" + | "inactive_surface" + | "unresolved"; + type GatewaySecretsResolveResult = { ok?: boolean; assignments?: Array<{ @@ -167,6 +184,8 @@ async function resolveCommandSecretRefsLocally(params: { commandName: string; targetIds: Set; preflightDiagnostics: string[]; + mode: CommandSecretResolutionMode; + allowedPaths?: ReadonlySet; }): Promise { const sourceConfig = params.config; const resolvedConfig = structuredClone(params.config); @@ -175,57 +194,191 @@ async function resolveCommandSecretRefsLocally(params: { env: process.env, }); collectConfigAssignments({ - config: resolvedConfig, + config: structuredClone(params.config), context, }); - if (context.assignments.length > 0) { - const resolved = await resolveSecretRefValues( - context.assignments.map((assignment) => assignment.ref), - { - config: sourceConfig, - env: context.env, - cache: context.cache, - }, - ); - applyResolvedAssignments({ - assignments: context.assignments, - resolved, - }); - } - const inactiveRefPaths = new Set( context.warnings .filter((warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE") .map((warning) => warning.path), ); - const commandAssignments = collectCommandSecretAssignmentsFromSnapshot({ + const activePaths = new Set(context.assignments.map((assignment) => assignment.path)); + const localResolutionDiagnostics: string[] = []; + for (const target of discoverConfigSecretTargetsByIds(sourceConfig, params.targetIds)) { + if (params.allowedPaths && !params.allowedPaths.has(target.path)) { + continue; + } + await resolveTargetSecretLocally({ + target, + sourceConfig, + resolvedConfig, + env: context.env, + cache: context.cache, + activePaths, + inactiveRefPaths, + mode: params.mode, + commandName: params.commandName, + localResolutionDiagnostics, + }); + } + const analyzed = analyzeCommandSecretAssignmentsFromSnapshot({ sourceConfig, resolvedConfig, - commandName: params.commandName, targetIds: params.targetIds, inactiveRefPaths, + ...(params.allowedPaths ? { allowedPaths: params.allowedPaths } : {}), }); + const targetStatesByPath = buildTargetStatesByPath({ + analyzed, + resolvedState: "resolved_local", + }); + if (params.mode !== "strict" && analyzed.unresolved.length > 0) { + scrubUnresolvedAssignments(resolvedConfig, analyzed.unresolved); + } else if (analyzed.unresolved.length > 0) { + throw new Error( + `${params.commandName}: ${analyzed.unresolved[0]?.path ?? "target"} is unresolved in the active runtime snapshot.`, + ); + } return { resolvedConfig, diagnostics: dedupeDiagnostics([ ...params.preflightDiagnostics, - ...commandAssignments.diagnostics, + ...filterInactiveSurfaceDiagnostics({ + diagnostics: analyzed.diagnostics, + inactiveRefPaths, + }), + ...localResolutionDiagnostics, + ...buildUnresolvedDiagnostics(params.commandName, analyzed.unresolved, params.mode), ]), + targetStatesByPath, + hadUnresolvedTargets: analyzed.unresolved.length > 0, }; } +function buildTargetStatesByPath(params: { + analyzed: ReturnType; + resolvedState: Extract; +}): Record { + const states: Record = {}; + for (const assignment of params.analyzed.assignments) { + states[assignment.path] = params.resolvedState; + } + for (const entry of params.analyzed.inactive) { + states[entry.path] = "inactive_surface"; + } + for (const entry of params.analyzed.unresolved) { + states[entry.path] = "unresolved"; + } + return states; +} + +function buildUnresolvedDiagnostics( + commandName: string, + unresolved: UnresolvedCommandSecretAssignment[], + mode: CommandSecretResolutionMode, +): string[] { + if (mode === "strict") { + return []; + } + return unresolved.map( + (entry) => + `${commandName}: ${entry.path} is unavailable in this command path; continuing with degraded read-only config.`, + ); +} + +function scrubUnresolvedAssignments( + config: OpenClawConfig, + unresolved: UnresolvedCommandSecretAssignment[], +): void { + for (const entry of unresolved) { + setPathExistingStrict(config, entry.pathSegments, undefined); + } +} + +function filterInactiveSurfaceDiagnostics(params: { + diagnostics: readonly string[]; + inactiveRefPaths: ReadonlySet; +}): string[] { + return params.diagnostics.filter((entry) => { + const marker = ": secret ref is configured on an inactive surface;"; + const markerIndex = entry.indexOf(marker); + if (markerIndex <= 0) { + return true; + } + const path = entry.slice(0, markerIndex).trim(); + return !params.inactiveRefPaths.has(path); + }); +} + +async function resolveTargetSecretLocally(params: { + target: DiscoveredConfigSecretTarget; + sourceConfig: OpenClawConfig; + resolvedConfig: OpenClawConfig; + env: NodeJS.ProcessEnv; + cache: ReturnType["cache"]; + activePaths: ReadonlySet; + inactiveRefPaths: ReadonlySet; + mode: CommandSecretResolutionMode; + commandName: string; + localResolutionDiagnostics: string[]; +}): Promise { + const defaults = params.sourceConfig.secrets?.defaults; + const { ref } = resolveSecretInputRef({ + value: params.target.value, + refValue: params.target.refValue, + defaults, + }); + if ( + !ref || + params.inactiveRefPaths.has(params.target.path) || + !params.activePaths.has(params.target.path) + ) { + return; + } + + try { + const resolved = await resolveSecretRefValue(ref, { + config: params.sourceConfig, + env: params.env, + cache: params.cache, + }); + assertExpectedResolvedSecretValue({ + value: resolved, + expected: params.target.entry.expectedResolvedValue, + errorMessage: + params.target.entry.expectedResolvedValue === "string" + ? `${params.target.path} resolved to a non-string or empty value.` + : `${params.target.path} resolved to an unsupported value type.`, + }); + setPathExistingStrict(params.resolvedConfig, params.target.pathSegments, resolved); + } catch (error) { + if (params.mode !== "strict") { + params.localResolutionDiagnostics.push( + `${params.commandName}: failed to resolve ${params.target.path} locally (${describeUnknownError(error)}).`, + ); + } + } +} + export async function resolveCommandSecretRefsViaGateway(params: { config: OpenClawConfig; commandName: string; targetIds: Set; + mode?: CommandSecretResolutionMode; }): Promise { + const mode = params.mode ?? "strict"; const configuredTargetRefPaths = collectConfiguredTargetRefPaths({ config: params.config, targetIds: params.targetIds, }); if (configuredTargetRefPaths.size === 0) { - return { resolvedConfig: params.config, diagnostics: [] }; + return { + resolvedConfig: params.config, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }; } const preflight = classifyConfiguredTargetRefs({ config: params.config, @@ -235,6 +388,8 @@ export async function resolveCommandSecretRefsViaGateway(params: { return { resolvedConfig: params.config, diagnostics: preflight.diagnostics, + targetStatesByPath: {}, + hadUnresolvedTargets: false, }; } @@ -258,13 +413,23 @@ export async function resolveCommandSecretRefsViaGateway(params: { commandName: params.commandName, targetIds: params.targetIds, preflightDiagnostics: preflight.diagnostics, + mode, }); + const recoveredLocally = Object.values(fallback.targetStatesByPath).some( + (state) => state === "resolved_local", + ); + const fallbackMessage = + recoveredLocally && !fallback.hadUnresolvedTargets + ? "resolved command secrets locally." + : "attempted local command-secret resolution."; return { resolvedConfig: fallback.resolvedConfig, diagnostics: dedupeDiagnostics([ ...fallback.diagnostics, - `${params.commandName}: gateway secrets.resolve unavailable (${describeUnknownError(err)}); resolved command secrets locally.`, + `${params.commandName}: gateway secrets.resolve unavailable (${describeUnknownError(err)}); ${fallbackMessage}`, ]), + targetStatesByPath: fallback.targetStatesByPath, + hadUnresolvedTargets: fallback.hadUnresolvedTargets, }; } catch { // Fall through to original gateway-specific error reporting. @@ -302,16 +467,86 @@ export async function resolveCommandSecretRefsViaGateway(params: { parsed.inactiveRefPaths.length > 0 ? new Set(parsed.inactiveRefPaths) : collectInactiveSurfacePathsFromDiagnostics(parsed.diagnostics); - collectCommandSecretAssignmentsFromSnapshot({ + const analyzed = analyzeCommandSecretAssignmentsFromSnapshot({ sourceConfig: params.config, resolvedConfig, - commandName: params.commandName, targetIds: params.targetIds, inactiveRefPaths, }); + let diagnostics = dedupeDiagnostics(parsed.diagnostics); + const targetStatesByPath = buildTargetStatesByPath({ + analyzed, + resolvedState: "resolved_gateway", + }); + if (analyzed.unresolved.length > 0) { + try { + const localFallback = await resolveCommandSecretRefsLocally({ + config: params.config, + commandName: params.commandName, + targetIds: params.targetIds, + preflightDiagnostics: [], + mode, + allowedPaths: new Set(analyzed.unresolved.map((entry) => entry.path)), + }); + for (const unresolved of analyzed.unresolved) { + if (localFallback.targetStatesByPath[unresolved.path] !== "resolved_local") { + continue; + } + setPathExistingStrict( + resolvedConfig, + unresolved.pathSegments, + getPath(localFallback.resolvedConfig, unresolved.pathSegments), + ); + targetStatesByPath[unresolved.path] = "resolved_local"; + } + const recoveredPaths = new Set( + Object.entries(localFallback.targetStatesByPath) + .filter(([, state]) => state === "resolved_local") + .map(([path]) => path), + ); + const stillUnresolved = analyzed.unresolved.filter( + (entry) => !recoveredPaths.has(entry.path), + ); + if (stillUnresolved.length > 0) { + if (mode === "strict") { + throw new Error( + `${params.commandName}: ${stillUnresolved[0]?.path ?? "target"} is unresolved in the active runtime snapshot.`, + ); + } + scrubUnresolvedAssignments(resolvedConfig, stillUnresolved); + diagnostics = dedupeDiagnostics([ + ...diagnostics, + ...localFallback.diagnostics, + ...buildUnresolvedDiagnostics(params.commandName, stillUnresolved, mode), + ]); + for (const unresolved of stillUnresolved) { + targetStatesByPath[unresolved.path] = "unresolved"; + } + } else if (recoveredPaths.size > 0) { + diagnostics = dedupeDiagnostics([ + ...diagnostics, + `${params.commandName}: resolved ${recoveredPaths.size} secret ${ + recoveredPaths.size === 1 ? "path" : "paths" + } locally after the gateway snapshot was incomplete.`, + ]); + } + } catch (error) { + if (mode === "strict") { + throw error; + } + scrubUnresolvedAssignments(resolvedConfig, analyzed.unresolved); + diagnostics = dedupeDiagnostics([ + ...diagnostics, + `${params.commandName}: local fallback after incomplete gateway snapshot failed (${describeUnknownError(error)}).`, + ...buildUnresolvedDiagnostics(params.commandName, analyzed.unresolved, mode), + ]); + } + } return { resolvedConfig, - diagnostics: dedupeDiagnostics(parsed.diagnostics), + diagnostics, + targetStatesByPath, + hadUnresolvedTargets: Object.values(targetStatesByPath).includes("unresolved"), }; } diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index 065abb3bbf7..f99b9f5b291 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -144,7 +144,7 @@ describe("registerPreActionHooks", () => { runtime: runtimeMock, commandPath: ["status"], }); - expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1); expect(process.title).toBe("openclaw-status"); vi.clearAllMocks(); diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 5984df6e4f4..e1ce076a528 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -33,6 +33,8 @@ const PLUGIN_REQUIRED_COMMANDS = new Set([ "agents", "configure", "onboard", + "status", + "health", ]); const CONFIG_GUARD_BYPASS_COMMANDS = new Set(["doctor", "completion", "secrets"]); const JSON_PARSE_ONLY_COMMANDS = new Set(["config set"]); diff --git a/src/commands/channels.config-only-status-output.test.ts b/src/commands/channels.config-only-status-output.test.ts new file mode 100644 index 00000000000..84ae27cee84 --- /dev/null +++ b/src/commands/channels.config-only-status-output.test.ts @@ -0,0 +1,271 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { formatConfigChannelsStatusLines } from "./channels/status.js"; + +function makeUnavailableTokenPlugin(): ChannelPlugin { + return { + id: "token-only", + meta: { + id: "token-only", + label: "TokenOnly", + selectionLabel: "TokenOnly", + docsPath: "/channels/token-only", + blurb: "test", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["primary"], + defaultAccountId: () => "primary", + resolveAccount: () => ({ + name: "Primary", + enabled: true, + configured: true, + token: "", + tokenSource: "config", + tokenStatus: "configured_unavailable", + }), + isConfigured: () => true, + isEnabled: () => true, + }, + actions: { + listActions: () => ["send"], + }, + }; +} + +function makeResolvedTokenPlugin(): ChannelPlugin { + return { + id: "token-only", + meta: { + id: "token-only", + label: "TokenOnly", + selectionLabel: "TokenOnly", + docsPath: "/channels/token-only", + blurb: "test", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["primary"], + defaultAccountId: () => "primary", + inspectAccount: (cfg) => + (cfg as { secretResolved?: boolean }).secretResolved + ? { + accountId: "primary", + name: "Primary", + enabled: true, + configured: true, + token: "resolved-token", + tokenSource: "config", + tokenStatus: "available", + } + : { + accountId: "primary", + name: "Primary", + enabled: true, + configured: true, + token: "", + tokenSource: "config", + tokenStatus: "configured_unavailable", + }, + resolveAccount: () => ({ + name: "Primary", + enabled: true, + configured: true, + token: "", + tokenSource: "config", + tokenStatus: "configured_unavailable", + }), + isConfigured: () => true, + isEnabled: () => true, + }, + actions: { + listActions: () => ["send"], + }, + }; +} + +function makeResolvedTokenPluginWithoutInspectAccount(): ChannelPlugin { + return { + id: "token-only", + meta: { + id: "token-only", + label: "TokenOnly", + selectionLabel: "TokenOnly", + docsPath: "/channels/token-only", + blurb: "test", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["primary"], + defaultAccountId: () => "primary", + resolveAccount: (cfg) => { + if (!(cfg as { secretResolved?: boolean }).secretResolved) { + throw new Error("raw SecretRef reached resolveAccount"); + } + return { + name: "Primary", + enabled: true, + configured: true, + token: "resolved-token", + tokenSource: "config", + tokenStatus: "available", + }; + }, + isConfigured: () => true, + isEnabled: () => true, + }, + actions: { + listActions: () => ["send"], + }, + }; +} + +function makeUnavailableHttpSlackPlugin(): 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: "resolved-bot", + botTokenSource: "config", + botTokenStatus: "available", + signingSecret: "", + signingSecretSource: "config", + signingSecretStatus: "configured_unavailable", + }), + resolveAccount: () => ({ + name: "Primary", + enabled: true, + configured: true, + }), + isConfigured: () => true, + isEnabled: () => true, + }, + actions: { + listActions: () => ["send"], + }, + }; +} + +describe("config-only channels status output", () => { + afterEach(() => { + setActivePluginRegistry(createTestRegistry([])); + }); + + it("shows configured-but-unavailable credentials distinctly from not configured", async () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "token-only", + source: "test", + plugin: makeUnavailableTokenPlugin(), + }, + ]), + ); + + const lines = await formatConfigChannelsStatusLines({ channels: {} } as never, { + mode: "local", + }); + + const joined = lines.join("\n"); + expect(joined).toContain("TokenOnly"); + expect(joined).toContain("configured, secret unavailable in this command path"); + expect(joined).toContain("token:config (unavailable)"); + }); + + it("prefers resolved config snapshots when command-local secret resolution succeeds", async () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "token-only", + source: "test", + plugin: makeResolvedTokenPlugin(), + }, + ]), + ); + + const lines = await formatConfigChannelsStatusLines( + { secretResolved: true, channels: {} } as never, + { + mode: "local", + }, + { + sourceConfig: { channels: {} } as never, + }, + ); + + const joined = lines.join("\n"); + expect(joined).toContain("TokenOnly"); + expect(joined).toContain("configured"); + expect(joined).toContain("token:config"); + expect(joined).not.toContain("secret unavailable in this command path"); + expect(joined).not.toContain("token:config (unavailable)"); + }); + + it("does not resolve raw source config for extension channels without inspectAccount", async () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "token-only", + source: "test", + plugin: makeResolvedTokenPluginWithoutInspectAccount(), + }, + ]), + ); + + const lines = await formatConfigChannelsStatusLines( + { secretResolved: true, channels: {} } as never, + { + mode: "local", + }, + { + sourceConfig: { channels: {} } as never, + }, + ); + + const joined = lines.join("\n"); + expect(joined).toContain("TokenOnly"); + expect(joined).toContain("configured"); + expect(joined).toContain("token:config"); + expect(joined).not.toContain("secret unavailable in this command path"); + }); + + it("renders Slack HTTP signing-secret availability in config-only status", async () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "slack", + source: "test", + plugin: makeUnavailableHttpSlackPlugin(), + }, + ]), + ); + + const lines = await formatConfigChannelsStatusLines({ channels: {} } as never, { + mode: "local", + }); + + const joined = lines.join("\n"); + expect(joined).toContain("Slack"); + expect(joined).toContain("configured, secret unavailable in this command path"); + expect(joined).toContain("mode:http"); + expect(joined).toContain("bot:config"); + expect(joined).toContain("signing:config (unavailable)"); + }); +}); diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index 9841a69c071..e9e0345871f 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -75,6 +75,7 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti config: loadedRaw, commandName: "channels resolve", targetIds: getChannelsCommandSecretTargetIds(), + mode: "operational_readonly", }); for (const entry of diagnostics) { runtime.log(`[secrets] ${entry}`); diff --git a/src/commands/channels/shared.ts b/src/commands/channels/shared.ts index 03c9e3c9749..d1e9b378518 100644 --- a/src/commands/channels/shared.ts +++ b/src/commands/channels/shared.ts @@ -1,5 +1,8 @@ import { type ChannelId, getChannelPlugin } from "../../channels/plugins/index.js"; -import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js"; +import { + type CommandSecretResolutionMode, + resolveCommandSecretRefsViaGateway, +} from "../../cli/command-secret-gateway.js"; import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; import type { OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; @@ -8,8 +11,14 @@ import { requireValidConfigSnapshot } from "../config-validation.js"; export type ChatChannel = ChannelId; +export { requireValidConfigSnapshot }; + export async function requireValidConfig( runtime: RuntimeEnv = defaultRuntime, + secretResolution?: { + commandName?: string; + mode?: CommandSecretResolutionMode; + }, ): Promise { const cfg = await requireValidConfigSnapshot(runtime); if (!cfg) { @@ -17,8 +26,9 @@ export async function requireValidConfig( } const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({ config: cfg, - commandName: "channels", + commandName: secretResolution?.commandName ?? "channels", targetIds: getChannelsCommandSecretTargetIds(), + mode: secretResolution?.mode, }); for (const entry of diagnostics) { runtime.log(`[secrets] ${entry}`); diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index 882b08d4ca1..3a56810e44c 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -1,7 +1,16 @@ +import { + hasConfiguredUnavailableCredentialStatus, + hasResolvedCredentialValue, +} from "../../channels/account-snapshot-fields.js"; import { listChannelPlugins } from "../../channels/plugins/index.js"; -import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js"; +import { + buildChannelAccountSnapshot, + buildReadOnlySourceChannelAccountSnapshot, +} from "../../channels/plugins/status.js"; import type { ChannelAccountSnapshot } from "../../channels/plugins/types.js"; import { formatCliCommand } from "../../cli/command-format.js"; +import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js"; +import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; import { withProgress } from "../../cli/progress.js"; import { type OpenClawConfig, readConfigFileSnapshot } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; @@ -10,7 +19,11 @@ import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; -import { type ChatChannel, formatChannelAccountLabel, requireValidConfig } from "./shared.js"; +import { + type ChatChannel, + formatChannelAccountLabel, + requireValidConfigSnapshot, +} from "./shared.js"; export type ChannelsStatusOptions = { json?: boolean; @@ -23,7 +36,14 @@ function appendEnabledConfiguredLinkedBits(bits: string[], account: Record) { } function appendTokenSourceBits(bits: string[], account: Record) { - if (typeof account.tokenSource === "string" && account.tokenSource) { - bits.push(`token:${account.tokenSource}`); - } - if (typeof account.botTokenSource === "string" && account.botTokenSource) { - bits.push(`bot:${account.botTokenSource}`); - } - if (typeof account.appTokenSource === "string" && account.appTokenSource) { - bits.push(`app:${account.appTokenSource}`); - } + const appendSourceBit = (label: string, sourceKey: string, statusKey: string) => { + const source = account[sourceKey]; + if (typeof source !== "string" || !source || source === "none") { + return; + } + const status = account[statusKey]; + const unavailable = status === "configured_unavailable" ? " (unavailable)" : ""; + bits.push(`${label}:${source}${unavailable}`); + }; + + appendSourceBit("token", "tokenSource", "tokenStatus"); + appendSourceBit("bot", "botTokenSource", "botTokenStatus"); + appendSourceBit("app", "appTokenSource", "appTokenStatus"); + appendSourceBit("signing", "signingSecretSource", "signingSecretStatus"); } function appendBaseUrlBit(bits: string[], account: Record) { @@ -184,9 +209,10 @@ export function formatGatewayChannelsStatusLines(payload: Record { const lines: string[] = []; lines.push(theme.warn("Gateway not reachable; showing config-only status.")); @@ -211,6 +237,7 @@ async function formatConfigChannelsStatusLines( }); const plugins = listChannelPlugins(); + const sourceConfig = opts?.sourceConfig ?? cfg; for (const plugin of plugins) { const accountIds = plugin.config.listAccountIds(cfg); if (!accountIds.length) { @@ -218,12 +245,24 @@ async function formatConfigChannelsStatusLines( } const snapshots: ChannelAccountSnapshot[] = []; for (const accountId of accountIds) { - const snapshot = await buildChannelAccountSnapshot({ + const sourceSnapshot = await buildReadOnlySourceChannelAccountSnapshot({ + plugin, + cfg: sourceConfig, + accountId, + }); + const resolvedSnapshot = await buildChannelAccountSnapshot({ plugin, cfg, accountId, }); - snapshots.push(snapshot); + snapshots.push( + sourceSnapshot && + hasConfiguredUnavailableCredentialStatus(sourceSnapshot) && + (!hasResolvedCredentialValue(resolvedSnapshot) || + (sourceSnapshot.configured === true && resolvedSnapshot.configured === false)) + ? sourceSnapshot + : resolvedSnapshot, + ); } if (snapshots.length > 0) { lines.push(...accountLines(plugin.id, snapshots)); @@ -268,18 +307,31 @@ export async function channelsStatusCommand( runtime.log(formatGatewayChannelsStatusLines(payload).join("\n")); } catch (err) { runtime.error(`Gateway not reachable: ${String(err)}`); - const cfg = await requireValidConfig(runtime); + const cfg = await requireValidConfigSnapshot(runtime); if (!cfg) { return; } + const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({ + config: cfg, + commandName: "channels status", + targetIds: getChannelsCommandSecretTargetIds(), + mode: "summary", + }); + for (const entry of diagnostics) { + runtime.log(`[secrets] ${entry}`); + } const snapshot = await readConfigFileSnapshot(); const mode = cfg.gateway?.mode === "remote" ? "remote" : "local"; runtime.log( ( - await formatConfigChannelsStatusLines(cfg, { - path: snapshot.path, - mode, - }) + await formatConfigChannelsStatusLines( + resolvedConfig, + { + path: snapshot.path, + mode, + }, + { sourceConfig: cfg }, + ) ).join("\n"), ); } diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 64db401e7cb..2ce46adeb29 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -251,6 +251,54 @@ describe("doctor config flow", () => { } }); + it("does not crash when Telegram allowFrom repair sees unavailable SecretRef-backed credentials", async () => { + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + try { + const result = await runDoctorConfigWithInput({ + repair: true, + config: { + secrets: { + providers: { + default: { source: "env" }, + }, + }, + channels: { + telegram: { + botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" }, + allowFrom: ["@testuser"], + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + + const cfg = result.cfg as { + channels?: { + telegram?: { + allowFrom?: string[]; + accounts?: Record; + }; + }; + }; + const retainedAllowFrom = + cfg.channels?.telegram?.accounts?.default?.allowFrom ?? cfg.channels?.telegram?.allowFrom; + expect(retainedAllowFrom).toEqual(["@testuser"]); + expect(fetchSpy).not.toHaveBeenCalled(); + expect( + noteSpy.mock.calls.some((call) => + String(call[0]).includes( + "configured Telegram bot credentials are unavailable in this command path", + ), + ), + ).toBe(true); + } finally { + noteSpy.mockRestore(); + vi.unstubAllGlobals(); + } + }); + it("converts numeric discord ids to strings on repair", async () => { await withTempHome(async (home) => { const configDir = path.join(home, ".openclaw"); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 8ae2e8d14b8..289b6b047cb 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -8,6 +8,8 @@ import { } from "../channels/telegram/allow-from.js"; import { fetchTelegramChatId } from "../channels/telegram/api.js"; import { formatCliCommand } from "../cli/command-format.js"; +import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; +import { getChannelsCommandSecretTargetIds } from "../cli/command-secret-targets.js"; import { listRouteBindings } from "../config/bindings.js"; import type { OpenClawConfig } from "../config/config.js"; import { CONFIG_PATH, migrateLegacyConfig, readConfigFileSnapshot } from "../config/config.js"; @@ -45,6 +47,7 @@ import { isMattermostMutableAllowEntry, isSlackMutableAllowEntry, } from "../security/mutable-allowlist-detectors.js"; +import { inspectTelegramAccount } from "../telegram/account-inspect.js"; import { listTelegramAccountIds, resolveTelegramAccount } from "../telegram/accounts.js"; import { note } from "../terminal/note.js"; import { isRecord, resolveHomeDir } from "../utils.js"; @@ -464,10 +467,20 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi return { config: cfg, changes: [] }; } + const { resolvedConfig } = await resolveCommandSecretRefsViaGateway({ + config: cfg, + commandName: "doctor --fix", + targetIds: getChannelsCommandSecretTargetIds(), + mode: "summary", + }); + const hasConfiguredUnavailableToken = listTelegramAccountIds(cfg).some((accountId) => { + const inspected = inspectTelegramAccount({ cfg, accountId }); + return inspected.enabled && inspected.tokenStatus === "configured_unavailable"; + }); const tokens = Array.from( new Set( - listTelegramAccountIds(cfg) - .map((accountId) => resolveTelegramAccount({ cfg, accountId })) + listTelegramAccountIds(resolvedConfig) + .map((accountId) => resolveTelegramAccount({ cfg: resolvedConfig, accountId })) .map((account) => (account.tokenSource === "none" ? "" : account.token)) .map((token) => token.trim()) .filter(Boolean), @@ -478,7 +491,9 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi return { config: cfg, changes: [ - `- Telegram allowFrom contains @username entries, but no Telegram bot token is configured; cannot auto-resolve (run onboarding or replace with numeric sender IDs).`, + hasConfiguredUnavailableToken + ? `- Telegram allowFrom contains @username entries, but configured Telegram bot credentials are unavailable in this command path; cannot auto-resolve (start the gateway or make the secret source available, then rerun doctor --fix).` + : `- Telegram allowFrom contains @username entries, but no Telegram bot token is configured; cannot auto-resolve (run onboarding or replace with numeric sender IDs).`, ], }; } diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index 53e0c3af55a..285e0884a43 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -43,6 +43,7 @@ export async function statusAllCommand( config: loadedRaw, commandName: "status --all", targetIds: getStatusCommandSecretTargetIds(), + mode: "summary", }); const osSummary = resolveOsSummary(); const snap = await readConfigFileSnapshot().catch(() => null); @@ -159,7 +160,10 @@ export async function statusAllCommand( const agentStatus = await getAgentLocalStatuses(cfg); progress.tick(); progress.setLabel("Summarizing channels…"); - const channels = await buildChannelsTable(cfg, { showSecrets: false }); + const channels = await buildChannelsTable(cfg, { + showSecrets: false, + sourceConfig: loadedRaw, + }); progress.tick(); const connectionDetailsForReport = (() => { diff --git a/src/commands/status-all/channels.mattermost-token-summary.test.ts b/src/commands/status-all/channels.mattermost-token-summary.test.ts index 53614388667..3d0a84d3ee6 100644 --- a/src/commands/status-all/channels.mattermost-token-summary.test.ts +++ b/src/commands/status-all/channels.mattermost-token-summary.test.ts @@ -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()]); diff --git a/src/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index c4b32ec46f2..bfa4fa03112 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -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 => value && typeof value === "object" ? (value as Record) : {}; @@ -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 { + 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) => + typeof rec.mode === "string" && rec.mode.trim() === "http"; + const hasCredentialAvailable = ( + rec: Record, + 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(" · "), }; }), diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index eee7949b75e..688ddd726dd 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -1,6 +1,6 @@ import { formatCliCommand } from "../cli/command-format.js"; import { withProgress } from "../cli/progress.js"; -import { loadConfig, resolveGatewayPort } from "../config/config.js"; +import { resolveGatewayPort } from "../config/config.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { info } from "../globals.js"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; @@ -80,33 +80,33 @@ export async function statusCommand( return; } - const [scan, securityAudit] = opts.json - ? await Promise.all([ - scanStatus({ json: opts.json, timeoutMs: opts.timeoutMs, all: opts.all }, runtime), - runSecurityAudit({ - config: loadConfig(), - deep: false, - includeFilesystem: true, - includeChannelSecurity: true, - }), - ]) - : [ - await scanStatus({ json: opts.json, timeoutMs: opts.timeoutMs, all: opts.all }, runtime), - await withProgress( - { - label: "Running security audit…", - indeterminate: true, - enabled: true, - }, - async () => - await runSecurityAudit({ - config: loadConfig(), - deep: false, - includeFilesystem: true, - includeChannelSecurity: true, - }), - ), - ]; + const scan = await scanStatus( + { json: opts.json, timeoutMs: opts.timeoutMs, all: opts.all }, + runtime, + ); + const securityAudit = opts.json + ? await runSecurityAudit({ + config: scan.cfg, + sourceConfig: scan.sourceConfig, + deep: false, + includeFilesystem: true, + includeChannelSecurity: true, + }) + : await withProgress( + { + label: "Running security audit…", + indeterminate: true, + enabled: true, + }, + async () => + await runSecurityAudit({ + config: scan.cfg, + sourceConfig: scan.sourceConfig, + deep: false, + includeFilesystem: true, + includeChannelSecurity: true, + }), + ); const { cfg, osSummary, @@ -126,6 +126,7 @@ export async function statusCommand( agentStatus, channels, summary, + secretDiagnostics, memory, memoryPlugin, } = scan; @@ -202,6 +203,7 @@ export async function statusCommand( nodeService: nodeDaemon, agents: agentStatus, securityAudit, + secretDiagnostics, ...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}), }, null, @@ -227,6 +229,14 @@ export async function statusCommand( const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + if (secretDiagnostics.length > 0) { + runtime.log(theme.warn("Secret diagnostics:")); + for (const entry of secretDiagnostics) { + runtime.log(`- ${entry}`); + } + runtime.log(""); + } + const dashboard = (() => { const controlUiEnabled = cfg.gateway?.controlUi?.enabled ?? true; if (!controlUiEnabled) { diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts new file mode 100644 index 00000000000..721d4fdeea4 --- /dev/null +++ b/src/commands/status.scan.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + loadConfig: vi.fn(), + resolveCommandSecretRefsViaGateway: vi.fn(), + buildChannelsTable: vi.fn(), + getUpdateCheckResult: vi.fn(), + getAgentLocalStatuses: vi.fn(), + getStatusSummary: vi.fn(), + buildGatewayConnectionDetails: vi.fn(), + probeGateway: vi.fn(), + resolveGatewayProbeAuthResolution: vi.fn(), +})); + +vi.mock("../cli/progress.js", () => ({ + withProgress: vi.fn(async (_opts, run) => await run({ setLabel: vi.fn(), tick: vi.fn() })), +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: mocks.loadConfig, +})); + +vi.mock("../cli/command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway, +})); + +vi.mock("./status-all/channels.js", () => ({ + buildChannelsTable: mocks.buildChannelsTable, +})); + +vi.mock("./status.update.js", () => ({ + getUpdateCheckResult: mocks.getUpdateCheckResult, +})); + +vi.mock("./status.agent-local.js", () => ({ + getAgentLocalStatuses: mocks.getAgentLocalStatuses, +})); + +vi.mock("./status.summary.js", () => ({ + getStatusSummary: mocks.getStatusSummary, +})); + +vi.mock("../infra/os-summary.js", () => ({ + resolveOsSummary: vi.fn(() => ({ label: "test-os" })), +})); + +vi.mock("../infra/tailscale.js", () => ({ + getTailnetHostname: vi.fn(), +})); + +vi.mock("../gateway/call.js", () => ({ + buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails, + callGateway: vi.fn(), +})); + +vi.mock("../gateway/probe.js", () => ({ + probeGateway: mocks.probeGateway, +})); + +vi.mock("./status.gateway-probe.js", () => ({ + pickGatewaySelfPresence: vi.fn(() => null), + resolveGatewayProbeAuthResolution: mocks.resolveGatewayProbeAuthResolution, +})); + +vi.mock("../memory/index.js", () => ({ + getMemorySearchManager: vi.fn(), +})); + +vi.mock("../process/exec.js", () => ({ + runExec: vi.fn(), +})); + +import { scanStatus } from "./status.scan.js"; + +describe("scanStatus", () => { + it("passes sourceConfig into buildChannelsTable for summary-mode status output", async () => { + mocks.loadConfig.mockReturnValue({ + marker: "source", + session: {}, + plugins: { enabled: false }, + gateway: {}, + }); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { + marker: "resolved", + session: {}, + plugins: { enabled: false }, + gateway: {}, + }, + diagnostics: [], + }); + mocks.getUpdateCheckResult.mockResolvedValue({ + installKind: "git", + git: null, + registry: null, + }); + mocks.getAgentLocalStatuses.mockResolvedValue({ + defaultId: "main", + agents: [], + }); + mocks.getStatusSummary.mockResolvedValue({ + linkChannel: { linked: false }, + sessions: { count: 0, paths: [], defaults: {}, recent: [] }, + }); + mocks.buildGatewayConnectionDetails.mockReturnValue({ + url: "ws://127.0.0.1:18789", + urlSource: "default", + }); + mocks.resolveGatewayProbeAuthResolution.mockReturnValue({ + auth: {}, + warning: undefined, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + mocks.buildChannelsTable.mockResolvedValue({ + rows: [], + details: [], + }); + + await scanStatus({ json: false }, {} as never); + + expect(mocks.buildChannelsTable).toHaveBeenCalledWith( + expect.objectContaining({ marker: "resolved" }), + expect.objectContaining({ + sourceConfig: expect.objectContaining({ marker: "source" }), + }), + ); + }); +}); diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 4fb161b7425..bce208af0cc 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -125,6 +125,8 @@ async function resolveChannelsStatus(params: { export type StatusScanResult = { cfg: ReturnType; + sourceConfig: ReturnType; + secretDiagnostics: string[]; osSummary: ReturnType; tailscaleMode: string; tailscaleDns: string | null; @@ -179,11 +181,13 @@ async function scanStatusJsonFast(opts: { all?: boolean; }): Promise { const loadedRaw = loadConfig(); - const { resolvedConfig: cfg } = await resolveCommandSecretRefsViaGateway({ - config: loadedRaw, - commandName: "status --json", - targetIds: getStatusCommandSecretTargetIds(), - }); + const { resolvedConfig: cfg, diagnostics: secretDiagnostics } = + await resolveCommandSecretRefsViaGateway({ + config: loadedRaw, + commandName: "status --json", + targetIds: getStatusCommandSecretTargetIds(), + mode: "summary", + }); const osSummary = resolveOsSummary(); const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; const updateTimeoutMs = opts.all ? 6500 : 2500; @@ -193,7 +197,7 @@ async function scanStatusJsonFast(opts: { includeRegistry: true, }); const agentStatusPromise = getAgentLocalStatuses(); - const summaryPromise = getStatusSummary({ config: cfg }); + const summaryPromise = getStatusSummary({ config: cfg, sourceConfig: loadedRaw }); const tailscaleDnsPromise = tailscaleMode === "off" @@ -236,6 +240,8 @@ async function scanStatusJsonFast(opts: { return { cfg, + sourceConfig: loadedRaw, + secretDiagnostics, osSummary, tailscaleMode, tailscaleDns, @@ -278,11 +284,13 @@ export async function scanStatus( async (progress) => { progress.setLabel("Loading config…"); const loadedRaw = loadConfig(); - const { resolvedConfig: cfg } = await resolveCommandSecretRefsViaGateway({ - config: loadedRaw, - commandName: "status", - targetIds: getStatusCommandSecretTargetIds(), - }); + const { resolvedConfig: cfg, diagnostics: secretDiagnostics } = + await resolveCommandSecretRefsViaGateway({ + config: loadedRaw, + commandName: "status", + targetIds: getStatusCommandSecretTargetIds(), + mode: "summary", + }); const osSummary = resolveOsSummary(); const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; const tailscaleDnsPromise = @@ -300,7 +308,9 @@ export async function scanStatus( }), ); const agentStatusPromise = deferResult(getAgentLocalStatuses()); - const summaryPromise = deferResult(getStatusSummary({ config: cfg })); + const summaryPromise = deferResult( + getStatusSummary({ config: cfg, sourceConfig: loadedRaw }), + ); progress.tick(); progress.setLabel("Checking Tailscale…"); @@ -344,6 +354,7 @@ export async function scanStatus( // Show token previews in regular status; keep `status --all` redacted. // Set `CLAWDBOT_SHOW_SECRETS=0` to force redaction. showSecrets: process.env.CLAWDBOT_SHOW_SECRETS?.trim() !== "0", + sourceConfig: loadedRaw, }); progress.tick(); @@ -361,6 +372,8 @@ export async function scanStatus( return { cfg, + sourceConfig: loadedRaw, + secretDiagnostics, osSummary, tailscaleMode, tailscaleDns, diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index f0d38bb4ad6..3a71464973f 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -77,7 +77,11 @@ export function redactSensitiveStatusSummary(summary: StatusSummary): StatusSumm } export async function getStatusSummary( - options: { includeSensitive?: boolean; config?: OpenClawConfig } = {}, + options: { + includeSensitive?: boolean; + config?: OpenClawConfig; + sourceConfig?: OpenClawConfig; + } = {}, ): Promise { const { includeSensitive = true } = options; const cfg = options.config ?? loadConfig(); @@ -95,6 +99,7 @@ export async function getStatusSummary( const channelSummary = await buildChannelSummary(cfg, { colorize: true, includeAllowFrom: true, + sourceConfig: options.sourceConfig, }); const mainSessionKey = resolveMainSessionKey(cfg); const queuedSystemEvents = peekSystemEvents(mainSessionKey); diff --git a/src/discord/account-inspect.ts b/src/discord/account-inspect.ts new file mode 100644 index 00000000000..0ece2072744 --- /dev/null +++ b/src/discord/account-inspect.ts @@ -0,0 +1,141 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { DiscordAccountConfig } from "../config/types.discord.js"; +import { hasConfiguredSecretInput, normalizeSecretInputString } from "../config/types.secrets.js"; +import { resolveAccountEntry } from "../routing/account-lookup.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import { resolveDefaultDiscordAccountId } from "./accounts.js"; + +export type DiscordCredentialStatus = "available" | "configured_unavailable" | "missing"; + +export type InspectedDiscordAccount = { + accountId: string; + enabled: boolean; + name?: string; + token: string; + tokenSource: "env" | "config" | "none"; + tokenStatus: DiscordCredentialStatus; + configured: boolean; + config: DiscordAccountConfig; +}; + +function resolveDiscordAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): DiscordAccountConfig | undefined { + return resolveAccountEntry(cfg.channels?.discord?.accounts, accountId); +} + +function mergeDiscordAccountConfig(cfg: OpenClawConfig, accountId: string): DiscordAccountConfig { + const { accounts: _ignored, ...base } = (cfg.channels?.discord ?? {}) as DiscordAccountConfig & { + accounts?: unknown; + }; + const account = resolveDiscordAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account }; +} + +function inspectDiscordTokenValue(value: unknown): { + token: string; + tokenSource: "config"; + tokenStatus: Exclude; +} | null { + const normalized = normalizeSecretInputString(value); + if (normalized) { + return { + token: normalized.replace(/^Bot\s+/i, ""), + tokenSource: "config", + tokenStatus: "available", + }; + } + if (hasConfiguredSecretInput(value)) { + return { + token: "", + tokenSource: "config", + tokenStatus: "configured_unavailable", + }; + } + return null; +} + +export function inspectDiscordAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; + envToken?: string | null; +}): InspectedDiscordAccount { + const accountId = normalizeAccountId( + params.accountId ?? resolveDefaultDiscordAccountId(params.cfg), + ); + const merged = mergeDiscordAccountConfig(params.cfg, accountId); + const enabled = params.cfg.channels?.discord?.enabled !== false && merged.enabled !== false; + const accountConfig = resolveDiscordAccountConfig(params.cfg, accountId); + const hasAccountToken = Boolean( + accountConfig && + Object.prototype.hasOwnProperty.call(accountConfig as Record, "token"), + ); + const accountToken = inspectDiscordTokenValue(accountConfig?.token); + if (accountToken) { + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: accountToken.token, + tokenSource: accountToken.tokenSource, + tokenStatus: accountToken.tokenStatus, + configured: true, + config: merged, + }; + } + if (hasAccountToken) { + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: "", + tokenSource: "none", + tokenStatus: "missing", + configured: false, + config: merged, + }; + } + + const channelToken = inspectDiscordTokenValue(params.cfg.channels?.discord?.token); + if (channelToken) { + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: channelToken.token, + tokenSource: channelToken.tokenSource, + tokenStatus: channelToken.tokenStatus, + configured: true, + config: merged, + }; + } + + const allowEnv = accountId === DEFAULT_ACCOUNT_ID; + const envToken = allowEnv + ? normalizeSecretInputString(params.envToken ?? process.env.DISCORD_BOT_TOKEN) + : undefined; + if (envToken) { + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: envToken.replace(/^Bot\s+/i, ""), + tokenSource: "env", + tokenStatus: "available", + configured: true, + config: merged, + }; + } + + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: "", + tokenSource: "none", + tokenStatus: "missing", + configured: false, + config: merged, + }; +} diff --git a/src/discord/audit.test.ts b/src/discord/audit.test.ts index 4474f70bd7f..55339b03381 100644 --- a/src/discord/audit.test.ts +++ b/src/discord/audit.test.ts @@ -104,4 +104,33 @@ describe("discord audit", () => { expect(collected.channelIds).toEqual([]); expect(collected.unresolvedChannels).toBe(0); }); + + it("collects audit channel ids without resolving SecretRef-backed Discord tokens", async () => { + const { collectDiscordAuditChannelIds } = await import("./audit.js"); + + const cfg = { + channels: { + discord: { + enabled: true, + token: { + source: "env", + provider: "default", + id: "DISCORD_BOT_TOKEN", + }, + guilds: { + "123": { + channels: { + "111": { allow: true }, + general: { allow: true }, + }, + }, + }, + }, + }, + } as unknown as import("../config/config.js").OpenClawConfig; + + const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" }); + expect(collected.channelIds).toEqual(["111"]); + expect(collected.unresolvedChannels).toBe(1); + }); }); diff --git a/src/discord/audit.ts b/src/discord/audit.ts index 8350d67e39a..d2a6477e47f 100644 --- a/src/discord/audit.ts +++ b/src/discord/audit.ts @@ -1,7 +1,7 @@ import type { OpenClawConfig } from "../config/config.js"; import type { DiscordGuildChannelConfig, DiscordGuildEntry } from "../config/types.js"; import { isRecord } from "../utils.js"; -import { resolveDiscordAccount } from "./accounts.js"; +import { inspectDiscordAccount } from "./account-inspect.js"; import { fetchChannelPermissionsDiscord } from "./send.js"; export type DiscordChannelPermissionsAuditEntry = { @@ -74,7 +74,7 @@ export function collectDiscordAuditChannelIds(params: { cfg: OpenClawConfig; accountId?: string | null; }) { - const account = resolveDiscordAccount({ + const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId, }); diff --git a/src/discord/ui.ts b/src/discord/ui.ts index e9533dbb332..d4238deac2e 100644 --- a/src/discord/ui.ts +++ b/src/discord/ui.ts @@ -1,6 +1,6 @@ import { Container } from "@buape/carbon"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveDiscordAccount } from "./accounts.js"; +import { inspectDiscordAccount } from "./account-inspect.js"; const DEFAULT_DISCORD_ACCENT_COLOR = "#5865F2"; @@ -24,7 +24,7 @@ export function normalizeDiscordAccentColor(raw?: string | null): string | null } export function resolveDiscordAccentColor(params: ResolveDiscordAccentColorParams): string { - const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); + const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); const configured = normalizeDiscordAccentColor(account.config.ui?.components?.accentColor); return configured ?? DEFAULT_DISCORD_ACCENT_COLOR; } diff --git a/src/infra/channel-summary.test.ts b/src/infra/channel-summary.test.ts new file mode 100644 index 00000000000..d56bdd7ac1e --- /dev/null +++ b/src/infra/channel-summary.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; + +vi.mock("../channels/plugins/index.js", () => ({ + listChannelPlugins: vi.fn(), +})); + +const { buildChannelSummary } = await import("./channel-summary.js"); +const { listChannelPlugins } = await import("../channels/plugins/index.js"); + +function makeSlackHttpSummaryPlugin(): 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" + ? { + accountId: "primary", + name: "Primary", + enabled: true, + configured: true, + mode: "http", + botToken: "xoxb-http", + signingSecret: "", + botTokenSource: "config", + signingSecretSource: "config", + botTokenStatus: "available", + signingSecretStatus: "configured_unavailable", + } + : { + accountId: "primary", + name: "Primary", + enabled: true, + configured: false, + mode: "http", + botToken: "xoxb-http", + botTokenSource: "config", + botTokenStatus: "available", + }, + resolveAccount: () => ({ + accountId: "primary", + name: "Primary", + enabled: true, + configured: false, + mode: "http", + botToken: "xoxb-http", + botTokenSource: "config", + botTokenStatus: "available", + }), + isConfigured: (account) => Boolean((account as { configured?: boolean }).configured), + isEnabled: () => true, + }, + actions: { + listActions: () => ["send"], + }, + }; +} + +describe("buildChannelSummary", () => { + it("preserves Slack HTTP signing-secret unavailable state from source config", async () => { + vi.mocked(listChannelPlugins).mockReturnValue([makeSlackHttpSummaryPlugin()]); + + const lines = await buildChannelSummary({ marker: "resolved", channels: {} } as never, { + colorize: false, + includeAllowFrom: false, + sourceConfig: { marker: "source", channels: {} } as never, + }); + + expect(lines).toContain("Slack: configured"); + expect(lines).toContain( + " - primary (Primary) (bot:config, signing:config, secret unavailable in this command path)", + ); + }); +}); diff --git a/src/infra/channel-summary.ts b/src/infra/channel-summary.ts index 19114a367e8..f412d687fd1 100644 --- a/src/infra/channel-summary.ts +++ b/src/infra/channel-summary.ts @@ -1,3 +1,7 @@ +import { + hasConfiguredUnavailableCredentialStatus, + hasResolvedCredentialValue, +} from "../channels/account-snapshot-fields.js"; import { buildChannelAccountSnapshot, formatChannelAllowFrom, @@ -6,6 +10,7 @@ import { } from "../channels/account-summary.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelAccountSnapshot, ChannelPlugin } from "../channels/plugins/types.js"; +import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; import { type OpenClawConfig, loadConfig } from "../config/config.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { theme } from "../terminal/theme.js"; @@ -14,9 +19,10 @@ import { formatTimeAgo } from "./format-time/format-relative.ts"; export type ChannelSummaryOptions = { colorize?: boolean; includeAllowFrom?: boolean; + sourceConfig?: OpenClawConfig; }; -const DEFAULT_OPTIONS: Required = { +const DEFAULT_OPTIONS: Omit, "sourceConfig"> = { colorize: false, includeAllowFrom: false, }; @@ -63,6 +69,12 @@ const buildAccountDetails = (params: { if (snapshot.appTokenSource && snapshot.appTokenSource !== "none") { details.push(`app:${snapshot.appTokenSource}`); } + if (snapshot.signingSecretSource && snapshot.signingSecretSource !== "none") { + details.push(`signing:${snapshot.signingSecretSource}`); + } + if (hasConfiguredUnavailableCredentialStatus(params.entry.account)) { + details.push("secret unavailable in this command path"); + } if (snapshot.baseUrl) { details.push(snapshot.baseUrl); } @@ -90,6 +102,17 @@ const buildAccountDetails = (params: { return details; }; +function inspectChannelAccount(plugin: ChannelPlugin, cfg: OpenClawConfig, accountId: string) { + return ( + plugin.config.inspectAccount?.(cfg, accountId) ?? + inspectReadOnlyChannelAccount({ + channelId: plugin.id, + cfg, + accountId, + }) + ); +} + export async function buildChannelSummary( cfg?: OpenClawConfig, options?: ChannelSummaryOptions, @@ -99,6 +122,7 @@ export async function buildChannelSummary( const resolved = { ...DEFAULT_OPTIONS, ...options }; const tint = (value: string, color?: (input: string) => string) => resolved.colorize && color ? color(value) : value; + const sourceConfig = options?.sourceConfig ?? effective; for (const plugin of listChannelPlugins()) { const accountIds = plugin.config.listAccountIds(effective); @@ -108,13 +132,39 @@ export async function buildChannelSummary( const entries: ChannelAccountEntry[] = []; for (const accountId of resolvedAccountIds) { - const account = plugin.config.resolveAccount(effective, accountId); - const enabled = resolveChannelAccountEnabled({ plugin, account, cfg: effective }); - const configured = await resolveChannelAccountConfigured({ - plugin, - account, - cfg: effective, - }); + const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId); + const resolvedInspectedAccount = inspectChannelAccount(plugin, effective, 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(effective, 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: effective }); + const configured = + selectedInspection?.configured ?? + (await resolveChannelAccountConfigured({ + plugin, + account, + cfg: effective, + readAccountConfiguredField: true, + })); const snapshot = buildChannelAccountSnapshot({ plugin, account, diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index 26a7b5c5031..f9c4b6051df 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -1,6 +1,7 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; +export type { InspectedDiscordAccount } from "../discord/account-inspect.js"; export type { ResolvedDiscordAccount } from "../discord/accounts.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; @@ -28,6 +29,11 @@ export { resolveDefaultDiscordAccountId, resolveDiscordAccount, } from "../discord/accounts.js"; +export { inspectDiscordAccount } from "../discord/account-inspect.js"; +export { + projectCredentialSnapshotFields, + resolveConfiguredFromCredentialStatuses, +} from "../channels/account-snapshot-fields.js"; export { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 7a7c43a53c9..2b8fc8e7a63 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -553,6 +553,8 @@ export { resolveDiscordAccount, type ResolvedDiscordAccount, } from "../discord/accounts.js"; +export { inspectDiscordAccount } from "../discord/account-inspect.js"; +export type { InspectedDiscordAccount } from "../discord/account-inspect.js"; export { collectDiscordAuditChannelIds } from "../discord/audit.js"; export { discordOnboardingAdapter } from "../channels/plugins/onboarding/discord.js"; export { @@ -591,6 +593,8 @@ export { resolveSlackReplyToMode, type ResolvedSlackAccount, } from "../slack/accounts.js"; +export { inspectSlackAccount } from "../slack/account-inspect.js"; +export type { InspectedSlackAccount } from "../slack/account-inspect.js"; export { extractSlackToolSend, listSlackMessageActions } from "../slack/message-actions.js"; export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js"; export { @@ -606,6 +610,8 @@ export { resolveTelegramAccount, type ResolvedTelegramAccount, } from "../telegram/accounts.js"; +export { inspectTelegramAccount } from "../telegram/account-inspect.js"; +export type { InspectedTelegramAccount } from "../telegram/account-inspect.js"; export { telegramOnboardingAdapter } from "../channels/plugins/onboarding/telegram.js"; export { looksLikeTelegramTargetId, diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index af338f46b70..b0df1329bb9 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -1,5 +1,6 @@ export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; +export type { InspectedSlackAccount } from "../slack/account-inspect.js"; export type { ResolvedSlackAccount } from "../slack/accounts.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; @@ -27,6 +28,12 @@ export { resolveSlackAccount, resolveSlackReplyToMode, } from "../slack/accounts.js"; +export { inspectSlackAccount } from "../slack/account-inspect.js"; +export { + projectCredentialSnapshotFields, + resolveConfiguredFromCredentialStatuses, + resolveConfiguredFromRequiredCredentialStatuses, +} from "../channels/account-snapshot-fields.js"; export { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig, diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 061230bb7ca..7d9e76ec6bc 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -5,6 +5,7 @@ import * as lineSdk from "openclaw/plugin-sdk/line"; import * as msteamsSdk from "openclaw/plugin-sdk/msteams"; import * as signalSdk from "openclaw/plugin-sdk/signal"; import * as slackSdk from "openclaw/plugin-sdk/slack"; +import * as telegramSdk from "openclaw/plugin-sdk/telegram"; import * as whatsappSdk from "openclaw/plugin-sdk/whatsapp"; import { describe, expect, it } from "vitest"; @@ -56,14 +57,22 @@ describe("plugin-sdk subpath exports", () => { it("exports Discord helpers", () => { expect(typeof discordSdk.resolveDiscordAccount).toBe("function"); + expect(typeof discordSdk.inspectDiscordAccount).toBe("function"); expect(typeof discordSdk.discordOnboardingAdapter).toBe("object"); }); it("exports Slack helpers", () => { expect(typeof slackSdk.resolveSlackAccount).toBe("function"); + expect(typeof slackSdk.inspectSlackAccount).toBe("function"); expect(typeof slackSdk.handleSlackMessageAction).toBe("function"); }); + it("exports Telegram helpers", () => { + expect(typeof telegramSdk.resolveTelegramAccount).toBe("function"); + expect(typeof telegramSdk.inspectTelegramAccount).toBe("function"); + expect(typeof telegramSdk.telegramOnboardingAdapter).toBe("object"); + }); + it("exports Signal helpers", () => { expect(typeof signalSdk.resolveSignalAccount).toBe("function"); expect(typeof signalSdk.signalOnboardingAdapter).toBe("object"); diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 75dfc920c29..c4dfce3e441 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -7,6 +7,7 @@ export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; +export type { InspectedTelegramAccount } from "../telegram/account-inspect.js"; export type { ResolvedTelegramAccount } from "../telegram/accounts.js"; export type { TelegramProbe } from "../telegram/probe.js"; @@ -33,6 +34,11 @@ export { resolveDefaultTelegramAccountId, resolveTelegramAccount, } from "../telegram/accounts.js"; +export { inspectTelegramAccount } from "../telegram/account-inspect.js"; +export { + projectCredentialSnapshotFields, + resolveConfiguredFromCredentialStatuses, +} from "../channels/account-snapshot-fields.js"; export { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig, diff --git a/src/secrets/command-config.ts b/src/secrets/command-config.ts index 6c2b436a13f..dc542eba00b 100644 --- a/src/secrets/command-config.ts +++ b/src/secrets/command-config.ts @@ -15,18 +15,35 @@ export type ResolveAssignmentsFromSnapshotResult = { diagnostics: string[]; }; -export function collectCommandSecretAssignmentsFromSnapshot(params: { +export type UnresolvedCommandSecretAssignment = { + path: string; + pathSegments: string[]; +}; + +export type AnalyzeAssignmentsFromSnapshotResult = { + assignments: CommandSecretAssignment[]; + diagnostics: string[]; + unresolved: UnresolvedCommandSecretAssignment[]; + inactive: UnresolvedCommandSecretAssignment[]; +}; + +export function analyzeCommandSecretAssignmentsFromSnapshot(params: { sourceConfig: OpenClawConfig; resolvedConfig: OpenClawConfig; - commandName: string; targetIds: ReadonlySet; inactiveRefPaths?: ReadonlySet; -}): ResolveAssignmentsFromSnapshotResult { + allowedPaths?: ReadonlySet; +}): AnalyzeAssignmentsFromSnapshotResult { const defaults = params.sourceConfig.secrets?.defaults; const assignments: CommandSecretAssignment[] = []; const diagnostics: string[] = []; + const unresolved: UnresolvedCommandSecretAssignment[] = []; + const inactive: UnresolvedCommandSecretAssignment[] = []; for (const target of discoverConfigSecretTargetsByIds(params.sourceConfig, params.targetIds)) { + if (params.allowedPaths && !params.allowedPaths.has(target.path)) { + continue; + } const { explicitRef, ref } = resolveSecretInputRef({ value: target.value, refValue: target.refValue, @@ -43,11 +60,17 @@ export function collectCommandSecretAssignmentsFromSnapshot(params: { diagnostics.push( `${target.path}: secret ref is configured on an inactive surface; skipping command-time assignment.`, ); + inactive.push({ + path: target.path, + pathSegments: [...target.pathSegments], + }); continue; } - throw new Error( - `${params.commandName}: ${target.path} is unresolved in the active runtime snapshot.`, - ); + unresolved.push({ + path: target.path, + pathSegments: [...target.pathSegments], + }); + continue; } assignments.push({ @@ -63,5 +86,31 @@ export function collectCommandSecretAssignmentsFromSnapshot(params: { } } - return { assignments, diagnostics }; + return { assignments, diagnostics, unresolved, inactive }; +} + +export function collectCommandSecretAssignmentsFromSnapshot(params: { + sourceConfig: OpenClawConfig; + resolvedConfig: OpenClawConfig; + commandName: string; + targetIds: ReadonlySet; + inactiveRefPaths?: ReadonlySet; + allowedPaths?: ReadonlySet; +}): ResolveAssignmentsFromSnapshotResult { + const analyzed = analyzeCommandSecretAssignmentsFromSnapshot({ + sourceConfig: params.sourceConfig, + resolvedConfig: params.resolvedConfig, + targetIds: params.targetIds, + inactiveRefPaths: params.inactiveRefPaths, + allowedPaths: params.allowedPaths, + }); + if (analyzed.unresolved.length > 0) { + throw new Error( + `${params.commandName}: ${analyzed.unresolved[0]?.path ?? "target"} is unresolved in the active runtime snapshot.`, + ); + } + return { + assignments: analyzed.assignments, + diagnostics: analyzed.diagnostics, + }; } diff --git a/src/security/audit-channel.ts b/src/security/audit-channel.ts index cfd216d90e9..70a21cf729c 100644 --- a/src/security/audit-channel.ts +++ b/src/security/audit-channel.ts @@ -1,6 +1,11 @@ +import { + hasConfiguredUnavailableCredentialStatus, + hasResolvedCredentialValue, +} from "../channels/account-snapshot-fields.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import type { listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; +import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; import { isNumericTelegramUserId, normalizeTelegramAllowFromEntry, @@ -113,9 +118,72 @@ function hasExplicitProviderAccountConfig( export async function collectChannelSecurityFindings(params: { cfg: OpenClawConfig; + sourceConfig?: OpenClawConfig; plugins: ReturnType; }): Promise { const findings: SecurityAuditFinding[] = []; + const sourceConfig = params.sourceConfig ?? params.cfg; + + const inspectChannelAccount = ( + plugin: (typeof params.plugins)[number], + cfg: OpenClawConfig, + accountId: string, + ) => + plugin.config.inspectAccount?.(cfg, accountId) ?? + inspectReadOnlyChannelAccount({ + channelId: plugin.id, + cfg, + accountId, + }); + + const asAccountRecord = (value: unknown): Record | null => + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; + + const resolveChannelAuditAccount = async ( + plugin: (typeof params.plugins)[number], + accountId: string, + ) => { + const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId); + const resolvedInspectedAccount = inspectChannelAccount(plugin, params.cfg, accountId); + const sourceInspection = sourceInspectedAccount as { + enabled?: boolean; + configured?: boolean; + } | null; + const resolvedInspection = resolvedInspectedAccount as { + enabled?: boolean; + configured?: boolean; + } | null; + const resolvedAccount = + resolvedInspectedAccount ?? plugin.config.resolveAccount(params.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 accountRecord = asAccountRecord(account); + const enabled = + typeof selectedInspection?.enabled === "boolean" + ? selectedInspection.enabled + : typeof accountRecord?.enabled === "boolean" + ? accountRecord.enabled + : plugin.config.isEnabled + ? plugin.config.isEnabled(account, params.cfg) + : true; + const configured = + typeof selectedInspection?.configured === "boolean" + ? selectedInspection.configured + : typeof accountRecord?.configured === "boolean" + ? accountRecord.configured + : plugin.config.isConfigured + ? await plugin.config.isConfigured(account, params.cfg) + : true; + return { account, enabled, configured }; + }; const coerceNativeSetting = (value: unknown): boolean | "auto" | undefined => { if (value === true) { @@ -197,28 +265,24 @@ export async function collectChannelSecurityFindings(params: { if (!plugin.security) { continue; } - const accountIds = plugin.config.listAccountIds(params.cfg); + const accountIds = plugin.config.listAccountIds(sourceConfig); const defaultAccountId = resolveChannelDefaultAccountId({ plugin, - cfg: params.cfg, + cfg: sourceConfig, accountIds, }); const orderedAccountIds = Array.from(new Set([defaultAccountId, ...accountIds])); for (const accountId of orderedAccountIds) { const hasExplicitAccountPath = hasExplicitProviderAccountConfig( - params.cfg, + sourceConfig, plugin.id, accountId, ); - const account = plugin.config.resolveAccount(params.cfg, accountId); - const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, params.cfg) : true; + const { account, enabled, configured } = await resolveChannelAuditAccount(plugin, accountId); if (!enabled) { continue; } - const configured = plugin.config.isConfigured - ? await plugin.config.isConfigured(account, params.cfg) - : true; if (!configured) { continue; } diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index a681273beff..0cae6c88256 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -30,7 +30,10 @@ function stubChannelPlugin(params: { id: "discord" | "slack" | "telegram"; label: string; resolveAccount: (cfg: OpenClawConfig, accountId: string | null | undefined) => unknown; + inspectAccount?: (cfg: OpenClawConfig, accountId: string | null | undefined) => unknown; listAccountIds?: (cfg: OpenClawConfig) => string[]; + isConfigured?: (account: unknown, cfg: OpenClawConfig) => boolean; + isEnabled?: (account: unknown, cfg: OpenClawConfig) => boolean; }): ChannelPlugin { return { id: params.id, @@ -54,9 +57,10 @@ function stubChannelPlugin(params: { ); return enabled ? ["default"] : []; }), + inspectAccount: params.inspectAccount, resolveAccount: (cfg, accountId) => params.resolveAccount(cfg, accountId), - isEnabled: () => true, - isConfigured: () => true, + isEnabled: (account, cfg) => params.isEnabled?.(account, cfg) ?? true, + isConfigured: (account, cfg) => params.isConfigured?.(account, cfg) ?? true, }, }; } @@ -1837,6 +1841,247 @@ description: test skill }); }); + it("keeps channel security findings when SecretRef credentials are configured but unavailable", async () => { + await withChannelSecurityStateDir(async () => { + const sourceConfig: OpenClawConfig = { + channels: { + discord: { + enabled: true, + token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + groupPolicy: "allowlist", + guilds: { + "123": { + channels: { + general: { allow: true }, + }, + }, + }, + }, + }, + }; + const resolvedConfig: OpenClawConfig = { + channels: { + discord: { + enabled: true, + groupPolicy: "allowlist", + guilds: { + "123": { + channels: { + general: { allow: true }, + }, + }, + }, + }, + }, + }; + + const inspectableDiscordPlugin = stubChannelPlugin({ + id: "discord", + label: "Discord", + inspectAccount: (cfg) => { + const channel = cfg.channels?.discord ?? {}; + const token = channel.token; + return { + accountId: "default", + enabled: true, + configured: + Boolean(token) && + typeof token === "object" && + !Array.isArray(token) && + "source" in token, + token: "", + tokenSource: + Boolean(token) && + typeof token === "object" && + !Array.isArray(token) && + "source" in token + ? "config" + : "none", + tokenStatus: + Boolean(token) && + typeof token === "object" && + !Array.isArray(token) && + "source" in token + ? "configured_unavailable" + : "missing", + config: channel, + }; + }, + resolveAccount: (cfg) => ({ config: cfg.channels?.discord ?? {} }), + isConfigured: (account) => Boolean((account as { configured?: boolean }).configured), + }); + + const res = await runSecurityAudit({ + config: resolvedConfig, + sourceConfig, + includeFilesystem: false, + includeChannelSecurity: true, + plugins: [inspectableDiscordPlugin], + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "channels.discord.commands.native.no_allowlists", + severity: "warn", + }), + ]), + ); + }); + }); + + it("keeps Slack HTTP slash-command findings when resolved inspection only exposes signingSecret status", async () => { + await withChannelSecurityStateDir(async () => { + const sourceConfig: OpenClawConfig = { + channels: { + slack: { + enabled: true, + mode: "http", + groupPolicy: "open", + slashCommand: { enabled: true }, + }, + }, + }; + const resolvedConfig: OpenClawConfig = { + channels: { + slack: { + enabled: true, + mode: "http", + groupPolicy: "open", + slashCommand: { enabled: true }, + }, + }, + }; + + const inspectableSlackPlugin = stubChannelPlugin({ + id: "slack", + label: "Slack", + inspectAccount: (cfg) => { + const channel = cfg.channels?.slack ?? {}; + if (cfg === sourceConfig) { + return { + accountId: "default", + enabled: false, + configured: true, + mode: "http", + botTokenSource: "config", + botTokenStatus: "configured_unavailable", + signingSecretSource: "config", + signingSecretStatus: "configured_unavailable", + config: channel, + }; + } + return { + accountId: "default", + enabled: true, + configured: true, + mode: "http", + botTokenSource: "config", + botTokenStatus: "available", + signingSecretSource: "config", + signingSecretStatus: "available", + config: channel, + }; + }, + resolveAccount: (cfg) => ({ config: cfg.channels?.slack ?? {} }), + isConfigured: (account) => Boolean((account as { configured?: boolean }).configured), + }); + + const res = await runSecurityAudit({ + config: resolvedConfig, + sourceConfig, + includeFilesystem: false, + includeChannelSecurity: true, + plugins: [inspectableSlackPlugin], + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "channels.slack.commands.slash.no_allowlists", + severity: "warn", + }), + ]), + ); + }); + }); + + it("keeps source-configured Slack HTTP findings when resolved inspection is unconfigured", async () => { + await withChannelSecurityStateDir(async () => { + const sourceConfig: OpenClawConfig = { + channels: { + slack: { + enabled: true, + mode: "http", + groupPolicy: "open", + slashCommand: { enabled: true }, + }, + }, + }; + const resolvedConfig: OpenClawConfig = { + channels: { + slack: { + enabled: true, + mode: "http", + groupPolicy: "open", + slashCommand: { enabled: true }, + }, + }, + }; + + const inspectableSlackPlugin = stubChannelPlugin({ + id: "slack", + label: "Slack", + inspectAccount: (cfg) => { + const channel = cfg.channels?.slack ?? {}; + if (cfg === sourceConfig) { + return { + accountId: "default", + enabled: true, + configured: true, + mode: "http", + botTokenSource: "config", + botTokenStatus: "configured_unavailable", + signingSecretSource: "config", + signingSecretStatus: "configured_unavailable", + config: channel, + }; + } + return { + accountId: "default", + enabled: true, + configured: false, + mode: "http", + botTokenSource: "config", + botTokenStatus: "available", + signingSecretSource: "config", + signingSecretStatus: "missing", + config: channel, + }; + }, + resolveAccount: (cfg) => ({ config: cfg.channels?.slack ?? {} }), + isConfigured: (account) => Boolean((account as { configured?: boolean }).configured), + }); + + const res = await runSecurityAudit({ + config: resolvedConfig, + sourceConfig, + includeFilesystem: false, + includeChannelSecurity: true, + plugins: [inspectableSlackPlugin], + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "channels.slack.commands.slash.no_allowlists", + severity: "warn", + }), + ]), + ); + }); + }); + it("does not flag Discord slash commands when dm.allowFrom includes a Discord snowflake id", async () => { await withChannelSecurityStateDir(async () => { const cfg: OpenClawConfig = { diff --git a/src/security/audit.ts b/src/security/audit.ts index e390666988c..119aa6e5f00 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -86,6 +86,7 @@ export type SecurityAuditReport = { export type SecurityAuditOptions = { config: OpenClawConfig; + sourceConfig?: OpenClawConfig; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; deep?: boolean; @@ -113,6 +114,7 @@ export type SecurityAuditOptions = { type AuditExecutionContext = { cfg: OpenClawConfig; + sourceConfig: OpenClawConfig; env: NodeJS.ProcessEnv; platform: NodeJS.Platform; includeFilesystem: boolean; @@ -1092,6 +1094,7 @@ async function createAuditExecutionContext( opts: SecurityAuditOptions, ): Promise { const cfg = opts.config; + const sourceConfig = opts.sourceConfig ?? opts.config; const env = opts.env ?? process.env; const platform = opts.platform ?? process.platform; const includeFilesystem = opts.includeFilesystem !== false; @@ -1107,6 +1110,7 @@ async function createAuditExecutionContext( : null; return { cfg, + sourceConfig, env, platform, includeFilesystem, @@ -1206,7 +1210,13 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise; + status: SlackCredentialStatus; +} { + const token = normalizeSecretInputString(value); + if (token) { + return { + token, + source: "config", + status: "available", + }; + } + if (hasConfiguredSecretInput(value)) { + return { + source: "config", + status: "configured_unavailable", + }; + } + return { + source: "none", + status: "missing", + }; +} + +export function inspectSlackAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; + envBotToken?: string | null; + envAppToken?: string | null; + envUserToken?: string | null; +}): InspectedSlackAccount { + const accountId = normalizeAccountId( + params.accountId ?? resolveDefaultSlackAccountId(params.cfg), + ); + const merged = mergeSlackAccountConfig(params.cfg, accountId); + const enabled = params.cfg.channels?.slack?.enabled !== false && merged.enabled !== false; + const allowEnv = accountId === DEFAULT_ACCOUNT_ID; + const mode = merged.mode ?? "socket"; + const isHttpMode = mode === "http"; + + const configBot = inspectSlackToken(merged.botToken); + const configApp = inspectSlackToken(merged.appToken); + const configSigningSecret = inspectSlackToken(merged.signingSecret); + const configUser = inspectSlackToken(merged.userToken); + + const envBot = allowEnv + ? normalizeSecretInputString(params.envBotToken ?? process.env.SLACK_BOT_TOKEN) + : undefined; + const envApp = allowEnv + ? normalizeSecretInputString(params.envAppToken ?? process.env.SLACK_APP_TOKEN) + : undefined; + const envUser = allowEnv + ? normalizeSecretInputString(params.envUserToken ?? process.env.SLACK_USER_TOKEN) + : undefined; + + const botToken = configBot.token ?? envBot; + const appToken = configApp.token ?? envApp; + const signingSecret = configSigningSecret.token; + const userToken = configUser.token ?? envUser; + const botTokenSource: SlackTokenSource = configBot.token + ? "config" + : configBot.status === "configured_unavailable" + ? "config" + : envBot + ? "env" + : "none"; + const appTokenSource: SlackTokenSource = configApp.token + ? "config" + : configApp.status === "configured_unavailable" + ? "config" + : envApp + ? "env" + : "none"; + const signingSecretSource: SlackTokenSource = configSigningSecret.token + ? "config" + : configSigningSecret.status === "configured_unavailable" + ? "config" + : "none"; + const userTokenSource: SlackTokenSource = configUser.token + ? "config" + : configUser.status === "configured_unavailable" + ? "config" + : envUser + ? "env" + : "none"; + + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + mode, + botToken, + appToken, + ...(isHttpMode ? { signingSecret } : {}), + userToken, + botTokenSource, + appTokenSource, + ...(isHttpMode ? { signingSecretSource } : {}), + userTokenSource, + botTokenStatus: configBot.token + ? "available" + : configBot.status === "configured_unavailable" + ? "configured_unavailable" + : envBot + ? "available" + : "missing", + appTokenStatus: configApp.token + ? "available" + : configApp.status === "configured_unavailable" + ? "configured_unavailable" + : envApp + ? "available" + : "missing", + ...(isHttpMode + ? { + signingSecretStatus: configSigningSecret.token + ? "available" + : configSigningSecret.status === "configured_unavailable" + ? "configured_unavailable" + : "missing", + } + : {}), + userTokenStatus: configUser.token + ? "available" + : configUser.status === "configured_unavailable" + ? "configured_unavailable" + : envUser + ? "available" + : "missing", + configured: isHttpMode + ? (configBot.status !== "missing" || Boolean(envBot)) && + configSigningSecret.status !== "missing" + : (configBot.status !== "missing" || Boolean(envBot)) && + (configApp.status !== "missing" || Boolean(envApp)), + config: merged, + groupPolicy: merged.groupPolicy, + textChunkLimit: merged.textChunkLimit, + mediaMaxMb: merged.mediaMaxMb, + reactionNotifications: merged.reactionNotifications, + reactionAllowlist: merged.reactionAllowlist, + replyToMode: merged.replyToMode, + replyToModeByChatType: merged.replyToModeByChatType, + actions: merged.actions, + slashCommand: merged.slashCommand, + dm: merged.dm, + channels: merged.channels, + }; +} diff --git a/src/telegram/account-inspect.ts b/src/telegram/account-inspect.ts new file mode 100644 index 00000000000..5c50c7d7d67 --- /dev/null +++ b/src/telegram/account-inspect.ts @@ -0,0 +1,213 @@ +import fs from "node:fs"; +import type { OpenClawConfig } from "../config/config.js"; +import { hasConfiguredSecretInput, normalizeSecretInputString } from "../config/types.secrets.js"; +import type { TelegramAccountConfig } from "../config/types.telegram.js"; +import { resolveAccountWithDefaultFallback } from "../plugin-sdk/account-resolution.js"; +import { resolveAccountEntry } from "../routing/account-lookup.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import { resolveDefaultTelegramAccountId } from "./accounts.js"; + +export type TelegramCredentialStatus = "available" | "configured_unavailable" | "missing"; + +export type InspectedTelegramAccount = { + accountId: string; + enabled: boolean; + name?: string; + token: string; + tokenSource: "env" | "tokenFile" | "config" | "none"; + tokenStatus: TelegramCredentialStatus; + configured: boolean; + config: TelegramAccountConfig; +}; + +function resolveTelegramAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): TelegramAccountConfig | undefined { + const normalized = normalizeAccountId(accountId); + return resolveAccountEntry(cfg.channels?.telegram?.accounts, normalized); +} + +function mergeTelegramAccountConfig(cfg: OpenClawConfig, accountId: string): TelegramAccountConfig { + const { + accounts: _ignored, + defaultAccount: _ignoredDefaultAccount, + groups: channelGroups, + ...base + } = (cfg.channels?.telegram ?? {}) as TelegramAccountConfig & { + accounts?: unknown; + defaultAccount?: unknown; + }; + const account = resolveTelegramAccountConfig(cfg, accountId) ?? {}; + const configuredAccountIds = Object.keys(cfg.channels?.telegram?.accounts ?? {}); + const isMultiAccount = configuredAccountIds.length > 1; + const groups = account.groups ?? (isMultiAccount ? undefined : channelGroups); + return { ...base, ...account, groups }; +} + +function inspectTokenFile(pathValue: unknown): { + token: string; + tokenSource: "tokenFile" | "none"; + tokenStatus: TelegramCredentialStatus; +} | null { + const tokenFile = typeof pathValue === "string" ? pathValue.trim() : ""; + if (!tokenFile) { + return null; + } + if (!fs.existsSync(tokenFile)) { + return { + token: "", + tokenSource: "tokenFile", + tokenStatus: "configured_unavailable", + }; + } + try { + const token = fs.readFileSync(tokenFile, "utf-8").trim(); + return { + token, + tokenSource: "tokenFile", + tokenStatus: token ? "available" : "configured_unavailable", + }; + } catch { + return { + token: "", + tokenSource: "tokenFile", + tokenStatus: "configured_unavailable", + }; + } +} + +function inspectTokenValue(value: unknown): { + token: string; + tokenSource: "config" | "none"; + tokenStatus: TelegramCredentialStatus; +} | null { + const token = normalizeSecretInputString(value); + if (token) { + return { + token, + tokenSource: "config", + tokenStatus: "available", + }; + } + if (hasConfiguredSecretInput(value)) { + return { + token: "", + tokenSource: "config", + tokenStatus: "configured_unavailable", + }; + } + return null; +} + +function inspectTelegramAccountPrimary(params: { + cfg: OpenClawConfig; + accountId: string; + envToken?: string | null; +}): InspectedTelegramAccount { + const accountId = normalizeAccountId(params.accountId); + const merged = mergeTelegramAccountConfig(params.cfg, accountId); + const enabled = params.cfg.channels?.telegram?.enabled !== false && merged.enabled !== false; + + const accountConfig = resolveTelegramAccountConfig(params.cfg, accountId); + const accountTokenFile = inspectTokenFile(accountConfig?.tokenFile); + if (accountTokenFile) { + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: accountTokenFile.token, + tokenSource: accountTokenFile.tokenSource, + tokenStatus: accountTokenFile.tokenStatus, + configured: accountTokenFile.tokenStatus !== "missing", + config: merged, + }; + } + + const accountToken = inspectTokenValue(accountConfig?.botToken); + if (accountToken) { + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: accountToken.token, + tokenSource: accountToken.tokenSource, + tokenStatus: accountToken.tokenStatus, + configured: accountToken.tokenStatus !== "missing", + config: merged, + }; + } + + const channelTokenFile = inspectTokenFile(params.cfg.channels?.telegram?.tokenFile); + if (channelTokenFile) { + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: channelTokenFile.token, + tokenSource: channelTokenFile.tokenSource, + tokenStatus: channelTokenFile.tokenStatus, + configured: channelTokenFile.tokenStatus !== "missing", + config: merged, + }; + } + + const channelToken = inspectTokenValue(params.cfg.channels?.telegram?.botToken); + if (channelToken) { + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: channelToken.token, + tokenSource: channelToken.tokenSource, + tokenStatus: channelToken.tokenStatus, + configured: channelToken.tokenStatus !== "missing", + config: merged, + }; + } + + const allowEnv = accountId === DEFAULT_ACCOUNT_ID; + const envToken = allowEnv ? (params.envToken ?? process.env.TELEGRAM_BOT_TOKEN)?.trim() : ""; + if (envToken) { + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: envToken, + tokenSource: "env", + tokenStatus: "available", + configured: true, + config: merged, + }; + } + + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: "", + tokenSource: "none", + tokenStatus: "missing", + configured: false, + config: merged, + }; +} + +export function inspectTelegramAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; + envToken?: string | null; +}): InspectedTelegramAccount { + return resolveAccountWithDefaultFallback({ + accountId: params.accountId, + normalizeAccountId, + resolvePrimary: (accountId) => + inspectTelegramAccountPrimary({ + cfg: params.cfg, + accountId, + envToken: params.envToken, + }), + hasCredential: (account) => account.tokenSource !== "none", + resolveDefaultAccountId: () => resolveDefaultTelegramAccountId(params.cfg), + }); +}