diff --git a/AGENTS.md b/AGENTS.md index eda4a3e..f232ba2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,9 +34,17 @@ The non-negotiable details: `codex -m deepseek/deepseek-v4-flash -c 'model_reasoning_effort="max"' -a never exec --skip-git-repo-check 'call a shell tool exactly once …'` and the same command with `deepseek/deepseek-v4-pro`. -6. Do not edit `~/.codex/config.toml` by hand unless the user asks; the CLI owns its two - marker-owned root keys. GUI-written `model` / `model_reasoning_effort` lines are user-owned and - must be preserved. +6. Do not edit `~/.codex/config.toml` by hand unless the user asks. The CLI owns exactly four + marker-owned root keys (`openai_base_url`, `experimental_realtime_ws_base_url`, `model_provider`, + and `model_catalog_json`) plus the exact marker-owned `[model_providers.dscodex]` table. It must + migrate both older root-block shapes, update the provider URL when the router token or port + changes, and preserve every unrelated root key and custom provider. Refuse before persisting a + token if the user already owns `model_provider` or `[model_providers.dscodex]`; uninstall removes + only exact DSCodex-owned blocks. Ownership checks must understand TOML-equivalent quoted and + dotted keys/tables without mistaking text inside multiline strings for config. If a user adds + fields or nested tables to the marker-owned provider, uninstall must refuse before stopping the + service or deleting any backing state. GUI-written `model` / `model_reasoning_effort` lines are + user-owned and must be preserved. 7. Provider selection memory lives in `~/.codex/dscodex/model-selections.json`. OpenAI and DeepSeek have separate reasoning-effort slots; only OpenAI owns the saved service tier. The file also persists per-thread provider memory (bounded, last 500 threads) so resumed threads @@ -77,13 +85,18 @@ The non-negotiable details: `NO_PROXY` always includes loopback plus `api.deepseek.com`). Proxy credentials are redacted in CLI output and DPAPI-protected on Windows; the proxy URL must never be confused with the DeepSeek key, which stays DPAPI/0600-protected and is never printed or committed. -11. `install` generates a 256-bit router token and writes it into the managed `openai_base_url`; - `start` / `serve` must reconcile that marker-owned URL with the persisted token and selected - port, and `doctor` must verify the exact binding. The proxy must reject requests without that - path token. `serve` owns a 0600 pid-state file with a per-instance shutdown token. `stop` may - only use the authenticated shutdown endpoint and must atomically preserve replacement-instance - state; it must never terminate an unverified or recycled PID. Cap both compressed request bytes - and decompressed request bytes before parsing JSON. +11. `install` generates a 256-bit router token and writes it into the managed `openai_base_url` and + `[model_providers.dscodex].base_url`. The root `model_provider` selects `dscodex`; its provider + table must declare `wire_api = "responses"`, `requires_openai_auth = true`, and + `supports_websockets = false`. HTTP-only normal model traffic is required so the router can + inspect the model before choosing DeepSeek or ChatGPT; sending `deepseek/...` through the + built-in OpenAI provider's Responses WebSocket makes ChatGPT reject it as unsupported for the + account. `start` / `serve` must reconcile both marker-owned URLs with the persisted token and + selected port, and `doctor` must verify the exact binding. The proxy must reject requests + without that path token. `serve` owns a 0600 pid-state file with a per-instance shutdown token. + `stop` may only use the authenticated shutdown endpoint and must atomically preserve + replacement-instance state; it must never terminate an unverified or recycled PID. Cap both + compressed request bytes and decompressed request bytes before parsing JSON. 12. DeepSeek does not implement Codex remote compaction v2. For a DeepSeek-bound request containing `compaction_trigger`, the router must remove tools and the trigger, ask the same DeepSeek model for a compact handoff summary, and return exactly one synthetic `compaction` output item before @@ -120,7 +133,23 @@ The non-negotiable details: and `doctor` passes trivially. Windows config lives under `%USERPROFILE%\\.codex`; `0600` file permissions do not apply on NTFS (DSCodex relies on the user account ACL). Autostart uses the platform-native scheduler on all three OSes (launchd / systemd / Task Scheduler + VBS). -15. Non-routed client features: Voice, Pets, plugins, skills, and MCP are all client-side and - unaffected by the router. Voice is driven by GPT-Live and is never routed to DeepSeek. - The catalog declares `prefer_websockets = false` — the router answers probes with 426, Codex - falls back to HTTP/SSE, and `codex doctor` may show a warning but requests work fine. +15. Client features: Pets, plugins, skills, and MCP are all client-side and unaffected by the + router. Voice is driven by GPT-Live and is never routed to DeepSeek, but its WebRTC call + creation does pass through the router: `POST /v1/live` multipart bodies (an `sdp` part and a + JSON `session` part) are re-encoded as the official JSON shape and forwarded to + `chatgpt.com`'s `/backend-api/codex/realtime/calls` endpoint with the AVAS query params + (`intent=quicksilver&architecture=avas`) and the `OpenAI-Alpha: quicksilver=v2` header, + mirroring the official client. Live requests forward the client's full header set (cookies, + integrity-state, DeviceCheck) because that endpoint sits behind stricter Cloudflare checks. + DSCodex owns the root `experimental_realtime_ws_base_url` setting and points it at the + authenticated loopback router's `/v1/realtime` base. Current Codex normalizes that base to + `/v1/live/` for V3 sideband sessions; the router also supports legacy + `/v1/realtime?call_id=...`. Both routes validate the call ID, preserve required + authorization/session/attestation headers, redact call IDs from logs, and connect only to the + fixed `api.openai.com/v1` Realtime upstream through the saved outbound proxy. The catalog + declares `prefer_websockets = false`, while the managed `dscodex` provider declares + `supports_websockets = false`, so normal model traffic (including Voice background Responses + work) is always inspectable HTTP/SSE. The router retains authenticated `/v1/responses` + WebSocket tunneling to ChatGPT only for older-client compatibility. Other upgrade probes + receive 426 so Codex falls back to HTTP/SSE; DeepSeek traffic must never use the WebSocket + tunnel. diff --git a/README.en.md b/README.en.md index 46ccc3a..7cfdd57 100644 --- a/README.en.md +++ b/README.en.md @@ -96,6 +96,8 @@ http://127.0.0.1:10110//v1 ← DSCodex loopback router Traffic is split by model name. Only DeepSeek-bound requests are rewritten; GPT traffic is forwarded transparently. +The installer manages root-level `model_provider = "dscodex"` plus `[model_providers.dscodex]`, pinning ordinary model requests to OAuth-authenticated HTTP Responses (`wire_api = "responses"`, `requires_openai_auth = true`, `supports_websockets = false`). This lets the router inspect the requested model before choosing DeepSeek or ChatGPT. Otherwise Codex can place a `deepseek/...` model on ChatGPT's account-only Responses WebSocket and report that the model is unsupported with a ChatGPT account. Voice keeps its separate Realtime sideband WebSocket. + ## Compatibility | Surface or behavior | Status | @@ -123,13 +125,17 @@ Yes. Codex CLI and IDE extensions are supported on macOS, Linux, and Windows; na Yes. Tool calls and web search use DeepSeek's Responses API. Because the text-only models cannot see images directly, DSCodex first asks GPT for an image description. Automatic and manual compaction produce an encrypted Codex compaction item. +### Why does Codex say DeepSeek is unsupported with a ChatGPT account? + +Codex is still treating DeepSeek as a WebSocket model on its built-in OpenAI provider. After updating DSCodex, rerun `node src/cli.mjs install` (and `node src/cli.mjs autostart enable` when autostart is used), then fully quit and relaunch ChatGPT / Codex. DSCodex adds its marker-owned HTTP-only provider configuration. It preserves other custom providers and refuses to replace a user-owned `model_provider` or `[model_providers.dscodex]` table. + ## Known edge cases - **Usage stats.** The Codex app's Profile page is read-only — DeepSeek usage cannot be added. - **Why reasoning folds mid-task.** DeepSeek emits `response.completed` after every tool round; Codex folds the reasoning block, runs the tool, and opens a new request. API behavior, not a bug. No-tool turns fold once at the end. - **GPT vision.** Borrows the request's ChatGPT OAuth headers (no extra key). Without OAuth headers images pass through untouched. Default model `gpt-5.6-sol`, override with `DSCODEX_VISION_MODEL`. - **Key storage, proxy resolution, bridge details, platform differences.** See `AGENTS.md`. -- **Voice / Pets / plugins / skills / MCP.** All client-side; Voice runs on GPT-Live and is never routed to DeepSeek. +- **Voice / Pets / plugins / skills / MCP.** Pets, plugins, skills, and MCP are all client-side. Voice runs on GPT-Live and is never routed to DeepSeek. Its WebRTC call creation is forwarded to chatgpt.com's `realtime/calls` endpoint, and its authenticated Realtime sideband WebSocket reaches the fixed OpenAI Realtime upstream through the configured proxy. Background Responses work uses the same routable HTTP/SSE path as ordinary model traffic. An authenticated ChatGPT Responses WebSocket relay remains only for older-client compatibility; DeepSeek never uses it. - **DeepSeek → GPT thread history.** Switching an existing task from DeepSeek back to GPT can currently leave plaintext `reasoning_text` in history and cause a persistent GPT 400 response; see [#17](https://github.com/fish2lab/DSCodex/issues/17). Switching back to DeepSeek or starting a new GPT task remains available. ## Uninstall @@ -138,7 +144,7 @@ Yes. Tool calls and web search use DeepSeek's Responses API. Because the text-on node src/cli.mjs stop && node src/cli.mjs uninstall ``` -Removes only DSCodex-owned config and files. The pre-install backup stays at `~/.codex/config.toml.pre-dscodex.bak`. +Removes only DSCodex-owned config and files. The pre-install backup stays at `~/.codex/config.toml.pre-dscodex.bak`. If fields or subtables were manually added to the marker-owned `[model_providers.dscodex]`, uninstall refuses before stopping the service or deleting state; restore the original table shape or handle those customizations manually first. ## References diff --git a/README.md b/README.md index f0d68dd..0a9ecf2 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,8 @@ http://127.0.0.1:10110//v1 ← DSCodex 本地路由 按模型名分流。路由仅改写 DeepSeek-bound 请求,GPT 流量透明旁路。 +安装器会管理根级 `model_provider = "dscodex"` 与 `[model_providers.dscodex]`,将普通模型请求固定为带 ChatGPT OAuth 的 HTTP Responses(`wire_api = "responses"`、`requires_openai_auth = true`、`supports_websockets = false`)。这样路由器能先读取请求中的模型名再决定去 DeepSeek 或 ChatGPT;否则 Codex 可能把 `deepseek/...` 直接放进 ChatGPT 账户专用的 Responses WebSocket,并返回 “model is not supported when using Codex with a ChatGPT account”。Voice 仍使用独立的 Realtime sideband WebSocket。 + ## 兼容性 | 场景 | 状态 | @@ -124,13 +126,17 @@ http://127.0.0.1:10110//v1 ← DSCodex 本地路由 能。工具调用和 web search 走 DeepSeek Responses API;文字模型无法直接看到图片,因此 DSCodex 先用 GPT 生成图片描述;自动或手动压缩由 DSCodex 生成加密的 Codex compaction item。 +### 为什么提示 DeepSeek 不支持 ChatGPT account? + +这表示当前 Codex 仍把 DeepSeek 当成内置 OpenAI provider 的 WebSocket 模型。更新 DSCodex 后重新运行 `node src/cli.mjs install`(如使用自启,再运行 `node src/cli.mjs autostart enable`),然后完全退出并重开 ChatGPT / Codex。DSCodex 会补齐 marker-owned 的 HTTP-only provider 配置;不会覆盖用户的其他自定义 provider,若 `model_provider` 或 `[model_providers.dscodex]` 已由用户配置则会拒绝并提示冲突。 + ## 已知边界 - **用量统计。** Codex 的 Profile 页面只读,无法计入 DeepSeek 用量。 - **思考反复折叠。** DeepSeek 每轮工具调用结束发 `response.completed`,Codex 折叠→执行→展开下一轮思考。这是 API 行为。无工具的单轮只折叠一次。 - **GPT 识图。** 借用请求自带的 OAuth 头,无需额外 key。无 OAuth 时图片原样透传。默认模型 `gpt-5.6-sol`,`DSCODEX_VISION_MODEL` 可换。 - **Key 存储、代理解析、bridge 细节、平台差异。** 详见 `AGENTS.md`。 -- **Voice / Pets / 插件 / 技能 / MCP。** 均为客户端功能;Voice 由 GPT-Live 驱动,不会路由到 DeepSeek。 +- **Voice / Pets / 插件 / 技能 / MCP。** Pets、插件、技能、MCP 均为客户端功能;Voice 由 GPT-Live 驱动,不会路由到 DeepSeek。语音通话创建请求(WebRTC)会由路由器转发到 chatgpt.com 的 `realtime/calls` 端点。DSCodex 还会管理 `experimental_realtime_ws_base_url`,让 V3 的 `/v1/live/` 侧带 WebSocket(并兼容旧版 `/v1/realtime?call_id=...`)通过已保存的出站代理连接固定的 OpenAI Realtime 上游;Voice 发起的后台 Responses 任务则和普通模型请求一样走可按模型分流的 HTTP/SSE。路由器仅为旧客户端保留经过认证的 ChatGPT Responses WebSocket 兼容通道,DeepSeek 不走该通道。 - **DeepSeek → GPT 任务历史。** 同一任务从 DeepSeek 切回 GPT 时,历史中的明文 `reasoning_text` 目前可能导致 GPT 请求返回 400;见 [#17](https://github.com/fish2lab/DSCodex/issues/17)。切回 DeepSeek 或新建 GPT 任务可继续使用。 ## 卸载 @@ -139,7 +145,7 @@ http://127.0.0.1:10110//v1 ← DSCodex 本地路由 node src/cli.mjs stop && node src/cli.mjs uninstall ``` -只删除 DSCodex 写入的配置和文件。备份保留在 `~/.codex/config.toml.pre-dscodex.bak`。 +只删除 DSCodex 写入的配置和文件。备份保留在 `~/.codex/config.toml.pre-dscodex.bak`。如果手动给 marker-owned 的 `[model_providers.dscodex]` 增加了字段或子表,卸载会在停服务或删除状态前拒绝;先恢复该表的原始结构,或自行处理这些定制项。 ## 参考 diff --git a/src/cli.mjs b/src/cli.mjs index 51ab374..bcca264 100755 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -18,6 +18,7 @@ import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; import { buildCatalog, syncCatalog } from "./catalog.mjs"; import { + assertSafeToUninstall, ensureManagedRouterBinding, install, managedRouterConfigMatches, @@ -491,7 +492,10 @@ async function serve(port) { const shutdown = () => { if (shuttingDown) return; shuttingDown = true; - const forceTimer = setTimeout(() => server.closeAllConnections?.(), 5_000); + const forceTimer = setTimeout(() => { + server.closeUpgradeConnections?.(); + server.closeAllConnections?.(); + }, 5_000); server.close(() => { clearTimeout(forceTimer); removePidState(paths, { pid: process.pid, instanceId }); @@ -1046,6 +1050,9 @@ async function main() { case "doctor": await doctor(port); break; case "stop": await stop(); break; case "uninstall": + // Validate marker ownership before stopping services or deleting any + // generated state; a customized provider must make uninstall a no-op. + assertSafeToUninstall({ paths }); await autostartDisable(paths, { quiet: true }); await stop(); uninstall({ paths }); diff --git a/src/config.mjs b/src/config.mjs index 44fd77f..e93c10a 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -17,7 +17,30 @@ import { readRouterToken, } from "./keys.mjs"; -const ROOT_KEYS = new Set(["openai_base_url", "model_catalog_json"]); +const MANAGED_ROOT_KEY_ORDER = [ + "openai_base_url", + "experimental_realtime_ws_base_url", + "model_provider", + "model_catalog_json", +]; +const REALTIME_LEGACY_MANAGED_ROOT_KEY_ORDER = [ + "openai_base_url", + "experimental_realtime_ws_base_url", + "model_catalog_json", +]; +const LEGACY_MANAGED_ROOT_KEY_ORDER = [ + "openai_base_url", + "model_catalog_json", +]; +const ROOT_KEYS = new Set(MANAGED_ROOT_KEY_ORDER); +const MANAGED_PROVIDER_HEADER = "[model_providers.dscodex]"; +const MANAGED_PROVIDER_KEY_ORDER = [ + "name", + "base_url", + "wire_api", + "requires_openai_auth", + "supports_websockets", +]; const DESKTOP_KEY = "enabled-reasoning-efforts"; const REASONING_EFFORTS = '["low", "medium", "high", "xhigh", "max", "ultra"]'; @@ -25,9 +48,203 @@ function keyOf(line) { return /^\s*([A-Za-z0-9_-]+)\s*=/.exec(line)?.[1] ?? null; } -function firstTableIndex(lines) { - const index = lines.findIndex((line) => /^\s*\[/.test(line)); - return index === -1 ? lines.length : index; +function tomlCode(line) { + let quote = ""; + let escaped = false; + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + if (quote === '"') { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === quote) quote = ""; + continue; + } + if (quote === "'") { + if (character === quote) quote = ""; + continue; + } + if (character === '"' || character === "'") quote = character; + else if (character === "#") return line.slice(0, index); + } + return line; +} + +function decodeBasicTomlKey(source, start) { + let value = ""; + for (let index = start + 1; index < source.length; index += 1) { + const character = source[index]; + if (character === '"') return { value, end: index + 1 }; + if (character !== "\\") { + if (character === "\n" || character === "\r") return null; + value += character; + continue; + } + const escape = source[index + 1]; + const simple = { b: "\b", t: "\t", n: "\n", f: "\f", r: "\r", '"': '"', "\\": "\\" }; + if (Object.hasOwn(simple, escape)) { + value += simple[escape]; + index += 1; + continue; + } + const digits = escape === "u" ? 4 : escape === "U" ? 8 : 0; + if (!digits) return null; + const encoded = source.slice(index + 2, index + 2 + digits); + if (!new RegExp(`^[0-9A-Fa-f]{${digits}}$`).test(encoded)) return null; + const codePoint = Number.parseInt(encoded, 16); + if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return null; + value += String.fromCodePoint(codePoint); + index += 1 + digits; + } + return null; +} + +function parseTomlKeyPath(source) { + const path = []; + let index = 0; + const skipSpace = () => { + while (source[index] === " " || source[index] === "\t") index += 1; + }; + skipSpace(); + while (index < source.length) { + let component; + if (source[index] === '"') { + const decoded = decodeBasicTomlKey(source, index); + if (!decoded) return null; + component = decoded.value; + index = decoded.end; + } else if (source[index] === "'") { + const end = source.indexOf("'", index + 1); + if (end === -1) return null; + component = source.slice(index + 1, end); + index = end + 1; + } else { + const match = /^[A-Za-z0-9_-]+/.exec(source.slice(index)); + if (!match) return null; + [component] = match; + index += component.length; + } + path.push(component); + skipSpace(); + if (index === source.length) return path; + if (source[index] !== ".") return null; + index += 1; + skipSpace(); + } + return null; +} + +function tomlEqualsIndex(source) { + let quote = ""; + let escaped = false; + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + if (quote === '"') { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === quote) quote = ""; + continue; + } + if (quote === "'") { + if (character === quote) quote = ""; + continue; + } + if (character === '"' || character === "'") quote = character; + else if (character === "=") return index; + } + return -1; +} + +function tomlTablePath(line) { + const code = tomlCode(line).trim(); + const arrayTable = code.startsWith("[[") && code.endsWith("]]"); + const regularTable = code.startsWith("[") && code.endsWith("]"); + if (!arrayTable && !regularTable) return null; + const edge = arrayTable ? 2 : 1; + return parseTomlKeyPath(code.slice(edge, -edge).trim()); +} + +function scanTomlValueLine(line, state, start = 0) { + let quote = ""; + let escaped = false; + for (let index = start; index < line.length; index += 1) { + if (state.multiline) { + if (line.startsWith(state.multiline, index)) { + state.multiline = ""; + index += 2; + } else if (state.multiline === '"""' && line[index] === "\\") { + index += 1; + } + continue; + } + const character = line[index]; + if (quote === '"') { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === quote) quote = ""; + continue; + } + if (quote === "'") { + if (character === quote) quote = ""; + continue; + } + if (character === "#") break; + if (line.startsWith('"""', index) || line.startsWith("'''", index)) { + state.multiline = line.slice(index, index + 3); + index += 2; + } else if (character === '"' || character === "'") { + quote = character; + } else if (character === "[" || character === "{") { + state.closers.push(character === "[" ? "]" : "}"); + } else if (character === "]" || character === "}") { + if (state.closers.at(-1) === character) state.closers.pop(); + } + } +} + +function scanToml(lines) { + const declarations = []; + const statementLines = new Set(); + let tablePath = []; + const valueState = { multiline: "", closers: [] }; + for (let index = 0; index < lines.length; index += 1) { + if (valueState.multiline || valueState.closers.length) { + scanTomlValueLine(lines[index], valueState); + continue; + } + statementLines.add(index); + const parsedTable = tomlTablePath(lines[index]); + if (parsedTable) { + tablePath = parsedTable; + declarations.push({ index, kind: "table", path: parsedTable, tablePath: [], keyPath: [] }); + continue; + } + const code = tomlCode(lines[index]); + const equals = tomlEqualsIndex(code); + const keyPath = equals === -1 ? null : parseTomlKeyPath(code.slice(0, equals).trim()); + if (keyPath) { + declarations.push({ + index, + kind: "key", + path: [...tablePath, ...keyPath], + tablePath, + keyPath, + }); + scanTomlValueLine(lines[index], valueState, equals + 1); + } + } + return { declarations, statementLines }; +} + +function tomlDeclarations(lines) { + return scanToml(lines).declarations; +} + +function pathStartsWith(path, prefix) { + return prefix.every((component, index) => path[index] === component); +} + +function firstTableIndex(lines, scan = scanToml(lines)) { + return scan.declarations.find((declaration) => declaration.kind === "table")?.index ?? lines.length; } function quoteToml(value) { @@ -91,13 +308,29 @@ function routerBaseUrl({ port, routerToken }) { } function managedRootLines(options) { + const baseUrl = routerBaseUrl(options); return [ MANAGED_MARKER, - `openai_base_url = ${quoteToml(routerBaseUrl(options))}`, + `openai_base_url = ${quoteToml(baseUrl)}`, + `experimental_realtime_ws_base_url = ${quoteToml(`${baseUrl}/realtime`)}`, + 'model_provider = "dscodex"', `model_catalog_json = ${quoteToml(options.catalogPath)}`, ]; } +function managedProviderLines(options) { + const baseUrl = routerBaseUrl(options); + return [ + MANAGED_MARKER, + MANAGED_PROVIDER_HEADER, + 'name = "DSCodex"', + `base_url = ${quoteToml(baseUrl)}`, + 'wire_api = "responses"', + "requires_openai_auth = true", + "supports_websockets = false", + ]; +} + function assignedString(line) { const equals = line.indexOf("="); if (equals === -1) return ""; @@ -109,22 +342,106 @@ function assignedString(line) { } } +function assignedBoolean(line) { + const equals = line.indexOf("="); + if (equals === -1) return null; + const value = line.slice(equals + 1).trim(); + if (value === "true") return true; + if (value === "false") return false; + return null; +} + +function managedRootBlockAt(lines, index, { allowCurrent = true, scan = scanToml(lines) } = {}) { + if (!scan.statementLines.has(index)) return null; + if (lines[index]?.trim() !== MANAGED_MARKER) return null; + const matches = (order) => order.every((key, offset) => ( + scan.statementLines.has(index + 1 + offset) + && keyOf(lines[index + 1 + offset] ?? "") === key + )); + const order = [ + ...(allowCurrent ? [MANAGED_ROOT_KEY_ORDER] : []), + REALTIME_LEGACY_MANAGED_ROOT_KEY_ORDER, + LEGACY_MANAGED_ROOT_KEY_ORDER, + ].find(matches) ?? null; + if (!order) return null; + const values = {}; + for (let offset = 0; offset < order.length; offset += 1) { + values[order[offset]] = assignedString(lines[index + 1 + offset]); + } + return { lines, start: index, end: index + 1 + order.length, values }; +} + +function managedRootBlockInLines(lines, options = {}) { + const scan = options.scan ?? scanToml(lines); + const allowCurrent = options.allowCurrent ?? Boolean(managedProviderBlockInLines(lines, scan)); + const rootEnd = firstTableIndex(lines, scan); + for (let index = 0; index < rootEnd; index += 1) { + const block = managedRootBlockAt(lines, index, { allowCurrent, scan }); + if (block) return block; + } + return null; +} + function managedRootBlock(content) { - const lines = content.replaceAll("\r\n", "\n").split("\n"); + return managedRootBlockInLines(content.replaceAll("\r\n", "\n").split("\n")); +} + +function managedProviderBlockAt(lines, index, scan = scanToml(lines)) { + if (!scan.statementLines.has(index) || !scan.statementLines.has(index + 1)) return null; + if (lines[index]?.trim() !== MANAGED_MARKER) return null; + if (lines[index + 1]?.trim() !== MANAGED_PROVIDER_HEADER) return null; + const values = {}; + for (let offset = 0; offset < MANAGED_PROVIDER_KEY_ORDER.length; offset += 1) { + const key = MANAGED_PROVIDER_KEY_ORDER[offset]; + const line = lines[index + 2 + offset] ?? ""; + if (!scan.statementLines.has(index + 2 + offset) || keyOf(line) !== key) return null; + values[key] = key === "requires_openai_auth" || key === "supports_websockets" + ? assignedBoolean(line) + : assignedString(line); + } + const end = index + 2 + MANAGED_PROVIDER_KEY_ORDER.length; + const tableEnd = scan.declarations.find((declaration) => ( + declaration.kind === "table" && declaration.index >= end + ))?.index ?? lines.length; + if (lines.slice(end, tableEnd).some((line) => line.trim() !== "")) return null; + return { lines, start: index, end, header: index + 1, values }; +} + +function managedProviderBlockInLines(lines, scan = scanToml(lines)) { for (let index = 0; index < lines.length; index += 1) { - if (lines[index].trim() !== MANAGED_MARKER) continue; - let next = index + 1; - if (!ROOT_KEYS.has(keyOf(lines[next] ?? ""))) continue; - const values = {}; - while (next < lines.length && ROOT_KEYS.has(keyOf(lines[next]))) { - values[keyOf(lines[next])] = assignedString(lines[next]); - next += 1; - } - return { lines, start: index, end: next, values }; + const block = managedProviderBlockAt(lines, index, scan); + if (block) return block; } return null; } +function managedProviderBlock(content) { + const lines = content.replaceAll("\r\n", "\n").split("\n"); + return managedProviderBlockInLines(lines, scanToml(lines)); +} + +function assertNoRootConflictOutsideBlock(block) { + for (const declaration of tomlDeclarations(block.lines)) { + if (declaration.kind !== "key" || declaration.tablePath.length) continue; + if (declaration.index >= block.start && declaration.index < block.end) continue; + const key = declaration.keyPath[0]; + if (ROOT_KEYS.has(key)) throw new Error(`Refusing to replace user-owned root key: ${key}`); + } +} + +function assertNoProviderConflictOutsideBlock(lines, block) { + const providerPath = ["model_providers", "dscodex"]; + for (const declaration of tomlDeclarations(lines)) { + const rootInlineTable = declaration.kind === "key" + && declaration.tablePath.length === 0 + && declaration.keyPath.length === 1 + && declaration.keyPath[0] === "model_providers"; + if (!rootInlineTable && !pathStartsWith(declaration.path, providerPath)) continue; + if (block && declaration.index >= block.start && declaration.index < block.end) continue; + throw new Error("Refusing to replace user-owned provider: model_providers.dscodex"); + } +} + export function readManagedRouterToken(content) { const baseUrl = managedRootBlock(content)?.values.openai_base_url; if (!baseUrl) return ""; @@ -141,73 +458,120 @@ export function readManagedRouterToken(content) { export function managedRouterConfigMatches(content, options) { const block = managedRootBlock(content); - if (!block) return false; - return block.values.openai_base_url === routerBaseUrl(options) - && block.values.model_catalog_json === options.catalogPath; + const provider = managedProviderBlock(content); + if (!block || !provider) return false; + const baseUrl = routerBaseUrl(options); + return block.values.openai_base_url === baseUrl + && block.values.experimental_realtime_ws_base_url === `${baseUrl}/realtime` + && block.values.model_provider === "dscodex" + && block.values.model_catalog_json === options.catalogPath + && provider.values.name === "DSCodex" + && provider.values.base_url === baseUrl + && provider.values.wire_api === "responses" + && provider.values.requires_openai_auth === true + && provider.values.supports_websockets === false; } function rewriteManagedRouterConfig(content, options) { - const block = managedRootBlock(content); + const lines = content.replaceAll("\r\n", "\n").split("\n"); + let block = managedRootBlockInLines(lines); if (!block) { throw new Error("DSCodex managed router config is missing; run `node src/cli.mjs install`"); } - block.lines.splice(block.start, block.end - block.start, ...managedRootLines(options)); - return block.lines.join("\n"); + assertNoRootConflictOutsideBlock(block); + const provider = managedProviderBlockInLines(lines); + assertNoProviderConflictOutsideBlock(lines, provider); + if (provider) lines.splice(provider.start, provider.end - provider.start); + block = managedRootBlockInLines(lines, { allowCurrent: Boolean(provider) }); + lines.splice(block.start, block.end - block.start, ...managedRootLines(options)); + lines.splice(firstTableIndex(lines), 0, ...managedProviderLines(options)); + return lines.join("\n"); } export function stripManagedConfig(content) { const lines = content.replaceAll("\r\n", "\n").split("\n"); + const scan = scanToml(lines); const kept = []; + const rootEnd = firstTableIndex(lines, scan); + const ownsCurrentRoot = Boolean(managedProviderBlockInLines(lines, scan)); for (let index = 0; index < lines.length; index += 1) { - if (lines[index].trim() !== MANAGED_MARKER) { + const provider = managedProviderBlockAt(lines, index, scan); + if (provider) { + index = provider.end - 1; + continue; + } + if (index < rootEnd) { + const block = managedRootBlockAt(lines, index, { allowCurrent: ownsCurrentRoot, scan }); + if (block) { + index = block.end - 1; + continue; + } + } + if (!scan.statementLines.has(index) || lines[index].trim() !== MANAGED_MARKER) { kept.push(lines[index]); continue; } - let next = index + 1; - while (next < lines.length) { - const key = keyOf(lines[next]); - if (!ROOT_KEYS.has(key) && key !== DESKTOP_KEY) break; - next += 1; + if (scan.statementLines.has(index + 1) && keyOf(lines[index + 1] ?? "") === DESKTOP_KEY) { + index += 1; + continue; } - index = next - 1; + kept.push(lines[index]); } return kept.join("\n").replace(/\n{3,}/g, "\n\n"); } function assertNoRootConflict(content) { const lines = content.replaceAll("\r\n", "\n").split("\n"); - const rootEnd = firstTableIndex(lines); - for (let index = 0; index < rootEnd; index += 1) { - const key = keyOf(lines[index]); + for (const declaration of tomlDeclarations(lines)) { + if (declaration.kind !== "key" || declaration.tablePath.length) continue; + const key = declaration.keyPath[0]; if (ROOT_KEYS.has(key)) { throw new Error(`Refusing to replace user-owned root key: ${key}`); } } } +function assertNoProviderConflict(content) { + const lines = content.replaceAll("\r\n", "\n").split("\n"); + assertNoProviderConflictOutsideBlock(lines, null); +} + function injectRoot(content, { port, catalogPath, routerToken }) { const lines = content.split("\n"); const insertAt = firstTableIndex(lines); - lines.splice(insertAt, 0, ...managedRootLines({ port, catalogPath, routerToken })); + const options = { port, catalogPath, routerToken }; + lines.splice(insertAt, 0, ...managedRootLines(options), ...managedProviderLines(options)); return lines.join("\n"); } function injectDesktopReasoning(content) { const lines = content.split("\n"); - const desktopStart = lines.findIndex((line) => /^\s*\[desktop\]\s*$/.test(line)); - if (desktopStart === -1) { + const declarations = tomlDeclarations(lines); + const desktop = declarations.find((declaration) => ( + declaration.kind === "table" + && declaration.path.length === 1 + && declaration.path[0] === "desktop" + )); + if (!desktop) { const suffix = content.endsWith("\n") ? "" : "\n"; return `${content}${suffix}\n[desktop]\n${MANAGED_MARKER}\n${DESKTOP_KEY} = ${REASONING_EFFORTS}\n`; } - let desktopEnd = lines.findIndex((line, index) => index > desktopStart && /^\s*\[/.test(line)); - if (desktopEnd === -1) desktopEnd = lines.length; - for (let index = desktopStart + 1; index < desktopEnd; index += 1) { - if (keyOf(lines[index]) !== DESKTOP_KEY) continue; - if (/\bmax\b/.test(lines[index])) return content; + const desktopEnd = declarations.find((declaration) => ( + declaration.kind === "table" && declaration.index > desktop.index + ))?.index ?? lines.length; + const existingEffort = declarations.find((declaration) => ( + declaration.kind === "key" + && declaration.tablePath.length === 1 + && declaration.tablePath[0] === "desktop" + && declaration.keyPath.length === 1 + && declaration.keyPath[0] === DESKTOP_KEY + )); + if (existingEffort) { + if (/\bmax\b/.test(lines[existingEffort.index])) return content; throw new Error(`Existing [desktop].${DESKTOP_KEY} does not expose max; update it manually`); } let insertAt = desktopEnd; - while (insertAt > desktopStart + 1 && lines[insertAt - 1].trim() === "") insertAt -= 1; + while (insertAt > desktop.index + 1 && lines[insertAt - 1].trim() === "") insertAt -= 1; lines.splice(insertAt, 0, MANAGED_MARKER, `${DESKTOP_KEY} = ${REASONING_EFFORTS}`); return lines.join("\n"); } @@ -215,6 +579,7 @@ function injectDesktopReasoning(content) { export function buildInstalledConfig(content, options) { const clean = stripManagedConfig(content); assertNoRootConflict(clean); + assertNoProviderConflict(clean); return injectDesktopReasoning(injectRoot(clean, options)); } @@ -260,9 +625,18 @@ export function ensureManagedRouterBinding({ paths, port }) { // the port and legacy pid state. `install` performs the same guard separately. assertNoActiveLegacyRouter(paths); const original = existsSync(paths.config) ? readFileSync(paths.config, "utf8") : ""; - if (!managedRootBlock(original)) { + const lines = original.replaceAll("\r\n", "\n").split("\n"); + const block = managedRootBlockInLines(lines); + if (!block) { + const unprovenCurrent = managedRootBlockInLines(lines, { allowCurrent: true }); + if (unprovenCurrent?.values.model_provider !== undefined) { + throw new Error("Refusing to replace user-owned root key: model_provider"); + } throw new Error("DSCodex managed router config is missing; run `node src/cli.mjs install`"); } + assertNoRootConflictOutsideBlock(block); + const provider = managedProviderBlockInLines(block.lines); + assertNoProviderConflictOutsideBlock(block.lines, provider); const routerToken = ensureRouterToken(paths.keyFile, readManagedRouterToken(original)); const options = { port, catalogPath: paths.catalog, routerToken }; const updated = !managedRouterConfigMatches(original, options); @@ -308,7 +682,34 @@ export function install({ paths, port }) { return { catalog, configPath: paths.config, catalogPath: paths.catalog, routerToken }; } +function assertUninstallConfigSafe(content) { + const lines = content.replaceAll("\r\n", "\n").split("\n"); + const currentRoot = managedRootBlockInLines(lines, { allowCurrent: true }); + if (!currentRoot || !Object.hasOwn(currentRoot.values, "model_provider")) return; + const provider = managedProviderBlockInLines(lines); + if (!provider) { + throw new Error( + "Refusing to uninstall while the managed DSCodex provider is customized; " + + "restore its exact managed shape or remove the DSCodex config manually", + ); + } + try { + assertNoProviderConflictOutsideBlock(lines, provider); + } catch { + throw new Error( + "Refusing to uninstall while the managed DSCodex provider is customized; " + + "restore its exact managed shape or remove the DSCodex config manually", + ); + } +} + +export function assertSafeToUninstall({ paths }) { + if (!existsSync(paths.config)) return; + assertUninstallConfigSafe(readFileSync(paths.config, "utf8")); +} + export function uninstall({ paths }) { + assertSafeToUninstall({ paths }); if (existsSync(paths.config)) { const current = readFileSync(paths.config, "utf8"); const stripped = stripManagedConfig(current); diff --git a/src/constants.mjs b/src/constants.mjs index b792fa1..fcc893f 100644 --- a/src/constants.mjs +++ b/src/constants.mjs @@ -20,6 +20,7 @@ export const DEEPSEEK_MODELS = Object.freeze([ ]); export const DEEPSEEK_BASE_URL = "https://api.deepseek.com"; export const CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"; +export const OPENAI_REALTIME_BASE_URL = "https://api.openai.com/v1"; export const MANAGED_MARKER = "# DSCodex managed; remove with `dscodex uninstall`"; export function deepSeekModelFor(model) { diff --git a/src/proxy.mjs b/src/proxy.mjs index 056e83c..6ef892c 100644 --- a/src/proxy.mjs +++ b/src/proxy.mjs @@ -1,4 +1,5 @@ import http from "node:http"; +import https from "node:https"; import { createCipheriv, createDecipheriv, @@ -11,6 +12,7 @@ import { Readable } from "node:stream"; import { CHATGPT_CODEX_BASE_URL, DEEPSEEK_BASE_URL, + OPENAI_REALTIME_BASE_URL, deepSeekModelFor, } from "./constants.mjs"; import { createVisionDescriber } from "./vision.mjs"; @@ -19,6 +21,7 @@ const CHATGPT_FORWARDED_REQUEST_HEADERS = new Set([ "authorization", "chatgpt-account-id", "openai-beta", + "openai-alpha", "originator", "session_id", "session-id", @@ -37,8 +40,11 @@ const CHATGPT_FORWARDED_REQUEST_HEADERS = new Set([ "x-openai-internal-codex-residency", "x-openai-internal-codex-responses-lite", "x-openai-memgen-request", + "x-openai-scoped-session-id", "x-openai-subagent", + "x-openai-thread-id", "x-responsesapi-include-timing-metrics", + "x-session-id", ]); const DEEPSEEK_FORWARDED_REQUEST_HEADERS = new Set(["user-agent"]); @@ -60,6 +66,10 @@ const DEFAULT_MAX_REQUEST_BYTES = 64 * 1024 * 1024; const DEFAULT_MAX_DECODED_BYTES = 128 * 1024 * 1024; const SHUTDOWN_HEADER = "x-dscodex-shutdown-token"; const SHUTDOWN_PATH = "/_dscodex/shutdown"; +const LIVE_PATH = "/v1/live"; +const LIVE_SIDEBAND_PATH = /^\/v1\/live\/([A-Za-z0-9][A-Za-z0-9_-]{0,255})$/; +const LEGACY_REALTIME_SIDEBAND_PATH = "/v1/realtime"; +const REALTIME_CALL_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,255}$/; const COMPACTION_PREFIX = "dscodex-compaction-v1:"; const COMPACTION_PROMPT = [ "Create a compact handoff summary of the conversation above for the next model turn.", @@ -400,6 +410,14 @@ function authorizedPath(pathname, routerToken) { return firstSlash === -1 ? "/" : pathname.slice(firstSlash) || "/"; } +function parseRequestUrl(request) { + try { + return new URL(request.url ?? "/", "http://127.0.0.1"); + } catch { + return null; + } +} + function copyRequestHeaders(request, deepSeekKey) { const headers = new Headers(); const forwarded = deepSeekKey @@ -415,12 +433,105 @@ function copyRequestHeaders(request, deepSeekKey) { return headers; } +function copyWebSocketRequestHeaders(request) { + const headers = {}; + for (const [name, value] of Object.entries(request.headers)) { + if (value === undefined) continue; + if ( + CHATGPT_FORWARDED_REQUEST_HEADERS.has(name) + || name === "origin" + || name.startsWith("sec-websocket-") + ) { + headers[name] = value; + } + } + headers.connection = "Upgrade"; + headers.upgrade = "websocket"; + return headers; +} + +// The /backend-api/realtime/calls endpoint sits behind stricter Cloudflare +// protection than /responses: it wants the desktop client's session cookies, +// integrity-state and DeviceCheck headers, not just the OAuth allowlist. +// Forward everything the client sent (minus hop-by-hop and the loopback Host). +function copyLiveRequestHeaders(request) { + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + if (value === undefined) continue; + if (name === "host" || name === "content-length" || HOP_BY_HOP_HEADERS.has(name)) continue; + headers.set(name, Array.isArray(value) ? value.join(", ") : value); + } + return headers; +} + +// The desktop client creates voice calls by POSTing a multipart body with an +// `sdp` part and a JSON `session` part. The official backend only accepts the +// JSON form ({ sdp, session }) at /backend-api/codex/realtime/calls, so parse +// the multipart and re-encode it before forwarding. +function buildLiveCallBody(raw, contentType) { + const match = /boundary=(?:"([^"]+)"|([^;,\s]+))/i.exec(contentType); + if (!match) { + const error = new Error("live call multipart is missing a boundary"); + error.statusCode = 400; + throw error; + } + const boundary = Buffer.from(`--${match[1] ?? match[2]}`); + const parts = []; + let start = raw.indexOf(boundary); + while (start !== -1) { + const markerEnd = start + boundary.length; + // A trailing "--" marks the closing boundary; the loop ends with it. + if (raw[markerEnd] === 0x2d && raw[markerEnd + 1] === 0x2d) break; + let headerStart = markerEnd; + if (raw[headerStart] === 0x0d && raw[headerStart + 1] === 0x0a) headerStart += 2; + const headerEnd = raw.indexOf("\r\n\r\n", headerStart); + if (headerEnd === -1) break; + const bodyStart = headerEnd + 4; + const nextBoundary = raw.indexOf(boundary, bodyStart); + const bodyEnd = Math.max(bodyStart, (nextBoundary === -1 ? raw.length : nextBoundary) - 2); + const headerText = raw.subarray(headerStart, headerEnd).toString("utf8"); + const nameMatch = /name="([^"]*)"/.exec(headerText); + if (nameMatch) parts.push({ name: nameMatch[1], body: raw.subarray(bodyStart, bodyEnd) }); + start = nextBoundary; + } + const sdp = parts.find((part) => part.name === "sdp"); + if (!sdp) { + const error = new Error("live call multipart is missing the sdp part"); + error.statusCode = 400; + throw error; + } + const call = { sdp: sdp.body.toString("utf8") }; + const session = parts.find((part) => part.name === "session"); + if (session) call.session = JSON.parse(session.body.toString("utf8")); + return Buffer.from(JSON.stringify(call)); +} + function copyResponseHeaders(upstream, response) { for (const [name, value] of upstream.headers) { if (!HOP_BY_HOP_HEADERS.has(name)) response.setHeader(name, value); } } +function writeRawResponseHead(socket, response, { closeDelimited = false } = {}) { + const statusMessage = response.statusMessage ? ` ${response.statusMessage}` : ""; + const lines = [`HTTP/1.1 ${response.statusCode}${statusMessage}`]; + const omitted = closeDelimited + ? new Set(["connection", "content-length", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]) + : null; + if (omitted) { + for (const token of String(response.headers.connection ?? "").split(",")) { + const name = token.trim().toLowerCase(); + if (name) omitted.add(name); + } + } + for (let index = 0; index < response.rawHeaders.length; index += 2) { + if (omitted?.has(response.rawHeaders[index].toLowerCase())) continue; + lines.push(`${response.rawHeaders[index]}: ${response.rawHeaders[index + 1]}`); + } + if (closeDelimited) lines.push("Connection: close"); + socket.write(`${lines.join("\r\n")}\r\n\r\n`); +} + function json(response, status, value) { const body = Buffer.from(`${JSON.stringify(value)}\n`); response.writeHead(status, { @@ -438,6 +549,7 @@ export function createProxyServer({ deepSeekKey = process.env.DEEPSEEK_API_KEY, deepSeekBaseUrl = DEEPSEEK_BASE_URL, chatGptBaseUrl = CHATGPT_CODEX_BASE_URL, + realtimeApiBaseUrl = OPENAI_REALTIME_BASE_URL, models = [], logger = console, visionModel, @@ -453,7 +565,12 @@ export function createProxyServer({ const vision = createVisionDescriber({ baseUrl: chatGptBaseUrl, model: visionModel, logger }); const server = http.createServer(async (request, response) => { const startedAt = Date.now(); - const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const url = parseRequestUrl(request); + if (!url) { + request.resume(); + json(response, 400, { error: { message: "Invalid request target" } }); + return; + } const pathname = authorizedPath(url.pathname, routerToken); if (!pathname) { json(response, 404, { error: { message: "Not found" } }); @@ -497,44 +614,83 @@ export function createProxyServer({ let direction = "unknown"; try { const raw = await readRequestBody(request, maxRequestBytes); - let decoded; - try { - decoded = decodeBody(raw, request.headers["content-encoding"], maxDecodedBytes); - } catch (error) { - if (error?.code === "ERR_BUFFER_TOO_LARGE") error.statusCode = 413; - throw error; - } - if (decoded.length > maxDecodedBytes) { - const error = new Error("Decoded request body exceeds the configured limit"); - error.statusCode = 413; - throw error; - } - const parsed = JSON.parse(decoded.toString("utf8")); - const deepSeekModel = deepSeekModelFor(parsed.model); - const deepSeek = Boolean(deepSeekModel); - const compactionRequest = deepSeek && isCompactionRequest(parsed); - direction = compactionRequest ? "deepseek-compaction" : deepSeek ? "deepseek" : "chatgpt"; - if (deepSeek && !deepSeekKey) { - json(response, 503, { error: { message: "DEEPSEEK_API_KEY is not configured in the DSCodex server process" } }); - return; - } + let deepSeek = false; + let deepSeekModel = null; + let compactionRequest = false; let outgoingBody = raw; - if (deepSeek) { - const body = compactionRequest - ? buildDeepSeekCompactionBody(parsed, routerToken) - : buildDeepSeekBody(parsed, { compactionSecret: routerToken }); - // DeepSeek V4 is text-only: borrow the caller's GPT OAuth to describe any - // attached images, then inject the descriptions as plain input_text. - const rewritten = await vision.rewriteImages(body, request.headers); - if (rewritten) logger.info?.(`vision: described ${rewritten} image(s) for ${pathname}`); - outgoingBody = Buffer.from(JSON.stringify(body)); + let liveCallJson = false; + if (pathname === LIVE_PATH) { + // Voice/Realtime calls arrive as multipart (sdp + session); the official + // backend only accepts the JSON form, so convert it before forwarding. + // Non-multipart bodies (e.g. probes) are passed through unchanged. + direction = "chatgpt-live"; + const contentType = request.headers["content-type"] ?? ""; + if (contentType.toLowerCase().startsWith("multipart/form-data")) { + outgoingBody = buildLiveCallBody(raw, contentType); + liveCallJson = true; + } + } else { + let decoded; + try { + decoded = decodeBody(raw, request.headers["content-encoding"], maxDecodedBytes); + } catch (error) { + if (error?.code === "ERR_BUFFER_TOO_LARGE") error.statusCode = 413; + throw error; + } + if (decoded.length > maxDecodedBytes) { + const error = new Error("Decoded request body exceeds the configured limit"); + error.statusCode = 413; + throw error; + } + const parsed = JSON.parse(decoded.toString("utf8")); + deepSeekModel = deepSeekModelFor(parsed.model); + deepSeek = Boolean(deepSeekModel); + compactionRequest = deepSeek && isCompactionRequest(parsed); + direction = compactionRequest ? "deepseek-compaction" : deepSeek ? "deepseek" : "chatgpt"; + if (deepSeek && !deepSeekKey) { + json(response, 503, { error: { message: "DEEPSEEK_API_KEY is not configured in the DSCodex server process" } }); + return; + } + if (deepSeek) { + const body = compactionRequest + ? buildDeepSeekCompactionBody(parsed, routerToken) + : buildDeepSeekBody(parsed, { compactionSecret: routerToken }); + // DeepSeek V4 is text-only: borrow the caller's GPT OAuth to describe any + // attached images, then inject the descriptions as plain input_text. + const rewritten = await vision.rewriteImages(body, request.headers); + if (rewritten) logger.info?.(`vision: described ${rewritten} image(s) for ${pathname}`); + outgoingBody = Buffer.from(JSON.stringify(body)); + } } const baseUrl = deepSeek ? deepSeekBaseUrl : chatGptBaseUrl; - const target = new URL(`${baseUrl.replace(/\/$/, "")}${upstreamPath(pathname)}${url.search}`); - const headers = copyRequestHeaders(request, deepSeek ? deepSeekKey : undefined); + const isLiveCall = pathname === LIVE_PATH; + // The desktop app's voice mode creates WebRTC calls on the official + // realtime/calls route; no other candidate is valid. The official + // client marks these calls with the AVAS query params, and the + // backend gate reads the same values from the OpenAI-Alpha header. + const liveCallBase = baseUrl.replace(/\/codex\/?$/, "").replace(/\/$/, ""); + const target = new URL( + `${isLiveCall ? `${liveCallBase}/codex/realtime/calls` : `${baseUrl.replace(/\/$/, "")}${upstreamPath(pathname)}`}${url.search}`, + ); + if (isLiveCall) { + target.searchParams.set("intent", "quicksilver"); + target.searchParams.set("architecture", "avas"); + } + const headers = isLiveCall + ? copyLiveRequestHeaders(request) + : copyRequestHeaders(request, deepSeek ? deepSeekKey : undefined); + if (isLiveCall) { + // The desktop client does not send this header through the router; + // the backend gate requires it to name the quicksilver protocol + // version; the AVAS call architecture requires v2. + headers.set("openai-alpha", "quicksilver=v2"); + } if (!deepSeek && request.headers["content-encoding"]) { headers.set("content-encoding", request.headers["content-encoding"]); } + if (isLiveCall && request.headers["content-type"]) { + headers.set("content-type", liveCallJson ? "application/json" : request.headers["content-type"]); + } headers.set("content-length", String(outgoingBody.length)); const controller = new AbortController(); @@ -543,6 +699,9 @@ export function createProxyServer({ response.on("close", () => { if (!response.writableFinished) controller.abort(); }); + if (isLiveCall) { + logger.info?.(`live ${pathname} -> ${target.host}${target.pathname}`); + } const upstream = await fetch(target, { method: "POST", headers, @@ -596,12 +755,95 @@ export function createProxyServer({ // headersTimeout must stay above keepAliveTimeout. server.keepAliveTimeout = 120_000; server.headersTimeout = 125_000; - server.on("upgrade", (_request, socket) => { + const upgradeSockets = new Set(); + server.closeUpgradeConnections = () => { + for (const socket of upgradeSockets) socket.destroy(); + }; + server.on("upgrade", (request, socket, clientHead) => { // Handling `upgrade` detaches the socket from the server's own error // handling, so an ECONNRESET here raised an unhandled 'error' event and // killed the whole router — Codex then sat in "reconnecting" forever. socket.on("error", () => {}); - socket.end("HTTP/1.1 426 Upgrade Required\r\nConnection: close\r\n\r\n"); + upgradeSockets.add(socket); + socket.once("close", () => upgradeSockets.delete(socket)); + const url = parseRequestUrl(request); + if (!url) { + logger.info?.("upgrade -> 400"); + socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); + return; + } + const pathname = authorizedPath(url.pathname, routerToken); + if (!pathname) { + logger.info?.("upgrade -> 404"); + socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n"); + return; + } + const liveSideband = LIVE_SIDEBAND_PATH.exec(pathname); + const legacyCallIds = pathname === LEGACY_REALTIME_SIDEBAND_PATH + ? url.searchParams.getAll("call_id") + : []; + const legacySideband = legacyCallIds.length === 1 && REALTIME_CALL_ID.test(legacyCallIds[0]); + if (pathname !== "/v1/responses" && !liveSideband && !legacySideband) { + const rejectedRouteLabel = pathname.startsWith(`${LIVE_PATH}/`) + ? "/v1/live/" + : pathname; + logger.info?.(`upgrade ${rejectedRouteLabel} -> 426`); + socket.end("HTTP/1.1 426 Upgrade Required\r\nConnection: close\r\n\r\n"); + return; + } + + const routeLabel = liveSideband ? "/v1/live/" : pathname; + const target = new URL(liveSideband + ? `${realtimeApiBaseUrl.replace(/\/$/, "")}/live/${liveSideband[1]}${url.search}` + : legacySideband + ? `${realtimeApiBaseUrl.replace(/\/$/, "")}/realtime${url.search}` + : `${chatGptBaseUrl.replace(/\/$/, "")}${upstreamPath(pathname)}${url.search}`); + const transport = target.protocol === "https:" ? https : target.protocol === "http:" ? http : null; + if (!transport) { + logger.error?.(`websocket proxy error (${routeLabel}): unsupported upstream protocol ${target.protocol}`); + socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); + return; + } + + const targetPathLabel = liveSideband ? "/v1/live/" : target.pathname; + logger.info?.(`upgrade ${routeLabel} -> ${target.host}${targetPathLabel}`); + let upstreamSocket; + const upstreamRequest = transport.request(target, { + method: "GET", + headers: copyWebSocketRequestHeaders(request), + }); + upstreamRequest.on("upgrade", (upstreamResponse, upgradedSocket, upstreamHead) => { + upstreamSocket = upgradedSocket; + upgradedSocket.on("error", () => socket.destroy()); + logger.info?.(`upgrade ${routeLabel} -> ${upstreamResponse.statusCode}`); + writeRawResponseHead(socket, upstreamResponse); + if (clientHead.length > 0) upgradedSocket.write(clientHead); + if (upstreamHead.length > 0) socket.write(upstreamHead); + socket.pipe(upgradedSocket); + upgradedSocket.pipe(socket); + }); + upstreamRequest.on("response", (upstreamResponse) => { + logger.info?.(`upgrade ${routeLabel} -> ${upstreamResponse.statusCode}`); + // IncomingMessage has already removed HTTP chunk frames. Do not copy + // its original framing headers onto the decoded stream; delimit the + // rejection body by closing the client connection instead. + writeRawResponseHead(socket, upstreamResponse, { closeDelimited: true }); + const closeTruncatedClient = () => socket.destroy(); + upstreamResponse.once("aborted", closeTruncatedClient); + upstreamResponse.once("error", closeTruncatedClient); + upstreamResponse.pipe(socket); + }); + upstreamRequest.on("error", (error) => { + logger.error?.(`websocket proxy error (${routeLabel}): ${error instanceof Error ? error.message : String(error)}`); + if (!socket.destroyed) { + socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); + } + }); + socket.once("close", () => { + if (upstreamSocket) upstreamSocket.destroy(); + else upstreamRequest.destroy(); + }); + upstreamRequest.end(); }); return server; } diff --git a/test/config.test.mjs b/test/config.test.mjs index 79073de..f3829bd 100644 --- a/test/config.test.mjs +++ b/test/config.test.mjs @@ -15,7 +15,7 @@ import { uninstall, } from "../src/config.mjs"; import { pathsFor, VERSION } from "../src/constants.mjs"; -import { readRouterToken, readStoredKey } from "../src/keys.mjs"; +import { readRouterToken, readStoredKey, writeRouterToken } from "../src/keys.mjs"; const TEMPLATE = { slug: "gpt-5.6-sol", @@ -84,6 +84,53 @@ test("config injection is root-correct, reversible, and preserves user config", assert.equal(stripManagedConfig(installed), original); }); +test("managed config routes Realtime sideband through the authenticated loopback router", () => { + const original = "[features]\nrealtime_conversation = true\n\n[desktop]\ntheme = \"light\"\n"; + const installed = buildInstalledConfig(original, { + port: 10110, + catalogPath: "/tmp/models.json", + routerToken: ROUTER_TOKEN, + }); + + assert.match( + installed, + new RegExp(`experimental_realtime_ws_base_url = "http://127\\.0\\.0\\.1:10110/${ROUTER_TOKEN}/v1/realtime"`), + ); + assert.equal(stripManagedConfig(installed), original); +}); + +test("managed config selects an authenticated HTTP-only DSCodex provider", () => { + const original = [ + 'personality = "pragmatic"', + "", + "[model_providers.company]", + 'name = "Company Gateway"', + 'base_url = "https://models.example/v1"', + "", + "[desktop]", + 'theme = "light"', + "", + ].join("\n"); + const installed = buildInstalledConfig(original, { + port: 10110, + catalogPath: "/tmp/models.json", + routerToken: ROUTER_TOKEN, + }); + + assert.ok(installed.indexOf('model_provider = "dscodex"') < installed.indexOf("[model_providers.company]")); + assert.match(installed, /\[model_providers\.dscodex\]/); + assert.match(installed, /name = "DSCodex"/); + assert.match( + installed, + new RegExp(`base_url = "http://127\\.0\\.0\\.1:10110/${ROUTER_TOKEN}/v1"`), + ); + assert.match(installed, /wire_api = "responses"/); + assert.match(installed, /requires_openai_auth = true/); + assert.match(installed, /supports_websockets = false/); + assert.match(installed, /\[model_providers\.company\]\nname = "Company Gateway"/); + assert.equal(stripManagedConfig(installed), original); +}); + test("managed router binding upgrades a legacy URL and preserves user config", () => { const codexHome = mkdtempSync(join(tmpdir(), "dscodex-binding-")); const paths = pathsFor(codexHome); @@ -113,6 +160,165 @@ test("managed router binding upgrades a legacy URL and preserves user config", ( assert.match(updated, /\[features\]\nmulti_agent = true/); }); +test("managed router binding upgrades the Realtime-era block and preserves another provider", () => { + const codexHome = mkdtempSync(join(tmpdir(), "dscodex-binding-provider-upgrade-")); + const paths = pathsFor(codexHome); + const oldToken = "B".repeat(43); + const original = [ + 'personality = "pragmatic"', + "# DSCodex managed; remove with `dscodex uninstall`", + `openai_base_url = "http://127.0.0.1:10001/${oldToken}/v1"`, + `experimental_realtime_ws_base_url = "http://127.0.0.1:10001/${oldToken}/v1/realtime"`, + `model_catalog_json = ${JSON.stringify(paths.catalog)}`, + "", + "[model_providers.company]", + 'name = "Company Gateway"', + 'base_url = "https://models.example/v1"', + "", + "[desktop]", + 'theme = "light"', + "", + ].join("\n"); + writeFileSync(paths.config, original); + + const result = ensureManagedRouterBinding({ paths, port: 10110 }); + const updated = readFileSync(paths.config, "utf8"); + + assert.equal(result.updated, true); + assert.equal(result.routerToken, oldToken); + assert.match(updated, /model_provider = "dscodex"/); + assert.match(updated, /\[model_providers\.dscodex\]/); + assert.match( + updated, + new RegExp(`base_url = "http://127\\.0\\.0\\.1:10110/${oldToken}/v1"`), + ); + assert.match(updated, /\[model_providers\.company\]\nname = "Company Gateway"/); + assert.equal(stripManagedConfig(updated), [ + 'personality = "pragmatic"', + "", + "[model_providers.company]", + 'name = "Company Gateway"', + 'base_url = "https://models.example/v1"', + "", + "[desktop]", + 'theme = "light"', + "", + ].join("\n")); +}); + +test("managed router binding refuses a user-owned DSCodex provider before mutation", () => { + const codexHome = mkdtempSync(join(tmpdir(), "dscodex-binding-user-provider-")); + const paths = pathsFor(codexHome); + const original = [ + "# DSCodex managed; remove with `dscodex uninstall`", + 'openai_base_url = "http://127.0.0.1:10110/v1"', + `model_catalog_json = ${JSON.stringify(paths.catalog)}`, + "", + "[model_providers.dscodex]", + 'name = "User-owned provider"', + 'base_url = "https://user.example/v1"', + "", + ].join("\n"); + writeFileSync(paths.config, original); + + assert.throws( + () => ensureManagedRouterBinding({ paths, port: 10110 }), + /Refusing to replace user-owned provider: model_providers\.dscodex/, + ); + assert.equal(readFileSync(paths.config, "utf8"), original); + assert.equal(existsSync(paths.keyFile), false); +}); + +test("managed router binding rejects a quoted DSCodex provider table before mutation", () => { + const codexHome = mkdtempSync(join(tmpdir(), "dscodex-binding-quoted-provider-")); + const paths = pathsFor(codexHome); + const original = [ + "# DSCodex managed; remove with `dscodex uninstall`", + 'openai_base_url = "http://127.0.0.1:10110/v1"', + `model_catalog_json = ${JSON.stringify(paths.catalog)}`, + "", + '["model_providers".dscodex]', + 'name = "User-owned provider"', + 'base_url = "https://user.example/v1"', + "", + ].join("\n"); + writeFileSync(paths.config, original); + + assert.throws( + () => ensureManagedRouterBinding({ paths, port: 10110 }), + /Refusing to replace user-owned provider: model_providers\.dscodex/, + ); + assert.equal(readFileSync(paths.config, "utf8"), original); + assert.equal(existsSync(paths.keyFile), false); +}); + +test("refuses to replace a user-owned model_provider", () => { + assert.throws( + () => buildInstalledConfig('model_provider = "company"\n', { + port: 10110, + catalogPath: "/tmp/models.json", + routerToken: ROUTER_TOKEN, + }), + /Refusing to replace user-owned root key: model_provider/, + ); +}); + +test("legacy migration does not claim a model_provider inserted inside the old managed span", () => { + const codexHome = mkdtempSync(join(tmpdir(), "dscodex-binding-interleaved-provider-")); + const paths = pathsFor(codexHome); + const original = [ + "# DSCodex managed; remove with `dscodex uninstall`", + 'openai_base_url = "http://127.0.0.1:10110/v1"', + 'experimental_realtime_ws_base_url = "wss://router.example/v1/realtime"', + 'model_provider = "company"', + `model_catalog_json = ${JSON.stringify(paths.catalog)}`, + "", + "[model_providers.company]", + 'name = "Company Gateway"', + 'base_url = "https://models.example/v1"', + "", + ].join("\n"); + writeFileSync(paths.config, original); + + assert.throws( + () => ensureManagedRouterBinding({ paths, port: 10110 }), + /Refusing to replace user-owned root key: model_provider/, + ); + assert.equal(readFileSync(paths.config, "utf8"), original); + assert.equal(existsSync(paths.keyFile), false); +}); + +for (const placement of ["before", "after"]) { + test(`managed router binding refuses a user-owned Realtime URL ${placement} a legacy managed block`, () => { + const codexHome = mkdtempSync(join(tmpdir(), `dscodex-binding-user-realtime-${placement}-`)); + const paths = pathsFor(codexHome); + const managed = [ + "# DSCodex managed; remove with `dscodex uninstall`", + 'openai_base_url = "http://127.0.0.1:10110/v1"', + `model_catalog_json = ${JSON.stringify(paths.catalog)}`, + ]; + const userOwned = 'experimental_realtime_ws_base_url = "wss://user.example/v1/realtime"'; + const original = [ + ...(placement === "before" ? [userOwned] : []), + ...managed, + ...(placement === "after" ? [userOwned] : []), + "", + "[features]", + "multi_agent = true", + "", + ].join("\n"); + writeFileSync(paths.config, original); + + assert.throws( + () => ensureManagedRouterBinding({ paths, port: 10110 }), + /Refusing to replace user-owned root key: experimental_realtime_ws_base_url/, + ); + assert.equal(readFileSync(paths.config, "utf8"), original); + assert.equal(existsSync(paths.keyFile), false); + assert.match(stripManagedConfig(original), /experimental_realtime_ws_base_url = "wss:\/\/user\.example\/v1\/realtime"/); + }); +} + test("managed router binding refuses a running legacy router before mutation", () => { const codexHome = mkdtempSync(join(tmpdir(), "dscodex-binding-legacy-router-")); const paths = pathsFor(codexHome); @@ -149,6 +355,54 @@ test("managed router binding adopts the installed URL token when state is missin assert.equal(readRouterToken(paths.keyFile), ROUTER_TOKEN); }); +test("managed router binding updates both managed URLs when the port changes", () => { + const codexHome = mkdtempSync(join(tmpdir(), "dscodex-binding-port-")); + const paths = pathsFor(codexHome); + const userConfig = '[desktop]\ntheme = "light"\n'; + writeFileSync(paths.config, buildInstalledConfig(userConfig, { + port: 10001, + catalogPath: paths.catalog, + routerToken: ROUTER_TOKEN, + })); + + const result = ensureManagedRouterBinding({ paths, port: 10110 }); + const updated = readFileSync(paths.config, "utf8"); + + assert.equal(result.updated, true); + assert.equal(updated.includes("127.0.0.1:10001"), false); + assert.equal(managedRouterConfigMatches(updated, { + port: 10110, + catalogPath: paths.catalog, + routerToken: ROUTER_TOKEN, + }), true); + assert.equal(stripManagedConfig(updated), userConfig); +}); + +test("managed router binding updates every managed URL when the persisted token changes", () => { + const codexHome = mkdtempSync(join(tmpdir(), "dscodex-binding-token-")); + const paths = pathsFor(codexHome); + const replacementToken = "B".repeat(43); + const userConfig = '[desktop]\ntheme = "light"\n'; + writeFileSync(paths.config, buildInstalledConfig(userConfig, { + port: 10110, + catalogPath: paths.catalog, + routerToken: ROUTER_TOKEN, + })); + writeRouterToken(paths.keyFile, replacementToken); + + const result = ensureManagedRouterBinding({ paths, port: 10110 }); + const updated = readFileSync(paths.config, "utf8"); + + assert.equal(result.updated, true); + assert.equal(updated.includes(ROUTER_TOKEN), false); + assert.equal(managedRouterConfigMatches(updated, { + port: 10110, + catalogPath: paths.catalog, + routerToken: replacementToken, + }), true); + assert.equal(stripManagedConfig(updated), userConfig); +}); + test("install and uninstall touch only DSCodex-owned files and lines", () => { const codexHome = mkdtempSync(join(tmpdir(), "dscodex-test-")); const paths = pathsFor(codexHome); @@ -176,6 +430,185 @@ test("install and uninstall touch only DSCodex-owned files and lines", () => { assert.equal(readFileSync(paths.backup, "utf8"), original); }); +test("uninstall removes only the managed DSCodex provider", () => { + const codexHome = mkdtempSync(join(tmpdir(), "dscodex-uninstall-provider-")); + const paths = pathsFor(codexHome); + const original = [ + 'personality = "pragmatic"', + "", + "[model_providers.company]", + 'name = "Company Gateway"', + 'base_url = "https://models.example/v1"', + "", + "[desktop]", + 'theme = "light"', + "", + ].join("\n"); + writeFileSync(paths.config, buildInstalledConfig(original, { + port: 10110, + catalogPath: paths.catalog, + routerToken: ROUTER_TOKEN, + })); + + uninstall({ paths }); + + assert.equal(readFileSync(paths.config, "utf8"), original); +}); + +test("uninstall is transactional when the managed provider was customized", () => { + const codexHome = mkdtempSync(join(tmpdir(), "dscodex-uninstall-customized-provider-")); + const paths = pathsFor(codexHome); + const original = '[desktop]\ntheme = "light"\n'; + writeFileSync(paths.config, original); + writeFileSync(paths.cache, JSON.stringify({ models: [TEMPLATE] })); + install({ paths, port: 10110 }); + const customized = readFileSync(paths.config, "utf8").replace( + "supports_websockets = false\n", + "supports_websockets = false\nrequest_max_retries = 2\n", + ); + writeFileSync(paths.config, customized); + mkdirSync(paths.stateDir, { recursive: true }); + writeFileSync(paths.selectionState, "{}\n"); + const keyBefore = readFileSync(paths.keyFile, "utf8"); + const catalogBefore = readFileSync(paths.catalog, "utf8"); + + assert.throws( + () => uninstall({ paths }), + /Refusing to uninstall while the managed DSCodex provider is customized/, + ); + assert.equal(readFileSync(paths.config, "utf8"), customized); + assert.equal(readFileSync(paths.keyFile, "utf8"), keyBefore); + assert.equal(readFileSync(paths.catalog, "utf8"), catalogBefore); + assert.equal(existsSync(paths.selectionState), true); +}); + +for (const [description, original] of [ + ["quoted root model_provider", '"model_provider" = "company"\n'], + ["quoted DSCodex provider table", '["model_providers".dscodex]\nname = "User"\n'], + ["dotted DSCodex provider assignment", 'model_providers.dscodex = { name = "User" }\n'], + ["inline model_providers table", 'model_providers = { company = { name = "User" } }\n'], + ["parent-table DSCodex assignment", '[model_providers]\n"dscodex" = { name = "User" }\n'], +]) { + test(`install rejects ${description} without publishing state`, () => { + const codexHome = mkdtempSync(join(tmpdir(), "dscodex-install-toml-conflict-")); + const paths = pathsFor(codexHome); + writeFileSync(paths.config, original); + writeFileSync(paths.cache, JSON.stringify({ models: [TEMPLATE] })); + + assert.throws( + () => install({ paths, port: 10110 }), + /Refusing to replace user-owned (?:root key|provider)/, + ); + assert.equal(readFileSync(paths.config, "utf8"), original); + assert.equal(existsSync(paths.keyFile), false); + assert.equal(existsSync(paths.catalog), false); + assert.equal(existsSync(paths.backup), false); + }); +} + +test("TOML ownership ignores key and table lookalikes inside multiline strings", () => { + const original = [ + 'developer_instructions = """', + 'model_provider = "this is documentation, not a key"', + "[model_providers.dscodex]", + 'name = "also documentation"', + "[desktop]", + 'enabled-reasoning-efforts = ["not", "configuration"]', + '"""', + "", + "[desktop]", + 'theme = "light"', + "", + ].join("\n"); + + const installed = buildInstalledConfig(original, { + port: 10110, + catalogPath: "/tmp/models.json", + routerToken: ROUTER_TOKEN, + }); + + assert.equal(stripManagedConfig(installed), original); +}); + +for (const delimiter of ['"""', "'''"]) { + test(`managed-block removal ignores exact snippets inside ${delimiter} strings`, () => { + const original = [ + `developer_instructions = ${delimiter}`, + "# DSCodex managed; remove with `dscodex uninstall`", + 'openai_base_url = "http://127.0.0.1:10110/not-config/v1"', + 'experimental_realtime_ws_base_url = "http://127.0.0.1:10110/not-config/v1/realtime"', + 'model_provider = "dscodex"', + 'model_catalog_json = "/not/config.json"', + "# DSCodex managed; remove with `dscodex uninstall`", + "[model_providers.dscodex]", + 'name = "DSCodex"', + 'base_url = "http://127.0.0.1:10110/not-config/v1"', + 'wire_api = "responses"', + "requires_openai_auth = true", + "supports_websockets = false", + "[desktop]", + "# DSCodex managed; remove with `dscodex uninstall`", + 'enabled-reasoning-efforts = ["low", "max"]', + delimiter, + "", + "[desktop]", + 'theme = "light"', + "", + ].join("\n"); + + assert.equal(stripManagedConfig(original), original); + const installed = buildInstalledConfig(original, { + port: 10110, + catalogPath: "/tmp/models.json", + routerToken: ROUTER_TOKEN, + }); + assert.equal(stripManagedConfig(installed), original); + }); +} + +test("TOML ownership ignores table lookalikes inside multiline arrays", () => { + const original = [ + "allowed_values = [", + " [true],", + ' ["desktop"]', + "]", + 'personality = "pragmatic"', + "", + "[desktop]", + 'theme = "light"', + "", + ].join("\n"); + + const installed = buildInstalledConfig(original, { + port: 10110, + catalogPath: "/tmp/models.json", + routerToken: ROUTER_TOKEN, + }); + + assert.ok(installed.indexOf("openai_base_url") > installed.indexOf('\n]\npersonality = "pragmatic"')); + assert.equal(stripManagedConfig(installed), original); +}); + +test("root conflicts after multiline arrays still refuse before installation", () => { + const original = [ + "allowed_values = [", + " [true],", + ' ["desktop"]', + "]", + '"model_provider" = "company"', + "", + ].join("\n"); + + assert.throws( + () => buildInstalledConfig(original, { + port: 10110, + catalogPath: "/tmp/models.json", + routerToken: ROUTER_TOKEN, + }), + /Refusing to replace user-owned root key: model_provider/, + ); +}); + test("refuses to replace a user-owned openai_base_url", () => { assert.throws( () => buildInstalledConfig('openai_base_url = "https://example.test/v1"\n', { port: 10110, catalogPath: "/tmp/models.json", routerToken: ROUTER_TOKEN }), diff --git a/test/proxy-live.test.mjs b/test/proxy-live.test.mjs new file mode 100644 index 0000000..1862172 --- /dev/null +++ b/test/proxy-live.test.mjs @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import http from "node:http"; +import test from "node:test"; +import { once } from "node:events"; +import { createProxyServer } from "../src/proxy.mjs"; + +const ROUTER_TOKEN = "A".repeat(43); + +function route(proxyUrl, path = "/v1/responses") { + return `${proxyUrl}/${ROUTER_TOKEN}${path}`; +} + +async function listen(server, host = "127.0.0.1") { + server.listen(0, host); + await once(server, "listening"); + const address = server.address(); + return `http://127.0.0.1:${address.port}`; +} + +async function close(server) { + const closed = once(server, "close"); + server.close(); + server.closeAllConnections?.(); + await closed; +} + +test("forwards POST /v1/live bodies to the upstream without JSON parsing", async (t) => { + let observed; + const upstream = http.createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + observed = { + path: request.url, + contentType: request.headers["content-type"], + openaiAlpha: request.headers["openai-alpha"], + authorization: request.headers.authorization, + cookie: request.headers.cookie, + custom: request.headers["x-codex-test-header"], + userAgent: request.headers["user-agent"], + body: Buffer.concat(chunks), + }; + response.writeHead(200, { "content-type": "application/json" }); + response.end('{"ok":true}'); + }); + const upstreamUrl = await listen(upstream); + const proxy = createProxyServer({ + chatGptBaseUrl: `${upstreamUrl}/backend-api/codex`, + logger: { info() {}, error() {} }, + routerToken: ROUTER_TOKEN, + }); + const proxyUrl = await listen(proxy); + t.after(async () => { + await close(proxy); + await close(upstream); + }); + + const payload = Buffer.from([0x2d, 0x00, 0x01, 0xff]); + const response = await fetch(route(proxyUrl, "/v1/live"), { + method: "POST", + headers: { + "content-type": "application/octet-stream", + authorization: "Bearer oauth-token", + cookie: "session=abc", + "x-codex-test-header": "custom-value", + "user-agent": "codex-test", + }, + body: payload, + }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true }); + assert.equal(observed.path, "/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas"); + assert.equal(observed.openaiAlpha, "quicksilver=v2"); + assert.equal(observed.contentType, "application/octet-stream"); + assert.equal(observed.authorization, "Bearer oauth-token"); + assert.equal(observed.cookie, "session=abc"); + assert.equal(observed.custom, "custom-value"); + assert.equal(observed.userAgent, "codex-test"); + assert.deepEqual(observed.body, payload); +}); + +test("posts live calls only to the official realtime/calls route without probing candidates", async (t) => { + const observed = []; + const upstream = http.createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + observed.push({ path: request.url, body: Buffer.concat(chunks) }); + response.writeHead(404, { "content-type": "application/json" }); + response.end('{"detail":"Not Found"}'); + }); + const upstreamUrl = await listen(upstream); + const proxy = createProxyServer({ + chatGptBaseUrl: `${upstreamUrl}/backend-api/codex`, + logger: { info() {}, error() {} }, + routerToken: ROUTER_TOKEN, + }); + const proxyUrl = await listen(proxy); + t.after(async () => { + await close(proxy); + await close(upstream); + }); + + const payload = Buffer.from([0x2d, 0x00, 0x01, 0xff]); + const response = await fetch(route(proxyUrl, "/v1/live"), { + method: "POST", + headers: { "content-type": "application/octet-stream" }, + body: payload, + }); + + assert.equal(response.status, 404); + assert.equal(observed.length, 1); + assert.equal(observed[0].path, "/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas"); + assert.deepEqual(observed[0].body, payload); +}); + +test("converts the live call multipart into the official realtime/calls JSON", async (t) => { + let observed; + const upstream = http.createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + observed = { + path: request.url, + contentType: request.headers["content-type"], + openaiAlpha: request.headers["openai-alpha"], + body: JSON.parse(Buffer.concat(chunks).toString("utf8")), + }; + response.writeHead(200, { + "content-type": "application/sdp", + location: "/v1/realtime/calls/call_test", + }); + response.end("v=0\r\nanswer-sdp"); + }); + const upstreamUrl = await listen(upstream); + const proxy = createProxyServer({ + chatGptBaseUrl: `${upstreamUrl}/backend-api/codex`, + logger: { info() {}, error() {} }, + routerToken: ROUTER_TOKEN, + }); + const proxyUrl = await listen(proxy); + t.after(async () => { + await close(proxy); + await close(upstream); + }); + + const sdp = "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=test"; + const session = { model: "gpt-5.6", voice: "alloy" }; + const multipart = [ + "--codex-realtime-call-boundary", + 'Content-Disposition: form-data; name="sdp"', + "Content-Type: application/sdp", + "", + sdp, + "--codex-realtime-call-boundary", + 'Content-Disposition: form-data; name="session"', + "Content-Type: application/json", + "", + JSON.stringify(session), + "--codex-realtime-call-boundary--", + "", + ].join("\r\n"); + + const response = await fetch(route(proxyUrl, "/v1/live"), { + method: "POST", + headers: { "content-type": "multipart/form-data; boundary=codex-realtime-call-boundary", authorization: "Bearer oauth-token" }, + body: multipart, + }); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("content-type"), "application/sdp"); + assert.equal(await response.text(), "v=0\r\nanswer-sdp"); + assert.equal(observed.path, "/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas"); + assert.equal(observed.openaiAlpha, "quicksilver=v2"); + assert.equal(observed.contentType, "application/json"); + assert.equal(observed.body.sdp, sdp); + assert.deepEqual(observed.body.session, session); +}); diff --git a/test/proxy.test.mjs b/test/proxy.test.mjs index a3b2af8..bd88969 100644 --- a/test/proxy.test.mjs +++ b/test/proxy.test.mjs @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { generateKeyPairSync, sign } from "node:crypto"; import http from "node:http"; +import https from "node:https"; import net from "node:net"; import test from "node:test"; import { once } from "node:events"; @@ -30,6 +33,129 @@ async function bodyOf(request) { return Buffer.concat(chunks).toString("utf8"); } +function readSocketUntil(socket, needle, timeoutMs = 1_000) { + return new Promise((resolve, reject) => { + let received = ""; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timed out waiting for socket data: ${needle}`)); + }, timeoutMs); + const cleanup = () => { + clearTimeout(timer); + socket.off("data", onData); + socket.off("end", onEnd); + socket.off("error", onError); + }; + const onData = (chunk) => { + received += chunk.toString("utf8"); + if (!received.includes(needle)) return; + cleanup(); + resolve(received); + }; + const onEnd = () => { + cleanup(); + resolve(received); + }; + const onError = (error) => { + cleanup(); + reject(error); + }; + socket.on("data", onData); + socket.on("end", onEnd); + socket.on("error", onError); + }); +} + +function derLength(length) { + if (length < 128) return Buffer.from([length]); + const bytes = []; + for (let remaining = length; remaining > 0; remaining = Math.floor(remaining / 256)) { + bytes.unshift(remaining & 0xff); + } + return Buffer.from([0x80 | bytes.length, ...bytes]); +} + +function derElement(tag, content) { + return Buffer.concat([Buffer.from([tag]), derLength(content.length), content]); +} + +function derSequence(...parts) { + return derElement(0x30, Buffer.concat(parts)); +} + +function derInteger(value) { + let hex = value.toString(16); + if (hex.length % 2 === 1) hex = `0${hex}`; + const bytes = Buffer.from(hex, "hex"); + const padded = bytes[0] & 0x80 ? Buffer.concat([Buffer.from([0]), bytes]) : bytes; + return derElement(0x02, padded); +} + +function derOid(oid) { + const numbers = oid.split(".").map(Number); + const encoded = [40 * numbers[0] + numbers[1]]; + for (const number of numbers.slice(2)) { + const base128 = [number % 128]; + let remaining = Math.floor(number / 128); + while (remaining > 0) { + base128.unshift((remaining % 128) | 0x80); + remaining = Math.floor(remaining / 128); + } + encoded.push(...base128); + } + return derElement(0x06, Buffer.from(encoded)); +} + +function derNull() { + return Buffer.from([0x05, 0x00]); +} + +function derBitString(contents) { + return derElement(0x03, Buffer.concat([Buffer.from([0]), contents])); +} + +function derUtcTime(date) { + const stamp = `${date.toISOString().replace(/[-:T.Z]/g, "").slice(2, 14)}Z`; + return derElement(0x17, Buffer.from(stamp, "ascii")); +} + +function derName(commonName) { + const cn = derSequence(derOid("2.5.4.3"), derElement(0x0c, Buffer.from(commonName, "utf8"))); + return derSequence(derElement(0x31, cn)); +} + +function pemEncode(label, der) { + const wrapped = der.toString("base64").match(/.{1,64}/g).join("\n"); + return `-----BEGIN ${label}-----\n${wrapped}\n-----END ${label}-----\n`; +} + +// Builds a throwaway RSA keypair and a self-signed v3 certificate at test time +// so no private-key material has to live in the repository. +function ephemeralTlsPair() { + const { publicKey, privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "pkcs1", format: "der" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + }); + const rsaAlgorithm = derSequence(derOid("1.2.840.113549.1.1.1"), derNull()); + const signatureAlgorithm = derSequence(derOid("1.2.840.113549.1.1.11"), derNull()); + const subject = derName("api.openai.test"); + const now = new Date(); + const notAfter = new Date(now.getTime() + 24 * 60 * 60 * 1000); + const tbsCertificate = derSequence( + derElement(0xa0, derInteger(2)), + derInteger(0x01), + signatureAlgorithm, + subject, + derSequence(derUtcTime(now), derUtcTime(notAfter)), + subject, + derSequence(rsaAlgorithm, derBitString(publicKey)), + ); + const signature = sign("sha256", tbsCertificate, privateKey); + const certificate = derSequence(tbsCertificate, signatureAlgorithm, derBitString(signature)); + return { key: privateKey, cert: pemEncode("CERTIFICATE", certificate) }; +} + test("routes V4 Flash and Pro to their native DeepSeek Responses models", async (t) => { const observed = []; const upstream = http.createServer(async (request, response) => { @@ -646,6 +772,717 @@ test("never asks DeepSeek for parallel tool calls", () => { assert.equal(body.parallel_tool_calls, false); }); +test("relays authenticated Responses WebSocket upgrades and bytes in both directions", async (t) => { + let observed; + let upstreamSocket; + let resolveClientPayload; + const clientPayload = new Promise((resolve) => { resolveClientPayload = resolve; }); + const upstream = http.createServer(); + upstream.on("upgrade", (request, socket, head) => { + upstreamSocket = socket; + observed = { + path: request.url, + authorization: request.headers.authorization, + account: request.headers["chatgpt-account-id"], + metadata: request.headers["x-codex-turn-metadata"], + protocol: request.headers["sec-websocket-protocol"], + host: request.headers.host, + }; + socket.on("data", (chunk) => { + if (chunk.toString("utf8").includes("client-payload")) resolveClientPayload(); + }); + if (head.toString("utf8").includes("client-payload")) resolveClientPayload(); + socket.write( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + "Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n" + + "Sec-WebSocket-Protocol: responses\r\n\r\n" + + "upstream-ready", + ); + }); + const upstreamUrl = await listen(upstream); + const proxy = createProxyServer({ + chatGptBaseUrl: `${upstreamUrl}/backend-api/codex`, + logger: { info() {}, error() {} }, + routerToken: ROUTER_TOKEN, + }); + const proxyUrl = await listen(proxy); + const { port } = proxy.address(); + const clientSocket = net.connect(port, "127.0.0.1"); + t.after(async () => { + clientSocket.destroy(); + upstreamSocket?.destroy(); + await close(proxy); + await close(upstream); + }); + await once(clientSocket, "connect"); + clientSocket.write( + `GET /${ROUTER_TOKEN}/v1/responses?conversation=voice HTTP/1.1\r\n` + + `Host: 127.0.0.1:${port}\r\n` + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + "Sec-WebSocket-Version: 13\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + + "Sec-WebSocket-Protocol: responses\r\n" + + "Authorization: Bearer oauth-token\r\n" + + "ChatGPT-Account-Id: acct-test\r\n" + + "X-Codex-Turn-Metadata: metadata-test\r\n\r\n", + ); + + const handshake = await readSocketUntil(clientSocket, "upstream-ready"); + assert.match(handshake, /^HTTP\/1\.1 101 Switching Protocols/); + assert.match(handshake, /Sec-WebSocket-Protocol: responses/i); + assert.equal(observed.path, "/backend-api/codex/responses?conversation=voice"); + assert.equal(observed.authorization, "Bearer oauth-token"); + assert.equal(observed.account, "acct-test"); + assert.equal(observed.metadata, "metadata-test"); + assert.equal(observed.protocol, "responses"); + assert.notEqual(observed.host, `127.0.0.1:${port}`); + + clientSocket.write("client-payload"); + await clientPayload; +}); + +test("relays authenticated Realtime sideband WebSockets through the dedicated upstream", async (t) => { + let observed; + let upstreamSocket; + let resolveClientPayload; + const clientPayload = new Promise((resolve) => { resolveClientPayload = resolve; }); + const upstream = http.createServer(); + upstream.on("upgrade", (request, socket, head) => { + upstreamSocket = socket; + observed = { + path: request.url, + authorization: request.headers.authorization, + account: request.headers["chatgpt-account-id"], + alpha: request.headers["openai-alpha"], + session: request.headers["x-session-id"], + scopedSession: request.headers["x-openai-scoped-session-id"], + thread: request.headers["x-openai-thread-id"], + attestation: request.headers["x-oai-attestation"], + host: request.headers.host, + }; + socket.on("data", (chunk) => { + if (chunk.toString("utf8").includes("client-sideband-payload")) resolveClientPayload(); + }); + if (head.toString("utf8").includes("client-sideband-payload")) resolveClientPayload(); + socket.write( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + "Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n" + + "sideband-ready", + ); + }); + const upstreamUrl = await listen(upstream); + const infoLogs = []; + const proxy = createProxyServer({ + chatGptBaseUrl: "http://unused.example/backend-api/codex", + realtimeApiBaseUrl: `${upstreamUrl}/v1`, + logger: { info(message) { infoLogs.push(message); }, error() {} }, + routerToken: ROUTER_TOKEN, + }); + await listen(proxy); + const { port } = proxy.address(); + const clientSocket = net.connect(port, "127.0.0.1"); + t.after(async () => { + clientSocket.destroy(); + upstreamSocket?.destroy(); + await close(proxy); + await close(upstream); + }); + await once(clientSocket, "connect"); + clientSocket.write( + `GET /${ROUTER_TOKEN}/v1/live/rtc_voice-123?source=voice HTTP/1.1\r\n` + + `Host: 127.0.0.1:${port}\r\n` + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + "Sec-WebSocket-Version: 13\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + + "Authorization: Bearer oauth-token\r\n" + + "ChatGPT-Account-Id: acct-test\r\n" + + "OpenAI-Alpha: quicksilver=v2\r\n" + + "X-Session-Id: realtime-session\r\n" + + "X-OpenAI-Scoped-Session-Id: codex-session\r\n" + + "X-OpenAI-Thread-Id: codex-thread\r\n" + + "X-OAI-Attestation: attestation-test\r\n\r\n", + ); + + const handshake = await readSocketUntil(clientSocket, "sideband-ready"); + assert.match(handshake, /^HTTP\/1\.1 101 Switching Protocols/); + assert.equal(observed.path, "/v1/live/rtc_voice-123?source=voice"); + assert.equal(observed.authorization, "Bearer oauth-token"); + assert.equal(observed.account, "acct-test"); + assert.equal(observed.alpha, "quicksilver=v2"); + assert.equal(observed.session, "realtime-session"); + assert.equal(observed.scopedSession, "codex-session"); + assert.equal(observed.thread, "codex-thread"); + assert.equal(observed.attestation, "attestation-test"); + assert.notEqual(observed.host, `127.0.0.1:${port}`); + assert.equal(infoLogs.join("\n").includes("rtc_voice-123"), false); + + clientSocket.write("client-sideband-payload"); + await clientPayload; +}); + +test("routes HTTPS Realtime sideband WebSockets through the configured CONNECT proxy", { timeout: 10_000 }, async (t) => { + const { key, cert } = ephemeralTlsPair(); + let upstreamSocket; + let observed; + const upstream = https.createServer({ key, cert }); + upstream.on("upgrade", (request, socket) => { + upstreamSocket = socket; + observed = { + path: request.url, + authorization: request.headers.authorization, + }; + socket.write( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + "Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n" + + "connect-proxy-ready", + ); + }); + await listen(upstream); + const upstreamPort = upstream.address().port; + + const tunnelSockets = new Set(); + const connectTargets = []; + const connectProxy = http.createServer((_request, response) => { + response.writeHead(405); + response.end(); + }); + connectProxy.on("connect", (request, clientSocket, head) => { + connectTargets.push(request.url); + const targetSocket = net.connect(upstreamPort, "127.0.0.1", () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length > 0) targetSocket.write(head); + clientSocket.pipe(targetSocket); + targetSocket.pipe(clientSocket); + }); + tunnelSockets.add(clientSocket); + tunnelSockets.add(targetSocket); + clientSocket.on("error", () => {}); + targetSocket.on("error", () => clientSocket.destroy()); + clientSocket.once("close", () => tunnelSockets.delete(clientSocket)); + targetSocket.once("close", () => tunnelSockets.delete(targetSocket)); + }); + const connectProxyUrl = await listen(connectProxy); + + const proxyModuleUrl = new URL("../src/proxy.mjs", import.meta.url).href; + const childSource = ` + import { once } from "node:events"; + const { createProxyServer } = await import(${JSON.stringify(proxyModuleUrl)}); + const server = createProxyServer({ + realtimeApiBaseUrl: process.env.TEST_REALTIME_BASE_URL, + logger: { info() {}, error() {} }, + routerToken: ${JSON.stringify(ROUTER_TOKEN)}, + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + console.log(server.address().port); + process.on("SIGTERM", () => { + server.closeUpgradeConnections?.(); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 1_000).unref(); + }); + `; + const child = spawn(process.execPath, ["--input-type=module", "--eval", childSource], { + env: { + ...process.env, + NODE_OPTIONS: "--use-env-proxy", + NODE_TLS_REJECT_UNAUTHORIZED: "0", + HTTPS_PROXY: connectProxyUrl, + https_proxy: connectProxyUrl, + HTTP_PROXY: connectProxyUrl, + http_proxy: connectProxyUrl, + NO_PROXY: "", + no_proxy: "", + ALL_PROXY: "", + all_proxy: "", + TEST_REALTIME_BASE_URL: `https://api.openai.test:${upstreamPort}/v1`, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let childStderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { childStderr += chunk; }); + const routerPort = await new Promise((resolve, reject) => { + let stdout = ""; + const timer = setTimeout(() => reject(new Error(`timed out starting proxy child: ${childStderr}`)), 3_000); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + const line = stdout.split("\n")[0].trim(); + if (!/^\d+$/.test(line)) return; + clearTimeout(timer); + resolve(Number(line)); + }); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.once("exit", (code) => { + clearTimeout(timer); + reject(new Error(`proxy child exited ${code}: ${childStderr}`)); + }); + }); + + const clientSocket = net.connect(routerPort, "127.0.0.1"); + t.after(async () => { + clientSocket.destroy(); + upstreamSocket?.destroy(); + for (const socket of tunnelSockets) socket.destroy(); + if (child.exitCode === null) { + child.kill("SIGTERM"); + await once(child, "exit"); + } + await close(connectProxy); + await close(upstream); + }); + await once(clientSocket, "connect"); + clientSocket.write( + `GET /${ROUTER_TOKEN}/v1/live/rtc_connect-123 HTTP/1.1\r\n` + + `Host: 127.0.0.1:${routerPort}\r\n` + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + "Sec-WebSocket-Version: 13\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + + "Authorization: Bearer oauth-through-connect\r\n\r\n", + ); + + const handshake = await readSocketUntil(clientSocket, "connect-proxy-ready", 3_000); + assert.match(handshake, /^HTTP\/1\.1 101 Switching Protocols/); + assert.deepEqual(connectTargets, [`api.openai.test:${upstreamPort}`]); + assert.equal(observed.path, "/v1/live/rtc_connect-123"); + assert.equal(observed.authorization, "Bearer oauth-through-connect"); +}); + +test("relays legacy Realtime sideband call_id upgrades to the Realtime endpoint", async (t) => { + let observedPath; + let upstreamSocket; + const upstream = http.createServer(); + upstream.on("upgrade", (request, socket) => { + upstreamSocket = socket; + observedPath = request.url; + socket.write( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + "Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + ); + }); + const upstreamUrl = await listen(upstream); + const infoLogs = []; + const proxy = createProxyServer({ + realtimeApiBaseUrl: `${upstreamUrl}/v1`, + logger: { info(message) { infoLogs.push(message); }, error() {} }, + routerToken: ROUTER_TOKEN, + }); + await listen(proxy); + const { port } = proxy.address(); + const clientSocket = net.connect(port, "127.0.0.1"); + t.after(async () => { + clientSocket.destroy(); + upstreamSocket?.destroy(); + await close(proxy); + await close(upstream); + }); + await once(clientSocket, "connect"); + clientSocket.write( + `GET /${ROUTER_TOKEN}/v1/realtime?intent=quicksilver&call_id=rtc_voice-legacy HTTP/1.1\r\n` + + `Host: 127.0.0.1:${port}\r\n` + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + "Sec-WebSocket-Version: 13\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + + "Authorization: Bearer oauth-token\r\n" + + "OpenAI-Alpha: quicksilver=v2\r\n\r\n", + ); + + const handshake = await readSocketUntil(clientSocket, "\r\n\r\n"); + assert.match(handshake, /^HTTP\/1\.1 101 Switching Protocols/); + assert.equal(observedPath, "/v1/realtime?intent=quicksilver&call_id=rtc_voice-legacy"); + assert.equal(infoLogs.join("\n").includes("rtc_voice-legacy"), false); +}); + +test("rejects invalid or duplicate Realtime call IDs without leaking them to logs", async (t) => { + let upstreamHits = 0; + const upstream = http.createServer(); + upstream.on("upgrade", (_request, socket) => { + upstreamHits += 1; + socket.destroy(); + }); + const upstreamUrl = await listen(upstream); + const infoLogs = []; + const proxy = createProxyServer({ + realtimeApiBaseUrl: `${upstreamUrl}/v1`, + logger: { info(message) { infoLogs.push(message); }, error() {} }, + routerToken: ROUTER_TOKEN, + }); + await listen(proxy); + const { port } = proxy.address(); + const sockets = new Set(); + t.after(async () => { + for (const socket of sockets) socket.destroy(); + await close(proxy); + await close(upstream); + }); + + const requestUpgrade = async (path) => { + const socket = net.connect(port, "127.0.0.1"); + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + await once(socket, "connect"); + socket.write( + `GET /${ROUTER_TOKEN}${path} HTTP/1.1\r\n` + + `Host: 127.0.0.1:${port}\r\n` + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + "Sec-WebSocket-Version: 13\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ); + return readSocketUntil(socket, "\r\n\r\n"); + }; + + const invalidCallId = "rtc.secret.invalid"; + const invalid = await requestUpgrade(`/v1/live/${invalidCallId}`); + const duplicate = await requestUpgrade("/v1/realtime?call_id=first-secret&call_id=second-secret"); + assert.match(invalid, /^HTTP\/1\.1 426 Upgrade Required/); + assert.match(duplicate, /^HTTP\/1\.1 426 Upgrade Required/); + assert.equal(upstreamHits, 0); + const logs = infoLogs.join("\n"); + assert.equal(logs.includes(invalidCallId), false); + assert.equal(logs.includes("first-secret"), false); + assert.equal(logs.includes("second-secret"), false); +}); + +test("rejects an unauthenticated Responses WebSocket before contacting upstream", async (t) => { + let upstreamHits = 0; + const infoLogs = []; + const invalidToken = "B".repeat(43); + const upstream = http.createServer(); + upstream.on("upgrade", (_request, socket) => { + upstreamHits += 1; + socket.destroy(); + }); + const upstreamUrl = await listen(upstream); + const proxy = createProxyServer({ + chatGptBaseUrl: `${upstreamUrl}/backend-api/codex`, + logger: { info(message) { infoLogs.push(message); }, error() {} }, + routerToken: ROUTER_TOKEN, + }); + await listen(proxy); + const { port } = proxy.address(); + const clientSocket = net.connect(port, "127.0.0.1"); + t.after(async () => { + clientSocket.destroy(); + await close(proxy); + await close(upstream); + }); + await once(clientSocket, "connect"); + clientSocket.write( + `GET /${invalidToken}/v1/responses HTTP/1.1\r\n` + + `Host: 127.0.0.1:${port}\r\n` + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + "Sec-WebSocket-Version: 13\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ); + + const response = await readSocketUntil(clientSocket, "\r\n\r\n"); + assert.match(response, /^HTTP\/1\.1 404 Not Found/); + assert.equal(upstreamHits, 0); + assert.equal(infoLogs.join("\n").includes(invalidToken), false); +}); + +test("keeps authenticated non-Responses upgrades on the HTTP fallback path", async (t) => { + let upstreamHits = 0; + const upstream = http.createServer(); + upstream.on("upgrade", (_request, socket) => { + upstreamHits += 1; + socket.destroy(); + }); + const upstreamUrl = await listen(upstream); + const proxy = createProxyServer({ + chatGptBaseUrl: `${upstreamUrl}/backend-api/codex`, + logger: { info() {}, error() {} }, + routerToken: ROUTER_TOKEN, + }); + await listen(proxy); + const { port } = proxy.address(); + const clientSocket = net.connect(port, "127.0.0.1"); + t.after(async () => { + clientSocket.destroy(); + await close(proxy); + await close(upstream); + }); + await once(clientSocket, "connect"); + clientSocket.write( + `GET /${ROUTER_TOKEN}/v1/models HTTP/1.1\r\n` + + `Host: 127.0.0.1:${port}\r\n` + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n\r\n", + ); + + const response = await readSocketUntil(clientSocket, "\r\n\r\n"); + assert.match(response, /^HTTP\/1\.1 426 Upgrade Required/); + assert.equal(upstreamHits, 0); +}); + +test("returns 502 when the Responses WebSocket upstream is unavailable", async (t) => { + const unavailable = http.createServer(); + const unavailableUrl = await listen(unavailable); + await close(unavailable); + const errorLogs = []; + const proxy = createProxyServer({ + chatGptBaseUrl: `${unavailableUrl}/backend-api/codex`, + logger: { info() {}, error(message) { errorLogs.push(message); } }, + routerToken: ROUTER_TOKEN, + }); + await listen(proxy); + const { port } = proxy.address(); + const clientSocket = net.connect(port, "127.0.0.1"); + t.after(async () => { + clientSocket.destroy(); + await close(proxy); + }); + await once(clientSocket, "connect"); + clientSocket.write( + `GET /${ROUTER_TOKEN}/v1/responses HTTP/1.1\r\n` + + `Host: 127.0.0.1:${port}\r\n` + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + "Sec-WebSocket-Version: 13\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ); + + const response = await readSocketUntil(clientSocket, "\r\n\r\n"); + assert.match(response, /^HTTP\/1\.1 502 Bad Gateway/); + assert.match(errorLogs.join("\n"), /websocket proxy error \(\/v1\/responses\)/); +}); + +test("relays an upstream WebSocket rejection to the Codex client", async (t) => { + const upstream = http.createServer(); + upstream.on("upgrade", (_request, socket) => { + socket.end( + "HTTP/1.1 401 Unauthorized\r\n" + + "Connection: close\r\n" + + "WWW-Authenticate: Bearer\r\n" + + "Content-Length: 0\r\n\r\n", + ); + }); + const upstreamUrl = await listen(upstream); + const proxy = createProxyServer({ + chatGptBaseUrl: `${upstreamUrl}/backend-api/codex`, + logger: { info() {}, error() {} }, + routerToken: ROUTER_TOKEN, + }); + await listen(proxy); + const { port } = proxy.address(); + const clientSocket = net.connect(port, "127.0.0.1"); + t.after(async () => { + clientSocket.destroy(); + await close(proxy); + await close(upstream); + }); + await once(clientSocket, "connect"); + clientSocket.write( + `GET /${ROUTER_TOKEN}/v1/responses HTTP/1.1\r\n` + + `Host: 127.0.0.1:${port}\r\n` + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + "Sec-WebSocket-Version: 13\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ); + + const response = await readSocketUntil(clientSocket, "\r\n\r\n"); + assert.match(response, /^HTTP\/1\.1 401 Unauthorized/); + assert.match(response, /WWW-Authenticate: Bearer/i); +}); + +test("reframes chunked WebSocket rejection bodies for a real HTTP client", async (t) => { + const upstream = http.createServer(); + upstream.on("upgrade", (_request, socket) => { + socket.end( + "HTTP/1.1 401 Unauthorized\r\n" + + "Connection: close\r\n" + + "Content-Type: text/plain\r\n" + + "Transfer-Encoding: chunked\r\n\r\n" + + "5\r\nhello\r\n0\r\n\r\n", + ); + }); + const upstreamUrl = await listen(upstream); + const proxy = createProxyServer({ + chatGptBaseUrl: `${upstreamUrl}/backend-api/codex`, + logger: { info() {}, error() {} }, + routerToken: ROUTER_TOKEN, + }); + await listen(proxy); + const { port } = proxy.address(); + t.after(async () => { + await close(proxy); + await close(upstream); + }); + + const result = await new Promise((resolve, reject) => { + const request = http.request({ + hostname: "127.0.0.1", + port, + path: `/${ROUTER_TOKEN}/v1/responses`, + headers: { + Connection: "Upgrade", + Upgrade: "websocket", + "Sec-WebSocket-Version": "13", + "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", + }, + }, (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("error", reject); + response.on("end", () => resolve({ + statusCode: response.statusCode, + headers: response.headers, + body: Buffer.concat(chunks).toString("utf8"), + })); + }); + request.on("upgrade", () => reject(new Error("unexpected WebSocket upgrade"))); + request.on("error", reject); + request.setTimeout(1_000, () => request.destroy(new Error("timed out reading rejection body"))); + request.end(); + }); + + assert.equal(result.statusCode, 401); + assert.equal(result.headers["transfer-encoding"], undefined); + assert.equal(result.headers.connection, "close"); + assert.equal(result.body, "hello"); +}); + +test("closes the client when a WebSocket rejection body is truncated upstream", async (t) => { + const upstream = http.createServer(); + upstream.on("upgrade", (_request, socket) => { + socket.write( + "HTTP/1.1 502 Bad Gateway\r\n" + + "Connection: close\r\n" + + "Content-Type: text/plain\r\n" + + "Content-Length: 10\r\n\r\n" + + "hello", + ); + setImmediate(() => socket.destroy()); + }); + const upstreamUrl = await listen(upstream); + const proxy = createProxyServer({ + chatGptBaseUrl: `${upstreamUrl}/backend-api/codex`, + logger: { info() {}, error() {} }, + routerToken: ROUTER_TOKEN, + }); + await listen(proxy); + const { port } = proxy.address(); + const clientSocket = net.connect(port, "127.0.0.1"); + t.after(async () => { + clientSocket.destroy(); + proxy.closeUpgradeConnections(); + await close(proxy); + await close(upstream); + }); + await once(clientSocket, "connect"); + const clientClosed = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("client stayed open after the upstream rejection was truncated")), 500); + clientSocket.once("close", () => { + clearTimeout(timer); + resolve(); + }); + }); + clientSocket.write( + `GET /${ROUTER_TOKEN}/v1/responses HTTP/1.1\r\n` + + `Host: 127.0.0.1:${port}\r\n` + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + "Sec-WebSocket-Version: 13\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ); + + const response = await readSocketUntil(clientSocket, "hello"); + assert.match(response, /^HTTP\/1\.1 502 Bad Gateway/); + await clientClosed; +}); + +for (const kind of ["HTTP", "upgrade"]) { + test(`rejects malformed absolute-form ${kind} targets without stopping the router`, async (t) => { + const proxy = createProxyServer({ + logger: { info() {}, error() {} }, + routerToken: ROUTER_TOKEN, + }); + const proxyUrl = await listen(proxy); + const { port } = proxy.address(); + const clientSocket = net.connect(port, "127.0.0.1"); + t.after(async () => { + clientSocket.destroy(); + await close(proxy); + }); + await once(clientSocket, "connect"); + clientSocket.write( + "GET http://[ HTTP/1.1\r\n" + + `Host: 127.0.0.1:${port}\r\n` + + (kind === "upgrade" ? "Connection: Upgrade\r\nUpgrade: websocket\r\n" : "Connection: close\r\n") + + "\r\n", + ); + + const response = await readSocketUntil(clientSocket, "\r\n\r\n"); + assert.match(response, /^HTTP\/1\.1 400 Bad Request/); + const health = await fetch(route(proxyUrl, "/health")); + assert.equal(health.status, 200); + }); +} + +test("force-closes upgraded sockets so router shutdown can finish", async (t) => { + let upstreamSocket; + const upstream = http.createServer(); + upstream.on("upgrade", (_request, socket) => { + upstreamSocket = socket; + socket.write( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + "Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + ); + }); + const upstreamUrl = await listen(upstream); + const proxy = createProxyServer({ + chatGptBaseUrl: `${upstreamUrl}/backend-api/codex`, + logger: { info() {}, error() {} }, + routerToken: ROUTER_TOKEN, + }); + await listen(proxy); + const { port } = proxy.address(); + const clientSocket = net.connect(port, "127.0.0.1"); + t.after(async () => { + clientSocket.destroy(); + upstreamSocket?.destroy(); + if (proxy.listening) await close(proxy); + await close(upstream); + }); + await once(clientSocket, "connect"); + clientSocket.write( + `GET /${ROUTER_TOKEN}/v1/responses HTTP/1.1\r\n` + + `Host: 127.0.0.1:${port}\r\n` + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + "Sec-WebSocket-Version: 13\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ); + await readSocketUntil(clientSocket, "\r\n\r\n"); + + const clientClosed = once(clientSocket, "close"); + const serverClosed = new Promise((resolve, reject) => { + proxy.close((error) => error ? reject(error) : resolve()); + }); + proxy.closeUpgradeConnections(); + await Promise.all([clientClosed, serverClosed]); +}); + // Regression: the `upgrade` handler used to leave its detached socket without an // error listener, so a client reset raised an unhandled 'error' event and took // the whole router down — Codex then sat in "reconnecting" until a manual start.