#!/usr/bin/env node // mirasim-relay — a standalone reimplementation of the local relay that Mirasim's // desktop build runs in-process, so the same account credit is reachable from Linux. // // Everything here mirrors what mirasim-server-linux-x64 0.0.146 does in `server.cjs`: // login POST {LOGIN_BASE}/auth/code {email} -> {dev_code?} // POST {LOGIN_BASE}/auth/verify {email, code} -> {access_token, refresh_token} // 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 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 // // 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'; 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'; // --------------------------------------------------------------------------- // Constants lifted from the shipped build. // --------------------------------------------------------------------------- const RELAY_BASE = stripSlash(process.env.MIRASIM_RELAY_BASE_URL || process.env.MIRASIM_RELAY_URL || 'https://mirasim-relay.mirofish.ai'); const LOGIN_BASE = stripSlash(process.env.MIRASIM_LOGIN_URL || 'https://admin.test.mirofish.ai'); const CLIENT_VERSION = process.env.MIRASIM_APP_VERSION || '0.0.146'; const SIG_SCHEME = 'mrs-sig-v1'; const DEVICE_SESSION_PATH = '/v1/device/session'; const NONCE_BYTES = 12; const DEVICE_ID_LEN = 22; const H_DEVICE = 'x-mirasim-device'; const H_TS = 'x-mirasim-ts'; const H_NONCE = 'x-mirasim-nonce'; const H_SIG = 'x-mirasim-sig'; const H_CLIENT = 'x-mirasim-client'; const H_AGENT = 'x-mirasim-agent'; const H_SESSION = 'x-mirasim-session'; const H_CALL = 'x-mirasim-call'; const H_PROBE = 'x-mirasim-probe'; // Only this anthropic-beta value survives the hop upstream; the rest are dropped, because // the relay's own account is what decides which betas are actually available. const KEPT_BETAS = ['context-1m-2025-08-07']; const HOP_BY_HOP = new Set(['content-length', 'transfer-encoding', 'connection', 'host']); const REFRESH_MARGIN_SEC = 900; // refresh the access token this long before `exp` const TICKET_RENEW_MARGIN_MS = 120_000; const TICKET_RETRY_MS = 30_000; // backoff after a failed mint const TICKET_UNSUPPORTED_MS = 900_000; // how long a 404/501 pins us to the plain token 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'); // --------------------------------------------------------------------------- // Small helpers // --------------------------------------------------------------------------- function stripSlash(u) { return String(u).replace(/\/+$/, ''); } function nowSec() { return Math.floor(Date.now() / 1000); } function die(msg) { process.stderr.write(`mirasim-relay: ${msg}\n`); process.exit(1); } function log(msg) { process.stderr.write(`${msg}\n`); } // Header values must stay ASCII; the desktop build percent-encodes anything else rather // than letting undici reject the request. function sanitizeHeader(value) { const s = String(value); if (!/[^ -~]/.test(s)) return s; let out = ''; for (const ch of s) { if (!/[^ -~]/.test(ch) && ch !== '%') { out += ch; continue; } for (const b of Buffer.from(ch, 'utf8')) out += '%' + b.toString(16).toUpperCase().padStart(2, '0'); } return out; } function decodeJwt(token) { try { const part = String(token).split('.')[1]; if (!part) return null; return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')); } catch { return null; } } async function readJsonError(res) { let detail = ''; try { const text = await res.text(); try { const j = JSON.parse(text); detail = j.error?.message || j.message || j.error || text; } catch { detail = text; } } catch { /* body already consumed or unreadable */ } return new Error(`HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 400)}` : ''}`); } // --------------------------------------------------------------------------- // Config store — 0600, same shape as the desktop build's `auth` / `device` sections. // --------------------------------------------------------------------------- function loadConfig() { try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); } catch { return {}; } } function saveConfig(cfg) { fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); const tmp = `${CONFIG_PATH}.tmp`; fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2), { mode: 0o600 }); fs.renameSync(tmp, CONFIG_PATH); } function storeTokens({ token, refreshToken }) { const claims = decodeJwt(token); if (!claims) throw new Error('the sign-in response was not a readable JWT'); const cfg = loadConfig(); cfg.auth = { token, userId: claims.sub, exp: claims.exp, ...(refreshToken ? { refreshToken } : cfg.auth?.refreshToken ? { refreshToken: cfg.auth.refreshToken } : {}), }; saveConfig(cfg); return cfg.auth; } // --------------------------------------------------------------------------- // Account auth // --------------------------------------------------------------------------- async function requestEmailCode(email) { const res = await fetch(`${LOGIN_BASE}/auth/code`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email }), }); if (!res.ok) throw await readJsonError(res); const body = await res.json().catch(() => ({})); return typeof body.dev_code === 'string' ? body.dev_code : null; } async function verifyEmailCode(email, code) { const res = await fetch(`${LOGIN_BASE}/auth/verify`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, code }), }); if (!res.ok) throw await readJsonError(res); const body = await res.json(); if (typeof body.access_token !== 'string' || !body.access_token) { throw new Error('sign-in response carried no access_token'); } return { token: body.access_token, refreshToken: body.refresh_token || undefined }; } async function listProviders() { try { const res = await fetch(`${LOGIN_BASE}/auth/oauth/providers`); if (!res.ok) return []; const body = await res.json(); return Array.isArray(body.providers) ? body.providers : []; } catch { return []; } } async function fetchProfile(token) { try { const res = await fetch(`${LOGIN_BASE}/auth/me`, { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) return {}; return await res.json(); } catch { return {}; } } // Browser sign-in: spin up a throwaway loopback listener, hand the relay a redirect_uri // pointing at it, and take the tokens off the callback query string. function oauthLogin(provider, { timeoutMs = 300_000 } = {}) { return new Promise((resolve, reject) => { let settled = false; const server = http.createServer((req, res) => { const url = new URL(req.url || '/', 'http://127.0.0.1'); if (url.pathname !== '/callback') { res.writeHead(404); res.end(); return; } const token = url.searchParams.get('access_token') || url.searchParams.get('token'); const refreshToken = url.searchParams.get('refresh_token') || undefined; const ok = Boolean(token); res.writeHead(ok ? 200 : 400, { 'content-type': 'text/html; charset=utf-8' }); res.end(`

