fix(reply): resolve active channel/account SecretRefs in reply runs (#66796)

* Reply: resolve active channel/account SecretRefs in agent runs

* tests(reply): assert queued config scope wiring

* fix: document reply secret-scope regression coverage (#66796) (thanks @joshavant)
This commit is contained in:
Josh Avant
2026-04-14 16:04:57 -05:00
committed by GitHub
parent d21f07a39e
commit 731d4666d2
7 changed files with 292 additions and 5 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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<string>;
};
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<string>;
allowedPaths?: Set<string>;
};
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<string>(),
});
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,
});
});
});

View File

@@ -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<OpenClawConfig> {
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;
}
/**

View File

@@ -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,

View File

@@ -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<typeof import("./agent-runner-utils.js")>("./agent-runner-utils.js");
resolveQueuedReplyExecutionConfigActual = actual.resolveQueuedReplyExecutionConfig;
resolveQueuedReplyExecutionConfigMock.mockImplementation(
async (...args: Parameters<typeof actual.resolveQueuedReplyExecutionConfig>) =>
await actual.resolveQueuedReplyExecutionConfig(...args),
);
return {
...actual,
resolveQueuedReplyExecutionConfig: (
...args: Parameters<typeof actual.resolveQueuedReplyExecutionConfig>
) => 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<string>() };
}
const targetIds = new Set<string>([`channels.${normalizedChannel}.token`]);
const normalizedAccountId = accountId?.trim() ?? "";
if (!normalizedAccountId) {
return { targetIds };
}
return {
targetIds,
allowedPaths: new Set<string>([
`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<typeof resolveQueuedReplyExecutionConfig>) =>
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", () => {

View File

@@ -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 =