fix(reply): keep resolved secret config stable (#64249)

Merged via squash.

Prepared head SHA: 973f863d8c
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-04-10 14:56:30 +02:00
committed by GitHub
parent af9272606f
commit 383ea34efe
8 changed files with 211 additions and 8 deletions

View File

@@ -97,6 +97,7 @@ Docs: https://docs.openclaw.ai
- Claude CLI/skills: pass eligible OpenClaw skills into CLI runs, including native Claude Code skill resolution via a temporary plugin plus per-run skill env/API key injection. (#62686, #62723) Thanks @zomars.
- Heartbeat: ignore doc-only Markdown fence markers in the default `HEARTBEAT.md` template so comment-only heartbeat scaffolds skip API calls again. (#63434) Thanks @ravyg.
- Control UI/BTW: render `/btw` side results as dismissible ephemeral cards in the browser, send `/btw` immediately during active runs, and clear stale BTW cards on reset flows so webchat matches the intended detached side-question behavior. (#64290) Thanks @ngutman.
- Reply/skills: keep resolved skill and memory secret config stable through embedded reply runs so raw SecretRefs in secondary skill settings no longer crash replies when the gateway already has the live env. (#64249) Thanks @mbelinky.
## 2026.4.9

View File

@@ -1,6 +1,8 @@
import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
resolveSecretInputRef,
} from "../../../../src/config/types.secrets.js";
export function hasConfiguredMemorySecretInput(value: unknown): boolean {
@@ -11,6 +13,13 @@ export function resolveMemorySecretInputString(params: {
value: unknown;
path: string;
}): string | undefined {
const { ref } = resolveSecretInputRef({ value: params.value });
if (ref?.source === "env") {
const envValue = normalizeSecretInputString(process.env[ref.id]);
if (envValue) {
return envValue;
}
}
return normalizeResolvedSecretInputString({
value: params.value,
path: params.path,

View File

@@ -97,6 +97,46 @@ describe("resolveEmbeddedRunSkillEntries", () => {
});
});
it("prefers caller config when the active runtime snapshot still contains raw skill SecretRefs", () => {
const sourceConfig: OpenClawConfig = {
skills: {
entries: {
diffs: {
apiKey: {
source: "file",
provider: "default",
id: "/skills/entries/diffs/apiKey",
},
},
},
},
};
const runtimeConfig: OpenClawConfig = structuredClone(sourceConfig);
const callerConfig: OpenClawConfig = {
skills: {
entries: {
diffs: {
apiKey: "resolved-key",
},
},
},
};
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
resolveEmbeddedRunSkillEntries({
workspaceDir: "/tmp/workspace",
config: callerConfig,
skillsSnapshot: {
prompt: "skills prompt",
skills: [],
},
});
expect(loadWorkspaceSkillEntriesSpy).toHaveBeenCalledWith("/tmp/workspace", {
config: callerConfig,
});
});
it("skips skill entry loading when resolved snapshot skills are present", () => {
const snapshot: SkillSnapshot = {
prompt: "skills prompt",

View File

@@ -470,6 +470,85 @@ describe("applySkillEnvOverrides", () => {
});
});
it("prefers resolved caller skill config when the active runtime snapshot is still raw", async () => {
const workspaceDir = await makeWorkspace();
await writeEnvSkill(workspaceDir);
const entries = loadWorkspaceSkillEntries(workspaceDir, resolveTestSkillDirs(workspaceDir));
const sourceConfig: OpenClawConfig = {
skills: {
entries: {
"env-skill": {
apiKey: {
source: "file",
provider: "default",
id: "/skills/entries/env-skill/apiKey",
},
},
},
},
};
const callerConfig: OpenClawConfig = {
skills: {
entries: {
"env-skill": {
apiKey: "resolved-key",
},
},
},
};
setRuntimeConfigSnapshot(sourceConfig, sourceConfig);
withClearedEnv(["ENV_KEY"], () => {
const restore = applySkillEnvOverrides({
skills: entries,
config: callerConfig,
});
try {
expect(process.env.ENV_KEY).toBe("resolved-key");
} finally {
restore();
expect(process.env.ENV_KEY).toBeUndefined();
}
});
});
it("does not resolve raw skill apiKey refs when the host already provides primaryEnv", async () => {
const workspaceDir = await makeWorkspace();
await writeEnvSkill(workspaceDir);
const entries = loadWorkspaceSkillEntries(workspaceDir, resolveTestSkillDirs(workspaceDir));
withClearedEnv(["ENV_KEY"], () => {
process.env.ENV_KEY = "host-key";
const restore = applySkillEnvOverrides({
skills: entries,
config: {
skills: {
entries: {
"env-skill": {
apiKey: {
source: "env",
provider: "default",
id: "OPENAI_API_KEY",
},
},
},
},
},
});
try {
expect(process.env.ENV_KEY).toBe("host-key");
} finally {
restore();
expect(process.env.ENV_KEY).toBe("host-key");
delete process.env.ENV_KEY;
}
});
});
it("blocks unsafe env overrides but allows declared secrets", async () => {
const workspaceDir = await makeWorkspace();
const skillDir = path.join(workspaceDir, "skills", "unsafe-env-skill");

View File

@@ -172,17 +172,17 @@ function applySkillConfigEnvOverrides(params: {
}
}
const resolvedApiKey =
normalizeResolvedSecretInputString({
value: skillConfig.apiKey,
path: `skills.entries.${skillKey}.apiKey`,
}) ?? "";
const canInjectPrimaryEnv =
normalizedPrimaryEnv &&
(process.env[normalizedPrimaryEnv] === undefined ||
activeSkillEnvEntries.has(normalizedPrimaryEnv));
if (canInjectPrimaryEnv && resolvedApiKey) {
if (!pendingOverrides[normalizedPrimaryEnv]) {
if (canInjectPrimaryEnv && !pendingOverrides[normalizedPrimaryEnv]) {
const resolvedApiKey =
normalizeResolvedSecretInputString({
value: skillConfig.apiKey,
path: `skills.entries.${skillKey}.apiKey`,
}) ?? "";
if (resolvedApiKey) {
pendingOverrides[normalizedPrimaryEnv] = resolvedApiKey;
}
}

View File

@@ -1,5 +1,34 @@
import { getRuntimeConfigSnapshot, type OpenClawConfig } from "../../config/config.js";
import { coerceSecretRef } from "../../config/types.secrets.js";
function hasConfiguredSkillApiKeyRef(config?: OpenClawConfig): boolean {
const entries = config?.skills?.entries;
if (!entries || typeof entries !== "object") {
return false;
}
for (const skillConfig of Object.values(entries)) {
if (!skillConfig || typeof skillConfig !== "object") {
continue;
}
if (coerceSecretRef(skillConfig.apiKey) !== null) {
return true;
}
}
return false;
}
export function resolveSkillRuntimeConfig(config?: OpenClawConfig): OpenClawConfig | undefined {
return getRuntimeConfigSnapshot() ?? config;
const runtimeConfig = getRuntimeConfigSnapshot();
if (!runtimeConfig) {
return config;
}
if (!config) {
return runtimeConfig;
}
const runtimeHasRawSkillSecretRefs = hasConfiguredSkillApiKeyRef(runtimeConfig);
const configHasRawSkillSecretRefs = hasConfiguredSkillApiKeyRef(config);
if (runtimeHasRawSkillSecretRefs && !configHasRawSkillSecretRefs) {
return config;
}
return runtimeConfig;
}

View File

@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it } from "vitest";
import { resolveMemorySecretInputString } from "./secret-input.js";
describe("resolveMemorySecretInputString", () => {
afterEach(() => {
delete process.env.GOOGLE_API_KEY;
});
it("uses the daemon env for env-backed SecretRefs", () => {
process.env.GOOGLE_API_KEY = "resolved-key";
expect(
resolveMemorySecretInputString({
value: {
source: "env",
provider: "default",
id: "GOOGLE_API_KEY",
},
path: "agents.main.memorySearch.remote.apiKey",
}),
).toBe("resolved-key");
});
it("still throws when an env-backed SecretRef is missing from the daemon env", () => {
expect(() =>
resolveMemorySecretInputString({
value: {
source: "env",
provider: "default",
id: "GOOGLE_API_KEY",
},
path: "agents.main.memorySearch.remote.apiKey",
}),
).toThrow(/unresolved SecretRef/);
});
});

View File

@@ -1,6 +1,8 @@
import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
resolveSecretInputRef,
} from "../../config/types.secrets.js";
export function hasConfiguredMemorySecretInput(value: unknown): boolean {
@@ -11,6 +13,13 @@ export function resolveMemorySecretInputString(params: {
value: unknown;
path: string;
}): string | undefined {
const { ref } = resolveSecretInputRef({ value: params.value });
if (ref?.source === "env") {
const envValue = normalizeSecretInputString(process.env[ref.id]);
if (envValue) {
return envValue;
}
}
return normalizeResolvedSecretInputString({
value: params.value,
path: params.path,