| |
| import fs from "node:fs"; |
| import os from "node:os"; |
| import path from "node:path"; |
| import { Headers as UndiciHeaders } from "undici"; |
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| import { registerSecretValueForRedaction } from "../logging/secret-redaction-registry.js"; |
| import { resetSecretRedactionRegistryForTest } from "../logging/secret-redaction-registry.test-support.js"; |
| import { createDeferredCore } from "../shared/deferred.js"; |
| import { closeOpenClawStateDatabaseByPath } from "../state/openclaw-state-db-cache.js"; |
| import type { DebugProxySettings } from "./env.js"; |
| import { |
| captureHttpExchange, |
| captureWsEvent, |
| finalizeDebugProxyCapture, |
| initializeDebugProxyCapture, |
| type DebugProxyCaptureRuntimeDeps, |
| } from "./runtime.js"; |
| import { DebugProxyCaptureStore, persistEventPayload } from "./store.sqlite.js"; |
|
|
| type StoreCall = { name: string; args: unknown[] }; |
|
|
| const settings: DebugProxySettings = { |
| enabled: true, |
| required: false, |
| dbPath: "/tmp/openclaw-proxy-runtime-test.sqlite", |
| blobDir: "/tmp/openclaw-proxy-runtime-test-blobs", |
| certDir: "/tmp/openclaw-proxy-runtime-test-certs", |
| sessionId: "runtime-test-session", |
| sourceProcess: "runtime-test", |
| }; |
|
|
| const fetchTarget: typeof globalThis = { |
| ...globalThis, |
| fetch: async () => new Response("{}", { status: 200 }), |
| }; |
|
|
| const events: Record<string, unknown>[] = []; |
| const calls: StoreCall[] = []; |
| const store = { |
| upsertSession: (...args: unknown[]) => { |
| calls.push({ name: "upsertSession", args }); |
| }, |
| endSession: (...args: unknown[]) => { |
| calls.push({ name: "endSession", args }); |
| }, |
| recordEvent: (event: Record<string, unknown>) => { |
| events.push(event); |
| }, |
| }; |
|
|
| const deps: DebugProxyCaptureRuntimeDeps = { |
| fetchTarget, |
| getStore: () => store, |
| closeStore: () => { |
| calls.push({ name: "closeStore", args: [] }); |
| }, |
| persistEventPayload: ( |
| _store: unknown, |
| payload: { data?: Buffer | string | null; contentType?: string }, |
| ) => ({ |
| contentType: payload.contentType, |
| ...(typeof payload.data === "string" |
| ? { dataText: payload.data } |
| : Buffer.isBuffer(payload.data) |
| ? { dataText: payload.data.toString("utf8") } |
| : {}), |
| }), |
| safeJsonString: (value: unknown) => (value == null ? undefined : JSON.stringify(value)), |
| }; |
|
|
| const ONE_MIB = 1024 * 1024; |
|
|
| |
| |
| function makeStreamingResponse(totalBytes: number, headers: Record<string, string> = {}): Response { |
| let sent = 0; |
| const stream = new ReadableStream<Uint8Array>({ |
| pull(controller) { |
| if (sent >= totalBytes) { |
| controller.close(); |
| return; |
| } |
| const size = Math.min(ONE_MIB, totalBytes - sent); |
| sent += size; |
| controller.enqueue(new Uint8Array(size)); |
| }, |
| }); |
| return new Response(stream, { |
| status: 200, |
| headers: { "content-type": "application/octet-stream", ...headers }, |
| }); |
| } |
|
|
| async function waitForResponseSettled(): Promise<void> { |
| for (let i = 0; i < 500; i += 1) { |
| if (events.some((event) => event.kind === "response" || event.kind === "error")) { |
| return; |
| } |
| await new Promise((resolve) => { |
| setImmediate(resolve); |
| }); |
| } |
| } |
|
|
| describe("debug proxy runtime", () => { |
| beforeEach(() => { |
| finalizeDebugProxyCapture(settings, deps); |
| |
| deps.getStore = () => store; |
| events.length = 0; |
| calls.length = 0; |
| resetSecretRedactionRegistryForTest(); |
| fetchTarget.fetch = async () => new Response("{}", { status: 200 }); |
| }); |
|
|
| afterEach(() => { |
| vi.unstubAllEnvs(); |
| vi.restoreAllMocks(); |
| }); |
|
|
| it("settles a pending response capture before finalization closes its store", async () => { |
| const root = fs.mkdtempSync(path.join(os.tmpdir(), "capture-finalize-test-")); |
| const realStore = new DebugProxyCaptureStore({ env: { OPENCLAW_STATE_DIR: root } }); |
| const readStarted = createDeferredCore(); |
| const terminalRecorded = createDeferredCore(); |
| let controller: ReadableStreamDefaultController<Uint8Array> | undefined; |
| let lateWrites = 0; |
| const record = realStore.recordEvent.bind(realStore); |
| const recording = vi.spyOn(realStore, "recordEvent").mockImplementation((event) => { |
| if (realStore.isClosed) { |
| lateWrites += 1; |
| } |
| record(event); |
| if (event.kind === "response" || event.kind === "error") { |
| terminalRecorded.resolve(); |
| } |
| }); |
| const realDeps: DebugProxyCaptureRuntimeDeps = { |
| fetchTarget, |
| getStore: () => realStore, |
| closeStore: () => realStore.close(), |
| persistEventPayload: (_store, payload) => persistEventPayload(realStore, payload), |
| }; |
| const response = new Response( |
| new ReadableStream<Uint8Array>({ |
| start(value) { |
| controller = value; |
| }, |
| pull() { |
| readStarted.resolve(); |
| }, |
| }), |
| { headers: { "content-type": "text/plain" } }, |
| ); |
| try { |
| captureHttpExchange( |
| { url: "https://example.test/pending", method: "GET", response }, |
| settings, |
| realDeps, |
| ); |
| await readStarted.promise; |
| expect(realStore.getSessionEvents(settings.sessionId)).toHaveLength(1); |
| const finalizing = Promise.resolve(finalizeDebugProxyCapture(settings, realDeps)); |
| controller!.enqueue(new TextEncoder().encode("complete")); |
| controller!.close(); |
| expect(await response.text()).toBe("complete"); |
| await terminalRecorded.promise; |
| await finalizing; |
| expect(realStore.isClosed).toBe(true); |
| expect(lateWrites).toBe(0); |
| } finally { |
| recording.mockRestore(); |
| realStore.close(); |
| closeOpenClawStateDatabaseByPath(realStore.dbPath); |
| fs.rmSync(root, { recursive: true, force: true }); |
| } |
| }); |
|
|
| it.each([ |
| ["initialization", () => initializeDebugProxyCapture("test", undefined, deps)], |
| ["finalization", () => finalizeDebugProxyCapture(undefined, deps)], |
| [ |
| "HTTP exchange", |
| () => |
| captureHttpExchange( |
| { url: "https://example.test", method: "GET", response: new Response(null) }, |
| undefined, |
| deps, |
| ), |
| ], |
| [ |
| "WebSocket frame", |
| () => |
| captureWsEvent( |
| { |
| url: "wss://example.test", |
| direction: "outbound", |
| kind: "ws-frame", |
| flowId: "disabled-capture", |
| payload: "{}", |
| }, |
| undefined, |
| deps, |
| ), |
| ], |
| ] as const)("does not discover capture paths for disabled %s", (_name, capture) => { |
| |
| vi.stubEnv("OPENCLAW_TEST_FAST", "0"); |
| vi.stubEnv("OPENCLAW_STATE_DIR", undefined); |
| vi.stubEnv("OPENCLAW_DEBUG_PROXY_ENABLED", undefined); |
| const existsSync = vi.spyOn(fs, "existsSync"); |
| const originalFetch = fetchTarget.fetch; |
|
|
| capture(); |
|
|
| expect(existsSync).not.toHaveBeenCalled(); |
| expect(calls).toEqual([]); |
| expect(events).toEqual([]); |
| expect(fetchTarget.fetch).toBe(originalFetch); |
| }); |
|
|
| it("observes environment changes while explicit capture settings remain authoritative", () => { |
| const frame = { |
| url: "wss://example.test", |
| direction: "outbound", |
| kind: "ws-frame", |
| flowId: "capture-toggle", |
| payload: "{}", |
| } as const; |
| vi.stubEnv("OPENCLAW_DEBUG_PROXY_SESSION_ID", "ambient-capture"); |
| vi.stubEnv("OPENCLAW_DEBUG_PROXY_ENABLED", "0"); |
| captureWsEvent(frame, undefined, deps); |
| expect(events).toEqual([]); |
|
|
| vi.stubEnv("OPENCLAW_DEBUG_PROXY_ENABLED", "1"); |
| captureWsEvent(frame, undefined, deps); |
| expect(events.map((event) => event.sessionId)).toEqual(["ambient-capture"]); |
| captureWsEvent(frame, { ...settings, enabled: false }, deps); |
| expect(events).toHaveLength(1); |
|
|
| vi.stubEnv("OPENCLAW_DEBUG_PROXY_ENABLED", "0"); |
| captureWsEvent(frame, undefined, deps); |
| expect(events).toHaveLength(1); |
| captureWsEvent(frame, settings, deps); |
| expect(events.map((event) => event.sessionId)).toEqual([ |
| "ambient-capture", |
| "runtime-test-session", |
| ]); |
| }); |
|
|
| it("captures ambient global fetch calls when debug proxy mode is enabled", async () => { |
| initializeDebugProxyCapture("test", settings, deps); |
| await fetchTarget.fetch("https://api.minimax.io/anthropic/messages", { |
| method: "POST", |
| headers: { "content-type": "application/json" }, |
| body: '{"input":"hello"}', |
| }); |
| await new Promise((resolve) => { |
| setImmediate(resolve); |
| }); |
| finalizeDebugProxyCapture(settings, deps); |
|
|
| const sessionEvents = events.filter((event) => event.sessionId === "runtime-test-session"); |
| expect(sessionEvents.map((event) => event.host)).toContain("api.minimax.io"); |
| expect(sessionEvents.map((event) => event.kind)).toEqual(["request", "response"]); |
| }); |
|
|
| it("normalizes symbol-bearing request headers before calling patched fetch targets", async () => { |
| fetchTarget.fetch = async (_input: RequestInfo | URL, init?: RequestInit) => { |
| const headers = new Headers(init?.headers); |
| expect(headers.get("content-type")).toBe("application/json"); |
| expect(headers.get("x-hidden")).toBe("yes"); |
| return new Response("{}", { status: 200 }); |
| }; |
| const headers = { "content-type": "application/json" } as Record<string, string> & { |
| [key: symbol]: unknown; |
| }; |
| Object.defineProperty(headers, "x-hidden", { |
| value: "yes", |
| enumerable: false, |
| }); |
| Object.defineProperty(headers, Symbol("sensitiveHeaders"), { |
| value: new Set(["content-type"]), |
| enumerable: false, |
| }); |
|
|
| initializeDebugProxyCapture("test", settings, deps); |
| await fetchTarget.fetch("https://api.example.com/messages", { |
| method: "POST", |
| headers, |
| body: "{}", |
| }); |
| await new Promise((resolve) => { |
| setImmediate(resolve); |
| }); |
| finalizeDebugProxyCapture(settings, deps); |
|
|
| const request = events.find((event) => event.kind === "request"); |
| expect(JSON.parse(String(request?.headersJson))).toStrictEqual({ |
| "content-type": "application/json", |
| "x-hidden": "yes", |
| }); |
| expect(Object.getOwnPropertySymbols(headers)).toHaveLength(1); |
| }); |
|
|
| it("redacts sensitive request and response headers before persistence", async () => { |
| initializeDebugProxyCapture("test", settings, deps); |
| captureHttpExchange( |
| { |
| url: "https://discord.com/api/v10/gateway/bot", |
| method: "GET", |
| requestHeaders: { |
| Authorization: "Bot discord-token", |
| Cookie: "sid=session-token", |
| "x-api-key": "provider-key", |
| "content-type": "application/json", |
| "X-Routing-Target": "staging-private-route", |
| "x-safe": "visible", |
| }, |
| meta: { sensitiveRequestHeaderNames: ["x-routing-target"] }, |
| response: new Response("{}", { |
| status: 200, |
| headers: { |
| "content-type": "application/json", |
| "set-cookie": "sid=response-token", |
| }, |
| }), |
| }, |
| settings, |
| deps, |
| ); |
| await new Promise((resolve) => { |
| setImmediate(resolve); |
| }); |
| finalizeDebugProxyCapture(settings, deps); |
|
|
| const request = events.find((event) => event.kind === "request"); |
| expect(JSON.parse(String(request?.headersJson))).toStrictEqual({ |
| Authorization: "[REDACTED]", |
| Cookie: "[REDACTED]", |
| "x-api-key": "[REDACTED]", |
| "content-type": "application/json", |
| "X-Routing-Target": "[REDACTED]", |
| "x-safe": "visible", |
| }); |
| const response = events.find((event) => event.kind === "response"); |
| expect(JSON.parse(String(response?.headersJson))).toStrictEqual({ |
| "content-type": "application/json", |
| "set-cookie": "[REDACTED]", |
| }); |
| }); |
|
|
| it("redacts registered exact values in custom headers and URL queries", async () => { |
| const secret = "capture-managed-secret"; |
| const pathSecret = "capture/path secret"; |
| registerSecretValueForRedaction(secret); |
| registerSecretValueForRedaction(pathSecret); |
| captureHttpExchange( |
| { |
| url: `https://api.example.com/models/${encodeURIComponent(pathSecret)}?key=${encodeURIComponent(secret)}`, |
| method: "GET", |
| requestHeaders: { "X-Managed": `Bearer ${secret}` }, |
| response: new Response("{}", { status: 200 }), |
| }, |
| settings, |
| deps, |
| ); |
| await new Promise((resolve) => { |
| setImmediate(resolve); |
| }); |
|
|
| const request = events.find((event) => event.kind === "request"); |
| expect(request?.path).toBe("/models/%5BREDACTED%5D?key=%5BREDACTED%5D"); |
| expect(JSON.parse(String(request?.headersJson))).toStrictEqual({ |
| "X-Managed": "Bearer [REDACTED]", |
| }); |
| }); |
|
|
| it("redacts registered values from every persisted WebSocket field", () => { |
| const secret = 'mattermost-"capture\\secret\nline'; |
| registerSecretValueForRedaction(secret); |
|
|
| captureWsEvent( |
| { |
| url: `wss://chat.example.test/api/v4/websocket?token=${encodeURIComponent(secret)}`, |
| direction: "outbound", |
| kind: "ws-frame", |
| flowId: "mattermost-auth", |
| payload: JSON.stringify({ action: "authentication_challenge", data: { token: secret } }), |
| errorText: `failed with ${secret}`, |
| meta: { subsystem: "mattermost-websocket", detail: secret }, |
| }, |
| settings, |
| deps, |
| ); |
| captureWsEvent( |
| { |
| url: "wss://chat.example.test/api/v4/websocket", |
| direction: "inbound", |
| kind: "ws-frame", |
| flowId: "mattermost-auth", |
| payload: Buffer.from(JSON.stringify({ echoedToken: secret })), |
| }, |
| settings, |
| deps, |
| ); |
|
|
| const [outbound, inbound] = events; |
| expect(outbound?.path).toBe("/api/v4/websocket?token=%5BREDACTED%5D"); |
| expect(outbound?.dataText).toContain('"token":"[REDACTED]"'); |
| expect(outbound?.errorText).toBe("failed with [REDACTED]"); |
| expect(JSON.parse(String(outbound?.metaJson))).toStrictEqual({ |
| subsystem: "mattermost-websocket", |
| detail: "[REDACTED]", |
| }); |
| expect(inbound?.dataText).toContain('"echoedToken":"[REDACTED]"'); |
| expect(JSON.stringify(events)).not.toContain(secret); |
| expect(JSON.stringify(events)).not.toContain(JSON.stringify(secret).slice(1, -1)); |
| }); |
|
|
| it("redacts registered credential bytes from otherwise non-UTF-8 frames", () => { |
| const secret = "binary-frame-capture-secret"; |
| registerSecretValueForRedaction(secret); |
| const payload = Buffer.concat([ |
| Buffer.from([0xff, 0x00]), |
| Buffer.from(secret, "utf8"), |
| Buffer.from([0xfe]), |
| ]); |
|
|
| captureWsEvent( |
| { |
| url: "wss://chat.example.test/api/v4/websocket", |
| direction: "outbound", |
| kind: "ws-frame", |
| flowId: "binary-auth", |
| payload, |
| }, |
| settings, |
| deps, |
| ); |
|
|
| expect(events[0]?.dataText).toBe("[REDACTED BINARY PAYLOAD]"); |
| expect(events[0]?.dataText).not.toContain(secret); |
| }); |
|
|
| it.each([ |
| ["record", undefined], |
| ["global Headers", Headers], |
| ["Undici Headers", UndiciHeaders], |
| ] as const)( |
| "redacts registered values from HTTP payloads and metadata with %s", |
| async (_name, HeadersConstructor) => { |
| const secret = 'http-"capture\\secret\nline'; |
| const contentTypeSecret = "http-content-type-secret"; |
| registerSecretValueForRedaction(secret); |
| registerSecretValueForRedaction(contentTypeSecret); |
| const requestHeaders = { "content-type": `application/json; token=${contentTypeSecret}` }; |
|
|
| captureHttpExchange( |
| { |
| url: "https://api.example.test/v1/messages", |
| method: "POST", |
| requestHeaders: HeadersConstructor |
| ? new HeadersConstructor(requestHeaders) |
| : requestHeaders, |
| requestBody: JSON.stringify({ credential: secret }), |
| response: new Response(JSON.stringify({ echoedCredential: secret }), { |
| status: 200, |
| headers: { "content-type": `application/json; token=${contentTypeSecret}` }, |
| }), |
| meta: { credential: secret }, |
| }, |
| settings, |
| deps, |
| ); |
| await waitForResponseSettled(); |
|
|
| const request = events.find((event) => event.kind === "request"); |
| const response = events.find((event) => event.kind === "response"); |
| expect(request?.dataText).toContain('"credential":"[REDACTED]"'); |
| expect(request?.metaJson).toContain('"credential":"[REDACTED]"'); |
| expect(request?.contentType).toBe("application/json; token=[REDACTED]"); |
| expect(response?.dataText).toContain('"echoedCredential":"[REDACTED]"'); |
| expect(response?.metaJson).toContain('"credential":"[REDACTED]"'); |
| expect(response?.contentType).toBe("application/json; token=[REDACTED]"); |
| expect(JSON.stringify(events)).not.toContain(secret); |
| }, |
| ); |
|
|
| it("redacts registered values from failed global-fetch capture events", async () => { |
| const secret = "capture-failure/secret"; |
| registerSecretValueForRedaction(secret); |
| fetchTarget.fetch = vi.fn(async () => { |
| throw new Error(`request failed for ${secret}`); |
| }) as typeof fetch; |
| initializeDebugProxyCapture("test", settings, deps); |
|
|
| await expect( |
| fetchTarget.fetch(`https://api.example.com/models/${encodeURIComponent(secret)}`), |
| ).rejects.toThrow("request failed"); |
|
|
| const event = events.find((candidate) => candidate.kind === "error"); |
| expect(event?.path).toBe("/models/%5BREDACTED%5D"); |
| expect(event?.errorText).toBe("request failed for [REDACTED]"); |
| }); |
|
|
| it("keeps capture URLs valid when the full URL is a registered secret", () => { |
| const secretUrl = "https://signed.example/v1/callback"; |
| registerSecretValueForRedaction(secretUrl); |
|
|
| captureHttpExchange( |
| { |
| url: secretUrl, |
| method: "GET", |
| response: new Response("{}", { status: 200 }), |
| }, |
| settings, |
| deps, |
| ); |
|
|
| const request = events.find((candidate) => candidate.kind === "request"); |
| expect(request?.host).toBe("redacted.invalid"); |
| expect(request?.path).toBe("/%5BREDACTED%5D"); |
| }); |
|
|
| it("does not fail capture on malformed percent escapes", async () => { |
| registerSecretValueForRedaction("capture-secret"); |
| initializeDebugProxyCapture("test", settings, deps); |
|
|
| await expect(fetchTarget.fetch("https://api.example.com/x#%")).resolves.toBeInstanceOf( |
| Response, |
| ); |
| }); |
|
|
| it("skips capturing the body when Content-Length exceeds the cap", async () => { |
| initializeDebugProxyCapture("test", settings, deps); |
| captureHttpExchange( |
| { |
| url: "https://api.openai.com/v1/files/big", |
| method: "GET", |
| response: new Response("{}", { |
| status: 200, |
| headers: { |
| "content-type": "application/json", |
| "content-length": String(32 * 1024 * 1024), |
| }, |
| }), |
| }, |
| settings, |
| deps, |
| ); |
| await waitForResponseSettled(); |
| finalizeDebugProxyCapture(settings, deps); |
|
|
| const response = events.find((event) => event.kind === "response"); |
| expect(response).toBeDefined(); |
| expect(response?.status).toBe(200); |
| |
| expect(JSON.parse(String(response?.metaJson))).toMatchObject({ bodyCapture: "too-large" }); |
| expect(response).not.toHaveProperty("dataText"); |
| expect(events.some((event) => event.kind === "error")).toBe(false); |
| }); |
|
|
| it("gives up on a stalled response body and lets the caller's cancellation settle", async () => { |
| vi.useFakeTimers(); |
| try { |
| initializeDebugProxyCapture("test", settings, deps); |
| |
| |
| |
| |
| const upstream = new Response( |
| new ReadableStream<Uint8Array>({ |
| start(controller) { |
| controller.enqueue(new TextEncoder().encode("partial")); |
| }, |
| }), |
| { status: 200, headers: { "content-type": "application/json" } }, |
| ); |
| |
| |
| |
| captureHttpExchange( |
| { url: "https://api.example.com/stalls", method: "GET", response: upstream }, |
| settings, |
| deps, |
| ); |
|
|
| let cancellationSettled = false; |
| void upstream.body |
| ?.cancel() |
| .catch(() => undefined) |
| .finally(() => { |
| cancellationSettled = true; |
| }); |
|
|
| await vi.advanceTimersByTimeAsync(9_000); |
| expect(cancellationSettled).toBe(false); |
| await vi.advanceTimersByTimeAsync(2_000); |
| await vi.waitFor(() => expect(cancellationSettled).toBe(true)); |
|
|
| const response = events.find((event) => event.kind === "response"); |
| expect(response?.status).toBe(200); |
| expect(JSON.parse(String(response?.metaJson))).toMatchObject({ bodyCapture: "stalled" }); |
| expect(response?.dataText).toBe("partial"); |
| expect(events.some((event) => event.kind === "error")).toBe(false); |
| } finally { |
| vi.useRealTimers(); |
| finalizeDebugProxyCapture(settings, deps); |
| } |
| }); |
|
|
| it("records a failing response stream as an error rather than a stall", async () => { |
| initializeDebugProxyCapture("test", settings, deps); |
| |
| |
| const upstream = new Response( |
| new ReadableStream<Uint8Array>({ |
| start(controller) { |
| controller.enqueue(new TextEncoder().encode("partial")); |
| controller.error(new Error("socket hang up")); |
| }, |
| }), |
| { status: 200 }, |
| ); |
|
|
| captureHttpExchange( |
| { url: "https://api.example.com/resets", method: "GET", response: upstream }, |
| settings, |
| deps, |
| ); |
| await waitForResponseSettled(); |
| finalizeDebugProxyCapture(settings, deps); |
|
|
| const errorEvent = events.find((event) => event.kind === "error"); |
| expect(errorEvent?.errorText).toContain("socket hang up"); |
| expect(events.some((event) => event.kind === "response")).toBe(false); |
| }); |
|
|
| it("skips capturing decimal Content-Length values above the safe integer range", async () => { |
| initializeDebugProxyCapture("test", settings, deps); |
| captureHttpExchange( |
| { |
| url: "https://api.openai.com/v1/files/huge", |
| method: "GET", |
| response: new Response("{}", { |
| status: 200, |
| headers: { |
| "content-type": "application/json", |
| "content-length": "9007199254740993", |
| }, |
| }), |
| }, |
| settings, |
| deps, |
| ); |
| await waitForResponseSettled(); |
| finalizeDebugProxyCapture(settings, deps); |
|
|
| const response = events.find((event) => event.kind === "response"); |
| expect(JSON.parse(String(response?.metaJson))).toMatchObject({ bodyCapture: "too-large" }); |
| expect(response).not.toHaveProperty("dataText"); |
| expect(events.some((event) => event.kind === "error")).toBe(false); |
| }); |
|
|
| it("streams non-decimal Content-Length values through the body cap", async () => { |
| initializeDebugProxyCapture("test", settings, deps); |
| captureHttpExchange( |
| { |
| url: "https://api.openai.com/v1/files/small", |
| method: "GET", |
| response: new Response("captured", { |
| status: 200, |
| headers: { |
| "content-type": "text/plain", |
| "content-length": "1e9", |
| }, |
| }), |
| }, |
| settings, |
| deps, |
| ); |
| await waitForResponseSettled(); |
| finalizeDebugProxyCapture(settings, deps); |
|
|
| const response = events.find((event) => event.kind === "response"); |
| expect(response).toBeDefined(); |
| expect(response?.dataText).toBe("captured"); |
| expect(response?.metaJson).toBeUndefined(); |
| expect(events.some((event) => event.kind === "error")).toBe(false); |
| }); |
|
|
| it("fails closed on chunked responses that stream past the cap", async () => { |
| initializeDebugProxyCapture("test", settings, deps); |
| |
| |
| captureHttpExchange( |
| { |
| url: "https://api.anthropic.com/v1/messages", |
| method: "POST", |
| response: makeStreamingResponse(20 * ONE_MIB), |
| }, |
| settings, |
| deps, |
| ); |
| await waitForResponseSettled(); |
| finalizeDebugProxyCapture(settings, deps); |
|
|
| const response = events.find((event) => event.kind === "response"); |
| expect(response).toBeDefined(); |
| expect(JSON.parse(String(response?.metaJson))).toMatchObject({ bodyCapture: "too-large" }); |
| expect(response).not.toHaveProperty("dataText"); |
| expect(events.some((event) => event.kind === "error")).toBe(false); |
| }); |
|
|
| it("captures small chunked bodies normally (under the cap)", async () => { |
| initializeDebugProxyCapture("test", settings, deps); |
| captureHttpExchange( |
| { |
| url: "https://api.anthropic.com/v1/models", |
| method: "GET", |
| response: makeStreamingResponse(64 * 1024), |
| }, |
| settings, |
| deps, |
| ); |
| await waitForResponseSettled(); |
| finalizeDebugProxyCapture(settings, deps); |
|
|
| const response = events.find((event) => event.kind === "response"); |
| expect(response).toBeDefined(); |
| expect(response?.status).toBe(200); |
| |
| |
| expect(response?.metaJson).toBeUndefined(); |
| expect(response?.contentType).toBe("application/octet-stream"); |
| expect(events.some((event) => event.kind === "error")).toBe(false); |
| }); |
|
|
| it("captures empty chunked bodies normally (zero-length edge)", async () => { |
| initializeDebugProxyCapture("test", settings, deps); |
| |
| |
| captureHttpExchange( |
| { |
| url: "https://api.anthropic.com/v1/empty", |
| method: "GET", |
| response: makeStreamingResponse(0), |
| }, |
| settings, |
| deps, |
| ); |
| await waitForResponseSettled(); |
| finalizeDebugProxyCapture(settings, deps); |
|
|
| const response = events.find((event) => event.kind === "response"); |
| expect(response).toBeDefined(); |
| expect(response?.status).toBe(200); |
| expect(response?.metaJson).toBeUndefined(); |
| expect(events.some((event) => event.kind === "error")).toBe(false); |
| }); |
|
|
| it("captures a spec-compliant null response body as empty", async () => { |
| initializeDebugProxyCapture("test", settings, deps); |
| captureHttpExchange( |
| { |
| url: "https://api.example.test/no-content", |
| method: "HEAD", |
| response: new Response(null, { status: 204 }), |
| }, |
| settings, |
| deps, |
| ); |
| await waitForResponseSettled(); |
| finalizeDebugProxyCapture(settings, deps); |
|
|
| const response = events.find((event) => event.kind === "response"); |
| expect(response?.status).toBe(204); |
| expect(response?.dataText).toBe(""); |
| expect(response?.metaJson).toBeUndefined(); |
| expect(events.some((event) => event.kind === "error")).toBe(false); |
| }); |
|
|
| it("captures a clone-only Response-like readable body", async () => { |
| initializeDebugProxyCapture("test", settings, deps); |
| const headers = new Headers({ "content-type": "text/plain" }); |
| const clone = vi.fn(() => new Response("captured", { headers })); |
| captureHttpExchange( |
| { |
| url: "https://api.example.test/clone-only", |
| method: "GET", |
| response: { status: 200, headers, clone } as unknown as Response, |
| }, |
| settings, |
| deps, |
| ); |
| await waitForResponseSettled(); |
| finalizeDebugProxyCapture(settings, deps); |
|
|
| const response = events.find((event) => event.kind === "response"); |
| expect(clone).toHaveBeenCalledOnce(); |
| expect(response?.dataText).toBe("captured"); |
| expect(response?.metaJson).toBeUndefined(); |
| expect(events.some((event) => event.kind === "error")).toBe(false); |
| }); |
|
|
| it.each([ |
| ["global", Headers], |
| ["Undici", UndiciHeaders], |
| ] as const)( |
| "records metadata-only for non-cloneable Response-like objects with %s Headers", |
| async (_name, HeadersConstructor) => { |
| initializeDebugProxyCapture("test", settings, deps); |
| |
| |
| |
| const secret = "metadata-only-content-type-secret"; |
| registerSecretValueForRedaction(secret); |
| const headers = new HeadersConstructor({ |
| "content-type": `application/json; token=${secret}`, |
| }); |
| captureHttpExchange( |
| { |
| url: "https://api.openai.com/v1/uncloneable", |
| method: "GET", |
| response: { status: 503, headers } as unknown as Response, |
| }, |
| settings, |
| deps, |
| ); |
| await waitForResponseSettled(); |
| finalizeDebugProxyCapture(settings, deps); |
|
|
| const response = events.find((event) => event.kind === "response"); |
| expect(response).toBeDefined(); |
| expect(response?.status).toBe(503); |
| expect(response?.contentType).toBe("application/json; token=[REDACTED]"); |
| expect(JSON.parse(String(response?.headersJson))).toStrictEqual({ |
| "content-type": "application/json; token=[REDACTED]", |
| }); |
| expect(JSON.parse(String(response?.metaJson))).toMatchObject({ bodyCapture: "unavailable" }); |
| expect(response).not.toHaveProperty("dataText"); |
| expect(events.some((event) => event.kind === "error")).toBe(false); |
| }, |
| ); |
|
|
| it("records Response-like status metadata when the Headers API is absent", async () => { |
| initializeDebugProxyCapture("test", settings, deps); |
| captureHttpExchange( |
| { |
| url: "https://api.openai.com/v1/no-headers-api", |
| method: "GET", |
| response: { status: 204 } as unknown as Response, |
| }, |
| settings, |
| deps, |
| ); |
| await waitForResponseSettled(); |
| finalizeDebugProxyCapture(settings, deps); |
|
|
| const response = events.find((event) => event.kind === "response"); |
| expect(response?.status).toBe(204); |
| expect(response?.contentType).toBeUndefined(); |
| expect(JSON.parse(String(response?.metaJson))).toMatchObject({ bodyCapture: "unavailable" }); |
| }); |
| }); |
|
|