Skip to content

Commit b409d92

Browse files
K3rnelK3rnel
authored andcommitted
Preserve runtime config for SecretRef message sends
1 parent 6bd9eb5 commit b409d92

8 files changed

Lines changed: 166 additions & 15 deletions

File tree

src/agents/model-auth-markers.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,24 @@ describe("model auth markers", () => {
8282
expect(markers.has("ollama-local")).toBe(true);
8383
});
8484

85+
it("keeps package-stable non-secret markers when excluded plugin manifests are absent", async () => {
86+
await withEnvAsync(
87+
{
88+
...cleanPluginManifestEnv(),
89+
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
90+
},
91+
loadMarkerModules,
92+
);
93+
94+
const markers = new Set(listKnownNonSecretApiKeyMarkers());
95+
expect(markers.has("codex-app-server")).toBe(true);
96+
expect(markers.has("gcp-vertex-credentials")).toBe(true);
97+
expect(isNonSecretApiKeyMarker("codex-app-server")).toBe(true);
98+
expect(isNonSecretApiKeyMarker(GCP_VERTEX_CREDENTIALS_MARKER)).toBe(true);
99+
100+
await withEnvAsync(cleanPluginManifestEnv(), loadMarkerModules);
101+
});
102+
85103
it("does not treat removed provider markers as active auth markers", () => {
86104
expect(isNonSecretApiKeyMarker("qwen-oauth")).toBe(false);
87105
});

src/agents/model-auth-markers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local";
99
/** @deprecated Bundled local-provider marker; do not use from third-party plugins. */
1010
export const CUSTOM_LOCAL_AUTH_MARKER = "custom-local";
1111
export const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
12+
export const CODEX_APP_SERVER_AUTH_MARKER = "codex-app-server";
1213
export const NON_ENV_SECRETREF_MARKER = "secretref-managed"; // pragma: allowlist secret
1314
export const SECRETREF_ENV_HEADER_MARKER_PREFIX = "secretref-env:"; // pragma: allowlist secret
1415

