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

* CLI: add read-only SecretRef inspection

* CLI: fix read-only SecretRef status regressions

* CLI: preserve read-only SecretRef status fallbacks

* Docs: document read-only channel inspection hook

* CLI: preserve audit coverage for read-only SecretRefs

* CLI: fix read-only status account selection

* CLI: fix targeted gateway fallback analysis

* CLI: fix Slack HTTP read-only inspection

* CLI: align audit credential status checks

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

View File

@@ -67,6 +67,7 @@ openclaw channels logout --channel whatsapp
- Run `openclaw status --deep` for a broad probe. - Run `openclaw status --deep` for a broad probe.
- Use `openclaw doctor` for guided fixes. - 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 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 ## Capabilities probe
@@ -97,3 +98,4 @@ Notes:
- Use `--kind user|group|auto` to force the target type. - Use `--kind user|group|auto` to force the target type.
- Resolution prefers active matches when multiple entries share the same name. - 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.

View File

@@ -24,3 +24,5 @@ Notes:
- Overview includes Gateway + node host service install/runtime status when available. - Overview includes Gateway + node host service install/runtime status when available.
- Overview includes update channel + git SHA (for source checkouts). - 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)). - 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`.

View File

@@ -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. - 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. - `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 ### 13) Gateway health check + restart
Doctor runs a health check and offers to restart the gateway when it looks Doctor runs a health check and offers to restart the gateway when it looks

View File

@@ -339,10 +339,22 @@ Behavior:
## Command-path resolution ## 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`. - Snapshot refresh after backend secret rotation is handled by `openclaw secrets reload`.
- Gateway RPC method used by these command paths: `secrets.resolve`. - Gateway RPC method used by these command paths: `secrets.resolve`.

View File

@@ -178,6 +178,38 @@ Compatibility note:
subpaths; use `core` for generic surfaces and `compat` only when broader subpaths; use `core` for generic surfaces and `compat` only when broader
shared helpers are required. 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: Performance note:
- Plugin discovery and manifest metadata use short in-process caches to reduce - Plugin discovery and manifest metadata use short in-process caches to reduce

View File