${ok ? '登录成功' : '登录失败'}

${ok ? '可以关闭此页面,回到终端继续。' : '未收到 token,请回到终端重试。'}

`); finish(() => (ok ? resolve({ token, refreshToken }) : reject(new Error('callback carried no token')))); }); const timer = setTimeout(() => finish(() => reject(new Error('browser sign-in timed out'))), timeoutMs); timer.unref?.(); function finish(fn) { if (settled) return; settled = true; clearTimeout(timer); server.close(() => fn()); } server.on('error', (err) => finish(() => reject(err))); server.listen(0, '127.0.0.1', () => { const redirect = `http://127.0.0.1:${server.address().port}/callback`; const url = `${LOGIN_BASE}/auth/oauth/${provider}/login?redirect_uri=${encodeURIComponent(redirect)}`; log(` 在浏览器里打开以下地址完成 ${provider} 登录:\n ${url}\n`); openBrowser(url); }); }); } function openBrowser(url) { const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]]; try { const child = spawn(cmd, args, { stdio: 'ignore', detached: true }); child.on('error', () => {}); child.unref(); } catch { /* headless box: the printed URL is the fallback */ } } // Returns a valid access token, refreshing through /auth/refresh when it is close to expiry. async function currentToken({ onReauthRequired } = {}) { const cfg = loadConfig(); const auth = cfg.auth; if (!auth?.token) throw new Error('not signed in — run `mirasim-relay login` first'); if (auth.exp - nowSec() > REFRESH_MARGIN_SEC) return auth.token; if (!auth.refreshToken) { if (auth.exp <= nowSec()) { onReauthRequired?.(); throw new Error('access token expired and no refresh token is stored — run `mirasim-relay login` again'); } return auth.token; } try { const res = await fetch(`${LOGIN_BASE}/auth/refresh`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ refresh_token: auth.refreshToken }), }); if (!res.ok) { if (res.status === 401 || res.status === 403) onReauthRequired?.(); return auth.token; // let the relay be the judge; a stale token still beats no token } const body = await res.json(); if (typeof body.access_token !== 'string' || !body.access_token) return auth.token; return storeTokens({ token: body.access_token, refreshToken: body.refresh_token || auth.refreshToken, }).token; } catch { return auth.token; } } // --------------------------------------------------------------------------- // Device identity and request signing (mrs-sig-v1) // --------------------------------------------------------------------------- let cachedIdentity = null; function deviceIdentity() { if (cachedIdentity) return cachedIdentity; const cfg = loadConfig(); let pem = cfg.device?.privateKey; if (!pem) { const { privateKey } = crypto.generateKeyPairSync('ed25519'); pem = privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(); saveConfig({ ...loadConfig(), device: { privateKey: pem } }); } const privateKey = crypto.createPrivateKey(pem); const publicKeyB64 = crypto.createPublicKey(privateKey) .export({ format: 'der', type: 'spki' }) .toString('base64'); const deviceId = crypto.createHash('sha256').update(publicKeyB64).digest('base64url').slice(0, DEVICE_ID_LEN); cachedIdentity = { deviceId, publicKeyB64, sign: (msg) => crypto.sign(null, Buffer.from(msg, 'utf8'), privateKey).toString('base64url'), }; return cachedIdentity; } function canonicalString({ method, path: p, ts, nonce, bodySha256 }) { return [SIG_SCHEME, method.toUpperCase(), p, ts, nonce, bodySha256].join('\n'); } function signatureHeaders(identity, { method, path: p, body }) { const ts = String(Date.now()); const nonce = crypto.randomBytes(NONCE_BYTES).toString('base64url'); const bodySha256 = crypto.createHash('sha256').update(body ?? Buffer.alloc(0)).digest('hex'); const sig = identity.sign(canonicalString({ method, path: p, ts, nonce, bodySha256 })); return { [H_DEVICE]: identity.deviceId, [H_TS]: ts, [H_NONCE]: nonce, [H_SIG]: sig, [H_CLIENT]: CLIENT_VERSION, }; } // --------------------------------------------------------------------------- // Device-session ticket // // The relay may hand back a short-lived ticket that supersedes the account JWT as the // bearer credential. It is strictly an upgrade: on 404/501 (or any failure) we keep using // the plain token, which is exactly the fallback the desktop build takes. // --------------------------------------------------------------------------- class TicketManager { #state = null; #inFlight = null; #mintedFor = ''; #nextAttemptMs = 0; #unsupportedUntilMs = 0; get unsupported() { return Date.now() < this.#unsupportedUntilMs; } /** The ticket to use as bearer, or null when the plain account token should be used. */ credential() { const s = this.#state; if (!s) return null; if (Date.now() >= s.expiresAtMs) { this.#state = null; return null; } return s.ticket; } /** Signing key, only while a ticket is live — unsigned traffic is what the fallback sends. */ signingIdentity() { return this.#state ? deviceIdentity() : null; } snapshot() { return { deviceId: this.#state?.deviceId ?? deviceIdentity()?.deviceId ?? null, expiresAtMs: this.#state?.expiresAtMs ?? null, unsupported: this.unsupported, }; } async ensureFresh(issuerToken) { if (this.#inFlight) return this.#inFlight; if (Date.now() < this.#nextAttemptMs || this.unsupported) return; if (!issuerToken) return; if (this.#state && this.#mintedFor === issuerToken && Date.now() < this.#state.expiresAtMs - TICKET_RENEW_MARGIN_MS) return; this.#inFlight = this.#mint(issuerToken).finally(() => { this.#inFlight = null; }); return this.#inFlight; } async #mint(issuerToken) { this.#nextAttemptMs = Date.now() + TICKET_RETRY_MS; const identity = deviceIdentity(); if (!identity) { log('relay-ticket: no device key available — falling back to the plain token'); return; } const body = JSON.stringify({ publicKey: identity.publicKeyB64, deviceId: identity.deviceId }); const headers = { 'content-type': 'application/json', authorization: `Bearer ${issuerToken}`, ...signatureHeaders(identity, { method: 'POST', path: DEVICE_SESSION_PATH, body }), }; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 15_000); try { const res = await fetch(`${RELAY_BASE}${DEVICE_SESSION_PATH}`, { method: 'POST', headers, body, signal: ctrl.signal, }); if (res.status === 404 || res.status === 501) { this.#unsupportedUntilMs = Date.now() + TICKET_UNSUPPORTED_MS; res.text().catch(() => {}); log('relay-ticket: relay does not support device signing — using the plain token'); return; } if (!res.ok) { res.text().catch(() => {}); log(`relay-ticket: mint failed (${res.status}) — using the plain token`); return; } const body2 = await res.json(); if (typeof body2.ticket !== 'string' || !body2.ticket) return; const expiresAtMs = typeof body2.expiresIn === 'number' ? Date.now() + body2.expiresIn * 1000 : typeof body2.expiresAt === 'number' ? body2.expiresAt * 1000 : Date.now() + TICKET_DEFAULT_TTL_MS; this.#state = { ticket: body2.ticket, expiresAtMs, deviceId: identity.deviceId }; this.#mintedFor = issuerToken; this.#nextAttemptMs = 0; this.#unsupportedUntilMs = 0; log(`relay-ticket: device session established (device ${identity.deviceId})`); } catch (err) { log(`relay-ticket: mint error (${err.message}) — using the plain token`); } finally { clearTimeout(timer); } } invalidate() { this.#state = null; this.#mintedFor = ''; } } // --------------------------------------------------------------------------- // The local proxy // --------------------------------------------------------------------------- function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; 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)) { if (v === undefined) continue; if (HOP_BY_HOP.has(k.toLowerCase())) continue; out[k.toLowerCase()] = Array.isArray(v) ? v.join(', ') : v; } // anthropic-beta is filtered rather than passed through: only the values the relay // actually honours survive, and the header disappears entirely if none do. const beta = out['anthropic-beta']; if (beta !== undefined) { const kept = beta.split(',').map((s) => s.trim()).filter((s) => KEPT_BETAS.includes(s)); if (kept.length) out['anthropic-beta'] = kept.join(','); else delete out['anthropic-beta']; } return out; } // 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 (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; async function handle(req, res) { const url = new URL(req.url || '/', 'http://127.0.0.1'); 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(); res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ ok: true, relay: RELAY_BASE, device: snap.deviceId, ticket: Boolean(tickets.credential()), reauthNeeded })); return; } const body = await readBody(req); const abort = new AbortController(); res.on('close', () => { if (!res.writableEnded) abort.abort(); }); // 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. // Without either, a 401 is the relay's final answer and re-sending just doubles it. for (let attempt = 0; attempt < 2; attempt++) { const token = await currentToken({ onReauthRequired: () => { reauthNeeded = true; } }); await tickets.ensureFresh(token); const ticket = tickets.credential(); const credential = ticket ?? token; const headers = forwardHeaders(req.headers); delete headers.authorization; delete headers['x-api-key']; headers.authorization = `Bearer ${credential}`; headers[H_CLIENT] = CLIENT_VERSION; headers[H_AGENT] = sanitizeHeader(reportedAgent); headers[H_SESSION] = sessionId; headers[H_CALL] = callId; const identity = tickets.signingIdentity(); if (identity) { for (const [k, v] of Object.entries(signatureHeaders(identity, { method, path: pathname, body }))) { if (v) headers[k.toLowerCase()] = sanitizeHeader(v); } } const upstream = `${RELAY_BASE}${pathname}${url.search || ''}`; const started = Date.now(); const res2 = await fetch(upstream, { method, headers, body: method === 'GET' || method === 'HEAD' ? undefined : body, signal: abort.signal, redirect: 'manual', }); if (res2.status === 401 && attempt === 0 && (ticket || loadConfig().auth?.refreshToken)) { tickets.invalidate(); res2.body?.cancel?.().catch(() => {}); continue; } if (verbose) { log(` ${method} ${pathname} -> ${res2.status} ${Date.now() - started}ms [${kind || 'passthrough'}/${reportedAgent}]${ticket ? ' [ticket]' : ''}`); } const outHeaders = {}; res2.headers.forEach((v, k) => { 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) { 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; } } await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, resolve); }); const bound = server.address(); const base = `http://${host}:${bound.port}`; const snap = tickets.snapshot(); log(`mirasim-relay listening on ${base}`); 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=${MANAGED_CREDENTIAL} # replaced per request`); log(` codex: mirasim-relay codex --port ${bound.port} -- `); log(''); return server; } // --------------------------------------------------------------------------- // Commands // --------------------------------------------------------------------------- async function cmdLogin(argv) { const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); try { let method = argv.method; if (!method) { const providers = await listProviders(); log('登录方式:'); log(' 1) 邮箱验证码'); for (const [i, p] of providers.entries()) log(` ${i + 2}) ${p} (浏览器)`); log(` ${providers.length + 2}) 直接粘贴 access token`); const pick = (await rl.question('选择 [1]: ')).trim() || '1'; const n = Number(pick); if (n === 1) method = 'email'; else if (n >= 2 && n < providers.length + 2) method = providers[n - 2]; else method = 'token'; } let tokens; if (method === 'email') { const email = argv.email || (await rl.question('邮箱: ')).trim(); if (!email) throw new Error('email is required'); const devCode = await requestEmailCode(email); if (devCode) log(` (测试环境直接返回了验证码: ${devCode})`); else log(' 验证码已发送,请查收邮件。'); const code = (await rl.question('验证码: ')).trim(); tokens = await verifyEmailCode(email, code); } else if (method === 'token') { const token = (await rl.question('access token: ')).trim(); if (!token) throw new Error('token is required'); const refreshToken = (await rl.question('refresh token (可留空): ')).trim() || undefined; tokens = { token, refreshToken }; } else { tokens = await oauthLogin(method); } const auth = storeTokens(tokens); const profile = await fetchProfile(auth.token); const claims = decodeJwt(auth.token) || {}; log(''); log('已登录。'); log(` 账号 : ${profile.name || profile.email || claims.email || auth.userId}`); log(` 过期 : ${new Date(auth.exp * 1000).toISOString()}`); log(` 刷新 : ${auth.refreshToken ? '有 refresh token,会自动续期' : '无 refresh token,过期后需要重新登录'}`); log(` 配置 : ${CONFIG_PATH}`); } finally { rl.close(); } } async function cmdLogout() { const cfg = loadConfig(); delete cfg.auth; saveConfig(cfg); log('已退出登录(设备密钥保留)。'); } async function cmdStatus(argv = {}) { const cfg = loadConfig(); if (!cfg.auth?.token) die('not signed in — run `mirasim-relay login` first'); const token = await currentToken(); const claims = decodeJwt(token) || {}; const profile = await fetchProfile(token); const identity = deviceIdentity(); log(`账号 : ${profile.name || profile.email || claims.email || claims.sub}`); if (claims.plan) log(`套餐 : ${claims.plan}`); log(`token : exp ${new Date((claims.exp || 0) * 1000).toISOString()} (${Math.max(0, (claims.exp || 0) - nowSec())}s 后过期)`); log(`refresh : ${cfg.auth.refreshToken ? 'yes' : 'no'}`); log(`device : ${identity.deviceId}`); log(`relay : ${RELAY_BASE}`); log(`login : ${LOGIN_BASE}`); // Same probe the desktop build uses to read quota: a 1-token request carrying the // probe marker, whose rate-limit response headers are the actual payload of interest. log(''); log('探测中继额度…'); const res = await fetch(`${RELAY_BASE}/v1/messages`, { method: 'POST', headers: { 'anthropic-version': '2023-06-01', 'content-type': 'application/json', [H_PROBE]: 'usage', [H_CLIENT]: CLIENT_VERSION, Authorization: `Bearer ${token}`, }, body: JSON.stringify({ model: PROBE_MODEL, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }), }).catch((err) => { log(` 探测失败: ${err.message}`); return null; }); if (!res) return; const verdict = res.status === 401 || res.status === 403 ? 'invalid' : res.status === 429 ? 'rate-limited' : 'ok'; log(` HTTP ${res.status} -> ${verdict}`); res.headers.forEach((v, k) => { if (/ratelimit|retry-after/i.test(k)) log(` ${k}: ${v}`); }); if (verdict !== 'ok') { const text = await res.text().catch(() => ''); if (text) log(` ${text.slice(0, 400)}`); } 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) { const port = argv.port || 8787; const host = argv.host || '127.0.0.1'; const base = `http://${host}:${port}`; const fish = /fish/.test(process.env.SHELL || '') && !argv.bash; 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() { process.stderr.write(`mirasim-relay — 把 Mirasim 的本地中继单独跑起来(Linux 可用) 用法: mirasim-relay login [--method email|github|google|token] [--email you@example.com] 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) `); } 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]; continue; } out._.push(a); } return out; } async function main() { const argv = parseArgs(process.argv.slice(2)); const cmd = argv._[0]; switch (cmd) { case 'login': return cmdLogin(argv); case 'logout': return cmdLogout(); 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, // unset means: report the agent the path implies verbose: Boolean(argv.verbose), }); default: usage(); process.exit(cmd ? 1 : 0); } } // Only take over the process when run as a command; importing this file (tests) must not // start the CLI. if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { main().catch((err) => die(err?.message || String(err))); } 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, classifyPath, codexOverrides, decodeJwt, TicketManager, serve, };