Skip to content

Commit 00cab8d

Browse files
fix(secrets): preserve partial gateway assignments (#105160)
Co-authored-by: SunnyShu <[email protected]>
1 parent cf2af03 commit 00cab8d

4 files changed

Lines changed: 149 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
3131
### Fixes
3232

3333
- **Gradium TTS credential egress:** reject non-HTTPS, foreign-host, and hostname-lookalike base URLs before dispatching API keys, and pin guarded transport to Gradium's documented API hostname. (#101280) Thanks @zhangguiping-xydt.
34+
- **Gateway command SecretRefs:** preserve authoritative active-snapshot values when another command secret remains unresolved, falling back locally only for missing paths instead of emitting a per-turn `secrets.resolve` failure. (#96661) Thanks @SunnyShu0925.
3435
- **Installed plugin loading:** make native-module fallback use jiti's transform path instead of retrying the same synchronous ESM load, preventing Node 24 startup races when official plugins import SDK contract modules.
3536
- **QA profile channel execution:** partition mixed Crabline channel scenarios into one aggregate host suite so taxonomy-backed profile commands and evidence workflows no longer abort before execution.
3637
- **Plugin SDK API baseline:** cover every public entrypoint, preserve complete declaration shapes without source-line churn, and run baseline and export-surface guards from changed-file validation.

src/cli/command-secret-gateway.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1342,6 +1342,52 @@ describe("resolveCommandSecretRefsViaGateway", () => {
13421342
});
13431343
});
13441344

