Files
mirlay/mirasim-relay.mjs

945 lines
38 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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(`<!doctype html><meta charset="utf-8"><body style="font:16px system-ui;padding:3rem">
<h2>${ok ? '登录成功' : '登录失败'}</h2>
<p>${ok ? '可以关闭此页面,回到终端继续。' : '未收到 token,请回到终端重试。'}</p></body>`);
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} -- <codex args>`);
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} -- <codex args>\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}] [-- <codex 参数>]
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 —— Codexwire_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,
};