feat(xiaomi-mimo): merge MiMo Desktop support into xiaomi-mimo as dual auth

Adds the Desktop-exclusive Preview models and the Xiaomi account-session
route to the existing xiaomi-mimo provider instead of a separate
xiaomi-desktop provider, so the dashboard shows one MiMo entry rather than
three overlapping ones.

Dual auth, same pattern as kimi — API key (sk-) covers the cloud API,
Desktop/OAuth adds the account session used by the Preview models:

- registry: category oauth, authModes [oauth, apikey], oauth block, the two
  mimo-x-*-preview models, and the invite signupUrl
- executor: routes Preview models to the account-service route with a Cookie
  session, everything else keeps the sourceFormat-matched transport
- oauth: custom ECDH encrypted-callback flow (X25519 -> SHA256 -> AES-256-GCM)
  with a loopback callback proxy, plus one-click import of the local Desktop
  auth.json
- usage: weekly quota from the account session

Fixes found while merging:

- the OAuth browser flow was dead: poll-status cleared the session before the
  client could POST /exchange, so every exchange returned 400
- a Claude-format client was sent to /v1/chat/completions instead of the
  declared /anthropic/v1/messages transport, because buildUrl ignored
  runtimeTransport
- stopXiaomiMimoProxy leaked every pending session (each holding an X25519
  private key) for the process lifetime
- the OAuth exchange did not persist the Desktop passToken, so the Preview
  models could never work after a browser sign-in

Removes dead code: the local engine token minting (mimoEngine, never called
on the request path), the model-catalog and usage routes, engineToken/
engineUrl plumbing, and an unread top-level usage block.

Adds tests/unit/xiaomi-mimo-{executor,oauth-session,oauth-proxy}.test.js —
the provider previously had none.
This commit is contained in:
叶炜朋
2026-09-10 23:41:40 +07:00
parent 83af3f1853
commit 73cb89143c
21 changed files with 1828 additions and 4 deletions

View File

