/** * OpenClaw Auth Profiles Utility * Writes API keys to ~/.openclaw/agents/main/agent/auth-profiles.json * so the OpenClaw Gateway can load them for AI provider calls. */ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { getProviderEnvVar, getProviderDefaultModel, getProviderConfig, } from './provider-registry'; const AUTH_STORE_VERSION = 1; const AUTH_PROFILE_FILENAME = 'auth-profiles.json'; /** * Auth profile entry for an API key */ interface AuthProfileEntry { type: 'api_key'; provider: string; key: string; } /** * Auth profiles store format */ interface AuthProfilesStore { version: number; profiles: Record; order?: Record; lastGood?: Record; } /** * Get the path to the auth-profiles.json for a given agent */ function getAuthProfilesPath(agentId = 'main'): string { return join(homedir(), '.openclaw', 'agents', agentId, 'agent', AUTH_PROFILE_FILENAME); } /** * Read existing auth profiles store, or create an empty one */ function readAuthProfiles(agentId = 'main'): AuthProfilesStore { const filePath = getAuthProfilesPath(agentId); try { if (existsSync(filePath)) { const raw = readFileSync(filePath, 'utf-8'); const data = JSON.parse(raw) as AuthProfilesStore; // Validate basic structure if (data.version && data.profiles && typeof data.profiles === 'object') { return data; } } } catch (error) { console.warn('Failed to read auth-profiles.json, creating fresh store:', error); } return { version: AUTH_STORE_VERSION, profiles: {}, }; } /** * Write auth profiles store to disk */ function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): void { const filePath = getAuthProfilesPath(agentId); const dir = join(filePath, '..'); // Ensure directory exists if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } writeFileSync(filePath, JSON.stringify(store, null, 2), 'utf-8'); } /** * Discover all agent IDs that have an agent/ subdirectory. */ function discoverAgentIds(): string[] { const agentsDir = join(homedir(), '.openclaw', 'agents'); try { if (!existsSync(agentsDir)) return ['main']; return readdirSync(agentsDir, { withFileTypes: true }) .filter((d) => d.isDirectory() && existsSync(join(agentsDir, d.name, 'agent'))) .map((d) => d.name); } catch { return ['main']; } } /** * Save a provider API key to OpenClaw's auth-profiles.json * This writes the key in the format OpenClaw expects so the gateway * can use it for AI provider calls. * * Writes to ALL discovered agent directories so every agent * (including non-"main" agents like "dev") stays in sync. * * @param provider - Provider type (e.g., 'anthropic', 'openrouter', 'openai', 'google') * @param apiKey - The API key to store * @param agentId - Optional single agent ID. When omitted, writes to every agent. */ export function saveProviderKeyToOpenClaw( provider: string, apiKey: string, agentId?: string ): void { const agentIds = agentId ? [agentId] : discoverAgentIds(); if (agentIds.length === 0) agentIds.push('main'); for (const id of agentIds) { const store = readAuthProfiles(id); // Profile ID follows OpenClaw convention: :default const profileId = `${provider}:default`; // Upsert the profile entry store.profiles[profileId] = { type: 'api_key', provider, key: apiKey, }; // Update order to include this profile if (!store.order) { store.order = {}; } if (!store.order[provider]) { store.order[provider] = []; } if (!store.order[provider].includes(profileId)) { store.order[provider].push(profileId); } // Set as last good if (!store.lastGood) { store.lastGood = {}; } store.lastGood[provider] = profileId; writeAuthProfiles(store, id); } console.log(`Saved API key for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`); } /** * Remove a provider API key from OpenClaw auth-profiles.json */ export function removeProviderKeyFromOpenClaw( provider: string, agentId?: string ): void { const agentIds = agentId ? [agentId] : discoverAgentIds(); if (agentIds.length === 0) agentIds.push('main'); for (const id of agentIds) { const store = readAuthProfiles(id); const profileId = `${provider}:default`; delete store.profiles[profileId]; if (store.order?.[provider]) { store.order[provider] = store.order[provider].filter((aid) => aid !== profileId); if (store.order[provider].length === 0) { delete store.order[provider]; } } if (store.lastGood?.[provider] === profileId) { delete store.lastGood[provider]; } writeAuthProfiles(store, id); } console.log(`Removed API key for provider "${provider}" from OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`); } /** * Build environment variables object with all stored API keys * for passing to the Gateway process */ export function buildProviderEnvVars(providers: Array<{ type: string; apiKey: string }>): Record { const env: Record = {}; for (const { type, apiKey } of providers) { const envVar = getProviderEnvVar(type); if (envVar && apiKey) { env[envVar] = apiKey; } } return env; } /** * Update the OpenClaw config to use the given provider and model * Writes to ~/.openclaw/openclaw.json * * @param provider - Provider type (e.g. 'anthropic', 'siliconflow') * @param modelOverride - Optional model string to use instead of the registry default. * For siliconflow this is the user-supplied model ID prefixed with "siliconflow/". */ export function setOpenClawDefaultModel(provider: string, modelOverride?: string): void { const configPath = join(homedir(), '.openclaw', 'openclaw.json'); let config: Record = {}; try { if (existsSync(configPath)) { config = JSON.parse(readFileSync(configPath, 'utf-8')); } } catch (err) { console.warn('Failed to read openclaw.json, creating fresh config:', err); } const model = modelOverride || getProviderDefaultModel(provider); if (!model) { console.warn(`No default model mapping for provider "${provider}"`); return; } const modelId = model.startsWith(`${provider}/`) ? model.slice(provider.length + 1) : model; // Set the default model for the agents // model must be an object: { primary: "provider/model", fallbacks?: [] } const agents = (config.agents || {}) as Record; const defaults = (agents.defaults || {}) as Record; defaults.model = { primary: model }; agents.defaults = defaults; config.agents = agents; // Configure models.providers for providers that need explicit registration. // Built-in providers (anthropic, google) are part of OpenClaw's pi-ai catalog // and must NOT have a models.providers entry — it would override the built-in. const providerCfg = getProviderConfig(provider); if (providerCfg) { const models = (config.models || {}) as Record; const providers = (models.providers || {}) as Record; const existingProvider = providers[provider] && typeof providers[provider] === 'object' ? (providers[provider] as Record) : {}; const existingModels = Array.isArray(existingProvider.models) ? (existingProvider.models as Array>) : []; const registryModels = (providerCfg.models ?? []).map((m) => ({ ...m })) as Array>; const mergedModels = [...registryModels]; for (const item of existingModels) { const id = typeof item?.id === 'string' ? item.id : ''; if (id && !mergedModels.some((m) => m.id === id)) { mergedModels.push(item); } } if (modelId && !mergedModels.some((m) => m.id === modelId)) { mergedModels.push({ id: modelId, name: modelId }); } providers[provider] = { ...existingProvider, baseUrl: providerCfg.baseUrl, api: providerCfg.api, apiKey: providerCfg.apiKeyEnv, models: mergedModels, }; console.log(`Configured models.providers.${provider} with baseUrl=${providerCfg.baseUrl}, model=${modelId}`); models.providers = providers; config.models = models; } else { // Built-in provider: remove any stale models.providers entry that may // have been written by an earlier version. Leaving it in place would // override the native pi-ai catalog and can break streaming/auth. const models = (config.models || {}) as Record; const providers = (models.providers || {}) as Record; if (providers[provider]) { delete providers[provider]; console.log(`Removed stale models.providers.${provider} (built-in provider)`); models.providers = providers; config.models = models; } } // Ensure gateway mode is set const gateway = (config.gateway || {}) as Record; if (!gateway.mode) { gateway.mode = 'local'; } config.gateway = gateway; // Ensure directory exists const dir = join(configPath, '..'); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`); } interface RuntimeProviderConfigOverride { baseUrl?: string; api?: string; apiKeyEnv?: string; } /** * Update OpenClaw model + provider config using runtime config values. * Useful for user-configurable providers (custom/ollama-like) where * baseUrl/model are not in the static registry. */ export function setOpenClawDefaultModelWithOverride( provider: string, modelOverride: string | undefined, override: RuntimeProviderConfigOverride ): void { const configPath = join(homedir(), '.openclaw', 'openclaw.json'); let config: Record = {}; try { if (existsSync(configPath)) { config = JSON.parse(readFileSync(configPath, 'utf-8')); } } catch (err) { console.warn('Failed to read openclaw.json, creating fresh config:', err); } const model = modelOverride || getProviderDefaultModel(provider); if (!model) { console.warn(`No default model mapping for provider "${provider}"`); return; } const modelId = model.startsWith(`${provider}/`) ? model.slice(provider.length + 1) : model; const agents = (config.agents || {}) as Record; const defaults = (agents.defaults || {}) as Record; defaults.model = { primary: model }; agents.defaults = defaults; config.agents = agents; if (override.baseUrl && override.api) { const models = (config.models || {}) as Record; const providers = (models.providers || {}) as Record; // Replace the provider entry entirely rather than merging. // Different custom/ollama provider instances have different baseUrls, // so merging models from a previous instance creates an inconsistent // config (models pointing at the wrong endpoint). const nextModels: Array> = []; if (modelId) { nextModels.push({ id: modelId, name: modelId }); } const nextProvider: Record = { baseUrl: override.baseUrl, api: override.api, models: nextModels, }; if (override.apiKeyEnv) { nextProvider.apiKey = override.apiKeyEnv; } providers[provider] = nextProvider; models.providers = providers; config.models = models; } const gateway = (config.gateway || {}) as Record; if (!gateway.mode) { gateway.mode = 'local'; } config.gateway = gateway; const dir = join(configPath, '..'); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); console.log( `Set OpenClaw default model to "${model}" for provider "${provider}" (runtime override)` ); } // Re-export for backwards compatibility /** * Write the ClawX gateway token into ~/.openclaw/openclaw.json so the * gateway process reads the same token we use for the WebSocket handshake. * * Without this, openclaw.json may contain a stale token written by the * system-managed gateway service (launchctl), causing a "token mismatch" * auth failure when ClawX connects to the process it just spawned. */ export function syncGatewayTokenToConfig(token: string): void { const configPath = join(homedir(), '.openclaw', 'openclaw.json'); let config: Record = {}; try { if (existsSync(configPath)) { config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record; } } catch { // start from a blank config if the file is corrupt } const gateway = ( config.gateway && typeof config.gateway === 'object' ? { ...(config.gateway as Record) } : {} ) as Record; const auth = ( gateway.auth && typeof gateway.auth === 'object' ? { ...(gateway.auth as Record) } : {} ) as Record; auth.mode = 'token'; auth.token = token; gateway.auth = auth; if (!gateway.mode) gateway.mode = 'local'; config.gateway = gateway; const dir = join(configPath, '..'); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); console.log('Synced gateway token to openclaw.json'); } /** * Ensure browser automation is enabled in ~/.openclaw/openclaw.json with the * "openclaw" managed profile as the default. * * Only sets values that are not already present so existing user * customisation (e.g. switching to a remote CDP profile) is preserved. */ export function syncBrowserConfigToOpenClaw(): void { const configPath = join(homedir(), '.openclaw', 'openclaw.json'); let config: Record = {}; try { if (existsSync(configPath)) { config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record; } } catch { // start from a blank config if the file is corrupt } const browser = ( config.browser && typeof config.browser === 'object' ? { ...(config.browser as Record) } : {} ) as Record; let changed = false; if (browser.enabled === undefined) { browser.enabled = true; changed = true; } if (browser.defaultProfile === undefined) { browser.defaultProfile = 'openclaw'; changed = true; } if (!changed) return; config.browser = browser; const dir = join(configPath, '..'); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); console.log('Synced browser config to openclaw.json'); } /** * Update a provider entry in every discovered agent's models.json. * * The gateway caches resolved provider configs in * ~/.openclaw/agents//agent/models.json and serves requests from * that file (not from openclaw.json directly). We must update it * whenever the active provider changes so the gateway immediately picks * up the new baseUrl / apiKey without requiring a full restart. * * Existing model-level metadata (contextWindow, cost, etc.) is preserved * when the model ID matches; only the top-level provider fields and the * models list are updated. */ export function updateAgentModelProvider( providerType: string, entry: { baseUrl?: string; api?: string; models?: Array<{ id: string; name: string }>; apiKey?: string; } ): void { const agentIds = discoverAgentIds(); for (const agentId of agentIds) { const modelsPath = join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'models.json'); let data: Record = {}; try { if (existsSync(modelsPath)) { data = JSON.parse(readFileSync(modelsPath, 'utf-8')) as Record; } } catch { // corrupt / missing – start with an empty object } const providers = ( data.providers && typeof data.providers === 'object' ? data.providers : {} ) as Record>; const existing: Record = providers[providerType] && typeof providers[providerType] === 'object' ? { ...providers[providerType] } : {}; // Preserve per-model metadata (reasoning, cost, contextWindow…) for // models that already exist; use a minimal stub for new models. const existingModels = Array.isArray(existing.models) ? (existing.models as Array>) : []; const mergedModels = (entry.models ?? []).map((m) => { const prev = existingModels.find((e) => e.id === m.id); return prev ? { ...prev, id: m.id, name: m.name } : { ...m }; }); if (entry.baseUrl !== undefined) existing.baseUrl = entry.baseUrl; if (entry.api !== undefined) existing.api = entry.api; if (mergedModels.length > 0) existing.models = mergedModels; if (entry.apiKey !== undefined) existing.apiKey = entry.apiKey; providers[providerType] = existing; data.providers = providers; try { writeFileSync(modelsPath, JSON.stringify(data, null, 2), 'utf-8'); console.log(`Updated models.json for agent "${agentId}" provider "${providerType}"`); } catch (err) { console.warn(`Failed to update models.json for agent "${agentId}":`, err); } } } export { getProviderEnvVar } from './provider-registry';