From 772309c06d42c1cdb98a38cc36d3624b37dbed43 Mon Sep 17 00:00:00 2001 From: iceBear67 Date: Sat, 8 Aug 2026 17:28:44 +0000 Subject: [PATCH] init --- .gitignore | 3 + README.md | 89 ++++++ mirasim-relay | 26 ++ mirasim-relay.mjs | 755 ++++++++++++++++++++++++++++++++++++++++++++++ test.mjs | 126 ++++++++ tools/ext.py | 29 ++ 6 files changed, 1028 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 mirasim-relay create mode 100644 mirasim-relay.mjs create mode 100644 test.mjs create mode 100644 tools/ext.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..56ca304 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist/ +vendor/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..21c4598 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# mirasim-relay + +把 Mirasim 桌面版内置的「本地中继」单独拆出来,让 Linux 也能用自己账号的额度。 + +Mirasim 官方只发 macOS / Android 包,但它的 `SHA256SUMS` 里其实还有没在下载页挂出来的 +`mirasim-server-linux-x64-0.0.146.tar.gz`。这个脚本就是从那份官方 Linux 构建里把中继部分 +(登录 → 设备签名 → 转发)重新实现出来的,行为跟桌面版一致。 + +中继本身说的是 **Anthropic 的协议**,所以任何兼容 Anthropic API 的 agent 直接指过来就能用。 + +## 快速开始 + +```sh +./mirasim-relay login # 邮箱验证码 / GitHub / Google / 直接粘 token +./mirasim-relay status # 看账号、有效期、剩余额度 +./mirasim-relay serve # 起本地中继,默认 127.0.0.1:8787 +``` + +另开一个终端: + +```fish +set -gx ANTHROPIC_BASE_URL http://127.0.0.1:8787 +set -gx ANTHROPIC_AUTH_TOKEN mirasim-relay-managed-credential +claude +``` + +bash / zsh 用 `./mirasim-relay env --bash`,它会按当前 shell 输出对应语法。 +`ANTHROPIC_AUTH_TOKEN` 的值是什么都无所谓——每个请求都会被换成真正的中继凭据, +但大多数 agent 不设这个变量就不发请求,所以得给一个占位值。 + +## 命令 + +| 命令 | 说明 | +| --- | --- | +| `login [--method email\|github\|google\|token] [--email ...]` | 登录并保存凭据 | +| `serve [--port 8787] [--host 127.0.0.1] [--agent claude] [-v]` | 起本地中继,`-v` 打印每条请求 | +| `status` | 账号信息 + 用 1 token 的探测请求读额度 | +| `env [--port 8787] [--bash]` | 输出环境变量 | +| `logout` | 清除登录态(设备密钥保留) | + +`--agent` 会作为 `x-mirasim-agent` 上报,取值跟桌面版一致:`claude` / `codex` / `pi-gui`。 + +## 它到底做了什么 + +``` +agent ──► 127.0.0.1:8787 ──► https://mirasim-relay.mirofish.ai ──► Anthropic + (本脚本) +``` + +每个请求经过时: + +1. **换凭据** —— 删掉 agent 自己的 `authorization` / `x-api-key`,换成你的账号凭据。 +2. **续期** —— access token 距过期不足 15 分钟就用 refresh token 走 `/auth/refresh` 换新的。 +3. **设备签名** —— 首次运行生成一对 Ed25519 密钥,向 `/v1/device/session` 换一张短期 + ticket;拿到后每个请求都按 `mrs-sig-v1` 签名(`x-mirasim-device/ts/nonce/sig`)。 + 中继若不支持(404/501)就退回裸 token,跟桌面版的降级路径一样。 +4. **收敛 header** —— 丢掉 hop-by-hop 头;`anthropic-beta` 只保留中继真正认的 + `context-1m-2025-08-07`,其余丢弃。 + +响应是流式透传的,SSE 不会被缓冲。 + +## 配置 + +凭据存在 `~/.config/mirasim-proxy/config.json`(`0600`),里面有账号 token、refresh token +和设备私钥。换目录用 `MIRASIM_PROXY_HOME`。 + +其它可覆盖项:`MIRASIM_RELAY_BASE_URL`、`MIRASIM_LOGIN_URL`、`MIRASIM_APP_VERSION`、 +`MIRASIM_NODE`(指定 Node 运行时)。 + +## Node + +系统没装 Node 也能跑:`vendor/node` 是官方 Linux 包里那个 v22.23.1,启动器按 +`$MIRASIM_NODE` → PATH 上的 `node` → `vendor/node` 的顺序找。 +装了自己的 Node(>= 20)的话可以把 `vendor/` 删掉。 + +## 测试 + +```sh +./vendor/node test.mjs +``` + +13 个离线用例,覆盖签名规范串、deviceId 推导、header 改写、配置文件权限。 +不需要登录,也不会碰真实凭据。 + +## 注意 + +Mirasim 赠送额度的说法是 “Claude Code Max / Codex Pro usage **inside Mirasim**”。 +把中继拆出来给别的 agent 用,大概率不符合他们的服务条款,最坏情况是封号。 +自行判断。 diff --git a/mirasim-relay b/mirasim-relay new file mode 100755 index 0000000..1366ea0 --- /dev/null +++ b/mirasim-relay @@ -0,0 +1,26 @@ +#!/bin/sh +# Launcher for mirasim-relay.mjs. +# +# Resolution order for the runtime, widest-override-first: +# 1. $MIRASIM_NODE — explicit override +# 2. node on PATH — needs >= 18 for global fetch; >= 20 in practice +# 3. vendor/node — the v22 binary shipped inside mirasim-server-linux-x64, kept +# alongside this script so the tool is self-contained on a box +# with no system Node at all. +set -eu + +DIR="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="$DIR/mirasim-relay.mjs" + +if [ -n "${MIRASIM_NODE:-}" ]; then + NODE="$MIRASIM_NODE" +elif command -v node >/dev/null 2>&1; then + NODE="$(command -v node)" +elif [ -x "$DIR/vendor/node" ]; then + NODE="$DIR/vendor/node" +else + echo "mirasim-relay: no Node.js runtime found (set MIRASIM_NODE, install node >= 20, or restore vendor/node)" >&2 + exit 1 +fi + +exec "$NODE" "$SCRIPT" "$@" diff --git a/mirasim-relay.mjs b/mirasim-relay.mjs new file mode 100644 index 0000000..dc32b0c --- /dev/null +++ b/mirasim-relay.mjs @@ -0,0 +1,755 @@ +#!/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-shaped, plus /v1/chat/completions, /v1/models) +// +// The relay speaks the Anthropic wire format, so any Anthropic-compatible agent can point +// ANTHROPIC_BASE_URL at this process and never learn that a relay is involved. + +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 { 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'; + +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); + }); +} + +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; +} + +async function serve({ port, host, agent, verbose }) { + const tickets = new TicketManager(); + const sessionId = crypto.randomUUID(); + let reauthNeeded = false; + + // 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) => { + handle(req, res).catch((err) => { + if (res.headersSent) { res.destroy(err instanceof Error ? err : new Error(String(err))); return; } + res.writeHead(502, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ type: 'error', error: { type: 'proxy_error', message: String(err?.message || err) } })); + }); + }); + + 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(); + + 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(); }); + res.on('error', () => {}); + + // One retry, but only when retrying can actually change the credential we present: + // a live ticket we can drop, or a refresh token that can mint a new access token. + // 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(agent); + 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${ticket ? ' [ticket]' : ''}`); + } + + const outHeaders = {}; + res2.headers.forEach((v, k) => { + if (HOP_BY_HOP.has(k.toLowerCase())) return; + outHeaders[k] = v; + }); + res.writeHead(res2.status, outHeaders); + + if (res2.body) Readable.fromWeb(res2.body).pipe(res); + else res.end(); + 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)'}`); + if (reauthNeeded) log(' warning : the stored credential looks stale — run `mirasim-relay login` if requests start failing'); + log(''); + log(' point an agent at it:'); + log(` ANTHROPIC_BASE_URL=${base}`); + log(` ANTHROPIC_AUTH_TOKEN=mirasim-relay-managed-credential # replaced per request`); + log(''); +} + +// --------------------------------------------------------------------------- +// 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() { + 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(() => {}); + } +} + +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; + if (fish) { + process.stdout.write(`set -gx ANTHROPIC_BASE_URL ${base}\n`); + process.stdout.write(`set -gx ANTHROPIC_AUTH_TOKEN mirasim-relay-managed-credential\n`); + } else { + process.stdout.write(`export ANTHROPIC_BASE_URL=${base}\n`); + process.stdout.write(`export ANTHROPIC_AUTH_TOKEN=mirasim-relay-managed-credential\n`); + } +} + +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] [-v] + mirasim-relay status + mirasim-relay env [--port 8787] [--bash] + mirasim-relay logout + +环境变量: + MIRASIM_RELAY_BASE_URL 覆盖中继地址 (默认 ${RELAY_BASE}) + MIRASIM_LOGIN_URL 覆盖登录地址 (默认 ${LOGIN_BASE}) + MIRASIM_APP_VERSION 覆盖上报的客户端版本 (默认 ${CLIENT_VERSION}) + MIRASIM_PROXY_HOME 覆盖配置目录 (默认 ${CONFIG_DIR}) +`); +} + +function parseArgs(args) { + const out = { _: [] }; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '-v' || a === '--verbose') { out.verbose = true; continue; } + if (a === '--bash') { out.bash = 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(); + case 'env': return cmdEnv(argv); + case 'serve': + return serve({ + port: Number(argv.port || 8787), + host: argv.host || '127.0.0.1', + agent: argv.agent || 'claude', + 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, + deviceIdentity, canonicalString, signatureHeaders, forwardHeaders, sanitizeHeader, + decodeJwt, TicketManager, serve, +}; diff --git a/test.mjs b/test.mjs new file mode 100644 index 0000000..0bfadcf --- /dev/null +++ b/test.mjs @@ -0,0 +1,126 @@ +// Offline checks for the parts that have to match the shipped build byte-for-byte: +// the mrs-sig-v1 canonical string, the device-id derivation, and the header rewriting. +// Run: ./vendor/node test.mjs + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import assert from 'node:assert/strict'; + +// Point the config at a scratch dir so a real login is never touched. +const home = fs.mkdtempSync(path.join(os.tmpdir(), 'mirasim-test-')); +process.env.MIRASIM_PROXY_HOME = home; + +const m = await import('./mirasim-relay.mjs'); + +let passed = 0; +function check(name, fn) { + try { fn(); process.stdout.write(` ok ${name}\n`); passed++; } + catch (err) { process.stdout.write(` FAIL ${name}\n ${err.message}\n`); process.exitCode = 1; } +} + +check('device identity is stable across calls and persisted', () => { + const a = m.deviceIdentity(); + const b = m.deviceIdentity(); + assert.equal(a.deviceId, b.deviceId); + assert.equal(a.deviceId.length, 22); + const stored = JSON.parse(fs.readFileSync(path.join(home, 'config.json'), 'utf8')); + assert.ok(stored.device.privateKey.includes('BEGIN PRIVATE KEY')); +}); + +check('deviceId is sha256(spki-b64) base64url, first 22 chars', () => { + const id = m.deviceIdentity(); + const expected = crypto.createHash('sha256').update(id.publicKeyB64).digest('base64url').slice(0, 22); + assert.equal(id.deviceId, expected); +}); + +check('config file is not world-readable', () => { + const mode = fs.statSync(path.join(home, 'config.json')).mode & 0o777; + assert.equal(mode, 0o600, `mode was ${mode.toString(8)}`); +}); + +check('canonical string is the 6-field newline join', () => { + const s = m.canonicalString({ method: 'post', path: '/v1/messages', ts: '123', nonce: 'n', bodySha256: 'abc' }); + assert.equal(s, `${m.SIG_SCHEME}\nPOST\n/v1/messages\n123\nn\nabc`); + assert.equal(s.split('\n').length, 6); +}); + +check('signature verifies with the device public key', () => { + const id = m.deviceIdentity(); + const body = Buffer.from(JSON.stringify({ hello: 'world' })); + const h = m.signatureHeaders(id, { method: 'POST', path: m.DEVICE_SESSION_PATH, body }); + + const canonical = m.canonicalString({ + method: 'POST', + path: m.DEVICE_SESSION_PATH, + ts: h['x-mirasim-ts'], + nonce: h['x-mirasim-nonce'], + bodySha256: crypto.createHash('sha256').update(body).digest('hex'), + }); + + const pub = crypto.createPublicKey({ + key: Buffer.from(id.publicKeyB64, 'base64'), + format: 'der', + type: 'spki', + }); + assert.ok(crypto.verify(null, Buffer.from(canonical, 'utf8'), pub, + Buffer.from(h['x-mirasim-sig'], 'base64url')), 'signature did not verify'); + assert.equal(h['x-mirasim-device'], id.deviceId); + assert.equal(h['x-mirasim-client'], m.CLIENT_VERSION); +}); + +check('nonces do not repeat', () => { + const id = m.deviceIdentity(); + const seen = new Set(); + for (let i = 0; i < 200; i++) { + seen.add(m.signatureHeaders(id, { method: 'POST', path: '/x', body: Buffer.alloc(0) })['x-mirasim-nonce']); + } + assert.equal(seen.size, 200); +}); + +check('empty body still hashes (no undefined in canonical)', () => { + const id = m.deviceIdentity(); + const h = m.signatureHeaders(id, { method: 'GET', path: '/v1/models', body: undefined }); + assert.match(h['x-mirasim-sig'], /^[A-Za-z0-9_-]+$/); +}); + +check('hop-by-hop headers are dropped', () => { + const out = m.forwardHeaders({ + 'content-length': '10', 'transfer-encoding': 'chunked', connection: 'keep-alive', + host: 'localhost:8787', 'anthropic-version': '2023-06-01', + }); + assert.deepEqual(Object.keys(out).sort(), ['anthropic-version']); +}); + +check('anthropic-beta keeps only the relay-honoured value', () => { + const kept = m.KEPT_BETAS[0]; + assert.equal(m.forwardHeaders({ 'anthropic-beta': `foo,${kept}, bar` })['anthropic-beta'], kept); + assert.equal('anthropic-beta' in m.forwardHeaders({ 'anthropic-beta': 'foo,bar' }), false); +}); + +check('array header values are joined', () => { + assert.equal(m.forwardHeaders({ 'x-thing': ['a', 'b'] })['x-thing'], 'a, b'); +}); + +check('non-ascii header values are percent-encoded', () => { + assert.equal(m.sanitizeHeader('claude'), 'claude'); + assert.equal(m.sanitizeHeader('中'), '%E4%B8%AD'); +}); + +check('jwt claims are decoded from the payload segment', () => { + const payload = Buffer.from(JSON.stringify({ sub: 'u1', exp: 42 })).toString('base64url'); + const claims = m.decodeJwt(`h.${payload}.s`); + assert.equal(claims.sub, 'u1'); + assert.equal(claims.exp, 42); + assert.equal(m.decodeJwt('garbage'), null); +}); + +check('ticket manager reports no credential until a mint succeeds', () => { + const t = new m.TicketManager(); + assert.equal(t.credential(), null); + assert.equal(t.signingIdentity(), null); // unsigned fallback, matching the desktop build +}); + +fs.rmSync(home, { recursive: true, force: true }); +process.stdout.write(`\n${passed} passed${process.exitCode ? ', with failures' : ''}\n`); diff --git a/tools/ext.py b/tools/ext.py new file mode 100644 index 0000000..46b9d2a --- /dev/null +++ b/tools/ext.py @@ -0,0 +1,29 @@ +import re,sys +D=open('/home/user/mirasim-proxy/dist/server.decoded.js','rb').read().decode('latin-1') + +def defn(name, limit=1, span=1400): + """print body of `function NAME(` occurrences""" + out=[] + for m in re.finditer(r'function\s+'+re.escape(name)+r'\s*\(', D): + out.append(D[m.start():m.start()+span]) + if len(out)>=limit: break + return out + +def ctx(pat, span=600, limit=6, regex=False): + out=[] + it = re.finditer(pat if regex else re.escape(pat), D) + for m in it: + out.append((m.start(), D[max(0,m.start()-span):m.end()+span])) + if len(out)>=limit: break + return out + +if __name__=='__main__': + mode=sys.argv[1] + if mode=='def': + for b in defn(sys.argv[2], int(sys.argv[3]) if len(sys.argv)>3 else 1, + int(sys.argv[4]) if len(sys.argv)>4 else 1400): + print("-"*90); print(b) + else: + for off,b in ctx(sys.argv[2], int(sys.argv[3]) if len(sys.argv)>3 else 600, + int(sys.argv[4]) if len(sys.argv)>4 else 6): + print("-"*90); print(f"@{off}"); print(b)