@@ -19,6 +19,10 @@ async function tryBunSqlite() {
async function tryBetterSqlite() {
// Skip on Bun — better-sqlite3 native bindings unsupported
if (process.versions.bun) return null;
// Skip on Node >= 24: the native addon SIGSEGVs on load there, which is a
// process-level crash the try/catch below cannot recover from. node:sqlite covers it.
const [nodeMajor] = process.versions.node.split(".").map(Number);
if (nodeMajor >= 24) return null;
try {
const { createBetterSqliteAdapter } = await import("./adapters/betterSqliteAdapter.js");
return createBetterSqliteAdapter(DATA_FILE);

View File

@@ -130,6 +130,21 @@ export const GROK_CLI_CONFIG = { ...PROVIDER_OAUTH["grok-cli"] };
// 3) Redirect → ${cb}?refreshToken=...&loginHost=...&isRedirect=true
// 4) POST ExchangeToken {ClientID, RefreshToken, ClientSecret:"-"} → {Result.AccessToken, ExpiresAt}
// 5) POST GetUserInfo (x-cloudide-token) → email/name
// Xiaomi MiMo Desktop OAuth — custom ECDH encrypted-callback flow (NOT standard OAuth2).
// 1) Client generates X25519 keypair
// 2) Browser opens ${platformUrl}/authorize?pk=<pubkey>&redirect_uri=http://localhost:<port>/&kn=mimocode&key_name=...
// 3) Redirect → http://localhost:<port>/?u=<base64 encrypted payload>
// 4) Decrypt: ECDH(shared) → SHA256 → AES-256-GCM
// Layout: [12-byte nonce][32-byte ephemeral pubkey][ciphertext][16-byte GCM tag]
// 5) Result JSON: { uid, sk, url }
export const XIAOMI_MIMO_CONFIG = {
platformUrl: process.env.MIMO_PLATFORM_URL || "https://platform.xiaomimimo.com",
defaultBaseUrl: "https://api.xiaomimimo.com/v1",
kn: "mimocode",
callbackPath: "/",
timeoutMs: 300000, // 5 minutes
};
export const TRAE_CONFIG = {
clientId: "ono9krqynydwx5",
clientSecret: "-",

View File

@@ -0,0 +1,123 @@
import crypto from "crypto";
import { XIAOMI_MIMO_CONFIG } from "../constants/oauth.js";
// ───────────────────────────────────────────────────────────────────────────
// Xiaomi MiMo OAuth helpers
// Custom ECDH + AES-256-GCM encrypted-callback flow (NOT standard OAuth2).
// ───────────────────────────────────────────────────────────────────────────
/**
* Generate an X25519 keypair for the OAuth handshake.
* @returns {{ publicKey: string, privateKeyDer: Buffer }}
* publicKey — base64 SPKI (for the `pk` URL param)
* privateKeyDer — PKCS8 DER Buffer (for ECDH later)
*/
export function generateKeyPair() {
const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519");
const publicKeyDer = publicKey.export({ format: "der", type: "spki" });
// SPKI for X25519 is 44 bytes; the raw 32-byte key is the last 32 bytes.
// But the platform expects the full base64 SPKI — pass as-is.
const publicKeyB64 = publicKeyDer.toString("base64");
const privateKeyDer = privateKey.export({ format: "der", type: "pkcs8" });
return { publicKey: publicKeyB64, privateKeyDer };
}
/**
* Decrypt the `u` query parameter from the Xiaomi OAuth callback.
*
* Wire format (base64-decoded):
* bytes 0..11 — 12-byte AES-GCM nonce
* bytes 12..43 — 32-byte ephemeral public key (raw X25519)
* bytes 44..n-16 — ciphertext
* last 16 bytes — GCM auth tag
*
* Key derivation: SHA256(ECDH(clientPrivateKey, ephemeralPublicKey))
*
* @param {Buffer} privateKeyDer — PKCS8 DER private key from generateKeyPair()
* @param {string} encryptedB64 — the `u` query param value (base64)
* @returns {{ uid: string, sk: string, url?: string }}
*/
export function decryptCallback(privateKeyDer, encryptedB64) {
const raw = Buffer.from(encryptedB64, "base64");
if (raw.length < 12 + 32 + 16 + 1) {
throw new Error(`Encrypted payload too short: ${raw.length} bytes`);
}
const nonce = raw.subarray(0, 12);
const ephemeralPubRaw = raw.subarray(12, 44);
const ciphertextAndTag = raw.subarray(44);
const tag = ciphertextAndTag.subarray(ciphertextAndTag.length - 16);
const ciphertext = ciphertextAndTag.subarray(0, ciphertextAndTag.length - 16);
// Reconstruct the ephemeral public key as SPKI DER for Node crypto.
// X25519 SPKI prefix: 302a300506032b656e032100
const ephemeralPub = crypto.createPublicKey({
key: Buffer.concat([
Buffer.from("302a300506032b656e032100", "hex"),
ephemeralPubRaw,
]),
format: "der",
type: "spki",
});
const privateKey = crypto.createPrivateKey({
key: privateKeyDer,
format: "der",
type: "pkcs8",
});
const sharedSecret = crypto.diffieHellman({ privateKey, publicKey: ephemeralPub });
const derivedKey = crypto.createHash("sha256").update(sharedSecret).digest();
const decipher = crypto.createDecipheriv("aes-256-gcm", derivedKey, nonce);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
const parsed = JSON.parse(decrypted.toString("utf-8"));
if (!parsed || typeof parsed !== "object") {
throw new Error("Decrypted payload is not a valid object");
}
return {
uid: parsed.uid || null,
sk: parsed.sk || null,
url: parsed.url || XIAOMI_MIMO_CONFIG.defaultBaseUrl,
};
}
/**
* Build the browser authorization URL.
* @param {string} publicKey — base64 SPKI from generateKeyPair()
* @param {string} redirectUri — e.g. http://localhost:12345/
* @param {string} [keyName] — optional stable key name
* @returns {string}
*/
export function buildAuthorizeUrl(publicKey, redirectUri, keyName) {
const params = new URLSearchParams({
pk: publicKey,
redirect_uri: redirectUri,
kn: XIAOMI_MIMO_CONFIG.kn,
});
if (keyName) params.set("key_name", keyName);
return `${XIAOMI_MIMO_CONFIG.platformUrl}/authorize?${params.toString()}`;
}
/**
* Get or create a stable key name for this installation.
* Stored in the 9Router data dir so re-auth reuses the same name.
*/
export function getKeyName() {
// Use a deterministic name based on machine — avoids needing filesystem writes
// in the OAuth provider layer. The platform treats key_name as a label only.
const machineId = crypto
.createHash("sha256")
.update(`${process.platform}-${process.env.COMPUTERNAME || process.env.HOSTNAME || "unknown"}`)
.digest("hex")
.slice(0, 8);
return `9router-xmd-${machineId}`;
}

View File

@@ -755,3 +755,185 @@ export function stopZedProxy() {
zedProxyPort = null;
}
// ───────────────────────────────────────────────────────────────────────────
// Xiaomi MiMo Desktop OAuth callback proxy
// Receives the ECDH-encrypted `u` param, decrypts it, stores the session.
// ───────────────────────────────────────────────────────────────────────────
let xiaomiMimoProxyServer = null;
let xiaomiMimoProxyPort = null;
let xiaomiMimoProxyTimeout = null;
const xiaomiMimoSessions = new Map();
export function registerXiaomiMimoSession({ state, privateKeyDer }) {
if (!state || !privateKeyDer) return false;
xiaomiMimoSessions.set(state, {
privateKeyDer,
status: "pending",
createdAt: Date.now(),
});
return true;
}
export function getXiaomiMimoSessionStatus(state) {
const s = xiaomiMimoSessions.get(state);
if (!s) return null;
// Don't leak the private key to the client
return { status: s.status, result: s.result || null, error: s.error || null };
}
export function clearXiaomiMimoSession(state) {
xiaomiMimoSessions.delete(state);
}
function renderXiaomiMimoResultPage(success, message) {
const color = success ? "#22c55e" : "#ef4444";
const icon = success ? "&#10003;" : "&#10007;";
const title = success ? "Authentication Successful" : "Authentication Failed";
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>${title}</title>
<style>
body { font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f5; }
.container { text-align: center; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.icon { color: ${color}; font-size: 3rem; }
h1 { margin: 1rem 0; font-size: 1.25rem; }
p { color: #666; font-size: 0.875rem; }
</style>
</head>
<body>
<div class="container">
<div class="icon">${icon}</div>
<h1>${title}</h1>
<p>${message || (success ? "You can close this tab and return to 9Router." : "Please try again.")}</p>
${success ? "<script>setTimeout(() => window.close(), 3000);</script>" : ""}
</div>
</body>
</html>`;
}
/**
* Start the Xiaomi Desktop OAuth callback proxy.
* @returns {Promise<{success: boolean, port?: number, callbackUrl?: string, reason?: string}>}
*/
export function startXiaomiMimoProxy() {
return new Promise((resolve) => {
if (xiaomiMimoProxyServer) {
resolve({
success: true,
port: xiaomiMimoProxyPort,
callbackUrl: `http://127.0.0.1:${xiaomiMimoProxyPort}/`,
});
return;
}
const server = http.createServer(async (req, res) => {
// Origin guard
if (!isLoopbackOrigin(req.headers.origin)) {
res.writeHead(403);
res.end("Forbidden");
return;
}
const url = new URL(req.url, "http://127.0.0.1");
const u = url.searchParams.get("u");
if (!u) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderXiaomiMimoResultPage(false, "Missing encrypted payload (u parameter)."));
return;
}
// Try each pending session's private key — the callback URL carries no
// state param, so we attempt decryption with every pending key.
const pendingSessions = [...xiaomiMimoSessions.entries()]
.filter(([, s]) => s.status === "pending");
if (pendingSessions.length === 0) {
res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderXiaomiMimoResultPage(false, "No active OAuth session. Please restart the login flow."));
return;
}
try {
const { decryptCallback } = await import("../providers/xiaomi-mimo.js");
let result = null;
let matchedState = null;
for (const [state, session] of pendingSessions) {
try {
result = decryptCallback(session.privateKeyDer, u);
matchedState = state;
break;
} catch {
// Wrong key for this session — try next
}
}
if (!result || !matchedState) {
throw new Error("Could not decrypt with any pending session key");
}
if (!result.sk) {
throw new Error("Decrypted payload missing sk (API key)");
}
// Store result only in the matched session
const session = xiaomiMimoSessions.get(matchedState);
if (session) {
session.status = "done";
session.result = {
uid: result.uid,
accessToken: result.sk,
baseUrl: result.url || "https://api.xiaomimimo.com/v1",
};
}
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderXiaomiMimoResultPage(true, "Xiaomi account linked. You can close this tab."));
console.log("[xiaomi-mimo oauth] callback decrypted, uid:", result.uid);
} catch (err) {
console.error("[xiaomi-mimo oauth] decrypt failed:", err.message);
for (const [, session] of pendingSessions) {
session.status = "error";
session.error = err.message;
}
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderXiaomiMimoResultPage(false, `Decryption failed: ${err.message}`));
}
});
server.on("error", (err) => {
console.log("[xiaomi-mimo oauth] listen error:", err.message);
resolve({ success: false, reason: err.message });
});
server.listen(0, "127.0.0.1", () => {
xiaomiMimoProxyServer = server;
xiaomiMimoProxyPort = server.address().port;
xiaomiMimoProxyTimeout = setTimeout(() => {
console.log("[xiaomi-mimo oauth] timeout, stopping");
stopXiaomiMimoProxy();
}, 300000);
console.log(`[xiaomi-mimo oauth] listening on port ${xiaomiMimoProxyPort}`);
resolve({
success: true,
port: xiaomiMimoProxyPort,
callbackUrl: `http://127.0.0.1:${xiaomiMimoProxyPort}/`,
});
});
});
}
export function stopXiaomiMimoProxy() {
console.log(`[xiaomi-mimo oauth] stopping (port ${xiaomiMimoProxyPort || "-"})`);
if (xiaomiMimoProxyTimeout) { clearTimeout(xiaomiMimoProxyTimeout); xiaomiMimoProxyTimeout = null; }
if (xiaomiMimoProxyServer) { xiaomiMimoProxyServer.close(); xiaomiMimoProxyServer = null; }
xiaomiMimoProxyPort = null;
// No callback can arrive once the listener is down, so drop every pending
// session — each holds an X25519 private key and they would otherwise
// accumulate for the process lifetime (one per /authorize call).
xiaomiMimoSessions.clear();
}