diff --git a/README.md b/README.md index 3a39f05..bb979d1 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,8 @@ Add an optional section like: [codex_remote_proxy] upstream_base_url = "https://your-upstream.example.com" upstream_api_key = "sk-your-key" +capture_enabled = true +capture_db_path = "/Users/you/.codex-remote-proxy/traffic.sqlite3" ``` Then later runs only need: @@ -128,6 +130,8 @@ crp start ```bash export CRP_UPSTREAM_BASE_URL="https://your-upstream.example.com" export CRP_UPSTREAM_API_KEY="sk-your-key" +export CRP_CAPTURE_ENABLED="true" +export CRP_CAPTURE_DB_PATH="/Users/you/.codex-remote-proxy/traffic.sqlite3" crp start ``` @@ -135,10 +139,49 @@ crp start 1. CLI flags 2. Environment variables -3. `~/.codex/config.toml` under `[codex_remote_proxy]` using `upstream_base_url` and `upstream_api_key` +3. `~/.codex/config.toml` under `[codex_remote_proxy]` using `upstream_base_url`, `upstream_api_key`, `capture_enabled`, and `capture_db_path` 4. Saved config from `crp init` 5. Interactive prompts +## Request Capture + +Request capture is optional and disabled by default. + +When enabled, the proxy stores one SQLite row per proxied HTTP transaction under: + +```text +~/.codex-remote-proxy/traffic.sqlite3 +``` + +or a custom path you provide with `capture_db_path`. + +What is stored: + +- full request headers after proxy rewrites +- full request body +- full response headers +- full response body +- SSE responses aggregated into one stored body + +Sensitive headers such as `Authorization`, `Cookie`, `Set-Cookie`, and token-like header names are redacted before writing. + +Enable capture at startup: + +```bash +crp start --capture +crp start --capture --capture-db-path /Users/you/.codex-remote-proxy/custom-traffic.sqlite3 +``` + +Hot-toggle capture on a running managed proxy: + +```bash +crp capture on +crp capture off +crp capture status --json +``` + +You can also edit `~/.codex-remote-proxy/node/proxy-config.json` directly. Changes to `capture.enabled` hot-apply after the proxy validates the SQLite connection. Changes to `capture.dbPath` are detected, but require a restart before the new path is used. + ## Global CLI Main commands: @@ -150,11 +193,14 @@ Main commands: Accept upstream settings from CLI flags, environment variables, `~/.codex/config.toml` `[codex_remote_proxy]`, or prompts; choose a free port, patch Codex, and start the proxy in the background by default - `crp init` - Save upstream settings once under `~/.codex-remote-proxy/` so later `crp start` calls do not require secrets again if you do not want to place them in `~/.codex/config.toml` + Save upstream settings and optional capture defaults once under `~/.codex-remote-proxy/` so later `crp start` calls do not require secrets again if you do not want to place them in `~/.codex/config.toml` - `crp install` Compatibility alias for `crp start` +- `crp capture on|off|status` + Toggle SQLite request capture on a running managed proxy, or persist the preference for the next start if the proxy is not running + - `crp status` Show managed service status and health. If the proxy is running but not managed by this CLI, it will try to detect that too @@ -168,6 +214,7 @@ Machine-readable examples: ```bash crp check --json +crp capture status --json crp guide --json crp status --json ``` @@ -179,7 +226,7 @@ Recommended flow: 1. Run `crp check --json` 2. Read `recommendedImplementation` 3. If Node dependencies are ready, prefer `node` -4. Prefer existing `~/.codex/config.toml` `[codex_remote_proxy]` with `upstream_base_url` and `upstream_api_key`, otherwise ask the user to run `crp init` once locally, or rely on environment variables already set outside the AI session +4. Prefer existing `~/.codex/config.toml` `[codex_remote_proxy]` with `upstream_base_url`, `upstream_api_key`, `capture_enabled`, and `capture_db_path`, otherwise ask the user to run `crp init` once locally, or rely on environment variables already set outside the AI session 5. Run `crp start` 6. Read `proxyUrl`, `pid`, and `health` from the JSON result 7. Use `crp status --json` for later verification @@ -189,6 +236,7 @@ Notes: - `start` modifies `~/.codex/config.toml` and creates a backup - the managed proxy runs in the background by default - managed state and logs live under `~/.codex-remote-proxy/` +- request capture writes to SQLite only when enabled - when running directly from this repository, install Node dependencies first - `~/.codex/config.toml`, `crp init`, or environment variables can keep secrets out of later AI interactions diff --git a/README.zh-CN.md b/README.zh-CN.md index 0ddaebb..74c0ce6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -92,6 +92,8 @@ CLI 统一管理目录: [codex_remote_proxy] upstream_base_url = "https://your-upstream.example.com" upstream_api_key = "sk-your-key" +capture_enabled = true +capture_db_path = "/Users/you/.codex-remote-proxy/traffic.sqlite3" ``` 之后直接执行: @@ -124,6 +126,8 @@ crp start ```bash export CRP_UPSTREAM_BASE_URL="https://your-upstream.example.com" export CRP_UPSTREAM_API_KEY="sk-your-key" +export CRP_CAPTURE_ENABLED="true" +export CRP_CAPTURE_DB_PATH="/Users/you/.codex-remote-proxy/traffic.sqlite3" crp start ``` @@ -131,10 +135,39 @@ crp start 1. CLI 参数 2. 环境变量 -3. `~/.codex/config.toml` 里的 `[codex_remote_proxy]`,键名使用 `upstream_base_url` 和 `upstream_api_key` +3. `~/.codex/config.toml` 里的 `[codex_remote_proxy]`,键名使用 `upstream_base_url`、`upstream_api_key`、`capture_enabled` 和 `capture_db_path` 4. `crp init` 保存的本地配置 5. 交互式输入 +## 请求记录 + +SQLite 请求记录是可选功能,默认关闭。 + +开启后,代理会把每次完整请求/响应保存成一条 SQLite 记录,默认数据库路径是: + +```text +~/.codex-remote-proxy/traffic.sqlite3 +``` + +启动时开启: + +```bash +crp start --capture +crp start --capture --capture-db-path /Users/you/.codex-remote-proxy/custom-traffic.sqlite3 +``` + +对正在运行的代理做热切换: + +```bash +crp capture on +crp capture off +crp capture status --json +``` + +你也可以直接编辑 `~/.codex-remote-proxy/node/proxy-config.json`。其中 `capture.enabled` 会在代理校验 SQLite 成功后热生效;`capture.dbPath` 的变化会被探测到,但需要重启后才会真正切到新路径。 + +写入前会默认脱敏敏感请求头,例如 `Authorization`、`Cookie`、`Set-Cookie` 以及名称中包含 `token`、`secret`、`api-key` 的头。 + ## 全局 CLI 主要命令: @@ -146,11 +179,14 @@ crp start 从 CLI 参数、环境变量、`~/.codex/config.toml` 的 `[codex_remote_proxy]` 或交互输入中获取上游配置,自动选择空闲端口,修改 Codex 配置,并默认后台启动代理 - `crp init` - 先把上游配置安全保存到 `~/.codex-remote-proxy/`,如果你不想把密钥写进 `~/.codex/config.toml`,以后 `crp start` 也不需要再重复输入 + 先把上游配置和可选的请求记录默认值安全保存到 `~/.codex-remote-proxy/`,如果你不想把密钥写进 `~/.codex/config.toml`,以后 `crp start` 也不需要再重复输入 - `crp install` 与 `crp start` 等价的兼容别名 +- `crp capture on|off|status` + 对托管中的代理热切换 SQLite 请求记录;如果代理当前没运行,则保存为下次启动时生效的偏好 + - `crp status` 查看当前托管服务状态和健康检查结果。如果代理在运行但不是 CLI 托管的,也会尝试探测 @@ -164,6 +200,7 @@ crp start ```bash crp check --json +crp capture status --json crp guide --json crp status --json ``` @@ -175,7 +212,7 @@ crp status --json 1. 先跑 `crp check --json` 2. 读取 `recommendedImplementation` 3. 如果 Node 依赖就绪,优先走 `node` -4. 优先使用现有 `~/.codex/config.toml` 里的 `[codex_remote_proxy]`,并使用 `upstream_base_url` / `upstream_api_key` 这两个键,否则让用户先在本地跑一次 `crp init`,或者提前在系统里设置好环境变量 +4. 优先使用现有 `~/.codex/config.toml` 里的 `[codex_remote_proxy]`,并使用 `upstream_base_url` / `upstream_api_key` / `capture_enabled` / `capture_db_path` 这些键,否则让用户先在本地跑一次 `crp init`,或者提前在系统里设置好环境变量 5. 再跑 `crp start` 6. 从返回结果中读取 `proxyUrl`、`pid`、`health` 7. 之后用 `crp status --json` 做确认 @@ -185,6 +222,7 @@ crp status --json - `start` 会修改 `~/.codex/config.toml` - `install` 会先创建备份 - 托管状态和日志保存在 `~/.codex-remote-proxy/` +- 只有在显式开启时才会写 SQLite 请求记录 - 如果你是直接从当前仓库运行,需要先执行 `cd node && npm install` - `~/.codex/config.toml`、`crp init` 或环境变量模式都可以避免后续 AI 直接接触密钥 diff --git a/node/.changeset/add-sqlite-capture.md b/node/.changeset/add-sqlite-capture.md new file mode 100644 index 0000000..36a2827 --- /dev/null +++ b/node/.changeset/add-sqlite-capture.md @@ -0,0 +1,5 @@ +--- +"@cluic/codex-remote-proxy": minor +--- + +Add optional SQLite request capture with hot toggle support. diff --git a/node/README.md b/node/README.md index 1568e19..d81e996 100644 --- a/node/README.md +++ b/node/README.md @@ -43,6 +43,8 @@ The easiest persistent setup is to add this section to `~/.codex/config.toml`: [codex_remote_proxy] upstream_base_url = "https://your-upstream.example.com" upstream_api_key = "sk-your-key" +capture_enabled = true +capture_db_path = "/Users/you/.codex-remote-proxy/traffic.sqlite3" ``` Then run: @@ -71,6 +73,8 @@ crp start ```bash export CRP_UPSTREAM_BASE_URL="https://your-upstream.example.com" export CRP_UPSTREAM_API_KEY="sk-your-key" +export CRP_CAPTURE_ENABLED="true" +export CRP_CAPTURE_DB_PATH="/Users/you/.codex-remote-proxy/traffic.sqlite3" crp start ``` @@ -78,10 +82,37 @@ crp start 1. CLI flags 2. Environment variables -3. `~/.codex/config.toml` under `[codex_remote_proxy]` using `upstream_base_url` and `upstream_api_key` +3. `~/.codex/config.toml` under `[codex_remote_proxy]` using `upstream_base_url`, `upstream_api_key`, `capture_enabled`, and `capture_db_path` 4. Saved config from `crp init` 5. Interactive prompts +## Request Capture + +SQLite request capture is optional and off by default. + +When enabled, the proxy stores one full request/response transaction per row in: + +```text +~/.codex-remote-proxy/traffic.sqlite3 +``` + +You can enable it at startup: + +```bash +crp start --capture +crp start --capture --capture-db-path /Users/you/.codex-remote-proxy/custom-traffic.sqlite3 +``` + +Or hot-toggle it on a running proxy: + +```bash +crp capture on +crp capture off +crp capture status --json +``` + +Edits to `~/.codex-remote-proxy/node/proxy-config.json` also hot-apply `capture.enabled`. Changes to `capture.dbPath` are detected but require a restart before the new database path is used. + ## Main Commands - `crp check` @@ -91,7 +122,10 @@ crp start Accept upstream settings from CLI flags, environment variables, `~/.codex/config.toml` `[codex_remote_proxy]`, or prompts; choose a free port, patch Codex, and start the proxy in the background by default - `crp init` - Save upstream settings once under `~/.codex-remote-proxy/` + Save upstream settings and optional capture defaults once under `~/.codex-remote-proxy/` + +- `crp capture on|off|status` + Toggle SQLite request capture at runtime for a managed proxy, or save the preference for the next start - `crp status` Show managed service status and health @@ -121,4 +155,5 @@ See [RELEASING.md](./RELEASING.md) for the one-time npm Trusted Publishing setup - `crp start` modifies `~/.codex/config.toml` and creates a backup - the managed proxy runs in the background by default - managed state and logs live under `~/.codex-remote-proxy/` -- Node.js 20 or newer is required +- request capture redacts sensitive headers before writing +- Node.js 22.13.0 or newer is required diff --git a/node/bin/crp.mjs b/node/bin/crp.mjs index 043057c..a78e7df 100644 --- a/node/bin/crp.mjs +++ b/node/bin/crp.mjs @@ -7,6 +7,8 @@ import readline from "node:readline/promises"; import os from "node:os"; import { setTimeout as delay } from "node:timers/promises"; +import { DEFAULT_CAPTURE_DB_PATH } from "../src/capture-store.mjs"; + const PACKAGE_ROOT = resolve(import.meta.dirname, ".."); const DEFAULT_CODEX_CONFIG_PATH = resolve(os.homedir(), ".codex", "config.toml"); const DEFAULT_AUTH_PATH = resolve(os.homedir(), ".codex", "auth.json"); @@ -23,7 +25,9 @@ const ENV_KEYS = { upstreamBaseUrl: "CRP_UPSTREAM_BASE_URL", apiKey: "CRP_UPSTREAM_API_KEY", listenHost: "CRP_LISTEN_HOST", - listenPort: "CRP_LISTEN_PORT" + listenPort: "CRP_LISTEN_PORT", + captureEnabled: "CRP_CAPTURE_ENABLED", + captureDbPath: "CRP_CAPTURE_DB_PATH" }; function parseCommandLine(argv) { @@ -56,9 +60,10 @@ function parseCommandLine(argv) { function printHelp() { console.log("Usage:"); console.log(" crp check [--json] [--codex-config PATH] [--auth PATH]"); - console.log(" crp init [--json] [--upstream-base-url URL] [--api-key KEY] [--listen-host 127.0.0.1] [--listen-port PORT]"); - console.log(" crp start [--json] [--upstream-base-url URL] [--api-key KEY] [--listen-host 127.0.0.1] [--listen-port PORT] [--debug]"); + console.log(" crp init [--json] [--upstream-base-url URL] [--api-key KEY] [--listen-host 127.0.0.1] [--listen-port PORT] [--capture] [--no-capture] [--capture-db-path PATH]"); + console.log(" crp start [--json] [--upstream-base-url URL] [--api-key KEY] [--listen-host 127.0.0.1] [--listen-port PORT] [--capture] [--no-capture] [--capture-db-path PATH] [--debug]"); console.log(" crp install [same as start]"); + console.log(" crp capture [--json]"); console.log(" crp status [--json]"); console.log(" crp stop [--json]"); console.log(" crp setup [same as start]"); @@ -102,6 +107,23 @@ function writeUserConfig(config) { } } +function applyUserConfigPatch(patch) { + const current = loadUserConfig(); + const next = { + ...current, + ...patch + }; + writeUserConfig(next); + return next; +} + +function loadRuntimeProxyConfig() { + if (!existsSync(NODE_RUNTIME_CONFIG_PATH)) { + return null; + } + return readJson(NODE_RUNTIME_CONFIG_PATH); +} + function splitLines(text) { return text.split(/\r?\n/); } @@ -177,6 +199,35 @@ function getCodexRemoteProxyUpstreamApiKey(section) { return section.upstream_api_key ?? section.api_key ?? null; } +function getCodexRemoteProxyCaptureEnabled(section) { + return typeof section.capture_enabled === "boolean" ? section.capture_enabled : null; +} + +function getCodexRemoteProxyCaptureDbPath(section) { + return section.capture_db_path ?? null; +} + +function normalizeBooleanInput(value, fallback = null) { + if (typeof value === "boolean") { + return value; + } + if (typeof value !== "string") { + return fallback; + } + const lowered = value.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(lowered)) { + return true; + } + if (["0", "false", "no", "off"].includes(lowered)) { + return false; + } + return fallback; +} + +function ensureCaptureDbPath(path) { + return typeof path === "string" && path.trim() ? path.trim() : DEFAULT_CAPTURE_DB_PATH; +} + function detectNodeRuntime() { const depCheck = spawnSync("node", ["-e", "import('fzstd').then(()=>process.exit(0)).catch(()=>process.exit(1))"], { cwd: PACKAGE_ROOT, @@ -421,8 +472,11 @@ function buildGuideData() { preferredImplementation: "node", commands: { inspect: "crp check --json", - init: "crp init --upstream-base-url --api-key --json", - start: "crp start --upstream-base-url --api-key --json", + init: "crp init --upstream-base-url --api-key [--capture] [--capture-db-path PATH] --json", + start: "crp start --upstream-base-url --api-key [--capture] [--capture-db-path PATH] --json", + captureOn: "crp capture on --json", + captureOff: "crp capture off --json", + captureStatus: "crp capture status --json", status: "crp status --json", stop: "crp stop --json", installCli: "npm install -g @cluic/codex-remote-proxy", @@ -434,6 +488,7 @@ function buildGuideData() { "If node dependencies are ready, use the node path.", "Optionally set [codex_remote_proxy] in ~/.codex/config.toml or run init once to save upstream settings under ~/.codex-remote-proxy/.", "Run start. It will resolve settings from CLI flags, then environment variables, then ~/.codex/config.toml [codex_remote_proxy], then saved config, and only prompt as a last resort.", + "Use `crp capture on|off` for runtime capture toggling; manual edits to the runtime proxy config also hot-apply capture.enabled.", "start launches the proxy in the background by default and patches ~/.codex/config.toml.", "Use status --json to confirm the proxy is healthy." ], @@ -441,7 +496,7 @@ function buildGuideData() { "The start command modifies ~/.codex/config.toml and creates a backup.", "The proxy configuration and state are stored under ~/.codex-remote-proxy/.", "Use CRP_UPSTREAM_BASE_URL and CRP_UPSTREAM_API_KEY when you want non-interactive start without exposing secrets in later AI interactions.", - "The optional ~/.codex/config.toml [codex_remote_proxy] section supports upstream_base_url and upstream_api_key as another non-interactive source." + "The optional ~/.codex/config.toml [codex_remote_proxy] section supports upstream_base_url, upstream_api_key, capture_enabled, and capture_db_path as non-interactive sources." ] }; } @@ -453,8 +508,11 @@ function buildCheckData(options) { const codexRemoteProxy = extractCodexRemoteProxySection(codexText); const codexRemoteProxyUpstreamBaseUrl = getCodexRemoteProxyUpstreamBaseUrl(codexRemoteProxy); const codexRemoteProxyUpstreamApiKey = getCodexRemoteProxyUpstreamApiKey(codexRemoteProxy); + const codexRemoteProxyCaptureEnabled = getCodexRemoteProxyCaptureEnabled(codexRemoteProxy); + const codexRemoteProxyCaptureDbPath = getCodexRemoteProxyCaptureDbPath(codexRemoteProxy); const authData = readJson(authPath); const userConfig = loadUserConfig(); + const runtimeProxyConfig = loadRuntimeProxyConfig(); const managedInfo = getManagedServiceInfo(); const runtimeStatus = { node: detectNodeRuntime() }; @@ -468,7 +526,9 @@ function buildCheckData(options) { }, codexRemoteProxy: { upstreamBaseUrl: codexRemoteProxyUpstreamBaseUrl, - upstreamApiKeyPreview: typeof codexRemoteProxyUpstreamApiKey === "string" ? maskSecret(codexRemoteProxyUpstreamApiKey) : null + upstreamApiKeyPreview: typeof codexRemoteProxyUpstreamApiKey === "string" ? maskSecret(codexRemoteProxyUpstreamApiKey) : null, + captureEnabled: codexRemoteProxyCaptureEnabled, + captureDbPath: codexRemoteProxyCaptureDbPath }, auth: { authMode: authData.auth_mode ?? null, @@ -485,12 +545,15 @@ function buildCheckData(options) { upstreamBaseUrl: Boolean(process.env[ENV_KEYS.upstreamBaseUrl]), apiKey: Boolean(process.env[ENV_KEYS.apiKey]), listenHost: Boolean(process.env[ENV_KEYS.listenHost]), - listenPort: Boolean(process.env[ENV_KEYS.listenPort]) + listenPort: Boolean(process.env[ENV_KEYS.listenPort]), + captureEnabled: Boolean(process.env[ENV_KEYS.captureEnabled]), + captureDbPath: Boolean(process.env[ENV_KEYS.captureDbPath]) } }, implementation: { configPath: NODE_RUNTIME_CONFIG_PATH, configExists: existsSync(NODE_RUNTIME_CONFIG_PATH), + runtimeConfig: runtimeProxyConfig, startCommand: startCommand(NODE_RUNTIME_CONFIG_PATH) }, recommendedImplementation: "node", @@ -517,6 +580,8 @@ function printHumanCheck(data) { console.log("Codex [codex_remote_proxy]:"); console.log(` upstream_base_url: ${data.codexRemoteProxy.upstreamBaseUrl || "(missing)"}`); console.log(` upstream_api_key: ${data.codexRemoteProxy.upstreamApiKeyPreview || "(missing)"}`); + console.log(` capture_enabled: ${data.codexRemoteProxy.captureEnabled ?? "(missing)"}`); + console.log(` capture_db_path: ${data.codexRemoteProxy.captureDbPath || "(missing)"}`); console.log(""); console.log("Runtime status:"); console.log(` node: ${data.runtimeStatus.node.available ? data.runtimeStatus.node.version : data.runtimeStatus.node.error}`); @@ -629,6 +694,8 @@ function resolveUserSettings(options) { const codexRemoteProxy = extractCodexRemoteProxySection(codexText); const codexRemoteProxyUpstreamBaseUrl = getCodexRemoteProxyUpstreamBaseUrl(codexRemoteProxy); const codexRemoteProxyUpstreamApiKey = getCodexRemoteProxyUpstreamApiKey(codexRemoteProxy); + const codexRemoteProxyCaptureEnabled = getCodexRemoteProxyCaptureEnabled(codexRemoteProxy); + const codexRemoteProxyCaptureDbPath = getCodexRemoteProxyCaptureDbPath(codexRemoteProxy); return { upstreamBaseUrl: resolveConfigValue({ cliValue: options["upstream-base-url"], @@ -660,6 +727,34 @@ function resolveUserSettings(options) { savedValues: [ { value: saved.listenPort ? String(saved.listenPort) : "", source: "saved" } ] + }), + captureEnabled: (() => { + if (options.capture === true) { + return { value: true, source: "cli" }; + } + if (options["no-capture"] === true) { + return { value: false, source: "cli" }; + } + const envValue = normalizeBooleanInput(process.env[ENV_KEYS.captureEnabled], null); + if (envValue !== null) { + return { value: envValue, source: "env" }; + } + if (typeof codexRemoteProxyCaptureEnabled === "boolean") { + return { value: codexRemoteProxyCaptureEnabled, source: "codex_config" }; + } + if (typeof saved.captureEnabled === "boolean") { + return { value: saved.captureEnabled, source: "saved" }; + } + return { value: false, source: "default" }; + })(), + captureDbPath: resolveConfigValue({ + cliValue: options["capture-db-path"], + envKey: ENV_KEYS.captureDbPath, + savedValues: [ + { value: codexRemoteProxyCaptureDbPath, source: "codex_config" }, + { value: saved.captureDbPath, source: "saved" } + ], + defaultValue: DEFAULT_CAPTURE_DB_PATH }) }; } @@ -687,6 +782,8 @@ async function installCommand(options) { const authPath = getCommonPaths(options).authPath; const proxyConfigPath = NODE_RUNTIME_CONFIG_PATH; const proxyUrl = `http://${listenHost}:${listenPort}`; + const captureEnabled = Boolean(resolved.captureEnabled.value); + const captureDbPath = ensureCaptureDbPath(resolved.captureDbPath.value); const proxyConfig = { server: { host: listenHost, port: listenPort, logLevel: "info" }, @@ -702,6 +799,10 @@ async function installCommand(options) { proxy: { overrideAuthorization: true, requestIdHeader: "x-client-request-id" + }, + capture: { + enabled: captureEnabled, + dbPath: captureDbPath } }; @@ -767,11 +868,21 @@ async function installCommand(options) { upstreamBaseUrl: resolved.upstreamBaseUrl.source, apiKey: resolved.apiKey.source, listenHost: resolved.listenHost.source, - listenPort: resolved.listenPort.source === "missing" ? "auto" : resolved.listenPort.source + listenPort: resolved.listenPort.source === "missing" ? "auto" : resolved.listenPort.source, + captureEnabled: resolved.captureEnabled.source, + captureDbPath: resolved.captureDbPath.source }, logFile: managedState.logFile, managedStatePath: STATE_FILE, health, + captureConfigured: health.captureConfigured ?? captureEnabled, + captureActive: health.captureActive ?? false, + captureDbPath: health.captureDbPath ?? captureDbPath, + captureState: health.captureState ?? (captureEnabled ? "enabled" : "disabled"), + captureRestartRequired: health.captureRestartRequired ?? false, + failedWriteCount: health.failedWriteCount ?? 0, + lastWriteErrorAt: health.lastWriteErrorAt ?? null, + lastWriteErrorMessage: health.lastWriteErrorMessage ?? null, message: "Proxy configured and started" }; @@ -799,6 +910,8 @@ async function initCommand(options) { const apiKey = resolved.apiKey.value || await promptSecret("Upstream API key", ""); const listenHost = resolved.listenHost.value || "127.0.0.1"; const listenPort = resolved.listenPort.value ? Number.parseInt(resolved.listenPort.value, 10) : undefined; + const captureEnabled = Boolean(resolved.captureEnabled.value); + const captureDbPath = ensureCaptureDbPath(resolved.captureDbPath.value); if (!upstreamBaseUrl || !apiKey) { throw new Error("Upstream base URL and API key are required"); @@ -808,7 +921,9 @@ async function initCommand(options) { upstreamBaseUrl, apiKey, listenHost, - listenPort + listenPort, + captureEnabled, + captureDbPath }); const payload = { @@ -818,7 +933,9 @@ async function initCommand(options) { upstreamBaseUrl, apiKeyPreview: maskSecret(apiKey), listenHost, - listenPort: listenPort ?? null + listenPort: listenPort ?? null, + captureEnabled, + captureDbPath } }; @@ -860,6 +977,14 @@ async function statusCommand(options) { if (state?.proxyUrl && alive) { try { payload.health = await waitForHealthyProxy(state.proxyUrl, 2000); + payload.captureConfigured = payload.health.captureConfigured ?? null; + payload.captureActive = payload.health.captureActive ?? null; + payload.captureDbPath = payload.health.captureDbPath ?? null; + payload.captureState = payload.health.captureState ?? null; + payload.captureRestartRequired = payload.health.captureRestartRequired ?? null; + payload.failedWriteCount = payload.health.failedWriteCount ?? 0; + payload.lastWriteErrorAt = payload.health.lastWriteErrorAt ?? null; + payload.lastWriteErrorMessage = payload.health.lastWriteErrorMessage ?? null; } catch (error) { payload.healthError = error.message; } @@ -881,6 +1006,91 @@ async function statusCommand(options) { } } +async function captureCommand(options, action) { + if (!["on", "off", "status"].includes(action)) { + throw new Error(`Unknown capture action: ${action}`); + } + + if (action === "status") { + const runtime = loadRuntimeProxyConfig(); + const state = loadManagedState(); + const payload = { + ok: true, + running: Boolean(state?.pid && isProcessAlive(state.pid)), + persistedConfig: loadUserConfig(), + runtimeConfig: runtime?.capture ?? null + }; + if (state?.proxyUrl && payload.running) { + try { + payload.health = await waitForHealthyProxy(state.proxyUrl, 2000); + } catch (error) { + payload.healthError = error.message; + } + } + if (!maybePrintJson(options, payload)) { + console.log(`Capture running: ${payload.running ? "yes" : "no"}`); + console.log(`Persisted capture enabled: ${payload.persistedConfig.captureEnabled ? "yes" : "no"}`); + console.log(`Persisted capture DB: ${payload.persistedConfig.captureDbPath || DEFAULT_CAPTURE_DB_PATH}`); + if (payload.runtimeConfig) { + console.log(`Runtime capture enabled: ${payload.runtimeConfig.enabled ? "yes" : "no"}`); + console.log(`Runtime capture DB: ${payload.runtimeConfig.dbPath || DEFAULT_CAPTURE_DB_PATH}`); + } + } + return; + } + + const enabled = action === "on"; + const persistedConfig = applyUserConfigPatch({ + captureEnabled: enabled, + captureDbPath: ensureCaptureDbPath(loadUserConfig().captureDbPath) + }); + + const payload = { + ok: true, + action, + persistedConfig, + runtimeUpdated: false, + message: "" + }; + + const managedState = loadManagedState(); + const running = Boolean(managedState?.pid && isProcessAlive(managedState.pid)); + if (!running) { + payload.message = "Capture preference saved. It will apply the next time the proxy starts."; + if (!maybePrintJson(options, payload)) { + console.log(payload.message); + } + return; + } + + const runtimeConfig = loadRuntimeProxyConfig(); + if (!runtimeConfig) { + throw new Error(`Runtime proxy config not found: ${NODE_RUNTIME_CONFIG_PATH}`); + } + runtimeConfig.capture = { + enabled, + dbPath: ensureCaptureDbPath( + runtimeConfig.capture?.dbPath || persistedConfig.captureDbPath || DEFAULT_CAPTURE_DB_PATH + ) + }; + writeProxyConfig(NODE_RUNTIME_CONFIG_PATH, runtimeConfig); + payload.runtimeUpdated = true; + payload.message = "Capture preference saved and runtime config updated."; + + if (managedState.proxyUrl) { + try { + const health = await waitForHealthyProxy(managedState.proxyUrl, 4000); + payload.health = health; + } catch (error) { + payload.healthError = error.message; + } + } + + if (!maybePrintJson(options, payload)) { + console.log(payload.message); + } +} + async function stopCommand(options) { const result = stopManagedService(loadManagedState()); const payload = { ok: true, stopped: result.stopped, reason: result.reason }; @@ -907,7 +1117,28 @@ async function installCliCommand(options) { } async function main() { - const { command, options } = parseCommandLine(process.argv.slice(2)); + const argv = process.argv.slice(2); + if (argv[0] === "capture") { + const action = argv[1]; + const options = {}; + for (let index = 2; index < argv.length; index += 1) { + const token = argv[index]; + if (!token.startsWith("--")) { + throw new Error(`Unexpected argument: ${token}`); + } + const key = token.slice(2); + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + options[key] = true; + continue; + } + options[key] = next; + index += 1; + } + return await captureCommand(options, action); + } + + const { command, options } = parseCommandLine(argv); if (command === "check") return checkCommand(options); if (command === "init") return await initCommand(options); if (command === "guide") return guideCommand(options); diff --git a/node/package-lock.json b/node/package-lock.json index 49ef539..bd23b90 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cluic/codex-remote-proxy", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cluic/codex-remote-proxy", - "version": "0.1.2", + "version": "0.1.3", "dependencies": { "fzstd": "^0.1.1" }, @@ -17,7 +17,7 @@ "@changesets/cli": "^2.31.0" }, "engines": { - "node": ">=20" + "node": ">=22.13.0" } }, "node_modules/@babel/runtime": { diff --git a/node/package.json b/node/package.json index 1ab10e0..8e39956 100644 --- a/node/package.json +++ b/node/package.json @@ -25,6 +25,7 @@ "check": "node bin/crp.mjs check", "status": "node bin/crp.mjs status", "guide": "node bin/crp.mjs guide", + "test": "node --test", "changeset": "changeset", "version-packages": "changeset version", "release": "changeset publish" @@ -33,7 +34,7 @@ "access": "public" }, "engines": { - "node": ">=20" + "node": ">=22.13.0" }, "dependencies": { "fzstd": "^0.1.1" diff --git a/node/proxy-config.example.json b/node/proxy-config.example.json index bdcc006..1ccd301 100644 --- a/node/proxy-config.example.json +++ b/node/proxy-config.example.json @@ -16,5 +16,9 @@ "proxy": { "overrideAuthorization": true, "requestIdHeader": "x-client-request-id" + }, + "capture": { + "enabled": false, + "dbPath": "/Users/you/.codex-remote-proxy/traffic.sqlite3" } } diff --git a/node/src/capture-store.mjs b/node/src/capture-store.mjs new file mode 100644 index 0000000..c7747b9 --- /dev/null +++ b/node/src/capture-store.mjs @@ -0,0 +1,548 @@ +import { watchFile, unwatchFile, readFileSync, mkdirSync } from "node:fs"; +import os from "node:os"; +import { dirname, isAbsolute, resolve } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +export const DEFAULT_CAPTURE_DB_PATH = resolve(os.homedir(), ".codex-remote-proxy", "traffic.sqlite3"); + +const WATCH_INTERVAL_MS = 500; +const WATCH_DEBOUNCE_MS = 100; +const REDACTED_VALUE = "[REDACTED]"; +const HEADER_REDACTION_NAMES = new Set([ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie" +]); +const HEADER_REDACTION_SUBSTRINGS = ["token", "secret", "api-key"]; + +function defaultLogger() {} + +function resolvePathValue(value, baseDir) { + return isAbsolute(value) ? value : resolve(baseDir, value); +} + +function validateCaptureEnabled(value) { + return value === undefined || typeof value === "boolean"; +} + +function validateCaptureDbPath(value) { + return value === undefined || (typeof value === "string" && value.trim().length > 0); +} + +export function normalizeCaptureConfig(rawCapture = {}, { baseDir = process.cwd(), defaultDbPath = DEFAULT_CAPTURE_DB_PATH, strict = false } = {}) { + const capture = rawCapture && typeof rawCapture === "object" && !Array.isArray(rawCapture) ? rawCapture : {}; + + if (!validateCaptureEnabled(capture.enabled)) { + throw new Error("capture.enabled must be a boolean when provided"); + } + if (!validateCaptureDbPath(capture.dbPath)) { + throw new Error("capture.dbPath must be a non-empty string when provided"); + } + if (strict && capture.enabled === undefined && capture.dbPath === undefined) { + return { + enabled: false, + dbPath: defaultDbPath + }; + } + + const dbPathRaw = typeof capture.dbPath === "string" && capture.dbPath.trim() ? capture.dbPath.trim() : defaultDbPath; + return { + enabled: typeof capture.enabled === "boolean" ? capture.enabled : false, + dbPath: resolvePathValue(dbPathRaw, baseDir) + }; +} + +export function loadRuntimeCaptureConfig(configPath, { defaultDbPath = DEFAULT_CAPTURE_DB_PATH } = {}) { + let parsed; + try { + parsed = JSON.parse(readFileSync(configPath, "utf8")); + } catch (error) { + throw new Error(`Failed to read runtime config at ${configPath}: ${error.message}`); + } + + return normalizeCaptureConfig(parsed.capture ?? {}, { + baseDir: dirname(configPath), + defaultDbPath, + strict: true + }); +} + +function upsertHeaderValue(headers, key, value) { + if (!(key in headers)) { + headers[key] = value; + return; + } + if (Array.isArray(headers[key])) { + headers[key].push(value); + return; + } + headers[key] = [headers[key], value]; +} + +export function headersToObject(headersInput) { + if (!headersInput) { + return {}; + } + + if (Array.isArray(headersInput)) { + const result = {}; + for (const entry of headersInput) { + if (!Array.isArray(entry) || entry.length < 2) { + continue; + } + upsertHeaderValue(result, String(entry[0]), String(entry[1])); + } + return result; + } + + if (typeof headersInput === "object") { + const result = {}; + for (const [key, value] of Object.entries(headersInput)) { + if (Array.isArray(value)) { + result[key] = value.map((item) => String(item)); + } else if (value != null) { + result[key] = String(value); + } + } + return result; + } + + return {}; +} + +function shouldRedactHeader(key) { + const lowered = key.toLowerCase(); + if (HEADER_REDACTION_NAMES.has(lowered)) { + return true; + } + return HEADER_REDACTION_SUBSTRINGS.some((part) => lowered.includes(part)); +} + +export function redactHeaders(headersInput) { + const headers = headersToObject(headersInput); + const result = {}; + for (const [key, value] of Object.entries(headers)) { + result[key] = shouldRedactHeader(key) ? REDACTED_VALUE : value; + } + return result; +} + +export function encodeBody(buffer) { + if (!buffer || buffer.length === 0) { + return { + body: "", + encoding: "empty", + bytes: 0 + }; + } + + const text = buffer.toString("utf8"); + if (Buffer.compare(Buffer.from(text, "utf8"), buffer) === 0) { + return { + body: text, + encoding: "utf8", + bytes: buffer.length + }; + } + + return { + body: buffer.toString("base64"), + encoding: "base64", + bytes: buffer.length + }; +} + +function createInsertStatement(db) { + return db.prepare(` + INSERT INTO http_transactions ( + started_at, + completed_at, + duration_ms, + request_id, + session_id, + thread_id, + method, + incoming_url, + target_url, + request_headers_json, + request_body, + request_body_encoding, + request_body_bytes, + response_status, + response_headers_json, + response_body, + response_body_encoding, + response_body_bytes, + is_stream, + upstream_request_id, + error_type, + error_message + ) VALUES ( + @started_at, + @completed_at, + @duration_ms, + @request_id, + @session_id, + @thread_id, + @method, + @incoming_url, + @target_url, + @request_headers_json, + @request_body, + @request_body_encoding, + @request_body_bytes, + @response_status, + @response_headers_json, + @response_body, + @response_body_encoding, + @response_body_bytes, + @is_stream, + @upstream_request_id, + @error_type, + @error_message + ) + `); +} + +function initializeDatabase(db) { + db.exec("PRAGMA journal_mode = WAL"); + db.exec("PRAGMA synchronous = NORMAL"); + db.exec("PRAGMA user_version = 1"); + db.exec(` + CREATE TABLE IF NOT EXISTS http_transactions ( + id INTEGER PRIMARY KEY, + started_at TEXT NOT NULL, + completed_at TEXT, + duration_ms INTEGER, + request_id TEXT, + session_id TEXT, + thread_id TEXT, + method TEXT, + incoming_url TEXT, + target_url TEXT, + request_headers_json TEXT NOT NULL, + request_body TEXT NOT NULL, + request_body_encoding TEXT NOT NULL, + request_body_bytes INTEGER NOT NULL, + response_status INTEGER, + response_headers_json TEXT NOT NULL, + response_body TEXT NOT NULL, + response_body_encoding TEXT NOT NULL, + response_body_bytes INTEGER NOT NULL, + is_stream INTEGER NOT NULL, + upstream_request_id TEXT, + error_type TEXT, + error_message TEXT + ); + CREATE INDEX IF NOT EXISTS idx_http_transactions_started_at + ON http_transactions (started_at); + CREATE INDEX IF NOT EXISTS idx_http_transactions_request_id + ON http_transactions (request_id); + CREATE INDEX IF NOT EXISTS idx_http_transactions_thread_id + ON http_transactions (thread_id); + CREATE INDEX IF NOT EXISTS idx_http_transactions_response_status + ON http_transactions (response_status); + `); +} + +function noopHandle() { + return { + save() {} + }; +} + +export class CaptureManager { + constructor({ + configPath, + capture, + log = defaultLogger, + defaultDbPath = DEFAULT_CAPTURE_DB_PATH, + watchRuntimeConfig = true + }) { + this.configPath = configPath; + this.log = log; + this.defaultDbPath = defaultDbPath; + this.watchRuntimeConfig = watchRuntimeConfig; + this.desiredConfig = normalizeCaptureConfig(capture, { + baseDir: dirname(configPath), + defaultDbPath, + strict: true + }); + this.activeDbPath = null; + this.db = null; + this.insertStatement = null; + this.acceptingRecords = false; + this.state = "disabled"; + this.restartRequired = false; + this.pendingRecords = 0; + this.failedWriteCount = 0; + this.lastWriteErrorAt = null; + this.lastWriteErrorMessage = null; + this.lastErrorAt = null; + this.lastErrorMessage = null; + this.closed = false; + this.watchTimer = null; + this.handleRuntimeConfigChange = this.handleRuntimeConfigChange.bind(this); + } + + start() { + if (this.desiredConfig.enabled) { + this.enableFromConfig(this.desiredConfig, { source: "startup" }); + } + if (this.watchRuntimeConfig) { + watchFile(this.configPath, { interval: WATCH_INTERVAL_MS }, this.handleRuntimeConfigChange); + } + return this; + } + + close() { + if (this.closed) { + return; + } + this.closed = true; + if (this.watchRuntimeConfig) { + unwatchFile(this.configPath, this.handleRuntimeConfigChange); + } + if (this.watchTimer) { + clearTimeout(this.watchTimer); + this.watchTimer = null; + } + this.closeDatabase(); + } + + handleRuntimeConfigChange() { + if (this.watchTimer) { + clearTimeout(this.watchTimer); + } + this.watchTimer = setTimeout(() => { + this.watchTimer = null; + this.reloadRuntimeConfig(); + }, WATCH_DEBOUNCE_MS); + } + + reloadRuntimeConfig() { + try { + const nextConfig = loadRuntimeCaptureConfig(this.configPath, { + defaultDbPath: this.defaultDbPath + }); + this.clearLastError(); + this.applyRuntimeConfig(nextConfig); + } catch (error) { + this.setLastError(error.message); + this.log("warn", "Failed to hot-apply capture config", { + config_path: this.configPath, + error: JSON.stringify(error.message) + }); + } + } + + applyRuntimeConfig(nextConfig) { + const previousDesired = this.desiredConfig; + const previousActiveDbPath = this.activeDbPath; + this.desiredConfig = nextConfig; + if (previousActiveDbPath && previousActiveDbPath !== nextConfig.dbPath) { + this.restartRequired = true; + } else if (!previousActiveDbPath) { + this.restartRequired = false; + } + + if (nextConfig.enabled) { + if (this.acceptingRecords) { + return; + } + if (this.state === "disabling" && previousActiveDbPath === this.activeDbPath) { + this.acceptingRecords = true; + this.state = "enabled"; + return; + } + this.enableFromConfig(nextConfig, { source: "runtime" }); + return; + } + + if (this.acceptingRecords || this.state === "enabled" || this.state === "error") { + this.disableRecording(); + return; + } + + this.state = "disabled"; + this.restartRequired = false; + if (previousDesired.dbPath !== nextConfig.dbPath && !this.activeDbPath) { + this.restartRequired = false; + } + } + + enableFromConfig(config, { source }) { + this.state = "enabling"; + try { + this.openDatabase(config.dbPath); + this.activeDbPath = config.dbPath; + this.acceptingRecords = true; + this.state = "enabled"; + this.restartRequired = false; + this.clearLastError(); + this.log("info", "Capture recording enabled", { + source, + db_path: this.activeDbPath + }); + } catch (error) { + this.acceptingRecords = false; + this.closeDatabase(); + this.activeDbPath = null; + this.state = "error"; + this.setLastError(error.message); + if (source === "startup") { + throw error; + } + this.log("warn", "Failed to enable capture recording", { + source, + db_path: config.dbPath, + error: JSON.stringify(error.message) + }); + } + } + + disableRecording() { + this.acceptingRecords = false; + if (!this.db) { + this.state = "disabled"; + this.activeDbPath = null; + return; + } + if (this.pendingRecords > 0) { + this.state = "disabling"; + return; + } + this.closeDatabase(); + this.activeDbPath = null; + this.state = "disabled"; + this.log("info", "Capture recording disabled", {}); + } + + openDatabase(dbPath) { + mkdirSync(dirname(dbPath), { recursive: true }); + const db = new DatabaseSync(dbPath); + initializeDatabase(db); + this.db = db; + this.insertStatement = createInsertStatement(db); + } + + closeDatabase() { + if (this.db) { + this.db.close(); + } + this.db = null; + this.insertStatement = null; + } + + beginRecord() { + if (!this.acceptingRecords || !this.db || !this.insertStatement) { + return null; + } + + this.pendingRecords += 1; + let finished = false; + + return { + save: (record) => { + if (finished) { + return; + } + finished = true; + try { + this.writeRecord(record); + } finally { + this.pendingRecords -= 1; + if (!this.acceptingRecords && this.pendingRecords === 0 && this.state === "disabling") { + this.closeDatabase(); + this.activeDbPath = null; + this.state = "disabled"; + } + } + } + }; + } + + writeRecord(record) { + if (!this.insertStatement) { + this.recordWriteFailure(new Error("Capture database is not available")); + return; + } + + const requestBody = encodeBody(record.requestBody); + const responseBody = encodeBody(record.responseBody); + try { + this.insertStatement.run({ + started_at: record.startedAt, + completed_at: record.completedAt, + duration_ms: record.durationMs, + request_id: record.requestId, + session_id: record.sessionId, + thread_id: record.threadId, + method: record.method, + incoming_url: record.incomingUrl, + target_url: record.targetUrl, + request_headers_json: JSON.stringify(redactHeaders(record.requestHeaders)), + request_body: requestBody.body, + request_body_encoding: requestBody.encoding, + request_body_bytes: requestBody.bytes, + response_status: record.responseStatus ?? null, + response_headers_json: JSON.stringify(redactHeaders(record.responseHeaders)), + response_body: responseBody.body, + response_body_encoding: responseBody.encoding, + response_body_bytes: responseBody.bytes, + is_stream: record.isStream ? 1 : 0, + upstream_request_id: record.upstreamRequestId ?? null, + error_type: record.errorType ?? null, + error_message: record.errorMessage ?? null + }); + } catch (error) { + this.recordWriteFailure(error); + } + } + + recordWriteFailure(error) { + const message = error instanceof Error ? error.message : String(error); + this.failedWriteCount += 1; + this.lastWriteErrorAt = new Date().toISOString(); + this.lastWriteErrorMessage = message; + this.log("warn", "Failed to write capture record", { + db_path: this.activeDbPath || this.desiredConfig.dbPath, + error: JSON.stringify(message) + }); + } + + setLastError(message) { + this.lastErrorAt = new Date().toISOString(); + this.lastErrorMessage = message; + } + + clearLastError() { + this.lastErrorAt = null; + this.lastErrorMessage = null; + } + + getPublicState() { + return { + captureConfigured: this.desiredConfig.enabled, + captureActive: this.acceptingRecords, + captureDbPath: this.desiredConfig.dbPath, + captureRuntimeDbPath: this.activeDbPath, + captureState: this.state, + captureRestartRequired: this.restartRequired, + failedWriteCount: this.failedWriteCount, + lastWriteErrorAt: this.lastWriteErrorAt, + lastWriteErrorMessage: this.lastWriteErrorMessage, + captureLastErrorAt: this.lastErrorAt, + captureLastErrorMessage: this.lastErrorMessage + }; + } +} + +export function createCaptureManager(options) { + return new CaptureManager(options); +} + +export function createNoopCaptureHandle() { + return noopHandle(); +} diff --git a/node/src/server.mjs b/node/src/server.mjs index 9625607..afb3aa0 100644 --- a/node/src/server.mjs +++ b/node/src/server.mjs @@ -1,11 +1,19 @@ import http from "node:http"; import https from "node:https"; -import { URL } from "node:url"; import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { resolve, dirname } from "node:path"; +import { URL } from "node:url"; import zlib from "node:zlib"; import { decompress as zstdDecompress } from "fzstd"; +import { + createCaptureManager, + createNoopCaptureHandle, + DEFAULT_CAPTURE_DB_PATH, + headersToObject, + normalizeCaptureConfig +} from "./capture-store.mjs"; + const CONFIG_ENV_VAR = "CODEX_PROXY_CONFIG"; const DEFAULT_CONFIG_PATH = resolve(import.meta.dirname, "..", "proxy-config.json"); const HEALTH_PATH = "/_proxy/health"; @@ -24,11 +32,18 @@ const HOP_BY_HOP_HEADERS = new Set([ let DEBUG_ENABLED = false; -function resolveConfigPath() { +export function resolveConfigPath() { return process.env[CONFIG_ENV_VAR] ? resolve(process.env[CONFIG_ENV_VAR]) : DEFAULT_CONFIG_PATH; } -function loadConfig(configPath = resolveConfigPath()) { +function isStringMap(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + return Object.entries(value).every(([key, item]) => typeof key === "string" && typeof item === "string"); +} + +export function loadConfig(configPath = resolveConfigPath()) { let parsed; try { parsed = JSON.parse(readFileSync(configPath, "utf8")); @@ -39,6 +54,7 @@ function loadConfig(configPath = resolveConfigPath()) { const server = parsed.server ?? {}; const upstream = parsed.upstream ?? {}; const proxy = parsed.proxy ?? {}; + const capture = parsed.capture ?? {}; if (!upstream.baseUrl || typeof upstream.baseUrl !== "string") { throw new Error("upstream.baseUrl is required"); @@ -66,17 +82,15 @@ function loadConfig(configPath = resolveConfigPath()) { proxy: { overrideAuthorization: typeof proxy.overrideAuthorization === "boolean" ? proxy.overrideAuthorization : true, requestIdHeader: typeof proxy.requestIdHeader === "string" && proxy.requestIdHeader ? proxy.requestIdHeader : "x-client-request-id" - } + }, + capture: normalizeCaptureConfig(capture, { + baseDir: dirname(configPath), + defaultDbPath: DEFAULT_CAPTURE_DB_PATH, + strict: true + }) }; } -function isStringMap(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return false; - } - return Object.entries(value).every(([key, item]) => typeof key === "string" && typeof item === "string"); -} - function maskSecret(value) { if (!value) { return "(empty)"; @@ -87,7 +101,7 @@ function maskSecret(value) { return `${value.slice(0, 4)}...${value.slice(-4)}`; } -function log(level, message, fields = {}) { +export function log(level, message, fields = {}) { const parts = Object.entries(fields).map(([key, value]) => `${key}=${value}`); const suffix = parts.length ? ` ${parts.join(" ")}` : ""; console.log(`${new Date().toISOString()} ${level.toUpperCase()} ${message}${suffix}`); @@ -106,13 +120,13 @@ function safeBodyPreview(buffer, maxLen = 4096) { if (!buffer || !buffer.length) return "(empty)"; try { const text = buffer.toString("utf-8"); - return text.length > maxLen ? text.slice(0, maxLen) + `... (${buffer.length} bytes total)` : text; + return text.length > maxLen ? `${text.slice(0, maxLen)}... (${buffer.length} bytes total)` : text; } catch { return `(${buffer.length} bytes, binary)`; } } -function buildTargetUrl(baseUrl, requestUrl) { +export function buildTargetUrl(baseUrl, requestUrl) { const incoming = new URL(requestUrl, "http://127.0.0.1"); const path = incoming.pathname === "/" ? "" : incoming.pathname; return new URL(`${baseUrl}${path}${incoming.search}`); @@ -156,7 +170,7 @@ function sanitizeHeadersForDebug(headersObject) { return result; } -function buildUpstreamHeaders(req, settings, targetUrl, { stripContentHeaders }) { +export function buildUpstreamHeaders(req, settings, targetUrl, { stripContentHeaders }) { const headers = []; const authHeader = settings.upstream.authHeader.toLowerCase(); @@ -223,7 +237,77 @@ function isEventStream(contentType = "") { return contentType.split(";", 1)[0].trim().toLowerCase() === "text/event-stream"; } -function createServer(settings) { +function buildHealthPayload(settings, captureManager) { + return { + ok: true, + configPath: settings.configPath, + listenHost: settings.server.host, + listenPort: settings.server.port, + upstreamBaseUrl: settings.upstream.baseUrl, + overrideAuthorization: settings.proxy.overrideAuthorization, + authHeader: settings.upstream.authHeader, + authScheme: settings.upstream.authScheme, + extraHeaderCount: Object.keys(settings.upstream.extraHeaders).length, + ...captureManager.getPublicState() + }; +} + +function buildRequestContext({ req, settings, targetUrl, requestId, requestHeaders, requestBody, startedAt, captureHandle }) { + const turnMetadataHeader = req.headers["x-codex-turn-metadata"]; + let turnMetadata = null; + if (typeof turnMetadataHeader === "string") { + try { + turnMetadata = JSON.parse(turnMetadataHeader); + } catch { + turnMetadata = null; + } + } + + return { + requestId, + sessionId: typeof req.headers["session-id"] === "string" + ? req.headers["session-id"] + : (typeof req.headers["session_id"] === "string" ? req.headers["session_id"] : (turnMetadata?.session_id || null)), + threadId: typeof req.headers["thread-id"] === "string" + ? req.headers["thread-id"] + : (typeof req.headers["thread_id"] === "string" ? req.headers["thread_id"] : (turnMetadata?.thread_id || null)), + method: req.method || "GET", + incomingUrl: new URL(req.url, `http://${settings.server.host}:${settings.server.port}`).href, + targetUrl: targetUrl.href, + requestHeaders: headersToObject(requestHeaders), + requestBody, + startedAt: new Date(startedAt).toISOString(), + captureHandle + }; +} + +function saveCaptureRecord(captureContext, fields) { + if (!captureContext?.captureHandle) { + return; + } + captureContext.captureHandle.save({ + startedAt: captureContext.startedAt, + completedAt: new Date().toISOString(), + durationMs: Date.now() - Date.parse(captureContext.startedAt), + requestId: captureContext.requestId, + sessionId: captureContext.sessionId, + threadId: captureContext.threadId, + method: captureContext.method, + incomingUrl: captureContext.incomingUrl, + targetUrl: captureContext.targetUrl, + requestHeaders: captureContext.requestHeaders, + requestBody: captureContext.requestBody, + responseStatus: fields.responseStatus, + responseHeaders: fields.responseHeaders ?? {}, + responseBody: fields.responseBody ?? Buffer.alloc(0), + isStream: fields.isStream ?? false, + upstreamRequestId: fields.upstreamRequestId ?? null, + errorType: fields.errorType ?? null, + errorMessage: fields.errorMessage ?? null + }); +} + +export function createServer(settings, { captureManager = createCaptureManager({ configPath: settings.configPath, capture: settings.capture, log }).start(), logFn = log } = {}) { return http.createServer((req, res) => { if (!req.url) { writeJson(res, 400, { error: { message: "Missing request URL", type: "proxy_bad_request" } }); @@ -231,17 +315,7 @@ function createServer(settings) { } if (req.url === HEALTH_PATH) { - writeJson(res, 200, { - ok: true, - configPath: settings.configPath, - listenHost: settings.server.host, - listenPort: settings.server.port, - upstreamBaseUrl: settings.upstream.baseUrl, - overrideAuthorization: settings.proxy.overrideAuthorization, - authHeader: settings.upstream.authHeader, - authScheme: settings.upstream.authScheme, - extraHeaderCount: Object.keys(settings.upstream.extraHeaders).length - }); + writeJson(res, 200, buildHealthPayload(settings, captureManager)); return; } @@ -250,42 +324,62 @@ function createServer(settings) { const transport = targetUrl.protocol === "https:" ? https : http; const startedAt = Date.now(); - const chunks = []; - req.on("data", (chunk) => chunks.push(chunk)); - req.on("end", () => { - let body = Buffer.concat(chunks); - const contentEncoding = req.headers["content-encoding"]; - let bodyTransformed = false; - if (contentEncoding && body.length) { - try { - body = decompressBody(body, contentEncoding); - bodyTransformed = true; - } catch (error) { - log("warn", "Failed to decompress request body", { - encoding: contentEncoding, - error: error.message - }); + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + let body = Buffer.concat(chunks); + const contentEncoding = req.headers["content-encoding"]; + let bodyTransformed = false; + if (contentEncoding && body.length) { + try { + body = decompressBody(body, contentEncoding); + bodyTransformed = true; + } catch (error) { + logFn("warn", "Failed to decompress request body", { + encoding: contentEncoding, + error: error.message + }); } - } else if (body.length >= 2 && contentEncoding === undefined) { - const decompressed = autoDecompress(body); - if (decompressed) { - debugLog("AUTODECOMP", { - originalSize: body.length, - decompressedSize: decompressed.length, - magicBytes: `0x${body[0].toString(16).padStart(2, "0")} 0x${body[1].toString(16).padStart(2, "0")}`, - }); - body = decompressed; - bodyTransformed = true; - } + } else if (body.length >= 2 && contentEncoding === undefined) { + const decompressed = autoDecompress(body); + if (decompressed) { + debugLog("AUTODECOMP", { + originalSize: body.length, + decompressedSize: decompressed.length, + magicBytes: `0x${body[0].toString(16).padStart(2, "0")} 0x${body[1].toString(16).padStart(2, "0")}` + }); + body = decompressed; + bodyTransformed = true; } + } const headers = buildUpstreamHeaders(req, settings, targetUrl, { stripContentHeaders: bodyTransformed }); - if (bodyTransformed) { - if (body.length) { - upsertHeader(headers, "content-length", String(Buffer.byteLength(body))); + if (bodyTransformed && body.length) { + upsertHeader(headers, "content-length", String(Buffer.byteLength(body))); + } + + const captureHandle = captureManager.beginRecord() ?? createNoopCaptureHandle(); + const captureContext = buildRequestContext({ + req, + settings, + targetUrl, + requestId, + requestHeaders: headers, + requestBody: body, + startedAt, + captureHandle + }); + let captureSaved = false; + let responseCompleted = false; + + function finalizeCapture(fields) { + if (captureSaved) { + return; } + captureSaved = true; + saveCaptureRecord(captureContext, fields); } debugLog("REQUEST", { @@ -294,7 +388,7 @@ function createServer(settings) { targetUrl: targetUrl.href, incomingHeaders: sanitizeHeadersForDebug(Object.fromEntries(Object.entries(req.headers))), upstreamHeaders: Object.fromEntries(headers.map(([k, v]) => [k, k.toLowerCase() === "authorization" ? maskSecret(v) : v])), - body: safeBodyPreview(body), + body: safeBodyPreview(body) }); const upstreamRequest = transport.request( @@ -309,28 +403,37 @@ function createServer(settings) { }, (upstreamResponse) => { const stream = isEventStream(upstreamResponse.headers["content-type"]); - debugLog("RESPONSE HEADERS", { status: upstreamResponse.statusCode, - headers: upstreamResponse.headers, + headers: upstreamResponse.headers }); + const responseHeaders = headersToObject(upstreamResponse.rawHeaders); const respChunks = []; - if (!stream) { - upstreamResponse.on("data", (chunk) => respChunks.push(chunk)); - } + upstreamResponse.on("data", (chunk) => { + respChunks.push(chunk); + }); res.statusCode = upstreamResponse.statusCode || 502; writeHeadersToResponse(res, upstreamResponse.rawHeaders); upstreamResponse.pipe(res); upstreamResponse.on("end", () => { - if (!stream && respChunks.length) { + responseCompleted = true; + const responseBody = Buffer.concat(respChunks); + if (responseBody.length) { debugLog("RESPONSE BODY", { status: upstreamResponse.statusCode, - body: safeBodyPreview(Buffer.concat(respChunks)), + body: safeBodyPreview(responseBody) }); } - log("info", "Proxied request", { + finalizeCapture({ + responseStatus: upstreamResponse.statusCode || 502, + responseHeaders, + responseBody, + isStream: stream, + upstreamRequestId: typeof upstreamResponse.headers["x-request-id"] === "string" ? upstreamResponse.headers["x-request-id"] : null + }); + logFn("info", "Proxied request", { request_id: requestId, method: req.method || "GET", path: req.url, @@ -348,23 +451,39 @@ function createServer(settings) { upstreamRequest.on("error", (error) => { const statusCode = error.message === "upstream timeout" ? 504 : 502; + const errorType = statusCode === 504 ? "proxy_timeout" : "proxy_upstream_error"; + const payload = { + error: { + message: statusCode === 504 ? "Upstream request timed out" : "Failed to reach upstream service", + type: errorType, + request_id: requestId + } + }; + const responseBody = Buffer.from(JSON.stringify(payload)); + const responseHeaders = { + "content-type": "application/json; charset=utf-8", + "content-length": String(responseBody.length) + }; + debugLog("UPSTREAM ERROR", { error: error.message, code: error.code || "(none)", - stack: error.stack, + stack: error.stack }); if (!res.headersSent) { - writeJson(res, statusCode, { - error: { - message: statusCode === 504 ? "Upstream request timed out" : "Failed to reach upstream service", - type: statusCode === 504 ? "proxy_timeout" : "proxy_upstream_error", - request_id: requestId - } - }); + writeJson(res, statusCode, payload); } else { res.destroy(error); } - log("warn", "Proxy request failed", { + finalizeCapture({ + responseStatus: statusCode, + responseHeaders, + responseBody, + errorType, + errorMessage: error.message, + upstreamRequestId: null + }); + logFn("warn", "Proxy request failed", { request_id: requestId, method: req.method || "GET", path: req.url, @@ -374,34 +493,73 @@ function createServer(settings) { }); }); + res.on("close", () => { + if (responseCompleted || res.writableFinished) { + return; + } + finalizeCapture({ + responseStatus: res.statusCode || null, + responseHeaders: {}, + responseBody: Buffer.alloc(0), + isStream: false, + upstreamRequestId: null, + errorType: "proxy_client_abort", + errorMessage: "Client closed connection" + }); + }); + upstreamRequest.end(body); }); }); } -const settings = loadConfig(); -DEBUG_ENABLED = settings.server.logLevel.toLowerCase() === "debug"; -log("info", "Loaded proxy config", { - config_path: settings.configPath, - upstream: settings.upstream.baseUrl, - auth_override: settings.proxy.overrideAuthorization, - auth_header: settings.upstream.authHeader, - api_key: maskSecret(settings.upstream.apiKey) -}); - -const server = createServer(settings); -server.on("error", (error) => { - log("error", "Node proxy failed to listen", { - host: settings.server.host, - port: settings.server.port, - error: JSON.stringify(error.message) +export function createApp(settings = loadConfig()) { + DEBUG_ENABLED = settings.server.logLevel.toLowerCase() === "debug"; + const captureManager = createCaptureManager({ + configPath: settings.configPath, + capture: settings.capture, + log + }).start(); + + log("info", "Loaded proxy config", { + config_path: settings.configPath, + upstream: settings.upstream.baseUrl, + auth_override: settings.proxy.overrideAuthorization, + auth_header: settings.upstream.authHeader, + api_key: maskSecret(settings.upstream.apiKey), + capture_enabled: settings.capture.enabled, + capture_db_path: settings.capture.dbPath }); - process.exit(1); -}); -server.listen(settings.server.port, settings.server.host, () => { - log("info", "Node proxy listening", { - host: settings.server.host, - port: settings.server.port + const server = createServer(settings, { captureManager, logFn: log }); + server.on("close", () => { + captureManager.close(); }); -}); + + return { server, settings, captureManager }; +} + +export function startServer(settings = loadConfig()) { + const app = createApp(settings); + app.server.on("error", (error) => { + log("error", "Node proxy failed to listen", { + host: settings.server.host, + port: settings.server.port, + error: JSON.stringify(error.message) + }); + process.exit(1); + }); + + app.server.listen(settings.server.port, settings.server.host, () => { + log("info", "Node proxy listening", { + host: settings.server.host, + port: settings.server.port + }); + }); + + return app; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + startServer(); +} diff --git a/node/test/capture-store.test.mjs b/node/test/capture-store.test.mjs new file mode 100644 index 0000000..2e5184f --- /dev/null +++ b/node/test/capture-store.test.mjs @@ -0,0 +1,208 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import { join, resolve } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { + CaptureManager, + DEFAULT_CAPTURE_DB_PATH, + encodeBody, + loadRuntimeCaptureConfig, + normalizeCaptureConfig, + redactHeaders +} from "../src/capture-store.mjs"; + +function makeTempDir(prefix) { + return join(os.tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`); +} + +function wait(ms = 700) { + return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +} + +test("normalizeCaptureConfig applies defaults", () => { + const normalized = normalizeCaptureConfig({}, { + baseDir: "/tmp/example", + defaultDbPath: DEFAULT_CAPTURE_DB_PATH, + strict: true + }); + assert.equal(normalized.enabled, false); + assert.equal(normalized.dbPath, DEFAULT_CAPTURE_DB_PATH); +}); + +test("redactHeaders redacts sensitive header names", () => { + const headers = redactHeaders({ + Authorization: "Bearer secret", + Cookie: "abc=123", + "X-Api-Key": "key", + Accept: "application/json" + }); + assert.equal(headers.Authorization, "[REDACTED]"); + assert.equal(headers.Cookie, "[REDACTED]"); + assert.equal(headers["X-Api-Key"], "[REDACTED]"); + assert.equal(headers.Accept, "application/json"); +}); + +test("encodeBody preserves utf8 and base64 encodes binary", () => { + const text = encodeBody(Buffer.from("hello", "utf8")); + assert.deepEqual(text, { body: "hello", encoding: "utf8", bytes: 5 }); + + const binary = encodeBody(Buffer.from([0xff, 0x00, 0x10])); + assert.equal(binary.encoding, "base64"); + assert.equal(binary.bytes, 3); +}); + +test("capture manager writes a complete request/response record", async () => { + const dir = makeTempDir("crp-capture"); + mkdirSync(dir, { recursive: true }); + const runtimeConfigPath = join(dir, "proxy-config.json"); + const dbPath = join(dir, "traffic.sqlite3"); + writeFileSync(runtimeConfigPath, `${JSON.stringify({ + capture: { + enabled: true, + dbPath + } + }, null, 2)}\n`, "utf8"); + + const manager = new CaptureManager({ + configPath: runtimeConfigPath, + capture: { + enabled: true, + dbPath + }, + watchRuntimeConfig: false + }).start(); + + const handle = manager.beginRecord(); + assert.ok(handle); + handle.save({ + startedAt: new Date("2026-05-19T00:00:00.000Z").toISOString(), + completedAt: new Date("2026-05-19T00:00:01.000Z").toISOString(), + durationMs: 1000, + requestId: "req-1", + sessionId: "sess-1", + threadId: "thread-1", + method: "POST", + incomingUrl: "http://127.0.0.1:15100/responses", + targetUrl: "https://example.com/responses", + requestHeaders: { + Authorization: "Bearer super-secret", + Accept: "application/json" + }, + requestBody: Buffer.from("{\"hello\":\"world\"}", "utf8"), + responseStatus: 200, + responseHeaders: { + "Content-Type": "text/event-stream", + "X-Request-Id": "upstream-1" + }, + responseBody: Buffer.from("event: ok\ndata: {}\n\n", "utf8"), + isStream: true, + upstreamRequestId: "upstream-1" + }); + + const db = new DatabaseSync(dbPath); + const rows = db.prepare("SELECT * FROM http_transactions").all(); + db.close(); + manager.close(); + + assert.equal(rows.length, 1); + assert.equal(rows[0].request_id, "req-1"); + assert.equal(rows[0].thread_id, "thread-1"); + assert.equal(rows[0].is_stream, 1); + assert.equal(rows[0].response_status, 200); + assert.match(rows[0].request_headers_json, /REDACTED/); + assert.match(rows[0].response_body, /event: ok/); + + rmSync(dir, { recursive: true, force: true }); +}); + +test("capture manager hot-disables when runtime config changes", async () => { + const dir = makeTempDir("crp-hot-disable"); + mkdirSync(dir, { recursive: true }); + const runtimeConfigPath = join(dir, "proxy-config.json"); + const dbPath = join(dir, "traffic.sqlite3"); + writeFileSync(runtimeConfigPath, `${JSON.stringify({ + capture: { + enabled: true, + dbPath + } + }, null, 2)}\n`, "utf8"); + + const manager = new CaptureManager({ + configPath: runtimeConfigPath, + capture: { + enabled: true, + dbPath + } + }).start(); + + assert.equal(manager.getPublicState().captureActive, true); + writeFileSync(runtimeConfigPath, `${JSON.stringify({ + capture: { + enabled: false, + dbPath + } + }, null, 2)}\n`, "utf8"); + await wait(); + + assert.equal(manager.getPublicState().captureActive, false); + assert.equal(manager.getPublicState().captureState, "disabled"); + + manager.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +test("capture manager marks restart required when db path changes", async () => { + const dir = makeTempDir("crp-db-change"); + mkdirSync(dir, { recursive: true }); + const runtimeConfigPath = join(dir, "proxy-config.json"); + const dbPath = join(dir, "traffic.sqlite3"); + const nextDbPath = join(dir, "traffic-next.sqlite3"); + writeFileSync(runtimeConfigPath, `${JSON.stringify({ + capture: { + enabled: true, + dbPath + } + }, null, 2)}\n`, "utf8"); + + const manager = new CaptureManager({ + configPath: runtimeConfigPath, + capture: { + enabled: true, + dbPath + } + }).start(); + + writeFileSync(runtimeConfigPath, `${JSON.stringify({ + capture: { + enabled: true, + dbPath: nextDbPath + } + }, null, 2)}\n`, "utf8"); + await wait(); + + const state = manager.getPublicState(); + assert.equal(state.captureRestartRequired, true); + assert.equal(resolve(state.captureRuntimeDbPath), resolve(dbPath)); + assert.equal(resolve(state.captureDbPath), resolve(nextDbPath)); + + manager.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +test("loadRuntimeCaptureConfig validates malformed config", () => { + const dir = makeTempDir("crp-bad-config"); + mkdirSync(dir, { recursive: true }); + const runtimeConfigPath = join(dir, "proxy-config.json"); + writeFileSync(runtimeConfigPath, `${JSON.stringify({ + capture: { + enabled: "yes" + } + }, null, 2)}\n`, "utf8"); + + assert.throws(() => loadRuntimeCaptureConfig(runtimeConfigPath), /capture\.enabled must be a boolean/); + + rmSync(dir, { recursive: true, force: true }); +}); diff --git a/node/test/server.test.mjs b/node/test/server.test.mjs new file mode 100644 index 0000000..35f5206 --- /dev/null +++ b/node/test/server.test.mjs @@ -0,0 +1,135 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; +import { once } from "node:events"; +import { DatabaseSync } from "node:sqlite"; + +import { createApp } from "../src/server.mjs"; + +function makeTempDir(prefix) { + return join(os.tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`); +} + +function listen(server, host = "127.0.0.1") { + return new Promise((resolvePromise, rejectPromise) => { + server.listen(0, host, () => { + const address = server.address(); + resolvePromise(address.port); + }); + server.once("error", rejectPromise); + }); +} + +function requestJson(url, body) { + return fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer local-secret", + "x-client-request-id": "req-it-1", + "thread-id": "thread-it-1" + }, + body: JSON.stringify(body) + }); +} + +test("server writes proxied request and response to sqlite", async () => { + const dir = makeTempDir("crp-server"); + mkdirSync(dir, { recursive: true }); + + const upstreamServer = http.createServer((req, res) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + const payload = Buffer.concat(chunks).toString("utf8"); + res.statusCode = 200; + res.setHeader("content-type", "application/json"); + res.setHeader("x-request-id", "upstream-test-1"); + res.end(JSON.stringify({ ok: true, echoed: JSON.parse(payload) })); + }); + }); + const upstreamPort = await listen(upstreamServer); + + const runtimeConfigPath = join(dir, "proxy-config.json"); + const dbPath = join(dir, "traffic.sqlite3"); + writeFileSync(runtimeConfigPath, `${JSON.stringify({ + server: { + host: "127.0.0.1", + port: 0, + logLevel: "info" + }, + upstream: { + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: "upstream-secret", + timeoutMs: 300000, + verifySsl: true, + authHeader: "authorization", + authScheme: "Bearer", + extraHeaders: {} + }, + proxy: { + overrideAuthorization: true, + requestIdHeader: "x-client-request-id" + }, + capture: { + enabled: true, + dbPath + } + }, null, 2)}\n`, "utf8"); + + const { server, captureManager } = createApp({ + configPath: runtimeConfigPath, + server: { + host: "127.0.0.1", + port: 0, + logLevel: "info" + }, + upstream: { + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: "upstream-secret", + timeoutMs: 300000, + verifySsl: true, + authHeader: "authorization", + authScheme: "Bearer", + extraHeaders: {} + }, + proxy: { + overrideAuthorization: true, + requestIdHeader: "x-client-request-id" + }, + capture: { + enabled: true, + dbPath + } + }); + const proxyPort = await listen(server); + + const response = await requestJson(`http://127.0.0.1:${proxyPort}/responses`, { + message: "hello" + }); + assert.equal(response.status, 200); + const json = await response.json(); + assert.equal(json.ok, true); + + server.close(); + await once(server, "close"); + upstreamServer.close(); + await once(upstreamServer, "close"); + + const db = new DatabaseSync(dbPath); + const rows = db.prepare("SELECT * FROM http_transactions").all(); + db.close(); + captureManager.close(); + + assert.equal(rows.length, 1); + assert.equal(rows[0].request_id, "req-it-1"); + assert.equal(rows[0].thread_id, "thread-it-1"); + assert.equal(rows[0].upstream_request_id, "upstream-test-1"); + assert.match(rows[0].request_headers_json, /REDACTED/); + assert.match(rows[0].response_body, /"ok":true/); + + rmSync(dir, { recursive: true, force: true }); +});