openclaw / src /proxy-capture /runtime.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
34810d2 verified
Raw
History Blame Contribute Delete
31.2 kB
// Proxy capture runtime tests cover session creation and capture lifecycle.
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;
// Builds a chunked (no Content-Length) response that streams `totalBytes` so the
// bounded body reader exercises its real overflow/cancel path on the clone.
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);
// Each test owns a fresh injected store binding, including its admission.
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) => {
// Exercise production path discovery rather than the test-only state-dir shortcut.
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);
// Metadata is recorded, but the oversized body is never buffered/persisted.
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);
// Headers plus one chunk, then the remote stops without EOF. clone() tees
// the body, and a tee branch cancels only once both branches cancel or the
// source ends — so a capture read with no idle bound holds the caller's
// branch open for as long as the remote stays silent.
const upstream = new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("partial"));
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
// Capture clones internally, exactly as the patched fetch does, so the
// caller keeps the branch this response already holds. Cloning here first
// would leave a third, unread branch that no one ever cancels.
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);
// A reset mid-body is the exchange failing, not the capture deciding to stop:
// it has to stay distinguishable from the idle deadline.
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);
// 20 MiB streamed without a Content-Length header: the bounded reader must
// cancel the clone at the cap and record metadata instead of buffering it.
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);
// Under the cap the body is read in full via the normal persist path, so no
// fail-closed metadata marker is set and the payload content-type is kept.
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);
// A streaming response that closes immediately must not be mistaken for an
// overflow: the bounded reader sees total=0, never trips the cap.
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);
// Some seams hand capture a Response-like object that cannot be cloned. It
// must still be observable (status/headers) via the shared metadata path,
// tagged bodyCapture: "unavailable" (distinct from the "too-large" cap path).
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" });
});
});