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.
|
||||
- Use `openclaw doctor` for guided fixes.
|
||||
- `openclaw channels list` prints `Claude: HTTP 403 ... user:profile` → usage snapshot needs the `user:profile` scope. Use `--no-usage`, or provide a claude.ai session key (`CLAUDE_WEB_SESSION_KEY` / `CLAUDE_WEB_COOKIE`), or re-auth via Claude Code CLI.
|
||||
- `openclaw channels status` falls back to config-only summaries when the gateway is unreachable. If a supported channel credential is configured via SecretRef but unavailable in the current command path, it reports that account as configured with degraded notes instead of showing it as not configured.
|
||||
|
||||
## Capabilities probe
|
||||
|
||||
@@ -97,3 +98,4 @@ Notes:
|
||||
|
||||
- Use `--kind user|group|auto` to force the target type.
|
||||
- Resolution prefers active matches when multiple entries share the same name.
|
||||
- `channels resolve` is read-only. If a selected account is configured via SecretRef but that credential is unavailable in the current command path, the command returns degraded unresolved results with notes instead of aborting the entire run.
|
||||
|
||||
@@ -24,3 +24,5 @@ Notes:
|
||||
- Overview includes Gateway + node host service install/runtime status when available.
|
||||
- Overview includes update channel + git SHA (for source checkouts).
|
||||
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `openclaw update` (see [Updating](/install/updating)).
|
||||
- Read-only status surfaces (`status`, `status --json`, `status --all`) resolve supported SecretRefs for their targeted config paths when possible.
|
||||
- If a supported channel SecretRef is configured but unavailable in the current command path, status stays read-only and reports degraded output instead of crashing. Human output shows warnings such as “configured token unavailable in this command path”, and JSON output includes `secretDiagnostics`.
|
||||
|
||||
@@ -244,6 +244,14 @@ Doctor checks local gateway token auth readiness.
|
||||
- If `gateway.auth.token` is SecretRef-managed but unavailable, doctor warns and does not overwrite it with plaintext.
|
||||
- `openclaw doctor --generate-gateway-token` forces generation only when no token SecretRef is configured.
|
||||
|
||||
### 12b) Read-only SecretRef-aware repairs
|
||||
|
||||
Some repair flows need to inspect configured credentials without weakening runtime fail-fast behavior.
|
||||
|
||||
- `openclaw doctor --fix` now uses the same read-only SecretRef summary model as status-family commands for targeted config repairs.
|
||||
- Example: Telegram `allowFrom` / `groupAllowFrom` `@username` repair tries to use configured bot credentials when available.
|
||||
- If the Telegram bot token is configured via SecretRef but unavailable in the current command path, doctor reports that the credential is configured-but-unavailable and skips auto-resolution instead of crashing or misreporting the token as missing.
|
||||
|
||||
### 13) Gateway health check + restart
|
||||
|
||||
Doctor runs a health check and offers to restart the gateway when it looks
|
||||
|
||||
@@ -339,10 +339,22 @@ Behavior:
|
||||
|
||||
## Command-path resolution
|
||||
|
||||
Credential-sensitive command paths that opt in (for example `openclaw memory` remote-memory paths and `openclaw qr --remote`) can resolve supported SecretRefs via gateway snapshot RPC.
|
||||
Command paths can opt into supported SecretRef resolution via gateway snapshot RPC.
|
||||
|
||||
There are two broad behaviors:
|
||||
|
||||
- Strict command paths (for example `openclaw memory` remote-memory paths and `openclaw qr --remote`) read from the active snapshot and fail fast when a required SecretRef is unavailable.
|
||||
- Read-only command paths (for example `openclaw status`, `openclaw status --all`, `openclaw channels status`, `openclaw channels resolve`, and read-only doctor/config repair flows) also prefer the active snapshot, but degrade instead of aborting when a targeted SecretRef is unavailable in that command path.
|
||||
|
||||
Read-only behavior:
|
||||
|
||||
- When the gateway is running, these commands read from the active snapshot first.
|
||||
- If gateway resolution is incomplete or the gateway is unavailable, they attempt targeted local fallback for the specific command surface.
|
||||
- If a targeted SecretRef is still unavailable, the command continues with degraded read-only output and explicit diagnostics such as “configured but unavailable in this command path”.
|
||||
- This degraded behavior is command-local only. It does not weaken runtime startup, reload, or send/auth paths.
|
||||
|
||||
Other notes:
|
||||
|
||||
- When gateway is running, those command paths read from the active snapshot.
|
||||
- If a configured SecretRef is required and gateway is unavailable, command resolution fails fast with actionable diagnostics.
|
||||
- Snapshot refresh after backend secret rotation is handled by `openclaw secrets reload`.
|
||||
- Gateway RPC method used by these command paths: `secrets.resolve`.
|
||||
|
||||
|
||||
@@ -178,6 +178,38 @@ Compatibility note:
|
||||
subpaths; use `core` for generic surfaces and `compat` only when broader
|
||||
shared helpers are required.
|
||||
|
||||
## Read-only channel inspection
|
||||
|
||||
If your plugin registers a channel, prefer implementing
|
||||
`plugin.config.inspectAccount(cfg, accountId)` alongside `resolveAccount(...)`.
|
||||
|
||||
Why:
|
||||
|
||||
- `resolveAccount(...)` is the runtime path. It is allowed to assume credentials
|
||||
are fully materialized and can fail fast when required secrets are missing.
|
||||
- Read-only command paths such as `openclaw status`, `openclaw status --all`,
|
||||
`openclaw channels status`, `openclaw channels resolve`, and doctor/config
|
||||
repair flows should not need to materialize runtime credentials just to
|
||||
describe configuration.
|
||||
|
||||
Recommended `inspectAccount(...)` behavior:
|
||||
|
||||
- Return descriptive account state only.
|
||||
- Preserve `enabled` and `configured`.
|
||||
- Include credential source/status fields when relevant, such as:
|
||||
- `tokenSource`, `tokenStatus`
|
||||
- `botTokenSource`, `botTokenStatus`
|
||||
- `appTokenSource`, `appTokenStatus`
|
||||
- `signingSecretSource`, `signingSecretStatus`
|
||||
- You do not need to return raw token values just to report read-only
|
||||
availability. Returning `tokenStatus: "available"` (and the matching source
|
||||
field) is enough for status-style commands.
|
||||
- Use `configured_unavailable` when a credential is configured via SecretRef but
|
||||
unavailable in the current command path.
|
||||
|
||||
This lets read-only commands report “configured but unavailable in this command
|
||||
path” instead of crashing or misreporting the account as not configured.
|
||||
|
||||
Performance note:
|
||||
|
||||
- Plugin discovery and manifest metadata use short in-process caches to reduce
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DiscordConfigSchema,
|
||||
formatPairingApproveHint,
|
||||
getChatChannelMeta,
|
||||
inspectDiscordAccount,
|
||||
listDiscordAccountIds,
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
@@ -19,6 +20,8 @@ import {
|
||||
normalizeDiscordMessagingTarget,
|
||||
normalizeDiscordOutboundTarget,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
projectCredentialSnapshotFields,
|
||||
resolveConfiguredFromCredentialStatuses,
|
||||
resolveDiscordAccount,
|
||||
resolveDefaultDiscordAccountId,
|
||||
resolveDiscordGroupRequireMention,
|
||||
@@ -80,6 +83,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
config: {
|
||||
listAccountIds: (cfg) => listDiscordAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
|
||||
inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultDiscordAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
@@ -390,7 +394,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
return { ...audit, unresolvedChannels };
|
||||
},
|
||||
buildAccountSnapshot: ({ account, runtime, probe, audit }) => {
|
||||
const configured = Boolean(account.token?.trim());
|
||||
const configured =
|
||||
resolveConfiguredFromCredentialStatuses(account) ?? Boolean(account.token?.trim());
|
||||
const app = runtime?.application ?? (probe as { application?: unknown })?.application;
|
||||
const bot = runtime?.bot ?? (probe as { bot?: unknown })?.bot;
|
||||
return {
|
||||
@@ -398,7 +403,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured,
|
||||
tokenSource: account.tokenSource,
|
||||
...projectCredentialSnapshotFields(account),
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
|
||||
@@ -182,4 +182,53 @@ describe("slackPlugin config", () => {
|
||||
expect(configured).toBe(false);
|
||||
expect(snapshot?.configured).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark partial configured-unavailable token status as configured", async () => {
|
||||
const snapshot = await slackPlugin.status?.buildAccountSnapshot?.({
|
||||
account: {
|
||||
accountId: "default",
|
||||
name: "Default",
|
||||
enabled: true,
|
||||
configured: false,
|
||||
botTokenStatus: "configured_unavailable",
|
||||
appTokenStatus: "missing",
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "none",
|
||||
config: {},
|
||||
} as never,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: undefined,
|
||||
});
|
||||
|
||||
expect(snapshot?.configured).toBe(false);
|
||||
expect(snapshot?.botTokenStatus).toBe("configured_unavailable");
|
||||
expect(snapshot?.appTokenStatus).toBe("missing");
|
||||
});
|
||||
|
||||
it("keeps HTTP mode signing-secret unavailable accounts configured in snapshots", async () => {
|
||||
const snapshot = await slackPlugin.status?.buildAccountSnapshot?.({
|
||||
account: {
|
||||
accountId: "default",
|
||||
name: "Default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
mode: "http",
|
||||
botTokenStatus: "available",
|
||||
signingSecretStatus: "configured_unavailable",
|
||||
botTokenSource: "config",
|
||||
signingSecretSource: "config",
|
||||
config: {
|
||||
mode: "http",
|
||||
botToken: "xoxb-http",
|
||||
signingSecret: { source: "env", provider: "default", id: "SLACK_SIGNING_SECRET" },
|
||||
},
|
||||
} as never,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: undefined,
|
||||
});
|
||||
|
||||
expect(snapshot?.configured).toBe(true);
|
||||
expect(snapshot?.botTokenStatus).toBe("available");
|
||||
expect(snapshot?.signingSecretStatus).toBe("configured_unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
formatPairingApproveHint,
|
||||
getChatChannelMeta,
|
||||
handleSlackMessageAction,
|
||||
inspectSlackAccount,
|
||||
listSlackMessageActions,
|
||||
listSlackAccountIds,
|
||||
listSlackDirectoryGroupsFromConfig,
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
normalizeAccountId,
|
||||
normalizeSlackMessagingTarget,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
projectCredentialSnapshotFields,
|
||||
resolveConfiguredFromRequiredCredentialStatuses,
|
||||
resolveDefaultSlackAccountId,
|
||||
resolveSlackAccount,
|
||||
resolveSlackReplyToMode,
|
||||
@@ -131,6 +134,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
config: {
|
||||
listAccountIds: (cfg) => listSlackAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }),
|
||||
inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultSlackAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
@@ -428,14 +432,23 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
return await getSlackRuntime().channel.slack.probeSlack(token, timeoutMs);
|
||||
},
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
||||
const configured = isSlackAccountConfigured(account);
|
||||
const mode = account.config.mode ?? "socket";
|
||||
const configured =
|
||||
(mode === "http"
|
||||
? resolveConfiguredFromRequiredCredentialStatuses(account, [
|
||||
"botTokenStatus",
|
||||
"signingSecretStatus",
|
||||
])
|
||||
: resolveConfiguredFromRequiredCredentialStatuses(account, [
|
||||
"botTokenStatus",
|
||||
"appTokenStatus",
|
||||
])) ?? isSlackAccountConfigured(account);
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured,
|
||||
botTokenSource: account.botTokenSource,
|
||||
appTokenSource: account.appTokenSource,
|
||||
...projectCredentialSnapshotFields(account),
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
getChatChannelMeta,
|
||||
inspectTelegramAccount,
|
||||
listTelegramAccountIds,
|
||||
listTelegramDirectoryGroupsFromConfig,
|
||||
listTelegramDirectoryPeersFromConfig,
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
parseTelegramReplyToMessageId,
|
||||
parseTelegramThreadId,
|
||||
projectCredentialSnapshotFields,
|
||||
resolveConfiguredFromCredentialStatuses,
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
@@ -43,7 +46,7 @@ function findTelegramTokenOwnerAccountId(params: {
|
||||
const normalizedAccountId = normalizeAccountId(params.accountId);
|
||||
const tokenOwners = new Map<string, string>();
|
||||
for (const id of listTelegramAccountIds(params.cfg)) {
|
||||
const account = resolveTelegramAccount({ cfg: params.cfg, accountId: id });
|
||||
const account = inspectTelegramAccount({ cfg: params.cfg, accountId: id });
|
||||
const token = (account.token ?? "").trim();
|
||||
if (!token) {
|
||||
continue;
|
||||
@@ -122,6 +125,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
config: {
|
||||
listAccountIds: (cfg) => listTelegramAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }),
|
||||
inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultTelegramAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
@@ -416,6 +420,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
return { ...audit, unresolvedGroups, hasWildcardUnmentionedGroups };
|
||||
},
|
||||
buildAccountSnapshot: ({ account, cfg, runtime, probe, audit }) => {
|
||||
const configuredFromStatus = resolveConfiguredFromCredentialStatuses(account);
|
||||
const ownerAccountId = findTelegramTokenOwnerAccountId({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
@@ -426,7 +431,8 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
ownerAccountId,
|
||||
})
|
||||
: null;
|
||||
const configured = Boolean(account.token?.trim()) && !ownerAccountId;
|
||||
const configured =
|
||||
(configuredFromStatus ?? Boolean(account.token?.trim())) && !ownerAccountId;
|
||||
const groups =
|
||||
cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ??
|
||||
cfg.channels?.telegram?.groups;
|
||||
@@ -440,7 +446,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured,
|
||||
tokenSource: account.tokenSource,
|
||||
...projectCredentialSnapshotFields(account),
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? 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 { projectSafeChannelAccountSnapshotFields } from "./account-snapshot-fields.js";
|
||||
import type { ChannelAccountSnapshot } from "./plugins/types.core.js";
|
||||
import type { ChannelPlugin } from "./plugins/types.plugin.js";
|
||||
|
||||
@@ -14,6 +15,7 @@ export function buildChannelAccountSnapshot(params: {
|
||||
return {
|
||||
enabled: params.enabled,
|
||||
configured: params.configured,
|
||||
...projectSafeChannelAccountSnapshotFields(params.account),
|
||||
...described,
|
||||
accountId: params.accountId,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { withEnv } from "../test-utils/env.js";
|
||||
import { getChannelDock } from "./dock.js";
|
||||
|
||||
function emptyConfig(): OpenClawConfig {
|
||||
@@ -69,7 +70,7 @@ describe("channels dock", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const accountDefault = ircDock?.config?.resolveDefaultTo?.({ cfg, accountId: "work" });
|
||||
const rootDefault = ircDock?.config?.resolveDefaultTo?.({ cfg, accountId: "missing" });
|
||||
@@ -99,4 +100,73 @@ describe("channels dock", () => {
|
||||
|
||||
expect(formatted).toEqual(["user", "foo", "plain"]);
|
||||
});
|
||||
|
||||
it("telegram dock config readers preserve omitted-account fallback semantics", () => {
|
||||
withEnv({ TELEGRAM_BOT_TOKEN: "tok-env" }, () => {
|
||||
const telegramDock = getChannelDock("telegram");
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["top-owner"],
|
||||
defaultTo: "@top-target",
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "tok-work",
|
||||
allowFrom: ["work-owner"],
|
||||
defaultTo: "@work-target",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expect(telegramDock?.config?.resolveAllowFrom?.({ cfg })).toEqual(["top-owner"]);
|
||||
expect(telegramDock?.config?.resolveDefaultTo?.({ cfg })).toBe("@top-target");
|
||||
});
|
||||
});
|
||||
|
||||
it("slack dock config readers stay read-only when tokens are unresolved SecretRefs", () => {
|
||||
const slackDock = getChannelDock("slack");
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "SLACK_BOT_TOKEN",
|
||||
},
|
||||
appToken: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "SLACK_APP_TOKEN",
|
||||
},
|
||||
defaultTo: "channel:C111",
|
||||
dm: { allowFrom: ["U123"] },
|
||||
channels: {
|
||||
C111: { requireMention: false },
|
||||
},
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expect(slackDock?.config?.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual(["U123"]);
|
||||
expect(slackDock?.config?.resolveDefaultTo?.({ cfg, accountId: "default" })).toBe(
|
||||
"channel:C111",
|
||||
);
|
||||
expect(
|
||||
slackDock?.threading?.resolveReplyToMode?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
chatType: "channel",
|
||||
}),
|
||||
).toBe("all");
|
||||
expect(
|
||||
slackDock?.groups?.resolveRequireMention?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
groupId: "C111",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
resolveChannelGroupRequireMention,
|
||||
resolveChannelGroupToolsPolicy,
|
||||
} from "../config/group-policy.js";
|
||||
import { resolveDiscordAccount } from "../discord/accounts.js";
|
||||
import { inspectDiscordAccount } from "../discord/account-inspect.js";
|
||||
import {
|
||||
formatTrimmedAllowFromEntries,
|
||||
formatWhatsAppConfigAllowFromEntries,
|
||||
@@ -14,9 +14,10 @@ import {
|
||||
import { requireActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { normalizeAccountId } from "../routing/session-key.js";
|
||||
import { resolveSignalAccount } from "../signal/accounts.js";
|
||||
import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts.js";
|
||||
import { inspectSlackAccount } from "../slack/account-inspect.js";
|
||||
import { resolveSlackReplyToMode } from "../slack/accounts.js";
|
||||
import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
|
||||
import { resolveTelegramAccount } from "../telegram/accounts.js";
|
||||
import { inspectTelegramAccount } from "../telegram/account-inspect.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import {
|
||||
resolveDiscordGroupRequireMention,
|
||||
@@ -246,13 +247,13 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000,
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
stringifyAllowFrom(resolveTelegramAccount({ cfg, accountId }).config.allowFrom ?? []),
|
||||
stringifyAllowFrom(inspectTelegramAccount({ cfg, accountId }).config.allowFrom ?? []),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
trimAllowFromEntries(allowFrom)
|
||||
.map((entry) => entry.replace(/^(telegram|tg):/i, ""))
|
||||
.map((entry) => entry.toLowerCase()),
|
||||
resolveDefaultTo: ({ cfg, accountId }) => {
|
||||
const val = resolveTelegramAccount({ cfg, accountId }).config.defaultTo;
|
||||
const val = inspectTelegramAccount({ cfg, accountId }).config.defaultTo;
|
||||
return val != null ? String(val) : undefined;
|
||||
},
|
||||
},
|
||||
@@ -335,14 +336,14 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
},
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) => {
|
||||
const account = resolveDiscordAccount({ cfg, accountId });
|
||||
const account = inspectDiscordAccount({ cfg, accountId });
|
||||
return (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map((entry) =>
|
||||
String(entry),
|
||||
);
|
||||
},
|
||||
formatAllowFrom: ({ allowFrom }) => formatDiscordAllowFrom(allowFrom),
|
||||
resolveDefaultTo: ({ cfg, accountId }) =>
|
||||
resolveDiscordAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
|
||||
inspectDiscordAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveDiscordGroupRequireMention,
|
||||
@@ -477,14 +478,14 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
streaming: DEFAULT_BLOCK_STREAMING_COALESCE,
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) => {
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const account = inspectSlackAccount({ cfg, accountId });
|
||||
return (account.config.allowFrom ?? account.dm?.allowFrom ?? []).map((entry) =>
|
||||
String(entry),
|
||||
);
|
||||
},
|
||||
formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom),
|
||||
resolveDefaultTo: ({ cfg, accountId }) =>
|
||||
resolveSlackAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
|
||||
inspectSlackAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||
@@ -495,7 +496,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
||||
resolveSlackReplyToMode(inspectSlackAccount({ cfg, accountId }), chatType),
|
||||
allowExplicitReplyTagsWhenOff: false,
|
||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { OpenClawConfig } from "../../config/types.js";
|
||||
import { resolveDiscordAccount } from "../../discord/accounts.js";
|
||||
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||
import { resolveTelegramAccount } from "../../telegram/accounts.js";
|
||||
import { inspectDiscordAccount } from "../../discord/account-inspect.js";
|
||||
import { inspectSlackAccount } from "../../slack/account-inspect.js";
|
||||
import { inspectTelegramAccount } from "../../telegram/account-inspect.js";
|
||||
import { resolveWhatsAppAccount } from "../../web/accounts.js";
|
||||
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
|
||||
import { normalizeSlackMessagingTarget } from "./normalize/slack.js";
|
||||
@@ -75,7 +75,7 @@ function toDirectoryEntries(kind: "user" | "group", ids: string[]): ChannelDirec
|
||||
export async function listSlackDirectoryPeersFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const account = inspectSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const ids = new Set<string>();
|
||||
|
||||
addAllowFromAndDmsIds(ids, account.config.allowFrom ?? account.dm?.allowFrom, account.config.dms);
|
||||
@@ -98,7 +98,7 @@ export async function listSlackDirectoryPeersFromConfig(
|
||||
export async function listSlackDirectoryGroupsFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const account = inspectSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const ids = Object.keys(account.config.channels ?? {})
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
@@ -110,7 +110,7 @@ export async function listSlackDirectoryGroupsFromConfig(
|
||||
export async function listDiscordDirectoryPeersFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const ids = new Set<string>();
|
||||
|
||||
addAllowFromAndDmsIds(
|
||||
@@ -139,7 +139,7 @@ export async function listDiscordDirectoryPeersFromConfig(
|
||||
export async function listDiscordDirectoryGroupsFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const ids = new Set<string>();
|
||||
for (const guild of Object.values(account.config.guilds ?? {})) {
|
||||
addTrimmedEntries(ids, Object.keys(guild.channels ?? {}));
|
||||
@@ -159,7 +159,7 @@ export async function listDiscordDirectoryGroupsFromConfig(
|
||||
export async function listTelegramDirectoryPeersFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const account = inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const raw = [
|
||||
...(account.config.allowFrom ?? []).map((entry) => String(entry)),
|
||||
...Object.keys(account.config.dms ?? {}),
|
||||
@@ -190,7 +190,7 @@ export async function listTelegramDirectoryPeersFromConfig(
|
||||
export async function listTelegramDirectoryGroupsFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const account = inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const ids = Object.keys(account.config.groups ?? {})
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => Boolean(id) && id !== "*");
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
GroupToolPolicyConfig,
|
||||
} from "../../config/types.tools.js";
|
||||
import { normalizeAtHashSlug, normalizeHyphenSlug } from "../../shared/string-normalization.js";
|
||||
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||
import { inspectSlackAccount } from "../../slack/account-inspect.js";
|
||||
import type { ChannelGroupContext } from "./types.js";
|
||||
|
||||
type GroupMentionParams = ChannelGroupContext;
|
||||
@@ -130,7 +130,7 @@ type ChannelGroupPolicyChannel =
|
||||
function resolveSlackChannelPolicyEntry(
|
||||
params: GroupMentionParams,
|
||||
): SlackChannelPolicyEntry | undefined {
|
||||
const account = resolveSlackAccount({
|
||||
const account = inspectSlackAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import type { DiscordGuildEntry } from "../../../config/types.discord.js";
|
||||
import { hasConfiguredSecretInput } from "../../../config/types.secrets.js";
|
||||
import { inspectDiscordAccount } from "../../../discord/account-inspect.js";
|
||||
import {
|
||||
listDiscordAccountIds,
|
||||
resolveDefaultDiscordAccountId,
|
||||
@@ -148,8 +149,8 @@ export const discordOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listDiscordAccountIds(cfg).some((accountId) => {
|
||||
const account = resolveDiscordAccount({ cfg, accountId });
|
||||
return Boolean(account.token) || hasConfiguredSecretInput(account.config.token);
|
||||
const account = inspectDiscordAccount({ cfg, accountId });
|
||||
return account.configured;
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import { hasConfiguredSecretInput } from "../../../config/types.secrets.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
|
||||
import { inspectSlackAccount } from "../../../slack/account-inspect.js";
|
||||
import {
|
||||
listSlackAccountIds,
|
||||
resolveDefaultSlackAccountId,
|
||||
@@ -199,12 +200,8 @@ export const slackOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listSlackAccountIds(cfg).some((accountId) => {
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const hasBotToken =
|
||||
Boolean(account.botToken) || hasConfiguredSecretInput(account.config.botToken);
|
||||
const hasAppToken =
|
||||
Boolean(account.appToken) || hasConfiguredSecretInput(account.config.appToken);
|
||||
return hasBotToken && hasAppToken;
|
||||
const account = inspectSlackAccount({ cfg, accountId });
|
||||
return account.configured;
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { formatCliCommand } from "../../../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import { hasConfiguredSecretInput } from "../../../config/types.secrets.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
|
||||
import { inspectTelegramAccount } from "../../../telegram/account-inspect.js";
|
||||
import {
|
||||
listTelegramAccountIds,
|
||||
resolveDefaultTelegramAccountId,
|
||||
@@ -153,12 +154,8 @@ export const telegramOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listTelegramAccountIds(cfg).some((accountId) => {
|
||||
const account = resolveTelegramAccount({ cfg, accountId });
|
||||
return (
|
||||
Boolean(account.token) ||
|
||||
Boolean(account.config.tokenFile?.trim()) ||
|
||||
hasConfiguredSecretInput(account.config.botToken)
|
||||
);
|
||||
const account = inspectTelegramAccount({ cfg, accountId });
|
||||
return account.configured;
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
createOutboundTestPlugin,
|
||||
createTestRegistry,
|
||||
} from "../../test-utils/channel-plugins.js";
|
||||
import { withEnvAsync } from "../../test-utils/env.js";
|
||||
import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js";
|
||||
import { resolveChannelConfigWrites } from "./config-writes.js";
|
||||
import {
|
||||
@@ -409,6 +410,72 @@ describe("directory (config-backed)", () => {
|
||||
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
|
||||
});
|
||||
|
||||
it("keeps Telegram config-backed directory fallback semantics when accountId is omitted", async () => {
|
||||
await withEnvAsync({ TELEGRAM_BOT_TOKEN: "tok-env" }, async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["alice"],
|
||||
groups: { "-1001": {} },
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "tok-work",
|
||||
allowFrom: ["bob"],
|
||||
groups: { "-2002": {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]);
|
||||
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps config-backed directories readable when channel tokens are unresolved SecretRefs", async () => {
|
||||
const envSecret = {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MISSING_TEST_SECRET",
|
||||
} as const;
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: envSecret,
|
||||
appToken: envSecret,
|
||||
dm: { allowFrom: ["U123"] },
|
||||
channels: { C111: {} },
|
||||
},
|
||||
discord: {
|
||||
token: envSecret,
|
||||
dm: { allowFrom: ["<@111>"] },
|
||||
guilds: {
|
||||
"123": {
|
||||
channels: {
|
||||
"555": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
telegram: {
|
||||
botToken: envSecret,
|
||||
allowFrom: ["alice"],
|
||||
groups: { "-1001": {} },
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
await expectDirectoryIds(listSlackDirectoryPeersFromConfig, cfg, ["user:u123"]);
|
||||
await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]);
|
||||
await expectDirectoryIds(listDiscordDirectoryPeersFromConfig, cfg, ["user:111"]);
|
||||
await expectDirectoryIds(listDiscordDirectoryGroupsFromConfig, cfg, ["channel:555"]);
|
||||
await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]);
|
||||
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
|
||||
});
|
||||
|
||||
it("lists WhatsApp peers/groups from config", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@@ -1,7 +1,70 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { projectSafeChannelAccountSnapshotFields } from "../account-snapshot-fields.js";
|
||||
import { inspectReadOnlyChannelAccount } from "../read-only-account-inspect.js";
|
||||
import type { ChannelAccountSnapshot, ChannelPlugin } from "./types.js";
|
||||
|
||||
// Channel docking: status snapshots flow through plugin.status hooks here.
|
||||
async function buildSnapshotFromAccount<ResolvedAccount>(params: {
|
||||
plugin: ChannelPlugin<ResolvedAccount>;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
account: ResolvedAccount;
|
||||
runtime?: ChannelAccountSnapshot;
|
||||
probe?: unknown;
|
||||
audit?: unknown;
|
||||
}): Promise<ChannelAccountSnapshot> {
|
||||
if (params.plugin.status?.buildAccountSnapshot) {
|
||||
return await params.plugin.status.buildAccountSnapshot({
|
||||
account: params.account,
|
||||
cfg: params.cfg,
|
||||
runtime: params.runtime,
|
||||
probe: params.probe,
|
||||
audit: params.audit,
|
||||
});
|
||||
}
|
||||
const enabled = params.plugin.config.isEnabled
|
||||
? params.plugin.config.isEnabled(params.account, params.cfg)
|
||||
: params.account && typeof params.account === "object"
|
||||
? (params.account as { enabled?: boolean }).enabled
|
||||
: undefined;
|
||||
const configured =
|
||||
params.account && typeof params.account === "object" && "configured" in params.account
|
||||
? (params.account as { configured?: boolean }).configured
|
||||
: params.plugin.config.isConfigured
|
||||
? await params.plugin.config.isConfigured(params.account, params.cfg)
|
||||
: undefined;
|
||||
return {
|
||||
accountId: params.accountId,
|
||||
enabled,
|
||||
configured,
|
||||
...projectSafeChannelAccountSnapshotFields(params.account),
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildReadOnlySourceChannelAccountSnapshot<ResolvedAccount>(params: {
|
||||
plugin: ChannelPlugin<ResolvedAccount>;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
runtime?: ChannelAccountSnapshot;
|
||||
probe?: unknown;
|
||||
audit?: unknown;
|
||||
}): Promise<ChannelAccountSnapshot | null> {
|
||||
const inspectedAccount =
|
||||
params.plugin.config.inspectAccount?.(params.cfg, params.accountId) ??
|
||||
inspectReadOnlyChannelAccount({
|
||||
channelId: params.plugin.id,
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (!inspectedAccount) {
|
||||
return null;
|
||||
}
|
||||
return await buildSnapshotFromAccount({
|
||||
...params,
|
||||
account: inspectedAccount as ResolvedAccount,
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildChannelAccountSnapshot<ResolvedAccount>(params: {
|
||||
plugin: ChannelPlugin<ResolvedAccount>;
|
||||
cfg: OpenClawConfig;
|
||||
@@ -10,27 +73,17 @@ export async function buildChannelAccountSnapshot<ResolvedAccount>(params: {
|
||||
probe?: unknown;
|
||||
audit?: unknown;
|
||||
}): Promise<ChannelAccountSnapshot> {
|
||||
const account = params.plugin.config.resolveAccount(params.cfg, params.accountId);
|
||||
if (params.plugin.status?.buildAccountSnapshot) {
|
||||
return await params.plugin.status.buildAccountSnapshot({
|
||||
account,
|
||||
const inspectedAccount =
|
||||
params.plugin.config.inspectAccount?.(params.cfg, params.accountId) ??
|
||||
inspectReadOnlyChannelAccount({
|
||||
channelId: params.plugin.id,
|
||||
cfg: params.cfg,
|
||||
runtime: params.runtime,
|
||||
probe: params.probe,
|
||||
audit: params.audit,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
const enabled = params.plugin.config.isEnabled
|
||||
? params.plugin.config.isEnabled(account, params.cfg)
|
||||
: account && typeof account === "object"
|
||||
? (account as { enabled?: boolean }).enabled
|
||||
: undefined;
|
||||
const configured = params.plugin.config.isConfigured
|
||||
? await params.plugin.config.isConfigured(account, params.cfg)
|
||||
: undefined;
|
||||
return {
|
||||
accountId: params.accountId,
|
||||
enabled,
|
||||
configured,
|
||||
};
|
||||
const account = (inspectedAccount ??
|
||||
params.plugin.config.resolveAccount(params.cfg, params.accountId)) as ResolvedAccount;
|
||||
return await buildSnapshotFromAccount({
|
||||
...params,
|
||||
account,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export type ChannelSetupAdapter = {
|
||||
export type ChannelConfigAdapter<ResolvedAccount> = {
|
||||
listAccountIds: (cfg: OpenClawConfig) => string[];
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount;
|
||||
inspectAccount?: (cfg: OpenClawConfig, accountId?: string | null) => unknown;
|
||||
defaultAccountId?: (cfg: OpenClawConfig) => string;
|
||||
setAccountEnabled?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
|
||||
@@ -129,6 +129,12 @@ export type ChannelAccountSnapshot = {
|
||||
tokenSource?: string;
|
||||
botTokenSource?: string;
|
||||
appTokenSource?: string;
|
||||
signingSecretSource?: string;
|
||||
tokenStatus?: string;
|
||||
botTokenStatus?: string;
|
||||
appTokenStatus?: string;
|
||||
signingSecretStatus?: string;
|
||||
userTokenStatus?: string;
|
||||
credentialSource?: string;
|
||||
secretSource?: string;
|
||||
audienceType?: string;
|
||||
|
||||
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(
|
||||
result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.diagnostics.some((entry) => entry.includes("resolved command secrets locally")),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
if (priorValue === undefined) {
|
||||
delete process.env.TALK_API_KEY;
|
||||
@@ -353,4 +356,213 @@ describe("resolveCommandSecretRefsViaGateway", () => {
|
||||
});
|
||||
expect(result.diagnostics).toEqual(["memory search ref inactive"]);
|
||||
});
|
||||
|
||||
it("degrades unresolved refs in summary mode instead of throwing", async () => {
|
||||
const envKey = "TALK_API_KEY_SUMMARY_MISSING";
|
||||
const priorValue = process.env[envKey];
|
||||
delete process.env[envKey];
|
||||
callGateway.mockResolvedValueOnce({
|
||||
assignments: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await resolveCommandSecretRefsViaGateway({
|
||||
config: {
|
||||
talk: {
|
||||
apiKey: { source: "env", provider: "default", id: envKey },
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
commandName: "status",
|
||||
targetIds: new Set(["talk.apiKey"]),
|
||||
mode: "summary",
|
||||
});
|
||||
|
||||
expect(result.resolvedConfig.talk?.apiKey).toBeUndefined();
|
||||
expect(result.hadUnresolvedTargets).toBe(true);
|
||||
expect(result.targetStatesByPath["talk.apiKey"]).toBe("unresolved");
|
||||
expect(
|
||||
result.diagnostics.some((entry) =>
|
||||
entry.includes("talk.apiKey is unavailable in this command path"),
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
if (priorValue === undefined) {
|
||||
delete process.env[envKey];
|
||||
} else {
|
||||
process.env[envKey] = priorValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("uses targeted local fallback after an incomplete gateway snapshot", async () => {
|
||||
const envKey = "TALK_API_KEY_PARTIAL_GATEWAY";
|
||||
const priorValue = process.env[envKey];
|
||||
process.env[envKey] = "recovered-locally";
|
||||
callGateway.mockResolvedValueOnce({
|
||||
assignments: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await resolveCommandSecretRefsViaGateway({
|
||||
config: {
|
||||
talk: {
|
||||
apiKey: { source: "env", provider: "default", id: envKey },
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
commandName: "status",
|
||||
targetIds: new Set(["talk.apiKey"]),
|
||||
mode: "summary",
|
||||
});
|
||||
|
||||
expect(result.resolvedConfig.talk?.apiKey).toBe("recovered-locally");
|
||||
expect(result.hadUnresolvedTargets).toBe(false);
|
||||
expect(result.targetStatesByPath["talk.apiKey"]).toBe("resolved_local");
|
||||
expect(
|
||||
result.diagnostics.some((entry) =>
|
||||
entry.includes(
|
||||
"resolved 1 secret path locally after the gateway snapshot was incomplete",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
if (priorValue === undefined) {
|
||||
delete process.env[envKey];
|
||||
} else {
|
||||
process.env[envKey] = priorValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("limits strict local fallback analysis to unresolved gateway paths", async () => {
|
||||
const gatewayResolvedKey = "TALK_API_KEY_PARTIAL_GATEWAY_RESOLVED";
|
||||
const locallyRecoveredKey = "TALK_API_KEY_PARTIAL_GATEWAY_LOCAL";
|
||||
const priorGatewayResolvedValue = process.env[gatewayResolvedKey];
|
||||
const priorLocallyRecoveredValue = process.env[locallyRecoveredKey];
|
||||
delete process.env[gatewayResolvedKey];
|
||||
process.env[locallyRecoveredKey] = "recovered-locally";
|
||||
callGateway.mockResolvedValueOnce({
|
||||
assignments: [
|
||||
{
|
||||
path: "talk.apiKey",
|
||||
pathSegments: ["talk", "apiKey"],
|
||||
value: "resolved-by-gateway",
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await resolveCommandSecretRefsViaGateway({
|
||||
config: {
|
||||
talk: {
|
||||
apiKey: { source: "env", provider: "default", id: gatewayResolvedKey },
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
apiKey: { source: "env", provider: "default", id: locallyRecoveredKey },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
commandName: "message send",
|
||||
targetIds: new Set(["talk.apiKey", "talk.providers.*.apiKey"]),
|
||||
});
|
||||
|
||||
expect(result.resolvedConfig.talk?.apiKey).toBe("resolved-by-gateway");
|
||||
expect(result.resolvedConfig.talk?.providers?.elevenlabs?.apiKey).toBe("recovered-locally");
|
||||
expect(result.hadUnresolvedTargets).toBe(false);
|
||||
expect(result.targetStatesByPath["talk.apiKey"]).toBe("resolved_gateway");
|
||||
expect(result.targetStatesByPath["talk.providers.elevenlabs.apiKey"]).toBe("resolved_local");
|
||||
} finally {
|
||||
if (priorGatewayResolvedValue === undefined) {
|
||||
delete process.env[gatewayResolvedKey];
|
||||
} else {
|
||||
process.env[gatewayResolvedKey] = priorGatewayResolvedValue;
|
||||
}
|
||||
if (priorLocallyRecoveredValue === undefined) {
|
||||
delete process.env[locallyRecoveredKey];
|
||||
} else {
|
||||
process.env[locallyRecoveredKey] = priorLocallyRecoveredValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("limits local fallback to targeted refs in read-only modes", async () => {
|
||||
const talkEnvKey = "TALK_API_KEY_TARGET_ONLY";
|
||||
const gatewayEnvKey = "GATEWAY_PASSWORD_UNRELATED";
|
||||
const priorTalkValue = process.env[talkEnvKey];
|
||||
const priorGatewayValue = process.env[gatewayEnvKey];
|
||||
process.env[talkEnvKey] = "target-only";
|
||||
delete process.env[gatewayEnvKey];
|
||||
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
|
||||
|
||||
try {
|
||||
const result = await resolveCommandSecretRefsViaGateway({
|
||||
config: {
|
||||
talk: {
|
||||
apiKey: { source: "env", provider: "default", id: talkEnvKey },
|
||||
},
|
||||
gateway: {
|
||||
auth: {
|
||||
password: { source: "env", provider: "default", id: gatewayEnvKey },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
commandName: "status",
|
||||
targetIds: new Set(["talk.apiKey"]),
|
||||
mode: "summary",
|
||||
});
|
||||
|
||||
expect(result.resolvedConfig.talk?.apiKey).toBe("target-only");
|
||||
expect(result.hadUnresolvedTargets).toBe(false);
|
||||
expect(result.targetStatesByPath["talk.apiKey"]).toBe("resolved_local");
|
||||
} finally {
|
||||
if (priorTalkValue === undefined) {
|
||||
delete process.env[talkEnvKey];
|
||||
} else {
|
||||
process.env[talkEnvKey] = priorTalkValue;
|
||||
}
|
||||
if (priorGatewayValue === undefined) {
|
||||
delete process.env[gatewayEnvKey];
|
||||
} else {
|
||||
process.env[gatewayEnvKey] = priorGatewayValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("degrades unresolved refs in operational read-only mode", async () => {
|
||||
const envKey = "TALK_API_KEY_OPERATIONAL_MISSING";
|
||||
const priorValue = process.env[envKey];
|
||||
delete process.env[envKey];
|
||||
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
|
||||
|
||||
try {
|
||||
const result = await resolveCommandSecretRefsViaGateway({
|
||||
config: {
|
||||
talk: {
|
||||
apiKey: { source: "env", provider: "default", id: envKey },
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
commandName: "channels resolve",
|
||||
targetIds: new Set(["talk.apiKey"]),
|
||||
mode: "operational_readonly",
|
||||
});
|
||||
|
||||
expect(result.resolvedConfig.talk?.apiKey).toBeUndefined();
|
||||
expect(result.hadUnresolvedTargets).toBe(true);
|
||||
expect(result.targetStatesByPath["talk.apiKey"]).toBe("unresolved");
|
||||
expect(
|
||||
result.diagnostics.some((entry) =>
|
||||
entry.includes("attempted local command-secret resolution"),
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
if (priorValue === undefined) {
|
||||
delete process.env[envKey];
|
||||
} else {
|
||||
process.env[envKey] = priorValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,20 +2,37 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveSecretInputRef } from "../config/types.secrets.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { validateSecretsResolveResult } from "../gateway/protocol/index.js";
|
||||
import { collectCommandSecretAssignmentsFromSnapshot } from "../secrets/command-config.js";
|
||||
import { setPathExistingStrict } from "../secrets/path-utils.js";
|
||||
import { resolveSecretRefValues } from "../secrets/resolve.js";
|
||||
import {
|
||||
analyzeCommandSecretAssignmentsFromSnapshot,
|
||||
type UnresolvedCommandSecretAssignment,
|
||||
} from "../secrets/command-config.js";
|
||||
import { getPath, setPathExistingStrict } from "../secrets/path-utils.js";
|
||||
import { resolveSecretRefValue } from "../secrets/resolve.js";
|
||||
import { collectConfigAssignments } from "../secrets/runtime-config-collectors.js";
|
||||
import { applyResolvedAssignments, createResolverContext } from "../secrets/runtime-shared.js";
|
||||
import { createResolverContext } from "../secrets/runtime-shared.js";
|
||||
import { assertExpectedResolvedSecretValue } from "../secrets/secret-value.js";
|
||||
import { describeUnknownError } from "../secrets/shared.js";
|
||||
import { discoverConfigSecretTargetsByIds } from "../secrets/target-registry.js";
|
||||
import {
|
||||
discoverConfigSecretTargetsByIds,
|
||||
type DiscoveredConfigSecretTarget,
|
||||
} from "../secrets/target-registry.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
|
||||
type ResolveCommandSecretsResult = {
|
||||
resolvedConfig: OpenClawConfig;
|
||||
diagnostics: string[];
|
||||
targetStatesByPath: Record<string, CommandSecretTargetState>;
|
||||
hadUnresolvedTargets: boolean;
|
||||
};
|
||||
|
||||
export type CommandSecretResolutionMode = "strict" | "summary" | "operational_readonly";
|
||||
|
||||
export type CommandSecretTargetState =
|
||||
| "resolved_gateway"
|
||||
| "resolved_local"
|
||||
| "inactive_surface"
|
||||
| "unresolved";
|
||||
|
||||
type GatewaySecretsResolveResult = {
|
||||
ok?: boolean;
|
||||
assignments?: Array<{
|
||||
@@ -167,6 +184,8 @@ async function resolveCommandSecretRefsLocally(params: {
|
||||
commandName: string;
|
||||
targetIds: Set<string>;
|
||||
preflightDiagnostics: string[];
|
||||
mode: CommandSecretResolutionMode;
|
||||
allowedPaths?: ReadonlySet<string>;
|
||||
}): Promise<ResolveCommandSecretsResult> {
|
||||
const sourceConfig = params.config;
|
||||
const resolvedConfig = structuredClone(params.config);
|
||||
@@ -175,57 +194,191 @@ async function resolveCommandSecretRefsLocally(params: {
|
||||
env: process.env,
|
||||
});
|
||||
collectConfigAssignments({
|
||||
config: resolvedConfig,
|
||||
config: structuredClone(params.config),
|
||||
context,
|
||||
});
|
||||
if (context.assignments.length > 0) {
|
||||
const resolved = await resolveSecretRefValues(
|
||||
context.assignments.map((assignment) => assignment.ref),
|
||||
{
|
||||
config: sourceConfig,
|
||||
env: context.env,
|
||||
cache: context.cache,
|
||||
},
|
||||
);
|
||||
applyResolvedAssignments({
|
||||
assignments: context.assignments,
|
||||
resolved,
|
||||
});
|
||||
}
|
||||
|
||||
const inactiveRefPaths = new Set(
|
||||
context.warnings
|
||||
.filter((warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE")
|
||||
.map((warning) => warning.path),
|
||||
);
|
||||
const commandAssignments = collectCommandSecretAssignmentsFromSnapshot({
|
||||
const activePaths = new Set(context.assignments.map((assignment) => assignment.path));
|
||||
const localResolutionDiagnostics: string[] = [];
|
||||
for (const target of discoverConfigSecretTargetsByIds(sourceConfig, params.targetIds)) {
|
||||
if (params.allowedPaths && !params.allowedPaths.has(target.path)) {
|
||||
continue;
|
||||
}
|
||||
await resolveTargetSecretLocally({
|
||||
target,
|
||||
sourceConfig,
|
||||
resolvedConfig,
|
||||
env: context.env,
|
||||
cache: context.cache,
|
||||
activePaths,
|
||||
inactiveRefPaths,
|
||||
mode: params.mode,
|
||||
commandName: params.commandName,
|
||||
localResolutionDiagnostics,
|
||||
});
|
||||
}
|
||||
const analyzed = analyzeCommandSecretAssignmentsFromSnapshot({
|
||||
sourceConfig,
|
||||
resolvedConfig,
|
||||
commandName: params.commandName,
|
||||
targetIds: params.targetIds,
|
||||
inactiveRefPaths,
|
||||
...(params.allowedPaths ? { allowedPaths: params.allowedPaths } : {}),
|
||||
});
|
||||
const targetStatesByPath = buildTargetStatesByPath({
|
||||
analyzed,
|
||||
resolvedState: "resolved_local",
|
||||
});
|
||||
if (params.mode !== "strict" && analyzed.unresolved.length > 0) {
|
||||
scrubUnresolvedAssignments(resolvedConfig, analyzed.unresolved);
|
||||
} else if (analyzed.unresolved.length > 0) {
|
||||
throw new Error(
|
||||
`${params.commandName}: ${analyzed.unresolved[0]?.path ?? "target"} is unresolved in the active runtime snapshot.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
resolvedConfig,
|
||||
diagnostics: dedupeDiagnostics([
|
||||
...params.preflightDiagnostics,
|
||||
...commandAssignments.diagnostics,
|
||||
...filterInactiveSurfaceDiagnostics({
|
||||
diagnostics: analyzed.diagnostics,
|
||||
inactiveRefPaths,
|
||||
}),
|
||||
...localResolutionDiagnostics,
|
||||
...buildUnresolvedDiagnostics(params.commandName, analyzed.unresolved, params.mode),
|
||||
]),
|
||||
targetStatesByPath,
|
||||
hadUnresolvedTargets: analyzed.unresolved.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTargetStatesByPath(params: {
|
||||
analyzed: ReturnType<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: {
|
||||
config: OpenClawConfig;
|
||||
commandName: string;
|
||||
targetIds: Set<string>;
|
||||
mode?: CommandSecretResolutionMode;
|
||||
}): Promise<ResolveCommandSecretsResult> {
|
||||
const mode = params.mode ?? "strict";
|
||||
const configuredTargetRefPaths = collectConfiguredTargetRefPaths({
|
||||
config: params.config,
|
||||
targetIds: params.targetIds,
|
||||
});
|
||||
if (configuredTargetRefPaths.size === 0) {
|
||||
return { resolvedConfig: params.config, diagnostics: [] };
|
||||
return {
|
||||
resolvedConfig: params.config,
|
||||
diagnostics: [],
|
||||
targetStatesByPath: {},
|
||||
hadUnresolvedTargets: false,
|
||||
};
|
||||
}
|
||||
const preflight = classifyConfiguredTargetRefs({
|
||||
config: params.config,
|
||||
@@ -235,6 +388,8 @@ export async function resolveCommandSecretRefsViaGateway(params: {
|
||||
return {
|
||||
resolvedConfig: params.config,
|
||||
diagnostics: preflight.diagnostics,
|
||||
targetStatesByPath: {},
|
||||
hadUnresolvedTargets: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -258,13 +413,23 @@ export async function resolveCommandSecretRefsViaGateway(params: {
|
||||
commandName: params.commandName,
|
||||
targetIds: params.targetIds,
|
||||
preflightDiagnostics: preflight.diagnostics,
|
||||
mode,
|
||||
});
|
||||
const recoveredLocally = Object.values(fallback.targetStatesByPath).some(
|
||||
(state) => state === "resolved_local",
|
||||
);
|
||||
const fallbackMessage =
|
||||
recoveredLocally && !fallback.hadUnresolvedTargets
|
||||
? "resolved command secrets locally."
|
||||
: "attempted local command-secret resolution.";
|
||||
return {
|
||||
resolvedConfig: fallback.resolvedConfig,
|
||||
diagnostics: dedupeDiagnostics([
|
||||
...fallback.diagnostics,
|
||||
`${params.commandName}: gateway secrets.resolve unavailable (${describeUnknownError(err)}); resolved command secrets locally.`,
|
||||
`${params.commandName}: gateway secrets.resolve unavailable (${describeUnknownError(err)}); ${fallbackMessage}`,
|
||||
]),
|
||||
targetStatesByPath: fallback.targetStatesByPath,
|
||||
hadUnresolvedTargets: fallback.hadUnresolvedTargets,
|
||||
};
|
||||
} catch {
|
||||
// Fall through to original gateway-specific error reporting.
|
||||
@@ -302,16 +467,86 @@ export async function resolveCommandSecretRefsViaGateway(params: {
|
||||
parsed.inactiveRefPaths.length > 0
|
||||
? new Set(parsed.inactiveRefPaths)
|
||||
: collectInactiveSurfacePathsFromDiagnostics(parsed.diagnostics);
|
||||
collectCommandSecretAssignmentsFromSnapshot({
|
||||
const analyzed = analyzeCommandSecretAssignmentsFromSnapshot({
|
||||
sourceConfig: params.config,
|
||||
resolvedConfig,
|
||||
commandName: params.commandName,
|
||||
targetIds: params.targetIds,
|
||||
inactiveRefPaths,
|
||||
});
|
||||
let diagnostics = dedupeDiagnostics(parsed.diagnostics);
|
||||
const targetStatesByPath = buildTargetStatesByPath({
|
||||
analyzed,
|
||||
resolvedState: "resolved_gateway",
|
||||
});
|
||||
if (analyzed.unresolved.length > 0) {
|
||||
try {
|
||||
const localFallback = await resolveCommandSecretRefsLocally({
|
||||
config: params.config,
|
||||
commandName: params.commandName,
|
||||
targetIds: params.targetIds,
|
||||
preflightDiagnostics: [],
|
||||
mode,
|
||||
allowedPaths: new Set(analyzed.unresolved.map((entry) => entry.path)),
|
||||
});
|
||||
for (const unresolved of analyzed.unresolved) {
|
||||
if (localFallback.targetStatesByPath[unresolved.path] !== "resolved_local") {
|
||||
continue;
|
||||
}
|
||||
setPathExistingStrict(
|
||||
resolvedConfig,
|
||||
unresolved.pathSegments,
|
||||
getPath(localFallback.resolvedConfig, unresolved.pathSegments),
|
||||
);
|
||||
targetStatesByPath[unresolved.path] = "resolved_local";
|
||||
}
|
||||
const recoveredPaths = new Set(
|
||||
Object.entries(localFallback.targetStatesByPath)
|
||||
.filter(([, state]) => state === "resolved_local")
|
||||
.map(([path]) => path),
|
||||
);
|
||||
const stillUnresolved = analyzed.unresolved.filter(
|
||||
(entry) => !recoveredPaths.has(entry.path),
|
||||
);
|
||||
if (stillUnresolved.length > 0) {
|
||||
if (mode === "strict") {
|
||||
throw new Error(
|
||||
`${params.commandName}: ${stillUnresolved[0]?.path ?? "target"} is unresolved in the active runtime snapshot.`,
|
||||
);
|
||||
}
|
||||
scrubUnresolvedAssignments(resolvedConfig, stillUnresolved);
|
||||
diagnostics = dedupeDiagnostics([
|
||||
...diagnostics,
|
||||
...localFallback.diagnostics,
|
||||
...buildUnresolvedDiagnostics(params.commandName, stillUnresolved, mode),
|
||||
]);
|
||||
for (const unresolved of stillUnresolved) {
|
||||
targetStatesByPath[unresolved.path] = "unresolved";
|
||||
}
|
||||
} else if (recoveredPaths.size > 0) {
|
||||
diagnostics = dedupeDiagnostics([
|
||||
...diagnostics,
|
||||
`${params.commandName}: resolved ${recoveredPaths.size} secret ${
|
||||
recoveredPaths.size === 1 ? "path" : "paths"
|
||||
} locally after the gateway snapshot was incomplete.`,
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
if (mode === "strict") {
|
||||
throw error;
|
||||
}
|
||||
scrubUnresolvedAssignments(resolvedConfig, analyzed.unresolved);
|
||||
diagnostics = dedupeDiagnostics([
|
||||
...diagnostics,
|
||||
`${params.commandName}: local fallback after incomplete gateway snapshot failed (${describeUnknownError(error)}).`,
|
||||
...buildUnresolvedDiagnostics(params.commandName, analyzed.unresolved, mode),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
resolvedConfig,
|
||||
diagnostics: dedupeDiagnostics(parsed.diagnostics),
|
||||
diagnostics,
|
||||
targetStatesByPath,
|
||||
hadUnresolvedTargets: Object.values(targetStatesByPath).includes("unresolved"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ describe("registerPreActionHooks", () => {
|
||||
runtime: runtimeMock,
|
||||
commandPath: ["status"],
|
||||
});
|
||||
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
|
||||
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1);
|
||||
expect(process.title).toBe("openclaw-status");
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -33,6 +33,8 @@ const PLUGIN_REQUIRED_COMMANDS = new Set([
|
||||
"agents",
|
||||
"configure",
|
||||
"onboard",
|
||||
"status",
|
||||
"health",
|
||||
]);
|
||||
const CONFIG_GUARD_BYPASS_COMMANDS = new Set(["doctor", "completion", "secrets"]);
|
||||
const JSON_PARSE_ONLY_COMMANDS = new Set(["config set"]);
|
||||
|
||||
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,
|
||||
commandName: "channels resolve",
|
||||
targetIds: getChannelsCommandSecretTargetIds(),
|
||||
mode: "operational_readonly",
|
||||
});
|
||||
for (const entry of diagnostics) {
|
||||
runtime.log(`[secrets] ${entry}`);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { type ChannelId, getChannelPlugin } from "../../channels/plugins/index.js";
|
||||
import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js";
|
||||
import {
|
||||
type CommandSecretResolutionMode,
|
||||
resolveCommandSecretRefsViaGateway,
|
||||
} from "../../cli/command-secret-gateway.js";
|
||||
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
|
||||
@@ -8,8 +11,14 @@ import { requireValidConfigSnapshot } from "../config-validation.js";
|
||||
|
||||
export type ChatChannel = ChannelId;
|
||||
|
||||
export { requireValidConfigSnapshot };
|
||||
|
||||
export async function requireValidConfig(
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
secretResolution?: {
|
||||
commandName?: string;
|
||||
mode?: CommandSecretResolutionMode;
|
||||
},
|
||||
): Promise<OpenClawConfig | null> {
|
||||
const cfg = await requireValidConfigSnapshot(runtime);
|
||||
if (!cfg) {
|
||||
@@ -17,8 +26,9 @@ export async function requireValidConfig(
|
||||
}
|
||||
const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({
|
||||
config: cfg,
|
||||
commandName: "channels",
|
||||
commandName: secretResolution?.commandName ?? "channels",
|
||||
targetIds: getChannelsCommandSecretTargetIds(),
|
||||
mode: secretResolution?.mode,
|
||||
});
|
||||
for (const entry of diagnostics) {
|
||||
runtime.log(`[secrets] ${entry}`);
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import {
|
||||
hasConfiguredUnavailableCredentialStatus,
|
||||
hasResolvedCredentialValue,
|
||||
} from "../../channels/account-snapshot-fields.js";
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
|
||||
import {
|
||||
buildChannelAccountSnapshot,
|
||||
buildReadOnlySourceChannelAccountSnapshot,
|
||||
} from "../../channels/plugins/status.js";
|
||||
import type { ChannelAccountSnapshot } from "../../channels/plugins/types.js";
|
||||
import { formatCliCommand } from "../../cli/command-format.js";
|
||||
import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js";
|
||||
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
|
||||
import { withProgress } from "../../cli/progress.js";
|
||||
import { type OpenClawConfig, readConfigFileSnapshot } from "../../config/config.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
@@ -10,7 +19,11 @@ import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { type ChatChannel, formatChannelAccountLabel, requireValidConfig } from "./shared.js";
|
||||
import {
|
||||
type ChatChannel,
|
||||
formatChannelAccountLabel,
|
||||
requireValidConfigSnapshot,
|
||||
} from "./shared.js";
|
||||
|
||||
export type ChannelsStatusOptions = {
|
||||
json?: boolean;
|
||||
@@ -23,7 +36,14 @@ function appendEnabledConfiguredLinkedBits(bits: string[], account: Record<strin
|
||||
bits.push(account.enabled ? "enabled" : "disabled");
|
||||
}
|
||||
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") {
|
||||
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>) {
|
||||
if (typeof account.tokenSource === "string" && account.tokenSource) {
|
||||
bits.push(`token:${account.tokenSource}`);
|
||||
}
|
||||
if (typeof account.botTokenSource === "string" && account.botTokenSource) {
|
||||
bits.push(`bot:${account.botTokenSource}`);
|
||||
}
|
||||
if (typeof account.appTokenSource === "string" && account.appTokenSource) {
|
||||
bits.push(`app:${account.appTokenSource}`);
|
||||
}
|
||||
const appendSourceBit = (label: string, sourceKey: string, statusKey: string) => {
|
||||
const source = account[sourceKey];
|
||||
if (typeof source !== "string" || !source || source === "none") {
|
||||
return;
|
||||
}
|
||||
const status = account[statusKey];
|
||||
const unavailable = status === "configured_unavailable" ? " (unavailable)" : "";
|
||||
bits.push(`${label}:${source}${unavailable}`);
|
||||
};
|
||||
|
||||
appendSourceBit("token", "tokenSource", "tokenStatus");
|
||||
appendSourceBit("bot", "botTokenSource", "botTokenStatus");
|
||||
appendSourceBit("app", "appTokenSource", "appTokenStatus");
|
||||
appendSourceBit("signing", "signingSecretSource", "signingSecretStatus");
|
||||
}
|
||||
|
||||
function appendBaseUrlBit(bits: string[], account: Record<string, unknown>) {
|
||||
@@ -184,9 +209,10 @@ export function formatGatewayChannelsStatusLines(payload: Record<string, unknown
|
||||
return lines;
|
||||
}
|
||||
|
||||
async function formatConfigChannelsStatusLines(
|
||||
export async function formatConfigChannelsStatusLines(
|
||||
cfg: OpenClawConfig,
|
||||
meta: { path?: string; mode?: "local" | "remote" },
|
||||
opts?: { sourceConfig?: OpenClawConfig },
|
||||
): Promise<string[]> {
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.warn("Gateway not reachable; showing config-only status."));
|
||||
@@ -211,6 +237,7 @@ async function formatConfigChannelsStatusLines(
|
||||
});
|
||||
|
||||
const plugins = listChannelPlugins();
|
||||
const sourceConfig = opts?.sourceConfig ?? cfg;
|
||||
for (const plugin of plugins) {
|
||||
const accountIds = plugin.config.listAccountIds(cfg);
|
||||
if (!accountIds.length) {
|
||||
@@ -218,12 +245,24 @@ async function formatConfigChannelsStatusLines(
|
||||
}
|
||||
const snapshots: ChannelAccountSnapshot[] = [];
|
||||
for (const accountId of accountIds) {
|
||||
const snapshot = await buildChannelAccountSnapshot({
|
||||
const sourceSnapshot = await buildReadOnlySourceChannelAccountSnapshot({
|
||||
plugin,
|
||||
cfg: sourceConfig,
|
||||
accountId,
|
||||
});
|
||||
const resolvedSnapshot = await buildChannelAccountSnapshot({
|
||||
plugin,
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
snapshots.push(snapshot);
|
||||
snapshots.push(
|
||||
sourceSnapshot &&
|
||||
hasConfiguredUnavailableCredentialStatus(sourceSnapshot) &&
|
||||
(!hasResolvedCredentialValue(resolvedSnapshot) ||
|
||||
(sourceSnapshot.configured === true && resolvedSnapshot.configured === false))
|
||||
? sourceSnapshot
|
||||
: resolvedSnapshot,
|
||||
);
|
||||
}
|
||||
if (snapshots.length > 0) {
|
||||
lines.push(...accountLines(plugin.id, snapshots));
|
||||
@@ -268,18 +307,31 @@ export async function channelsStatusCommand(
|
||||
runtime.log(formatGatewayChannelsStatusLines(payload).join("\n"));
|
||||
} catch (err) {
|
||||
runtime.error(`Gateway not reachable: ${String(err)}`);
|
||||
const cfg = await requireValidConfig(runtime);
|
||||
const cfg = await requireValidConfigSnapshot(runtime);
|
||||
if (!cfg) {
|
||||
return;
|
||||
}
|
||||
const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({
|
||||
config: cfg,
|
||||
commandName: "channels status",
|
||||
targetIds: getChannelsCommandSecretTargetIds(),
|
||||
mode: "summary",
|
||||
});
|
||||
for (const entry of diagnostics) {
|
||||
runtime.log(`[secrets] ${entry}`);
|
||||
}
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const mode = cfg.gateway?.mode === "remote" ? "remote" : "local";
|
||||
runtime.log(
|
||||
(
|
||||
await formatConfigChannelsStatusLines(cfg, {
|
||||
path: snapshot.path,
|
||||
mode,
|
||||
})
|
||||
await formatConfigChannelsStatusLines(
|
||||
resolvedConfig,
|
||||
{
|
||||
path: snapshot.path,
|
||||
mode,
|
||||
},
|
||||
{ sourceConfig: cfg },
|
||||
)
|
||||
).join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".openclaw");
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from "../channels/telegram/allow-from.js";
|
||||
import { fetchTelegramChatId } from "../channels/telegram/api.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js";
|
||||
import { getChannelsCommandSecretTargetIds } from "../cli/command-secret-targets.js";
|
||||
import { listRouteBindings } from "../config/bindings.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { CONFIG_PATH, migrateLegacyConfig, readConfigFileSnapshot } from "../config/config.js";
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
isMattermostMutableAllowEntry,
|
||||
isSlackMutableAllowEntry,
|
||||
} from "../security/mutable-allowlist-detectors.js";
|
||||
import { inspectTelegramAccount } from "../telegram/account-inspect.js";
|
||||
import { listTelegramAccountIds, resolveTelegramAccount } from "../telegram/accounts.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { isRecord, resolveHomeDir } from "../utils.js";
|
||||
@@ -464,10 +467,20 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
const { resolvedConfig } = await resolveCommandSecretRefsViaGateway({
|
||||
config: cfg,
|
||||
commandName: "doctor --fix",
|
||||
targetIds: getChannelsCommandSecretTargetIds(),
|
||||
mode: "summary",
|
||||
});
|
||||
const hasConfiguredUnavailableToken = listTelegramAccountIds(cfg).some((accountId) => {
|
||||
const inspected = inspectTelegramAccount({ cfg, accountId });
|
||||
return inspected.enabled && inspected.tokenStatus === "configured_unavailable";
|
||||
});
|
||||
const tokens = Array.from(
|
||||
new Set(
|
||||
listTelegramAccountIds(cfg)
|
||||
.map((accountId) => resolveTelegramAccount({ cfg, accountId }))
|
||||
listTelegramAccountIds(resolvedConfig)
|
||||
.map((accountId) => resolveTelegramAccount({ cfg: resolvedConfig, accountId }))
|
||||
.map((account) => (account.tokenSource === "none" ? "" : account.token))
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean),
|
||||
@@ -478,7 +491,9 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi
|
||||
return {
|
||||
config: cfg,
|
||||
changes: [
|
||||
`- Telegram allowFrom contains @username entries, but no Telegram bot token is configured; cannot auto-resolve (run onboarding or replace with numeric sender IDs).`,
|
||||
hasConfiguredUnavailableToken
|
||||
? `- Telegram allowFrom contains @username entries, but configured Telegram bot credentials are unavailable in this command path; cannot auto-resolve (start the gateway or make the secret source available, then rerun doctor --fix).`
|
||||
: `- Telegram allowFrom contains @username entries, but no Telegram bot token is configured; cannot auto-resolve (run onboarding or replace with numeric sender IDs).`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export async function statusAllCommand(
|
||||
config: loadedRaw,
|
||||
commandName: "status --all",
|
||||
targetIds: getStatusCommandSecretTargetIds(),
|
||||
mode: "summary",
|
||||
});
|
||||
const osSummary = resolveOsSummary();
|
||||
const snap = await readConfigFileSnapshot().catch(() => null);
|
||||
@@ -159,7 +160,10 @@ export async function statusAllCommand(
|
||||
const agentStatus = await getAgentLocalStatuses(cfg);
|
||||
progress.tick();
|
||||
progress.setLabel("Summarizing channels…");
|
||||
const channels = await buildChannelsTable(cfg, { showSecrets: false });
|
||||
const channels = await buildChannelsTable(cfg, {
|
||||
showSecrets: false,
|
||||
sourceConfig: loadedRaw,
|
||||
});
|
||||
progress.tick();
|
||||
|
||||
const connectionDetailsForReport = (() => {
|
||||
|
||||
@@ -50,6 +50,12 @@ function makeSlackPlugin(params?: { botToken?: string; appToken?: string }): Cha
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
botToken: params?.botToken ?? "bot-token",
|
||||
appToken: params?.appToken ?? "app-token",
|
||||
}),
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
@@ -65,6 +71,196 @@ function makeSlackPlugin(params?: { botToken?: string; appToken?: string }): Cha
|
||||
};
|
||||
}
|
||||
|
||||
function makeUnavailableSlackPlugin(): ChannelPlugin {
|
||||
return {
|
||||
id: "slack",
|
||||
meta: {
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
selectionLabel: "Slack",
|
||||
docsPath: "/channels/slack",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
botTokenStatus: "configured_unavailable",
|
||||
appTokenStatus: "configured_unavailable",
|
||||
}),
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
botTokenStatus: "configured_unavailable",
|
||||
appTokenStatus: "configured_unavailable",
|
||||
}),
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeSourceAwareUnavailablePlugin(): ChannelPlugin {
|
||||
return {
|
||||
id: "slack",
|
||||
meta: {
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
selectionLabel: "Slack",
|
||||
docsPath: "/channels/slack",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: (cfg) =>
|
||||
(cfg as { marker?: string }).marker === "source"
|
||||
? {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
botTokenStatus: "configured_unavailable",
|
||||
appTokenStatus: "configured_unavailable",
|
||||
}
|
||||
: {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: false,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
botTokenSource: "none",
|
||||
appTokenSource: "none",
|
||||
},
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
}),
|
||||
isConfigured: (account) => Boolean((account as { configured?: boolean }).configured),
|
||||
isEnabled: () => true,
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeSourceUnavailableResolvedAvailablePlugin(): ChannelPlugin {
|
||||
return {
|
||||
id: "discord",
|
||||
meta: {
|
||||
id: "discord",
|
||||
label: "Discord",
|
||||
selectionLabel: "Discord",
|
||||
docsPath: "/channels/discord",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: (cfg) =>
|
||||
(cfg as { marker?: string }).marker === "source"
|
||||
? {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
tokenSource: "config",
|
||||
tokenStatus: "configured_unavailable",
|
||||
}
|
||||
: {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
tokenSource: "config",
|
||||
tokenStatus: "available",
|
||||
},
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
tokenSource: "config",
|
||||
tokenStatus: "available",
|
||||
}),
|
||||
isConfigured: (account) => Boolean((account as { configured?: boolean }).configured),
|
||||
isEnabled: () => true,
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeHttpSlackUnavailablePlugin(): ChannelPlugin {
|
||||
return {
|
||||
id: "slack",
|
||||
meta: {
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
selectionLabel: "Slack",
|
||||
docsPath: "/channels/slack",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: () => ({
|
||||
accountId: "primary",
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
mode: "http",
|
||||
botToken: "xoxb-http",
|
||||
signingSecret: "",
|
||||
botTokenSource: "config",
|
||||
signingSecretSource: "config",
|
||||
botTokenStatus: "available",
|
||||
signingSecretStatus: "configured_unavailable",
|
||||
}),
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
mode: "http",
|
||||
botToken: "xoxb-http",
|
||||
signingSecret: "",
|
||||
botTokenSource: "config",
|
||||
signingSecretSource: "config",
|
||||
botTokenStatus: "available",
|
||||
signingSecretStatus: "configured_unavailable",
|
||||
}),
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeTokenPlugin(): ChannelPlugin {
|
||||
return {
|
||||
id: "token-only",
|
||||
@@ -122,6 +318,90 @@ describe("buildChannelsTable - mattermost token summary", () => {
|
||||
expect(slackRow?.detail).toContain("need bot+app");
|
||||
});
|
||||
|
||||
it("reports configured-but-unavailable Slack credentials as warn", async () => {
|
||||
vi.mocked(listChannelPlugins).mockReturnValue([makeUnavailableSlackPlugin()]);
|
||||
|
||||
const table = await buildChannelsTable({ channels: {} } as never, {
|
||||
showSecrets: false,
|
||||
});
|
||||
|
||||
const slackRow = table.rows.find((row) => row.id === "slack");
|
||||
expect(slackRow).toBeDefined();
|
||||
expect(slackRow?.state).toBe("warn");
|
||||
expect(slackRow?.detail).toContain("unavailable in this command path");
|
||||
});
|
||||
|
||||
it("preserves unavailable credential state from the source config snapshot", async () => {
|
||||
vi.mocked(listChannelPlugins).mockReturnValue([makeSourceAwareUnavailablePlugin()]);
|
||||
|
||||
const table = await buildChannelsTable({ marker: "resolved", channels: {} } as never, {
|
||||
showSecrets: false,
|
||||
sourceConfig: { marker: "source", channels: {} } as never,
|
||||
});
|
||||
|
||||
const slackRow = table.rows.find((row) => row.id === "slack");
|
||||
expect(slackRow).toBeDefined();
|
||||
expect(slackRow?.state).toBe("warn");
|
||||
expect(slackRow?.detail).toContain("unavailable in this command path");
|
||||
|
||||
const slackDetails = table.details.find((detail) => detail.title === "Slack accounts");
|
||||
expect(slackDetails).toBeDefined();
|
||||
expect(slackDetails?.rows).toEqual([
|
||||
{
|
||||
Account: "primary (Primary)",
|
||||
Notes: "bot:config · app:config · secret unavailable in this command path",
|
||||
Status: "WARN",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats status-only available credentials as resolved", async () => {
|
||||
vi.mocked(listChannelPlugins).mockReturnValue([makeSourceUnavailableResolvedAvailablePlugin()]);
|
||||
|
||||
const table = await buildChannelsTable({ marker: "resolved", channels: {} } as never, {
|
||||
showSecrets: false,
|
||||
sourceConfig: { marker: "source", channels: {} } as never,
|
||||
});
|
||||
|
||||
const discordRow = table.rows.find((row) => row.id === "discord");
|
||||
expect(discordRow).toBeDefined();
|
||||
expect(discordRow?.state).toBe("ok");
|
||||
expect(discordRow?.detail).toBe("configured");
|
||||
|
||||
const discordDetails = table.details.find((detail) => detail.title === "Discord accounts");
|
||||
expect(discordDetails).toBeDefined();
|
||||
expect(discordDetails?.rows).toEqual([
|
||||
{
|
||||
Account: "primary (Primary)",
|
||||
Notes: "token:config",
|
||||
Status: "OK",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats Slack HTTP signing-secret availability as required config", async () => {
|
||||
vi.mocked(listChannelPlugins).mockReturnValue([makeHttpSlackUnavailablePlugin()]);
|
||||
|
||||
const table = await buildChannelsTable({ channels: {} } as never, {
|
||||
showSecrets: false,
|
||||
});
|
||||
|
||||
const slackRow = table.rows.find((row) => row.id === "slack");
|
||||
expect(slackRow).toBeDefined();
|
||||
expect(slackRow?.state).toBe("warn");
|
||||
expect(slackRow?.detail).toContain("configured http credentials unavailable");
|
||||
|
||||
const slackDetails = table.details.find((detail) => detail.title === "Slack accounts");
|
||||
expect(slackDetails).toBeDefined();
|
||||
expect(slackDetails?.rows).toEqual([
|
||||
{
|
||||
Account: "primary (Primary)",
|
||||
Notes: "bot:config · signing:config · secret unavailable in this command path",
|
||||
Status: "WARN",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("still reports single-token channels as ok", async () => {
|
||||
vi.mocked(listChannelPlugins).mockReturnValue([makeTokenPlugin()]);
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import {
|
||||
hasConfiguredUnavailableCredentialStatus,
|
||||
hasResolvedCredentialValue,
|
||||
} from "../../channels/account-snapshot-fields.js";
|
||||
import {
|
||||
buildChannelAccountSnapshot,
|
||||
formatChannelAllowFrom,
|
||||
@@ -12,6 +16,7 @@ import type {
|
||||
ChannelId,
|
||||
ChannelPlugin,
|
||||
} from "../../channels/plugins/types.js";
|
||||
import { inspectReadOnlyChannelAccount } from "../../channels/read-only-account-inspect.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { sha256HexPrefix } from "../../logging/redact-identifier.js";
|
||||
import { formatTimeAgo } from "./format.js";
|
||||
@@ -32,6 +37,13 @@ type ChannelAccountRow = {
|
||||
snapshot: ChannelAccountSnapshot;
|
||||
};
|
||||
|
||||
type ResolvedChannelAccountRowParams = {
|
||||
plugin: ChannelPlugin;
|
||||
cfg: OpenClawConfig;
|
||||
sourceConfig: OpenClawConfig;
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> =>
|
||||
value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
|
||||
@@ -79,6 +91,61 @@ function formatTokenHint(token: string, opts: { showSecrets: boolean }): string
|
||||
return `${head}…${tail} · len ${t.length}`;
|
||||
}
|
||||
|
||||
function inspectChannelAccount(plugin: ChannelPlugin, cfg: OpenClawConfig, accountId: string) {
|
||||
return (
|
||||
plugin.config.inspectAccount?.(cfg, accountId) ??
|
||||
inspectReadOnlyChannelAccount({
|
||||
channelId: plugin.id,
|
||||
cfg,
|
||||
accountId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveChannelAccountRow(
|
||||
params: ResolvedChannelAccountRowParams,
|
||||
): Promise<ChannelAccountRow> {
|
||||
const { plugin, cfg, sourceConfig, accountId } = params;
|
||||
const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId);
|
||||
const resolvedInspectedAccount = inspectChannelAccount(plugin, cfg, accountId);
|
||||
const resolvedInspection = resolvedInspectedAccount as {
|
||||
enabled?: boolean;
|
||||
configured?: boolean;
|
||||
} | null;
|
||||
const sourceInspection = sourceInspectedAccount as {
|
||||
enabled?: boolean;
|
||||
configured?: boolean;
|
||||
} | null;
|
||||
const resolvedAccount = resolvedInspectedAccount ?? plugin.config.resolveAccount(cfg, accountId);
|
||||
const useSourceUnavailableAccount = Boolean(
|
||||
sourceInspectedAccount &&
|
||||
hasConfiguredUnavailableCredentialStatus(sourceInspectedAccount) &&
|
||||
(!hasResolvedCredentialValue(resolvedAccount) ||
|
||||
(sourceInspection?.configured === true && resolvedInspection?.configured === false)),
|
||||
);
|
||||
const account = useSourceUnavailableAccount ? sourceInspectedAccount : resolvedAccount;
|
||||
const selectedInspection = useSourceUnavailableAccount ? sourceInspection : resolvedInspection;
|
||||
const enabled =
|
||||
selectedInspection?.enabled ?? resolveChannelAccountEnabled({ plugin, account, cfg });
|
||||
const configured =
|
||||
selectedInspection?.configured ??
|
||||
(await resolveChannelAccountConfigured({
|
||||
plugin,
|
||||
account,
|
||||
cfg,
|
||||
readAccountConfiguredField: true,
|
||||
}));
|
||||
const snapshot = buildChannelAccountSnapshot({
|
||||
plugin,
|
||||
cfg,
|
||||
accountId,
|
||||
account,
|
||||
enabled,
|
||||
configured,
|
||||
});
|
||||
return { accountId, account, enabled, configured, snapshot };
|
||||
}
|
||||
|
||||
const formatAccountLabel = (params: { accountId: string; name?: string }) => {
|
||||
const base = params.accountId || "default";
|
||||
if (params.name?.trim()) {
|
||||
@@ -110,6 +177,12 @@ const buildAccountNotes = (params: {
|
||||
if (snapshot.appTokenSource && snapshot.appTokenSource !== "none") {
|
||||
notes.push(`app:${snapshot.appTokenSource}`);
|
||||
}
|
||||
if (snapshot.signingSecretSource && snapshot.signingSecretSource !== "none") {
|
||||
notes.push(`signing:${snapshot.signingSecretSource}`);
|
||||
}
|
||||
if (hasConfiguredUnavailableCredentialStatus(entry.account)) {
|
||||
notes.push("secret unavailable in this command path");
|
||||
}
|
||||
if (snapshot.baseUrl) {
|
||||
notes.push(snapshot.baseUrl);
|
||||
}
|
||||
@@ -191,13 +264,90 @@ function summarizeTokenConfig(params: {
|
||||
const accountRecs = enabled.map((a) => asRecord(a.account));
|
||||
const hasBotTokenField = accountRecs.some((r) => "botToken" in r);
|
||||
const hasAppTokenField = accountRecs.some((r) => "appToken" in r);
|
||||
const hasSigningSecretField = accountRecs.some(
|
||||
(r) => "signingSecret" in r || "signingSecretSource" in r || "signingSecretStatus" in r,
|
||||
);
|
||||
const hasTokenField = accountRecs.some((r) => "token" in r);
|
||||
|
||||
if (!hasBotTokenField && !hasAppTokenField && !hasTokenField) {
|
||||
if (!hasBotTokenField && !hasAppTokenField && !hasSigningSecretField && !hasTokenField) {
|
||||
return { state: null, detail: null };
|
||||
}
|
||||
|
||||
const accountIsHttpMode = (rec: Record<string, unknown>) =>
|
||||
typeof rec.mode === "string" && rec.mode.trim() === "http";
|
||||
const hasCredentialAvailable = (
|
||||
rec: Record<string, unknown>,
|
||||
valueKey: string,
|
||||
statusKey: string,
|
||||
) => {
|
||||
const value = rec[valueKey];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return true;
|
||||
}
|
||||
return rec[statusKey] === "available";
|
||||
};
|
||||
|
||||
if (
|
||||
hasBotTokenField &&
|
||||
hasSigningSecretField &&
|
||||
enabled.every((a) => accountIsHttpMode(asRecord(a.account)))
|
||||
) {
|
||||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||||
const ready = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
return (
|
||||
hasCredentialAvailable(rec, "botToken", "botTokenStatus") &&
|
||||
hasCredentialAvailable(rec, "signingSecret", "signingSecretStatus")
|
||||
);
|
||||
});
|
||||
const partial = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
const hasBot = hasCredentialAvailable(rec, "botToken", "botTokenStatus");
|
||||
const hasSigning = hasCredentialAvailable(rec, "signingSecret", "signingSecretStatus");
|
||||
return (hasBot && !hasSigning) || (!hasBot && hasSigning);
|
||||
});
|
||||
|
||||
if (unavailable.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `configured http credentials unavailable in this command path · accounts ${unavailable.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (partial.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `partial credentials (need bot+signing) · accounts ${partial.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ready.length === 0) {
|
||||
return { state: "setup", detail: "no credentials (need bot+signing)" };
|
||||
}
|
||||
|
||||
const botSources = summarizeSources(ready.map((a) => a.snapshot.botTokenSource ?? "none"));
|
||||
const signingSources = summarizeSources(
|
||||
ready.map((a) => a.snapshot.signingSecretSource ?? "none"),
|
||||
);
|
||||
const sample = ready[0]?.account ? asRecord(ready[0].account) : {};
|
||||
const botToken = typeof sample.botToken === "string" ? sample.botToken : "";
|
||||
const signingSecret = typeof sample.signingSecret === "string" ? sample.signingSecret : "";
|
||||
const botHint = botToken.trim()
|
||||
? formatTokenHint(botToken, { showSecrets: params.showSecrets })
|
||||
: "";
|
||||
const signingHint = signingSecret.trim()
|
||||
? formatTokenHint(signingSecret, { showSecrets: params.showSecrets })
|
||||
: "";
|
||||
const hint =
|
||||
botHint || signingHint ? ` (bot ${botHint || "?"}, signing ${signingHint || "?"})` : "";
|
||||
return {
|
||||
state: "ok",
|
||||
detail: `credentials ok (bot ${botSources.label}, signing ${signingSources.label})${hint} · accounts ${ready.length}/${enabled.length || 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasBotTokenField && hasAppTokenField) {
|
||||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||||
const ready = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
const bot = typeof rec.botToken === "string" ? rec.botToken.trim() : "";
|
||||
@@ -220,6 +370,13 @@ function summarizeTokenConfig(params: {
|
||||
};
|
||||
}
|
||||
|
||||
if (unavailable.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `configured tokens unavailable in this command path · accounts ${unavailable.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ready.length === 0) {
|
||||
return { state: "setup", detail: "no tokens (need bot+app)" };
|
||||
}
|
||||
@@ -245,12 +402,20 @@ function summarizeTokenConfig(params: {
|
||||
}
|
||||
|
||||
if (hasBotTokenField) {
|
||||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||||
const ready = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
const bot = typeof rec.botToken === "string" ? rec.botToken.trim() : "";
|
||||
return Boolean(bot);
|
||||
});
|
||||
|
||||
if (unavailable.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `configured bot token unavailable in this command path · accounts ${unavailable.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ready.length === 0) {
|
||||
return { state: "setup", detail: "no bot token" };
|
||||
}
|
||||
@@ -268,10 +433,17 @@ function summarizeTokenConfig(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||||
const ready = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
return typeof rec.token === "string" ? Boolean(rec.token.trim()) : false;
|
||||
});
|
||||
if (unavailable.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `configured token unavailable in this command path · accounts ${unavailable.length}`,
|
||||
};
|
||||
}
|
||||
if (ready.length === 0) {
|
||||
return { state: "setup", detail: "no token" };
|
||||
}
|
||||
@@ -292,7 +464,7 @@ function summarizeTokenConfig(params: {
|
||||
// Keep this generic: channel-specific rules belong in the channel plugin.
|
||||
export async function buildChannelsTable(
|
||||
cfg: OpenClawConfig,
|
||||
opts?: { showSecrets?: boolean },
|
||||
opts?: { showSecrets?: boolean; sourceConfig?: OpenClawConfig },
|
||||
): Promise<{
|
||||
rows: ChannelRow[];
|
||||
details: Array<{
|
||||
@@ -319,29 +491,24 @@ export async function buildChannelsTable(
|
||||
const resolvedAccountIds = accountIds.length > 0 ? accountIds : [defaultAccountId];
|
||||
|
||||
const accounts: ChannelAccountRow[] = [];
|
||||
const sourceConfig = opts?.sourceConfig ?? cfg;
|
||||
for (const accountId of resolvedAccountIds) {
|
||||
const account = plugin.config.resolveAccount(cfg, accountId);
|
||||
const enabled = resolveChannelAccountEnabled({ plugin, account, cfg });
|
||||
const configured = await resolveChannelAccountConfigured({
|
||||
plugin,
|
||||
account,
|
||||
cfg,
|
||||
readAccountConfiguredField: true,
|
||||
});
|
||||
const snapshot = buildChannelAccountSnapshot({
|
||||
plugin,
|
||||
cfg,
|
||||
accountId,
|
||||
account,
|
||||
enabled,
|
||||
configured,
|
||||
});
|
||||
accounts.push({ accountId, account, enabled, configured, snapshot });
|
||||
accounts.push(
|
||||
await resolveChannelAccountRow({
|
||||
plugin,
|
||||
cfg,
|
||||
sourceConfig,
|
||||
accountId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const anyEnabled = accounts.some((a) => a.enabled);
|
||||
const enabledAccounts = accounts.filter((a) => a.enabled);
|
||||
const configuredAccounts = enabledAccounts.filter((a) => a.configured);
|
||||
const unavailableConfiguredAccounts = enabledAccounts.filter((a) =>
|
||||
hasConfiguredUnavailableCredentialStatus(a.account),
|
||||
);
|
||||
const defaultEntry = accounts.find((a) => a.accountId === defaultAccountId) ?? accounts[0];
|
||||
|
||||
const summary = plugin.status?.buildChannelSummary
|
||||
@@ -379,6 +546,9 @@ export async function buildChannelsTable(
|
||||
if (issues.length > 0) {
|
||||
return "warn";
|
||||
}
|
||||
if (unavailableConfiguredAccounts.length > 0) {
|
||||
return "warn";
|
||||
}
|
||||
if (link.linked === false) {
|
||||
return "setup";
|
||||
}
|
||||
@@ -423,6 +593,13 @@ export async function buildChannelsTable(
|
||||
return extra.length > 0 ? `${base} · ${extra.join(" · ")}` : base;
|
||||
}
|
||||
|
||||
if (unavailableConfiguredAccounts.length > 0) {
|
||||
if (tokenSummary.detail?.includes("unavailable")) {
|
||||
return tokenSummary.detail;
|
||||
}
|
||||
return `configured credentials unavailable in this command path · accounts ${unavailableConfiguredAccounts.length}`;
|
||||
}
|
||||
|
||||
if (tokenSummary.detail) {
|
||||
return tokenSummary.detail;
|
||||
}
|
||||
@@ -461,7 +638,10 @@ export async function buildChannelsTable(
|
||||
accountId: entry.accountId,
|
||||
name: entry.snapshot.name,
|
||||
}),
|
||||
Status: entry.enabled ? "OK" : "WARN",
|
||||
Status:
|
||||
entry.enabled && !hasConfiguredUnavailableCredentialStatus(entry.account)
|
||||
? "OK"
|
||||
: "WARN",
|
||||
Notes: notes.join(" · "),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
import { loadConfig, resolveGatewayPort } from "../config/config.js";
|
||||
import { resolveGatewayPort } from "../config/config.js";
|
||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||
import { info } from "../globals.js";
|
||||
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
|
||||
@@ -80,33 +80,33 @@ export async function statusCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
const [scan, securityAudit] = opts.json
|
||||
? await Promise.all([
|
||||
scanStatus({ json: opts.json, timeoutMs: opts.timeoutMs, all: opts.all }, runtime),
|
||||
runSecurityAudit({
|
||||
config: loadConfig(),
|
||||
deep: false,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: true,
|
||||
}),
|
||||
])
|
||||
: [
|
||||
await scanStatus({ json: opts.json, timeoutMs: opts.timeoutMs, all: opts.all }, runtime),
|
||||
await withProgress(
|
||||
{
|
||||
label: "Running security audit…",
|
||||
indeterminate: true,
|
||||
enabled: true,
|
||||
},
|
||||
async () =>
|
||||
await runSecurityAudit({
|
||||
config: loadConfig(),
|
||||
deep: false,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: true,
|
||||
}),
|
||||
),
|
||||
];
|
||||
const scan = await scanStatus(
|
||||
{ json: opts.json, timeoutMs: opts.timeoutMs, all: opts.all },
|
||||
runtime,
|
||||
);
|
||||
const securityAudit = opts.json
|
||||
? await runSecurityAudit({
|
||||
config: scan.cfg,
|
||||
sourceConfig: scan.sourceConfig,
|
||||
deep: false,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: true,
|
||||
})
|
||||
: await withProgress(
|
||||
{
|
||||
label: "Running security audit…",
|
||||
indeterminate: true,
|
||||
enabled: true,
|
||||
},
|
||||
async () =>
|
||||
await runSecurityAudit({
|
||||
config: scan.cfg,
|
||||
sourceConfig: scan.sourceConfig,
|
||||
deep: false,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: true,
|
||||
}),
|
||||
);
|
||||
const {
|
||||
cfg,
|
||||
osSummary,
|
||||
@@ -126,6 +126,7 @@ export async function statusCommand(
|
||||
agentStatus,
|
||||
channels,
|
||||
summary,
|
||||
secretDiagnostics,
|
||||
memory,
|
||||
memoryPlugin,
|
||||
} = scan;
|
||||
@@ -202,6 +203,7 @@ export async function statusCommand(
|
||||
nodeService: nodeDaemon,
|
||||
agents: agentStatus,
|
||||
securityAudit,
|
||||
secretDiagnostics,
|
||||
...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}),
|
||||
},
|
||||
null,
|
||||
@@ -227,6 +229,14 @@ export async function statusCommand(
|
||||
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
|
||||
if (secretDiagnostics.length > 0) {
|
||||
runtime.log(theme.warn("Secret diagnostics:"));
|
||||
for (const entry of secretDiagnostics) {
|
||||
runtime.log(`- ${entry}`);
|
||||
}
|
||||
runtime.log("");
|
||||
}
|
||||
|
||||
const dashboard = (() => {
|
||||
const controlUiEnabled = cfg.gateway?.controlUi?.enabled ?? true;
|
||||
if (!controlUiEnabled) {
|
||||
|
||||
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 = {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
sourceConfig: ReturnType<typeof loadConfig>;
|
||||
secretDiagnostics: string[];
|
||||
osSummary: ReturnType<typeof resolveOsSummary>;
|
||||
tailscaleMode: string;
|
||||
tailscaleDns: string | null;
|
||||
@@ -179,11 +181,13 @@ async function scanStatusJsonFast(opts: {
|
||||
all?: boolean;
|
||||
}): Promise<StatusScanResult> {
|
||||
const loadedRaw = loadConfig();
|
||||
const { resolvedConfig: cfg } = await resolveCommandSecretRefsViaGateway({
|
||||
config: loadedRaw,
|
||||
commandName: "status --json",
|
||||
targetIds: getStatusCommandSecretTargetIds(),
|
||||
});
|
||||
const { resolvedConfig: cfg, diagnostics: secretDiagnostics } =
|
||||
await resolveCommandSecretRefsViaGateway({
|
||||
config: loadedRaw,
|
||||
commandName: "status --json",
|
||||
targetIds: getStatusCommandSecretTargetIds(),
|
||||
mode: "summary",
|
||||
});
|
||||
const osSummary = resolveOsSummary();
|
||||
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
|
||||
const updateTimeoutMs = opts.all ? 6500 : 2500;
|
||||
@@ -193,7 +197,7 @@ async function scanStatusJsonFast(opts: {
|
||||
includeRegistry: true,
|
||||
});
|
||||
const agentStatusPromise = getAgentLocalStatuses();
|
||||
const summaryPromise = getStatusSummary({ config: cfg });
|
||||
const summaryPromise = getStatusSummary({ config: cfg, sourceConfig: loadedRaw });
|
||||
|
||||
const tailscaleDnsPromise =
|
||||
tailscaleMode === "off"
|
||||
@@ -236,6 +240,8 @@ async function scanStatusJsonFast(opts: {
|
||||
|
||||
return {
|
||||
cfg,
|
||||
sourceConfig: loadedRaw,
|
||||
secretDiagnostics,
|
||||
osSummary,
|
||||
tailscaleMode,
|
||||
tailscaleDns,
|
||||
@@ -278,11 +284,13 @@ export async function scanStatus(
|
||||
async (progress) => {
|
||||
progress.setLabel("Loading config…");
|
||||
const loadedRaw = loadConfig();
|
||||
const { resolvedConfig: cfg } = await resolveCommandSecretRefsViaGateway({
|
||||
config: loadedRaw,
|
||||
commandName: "status",
|
||||
targetIds: getStatusCommandSecretTargetIds(),
|
||||
});
|
||||
const { resolvedConfig: cfg, diagnostics: secretDiagnostics } =
|
||||
await resolveCommandSecretRefsViaGateway({
|
||||
config: loadedRaw,
|
||||
commandName: "status",
|
||||
targetIds: getStatusCommandSecretTargetIds(),
|
||||
mode: "summary",
|
||||
});
|
||||
const osSummary = resolveOsSummary();
|
||||
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
|
||||
const tailscaleDnsPromise =
|
||||
@@ -300,7 +308,9 @@ export async function scanStatus(
|
||||
}),
|
||||
);
|
||||
const agentStatusPromise = deferResult(getAgentLocalStatuses());
|
||||
const summaryPromise = deferResult(getStatusSummary({ config: cfg }));
|
||||
const summaryPromise = deferResult(
|
||||
getStatusSummary({ config: cfg, sourceConfig: loadedRaw }),
|
||||
);
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Checking Tailscale…");
|
||||
@@ -344,6 +354,7 @@ export async function scanStatus(
|
||||
// Show token previews in regular status; keep `status --all` redacted.
|
||||
// Set `CLAWDBOT_SHOW_SECRETS=0` to force redaction.
|
||||
showSecrets: process.env.CLAWDBOT_SHOW_SECRETS?.trim() !== "0",
|
||||
sourceConfig: loadedRaw,
|
||||
});
|
||||
progress.tick();
|
||||
|
||||
@@ -361,6 +372,8 @@ export async function scanStatus(
|
||||
|
||||
return {
|
||||
cfg,
|
||||
sourceConfig: loadedRaw,
|
||||
secretDiagnostics,
|
||||
osSummary,
|
||||
tailscaleMode,
|
||||
tailscaleDns,
|
||||
|
||||
@@ -77,7 +77,11 @@ export function redactSensitiveStatusSummary(summary: StatusSummary): StatusSumm
|
||||
}
|
||||
|
||||
export async function getStatusSummary(
|
||||
options: { includeSensitive?: boolean; config?: OpenClawConfig } = {},
|
||||
options: {
|
||||
includeSensitive?: boolean;
|
||||
config?: OpenClawConfig;
|
||||
sourceConfig?: OpenClawConfig;
|
||||
} = {},
|
||||
): Promise<StatusSummary> {
|
||||
const { includeSensitive = true } = options;
|
||||
const cfg = options.config ?? loadConfig();
|
||||
@@ -95,6 +99,7 @@ export async function getStatusSummary(
|
||||
const channelSummary = await buildChannelSummary(cfg, {
|
||||
colorize: true,
|
||||
includeAllowFrom: true,
|
||||
sourceConfig: options.sourceConfig,
|
||||
});
|
||||
const mainSessionKey = resolveMainSessionKey(cfg);
|
||||
const queuedSystemEvents = peekSystemEvents(mainSessionKey);
|
||||
|
||||
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.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 { DiscordGuildChannelConfig, DiscordGuildEntry } from "../config/types.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import { inspectDiscordAccount } from "./account-inspect.js";
|
||||
import { fetchChannelPermissionsDiscord } from "./send.js";
|
||||
|
||||
export type DiscordChannelPermissionsAuditEntry = {
|
||||
@@ -74,7 +74,7 @@ export function collectDiscordAuditChannelIds(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}) {
|
||||
const account = resolveDiscordAccount({
|
||||
const account = inspectDiscordAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Container } from "@buape/carbon";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import { inspectDiscordAccount } from "./account-inspect.js";
|
||||
|
||||
const DEFAULT_DISCORD_ACCENT_COLOR = "#5865F2";
|
||||
|
||||
@@ -24,7 +24,7 @@ export function normalizeDiscordAccentColor(raw?: string | null): string | null
|
||||
}
|
||||
|
||||
export function resolveDiscordAccentColor(params: ResolveDiscordAccentColorParams): string {
|
||||
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const configured = normalizeDiscordAccentColor(account.config.ui?.components?.accentColor);
|
||||
return configured ?? DEFAULT_DISCORD_ACCENT_COLOR;
|
||||
}
|
||||
|
||||
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 {
|
||||
buildChannelAccountSnapshot,
|
||||
formatChannelAllowFrom,
|
||||
@@ -6,6 +10,7 @@ import {
|
||||
} from "../channels/account-summary.js";
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import type { ChannelAccountSnapshot, ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
|
||||
import { type OpenClawConfig, loadConfig } from "../config/config.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
@@ -14,9 +19,10 @@ import { formatTimeAgo } from "./format-time/format-relative.ts";
|
||||
export type ChannelSummaryOptions = {
|
||||
colorize?: boolean;
|
||||
includeAllowFrom?: boolean;
|
||||
sourceConfig?: OpenClawConfig;
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS: Required<ChannelSummaryOptions> = {
|
||||
const DEFAULT_OPTIONS: Omit<Required<ChannelSummaryOptions>, "sourceConfig"> = {
|
||||
colorize: false,
|
||||
includeAllowFrom: false,
|
||||
};
|
||||
@@ -63,6 +69,12 @@ const buildAccountDetails = (params: {
|
||||
if (snapshot.appTokenSource && snapshot.appTokenSource !== "none") {
|
||||
details.push(`app:${snapshot.appTokenSource}`);
|
||||
}
|
||||
if (snapshot.signingSecretSource && snapshot.signingSecretSource !== "none") {
|
||||
details.push(`signing:${snapshot.signingSecretSource}`);
|
||||
}
|
||||
if (hasConfiguredUnavailableCredentialStatus(params.entry.account)) {
|
||||
details.push("secret unavailable in this command path");
|
||||
}
|
||||
if (snapshot.baseUrl) {
|
||||
details.push(snapshot.baseUrl);
|
||||
}
|
||||
@@ -90,6 +102,17 @@ const buildAccountDetails = (params: {
|
||||
return details;
|
||||
};
|
||||
|
||||
function inspectChannelAccount(plugin: ChannelPlugin, cfg: OpenClawConfig, accountId: string) {
|
||||
return (
|
||||
plugin.config.inspectAccount?.(cfg, accountId) ??
|
||||
inspectReadOnlyChannelAccount({
|
||||
channelId: plugin.id,
|
||||
cfg,
|
||||
accountId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildChannelSummary(
|
||||
cfg?: OpenClawConfig,
|
||||
options?: ChannelSummaryOptions,
|
||||
@@ -99,6 +122,7 @@ export async function buildChannelSummary(
|
||||
const resolved = { ...DEFAULT_OPTIONS, ...options };
|
||||
const tint = (value: string, color?: (input: string) => string) =>
|
||||
resolved.colorize && color ? color(value) : value;
|
||||
const sourceConfig = options?.sourceConfig ?? effective;
|
||||
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
const accountIds = plugin.config.listAccountIds(effective);
|
||||
@@ -108,13 +132,39 @@ export async function buildChannelSummary(
|
||||
const entries: ChannelAccountEntry[] = [];
|
||||
|
||||
for (const accountId of resolvedAccountIds) {
|
||||
const account = plugin.config.resolveAccount(effective, accountId);
|
||||
const enabled = resolveChannelAccountEnabled({ plugin, account, cfg: effective });
|
||||
const configured = await resolveChannelAccountConfigured({
|
||||
plugin,
|
||||
account,
|
||||
cfg: effective,
|
||||
});
|
||||
const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId);
|
||||
const resolvedInspectedAccount = inspectChannelAccount(plugin, effective, accountId);
|
||||
const resolvedInspection = resolvedInspectedAccount as {
|
||||
enabled?: boolean;
|
||||
configured?: boolean;
|
||||
} | null;
|
||||
const sourceInspection = sourceInspectedAccount as {
|
||||
enabled?: boolean;
|
||||
configured?: boolean;
|
||||
} | null;
|
||||
const resolvedAccount =
|
||||
resolvedInspectedAccount ?? plugin.config.resolveAccount(effective, accountId);
|
||||
const useSourceUnavailableAccount = Boolean(
|
||||
sourceInspectedAccount &&
|
||||
hasConfiguredUnavailableCredentialStatus(sourceInspectedAccount) &&
|
||||
(!hasResolvedCredentialValue(resolvedAccount) ||
|
||||
(sourceInspection?.configured === true && resolvedInspection?.configured === false)),
|
||||
);
|
||||
const account = useSourceUnavailableAccount ? sourceInspectedAccount : resolvedAccount;
|
||||
const selectedInspection = useSourceUnavailableAccount
|
||||
? sourceInspection
|
||||
: resolvedInspection;
|
||||
const enabled =
|
||||
selectedInspection?.enabled ??
|
||||
resolveChannelAccountEnabled({ plugin, account, cfg: effective });
|
||||
const configured =
|
||||
selectedInspection?.configured ??
|
||||
(await resolveChannelAccountConfigured({
|
||||
plugin,
|
||||
account,
|
||||
cfg: effective,
|
||||
readAccountConfiguredField: true,
|
||||
}));
|
||||
const snapshot = buildChannelAccountSnapshot({
|
||||
plugin,
|
||||
account,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js";
|
||||
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
export type { OpenClawConfig } from "../config/config.js";
|
||||
export type { InspectedDiscordAccount } from "../discord/account-inspect.js";
|
||||
export type { ResolvedDiscordAccount } from "../discord/accounts.js";
|
||||
export type { PluginRuntime } from "../plugins/runtime/types.js";
|
||||
export type { OpenClawPluginApi } from "../plugins/types.js";
|
||||
@@ -28,6 +29,11 @@ export {
|
||||
resolveDefaultDiscordAccountId,
|
||||
resolveDiscordAccount,
|
||||
} from "../discord/accounts.js";
|
||||
export { inspectDiscordAccount } from "../discord/account-inspect.js";
|
||||
export {
|
||||
projectCredentialSnapshotFields,
|
||||
resolveConfiguredFromCredentialStatuses,
|
||||
} from "../channels/account-snapshot-fields.js";
|
||||
export {
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
|
||||
@@ -553,6 +553,8 @@ export {
|
||||
resolveDiscordAccount,
|
||||
type ResolvedDiscordAccount,
|
||||
} from "../discord/accounts.js";
|
||||
export { inspectDiscordAccount } from "../discord/account-inspect.js";
|
||||
export type { InspectedDiscordAccount } from "../discord/account-inspect.js";
|
||||
export { collectDiscordAuditChannelIds } from "../discord/audit.js";
|
||||
export { discordOnboardingAdapter } from "../channels/plugins/onboarding/discord.js";
|
||||
export {
|
||||
@@ -591,6 +593,8 @@ export {
|
||||
resolveSlackReplyToMode,
|
||||
type ResolvedSlackAccount,
|
||||
} from "../slack/accounts.js";
|
||||
export { inspectSlackAccount } from "../slack/account-inspect.js";
|
||||
export type { InspectedSlackAccount } from "../slack/account-inspect.js";
|
||||
export { extractSlackToolSend, listSlackMessageActions } from "../slack/message-actions.js";
|
||||
export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js";
|
||||
export {
|
||||
@@ -606,6 +610,8 @@ export {
|
||||
resolveTelegramAccount,
|
||||
type ResolvedTelegramAccount,
|
||||
} from "../telegram/accounts.js";
|
||||
export { inspectTelegramAccount } from "../telegram/account-inspect.js";
|
||||
export type { InspectedTelegramAccount } from "../telegram/account-inspect.js";
|
||||
export { telegramOnboardingAdapter } from "../channels/plugins/onboarding/telegram.js";
|
||||
export {
|
||||
looksLikeTelegramTargetId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
export type { OpenClawConfig } from "../config/config.js";
|
||||
export type { InspectedSlackAccount } from "../slack/account-inspect.js";
|
||||
export type { ResolvedSlackAccount } from "../slack/accounts.js";
|
||||
export type { PluginRuntime } from "../plugins/runtime/types.js";
|
||||
export type { OpenClawPluginApi } from "../plugins/types.js";
|
||||
@@ -27,6 +28,12 @@ export {
|
||||
resolveSlackAccount,
|
||||
resolveSlackReplyToMode,
|
||||
} from "../slack/accounts.js";
|
||||
export { inspectSlackAccount } from "../slack/account-inspect.js";
|
||||
export {
|
||||
projectCredentialSnapshotFields,
|
||||
resolveConfiguredFromCredentialStatuses,
|
||||
resolveConfiguredFromRequiredCredentialStatuses,
|
||||
} from "../channels/account-snapshot-fields.js";
|
||||
export {
|
||||
listSlackDirectoryGroupsFromConfig,
|
||||
listSlackDirectoryPeersFromConfig,
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as lineSdk from "openclaw/plugin-sdk/line";
|
||||
import * as msteamsSdk from "openclaw/plugin-sdk/msteams";
|
||||
import * as signalSdk from "openclaw/plugin-sdk/signal";
|
||||
import * as slackSdk from "openclaw/plugin-sdk/slack";
|
||||
import * as telegramSdk from "openclaw/plugin-sdk/telegram";
|
||||
import * as whatsappSdk from "openclaw/plugin-sdk/whatsapp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
@@ -56,14 +57,22 @@ describe("plugin-sdk subpath exports", () => {
|
||||
|
||||
it("exports Discord helpers", () => {
|
||||
expect(typeof discordSdk.resolveDiscordAccount).toBe("function");
|
||||
expect(typeof discordSdk.inspectDiscordAccount).toBe("function");
|
||||
expect(typeof discordSdk.discordOnboardingAdapter).toBe("object");
|
||||
});
|
||||
|
||||
it("exports Slack helpers", () => {
|
||||
expect(typeof slackSdk.resolveSlackAccount).toBe("function");
|
||||
expect(typeof slackSdk.inspectSlackAccount).toBe("function");
|
||||
expect(typeof slackSdk.handleSlackMessageAction).toBe("function");
|
||||
});
|
||||
|
||||
it("exports Telegram helpers", () => {
|
||||
expect(typeof telegramSdk.resolveTelegramAccount).toBe("function");
|
||||
expect(typeof telegramSdk.inspectTelegramAccount).toBe("function");
|
||||
expect(typeof telegramSdk.telegramOnboardingAdapter).toBe("object");
|
||||
});
|
||||
|
||||
it("exports Signal helpers", () => {
|
||||
expect(typeof signalSdk.resolveSignalAccount).toBe("function");
|
||||
expect(typeof signalSdk.signalOnboardingAdapter).toBe("object");
|
||||
|
||||
@@ -7,6 +7,7 @@ export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
export type { OpenClawConfig } from "../config/config.js";
|
||||
export type { PluginRuntime } from "../plugins/runtime/types.js";
|
||||
export type { OpenClawPluginApi } from "../plugins/types.js";
|
||||
export type { InspectedTelegramAccount } from "../telegram/account-inspect.js";
|
||||
export type { ResolvedTelegramAccount } from "../telegram/accounts.js";
|
||||
export type { TelegramProbe } from "../telegram/probe.js";
|
||||
|
||||
@@ -33,6 +34,11 @@ export {
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveTelegramAccount,
|
||||
} from "../telegram/accounts.js";
|
||||
export { inspectTelegramAccount } from "../telegram/account-inspect.js";
|
||||
export {
|
||||
projectCredentialSnapshotFields,
|
||||
resolveConfiguredFromCredentialStatuses,
|
||||
} from "../channels/account-snapshot-fields.js";
|
||||
export {
|
||||
listTelegramDirectoryGroupsFromConfig,
|
||||
listTelegramDirectoryPeersFromConfig,
|
||||
|
||||
@@ -15,18 +15,35 @@ export type ResolveAssignmentsFromSnapshotResult = {
|
||||
diagnostics: string[];
|
||||
};
|
||||
|
||||
export function collectCommandSecretAssignmentsFromSnapshot(params: {
|
||||
export type UnresolvedCommandSecretAssignment = {
|
||||
path: string;
|
||||
pathSegments: string[];
|
||||
};
|
||||
|
||||
export type AnalyzeAssignmentsFromSnapshotResult = {
|
||||
assignments: CommandSecretAssignment[];
|
||||
diagnostics: string[];
|
||||
unresolved: UnresolvedCommandSecretAssignment[];
|
||||
inactive: UnresolvedCommandSecretAssignment[];
|
||||
};
|
||||
|
||||
export function analyzeCommandSecretAssignmentsFromSnapshot(params: {
|
||||
sourceConfig: OpenClawConfig;
|
||||
resolvedConfig: OpenClawConfig;
|
||||
commandName: string;
|
||||
targetIds: ReadonlySet<string>;
|
||||
inactiveRefPaths?: ReadonlySet<string>;
|
||||
}): ResolveAssignmentsFromSnapshotResult {
|
||||
allowedPaths?: ReadonlySet<string>;
|
||||
}): AnalyzeAssignmentsFromSnapshotResult {
|
||||
const defaults = params.sourceConfig.secrets?.defaults;
|
||||
const assignments: CommandSecretAssignment[] = [];
|
||||
const diagnostics: string[] = [];
|
||||
const unresolved: UnresolvedCommandSecretAssignment[] = [];
|
||||
const inactive: UnresolvedCommandSecretAssignment[] = [];
|
||||
|
||||
for (const target of discoverConfigSecretTargetsByIds(params.sourceConfig, params.targetIds)) {
|
||||
if (params.allowedPaths && !params.allowedPaths.has(target.path)) {
|
||||
continue;
|
||||
}
|
||||
const { explicitRef, ref } = resolveSecretInputRef({
|
||||
value: target.value,
|
||||
refValue: target.refValue,
|
||||
@@ -43,11 +60,17 @@ export function collectCommandSecretAssignmentsFromSnapshot(params: {
|
||||
diagnostics.push(
|
||||
`${target.path}: secret ref is configured on an inactive surface; skipping command-time assignment.`,
|
||||
);
|
||||
inactive.push({
|
||||
path: target.path,
|
||||
pathSegments: [...target.pathSegments],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
throw new Error(
|
||||
`${params.commandName}: ${target.path} is unresolved in the active runtime snapshot.`,
|
||||
);
|
||||
unresolved.push({
|
||||
path: target.path,
|
||||
pathSegments: [...target.pathSegments],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
assignments.push({
|
||||
@@ -63,5 +86,31 @@ export function collectCommandSecretAssignmentsFromSnapshot(params: {
|
||||
}
|
||||
}
|
||||
|
||||
return { assignments, diagnostics };
|
||||
return { assignments, diagnostics, unresolved, inactive };
|
||||
}
|
||||
|
||||
export function collectCommandSecretAssignmentsFromSnapshot(params: {
|
||||
sourceConfig: OpenClawConfig;
|
||||
resolvedConfig: OpenClawConfig;
|
||||
commandName: string;
|
||||
targetIds: ReadonlySet<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 type { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
|
||||
import {
|
||||
isNumericTelegramUserId,
|
||||
normalizeTelegramAllowFromEntry,
|
||||
@@ -113,9 +118,72 @@ function hasExplicitProviderAccountConfig(
|
||||
|
||||
export async function collectChannelSecurityFindings(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sourceConfig?: OpenClawConfig;
|
||||
plugins: ReturnType<typeof listChannelPlugins>;
|
||||
}): Promise<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 => {
|
||||
if (value === true) {
|
||||
@@ -197,28 +265,24 @@ export async function collectChannelSecurityFindings(params: {
|
||||
if (!plugin.security) {
|
||||
continue;
|
||||
}
|
||||
const accountIds = plugin.config.listAccountIds(params.cfg);
|
||||
const accountIds = plugin.config.listAccountIds(sourceConfig);
|
||||
const defaultAccountId = resolveChannelDefaultAccountId({
|
||||
plugin,
|
||||
cfg: params.cfg,
|
||||
cfg: sourceConfig,
|
||||
accountIds,
|
||||
});
|
||||
const orderedAccountIds = Array.from(new Set([defaultAccountId, ...accountIds]));
|
||||
|
||||
for (const accountId of orderedAccountIds) {
|
||||
const hasExplicitAccountPath = hasExplicitProviderAccountConfig(
|
||||
params.cfg,
|
||||
sourceConfig,
|
||||
plugin.id,
|
||||
accountId,
|
||||
);
|
||||
const account = plugin.config.resolveAccount(params.cfg, accountId);
|
||||
const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, params.cfg) : true;
|
||||
const { account, enabled, configured } = await resolveChannelAuditAccount(plugin, accountId);
|
||||
if (!enabled) {
|
||||
continue;
|
||||
}
|
||||
const configured = plugin.config.isConfigured
|
||||
? await plugin.config.isConfigured(account, params.cfg)
|
||||
: true;
|
||||
if (!configured) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,10 @@ function stubChannelPlugin(params: {
|
||||
id: "discord" | "slack" | "telegram";
|
||||
label: string;
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId: string | null | undefined) => unknown;
|
||||
inspectAccount?: (cfg: OpenClawConfig, accountId: string | null | undefined) => unknown;
|
||||
listAccountIds?: (cfg: OpenClawConfig) => string[];
|
||||
isConfigured?: (account: unknown, cfg: OpenClawConfig) => boolean;
|
||||
isEnabled?: (account: unknown, cfg: OpenClawConfig) => boolean;
|
||||
}): ChannelPlugin {
|
||||
return {
|
||||
id: params.id,
|
||||
@@ -54,9 +57,10 @@ function stubChannelPlugin(params: {
|
||||
);
|
||||
return enabled ? ["default"] : [];
|
||||
}),
|
||||
inspectAccount: params.inspectAccount,
|
||||
resolveAccount: (cfg, accountId) => params.resolveAccount(cfg, accountId),
|
||||
isEnabled: () => true,
|
||||
isConfigured: () => true,
|
||||
isEnabled: (account, cfg) => params.isEnabled?.(account, cfg) ?? true,
|
||||
isConfigured: (account, cfg) => params.isConfigured?.(account, cfg) ?? true,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1837,6 +1841,247 @@ description: test skill
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps channel security findings when SecretRef credentials are configured but unavailable", async () => {
|
||||
await withChannelSecurityStateDir(async () => {
|
||||
const sourceConfig: OpenClawConfig = {
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
|
||||
groupPolicy: "allowlist",
|
||||
guilds: {
|
||||
"123": {
|
||||
channels: {
|
||||
general: { allow: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const resolvedConfig: OpenClawConfig = {
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
groupPolicy: "allowlist",
|
||||
guilds: {
|
||||
"123": {
|
||||
channels: {
|
||||
general: { allow: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const inspectableDiscordPlugin = stubChannelPlugin({
|
||||
id: "discord",
|
||||
label: "Discord",
|
||||
inspectAccount: (cfg) => {
|
||||
const channel = cfg.channels?.discord ?? {};
|
||||
const token = channel.token;
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured:
|
||||
Boolean(token) &&
|
||||
typeof token === "object" &&
|
||||
!Array.isArray(token) &&
|
||||
"source" in token,
|
||||
token: "",
|
||||
tokenSource:
|
||||
Boolean(token) &&
|
||||
typeof token === "object" &&
|
||||
!Array.isArray(token) &&
|
||||
"source" in token
|
||||
? "config"
|
||||
: "none",
|
||||
tokenStatus:
|
||||
Boolean(token) &&
|
||||
typeof token === "object" &&
|
||||
!Array.isArray(token) &&
|
||||
"source" in token
|
||||
? "configured_unavailable"
|
||||
: "missing",
|
||||
config: channel,
|
||||
};
|
||||
},
|
||||
resolveAccount: (cfg) => ({ config: cfg.channels?.discord ?? {} }),
|
||||
isConfigured: (account) => Boolean((account as { configured?: boolean }).configured),
|
||||
});
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: resolvedConfig,
|
||||
sourceConfig,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: true,
|
||||
plugins: [inspectableDiscordPlugin],
|
||||
});
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "channels.discord.commands.native.no_allowlists",
|
||||
severity: "warn",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Slack HTTP slash-command findings when resolved inspection only exposes signingSecret status", async () => {
|
||||
await withChannelSecurityStateDir(async () => {
|
||||
const sourceConfig: OpenClawConfig = {
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
mode: "http",
|
||||
groupPolicy: "open",
|
||||
slashCommand: { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
const resolvedConfig: OpenClawConfig = {
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
mode: "http",
|
||||
groupPolicy: "open",
|
||||
slashCommand: { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const inspectableSlackPlugin = stubChannelPlugin({
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
inspectAccount: (cfg) => {
|
||||
const channel = cfg.channels?.slack ?? {};
|
||||
if (cfg === sourceConfig) {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: false,
|
||||
configured: true,
|
||||
mode: "http",
|
||||
botTokenSource: "config",
|
||||
botTokenStatus: "configured_unavailable",
|
||||
signingSecretSource: "config",
|
||||
signingSecretStatus: "configured_unavailable",
|
||||
config: channel,
|
||||
};
|
||||
}
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
mode: "http",
|
||||
botTokenSource: "config",
|
||||
botTokenStatus: "available",
|
||||
signingSecretSource: "config",
|
||||
signingSecretStatus: "available",
|
||||
config: channel,
|
||||
};
|
||||
},
|
||||
resolveAccount: (cfg) => ({ config: cfg.channels?.slack ?? {} }),
|
||||
isConfigured: (account) => Boolean((account as { configured?: boolean }).configured),
|
||||
});
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: resolvedConfig,
|
||||
sourceConfig,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: true,
|
||||
plugins: [inspectableSlackPlugin],
|
||||
});
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "channels.slack.commands.slash.no_allowlists",
|
||||
severity: "warn",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps source-configured Slack HTTP findings when resolved inspection is unconfigured", async () => {
|
||||
await withChannelSecurityStateDir(async () => {
|
||||
const sourceConfig: OpenClawConfig = {
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
mode: "http",
|
||||
groupPolicy: "open",
|
||||
slashCommand: { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
const resolvedConfig: OpenClawConfig = {
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
mode: "http",
|
||||
groupPolicy: "open",
|
||||
slashCommand: { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const inspectableSlackPlugin = stubChannelPlugin({
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
inspectAccount: (cfg) => {
|
||||
const channel = cfg.channels?.slack ?? {};
|
||||
if (cfg === sourceConfig) {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
mode: "http",
|
||||
botTokenSource: "config",
|
||||
botTokenStatus: "configured_unavailable",
|
||||
signingSecretSource: "config",
|
||||
signingSecretStatus: "configured_unavailable",
|
||||
config: channel,
|
||||
};
|
||||
}
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: false,
|
||||
mode: "http",
|
||||
botTokenSource: "config",
|
||||
botTokenStatus: "available",
|
||||
signingSecretSource: "config",
|
||||
signingSecretStatus: "missing",
|
||||
config: channel,
|
||||
};
|
||||
},
|
||||
resolveAccount: (cfg) => ({ config: cfg.channels?.slack ?? {} }),
|
||||
isConfigured: (account) => Boolean((account as { configured?: boolean }).configured),
|
||||
});
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: resolvedConfig,
|
||||
sourceConfig,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: true,
|
||||
plugins: [inspectableSlackPlugin],
|
||||
});
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "channels.slack.commands.slash.no_allowlists",
|
||||
severity: "warn",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not flag Discord slash commands when dm.allowFrom includes a Discord snowflake id", async () => {
|
||||
await withChannelSecurityStateDir(async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
|
||||
@@ -86,6 +86,7 @@ export type SecurityAuditReport = {
|
||||
|
||||
export type SecurityAuditOptions = {
|
||||
config: OpenClawConfig;
|
||||
sourceConfig?: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
platform?: NodeJS.Platform;
|
||||
deep?: boolean;
|
||||
@@ -113,6 +114,7 @@ export type SecurityAuditOptions = {
|
||||
|
||||
type AuditExecutionContext = {
|
||||
cfg: OpenClawConfig;
|
||||
sourceConfig: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
platform: NodeJS.Platform;
|
||||
includeFilesystem: boolean;
|
||||
@@ -1092,6 +1094,7 @@ async function createAuditExecutionContext(
|
||||
opts: SecurityAuditOptions,
|
||||
): Promise<AuditExecutionContext> {
|
||||
const cfg = opts.config;
|
||||
const sourceConfig = opts.sourceConfig ?? opts.config;
|
||||
const env = opts.env ?? process.env;
|
||||
const platform = opts.platform ?? process.platform;
|
||||
const includeFilesystem = opts.includeFilesystem !== false;
|
||||
@@ -1107,6 +1110,7 @@ async function createAuditExecutionContext(
|
||||
: null;
|
||||
return {
|
||||
cfg,
|
||||
sourceConfig,
|
||||
env,
|
||||
platform,
|
||||
includeFilesystem,
|
||||
@@ -1206,7 +1210,13 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
|
||||
|
||||
if (context.includeChannelSecurity) {
|
||||
const plugins = context.plugins ?? listChannelPlugins();
|
||||
findings.push(...(await collectChannelSecurityFindings({ cfg, plugins })));
|
||||
findings.push(
|
||||
...(await collectChannelSecurityFindings({
|
||||
cfg,
|
||||
sourceConfig: context.sourceConfig,
|
||||
plugins,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
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