From 907075f70403a5491cd8b520cef4b877f490d4e8 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 28 May 2026 17:07:01 -0300 Subject: [PATCH] feat(db,inspector): add snapshotSession + restore hookBufferUpdate spec contract (M1+M2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `snapshotSession(sessionId)` to inspectorSessions.ts per master-plan §3.8: returns parsed InterceptedRequest[] in seq order, or null for non-existent sessions. Silently skips rows that fail InterceptedRequestSchema validation (defensive). - Restore canonical no-arg form of `MitmHandlerBase.hookBufferUpdate(intercepted)` per master-plan §3.5: when opts is omitted, derive completion fields from the intercepted object itself (status/responseHeaders/responseBody/responseSize/ *LatencyMs) rather than no-op'ing. Extended opts form preserved for legacy callers. - Update Zod record() calls in InterceptedRequestSchema to current (key,value) signature. - Add 3 unit tests for snapshotSession (happy path / non-existent / silent skip). - Add 2 unit tests for hookBufferUpdate (no-arg form + extended opts form). --- src/lib/db/inspectorSessions.ts | 40 +++++++++++++++++++ src/mitm/handlers/base.ts | 18 ++++++++- src/mitm/inspector/types.ts | 4 +- tests/unit/db-inspector-sessions.test.ts | 51 ++++++++++++++++++++++++ tests/unit/mitm-handler-base.test.ts | 46 +++++++++++++++++++++ 5 files changed, 155 insertions(+), 4 deletions(-) diff --git a/src/lib/db/inspectorSessions.ts b/src/lib/db/inspectorSessions.ts index c0d102c99b..aae84257b5 100644 --- a/src/lib/db/inspectorSessions.ts +++ b/src/lib/db/inspectorSessions.ts @@ -6,6 +6,8 @@ import { randomUUID } from "crypto"; import { getDbInstance } from "./core"; import type { InspectorSessionRow } from "./_rowTypes"; +import { InterceptedRequestSchema } from "../../mitm/inspector/types"; +import type { InterceptedRequest } from "../../mitm/inspector/types"; interface InspectorSessionDbRow { id: string; @@ -115,3 +117,41 @@ export function deleteSession(id: string): void { // Cascade via FK ON DELETE CASCADE for inspector_session_requests db.prepare("DELETE FROM inspector_sessions WHERE id = ?").run(id); } + +/** + * Return a parsed + validated snapshot of all requests for the given session, + * sorted by ascending seq. + * + * Returns null when the session does not exist. + * Rows whose payload fails InterceptedRequestSchema validation are silently + * skipped (defensive — protects callers from corrupt/partial rows). + * + * Satisfies master-plan §3.8 (F2 spec) named-export contract. + */ +export function snapshotSession(sessionId: string): InterceptedRequest[] | null { + // 1. Verify session exists. + const session = getSession(sessionId); + if (session === null) return null; + + // 2. Retrieve raw rows (already ordered by seq ASC). + const rawRows = getSessionRequests(sessionId); + + // 3. Parse each payload JSON, validate via Zod schema, skip bad rows. + const results: InterceptedRequest[] = []; + for (const row of rawRows) { + let parsed: unknown; + try { + parsed = JSON.parse(row.payload); + } catch { + // Corrupt JSON — skip. + continue; + } + const result = InterceptedRequestSchema.safeParse(parsed); + if (result.success) { + results.push(result.data as InterceptedRequest); + } + // Invalid rows are silently skipped per defensive contract. + } + + return results; +} diff --git a/src/mitm/handlers/base.ts b/src/mitm/handlers/base.ts index 8709e71e2f..6b1e45023e 100644 --- a/src/mitm/handlers/base.ts +++ b/src/mitm/handlers/base.ts @@ -250,6 +250,13 @@ export abstract class MitmHandlerBase { /** * Update a previously published Traffic Inspector entry with completion data. * No-op when the inspector module is not present. + * + * Per master-plan §3.5: the canonical no-arg form `hookBufferUpdate(intercepted)` + * must update the buffer using completion fields already present on `intercepted` + * (status, responseBody, responseHeaders, responseSize, *LatencyMs). When the + * extended `opts` form is used (legacy internal callers), it overrides those + * fields explicitly. Both forms route through `recordRequestComplete` so the + * inspector receives a consistent shape. */ protected hookBufferUpdate( intercepted: InterceptedRequest, @@ -262,11 +269,18 @@ export abstract class MitmHandlerBase { upstreamLatencyMs: number; }, ): void { - if (!opts) return; + const finalOpts = opts ?? { + status: typeof intercepted.status === "number" ? intercepted.status : 0, + responseHeaders: intercepted.responseHeaders, + responseBody: intercepted.responseBody, + responseSize: intercepted.responseSize, + proxyLatencyMs: intercepted.proxyLatencyMs ?? 0, + upstreamLatencyMs: intercepted.upstreamLatencyMs ?? 0, + }; void loadAgentBridgeHook().then((hook) => { if (hook?.recordRequestComplete) { try { - hook.recordRequestComplete(intercepted, opts); + hook.recordRequestComplete(intercepted, finalOpts); } catch { // Hook should never break interception. } diff --git a/src/mitm/inspector/types.ts b/src/mitm/inspector/types.ts index 5079d836b7..dff03e20f1 100644 --- a/src/mitm/inspector/types.ts +++ b/src/mitm/inspector/types.ts @@ -39,10 +39,10 @@ export const InterceptedRequestSchema = z.object({ method: z.string(), host: z.string(), path: z.string(), - requestHeaders: z.record(z.string()), + requestHeaders: z.record(z.string(), z.string()), requestBody: z.string().nullable(), requestSize: z.number().int().nonnegative(), - responseHeaders: z.record(z.string()), + responseHeaders: z.record(z.string(), z.string()), responseBody: z.string().nullable(), responseSize: z.number().int().nonnegative(), status: z.union([z.number().int(), z.literal("in-flight"), z.literal("error")]), diff --git a/tests/unit/db-inspector-sessions.test.ts b/tests/unit/db-inspector-sessions.test.ts index 5131a35433..801309d7d1 100644 --- a/tests/unit/db-inspector-sessions.test.ts +++ b/tests/unit/db-inspector-sessions.test.ts @@ -147,3 +147,54 @@ test("getSessionRequests returns empty array for session with no requests", () = const requests = mod.getSessionRequests(id); assert.deepEqual(requests, []); }); + +function makeValidInterceptedPayload(overrides: Record = {}): string { + return JSON.stringify({ + id: crypto.randomUUID(), + source: "agent-bridge", + timestamp: new Date().toISOString(), + method: "POST", + host: "api.example.com", + path: "/v1/chat/completions", + requestHeaders: { "content-type": "application/json" }, + requestBody: null, + requestSize: 0, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + status: 200, + ...overrides, + }); +} + +test("snapshotSession returns parsed InterceptedRequest[] in seq order", () => { + const { id } = mod.createSession(); + mod.appendSessionRequest(id, makeValidInterceptedPayload({ path: "/req-1" })); + mod.appendSessionRequest(id, makeValidInterceptedPayload({ path: "/req-2" })); + mod.appendSessionRequest(id, makeValidInterceptedPayload({ path: "/req-3" })); + + const snapshot = mod.snapshotSession(id); + assert.ok(snapshot !== null); + assert.equal(snapshot.length, 3); + assert.equal(snapshot[0].path, "/req-1"); + assert.equal(snapshot[1].path, "/req-2"); + assert.equal(snapshot[2].path, "/req-3"); +}); + +test("snapshotSession returns null for non-existent session", () => { + const snapshot = mod.snapshotSession("00000000-0000-4000-8000-000000000000"); + assert.equal(snapshot, null); +}); + +test("snapshotSession silently skips rows that fail schema validation", () => { + const { id } = mod.createSession(); + mod.appendSessionRequest(id, makeValidInterceptedPayload({ path: "/good" })); + mod.appendSessionRequest(id, JSON.stringify({ malformed: true })); + mod.appendSessionRequest(id, makeValidInterceptedPayload({ path: "/good-2" })); + + const snapshot = mod.snapshotSession(id); + assert.ok(snapshot !== null); + assert.equal(snapshot.length, 2); + assert.equal(snapshot[0].path, "/good"); + assert.equal(snapshot[1].path, "/good-2"); +}); diff --git a/tests/unit/mitm-handler-base.test.ts b/tests/unit/mitm-handler-base.test.ts index d2c5c4a56a..cc35b58814 100644 --- a/tests/unit/mitm-handler-base.test.ts +++ b/tests/unit/mitm-handler-base.test.ts @@ -23,6 +23,13 @@ class TestHandler extends MitmHandlerBase { ): Promise> { return this.hookBufferStart(req, body, mapped); } + + publicHookUpdate( + intercepted: Parameters[0], + opts?: Parameters[1] + ): void { + return this.hookBufferUpdate(intercepted, opts); + } } function fakeReq(headers: Record = {}): IncomingMessage { @@ -87,6 +94,45 @@ test("base.hookBufferStart — body is captured (default shouldCaptureBody=true) assert.ok(typeof r.requestBody === "string"); }); +test("base.hookBufferUpdate — no-arg form (per spec §3.5) derives completion data from intercepted", async () => { + const h = new TestHandler(); + const req = fakeReq(); + const body = Buffer.from(JSON.stringify({ model: "gpt-4o", messages: [] })); + const intercepted = await h.publicHookStart(req, body, "gpt-4o-mapped"); + + // Mutate the completion fields on `intercepted` to simulate a finished request. + intercepted.status = 200; + intercepted.responseHeaders = { "content-type": "application/json" }; + intercepted.responseBody = JSON.stringify({ ok: true }); + intercepted.responseSize = 12; + intercepted.proxyLatencyMs = 5; + intercepted.upstreamLatencyMs = 120; + + // Per master-plan §3.5, hookBufferUpdate(intercepted) without opts must + // succeed and update the buffer using fields already on `intercepted`. + // The local stub treats hook as absent, so this is effectively a no-op + // but must NOT throw. + assert.doesNotThrow(() => h.publicHookUpdate(intercepted)); +}); + +test("base.hookBufferUpdate — extended opts form still works", async () => { + const h = new TestHandler(); + const req = fakeReq(); + const body = Buffer.from(JSON.stringify({ messages: [] })); + const intercepted = await h.publicHookStart(req, body, "mapped"); + + assert.doesNotThrow(() => + h.publicHookUpdate(intercepted, { + status: 200, + responseHeaders: {}, + responseBody: null, + responseSize: 0, + proxyLatencyMs: 1, + upstreamLatencyMs: 2, + }) + ); +}); + test("base.writeError — writes sanitized JSON error body", async () => { const h = new TestHandler(); let status = 0;