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, loadAuthProfileStoreForSecretsRuntime, replaceRuntimeAuthProfileStoreSnapshots, } from "../agents/auth-profiles.js"; import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot, type OpenClawConfig, } from "../config/config.js"; import { coerceSecretRef, type SecretRef } from "../config/types.secrets.js"; import { resolveUserPath } from "../utils.js"; import { secretRefKey } from "./ref-contract.js"; import { resolveSecretRefValues, type SecretRefResolveCache } from "./resolve.js"; import { isNonEmptyString, isRecord } from "./shared.js"; type SecretResolverWarningCode = "SECRETS_REF_OVERRIDES_PLAINTEXT"; export type SecretResolverWarning = { code: SecretResolverWarningCode; path: string; message: string; }; export type PreparedSecretsRuntimeSnapshot = { sourceConfig: OpenClawConfig; config: OpenClawConfig; authStores: Array<{ agentDir: string; store: AuthProfileStore }>; warnings: SecretResolverWarning[]; }; type ProviderLike = { apiKey?: unknown; }; type SkillEntryLike = { 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; }; type SecretAssignment = { ref: SecretRef; path: string; expected: "string" | "string-or-object"; apply: (value: unknown) => void; }; type ResolverContext = { sourceConfig: OpenClawConfig; env: NodeJS.ProcessEnv; cache: SecretRefResolveCache; warnings: SecretResolverWarning[]; assignments: SecretAssignment[]; }; type SecretDefaults = NonNullable["defaults"]; let activeSnapshot: PreparedSecretsRuntimeSnapshot | null = null; function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecretsRuntimeSnapshot { return { sourceConfig: structuredClone(snapshot.sourceConfig), config: structuredClone(snapshot.config), authStores: snapshot.authStores.map((entry) => ({ agentDir: entry.agentDir, store: structuredClone(entry.store), })), warnings: snapshot.warnings.map((warning) => ({ ...warning })), }; } function pushAssignment(context: ResolverContext, assignment: SecretAssignment): void { context.assignments.push(assignment); } function collectModelProviderAssignments(params: { providers: Record; defaults: SecretDefaults | undefined; context: ResolverContext; }): void { for (const [providerId, provider] of Object.entries(params.providers)) { const ref = coerceSecretRef(provider.apiKey, params.defaults); if (!ref) { continue; } pushAssignment(params.context, { ref, path: `models.providers.${providerId}.apiKey`, expected: "string", apply: (value) => { provider.apiKey = value; }, }); } } function collectSkillAssignments(params: { entries: Record; defaults: SecretDefaults | undefined; context: ResolverContext; }): void { for (const [skillKey, entry] of Object.entries(params.entries)) { const ref = coerceSecretRef(entry.apiKey, params.defaults); if (!ref) { continue; } pushAssignment(params.context, { ref, path: `skills.entries.${skillKey}.apiKey`, expected: "string", apply: (value) => { entry.apiKey = value; }, }); } } function collectGoogleChatAccountAssignment(params: { target: GoogleChatAccountLike; path: string; defaults: SecretDefaults | undefined; context: ResolverContext; }): void { const explicitRef = coerceSecretRef(params.target.serviceAccountRef, params.defaults); const inlineRef = coerceSecretRef(params.target.serviceAccount, params.defaults); const ref = explicitRef ?? inlineRef; if (!ref) { return; } if ( explicitRef && params.target.serviceAccount !== undefined && !coerceSecretRef(params.target.serviceAccount, params.defaults) ) { params.context.warnings.push({ code: "SECRETS_REF_OVERRIDES_PLAINTEXT", path: params.path, message: `${params.path}: serviceAccountRef is set; runtime will ignore plaintext serviceAccount.`, }); } pushAssignment(params.context, { ref, path: `${params.path}.serviceAccount`, expected: "string-or-object", apply: (value) => { params.target.serviceAccount = value; }, }); } function collectGoogleChatAssignments(params: { googleChat: GoogleChatAccountLike; defaults: SecretDefaults | undefined; context: ResolverContext; }): void { collectGoogleChatAccountAssignment({ target: params.googleChat, path: "channels.googlechat", defaults: params.defaults, context: params.context, }); if (!isRecord(params.googleChat.accounts)) { return; } for (const [accountId, account] of Object.entries(params.googleChat.accounts)) { if (!isRecord(account)) { continue; } collectGoogleChatAccountAssignment({ target: account as GoogleChatAccountLike, path: `channels.googlechat.accounts.${accountId}`, defaults: params.defaults, context: params.context, }); } } function collectConfigAssignments(params: { config: OpenClawConfig; context: ResolverContext; }): void { const defaults = params.context.sourceConfig.secrets?.defaults; const providers = params.config.models?.providers as Record | undefined; if (providers) { collectModelProviderAssignments({ providers, defaults, context: params.context, }); } const skillEntries = params.config.skills?.entries as Record | undefined; if (skillEntries) { collectSkillAssignments({ entries: skillEntries, defaults, context: params.context, }); } const googleChat = params.config.channels?.googlechat as GoogleChatAccountLike | undefined; if (googleChat) { collectGoogleChatAssignments({ googleChat, defaults, context: params.context, }); } } function collectApiKeyProfileAssignment(params: { profile: ApiKeyCredentialLike; profileId: string; agentDir: string; defaults: SecretDefaults | undefined; context: ResolverContext; }): void { const keyRef = coerceSecretRef(params.profile.keyRef, params.defaults); const inlineKeyRef = keyRef ? null : coerceSecretRef(params.profile.key, params.defaults); const resolvedKeyRef = keyRef ?? inlineKeyRef; if (!resolvedKeyRef) { return; } if (inlineKeyRef && !keyRef) { params.profile.keyRef = inlineKeyRef; delete (params.profile as unknown as Record).key; } if (keyRef && isNonEmptyString(params.profile.key)) { params.context.warnings.push({ code: "SECRETS_REF_OVERRIDES_PLAINTEXT", path: `${params.agentDir}.auth-profiles.${params.profileId}.key`, message: `auth-profiles ${params.profileId}: keyRef is set; runtime will ignore plaintext key.`, }); } pushAssignment(params.context, { ref: resolvedKeyRef, path: `${params.agentDir}.auth-profiles.${params.profileId}.key`, expected: "string", apply: (value) => { params.profile.key = String(value); }, }); } function collectTokenProfileAssignment(params: { profile: TokenCredentialLike; profileId: string; agentDir: string; defaults: SecretDefaults | undefined; context: ResolverContext; }): void { const tokenRef = coerceSecretRef(params.profile.tokenRef, params.defaults); const inlineTokenRef = tokenRef ? null : coerceSecretRef(params.profile.token, params.defaults); const resolvedTokenRef = tokenRef ?? inlineTokenRef; if (!resolvedTokenRef) { return; } if (inlineTokenRef && !tokenRef) { params.profile.tokenRef = inlineTokenRef; delete (params.profile as unknown as Record).token; } if (tokenRef && isNonEmptyString(params.profile.token)) { params.context.warnings.push({ code: "SECRETS_REF_OVERRIDES_PLAINTEXT", path: `${params.agentDir}.auth-profiles.${params.profileId}.token`, message: `auth-profiles ${params.profileId}: tokenRef is set; runtime will ignore plaintext token.`, }); } pushAssignment(params.context, { ref: resolvedTokenRef, path: `${params.agentDir}.auth-profiles.${params.profileId}.token`, expected: "string", apply: (value) => { params.profile.token = String(value); }, }); } function collectAuthStoreAssignments(params: { store: AuthProfileStore; context: ResolverContext; agentDir: string; }): void { const defaults = params.context.sourceConfig.secrets?.defaults; for (const [profileId, profile] of Object.entries(params.store.profiles)) { if (profile.type === "api_key") { collectApiKeyProfileAssignment({ profile: profile as ApiKeyCredentialLike, profileId, agentDir: params.agentDir, defaults, context: params.context, }); continue; } if (profile.type === "token") { collectTokenProfileAssignment({ profile: profile as TokenCredentialLike, profileId, agentDir: params.agentDir, defaults, context: params.context, }); } } } function applyAssignments(params: { assignments: SecretAssignment[]; resolved: Map; }): void { for (const assignment of params.assignments) { const key = secretRefKey(assignment.ref); if (!params.resolved.has(key)) { throw new Error(`Secret reference "${key}" resolved to no value.`); } const value = params.resolved.get(key); if (assignment.expected === "string") { if (!isNonEmptyString(value)) { throw new Error(`${assignment.path} resolved to a non-string or empty value.`); } assignment.apply(value); continue; } if (!(isNonEmptyString(value) || isRecord(value))) { throw new Error(`${assignment.path} resolved to an unsupported value type.`); } assignment.apply(value); } } 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 sourceConfig = structuredClone(params.config); const resolvedConfig = structuredClone(params.config); const context: ResolverContext = { sourceConfig, env: params.env ?? process.env, cache: {}, warnings: [], assignments: [], }; collectConfigAssignments({ config: resolvedConfig, context, }); const loadAuthStore = params.loadAuthStore ?? loadAuthProfileStoreForSecretsRuntime; 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 store = structuredClone(loadAuthStore(agentDir)); collectAuthStoreAssignments({ store, context, agentDir, }); authStores.push({ agentDir, store }); } if (context.assignments.length > 0) { const refs = context.assignments.map((assignment) => assignment.ref); const resolved = await resolveSecretRefValues(refs, { config: sourceConfig, env: context.env, cache: context.cache, }); applyAssignments({ assignments: context.assignments, resolved, }); } return { sourceConfig, config: resolvedConfig, authStores, warnings: context.warnings, }; } export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): void { const next = cloneSnapshot(snapshot); setRuntimeConfigSnapshot(next.config, next.sourceConfig); replaceRuntimeAuthProfileStoreSnapshots(next.authStores); activeSnapshot = next; } export function getActiveSecretsRuntimeSnapshot(): PreparedSecretsRuntimeSnapshot | null { return activeSnapshot ? cloneSnapshot(activeSnapshot) : null; } export function clearSecretsRuntimeSnapshot(): void { activeSnapshot = null; clearRuntimeConfigSnapshot(); clearRuntimeAuthProfileStoreSnapshots(); }