#!/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 —— 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,
};