@@ -20,6 +21,8 @@ const AWS_SDK_ENV_MARKERS = new Set([
2021
const CORE_NON_SECRET_API_KEY_MARKERS = [
2122
CUSTOM_LOCAL_AUTH_MARKER,
2223
OLLAMA_LOCAL_AUTH_MARKER,
24+
GCP_VERTEX_CREDENTIALS_MARKER,
25+
CODEX_APP_SERVER_AUTH_MARKER,
2326
NON_ENV_SECRETREF_MARKER,
2427
] as const;
2528
let knownEnvApiKeyMarkersCache: Set<string> | undefined;

src/channels/message/send.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { describe, expect, it, vi } from "vitest";
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js";
23
import type { OpenClawConfig } from "../../config/types.openclaw.js";
34
import { OutboundDeliveryError } from "../../infra/outbound/deliver-types.js";
45
import type { OutboundPayloadDeliveryOutcome } from "../../infra/outbound/deliver-types.js";
@@ -25,6 +26,7 @@ type DeliveryIntentCallbackParams = {
2526

2627
type DeliveryRequest = DeliveryIntentCallbackParams & {
2728
abortSignal?: AbortSignal;
29+
cfg?: OpenClawConfig;
2830
payloads?: unknown;
2931
queuePolicy?: string;
3032
replyToId?: string;
@@ -33,6 +35,10 @@ type DeliveryRequest = DeliveryIntentCallbackParams & {
3335

3436
const cfg = {} as OpenClawConfig;
3537

38+
afterEach(() => {
39+
clearRuntimeConfigSnapshot();
40+
});
41+
3642
function requireMockCall(
3743
mock: { mock: { calls: unknown[][] } },
3844
callIndex: number,
@@ -64,6 +70,43 @@ function expectBatchStatus<TStatus extends DurableMessageBatchSendResult["status
6470
}
6571

6672
describe("withDurableMessageSendContext", () => {
73+
it("preserves explicit configs when a runtime snapshot has no source snapshot", async () => {
74+
const explicitCfg = { channels: { telegram: { enabled: true } } };
75+
const runtimeCfg = { channels: { telegram: { enabled: false } } };
76+
setRuntimeConfigSnapshot(runtimeCfg as OpenClawConfig);
77+
deliverOutboundPayloads.mockResolvedValueOnce([{ channel: "telegram", messageId: "msg-1" }]);
78+
79+
const result = await sendDurableMessageBatch({
80+
cfg: explicitCfg as OpenClawConfig,
81+
channel: "telegram",
82+
to: "chat-1",
83+
payloads: [{ text: "hello" }],
84+
});
85+
86+
expectBatchStatus(result, "sent");
87+
expect(latestDeliveryRequest().cfg).toBe(explicitCfg);
88+
});
89+
90+
it("upgrades source-shaped configs to the active runtime snapshot", async () => {
91+
const resolvedCredential = "resolved-runtime-credential";
92+
const sourceCfg = {
93+
channels: { telegram: { token: { source: "env", provider: "default", id: "TG_TOKEN" } } },
94+
};
95+
const runtimeCfg = { channels: { telegram: { token: resolvedCredential } } };
96+
setRuntimeConfigSnapshot(runtimeCfg as OpenClawConfig, sourceCfg as unknown as OpenClawConfig);
97+
deliverOutboundPayloads.mockResolvedValueOnce([{ channel: "telegram", messageId: "msg-1" }]);
98+
99+
const result = await sendDurableMessageBatch({
100+
cfg: sourceCfg as unknown as OpenClawConfig,
101+
channel: "telegram",
102+
to: "chat-1",
103+
payloads: [{ text: "hello" }],
104+
});
105+
106+
expectBatchStatus(result, "sent");
107+
expect(latestDeliveryRequest().cfg).toBe(runtimeCfg);
108+
});
109+
67110
it("renders and sends through a durable send context", async () => {
68111
deliverOutboundPayloads.mockImplementationOnce(async (params: DeliveryIntentCallbackParams) => {
69112
params.onDeliveryIntent?.({

src/channels/message/send.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
2+
import {
3+
getRuntimeConfigSnapshot,
4+
getRuntimeConfigSourceSnapshot,
5+
selectApplicableRuntimeConfig,
6+
} from "../../config/config.js";
7+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
28
import { formatErrorMessage } from "../../infra/errors.js";
39
import type { OutboundDeliveryResult } from "../../infra/outbound/deliver-types.js";
410
import {
@@ -105,6 +111,27 @@ export type DurableMessageDeliveryOutcome = DurableMessageBatchSendResult;
105111

106112
const neverAbortedSignal = new AbortController().signal;
107113

114+
function selectDurableDeliveryRuntimeConfig(
115+
inputConfig?: OpenClawConfig,
116+
): OpenClawConfig | undefined {
117+
const runtimeConfig = getRuntimeConfigSnapshot() ?? undefined;
118+
if (!runtimeConfig) {
119+
return inputConfig;
120+
}
121+
if (!inputConfig || inputConfig === runtimeConfig) {
122+
return runtimeConfig;
123+
}
124+
const runtimeSourceConfig = getRuntimeConfigSourceSnapshot() ?? undefined;
125+
if (!runtimeSourceConfig) {
126+
return inputConfig;
127+
}
128+
return selectApplicableRuntimeConfig({
129+
inputConfig,
130+
runtimeConfig,
131+
runtimeSourceConfig,
132+
});
133+
}
134+
108135
function toDurableMessageIntent(
109136
intent: OutboundDeliveryIntent,
110137
renderedBatch: RenderedMessageBatch<ReplyPayload>,
@@ -200,8 +227,10 @@ export async function withDurableMessageSendContext<T>(
200227
const durablePayloadOutcomes = (): DurableMessagePayloadDeliveryOutcome[] =>
201228
toDurablePayloadOutcomes(payloadOutcomes);
202229
try {
230+
const selectedCfg = selectDurableDeliveryRuntimeConfig(deliveryParams.cfg);
203231
const results = await deliverOutboundPayloadsInternal({
204232
...deliveryParams,
233+
...(selectedCfg ? { cfg: selectedCfg } : {}),
205234
payloads: rendered.payloads,
206235
renderedBatchPlan: rendered.plan,
207236
queuePolicy,

src/gateway/server-methods/send.test.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const mocks = vi.hoisted(() => ({
2828
getChannelPlugin: vi.fn(),
2929
loadOpenClawPlugins: vi.fn(),
3030
applyPluginAutoEnable: vi.fn(),
31-
activeSecretsSnapshot: null as { config: Record<string, unknown> } | null,
31+
runtimeConfigSnapshot: null as Record<string, unknown> | null,
3232
}));
3333

3434
vi.mock("../../config/config.js", async () => {
@@ -37,13 +37,10 @@ vi.mock("../../config/config.js", async () => {
3737
return {
3838
...actual,
3939
getRuntimeConfig: () => ({}),
40+
getRuntimeConfigSnapshot: () => mocks.runtimeConfigSnapshot,
4041
};
4142
});
4243

43-
vi.mock("../../secrets/runtime.js", () => ({
44-
getActiveSecretsRuntimeSnapshot: () => mocks.activeSecretsSnapshot,
45-
}));
46-
4744
vi.mock("../../channels/plugins/index.js", () => ({
4845
getLoadedChannelPlugin: mocks.getChannelPlugin,
4946
getChannelPlugin: mocks.getChannelPlugin,
@@ -278,7 +275,7 @@ describe("gateway send mirroring", () => {
278275
changes: [],
279276
autoEnabledReasons: {},
280277
}));
281-
mocks.activeSecretsSnapshot = null;
278+
mocks.runtimeConfigSnapshot = null;
282279
mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "resolved" });
283280
mocks.resolveOutboundSessionRoute.mockImplementation(
284281
async ({ agentId, channel }: { agentId?: string; channel?: string }) => ({
@@ -360,7 +357,7 @@ describe("gateway send mirroring", () => {
360357
expect(secondCall?.[3]?.cached).toBe(true);
361358
});
362359

363-
it("dispatches message actions with the active secrets runtime snapshot config", async () => {
360+
it("dispatches message actions with the active runtime config snapshot", async () => {
364361
const rawSecretRef = { source: "exec", provider: "default", id: "discord-bot-token" };
365362
const resolvedConfig = {
366363
channels: {
@@ -369,7 +366,7 @@ describe("gateway send mirroring", () => {
369366
},
370367
},
371368
};
372-
mocks.activeSecretsSnapshot = { config: resolvedConfig };
369+
mocks.runtimeConfigSnapshot = resolvedConfig;
373370
mocks.getChannelPlugin.mockReturnValue({
374371
actions: { handleAction: true },
375372
});
@@ -380,7 +377,7 @@ describe("gateway send mirroring", () => {
380377
channel: "discord",
381378
action: "send",
382379
params: { channelId: "C1", message: "hello" },
383-
idempotencyKey: "idem-message-action-active-secrets",
380+
idempotencyKey: "idem-message-action-runtime-snapshot",
384381
} as never,
385382
respond,
386383
context: {

src/gateway/server-methods/send.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { sendDurableMessageBatch } from "../../channels/message/runtime.js";
33
import { normalizeChannelId } from "../../channels/plugins/index.js";
44
import { dispatchChannelMessageAction } from "../../channels/plugins/message-action-dispatch.js";
55
import { createOutboundSendDeps } from "../../cli/deps.js";
6+
import { getRuntimeConfigSnapshot } from "../../config/config.js";
67
import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js";
78
import type { OpenClawConfig } from "../../config/types.openclaw.js";
89
import { resolveOutboundChannelPlugin } from "../../infra/outbound/channel-resolution.js";
@@ -22,7 +23,6 @@ import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
2223
import { extractToolPayload } from "../../infra/outbound/tool-payload.js";
2324
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
2425
import { normalizePollInput } from "../../polls.js";
25-
import { getActiveSecretsRuntimeSnapshot } from "../../secrets/runtime.js";
2626
import { parseThreadSessionSuffix } from "../../sessions/session-key-utils.js";
2727
import {
2828
normalizeOptionalLowercaseString,
@@ -132,9 +132,9 @@ async function resolveRequestedChannel(params: {
132132
error: errorShape(ErrorCodes.INVALID_REQUEST, params.unsupportedMessage(channelInput)),
133133
};
134134
}
135-
const activeSecretsRuntimeConfig = getActiveSecretsRuntimeSnapshot()?.config;
135+
const activeRuntimeConfig = getRuntimeConfigSnapshot();
136136
const cfg = applyPluginAutoEnable({
137-
config: activeSecretsRuntimeConfig ?? params.context.getRuntimeConfig(),
137+
config: activeRuntimeConfig ?? params.context.getRuntimeConfig(),
138138
env: process.env,
139139
}).config;
140140
let channel = normalizedChannel;

src/infra/outbound/message.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
23

34
const mocks = vi.hoisted(() => ({
45
getChannelPlugin: vi.fn(),
@@ -63,6 +64,7 @@ vi.mock("../../utils/message-channel.js", async () => {
6364
};
6465
});
6566

67+
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js";
6668
import { setActivePluginRegistry } from "../../plugins/runtime.js";
6769
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
6870

@@ -134,6 +136,7 @@ describe("sendMessage", () => {
134136
});
135137

136138
beforeEach(() => {
139+
clearRuntimeConfigSnapshot();
137140
setActivePluginRegistry(createTestRegistry([]));
138141
resetOutboundChannelResolutionStateForTest();
139142
mocks.getChannelPlugin.mockClear();
@@ -163,6 +166,39 @@ describe("sendMessage", () => {
163166
expectRecordFields(deliveryParams.session, { agentId: "work" }, "outbound session");
164167
});
165168

169+
it("preserves explicit configs when a runtime snapshot has no source snapshot", async () => {
170+
const explicitCfg = { channels: { forum: { enabled: true } } };
171+
const runtimeCfg = { channels: { forum: { enabled: false } } };
172+
setRuntimeConfigSnapshot(runtimeCfg as OpenClawConfig);
173+
174+
await sendMessage({
175+
cfg: explicitCfg as OpenClawConfig,
176+
channel: "forum",
177+
to: "123456",
178+
content: "hi",
179+
});
180+
181+
expect(expectDeliveryCallFields({}).cfg).toBe(explicitCfg);
182+
});
183+
184+
it("upgrades source-shaped configs to the active runtime snapshot", async () => {
185+
const resolvedCredential = "resolved-runtime-credential";
186+
const sourceCfg = {
187+
channels: { forum: { token: { source: "env", provider: "default", id: "FORUM_TOKEN" } } },
188+
};
189+
const runtimeCfg = { channels: { forum: { token: resolvedCredential } } };
190+
setRuntimeConfigSnapshot(runtimeCfg as OpenClawConfig, sourceCfg as unknown as OpenClawConfig);
191+
192+
await sendMessage({
193+
cfg: sourceCfg as unknown as OpenClawConfig,
194+
channel: "forum",
195+
to: "123456",
196+
content: "hi",
197+
});
198+
199+
expect(expectDeliveryCallFields({}).cfg).toBe(runtimeCfg);
200+
});
201+
166202
it("forwards requesterSenderId into the outbound delivery session", async () => {
167203
await sendMessage({
168204
cfg: {},

src/infra/outbound/message.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
22
import { deriveDurableFinalDeliveryRequirements } from "../../channels/message/capabilities.js";
33
import { sendDurableMessageBatch } from "../../channels/message/runtime.js";
4+
import {
5+
getRuntimeConfigSnapshot,
6+
getRuntimeConfigSourceSnapshot,
7+
selectApplicableRuntimeConfig,
8+
} from "../../config/config.js";
49
import type { OpenClawConfig } from "../../config/types.openclaw.js";
510
import type { OutboundMediaAccess } from "../../media/load-options.js";
611
import type { PollInput } from "../../polls.js";
@@ -305,6 +310,24 @@ async function resolveMessageConfig(cfg?: OpenClawConfig): Promise<OpenClawConfi
305310
return getRuntimeConfig();
306311
}
307312

313+
function selectMessageDeliveryRuntimeConfig(inputConfig: OpenClawConfig): OpenClawConfig {
314+
const runtimeConfig = getRuntimeConfigSnapshot() ?? undefined;
315+
if (!runtimeConfig || inputConfig === runtimeConfig) {
316+
return inputConfig;
317+
}
318+
const runtimeSourceConfig = getRuntimeConfigSourceSnapshot() ?? undefined;
319+
if (!runtimeSourceConfig) {
320+
return inputConfig;
321+
}
322+
return (
323+
selectApplicableRuntimeConfig({
324+
inputConfig,
325+
runtimeConfig,
326+
runtimeSourceConfig,
327+
}) ?? inputConfig
328+
);
329+
}
330+
308331
async function resolveGatewayIdempotencyKey(idempotencyKey?: string): Promise<string> {
309332
if (idempotencyKey) {
310333
return idempotencyKey;
@@ -314,7 +337,8 @@ async function resolveGatewayIdempotencyKey(idempotencyKey?: string): Promise<st
314337
}
315338

316339
export async function sendMessage(params: MessageSendParams): Promise<MessageSendResult> {
317-
const cfg = await resolveMessageConfig(params.cfg);
340+
let cfg = await resolveMessageConfig(params.cfg);
341+
cfg = selectMessageDeliveryRuntimeConfig(cfg);
318342
const channel = await resolveRequiredChannel({ cfg, channel: params.channel });
319343
const plugin = resolveRequiredPlugin(channel, cfg);
320344
const deliveryMode = plugin.outbound?.deliveryMode ?? "direct";
@@ -457,7 +481,8 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
457481
}
458482

459483
export async function sendPoll(params: MessagePollParams): Promise<MessagePollResult> {
460-
const cfg = await resolveMessageConfig(params.cfg);
484+
let cfg = await resolveMessageConfig(params.cfg);
485+
cfg = selectMessageDeliveryRuntimeConfig(cfg);
461486
const channel = await resolveRequiredChannel({ cfg, channel: params.channel });
462487

463488
const pollInput: PollInput = {

0 commit comments

Comments
 (0)