@@ -10,6 +10,7 @@ import {
DiscordConfigSchema, DiscordConfigSchema,
formatPairingApproveHint, formatPairingApproveHint,
getChatChannelMeta, getChatChannelMeta,
inspectDiscordAccount,
listDiscordAccountIds, listDiscordAccountIds,
listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig, listDiscordDirectoryPeersFromConfig,
@@ -19,6 +20,8 @@ import {
normalizeDiscordMessagingTarget, normalizeDiscordMessagingTarget,
normalizeDiscordOutboundTarget, normalizeDiscordOutboundTarget,
PAIRING_APPROVED_MESSAGE, PAIRING_APPROVED_MESSAGE,
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,
resolveDiscordAccount, resolveDiscordAccount,
resolveDefaultDiscordAccountId, resolveDefaultDiscordAccountId,
resolveDiscordGroupRequireMention, resolveDiscordGroupRequireMention,
@@ -80,6 +83,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
config: { config: {
listAccountIds: (cfg) => listDiscordAccountIds(cfg), listAccountIds: (cfg) => listDiscordAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultDiscordAccountId(cfg), defaultAccountId: (cfg) => resolveDefaultDiscordAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({ setAccountEnabledInConfigSection({
@@ -390,7 +394,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
return { ...audit, unresolvedChannels }; return { ...audit, unresolvedChannels };
}, },
buildAccountSnapshot: ({ account, runtime, probe, audit }) => { 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 app = runtime?.application ?? (probe as { application?: unknown })?.application;
const bot = runtime?.bot ?? (probe as { bot?: unknown })?.bot; const bot = runtime?.bot ?? (probe as { bot?: unknown })?.bot;
return { return {
@@ -398,7 +403,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
name: account.name, name: account.name,
enabled: account.enabled, enabled: account.enabled,
configured, configured,
tokenSource: account.tokenSource, ...projectCredentialSnapshotFields(account),
running: runtime?.running ?? false, running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null, lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null, lastStopAt: runtime?.lastStopAt ?? null,

View File

@@ -182,4 +182,53 @@ describe("slackPlugin config", () => {
expect(configured).toBe(false); expect(configured).toBe(false);
expect(snapshot?.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");
});
}); });

View File

@@ -7,6 +7,7 @@ import {
formatPairingApproveHint, formatPairingApproveHint,
getChatChannelMeta, getChatChannelMeta,
handleSlackMessageAction, handleSlackMessageAction,
inspectSlackAccount,
listSlackMessageActions, listSlackMessageActions,
listSlackAccountIds, listSlackAccountIds,
listSlackDirectoryGroupsFromConfig, listSlackDirectoryGroupsFromConfig,
@@ -16,6 +17,8 @@ import {
normalizeAccountId, normalizeAccountId,
normalizeSlackMessagingTarget, normalizeSlackMessagingTarget,
PAIRING_APPROVED_MESSAGE, PAIRING_APPROVED_MESSAGE,
projectCredentialSnapshotFields,
resolveConfiguredFromRequiredCredentialStatuses,
resolveDefaultSlackAccountId, resolveDefaultSlackAccountId,
resolveSlackAccount, resolveSlackAccount,
resolveSlackReplyToMode, resolveSlackReplyToMode,
@@ -131,6 +134,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
config: { config: {
listAccountIds: (cfg) => listSlackAccountIds(cfg), listAccountIds: (cfg) => listSlackAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }),
inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultSlackAccountId(cfg), defaultAccountId: (cfg) => resolveDefaultSlackAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({ setAccountEnabledInConfigSection({
@@ -428,14 +432,23 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
return await getSlackRuntime().channel.slack.probeSlack(token, timeoutMs); return await getSlackRuntime().channel.slack.probeSlack(token, timeoutMs);
}, },
buildAccountSnapshot: ({ account, runtime, probe }) => { 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 { return {
accountId: account.accountId, accountId: account.accountId,
name: account.name, name: account.name,
enabled: account.enabled, enabled: account.enabled,
configured, configured,
botTokenSource: account.botTokenSource, ...projectCredentialSnapshotFields(account),
appTokenSource: account.appTokenSource,
running: runtime?.running ?? false, running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null, lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null, lastStopAt: runtime?.lastStopAt ?? null,

View File

@@ -7,6 +7,7 @@ import {
deleteAccountFromConfigSection, deleteAccountFromConfigSection,
formatPairingApproveHint, formatPairingApproveHint,
getChatChannelMeta, getChatChannelMeta,
inspectTelegramAccount,
listTelegramAccountIds, listTelegramAccountIds,
listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryGroupsFromConfig,
listTelegramDirectoryPeersFromConfig, listTelegramDirectoryPeersFromConfig,
@@ -17,6 +18,8 @@ import {
PAIRING_APPROVED_MESSAGE, PAIRING_APPROVED_MESSAGE,
parseTelegramReplyToMessageId, parseTelegramReplyToMessageId,
parseTelegramThreadId, parseTelegramThreadId,
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,
resolveDefaultTelegramAccountId, resolveDefaultTelegramAccountId,
resolveAllowlistProviderRuntimeGroupPolicy, resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy, resolveDefaultGroupPolicy,
@@ -43,7 +46,7 @@ function findTelegramTokenOwnerAccountId(params: {
const normalizedAccountId = normalizeAccountId(params.accountId); const normalizedAccountId = normalizeAccountId(params.accountId);
const tokenOwners = new Map<string, string>(); const tokenOwners = new Map<string, string>();
for (const id of listTelegramAccountIds(params.cfg)) { 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(); const token = (account.token ?? "").trim();
if (!token) { if (!token) {
continue; continue;
@@ -122,6 +125,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
config: { config: {
listAccountIds: (cfg) => listTelegramAccountIds(cfg), listAccountIds: (cfg) => listTelegramAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }),
inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultTelegramAccountId(cfg), defaultAccountId: (cfg) => resolveDefaultTelegramAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({ setAccountEnabledInConfigSection({
@@ -416,6 +420,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
return { ...audit, unresolvedGroups, hasWildcardUnmentionedGroups }; return { ...audit, unresolvedGroups, hasWildcardUnmentionedGroups };
}, },
buildAccountSnapshot: ({ account, cfg, runtime, probe, audit }) => { buildAccountSnapshot: ({ account, cfg, runtime, probe, audit }) => {
const configuredFromStatus = resolveConfiguredFromCredentialStatuses(account);
const ownerAccountId = findTelegramTokenOwnerAccountId({ const ownerAccountId = findTelegramTokenOwnerAccountId({
cfg, cfg,
accountId: account.accountId, accountId: account.accountId,
@@ -426,7 +431,8 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
ownerAccountId, ownerAccountId,
}) })
: null; : null;
const configured = Boolean(account.token?.trim()) && !ownerAccountId; const configured =
(configuredFromStatus ?? Boolean(account.token?.trim())) && !ownerAccountId;
const groups = const groups =
cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ?? cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ??
cfg.channels?.telegram?.groups; cfg.channels?.telegram?.groups;
@@ -440,7 +446,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
name: account.name, name: account.name,
enabled: account.enabled, enabled: account.enabled,
configured, configured,
tokenSource: account.tokenSource, ...projectCredentialSnapshotFields(account),
running: runtime?.running ?? false, running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null, lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null, lastStopAt: runtime?.lastStopAt ?? null,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import {
createOutboundTestPlugin, createOutboundTestPlugin,
createTestRegistry, createTestRegistry,
} from "../../test-utils/channel-plugins.js"; } from "../../test-utils/channel-plugins.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js"; import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js";
import { resolveChannelConfigWrites } from "./config-writes.js"; import { resolveChannelConfigWrites } from "./config-writes.js";
import { import {
@@ -409,6 +410,72 @@ describe("directory (config-backed)", () => {
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]); 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 () => { it("lists WhatsApp peers/groups from config", async () => {
const cfg = { const cfg = {
channels: { channels: {

View File

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

View File

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

View File

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

View File

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

View File

@@ -139,6 +139,9 @@ describe("resolveCommandSecretRefsViaGateway", () => {
expect( expect(
result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")), result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")),
).toBe(true); ).toBe(true);
expect(
result.diagnostics.some((entry) => entry.includes("resolved command secrets locally")),
).toBe(true);
} finally { } finally {
if (priorValue === undefined) { if (priorValue === undefined) {
delete process.env.TALK_API_KEY; delete process.env.TALK_API_KEY;
@@ -353,4 +356,213 @@ describe("resolveCommandSecretRefsViaGateway", () => {
}); });
expect(result.diagnostics).toEqual(["memory search ref inactive"]); 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;
}
}
});
}); });

View File

@@ -2,20 +2,37 @@ import type { OpenClawConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js"; import { resolveSecretInputRef } from "../config/types.secrets.js";
import { callGateway } from "../gateway/call.js"; import { callGateway } from "../gateway/call.js";
import { validateSecretsResolveResult } from "../gateway/protocol/index.js"; import { validateSecretsResolveResult } from "../gateway/protocol/index.js";
import { collectCommandSecretAssignmentsFromSnapshot } from "../secrets/command-config.js"; import {
import { setPathExistingStrict } from "../secrets/path-utils.js"; analyzeCommandSecretAssignmentsFromSnapshot,
import { resolveSecretRefValues } from "../secrets/resolve.js"; 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 { 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 { 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"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
type ResolveCommandSecretsResult = { type ResolveCommandSecretsResult = {
resolvedConfig: OpenClawConfig; resolvedConfig: OpenClawConfig;
diagnostics: string[]; diagnostics: string[];
targetStatesByPath: Record<string, CommandSecretTargetState>;
hadUnresolvedTargets: boolean;
}; };
export type CommandSecretResolutionMode = "strict" | "summary" | "operational_readonly";
export type CommandSecretTargetState =
| "resolved_gateway"
| "resolved_local"
| "inactive_surface"
| "unresolved";
type GatewaySecretsResolveResult = { type GatewaySecretsResolveResult = {
ok?: boolean; ok?: boolean;
assignments?: Array<{ assignments?: Array<{
@@ -167,6 +184,8 @@ async function resolveCommandSecretRefsLocally(params: {
commandName: string; commandName: string;
targetIds: Set<string>; targetIds: Set<string>;
preflightDiagnostics: string[]; preflightDiagnostics: string[];
mode: CommandSecretResolutionMode;
allowedPaths?: ReadonlySet<string>;
}): Promise<ResolveCommandSecretsResult> { }): Promise<ResolveCommandSecretsResult> {
const sourceConfig = params.config; const sourceConfig = params.config;
const resolvedConfig = structuredClone(params.config); const resolvedConfig = structuredClone(params.config);
@@ -175,57 +194,191 @@ async function resolveCommandSecretRefsLocally(params: {
env: process.env, env: process.env,
}); });
collectConfigAssignments({ collectConfigAssignments({
config: resolvedConfig, config: structuredClone(params.config),
context, 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( const inactiveRefPaths = new Set(
context.warnings context.warnings
.filter((warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE") .filter((warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE")
.map((warning) => warning.path), .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, sourceConfig,
resolvedConfig, resolvedConfig,
commandName: params.commandName,
targetIds: params.targetIds, targetIds: params.targetIds,
inactiveRefPaths, 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 { return {
resolvedConfig, resolvedConfig,
diagnostics: dedupeDiagnostics([ diagnostics: dedupeDiagnostics([
...params.preflightDiagnostics, ...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<typeof analyzeCommandSecretAssignmentsFromSnapshot>;
resolvedState: Extract<CommandSecretTargetState, "resolved_gateway" | "resolved_local">;
}): Record<string, CommandSecretTargetState> {
const states: Record<string, CommandSecretTargetState> = {};
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>;
}): 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<typeof createResolverContext>["cache"];
activePaths: ReadonlySet<string>;
inactiveRefPaths: ReadonlySet<string>;
mode: CommandSecretResolutionMode;
commandName: string;
localResolutionDiagnostics: string[];
}): Promise<void> {
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: { export async function resolveCommandSecretRefsViaGateway(params: {
config: OpenClawConfig; config: OpenClawConfig;
commandName: string; commandName: string;
targetIds: Set<string>; targetIds: Set<string>;
mode?: CommandSecretResolutionMode;
}): Promise<ResolveCommandSecretsResult> { }): Promise<ResolveCommandSecretsResult> {
const mode = params.mode ?? "strict";
const configuredTargetRefPaths = collectConfiguredTargetRefPaths({ const configuredTargetRefPaths = collectConfiguredTargetRefPaths({
config: params.config, config: params.config,
targetIds: params.targetIds, targetIds: params.targetIds,
}); });
if (configuredTargetRefPaths.size === 0) { if (configuredTargetRefPaths.size === 0) {
return { resolvedConfig: params.config, diagnostics: [] }; return {
resolvedConfig: params.config,
diagnostics: [],
targetStatesByPath: {},
hadUnresolvedTargets: false,
};
} }
const preflight = classifyConfiguredTargetRefs({ const preflight = classifyConfiguredTargetRefs({
config: params.config, config: params.config,
@@ -235,6 +388,8 @@ export async function resolveCommandSecretRefsViaGateway(params: {
return { return {
resolvedConfig: params.config, resolvedConfig: params.config,
diagnostics: preflight.diagnostics, diagnostics: preflight.diagnostics,
targetStatesByPath: {},
hadUnresolvedTargets: false,
}; };
} }
@@ -258,13 +413,23 @@ export async function resolveCommandSecretRefsViaGateway(params: {
commandName: params.commandName, commandName: params.commandName,
targetIds: params.targetIds, targetIds: params.targetIds,
preflightDiagnostics: preflight.diagnostics, 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 { return {
resolvedConfig: fallback.resolvedConfig, resolvedConfig: fallback.resolvedConfig,
diagnostics: dedupeDiagnostics([ diagnostics: dedupeDiagnostics([
...fallback.diagnostics, ...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 { } catch {
// Fall through to original gateway-specific error reporting. // Fall through to original gateway-specific error reporting.
@@ -302,16 +467,86 @@ export async function resolveCommandSecretRefsViaGateway(params: {
parsed.inactiveRefPaths.length > 0 parsed.inactiveRefPaths.length > 0
? new Set(parsed.inactiveRefPaths) ? new Set(parsed.inactiveRefPaths)
: collectInactiveSurfacePathsFromDiagnostics(parsed.diagnostics); : collectInactiveSurfacePathsFromDiagnostics(parsed.diagnostics);
collectCommandSecretAssignmentsFromSnapshot({ const analyzed = analyzeCommandSecretAssignmentsFromSnapshot({
sourceConfig: params.config, sourceConfig: params.config,
resolvedConfig, resolvedConfig,
commandName: params.commandName,
targetIds: params.targetIds, targetIds: params.targetIds,
inactiveRefPaths, 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 { return {
resolvedConfig, resolvedConfig,
diagnostics: dedupeDiagnostics(parsed.diagnostics), diagnostics,
targetStatesByPath,
hadUnresolvedTargets: Object.values(targetStatesByPath).includes("unresolved"),
}; };
} }

View File

@@ -144,7 +144,7 @@ describe("registerPreActionHooks", () => {
runtime: runtimeMock, runtime: runtimeMock,
commandPath: ["status"], commandPath: ["status"],
}); });
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1);
expect(process.title).toBe("openclaw-status"); expect(process.title).toBe("openclaw-status");
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -33,6 +33,8 @@ const PLUGIN_REQUIRED_COMMANDS = new Set([
"agents", "agents",
"configure", "configure",
"onboard", "onboard",
"status",
"health",
]); ]);
const CONFIG_GUARD_BYPASS_COMMANDS = new Set(["doctor", "completion", "secrets"]); const CONFIG_GUARD_BYPASS_COMMANDS = new Set(["doctor", "completion", "secrets"]);
const JSON_PARSE_ONLY_COMMANDS = new Set(["config set"]); const JSON_PARSE_ONLY_COMMANDS = new Set(["config set"]);

View File

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

View File

@@ -75,6 +75,7 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti
config: loadedRaw, config: loadedRaw,
commandName: "channels resolve", commandName: "channels resolve",
targetIds: getChannelsCommandSecretTargetIds(), targetIds: getChannelsCommandSecretTargetIds(),
mode: "operational_readonly",
}); });
for (const entry of diagnostics) { for (const entry of diagnostics) {
runtime.log(`[secrets] ${entry}`); runtime.log(`[secrets] ${entry}`);

View File

@@ -1,5 +1,8 @@
import { type ChannelId, getChannelPlugin } from "../../channels/plugins/index.js"; 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 { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.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 type ChatChannel = ChannelId;
export { requireValidConfigSnapshot };
export async function requireValidConfig( export async function requireValidConfig(
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
secretResolution?: {
commandName?: string;
mode?: CommandSecretResolutionMode;
},
): Promise<OpenClawConfig | null> { ): Promise<OpenClawConfig | null> {
const cfg = await requireValidConfigSnapshot(runtime); const cfg = await requireValidConfigSnapshot(runtime);
if (!cfg) { if (!cfg) {
@@ -17,8 +26,9 @@ export async function requireValidConfig(
} }
const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({ const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({
config: cfg, config: cfg,
commandName: "channels", commandName: secretResolution?.commandName ?? "channels",
targetIds: getChannelsCommandSecretTargetIds(), targetIds: getChannelsCommandSecretTargetIds(),
mode: secretResolution?.mode,
}); });
for (const entry of diagnostics) { for (const entry of diagnostics) {
runtime.log(`[secrets] ${entry}`); runtime.log(`[secrets] ${entry}`);

View File

@@ -1,7 +1,16 @@
import {
hasConfiguredUnavailableCredentialStatus,
hasResolvedCredentialValue,
} from "../../channels/account-snapshot-fields.js";
import { listChannelPlugins } from "../../channels/plugins/index.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 type { ChannelAccountSnapshot } from "../../channels/plugins/types.js";
import { formatCliCommand } from "../../cli/command-format.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 { withProgress } from "../../cli/progress.js";
import { type OpenClawConfig, readConfigFileSnapshot } from "../../config/config.js"; import { type OpenClawConfig, readConfigFileSnapshot } from "../../config/config.js";
import { callGateway } from "../../gateway/call.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 { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js"; import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.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 = { export type ChannelsStatusOptions = {
json?: boolean; json?: boolean;
@@ -23,7 +36,14 @@ function appendEnabledConfiguredLinkedBits(bits: string[], account: Record<strin
bits.push(account.enabled ? "enabled" : "disabled"); bits.push(account.enabled ? "enabled" : "disabled");
} }
if (typeof account.configured === "boolean") { if (typeof account.configured === "boolean") {
bits.push(account.configured ? "configured" : "not configured"); if (account.configured) {
bits.push("configured");
if (hasConfiguredUnavailableCredentialStatus(account)) {
bits.push("secret unavailable in this command path");
}
} else {
bits.push("not configured");
}
} }
if (typeof account.linked === "boolean") { if (typeof account.linked === "boolean") {
bits.push(account.linked ? "linked" : "not linked"); bits.push(account.linked ? "linked" : "not linked");
@@ -37,15 +57,20 @@ function appendModeBit(bits: string[], account: Record<string, unknown>) {
} }
function appendTokenSourceBits(bits: string[], account: Record<string, unknown>) { function appendTokenSourceBits(bits: string[], account: Record<string, unknown>) {
if (typeof account.tokenSource === "string" && account.tokenSource) { const appendSourceBit = (label: string, sourceKey: string, statusKey: string) => {
bits.push(`token:${account.tokenSource}`); const source = account[sourceKey];
} if (typeof source !== "string" || !source || source === "none") {
if (typeof account.botTokenSource === "string" && account.botTokenSource) { return;
bits.push(`bot:${account.botTokenSource}`); }
} const status = account[statusKey];
if (typeof account.appTokenSource === "string" && account.appTokenSource) { const unavailable = status === "configured_unavailable" ? " (unavailable)" : "";
bits.push(`app:${account.appTokenSource}`); 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<string, unknown>) { function appendBaseUrlBit(bits: string[], account: Record<string, unknown>) {
@@ -184,9 +209,10 @@ export function formatGatewayChannelsStatusLines(payload: Record<string, unknown
return lines; return lines;
} }
async function formatConfigChannelsStatusLines( export async function formatConfigChannelsStatusLines(
cfg: OpenClawConfig, cfg: OpenClawConfig,
meta: { path?: string; mode?: "local" | "remote" }, meta: { path?: string; mode?: "local" | "remote" },
opts?: { sourceConfig?: OpenClawConfig },
): Promise<string[]> { ): Promise<string[]> {
const lines: string[] = []; const lines: string[] = [];
lines.push(theme.warn("Gateway not reachable; showing config-only status.")); lines.push(theme.warn("Gateway not reachable; showing config-only status."));
@@ -211,6 +237,7 @@ async function formatConfigChannelsStatusLines(
}); });
const plugins = listChannelPlugins(); const plugins = listChannelPlugins();
const sourceConfig = opts?.sourceConfig ?? cfg;
for (const plugin of plugins) { for (const plugin of plugins) {
const accountIds = plugin.config.listAccountIds(cfg); const accountIds = plugin.config.listAccountIds(cfg);
if (!accountIds.length) { if (!accountIds.length) {
@@ -218,12 +245,24 @@ async function formatConfigChannelsStatusLines(
} }
const snapshots: ChannelAccountSnapshot[] = []; const snapshots: ChannelAccountSnapshot[] = [];
for (const accountId of accountIds) { for (const accountId of accountIds) {
const snapshot = await buildChannelAccountSnapshot({ const sourceSnapshot = await buildReadOnlySourceChannelAccountSnapshot({
plugin,
cfg: sourceConfig,
accountId,
});
const resolvedSnapshot = await buildChannelAccountSnapshot({
plugin, plugin,
cfg, cfg,
accountId, accountId,
}); });
snapshots.push(snapshot); snapshots.push(
sourceSnapshot &&
hasConfiguredUnavailableCredentialStatus(sourceSnapshot) &&
(!hasResolvedCredentialValue(resolvedSnapshot) ||
(sourceSnapshot.configured === true && resolvedSnapshot.configured === false))
? sourceSnapshot
: resolvedSnapshot,
);
} }
if (snapshots.length > 0) { if (snapshots.length > 0) {
lines.push(...accountLines(plugin.id, snapshots)); lines.push(...accountLines(plugin.id, snapshots));
@@ -268,18 +307,31 @@ export async function channelsStatusCommand(
runtime.log(formatGatewayChannelsStatusLines(payload).join("\n")); runtime.log(formatGatewayChannelsStatusLines(payload).join("\n"));
} catch (err) { } catch (err) {
runtime.error(`Gateway not reachable: ${String(err)}`); runtime.error(`Gateway not reachable: ${String(err)}`);
const cfg = await requireValidConfig(runtime); const cfg = await requireValidConfigSnapshot(runtime);
if (!cfg) { if (!cfg) {
return; 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 snapshot = await readConfigFileSnapshot();
const mode = cfg.gateway?.mode === "remote" ? "remote" : "local"; const mode = cfg.gateway?.mode === "remote" ? "remote" : "local";
runtime.log( runtime.log(
( (
await formatConfigChannelsStatusLines(cfg, { await formatConfigChannelsStatusLines(
path: snapshot.path, resolvedConfig,
mode, {
}) path: snapshot.path,
mode,
},
{ sourceConfig: cfg },
)
).join("\n"), ).join("\n"),
); );
} }

View File

@@ -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<string, { allowFrom?: string[] }>;
};
};
};
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 () => { it("converts numeric discord ids to strings on repair", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw"); const configDir = path.join(home, ".openclaw");

View File

@@ -8,6 +8,8 @@ import {
} from "../channels/telegram/allow-from.js"; } from "../channels/telegram/allow-from.js";
import { fetchTelegramChatId } from "../channels/telegram/api.js"; import { fetchTelegramChatId } from "../channels/telegram/api.js";
import { formatCliCommand } from "../cli/command-format.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 { listRouteBindings } from "../config/bindings.js";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { CONFIG_PATH, migrateLegacyConfig, readConfigFileSnapshot } from "../config/config.js"; import { CONFIG_PATH, migrateLegacyConfig, readConfigFileSnapshot } from "../config/config.js";
@@ -45,6 +47,7 @@ import {
isMattermostMutableAllowEntry, isMattermostMutableAllowEntry,
isSlackMutableAllowEntry, isSlackMutableAllowEntry,
} from "../security/mutable-allowlist-detectors.js"; } from "../security/mutable-allowlist-detectors.js";
import { inspectTelegramAccount } from "../telegram/account-inspect.js";
import { listTelegramAccountIds, resolveTelegramAccount } from "../telegram/accounts.js"; import { listTelegramAccountIds, resolveTelegramAccount } from "../telegram/accounts.js";
import { note } from "../terminal/note.js"; import { note } from "../terminal/note.js";
import { isRecord, resolveHomeDir } from "../utils.js"; import { isRecord, resolveHomeDir } from "../utils.js";
@@ -464,10 +467,20 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi
return { config: cfg, changes: [] }; 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( const tokens = Array.from(
new Set( new Set(
listTelegramAccountIds(cfg) listTelegramAccountIds(resolvedConfig)
.map((accountId) => resolveTelegramAccount({ cfg, accountId })) .map((accountId) => resolveTelegramAccount({ cfg: resolvedConfig, accountId }))
.map((account) => (account.tokenSource === "none" ? "" : account.token)) .map((account) => (account.tokenSource === "none" ? "" : account.token))
.map((token) => token.trim()) .map((token) => token.trim())
.filter(Boolean), .filter(Boolean),
@@ -478,7 +491,9 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi
return { return {
config: cfg, config: cfg,
changes: [ 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).`,
], ],
}; };
} }

View File

@@ -43,6 +43,7 @@ export async function statusAllCommand(
config: loadedRaw, config: loadedRaw,
commandName: "status --all", commandName: "status --all",
targetIds: getStatusCommandSecretTargetIds(), targetIds: getStatusCommandSecretTargetIds(),
mode: "summary",
}); });
const osSummary = resolveOsSummary(); const osSummary = resolveOsSummary();
const snap = await readConfigFileSnapshot().catch(() => null); const snap = await readConfigFileSnapshot().catch(() => null);
@@ -159,7 +160,10 @@ export async function statusAllCommand(
const agentStatus = await getAgentLocalStatuses(cfg); const agentStatus = await getAgentLocalStatuses(cfg);
progress.tick(); progress.tick();
progress.setLabel("Summarizing channels…"); progress.setLabel("Summarizing channels…");
const channels = await buildChannelsTable(cfg, { showSecrets: false }); const channels = await buildChannelsTable(cfg, {
showSecrets: false,
sourceConfig: loadedRaw,
});
progress.tick(); progress.tick();
const connectionDetailsForReport = (() => { const connectionDetailsForReport = (() => {

View File

@@ -50,6 +50,12 @@ function makeSlackPlugin(params?: { botToken?: string; appToken?: string }): Cha
config: { config: {
listAccountIds: () => ["primary"], listAccountIds: () => ["primary"],
defaultAccountId: () => "primary", defaultAccountId: () => "primary",
inspectAccount: () => ({
name: "Primary",
enabled: true,
botToken: params?.botToken ?? "bot-token",
appToken: params?.appToken ?? "app-token",
}),
resolveAccount: () => ({ resolveAccount: () => ({
name: "Primary", name: "Primary",
enabled: true, 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 { function makeTokenPlugin(): ChannelPlugin {
return { return {
id: "token-only", id: "token-only",
@@ -122,6 +318,90 @@ describe("buildChannelsTable - mattermost token summary", () => {
expect(slackRow?.detail).toContain("need bot+app"); 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 () => { it("still reports single-token channels as ok", async () => {
vi.mocked(listChannelPlugins).mockReturnValue([makeTokenPlugin()]); vi.mocked(listChannelPlugins).mockReturnValue([makeTokenPlugin()]);

View File

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

View File

@@ -1,6 +1,6 @@
import { formatCliCommand } from "../cli/command-format.js"; import { formatCliCommand } from "../cli/command-format.js";
import { withProgress } from "../cli/progress.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 { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { info } from "../globals.js"; import { info } from "../globals.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
@@ -80,33 +80,33 @@ export async function statusCommand(
return; return;
} }
const [scan, securityAudit] = opts.json const scan = await scanStatus(
? await Promise.all([ { json: opts.json, timeoutMs: opts.timeoutMs, all: opts.all },
scanStatus({ json: opts.json, timeoutMs: opts.timeoutMs, all: opts.all }, runtime), runtime,
runSecurityAudit({ );
config: loadConfig(), const securityAudit = opts.json
deep: false, ? await runSecurityAudit({
includeFilesystem: true, config: scan.cfg,
includeChannelSecurity: true, sourceConfig: scan.sourceConfig,
}), deep: false,
]) includeFilesystem: true,
: [ includeChannelSecurity: true,
await scanStatus({ json: opts.json, timeoutMs: opts.timeoutMs, all: opts.all }, runtime), })
await withProgress( : await withProgress(
{ {
label: "Running security audit…", label: "Running security audit…",
indeterminate: true, indeterminate: true,
enabled: true, enabled: true,
}, },
async () => async () =>
await runSecurityAudit({ await runSecurityAudit({
config: loadConfig(), config: scan.cfg,
deep: false, sourceConfig: scan.sourceConfig,
includeFilesystem: true, deep: false,
includeChannelSecurity: true, includeFilesystem: true,
}), includeChannelSecurity: true,
), }),
]; );
const { const {
cfg, cfg,
osSummary, osSummary,
@@ -126,6 +126,7 @@ export async function statusCommand(
agentStatus, agentStatus,
channels, channels,
summary, summary,
secretDiagnostics,
memory, memory,
memoryPlugin, memoryPlugin,
} = scan; } = scan;
@@ -202,6 +203,7 @@ export async function statusCommand(
nodeService: nodeDaemon, nodeService: nodeDaemon,
agents: agentStatus, agents: agentStatus,
securityAudit, securityAudit,
secretDiagnostics,
...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}), ...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}),
}, },
null, null,
@@ -227,6 +229,14 @@ export async function statusCommand(
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); 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 dashboard = (() => {
const controlUiEnabled = cfg.gateway?.controlUi?.enabled ?? true; const controlUiEnabled = cfg.gateway?.controlUi?.enabled ?? true;
if (!controlUiEnabled) { if (!controlUiEnabled) {

View File

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

View File

@@ -125,6 +125,8 @@ async function resolveChannelsStatus(params: {
export type StatusScanResult = { export type StatusScanResult = {
cfg: ReturnType<typeof loadConfig>; cfg: ReturnType<typeof loadConfig>;
sourceConfig: ReturnType<typeof loadConfig>;
secretDiagnostics: string[];
osSummary: ReturnType<typeof resolveOsSummary>; osSummary: ReturnType<typeof resolveOsSummary>;
tailscaleMode: string; tailscaleMode: string;
tailscaleDns: string | null; tailscaleDns: string | null;
@@ -179,11 +181,13 @@ async function scanStatusJsonFast(opts: {
all?: boolean; all?: boolean;
}): Promise<StatusScanResult> { }): Promise<StatusScanResult> {
const loadedRaw = loadConfig(); const loadedRaw = loadConfig();
const { resolvedConfig: cfg } = await resolveCommandSecretRefsViaGateway({ const { resolvedConfig: cfg, diagnostics: secretDiagnostics } =
config: loadedRaw, await resolveCommandSecretRefsViaGateway({
commandName: "status --json", config: loadedRaw,
targetIds: getStatusCommandSecretTargetIds(), commandName: "status --json",
}); targetIds: getStatusCommandSecretTargetIds(),
mode: "summary",
});
const osSummary = resolveOsSummary(); const osSummary = resolveOsSummary();
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
const updateTimeoutMs = opts.all ? 6500 : 2500; const updateTimeoutMs = opts.all ? 6500 : 2500;
@@ -193,7 +197,7 @@ async function scanStatusJsonFast(opts: {
includeRegistry: true, includeRegistry: true,
}); });
const agentStatusPromise = getAgentLocalStatuses(); const agentStatusPromise = getAgentLocalStatuses();
const summaryPromise = getStatusSummary({ config: cfg }); const summaryPromise = getStatusSummary({ config: cfg, sourceConfig: loadedRaw });
const tailscaleDnsPromise = const tailscaleDnsPromise =
tailscaleMode === "off" tailscaleMode === "off"
@@ -236,6 +240,8 @@ async function scanStatusJsonFast(opts: {
return { return {
cfg, cfg,
sourceConfig: loadedRaw,
secretDiagnostics,
osSummary, osSummary,
tailscaleMode, tailscaleMode,
tailscaleDns, tailscaleDns,
@@ -278,11 +284,13 @@ export async function scanStatus(
async (progress) => { async (progress) => {
progress.setLabel("Loading config…"); progress.setLabel("Loading config…");
const loadedRaw = loadConfig(); const loadedRaw = loadConfig();
const { resolvedConfig: cfg } = await resolveCommandSecretRefsViaGateway({ const { resolvedConfig: cfg, diagnostics: secretDiagnostics } =
config: loadedRaw, await resolveCommandSecretRefsViaGateway({
commandName: "status", config: loadedRaw,
targetIds: getStatusCommandSecretTargetIds(), commandName: "status",
}); targetIds: getStatusCommandSecretTargetIds(),
mode: "summary",
});
const osSummary = resolveOsSummary(); const osSummary = resolveOsSummary();
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
const tailscaleDnsPromise = const tailscaleDnsPromise =
@@ -300,7 +308,9 @@ export async function scanStatus(
}), }),
); );
const agentStatusPromise = deferResult(getAgentLocalStatuses()); const agentStatusPromise = deferResult(getAgentLocalStatuses());
const summaryPromise = deferResult(getStatusSummary({ config: cfg })); const summaryPromise = deferResult(
getStatusSummary({ config: cfg, sourceConfig: loadedRaw }),
);
progress.tick(); progress.tick();
progress.setLabel("Checking Tailscale…"); progress.setLabel("Checking Tailscale…");
@@ -344,6 +354,7 @@ export async function scanStatus(
// Show token previews in regular status; keep `status --all` redacted. // Show token previews in regular status; keep `status --all` redacted.
// Set `CLAWDBOT_SHOW_SECRETS=0` to force redaction. // Set `CLAWDBOT_SHOW_SECRETS=0` to force redaction.
showSecrets: process.env.CLAWDBOT_SHOW_SECRETS?.trim() !== "0", showSecrets: process.env.CLAWDBOT_SHOW_SECRETS?.trim() !== "0",
sourceConfig: loadedRaw,
}); });
progress.tick(); progress.tick();
@@ -361,6 +372,8 @@ export async function scanStatus(
return { return {
cfg, cfg,
sourceConfig: loadedRaw,
secretDiagnostics,
osSummary, osSummary,
tailscaleMode, tailscaleMode,
tailscaleDns, tailscaleDns,

View File

@@ -77,7 +77,11 @@ export function redactSensitiveStatusSummary(summary: StatusSummary): StatusSumm
} }
export async function getStatusSummary( export async function getStatusSummary(
options: { includeSensitive?: boolean; config?: OpenClawConfig } = {}, options: {
includeSensitive?: boolean;
config?: OpenClawConfig;
sourceConfig?: OpenClawConfig;
} = {},
): Promise<StatusSummary> { ): Promise<StatusSummary> {
const { includeSensitive = true } = options; const { includeSensitive = true } = options;
const cfg = options.config ?? loadConfig(); const cfg = options.config ?? loadConfig();
@@ -95,6 +99,7 @@ export async function getStatusSummary(
const channelSummary = await buildChannelSummary(cfg, { const channelSummary = await buildChannelSummary(cfg, {
colorize: true, colorize: true,
includeAllowFrom: true, includeAllowFrom: true,
sourceConfig: options.sourceConfig,
}); });
const mainSessionKey = resolveMainSessionKey(cfg); const mainSessionKey = resolveMainSessionKey(cfg);
const queuedSystemEvents = peekSystemEvents(mainSessionKey); const queuedSystemEvents = peekSystemEvents(mainSessionKey);

View File

@@ -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<DiscordCredentialStatus, "missing">;
} | 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<string, unknown>, "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,
};
}

View File

@@ -104,4 +104,33 @@ describe("discord audit", () => {
expect(collected.channelIds).toEqual([]); expect(collected.channelIds).toEqual([]);
expect(collected.unresolvedChannels).toBe(0); 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);
});
}); });

View File

@@ -1,7 +1,7 @@
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import type { DiscordGuildChannelConfig, DiscordGuildEntry } from "../config/types.js"; import type { DiscordGuildChannelConfig, DiscordGuildEntry } from "../config/types.js";
import { isRecord } from "../utils.js"; import { isRecord } from "../utils.js";
import { resolveDiscordAccount } from "./accounts.js"; import { inspectDiscordAccount } from "./account-inspect.js";
import { fetchChannelPermissionsDiscord } from "./send.js"; import { fetchChannelPermissionsDiscord } from "./send.js";
export type DiscordChannelPermissionsAuditEntry = { export type DiscordChannelPermissionsAuditEntry = {
@@ -74,7 +74,7 @@ export function collectDiscordAuditChannelIds(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
accountId?: string | null; accountId?: string | null;
}) { }) {
const account = resolveDiscordAccount({ const account = inspectDiscordAccount({
cfg: params.cfg, cfg: params.cfg,
accountId: params.accountId, accountId: params.accountId,
}); });

View File

@@ -1,6 +1,6 @@
import { Container } from "@buape/carbon"; import { Container } from "@buape/carbon";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { resolveDiscordAccount } from "./accounts.js"; import { inspectDiscordAccount } from "./account-inspect.js";
const DEFAULT_DISCORD_ACCENT_COLOR = "#5865F2"; const DEFAULT_DISCORD_ACCENT_COLOR = "#5865F2";
@@ -24,7 +24,7 @@ export function normalizeDiscordAccentColor(raw?: string | null): string | null
} }
export function resolveDiscordAccentColor(params: ResolveDiscordAccentColorParams): string { 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); const configured = normalizeDiscordAccentColor(account.config.ui?.components?.accentColor);
return configured ?? DEFAULT_DISCORD_ACCENT_COLOR; return configured ?? DEFAULT_DISCORD_ACCENT_COLOR;
} }

View File

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

View File

@@ -1,3 +1,7 @@
import {
hasConfiguredUnavailableCredentialStatus,
hasResolvedCredentialValue,
} from "../channels/account-snapshot-fields.js";
import { import {
buildChannelAccountSnapshot, buildChannelAccountSnapshot,
formatChannelAllowFrom, formatChannelAllowFrom,
@@ -6,6 +10,7 @@ import {
} from "../channels/account-summary.js"; } from "../channels/account-summary.js";
import { listChannelPlugins } from "../channels/plugins/index.js"; import { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "../channels/plugins/types.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 { type OpenClawConfig, loadConfig } from "../config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
@@ -14,9 +19,10 @@ import { formatTimeAgo } from "./format-time/format-relative.ts";
export type ChannelSummaryOptions = { export type ChannelSummaryOptions = {
colorize?: boolean; colorize?: boolean;
includeAllowFrom?: boolean; includeAllowFrom?: boolean;
sourceConfig?: OpenClawConfig;
}; };
const DEFAULT_OPTIONS: Required<ChannelSummaryOptions> = { const DEFAULT_OPTIONS: Omit<Required<ChannelSummaryOptions>, "sourceConfig"> = {
colorize: false, colorize: false,
includeAllowFrom: false, includeAllowFrom: false,
}; };
@@ -63,6 +69,12 @@ const buildAccountDetails = (params: {
if (snapshot.appTokenSource && snapshot.appTokenSource !== "none") { if (snapshot.appTokenSource && snapshot.appTokenSource !== "none") {
details.push(`app:${snapshot.appTokenSource}`); 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) { if (snapshot.baseUrl) {
details.push(snapshot.baseUrl); details.push(snapshot.baseUrl);
} }
@@ -90,6 +102,17 @@ const buildAccountDetails = (params: {
return details; 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( export async function buildChannelSummary(
cfg?: OpenClawConfig, cfg?: OpenClawConfig,
options?: ChannelSummaryOptions, options?: ChannelSummaryOptions,
@@ -99,6 +122,7 @@ export async function buildChannelSummary(
const resolved = { ...DEFAULT_OPTIONS, ...options }; const resolved = { ...DEFAULT_OPTIONS, ...options };
const tint = (value: string, color?: (input: string) => string) => const tint = (value: string, color?: (input: string) => string) =>
resolved.colorize && color ? color(value) : value; resolved.colorize && color ? color(value) : value;
const sourceConfig = options?.sourceConfig ?? effective;
for (const plugin of listChannelPlugins()) { for (const plugin of listChannelPlugins()) {
const accountIds = plugin.config.listAccountIds(effective); const accountIds = plugin.config.listAccountIds(effective);
@@ -108,13 +132,39 @@ export async function buildChannelSummary(
const entries: ChannelAccountEntry[] = []; const entries: ChannelAccountEntry[] = [];
for (const accountId of resolvedAccountIds) { for (const accountId of resolvedAccountIds) {
const account = plugin.config.resolveAccount(effective, accountId); const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId);
const enabled = resolveChannelAccountEnabled({ plugin, account, cfg: effective }); const resolvedInspectedAccount = inspectChannelAccount(plugin, effective, accountId);
const configured = await resolveChannelAccountConfigured({ const resolvedInspection = resolvedInspectedAccount as {
plugin, enabled?: boolean;
account, configured?: boolean;
cfg: effective, } | 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({ const snapshot = buildChannelAccountSnapshot({
plugin, plugin,
account, account,

View File

@@ -1,6 +1,7 @@
export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js";
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
export type { OpenClawConfig } from "../config/config.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 { ResolvedDiscordAccount } from "../discord/accounts.js";
export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { PluginRuntime } from "../plugins/runtime/types.js";
export type { OpenClawPluginApi } from "../plugins/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js";
@@ -28,6 +29,11 @@ export {
resolveDefaultDiscordAccountId, resolveDefaultDiscordAccountId,
resolveDiscordAccount, resolveDiscordAccount,
} from "../discord/accounts.js"; } from "../discord/accounts.js";
export { inspectDiscordAccount } from "../discord/account-inspect.js";
export {
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,
} from "../channels/account-snapshot-fields.js";
export { export {
listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig, listDiscordDirectoryPeersFromConfig,

View File

@@ -553,6 +553,8 @@ export {
resolveDiscordAccount, resolveDiscordAccount,
type ResolvedDiscordAccount, type ResolvedDiscordAccount,
} from "../discord/accounts.js"; } 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 { collectDiscordAuditChannelIds } from "../discord/audit.js";
export { discordOnboardingAdapter } from "../channels/plugins/onboarding/discord.js"; export { discordOnboardingAdapter } from "../channels/plugins/onboarding/discord.js";
export { export {
@@ -591,6 +593,8 @@ export {
resolveSlackReplyToMode, resolveSlackReplyToMode,
type ResolvedSlackAccount, type ResolvedSlackAccount,
} from "../slack/accounts.js"; } 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 { extractSlackToolSend, listSlackMessageActions } from "../slack/message-actions.js";
export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js"; export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js";
export { export {
@@ -606,6 +610,8 @@ export {
resolveTelegramAccount, resolveTelegramAccount,
type ResolvedTelegramAccount, type ResolvedTelegramAccount,
} from "../telegram/accounts.js"; } 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 { telegramOnboardingAdapter } from "../channels/plugins/onboarding/telegram.js";
export { export {
looksLikeTelegramTargetId, looksLikeTelegramTargetId,

View File

@@ -1,5 +1,6 @@
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
export type { OpenClawConfig } from "../config/config.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 { ResolvedSlackAccount } from "../slack/accounts.js";
export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { PluginRuntime } from "../plugins/runtime/types.js";
export type { OpenClawPluginApi } from "../plugins/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js";
@@ -27,6 +28,12 @@ export {
resolveSlackAccount, resolveSlackAccount,
resolveSlackReplyToMode, resolveSlackReplyToMode,
} from "../slack/accounts.js"; } from "../slack/accounts.js";
export { inspectSlackAccount } from "../slack/account-inspect.js";
export {
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,
resolveConfiguredFromRequiredCredentialStatuses,
} from "../channels/account-snapshot-fields.js";
export { export {
listSlackDirectoryGroupsFromConfig, listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig, listSlackDirectoryPeersFromConfig,

View File

@@ -5,6 +5,7 @@ import * as lineSdk from "openclaw/plugin-sdk/line";
import * as msteamsSdk from "openclaw/plugin-sdk/msteams"; import * as msteamsSdk from "openclaw/plugin-sdk/msteams";
import * as signalSdk from "openclaw/plugin-sdk/signal"; import * as signalSdk from "openclaw/plugin-sdk/signal";
import * as slackSdk from "openclaw/plugin-sdk/slack"; 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 * as whatsappSdk from "openclaw/plugin-sdk/whatsapp";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
@@ -56,14 +57,22 @@ describe("plugin-sdk subpath exports", () => {
it("exports Discord helpers", () => { it("exports Discord helpers", () => {
expect(typeof discordSdk.resolveDiscordAccount).toBe("function"); expect(typeof discordSdk.resolveDiscordAccount).toBe("function");
expect(typeof discordSdk.inspectDiscordAccount).toBe("function");
expect(typeof discordSdk.discordOnboardingAdapter).toBe("object"); expect(typeof discordSdk.discordOnboardingAdapter).toBe("object");
}); });
it("exports Slack helpers", () => { it("exports Slack helpers", () => {
expect(typeof slackSdk.resolveSlackAccount).toBe("function"); expect(typeof slackSdk.resolveSlackAccount).toBe("function");
expect(typeof slackSdk.inspectSlackAccount).toBe("function");
expect(typeof slackSdk.handleSlackMessageAction).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", () => { it("exports Signal helpers", () => {
expect(typeof signalSdk.resolveSignalAccount).toBe("function"); expect(typeof signalSdk.resolveSignalAccount).toBe("function");
expect(typeof signalSdk.signalOnboardingAdapter).toBe("object"); expect(typeof signalSdk.signalOnboardingAdapter).toBe("object");

View File

@@ -7,6 +7,7 @@ export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
export type { OpenClawConfig } from "../config/config.js"; export type { OpenClawConfig } from "../config/config.js";
export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { PluginRuntime } from "../plugins/runtime/types.js";
export type { OpenClawPluginApi } from "../plugins/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 { ResolvedTelegramAccount } from "../telegram/accounts.js";
export type { TelegramProbe } from "../telegram/probe.js"; export type { TelegramProbe } from "../telegram/probe.js";
@@ -33,6 +34,11 @@ export {
resolveDefaultTelegramAccountId, resolveDefaultTelegramAccountId,
resolveTelegramAccount, resolveTelegramAccount,
} from "../telegram/accounts.js"; } from "../telegram/accounts.js";
export { inspectTelegramAccount } from "../telegram/account-inspect.js";
export {
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,
} from "../channels/account-snapshot-fields.js";
export { export {
listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryGroupsFromConfig,
listTelegramDirectoryPeersFromConfig, listTelegramDirectoryPeersFromConfig,

View File

@@ -15,18 +15,35 @@ export type ResolveAssignmentsFromSnapshotResult = {
diagnostics: string[]; 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; sourceConfig: OpenClawConfig;
resolvedConfig: OpenClawConfig; resolvedConfig: OpenClawConfig;
commandName: string;
targetIds: ReadonlySet<string>; targetIds: ReadonlySet<string>;
inactiveRefPaths?: ReadonlySet<string>; inactiveRefPaths?: ReadonlySet<string>;
}): ResolveAssignmentsFromSnapshotResult { allowedPaths?: ReadonlySet<string>;
}): AnalyzeAssignmentsFromSnapshotResult {
const defaults = params.sourceConfig.secrets?.defaults; const defaults = params.sourceConfig.secrets?.defaults;
const assignments: CommandSecretAssignment[] = []; const assignments: CommandSecretAssignment[] = [];
const diagnostics: string[] = []; const diagnostics: string[] = [];
const unresolved: UnresolvedCommandSecretAssignment[] = [];
const inactive: UnresolvedCommandSecretAssignment[] = [];
for (const target of discoverConfigSecretTargetsByIds(params.sourceConfig, params.targetIds)) { for (const target of discoverConfigSecretTargetsByIds(params.sourceConfig, params.targetIds)) {
if (params.allowedPaths && !params.allowedPaths.has(target.path)) {
continue;
}
const { explicitRef, ref } = resolveSecretInputRef({ const { explicitRef, ref } = resolveSecretInputRef({
value: target.value, value: target.value,
refValue: target.refValue, refValue: target.refValue,
@@ -43,11 +60,17 @@ export function collectCommandSecretAssignmentsFromSnapshot(params: {
diagnostics.push( diagnostics.push(
`${target.path}: secret ref is configured on an inactive surface; skipping command-time assignment.`, `${target.path}: secret ref is configured on an inactive surface; skipping command-time assignment.`,
); );
inactive.push({
path: target.path,
pathSegments: [...target.pathSegments],
});
continue; continue;
} }
throw new Error( unresolved.push({
`${params.commandName}: ${target.path} is unresolved in the active runtime snapshot.`, path: target.path,
); pathSegments: [...target.pathSegments],
});
continue;
} }
assignments.push({ 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<string>;
inactiveRefPaths?: ReadonlySet<string>;
allowedPaths?: ReadonlySet<string>;
}): 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,
};
} }

View File

@@ -1,6 +1,11 @@
import {
hasConfiguredUnavailableCredentialStatus,
hasResolvedCredentialValue,
} from "../channels/account-snapshot-fields.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import type { listChannelPlugins } from "../channels/plugins/index.js"; import type { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelId } from "../channels/plugins/types.js"; import type { ChannelId } from "../channels/plugins/types.js";
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
import { import {
isNumericTelegramUserId, isNumericTelegramUserId,
normalizeTelegramAllowFromEntry, normalizeTelegramAllowFromEntry,
@@ -113,9 +118,72 @@ function hasExplicitProviderAccountConfig(
export async function collectChannelSecurityFindings(params: { export async function collectChannelSecurityFindings(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
sourceConfig?: OpenClawConfig;
plugins: ReturnType<typeof listChannelPlugins>; plugins: ReturnType<typeof listChannelPlugins>;
}): Promise<SecurityAuditFinding[]> { }): Promise<SecurityAuditFinding[]> {
const findings: SecurityAuditFinding[] = []; 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<string, unknown> | null =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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 => { const coerceNativeSetting = (value: unknown): boolean | "auto" | undefined => {
if (value === true) { if (value === true) {
@@ -197,28 +265,24 @@ export async function collectChannelSecurityFindings(params: {
if (!plugin.security) { if (!plugin.security) {
continue; continue;
} }
const accountIds = plugin.config.listAccountIds(params.cfg); const accountIds = plugin.config.listAccountIds(sourceConfig);
const defaultAccountId = resolveChannelDefaultAccountId({ const defaultAccountId = resolveChannelDefaultAccountId({
plugin, plugin,
cfg: params.cfg, cfg: sourceConfig,
accountIds, accountIds,
}); });
const orderedAccountIds = Array.from(new Set([defaultAccountId, ...accountIds])); const orderedAccountIds = Array.from(new Set([defaultAccountId, ...accountIds]));
for (const accountId of orderedAccountIds) { for (const accountId of orderedAccountIds) {
const hasExplicitAccountPath = hasExplicitProviderAccountConfig( const hasExplicitAccountPath = hasExplicitProviderAccountConfig(
params.cfg, sourceConfig,
plugin.id, plugin.id,
accountId, accountId,
); );
const account = plugin.config.resolveAccount(params.cfg, accountId); const { account, enabled, configured } = await resolveChannelAuditAccount(plugin, accountId);
const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, params.cfg) : true;
if (!enabled) { if (!enabled) {
continue; continue;
} }
const configured = plugin.config.isConfigured
? await plugin.config.isConfigured(account, params.cfg)
: true;
if (!configured) { if (!configured) {
continue; continue;
} }

View File

@@ -30,7 +30,10 @@ function stubChannelPlugin(params: {
id: "discord" | "slack" | "telegram"; id: "discord" | "slack" | "telegram";
label: string; label: string;
resolveAccount: (cfg: OpenClawConfig, accountId: string | null | undefined) => unknown; resolveAccount: (cfg: OpenClawConfig, accountId: string | null | undefined) => unknown;
inspectAccount?: (cfg: OpenClawConfig, accountId: string | null | undefined) => unknown;
listAccountIds?: (cfg: OpenClawConfig) => string[]; listAccountIds?: (cfg: OpenClawConfig) => string[];
isConfigured?: (account: unknown, cfg: OpenClawConfig) => boolean;
isEnabled?: (account: unknown, cfg: OpenClawConfig) => boolean;
}): ChannelPlugin { }): ChannelPlugin {
return { return {
id: params.id, id: params.id,
@@ -54,9 +57,10 @@ function stubChannelPlugin(params: {
); );
return enabled ? ["default"] : []; return enabled ? ["default"] : [];
}), }),
inspectAccount: params.inspectAccount,
resolveAccount: (cfg, accountId) => params.resolveAccount(cfg, accountId), resolveAccount: (cfg, accountId) => params.resolveAccount(cfg, accountId),
isEnabled: () => true, isEnabled: (account, cfg) => params.isEnabled?.(account, cfg) ?? true,
isConfigured: () => 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 () => { it("does not flag Discord slash commands when dm.allowFrom includes a Discord snowflake id", async () => {
await withChannelSecurityStateDir(async () => { await withChannelSecurityStateDir(async () => {
const cfg: OpenClawConfig = { const cfg: OpenClawConfig = {

View File

@@ -86,6 +86,7 @@ export type SecurityAuditReport = {
export type SecurityAuditOptions = { export type SecurityAuditOptions = {
config: OpenClawConfig; config: OpenClawConfig;
sourceConfig?: OpenClawConfig;
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform; platform?: NodeJS.Platform;
deep?: boolean; deep?: boolean;
@@ -113,6 +114,7 @@ export type SecurityAuditOptions = {
type AuditExecutionContext = { type AuditExecutionContext = {
cfg: OpenClawConfig; cfg: OpenClawConfig;
sourceConfig: OpenClawConfig;
env: NodeJS.ProcessEnv; env: NodeJS.ProcessEnv;
platform: NodeJS.Platform; platform: NodeJS.Platform;
includeFilesystem: boolean; includeFilesystem: boolean;
@@ -1092,6 +1094,7 @@ async function createAuditExecutionContext(
opts: SecurityAuditOptions, opts: SecurityAuditOptions,
): Promise<AuditExecutionContext> { ): Promise<AuditExecutionContext> {
const cfg = opts.config; const cfg = opts.config;
const sourceConfig = opts.sourceConfig ?? opts.config;
const env = opts.env ?? process.env; const env = opts.env ?? process.env;
const platform = opts.platform ?? process.platform; const platform = opts.platform ?? process.platform;
const includeFilesystem = opts.includeFilesystem !== false; const includeFilesystem = opts.includeFilesystem !== false;
@@ -1107,6 +1110,7 @@ async function createAuditExecutionContext(
: null; : null;
return { return {
cfg, cfg,
sourceConfig,
env, env,
platform, platform,
includeFilesystem, includeFilesystem,
@@ -1206,7 +1210,13 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
if (context.includeChannelSecurity) { if (context.includeChannelSecurity) {
const plugins = context.plugins ?? listChannelPlugins(); const plugins = context.plugins ?? listChannelPlugins();
findings.push(...(await collectChannelSecurityFindings({ cfg, plugins }))); findings.push(
...(await collectChannelSecurityFindings({
cfg,
sourceConfig: context.sourceConfig,
plugins,
})),
);
} }
const deepProbeResult = context.deep const deepProbeResult = context.deep

View File

@@ -0,0 +1,205 @@
import type { OpenClawConfig } from "../config/config.js";
import { hasConfiguredSecretInput, normalizeSecretInputString } from "../config/types.secrets.js";
import type { SlackAccountConfig } from "../config/types.slack.js";
import { resolveAccountEntry } from "../routing/account-lookup.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import { resolveDefaultSlackAccountId, type SlackTokenSource } from "./accounts.js";
export type SlackCredentialStatus = "available" | "configured_unavailable" | "missing";
export type InspectedSlackAccount = {
accountId: string;
enabled: boolean;
name?: string;
mode?: SlackAccountConfig["mode"];
botToken?: string;
appToken?: string;
signingSecret?: string;
userToken?: string;
botTokenSource: SlackTokenSource;
appTokenSource: SlackTokenSource;
signingSecretSource?: SlackTokenSource;
userTokenSource: SlackTokenSource;
botTokenStatus: SlackCredentialStatus;
appTokenStatus: SlackCredentialStatus;
signingSecretStatus?: SlackCredentialStatus;
userTokenStatus: SlackCredentialStatus;
configured: boolean;
config: SlackAccountConfig;
groupPolicy?: SlackAccountConfig["groupPolicy"];
textChunkLimit?: SlackAccountConfig["textChunkLimit"];
mediaMaxMb?: SlackAccountConfig["mediaMaxMb"];
reactionNotifications?: SlackAccountConfig["reactionNotifications"];
reactionAllowlist?: SlackAccountConfig["reactionAllowlist"];
replyToMode?: SlackAccountConfig["replyToMode"];
replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"];
actions?: SlackAccountConfig["actions"];
slashCommand?: SlackAccountConfig["slashCommand"];
dm?: SlackAccountConfig["dm"];
channels?: SlackAccountConfig["channels"];
};
function resolveSlackAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): SlackAccountConfig | undefined {
return resolveAccountEntry(cfg.channels?.slack?.accounts, accountId);
}
function mergeSlackAccountConfig(cfg: OpenClawConfig, accountId: string): SlackAccountConfig {
const { accounts: _ignored, ...base } = (cfg.channels?.slack ?? {}) as SlackAccountConfig & {
accounts?: unknown;
};
const account = resolveSlackAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account };
}
function inspectSlackToken(value: unknown): {
token?: string;
source: Exclude<SlackTokenSource, "env">;
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,
};
}

View File

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