mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-19 14:15:30 +00:00
fix(secrets): resolve web tool SecretRefs atomically at runtime
This commit is contained in:
@@ -206,6 +206,119 @@ describe("resolveCommandSecretRefsViaGateway", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to local resolution for web search SecretRefs when gateway is unavailable", async () => {
|
||||
const envKey = "WEB_SEARCH_GEMINI_API_KEY_LOCAL_FALLBACK";
|
||||
const priorValue = process.env[envKey];
|
||||
process.env[envKey] = "gemini-local-fallback-key";
|
||||
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
|
||||
|
||||
try {
|
||||
const result = await resolveCommandSecretRefsViaGateway({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "gemini",
|
||||
gemini: {
|
||||
apiKey: { source: "env", provider: "default", id: envKey },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
commandName: "agent",
|
||||
targetIds: new Set(["tools.web.search.gemini.apiKey"]),
|
||||
});
|
||||
|
||||
expect(result.resolvedConfig.tools?.web?.search?.gemini?.apiKey).toBe(
|
||||
"gemini-local-fallback-key",
|
||||
);
|
||||
expect(result.targetStatesByPath["tools.web.search.gemini.apiKey"]).toBe("resolved_local");
|
||||
expect(
|
||||
result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.diagnostics.some((entry) => entry.includes("resolved command secrets locally")),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
if (priorValue === undefined) {
|
||||
delete process.env[envKey];
|
||||
} else {
|
||||
process.env[envKey] = priorValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to local resolution for Firecrawl SecretRefs when gateway is unavailable", async () => {
|
||||
const envKey = "WEB_FETCH_FIRECRAWL_API_KEY_LOCAL_FALLBACK";
|
||||
const priorValue = process.env[envKey];
|
||||
process.env[envKey] = "firecrawl-local-fallback-key";
|
||||
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
|
||||
|
||||
try {
|
||||
const result = await resolveCommandSecretRefsViaGateway({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
firecrawl: {
|
||||
apiKey: { source: "env", provider: "default", id: envKey },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
commandName: "agent",
|
||||
targetIds: new Set(["tools.web.fetch.firecrawl.apiKey"]),
|
||||
});
|
||||
|
||||
expect(result.resolvedConfig.tools?.web?.fetch?.firecrawl?.apiKey).toBe(
|
||||
"firecrawl-local-fallback-key",
|
||||
);
|
||||
expect(result.targetStatesByPath["tools.web.fetch.firecrawl.apiKey"]).toBe("resolved_local");
|
||||
expect(
|
||||
result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.diagnostics.some((entry) => entry.includes("resolved command secrets locally")),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
if (priorValue === undefined) {
|
||||
delete process.env[envKey];
|
||||
} else {
|
||||
process.env[envKey] = priorValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("marks web SecretRefs inactive when the web surface is disabled during local fallback", async () => {
|
||||
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
|
||||
const result = await resolveCommandSecretRefsViaGateway({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: false,
|
||||
gemini: {
|
||||
apiKey: { source: "env", provider: "default", id: "WEB_SEARCH_DISABLED_KEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
commandName: "agent",
|
||||
targetIds: new Set(["tools.web.search.gemini.apiKey"]),
|
||||
});
|
||||
|
||||
expect(result.hadUnresolvedTargets).toBe(false);
|
||||
expect(result.targetStatesByPath["tools.web.search.gemini.apiKey"]).toBe("inactive_surface");
|
||||
expect(
|
||||
result.diagnostics.some((entry) =>
|
||||
entry.includes("tools.web.search.gemini.apiKey: tools.web.search is disabled."),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a version-skew hint when gateway does not support secrets.resolve", async () => {
|
||||
const envKey = "TALK_API_KEY_UNSUPPORTED";
|
||||
callGateway.mockRejectedValueOnce(new Error("unknown method: secrets.resolve"));
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getPath, setPathExistingStrict } from "../secrets/path-utils.js";
|
||||
import { resolveSecretRefValue } from "../secrets/resolve.js";
|
||||
import { collectConfigAssignments } from "../secrets/runtime-config-collectors.js";
|
||||
import { createResolverContext } from "../secrets/runtime-shared.js";
|
||||
import { resolveRuntimeWebTools } from "../secrets/runtime-web-tools.js";
|
||||
import { assertExpectedResolvedSecretValue } from "../secrets/secret-value.js";
|
||||
import { describeUnknownError } from "../secrets/shared.js";
|
||||
import {
|
||||
@@ -44,6 +45,15 @@ type GatewaySecretsResolveResult = {
|
||||
inactiveRefPaths?: string[];
|
||||
};
|
||||
|
||||
const WEB_RUNTIME_SECRET_TARGET_ID_PREFIXES = [
|
||||
"tools.web.search",
|
||||
"tools.web.fetch.firecrawl",
|
||||
] as const;
|
||||
const WEB_RUNTIME_SECRET_PATH_PREFIXES = [
|
||||
"tools.web.search.",
|
||||
"tools.web.fetch.firecrawl.",
|
||||
] as const;
|
||||
|
||||
function dedupeDiagnostics(entries: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
@@ -58,6 +68,30 @@ function dedupeDiagnostics(entries: readonly string[]): string[] {
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function targetsRuntimeWebPath(path: string): boolean {
|
||||
return WEB_RUNTIME_SECRET_PATH_PREFIXES.some((prefix) => path.startsWith(prefix));
|
||||
}
|
||||
|
||||
function targetsRuntimeWebResolution(params: {
|
||||
targetIds: ReadonlySet<string>;
|
||||
allowedPaths?: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
if (params.allowedPaths) {
|
||||
for (const path of params.allowedPaths) {
|
||||
if (targetsRuntimeWebPath(path)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
for (const targetId of params.targetIds) {
|
||||
if (WEB_RUNTIME_SECRET_TARGET_ID_PREFIXES.some((prefix) => targetId.startsWith(prefix))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectConfiguredTargetRefPaths(params: {
|
||||
config: OpenClawConfig;
|
||||
targetIds: Set<string>;
|
||||
@@ -193,17 +227,40 @@ async function resolveCommandSecretRefsLocally(params: {
|
||||
sourceConfig,
|
||||
env: process.env,
|
||||
});
|
||||
const localResolutionDiagnostics: string[] = [];
|
||||
collectConfigAssignments({
|
||||
config: structuredClone(params.config),
|
||||
context,
|
||||
});
|
||||
if (
|
||||
targetsRuntimeWebResolution({ targetIds: params.targetIds, allowedPaths: params.allowedPaths })
|
||||
) {
|
||||
try {
|
||||
await resolveRuntimeWebTools({
|
||||
sourceConfig,
|
||||
resolvedConfig,
|
||||
context,
|
||||
});
|
||||
} catch (error) {
|
||||
if (params.mode === "strict") {
|
||||
throw error;
|
||||
}
|
||||
localResolutionDiagnostics.push(
|
||||
`${params.commandName}: failed to resolve web tool secrets locally (${describeUnknownError(error)}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const inactiveRefPaths = new Set(
|
||||
context.warnings
|
||||
.filter((warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE")
|
||||
.filter((warning) => !params.allowedPaths || params.allowedPaths.has(warning.path))
|
||||
.map((warning) => warning.path),
|
||||
);
|
||||
const inactiveWarningDiagnostics = context.warnings
|
||||
.filter((warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE")
|
||||
.filter((warning) => !params.allowedPaths || params.allowedPaths.has(warning.path))
|
||||
.map((warning) => warning.message);
|
||||
const activePaths = new Set(context.assignments.map((assignment) => assignment.path));
|
||||
const localResolutionDiagnostics: string[] = [];
|
||||
for (const target of discoverConfigSecretTargetsByIds(sourceConfig, params.targetIds)) {
|
||||
if (params.allowedPaths && !params.allowedPaths.has(target.path)) {
|
||||
continue;
|
||||
@@ -244,6 +301,7 @@ async function resolveCommandSecretRefsLocally(params: {
|
||||
resolvedConfig,
|
||||
diagnostics: dedupeDiagnostics([
|
||||
...params.preflightDiagnostics,
|
||||
...inactiveWarningDiagnostics,
|
||||
...filterInactiveSurfaceDiagnostics({
|
||||
diagnostics: analyzed.diagnostics,
|
||||
inactiveRefPaths,
|
||||
|
||||
@@ -9,6 +9,7 @@ describe("command secret target ids", () => {
|
||||
const ids = getAgentRuntimeCommandSecretTargetIds();
|
||||
expect(ids.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true);
|
||||
expect(ids.has("agents.list[].memorySearch.remote.apiKey")).toBe(true);
|
||||
expect(ids.has("tools.web.fetch.firecrawl.apiKey")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps memory command target set focused on memorySearch remote credentials", () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ const COMMAND_SECRET_TARGETS = {
|
||||
"skills.entries.",
|
||||
"messages.tts.",
|
||||
"tools.web.search",
|
||||
"tools.web.fetch.firecrawl.",
|
||||
]),
|
||||
status: idsByPrefix([
|
||||
"channels.",
|
||||
|
||||
Reference in New Issue
Block a user