import { existsSync, readFileSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; import { CredentialError } from './types.js'; import type { ProviderRegistry } from './types.js'; export const DEFAULT_CREDENTIALS_DIR = resolve(join(homedir(), '.config', 'mosaic', 'credentials')); export const OC_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json'); export const REDACTED_MARKER = '__OPENCLAW_REDACTED__'; export const PROVIDER_REGISTRY: ProviderRegistry = { anthropic: { credential_file: 'anthropic.env', env_var: 'ANTHROPIC_API_KEY', oc_env_key: 'ANTHROPIC_API_KEY', oc_provider_path: 'anthropic', }, openai: { credential_file: 'openai.env', env_var: 'OPENAI_API_KEY', oc_env_key: 'OPENAI_API_KEY', oc_provider_path: 'openai', }, zai: { credential_file: 'zai.env', env_var: 'ZAI_API_KEY', oc_env_key: 'ZAI_API_KEY', oc_provider_path: 'zai', }, }; export function extractProvider(modelRef: string): string { const provider = String(modelRef).trim().split('/')[0]?.trim().toLowerCase() ?? ''; if (!provider) { throw new CredentialError(`Unable to resolve provider from model reference: '${modelRef}'`); } if (!(provider in PROVIDER_REGISTRY)) { throw new CredentialError(`Unsupported credential provider: ${provider}`); } return provider; } export function parseDotenv(content: string): Record { const parsed: Record = {}; for (const rawLine of content.split('\n')) { const line = rawLine.trim(); if (!line || line.startsWith('#')) continue; if (!line.includes('=')) continue; const eqIdx = line.indexOf('='); const key = line.slice(0, eqIdx).trim(); if (!key) continue; let value = line.slice(eqIdx + 1).trim(); if ( value.length >= 2 && value[0] === value[value.length - 1] && (value[0] === '"' || value[0] === "'") ) { value = value.slice(1, -1); } parsed[key] = value; } return parsed; } function loadCredentialFile(path: string): Record { if (!existsSync(path)) return {}; return parseDotenv(readFileSync(path, 'utf-8')); } export function stripJSON5Extensions(content: string): string { const strings: string[] = []; const MARKER = '\x00OCSTR'; // 1. Remove full-line comments content = content.replace(/^\s*\/\/[^\n]*$/gm, ''); // 2. Protect single-quoted strings content = content.replace(/'([^']*)'/g, (_m, g1: string) => { const idx = strings.length; strings.push(g1); return `${MARKER}${idx}\x00`; }); // 3. Protect double-quoted strings content = content.replace(/"([^"]*)"/g, (_m, g1: string) => { const idx = strings.length; strings.push(g1); return `${MARKER}${idx}\x00`; }); // 4. Structural transforms — safe because strings are now placeholders content = content.replace(/,\s*([}\]])/g, '$1'); content = content.replace(/\b(\w[\w-]*)\b(?=\s*:)/g, '"$1"'); // 5. Restore string values with proper JSON escaping for (let i = 0; i < strings.length; i++) { content = content.replace(`${MARKER}${i}\x00`, JSON.stringify(strings[i]!)); } return content; } export interface PermissionCheckOptions { ocConfigPath?: string; } export function checkOCConfigPermissions(path: string, opts?: { getuid?: () => number }): boolean { if (!existsSync(path)) return false; const stat = statSync(path); const mode = stat.mode & 0o777; if (mode & 0o077) { // world/group readable — log warning (matches Python behavior) } const getuid = opts?.getuid ?? process.getuid?.bind(process); if (getuid && stat.uid !== getuid()) { return false; } return true; } export function isValidCredential(value: string): boolean { const stripped = String(value).trim(); return stripped.length > 0 && stripped !== REDACTED_MARKER; } function loadOCConfigCredentials( provider: string, envVar: string, ocConfigPath?: string, ): Record { const configPath = ocConfigPath ?? OC_CONFIG_PATH; if (!existsSync(configPath)) return {}; try { if (!checkOCConfigPermissions(configPath)) return {}; const rawContent = readFileSync(configPath, 'utf-8'); const config = JSON.parse(stripJSON5Extensions(rawContent)) as Record; const providerMeta = PROVIDER_REGISTRY[provider]; const ocEnvKey = providerMeta?.oc_env_key ?? envVar; const envBlock = config['env']; if (typeof envBlock === 'object' && envBlock !== null && !Array.isArray(envBlock)) { const envValue = (envBlock as Record)[ocEnvKey]; if (typeof envValue === 'string' && isValidCredential(envValue)) { return { [envVar]: envValue.trim() }; } } const models = config['models']; const providers = typeof models === 'object' && models !== null && !Array.isArray(models) ? ((models as Record)['providers'] as Record | undefined) : undefined; const ocProviderPath = providerMeta?.oc_provider_path ?? provider; if (typeof providers === 'object' && providers !== null && !Array.isArray(providers)) { const providerConfig = providers[ocProviderPath]; if ( typeof providerConfig === 'object' && providerConfig !== null && !Array.isArray(providerConfig) ) { const apiKey = (providerConfig as Record)['apiKey']; if (typeof apiKey === 'string' && isValidCredential(apiKey)) { return { [envVar]: apiKey.trim() }; } } } } catch { return {}; } return {}; } function resolveTargetEnvVar( provider: string, taskConfig?: Record | null, ): string { const providerMeta = PROVIDER_REGISTRY[provider]!; const rawCredentials = typeof taskConfig === 'object' && taskConfig !== null ? (taskConfig['credentials'] as Record | undefined) : undefined; const credentials = typeof rawCredentials === 'object' && rawCredentials !== null ? rawCredentials : {}; const envVar = String(credentials['provider_key_env'] || providerMeta.env_var).trim(); if (!envVar) { throw new CredentialError(`Invalid credential env var override for provider: ${provider}`); } return envVar; } export interface ResolveCredentialsOptions { taskConfig?: Record | null; credentialsDir?: string; ocConfigPath?: string; } export function resolveCredentials( modelRef: string, opts?: ResolveCredentialsOptions, ): Record { const provider = extractProvider(modelRef); const providerMeta = PROVIDER_REGISTRY[provider]!; const envVar = resolveTargetEnvVar(provider, opts?.taskConfig); const credentialRoot = resolve(opts?.credentialsDir ?? DEFAULT_CREDENTIALS_DIR); const credentialFile = join(credentialRoot, providerMeta.credential_file); // 1. Mosaic credential file const fileValues = loadCredentialFile(credentialFile); const fileValue = (fileValues[envVar] ?? '').trim(); if (fileValue) { return { [envVar]: fileValue }; } // 2. OpenClaw config const ocValues = loadOCConfigCredentials(provider, envVar, opts?.ocConfigPath); if (Object.keys(ocValues).length > 0) { return ocValues; } // 3. Ambient environment const ambientValue = String(process.env[envVar] ?? '').trim(); if (ambientValue) { return { [envVar]: ambientValue }; } throw new CredentialError( `Missing required credential ${envVar} for provider ${provider} ` + `(checked ${credentialFile}, OC config, then ambient environment)`, ); }