refactor: simplify paired review system and add reviewer container isolation
Phase 1 — Strip over-engineering (-3,665 lines): - DB tables: 7 → 3 (remove executions, approvals, artifacts, events) - Task statuses: 11 → 5 (active, review_ready, in_review, merge_ready, completed) - Remove plan governance, event sourcing, gate/verdict, freshness guards - paired-execution-context: 980 → 299 lines - Session commands: remove /risk, /plan, /approve-plan, /request-plan-changes Phase 2 — Container isolation for reviewers: - Add container-runtime.ts (Docker abstraction) - Add credential-proxy.ts (API key injection without container exposure) - Add container-runner.ts (reviewer-specific read-only mount + tmpfs) - Add container/Dockerfile + agent-runner (Chromium, Claude Code, Codex) - agent-runner.ts: auto-route reviewer execution to container mode
This commit is contained in:
172
src/credential-proxy.ts
Normal file
172
src/credential-proxy.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 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<Server> {
|
||||
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<
|
||||
string,
|
||||
string | number | string[] | undefined
|
||||
> = {
|
||||
...(req.headers as Record<string, string>),
|
||||
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<string, string | number | string[] | undefined> = {
|
||||
...(req.headers as Record<string, string>),
|
||||
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';
|
||||
}
|
||||
Reference in New Issue
Block a user