diff --git a/README.md b/README.md index 99c6c2c22c..5794bf2a23 100644 --- a/README.md +++ b/README.md @@ -1621,7 +1621,7 @@ OmniRoute stands on the shoulders of giants. It started as a fork of **[9router] - +
ProjectHow it inspired OmniRoute
llm-interceptor66MITM interception/analysis of coding-assistant ↔ LLM traffic — our Traffic Inspector ports its SSE merge, conversation normalization, host passthrough and secret masking. The upstream's complete license text is still under provenance review.
llm-interceptor66MITM interception/analysis of coding-assistant ↔ LLM traffic informed early Traffic Inspector requirements. Four previously derived modules — SSE merging, conversation normalization, secret masking and header sanitization — have been replaced by independent clean-room implementations based on public protocol standards. The two host-passthrough surfaces (passthrough.ts and _internal/bypass.cjs) remain OmniRoute-internal implementations classified independently; they were not rewritten as part of that replacement.
ProxyBridge5,995Transparent per-process proxy routing — inspired our crash-safe MITM teardown, socket idle-timeouts, /proc process attribution and TPROXY capture.
diff --git a/changelog.d/maintenance/11748-clean-room-traffic-inspector.md b/changelog.d/maintenance/11748-clean-room-traffic-inspector.md new file mode 100644 index 0000000000..19f133a949 --- /dev/null +++ b/changelog.d/maintenance/11748-clean-room-traffic-inspector.md @@ -0,0 +1 @@ +- **refactor(mitm):** replace four previously derived Traffic Inspector utilities — conversation normalization, SSE merging, secret masking, and header sanitization — with independent clean-room implementations based on public provider schemas, WHATWG SSE, and RFC 6750, while keeping the two independently classified passthrough surfaces unchanged ([#11748](https://github.com/diegosouzapw/OmniRoute/pull/11748)). diff --git a/docs/frameworks/AGENTBRIDGE.md b/docs/frameworks/AGENTBRIDGE.md index 85cba9a28c..c49d6f49ea 100644 --- a/docs/frameworks/AGENTBRIDGE.md +++ b/docs/frameworks/AGENTBRIDGE.md @@ -172,11 +172,16 @@ When set, configures `undici`'s global dispatcher with the extra CA cert, allowi ### 2.7 Secret masking (`src/mitm/maskSecrets.ts`) -Applied to all request bodies and headers **before** they enter the Traffic Inspector buffer or any log: +The independent clean-room scanner is applied to request bodies and credential headers +**before** they enter the Traffic Inspector buffer or any log. It performs a single linear pass: - `sk-` / `ak-` / `pk-` prefixed tokens (OpenAI/Anthropic-style) -- `Authorization: Bearer ` headers -- Generic long tokens (≥40 chars) +- RFC 6750 `Authorization: Bearer ` credentials, with whole-token precedence +- Generic long opaque tokens (≥40 chars), including dotted and padded forms + +`sanitizeHeaders()` lowercases retained names, joins array values deterministically, drops the +shared hop-by-hop/framing denylist (including proxy authentication), fully redacts `cookie` and +`set-cookie`, and delegates credential values to the scanner. --- diff --git a/docs/frameworks/TRAFFIC_INSPECTOR.md b/docs/frameworks/TRAFFIC_INSPECTOR.md index 7ac5ef6a8a..6fb9ecff28 100644 --- a/docs/frameworks/TRAFFIC_INSPECTOR.md +++ b/docs/frameworks/TRAFFIC_INSPECTOR.md @@ -174,7 +174,7 @@ This is a substantial subsystem with its own dedicated operator guide — see ** | Control | Action | | ---------------- | --------------------------------------------------------------------- | | ⎉ Pause | Stops rendering new requests; "X new" badge accumulates | -| 🗑 Clear | Clears the UI list (server buffer is not affected) | +| 🗑 Clear | Clears the UI list (server buffer is not affected) | | ⬇ Export .har | Downloads current filtered list as HAR file | | ● Record session | Starts a named recording session | | Profile selector | LLM only / Custom hosts / All | @@ -207,12 +207,18 @@ Custom hosts added via Mode 2 inherit their `kind` from the form input (defaults ### 4.2 SSE merger (`src/mitm/inspector/sseMerger.ts`) -**MIT port from [chouzz/llm-interceptor](https://github.com/chouzz/llm-interceptor)** +**Independent clean-room implementation.** Event parsing follows the +[WHATWG server-sent events algorithm](https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream), +while reconstruction follows the public [OpenAI](https://platform.openai.com/docs/api-reference/chat/create), +[Anthropic](https://platform.claude.com/docs/en/build-with-claude/streaming), and +[Gemini](https://ai.google.dev/api/generate-content#method:-models.streamgeneratecontent) +streaming schemas. Reconstructs the final assistant message from raw SSE delta events: - **Anthropic**: accumulates `content_block_delta` by index; handles `text_delta`, `input_json_delta` (tool calls), `thinking_delta` -- **OpenAI**: accumulates `choices[i].delta.content` and `tool_calls` by index +- **OpenAI**: accumulates Chat Completions choices/tool calls and Responses API output items + by index - **Gemini**: accumulates `candidates[i].content.parts` - **Unknown**: returns raw events as-is @@ -220,7 +226,9 @@ The Response tab shows a toggle: **"Raw events ↔ Merged"**. ### 4.3 Conversation normalizer (`src/mitm/inspector/conversationNormalizer.ts`) -**MIT port from [chouzz/llm-interceptor](https://github.com/chouzz/llm-interceptor)** +**Independent clean-room implementation.** Normalization is defined by local black-box +contracts and the public OpenAI, Anthropic, and Gemini message schemas; no upstream +implementation source is used. Converts OpenAI, Anthropic, and Gemini message formats to a single `NormalizedConversation` before rendering: @@ -337,14 +345,14 @@ Export via `GET /api/tools/traffic-inspector/sessions/{id}/export.har` or the Traffic Inspector shows **all intercepted HTTPS traffic**, including authorization headers and request bodies. The following controls are in place: -| Control | Details | -| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| **LOCAL_ONLY** | All routes and the WebSocket endpoint are loopback-only (enforced in `routeGuard.ts` before auth) | -| **Secret masking** | `maskSecrets()` applied to all headers and bodies before `TrafficBuffer.push()` — enabled by default (`INSPECTOR_MASK_SECRETS=true`) | -| **Body size cap** | Bodies > `INSPECTOR_MAX_BODY_KB` (default 1024 KB) are truncated with `"(truncated for performance)"` notice | -| **Sensitive header masking** | `authorization`, `cookie`, `api-key`, `x-api-key`, `proxy-authorization` → `Bearer ***` in Headers tab; "Show secrets" toggle | -| **CSP** | Strict Content Security Policy on Traffic Inspector pages to prevent XSS via injected response bodies | -| **No persistence by default** | The `TrafficBuffer` is in-memory and lost on server restart. Sessions are persisted only when explicitly recorded | +| Control | Details | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| **LOCAL_ONLY** | All routes and the WebSocket endpoint are loopback-only (enforced in `routeGuard.ts` before auth) | +| **Secret masking** | Linear `maskSecret()` scanner redacts RFC 6750 Bearer credentials, provider-prefixed keys and long opaque tokens before `TrafficBuffer.push()` | +| **Body size cap** | Bodies > `INSPECTOR_MAX_BODY_KB` (default 1024 KB) are truncated with `"(truncated for performance)"` notice | +| **Header sanitization** | Names are lowercased; framing/hop-by-hop and proxy-auth headers are dropped; cookies are fully redacted; credential values delegate to `maskSecret()` | +| **CSP** | Strict Content Security Policy on Traffic Inspector pages to prevent XSS via injected response bodies | +| **No persistence by default** | The `TrafficBuffer` is in-memory and lost on server restart. Sessions are persisted only when explicitly recorded | ### Hard Rules applied diff --git a/src/mitm/inspector/conversationNormalizer.ts b/src/mitm/inspector/conversationNormalizer.ts index d0347e03b1..1fab1fceff 100644 --- a/src/mitm/inspector/conversationNormalizer.ts +++ b/src/mitm/inspector/conversationNormalizer.ts @@ -1,11 +1,9 @@ /** - * Conversation normalizer — converts OpenAI / Anthropic / Gemini request + - * response payloads into a single provider-agnostic shape. + * Provider-neutral conversation projection for Traffic Inspector views. * - * MIT — port from https://github.com/chouzz/llm-interceptor (ui/utils.ts) - * - * Returns `null` for non-LLM requests or payloads we cannot understand — - * never throws — so the renderer can fall back to the raw view. + * This is an independent implementation of the public OpenAI, Anthropic, and + * Gemini payload shapes. It intentionally emits only the normalized block + * types consumed by the inspector UI. */ import { mergeStream, parseSseStream } from "./sseMerger.ts"; @@ -16,435 +14,333 @@ import type { NormalizedTurn, } from "./types.ts"; +type JsonRecord = Record; type NormalizedRole = NormalizedTurn["role"]; -function asRecord(value: unknown): Record | null { - if (value && typeof value === "object" && !Array.isArray(value)) { - return value as Record; - } - return null; +const WRAPPER_KEYS = ["body", "payload", "data", "request", "requestBody", "response"]; + +function asRecord(value: unknown): JsonRecord | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as JsonRecord) + : null; } -function tryParseJson(value: string | null | undefined): unknown { - if (!value) return null; +function tryParseJson(value: unknown): unknown { + if (typeof value !== "string") return value; + const trimmed = value.trim(); + if (!trimmed) return null; try { - return JSON.parse(value); + return JSON.parse(trimmed) as unknown; } catch { return null; } } -function normalizeRole(raw: unknown): NormalizedRole { - if (raw === "system" || raw === "user" || raw === "assistant" || raw === "tool") { - return raw; - } - if (raw === "model") return "assistant"; - if (raw === "function") return "tool"; - return "user"; +function hasConversationShape(record: JsonRecord): boolean { + return ( + "messages" in record || + "input" in record || + "contents" in record || + "system" in record || + "systemInstruction" in record || + "choices" in record || + "candidates" in record || + "content" in record || + "output" in record + ); } -/** - * OpenAI / Anthropic message content can be a string, or an array of blocks. - * Returns a list of normalized blocks. - */ -function blocksFromOpenAiContent(content: unknown): NormalizedBlock[] { - if (content == null) return []; - if (typeof content === "string") { - if (content.length === 0) return []; - return [{ type: "text", text: content }]; - } - if (!Array.isArray(content)) return []; - const out: NormalizedBlock[] = []; - for (const raw of content) { - if (typeof raw === "string") { - out.push({ type: "text", text: raw }); - continue; - } - const block = asRecord(raw); - if (!block) continue; - const type = block.type; - if (type === "text" || type === "output_text") { - const text = typeof block.text === "string" ? block.text : ""; - out.push({ type: "text", text }); - } else if (type === "input_text") { - const text = typeof block.text === "string" ? block.text : ""; - out.push({ type: "text", text }); - } else if (type === "tool_use") { - out.push({ - type: "tool_use", - id: typeof block.id === "string" ? block.id : "", - name: typeof block.name === "string" ? block.name : "", - input: block.input ?? {}, - }); - } else if (type === "tool_result") { - out.push({ - type: "tool_result", - tool_use_id: typeof block.tool_use_id === "string" ? block.tool_use_id : "", - content: block.content ?? null, - }); - } else if (typeof block.text === "string") { - out.push({ type: "text", text: block.text }); - } - } - return out; -} - -/** - * OpenAI assistant messages may declare `tool_calls`. Each becomes a - * `tool_use` block alongside any text content. - */ -function appendOpenAiToolCalls(blocks: NormalizedBlock[], toolCalls: unknown): NormalizedBlock[] { - if (!Array.isArray(toolCalls)) return blocks; - for (const raw of toolCalls) { - const tc = asRecord(raw); - if (!tc) continue; - const fn = asRecord(tc.function) ?? {}; - let parsedInput: unknown = {}; - if (typeof fn.arguments === "string") { - try { - parsedInput = JSON.parse(fn.arguments); - } catch { - parsedInput = fn.arguments; +function unwrapPayload(value: unknown): unknown { + let current = tryParseJson(value); + for (let depth = 0; depth < 5; depth += 1) { + const record = asRecord(current); + if (!record || hasConversationShape(record)) return current; + let next: unknown = undefined; + for (const key of WRAPPER_KEYS) { + if (record[key] !== undefined) { + next = record[key]; + break; } - } else if (fn.arguments != null) { - parsedInput = fn.arguments; } - blocks.push({ - type: "tool_use", - id: typeof tc.id === "string" ? tc.id : "", - name: typeof fn.name === "string" ? fn.name : "", - input: parsedInput, - }); + if (next === undefined) return current; + current = tryParseJson(next); + } + return current; +} + +function normalizedRole(value: unknown): NormalizedRole | null { + if (value === "system" || value === "user" || value === "assistant" || value === "tool") { + return value; + } + if (value === "model") return "assistant"; + if (value === "function") return "tool"; + return null; +} + +function textBlock(value: unknown): NormalizedBlock | null { + return typeof value === "string" && value.length > 0 ? { type: "text", text: value } : null; +} + +function parseArguments(value: unknown): unknown { + if (typeof value !== "string") return value ?? {}; + if (!value.trim()) return {}; + try { + return JSON.parse(value) as unknown; + } catch { + return value; + } +} + +function toolUseBlock(value: JsonRecord): NormalizedBlock | null { + const directFunction = asRecord(value.function); + const geminiFunction = asRecord(value.functionCall); + const source = directFunction ?? geminiFunction ?? value; + const name = typeof source.name === "string" ? source.name : null; + if (!name) return null; + + const idValue = value.id ?? value.call_id ?? geminiFunction?.id ?? name; + const input = + source.arguments !== undefined + ? parseArguments(source.arguments) + : source.args !== undefined + ? source.args + : source.input !== undefined + ? source.input + : {}; + return { + type: "tool_use", + id: typeof idValue === "string" ? idValue : name, + name, + input, + }; +} + +function toolResultBlock(value: JsonRecord): NormalizedBlock | null { + const geminiResponse = asRecord(value.functionResponse); + const idValue = + value.tool_use_id ?? + value.tool_call_id ?? + value.call_id ?? + geminiResponse?.id ?? + geminiResponse?.name; + if (typeof idValue !== "string") return null; + const content = + value.output !== undefined + ? value.output + : value.content !== undefined + ? value.content + : geminiResponse?.response; + return { type: "tool_result", tool_use_id: idValue, content }; +} + +function reasoningBlocks(value: JsonRecord): NormalizedBlock[] { + const blocks: NormalizedBlock[] = []; + const summary = Array.isArray(value.summary) ? value.summary : []; + for (const entry of summary) { + const record = asRecord(entry); + const block = textBlock(record?.text); + if (block) blocks.push(block); } return blocks; } -/** - * Build NormalizedTurn[] from OpenAI / Anthropic chat messages. - */ -/** Responses API reasoning items carry `summary: [{type: "summary_text", text}]`. */ -function reasoningSummaryText(summary: unknown): string { - if (!Array.isArray(summary)) return ""; - const parts: string[] = []; - for (const raw of summary) { - const block = asRecord(raw); - if (block && typeof block.text === "string") parts.push(block.text); +function blocksFromPart(value: unknown): NormalizedBlock[] { + if (typeof value === "string") { + const block = textBlock(value); + return block ? [block] : []; } - return parts.join("\n\n"); -} + const part = asRecord(value); + if (!part) return []; -function turnsFromOpenAiMessages(messages: unknown[]): NormalizedTurn[] { - const out: NormalizedTurn[] = []; - for (const raw of messages) { - const msg = asRecord(raw); - if (!msg) continue; - - // Responses API items for tool activity/reasoning carry no `role` at - // all — they're distinguished by `type` instead. Handle these before the - // role-based branches below, which would otherwise silently drop them - // (empty `content`, no `tool_calls`, `normalizeRole(undefined)` defaults - // to "user") — the exact gap that made a real OpenClaw request's - // function_call/function_call_output items vanish from the Conversation - // Context panel entirely (2026-08-06). - if (msg.type === "function_call") { - let parsedInput: unknown = {}; - if (typeof msg.arguments === "string") { - try { - parsedInput = JSON.parse(msg.arguments); - } catch { - parsedInput = msg.arguments; - } - } else if (msg.arguments != null) { - parsedInput = msg.arguments; - } - out.push({ - role: "assistant", - blocks: [ - { - type: "tool_use", - id: typeof msg.call_id === "string" ? msg.call_id : "", - name: typeof msg.name === "string" ? msg.name : "", - input: parsedInput, - }, - ], - }); - continue; - } - if (msg.type === "function_call_output") { - out.push({ - role: "tool", - blocks: [ - { - type: "tool_result", - tool_use_id: typeof msg.call_id === "string" ? msg.call_id : "", - content: msg.output ?? null, - }, - ], - }); - continue; - } - if (msg.type === "reasoning") { - const text = reasoningSummaryText(msg.summary); - if (!text) continue; - out.push({ role: "assistant", blocks: [{ type: "text", text }] }); - continue; - } - - const role = normalizeRole(msg.role); - - if (msg.role === "tool" || msg.role === "function") { - const content = msg.content; - out.push({ - role: "tool", - blocks: [ - { - type: "tool_result", - tool_use_id: - typeof msg.tool_call_id === "string" - ? msg.tool_call_id - : typeof msg.name === "string" - ? msg.name - : "", - content, - }, - ], - }); - continue; - } - - const blocks = blocksFromOpenAiContent(msg.content); - if ("tool_calls" in msg) { - appendOpenAiToolCalls(blocks, msg.tool_calls); - } - if (blocks.length === 0 && msg.content == null && !("tool_calls" in msg)) { - continue; - } - out.push({ role, blocks }); + const type = typeof part.type === "string" ? part.type : ""; + if (type === "tool_use" || type === "function_call" || part.functionCall) { + const block = toolUseBlock(part); + return block ? [block] : []; } - return out; -} - -/** - * Gemini contents have a different shape: `[{role, parts: [{text|...}]}]`. - */ -function turnsFromGeminiContents(contents: unknown[]): NormalizedTurn[] { - const out: NormalizedTurn[] = []; - for (const raw of contents) { - const turn = asRecord(raw); - if (!turn) continue; - const role = normalizeRole(turn.role); - const blocks: NormalizedBlock[] = []; - if (Array.isArray(turn.parts)) { - for (const partRaw of turn.parts) { - const part = asRecord(partRaw); - if (!part) continue; - if (typeof part.text === "string") { - blocks.push({ type: "text", text: part.text }); - } else if (part.functionCall) { - const fc = asRecord(part.functionCall) ?? {}; - blocks.push({ - type: "tool_use", - id: typeof fc.name === "string" ? fc.name : "", - name: typeof fc.name === "string" ? fc.name : "", - input: fc.args ?? {}, - }); - } else if (part.functionResponse) { - const fr = asRecord(part.functionResponse) ?? {}; - blocks.push({ - type: "tool_result", - tool_use_id: typeof fr.name === "string" ? fr.name : "", - content: fr.response ?? null, - }); - } - } - } - if (blocks.length > 0) out.push({ role, blocks }); + if (type === "tool_result" || type === "function_call_output" || part.functionResponse) { + const block = toolResultBlock(part); + return block ? [block] : []; } - return out; -} - -/** - * Anthropic Messages API requests carry a top-level `system` field (string - * or array of `{type:"text"|text}` blocks). Convert to a `system` turn. - */ -function systemTurnFromAnthropic(system: unknown): NormalizedTurn | null { - if (!system) return null; - if (typeof system === "string") { - return system.length === 0 - ? null - : { role: "system", blocks: [{ type: "text", text: system }] }; + if (type === "reasoning") return reasoningBlocks(part); + if ( + type === "text" || + type === "input_text" || + type === "output_text" || + type === "summary_text" || + type === "" + ) { + const block = textBlock(part.text); + return block ? [block] : []; } - if (!Array.isArray(system)) return null; - const blocks: NormalizedBlock[] = []; - for (const raw of system) { - const item = asRecord(raw); - if (item && typeof item.text === "string") { - blocks.push({ type: "text", text: item.text }); - } else if (typeof raw === "string") { - blocks.push({ type: "text", text: raw }); - } - } - if (blocks.length === 0) return null; - return { role: "system", blocks }; -} - -export function buildRequestTurns(body: unknown): NormalizedTurn[] | null { - const obj = asRecord(body); - if (!obj) return null; - - if (Array.isArray(obj.messages)) { - const turns: NormalizedTurn[] = []; - const systemTurn = systemTurnFromAnthropic(obj.system); - if (systemTurn) turns.push(systemTurn); - turns.push(...turnsFromOpenAiMessages(obj.messages)); - return turns; - } - - if (Array.isArray(obj.contents)) { - const turns: NormalizedTurn[] = []; - const sysObj = asRecord(obj.systemInstruction); - if (sysObj && Array.isArray(sysObj.parts)) { - const parts: NormalizedBlock[] = []; - for (const partRaw of sysObj.parts) { - const p = asRecord(partRaw); - if (p && typeof p.text === "string") parts.push({ type: "text", text: p.text }); - } - if (parts.length > 0) turns.push({ role: "system", blocks: parts }); - } - turns.push(...turnsFromGeminiContents(obj.contents)); - return turns; - } - - if (typeof obj.prompt === "string") { - return [{ role: "user", blocks: [{ type: "text", text: obj.prompt }] }]; - } - if (typeof obj.input === "string") { - return [{ role: "user", blocks: [{ type: "text", text: obj.input }] }]; - } - if (Array.isArray(obj.input)) { - return turnsFromOpenAiMessages(obj.input); - } - - return null; -} - -function isSseResponse(req: InterceptedRequest): boolean { - const accept = req.requestHeaders["accept"] ?? req.requestHeaders["Accept"] ?? ""; - const ct = req.responseHeaders["content-type"] ?? req.responseHeaders["Content-Type"] ?? ""; - return ( - accept.includes("event-stream") || - ct.includes("event-stream") || - /^\s*event:|^\s*data:/m.test(req.responseBody ?? "") - ); -} - -function extractAnthropicResponseTurn(message: unknown): NormalizedTurn | null { - const obj = asRecord(message); - if (!obj) return null; - const content = obj.content; - if (!Array.isArray(content)) return null; - const blocks: NormalizedBlock[] = []; - for (const raw of content) { - const block = asRecord(raw); - if (!block) continue; - if (block.type === "text" && typeof block.text === "string") { - blocks.push({ type: "text", text: block.text }); - } else if (block.type === "tool_use") { - blocks.push({ - type: "tool_use", - id: typeof block.id === "string" ? block.id : "", - name: typeof block.name === "string" ? block.name : "", - input: block.input ?? {}, - }); - } else if (block.type === "thinking" && typeof block.thinking === "string") { - blocks.push({ type: "text", text: block.thinking }); - } - } - if (blocks.length === 0) return null; - return { role: "assistant", blocks }; -} - -function extractOpenAiResponseTurn(message: unknown): NormalizedTurn | null { - const obj = asRecord(message); - if (!obj || !Array.isArray(obj.choices)) return null; - const first = asRecord(obj.choices[0]); - if (!first) return null; - const msg = asRecord(first.message) ?? asRecord(first.delta); - if (!msg) return null; - const blocks = blocksFromOpenAiContent(msg.content); - if ("tool_calls" in msg) appendOpenAiToolCalls(blocks, msg.tool_calls); - if (blocks.length === 0) return null; - return { role: "assistant", blocks }; -} - -function extractGeminiResponseTurn(message: unknown): NormalizedTurn | null { - const obj = asRecord(message); - if (!obj || !Array.isArray(obj.candidates)) return null; - const first = asRecord(obj.candidates[0]); - if (!first) return null; - const content = asRecord(first.content); - if (!content || !Array.isArray(content.parts)) return null; - const blocks: NormalizedBlock[] = []; - for (const partRaw of content.parts) { - const part = asRecord(partRaw); - if (!part) continue; - if (typeof part.text === "string") { - blocks.push({ type: "text", text: part.text }); - } else if (part.functionCall) { - const fc = asRecord(part.functionCall) ?? {}; - blocks.push({ - type: "tool_use", - id: typeof fc.name === "string" ? fc.name : "", - name: typeof fc.name === "string" ? fc.name : "", - input: fc.args ?? {}, - }); - } - } - if (blocks.length === 0) return null; - return { role: "assistant", blocks }; -} - -export function buildResponseTurns(req: InterceptedRequest): NormalizedTurn[] { - const raw = req.responseBody ?? ""; - if (!raw) return []; - - let payload: unknown = null; - - if (isSseResponse(req)) { - const merged = mergeStream(parseSseStream(raw)); - payload = merged.message ?? null; - } else { - payload = tryParseJson(raw); - } - - if (!payload) return []; - - const anth = extractAnthropicResponseTurn(payload); - if (anth) return [anth]; - const oai = extractOpenAiResponseTurn(payload); - if (oai) return [oai]; - const gem = extractGeminiResponseTurn(payload); - if (gem) return [gem]; - return []; } -/** - * Normalize an intercepted LLM request + response into a provider-agnostic - * conversation. Returns `null` for non-LLM requests or unparseable payloads. - */ +function blocksFromContent(value: unknown): NormalizedBlock[] { + if (Array.isArray(value)) return value.flatMap(blocksFromPart); + return blocksFromPart(value); +} + +function turnFromMessage(value: unknown): NormalizedTurn | null { + const message = asRecord(value); + if (!message) return null; + const type = typeof message.type === "string" ? message.type : ""; + + if (type === "function_call") { + const block = toolUseBlock(message); + return block ? { role: "assistant", blocks: [block] } : null; + } + if (type === "function_call_output") { + const block = toolResultBlock(message); + return block ? { role: "tool", blocks: [block] } : null; + } + if (type === "reasoning") { + const blocks = reasoningBlocks(message); + return blocks.length > 0 ? { role: "assistant", blocks } : null; + } + + const role = normalizedRole(message.role); + if (!role) return null; + if (role === "tool") { + const block = toolResultBlock(message); + return block ? { role, blocks: [block] } : null; + } + + const blocks = blocksFromContent(message.content ?? message.parts); + for (const rawCall of Array.isArray(message.tool_calls) ? message.tool_calls : []) { + const call = asRecord(rawCall); + const block = call ? toolUseBlock(call) : null; + if (block) blocks.push(block); + } + const legacyCall = asRecord(message.function_call); + if (legacyCall) { + const block = toolUseBlock({ + id: message.tool_call_id ?? legacyCall.name, + function: legacyCall, + }); + if (block) blocks.push(block); + } + if (blocks.length === 0) return null; + const projectedRole = + role === "user" && blocks.every((block) => block.type === "tool_result") ? "tool" : role; + return { role: projectedRole, blocks }; +} + +function turnsFromItems(items: unknown[]): NormalizedTurn[] { + const turns: NormalizedTurn[] = []; + for (const item of items) { + const turn = turnFromMessage(item); + if (turn) turns.push(turn); + } + return turns; +} + +function systemTurn(value: unknown): NormalizedTurn | null { + const record = asRecord(value); + const content = record?.parts ?? value; + const blocks = blocksFromContent(content); + return blocks.length > 0 ? { role: "system", blocks } : null; +} + +/** Normalize a decoded request payload, or a JSON string/wrapper containing one. */ +export function buildRequestTurns(body: unknown): NormalizedTurn[] | null { + const payload = asRecord(unwrapPayload(body)); + if (!payload) { + const block = textBlock(typeof body === "string" ? body : null); + return block ? [{ role: "user", blocks: [block] }] : null; + } + + const turns: NormalizedTurn[] = []; + const topLevelSystem = payload.systemInstruction ?? payload.system ?? payload.instructions; + if (topLevelSystem !== undefined) { + const turn = systemTurn(topLevelSystem); + if (turn) turns.push(turn); + } + + if (Array.isArray(payload.messages)) turns.push(...turnsFromItems(payload.messages)); + if (Array.isArray(payload.contents)) turns.push(...turnsFromItems(payload.contents)); + if (Array.isArray(payload.input)) turns.push(...turnsFromItems(payload.input)); + if (typeof payload.input === "string") { + const block = textBlock(payload.input); + if (block) turns.push({ role: "user", blocks: [block] }); + } + + return turns.length > 0 ? turns : null; +} + +function turnFromAnthropicResponse(payload: JsonRecord): NormalizedTurn | null { + if (!Array.isArray(payload.content)) return null; + const blocks = blocksFromContent(payload.content); + return blocks.length > 0 ? { role: "assistant", blocks } : null; +} + +function turnsFromOpenAiResponse(payload: JsonRecord): NormalizedTurn[] { + const turns: NormalizedTurn[] = []; + for (const rawChoice of Array.isArray(payload.choices) ? payload.choices : []) { + const choice = asRecord(rawChoice); + const turn = choice ? turnFromMessage(choice.message ?? choice.delta) : null; + if (turn) turns.push(turn); + } + return turns; +} + +function turnsFromGeminiResponse(payload: JsonRecord): NormalizedTurn[] { + const turns: NormalizedTurn[] = []; + for (const rawCandidate of Array.isArray(payload.candidates) ? payload.candidates : []) { + const candidate = asRecord(rawCandidate); + const content = asRecord(candidate?.content); + const blocks = blocksFromContent(content?.parts); + if (blocks.length > 0) turns.push({ role: "assistant", blocks }); + } + return turns; +} + +function turnsFromResponsesApi(payload: JsonRecord): NormalizedTurn[] { + return turnsFromItems(Array.isArray(payload.output) ? payload.output : []); +} + +function isSseResponse(req: InterceptedRequest): boolean { + const contentType = + req.responseHeaders["content-type"] ?? req.responseHeaders["Content-Type"] ?? ""; + const body = req.responseBody ?? ""; + return ( + contentType.toLowerCase().includes("text/event-stream") || + body.startsWith("data:") || + body.startsWith("event:") || + body.includes("\ndata:") || + body.includes("\nevent:") + ); +} + +/** Normalize the response side of an intercepted request. */ +export function buildResponseTurns(req: InterceptedRequest): NormalizedTurn[] { + if (!req.responseBody) return []; + let decoded: unknown; + if (isSseResponse(req)) { + decoded = mergeStream(parseSseStream(req.responseBody)).message; + } else { + decoded = unwrapPayload(req.responseBody); + } + const payload = asRecord(unwrapPayload(decoded)); + if (!payload) return []; + + const turns = turnsFromOpenAiResponse(payload); + turns.push(...turnsFromGeminiResponse(payload)); + turns.push(...turnsFromResponsesApi(payload)); + const anthropic = turnFromAnthropicResponse(payload); + if (anthropic) turns.push(anthropic); + if (turns.length > 0) return turns; + + const nestedMessage = turnFromMessage(payload.message); + return nestedMessage ? [nestedMessage] : []; +} + export function normalizeConversation(req: InterceptedRequest): NormalizedConversation | null { - if (req.detectedKind !== "llm") return null; - - const requestBody = tryParseJson(req.requestBody); - const requestTurns = buildRequestTurns(requestBody); - if (!requestTurns) return null; - - const responseTurns = buildResponseTurns(req); - + if (req.detectedKind !== undefined && req.detectedKind !== "llm") return null; + const request = buildRequestTurns(req.requestBody); + if (!request || request.length === 0) return null; return { - request: requestTurns, - response: responseTurns, + request, + response: buildResponseTurns(req), contextKey: req.contextKey ?? null, }; } diff --git a/src/mitm/inspector/sseMerger.ts b/src/mitm/inspector/sseMerger.ts index 8a1d245603..23de1d91ec 100644 --- a/src/mitm/inspector/sseMerger.ts +++ b/src/mitm/inspector/sseMerger.ts @@ -1,11 +1,8 @@ /** - * SSE merger — reconstructs complete LLM response from streaming SSE chunks. + * Server-sent event parsing and provider response reconstruction. * - * MIT — port from https://github.com/chouzz/llm-interceptor (merger.py) - * - * Detects API format by chunk shape (not URL — robust to URL rewrite) and - * rebuilds Anthropic / OpenAI / Gemini responses. Falls back to a raw event - * list when the format is unrecognised so the caller never crashes. + * This module is an independent implementation based on the WHATWG event-stream + * algorithm and the public OpenAI, Anthropic, and Gemini streaming schemas. */ export type ApiFormat = "anthropic" | "openai" | "gemini" | "unknown"; @@ -23,294 +20,475 @@ export interface MergedResponse { raw?: SseEvent[]; } -function asRecord(value: unknown): Record | null { - if (value && typeof value === "object" && !Array.isArray(value)) { - return value as Record; - } - return null; +type JsonRecord = Record; + +interface AnthropicBlockState { + block: JsonRecord; + partialInput: string; } -/** - * Inspect chunk shapes to determine the upstream API. Matches on the first - * recognisable hint; returns `"unknown"` if none match. - */ -export function detectApiFormat(chunks: SseEvent[]): ApiFormat { - for (const c of chunks) { - const j = asRecord(c.json); - if (!j) continue; - if (j.type === "message_start" || j.type === "content_block_delta") return "anthropic"; - if (Array.isArray(j.choices)) { - const first = j.choices[0]; - if (first && typeof first === "object" && "delta" in first) return "openai"; +interface OpenAiToolState { + value: JsonRecord; + functionValue: JsonRecord; +} + +interface OpenAiChoiceState { + value: JsonRecord; + message: JsonRecord; + tools: Map; +} + +function asRecord(value: unknown): JsonRecord | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as JsonRecord) + : null; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function asIndex(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : fallback; +} + +function appendString(record: JsonRecord, key: string, value: unknown): void { + if (typeof value !== "string") return; + const current = typeof record[key] === "string" ? record[key] : ""; + record[key] = current + value; +} + +function mergeRecord(previous: unknown, next: unknown): JsonRecord { + return { ...(asRecord(previous) ?? {}), ...(asRecord(next) ?? {}) }; +} + +function dispatchSseEvent( + events: SseEvent[], + dataLines: string[], + sawDataField: boolean, + eventName: string +): void { + if (!sawDataField) return; + + const data = dataLines.join("\n"); + const event: SseEvent = { data }; + if (eventName) event.event = eventName; + + if (data !== "[DONE]") { + try { + event.json = JSON.parse(data) as unknown; + } catch { + // Raw data is still useful to callers when a provider emits a sentinel or malformed JSON. } - if (Array.isArray(j.candidates)) return "gemini"; + } + events.push(event); +} + +/** Parse a complete `text/event-stream` payload using WHATWG field semantics. */ +export function parseSseStream(raw: string): SseEvent[] { + const input = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw; + const events: SseEvent[] = []; + let dataLines: string[] = []; + let eventName = ""; + let sawDataField = false; + let lineStart = 0; + + const processLine = (line: string): void => { + if (line.length === 0) { + dispatchSseEvent(events, dataLines, sawDataField, eventName); + dataLines = []; + eventName = ""; + sawDataField = false; + return; + } + if (line.startsWith(":")) return; + + const colon = line.indexOf(":"); + const field = colon === -1 ? line : line.slice(0, colon); + let value = colon === -1 ? "" : line.slice(colon + 1); + if (value.startsWith(" ")) value = value.slice(1); + + if (field === "data") { + dataLines.push(value); + sawDataField = true; + } else if (field === "event") { + eventName = value; + } + // `id`, `retry`, comments, and extension fields do not alter the public event shape. + }; + + for (let index = 0; index < input.length; index += 1) { + const code = input.charCodeAt(index); + if (code !== 0x0a && code !== 0x0d) continue; + + processLine(input.slice(lineStart, index)); + if (code === 0x0d && input.charCodeAt(index + 1) === 0x0a) index += 1; + lineStart = index + 1; + } + + // WHATWG does not dispatch an event that lacks its terminating blank line. + return events; +} + +export function detectApiFormat(chunks: SseEvent[]): ApiFormat { + for (const chunk of chunks) { + const namedEvent = chunk.event ?? ""; + if ( + namedEvent === "message_start" || + namedEvent === "message_delta" || + namedEvent === "message_stop" || + namedEvent.startsWith("content_block_") + ) { + return "anthropic"; + } + const payload = asRecord(chunk.json); + if (!payload) continue; + + const type = typeof payload.type === "string" ? payload.type : ""; + if ( + type === "message_start" || + type === "message_delta" || + type === "message_stop" || + type.startsWith("content_block_") + ) { + return "anthropic"; + } + if (Array.isArray(payload.choices) || type.startsWith("response.")) return "openai"; + if (Array.isArray(payload.candidates) || asRecord(payload.usageMetadata)) return "gemini"; } return "unknown"; } -/** - * Parse a raw SSE stream (the response body string captured by the proxy) - * into discrete events. Empty blocks and `[DONE]` terminators are skipped - * silently; malformed JSON payloads are kept as raw `data` (no `json`). - */ -export function parseSseStream(raw: string): SseEvent[] { - const events: SseEvent[] = []; - if (!raw) return events; - // SSE blocks separated by blank lines — accept both LF and CRLF. - for (const block of raw.split(/\r?\n\r?\n/)) { - if (!block.trim()) continue; - const ev: SseEvent = {}; - for (const line of block.split(/\r?\n/)) { - if (line.startsWith("event:")) { - ev.event = line.slice(6).trim(); - } else if (line.startsWith("data:")) { - ev.data = (ev.data ?? "") + line.slice(5).trim(); +export function rebuildAnthropic(chunks: SseEvent[]): MergedResponse { + let message: JsonRecord = { type: "message", role: "assistant", content: [] }; + const blocks = new Map(); + + for (const chunk of chunks) { + const payload = asRecord(chunk.json); + if (!payload) continue; + const type = typeof payload.type === "string" ? payload.type : ""; + + if (type === "message_start") { + const startedMessage = asRecord(payload.message); + if (startedMessage) { + message = { ...startedMessage }; + for (const [position, value] of asArray(startedMessage.content).entries()) { + const block = asRecord(value); + if (block) blocks.set(position, { block: { ...block }, partialInput: "" }); + } } - } - if (ev.data === undefined) continue; - if (ev.data === "[DONE]") { - events.push(ev); continue; } - try { - ev.json = JSON.parse(ev.data); - } catch { - // keep raw data only + + const index = asIndex(payload.index, blocks.size); + if (type === "content_block_start") { + const contentBlock = asRecord(payload.content_block); + if (contentBlock) { + blocks.set(index, { block: { ...contentBlock }, partialInput: "" }); + } + continue; } - events.push(ev); - } - return events; -} -interface AnthropicBlock { - type: string; - text?: string; - thinking?: string; - id?: string; - name?: string; - input?: unknown; -} - -/** - * Rebuild an Anthropic Messages API response from streaming events. - * Handles `text_delta`, `thinking_delta`, and `input_json_delta` deltas; - * applies `JSON.parse` (best-effort) on accumulated tool-use input. - */ -export function rebuildAnthropic(chunks: SseEvent[]): MergedResponse { - const blocks: AnthropicBlock[] = []; - let message: Record | null = null; - const inputJsonByIndex: Record = {}; - - for (const c of chunks) { - const j = asRecord(c.json); - if (!j) continue; - const t = j.type; - - if (t === "message_start") { - const m = asRecord(j.message); - message = m ? { ...m } : {}; - } else if (t === "content_block_start") { - const idx = typeof j.index === "number" ? j.index : blocks.length; - const cb = asRecord(j.content_block); - const block: AnthropicBlock = { type: "text" }; - if (cb) { - for (const [k, v] of Object.entries(cb)) (block as Record)[k] = v; + if (type === "content_block_delta") { + const delta = asRecord(payload.delta); + if (!delta) continue; + const state = blocks.get(index) ?? { block: {}, partialInput: "" }; + const deltaType = typeof delta.type === "string" ? delta.type : ""; + if (deltaType === "text_delta") appendString(state.block, "text", delta.text); + if (deltaType === "thinking_delta") appendString(state.block, "thinking", delta.thinking); + if (deltaType === "signature_delta") { + appendString(state.block, "signature", delta.signature); } - if (block.type === "text" && block.text === undefined) block.text = ""; - if (block.type === "thinking" && block.thinking === undefined) block.thinking = ""; - if (block.type === "tool_use" && block.input === undefined) block.input = {}; - blocks[idx] = block; - } else if (t === "content_block_delta") { - const idx = typeof j.index === "number" ? j.index : 0; - const d = asRecord(j.delta); - if (!d) continue; - // Ensure a block slot exists (some streams skip content_block_start). - const slot = blocks[idx] ?? (blocks[idx] = { type: "text", text: "" }); - const dType = d.type; - if (dType === "text_delta" && typeof d.text === "string") { - slot.text = (slot.text ?? "") + d.text; - } else if (dType === "thinking_delta" && typeof d.thinking === "string") { - slot.thinking = (slot.thinking ?? "") + d.thinking; - } else if (dType === "input_json_delta" && typeof d.partial_json === "string") { - inputJsonByIndex[idx] = (inputJsonByIndex[idx] ?? "") + d.partial_json; + if (deltaType === "input_json_delta" && typeof delta.partial_json === "string") { + state.partialInput += delta.partial_json; } - } else if (t === "content_block_stop") { - const idx = typeof j.index === "number" ? j.index : 0; - const slot = blocks[idx]; - if (slot && slot.type === "tool_use" && inputJsonByIndex[idx]) { + blocks.set(index, state); + continue; + } + + if (type === "content_block_stop") { + const state = blocks.get(index); + if (state?.partialInput) { try { - slot.input = JSON.parse(inputJsonByIndex[idx]); + state.block.input = JSON.parse(state.partialInput) as unknown; } catch { - // keep accumulated string for forensic visibility - slot.input = inputJsonByIndex[idx]; + state.block.input = state.partialInput; } } - } else if (t === "message_delta") { - if (!message) message = {}; - const d = asRecord(j.delta); - if (d && typeof d.stop_reason === "string") { - message.stop_reason = d.stop_reason; - } - const usage = asRecord(j.usage); - if (usage) { - const prev = asRecord(message.usage) ?? {}; - message.usage = { ...prev, ...usage }; + continue; + } + + if (type === "message_delta") { + Object.assign(message, asRecord(payload.delta) ?? {}); + if (payload.usage !== undefined) { + message.usage = mergeRecord(message.usage, payload.usage); } } } - const filledBlocks = blocks.filter((b) => b !== undefined); - return { - format: "anthropic", - message: { ...(message ?? {}), content: filledBlocks }, + message.content = [...blocks.entries()] + .sort(([left], [right]) => left - right) + .map(([, state]) => state.block); + return { format: "anthropic", message }; +} + +function getOpenAiChoice( + choices: Map, + index: number +): OpenAiChoiceState { + const existing = choices.get(index); + if (existing) return existing; + const created: OpenAiChoiceState = { + value: { index }, + message: { role: "assistant", content: "" }, + tools: new Map(), }; + choices.set(index, created); + return created; } -interface OpenAiToolCall { - index: number; - id?: string; - type?: string; - function: { name: string; arguments: string }; +function mergeOpenAiTools(state: OpenAiChoiceState, toolDeltas: unknown[]): void { + for (const [position, rawTool] of toolDeltas.entries()) { + const tool = asRecord(rawTool); + if (!tool) continue; + const index = asIndex(tool.index, position); + const current = state.tools.get(index) ?? { value: { index }, functionValue: {} }; + + for (const [key, value] of Object.entries(tool)) { + if (key !== "function" && key !== "index" && value !== undefined) { + current.value[key] = value; + } + } + const functionDelta = asRecord(tool.function); + if (functionDelta) { + if (typeof functionDelta.name === "string") { + appendString(current.functionValue, "name", functionDelta.name); + } + if (typeof functionDelta.arguments === "string") { + appendString(current.functionValue, "arguments", functionDelta.arguments); + } + current.value.function = current.functionValue; + } + state.tools.set(index, current); + } } -interface OpenAiChoice { - index: number; - message: { - role: string; - content: string; - tool_calls?: OpenAiToolCall[]; - refusal?: string; +function rebuildOpenAiResponses(chunks: SseEvent[]): JsonRecord { + let response: JsonRecord = { object: "response", output: [] }; + const items = new Map(); + const contentByItem = new Map>(); + + const getItem = (outputIndex: number): JsonRecord => { + const existing = items.get(outputIndex); + if (existing) return existing; + const created: JsonRecord = { type: "message", role: "assistant", content: [] }; + items.set(outputIndex, created); + return created; }; - finish_reason: string | null; + + const getPart = (outputIndex: number, contentIndex: number): JsonRecord => { + let content = contentByItem.get(outputIndex); + if (!content) { + content = new Map(); + contentByItem.set(outputIndex, content); + } + const existing = content.get(contentIndex); + if (existing) return existing; + const created: JsonRecord = { type: "output_text", text: "" }; + content.set(contentIndex, created); + return created; + }; + + const mergeItem = (outputIndex: number, rawItem: unknown): void => { + const item = asRecord(rawItem); + if (!item) return; + const current = getItem(outputIndex); + for (const [key, value] of Object.entries(item)) { + if (key !== "content" && value !== undefined) current[key] = value; + } + for (const [contentIndex, rawPart] of asArray(item.content).entries()) { + const part = asRecord(rawPart); + if (part) Object.assign(getPart(outputIndex, contentIndex), part); + } + }; + + for (const chunk of chunks) { + const payload = asRecord(chunk.json); + if (!payload) continue; + const embeddedResponse = asRecord(payload.response); + if (embeddedResponse) { + for (const [key, value] of Object.entries(embeddedResponse)) { + if (key !== "output" && value !== undefined) response[key] = value; + } + for (const [outputIndex, item] of asArray(embeddedResponse.output).entries()) { + mergeItem(outputIndex, item); + } + } + + const outputIndex = asIndex(payload.output_index, 0); + const contentIndex = asIndex(payload.content_index, 0); + if ( + payload.type === "response.output_item.added" || + payload.type === "response.output_item.done" + ) { + mergeItem(outputIndex, payload.item); + } + if ( + payload.type === "response.content_part.added" || + payload.type === "response.content_part.done" + ) { + const part = asRecord(payload.part); + if (part) Object.assign(getPart(outputIndex, contentIndex), part); + } + if (payload.type === "response.output_text.delta") { + appendString(getPart(outputIndex, contentIndex), "text", payload.delta); + } + if (payload.type === "response.output_text.done" && typeof payload.text === "string") { + getPart(outputIndex, contentIndex).text = payload.text; + } + if (payload.type === "response.refusal.delta") { + const part = getPart(outputIndex, contentIndex); + part.type = "refusal"; + appendString(part, "refusal", payload.delta); + } + if (payload.type === "response.refusal.done" && typeof payload.refusal === "string") { + const part = getPart(outputIndex, contentIndex); + part.type = "refusal"; + part.refusal = payload.refusal; + } + if (payload.type === "response.function_call_arguments.delta") { + const item = getItem(outputIndex); + item.type = "function_call"; + appendString(item, "arguments", payload.delta); + } + if ( + payload.type === "response.function_call_arguments.done" && + typeof payload.arguments === "string" + ) { + const item = getItem(outputIndex); + item.type = "function_call"; + item.arguments = payload.arguments; + } + } + + response.output = [...items.entries()] + .sort(([left], [right]) => left - right) + .map(([outputIndex, item]) => { + const content = contentByItem.get(outputIndex); + if (content && content.size > 0) { + item.content = [...content.entries()] + .sort(([left], [right]) => left - right) + .map(([, part]) => part); + } + return item; + }); + return response; } -/** - * Rebuild an OpenAI Chat Completions response from streaming events. - * Accumulates content text and tool-call fragments per choice/index. - */ export function rebuildOpenAI(chunks: SseEvent[]): MergedResponse { - const choicesByIdx: Record = {}; - let model: string | null = null; - let usage: unknown = null; - let id: string | null = null; + const hasChatChunks = chunks.some((chunk) => Array.isArray(asRecord(chunk.json)?.choices)); + if (!hasChatChunks) { + return { format: "openai", message: rebuildOpenAiResponses(chunks) }; + } - for (const c of chunks) { - const j = asRecord(c.json); - if (!j) continue; - if (typeof j.model === "string") model = j.model; - if (typeof j.id === "string") id = j.id; - if (j.usage != null) usage = j.usage; - if (!Array.isArray(j.choices)) continue; + const result: JsonRecord = {}; + const choices = new Map(); - for (const raw of j.choices) { - const ch = asRecord(raw); - if (!ch) continue; - const idx = typeof ch.index === "number" ? ch.index : 0; - const slot = (choicesByIdx[idx] ??= { - index: idx, - message: { role: "assistant", content: "" }, - finish_reason: null, - }); - const delta = asRecord(ch.delta) ?? {}; - if (typeof delta.role === "string") slot.message.role = delta.role; - if (typeof delta.content === "string") slot.message.content += delta.content; - if (typeof delta.refusal === "string") { - slot.message.refusal = (slot.message.refusal ?? "") + delta.refusal; - } - if (Array.isArray(delta.tool_calls)) { - slot.message.tool_calls ??= []; - for (const tcRaw of delta.tool_calls) { - const tc = asRecord(tcRaw); - if (!tc) continue; - const ti = typeof tc.index === "number" ? tc.index : 0; - const tcSlot = - slot.message.tool_calls[ti] ?? - (slot.message.tool_calls[ti] = { - index: ti, - function: { name: "", arguments: "" }, - }); - if (typeof tc.id === "string") tcSlot.id = tc.id; - if (typeof tc.type === "string") tcSlot.type = tc.type; - const fn = asRecord(tc.function); - if (fn) { - if (typeof fn.name === "string") tcSlot.function.name += fn.name; - if (typeof fn.arguments === "string") tcSlot.function.arguments += fn.arguments; - } + for (const chunk of chunks) { + const payload = asRecord(chunk.json); + if (!payload) continue; + for (const [key, value] of Object.entries(payload)) { + if (key !== "choices" && key !== "usage" && value !== undefined) result[key] = value; + } + if (payload.usage !== undefined) result.usage = payload.usage; + + for (const [position, rawChoice] of asArray(payload.choices).entries()) { + const choice = asRecord(rawChoice); + if (!choice) continue; + const index = asIndex(choice.index, position); + const state = getOpenAiChoice(choices, index); + const delta = asRecord(choice.delta); + + if (delta) { + if (typeof delta.role === "string") state.message.role = delta.role; + appendString(state.message, "content", delta.content); + appendString(state.message, "refusal", delta.refusal); + mergeOpenAiTools(state, asArray(delta.tool_calls)); + + const functionCall = asRecord(delta.function_call); + if (functionCall) { + const current = asRecord(state.message.function_call) ?? {}; + appendString(current, "name", functionCall.name); + appendString(current, "arguments", functionCall.arguments); + state.message.function_call = current; } } - if (typeof ch.finish_reason === "string") slot.finish_reason = ch.finish_reason; - } - } - return { - format: "openai", - message: { - id, - model, - choices: Object.values(choicesByIdx).sort((a, b) => a.index - b.index), - usage, - }, - }; -} - -/** - * Rebuild a Gemini `generateContent`-style response from streaming events. - * Concatenates all parts across emitted candidates into a single candidate. - */ -export function rebuildGemini(chunks: SseEvent[]): MergedResponse { - const parts: unknown[] = []; - let usageMetadata: unknown = null; - let finishReason: unknown = null; - let modelVersion: string | null = null; - - for (const c of chunks) { - const j = asRecord(c.json); - if (!j) continue; - if (j.usageMetadata != null) usageMetadata = j.usageMetadata; - if (typeof j.modelVersion === "string") modelVersion = j.modelVersion; - if (!Array.isArray(j.candidates)) continue; - for (const candRaw of j.candidates) { - const cand = asRecord(candRaw); - if (!cand) continue; - if (cand.finishReason != null) finishReason = cand.finishReason; - const content = asRecord(cand.content); - if (!content) continue; - const ps = content.parts; - if (Array.isArray(ps)) { - for (const p of ps) parts.push(p); + for (const [key, value] of Object.entries(choice)) { + if (key !== "delta" && value !== null && value !== undefined) state.value[key] = value; } } } - return { - format: "gemini", - message: { - candidates: [ - { - content: { parts, role: "model" }, - ...(finishReason != null ? { finishReason } : {}), - }, - ], - ...(modelVersion ? { modelVersion } : {}), - ...(usageMetadata != null ? { usageMetadata } : {}), - }, - }; + result.choices = [...choices.entries()] + .sort(([left], [right]) => left - right) + .map(([, state]) => { + if (state.tools.size > 0) { + state.message.tool_calls = [...state.tools.entries()] + .sort(([left], [right]) => left - right) + .map(([, tool]) => tool.value); + } + return { ...state.value, message: state.message }; + }); + return { format: "openai", message: result }; +} + +export function rebuildGemini(chunks: SseEvent[]): MergedResponse { + const result: JsonRecord = {}; + const candidates = new Map(); + const parts = new Map(); + + for (const chunk of chunks) { + const payload = asRecord(chunk.json); + if (!payload) continue; + for (const [key, value] of Object.entries(payload)) { + if (key !== "candidates" && value !== undefined) result[key] = value; + } + + for (const [position, rawCandidate] of asArray(payload.candidates).entries()) { + const candidate = asRecord(rawCandidate); + if (!candidate) continue; + const index = asIndex(candidate.index, position); + const current = candidates.get(index) ?? { index }; + const content = asRecord(candidate.content); + const currentParts = parts.get(index) ?? []; + if (content) currentParts.push(...asArray(content.parts)); + parts.set(index, currentParts); + + for (const [key, value] of Object.entries(candidate)) { + if (key !== "content" && value !== undefined) current[key] = value; + } + if (content) { + current.content = { + ...asRecord(current.content), + ...content, + parts: currentParts, + }; + } + candidates.set(index, current); + } + } + + result.candidates = [...candidates.entries()] + .sort(([left], [right]) => left - right) + .map(([, candidate]) => candidate); + return { format: "gemini", message: result }; } -/** - * Merge an array of SSE events into a single rebuilt response. Returns - * `{ format: "unknown", raw }` (no throw) for unrecognised shapes. - */ export function mergeStream(chunks: SseEvent[]): MergedResponse { const format = detectApiFormat(chunks); - switch (format) { - case "anthropic": - return rebuildAnthropic(chunks); - case "openai": - return rebuildOpenAI(chunks); - case "gemini": - return rebuildGemini(chunks); - default: - return { format: "unknown", raw: chunks }; - } + if (format === "anthropic") return rebuildAnthropic(chunks); + if (format === "openai") return rebuildOpenAI(chunks); + if (format === "gemini") return rebuildGemini(chunks); + return { format: "unknown", raw: chunks }; } diff --git a/src/mitm/maskSecrets.ts b/src/mitm/maskSecrets.ts index e1695d36dd..218d49b02c 100644 --- a/src/mitm/maskSecrets.ts +++ b/src/mitm/maskSecrets.ts @@ -1,31 +1,122 @@ /** - * Secret masking utilities for MITM traffic inspection. - * Applied to all headers/bodies before any log or broadcast. - * Regex patterns are pre-compiled (order matters: BEARER first). + * Linear secret scanner for captured Traffic Inspector text. * - * Pattern sources: plano 11 §4.8 (origin: llm-interceptor proxy.py:310) + * Bearer credentials follow RFC 6750's token alphabet and take precedence over + * heuristic key masking so no suffix of a credential survives a partial match. */ -// Pre-compiled regex patterns — ORDER IS SIGNIFICANT (BEARER must run first). -// BEARER matches the token after a standalone "Bearer " — NOT only after a -// literal "authorization:" prefix. sanitizeHeaders() masks header *values* -// ("Bearer ") with the key already stripped, so a prefix-anchored regex -// never fired there and short/opaque-but-<40 tokens leaked into the inspector -// (found by the AgentBridge live capture). The char class is bounded + linear -// (no nested quantifiers) to stay ReDoS-safe. -const BEARER = /(\bBearer\s+)[A-Za-z0-9._~+/-]+=*/gi; -const SK_KEY = /\b(sk|ak|pk)-[A-Za-z0-9_-]{16,}\b/g; -const LONG_TOKEN = /\b[A-Za-z0-9_-]{40,}\b/g; +const PREFIXED_KEY_BODY_MIN = 16; +const OPAQUE_TOKEN_MIN = 40; -/** - * Mask secrets in a string value. - * - Bearer tokens: replaces the token after any "Bearer " with "***" - * - sk-/ak-/pk- keys: keeps first 6 chars + last 2 chars - * - Long opaque tokens (≥40 chars): keeps first 4 chars + last 2 chars - */ +interface Match { + start: number; + end: number; + replacement: string; +} + +function isAsciiLetter(code: number): boolean { + return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a); +} + +function isAsciiDigit(code: number): boolean { + return code >= 0x30 && code <= 0x39; +} + +function isKeyCharCode(code: number): boolean { + return isAsciiLetter(code) || isAsciiDigit(code) || code === 0x2d || code === 0x5f; +} + +function isBearerCoreCharCode(code: number): boolean { + return isKeyCharCode(code) || code === 0x2e || code === 0x7e || code === 0x2b || code === 0x2f; +} + +function isOpaqueCharCode(code: number): boolean { + return isBearerCoreCharCode(code); +} + +function equalsAsciiIgnoreCase(value: string, index: number, expected: string): boolean { + if (index + expected.length > value.length) return false; + for (let offset = 0; offset < expected.length; offset += 1) { + const actualCode = value.charCodeAt(index + offset); + const expectedCode = expected.charCodeAt(offset); + const folded = actualCode >= 0x41 && actualCode <= 0x5a ? actualCode + 0x20 : actualCode; + if (folded !== expectedCode) return false; + } + return true; +} + +function bearerMatch(value: string, index: number): Match | null { + if (!equalsAsciiIgnoreCase(value, index, "bearer")) return null; + if (index > 0 && isKeyCharCode(value.charCodeAt(index - 1))) return null; + + const schemeEnd = index + 6; + if (value.charCodeAt(schemeEnd) !== 0x20) return null; + let tokenStart = schemeEnd; + while (value.charCodeAt(tokenStart) === 0x20) tokenStart += 1; + + let end = tokenStart; + while (end < value.length && isBearerCoreCharCode(value.charCodeAt(end))) end += 1; + if (end === tokenStart) return null; + while (value.charCodeAt(end) === 0x3d) end += 1; + + return { start: tokenStart, end, replacement: "***" }; +} + +function prefixedKeyMatch(value: string, index: number): Match | null { + if (index > 0 && isOpaqueCharCode(value.charCodeAt(index - 1))) return null; + const prefix = value.slice(index, index + 3); + if (prefix !== "sk-" && prefix !== "ak-" && prefix !== "pk-") return null; + + let end = index + 3; + while (end < value.length && isOpaqueCharCode(value.charCodeAt(end))) end += 1; + if (end - (index + 3) < PREFIXED_KEY_BODY_MIN) return null; + while (value.charCodeAt(end) === 0x3d) end += 1; + + const token = value.slice(index, end); + return { + start: index, + end, + replacement: `${token.slice(0, 6)}…${token.slice(-2)}`, + }; +} + +function opaqueTokenMatch(value: string, index: number): Match | null { + if (!isOpaqueCharCode(value.charCodeAt(index))) return null; + if (index > 0 && isOpaqueCharCode(value.charCodeAt(index - 1))) return null; + + let end = index + 1; + while (end < value.length && isOpaqueCharCode(value.charCodeAt(end))) end += 1; + if (end - index < OPAQUE_TOKEN_MIN) return null; + while (value.charCodeAt(end) === 0x3d) end += 1; + + const token = value.slice(index, end); + return { + start: index, + end, + replacement: `${token.slice(0, 4)}…${token.slice(-2)}`, + }; +} + +/** Mask Bearer credentials, provider-style keys, and long opaque tokens. */ export function maskSecret(value: string): string { - return value - .replace(BEARER, "$1***") - .replace(SK_KEY, (m) => `${m.slice(0, 6)}…${m.slice(-2)}`) - .replace(LONG_TOKEN, (m) => `${m.slice(0, 4)}…${m.slice(-2)}`); + let output = ""; + let literalStart = 0; + let index = 0; + + while (index < value.length) { + const match = + bearerMatch(value, index) ?? prefixedKeyMatch(value, index) ?? opaqueTokenMatch(value, index); + if (!match) { + index += 1; + continue; + } + + output += value.slice(literalStart, match.start); + output += match.replacement; + index = match.end; + literalStart = match.end; + } + + if (literalStart === 0) return value; + return output + value.slice(literalStart); } diff --git a/src/mitm/sanitizeHeaders.ts b/src/mitm/sanitizeHeaders.ts index 386ff89681..fd736d501c 100644 --- a/src/mitm/sanitizeHeaders.ts +++ b/src/mitm/sanitizeHeaders.ts @@ -1,61 +1,37 @@ import type { IncomingHttpHeaders } from "node:http"; -import { isForbiddenUpstreamHeaderName } from "@/shared/constants/upstreamHeaders"; -import { maskSecret } from "./maskSecrets"; +import { isForbiddenUpstreamHeaderName } from "../shared/constants/upstreamHeaders.ts"; +import { maskSecret } from "./maskSecrets.ts"; -/** - * Header names whose values must be masked (case-insensitive). - * These carry credentials/tokens that must not appear in logs or broadcasts. - */ -const SECRET_HEADER_NAMES = new Set([ +const MASKED_CREDENTIAL_HEADERS = new Set([ "authorization", - "cookie", - // `set-cookie` is the RESPONSE-side credential header — upstream session/CSRF - // cookies must be masked too, else they leak verbatim into inspector JSON. - "set-cookie", - "x-api-key", "api-key", - "bearer", - "proxy-authorization", + "x-api-key", + "x-auth-token", + "x-goog-api-key", ]); +const FULLY_REDACTED_HEADERS = new Set(["cookie", "set-cookie"]); -function isSecretHeader(name: string): boolean { - return SECRET_HEADER_NAMES.has(name.toLowerCase()); +function sanitizeCredentialValue(value: string): string { + const masked = maskSecret(value); + return masked === value ? "[REDACTED]" : masked; } -/** - * Sanitize HTTP headers for safe logging/broadcasting. - * - * - Removes headers in the upstream denylist (hop-by-hop, Host, etc.) - * - Applies maskSecret() to values of authorization/cookie/key headers - * - Coerces array values to comma-joined strings - * - Returns a plain Record (never undefined values) - */ export function sanitizeHeaders( - headers: IncomingHttpHeaders | Record, + headers: IncomingHttpHeaders | Record ): Record { - const result: Record = {}; - - for (const [key, value] of Object.entries(headers)) { - if (value === undefined || value === null) continue; - - const lowerKey = key.toLowerCase(); - - // Remove denylist headers (hop-by-hop, framing) - if (isForbiddenUpstreamHeaderName(lowerKey)) continue; - - // Normalize array values - const strValue = Array.isArray(value) ? value.join(", ") : String(value); - - // Mask secret header values - if (isSecretHeader(lowerKey)) { - // `set-cookie` carries an entire session/CSRF cookie value that maskSecret's - // format heuristics (Bearer / sk- / ≥40-char) do NOT catch, so it would leak - // verbatim into inspector JSON — fully redact it (SECURITY_AUDIT M6). - result[lowerKey] = lowerKey === "set-cookie" ? "[REDACTED]" : maskSecret(strValue); - } else { - result[lowerKey] = strValue; + const sanitized: Record = {}; + for (const [rawName, rawValue] of Object.entries(headers)) { + const name = rawName.trim().toLowerCase(); + if (!name) continue; + if (isForbiddenUpstreamHeaderName(name)) continue; + if (FULLY_REDACTED_HEADERS.has(name)) { + sanitized[name] = "[REDACTED]"; + continue; } - } + if (rawValue === undefined) continue; - return result; + const value = Array.isArray(rawValue) ? rawValue.join(", ") : rawValue; + sanitized[name] = MASKED_CREDENTIAL_HEADERS.has(name) ? sanitizeCredentialValue(value) : value; + } + return sanitized; } diff --git a/tests/unit/inspector-conversation-normalizer.test.ts b/tests/unit/inspector-conversation-normalizer.test.ts index f8a4b1ad27..13bb055ad3 100644 --- a/tests/unit/inspector-conversation-normalizer.test.ts +++ b/tests/unit/inspector-conversation-normalizer.test.ts @@ -165,6 +165,26 @@ test("normalizes Responses API reasoning items (no `role` field) into an assista assert.equal((conv.request[0].blocks[0] as { text: string }).text, "Thinking about the request."); }); +test("normalizes Responses API instructions as a system turn before input", () => { + const req = makeReq({ + path: "/v1/responses", + requestBody: JSON.stringify({ + instructions: "Answer tersely.", + input: [{ role: "user", content: [{ type: "input_text", text: "Hello" }] }], + }), + }); + const conv = normalizeConversation(req); + assert.ok(conv); + assert.deepEqual( + conv.request.map((turn) => turn.role), + ["system", "user"] + ); + assert.equal( + (conv.request[0].blocks[0] as { type: "text"; text: string }).text, + "Answer tersely." + ); +}); + test("normalizes Anthropic request with top-level system + tool_use response", () => { const req = makeReq({ host: "api.anthropic.com", @@ -190,6 +210,29 @@ test("normalizes Anthropic request with top-level system + tool_use response", ( assert.equal(conv.response[0].blocks[1].type, "tool_use"); }); +test("normalizes Anthropic user-carried tool_result content as a tool turn", () => { + const req = makeReq({ + host: "api.anthropic.com", + path: "/v1/messages", + requestBody: JSON.stringify({ + messages: [ + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "sunny" }], + }, + ], + }), + }); + const conv = normalizeConversation(req); + assert.ok(conv); + assert.equal(conv.request[0].role, "tool"); + assert.deepEqual(conv.request[0].blocks[0], { + type: "tool_result", + tool_use_id: "toolu_1", + content: "sunny", + }); +}); + test("normalizes Gemini request contents + functionCall response", () => { const req = makeReq({ host: "generativelanguage.googleapis.com", diff --git a/tests/unit/inspector-sse-merger.test.ts b/tests/unit/inspector-sse-merger.test.ts index 10f8b996fb..a1ca5b4447 100644 --- a/tests/unit/inspector-sse-merger.test.ts +++ b/tests/unit/inspector-sse-merger.test.ts @@ -15,9 +15,7 @@ function jsonChunks(payloads: unknown[]): SseEvent[] { } test("detectApiFormat — message_start → anthropic", () => { - const chunks = jsonChunks([ - { type: "message_start", message: { id: "msg_1" } }, - ]); + const chunks = jsonChunks([{ type: "message_start", message: { id: "msg_1" } }]); assert.equal(detectApiFormat(chunks), "anthropic"); }); @@ -28,16 +26,24 @@ test("detectApiFormat — content_block_delta → anthropic", () => { assert.equal(detectApiFormat(chunks), "anthropic"); }); +test("detectApiFormat — Anthropic SSE event name is authoritative when JSON type is absent", () => { + const chunks: SseEvent[] = [{ event: "message_start", data: "{}", json: {} }]; + assert.equal(detectApiFormat(chunks), "anthropic"); +}); + test("detectApiFormat — choices[].delta → openai", () => { - const chunks = jsonChunks([ - { choices: [{ index: 0, delta: { content: "Hi" } }] }, - ]); + const chunks = jsonChunks([{ choices: [{ index: 0, delta: { content: "Hi" } }] }]); assert.equal(detectApiFormat(chunks), "openai"); }); test("detectApiFormat — candidates → gemini", () => { + const chunks = jsonChunks([{ candidates: [{ content: { parts: [{ text: "Hello" }] } }] }]); + assert.equal(detectApiFormat(chunks), "gemini"); +}); + +test("detectApiFormat — Gemini usage-only terminal chunk remains identifiable", () => { const chunks = jsonChunks([ - { candidates: [{ content: { parts: [{ text: "Hello" }] } }] }, + { usageMetadata: { promptTokenCount: 3, candidatesTokenCount: 2 }, modelVersion: "gemini" }, ]); assert.equal(detectApiFormat(chunks), "gemini"); }); @@ -57,7 +63,11 @@ test("rebuildAnthropic — concat text_delta by index", () => { ]); const merged = rebuildAnthropic(chunks); assert.equal(merged.format, "anthropic"); - const msg = merged.message as { content: Array<{ text: string }>; stop_reason: string; usage: { output_tokens: number } }; + const msg = merged.message as { + content: Array<{ text: string }>; + stop_reason: string; + usage: { output_tokens: number }; + }; assert.equal(msg.content[0].text, "Hello world"); assert.equal(msg.stop_reason, "end_turn"); assert.equal(msg.usage.output_tokens, 7); @@ -71,12 +81,22 @@ test("rebuildAnthropic — input_json_delta merges and JSON.parses", () => { index: 0, content_block: { type: "tool_use", id: "tu_1", name: "search" }, }, - { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"q":' } }, - { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '"hi"}' } }, + { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"q":' }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '"hi"}' }, + }, { type: "content_block_stop", index: 0 }, ]); const merged = rebuildAnthropic(chunks); - const msg = merged.message as { content: Array<{ type: string; input: { q: string }; name: string }> }; + const msg = merged.message as { + content: Array<{ type: string; input: { q: string }; name: string }>; + }; assert.equal(msg.content[0].type, "tool_use"); assert.equal(msg.content[0].name, "search"); assert.deepEqual(msg.content[0].input, { q: "hi" }); @@ -86,7 +106,11 @@ test("rebuildAnthropic — thinking_delta accumulates", () => { const chunks = jsonChunks([ { type: "content_block_start", index: 0, content_block: { type: "thinking" } }, { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "I am " } }, - { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "thinking..." } }, + { + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: "thinking..." }, + }, ]); const merged = rebuildAnthropic(chunks); const msg = merged.message as { content: Array<{ thinking: string }> }; @@ -95,7 +119,11 @@ test("rebuildAnthropic — thinking_delta accumulates", () => { test("rebuildOpenAI — concat delta.content per choice index", () => { const chunks = jsonChunks([ - { id: "c1", model: "gpt-4", choices: [{ index: 0, delta: { role: "assistant", content: "Hel" } }] }, + { + id: "c1", + model: "gpt-4", + choices: [{ index: 0, delta: { role: "assistant", content: "Hel" } }], + }, { choices: [{ index: 0, delta: { content: "lo" }, finish_reason: null }] }, { choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }, { usage: { prompt_tokens: 3, completion_tokens: 1 } }, @@ -123,7 +151,14 @@ test("rebuildOpenAI — merges tool_calls per (choice, tool) index", () => { { index: 0, delta: { - tool_calls: [{ index: 0, id: "tc_1", type: "function", function: { name: "search", arguments: '{"q":' } }], + tool_calls: [ + { + index: 0, + id: "tc_1", + type: "function", + function: { name: "search", arguments: '{"q":' }, + }, + ], }, }, ], @@ -152,6 +187,46 @@ test("rebuildOpenAI — merges tool_calls per (choice, tool) index", () => { assert.equal(msg.choices[0].message.tool_calls[0].function.arguments, '{"q":"hi"}'); }); +test("rebuildOpenAI — reconstructs Responses API text items and completion metadata", () => { + const chunks = jsonChunks([ + { type: "response.created", response: { id: "resp_1", object: "response", output: [] } }, + { + type: "response.output_item.added", + output_index: 0, + item: { id: "msg_1", type: "message", role: "assistant", content: [] }, + }, + { + type: "response.content_part.added", + output_index: 0, + content_index: 0, + part: { type: "output_text", text: "" }, + }, + { type: "response.output_text.delta", output_index: 0, content_index: 0, delta: "Hel" }, + { type: "response.output_text.delta", output_index: 0, content_index: 0, delta: "lo" }, + { + type: "response.completed", + response: { + id: "resp_1", + object: "response", + status: "completed", + output: [], + usage: { input_tokens: 2, output_tokens: 1, total_tokens: 3 }, + }, + }, + ]); + + const merged = rebuildOpenAI(chunks); + const response = merged.message as { + status: string; + usage: { total_tokens: number }; + output: Array<{ id: string; content: Array<{ text: string }> }>; + }; + assert.equal(response.status, "completed"); + assert.equal(response.usage.total_tokens, 3); + assert.equal(response.output[0].id, "msg_1"); + assert.equal(response.output[0].content[0].text, "Hello"); +}); + test("rebuildGemini — merges parts across candidates", () => { const chunks = jsonChunks([ { candidates: [{ content: { parts: [{ text: "Hello " }] } }] }, @@ -171,10 +246,7 @@ test("rebuildGemini — merges parts across candidates", () => { }); test("mergeStream — unknown format returns raw fallback (no crash)", () => { - const chunks: SseEvent[] = [ - { data: "garbage" }, - { data: "{}", json: { foo: 1 } }, - ]; + const chunks: SseEvent[] = [{ data: "garbage" }, { data: "{}", json: { foo: 1 } }]; const merged = mergeStream(chunks); assert.equal(merged.format, "unknown"); assert.ok(Array.isArray(merged.raw)); @@ -182,10 +254,7 @@ test("mergeStream — unknown format returns raw fallback (no crash)", () => { }); test("parseSseStream — parses event/data blocks separated by blank lines", () => { - const raw = - "event: foo\ndata: 1\n\n" + - 'data: {"x":1}\n\n' + - "data: [DONE]\n\n"; + const raw = "event: foo\ndata: 1\n\n" + 'data: {"x":1}\n\n' + "data: [DONE]\n\n"; const events = parseSseStream(raw); assert.equal(events.length, 3); assert.equal(events[0].event, "foo"); diff --git a/tests/unit/mitm-masksecrets.test.ts b/tests/unit/mitm-masksecrets.test.ts index f863c0d3bb..70dcc760f2 100644 --- a/tests/unit/mitm-masksecrets.test.ts +++ b/tests/unit/mitm-masksecrets.test.ts @@ -39,6 +39,23 @@ test("maskSecret — long opaque token (≥40 chars) is masked", () => { assert.ok(result.length < longToken.length); }); +test("maskSecret — dotted opaque tokens are redacted as one integral credential", () => { + const token = + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2NvcGUiOiJhZG1pbiJ9.signature1234567890"; + const result = maskSecret(token); + assert.ok(result.startsWith("eyJh")); + assert.ok(result.endsWith("…90")); + assert.ok(!result.includes("eyJzdWIi"), "the middle JWT-like segment must not survive"); + assert.equal((result.match(/…/g) ?? []).length, 1, "the credential must be masked once"); +}); + +test("maskSecret — padded opaque tokens include trailing padding in one redaction", () => { + const token = `${"A".repeat(42)}==`; + const result = maskSecret(token); + assert.equal(result, "AAAA…=="); + assert.equal((result.match(/…/g) ?? []).length, 1); +}); + test("maskSecret — string without secrets is unchanged", () => { const safe = "Content-Type: application/json"; assert.equal(maskSecret(safe), safe); diff --git a/tests/unit/mitm-sanitize-headers.test.ts b/tests/unit/mitm-sanitize-headers.test.ts index 4e7bb80def..c7c9bda1e0 100644 --- a/tests/unit/mitm-sanitize-headers.test.ts +++ b/tests/unit/mitm-sanitize-headers.test.ts @@ -26,9 +26,31 @@ test("sanitizeHeaders — Set-Cookie header name is matched case-insensitively", assert.equal(out["set-cookie"], "[REDACTED]"); }); +test("sanitizeHeaders — request cookies are fully redacted regardless of token shape", () => { + const out = sanitizeHeaders({ Cookie: "session=short; csrf=also-short" }); + assert.deepEqual(out, { cookie: "[REDACTED]" }); +}); + test("sanitizeHeaders — authorization bearer token is still masked, not leaked", () => { const out = sanitizeHeaders({ authorization: "Bearer sk-proj-abcdefghijklmnop" }); - assert.ok(!out["authorization"].includes("sk-proj-abcdefghijklmnop"), "bearer token must be masked"); + assert.ok( + !out["authorization"].includes("sk-proj-abcdefghijklmnop"), + "bearer token must be masked" + ); +}); + +test("sanitizeHeaders — drops framing, hop-by-hop, and proxy credential headers", () => { + const out = sanitizeHeaders({ + Host: "provider.example", + Connection: "keep-alive", + "Content-Length": "42", + "Proxy-Authorization": "Basic c2VjcmV0", + "Proxy-Authenticate": "Basic realm=proxy", + TE: "trailers", + Upgrade: "websocket", + "X-Keep": "visible", + }); + assert.deepEqual(out, { "x-keep": "visible" }); }); test("sanitizeHeaders — non-secret headers pass through unchanged", () => { @@ -36,3 +58,11 @@ test("sanitizeHeaders — non-secret headers pass through unchanged", () => { assert.equal(out["content-type"], "application/json"); assert.equal(out["x-request-id"], "req-42"); }); + +test("sanitizeHeaders — lowercases names, joins arrays predictably, and omits undefined", () => { + const out = sanitizeHeaders({ + "X-Trace": ["edge-a", "edge-b"], + "X-Missing": undefined, + }); + assert.deepEqual(out, { "x-trace": "edge-a, edge-b" }); +});