From b50c4c2c44a03cfc4d2e94e2f0174344dc472fb8 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Sat, 21 Feb 2026 11:13:25 -0800 Subject: [PATCH] Gateway: add eager secrets runtime snapshot activation --- src/agents/auth-profiles.ts | 3 + src/agents/auth-profiles/store.ts | 79 ++++++ src/agents/auth-profiles/types.ts | 3 + src/agents/models-config.providers.ts | 12 +- src/commands/onboard-custom.ts | 3 +- src/config/config.ts | 3 + src/config/io.ts | 18 ++ src/config/types.models.ts | 4 +- src/gateway/config-reload.ts | 2 +- src/gateway/server.impl.ts | 97 ++++++- src/secrets/runtime.test.ts | 159 +++++++++++ src/secrets/runtime.ts | 385 ++++++++++++++++++++++++++ 12 files changed, 758 insertions(+), 10 deletions(-) create mode 100644 src/secrets/runtime.test.ts create mode 100644 src/secrets/runtime.ts diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index 42941e6b1c8..d4d1e677ee2 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -17,7 +17,10 @@ export { suggestOAuthProfileIdForLegacyDefault, } from "./auth-profiles/repair.js"; export { + clearRuntimeAuthProfileStoreSnapshots, ensureAuthProfileStore, + loadAuthProfileStoreForRuntime, + replaceRuntimeAuthProfileStoreSnapshots, loadAuthProfileStore, saveAuthProfileStore, } from "./auth-profiles/store.js"; diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 50772f8e4a6..ac94967ba58 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -14,6 +14,65 @@ type RejectedCredentialEntry = { key: string; reason: CredentialRejectReason }; const AUTH_PROFILE_TYPES = new Set(["api_key", "oauth", "token"]); +const runtimeAuthStoreSnapshots = new Map(); + +function resolveRuntimeStoreKey(agentDir?: string): string { + return resolveAuthStorePath(agentDir); +} + +function cloneAuthProfileStore(store: AuthProfileStore): AuthProfileStore { + return structuredClone(store); +} + +function resolveRuntimeAuthProfileStore(agentDir?: string): AuthProfileStore | null { + if (runtimeAuthStoreSnapshots.size === 0) { + return null; + } + + const mainKey = resolveRuntimeStoreKey(undefined); + const requestedKey = resolveRuntimeStoreKey(agentDir); + const mainStore = runtimeAuthStoreSnapshots.get(mainKey); + const requestedStore = runtimeAuthStoreSnapshots.get(requestedKey); + + if (!agentDir || requestedKey === mainKey) { + if (!mainStore) { + return null; + } + return cloneAuthProfileStore(mainStore); + } + + if (mainStore && requestedStore) { + return mergeAuthProfileStores( + cloneAuthProfileStore(mainStore), + cloneAuthProfileStore(requestedStore), + ); + } + if (requestedStore) { + return cloneAuthProfileStore(requestedStore); + } + if (mainStore) { + return cloneAuthProfileStore(mainStore); + } + + return null; +} + +export function replaceRuntimeAuthProfileStoreSnapshots( + entries: Array<{ agentDir?: string; store: AuthProfileStore }>, +): void { + runtimeAuthStoreSnapshots.clear(); + for (const entry of entries) { + runtimeAuthStoreSnapshots.set( + resolveRuntimeStoreKey(entry.agentDir), + cloneAuthProfileStore(entry.store), + ); + } +} + +export function clearRuntimeAuthProfileStoreSnapshots(): void { + runtimeAuthStoreSnapshots.clear(); +} + export async function updateAuthProfileStoreWithLock(params: { agentDir?: string; updater: (store: AuthProfileStore) => boolean; @@ -372,10 +431,30 @@ function loadAuthProfileStoreForAgent( return store; } +export function loadAuthProfileStoreForRuntime( + agentDir?: string, + options?: { allowKeychainPrompt?: boolean }, +): AuthProfileStore { + const store = loadAuthProfileStoreForAgent(agentDir, options); + const authPath = resolveAuthStorePath(agentDir); + const mainAuthPath = resolveAuthStorePath(); + if (!agentDir || authPath === mainAuthPath) { + return store; + } + + const mainStore = loadAuthProfileStoreForAgent(undefined, options); + return mergeAuthProfileStores(mainStore, store); +} + export function ensureAuthProfileStore( agentDir?: string, options?: { allowKeychainPrompt?: boolean }, ): AuthProfileStore { + const runtimeStore = resolveRuntimeAuthProfileStore(agentDir); + if (runtimeStore) { + return runtimeStore; + } + const store = loadAuthProfileStoreForAgent(agentDir, options); const authPath = resolveAuthStorePath(agentDir); const mainAuthPath = resolveAuthStorePath(); diff --git a/src/agents/auth-profiles/types.ts b/src/agents/auth-profiles/types.ts index c23e6aa404d..f4e56f59d68 100644 --- a/src/agents/auth-profiles/types.ts +++ b/src/agents/auth-profiles/types.ts @@ -1,10 +1,12 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai"; import type { OpenClawConfig } from "../../config/config.js"; +import type { SecretRef } from "../../config/types.secrets.js"; export type ApiKeyCredential = { type: "api_key"; provider: string; key?: string; + keyRef?: SecretRef; email?: string; /** Optional provider-specific metadata (e.g., account IDs, gateway IDs). */ metadata?: Record; @@ -18,6 +20,7 @@ export type TokenCredential = { type: "token"; provider: string; token: string; + tokenRef?: SecretRef; /** Optional expiry timestamp (ms since epoch). */ expires?: number; email?: string; diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 4f921b6dd81..fd6ee1e25f1 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -12,6 +12,7 @@ import { KILOCODE_DEFAULT_MAX_TOKENS, KILOCODE_MODEL_CATALOG, } from "../providers/kilocode-shared.js"; +import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; import { discoverBedrockModels } from "./bedrock-discovery.js"; import { @@ -405,16 +406,17 @@ export function normalizeProviders(params: { for (const [key, provider] of Object.entries(providers)) { const normalizedKey = key.trim(); let normalizedProvider = provider; + const configuredApiKey = normalizedProvider.apiKey; // Fix common misconfig: apiKey set to "${ENV_VAR}" instead of "ENV_VAR". if ( - normalizedProvider.apiKey && - normalizeApiKeyConfig(normalizedProvider.apiKey) !== normalizedProvider.apiKey + typeof configuredApiKey === "string" && + normalizeApiKeyConfig(configuredApiKey) !== configuredApiKey ) { mutated = true; normalizedProvider = { ...normalizedProvider, - apiKey: normalizeApiKeyConfig(normalizedProvider.apiKey), + apiKey: normalizeApiKeyConfig(configuredApiKey), }; } @@ -422,7 +424,9 @@ export function normalizeProviders(params: { // Fill it from the environment or auth profiles when possible. const hasModels = Array.isArray(normalizedProvider.models) && normalizedProvider.models.length > 0; - if (hasModels && !normalizedProvider.apiKey?.trim()) { + const normalizedApiKey = normalizeOptionalSecretInput(normalizedProvider.apiKey); + const hasConfiguredApiKey = Boolean(normalizedApiKey || normalizedProvider.apiKey); + if (hasModels && !hasConfiguredApiKey) { const authMode = normalizedProvider.auth ?? (normalizedKey === "amazon-bedrock" ? "aws-sdk" : undefined); if (authMode === "aws-sdk") { diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index a00471701b2..509032e9b5d 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/config.js"; import type { ModelProviderConfig } from "../config/types.models.js"; import type { RuntimeEnv } from "../runtime.js"; import { fetchWithTimeout } from "../utils/fetch-timeout.js"; +import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { applyPrimaryModel } from "./model-picker.js"; import { normalizeAlias } from "./models/shared.js"; @@ -541,7 +542,7 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom const mergedModels = hasModel ? existingModels : [...existingModels, nextModel]; const { apiKey: existingApiKey, ...existingProviderRest } = existingProvider ?? {}; const normalizedApiKey = - params.apiKey?.trim() || (existingApiKey ? existingApiKey.trim() : undefined); + normalizeOptionalSecretInput(params.apiKey) ?? normalizeOptionalSecretInput(existingApiKey); let config: OpenClawConfig = { ...params.config, diff --git a/src/config/config.ts b/src/config/config.ts index a20d9495b00..df667d498b1 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,11 +1,14 @@ export { clearConfigCache, + clearRuntimeConfigSnapshot, createConfigIO, + getRuntimeConfigSnapshot, loadConfig, parseConfigJson5, readConfigFileSnapshot, readConfigFileSnapshotForWrite, resolveConfigSnapshotHash, + setRuntimeConfigSnapshot, writeConfigFile, } from "./io.js"; export { migrateLegacyConfig } from "./legacy-migrate.js"; diff --git a/src/config/io.ts b/src/config/io.ts index 89d6b332676..688031ea716 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1298,6 +1298,7 @@ let configCache: { expiresAt: number; config: OpenClawConfig; } | null = null; +let runtimeConfigSnapshot: OpenClawConfig | null = null; function resolveConfigCacheMs(env: NodeJS.ProcessEnv): number { const raw = env.OPENCLAW_CONFIG_CACHE_MS?.trim(); @@ -1325,7 +1326,24 @@ export function clearConfigCache(): void { configCache = null; } +export function setRuntimeConfigSnapshot(config: OpenClawConfig): void { + runtimeConfigSnapshot = config; + clearConfigCache(); +} + +export function clearRuntimeConfigSnapshot(): void { + runtimeConfigSnapshot = null; + clearConfigCache(); +} + +export function getRuntimeConfigSnapshot(): OpenClawConfig | null { + return runtimeConfigSnapshot; +} + export function loadConfig(): OpenClawConfig { + if (runtimeConfigSnapshot) { + return runtimeConfigSnapshot; + } const io = createConfigIO(); const configPath = io.configPath; const now = Date.now(); diff --git a/src/config/types.models.ts b/src/config/types.models.ts index ebc81f54bdd..9e97c675935 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -1,3 +1,5 @@ +import type { SecretInput } from "./types.secrets.js"; + export type ModelApi = | "openai-completions" | "openai-responses" @@ -43,7 +45,7 @@ export type ModelDefinitionConfig = { export type ModelProviderConfig = { baseUrl: string; - apiKey?: string; + apiKey?: SecretInput; auth?: ModelProviderAuthMode; api?: ModelApi; headers?: Record; diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index 64f04b15e65..9256eead908 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -255,7 +255,7 @@ export function startGatewayConfigReloader(opts: { initialConfig: OpenClawConfig; readSnapshot: () => Promise; onHotReload: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => Promise; - onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void; + onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise; log: { info: (msg: string) => void; warn: (msg: string) => void; diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 3dbd86e1e5e..b8d9969b56b 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -11,6 +11,7 @@ import { createDefaultDeps } from "../cli/deps.js"; import { isRestartEnabled } from "../config/commands.js"; import { CONFIG_PATH, + type OpenClawConfig, isNixMode, loadConfig, migrateLegacyConfig, @@ -45,6 +46,12 @@ import { createEmptyPluginRegistry } from "../plugins/registry.js"; import type { PluginServicesHandle } from "../plugins/services.js"; import { getTotalQueueSize } from "../process/command-queue.js"; import type { RuntimeEnv } from "../runtime.js"; +import { + activateSecretsRuntimeSnapshot, + clearSecretsRuntimeSnapshot, + getActiveSecretsRuntimeSnapshot, + prepareSecretsRuntimeSnapshot, +} from "../secrets/runtime.js"; import { runOnboardingWizard } from "../wizard/onboarding.js"; import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js"; import { startChannelHealthMonitor } from "./channel-health-monitor.js"; @@ -107,6 +114,7 @@ const logReload = log.child("reload"); const logHooks = log.child("hooks"); const logPlugins = log.child("plugins"); const logWsControl = log.child("ws"); +const logSecrets = log.child("secrets"); const gatewayRuntime = runtimeForLogger(log); const canvasRuntime = runtimeForLogger(logCanvas); @@ -248,7 +256,64 @@ export async function startGatewayServer( } } - let cfgAtStart = loadConfig(); + let secretsDegraded = false; + const activateRuntimeSecrets = async ( + config: OpenClawConfig, + params: { reason: "startup" | "reload" | "restart-check"; activate: boolean }, + ) => { + try { + const prepared = await prepareSecretsRuntimeSnapshot({ config }); + if (params.activate) { + activateSecretsRuntimeSnapshot(prepared); + } + for (const warning of prepared.warnings) { + logSecrets.warn(`[${warning.code}] ${warning.message}`); + } + if (secretsDegraded) { + logSecrets.info( + "[SECRETS_RELOADER_RECOVERED] Secret resolution recovered; runtime remained on last-known-good during the outage.", + ); + } + secretsDegraded = false; + return prepared; + } catch (err) { + const details = String(err); + if (!secretsDegraded) { + logSecrets.error(`[SECRETS_RELOADER_DEGRADED] ${details}`); + } else { + logSecrets.warn(`[SECRETS_RELOADER_DEGRADED] ${details}`); + } + secretsDegraded = true; + if (params.reason === "startup") { + throw new Error(`Startup failed: required secrets are unavailable. ${details}`, { + cause: err, + }); + } + throw err; + } + }; + + // Fail fast before startup if required refs are unresolved. + let cfgAtStart: OpenClawConfig; + { + const freshSnapshot = await readConfigFileSnapshot(); + if (!freshSnapshot.valid) { + const issues = + freshSnapshot.issues.length > 0 + ? freshSnapshot.issues + .map((issue) => `${issue.path || ""}: ${issue.message}`) + .join("\n") + : "Unknown validation issue."; + throw new Error(`Invalid config at ${freshSnapshot.path}.\n${issues}`); + } + const prepared = await activateRuntimeSecrets(freshSnapshot.config, { + reason: "startup", + activate: true, + }); + cfgAtStart = prepared.config; + } + + cfgAtStart = loadConfig(); const authBootstrap = await ensureGatewayStartupAuth({ cfg: cfgAtStart, env: process.env, @@ -268,6 +333,12 @@ export async function startGatewayServer( ); } } + cfgAtStart = ( + await activateRuntimeSecrets(cfgAtStart, { + reason: "startup", + activate: true, + }) + ).config; const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart); if (diagnosticsEnabled) { startDiagnosticHeartbeat(); @@ -738,8 +809,27 @@ export async function startGatewayServer( return startGatewayConfigReloader({ initialConfig: cfgAtStart, readSnapshot: readConfigFileSnapshot, - onHotReload: applyHotReload, - onRestart: requestGatewayRestart, + onHotReload: async (plan, nextConfig) => { + const previousSnapshot = getActiveSecretsRuntimeSnapshot(); + const prepared = await activateRuntimeSecrets(nextConfig, { + reason: "reload", + activate: true, + }); + try { + await applyHotReload(plan, prepared.config); + } catch (err) { + if (previousSnapshot) { + activateSecretsRuntimeSnapshot(previousSnapshot); + } else { + clearSecretsRuntimeSnapshot(); + } + throw err; + } + }, + onRestart: async (plan, nextConfig) => { + await activateRuntimeSecrets(nextConfig, { reason: "restart-check", activate: false }); + requestGatewayRestart(plan, nextConfig); + }, log: { info: (msg) => logReload.info(msg), warn: (msg) => logReload.warn(msg), @@ -794,6 +884,7 @@ export async function startGatewayServer( authRateLimiter?.dispose(); browserAuthRateLimiter.dispose(); channelHealthMonitor?.stop(); + clearSecretsRuntimeSnapshot(); await close(opts); }, }; diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts new file mode 100644 index 00000000000..5920ba0d2b4 --- /dev/null +++ b/src/secrets/runtime.test.ts @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; +import { loadConfig, type OpenClawConfig } from "../config/config.js"; +import { + activateSecretsRuntimeSnapshot, + clearSecretsRuntimeSnapshot, + prepareSecretsRuntimeSnapshot, +} from "./runtime.js"; + +const runExecMock = vi.hoisted(() => vi.fn()); + +vi.mock("../process/exec.js", () => ({ + runExec: runExecMock, +})); + +describe("secrets runtime snapshot", () => { + afterEach(() => { + runExecMock.mockReset(); + clearSecretsRuntimeSnapshot(); + }); + + it("resolves env refs for config and auth profiles", async () => { + const config: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", id: "OPENAI_API_KEY" }, + models: [], + }, + }, + }, + }; + + const snapshot = await prepareSecretsRuntimeSnapshot({ + config, + env: { + OPENAI_API_KEY: "sk-env-openai", + GITHUB_TOKEN: "ghp-env-token", + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: "old-openai", + keyRef: { source: "env", id: "OPENAI_API_KEY" }, + }, + "github-copilot:default": { + type: "token", + provider: "github-copilot", + token: "old-gh", + tokenRef: { source: "env", id: "GITHUB_TOKEN" }, + }, + }, + }), + }); + + expect(snapshot.config.models?.providers?.openai?.apiKey).toBe("sk-env-openai"); + expect(snapshot.warnings).toHaveLength(2); + expect(snapshot.authStores[0]?.store.profiles["openai:default"]).toMatchObject({ + type: "api_key", + key: "sk-env-openai", + }); + expect(snapshot.authStores[0]?.store.profiles["github-copilot:default"]).toMatchObject({ + type: "token", + token: "ghp-env-token", + }); + }); + + it("resolves file refs via sops json payload", async () => { + runExecMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + providers: { + openai: { + apiKey: "sk-from-sops", + }, + }, + }), + stderr: "", + }); + + const config: OpenClawConfig = { + secrets: { + sources: { + file: { + type: "sops", + path: "~/.openclaw/secrets.enc.json", + timeoutMs: 7000, + }, + }, + }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "file", id: "/providers/openai/apiKey" }, + models: [], + }, + }, + }, + }; + + const snapshot = await prepareSecretsRuntimeSnapshot({ + config, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ version: 1, profiles: {} }), + }); + + expect(snapshot.config.models?.providers?.openai?.apiKey).toBe("sk-from-sops"); + expect(runExecMock).toHaveBeenCalledWith( + "sops", + ["--decrypt", "--output-type", "json", expect.stringContaining("secrets.enc.json")], + { + timeoutMs: 7000, + maxBuffer: 10 * 1024 * 1024, + }, + ); + }); + + it("activates runtime snapshots for loadConfig and ensureAuthProfileStore", async () => { + const prepared = await prepareSecretsRuntimeSnapshot({ + config: { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", id: "OPENAI_API_KEY" }, + models: [], + }, + }, + }, + }, + env: { OPENAI_API_KEY: "sk-runtime" }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + keyRef: { source: "env", id: "OPENAI_API_KEY" }, + }, + }, + }), + }); + + activateSecretsRuntimeSnapshot(prepared); + + expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-runtime"); + const store = ensureAuthProfileStore("/tmp/openclaw-agent-main"); + expect(store.profiles["openai:default"]).toMatchObject({ + type: "api_key", + key: "sk-runtime", + }); + }); +}); diff --git a/src/secrets/runtime.ts b/src/secrets/runtime.ts new file mode 100644 index 00000000000..086cffa38d5 --- /dev/null +++ b/src/secrets/runtime.ts @@ -0,0 +1,385 @@ +import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; +import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js"; +import type { AuthProfileCredential, AuthProfileStore } from "../agents/auth-profiles.js"; +import { + clearRuntimeAuthProfileStoreSnapshots, + loadAuthProfileStoreForRuntime, + replaceRuntimeAuthProfileStoreSnapshots, +} from "../agents/auth-profiles.js"; +import { + clearRuntimeConfigSnapshot, + setRuntimeConfigSnapshot, + type OpenClawConfig, + type SecretRef, +} from "../config/config.js"; +import { runExec } from "../process/exec.js"; +import { resolveUserPath } from "../utils.js"; + +const DEFAULT_SOPS_TIMEOUT_MS = 5_000; +const MAX_SOPS_OUTPUT_BYTES = 10 * 1024 * 1024; + +type SecretResolverWarningCode = "SECRETS_REF_OVERRIDES_PLAINTEXT"; + +export type SecretResolverWarning = { + code: SecretResolverWarningCode; + path: string; + message: string; +}; + +export type PreparedSecretsRuntimeSnapshot = { + config: OpenClawConfig; + authStores: Array<{ agentDir: string; store: AuthProfileStore }>; + warnings: SecretResolverWarning[]; +}; + +type ResolverContext = { + config: OpenClawConfig; + env: NodeJS.ProcessEnv; + fileSecretsPromise: Promise | null; +}; + +type ProviderLike = { + apiKey?: unknown; +}; + +type GoogleChatAccountLike = { + serviceAccount?: unknown; + serviceAccountRef?: unknown; + accounts?: Record; +}; + +type ApiKeyCredentialLike = AuthProfileCredential & { + type: "api_key"; + key?: string; + keyRef?: unknown; +}; + +type TokenCredentialLike = AuthProfileCredential & { + type: "token"; + token?: string; + tokenRef?: unknown; +}; + +let activeSnapshot: PreparedSecretsRuntimeSnapshot | null = null; + +function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecretsRuntimeSnapshot { + return { + config: structuredClone(snapshot.config), + authStores: snapshot.authStores.map((entry) => ({ + agentDir: entry.agentDir, + store: structuredClone(entry.store), + })), + warnings: snapshot.warnings.map((warning) => ({ ...warning })), + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isSecretRef(value: unknown): value is SecretRef { + if (!isRecord(value)) { + return false; + } + if (Object.keys(value).length !== 2) { + return false; + } + return ( + (value.source === "env" || value.source === "file") && + typeof value.id === "string" && + value.id.trim().length > 0 + ); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function decodeJsonPointerToken(token: string): string { + return token.replace(/~1/g, "/").replace(/~0/g, "~"); +} + +function readJsonPointer(root: unknown, pointer: string): unknown { + if (!pointer.startsWith("/")) { + throw new Error( + `File-backed secret ids must be absolute JSON pointers (for example: /providers/openai/apiKey).`, + ); + } + + const tokens = pointer + .slice(1) + .split("/") + .map((token) => decodeJsonPointerToken(token)); + + let current: unknown = root; + for (const token of tokens) { + if (Array.isArray(current)) { + const index = Number.parseInt(token, 10); + if (!Number.isFinite(index) || index < 0 || index >= current.length) { + throw new Error(`JSON pointer segment "${token}" is out of bounds.`); + } + current = current[index]; + continue; + } + if (!isRecord(current)) { + throw new Error(`JSON pointer segment "${token}" does not exist.`); + } + if (!Object.hasOwn(current, token)) { + throw new Error(`JSON pointer segment "${token}" does not exist.`); + } + current = current[token]; + } + return current; +} + +async function decryptSopsFile(config: OpenClawConfig): Promise { + const fileSource = config.secrets?.sources?.file; + if (!fileSource) { + throw new Error( + `Secret reference source "file" is not configured. Configure secrets.sources.file first.`, + ); + } + if (fileSource.type !== "sops") { + throw new Error(`Unsupported secrets.sources.file.type "${String(fileSource.type)}".`); + } + + const resolvedPath = resolveUserPath(fileSource.path); + const timeoutMs = + typeof fileSource.timeoutMs === "number" && Number.isFinite(fileSource.timeoutMs) + ? Math.max(1, Math.floor(fileSource.timeoutMs)) + : DEFAULT_SOPS_TIMEOUT_MS; + + try { + const { stdout } = await runExec("sops", ["--decrypt", "--output-type", "json", resolvedPath], { + timeoutMs, + maxBuffer: MAX_SOPS_OUTPUT_BYTES, + }); + return JSON.parse(stdout) as unknown; + } catch (err) { + const error = err as NodeJS.ErrnoException & { message?: string }; + if (error.code === "ENOENT") { + throw new Error( + `sops binary not found in PATH. Install sops >= 3.9.0 or disable secrets.sources.file.`, + { cause: err }, + ); + } + if (typeof error.message === "string" && error.message.toLowerCase().includes("timed out")) { + throw new Error(`sops decrypt timed out after ${timeoutMs}ms for ${resolvedPath}.`, { + cause: err, + }); + } + throw new Error(`sops decrypt failed for ${resolvedPath}: ${String(error.message ?? err)}`, { + cause: err, + }); + } +} + +async function resolveSecretRefValue(ref: SecretRef, context: ResolverContext): Promise { + const id = ref.id.trim(); + if (!id) { + throw new Error(`Secret reference id is empty.`); + } + + if (ref.source === "env") { + const envValue = context.env[id]; + if (!isNonEmptyString(envValue)) { + throw new Error(`Environment variable "${id}" is missing or empty.`); + } + return envValue; + } + + if (ref.source === "file") { + context.fileSecretsPromise ??= decryptSopsFile(context.config); + const payload = await context.fileSecretsPromise; + return readJsonPointer(payload, id); + } + + throw new Error(`Unsupported secret source "${String((ref as { source?: unknown }).source)}".`); +} + +async function resolveGoogleChatServiceAccount( + target: GoogleChatAccountLike, + path: string, + context: ResolverContext, + warnings: SecretResolverWarning[], +): Promise { + const explicitRef = isSecretRef(target.serviceAccountRef) ? target.serviceAccountRef : null; + const inlineRef = isSecretRef(target.serviceAccount) ? target.serviceAccount : null; + const ref = explicitRef ?? inlineRef; + if (!ref) { + return; + } + if (explicitRef && target.serviceAccount !== undefined && !isSecretRef(target.serviceAccount)) { + warnings.push({ + code: "SECRETS_REF_OVERRIDES_PLAINTEXT", + path, + message: `${path}: serviceAccountRef is set; runtime will ignore plaintext serviceAccount.`, + }); + } + target.serviceAccount = await resolveSecretRefValue(ref, context); +} + +async function resolveConfigSecretRefs(params: { + config: OpenClawConfig; + context: ResolverContext; + warnings: SecretResolverWarning[]; +}): Promise { + const resolved = structuredClone(params.config); + const providers = resolved.models?.providers as Record | undefined; + if (providers) { + for (const [providerId, provider] of Object.entries(providers)) { + if (!isSecretRef(provider.apiKey)) { + continue; + } + const resolvedValue = await resolveSecretRefValue(provider.apiKey, params.context); + if (!isNonEmptyString(resolvedValue)) { + throw new Error( + `models.providers.${providerId}.apiKey resolved to a non-string or empty value.`, + ); + } + provider.apiKey = resolvedValue; + } + } + + const googleChat = resolved.channels?.googlechat as GoogleChatAccountLike | undefined; + if (googleChat) { + await resolveGoogleChatServiceAccount( + googleChat, + "channels.googlechat", + params.context, + params.warnings, + ); + if (isRecord(googleChat.accounts)) { + for (const [accountId, account] of Object.entries(googleChat.accounts)) { + if (!isRecord(account)) { + continue; + } + await resolveGoogleChatServiceAccount( + account as GoogleChatAccountLike, + `channels.googlechat.accounts.${accountId}`, + params.context, + params.warnings, + ); + } + } + } + + return resolved; +} + +async function resolveAuthStoreSecretRefs(params: { + store: AuthProfileStore; + context: ResolverContext; + warnings: SecretResolverWarning[]; + agentDir: string; +}): Promise { + const resolvedStore = structuredClone(params.store); + for (const [profileId, profile] of Object.entries(resolvedStore.profiles)) { + if (profile.type === "api_key") { + const apiProfile = profile as ApiKeyCredentialLike; + const keyRef = isSecretRef(apiProfile.keyRef) ? apiProfile.keyRef : null; + if (keyRef && isNonEmptyString(apiProfile.key)) { + params.warnings.push({ + code: "SECRETS_REF_OVERRIDES_PLAINTEXT", + path: `${params.agentDir}.auth-profiles.${profileId}.key`, + message: `auth-profiles ${profileId}: keyRef is set; runtime will ignore plaintext key.`, + }); + } + if (keyRef) { + const resolvedValue = await resolveSecretRefValue(keyRef, params.context); + if (!isNonEmptyString(resolvedValue)) { + throw new Error(`auth profile "${profileId}" keyRef resolved to an empty value.`); + } + apiProfile.key = resolvedValue; + } + continue; + } + + if (profile.type === "token") { + const tokenProfile = profile as TokenCredentialLike; + const tokenRef = isSecretRef(tokenProfile.tokenRef) ? tokenProfile.tokenRef : null; + if (tokenRef && isNonEmptyString(tokenProfile.token)) { + params.warnings.push({ + code: "SECRETS_REF_OVERRIDES_PLAINTEXT", + path: `${params.agentDir}.auth-profiles.${profileId}.token`, + message: `auth-profiles ${profileId}: tokenRef is set; runtime will ignore plaintext token.`, + }); + } + if (tokenRef) { + const resolvedValue = await resolveSecretRefValue(tokenRef, params.context); + if (!isNonEmptyString(resolvedValue)) { + throw new Error(`auth profile "${profileId}" tokenRef resolved to an empty value.`); + } + tokenProfile.token = resolvedValue; + } + } + } + return resolvedStore; +} + +function collectCandidateAgentDirs(config: OpenClawConfig): string[] { + const dirs = new Set(); + dirs.add(resolveUserPath(resolveOpenClawAgentDir())); + for (const agentId of listAgentIds(config)) { + dirs.add(resolveUserPath(resolveAgentDir(config, agentId))); + } + return [...dirs]; +} + +export async function prepareSecretsRuntimeSnapshot(params: { + config: OpenClawConfig; + env?: NodeJS.ProcessEnv; + agentDirs?: string[]; + loadAuthStore?: (agentDir?: string) => AuthProfileStore; +}): Promise { + const warnings: SecretResolverWarning[] = []; + const context: ResolverContext = { + config: params.config, + env: params.env ?? process.env, + fileSecretsPromise: null, + }; + const resolvedConfig = await resolveConfigSecretRefs({ + config: params.config, + context, + warnings, + }); + + const loadAuthStore = params.loadAuthStore ?? loadAuthProfileStoreForRuntime; + const candidateDirs = params.agentDirs?.length + ? [...new Set(params.agentDirs.map((entry) => resolveUserPath(entry)))] + : collectCandidateAgentDirs(resolvedConfig); + const authStores: Array<{ agentDir: string; store: AuthProfileStore }> = []; + for (const agentDir of candidateDirs) { + const rawStore = loadAuthStore(agentDir); + const resolvedStore = await resolveAuthStoreSecretRefs({ + store: rawStore, + context, + warnings, + agentDir, + }); + authStores.push({ agentDir, store: resolvedStore }); + } + + return { + config: resolvedConfig, + authStores, + warnings, + }; +} + +export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): void { + const next = cloneSnapshot(snapshot); + setRuntimeConfigSnapshot(next.config); + replaceRuntimeAuthProfileStoreSnapshots(next.authStores); + activeSnapshot = next; +} + +export function getActiveSecretsRuntimeSnapshot(): PreparedSecretsRuntimeSnapshot | null { + return activeSnapshot ? cloneSnapshot(activeSnapshot) : null; +} + +export function clearSecretsRuntimeSnapshot(): void { + activeSnapshot = null; + clearRuntimeConfigSnapshot(); + clearRuntimeAuthProfileStoreSnapshots(); +}