feat(mitm): add handler base + 9 concrete agent handlers (F3)

Implements MitmHandlerBase abstract class with shared concerns
(request body capture, secret masking, router forwarding, SSE
piping, Traffic Inspector hooks via dynamic import) plus concrete
handlers for antigravity, kiro, copilot, codex, cursor, zed,
claude-code, open-code, and trae (stub for investigating viability).

Each concrete handler maps request body model field to a configured
target, forwards to OmniRoute router, and pipes back SSE.

Targets antigravity and kiro updated to the new MitmTarget shape
while preserving legacy MITM_PROFILE export aliases.
This commit is contained in:
diegosouzapw
2026-05-27 21:31:26 -03:00
parent ddf6b0ef63
commit 316e3b39f3
14 changed files with 968 additions and 60 deletions

View File

@@ -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<void> {
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);
}
}
}

317
src/mitm/handlers/base.ts Normal file
View File

@@ -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/<agentId>.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<string> {
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<InterceptedRequest>;
recordRequestComplete?: (
intercepted: InterceptedRequest,
opts: {
status: number;
responseHeaders: Record<string, string>;
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<void>;
/**
* 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<Response> {
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<void> {
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<InterceptedRequest> {
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<string, string>;
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<void> {
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<string> {
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();
}
}

View File

@@ -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<void> {
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);
}
}
}

View File

@@ -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<void> {
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);
}
}
}

View File

@@ -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<void> {
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);
}
}
}

View File

@@ -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<void> {
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);
}
}
}

58
src/mitm/handlers/kiro.ts Normal file
View File

@@ -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<void> {
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);
}
}
}

View File

@@ -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<void> {
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);
}
}
}

24
src/mitm/handlers/trae.ts Normal file
View File

@@ -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<void> {
throw new Error("Not yet implemented — Trae viability under investigation. See plan 11 §5.");
}
}

55
src/mitm/handlers/zed.ts Normal file
View File

@@ -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<void> {
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);
}
}
}

View File

@@ -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,
};

30
src/mitm/targets/codex.ts Normal file
View File

@@ -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",
};

View File

@@ -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",
};

View File

@@ -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: <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",
};