SecretRef: harden custom/provider secret persistence and reuse (#42554)

* Models: gate custom provider keys by usable secret semantics

* Config: project runtime writes onto source snapshot

* Models: prevent stale apiKey preservation for marker-managed providers

* Runner: strip SecretRef marker headers from resolved models

* Secrets: scan active agent models.json path in audit

* Config: guard runtime-source projection for unrelated configs

* Extensions: fix onboarding type errors in CI

* Tests: align setup helper account-enabled expectation

* Secrets audit: harden models.json file reads

* fix: harden SecretRef custom/provider secret persistence (#42554) (thanks @joshavant)
This commit is contained in:
Josh Avant
2026-03-10 18:46:47 -05:00
committed by Peter Steinberger
parent 20237358d9
commit 36d2ae2a22
40 changed files with 651 additions and 73 deletions

View File

@@ -5,6 +5,7 @@ export {
createConfigIO,
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
projectConfigOntoRuntimeSourceSnapshot,
loadConfig,
readBestEffortConfig,
parseConfigJson5,

View File

@@ -7,6 +7,7 @@ import {
clearRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
loadConfig,
projectConfigOntoRuntimeSourceSnapshot,
setRuntimeConfigSnapshotRefreshHandler,
setRuntimeConfigSnapshot,
writeConfigFile,
@@ -61,6 +62,46 @@ describe("runtime config snapshot writes", () => {
});
});
it("skips source projection for non-runtime-derived configs", async () => {
await withTempHome("openclaw-config-runtime-projection-shape-", async () => {
const sourceConfig: OpenClawConfig = {
...createSourceConfig(),
gateway: {
auth: {
mode: "token",
},
},
};
const runtimeConfig: OpenClawConfig = {
...createRuntimeConfig(),
gateway: {
auth: {
mode: "token",
},
},
};
const independentConfig: OpenClawConfig = {
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "sk-independent-config", // pragma: allowlist secret
models: [],
},
},
},
};
try {
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
const projected = projectConfigOntoRuntimeSourceSnapshot(independentConfig);
expect(projected).toBe(independentConfig);
} finally {
resetRuntimeConfigState();
}
});
});
it("clears runtime source snapshot when runtime snapshot is cleared", async () => {
const sourceConfig = createSourceConfig();
const runtimeConfig = createRuntimeConfig();

View File

@@ -1374,6 +1374,58 @@ export function getRuntimeConfigSourceSnapshot(): OpenClawConfig | null {
return runtimeConfigSourceSnapshot;
}
function isCompatibleTopLevelRuntimeProjectionShape(params: {
runtimeSnapshot: OpenClawConfig;
candidate: OpenClawConfig;
}): boolean {
const runtime = params.runtimeSnapshot as Record<string, unknown>;
const candidate = params.candidate as Record<string, unknown>;
for (const key of Object.keys(runtime)) {
if (!Object.hasOwn(candidate, key)) {
return false;
}
const runtimeValue = runtime[key];
const candidateValue = candidate[key];
const runtimeType = Array.isArray(runtimeValue)
? "array"
: runtimeValue === null
? "null"
: typeof runtimeValue;
const candidateType = Array.isArray(candidateValue)
? "array"
: candidateValue === null
? "null"
: typeof candidateValue;
if (runtimeType !== candidateType) {
return false;
}
}
return true;
}
export function projectConfigOntoRuntimeSourceSnapshot(config: OpenClawConfig): OpenClawConfig {
if (!runtimeConfigSnapshot || !runtimeConfigSourceSnapshot) {
return config;
}
if (config === runtimeConfigSnapshot) {
return runtimeConfigSourceSnapshot;
}
// This projection expects callers to pass config objects derived from the
// active runtime snapshot (for example shallow/deep clones with targeted edits).
// For structurally unrelated configs, skip projection to avoid accidental
// merge-patch deletions or reintroducing resolved values into source refs.
if (
!isCompatibleTopLevelRuntimeProjectionShape({
runtimeSnapshot: runtimeConfigSnapshot,
candidate: config,
})
) {
return config;
}
const runtimePatch = createMergePatch(runtimeConfigSnapshot, config);
return coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot, runtimePatch));
}
export function setRuntimeConfigSnapshotRefreshHandler(
refreshHandler: RuntimeConfigSnapshotRefreshHandler | null,
): void {