diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ff37ae11c..042332d384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - Browser/existing-session: support `browser.profiles..userDataDir` so Chrome DevTools MCP can attach to Brave, Edge, and other Chromium-based browsers through their own user data directories. (#48170) Thanks @velvet-shark. - Skills/prompt budget: preserve all registered skills via a compact catalog fallback before dropping entries when the full prompt format exceeds `maxSkillsPromptChars`. (#47553) Thanks @snese. - Plugins/bundles: make enabled bundle MCP servers expose runnable tools in embedded Pi, and default relative bundle MCP launches to the bundle root so marketplace bundles like Context7 work through Pi instead of stopping at config import. +- Scope message SecretRef resolution and harden doctor/status paths. (#48728) Thanks @joshavant. ### Breaking diff --git a/docs/channels/discord.md b/docs/channels/discord.md index e179417e9b..2b2266c4c8 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -168,7 +168,7 @@ openclaw pairing approve discord Token resolution is account-aware. Config token values win over env fallback. `DISCORD_BOT_TOKEN` is only used for the default account. -For advanced outbound calls (message tool/channel actions), an explicit per-call `token` is used for that call. Account policy/retry settings still come from the selected account in the active runtime snapshot. +For advanced outbound calls (message tool/channel actions), an explicit per-call `token` is used for that call. This applies to send and read/probe-style actions (for example read/search/fetch/thread/pins/permissions). Account policy/retry settings still come from the selected account in the active runtime snapshot. ## Recommended: Set up a guild workspace diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 4718135ee6..d5429b5b01 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -32,6 +32,8 @@ Notes: - Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing. - If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`). - If `gateway.auth.token`/`gateway.auth.password` are SecretRef-managed and unavailable in the current command path, doctor reports a read-only warning and does not write plaintext fallback credentials. +- If channel SecretRef inspection fails in a fix path, doctor continues and reports a warning instead of exiting early. +- Telegram `allowFrom` username auto-resolution (`doctor --fix`) requires a resolvable Telegram token in the current command path. If token inspection is unavailable, doctor reports a warning and skips auto-resolution for that pass. ## macOS: `launchctl` env overrides diff --git a/docs/cli/message.md b/docs/cli/message.md index 1633554f31..665d0e74bd 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -50,6 +50,16 @@ Name lookup: - `--dry-run` - `--verbose` +## SecretRef behavior + +- `openclaw message` resolves supported channel SecretRefs before running the selected action. +- Resolution is scoped to the active action target when possible: + - channel-scoped when `--channel` is set (or inferred from prefixed targets like `discord:...`) + - account-scoped when `--account` is set (channel globals + selected account surfaces) + - when `--account` is omitted, OpenClaw does not force a `default` account SecretRef scope +- Unresolved SecretRefs on unrelated channels do not block a targeted message action. +- If the selected channel/account SecretRef is unresolved, the command fails closed for that action. + ## Actions ### Core diff --git a/docs/cli/status.md b/docs/cli/status.md index 770bf6ab50..3f0f5bb5bf 100644 --- a/docs/cli/status.md +++ b/docs/cli/status.md @@ -27,3 +27,4 @@ Notes: - 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`. - When command-local SecretRef resolution succeeds, status prefers the resolved snapshot and clears transient “secret unavailable” channel markers from the final output. +- `status --all` includes a Secrets overview row and a diagnosis section that summarizes secret diagnostics (truncated for readability) without stopping report generation. diff --git a/src/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts index 20fdfcc6a0..bad969ede8 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -182,8 +182,8 @@ export async function handleDiscordMessagingAction( } const channelId = resolveChannelId(); const permissions = accountId - ? await fetchChannelPermissionsDiscord(channelId, { accountId }) - : await fetchChannelPermissionsDiscord(channelId); + ? await fetchChannelPermissionsDiscord(channelId, { ...cfgOptions, accountId }) + : await fetchChannelPermissionsDiscord(channelId, cfgOptions); return jsonResult({ ok: true, permissions }); } case "fetchMessage": { @@ -206,8 +206,8 @@ export async function handleDiscordMessagingAction( ); } const message = accountId - ? await fetchMessageDiscord(channelId, messageId, { accountId }) - : await fetchMessageDiscord(channelId, messageId); + ? await fetchMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }) + : await fetchMessageDiscord(channelId, messageId, cfgOptions); return jsonResult({ ok: true, message: normalizeMessage(message), @@ -228,8 +228,8 @@ export async function handleDiscordMessagingAction( around: readStringParam(params, "around"), }; const messages = accountId - ? await readMessagesDiscord(channelId, query, { accountId }) - : await readMessagesDiscord(channelId, query); + ? await readMessagesDiscord(channelId, query, { ...cfgOptions, accountId }) + : await readMessagesDiscord(channelId, query, cfgOptions); return jsonResult({ ok: true, messages: messages.map((message) => normalizeMessage(message)), @@ -338,8 +338,8 @@ export async function handleDiscordMessagingAction( required: true, }); const message = accountId - ? await editMessageDiscord(channelId, messageId, { content }, { accountId }) - : await editMessageDiscord(channelId, messageId, { content }); + ? await editMessageDiscord(channelId, messageId, { content }, { ...cfgOptions, accountId }) + : await editMessageDiscord(channelId, messageId, { content }, cfgOptions); return jsonResult({ ok: true, message }); } case "deleteMessage": { @@ -351,9 +351,9 @@ export async function handleDiscordMessagingAction( required: true, }); if (accountId) { - await deleteMessageDiscord(channelId, messageId, { accountId }); + await deleteMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }); } else { - await deleteMessageDiscord(channelId, messageId); + await deleteMessageDiscord(channelId, messageId, cfgOptions); } return jsonResult({ ok: true }); } @@ -375,8 +375,8 @@ export async function handleDiscordMessagingAction( appliedTags: appliedTags ?? undefined, }; const thread = accountId - ? await createThreadDiscord(channelId, payload, { accountId }) - : await createThreadDiscord(channelId, payload); + ? await createThreadDiscord(channelId, payload, { ...cfgOptions, accountId }) + : await createThreadDiscord(channelId, payload, cfgOptions); return jsonResult({ ok: true, thread }); } case "threadList": { @@ -399,15 +399,18 @@ export async function handleDiscordMessagingAction( before, limit, }, - { accountId }, + { ...cfgOptions, accountId }, ) - : await listThreadsDiscord({ - guildId, - channelId, - includeArchived, - before, - limit, - }); + : await listThreadsDiscord( + { + guildId, + channelId, + includeArchived, + before, + limit, + }, + cfgOptions, + ); return jsonResult({ ok: true, threads }); } case "threadReply": { @@ -438,9 +441,9 @@ export async function handleDiscordMessagingAction( required: true, }); if (accountId) { - await pinMessageDiscord(channelId, messageId, { accountId }); + await pinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }); } else { - await pinMessageDiscord(channelId, messageId); + await pinMessageDiscord(channelId, messageId, cfgOptions); } return jsonResult({ ok: true }); } @@ -453,9 +456,9 @@ export async function handleDiscordMessagingAction( required: true, }); if (accountId) { - await unpinMessageDiscord(channelId, messageId, { accountId }); + await unpinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId }); } else { - await unpinMessageDiscord(channelId, messageId); + await unpinMessageDiscord(channelId, messageId, cfgOptions); } return jsonResult({ ok: true }); } @@ -465,8 +468,8 @@ export async function handleDiscordMessagingAction( } const channelId = resolveChannelId(); const pins = accountId - ? await listPinsDiscord(channelId, { accountId }) - : await listPinsDiscord(channelId); + ? await listPinsDiscord(channelId, { ...cfgOptions, accountId }) + : await listPinsDiscord(channelId, cfgOptions); return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) }); } case "searchMessages": { @@ -495,15 +498,18 @@ export async function handleDiscordMessagingAction( authorIds: authorIdList.length ? authorIdList : undefined, limit, }, - { accountId }, + { ...cfgOptions, accountId }, ) - : await searchMessagesDiscord({ - guildId, - content, - channelIds: channelIdList.length ? channelIdList : undefined, - authorIds: authorIdList.length ? authorIdList : undefined, - limit, - }); + : await searchMessagesDiscord( + { + guildId, + content, + channelIds: channelIdList.length ? channelIdList : undefined, + authorIds: authorIdList.length ? authorIdList : undefined, + limit, + }, + cfgOptions, + ); if (!results || typeof results !== "object") { return jsonResult({ ok: true, results }); } diff --git a/src/agents/tools/discord-actions.test.ts b/src/agents/tools/discord-actions.test.ts index ab2d71caf2..c03cb2fdaf 100644 --- a/src/agents/tools/discord-actions.test.ts +++ b/src/agents/tools/discord-actions.test.ts @@ -211,6 +211,24 @@ describe("handleDiscordMessagingAction", () => { expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString()); }); + it("threads provided cfg into readMessages calls", async () => { + const cfg = { + channels: { + discord: { + token: "token", + }, + }, + } as OpenClawConfig; + await handleDiscordMessagingAction( + "readMessages", + { channelId: "C1" }, + enableAllActions, + {}, + cfg, + ); + expect(readMessagesDiscord).toHaveBeenCalledWith("C1", expect.any(Object), { cfg }); + }); + it("adds normalized timestamps to fetchMessage payloads", async () => { fetchMessageDiscord.mockResolvedValueOnce({ id: "1", @@ -229,6 +247,24 @@ describe("handleDiscordMessagingAction", () => { expect(payload.message?.timestampUtc).toBe(new Date(expectedMs).toISOString()); }); + it("threads provided cfg into fetchMessage calls", async () => { + const cfg = { + channels: { + discord: { + token: "token", + }, + }, + } as OpenClawConfig; + await handleDiscordMessagingAction( + "fetchMessage", + { guildId: "G1", channelId: "C1", messageId: "M1" }, + enableAllActions, + {}, + cfg, + ); + expect(fetchMessageDiscord).toHaveBeenCalledWith("C1", "M1", { cfg }); + }); + it("adds normalized timestamps to listPins payloads", async () => { listPinsDiscord.mockResolvedValueOnce([{ id: "1", timestamp: "2026-01-15T12:00:00.000Z" }]); @@ -338,12 +374,17 @@ describe("handleDiscordMessagingAction", () => { }, enableAllActions, ); - expect(createThreadDiscord).toHaveBeenCalledWith("C1", { - name: "Forum thread", - messageId: undefined, - autoArchiveMinutes: undefined, - content: "Initial forum post body", - }); + expect(createThreadDiscord).toHaveBeenCalledWith( + "C1", + { + name: "Forum thread", + messageId: undefined, + autoArchiveMinutes: undefined, + content: "Initial forum post body", + appliedTags: undefined, + }, + {}, + ); }); }); diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index a148494c8d..88062eacaa 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js"; import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js"; import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js"; @@ -8,6 +8,11 @@ import { createMessageTool } from "./message-tool.js"; const mocks = vi.hoisted(() => ({ runMessageAction: vi.fn(), + loadConfig: vi.fn(() => ({})), + resolveCommandSecretRefsViaGateway: vi.fn(async ({ config }: { config: unknown }) => ({ + resolvedConfig: config, + diagnostics: [], + })), })); vi.mock("../../infra/outbound/message-action-runner.js", async () => { @@ -20,6 +25,18 @@ vi.mock("../../infra/outbound/message-action-runner.js", async () => { }; }); +vi.mock("../../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: mocks.loadConfig, + }; +}); + +vi.mock("../../cli/command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway, +})); + function mockSendResult(overrides: { channel?: string; to?: string } = {}) { mocks.runMessageAction.mockClear(); mocks.runMessageAction.mockResolvedValue({ @@ -41,6 +58,15 @@ function getActionEnum(properties: Record) { return (properties.action as { enum?: string[] } | undefined)?.enum ?? []; } +beforeEach(() => { + mocks.runMessageAction.mockReset(); + mocks.loadConfig.mockReset().mockReturnValue({}); + mocks.resolveCommandSecretRefsViaGateway.mockReset().mockImplementation(async ({ config }) => ({ + resolvedConfig: config, + diagnostics: [], + })); +}); + function createChannelPlugin(params: { id: string; label: string; @@ -101,6 +127,49 @@ async function executeSend(params: { | undefined; } +describe("message tool secret scoping", () => { + it("scopes command-time secret resolution to the selected channel/account", async () => { + mockSendResult({ channel: "discord", to: "discord:123" }); + mocks.loadConfig.mockReturnValue({ + channels: { + discord: { + token: { source: "env", provider: "default", id: "DISCORD_TOKEN" }, + accounts: { + ops: { token: { source: "env", provider: "default", id: "DISCORD_OPS_TOKEN" } }, + chat: { token: { source: "env", provider: "default", id: "DISCORD_CHAT_TOKEN" } }, + }, + }, + slack: { + botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" }, + }, + }, + }); + + const tool = createMessageTool({ + currentChannelProvider: "discord", + agentAccountId: "ops", + }); + + await tool.execute("1", { + action: "send", + target: "channel:123", + message: "hi", + }); + + const secretResolveCall = mocks.resolveCommandSecretRefsViaGateway.mock.calls[0]?.[0] as { + targetIds?: Set; + allowedPaths?: Set; + }; + expect(secretResolveCall.targetIds).toBeInstanceOf(Set); + expect( + [...(secretResolveCall.targetIds ?? [])].every((id) => id.startsWith("channels.discord.")), + ).toBe(true); + expect(secretResolveCall.allowedPaths).toEqual( + new Set(["channels.discord.token", "channels.discord.accounts.ops.token"]), + ); + }); +}); + describe("message tool agent routing", () => { it("derives agentId from the session key", async () => { mockSendResult(); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 0e6c846e75..1dcaf04e1f 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -12,7 +12,8 @@ import { type ChannelMessageActionName, } from "../../channels/plugins/types.js"; import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js"; -import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; +import { getScopedChannelsCommandSecretTargets } from "../../cli/command-secret-targets.js"; +import { resolveMessageSecretScope } from "../../cli/message-secret-scope.js"; import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js"; @@ -820,19 +821,35 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { } } - const cfg = options?.config - ? options.config - : ( - await resolveCommandSecretRefsViaGateway({ - config: loadConfig(), - commandName: "tools.message", - targetIds: getChannelsCommandSecretTargetIds(), - mode: "enforce_resolved", - }) - ).resolvedConfig; const action = readStringParam(params, "action", { required: true, }) as ChannelMessageActionName; + let cfg = options?.config; + if (!cfg) { + const loadedRaw = loadConfig(); + const scope = resolveMessageSecretScope({ + channel: params.channel, + target: params.target, + targets: params.targets, + fallbackChannel: options?.currentChannelProvider, + accountId: params.accountId, + fallbackAccountId: agentAccountId, + }); + const scopedTargets = getScopedChannelsCommandSecretTargets({ + config: loadedRaw, + channel: scope.channel, + accountId: scope.accountId, + }); + cfg = ( + await resolveCommandSecretRefsViaGateway({ + config: loadedRaw, + commandName: "tools.message", + targetIds: scopedTargets.targetIds, + ...(scopedTargets.allowedPaths ? { allowedPaths: scopedTargets.allowedPaths } : {}), + mode: "enforce_resolved", + }) + ).resolvedConfig; + } const requireExplicitTarget = options?.requireExplicitTarget === true; if (requireExplicitTarget && actionNeedsExplicitTarget(action)) { const explicitTarget = diff --git a/src/channels/plugins/message-actions.test.ts b/src/channels/plugins/message-actions.test.ts index 92af406e2f..17fdf8fe19 100644 --- a/src/channels/plugins/message-actions.test.ts +++ b/src/channels/plugins/message-actions.test.ts @@ -1,13 +1,16 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { defaultRuntime } from "../../runtime.js"; import { createChannelTestPluginBase, createTestRegistry, } from "../../test-utils/channel-plugins.js"; import { + __testing, channelSupportsMessageCapability, channelSupportsMessageCapabilityForChannel, + listChannelMessageActions, listChannelMessageCapabilities, listChannelMessageCapabilitiesForChannel, } from "./message-actions.js"; @@ -56,8 +59,12 @@ function activateMessageActionTestRegistry() { } describe("message action capability checks", () => { + const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined); + afterEach(() => { setActivePluginRegistry(emptyRegistry); + __testing.resetLoggedMessageActionErrors(); + errorSpy.mockClear(); }); it("aggregates capabilities across plugins", () => { @@ -122,4 +129,36 @@ describe("message action capability checks", () => { false, ); }); + + it("skips crashing action/capability discovery paths and logs once", () => { + const crashingPlugin: ChannelPlugin = { + ...createChannelTestPluginBase({ + id: "discord", + label: "Discord", + capabilities: { chatTypes: ["direct", "group"] }, + config: { + listAccountIds: () => ["default"], + }, + }), + actions: { + listActions: () => { + throw new Error("boom"); + }, + getCapabilities: () => { + throw new Error("boom"); + }, + }, + }; + setActivePluginRegistry( + createTestRegistry([{ pluginId: "discord", source: "test", plugin: crashingPlugin }]), + ); + + expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast"]); + expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual([]); + expect(errorSpy).toHaveBeenCalledTimes(2); + + expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast"]); + expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual([]); + expect(errorSpy).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/channels/plugins/message-actions.ts b/src/channels/plugins/message-actions.ts index 506f220449..07d0817158 100644 --- a/src/channels/plugins/message-actions.ts +++ b/src/channels/plugins/message-actions.ts @@ -1,5 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { OpenClawConfig } from "../../config/config.js"; +import { defaultRuntime } from "../../runtime.js"; import { getChannelPlugin, listChannelPlugins } from "./index.js"; import type { ChannelMessageCapability } from "./message-capabilities.js"; import type { ChannelMessageActionContext, ChannelMessageActionName } from "./types.js"; @@ -16,13 +17,54 @@ function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boole ); } +const loggedMessageActionErrors = new Set(); + +function logMessageActionError(params: { + pluginId: string; + operation: "listActions" | "getCapabilities"; + error: unknown; +}) { + const message = params.error instanceof Error ? params.error.message : String(params.error); + const key = `${params.pluginId}:${params.operation}:${message}`; + if (loggedMessageActionErrors.has(key)) { + return; + } + loggedMessageActionErrors.add(key); + const stack = params.error instanceof Error && params.error.stack ? params.error.stack : null; + defaultRuntime.error?.( + `[message-actions] ${params.pluginId}.actions.${params.operation} failed: ${stack ?? message}`, + ); +} + +function runListActionsSafely(params: { + pluginId: string; + cfg: OpenClawConfig; + listActions: NonNullable; +}): ChannelMessageActionName[] { + try { + const listed = params.listActions({ cfg: params.cfg }); + return Array.isArray(listed) ? listed : []; + } catch (error) { + logMessageActionError({ + pluginId: params.pluginId, + operation: "listActions", + error, + }); + return []; + } +} + export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] { const actions = new Set(["send", "broadcast"]); for (const plugin of listChannelPlugins()) { - const list = plugin.actions?.listActions?.({ cfg }); - if (!list) { + if (!plugin.actions?.listActions) { continue; } + const list = runListActionsSafely({ + pluginId: plugin.id, + cfg, + listActions: plugin.actions.listActions, + }); for (const action of list) { actions.add(action); } @@ -30,11 +72,21 @@ export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageAc return Array.from(actions); } -function listCapabilities( - actions: ChannelActions, - cfg: OpenClawConfig, -): readonly ChannelMessageCapability[] { - return actions.getCapabilities?.({ cfg }) ?? []; +function listCapabilities(params: { + pluginId: string; + actions: ChannelActions; + cfg: OpenClawConfig; +}): readonly ChannelMessageCapability[] { + try { + return params.actions.getCapabilities?.({ cfg: params.cfg }) ?? []; + } catch (error) { + logMessageActionError({ + pluginId: params.pluginId, + operation: "getCapabilities", + error, + }); + return []; + } } export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] { @@ -43,7 +95,11 @@ export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMess if (!plugin.actions) { continue; } - for (const capability of listCapabilities(plugin.actions, cfg)) { + for (const capability of listCapabilities({ + pluginId: plugin.id, + actions: plugin.actions, + cfg, + })) { capabilities.add(capability); } } @@ -58,7 +114,15 @@ export function listChannelMessageCapabilitiesForChannel(params: { return []; } const plugin = getChannelPlugin(params.channel as Parameters[0]); - return plugin?.actions ? Array.from(listCapabilities(plugin.actions, params.cfg)) : []; + return plugin?.actions + ? Array.from( + listCapabilities({ + pluginId: plugin.id, + actions: plugin.actions, + cfg: params.cfg, + }), + ) + : []; } export function channelSupportsMessageCapability( @@ -95,3 +159,9 @@ export async function dispatchChannelMessageAction( } return await plugin.actions.handleAction(ctx); } + +export const __testing = { + resetLoggedMessageActionErrors() { + loggedMessageActionErrors.clear(); + }, +}; diff --git a/src/cli/command-secret-gateway.test.ts b/src/cli/command-secret-gateway.test.ts index c9de91d425..6a2dff2958 100644 --- a/src/cli/command-secret-gateway.test.ts +++ b/src/cli/command-secret-gateway.test.ts @@ -155,6 +155,45 @@ describe("resolveCommandSecretRefsViaGateway", () => { expect(result.resolvedConfig.talk?.apiKey).toBe("sk-live"); }); + it("enforces unresolved checks only for allowed paths when provided", async () => { + callGateway.mockResolvedValueOnce({ + assignments: [ + { + path: "channels.discord.accounts.ops.token", + pathSegments: ["channels", "discord", "accounts", "ops", "token"], + value: "ops-token", + }, + ], + diagnostics: [], + }); + + const result = await resolveCommandSecretRefsViaGateway({ + config: { + channels: { + discord: { + accounts: { + ops: { + token: { source: "env", provider: "default", id: "DISCORD_OPS_TOKEN" }, + }, + chat: { + token: { source: "env", provider: "default", id: "DISCORD_CHAT_TOKEN" }, + }, + }, + }, + }, + } as OpenClawConfig, + commandName: "message", + targetIds: new Set(["channels.discord.accounts.*.token"]), + allowedPaths: new Set(["channels.discord.accounts.ops.token"]), + }); + + expect(result.resolvedConfig.channels?.discord?.accounts?.ops?.token).toBe("ops-token"); + expect(result.targetStatesByPath).toEqual({ + "channels.discord.accounts.ops.token": "resolved_gateway", + }); + expect(result.hadUnresolvedTargets).toBe(false); + }); + it("fails fast when gateway-backed resolution is unavailable", async () => { const envKey = "TALK_API_KEY_FAILFAST"; const priorValue = process.env[envKey]; diff --git a/src/cli/command-secret-gateway.ts b/src/cli/command-secret-gateway.ts index 8b2b73c9f0..bab49155c9 100644 --- a/src/cli/command-secret-gateway.ts +++ b/src/cli/command-secret-gateway.ts @@ -120,10 +120,14 @@ function targetsRuntimeWebResolution(params: { function collectConfiguredTargetRefPaths(params: { config: OpenClawConfig; targetIds: Set; + allowedPaths?: ReadonlySet; }): Set { const defaults = params.config.secrets?.defaults; const configuredTargetRefPaths = new Set(); for (const target of discoverConfigSecretTargetsByIds(params.config, params.targetIds)) { + if (params.allowedPaths && !params.allowedPaths.has(target.path)) { + continue; + } const { ref } = resolveSecretInputRef({ value: target.value, refValue: target.refValue, @@ -449,11 +453,13 @@ export async function resolveCommandSecretRefsViaGateway(params: { commandName: string; targetIds: Set; mode?: CommandSecretResolutionModeInput; + allowedPaths?: ReadonlySet; }): Promise { const mode = normalizeCommandSecretResolutionMode(params.mode); const configuredTargetRefPaths = collectConfiguredTargetRefPaths({ config: params.config, targetIds: params.targetIds, + allowedPaths: params.allowedPaths, }); if (configuredTargetRefPaths.size === 0) { return { @@ -498,6 +504,7 @@ export async function resolveCommandSecretRefsViaGateway(params: { targetIds: params.targetIds, preflightDiagnostics: preflight.diagnostics, mode, + allowedPaths: params.allowedPaths, }); const recoveredLocally = Object.values(fallback.targetStatesByPath).some( (state) => state === "resolved_local", @@ -556,6 +563,7 @@ export async function resolveCommandSecretRefsViaGateway(params: { resolvedConfig, targetIds: params.targetIds, inactiveRefPaths, + allowedPaths: params.allowedPaths, }); let diagnostics = dedupeDiagnostics(parsed.diagnostics); const targetStatesByPath = buildTargetStatesByPath({ diff --git a/src/cli/command-secret-resolution.coverage.test.ts b/src/cli/command-secret-resolution.coverage.test.ts index 5508c39792..fea0fb35ee 100644 --- a/src/cli/command-secret-resolution.coverage.test.ts +++ b/src/cli/command-secret-resolution.coverage.test.ts @@ -14,6 +14,13 @@ const SECRET_TARGET_CALLSITES = [ "src/commands/status.scan.ts", ] as const; +function hasSupportedTargetIdsWiring(source: string): boolean { + return ( + /targetIds:\s*get[A-Za-z0-9_]+\(\)/m.test(source) || + /targetIds:\s*scopedTargets\.targetIds/m.test(source) + ); +} + describe("command secret resolution coverage", () => { it.each(SECRET_TARGET_CALLSITES)( "routes target-id command path through shared gateway resolver: %s", @@ -21,7 +28,7 @@ describe("command secret resolution coverage", () => { const absolutePath = path.join(process.cwd(), relativePath); const source = await fs.readFile(absolutePath, "utf8"); expect(source).toContain("resolveCommandSecretRefsViaGateway"); - expect(source).toContain("targetIds: get"); + expect(hasSupportedTargetIdsWiring(source)).toBe(true); expect(source).toContain("resolveCommandSecretRefsViaGateway({"); }, ); diff --git a/src/cli/command-secret-targets.test.ts b/src/cli/command-secret-targets.test.ts index 22a23b3605..5f6a98b70b 100644 --- a/src/cli/command-secret-targets.test.ts +++ b/src/cli/command-secret-targets.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { getAgentRuntimeCommandSecretTargetIds, getMemoryCommandSecretTargetIds, + getScopedChannelsCommandSecretTargets, getSecurityAuditCommandSecretTargetIds, } from "./command-secret-targets.js"; @@ -31,4 +32,83 @@ describe("command secret target ids", () => { expect(ids.has("gateway.remote.token")).toBe(true); expect(ids.has("gateway.remote.password")).toBe(true); }); + + it("scopes channel targets to the requested channel", () => { + const scoped = getScopedChannelsCommandSecretTargets({ + config: {} as never, + channel: "discord", + }); + + expect(scoped.targetIds.size).toBeGreaterThan(0); + expect([...scoped.targetIds].every((id) => id.startsWith("channels.discord."))).toBe(true); + expect([...scoped.targetIds].some((id) => id.startsWith("channels.telegram."))).toBe(false); + }); + + it("does not coerce missing accountId to default when channel is scoped", () => { + const scoped = getScopedChannelsCommandSecretTargets({ + config: { + channels: { + discord: { + defaultAccount: "ops", + accounts: { + ops: { + token: { source: "env", provider: "default", id: "DISCORD_OPS" }, + }, + }, + }, + }, + } as never, + channel: "discord", + }); + + expect(scoped.allowedPaths).toBeUndefined(); + expect(scoped.targetIds.size).toBeGreaterThan(0); + expect([...scoped.targetIds].every((id) => id.startsWith("channels.discord."))).toBe(true); + }); + + it("scopes allowed paths to channel globals + selected account", () => { + const scoped = getScopedChannelsCommandSecretTargets({ + config: { + channels: { + discord: { + token: { source: "env", provider: "default", id: "DISCORD_DEFAULT" }, + accounts: { + ops: { + token: { source: "env", provider: "default", id: "DISCORD_OPS" }, + }, + chat: { + token: { source: "env", provider: "default", id: "DISCORD_CHAT" }, + }, + }, + }, + }, + } as never, + channel: "discord", + accountId: "ops", + }); + + expect(scoped.allowedPaths).toBeDefined(); + expect(scoped.allowedPaths?.has("channels.discord.token")).toBe(true); + expect(scoped.allowedPaths?.has("channels.discord.accounts.ops.token")).toBe(true); + expect(scoped.allowedPaths?.has("channels.discord.accounts.chat.token")).toBe(false); + }); + + it("keeps account-scoped allowedPaths as an empty set when scoped target paths are absent", () => { + const scoped = getScopedChannelsCommandSecretTargets({ + config: { + channels: { + discord: { + accounts: { + ops: { enabled: true }, + }, + }, + }, + } as never, + channel: "custom-plugin-channel-without-secret-targets", + accountId: "ops", + }); + + expect(scoped.allowedPaths).toBeDefined(); + expect(scoped.allowedPaths?.size).toBe(0); + }); }); diff --git a/src/cli/command-secret-targets.ts b/src/cli/command-secret-targets.ts index d6dde83cd1..89284892f3 100644 --- a/src/cli/command-secret-targets.ts +++ b/src/cli/command-secret-targets.ts @@ -1,4 +1,9 @@ -import { listSecretTargetRegistryEntries } from "../secrets/target-registry.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { normalizeOptionalAccountId } from "../routing/session-key.js"; +import { + discoverConfigSecretTargetsByIds, + listSecretTargetRegistryEntries, +} from "../secrets/target-registry.js"; function idsByPrefix(prefixes: readonly string[]): string[] { return listSecretTargetRegistryEntries() @@ -37,6 +42,65 @@ function toTargetIdSet(values: readonly string[]): Set { return new Set(values); } +function normalizeScopedChannelId(value?: string | null): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function selectChannelTargetIds(channel?: string): Set { + if (!channel) { + return toTargetIdSet(COMMAND_SECRET_TARGETS.channels); + } + return toTargetIdSet( + COMMAND_SECRET_TARGETS.channels.filter((id) => id.startsWith(`channels.${channel}.`)), + ); +} + +function pathTargetsScopedChannelAccount(params: { + pathSegments: readonly string[]; + channel: string; + accountId: string; +}): boolean { + const [root, channelId, accountRoot, accountId] = params.pathSegments; + if (root !== "channels" || channelId !== params.channel) { + return false; + } + if (accountRoot !== "accounts") { + return true; + } + return accountId === params.accountId; +} + +export function getScopedChannelsCommandSecretTargets(params: { + config: OpenClawConfig; + channel?: string | null; + accountId?: string | null; +}): { + targetIds: Set; + allowedPaths?: Set; +} { + const channel = normalizeScopedChannelId(params.channel); + const targetIds = selectChannelTargetIds(channel); + const normalizedAccountId = normalizeOptionalAccountId(params.accountId); + if (!channel || !normalizedAccountId) { + return { targetIds }; + } + + const allowedPaths = new Set(); + for (const target of discoverConfigSecretTargetsByIds(params.config, targetIds)) { + if ( + pathTargetsScopedChannelAccount({ + pathSegments: target.pathSegments, + channel, + accountId: normalizedAccountId, + }) + ) { + allowedPaths.add(target.path); + } + } + return { targetIds, allowedPaths }; +} + export function getMemoryCommandSecretTargetIds(): Set { return toTargetIdSet(COMMAND_SECRET_TARGETS.memory); } diff --git a/src/cli/message-secret-scope.test.ts b/src/cli/message-secret-scope.test.ts new file mode 100644 index 0000000000..9e243f48b7 --- /dev/null +++ b/src/cli/message-secret-scope.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { resolveMessageSecretScope } from "./message-secret-scope.js"; + +describe("resolveMessageSecretScope", () => { + it("prefers explicit channel/account inputs", () => { + expect( + resolveMessageSecretScope({ + channel: "Discord", + accountId: "Ops", + }), + ).toEqual({ + channel: "discord", + accountId: "ops", + }); + }); + + it("infers channel from a prefixed target", () => { + expect( + resolveMessageSecretScope({ + target: "telegram:12345", + }), + ).toEqual({ + channel: "telegram", + }); + }); + + it("infers a shared channel from target arrays", () => { + expect( + resolveMessageSecretScope({ + targets: ["discord:one", "discord:two"], + }), + ).toEqual({ + channel: "discord", + }); + }); + + it("does not infer a channel when target arrays mix channels", () => { + expect( + resolveMessageSecretScope({ + targets: ["discord:one", "slack:two"], + }), + ).toEqual({}); + }); + + it("uses fallback channel/account when direct inputs are missing", () => { + expect( + resolveMessageSecretScope({ + fallbackChannel: "Signal", + fallbackAccountId: "Chat", + }), + ).toEqual({ + channel: "signal", + accountId: "chat", + }); + }); +}); diff --git a/src/cli/message-secret-scope.ts b/src/cli/message-secret-scope.ts new file mode 100644 index 0000000000..5dd72655ec --- /dev/null +++ b/src/cli/message-secret-scope.ts @@ -0,0 +1,83 @@ +import { normalizeAccountId } from "../routing/session-key.js"; +import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js"; + +function resolveScopedChannelCandidate(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = normalizeMessageChannel(value); + if (!normalized || !isDeliverableMessageChannel(normalized)) { + return undefined; + } + return normalized; +} + +function resolveChannelFromTargetValue(target: unknown): string | undefined { + if (typeof target !== "string") { + return undefined; + } + const trimmed = target.trim(); + if (!trimmed) { + return undefined; + } + const separator = trimmed.indexOf(":"); + if (separator <= 0) { + return undefined; + } + return resolveScopedChannelCandidate(trimmed.slice(0, separator)); +} + +function resolveChannelFromTargets(targets: unknown): string | undefined { + if (!Array.isArray(targets)) { + return undefined; + } + const seen = new Set(); + for (const target of targets) { + const channel = resolveChannelFromTargetValue(target); + if (channel) { + seen.add(channel); + } + } + if (seen.size !== 1) { + return undefined; + } + return [...seen][0]; +} + +function resolveScopedAccountId(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + return normalizeAccountId(trimmed); +} + +export function resolveMessageSecretScope(params: { + channel?: unknown; + target?: unknown; + targets?: unknown; + fallbackChannel?: string | null; + accountId?: unknown; + fallbackAccountId?: string | null; +}): { + channel?: string; + accountId?: string; +} { + const channel = + resolveScopedChannelCandidate(params.channel) ?? + resolveChannelFromTargetValue(params.target) ?? + resolveChannelFromTargets(params.targets) ?? + resolveScopedChannelCandidate(params.fallbackChannel); + + const accountId = + resolveScopedAccountId(params.accountId) ?? + resolveScopedAccountId(params.fallbackAccountId ?? undefined); + + return { + ...(channel ? { channel } : {}), + ...(accountId ? { accountId } : {}), + }; +} diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index a1b204b599..39e7b9d00f 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -387,6 +387,61 @@ describe("doctor config flow", () => { } }); + it("warns and continues when Telegram account inspection hits inactive SecretRef surfaces", 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: { + accounts: { + inactive: { + enabled: false, + botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" }, + allowFrom: ["@testuser"], + }, + }, + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + + const cfg = result.cfg as { + channels?: { + telegram?: { + accounts?: Record; + }; + }; + }; + expect(cfg.channels?.telegram?.accounts?.inactive?.allowFrom).toEqual(["@testuser"]); + expect(fetchSpy).not.toHaveBeenCalled(); + expect( + noteSpy.mock.calls.some((call) => + String(call[0]).includes("Telegram account inactive: failed to inspect bot token"), + ), + ).toBe(true); + expect( + noteSpy.mock.calls.some((call) => + String(call[0]).includes( + "Telegram allowFrom contains @username entries, but no Telegram bot token is configured", + ), + ), + ).toBe(true); + } finally { + noteSpy.mockRestore(); + vi.unstubAllGlobals(); + } + }); + it("converts numeric discord ids to strings on repair", async () => { await withTempHome(async (home) => { const configDir = path.join(home, ".openclaw"); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 912869f390..ae75542398 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -40,6 +40,7 @@ import { normalizeAccountId, normalizeOptionalAccountId, } from "../routing/session-key.js"; +import { describeUnknownError } from "../secrets/shared.js"; import { isDiscordMutableAllowEntry, isGoogleChatMutableAllowEntry, @@ -334,10 +335,23 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi const inspected = inspectTelegramAccount({ cfg, accountId }); return inspected.enabled && inspected.tokenStatus === "configured_unavailable"; }); + const tokenResolutionWarnings: string[] = []; const tokens = Array.from( new Set( listTelegramAccountIds(resolvedConfig) - .map((accountId) => resolveTelegramAccount({ cfg: resolvedConfig, accountId })) + .map((accountId) => { + try { + return resolveTelegramAccount({ cfg: resolvedConfig, accountId }); + } catch (error) { + tokenResolutionWarnings.push( + `- Telegram account ${accountId}: failed to inspect bot token (${describeUnknownError(error)}).`, + ); + return null; + } + }) + .filter((account): account is NonNullable> => + Boolean(account), + ) .map((account) => (account.tokenSource === "none" ? "" : account.token)) .map((token) => token.trim()) .filter(Boolean), @@ -348,6 +362,7 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi return { config: cfg, changes: [ + ...tokenResolutionWarnings, 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 setup or replace with numeric sender IDs).`, diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index adbe4ae785..182946ba7a 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -301,6 +301,13 @@ describe("messageCommand", () => { commandName: "message", }), ); + const secretResolveCall = resolveCommandSecretRefsViaGateway.mock.calls[0]?.[0] as { + targetIds?: Set; + }; + expect(secretResolveCall.targetIds).toBeInstanceOf(Set); + expect( + [...(secretResolveCall.targetIds ?? [])].every((id) => id.startsWith("channels.telegram.")), + ).toBe(true); expect(handleTelegramAction).toHaveBeenCalledWith( expect.objectContaining({ action: "send", to: "123456", accountId: undefined }), resolvedConfig, diff --git a/src/commands/message.ts b/src/commands/message.ts index 76e622e2cf..52540e8916 100644 --- a/src/commands/message.ts +++ b/src/commands/message.ts @@ -3,7 +3,8 @@ import { type ChannelMessageActionName, } from "../channels/plugins/types.js"; import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; -import { getChannelsCommandSecretTargetIds } from "../cli/command-secret-targets.js"; +import { getScopedChannelsCommandSecretTargets } from "../cli/command-secret-targets.js"; +import { resolveMessageSecretScope } from "../cli/message-secret-scope.js"; import { createOutboundSendDeps, type CliDeps } from "../cli/outbound-send-deps.js"; import { withProgress } from "../cli/progress.js"; import { loadConfig } from "../config/config.js"; @@ -19,10 +20,22 @@ export async function messageCommand( runtime: RuntimeEnv, ) { const loadedRaw = loadConfig(); + const scope = resolveMessageSecretScope({ + channel: opts.channel, + target: opts.target, + targets: opts.targets, + accountId: opts.accountId, + }); + const scopedTargets = getScopedChannelsCommandSecretTargets({ + config: loadedRaw, + channel: scope.channel, + accountId: scope.accountId, + }); const { resolvedConfig: cfg, diagnostics } = await resolveCommandSecretRefsViaGateway({ config: loadedRaw, commandName: "message", - targetIds: getChannelsCommandSecretTargetIds(), + targetIds: scopedTargets.targetIds, + ...(scopedTargets.allowedPaths ? { allowedPaths: scopedTargets.allowedPaths } : {}), }); for (const entry of diagnostics) { runtime.log(`[secrets] ${entry}`); diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index b643c30ff3..3ef91457a5 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -44,12 +44,13 @@ export async function statusAllCommand( await withProgress({ label: "Scanning status --all…", total: 11 }, async (progress) => { progress.setLabel("Loading config…"); const loadedRaw = await readBestEffortConfig(); - const { resolvedConfig: cfg } = await resolveCommandSecretRefsViaGateway({ - config: loadedRaw, - commandName: "status --all", - targetIds: getStatusCommandSecretTargetIds(), - mode: "read_only_status", - }); + const { resolvedConfig: cfg, diagnostics: secretDiagnostics } = + await resolveCommandSecretRefsViaGateway({ + config: loadedRaw, + commandName: "status --all", + targetIds: getStatusCommandSecretTargetIds(), + mode: "read_only_status", + }); const osSummary = resolveOsSummary(); const snap = await readConfigFileSnapshot().catch(() => null); progress.tick(); @@ -328,6 +329,13 @@ export async function statusAllCommand( Item: "Agents", Value: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`, }, + { + Item: "Secrets", + Value: + secretDiagnostics.length > 0 + ? `${secretDiagnostics.length} diagnostic${secretDiagnostics.length === 1 ? "" : "s"}` + : "none", + }, ]; const lines = await buildStatusAllReportLines({ @@ -343,6 +351,7 @@ export async function statusAllCommand( diagnosis: { snap, remoteUrlMissing, + secretDiagnostics, sentinel, lastErr, port, diff --git a/src/commands/status-all/diagnosis.ts b/src/commands/status-all/diagnosis.ts index 59140e49b4..5b86641302 100644 --- a/src/commands/status-all/diagnosis.ts +++ b/src/commands/status-all/diagnosis.ts @@ -50,6 +50,7 @@ export async function appendStatusAllDiagnosis(params: { connectionDetailsForReport: string; snap: ConfigSnapshotLike | null; remoteUrlMissing: boolean; + secretDiagnostics: string[]; sentinel: { payload?: RestartSentinelPayload | null } | null; lastErr: string | null; port: number; @@ -104,6 +105,17 @@ export async function appendStatusAllDiagnosis(params: { lines.push(` ${muted("Fix: set gateway.remote.url, or set gateway.mode=local.")}`); } + emitCheck( + `Secret diagnostics (${params.secretDiagnostics.length})`, + params.secretDiagnostics.length === 0 ? "ok" : "warn", + ); + for (const diagnostic of params.secretDiagnostics.slice(0, 10)) { + lines.push(` - ${muted(redactSecrets(diagnostic))}`); + } + if (params.secretDiagnostics.length > 10) { + lines.push(` ${muted(`… +${params.secretDiagnostics.length - 10} more`)}`); + } + if (params.sentinel?.payload) { emitCheck("Restart sentinel present", "warn"); lines.push( diff --git a/src/commands/status-all/report-lines.test.ts b/src/commands/status-all/report-lines.test.ts index 5769bc0d41..0a71665224 100644 --- a/src/commands/status-all/report-lines.test.ts +++ b/src/commands/status-all/report-lines.test.ts @@ -46,6 +46,7 @@ describe("buildStatusAllReportLines", () => { diagnosis: { snap: null, remoteUrlMissing: false, + secretDiagnostics: [], sentinel: null, lastErr: null, port: 18789, @@ -70,5 +71,10 @@ describe("buildStatusAllReportLines", () => { expect(output).toContain("Bootstrap file"); expect(output).toContain("PRESENT"); expect(output).toContain("ABSENT"); + expect(diagnosisSpy).toHaveBeenCalledWith( + expect.objectContaining({ + secretDiagnostics: [], + }), + ); }); }); diff --git a/src/infra/outbound/channel-selection.test.ts b/src/infra/outbound/channel-selection.test.ts index 9448b91931..5f3ac31962 100644 --- a/src/infra/outbound/channel-selection.test.ts +++ b/src/infra/outbound/channel-selection.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { defaultRuntime } from "../../runtime.js"; const mocks = vi.hoisted(() => ({ listChannelPlugins: vi.fn(), @@ -14,6 +15,7 @@ vi.mock("./channel-resolution.js", () => ({ })); import { + __testing, listConfiguredMessageChannels, resolveMessageChannelSelection, } from "./channel-selection.js"; @@ -38,6 +40,8 @@ function makePlugin(params: { } describe("listConfiguredMessageChannels", () => { + const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined); + beforeEach(() => { mocks.listChannelPlugins.mockReset(); mocks.listChannelPlugins.mockReturnValue([]); @@ -45,6 +49,8 @@ describe("listConfiguredMessageChannels", () => { mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) => ({ id: channel, })); + __testing.resetLoggedChannelSelectionErrors(); + errorSpy.mockClear(); }); it("skips unknown plugin ids and plugins without accounts", async () => { @@ -93,6 +99,20 @@ describe("listConfiguredMessageChannels", () => { await expect(listConfiguredMessageChannels({} as never)).resolves.toEqual([]); }); + + it("skips plugin accounts whose resolveAccount throws", async () => { + mocks.listChannelPlugins.mockReturnValue([ + makePlugin({ + id: "discord", + resolveAccount: () => { + throw new Error("boom"); + }, + }), + ]); + + await expect(listConfiguredMessageChannels({} as never)).resolves.toEqual([]); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); }); describe("resolveMessageChannelSelection", () => { diff --git a/src/infra/outbound/channel-selection.ts b/src/infra/outbound/channel-selection.ts index 024fc2273f..0e87a8e495 100644 --- a/src/infra/outbound/channel-selection.ts +++ b/src/infra/outbound/channel-selection.ts @@ -1,6 +1,7 @@ import { listChannelPlugins } from "../../channels/plugins/index.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { defaultRuntime } from "../../runtime.js"; import { listDeliverableMessageChannels, type DeliverableMessageChannel, @@ -59,6 +60,25 @@ function isAccountEnabled(account: unknown): boolean { return enabled !== false; } +const loggedChannelSelectionErrors = new Set(); + +function logChannelSelectionError(params: { + pluginId: string; + accountId: string; + operation: "resolveAccount" | "isConfigured"; + error: unknown; +}) { + const message = params.error instanceof Error ? params.error.message : String(params.error); + const key = `${params.pluginId}:${params.accountId}:${params.operation}:${message}`; + if (loggedChannelSelectionErrors.has(key)) { + return; + } + loggedChannelSelectionErrors.add(key); + defaultRuntime.error?.( + `[channel-selection] ${params.pluginId}(${params.accountId}) ${params.operation} failed: ${message}`, + ); +} + async function isPluginConfigured(plugin: ChannelPlugin, cfg: OpenClawConfig): Promise { const accountIds = plugin.config.listAccountIds(cfg); if (accountIds.length === 0) { @@ -66,7 +86,18 @@ async function isPluginConfigured(plugin: ChannelPlugin, cfg: OpenClawConfig): P } for (const accountId of accountIds) { - const account = plugin.config.resolveAccount(cfg, accountId); + let account: unknown; + try { + account = plugin.config.resolveAccount(cfg, accountId); + } catch (error) { + logChannelSelectionError({ + pluginId: plugin.id, + accountId, + operation: "resolveAccount", + error, + }); + continue; + } const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : isAccountEnabled(account); @@ -76,7 +107,18 @@ async function isPluginConfigured(plugin: ChannelPlugin, cfg: OpenClawConfig): P if (!plugin.config.isConfigured) { return true; } - const configured = await plugin.config.isConfigured(account, cfg); + let configured = false; + try { + configured = await plugin.config.isConfigured(account, cfg); + } catch (error) { + logChannelSelectionError({ + pluginId: plugin.id, + accountId, + operation: "isConfigured", + error, + }); + continue; + } if (configured) { return true; } @@ -162,3 +204,9 @@ export async function resolveMessageChannelSelection(params: { `Channel is required when multiple channels are configured: ${configured.join(", ")}`, ); } + +export const __testing = { + resetLoggedChannelSelectionErrors() { + loggedChannelSelectionErrors.clear(); + }, +};