mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 04:50:44 +00:00
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:
@@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs.
|
- Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs.
|
||||||
- TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc.
|
- TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc.
|
||||||
- Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232)
|
- Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232)
|
||||||
|
- Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending.
|
||||||
|
|
||||||
## 2026.3.7
|
## 2026.3.7
|
||||||
|
|
||||||
|
|||||||
@@ -36,16 +36,17 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"])
|
|||||||
const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]);
|
const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]);
|
||||||
const resolveGatewayPort = vi.fn(() => 18789);
|
const resolveGatewayPort = vi.fn(() => 18789);
|
||||||
const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []);
|
const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []);
|
||||||
const probeGateway = vi.fn<
|
const probeGateway =
|
||||||
(opts: {
|
vi.fn<
|
||||||
url: string;
|
(opts: {
|
||||||
auth?: { token?: string; password?: string };
|
url: string;
|
||||||
timeoutMs: number;
|
auth?: { token?: string; password?: string };
|
||||||
}) => Promise<{
|
timeoutMs: number;
|
||||||
ok: boolean;
|
}) => Promise<{
|
||||||
configSnapshot: unknown;
|
ok: boolean;
|
||||||
}>
|
configSnapshot: unknown;
|
||||||
>();
|
}>
|
||||||
|
>();
|
||||||
const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true);
|
const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true);
|
||||||
const loadConfig = vi.fn(() => ({}));
|
const loadConfig = vi.fn(() => ({}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export {
|
export {
|
||||||
clearConfigCache,
|
clearConfigCache,
|
||||||
|
ConfigRuntimeRefreshError,
|
||||||
clearRuntimeConfigSnapshot,
|
clearRuntimeConfigSnapshot,
|
||||||
createConfigIO,
|
createConfigIO,
|
||||||
getRuntimeConfigSnapshot,
|
getRuntimeConfigSnapshot,
|
||||||
@@ -10,6 +11,7 @@ export {
|
|||||||
readConfigFileSnapshot,
|
readConfigFileSnapshot,
|
||||||
readConfigFileSnapshotForWrite,
|
readConfigFileSnapshotForWrite,
|
||||||
resolveConfigSnapshotHash,
|
resolveConfigSnapshotHash,
|
||||||
|
setRuntimeConfigSnapshotRefreshHandler,
|
||||||
setRuntimeConfigSnapshot,
|
setRuntimeConfigSnapshot,
|
||||||
writeConfigFile,
|
writeConfigFile,
|
||||||
} from "./io.js";
|
} from "./io.js";
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
clearRuntimeConfigSnapshot,
|
clearRuntimeConfigSnapshot,
|
||||||
getRuntimeConfigSourceSnapshot,
|
getRuntimeConfigSourceSnapshot,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
|
setRuntimeConfigSnapshotRefreshHandler,
|
||||||
setRuntimeConfigSnapshot,
|
setRuntimeConfigSnapshot,
|
||||||
writeConfigFile,
|
writeConfigFile,
|
||||||
} from "./io.js";
|
} from "./io.js";
|
||||||
@@ -41,6 +42,7 @@ function createRuntimeConfig(): OpenClawConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetRuntimeConfigState(): void {
|
function resetRuntimeConfigState(): void {
|
||||||
|
setRuntimeConfigSnapshotRefreshHandler(null);
|
||||||
clearRuntimeConfigSnapshot();
|
clearRuntimeConfigSnapshot();
|
||||||
clearConfigCache();
|
clearConfigCache();
|
||||||
}
|
}
|
||||||
@@ -96,4 +98,117 @@ describe("runtime config snapshot writes", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refreshes the runtime snapshot after writes so follow-up reads see persisted changes", async () => {
|
||||||
|
await withTempHome("openclaw-config-runtime-write-refresh-", async (home) => {
|
||||||
|
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||||
|
const sourceConfig: OpenClawConfig = {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
openai: {
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const runtimeConfig: OpenClawConfig = {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
openai: {
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
apiKey: "sk-runtime-resolved", // pragma: allowlist secret
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const nextRuntimeConfig: OpenClawConfig = {
|
||||||
|
...runtimeConfig,
|
||||||
|
gateway: { auth: { mode: "token" as const } },
|
||||||
|
};
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||||
|
await fs.writeFile(configPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf8");
|
||||||
|
|
||||||
|
try {
|
||||||
|
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
||||||
|
expect(loadConfig().gateway?.auth).toBeUndefined();
|
||||||
|
|
||||||
|
await writeConfigFile(nextRuntimeConfig);
|
||||||
|
|
||||||
|
expect(loadConfig().gateway?.auth).toEqual({ mode: "token" });
|
||||||
|
expect(loadConfig().models?.providers?.openai?.apiKey).toBeDefined();
|
||||||
|
|
||||||
|
let persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as {
|
||||||
|
gateway?: { auth?: unknown };
|
||||||
|
models?: { providers?: { openai?: { apiKey?: unknown } } };
|
||||||
|
};
|
||||||
|
expect(persisted.gateway?.auth).toEqual({ mode: "token" });
|
||||||
|
// Post-write secret-ref: apiKey must stay as source ref (not plaintext).
|
||||||
|
expect(persisted.models?.providers?.openai?.apiKey).toEqual({
|
||||||
|
source: "env",
|
||||||
|
provider: "default",
|
||||||
|
id: "OPENAI_API_KEY",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Follow-up write: runtimeConfigSourceSnapshot must be restored so second write
|
||||||
|
// still runs secret-preservation merge-patch and keeps apiKey as ref (not plaintext).
|
||||||
|
await writeConfigFile(loadConfig());
|
||||||
|
persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as {
|
||||||
|
gateway?: { auth?: unknown };
|
||||||
|
models?: { providers?: { openai?: { apiKey?: unknown } } };
|
||||||
|
};
|
||||||
|
expect(persisted.models?.providers?.openai?.apiKey).toEqual({
|
||||||
|
source: "env",
|
||||||
|
provider: "default",
|
||||||
|
id: "OPENAI_API_KEY",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearRuntimeConfigSnapshot();
|
||||||
|
clearConfigCache();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the last-known-good runtime snapshot active while a specialized refresh is pending", async () => {
|
||||||
|
await withTempHome("openclaw-config-runtime-refresh-pending-", async (home) => {
|
||||||
|
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||||
|
const sourceConfig = createSourceConfig();
|
||||||
|
const runtimeConfig = createRuntimeConfig();
|
||||||
|
const nextRuntimeConfig: OpenClawConfig = {
|
||||||
|
...runtimeConfig,
|
||||||
|
gateway: { auth: { mode: "token" as const } },
|
||||||
|
};
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||||
|
await fs.writeFile(configPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf8");
|
||||||
|
|
||||||
|
let releaseRefresh!: () => void;
|
||||||
|
const refreshPending = new Promise<boolean>((resolve) => {
|
||||||
|
releaseRefresh = () => resolve(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
||||||
|
setRuntimeConfigSnapshotRefreshHandler({
|
||||||
|
refresh: async ({ sourceConfig: refreshedSource }) => {
|
||||||
|
expect(refreshedSource.gateway?.auth).toEqual({ mode: "token" });
|
||||||
|
expect(loadConfig().gateway?.auth).toBeUndefined();
|
||||||
|
return await refreshPending;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const writePromise = writeConfigFile(nextRuntimeConfig);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(loadConfig().gateway?.auth).toBeUndefined();
|
||||||
|
releaseRefresh();
|
||||||
|
await writePromise;
|
||||||
|
} finally {
|
||||||
|
resetRuntimeConfigState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -140,6 +140,22 @@ export type ReadConfigFileSnapshotForWriteResult = {
|
|||||||
writeOptions: ConfigWriteOptions;
|
writeOptions: ConfigWriteOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RuntimeConfigSnapshotRefreshParams = {
|
||||||
|
sourceConfig: OpenClawConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeConfigSnapshotRefreshHandler = {
|
||||||
|
refresh: (params: RuntimeConfigSnapshotRefreshParams) => boolean | Promise<boolean>;
|
||||||
|
clearOnRefreshFailure?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ConfigRuntimeRefreshError extends Error {
|
||||||
|
constructor(message: string, options?: { cause?: unknown }) {
|
||||||
|
super(message, options);
|
||||||
|
this.name = "ConfigRuntimeRefreshError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function hashConfigRaw(raw: string | null): string {
|
function hashConfigRaw(raw: string | null): string {
|
||||||
return crypto
|
return crypto
|
||||||
.createHash("sha256")
|
.createHash("sha256")
|
||||||
@@ -1306,6 +1322,7 @@ let configCache: {
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
let runtimeConfigSnapshot: OpenClawConfig | null = null;
|
let runtimeConfigSnapshot: OpenClawConfig | null = null;
|
||||||
let runtimeConfigSourceSnapshot: OpenClawConfig | null = null;
|
let runtimeConfigSourceSnapshot: OpenClawConfig | null = null;
|
||||||
|
let runtimeConfigSnapshotRefreshHandler: RuntimeConfigSnapshotRefreshHandler | null = null;
|
||||||
|
|
||||||
function resolveConfigCacheMs(env: NodeJS.ProcessEnv): number {
|
function resolveConfigCacheMs(env: NodeJS.ProcessEnv): number {
|
||||||
const raw = env.OPENCLAW_CONFIG_CACHE_MS?.trim();
|
const raw = env.OPENCLAW_CONFIG_CACHE_MS?.trim();
|
||||||
@@ -1356,6 +1373,12 @@ export function getRuntimeConfigSourceSnapshot(): OpenClawConfig | null {
|
|||||||
return runtimeConfigSourceSnapshot;
|
return runtimeConfigSourceSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setRuntimeConfigSnapshotRefreshHandler(
|
||||||
|
refreshHandler: RuntimeConfigSnapshotRefreshHandler | null,
|
||||||
|
): void {
|
||||||
|
runtimeConfigSnapshotRefreshHandler = refreshHandler;
|
||||||
|
}
|
||||||
|
|
||||||
export function loadConfig(): OpenClawConfig {
|
export function loadConfig(): OpenClawConfig {
|
||||||
if (runtimeConfigSnapshot) {
|
if (runtimeConfigSnapshot) {
|
||||||
return runtimeConfigSnapshot;
|
return runtimeConfigSnapshot;
|
||||||
@@ -1402,9 +1425,11 @@ export async function writeConfigFile(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const io = createConfigIO();
|
const io = createConfigIO();
|
||||||
let nextCfg = cfg;
|
let nextCfg = cfg;
|
||||||
if (runtimeConfigSnapshot && runtimeConfigSourceSnapshot) {
|
const hadRuntimeSnapshot = Boolean(runtimeConfigSnapshot);
|
||||||
const runtimePatch = createMergePatch(runtimeConfigSnapshot, cfg);
|
const hadBothSnapshots = Boolean(runtimeConfigSnapshot && runtimeConfigSourceSnapshot);
|
||||||
nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot, runtimePatch));
|
if (hadBothSnapshots) {
|
||||||
|
const runtimePatch = createMergePatch(runtimeConfigSnapshot!, cfg);
|
||||||
|
nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot!, runtimePatch));
|
||||||
}
|
}
|
||||||
const sameConfigPath =
|
const sameConfigPath =
|
||||||
options.expectedConfigPath === undefined || options.expectedConfigPath === io.configPath;
|
options.expectedConfigPath === undefined || options.expectedConfigPath === io.configPath;
|
||||||
@@ -1412,4 +1437,38 @@ export async function writeConfigFile(
|
|||||||
envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined,
|
envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined,
|
||||||
unsetPaths: options.unsetPaths,
|
unsetPaths: options.unsetPaths,
|
||||||
});
|
});
|
||||||
|
// Keep the last-known-good runtime snapshot active until the specialized refresh path
|
||||||
|
// succeeds, so concurrent readers do not observe unresolved SecretRefs mid-refresh.
|
||||||
|
const refreshHandler = runtimeConfigSnapshotRefreshHandler;
|
||||||
|
if (refreshHandler) {
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshHandler.refresh({ sourceConfig: nextCfg });
|
||||||
|
if (refreshed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
refreshHandler.clearOnRefreshFailure?.();
|
||||||
|
} catch {
|
||||||
|
// Keep the original refresh failure as the surfaced error.
|
||||||
|
}
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new ConfigRuntimeRefreshError(
|
||||||
|
`Config was written to ${io.configPath}, but runtime snapshot refresh failed: ${detail}`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hadBothSnapshots) {
|
||||||
|
// Refresh both snapshots from disk atomically so follow-up reads get normalized config and
|
||||||
|
// subsequent writes still get secret-preservation merge-patch (hadBothSnapshots stays true).
|
||||||
|
const fresh = io.loadConfig();
|
||||||
|
setRuntimeConfigSnapshot(fresh, nextCfg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hadRuntimeSnapshot) {
|
||||||
|
clearRuntimeConfigSnapshot();
|
||||||
|
}
|
||||||
|
// When we had no runtime snapshot, keep callers reading from disk/cache so external/manual
|
||||||
|
// edits to openclaw.json remain visible (no stale snapshot).
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { ensureAuthProfileStore, type AuthProfileStore } from "../agents/auth-profiles.js";
|
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 {
|
import {
|
||||||
activateSecretsRuntimeSnapshot,
|
activateSecretsRuntimeSnapshot,
|
||||||
clearSecretsRuntimeSnapshot,
|
clearSecretsRuntimeSnapshot,
|
||||||
|
getActiveSecretsRuntimeSnapshot,
|
||||||
prepareSecretsRuntimeSnapshot,
|
prepareSecretsRuntimeSnapshot,
|
||||||
} from "./runtime.js";
|
} 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 () => {
|
it("skips inactive-surface refs and emits diagnostics", async () => {
|
||||||
const config = asConfig({
|
const config = asConfig({
|
||||||
agents: {
|
agents: {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "../agents/auth-profiles.js";
|
} from "../agents/auth-profiles.js";
|
||||||
import {
|
import {
|
||||||
clearRuntimeConfigSnapshot,
|
clearRuntimeConfigSnapshot,
|
||||||
|
setRuntimeConfigSnapshotRefreshHandler,
|
||||||
setRuntimeConfigSnapshot,
|
setRuntimeConfigSnapshot,
|
||||||
type OpenClawConfig,
|
type OpenClawConfig,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
@@ -34,7 +35,18 @@ export type PreparedSecretsRuntimeSnapshot = {
|
|||||||
warnings: SecretResolverWarning[];
|
warnings: SecretResolverWarning[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SecretsRuntimeRefreshContext = {
|
||||||
|
env: Record<string, string | undefined>;
|
||||||
|
explicitAgentDirs: string[] | null;
|
||||||
|
loadAuthStore: (agentDir?: string) => AuthProfileStore;
|
||||||
|
};
|
||||||
|
|
||||||
let activeSnapshot: PreparedSecretsRuntimeSnapshot | null = null;
|
let activeSnapshot: PreparedSecretsRuntimeSnapshot | null = null;
|
||||||
|
let activeRefreshContext: SecretsRuntimeRefreshContext | null = null;
|
||||||
|
const preparedSnapshotRefreshContext = new WeakMap<
|
||||||
|
PreparedSecretsRuntimeSnapshot,
|
||||||
|
SecretsRuntimeRefreshContext
|
||||||
|
>();
|
||||||
|
|
||||||
function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecretsRuntimeSnapshot {
|
function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecretsRuntimeSnapshot {
|
||||||
return {
|
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[] {
|
function collectCandidateAgentDirs(config: OpenClawConfig): string[] {
|
||||||
const dirs = new Set<string>();
|
const dirs = new Set<string>();
|
||||||
dirs.add(resolveUserPath(resolveOpenClawAgentDir()));
|
dirs.add(resolveUserPath(resolveOpenClawAgentDir()));
|
||||||
@@ -57,6 +85,17 @@ function collectCandidateAgentDirs(config: OpenClawConfig): string[] {
|
|||||||
return [...dirs];
|
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: {
|
export async function prepareSecretsRuntimeSnapshot(params: {
|
||||||
config: OpenClawConfig;
|
config: OpenClawConfig;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
@@ -104,23 +143,61 @@ export async function prepareSecretsRuntimeSnapshot(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const snapshot = {
|
||||||
sourceConfig,
|
sourceConfig,
|
||||||
config: resolvedConfig,
|
config: resolvedConfig,
|
||||||
authStores,
|
authStores,
|
||||||
warnings: context.warnings,
|
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 {
|
export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): void {
|
||||||
const next = cloneSnapshot(snapshot);
|
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);
|
setRuntimeConfigSnapshot(next.config, next.sourceConfig);
|
||||||
replaceRuntimeAuthProfileStoreSnapshots(next.authStores);
|
replaceRuntimeAuthProfileStoreSnapshots(next.authStores);
|
||||||
activeSnapshot = next;
|
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 {
|
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: {
|
export function resolveCommandSecretsFromActiveRuntimeSnapshot(params: {
|
||||||
@@ -155,7 +232,5 @@ export function resolveCommandSecretsFromActiveRuntimeSnapshot(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function clearSecretsRuntimeSnapshot(): void {
|
export function clearSecretsRuntimeSnapshot(): void {
|
||||||
activeSnapshot = null;
|
clearActiveSecretsRuntimeState();
|
||||||
clearRuntimeConfigSnapshot();
|
|
||||||
clearRuntimeAuthProfileStoreSnapshots();
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user