init
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
dist/
|
||||||
|
vendor/
|
||||||
|
|
||||||
@@ -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 用,大概率不符合他们的服务条款,最坏情况是封号。
|
||||||
|
自行判断。
|
||||||
Executable
+26
@@ -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" "$@"
|
||||||
@@ -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(`<!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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -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`);
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user