fix(config): refresh runtime snapshot from disk after write. Fixes #37175 (#37313)

Merged via squash.

Prepared head SHA: 69e1861abf
Co-authored-by: bbblending <122739024+bbblending@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
bbblending
2026-03-09 07:49:15 +08:00
committed by GitHub
parent 362248e559
commit 4ff4ed7ec9
7 changed files with 516 additions and 19 deletions

View File

@@ -3,10 +3,12 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { ensureAuthProfileStore, type AuthProfileStore } from "../agents/auth-profiles.js";
import { loadConfig, type OpenClawConfig } from "../config/config.js";
import { loadConfig, type OpenClawConfig, writeConfigFile } from "../config/config.js";
import { withTempHome } from "../config/home-env.test-harness.js";
import {
activateSecretsRuntimeSnapshot,
clearSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshot,
prepareSecretsRuntimeSnapshot,
} from "./runtime.js";
@@ -527,6 +529,248 @@ describe("secrets runtime snapshot", () => {
});
});
it("keeps active secrets runtime snapshots resolved after config writes", async () => {
await withTempHome("openclaw-secrets-runtime-write-", async (home) => {
const configDir = path.join(home, ".openclaw");
const secretFile = path.join(configDir, "secrets.json");
const agentDir = path.join(configDir, "agents", "main", "agent");
const authStorePath = path.join(agentDir, "auth-profiles.json");
await fs.mkdir(agentDir, { recursive: true });
await fs.chmod(configDir, 0o700).catch(() => {
// best-effort on tmp dirs that already have secure perms
});
await fs.writeFile(
secretFile,
`${JSON.stringify({ providers: { openai: { apiKey: "sk-file-runtime" } } }, null, 2)}\n`, // pragma: allowlist secret
{ encoding: "utf8", mode: 0o600 },
);
await fs.writeFile(
authStorePath,
`${JSON.stringify(
{
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
keyRef: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
},
},
},
null,
2,
)}\n`,
{ encoding: "utf8", mode: 0o600 },
);
const prepared = await prepareSecretsRuntimeSnapshot({
config: asConfig({
secrets: {
providers: {
default: { source: "file", path: secretFile, mode: "json" },
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
models: [],
},
},
},
}),
agentDirs: [agentDir],
});
activateSecretsRuntimeSnapshot(prepared);
expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-file-runtime");
expect(ensureAuthProfileStore(agentDir).profiles["openai:default"]).toMatchObject({
type: "api_key",
key: "sk-file-runtime",
});
await writeConfigFile({
...loadConfig(),
gateway: { auth: { mode: "token" } },
});
expect(loadConfig().gateway?.auth).toEqual({ mode: "token" });
expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-file-runtime");
expect(ensureAuthProfileStore(agentDir).profiles["openai:default"]).toMatchObject({
type: "api_key",
key: "sk-file-runtime",
});
});
});
it("clears active secrets runtime state and throws when refresh fails after a write", async () => {
await withTempHome("openclaw-secrets-runtime-refresh-fail-", async (home) => {
const configDir = path.join(home, ".openclaw");
const secretFile = path.join(configDir, "secrets.json");
const agentDir = path.join(configDir, "agents", "main", "agent");
const authStorePath = path.join(agentDir, "auth-profiles.json");
await fs.mkdir(agentDir, { recursive: true });
await fs.chmod(configDir, 0o700).catch(() => {
// best-effort on tmp dirs that already have secure perms
});
await fs.writeFile(
secretFile,
`${JSON.stringify({ providers: { openai: { apiKey: "sk-file-runtime" } } }, null, 2)}\n`,
{ encoding: "utf8", mode: 0o600 },
);
await fs.writeFile(
authStorePath,
`${JSON.stringify(
{
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
keyRef: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
},
},
},
null,
2,
)}\n`,
{ encoding: "utf8", mode: 0o600 },
);
let loadAuthStoreCalls = 0;
const loadAuthStore = () => {
loadAuthStoreCalls += 1;
if (loadAuthStoreCalls > 1) {
throw new Error("simulated secrets runtime refresh failure");
}
return loadAuthStoreWithProfiles({
"openai:default": {
type: "api_key",
provider: "openai",
keyRef: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
},
});
};
const prepared = await prepareSecretsRuntimeSnapshot({
config: asConfig({
secrets: {
providers: {
default: { source: "file", path: secretFile, mode: "json" },
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
models: [],
},
},
},
}),
agentDirs: [agentDir],
loadAuthStore,
});
activateSecretsRuntimeSnapshot(prepared);
await expect(
writeConfigFile({
...loadConfig(),
gateway: { auth: { mode: "token" } },
}),
).rejects.toThrow(
/runtime snapshot refresh failed: simulated secrets runtime refresh failure/i,
);
expect(getActiveSecretsRuntimeSnapshot()).toBeNull();
expect(loadConfig().gateway?.auth).toEqual({ mode: "token" });
expect(loadConfig().models?.providers?.openai?.apiKey).toEqual({
source: "file",
provider: "default",
id: "/providers/openai/apiKey",
});
const persistedStore = ensureAuthProfileStore(agentDir).profiles["openai:default"];
expect(persistedStore).toMatchObject({
type: "api_key",
keyRef: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
});
expect("key" in persistedStore ? persistedStore.key : undefined).toBeUndefined();
});
});
it("recomputes config-derived agent dirs when refreshing active secrets runtime snapshots", async () => {
await withTempHome("openclaw-secrets-runtime-agent-dirs-", async (home) => {
const mainAgentDir = path.join(home, ".openclaw", "agents", "main", "agent");
const opsAgentDir = path.join(home, ".openclaw", "agents", "ops", "agent");
await fs.mkdir(mainAgentDir, { recursive: true });
await fs.mkdir(opsAgentDir, { recursive: true });
await fs.writeFile(
path.join(mainAgentDir, "auth-profiles.json"),
`${JSON.stringify(
{
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
},
},
null,
2,
)}\n`,
{ encoding: "utf8", mode: 0o600 },
);
await fs.writeFile(
path.join(opsAgentDir, "auth-profiles.json"),
`${JSON.stringify(
{
version: 1,
profiles: {
"anthropic:ops": {
type: "api_key",
provider: "anthropic",
keyRef: { source: "env", provider: "default", id: "ANTHROPIC_API_KEY" },
},
},
},
null,
2,
)}\n`,
{ encoding: "utf8", mode: 0o600 },
);
const prepared = await prepareSecretsRuntimeSnapshot({
config: asConfig({}),
env: {
OPENAI_API_KEY: "sk-main-runtime", // pragma: allowlist secret
ANTHROPIC_API_KEY: "sk-ops-runtime", // pragma: allowlist secret
},
});
activateSecretsRuntimeSnapshot(prepared);
expect(ensureAuthProfileStore(opsAgentDir).profiles["anthropic:ops"]).toBeUndefined();
await writeConfigFile({
agents: {
list: [{ id: "ops", agentDir: opsAgentDir }],
},
});
expect(ensureAuthProfileStore(opsAgentDir).profiles["anthropic:ops"]).toMatchObject({
type: "api_key",
key: "sk-ops-runtime",
keyRef: { source: "env", provider: "default", id: "ANTHROPIC_API_KEY" },
});
});
});
it("skips inactive-surface refs and emits diagnostics", async () => {
const config = asConfig({
agents: {

View File

@@ -8,6 +8,7 @@ import {
} from "../agents/auth-profiles.js";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshotRefreshHandler,
setRuntimeConfigSnapshot,
type OpenClawConfig,
} from "../config/config.js";
@@ -34,7 +35,18 @@ export type PreparedSecretsRuntimeSnapshot = {
warnings: SecretResolverWarning[];
};
type SecretsRuntimeRefreshContext = {
env: Record<string, string | undefined>;
explicitAgentDirs: string[] | null;
loadAuthStore: (agentDir?: string) => AuthProfileStore;
};
let activeSnapshot: PreparedSecretsRuntimeSnapshot | null = null;
let activeRefreshContext: SecretsRuntimeRefreshContext | null = null;
const preparedSnapshotRefreshContext = new WeakMap<
PreparedSecretsRuntimeSnapshot,
SecretsRuntimeRefreshContext
>();
function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecretsRuntimeSnapshot {
return {
@@ -48,6 +60,22 @@ function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecret
};
}
function cloneRefreshContext(context: SecretsRuntimeRefreshContext): SecretsRuntimeRefreshContext {
return {
env: { ...context.env },
explicitAgentDirs: context.explicitAgentDirs ? [...context.explicitAgentDirs] : null,
loadAuthStore: context.loadAuthStore,
};
}
function clearActiveSecretsRuntimeState(): void {
activeSnapshot = null;
activeRefreshContext = null;
setRuntimeConfigSnapshotRefreshHandler(null);
clearRuntimeConfigSnapshot();
clearRuntimeAuthProfileStoreSnapshots();
}
function collectCandidateAgentDirs(config: OpenClawConfig): string[] {
const dirs = new Set<string>();
dirs.add(resolveUserPath(resolveOpenClawAgentDir()));
@@ -57,6 +85,17 @@ function collectCandidateAgentDirs(config: OpenClawConfig): string[] {
return [...dirs];
}
function resolveRefreshAgentDirs(
config: OpenClawConfig,
context: SecretsRuntimeRefreshContext,
): string[] {
const configDerived = collectCandidateAgentDirs(config);
if (!context.explicitAgentDirs || context.explicitAgentDirs.length === 0) {
return configDerived;
}
return [...new Set([...context.explicitAgentDirs, ...configDerived])];
}
export async function prepareSecretsRuntimeSnapshot(params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
@@ -104,23 +143,61 @@ export async function prepareSecretsRuntimeSnapshot(params: {
});
}
return {
const snapshot = {
sourceConfig,
config: resolvedConfig,
authStores,
warnings: context.warnings,
};
preparedSnapshotRefreshContext.set(snapshot, {
env: { ...(params.env ?? process.env) } as Record<string, string | undefined>,
explicitAgentDirs: params.agentDirs?.length ? [...candidateDirs] : null,
loadAuthStore,
});
return snapshot;
}
export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): void {
const next = cloneSnapshot(snapshot);
const refreshContext =
preparedSnapshotRefreshContext.get(snapshot) ??
activeRefreshContext ??
({
env: { ...process.env } as Record<string, string | undefined>,
explicitAgentDirs: null,
loadAuthStore: loadAuthProfileStoreForSecretsRuntime,
} satisfies SecretsRuntimeRefreshContext);
setRuntimeConfigSnapshot(next.config, next.sourceConfig);
replaceRuntimeAuthProfileStoreSnapshots(next.authStores);
activeSnapshot = next;
activeRefreshContext = cloneRefreshContext(refreshContext);
setRuntimeConfigSnapshotRefreshHandler({
refresh: async ({ sourceConfig }) => {
if (!activeSnapshot || !activeRefreshContext) {
return false;
}
const refreshed = await prepareSecretsRuntimeSnapshot({
config: sourceConfig,
env: activeRefreshContext.env,
agentDirs: resolveRefreshAgentDirs(sourceConfig, activeRefreshContext),
loadAuthStore: activeRefreshContext.loadAuthStore,
});
activateSecretsRuntimeSnapshot(refreshed);
return true;
},
clearOnRefreshFailure: clearActiveSecretsRuntimeState,
});
}
export function getActiveSecretsRuntimeSnapshot(): PreparedSecretsRuntimeSnapshot | null {
return activeSnapshot ? cloneSnapshot(activeSnapshot) : null;
if (!activeSnapshot) {
return null;
}
const snapshot = cloneSnapshot(activeSnapshot);
if (activeRefreshContext) {
preparedSnapshotRefreshContext.set(snapshot, cloneRefreshContext(activeRefreshContext));
}
return snapshot;
}
export function resolveCommandSecretsFromActiveRuntimeSnapshot(params: {
@@ -155,7 +232,5 @@ export function resolveCommandSecretsFromActiveRuntimeSnapshot(params: {
}
export function clearSecretsRuntimeSnapshot(): void {
activeSnapshot = null;
clearRuntimeConfigSnapshot();
clearRuntimeAuthProfileStoreSnapshots();
clearActiveSecretsRuntimeState();
}