1345+
it("preserves gateway assignments while resolving only missing paths locally", async () => {
1346+
const localEnvKey = "TALK_API_KEY_PARTIAL_LOCAL";
1347+
callGateway.mockResolvedValueOnce({
1348+
assignments: [
1349+
{
1350+
path: "talk.providers.gateway.apiKey",
1351+
pathSegments: ["talk", "providers", "gateway", "apiKey"],
1352+
value: "gateway-owned-key",
1353+
},
1354+
],
1355+
diagnostics: [],
1356+
});
1357+
await withEnvAsync({ [localEnvKey]: "local-fallback-key" }, async () => {
1358+
const result = await resolveCommandSecretRefsViaGateway({
1359+
config: {
1360+
talk: {
1361+
providers: {
1362+
gateway: {
1363+
apiKey: {
1364+
source: "env",
1365+
provider: "default",
1366+
id: "GATEWAY_ONLY_TALK_KEY",
1367+
},
1368+
},
1369+
local: {
1370+
apiKey: { source: "env", provider: "default", id: localEnvKey },
1371+
},
1372+
},
1373+
},
1374+
} as OpenClawConfig,
1375+
commandName: "reply",
1376+
targetIds: new Set(["talk.providers.*.apiKey"]),
1377+
});
1378+
1379+
expect(result.resolvedConfig.talk?.providers?.gateway?.apiKey).toBe("gateway-owned-key");
1380+
expect(result.resolvedConfig.talk?.providers?.local?.apiKey).toBe("local-fallback-key");
1381+
expect(result.targetStatesByPath).toMatchObject({
1382+
"talk.providers.gateway.apiKey": "resolved_gateway",
1383+
"talk.providers.local.apiKey": "resolved_local",
1384+
});
1385+
expect(
1386+
result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")),
1387+
).toBe(false);
1388+
});
1389+
});
1390+
13451391
it("limits local fallback to targeted refs in read-only modes", async () => {
13461392
const talkEnvKey = "TALK_API_KEY_TARGET_ONLY";
13471393
const gatewayEnvKey = "GATEWAY_PASSWORD_UNRELATED";

src/secrets/runtime-command-secrets.test.ts

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
/** Tests command-scoped secret resolution from active runtime snapshots. */
2+
import fs from "node:fs/promises";
3+
import os from "node:os";
4+
import path from "node:path";
25
import { afterEach, describe, expect, it } from "vitest";
36
import type { OpenClawConfig } from "../config/types.openclaw.js";
47
import { resolveCommandSecretsFromActiveRuntimeSnapshot } from "./runtime-command-secrets.js";
58
import { createEmptyRuntimeWebToolsMetadata } from "./runtime-fast-path.js";
69
import { activateSecretsRuntimeSnapshotState } from "./runtime-state.js";
7-
import { clearSecretsRuntimeSnapshot } from "./runtime.js";
10+
import { activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot } from "./runtime.js";
11+
import { asConfig, setupSecretsRuntimeSnapshotTestHooks } from "./runtime.test-support.ts";
812
import { discoverConfigSecretTargetsByIds } from "./target-registry.js";
913

1014
const firecrawlPath = "plugins.entries.firecrawl.config.webSearch.apiKey";
@@ -60,11 +64,12 @@ discoverConfigSecretTargetsByIds(forcedFallbackConfig, new Set([firecrawlPath]))
6064

6165
function activateMinimalSecretsRuntimeSnapshot(params: {
6266
config: OpenClawConfig;
67+
resolvedConfig?: OpenClawConfig;
6368
env: Record<string, string | undefined>;
6469
}) {
6570
const snapshot = {
6671
sourceConfig: structuredClone(params.config),
67-
config: structuredClone(params.config),
72+
config: structuredClone(params.resolvedConfig ?? params.config),
6873
authStores: [],
6974
warnings: [],
7075
webTools: createEmptyRuntimeWebToolsMetadata(),
@@ -81,6 +86,8 @@ function activateMinimalSecretsRuntimeSnapshot(params: {
8186
});
8287
}
8388

89+
const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks();
90+
8491
describe("runtime command secrets", () => {
8592
const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
8693
const previousTrustBundledPluginsDir = process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR;
@@ -157,4 +164,93 @@ describe("runtime command secrets", () => {
157164
expect(resolved.diagnostics).toEqual([]);
158165
expect(resolved.inactiveRefPaths).toEqual([]);
159166
});
167+
168+
it("returns authoritative assignments from an incomplete runtime snapshot", async () => {
169+
const sourceConfig = asConfig({
170+
talk: {
171+
providers: {
172+
gateway: {
173+
apiKey: { source: "env", provider: "default", id: "GATEWAY_TALK_KEY" },
174+
},
175+
local: {
176+
apiKey: { source: "env", provider: "default", id: "LOCAL_TALK_KEY" },
177+
},
178+
},
179+
},
180+
});
181+
const resolvedConfig = structuredClone(sourceConfig);
182+
resolvedConfig.talk!.providers!.gateway!.apiKey = "gateway-owned-key";
183+
activateMinimalSecretsRuntimeSnapshot({
184+
config: sourceConfig,
185+
resolvedConfig,
186+
env: {},
187+
});
188+
189+
const resolved = await resolveCommandSecretsFromActiveRuntimeSnapshot({
190+
commandName: "reply",
191+
targetIds: new Set(["talk.providers.*.apiKey"]),
192+
});
193+
194+
expect(resolved.assignments).toEqual([
195+
{
196+
path: "talk.providers.gateway.apiKey",
197+
pathSegments: ["talk", "providers", "gateway", "apiKey"],
198+
value: "gateway-owned-key",
199+
},
200+
]);
201+
});
202+
203+
it.skipIf(process.platform === "win32")(
204+
"serves an exec SecretRef materialized during runtime preparation",
205+
async () => {
206+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-command-secret-exec-"));
207+
try {
208+
const resolverPath = path.join(root, "resolver.sh");
209+
await fs.writeFile(
210+
resolverPath,
211+
[
212+
"#!/bin/sh",
213+
"cat >/dev/null",
214+
'printf \'{"protocolVersion":1,"values":{"talk/key":"gateway-exec-key"}}\'',
215+
].join("\n"),
216+
{ mode: 0o700 },
217+
);
218+
const config = asConfig({
219+
secrets: {
220+
providers: {
221+
command: {
222+
source: "exec",
223+
command: resolverPath,
224+
jsonOnly: true,
225+
},
226+
},
227+
},
228+
talk: {
229+
providers: {
230+
acme: {
231+
apiKey: { source: "exec", provider: "command", id: "talk/key" },
232+
},
233+
},
234+
},
235+
});
236+
const snapshot = await prepareSecretsRuntimeSnapshot({
237+
config,
238+
agentDirs: [path.join(root, "agent")],
239+
loadAuthStore: () => ({ version: 1, profiles: {} }),
240+
});
241+
activateSecretsRuntimeSnapshot(snapshot);
242+
243+
const resolved = await resolveCommandSecretsFromActiveRuntimeSnapshot({
244+
commandName: "reply",
245+
targetIds: new Set(["talk.providers.*.apiKey"]),
246+
});
247+
248+
expect(resolved.assignments).toMatchObject([
249+
{ path: "talk.providers.acme.apiKey", value: "gateway-exec-key" },
250+
]);
251+
} finally {
252+
await fs.rm(root, { recursive: true, force: true });
253+
}
254+
},
255+
);
160256
});

src/secrets/runtime-command-secrets.ts

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { resolveManifestContractOwnerPluginId } from "../plugins/plugin-registry
77
import { resolveBundledExplicitWebSearchProvidersFromPublicArtifacts } from "../plugins/web-provider-public-artifacts.explicit.js";
88
import {
99
analyzeCommandSecretAssignmentsFromSnapshot,
10-
collectCommandSecretAssignmentsFromSnapshot,
1110
type CommandSecretAssignment,
1211
} from "./command-config.js";
1312
import { getPath, setPathExistingStrict } from "./path-utils.js";
@@ -558,34 +557,11 @@ async function resolveCommandSecretsFromSnapshot(params: {
558557
...(params.allowedPaths ? { allowedPaths: params.allowedPaths } : {}),
559558
});
560559
}
561-
const selectedProviderUnresolved = analyzed.unresolved.filter((entry) =>
562-
isProviderOverridePath({
563-
config: sourceConfig,
564-
path: entry.path,
565-
providerOverrides: params.providerOverrides,
566-
}),
567-
);
568-
const forcedActiveUnresolved = analyzed.unresolved.filter((entry) =>
569-
params.forcedActivePaths?.has(entry.path),
570-
);
571-
if (selectedProviderUnresolved.length > 0 || forcedActiveUnresolved.length > 0) {
572-
return {
573-
assignments: analyzed.assignments,
574-
diagnostics: analyzed.diagnostics,
575-
inactiveRefPaths,
576-
};
577-
}
578-
const resolved = collectCommandSecretAssignmentsFromSnapshot({
579-
sourceConfig,
580-
resolvedConfig,
581-
commandName: params.commandName,
582-
targetIds: params.targetIds,
583-
inactiveRefPaths: new Set(inactiveRefPaths),
584-
...(params.allowedPaths ? { allowedPaths: params.allowedPaths } : {}),
585-
});
586560
return {
587-
assignments: resolved.assignments,
588-
diagnostics: resolved.diagnostics,
561+
// A runtime snapshot can be authoritative for only part of a command's target set.
562+
// Preserve those values so the caller falls back locally only for unresolved paths.
563+
assignments: analyzed.assignments,
564+
diagnostics: analyzed.diagnostics,
589565
inactiveRefPaths,
590566
};
591567
}

0 commit comments

Comments
 (0)