// Proxy capture server tests cover request recording and response handling. import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { request as httpRequest, createServer as createHttpServer, type IncomingMessage, } from "node:http"; import net, { type AddressInfo } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import type { DebugProxySettings } from "./env.js"; import { startDebugProxyServer } from "./proxy-server.js"; import { closeDebugProxyCaptureStore, getDebugProxyCaptureStore } from "./store.sqlite.js"; let testRoot: string | undefined; const originalStateDir = process.env.OPENCLAW_STATE_DIR; async function cleanupTestRoot(): Promise { closeDebugProxyCaptureStore(); closeOpenClawStateDatabaseForTest(); if (originalStateDir === undefined) { delete process.env.OPENCLAW_STATE_DIR; } else { process.env.OPENCLAW_STATE_DIR = originalStateDir; } if (!testRoot) { return; } const root = testRoot; testRoot = undefined; await rm(root, { recursive: true, force: true }); } async function makeSettings(): Promise { testRoot = await mkdtemp(join(tmpdir(), "openclaw-debug-proxy-server-")); const certDir = join(testRoot, "certs"); await mkdir(certDir, { recursive: true }); await writeFile(join(certDir, "root-ca.pem"), "test root cert\n", "utf8"); await writeFile(join(certDir, "root-ca-key.pem"), "test root key\n", "utf8"); process.env.OPENCLAW_STATE_DIR = testRoot; return { enabled: true, required: false, dbPath: join(testRoot, "capture.sqlite"), blobDir: join(testRoot, "blobs"), certDir, sessionId: "debug-proxy-server-test", sourceProcess: "test", }; } async function startLargeBodyOrigin(responseBody: string): Promise<{ receivedRequestBody: () => string; responseBody: string; stop: () => Promise; url: string; }> { let receivedBody = ""; const server = createHttpServer((req, res) => { req.setEncoding("utf8"); req.on("data", (chunk) => { receivedBody += chunk; }); req.on("end", () => { res.writeHead(200, { "content-length": Buffer.byteLength(responseBody), "content-type": "text/plain; charset=utf-8", }); res.end(responseBody); }); }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); }); }); const address = server.address() as AddressInfo; return { receivedRequestBody: () => receivedBody, responseBody, stop: async () => await new Promise((resolve, reject) => { server.close((error) => { if (error) { reject(error); return; } resolve(); }); }), url: `http://127.0.0.1:${address.port}/capture`, }; } async function startResponseErrorOrigin(): Promise<{ stop: () => Promise; url: string; }> { const server = createHttpServer((req, res) => { if (req.url === "/before-headers") { res.socket?.destroy(); return; } if (req.url === "/after-headers") { res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }); res.flushHeaders(); res.write("partial"); setTimeout(() => res.socket?.destroy(), 50); return; } res.writeHead(200, { "content-length": 2, "content-type": "text/plain; charset=utf-8", }); res.end("ok"); }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); }); }); const address = server.address() as AddressInfo; return { stop: async () => await new Promise((resolve, reject) => { server.close((error) => { if (error) { reject(error); return; } resolve(); }); }), url: `http://127.0.0.1:${address.port}`, }; } type ProxyResponseResult = { body: string; complete: boolean; errorMessage?: string; statusCode?: number; }; async function getThroughProxy(proxyUrl: string, targetUrl: string): Promise { const proxy = new URL(proxyUrl); return await new Promise((resolve) => { let settled = false; let body = ""; let response: IncomingMessage | undefined; const finish = (error?: Error) => { if (settled) { return; } settled = true; resolve({ body, complete: response?.complete ?? false, ...(error ? { errorMessage: error.message } : {}), ...(response?.statusCode === undefined ? {} : { statusCode: response.statusCode }), }); }; const req = httpRequest( { host: proxy.hostname, port: Number(proxy.port), method: "GET", path: targetUrl, headers: { connection: "close" }, }, (res) => { response = res; res.setEncoding("utf8"); res.on("data", (chunk) => { body += chunk; }); res.on("end", () => finish()); res.on("error", finish); res.on("close", () => { if (!res.complete) { finish(new Error("response closed before completion")); } }); }, ); req.on("error", finish); req.end(); }); } async function postThroughProxy(params: { body: string; proxyUrl: string; targetUrl: string; }): Promise { const proxy = new URL(params.proxyUrl); return await new Promise((resolve, reject) => { const req = httpRequest( { host: proxy.hostname, port: Number(proxy.port), method: "POST", path: params.targetUrl, headers: { connection: "close", "content-length": Buffer.byteLength(params.body), "content-type": "text/plain; charset=utf-8", }, }, (res) => { const chunks: Buffer[] = []; res.on("data", (chunk) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }); res.on("end", () => { resolve(Buffer.concat(chunks).toString("utf8")); }); }, ); req.on("error", reject); req.end(params.body); }); } type StreamingProxyOrigin = { state: { closedResponses: number; drainWaits: number; finishedResponses: number; queuedBytes: number; }; stop: () => Promise; url: string; }; async function startStreamingProxyOrigin(responseBodyBytes: number): Promise { const state = { closedResponses: 0, drainWaits: 0, finishedResponses: 0, queuedBytes: 0, }; const chunk = Buffer.alloc(64 * 1024, "x"); const server = createHttpServer((req, res) => { req.resume(); if (req.url === "/healthy") { res.writeHead(200, { "content-length": 2, "content-type": "text/plain; charset=utf-8", }); res.end("ok"); return; } res.writeHead(200, { "content-length": responseBodyBytes, "content-type": "text/plain; charset=utf-8", }); res.on("close", () => { state.closedResponses++; }); res.on("finish", () => { state.finishedResponses++; }); const writeNextChunk = () => { while (state.queuedBytes < responseBodyBytes && !res.destroyed) { const chunkBytes = Math.min(chunk.byteLength, responseBodyBytes - state.queuedBytes); state.queuedBytes += chunkBytes; if (!res.write(chunk.subarray(0, chunkBytes))) { state.drainWaits++; res.once("drain", writeNextChunk); return; } } if (state.queuedBytes === responseBodyBytes && !res.destroyed) { res.end(); } }; writeNextChunk(); }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); }); }); const address = server.address() as AddressInfo; return { state, stop: async () => await new Promise((resolve, reject) => { server.close((error) => { if (error) { reject(error); return; } resolve(); }); }), url: `http://127.0.0.1:${address.port}`, }; } async function rawSlowGetThroughProxy(params: { abortAfterBytes?: number; abortWithReset?: boolean; onResume?: () => void; pauseBeforeReadMs: number; proxyUrl: string; targetUrl: string; }): Promise<{ aborted: boolean; bodyBytes: number; receivedBytes: number; resumed: boolean; statusLine: string; }> { const proxy = new URL(params.proxyUrl); return await new Promise((resolve, reject) => { let aborted = false; let bodyBytes = 0; let contentLength: number | undefined; let headerBytes = Buffer.alloc(0); let receivedBytes = 0; let resumed = false; let settled = false; let statusLine = ""; let resumeTimeout: ReturnType | undefined; const socket = net.connect(Number(proxy.port), proxy.hostname); const timeout = setTimeout(() => { socket.destroy(); if (!settled) { settled = true; reject(new Error("slow proxy client timed out")); } }, 15000); const finish = () => { clearTimeout(timeout); clearTimeout(resumeTimeout); if (!settled) { settled = true; resolve({ aborted, bodyBytes, receivedBytes, resumed, statusLine }); } }; const maybeFinishCompleteBody = () => { if (contentLength !== undefined && bodyBytes >= contentLength) { socket.destroy(); finish(); } }; socket.on("connect", () => { socket.write( `GET ${params.targetUrl} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n`, ); socket.pause(); resumeTimeout = setTimeout(() => { resumed = true; params.onResume?.(); socket.resume(); }, params.pauseBeforeReadMs); }); socket.on("data", (chunk) => { receivedBytes += chunk.length; if (contentLength === undefined) { headerBytes = Buffer.concat([headerBytes, Buffer.from(chunk)]); const headerEnd = headerBytes.indexOf("\r\n\r\n"); if (headerEnd === -1) { return; } const headerText = headerBytes.subarray(0, headerEnd).toString("latin1"); statusLine = headerText.split("\r\n")[0] ?? ""; const contentLengthHeader = headerText .split("\r\n") .find((line) => line.toLowerCase().startsWith("content-length:")); const parsedContentLength = Number(contentLengthHeader?.split(":")[1]?.trim()); contentLength = Number.isFinite(parsedContentLength) ? parsedContentLength : 0; bodyBytes += headerBytes.byteLength - headerEnd - 4; headerBytes = Buffer.alloc(0); } else { bodyBytes += chunk.length; } if (params.abortAfterBytes !== undefined && bodyBytes >= params.abortAfterBytes) { aborted = true; if (params.abortWithReset) { socket.resetAndDestroy(); } else { socket.destroy(); } finish(); return; } maybeFinishCompleteBody(); }); socket.on("error", (error) => { clearTimeout(timeout); clearTimeout(resumeTimeout); if (!settled) { settled = true; reject(error); } }); socket.on("close", finish); }); } afterEach(async () => { await cleanupTestRoot(); }); describe("startDebugProxyServer", () => { it("caps UTF-8 previews on character boundaries while forwarding full bodies", async () => { const settings = await makeSettings(); const origin = await startLargeBodyOrigin(`${"r".repeat(8191)}đŸ˜€tail`); const proxy = await startDebugProxyServer({ settings }); const requestBody = `${"q".repeat(8191)}Ă©tail`; try { const responseBody = await postThroughProxy({ body: requestBody, proxyUrl: proxy.proxyUrl, targetUrl: origin.url, }); expect(origin.receivedRequestBody()).toBe(requestBody); expect(responseBody).toBe(origin.responseBody); const events = getDebugProxyCaptureStore().getSessionEvents(settings.sessionId, 10); const capturedRequest = events.find((event) => event.kind === "request"); const capturedResponse = events.find((event) => event.kind === "response"); expect(capturedRequest?.dataText).toBe("q".repeat(8191)); expect(capturedResponse?.dataText).toBe("r".repeat(8191)); expect(JSON.parse(String(capturedRequest?.metaJson))).toMatchObject({ bodyBytes: Buffer.byteLength(requestBody), capturePreviewBytes: 8192, captureTruncated: true, }); expect(JSON.parse(String(capturedResponse?.metaJson))).toMatchObject({ bodyBytes: Buffer.byteLength(origin.responseBody), capturePreviewBytes: 8192, captureTruncated: true, }); } finally { await proxy.stop(); await origin.stop(); } }); it("pauses the upstream response while downstream forwarding is backpressured", async () => { const settings = await makeSettings(); const responseBodyBytes = 16 * 1024 * 1024; const origin = await startStreamingProxyOrigin(responseBodyBytes); const proxy = await startDebugProxyServer({ settings }); let queuedBytesAtResume = 0; try { const forwarded = await rawSlowGetThroughProxy({ onResume: () => { queuedBytesAtResume = origin.state.queuedBytes; }, pauseBeforeReadMs: 150, proxyUrl: proxy.proxyUrl, targetUrl: `${origin.url}/capture`, }); expect(forwarded).toMatchObject({ aborted: false, bodyBytes: responseBodyBytes, resumed: true, statusLine: "HTTP/1.1 200 OK", }); expect(queuedBytesAtResume).toBeLessThan(responseBodyBytes); expect(origin.state.drainWaits).toBeGreaterThan(0); expect(origin.state.queuedBytes).toBe(responseBodyBytes); const captureEvents = getDebugProxyCaptureStore() .getSessionEvents(settings.sessionId, 20) .filter((event) => event.path === "/capture"); expect(captureEvents.filter((event) => event.kind === "error")).toEqual([]); expect(captureEvents.filter((event) => event.kind === "response")).toEqual([ expect.objectContaining({ direction: "inbound", status: 200 }), ]); } finally { await proxy.stop(); await origin.stop(); } }); it("closes the upstream response when a slow downstream client aborts", async () => { const settings = await makeSettings(); const responseBodyBytes = 16 * 1024 * 1024; const origin = await startStreamingProxyOrigin(responseBodyBytes); const proxy = await startDebugProxyServer({ settings }); try { const aborted = await rawSlowGetThroughProxy({ abortAfterBytes: 64 * 1024, pauseBeforeReadMs: 0, proxyUrl: proxy.proxyUrl, targetUrl: `${origin.url}/capture`, }); expect(aborted).toMatchObject({ aborted: true, resumed: true, statusLine: "HTTP/1.1 200 OK", }); expect(aborted.bodyBytes).toBeGreaterThanOrEqual(64 * 1024); await vi.waitFor(() => { expect(origin.state.closedResponses).toBe(1); expect(origin.state.finishedResponses).toBe(0); const captureEvents = getDebugProxyCaptureStore() .getSessionEvents(settings.sessionId, 20) .filter((event) => event.path === "/capture"); const capturedRequest = captureEvents.find((event) => event.kind === "request"); expect(capturedRequest).toBeDefined(); expect(captureEvents.filter((event) => event.kind === "error")).toEqual([ expect.objectContaining({ direction: "local", errorText: "Downstream response closed before completion", flowId: capturedRequest?.flowId, }), ]); expect(captureEvents.filter((event) => event.kind === "response")).toEqual([]); }); const healthy = await getThroughProxy(proxy.proxyUrl, `${origin.url}/healthy`); expect(healthy).toMatchObject({ body: "ok", complete: true, statusCode: 200 }); expect( getDebugProxyCaptureStore() .getSessionEvents(settings.sessionId, 20) .filter((event) => event.path === "/healthy" && event.kind === "error"), ).toEqual([]); } finally { await proxy.stop(); await origin.stop(); } }); it("records a reset downstream client as exactly one local capture error", async () => { const settings = await makeSettings(); const origin = await startStreamingProxyOrigin(16 * 1024 * 1024); const proxy = await startDebugProxyServer({ settings }); try { const aborted = await rawSlowGetThroughProxy({ abortAfterBytes: 64 * 1024, abortWithReset: true, pauseBeforeReadMs: 0, proxyUrl: proxy.proxyUrl, targetUrl: `${origin.url}/capture`, }); expect(aborted).toMatchObject({ aborted: true, resumed: true, statusLine: "HTTP/1.1 200 OK", }); await vi.waitFor(() => { expect(origin.state.closedResponses).toBe(1); expect(origin.state.finishedResponses).toBe(0); const captureEvents = getDebugProxyCaptureStore() .getSessionEvents(settings.sessionId, 20) .filter((event) => event.path === "/capture"); const capturedRequest = captureEvents.find((event) => event.kind === "request"); expect(capturedRequest).toBeDefined(); expect(captureEvents.filter((event) => event.kind === "error")).toEqual([ expect.objectContaining({ direction: "local", errorText: expect.any(String), flowId: capturedRequest?.flowId, }), ]); expect(captureEvents.filter((event) => event.kind === "response")).toEqual([]); }); const healthy = await getThroughProxy(proxy.proxyUrl, `${origin.url}/healthy`); expect(healthy).toMatchObject({ body: "ok", complete: true, statusCode: 200 }); expect( getDebugProxyCaptureStore() .getSessionEvents(settings.sessionId, 20) .filter((event) => event.path === "/healthy" && event.kind === "error"), ).toEqual([]); } finally { await proxy.stop(); await origin.stop(); } }); it("returns a complete 502 and survives an upstream failure before response headers", async () => { const settings = await makeSettings(); const origin = await startResponseErrorOrigin(); const proxy = await startDebugProxyServer({ settings }); try { const failed = await getThroughProxy(proxy.proxyUrl, `${origin.url}/before-headers`); expect(failed).toMatchObject({ body: "Bad Gateway\n", complete: true, statusCode: 502, }); const healthy = await getThroughProxy(proxy.proxyUrl, `${origin.url}/healthy`); expect(healthy).toMatchObject({ body: "ok", complete: true, statusCode: 200 }); expect(getDebugProxyCaptureStore().getSessionEvents(settings.sessionId, 20)).toContainEqual( expect.objectContaining({ direction: "local", kind: "error" }), ); } finally { await proxy.stop(); await origin.stop(); } }); it("aborts a partial response after headers and survives the upstream stream error", async () => { const settings = await makeSettings(); const origin = await startResponseErrorOrigin(); const proxy = await startDebugProxyServer({ settings }); try { const failed = await getThroughProxy(proxy.proxyUrl, `${origin.url}/after-headers`); expect(failed).toMatchObject({ body: "partial", complete: false, statusCode: 200, }); expect(failed.errorMessage).toBeDefined(); const healthy = await getThroughProxy(proxy.proxyUrl, `${origin.url}/healthy`); expect(healthy).toMatchObject({ body: "ok", complete: true, statusCode: 200 }); expect(getDebugProxyCaptureStore().getSessionEvents(settings.sessionId, 20)).toContainEqual( expect.objectContaining({ direction: "inbound", kind: "error" }), ); } finally { await proxy.stop(); await origin.stop(); } }); });