Skip to content

Commit 9e67f53

Browse files
steipeteleno23
andauthored
fix(cli): resolve web command SecretRefs
Fix CLI web search/fetch command SecretRef resolution for provider-scoped plugin credentials. - Carry command provider overrides through gateway and local secret resolution. - Mark the selected web provider targets active and unrelated plugin refs inactive. - Cover Tavily, DuckDuckGo, legacy Firecrawl fetch, protocol overrides, and runtime command-secret behavior. - Add public plugin-sdk test mock exports needed by existing plugin tests after CI boundary enforcement. Fixes #82621. Replacement for #82699. Co-authored-by: 吴杨帆 <39647285+leno23@users.noreply.github.com>
1 parent ecb9028 commit 9e67f53

24 files changed

Lines changed: 1478 additions & 74 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
3232
- Providers/GitHub Copilot: hash Responses replay item ids with sha256 instead of a weak 32-bit hash and build same-provider Copilot tool-call ids distinctly, so concurrent tool-call replays no longer collide and reject follow-up turns.
3333
- Providers/Anthropic-messages: extract `reasoning_content` from `thinking` blocks during assistant replay so proxy providers that route through the Anthropic-messages transport preserve reasoning context across tool-call follow-up turns. Thanks @Sunnyone2three.
3434
- Agents/GitHub Copilot: normalize replayed Responses tool-call IDs before dispatch so resumed sessions with historical overlong tool IDs continue instead of failing Copilot schema validation. (#82750) Thanks @galiniliev.
35+
- CLI/web: resolve provider-scoped web search/fetch SecretRefs for `infer web ... --provider ...` while leaving unrelated plugin secrets untouched. Fixes #82621. Thanks @leno23.
3536
- Mac app: let menu gateway/session error text wrap across a few lines and stop rebuilding dynamic Context/Gateway menu rows while the menu is open, reducing flicker.
3637
- Mac app: make device pairing approval sheets friendlier, with concise Mac/device copy, shortened identifiers, friendly scope labels, and Approve as the primary action.
3738
- Providers/Qwen: honor session thinking level for `qwen-chat-template` payloads so `/think off` disables nested llama.cpp chat-template thinking controls. Fixes #82768. Thanks @bfox55.

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1501,18 +1501,22 @@ public struct SecretsReloadParams: Codable, Sendable {}
15011501
public struct SecretsResolveParams: Codable, Sendable {
15021502
public let commandname: String
15031503
public let targetids: [String]
1504+
public let provideroverrides: [String: AnyCodable]?
15041505

15051506
public init(
15061507
commandname: String,
1507-
targetids: [String])
1508+
targetids: [String],
1509+
provideroverrides: [String: AnyCodable]?)
15081510
{
15091511
self.commandname = commandname
15101512
self.targetids = targetids
1513+
self.provideroverrides = provideroverrides
15111514
}
15121515

15131516
private enum CodingKeys: String, CodingKey {
15141517
case commandname = "commandName"
15151518
case targetids = "targetIds"
1519+
case provideroverrides = "providerOverrides"
15161520
}
15171521
}
15181522

extensions/memory-core/src/memory/qmd-manager.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import os from "node:os";
44
import path from "node:path";
55
import type { DatabaseSync } from "node:sqlite";
66
import { setTimeout as scheduleNativeTimeout } from "node:timers";
7+
import { withMockedWindowsPlatform } from "openclaw/plugin-sdk/test-node-mocks";
78
import type { Mock } from "vitest";
89
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
9-
import { withMockedWindowsPlatform } from "../../../../src/test-utils/vitest-spies.js";
1010

