mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-30 14:02:56 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user