fix(secrets): resolve web tool SecretRefs atomically at runtime

This commit is contained in:
Josh Avant
2026-03-09 22:57:03 -05:00
committed by GitHub
parent 93c44e3dad
commit f0eb67923c
28 changed files with 2059 additions and 112 deletions

View File

@@ -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"));

View File

@@ -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,

View File

@@ -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", () => {

View File

@@ -23,6 +23,7 @@ const COMMAND_SECRET_TARGETS = {
"skills.entries.",
"messages.tts.",
"tools.web.search",
"tools.web.fetch.firecrawl.",
]),
status: idsByPrefix([
"channels.",