diff --git a/CHANGELOG.md b/CHANGELOG.md index de7d17b109f..5d5d4af54d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Gateway/MCP loopback: switch the `/mcp` bearer comparison from plain `!==` to constant-time `safeEqualSecret` (matching the convention every other auth surface in the codebase uses), and reject non-loopback browser-origin requests via `checkBrowserOrigin` before the auth gate runs. Loopback origins (`127.0.0.1:*`, `localhost:*`, same-origin) still go through, including the `localhost`↔`127.0.0.1` host mismatch that browsers flag as `Sec-Fetch-Site: cross-site`. (#66665) Thanks @eleqtrizit. - Auto-reply/billing: classify pure billing cooldown fallback summaries from structured fallback reasons so users see billing guidance instead of the generic failure reply. (#66363) Thanks @Rohan5commit. - Agents/fallback: preserve the original prompt body on model fallback retries with session history so the retrying model keeps the active task instead of only seeing a generic continue message. (#66029) Thanks @WuKongAI-CMU. +- Reply/secrets: resolve active reply channel/account SecretRefs before reply-run message-action discovery so channel token SecretRefs (for example Discord) do not degrade into discovery-time unresolved-secret failures. (#66796) Thanks @joshavant. ## 2026.4.14 diff --git a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts index 5c53e4970fc..35637bdeefe 100644 --- a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts +++ b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts @@ -136,7 +136,13 @@ describe("runReplyAgent runtime config", () => { ).rejects.toBe(sentinelError); expect(followupRun.run.config).toBe(freshCfg); - expect(resolveQueuedReplyExecutionConfigMock).toHaveBeenCalledWith(staleCfg); + expect(resolveQueuedReplyExecutionConfigMock).toHaveBeenCalledWith( + staleCfg, + expect.objectContaining({ + originatingChannel: "telegram", + messageProvider: "telegram", + }), + ); expect(resolveReplyToModeMock).toHaveBeenCalledWith(freshCfg, "telegram", "default", "dm"); expect(createReplyMediaPathNormalizerMock).toHaveBeenCalledWith({ cfg: freshCfg, diff --git a/src/auto-reply/reply/agent-runner-utils.secret-resolution.test.ts b/src/auto-reply/reply/agent-runner-utils.secret-resolution.test.ts new file mode 100644 index 00000000000..86657fc62e9 --- /dev/null +++ b/src/auto-reply/reply/agent-runner-utils.secret-resolution.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; + +const hoisted = vi.hoisted(() => ({ + resolveCommandSecretRefsViaGatewayMock: vi.fn(), + getScopedChannelsCommandSecretTargetsMock: vi.fn(), +})); + +vi.mock("../../cli/command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: (...args: unknown[]) => + hoisted.resolveCommandSecretRefsViaGatewayMock(...args), +})); + +vi.mock("../../cli/command-secret-targets.js", () => ({ + getAgentRuntimeCommandSecretTargetIds: () => new Set(["skills.entries.*.apiKey"]), + getScopedChannelsCommandSecretTargets: (...args: unknown[]) => + hoisted.getScopedChannelsCommandSecretTargetsMock(...args), +})); + +const { resolveQueuedReplyExecutionConfig } = await import("./agent-runner-utils.js"); +const { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } = + await import("../../config/config.js"); + +describe("resolveQueuedReplyExecutionConfig channel scope", () => { + beforeEach(() => { + clearRuntimeConfigSnapshot(); + hoisted.resolveCommandSecretRefsViaGatewayMock + .mockReset() + .mockImplementation(async ({ config }) => ({ + resolvedConfig: config, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + })); + hoisted.getScopedChannelsCommandSecretTargetsMock.mockReset().mockReturnValue({ + targetIds: new Set(["channels.discord.token"]), + allowedPaths: new Set(["channels.discord.token", "channels.discord.accounts.work.token"]), + }); + }); + + afterEach(() => { + clearRuntimeConfigSnapshot(); + }); + + it("resolves base runtime targets, then active channel/account targets from originating context", async () => { + const sourceConfig = { source: true } as unknown as OpenClawConfig; + const baseResolved = { baseResolved: true } as unknown as OpenClawConfig; + const scopedResolved = { scopedResolved: true } as unknown as OpenClawConfig; + hoisted.resolveCommandSecretRefsViaGatewayMock + .mockResolvedValueOnce({ + resolvedConfig: baseResolved, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }) + .mockResolvedValueOnce({ + resolvedConfig: scopedResolved, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + + const resolved = await resolveQueuedReplyExecutionConfig(sourceConfig, { + originatingChannel: "discord", + messageProvider: "slack", + originatingAccountId: "work", + agentAccountId: "default", + }); + + expect(resolved).toBe(scopedResolved); + expect(hoisted.resolveCommandSecretRefsViaGatewayMock).toHaveBeenCalledTimes(2); + const baseCall = hoisted.resolveCommandSecretRefsViaGatewayMock.mock.calls[0]?.[0] as { + config: OpenClawConfig; + commandName: string; + targetIds: Set; + }; + expect(baseCall.config).toBe(sourceConfig); + expect(baseCall.commandName).toBe("reply"); + expect(baseCall.targetIds).toEqual(new Set(["skills.entries.*.apiKey"])); + expect(hoisted.getScopedChannelsCommandSecretTargetsMock).toHaveBeenCalledWith({ + config: baseResolved, + channel: "discord", + accountId: "work", + }); + const scopedCall = hoisted.resolveCommandSecretRefsViaGatewayMock.mock.calls[1]?.[0] as { + config: OpenClawConfig; + commandName: string; + targetIds: Set; + allowedPaths?: Set; + }; + expect(scopedCall.config).toBe(baseResolved); + expect(scopedCall.commandName).toBe("reply"); + expect(scopedCall.targetIds).toEqual(new Set(["channels.discord.token"])); + expect(scopedCall.allowedPaths).toEqual( + new Set(["channels.discord.token", "channels.discord.accounts.work.token"]), + ); + }); + + it("falls back to messageProvider and agentAccountId when originating values are missing", async () => { + const sourceConfig = { source: true } as unknown as OpenClawConfig; + + await resolveQueuedReplyExecutionConfig(sourceConfig, { + messageProvider: "discord", + agentAccountId: "ops", + }); + + expect(hoisted.getScopedChannelsCommandSecretTargetsMock).toHaveBeenCalledWith({ + config: sourceConfig, + channel: "discord", + accountId: "ops", + }); + }); + + it("skips scoped channel resolution when no active channel can be resolved", async () => { + const sourceConfig = { source: true } as unknown as OpenClawConfig; + + const resolved = await resolveQueuedReplyExecutionConfig(sourceConfig); + + expect(resolved).toBe(sourceConfig); + expect(hoisted.resolveCommandSecretRefsViaGatewayMock).toHaveBeenCalledTimes(1); + expect(hoisted.getScopedChannelsCommandSecretTargetsMock).not.toHaveBeenCalled(); + }); + + it("prefers the runtime snapshot as the base config for secret resolution", async () => { + const sourceConfig = { source: true } as unknown as OpenClawConfig; + const runtimeConfig = { runtime: true } as unknown as OpenClawConfig; + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + hoisted.getScopedChannelsCommandSecretTargetsMock.mockReturnValue({ + targetIds: new Set(), + }); + + await resolveQueuedReplyExecutionConfig(sourceConfig, { + messageProvider: "discord", + }); + + const baseCall = hoisted.resolveCommandSecretRefsViaGatewayMock.mock.calls[0]?.[0] as { + config: OpenClawConfig; + commandName: string; + }; + expect(baseCall.config).toBe(runtimeConfig); + expect(baseCall.commandName).toBe("reply"); + expect(hoisted.getScopedChannelsCommandSecretTargetsMock).toHaveBeenCalledWith({ + config: runtimeConfig, + channel: "discord", + accountId: undefined, + }); + }); +}); diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 5697ec7baaa..fc13ac6db6a 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -6,7 +6,11 @@ import type { } from "../../channels/plugins/types.public.js"; import { normalizeAnyChannelId, normalizeChannelId } from "../../channels/registry.js"; import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js"; -import { getAgentRuntimeCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; +import { + getAgentRuntimeCommandSecretTargetIds, + getScopedChannelsCommandSecretTargets, +} from "../../cli/command-secret-targets.js"; +import { resolveMessageSecretScope } from "../../cli/message-secret-scope.js"; import { getRuntimeConfigSnapshot, type OpenClawConfig } from "../../config/config.js"; import { normalizeOptionalLowercaseString, @@ -32,6 +36,12 @@ export function resolveQueuedReplyRuntimeConfig(config: OpenClawConfig): OpenCla export async function resolveQueuedReplyExecutionConfig( config: OpenClawConfig, + params?: { + originatingChannel?: string; + messageProvider?: string; + originatingAccountId?: string; + agentAccountId?: string; + }, ): Promise { const runtimeConfig = resolveQueuedReplyRuntimeConfig(config); const { resolvedConfig } = await resolveCommandSecretRefsViaGateway({ @@ -39,7 +49,34 @@ export async function resolveQueuedReplyExecutionConfig( commandName: "reply", targetIds: getAgentRuntimeCommandSecretTargetIds(), }); - return resolvedConfig ?? runtimeConfig; + const baseResolvedConfig = resolvedConfig ?? runtimeConfig; + + const scope = resolveMessageSecretScope({ + channel: params?.originatingChannel, + fallbackChannel: params?.messageProvider, + accountId: params?.originatingAccountId, + fallbackAccountId: params?.agentAccountId, + }); + if (!scope.channel) { + return baseResolvedConfig; + } + + const scopedTargets = getScopedChannelsCommandSecretTargets({ + config: baseResolvedConfig, + channel: scope.channel, + accountId: scope.accountId, + }); + if (scopedTargets.targetIds.size === 0) { + return baseResolvedConfig; + } + + const scopedResolved = await resolveCommandSecretRefsViaGateway({ + config: baseResolvedConfig, + commandName: "reply", + targetIds: scopedTargets.targetIds, + ...(scopedTargets.allowedPaths ? { allowedPaths: scopedTargets.allowedPaths } : {}), + }); + return scopedResolved.resolvedConfig ?? baseResolvedConfig; } /** diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index a169e62fe2d..c4f4d2c5f3f 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -1013,7 +1013,12 @@ export async function runReplyAgent(params: { return undefined; } - followupRun.run.config = await resolveQueuedReplyExecutionConfig(followupRun.run.config); + followupRun.run.config = await resolveQueuedReplyExecutionConfig(followupRun.run.config, { + originatingChannel: sessionCtx.OriginatingChannel, + messageProvider: followupRun.run.messageProvider, + originatingAccountId: followupRun.originatingAccountId, + agentAccountId: followupRun.run.agentAccountId, + }); const replyToChannel = resolveOriginMessageProvider({ originatingChannel: sessionCtx.OriginatingChannel, diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 776983f5b07..7543fd41003 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -12,6 +12,10 @@ const routeReplyMock = vi.fn(); const isRoutableChannelMock = vi.fn(); const runPreflightCompactionIfNeededMock = vi.fn(); const resolveCommandSecretRefsViaGatewayMock = vi.fn(); +const resolveQueuedReplyExecutionConfigMock = vi.fn(); +let resolveQueuedReplyExecutionConfigActual: + | (typeof import("./agent-runner-utils.js"))["resolveQueuedReplyExecutionConfig"] + | undefined; let createFollowupRunner: typeof import("./followup-runner.js").createFollowupRunner; let clearRuntimeConfigSnapshot: typeof import("../../config/config.js").clearRuntimeConfigSnapshot; let loadSessionStore: typeof import("../../config/sessions/store.js").loadSessionStore; @@ -277,12 +281,51 @@ async function loadFreshFollowupRunnerModuleForTest() { isRoutableChannel: (...args: unknown[]) => isRoutableChannelMock(...args), routeReply: (...args: unknown[]) => routeReplyMock(...args), })); + vi.doMock("./agent-runner-utils.js", async () => { + const actual = + await vi.importActual("./agent-runner-utils.js"); + resolveQueuedReplyExecutionConfigActual = actual.resolveQueuedReplyExecutionConfig; + resolveQueuedReplyExecutionConfigMock.mockImplementation( + async (...args: Parameters) => + await actual.resolveQueuedReplyExecutionConfig(...args), + ); + return { + ...actual, + resolveQueuedReplyExecutionConfig: ( + ...args: Parameters + ) => resolveQueuedReplyExecutionConfigMock(...args), + }; + }); vi.doMock("../../cli/command-secret-gateway.js", () => ({ resolveCommandSecretRefsViaGateway: (...args: unknown[]) => resolveCommandSecretRefsViaGatewayMock(...args), })); vi.doMock("../../cli/command-secret-targets.js", () => ({ getAgentRuntimeCommandSecretTargetIds: () => new Set(["skills.entries."]), + getScopedChannelsCommandSecretTargets: ({ + channel, + accountId, + }: { + channel?: string; + accountId?: string; + }) => { + const normalizedChannel = channel?.trim() ?? ""; + if (!normalizedChannel) { + return { targetIds: new Set() }; + } + const targetIds = new Set([`channels.${normalizedChannel}.token`]); + const normalizedAccountId = accountId?.trim() ?? ""; + if (!normalizedAccountId) { + return { targetIds }; + } + return { + targetIds, + allowedPaths: new Set([ + `channels.${normalizedChannel}.token`, + `channels.${normalizedChannel}.accounts.${normalizedAccountId}.token`, + ]), + }; + }, })); ({ createFollowupRunner } = await import("./followup-runner.js")); ({ clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } = @@ -314,6 +357,15 @@ beforeEach(() => { compactEmbeddedPiSessionMock.mockReset(); runPreflightCompactionIfNeededMock.mockReset(); resolveCommandSecretRefsViaGatewayMock.mockReset(); + resolveQueuedReplyExecutionConfigMock.mockReset(); + const resolveQueuedReplyExecutionConfig = resolveQueuedReplyExecutionConfigActual; + if (!resolveQueuedReplyExecutionConfig) { + throw new Error("resolveQueuedReplyExecutionConfig mock not initialized"); + } + resolveQueuedReplyExecutionConfigMock.mockImplementation( + async (...args: Parameters) => + await resolveQueuedReplyExecutionConfig(...args), + ); runPreflightCompactionIfNeededMock.mockImplementation( async (params: { sessionEntry?: SessionEntry }) => params.sessionEntry, ); @@ -513,6 +565,39 @@ describe("createFollowupRunner runtime config", () => { | undefined; expect(call?.config).toBe(runtimeConfig); }); + + it("passes queued origin scope into queued execution-config resolution", async () => { + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [], + meta: {}, + }); + const sourceConfig: OpenClawConfig = {}; + const runner = createFollowupRunner({ + typing: createMockTypingController(), + typingMode: "instant", + defaultModel: "openai/gpt-5.4", + }); + const queued = createQueuedRun({ + originatingChannel: "discord", + originatingAccountId: "work", + run: { + config: sourceConfig, + provider: "openai", + model: "gpt-5.4", + messageProvider: "discord", + agentAccountId: "bot-account", + }, + }); + + await runner(queued); + + expect(resolveQueuedReplyExecutionConfigMock).toHaveBeenCalledWith(sourceConfig, { + originatingChannel: "discord", + messageProvider: "discord", + originatingAccountId: "work", + agentAccountId: "bot-account", + }); + }); }); describe("createFollowupRunner compaction", () => { diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 01594cdb17e..5ed6285f51a 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -135,7 +135,12 @@ export function createFollowupRunner(params: { }; return async (queued: FollowupRun) => { - queued.run.config = await resolveQueuedReplyExecutionConfig(queued.run.config); + queued.run.config = await resolveQueuedReplyExecutionConfig(queued.run.config, { + originatingChannel: queued.originatingChannel, + messageProvider: queued.run.messageProvider, + originatingAccountId: queued.originatingAccountId, + agentAccountId: queued.run.agentAccountId, + }); const replySessionKey = queued.run.sessionKey ?? sessionKey; const runtimeConfig = resolveQueuedReplyRuntimeConfig(queued.run.config); const effectiveQueued =