Skip to content

Commit 731d466

Browse files
authored
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)
1 parent d21f07a commit 731d466

7 files changed

Lines changed: 292 additions & 5 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
3030
- 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.
3131
- 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.
3232
- 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.
33+
- 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.
3334

3435
## 2026.4.14
3536

src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,13 @@ describe("runReplyAgent runtime config", () => {
136136
).rejects.toBe(sentinelError);
137137

138138
expect(followupRun.run.config).toBe(freshCfg);
139-
expect(resolveQueuedReplyExecutionConfigMock).toHaveBeenCalledWith(staleCfg);
139+
expect(resolveQueuedReplyExecutionConfigMock).toHaveBeenCalledWith(
140+
staleCfg,
141+
expect.objectContaining({
142+
originatingChannel: "telegram",
143+
messageProvider: "telegram",
144+
}),
145+
);
140146
expect(resolveReplyToModeMock).toHaveBeenCalledWith(freshCfg, "telegram", "default", "dm");
141147
expect(createReplyMediaPathNormalizerMock).toHaveBeenCalledWith({
142148
cfg: freshCfg,
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { OpenClawConfig } from "../../config/config.js";
3+
4+
const hoisted = vi.hoisted(() => ({
5+
resolveCommandSecretRefsViaGatewayMock: vi.fn(),
6+
getScopedChannelsCommandSecretTargetsMock: vi.fn(),
7+
}));
8+
9+
vi.mock("../../cli/command-secret-gateway.js", () => ({
10+
resolveCommandSecretRefsViaGateway: (...args: unknown[]) =>
11+
hoisted.resolveCommandSecretRefsViaGatewayMock(...args),
12+
}));
13+
14+
vi.mock("../../cli/command-secret-targets.js", () => ({
15+
getAgentRuntimeCommandSecretTargetIds: () => new Set(["skills.entries.*.apiKey"]),
16+
getScopedChannelsCommandSecretTargets: (...args: unknown[]) =>
17+
hoisted.getScopedChannelsCommandSecretTargetsMock(...args),
18+
}));
19+
20+
const { resolveQueuedReplyExecutionConfig } = await import("./agent-runner-utils.js");
21+
const { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } =
22+
await import("../../config/config.js");
23+
24+
describe("resolveQueuedReplyExecutionConfig channel scope", () => {
25+
beforeEach(() => {
26+
clearRuntimeConfigSnapshot();
27+
hoisted.resolveCommandSecretRefsViaGatewayMock
28+
.mockReset()
29+
.mockImplementation(async ({ config }) => ({
30+
resolvedConfig: config,
31+
diagnostics: [],
32+
targetStatesByPath: {},
33+
hadUnresolvedTargets: false,
34+
}));
35+
hoisted.getScopedChannelsCommandSecretTargetsMock.mockReset().mockReturnValue({
36+
targetIds: new Set(["channels.discord.token"]),
37+
allowedPaths: new Set(["channels.discord.token", "channels.discord.accounts.work.token"]),
38+
});
39+
});
40+
41+
afterEach(() => {
42+
clearRuntimeConfigSnapshot();
43+
});
44+
45+
it("resolves base runtime targets, then active channel/account targets from originating context", async () => {
46+
const sourceConfig = { source: true } as unknown as OpenClawConfig;
47+
const baseResolved = { baseResolved: true } as unknown as OpenClawConfig;
48+
const scopedResolved = { scopedResolved: true } as unknown as OpenClawConfig;
49+
hoisted.resolveCommandSecretRefsViaGatewayMock
50+
.mockResolvedValueOnce({
51+
resolvedConfig: baseResolved,
52+
diagnostics: [],
53+
targetStatesByPath: {},
54+
hadUnresolvedTargets: false,
55+
})
56+
.mockResolvedValueOnce({
57+
resolvedConfig: scopedResolved,
58+
diagnostics: [],
59+
targetStatesByPath: {},
60+
hadUnresolvedTargets: false,
61+
});
62+
63+
const resolved = await resolveQueuedReplyExecutionConfig(sourceConfig, {
64+
originatingChannel: "discord",
65+
messageProvider: "slack",
66+
originatingAccountId: "work",
67+
agentAccountId: "default",
68+
});
69+
70+
expect(resolved).toBe(scopedResolved);
71+
expect(hoisted.resolveCommandSecretRefsViaGatewayMock).toHaveBeenCalledTimes(2);
72+
const baseCall = hoisted.resolveCommandSecretRefsViaGatewayMock.mock.calls[0]?.[0] as {
73+
config: OpenClawConfig;
74+
commandName: string;
75+
targetIds: Set<string>;
76+
};
77+
expect(baseCall.config).toBe(sourceConfig);
78+
expect(baseCall.commandName).toBe("reply");
79+
expect(baseCall.targetIds).toEqual(new Set(["skills.entries.*.apiKey"]));
80+
expect(hoisted.getScopedChannelsCommandSecretTargetsMock).toHaveBeenCalledWith({
81+
config: baseResolved,
82+
channel: "discord",
83+
accountId: "work",
84+
});
85+
const scopedCall = hoisted.resolveCommandSecretRefsViaGatewayMock.mock.calls[1]?.[0] as {
86+
config: OpenClawConfig;
87+
commandName: string;
88+
targetIds: Set<string>;
89+
allowedPaths?: Set<string>;
90+
};
91+
expect(scopedCall.config).toBe(baseResolved);
92+
expect(scopedCall.commandName).toBe("reply");
93+
expect(scopedCall.targetIds).toEqual(new Set(["channels.discord.token"]));
94+
expect(scopedCall.allowedPaths).toEqual(
95+
new Set(["channels.discord.token", "channels.discord.accounts.work.token"]),
96+
);
97+
});
98+
99+
it("falls back to messageProvider and agentAccountId when originating values are missing", async () => {
100+
const sourceConfig = { source: true } as unknown as OpenClawConfig;
101+
102+
await resolveQueuedReplyExecutionConfig(sourceConfig, {
103+
messageProvider: "discord",
104+
agentAccountId: "ops",
105+
});
106+
107+
expect(hoisted.getScopedChannelsCommandSecretTargetsMock).toHaveBeenCalledWith({
108+
config: sourceConfig,
109+
channel: "discord",
110+
accountId: "ops",
111+
});
112+
});
113+
114+
it("skips scoped channel resolution when no active channel can be resolved", async () => {
115+
const sourceConfig = { source: true } as unknown as OpenClawConfig;
116+
117+
const resolved = await resolveQueuedReplyExecutionConfig(sourceConfig);
118+
119+
expect(resolved).toBe(sourceConfig);
120+
expect(hoisted.resolveCommandSecretRefsViaGatewayMock).toHaveBeenCalledTimes(1);
121+
expect(hoisted.getScopedChannelsCommandSecretTargetsMock).not.toHaveBeenCalled();
122+
});
123+
124+
it("prefers the runtime snapshot as the base config for secret resolution", async () => {
125+
const sourceConfig = { source: true } as unknown as OpenClawConfig;
126+
const runtimeConfig = { runtime: true } as unknown as OpenClawConfig;
127+
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
128+
hoisted.getScopedChannelsCommandSecretTargetsMock.mockReturnValue({
129+
targetIds: new Set<string>(),
130+
});
131+
132+
await resolveQueuedReplyExecutionConfig(sourceConfig, {
133+
messageProvider: "discord",
134+
});
135+
136+
const baseCall = hoisted.resolveCommandSecretRefsViaGatewayMock.mock.calls[0]?.[0] as {
137+
config: OpenClawConfig;
138+
commandName: string;
139+
};
140+
expect(baseCall.config).toBe(runtimeConfig);
141+
expect(baseCall.commandName).toBe("reply");
142+
expect(hoisted.getScopedChannelsCommandSecretTargetsMock).toHaveBeenCalledWith({
143+
config: runtimeConfig,
144+
channel: "discord",
145+
accountId: undefined,
146+
});
147+
});
148+
});

src/auto-reply/reply/agent-runner-utils.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import type {
66
} from "../../channels/plugins/types.public.js";
77
import { normalizeAnyChannelId, normalizeChannelId } from "../../channels/registry.js";
88
import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js";
9-
import { getAgentRuntimeCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
9+
import {
10+
getAgentRuntimeCommandSecretTargetIds,
11+
getScopedChannelsCommandSecretTargets,
12+
} from "../../cli/command-secret-targets.js";
13+
import { resolveMessageSecretScope } from "../../cli/message-secret-scope.js";
1014
import { getRuntimeConfigSnapshot, type OpenClawConfig } from "../../config/config.js";
1115
import {
1216
normalizeOptionalLowercaseString,
@@ -32,14 +36,47 @@ export function resolveQueuedReplyRuntimeConfig(config: OpenClawConfig): OpenCla
3236

3337
export async function resolveQueuedReplyExecutionConfig(
3438
config: OpenClawConfig,
39+
params?: {
40+
originatingChannel?: string;
41+
messageProvider?: string;
42+
originatingAccountId?: string;
43+
agentAccountId?: string;
44+
},
3545
): Promise<OpenClawConfig> {
3646
const runtimeConfig = resolveQueuedReplyRuntimeConfig(config);
3747
const { resolvedConfig } = await resolveCommandSecretRefsViaGateway({
3848
config: runtimeConfig,
3949
commandName: "reply",
4050
targetIds: getAgentRuntimeCommandSecretTargetIds(),
4151
});
42-
return resolvedConfig ?? runtimeConfig;
52+
const baseResolvedConfig = resolvedConfig ?? runtimeConfig;
53+
54+
const scope = resolveMessageSecretScope({
55+
channel: params?.originatingChannel,
56+
fallbackChannel: params?.messageProvider,
57+
accountId: params?.originatingAccountId,
58+
fallbackAccountId: params?.agentAccountId,
59+
});
60+
if (!scope.channel) {
61+
return baseResolvedConfig;
62+
}
63+
64+
const scopedTargets = getScopedChannelsCommandSecretTargets({
65+
config: baseResolvedConfig,
66+
channel: scope.channel,
67+
accountId: scope.accountId,
68+
});
69+
if (scopedTargets.targetIds.size === 0) {
70+
return baseResolvedConfig;
71+
}
72+
73+
const scopedResolved = await resolveCommandSecretRefsViaGateway({
74+
config: baseResolvedConfig,
75+
commandName: "reply",
76+
targetIds: scopedTargets.targetIds,
77+
...(scopedTargets.allowedPaths ? { allowedPaths: scopedTargets.allowedPaths } : {}),
78+
});
79+
return scopedResolved.resolvedConfig ?? baseResolvedConfig;
4380
}
4481

4582
/**

src/auto-reply/reply/agent-runner.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1013,7 +1013,12 @@ export async function runReplyAgent(params: {
10131013
return undefined;
10141014
}
10151015

1016-
followupRun.run.config = await resolveQueuedReplyExecutionConfig(followupRun.run.config);
1016+
followupRun.run.config = await resolveQueuedReplyExecutionConfig(followupRun.run.config, {
1017+
originatingChannel: sessionCtx.OriginatingChannel,
1018+
messageProvider: followupRun.run.messageProvider,
1019+
originatingAccountId: followupRun.originatingAccountId,
1020+
agentAccountId: followupRun.run.agentAccountId,
1021+
});
10171022

10181023
const replyToChannel = resolveOriginMessageProvider({
10191024
originatingChannel: sessionCtx.OriginatingChannel,

src/auto-reply/reply/followup-runner.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ const routeReplyMock = vi.fn();
1212
const isRoutableChannelMock = vi.fn();
1313
const runPreflightCompactionIfNeededMock = vi.fn();
1414
const resolveCommandSecretRefsViaGatewayMock = vi.fn();
15+
const resolveQueuedReplyExecutionConfigMock = vi.fn();
16+
let resolveQueuedReplyExecutionConfigActual:
17+
| (typeof import("./agent-runner-utils.js"))["resolveQueuedReplyExecutionConfig"]
18+
| undefined;
1519
let createFollowupRunner: typeof import("./followup-runner.js").createFollowupRunner;
1620
let clearRuntimeConfigSnapshot: typeof import("../../config/config.js").clearRuntimeConfigSnapshot;
1721
let loadSessionStore: typeof import("../../config/sessions/store.js").loadSessionStore;
@@ -277,12 +281,51 @@ async function loadFreshFollowupRunnerModuleForTest() {
277281
isRoutableChannel: (...args: unknown[]) => isRoutableChannelMock(...args),
278282
routeReply: (...args: unknown[]) => routeReplyMock(...args),
279283
}));
284+
vi.doMock("./agent-runner-utils.js", async () => {
285+
const actual =
286+
await vi.importActual<typeof import("./agent-runner-utils.js")>("./agent-runner-utils.js");
287+
resolveQueuedReplyExecutionConfigActual = actual.resolveQueuedReplyExecutionConfig;
288+
resolveQueuedReplyExecutionConfigMock.mockImplementation(
289+
async (...args: Parameters<typeof actual.resolveQueuedReplyExecutionConfig>) =>
290+
await actual.resolveQueuedReplyExecutionConfig(...args),
291+
);
292+
return {
293+
...actual,
294+
resolveQueuedReplyExecutionConfig: (
295+
...args: Parameters<typeof actual.resolveQueuedReplyExecutionConfig>
296+
) => resolveQueuedReplyExecutionConfigMock(...args),
297+
};
298+
});
280299
vi.doMock("../../cli/command-secret-gateway.js", () => ({
281300
resolveCommandSecretRefsViaGateway: (...args: unknown[]) =>
282301
resolveCommandSecretRefsViaGatewayMock(...args),
283302
}));
284303
vi.doMock("../../cli/command-secret-targets.js", () => ({
285304
getAgentRuntimeCommandSecretTargetIds: () => new Set(["skills.entries."]),
305+
getScopedChannelsCommandSecretTargets: ({
306+
channel,
307+
accountId,
308+
}: {
309+
channel?: string;
310+
accountId?: string;
311+
}) => {
312+
const normalizedChannel = channel?.trim() ?? "";
313+
if (!normalizedChannel) {
314+
return { targetIds: new Set<string>() };
315+
}
316+
const targetIds = new Set<string>([`channels.${normalizedChannel}.token`]);
317+
const normalizedAccountId = accountId?.trim() ?? "";
318+
if (!normalizedAccountId) {
319+
return { targetIds };
320+
}
321+
return {
322+
targetIds,
323+
allowedPaths: new Set<string>([
324+
`channels.${normalizedChannel}.token`,
325+
`channels.${normalizedChannel}.accounts.${normalizedAccountId}.token`,
326+
]),
327+
};
328+
},
286329
}));
287330
({ createFollowupRunner } = await import("./followup-runner.js"));
288331
({ clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } =
@@ -314,6 +357,15 @@ beforeEach(() => {
314357
compactEmbeddedPiSessionMock.mockReset();
315358
runPreflightCompactionIfNeededMock.mockReset();
316359
resolveCommandSecretRefsViaGatewayMock.mockReset();
360+
resolveQueuedReplyExecutionConfigMock.mockReset();
361+
const resolveQueuedReplyExecutionConfig = resolveQueuedReplyExecutionConfigActual;
362+
if (!resolveQueuedReplyExecutionConfig) {
363+
throw new Error("resolveQueuedReplyExecutionConfig mock not initialized");
364+
}
365+
resolveQueuedReplyExecutionConfigMock.mockImplementation(
366+
async (...args: Parameters<typeof resolveQueuedReplyExecutionConfig>) =>
367+
await resolveQueuedReplyExecutionConfig(...args),
368+
);
317369
runPreflightCompactionIfNeededMock.mockImplementation(
318370
async (params: { sessionEntry?: SessionEntry }) => params.sessionEntry,
319371
);
@@ -513,6 +565,39 @@ describe("createFollowupRunner runtime config", () => {
513565
| undefined;
514566
expect(call?.config).toBe(runtimeConfig);
515567
});
568+
569+
it("passes queued origin scope into queued execution-config resolution", async () => {
570+
runEmbeddedPiAgentMock.mockResolvedValueOnce({
571+
payloads: [],
572+
meta: {},
573+
});
574+
const sourceConfig: OpenClawConfig = {};
575+
const runner = createFollowupRunner({
576+
typing: createMockTypingController(),
577+
typingMode: "instant",
578+
defaultModel: "openai/gpt-5.4",
579+
});
580+
const queued = createQueuedRun({
581+
originatingChannel: "discord",
582+
originatingAccountId: "work",
583+
run: {
584+
config: sourceConfig,
585+
provider: "openai",
586+
model: "gpt-5.4",
587+
messageProvider: "discord",
588+
agentAccountId: "bot-account",
589+
},
590+
});
591+
592+
await runner(queued);
593+
594+
expect(resolveQueuedReplyExecutionConfigMock).toHaveBeenCalledWith(sourceConfig, {
595+
originatingChannel: "discord",
596+
messageProvider: "discord",
597+
originatingAccountId: "work",
598+
agentAccountId: "bot-account",
599+
});
600+
});
516601
});
517602

518603
describe("createFollowupRunner compaction", () => {

src/auto-reply/reply/followup-runner.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,12 @@ export function createFollowupRunner(params: {
135135
};
136136

137137
return async (queued: FollowupRun) => {
138-
queued.run.config = await resolveQueuedReplyExecutionConfig(queued.run.config);
138+
queued.run.config = await resolveQueuedReplyExecutionConfig(queued.run.config, {
139+
originatingChannel: queued.originatingChannel,
140+
messageProvider: queued.run.messageProvider,
141+
originatingAccountId: queued.originatingAccountId,
142+
agentAccountId: queued.run.agentAccountId,
143+
});
139144
const replySessionKey = queued.run.sessionKey ?? sessionKey;
140145
const runtimeConfig = resolveQueuedReplyRuntimeConfig(queued.run.config);
141146
const effectiveQueued =

0 commit comments

Comments
 (0)