From 7ef8cf1861d7b3cf3dd56efcbf488c7fc4d06d16 Mon Sep 17 00:00:00 2001 From: iceBear67 Date: Sun, 9 Aug 2026 05:07:50 +0000 Subject: [PATCH] add codex support; fix interruption --- .gitignore | 1 - README.md | 69 ++++++++++--- mirasim-relay.mjs | 243 ++++++++++++++++++++++++++++++++++++++++------ test.mjs | 144 +++++++++++++++++++++++++++ 4 files changed, 416 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index 56ca304..30b9507 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ dist/ -vendor/ diff --git a/README.md b/README.md index 21c4598..f0634b1 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,18 @@ Mirasim 官方只发 macOS / Android 包,但它的 `SHA256SUMS` 里其实还 `mirasim-server-linux-x64-0.0.146.tar.gz`。这个脚本就是从那份官方 Linux 构建里把中继部分 (登录 → 设备签名 → 转发)重新实现出来的,行为跟桌面版一致。 -中继本身说的是 **Anthropic 的协议**,所以任何兼容 Anthropic API 的 agent 直接指过来就能用。 +中继本身说的是 **Anthropic 的协议**,同时也提供 **OpenAI Responses 协议**(Codex 用的那个), +按路径分发,凭据完全一样。所以 Claude Code 和 Codex 都能直接指过来。 ## 快速开始 ```sh ./mirasim-relay login # 邮箱验证码 / GitHub / Google / 直接粘 token -./mirasim-relay status # 看账号、有效期、剩余额度 +./mirasim-relay status # 看账号、有效期、剩余额度(两条协议各探一次) ./mirasim-relay serve # 起本地中继,默认 127.0.0.1:8787 ``` -另开一个终端: +另开一个终端,Claude Code: ```fish set -gx ANTHROPIC_BASE_URL http://127.0.0.1:8787 @@ -24,6 +25,13 @@ set -gx ANTHROPIC_AUTH_TOKEN mirasim-relay-managed-credential claude ``` +Codex: + +```sh +./mirasim-relay codex # 等价于 codex,但指向中继 +./mirasim-relay codex -- exec "写个 fib" # -- 后面原样透传给 codex +``` + bash / zsh 用 `./mirasim-relay env --bash`,它会按当前 shell 输出对应语法。 `ANTHROPIC_AUTH_TOKEN` 的值是什么都无所谓——每个请求都会被换成真正的中继凭据, 但大多数 agent 不设这个变量就不发请求,所以得给一个占位值。 @@ -33,17 +41,50 @@ bash / zsh 用 `./mirasim-relay env --bash`,它会按当前 shell 输出对应 | 命令 | 说明 | | --- | --- | | `login [--method email\|github\|google\|token] [--email ...]` | 登录并保存凭据 | -| `serve [--port 8787] [--host 127.0.0.1] [--agent claude] [-v]` | 起本地中继,`-v` 打印每条请求 | -| `status` | 账号信息 + 用 1 token 的探测请求读额度 | -| `env [--port 8787] [--bash]` | 输出环境变量 | +| `serve [--port 8787] [--host 127.0.0.1] [--agent claude\|codex] [-v]` | 起本地中继,`-v` 打印每条请求 | +| `codex [--port 8787] [--model ...] [--effort ...] [-- ]` | 拉起真正的 codex,指向中继 | +| `status [--model ...]` | 账号信息 + 探测两条协议的额度 | +| `env [--port 8787] [--bash] [--codex]` | 输出环境变量 | | `logout` | 清除登录态(设备密钥保留) | -`--agent` 会作为 `x-mirasim-agent` 上报,取值跟桌面版一致:`claude` / `codex` / `pi-gui`。 +不给 `--agent` 时,`x-mirasim-agent` 按请求路径自动推断(`/v1/messages` → `claude`, +`/v1/responses` → `codex`),跟桌面版的上报一致;给了就固定。 + +## 两条协议 + +中继按路径分发,跟桌面版 `server.cjs` 里的分类函数(`tD()`)一字不差: + +| 路径 | 协议 | 谁在用 | +| --- | --- | --- | +| `POST /v1/messages` | Anthropic Messages | Claude Code | +| `POST /v1/responses` | OpenAI Responses | Codex(`wire_api = responses`)| +| `POST /v1/chat/completions` | OpenAI Chat Completions | 各种 OpenAI 兼容客户端 | +| `GET /v1/models` | — | 通用 | + +Codex 没有「base url 环境变量」这种东西,它只认 config.toml 里的 provider 块。桌面版的做法 +是在命令行上现场注入,本脚本照抄: + +``` +codex -c model_providers.apodex.name=apodex \ + -c model_providers.apodex.base_url=http://127.0.0.1:8787/v1 \ + -c model_providers.apodex.wire_api=responses \ + -c model_providers.apodex.env_key=OPENAI_API_KEY \ + -c model_provider=apodex +``` + +加上 `OPENAI_API_KEY=mirasim-relay-managed-credential`。`mirasim-relay codex` 就是把这一串 +拼好再 exec,`./mirasim-relay env --codex` 只打印不执行。 + +中继侧的 codex 模型:`gpt-5.6-sol`(默认)、`gpt-5.6-terra`、`gpt-5.6-luna`。 +前两个支持到 `ultra` 档推理强度,`luna` 到 `max`;默认 `xhigh`。 + +顺带一提,`POST /backend-api/codex/responses`(ChatGPT 后端那条路径)分类函数里也认, +但中继本身对它返回 404 —— 桌面版只在 MITM 抓 `chatgpt.com` 时才会走到那儿。 ## 它到底做了什么 ``` -agent ──► 127.0.0.1:8787 ──► https://mirasim-relay.mirofish.ai ──► Anthropic +agent ──► 127.0.0.1:8787 ──► https://mirasim-relay.mirofish.ai ──► Anthropic / OpenAI (本脚本) ``` @@ -55,9 +96,10 @@ agent ──► 127.0.0.1:8787 ──► https://mirasim-relay.mirofish.ai ─ ticket;拿到后每个请求都按 `mrs-sig-v1` 签名(`x-mirasim-device/ts/nonce/sig`)。 中继若不支持(404/501)就退回裸 token,跟桌面版的降级路径一样。 4. **收敛 header** —— 丢掉 hop-by-hop 头;`anthropic-beta` 只保留中继真正认的 - `context-1m-2025-08-07`,其余丢弃。 + `context-1m-2025-08-07`,其余丢弃。其它头(含 `openai-beta`)原样透传。 -响应是流式透传的,SSE 不会被缓冲。 +响应是流式透传的,SSE 不会被缓冲。客户端中途断开(Ctrl-C、取消一轮对话、关掉 pane) +只会掉这一个请求,不会影响服务端本身。 ## 配置 @@ -65,7 +107,7 @@ agent ──► 127.0.0.1:8787 ──► https://mirasim-relay.mirofish.ai ─ 和设备私钥。换目录用 `MIRASIM_PROXY_HOME`。 其它可覆盖项:`MIRASIM_RELAY_BASE_URL`、`MIRASIM_LOGIN_URL`、`MIRASIM_APP_VERSION`、 -`MIRASIM_NODE`(指定 Node 运行时)。 +`MIRASIM_NODE`(指定 Node 运行时)、`MIRASIM_CODEX_BIN`(指定 codex 可执行文件)。 ## Node @@ -79,8 +121,9 @@ agent ──► 127.0.0.1:8787 ──► https://mirasim-relay.mirofish.ai ─ ./vendor/node test.mjs ``` -13 个离线用例,覆盖签名规范串、deviceId 推导、header 改写、配置文件权限。 -不需要登录,也不会碰真实凭据。 +21 个用例,不需要登录,也不会碰真实凭据:签名规范串、deviceId 推导、header 改写、 +配置文件权限、路径分类、Codex provider 块,以及四个断连用例(起一个假上游 + 真的 +proxy,客户端在流中间 / 上游还没回 header 时 / 上传中途消失,验证服务端还活着)。 ## 注意 diff --git a/mirasim-relay.mjs b/mirasim-relay.mjs index dc32b0c..1492c55 100644 --- a/mirasim-relay.mjs +++ b/mirasim-relay.mjs @@ -8,10 +8,15 @@ // GET {LOGIN_BASE}/auth/oauth/{provider}/login?redirect_uri=http://127.0.0.1:P/callback // refresh POST {LOGIN_BASE}/auth/refresh {refresh_token} -> {access_token, refresh_token?} // device POST {RELAY_BASE}/v1/device/session {publicKey, deviceId} -> {ticket, expiresIn|expiresAt} -// traffic POST {RELAY_BASE}/v1/messages (Anthropic-shaped, plus /v1/chat/completions, /v1/models) +// traffic POST {RELAY_BASE}/v1/messages (Anthropic wire format) +// POST {RELAY_BASE}/v1/responses (OpenAI Responses — what Codex uses) +// POST {RELAY_BASE}/v1/chat/completions (OpenAI Chat Completions) +// GET {RELAY_BASE}/v1/models // -// The relay speaks the Anthropic wire format, so any Anthropic-compatible agent can point -// ANTHROPIC_BASE_URL at this process and never learn that a relay is involved. +// One relay, two dialects. `server.cjs` routes on the path alone (its classifier is +// reproduced verbatim in classifyPath below), so an Anthropic-compatible agent points +// ANTHROPIC_BASE_URL here and Codex points its `apodex` provider block here, and neither +// learns that a relay is involved. import http from 'node:http'; import crypto from 'node:crypto'; @@ -19,6 +24,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; import { spawn } from 'node:child_process'; import readline from 'node:readline/promises'; import { fileURLToPath } from 'node:url'; @@ -59,6 +65,17 @@ const TICKET_DEFAULT_TTL_MS = 600_000; const PROBE_MODEL = 'claude-haiku-4-5-20251001'; +// The sentinel the desktop build hands to a spawned agent in place of a real key; the relay +// recognises it as "use the managed credential" and this proxy substitutes it per request. +const MANAGED_CREDENTIAL = 'mirasim-relay-managed-credential'; + +// Codex's side of the relay, from the shipped build's model table: the provider block it +// injects is named `apodex`, wire_api is `responses`, and the key comes from OPENAI_API_KEY. +const CODEX_PROVIDER = 'apodex'; +const CODEX_MODELS = ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna']; +const CODEX_DEFAULT_MODEL = CODEX_MODELS[0]; +const CODEX_DEFAULT_EFFORT = 'xhigh'; + const CONFIG_DIR = process.env.MIRASIM_PROXY_HOME || path.join(os.homedir(), '.config', 'mirasim-proxy'); const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json'); @@ -425,9 +442,22 @@ function readBody(req) { req.on('data', (c) => chunks.push(c)); req.on('end', () => resolve(Buffer.concat(chunks))); req.on('error', reject); + // A client that vanishes mid-upload ends the request without 'end'; treat the partial + // read as a disconnect rather than leaving this promise pending forever. + req.on('aborted', () => reject(Object.assign(new Error('client aborted the request'), { name: 'AbortError' }))); }); } +// Everything that means "the other end went away" — expected during normal agent use +// (Ctrl-C, a cancelled turn, a closed pane) and never worth reporting as a proxy fault. +function isDisconnect(err) { + if (!err) return false; + if (err.name === 'AbortError' || err.code === 'ABORT_ERR') return true; + const code = err.code || err.cause?.code; + if (code === 'ECONNRESET' || code === 'EPIPE' || code === 'ERR_STREAM_PREMATURE_CLOSE') return true; + return isDisconnect(err.cause !== err ? err.cause : null); +} + function forwardHeaders(incoming) { const out = {}; for (const [k, v] of Object.entries(incoming)) { @@ -446,23 +476,65 @@ function forwardHeaders(incoming) { return out; } -async function serve({ port, host, agent, verbose }) { - const tickets = new TicketManager(); +// Which dialect the relay will read a request as. This is `tD()` from server.cjs, regexes +// and all: the relay dispatches on the path alone, so the same endpoint and the same +// credential serve Claude Code and Codex. `/backend-api/codex/responses` is the ChatGPT +// backend shape — the desktop build only ever sees it via its MITM capture of chatgpt.com, +// and the relay itself answers 404 there, but it is recognised for completeness. +function classifyPath(pathname) { + if (/^\/v1\/messages\/?$/.test(pathname)) return 'anthropic'; + if (/^\/v1\/responses\/?$/.test(pathname) || /^\/backend-api\/codex\/responses\/?$/.test(pathname)) return 'openai-responses'; + if (/(^|\/)chat\/completions\/?$/.test(pathname)) return 'openai-chat'; + return null; +} + +// Which agent the traffic claims to be, when the caller has not pinned one with --agent. +// The desktop build reports `codex` for both OpenAI dialects. +const AGENT_FOR_KIND = { anthropic: 'claude', 'openai-responses': 'codex', 'openai-chat': 'codex' }; + +async function serve({ port, host, agent, verbose }) { const tickets = new TicketManager(); const sessionId = crypto.randomUUID(); let reauthNeeded = false; + // Backstop. Every disconnect path below is handled explicitly, but a long-lived proxy + // must not die because one of them was missed — an agent hanging up should cost one + // request, never the whole server. Real bugs still crash loudly. + const survive = (err) => { + if (isDisconnect(err)) { if (verbose) log(` ignored late disconnect: ${err?.code || err?.name}`); return; } + console.error(err); + process.exit(1); + }; + process.on('uncaughtException', survive); + process.on('unhandledRejection', survive); + // Warm the credential path once so failures surface at startup, not on first prompt. const bootToken = await currentToken({ onReauthRequired: () => { reauthNeeded = true; } }); await tickets.ensureFresh(bootToken); const server = http.createServer((req, res) => { + // Socket-level errors on either half surface as 'error' events; unhandled, they are fatal. + req.on('error', () => {}); + res.on('error', () => {}); + + // A client hanging up is routine, so it must never reach the process as an unhandled + // rejection — and once the socket is gone there is nothing left to reply to either. handle(req, res).catch((err) => { - if (res.headersSent) { res.destroy(err instanceof Error ? err : new Error(String(err))); return; } - res.writeHead(502, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ type: 'error', error: { type: 'proxy_error', message: String(err?.message || err) } })); + if (isDisconnect(err)) { res.destroy(); return; } + if (res.writableEnded || res.destroyed) return; + if (res.headersSent) { res.destroy(); return; } + try { + res.writeHead(502, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ type: 'error', error: { type: 'proxy_error', message: String(err?.message || err) } })); + } catch { res.destroy(); } }); }); + // Malformed requests arrive on the raw socket, before any handler exists. + server.on('clientError', (err, socket) => { + if (!socket.writable) return; + socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); + }); + server.keepAliveTimeout = 125_000; server.headersTimeout = 130_000; @@ -471,6 +543,8 @@ async function serve({ port, host, agent, verbose }) { const pathname = url.pathname; const method = req.method || 'POST'; const callId = crypto.randomUUID(); + const kind = classifyPath(pathname); + const reportedAgent = agent || AGENT_FOR_KIND[kind] || 'claude'; if (pathname === '/__health') { const snap = tickets.snapshot(); @@ -482,7 +556,6 @@ async function serve({ port, host, agent, verbose }) { const body = await readBody(req); const abort = new AbortController(); res.on('close', () => { if (!res.writableEnded) abort.abort(); }); - res.on('error', () => {}); // One retry, but only when retrying can actually change the credential we present: // a live ticket we can drop, or a refresh token that can mint a new access token. @@ -499,7 +572,7 @@ async function serve({ port, host, agent, verbose }) { delete headers['x-api-key']; headers.authorization = `Bearer ${credential}`; headers[H_CLIENT] = CLIENT_VERSION; - headers[H_AGENT] = sanitizeHeader(agent); + headers[H_AGENT] = sanitizeHeader(reportedAgent); headers[H_SESSION] = sessionId; headers[H_CALL] = callId; @@ -527,7 +600,7 @@ async function serve({ port, host, agent, verbose }) { } if (verbose) { - log(` ${method} ${pathname} -> ${res2.status} ${Date.now() - started}ms${ticket ? ' [ticket]' : ''}`); + log(` ${method} ${pathname} -> ${res2.status} ${Date.now() - started}ms [${kind || 'passthrough'}/${reportedAgent}]${ticket ? ' [ticket]' : ''}`); } const outHeaders = {}; @@ -535,10 +608,21 @@ async function serve({ port, host, agent, verbose }) { if (HOP_BY_HOP.has(k.toLowerCase())) return; outHeaders[k] = v; }); + if (res.destroyed) { res2.body?.cancel?.().catch(() => {}); return; } res.writeHead(res2.status, outHeaders); - if (res2.body) Readable.fromWeb(res2.body).pipe(res); - else res.end(); + if (!res2.body) { res.end(); return; } + + // pipeline(), unlike .pipe(), forwards errors to the caller instead of leaving the + // source stream to emit an unhandled 'error'. A client that hangs up mid-stream trips + // the abort controller above, so this is the common case, not the exceptional one. + try { + await pipeline(Readable.fromWeb(res2.body), res); + } catch (err) { + if (!isDisconnect(err)) throw err; + if (verbose) log(` ${method} ${pathname} client went away mid-stream`); + res.destroy(); + } return; } } @@ -556,12 +640,16 @@ async function serve({ port, host, agent, verbose }) { log(` upstream : ${RELAY_BASE}`); log(` device : ${snap.deviceId}${snap.unsupported ? ' (signing unsupported by relay — plain token)' : ''}`); log(` ticket : ${tickets.credential() ? 'active' : 'none (plain token)'}`); + log(` dialects : /v1/messages (anthropic) · /v1/responses (codex) · /v1/chat/completions`); if (reauthNeeded) log(' warning : the stored credential looks stale — run `mirasim-relay login` if requests start failing'); log(''); log(' point an agent at it:'); log(` ANTHROPIC_BASE_URL=${base}`); - log(` ANTHROPIC_AUTH_TOKEN=mirasim-relay-managed-credential # replaced per request`); + log(` ANTHROPIC_AUTH_TOKEN=${MANAGED_CREDENTIAL} # replaced per request`); + log(` codex: mirasim-relay codex --port ${bound.port} -- `); log(''); + + return server; } // --------------------------------------------------------------------------- @@ -624,7 +712,7 @@ async function cmdLogout() { log('已退出登录(设备密钥保留)。'); } -async function cmdStatus() { +async function cmdStatus(argv = {}) { const cfg = loadConfig(); if (!cfg.auth?.token) die('not signed in — run `mirasim-relay login` first'); @@ -670,6 +758,48 @@ async function cmdStatus() { } else { res.text().catch(() => {}); } + + // The OpenAI half of the relay. Worth checking separately: an account can carry Claude + // credit and no Codex credit, and the two dialects answer on different routes. + log(''); + log('探测 codex (responses) 路由…'); + const res2 = await fetch(`${RELAY_BASE}/v1/responses`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + [H_PROBE]: 'usage', + [H_CLIENT]: CLIENT_VERSION, + [H_AGENT]: 'codex', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + model: argv.model || CODEX_DEFAULT_MODEL, + input: 'hi', + max_output_tokens: 16, + stream: false, + }), + }).catch((err) => { log(` 探测失败: ${err.message}`); return null; }); + + if (!res2) return; + log(` HTTP ${res2.status} -> ${res2.status === 404 ? 'route absent' : res2.status === 401 || res2.status === 403 ? 'not entitled' : res2.status === 429 ? 'rate-limited' : 'ok'}`); + res2.headers.forEach((v, k) => { if (/ratelimit|retry-after/i.test(k)) log(` ${k}: ${v}`); }); + const body2 = await res2.text().catch(() => ''); + if (res2.status >= 400 && body2) log(` ${body2.slice(0, 400)}`); + log(` models : ${CODEX_MODELS.join(', ')}`); +} + +// Codex takes no base-URL env var — it reads a provider block out of config.toml. The +// desktop build injects that block on the command line instead of writing the file, and +// these are the same five overrides it uses (`Hpn()` in server.cjs), with the proxy +// substituted for its own in-process relay. +function codexOverrides(base) { + return [ + `model_providers.${CODEX_PROVIDER}.name=${CODEX_PROVIDER}`, + `model_providers.${CODEX_PROVIDER}.base_url=${base}/v1`, + `model_providers.${CODEX_PROVIDER}.wire_api=responses`, + `model_providers.${CODEX_PROVIDER}.env_key=OPENAI_API_KEY`, + `model_provider=${CODEX_PROVIDER}`, + ]; } function cmdEnv(argv) { @@ -677,13 +807,58 @@ function cmdEnv(argv) { const host = argv.host || '127.0.0.1'; const base = `http://${host}:${port}`; const fish = /fish/.test(process.env.SHELL || '') && !argv.bash; - if (fish) { - process.stdout.write(`set -gx ANTHROPIC_BASE_URL ${base}\n`); - process.stdout.write(`set -gx ANTHROPIC_AUTH_TOKEN mirasim-relay-managed-credential\n`); - } else { - process.stdout.write(`export ANTHROPIC_BASE_URL=${base}\n`); - process.stdout.write(`export ANTHROPIC_AUTH_TOKEN=mirasim-relay-managed-credential\n`); + const set = (k, v) => process.stdout.write(fish ? `set -gx ${k} ${v}\n` : `export ${k}=${v}\n`); + + if (argv.codex) { + // Only the key is an env var; the endpoint has to arrive as config overrides. + set('OPENAI_API_KEY', MANAGED_CREDENTIAL); + set('CODEX_MODEL', argv.model || CODEX_DEFAULT_MODEL); + process.stdout.write(`\n# codex has no base-URL env var — pass these too:\n`); + process.stdout.write(`# codex ${codexOverrides(base).map((o) => `-c ${o}`).join(' ')}\n`); + process.stdout.write(`# or just: mirasim-relay codex --port ${port} -- \n`); + return; } + + set('ANTHROPIC_BASE_URL', base); + set('ANTHROPIC_AUTH_TOKEN', MANAGED_CREDENTIAL); +} + +// Launch the real codex against the proxy. Everything after `--` is forwarded verbatim. +async function cmdCodex(argv) { + const port = argv.port || 8787; + const host = argv.host || '127.0.0.1'; + const base = `http://${host}:${port}`; + const model = argv.model || CODEX_DEFAULT_MODEL; + const effort = argv.effort || CODEX_DEFAULT_EFFORT; + + if (!CODEX_MODELS.includes(model)) { + log(`warning: ${model} is not one of the relay's codex models (${CODEX_MODELS.join(', ')})`); + } + + const passthrough = argv._.slice(1); + const args = [...codexOverrides(base).flatMap((o) => ['-c', o]), '--model', model, ...passthrough]; + const env = { + ...process.env, + OPENAI_API_KEY: MANAGED_CREDENTIAL, + CODEX_MODEL: model, + CODEX_REASONING_EFFORT: effort, + }; + + const bin = process.env.MIRASIM_CODEX_BIN || 'codex'; + log(`${bin} -> ${base}/v1/responses (${model}, effort ${effort})`); + + const child = spawn(bin, args, { stdio: 'inherit', env }); + child.on('error', (err) => { + if (err.code === 'ENOENT') { + die(`codex not found — install @openai/codex, or set MIRASIM_CODEX_BIN.\n` + + `run it yourself with:\n OPENAI_API_KEY=${MANAGED_CREDENTIAL} codex ${codexOverrides(base).map((o) => `-c ${o}`).join(' ')}`); + } + die(err.message); + }); + await new Promise((resolve) => child.on('exit', (code, signal) => { + process.exitCode = signal ? 1 : code ?? 0; + resolve(); + })); } function usage() { @@ -691,16 +866,26 @@ function usage() { 用法: mirasim-relay login [--method email|github|google|token] [--email you@example.com] - mirasim-relay serve [--port 8787] [--host 127.0.0.1] [--agent claude] [-v] - mirasim-relay status - mirasim-relay env [--port 8787] [--bash] + mirasim-relay serve [--port 8787] [--host 127.0.0.1] [--agent claude|codex] [-v] + mirasim-relay codex [--port 8787] [--model ${CODEX_DEFAULT_MODEL}] [--effort ${CODEX_DEFAULT_EFFORT}] [-- ] + mirasim-relay status [--model ${CODEX_DEFAULT_MODEL}] + mirasim-relay env [--port 8787] [--bash] [--codex] mirasim-relay logout +中继同时提供两种协议(按路径分发,凭据完全一样): + POST /v1/messages Anthropic —— Claude Code 等 + POST /v1/responses OpenAI Responses —— Codex(wire_api=responses) + POST /v1/chat/completions OpenAI Chat Completions + GET /v1/models + +codex 模型: ${CODEX_MODELS.join(', ')} + 环境变量: MIRASIM_RELAY_BASE_URL 覆盖中继地址 (默认 ${RELAY_BASE}) MIRASIM_LOGIN_URL 覆盖登录地址 (默认 ${LOGIN_BASE}) MIRASIM_APP_VERSION 覆盖上报的客户端版本 (默认 ${CLIENT_VERSION}) MIRASIM_PROXY_HOME 覆盖配置目录 (默认 ${CONFIG_DIR}) + MIRASIM_CODEX_BIN codex 可执行文件 (默认 PATH 上的 codex) `); } @@ -708,8 +893,10 @@ function parseArgs(args) { const out = { _: [] }; for (let i = 0; i < args.length; i++) { const a = args[i]; + if (a === '--') { out._.push(...args.slice(i + 1)); break; } // rest belongs to the child process if (a === '-v' || a === '--verbose') { out.verbose = true; continue; } if (a === '--bash') { out.bash = true; continue; } + if (a === '--codex') { out.codex = true; continue; } if (a.startsWith('--')) { const [k, inline] = a.slice(2).split('='); out[k] = inline !== undefined ? inline : args[++i]; @@ -727,13 +914,14 @@ async function main() { switch (cmd) { case 'login': return cmdLogin(argv); case 'logout': return cmdLogout(); - case 'status': return cmdStatus(); + case 'status': return cmdStatus(argv); case 'env': return cmdEnv(argv); + case 'codex': return cmdCodex(argv); case 'serve': return serve({ port: Number(argv.port || 8787), host: argv.host || '127.0.0.1', - agent: argv.agent || 'claude', + agent: argv.agent, // unset means: report the agent the path implies verbose: Boolean(argv.verbose), }); default: @@ -750,6 +938,7 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me export { RELAY_BASE, LOGIN_BASE, CLIENT_VERSION, SIG_SCHEME, DEVICE_SESSION_PATH, KEPT_BETAS, + MANAGED_CREDENTIAL, CODEX_PROVIDER, CODEX_MODELS, CODEX_DEFAULT_MODEL, deviceIdentity, canonicalString, signatureHeaders, forwardHeaders, sanitizeHeader, - decodeJwt, TicketManager, serve, + classifyPath, codexOverrides, decodeJwt, TicketManager, serve, }; diff --git a/test.mjs b/test.mjs index 0bfadcf..43200ee 100644 --- a/test.mjs +++ b/test.mjs @@ -1,9 +1,11 @@ // Offline checks for the parts that have to match the shipped build byte-for-byte: // the mrs-sig-v1 canonical string, the device-id derivation, and the header rewriting. +// Plus live loopback checks that a client hanging up cannot take the proxy down. // Run: ./vendor/node test.mjs import crypto from 'node:crypto'; import fs from 'node:fs'; +import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; import assert from 'node:assert/strict'; @@ -12,6 +14,26 @@ import assert from 'node:assert/strict'; const home = fs.mkdtempSync(path.join(os.tmpdir(), 'mirasim-test-')); process.env.MIRASIM_PROXY_HOME = home; +// A stand-in relay, bound before the import so RELAY_BASE picks it up. It streams SSE one +// chunk at a time and never returns on its own, which is what lets the client hang up +// mid-response. `held` lets a test see that the upstream read was actually torn down. +const upstream = http.createServer(); +const held = { aborted: 0, requests: 0, last: null }; +upstream.on('request', (req, res) => { + held.requests++; + held.last = { path: req.url, headers: req.headers }; + req.resume(); + if (req.url.startsWith('/v1/device/session')) { res.writeHead(404).end('{}'); return; } + if (req.url.startsWith('/slow-headers')) return; // never responds at all + res.writeHead(200, { 'content-type': 'text/event-stream' }); + let n = 0; + const timer = setInterval(() => { if (!res.write(`data: {"n":${n++}}\n\n`)) clearInterval(timer); }, 5); + res.on('close', () => { clearInterval(timer); if (!res.writableEnded) held.aborted++; }); +}); +await new Promise((r) => upstream.listen(0, '127.0.0.1', r)); +process.env.MIRASIM_RELAY_BASE_URL = `http://127.0.0.1:${upstream.address().port}`; +process.env.MIRASIM_LOGIN_URL = process.env.MIRASIM_RELAY_BASE_URL; + const m = await import('./mirasim-relay.mjs'); let passed = 0; @@ -20,6 +42,11 @@ function check(name, fn) { catch (err) { process.stdout.write(` FAIL ${name}\n ${err.message}\n`); process.exitCode = 1; } } +async function checkAsync(name, fn) { + try { await fn(); process.stdout.write(` ok ${name}\n`); passed++; } + catch (err) { process.stdout.write(` FAIL ${name}\n ${err.message}\n`); process.exitCode = 1; } +} + check('device identity is stable across calls and persisted', () => { const a = m.deviceIdentity(); const b = m.deviceIdentity(); @@ -122,5 +149,122 @@ check('ticket manager reports no credential until a mint succeeds', () => { assert.equal(t.signingIdentity(), null); // unsigned fallback, matching the desktop build }); +check('path classifier matches the shipped router', () => { + assert.equal(m.classifyPath('/v1/messages'), 'anthropic'); + assert.equal(m.classifyPath('/v1/messages/'), 'anthropic'); + assert.equal(m.classifyPath('/v1/responses'), 'openai-responses'); + assert.equal(m.classifyPath('/backend-api/codex/responses'), 'openai-responses'); + assert.equal(m.classifyPath('/v1/chat/completions'), 'openai-chat'); + assert.equal(m.classifyPath('/openai/v1/chat/completions'), 'openai-chat'); + assert.equal(m.classifyPath('/v1/messages/count_tokens'), null); // forwarded, just unclassified + assert.equal(m.classifyPath('/v1/models'), null); +}); + +check('codex provider block matches the desktop overrides', () => { + const o = m.codexOverrides('http://127.0.0.1:8787'); + assert.deepEqual(o, [ + 'model_providers.apodex.name=apodex', + 'model_providers.apodex.base_url=http://127.0.0.1:8787/v1', + 'model_providers.apodex.wire_api=responses', + 'model_providers.apodex.env_key=OPENAI_API_KEY', + 'model_provider=apodex', + ]); + // wire_api=responses means codex POSTs base_url + /responses — the route the relay serves. + assert.equal(o[1].split('=')[1] + '/responses', 'http://127.0.0.1:8787/v1/responses'); +}); + +// --------------------------------------------------------------------------- +// Disconnect resilience — an agent hanging up must cost one request, not the server. +// --------------------------------------------------------------------------- + +// Fabricate a long-lived credential so serve() gets past the boot check. It is only ever +// presented to the loopback stand-in above. +{ + const file = path.join(home, 'config.json'); + const cfg = JSON.parse(fs.readFileSync(file, 'utf8')); + const claims = Buffer.from(JSON.stringify({ sub: 'test', exp: Math.floor(Date.now() / 1000) + 86400 })).toString('base64url'); + cfg.auth = { token: `h.${claims}.s`, exp: Math.floor(Date.now() / 1000) + 86400 }; + fs.writeFileSync(file, JSON.stringify(cfg), { mode: 0o600 }); +} + +const proxy = await m.serve({ port: 0, host: '127.0.0.1', verbose: false }); +const PROXY = `http://127.0.0.1:${proxy.address().port}`; + +const alive = async () => { + const res = await fetch(`${PROXY}/__health`); + assert.equal(res.status, 200, `health check returned ${res.status}`); + assert.equal((await res.json()).ok, true); +}; + +await checkAsync('survives a client aborting mid-stream', async () => { + const abort = new AbortController(); + const res = await fetch(`${PROXY}/v1/messages`, { + method: 'POST', body: '{"stream":true}', signal: abort.signal, + }); + assert.equal(res.status, 200); + const reader = res.body.getReader(); + await reader.read(); // one SSE chunk really arrived + const before = held.aborted; + abort.abort(); // this is what used to kill the process + await new Promise((r) => setTimeout(r, 120)); + assert.ok(held.aborted > before, 'upstream read was not torn down'); + await alive(); +}); + +await checkAsync('survives an abort while the upstream is still silent', async () => { + const abort = new AbortController(); + const pending = fetch(`${PROXY}/slow-headers`, { method: 'POST', body: '{}', signal: abort.signal }) + .catch(() => {}); + await new Promise((r) => setTimeout(r, 60)); + abort.abort(); + await pending; + await new Promise((r) => setTimeout(r, 60)); + await alive(); +}); + +await checkAsync('survives a client vanishing mid-upload', async () => { + const net = await import('node:net'); + const sock = net.connect(proxy.address().port, '127.0.0.1'); + await new Promise((r) => sock.once('connect', r)); + // Announce more body than we intend to send, then disappear. + sock.write('POST /v1/messages HTTP/1.1\r\nHost: x\r\ncontent-length: 4096\r\n\r\n{"a":1}'); + await new Promise((r) => setTimeout(r, 40)); + sock.destroy(); + await new Promise((r) => setTimeout(r, 60)); + await alive(); +}); + +await checkAsync('still serves normally after all of that', async () => { + const res = await fetch(`${PROXY}/v1/messages`, { method: 'POST', body: '{}' }); + assert.equal(res.status, 200); + const reader = res.body.getReader(); + const first = await reader.read(); + assert.match(Buffer.from(first.value).toString(), /^data: /); + await reader.cancel(); +}); + +await checkAsync('responses traffic is forwarded and reported as codex', async () => { + const res = await fetch(`${PROXY}/v1/responses`, { + method: 'POST', + headers: { authorization: 'Bearer whatever-codex-sent', 'openai-beta': 'responses=experimental' }, + body: JSON.stringify({ model: m.CODEX_DEFAULT_MODEL, input: 'hi' }), + }); + assert.equal(res.status, 200); + await res.body.cancel(); + assert.equal(held.last.path, '/v1/responses'); + assert.equal(held.last.headers['x-mirasim-agent'], 'codex'); + // codex's own key is replaced, never forwarded. + assert.match(held.last.headers.authorization, /^Bearer h\./); + assert.equal(held.last.headers['openai-beta'], 'responses=experimental'); +}); + +await checkAsync('anthropic traffic is still reported as claude', async () => { + const res = await fetch(`${PROXY}/v1/messages`, { method: 'POST', body: '{}' }); + await res.body.cancel(); + assert.equal(held.last.headers['x-mirasim-agent'], 'claude'); +}); + +proxy.close(); +upstream.close(); fs.rmSync(home, { recursive: true, force: true }); process.stdout.write(`\n${passed} passed${process.exitCode ? ', with failures' : ''}\n`);