mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
27
src/channels/account-snapshot-fields.test.ts
Normal file
27
src/channels/account-snapshot-fields.test.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
217
src/channels/account-snapshot-fields.ts
Normal file
217
src/channels/account-snapshot-fields.ts
Normal 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") } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 !== "*");
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
39
src/channels/read-only-account-inspect.ts
Normal file
39
src/channels/read-only-account-inspect.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
|||||||
271
src/commands/channels.config-only-status-output.test.ts
Normal file
271
src/commands/channels.config-only-status-output.test.ts
Normal 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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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).`,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = (() => {
|
||||||
|
|||||||
@@ -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()]);
|
||||||
|
|
||||||
|
|||||||
@@ -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(" · "),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
138
src/commands/status.scan.test.ts
Normal file
138
src/commands/status.scan.test.ts
Normal 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" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
141
src/discord/account-inspect.ts
Normal file
141
src/discord/account-inspect.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
84
src/infra/channel-summary.test.ts
Normal file
84
src/infra/channel-summary.test.ts
Normal 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)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
205
src/slack/account-inspect.ts
Normal file
205
src/slack/account-inspect.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
213
src/telegram/account-inspect.ts
Normal file
213
src/telegram/account-inspect.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user