1111
const { logWarnMock, logDebugMock, logInfoMock } = vi.hoisted(() => ({
1212
logWarnMock: vi.fn(),

extensions/whatsapp/src/media.test.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,10 @@ import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
55
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
66
import { captureEnv } from "openclaw/plugin-sdk/test-env";
77
import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env";
8+
import { withMockedWindowsPlatform, withRestoredMocks } from "openclaw/plugin-sdk/test-node-mocks";
89
import { optimizeImageToPng } from "openclaw/plugin-sdk/web-media";
910
import sharp from "sharp";
1011
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
11-
import {
12-
withMockedWindowsPlatform,
13-
withRestoredMocks,
14-
} from "../../../src/test-utils/vitest-spies.js";
1512
import {
1613
LocalMediaAccessError,
1714
loadWebMedia,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Live repro for CLI infer web search SecretRef resolution (PR #82699).
4+
* Run: TAVILY_API_KEY=resolved-live-proof pnpm exec tsx scripts/repro/cli-web-search-secret-refs-live-proof.mjs
5+
*/
6+
import { resolveCommandConfigWithSecrets } from "../../src/cli/command-config-resolution.js";
7+
import { getWebSearchCommandSecretTargetIds } from "../../src/cli/command-secret-targets.js";
8+
9+
const unresolvedConfig = {
10+
tools: { web: { search: { provider: "tavily", enabled: true } } },
11+
plugins: {
12+
entries: {
13+
tavily: {
14+
config: {
15+
webSearch: {
16+
apiKey: { source: "env", provider: "default", id: "TAVILY_API_KEY" },
17+
},
18+
},
19+
},
20+
},
21+
},
22+
};
23+
24+
process.env.TAVILY_API_KEY = process.env.TAVILY_API_KEY ?? "resolved-live-proof";
25+
26+
const { effectiveConfig, diagnostics } = await resolveCommandConfigWithSecrets({
27+
config: unresolvedConfig,
28+
commandName: "infer web search",
29+
targetIds: getWebSearchCommandSecretTargetIds(),
30+
autoEnable: true,
31+
});
32+
33+
const apiKey = effectiveConfig.plugins?.entries?.tavily?.config?.webSearch?.apiKey;
34+
const unresolved = unresolvedConfig.plugins.entries.tavily.config.webSearch.apiKey;
35+
36+
console.log("unresolved apiKey is SecretRef object =", typeof unresolved === "object");
37+
console.log(
38+
"resolveCommandConfigWithSecrets apiKey is string =",
39+
typeof apiKey === "string" && apiKey.length > 0,
40+
);
41+
console.log("resolved apiKey remains redacted =", typeof apiKey === "string");
42+
console.log("diagnostics count =", diagnostics.length);

src/cli/capability-cli.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2254,6 +2254,7 @@ describe("capability cli", () => {
22542254
commandName: "infer web search",
22552255
targetIds,
22562256
allowedPaths,
2257+
providerOverrides: { webSearch: "tavily" },
22572258
runtime: mocks.runtime,
22582259
}),
22592260
);
@@ -2334,6 +2335,7 @@ describe("capability cli", () => {
23342335
commandName: "infer web fetch",
23352336
targetIds,
23362337
allowedPaths,
2338+
providerOverrides: { webFetch: "firecrawl" },
23372339
runtime: mocks.runtime,
23382340
}),
23392341
);

src/cli/capability-cli.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,7 @@ async function resolveLocalCapabilityRuntimeConfig(params: {
683683
commandName: string;
684684
targetIds: Set<string>;
685685
allowedPaths?: Set<string>;
686+
providerOverrides?: { webSearch?: string; webFetch?: string };
686687
config?: OpenClawConfig;
687688
}): Promise<OpenClawConfig> {
688689
const cfg = params.config ?? getRuntimeConfig();
@@ -692,6 +693,7 @@ async function resolveLocalCapabilityRuntimeConfig(params: {
692693
commandName: params.commandName,
693694
targetIds: params.targetIds,
694695
...(params.allowedPaths ? { allowedPaths: params.allowedPaths } : {}),
696+
...(params.providerOverrides ? { providerOverrides: params.providerOverrides } : {}),
695697
runtime: defaultRuntime,
696698
});
697699
if (sourceConfig) {
@@ -1614,14 +1616,16 @@ async function runTtsStateMutation(params: {
16141616
async function runWebSearchCommand(params: { query: string; provider?: string; limit?: number }) {
16151617
const rawConfig = getRuntimeConfig();
16161618
const config = withWebProviderOverride(rawConfig, "search", params.provider);
1619+
const provider = normalizeOptionalString(params.provider);
16171620
const secretTargets = getWebSearchCommandSecretTargets({
16181621
config,
1619-
provider: params.provider,
1622+
provider,
16201623
});
16211624
const cfg = await resolveLocalCapabilityRuntimeConfig({
16221625
commandName: "infer web search",
16231626
targetIds: secretTargets.targetIds,
16241627
...(secretTargets.allowedPaths ? { allowedPaths: secretTargets.allowedPaths } : {}),
1628+
...(provider ? { providerOverrides: { webSearch: provider } } : {}),
16251629
config,
16261630
});
16271631
const result = await runWebSearch({
@@ -1647,14 +1651,16 @@ async function runWebSearchCommand(params: { query: string; provider?: string; l
16471651
async function runWebFetchCommand(params: { url: string; provider?: string; format?: string }) {
16481652
const rawConfig = getRuntimeConfig();
16491653
const config = withWebProviderOverride(rawConfig, "fetch", params.provider);
1654+
const provider = normalizeOptionalString(params.provider);
16501655
const secretTargets = getWebFetchCommandSecretTargets({
16511656
config,
1652-
provider: params.provider,
1657+
provider,
16531658
});
16541659
const cfg = await resolveLocalCapabilityRuntimeConfig({
16551660
commandName: "infer web fetch",
16561661
targetIds: secretTargets.targetIds,
16571662
...(secretTargets.allowedPaths ? { allowedPaths: secretTargets.allowedPaths } : {}),
1663+
...(provider ? { providerOverrides: { webFetch: provider } } : {}),
16581664
config,
16591665
});
16601666
const resolved = resolveWebFetchDefinition({

src/cli/command-config-resolution.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,25 @@ describe("resolveCommandConfigWithSecrets", () => {
7979
});
8080
expect(result.effectiveConfig).toBe(effectiveConfig);
8181
});
82+
83+
it("passes provider overrides to command secret resolution", async () => {
84+
const config = { tools: { web: { search: { provider: "tavily" } } } };
85+
mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({
86+
resolvedConfig: config,
87+
diagnostics: [],
88+
});
89+
90+
await resolveCommandConfigWithSecrets({
91+
config,
92+
commandName: "infer web search",
93+
targetIds: new Set(["plugins.entries.*.config.webSearch.apiKey"]),
94+
providerOverrides: { webSearch: "tavily" },
95+
});
96+
97+
expect(mocks.resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith(
98+
expect.objectContaining({
99+
providerOverrides: { webSearch: "tavily" },
100+
}),
101+
);
102+
});
82103
});

src/cli/command-config-resolution.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/types.js";
33
import type { RuntimeEnv } from "../runtime.js";
44
import {
55
type CommandSecretResolutionMode,
6+
type CommandSecretsProviderOverrides,
67
resolveCommandSecretRefsViaGateway,
78
} from "./command-secret-gateway.js";
89

@@ -12,6 +13,7 @@ export async function resolveCommandConfigWithSecrets<TConfig extends OpenClawCo
1213
targetIds: Set<string>;
1314
mode?: CommandSecretResolutionMode;
1415
allowedPaths?: Set<string>;
16+
providerOverrides?: CommandSecretsProviderOverrides;
1517
runtime?: RuntimeEnv;
1618
autoEnable?: boolean;
1719
env?: NodeJS.ProcessEnv;
@@ -26,6 +28,7 @@ export async function resolveCommandConfigWithSecrets<TConfig extends OpenClawCo
2628
targetIds: params.targetIds,
2729
...(params.mode ? { mode: params.mode } : {}),
2830
...(params.allowedPaths ? { allowedPaths: params.allowedPaths } : {}),
31+
...(params.providerOverrides ? { providerOverrides: params.providerOverrides } : {}),
2932
});
3033
if (params.runtime) {
3134
for (const entry of diagnostics) {

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

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,103 @@ describe("resolveCommandSecretRefsViaGateway", () => {
172172
expect(readTalkProviderApiKey(result.resolvedConfig)).toBe("sk-live");
173173
});
174174

175+
it("passes command provider overrides to gateway secret resolution", async () => {
176+
callGateway.mockResolvedValueOnce({
177+
assignments: [
178+
{
179+
path: TALK_TEST_PROVIDER_API_KEY_PATH,
180+
pathSegments: [...TALK_TEST_PROVIDER_API_KEY_PATH_SEGMENTS],
181+
value: "sk-live",
182+
},
183+
],
184+
diagnostics: [],
185+
});
186+
const config = buildTalkTestProviderConfig({
187+
source: "env",
188+
provider: "default",
189+
id: "TALK_API_KEY",
190+
});
191+
192+
await resolveCommandSecretRefsViaGateway({
193+
config,
194+
commandName: "infer web search",
195+
targetIds: new Set(["talk.providers.*.apiKey"]),
196+
providerOverrides: { webSearch: "tavily" },
197+
});
198+
199+
expect(callGateway.mock.calls[0]?.[0]?.params).toEqual({
200+
commandName: "infer web search",
201+
targetIds: ["talk.providers.*.apiKey"],
202+
providerOverrides: { webSearch: "tavily" },
203+
});
204+
});
205+
206+
it("applies provider overrides during unavailable-gateway local fallback", async () => {
207+
const restoreDeps = commandSecretGatewayTesting.setDepsForTest({
208+
collectConfigAssignments: ({ context }) => {
209+
context.assignments.push({
210+
path: "plugins.entries.google.config.webSearch.apiKey",
211+
} as never);
212+
},
213+
resolveManifestContractOwnerPluginId: (params) =>
214+
params.contract === "webSearchProviders" && params.value === "gemini"
215+
? "google"
216+
: undefined,
217+
});
218+
const envKey = "WEB_SEARCH_GEMINI_OVERRIDE_LOCAL_FALLBACK";
219+
await withEnvValue(envKey, "gemini-override-local-fallback-key", async () => {
220+
try {
221+
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
222+
const result = await resolveCommandSecretRefsViaGateway({
223+
config: {
224+
tools: {
225+
web: {
226+
search: {
227+
provider: "brave",
228+
apiKey: {
229+
source: "env",
230+
provider: "default",
231+
id: "WEB_SEARCH_BRAVE_MISSING_LOCAL_FALLBACK",
232+
},
233+
},
234+
},
235+
},
236+
plugins: {
237+
entries: {
238+
google: {
239+
config: {
240+
webSearch: {
241+
apiKey: { source: "env", provider: "default", id: envKey },
242+
},
243+
},
244+
},
245+
},
246+
},
247+
} as unknown as OpenClawConfig,
248+
commandName: "infer web search",
249+
targetIds: new Set([
250+
"tools.web.search.apiKey",
251+
"plugins.entries.google.config.webSearch.apiKey",
252+
]),
253+
providerOverrides: { webSearch: "gemini" },
254+
});
255+
256+
const googleWebSearchConfig = result.resolvedConfig.plugins?.entries?.google?.config as
257+
| { webSearch?: { apiKey?: unknown } }
258+
| undefined;
259+
expect(result.resolvedConfig.tools?.web?.search?.provider).toBe("gemini");
260+
expect(googleWebSearchConfig?.webSearch?.apiKey).toBe("gemini-override-local-fallback-key");
261+
expect(result.targetStatesByPath["plugins.entries.google.config.webSearch.apiKey"]).toBe(
262+
"resolved_local",
263+
);
264+
expect(result.targetStatesByPath["tools.web.search.apiKey"]).toBe("inactive_surface");
265+
expectGatewayUnavailableLocalFallbackDiagnostics(result);
266+
} finally {
267+
restoreDeps();
268+
}
269+
});
270+
});
271+
175272
it("enforces unresolved checks only for allowed paths when provided", async () => {
176273
const restoreDeps = commandSecretGatewayTesting.setDepsForTest({
177274
analyzeCommandSecretAssignmentsFromSnapshot: () =>
@@ -425,6 +522,36 @@ describe("resolveCommandSecretRefsViaGateway", () => {
425522
});
426523
});
427524

525+
it("falls back to local resolution for legacy web fetch SecretRefs when gateway is unavailable", async () => {
526+
const envKey = "WEB_FETCH_FIRECRAWL_LEGACY_LOCAL_FALLBACK";
527+
await withEnvValue(envKey, "legacy-firecrawl-local-fallback-key", async () => {
528+
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
529+
const result = await resolveCommandSecretRefsViaGateway({
530+
config: {
531+
tools: {
532+
web: {
533+
fetch: {
534+
provider: "firecrawl",
535+
firecrawl: {
536+
apiKey: { source: "env", provider: "default", id: envKey },
537+
},
538+
},
539+
},
540+
},
541+
} as unknown as OpenClawConfig,
542+
commandName: "infer web fetch",
543+
targetIds: new Set(["tools.web.fetch.firecrawl.apiKey"]),
544+
});
545+
546+
const resolvedFetch = result.resolvedConfig.tools?.web?.fetch as
547+
| { firecrawl?: { apiKey?: unknown } }
548+
| undefined;
549+
expect(resolvedFetch?.firecrawl?.apiKey).toBe("legacy-firecrawl-local-fallback-key");
550+
expect(result.targetStatesByPath["tools.web.fetch.firecrawl.apiKey"]).toBe("resolved_local");
551+
expectGatewayUnavailableLocalFallbackDiagnostics(result);
552+
});
553+
});
554+
428555
it("marks web SecretRefs inactive when the web surface is disabled during local fallback", async () => {
429556
const restoreDeps = commandSecretGatewayTesting.setDepsForTest({
430557
collectConfigAssignments: ({ context }) => {
@@ -467,6 +594,7 @@ describe("resolveCommandSecretRefsViaGateway", () => {
467594
} as OpenClawConfig,
468595
commandName: "agent",
469596
targetIds: new Set(["plugins.entries.google.config.webSearch.apiKey"]),
597+
providerOverrides: { webSearch: "gemini" },
470598
});
471599

472600
expect(result.hadUnresolvedTargets).toBe(false);

0 commit comments

Comments
 (0)