/** * Credential proxy for reviewer container isolation. * Containers connect here instead of directly to AI APIs. * The proxy injects real credentials so containers never see them. * * Supports both Claude (Anthropic) and Codex (OpenAI) APIs: * Claude API key: Proxy injects x-api-key on every request. * Claude OAuth: Proxy replaces placeholder Bearer token with real one. * Codex: Proxy injects Authorization: Bearer {OPENAI_API_KEY}. */ import { createServer, Server } from 'http'; import { request as httpsRequest } from 'https'; import { request as httpRequest, RequestOptions } from 'http'; import { getEnv } from './env.js'; import { logger } from './logger.js'; export type AuthMode = 'api-key' | 'oauth'; export function startCredentialProxy( port: number, host = '127.0.0.1', ): Promise { const anthropicApiKey = getEnv('ANTHROPIC_API_KEY') || ''; const oauthToken = getEnv('CLAUDE_CODE_OAUTH_TOKEN') || getEnv('ANTHROPIC_AUTH_TOKEN') || ''; const anthropicBaseUrl = getEnv('ANTHROPIC_BASE_URL') || 'https://api.anthropic.com'; const openaiApiKey = getEnv('OPENAI_API_KEY') || ''; const authMode: AuthMode = anthropicApiKey ? 'api-key' : 'oauth'; const anthropicUrl = new URL(anthropicBaseUrl); const anthropicIsHttps = anthropicUrl.protocol === 'https:'; const makeAnthropicRequest = anthropicIsHttps ? httpsRequest : httpRequest; return new Promise((resolve, reject) => { const server = createServer((req, res) => { const chunks: Buffer[] = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { const body = Buffer.concat(chunks); // Route: Codex/OpenAI requests go to OpenAI API const isOpenAI = req.headers['x-ejclaw-provider'] === 'openai' || (req.url || '').startsWith('/v1/chat/completions'); if (isOpenAI) { proxyToOpenAI(req, res, body, openaiApiKey); return; } // Default: Anthropic/Claude API const headers: Record = { ...(req.headers as Record), host: anthropicUrl.host, 'content-length': body.length, }; delete headers['connection']; delete headers['keep-alive']; delete headers['transfer-encoding']; delete headers['x-ejclaw-provider']; if (authMode === 'api-key') { delete headers['x-api-key']; headers['x-api-key'] = anthropicApiKey; } else if (headers['authorization']) { delete headers['authorization']; if (oauthToken) { headers['authorization'] = `Bearer ${oauthToken}`; } } const upstream = makeAnthropicRequest( { hostname: anthropicUrl.hostname, port: anthropicUrl.port || (anthropicIsHttps ? 443 : 80), path: req.url, method: req.method, headers, } as RequestOptions, (upRes) => { res.writeHead(upRes.statusCode!, upRes.headers); upRes.pipe(res); }, ); upstream.on('error', (err) => { logger.error( { err, url: req.url }, 'Credential proxy upstream error (Anthropic)', ); if (!res.headersSent) { res.writeHead(502); res.end('Bad Gateway'); } }); upstream.write(body); upstream.end(); }); }); server.listen(port, host, () => { logger.info({ port, host, authMode }, 'Credential proxy started'); resolve(server); }); server.on('error', reject); }); } function proxyToOpenAI( req: import('http').IncomingMessage, res: import('http').ServerResponse, body: Buffer, apiKey: string, ): void { const headers: Record = { ...(req.headers as Record), host: 'api.openai.com', 'content-length': body.length, }; delete headers['connection']; delete headers['keep-alive']; delete headers['transfer-encoding']; delete headers['x-ejclaw-provider']; // Inject real OpenAI API key delete headers['authorization']; if (apiKey) { headers['authorization'] = `Bearer ${apiKey}`; } const upstream = httpsRequest( { hostname: 'api.openai.com', port: 443, path: req.url, method: req.method, headers, } as RequestOptions, (upRes) => { res.writeHead(upRes.statusCode!, upRes.headers); upRes.pipe(res); }, ); upstream.on('error', (err) => { logger.error( { err, url: req.url }, 'Credential proxy upstream error (OpenAI)', ); if (!res.headersSent) { res.writeHead(502); res.end('Bad Gateway'); } }); upstream.write(body); upstream.end(); } export function detectAuthMode(): AuthMode { return getEnv('ANTHROPIC_API_KEY') ? 'api-key' : 'oauth'; }