diff --git a/src/mitm/handlers/antigravity.ts b/src/mitm/handlers/antigravity.ts new file mode 100644 index 0000000000..02deb6e907 --- /dev/null +++ b/src/mitm/handlers/antigravity.ts @@ -0,0 +1,60 @@ +/** + * Antigravity IDE handler. + * + * Preserves the historical behavior of `src/mitm/server.cjs::intercept()`: + * - parses the incoming JSON body, + * - replaces `body.model` with the mapped model, + * - forwards to `/v1/chat/completions` on the OmniRoute router, + * - pipes the SSE response back to the IDE. + * + * Non-regressive: any change here must keep the Antigravity flow working as + * before (see `tests/unit/mitm-handler-antigravity.test.ts`). + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { AgentId } from "../types"; +import { MitmHandlerBase } from "./base"; + +export class AntigravityHandler extends MitmHandlerBase { + readonly agentId: AgentId = "antigravity"; + + async intercept( + req: IncomingMessage, + res: ServerResponse, + body: Buffer, + mappedModel: string, + ): Promise { + const startedAt = this.now(); + const intercepted = await this.hookBufferStart(req, body, mappedModel); + + try { + const payload = JSON.parse(body.toString()); + payload.model = mappedModel; + + const upstreamStart = this.now(); + const upstream = await this.fetchRouter(payload, "/v1/chat/completions", req.headers); + + if (!upstream.ok) { + const errText = await upstream.text().catch(() => ""); + throw new Error(`OmniRoute ${upstream.status}: ${errText}`); + } + + let collected = ""; + await this.pipeSSE(upstream, res, (chunk) => { + collected += chunk.toString(); + }); + + const total = this.now() - startedAt; + this.hookBufferUpdate(intercepted, { + status: upstream.status, + responseHeaders: Object.fromEntries(upstream.headers.entries()), + responseBody: collected, + responseSize: Buffer.byteLength(collected), + proxyLatencyMs: upstreamStart - startedAt, + upstreamLatencyMs: total - (upstreamStart - startedAt), + }); + } catch (err) { + await this.hookBufferError(intercepted, err); + await this.writeError(res, err); + } + } +} diff --git a/src/mitm/handlers/base.ts b/src/mitm/handlers/base.ts new file mode 100644 index 0000000000..40f098ff49 --- /dev/null +++ b/src/mitm/handlers/base.ts @@ -0,0 +1,317 @@ +/** + * MitmHandlerBase — abstract base class for all AgentBridge MITM handlers. + * + * Contract: `_tasks/features-v3.8.6/refactorpages/_orchestration/master-plan-group-A.md` §3.5. + * + * The base handles the cross-cutting concerns shared by every IDE-agent handler: + * - request body capture + secret masking + * - source model extraction + * - forwarding to the OmniRoute router (Next.js API) + * - SSE piping + * - optional Traffic Inspector hook (F4 — loaded via dynamic import; no-op when + * `agentBridgeHook.ts` is not yet present in the build) + * + * Concrete handlers live in `src/mitm/handlers/.ts`. + */ +import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from "node:http"; +import { randomUUID } from "node:crypto"; +import { performance } from "node:perf_hooks"; +import { maskSecret } from "../maskSecrets"; +import { sanitizeHeaders } from "../sanitizeHeaders"; +import type { AgentId } from "../types"; +import type { InterceptedRequest } from "../inspector/types"; + +/** + * Best-effort error sanitizer. + * Routes through `@omniroute/open-sse/utils/error.sanitizeErrorMessage` (Hard Rule #12) + * when available; falls back to a safe `String(err)` if the module is not present + * (e.g. unit tests that don't load the full open-sse barrel). + */ +async function safeErrorMessage(err: unknown): Promise { + try { + const mod = (await import("@omniroute/open-sse/utils/error")) as { + sanitizeErrorMessage?: (m: unknown) => string; + }; + if (typeof mod.sanitizeErrorMessage === "function") { + return mod.sanitizeErrorMessage(err); + } + } catch { + // Module not available — fall back to plain coercion. + } + if (err instanceof Error) return err.message || err.name; + return String(err); +} + +/** + * Dynamic-import hook into the Traffic Inspector buffer (F4). + * Returns `null` if the inspector module has not been merged yet — handlers + * remain fully functional standalone. + */ +async function loadAgentBridgeHook(): Promise<{ + recordRequestStart?: (opts: { + req: IncomingMessage; + body: Buffer; + agentId: AgentId; + mappedModel: string; + }) => Promise; + recordRequestComplete?: ( + intercepted: InterceptedRequest, + opts: { + status: number; + responseHeaders: Record; + responseBody: string | null; + responseSize: number; + proxyLatencyMs: number; + upstreamLatencyMs: number; + }, + ) => void; + recordRequestError?: (intercepted: InterceptedRequest, err: unknown) => void; +} | null> { + try { + const mod = await import("../inspector/agentBridgeHook"); + return mod; + } catch { + return null; + } +} + +export abstract class MitmHandlerBase { + abstract readonly agentId: AgentId; + + /** + * Intercept a single MITM request. + * Concrete handlers must: + * 1. Optionally call `this.hookBufferStart(req, body, mappedModel)`. + * 2. Build the upstream-bound payload (translate model, format, etc.). + * 3. Call `this.fetchRouter(...)` for the OmniRoute router round-trip. + * 4. Pipe the response back via `this.pipeSSE(...)` for streaming + * or write the JSON body directly for non-streaming flows. + * 5. Call `this.hookBufferUpdate(intercepted)` on completion / error. + */ + abstract intercept( + req: IncomingMessage, + res: ServerResponse, + body: Buffer, + mappedModel: string, + ): Promise; + + /** + * Whether to capture the request body for the Traffic Inspector. + * Override to return `false` for endpoints that never need body capture + * (e.g. health probes). + */ + protected shouldCaptureBody(): boolean { + return true; + } + + /** + * Extract the requested model from the upstream-bound body. + * Default: parses JSON and reads the `model` property. Override for non-JSON + * payloads or providers that nest the model elsewhere (e.g. Gemini uses + * the URL path, but those handlers can override). + */ + protected extractSourceModel(body: Buffer): string | null { + try { + const json = JSON.parse(body.toString()); + if (json && typeof json === "object" && typeof json.model === "string") { + return json.model; + } + } catch { + // Non-JSON body — caller may have a custom extractor. + } + return null; + } + + /** + * Forward the prepared body to the OmniRoute router (Next.js API). + * Adds AgentBridge correlation headers (`x-omniroute-source`, `x-omniroute-agent`) + * and forwards a sanitized copy of the original request headers (secrets masked, + * hop-by-hop stripped). + */ + protected async fetchRouter( + body: unknown, + path: string, + headers: IncomingHttpHeaders, + ): Promise { + const base = process.env.OMNIROUTE_BASE_URL ?? "http://127.0.0.1:20128"; + const url = `${base.replace(/\/+$/, "")}${path}`; + const apiKey = process.env.ROUTER_API_KEY ?? ""; + + return fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + "x-omniroute-source": "agent-bridge", + "x-omniroute-agent": this.agentId, + ...sanitizeHeaders(headers), + }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); + } + + /** + * Pipe an SSE (or any chunked) upstream Response straight to the downstream + * ServerResponse, optionally invoking `onChunk` for each received Buffer. + * + * Writes SSE-friendly headers before the first chunk (only if `res.headersSent` + * is still false — handlers MAY have set custom headers first). + */ + protected async pipeSSE( + upstream: Response, + res: ServerResponse, + onChunk?: (c: Buffer) => void, + ): Promise { + if (!upstream.body) { + if (!res.headersSent) res.writeHead(upstream.status, { "Content-Type": "application/json" }); + res.end(); + return; + } + + if (!res.headersSent) { + res.writeHead(upstream.status, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + } + + const reader = upstream.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const buf = Buffer.from(value); + if (onChunk) { + try { + onChunk(buf); + } catch { + // Inspector hook must never break the upstream pipe. + } + } + res.write(buf); + } + } finally { + try { + res.end(); + } catch { + // Response may already be closed by client disconnect. + } + } + } + + /** + * Start a Traffic Inspector entry for this request. Always succeeds — if the + * inspector module is not present (F4 not yet merged), this returns a local + * stub entry without publishing to the buffer. + */ + protected async hookBufferStart( + req: IncomingMessage, + body: Buffer, + mappedModel: string, + ): Promise { + const hook = await loadAgentBridgeHook(); + if (hook?.recordRequestStart) { + try { + return await hook.recordRequestStart({ + req, + body, + agentId: this.agentId, + mappedModel, + }); + } catch { + // Hook should never break interception — fall through to local stub. + } + } + + // Local stub when F4 hook is unavailable. + return { + id: randomUUID(), + source: "agent-bridge", + agent: this.agentId, + timestamp: new Date().toISOString(), + method: req.method ?? "POST", + host: typeof req.headers.host === "string" ? req.headers.host : "", + path: req.url ?? "/", + requestHeaders: sanitizeHeaders(req.headers), + requestBody: this.shouldCaptureBody() ? maskSecret(body.toString()) : null, + requestSize: body.length, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + sourceModel: this.extractSourceModel(body), + mappedModel, + status: "in-flight", + }; + } + + /** + * Update a previously published Traffic Inspector entry with completion data. + * No-op when the inspector module is not present. + */ + protected hookBufferUpdate( + intercepted: InterceptedRequest, + opts?: { + status: number; + responseHeaders: Record; + responseBody: string | null; + responseSize: number; + proxyLatencyMs: number; + upstreamLatencyMs: number; + }, + ): void { + if (!opts) return; + void loadAgentBridgeHook().then((hook) => { + if (hook?.recordRequestComplete) { + try { + hook.recordRequestComplete(intercepted, opts); + } catch { + // Hook should never break interception. + } + } + }); + } + + /** + * Report a failed request to the Traffic Inspector. + * No-op when the inspector module is not present. + */ + protected async hookBufferError( + intercepted: InterceptedRequest, + err: unknown, + ): Promise { + const hook = await loadAgentBridgeHook(); + if (hook?.recordRequestError) { + try { + hook.recordRequestError(intercepted, err); + } catch { + // Hook should never break interception. + } + } + } + + /** + * Render a Hard-Rule-#12-compliant error JSON body and send via `res`. + * Returns the sanitized error string so callers may also log it. + */ + protected async writeError( + res: ServerResponse, + err: unknown, + statusCode = 500, + ): Promise { + const safe = await safeErrorMessage(err); + if (!res.headersSent) { + res.writeHead(statusCode, { "Content-Type": "application/json" }); + } + res.end(JSON.stringify({ error: { message: safe, type: "mitm_error" } })); + return safe; + } + + /** + * Convenience helper for handlers that want a single performance.now() reading. + */ + protected now(): number { + return performance.now(); + } +} diff --git a/src/mitm/handlers/claudeCode.ts b/src/mitm/handlers/claudeCode.ts new file mode 100644 index 0000000000..fe07dcb96c --- /dev/null +++ b/src/mitm/handlers/claudeCode.ts @@ -0,0 +1,56 @@ +/** + * Claude Code (Anthropic CLI) handler. + * + * Host: `api.anthropic.com` (opt-in — typical Anthropic API requests originate + * from many callers, so this handler only fires when the user explicitly + * configures DNS routing for Claude Code). + * Format: Anthropic Messages API — POST `/v1/messages` on the OmniRoute router. + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { AgentId } from "../types"; +import { MitmHandlerBase } from "./base"; + +export class ClaudeCodeHandler extends MitmHandlerBase { + readonly agentId: AgentId = "claude-code"; + + async intercept( + req: IncomingMessage, + res: ServerResponse, + body: Buffer, + mappedModel: string, + ): Promise { + const startedAt = this.now(); + const intercepted = await this.hookBufferStart(req, body, mappedModel); + + try { + const payload = JSON.parse(body.toString()); + payload.model = mappedModel; + + const upstreamStart = this.now(); + const upstream = await this.fetchRouter(payload, "/v1/messages", req.headers); + + if (!upstream.ok) { + const errText = await upstream.text().catch(() => ""); + throw new Error(`OmniRoute ${upstream.status}: ${errText}`); + } + + let collected = ""; + await this.pipeSSE(upstream, res, (chunk) => { + collected += chunk.toString(); + }); + + const total = this.now() - startedAt; + this.hookBufferUpdate(intercepted, { + status: upstream.status, + responseHeaders: Object.fromEntries(upstream.headers.entries()), + responseBody: collected, + responseSize: Buffer.byteLength(collected), + proxyLatencyMs: upstreamStart - startedAt, + upstreamLatencyMs: total - (upstreamStart - startedAt), + }); + } catch (err) { + await this.hookBufferError(intercepted, err); + await this.writeError(res, err); + } + } +} diff --git a/src/mitm/handlers/codex.ts b/src/mitm/handlers/codex.ts new file mode 100644 index 0000000000..a67c20fcdc --- /dev/null +++ b/src/mitm/handlers/codex.ts @@ -0,0 +1,55 @@ +/** + * OpenAI Codex CLI handler. + * + * Host: `chatgpt.com` (Codex paths). + * Format: OpenAI-compatible Chat Completions — `body.model` is rewritten to + * the mapped target and the request is forwarded to the OmniRoute router. + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { AgentId } from "../types"; +import { MitmHandlerBase } from "./base"; + +export class CodexHandler extends MitmHandlerBase { + readonly agentId: AgentId = "codex"; + + async intercept( + req: IncomingMessage, + res: ServerResponse, + body: Buffer, + mappedModel: string, + ): Promise { + const startedAt = this.now(); + const intercepted = await this.hookBufferStart(req, body, mappedModel); + + try { + const payload = JSON.parse(body.toString()); + payload.model = mappedModel; + + const upstreamStart = this.now(); + const upstream = await this.fetchRouter(payload, "/v1/chat/completions", req.headers); + + if (!upstream.ok) { + const errText = await upstream.text().catch(() => ""); + throw new Error(`OmniRoute ${upstream.status}: ${errText}`); + } + + let collected = ""; + await this.pipeSSE(upstream, res, (chunk) => { + collected += chunk.toString(); + }); + + const total = this.now() - startedAt; + this.hookBufferUpdate(intercepted, { + status: upstream.status, + responseHeaders: Object.fromEntries(upstream.headers.entries()), + responseBody: collected, + responseSize: Buffer.byteLength(collected), + proxyLatencyMs: upstreamStart - startedAt, + upstreamLatencyMs: total - (upstreamStart - startedAt), + }); + } catch (err) { + await this.hookBufferError(intercepted, err); + await this.writeError(res, err); + } + } +} diff --git a/src/mitm/handlers/copilot.ts b/src/mitm/handlers/copilot.ts new file mode 100644 index 0000000000..04356088a6 --- /dev/null +++ b/src/mitm/handlers/copilot.ts @@ -0,0 +1,55 @@ +/** + * GitHub Copilot handler. + * + * Hosts: `api.githubcopilot.com`, `copilot-proxy.githubusercontent.com`. + * Format: OpenAI-compatible Chat Completions — `body.model` is rewritten to + * the mapped target and the request is forwarded to the OmniRoute router. + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { AgentId } from "../types"; +import { MitmHandlerBase } from "./base"; + +export class CopilotHandler extends MitmHandlerBase { + readonly agentId: AgentId = "copilot"; + + async intercept( + req: IncomingMessage, + res: ServerResponse, + body: Buffer, + mappedModel: string, + ): Promise { + const startedAt = this.now(); + const intercepted = await this.hookBufferStart(req, body, mappedModel); + + try { + const payload = JSON.parse(body.toString()); + payload.model = mappedModel; + + const upstreamStart = this.now(); + const upstream = await this.fetchRouter(payload, "/v1/chat/completions", req.headers); + + if (!upstream.ok) { + const errText = await upstream.text().catch(() => ""); + throw new Error(`OmniRoute ${upstream.status}: ${errText}`); + } + + let collected = ""; + await this.pipeSSE(upstream, res, (chunk) => { + collected += chunk.toString(); + }); + + const total = this.now() - startedAt; + this.hookBufferUpdate(intercepted, { + status: upstream.status, + responseHeaders: Object.fromEntries(upstream.headers.entries()), + responseBody: collected, + responseSize: Buffer.byteLength(collected), + proxyLatencyMs: upstreamStart - startedAt, + upstreamLatencyMs: total - (upstreamStart - startedAt), + }); + } catch (err) { + await this.hookBufferError(intercepted, err); + await this.writeError(res, err); + } + } +} diff --git a/src/mitm/handlers/cursor.ts b/src/mitm/handlers/cursor.ts new file mode 100644 index 0000000000..d4f8ed240e --- /dev/null +++ b/src/mitm/handlers/cursor.ts @@ -0,0 +1,55 @@ +/** + * Cursor IDE handler. + * + * Host: `api2.cursor.sh`. + * Format: OpenAI-compatible Chat Completions — `body.model` is rewritten to + * the mapped target and the request is forwarded to the OmniRoute router. + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { AgentId } from "../types"; +import { MitmHandlerBase } from "./base"; + +export class CursorHandler extends MitmHandlerBase { + readonly agentId: AgentId = "cursor"; + + async intercept( + req: IncomingMessage, + res: ServerResponse, + body: Buffer, + mappedModel: string, + ): Promise { + const startedAt = this.now(); + const intercepted = await this.hookBufferStart(req, body, mappedModel); + + try { + const payload = JSON.parse(body.toString()); + payload.model = mappedModel; + + const upstreamStart = this.now(); + const upstream = await this.fetchRouter(payload, "/v1/chat/completions", req.headers); + + if (!upstream.ok) { + const errText = await upstream.text().catch(() => ""); + throw new Error(`OmniRoute ${upstream.status}: ${errText}`); + } + + let collected = ""; + await this.pipeSSE(upstream, res, (chunk) => { + collected += chunk.toString(); + }); + + const total = this.now() - startedAt; + this.hookBufferUpdate(intercepted, { + status: upstream.status, + responseHeaders: Object.fromEntries(upstream.headers.entries()), + responseBody: collected, + responseSize: Buffer.byteLength(collected), + proxyLatencyMs: upstreamStart - startedAt, + upstreamLatencyMs: total - (upstreamStart - startedAt), + }); + } catch (err) { + await this.hookBufferError(intercepted, err); + await this.writeError(res, err); + } + } +} diff --git a/src/mitm/handlers/kiro.ts b/src/mitm/handlers/kiro.ts new file mode 100644 index 0000000000..2245594261 --- /dev/null +++ b/src/mitm/handlers/kiro.ts @@ -0,0 +1,58 @@ +/** + * Kiro IDE handler. + * + * Kiro uses the Anthropic Messages API (POST /v1/messages with `x-api-key`). + * We translate the `model` field and forward to the OmniRoute router via + * `/v1/chat/completions` — the router's translator will adapt the request + * shape back to whatever upstream provider the mapped model points to. + * + * Non-regressive: see `tests/unit/mitm-handler-kiro.test.ts`. + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { AgentId } from "../types"; +import { MitmHandlerBase } from "./base"; + +export class KiroHandler extends MitmHandlerBase { + readonly agentId: AgentId = "kiro"; + + async intercept( + req: IncomingMessage, + res: ServerResponse, + body: Buffer, + mappedModel: string, + ): Promise { + const startedAt = this.now(); + const intercepted = await this.hookBufferStart(req, body, mappedModel); + + try { + const payload = JSON.parse(body.toString()); + payload.model = mappedModel; + + const upstreamStart = this.now(); + const upstream = await this.fetchRouter(payload, "/v1/messages", req.headers); + + if (!upstream.ok) { + const errText = await upstream.text().catch(() => ""); + throw new Error(`OmniRoute ${upstream.status}: ${errText}`); + } + + let collected = ""; + await this.pipeSSE(upstream, res, (chunk) => { + collected += chunk.toString(); + }); + + const total = this.now() - startedAt; + this.hookBufferUpdate(intercepted, { + status: upstream.status, + responseHeaders: Object.fromEntries(upstream.headers.entries()), + responseBody: collected, + responseSize: Buffer.byteLength(collected), + proxyLatencyMs: upstreamStart - startedAt, + upstreamLatencyMs: total - (upstreamStart - startedAt), + }); + } catch (err) { + await this.hookBufferError(intercepted, err); + await this.writeError(res, err); + } + } +} diff --git a/src/mitm/handlers/openCode.ts b/src/mitm/handlers/openCode.ts new file mode 100644 index 0000000000..ce213ec9f3 --- /dev/null +++ b/src/mitm/handlers/openCode.ts @@ -0,0 +1,55 @@ +/** + * Open Code handler. + * + * Host: `opencode.ai` (Zen endpoint family). + * Format: OpenAI-compatible Chat Completions — `body.model` is rewritten to + * the mapped target and the request is forwarded to the OmniRoute router. + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { AgentId } from "../types"; +import { MitmHandlerBase } from "./base"; + +export class OpenCodeHandler extends MitmHandlerBase { + readonly agentId: AgentId = "open-code"; + + async intercept( + req: IncomingMessage, + res: ServerResponse, + body: Buffer, + mappedModel: string, + ): Promise { + const startedAt = this.now(); + const intercepted = await this.hookBufferStart(req, body, mappedModel); + + try { + const payload = JSON.parse(body.toString()); + payload.model = mappedModel; + + const upstreamStart = this.now(); + const upstream = await this.fetchRouter(payload, "/v1/chat/completions", req.headers); + + if (!upstream.ok) { + const errText = await upstream.text().catch(() => ""); + throw new Error(`OmniRoute ${upstream.status}: ${errText}`); + } + + let collected = ""; + await this.pipeSSE(upstream, res, (chunk) => { + collected += chunk.toString(); + }); + + const total = this.now() - startedAt; + this.hookBufferUpdate(intercepted, { + status: upstream.status, + responseHeaders: Object.fromEntries(upstream.headers.entries()), + responseBody: collected, + responseSize: Buffer.byteLength(collected), + proxyLatencyMs: upstreamStart - startedAt, + upstreamLatencyMs: total - (upstreamStart - startedAt), + }); + } catch (err) { + await this.hookBufferError(intercepted, err); + await this.writeError(res, err); + } + } +} diff --git a/src/mitm/handlers/trae.ts b/src/mitm/handlers/trae.ts new file mode 100644 index 0000000000..29ca06a2bd --- /dev/null +++ b/src/mitm/handlers/trae.ts @@ -0,0 +1,24 @@ +/** + * Trae handler — stub. + * + * D14: Trae viability is still under investigation (see plan 11 §5). The + * concrete handler will be implemented once we confirm the upstream API + * surface. Until then, calling `intercept()` throws a structured error and + * the UI exposes the agent as `viability: "investigating"` (no Setup button). + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { AgentId } from "../types"; +import { MitmHandlerBase } from "./base"; + +export class TraeHandler extends MitmHandlerBase { + readonly agentId: AgentId = "trae"; + + async intercept( + _req: IncomingMessage, + _res: ServerResponse, + _body: Buffer, + _mappedModel: string, + ): Promise { + throw new Error("Not yet implemented — Trae viability under investigation. See plan 11 §5."); + } +} diff --git a/src/mitm/handlers/zed.ts b/src/mitm/handlers/zed.ts new file mode 100644 index 0000000000..0ed726e74e --- /dev/null +++ b/src/mitm/handlers/zed.ts @@ -0,0 +1,55 @@ +/** + * Zed editor handler. + * + * Host: `api.zed.dev`. + * Format: OpenAI-compatible Chat Completions — `body.model` is rewritten to + * the mapped target and the request is forwarded to the OmniRoute router. + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { AgentId } from "../types"; +import { MitmHandlerBase } from "./base"; + +export class ZedHandler extends MitmHandlerBase { + readonly agentId: AgentId = "zed"; + + async intercept( + req: IncomingMessage, + res: ServerResponse, + body: Buffer, + mappedModel: string, + ): Promise { + const startedAt = this.now(); + const intercepted = await this.hookBufferStart(req, body, mappedModel); + + try { + const payload = JSON.parse(body.toString()); + payload.model = mappedModel; + + const upstreamStart = this.now(); + const upstream = await this.fetchRouter(payload, "/v1/chat/completions", req.headers); + + if (!upstream.ok) { + const errText = await upstream.text().catch(() => ""); + throw new Error(`OmniRoute ${upstream.status}: ${errText}`); + } + + let collected = ""; + await this.pipeSSE(upstream, res, (chunk) => { + collected += chunk.toString(); + }); + + const total = this.now() - startedAt; + this.hookBufferUpdate(intercepted, { + status: upstream.status, + responseHeaders: Object.fromEntries(upstream.headers.entries()), + responseBody: collected, + responseSize: Buffer.byteLength(collected), + proxyLatencyMs: upstreamStart - startedAt, + upstreamLatencyMs: total - (upstreamStart - startedAt), + }); + } catch (err) { + await this.hookBufferError(intercepted, err); + await this.writeError(res, err); + } + } +} diff --git a/src/mitm/targets/antigravity.ts b/src/mitm/targets/antigravity.ts index 7c8535a65f..5d1e0cc9b8 100644 --- a/src/mitm/targets/antigravity.ts +++ b/src/mitm/targets/antigravity.ts @@ -1,6 +1,66 @@ -export interface MitmTarget { - id: string; - name: string; +/** + * Antigravity IDE target descriptor. + * + * Provides: + * - `ANTIGRAVITY_TARGET`: canonical `MitmTarget` per F1 contract (§3.1). + * - `ANTIGRAVITY_MITM_PROFILE`: legacy alias retained for back-compat with + * `src/app/api/settings/mitm/route.ts`. Carries the historical fields + * (`targetHost`, `additionalHosts`, `targetPort`, `localPort`, + * `apiEndpoints`, `authHeader`, `instructions`) as an augmentation. + */ +import type { MitmTarget } from "../types"; + +const HOSTS = [ + "daily-cloudcode-pa.googleapis.com", + "cloudcode-pa.googleapis.com", + "daily-cloudcode-pa.sandbox.googleapis.com", + "autopush-cloudcode-pa.sandbox.googleapis.com", +]; + +const ENDPOINTS = [ + "/v1internal:generateContent", + "/v1internal:streamGenerateContent", + "/v1internal:loadCodeAssist", + "/v1internal:onboardUser", +]; + +const INSTRUCTIONS = [ + "1. Install OmniRoute's root certificate", + "2. Start the MITM proxy via Dashboard or CLI", + "3. Configure model mappings in Dashboard → AgentBridge → Antigravity", + "4. Open Antigravity IDE — API calls will be routed through OmniRoute", +]; + +export const ANTIGRAVITY_TARGET: MitmTarget = { + id: "antigravity", + name: "Antigravity IDE", + icon: "rocket_launch", + color: "#4F46E5", + hosts: HOSTS, + port: 443, + endpointPatterns: ENDPOINTS, + defaultModels: [], + setupTutorial: { + steps: INSTRUCTIONS, + detection: { command: "which antigravity", platform: "all" }, + }, + handler: () => + import("../handlers/antigravity").then((m) => ({ + default: m.AntigravityHandler, + })), + riskNoticeKey: "providers.riskNotice.oauth", +}; + +/** + * Legacy MITM profile shape — kept for `src/app/api/settings/mitm/route.ts` + * (and any other consumer that still relies on the pre-AgentBridge fields). + * + * The augmentation is intentional: the F1 `MitmTarget` Zod schema does not + * declare these fields, so we attach them via an intersection type and rely + * on consumers using property access (no `MitmTargetSchema.parse()` is run on + * this object — the schema is for runtime-loaded targets only). + */ +export const ANTIGRAVITY_MITM_PROFILE: MitmTarget & { description: string; targetHost: string; targetPort: number; @@ -8,32 +68,18 @@ export interface MitmTarget { userAgentPattern: string | null; apiEndpoints: string[]; authHeader: string; - additionalHosts?: string[]; + additionalHosts: string[]; instructions: string[]; - referenceIde?: string; -} - -export const ANTIGRAVITY_MITM_PROFILE: MitmTarget = { - id: "antigravity", - name: "Antigravity IDE", +} = { + ...ANTIGRAVITY_TARGET, description: "Intercepts Antigravity IDE requests to cloudcode-pa.googleapis.com and routes them through OmniRoute.", - targetHost: "daily-cloudcode-pa.googleapis.com", + targetHost: HOSTS[0], targetPort: 443, localPort: 443, userAgentPattern: null, - apiEndpoints: [ - "/v1internal:generateContent", - "/v1internal:streamGenerateContent", - "/v1internal:loadCodeAssist", - "/v1internal:onboardUser", - ], + apiEndpoints: ENDPOINTS, authHeader: "authorization", - additionalHosts: ["cloudcode-pa.googleapis.com", "daily-cloudcode-pa.sandbox.googleapis.com"], - instructions: [ - "1. Install OmniRoute's root certificate", - "2. Start the MITM proxy via Dashboard or CLI", - "3. Configure model mappings in Dashboard → CLI Tools → Antigravity", - "4. Open Antigravity IDE — API calls will be routed through OmniRoute", - ], + additionalHosts: HOSTS.slice(1), + instructions: INSTRUCTIONS, }; diff --git a/src/mitm/targets/codex.ts b/src/mitm/targets/codex.ts new file mode 100644 index 0000000000..a14b9f470c --- /dev/null +++ b/src/mitm/targets/codex.ts @@ -0,0 +1,30 @@ +/** + * OpenAI Codex CLI — MITM target descriptor. + */ +import type { MitmTarget } from "../types"; + +export const CODEX_TARGET: MitmTarget = { + id: "codex", + name: "OpenAI Codex", + icon: "smart_toy", + color: "#F59E0B", + hosts: ["chatgpt.com"], + port: 443, + endpointPatterns: ["/backend-api/codex/chat/completions", "/v1/chat/completions"], + defaultModels: [ + { id: "gpt-4.1", name: "GPT-4.1", alias: "gpt-4.1" }, + { id: "gpt-4o-mini", name: "GPT-4o mini", alias: "gpt-4o-mini" }, + ], + setupTutorial: { + steps: [ + "Install the OpenAI Codex CLI", + "Authenticate with your ChatGPT/Plus credentials", + "Enable DNS routing for this agent", + "Run `codex` — requests are now proxied via OmniRoute", + ], + detection: { command: "which codex", platform: "all" }, + }, + handler: () => + import("../handlers/codex").then((m) => ({ default: m.CodexHandler })), + riskNoticeKey: "providers.riskNotice.oauth", +}; diff --git a/src/mitm/targets/copilot.ts b/src/mitm/targets/copilot.ts new file mode 100644 index 0000000000..ef28df0284 --- /dev/null +++ b/src/mitm/targets/copilot.ts @@ -0,0 +1,32 @@ +/** + * GitHub Copilot — MITM target descriptor. + */ +import type { MitmTarget } from "../types"; + +export const COPILOT_TARGET: MitmTarget = { + id: "copilot", + name: "GitHub Copilot", + icon: "code", + color: "#10B981", + hosts: ["api.githubcopilot.com", "copilot-proxy.githubusercontent.com"], + port: 443, + endpointPatterns: ["/chat/completions", "/v1/chat/completions"], + defaultModels: [ + { id: "gpt-4o", name: "GPT-4o", alias: "gpt-4o" }, + { id: "claude-3.5-sonnet", name: "Claude 3.5 Sonnet", alias: "claude-3.5-sonnet" }, + { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", alias: "gemini-2.0-flash" }, + ], + setupTutorial: { + steps: [ + "Install GitHub Copilot extension in VS Code", + "Sign in to GitHub with a Copilot-enabled account", + "Enable DNS routing for this agent", + "Restart VS Code", + "Done — Copilot now routes via OmniRoute", + ], + detection: { command: "code --list-extensions", platform: "all" }, + }, + handler: () => + import("../handlers/copilot").then((m) => ({ default: m.CopilotHandler })), + riskNoticeKey: "providers.riskNotice.oauth", +}; diff --git a/src/mitm/targets/kiro.ts b/src/mitm/targets/kiro.ts index ea0956ece3..e4eca0c831 100644 --- a/src/mitm/targets/kiro.ts +++ b/src/mitm/targets/kiro.ts @@ -1,25 +1,45 @@ /** - * Kiro IDE MITM Configuration (#336) + * Kiro IDE target descriptor (#336). * - * Kiro IDE removed the Base URL / API Key configuration UI. - * To route Kiro's traffic through OmniRoute, we intercept it using MITM, - * similar to the existing Antigravity/Claude Code implementation. - * - * Kiro IDE uses the Anthropic API at https://api.anthropic.com: - * - Main endpoint: POST /v1/messages - * - Auth header: x-api-key: - * - User-Agent contains: "kiro" or "Kiro" - * - * To use: Install OmniRoute's MITM certificate, then run: - * omniroute mitm start --targets kiro - * - * The MITM server intercepts requests to api.anthropic.com and forwards - * them to the OmniRoute proxy (localhost:20128) instead. + * Kiro removed its Base URL / API Key UI; we intercept its Anthropic-style + * traffic via MITM. Provides: + * - `KIRO_TARGET`: canonical `MitmTarget` per F1 contract (§3.1). + * - `KIRO_MITM_PROFILE`: legacy alias retained for back-compat with + * `src/app/api/settings/mitm/route.ts`. */ +import type { MitmTarget } from "../types"; -export interface MitmTarget { - id: string; - name: string; +const HOSTS = ["api.anthropic.com"]; +const ENDPOINTS = ["/v1/messages"]; +const INSTRUCTIONS = [ + "1. Install OmniRoute's root certificate (Dashboard → AgentBridge → Cert)", + "2. Start the MITM proxy: `omniroute mitm start --target kiro`", + "3. Set your system HTTP proxy to 127.0.0.1:20130 (or use transparent MITM via DNS override)", + "4. Open Kiro IDE — API calls will be automatically routed through OmniRoute.", + "5. Verify: check the Proxy Logs in OmniRoute dashboard and look for provider=anthropic source=mitm", +]; + +export const KIRO_TARGET: MitmTarget = { + id: "kiro", + name: "Kiro IDE", + icon: "code_blocks", + color: "#8B5CF6", + hosts: HOSTS, + port: 443, + endpointPatterns: ENDPOINTS, + defaultModels: [], + setupTutorial: { + steps: INSTRUCTIONS, + detection: { command: "which kiro", platform: "all" }, + }, + handler: () => + import("../handlers/kiro").then((m) => ({ + default: m.KiroHandler, + })), + riskNoticeKey: "providers.riskNotice.oauth", +}; + +export const KIRO_MITM_PROFILE: MitmTarget & { description: string; targetHost: string; targetPort: number; @@ -28,27 +48,17 @@ export interface MitmTarget { apiEndpoints: string[]; authHeader: string; instructions: string[]; - referenceIde?: string; -} - -/** Kiro IDE MITM profile */ -export const KIRO_MITM_PROFILE: MitmTarget = { - id: "kiro", - name: "Kiro IDE", + referenceIde: string; +} = { + ...KIRO_TARGET, description: "Intercepts Kiro IDE requests to api.anthropic.com and routes them through OmniRoute.", - targetHost: "api.anthropic.com", + targetHost: HOSTS[0], targetPort: 443, localPort: 20130, - userAgentPattern: null, // Kiro does not expose a stable User-Agent - apiEndpoints: ["/v1/messages"], + userAgentPattern: null, + apiEndpoints: ENDPOINTS, authHeader: "x-api-key", - instructions: [ - "1. Install OmniRoute's root certificate: run `omniroute cert install` or go to Settings → MITM Certificates", - "2. Start the MITM proxy: `omniroute mitm start --target kiro`", - "3. Set your system HTTP proxy to 127.0.0.1:20130 (or use transparent MITM via DNS override)", - "4. Open Kiro IDE — API calls will be automatically routed through OmniRoute.", - "5. Verify: check the Proxy Logs in OmniRoute dashboard and look for provider=anthropic source=mitm", - ], - referenceIde: "antigravity", // Same MITM infrastructure as Antigravity + instructions: INSTRUCTIONS, + referenceIde: "antigravity", };