From 910db749aa52da368658e8949b54c1b85a4bc070 Mon Sep 17 00:00:00 2001 From: wismyzhizi Date: Wed, 23 Sep 2026 09:43:39 +0700 Subject: [PATCH] feat(xiaomi-mimo): server-assisted desktop login, five account clusters, v2.6 models Reproduce the MiMo Desktop login surface server-side so headless/Docker deployments can link a Xiaomi account without the Desktop client. The account session (passToken) is captured during the proxied login and stored per connection. - Five account clusters (cn/sgp/ams/ru/in): per-region mimo-server host and SSO sid, unknown region falls back to sgp - mimo-v2.6-pro/flash/pro-ultraspeed dual-route models: account-service route when desktop credentials exist, cloud API (sk- key) otherwise; drops obsolete mimo-x-*-preview ids - Desktop ServiceTokenManager 2-phase handshake (single serviceLogin with target sid, raw 64-bit nonce preserved), per-region session cache - reasoning_effort bridged to output_config.effort; i18n runtime now observes characterData mutations so React text rewrites get translated - Security hardening on the login proxy: session travels only in the httpOnly cookie (never in the URL), proxy branch requires dashboard auth, authorization/proxy-authorization never forwarded upstream, and upstream Set-Cookie is not replayed onto the app origin --- open-sse/executors/xiaomi-mimo.js | 63 +- open-sse/providers/registry/xiaomi-mimo.js | 24 +- open-sse/providers/thinkingLevels.js | 1 + open-sse/shared/mimoAccount.js | 191 +++-- open-sse/translator/concerns/paramSupport.js | 3 - public/i18n/literals/zh-CN.json | 27 +- .../api/oauth/xiaomi-mimo/api-key/route.js | 76 +- .../oauth/xiaomi-mimo/login/start/route.js | 140 ++++ .../oauth/xiaomi-mimo/login/status/route.js | 45 ++ src/dashboardGuard.js | 3 + src/i18n/runtime.js | 47 +- src/lib/mimoLoginSession.js | 726 ++++++++++++++++++ src/proxy.js | 66 +- src/shared/components/XiaomiMimoAuthModal.js | 471 +++++++----- tests/unit/xiaomi-mimo-executor.test.js | 116 ++- ...xiaomi-mimo-login-session-security.test.js | 40 + 16 files changed, 1687 insertions(+), 352 deletions(-) create mode 100644 src/app/api/oauth/xiaomi-mimo/login/start/route.js create mode 100644 src/app/api/oauth/xiaomi-mimo/login/status/route.js create mode 100644 src/lib/mimoLoginSession.js create mode 100644 tests/unit/xiaomi-mimo-login-session-security.test.js diff --git a/open-sse/executors/xiaomi-mimo.js b/open-sse/executors/xiaomi-mimo.js index 8b69412a..79f1bf30 100644 --- a/open-sse/executors/xiaomi-mimo.js +++ b/open-sse/executors/xiaomi-mimo.js @@ -1,10 +1,15 @@ import { DefaultExecutor } from "./default.js"; -import { getMimoAccountCookie, invalidateMimoAccountCookieCache, MIMO_API_BASE, MIMO_API_UA } from "../shared/mimoAccount.js"; +import { getMimoAccountCookie, invalidateMimoAccountCookieCache, resolveMimoServerBase, MIMO_API_UA } from "../shared/mimoAccount.js"; -// Desktop-exclusive Preview models. These are served by the account service's -// /api/route proxy, authorized by the Xiaomi account session (NOT the sk- key). -// See shared/mimoAccount.js for the session handshake. -const PREVIEW_MODELS = new Set(["mimo-x-pro-preview", "mimo-x-flash-preview"]); +// Dual-route v2.6 models. +// v2.6 models dynamically route to the account service when desktop session credentials +// (mimoPassToken or account cookie) are present to consume weekly quota, falling back to +// the cloud API (sk- key) otherwise. +const ACCOUNT_MODELS = new Set([ + "mimo-v2.6-pro", + "mimo-v2.6-flash", + "mimo-v2.6-pro-ultraspeed", +]); // Session cookie resolved in execute() (async) and read back by buildHeaders() // (sync — BaseExecutor.execute does not await it). Carried on the per-request @@ -23,15 +28,24 @@ export class XiaomiMimoExecutor extends DefaultExecutor { super("xiaomi-mimo"); } - static isPreviewModel(model) { - return PREVIEW_MODELS.has(bareModel(model)); + static isAccountRoute(model, credentials) { + const bare = bareModel(model); + if (!ACCOUNT_MODELS.has(bare)) return false; + return Boolean( + credentials?.[COOKIE_KEY] || + credentials?.providerSpecificData?.mimoPassToken + ); + } + + isAccountRoute(model, credentials) { + return XiaomiMimoExecutor.isAccountRoute(model, credentials); } buildUrl(model, stream, urlIndex = 0, credentials = null) { - // Preview models live on the account-service route, which is not one of the + // Account route models live on the account-service route, which is not one of the // declared transports — resolve it before the default runtimeTransport path. - if (XiaomiMimoExecutor.isPreviewModel(model)) { - return `${MIMO_API_BASE}/api/route/chat/completions`; + if (this.isAccountRoute(model, credentials)) { + return `${resolveMimoServerBase(credentials?.providerSpecificData)}/api/route/chat/completions`; } // Cloud API models keep default handling, so a Claude-format client reaches // the /anthropic/v1/messages transport. @@ -39,8 +53,8 @@ export class XiaomiMimoExecutor extends DefaultExecutor { } buildHeaders(credentials, stream = true, url, model) { - if (XiaomiMimoExecutor.isPreviewModel(model) && credentials?.[COOKIE_KEY]) { - // Preview models authenticate with the account-session cookie, not the key. + if (this.isAccountRoute(model, credentials) && credentials?.[COOKIE_KEY]) { + // Account route models authenticate with the account-session cookie, not the key. return { "Content-Type": "application/json", Accept: stream ? "text/event-stream" : "application/json", @@ -52,17 +66,22 @@ export class XiaomiMimoExecutor extends DefaultExecutor { } transformRequest(model, body, stream, credentials) { - // super runs stripUnsupportedParams, which flattens Preview content-part + // super runs stripUnsupportedParams, which flattens content-part // arrays (see the xiaomi-mimo rule in translator/concerns/paramSupport.js). const out = super.transformRequest(model, body, stream, credentials); - // Preview models: thinking/params get defaults only — never override what the - // caller set explicitly. (body.model is already `xiaomi/` via upstreamModelId.) - if (XiaomiMimoExecutor.isPreviewModel(model)) { - if (out.thinking == null) out.thinking = { type: "enabled" }; + // Account route models: bridge reasoning_effort to official output_config.effort + // (matches MiMo Desktop app.asar behavior). + if (this.isAccountRoute(model, credentials)) { + const rawEffort = out.reasoning_effort || body?.reasoning_effort || body?.output_config?.effort; + if (rawEffort) { + delete out.reasoning_effort; + const norm = String(rawEffort).toLowerCase() === "xhigh" ? "high" : String(rawEffort).toLowerCase(); + out.output_config = { ...(out.output_config || {}), effort: norm }; + } + if (out.temperature == null) out.temperature = 1.0; if (out.top_p == null) out.top_p = 0.95; - if (!out.max_tokens) out.max_tokens = 4096; } return out; @@ -70,13 +89,11 @@ export class XiaomiMimoExecutor extends DefaultExecutor { async execute(args) { const { model, credentials, proxyOptions = null } = args; - if (!XiaomiMimoExecutor.isPreviewModel(model)) return super.execute(args); + if (!this.isAccountRoute(model, credentials)) return super.execute(args); const cookie = await getMimoAccountCookie(credentials?.providerSpecificData, proxyOptions); if (!cookie) { - throw new Error( - "Xiaomi MiMo account session unavailable. Sign in to MiMo Desktop once so its passToken is present, then retry.", - ); + return super.execute(args); } credentials[COOKIE_KEY] = cookie; const result = await super.execute(args); @@ -94,6 +111,6 @@ export class XiaomiMimoExecutor extends DefaultExecutor { } } -export const __test__ = { PREVIEW_MODELS, bareModel, COOKIE_KEY }; +export const __test__ = { ACCOUNT_MODELS, bareModel, COOKIE_KEY }; export default XiaomiMimoExecutor; diff --git a/open-sse/providers/registry/xiaomi-mimo.js b/open-sse/providers/registry/xiaomi-mimo.js index cb0139b3..a92d48c5 100644 --- a/open-sse/providers/registry/xiaomi-mimo.js +++ b/open-sse/providers/registry/xiaomi-mimo.js @@ -2,9 +2,9 @@ import { CLAUDE_API_HEADERS } from "../shared.js"; // Dual auth (same pattern as kimi): // - API key (sk-...) → cloud API on api.xiaomimimo.com -// - Desktop account/OAuth → same cloud host, plus the Desktop-exclusive Preview -// models served by the account-service route on mimo-server-cn.xiaomimimo.com -// (authorized by a Xiaomi account session cookie, not the key). +// - Desktop account/OAuth → same cloud host, plus the dual-route v2.6 models +// served by the account-service route (mimo-server-.xiaomimimo.com), +// authorized by a Xiaomi account session cookie, not the key. // Endpoint is picked per model in the executor, same as opencode-go's /responses split. export default { id: "xiaomi-mimo", @@ -30,6 +30,16 @@ export default { category: "oauth", authModes: ["oauth", "apikey"], hasOAuth: true, + // Keys are cluster-specific. MiMo Desktop declares five regions + // (CN/SGP/AMS/RU/IN) — host + sid follow mimo-server- / mimo. + regions: [ + { id: "cn", label: "China (中国大陆)" }, + { id: "sgp", label: "Singapore (新加坡)" }, + { id: "ams", label: "Europe · Amsterdam (欧洲)" }, + { id: "ru", label: "Russia (俄罗斯)" }, + { id: "in", label: "India (印度)" }, + ], + defaultRegion: "sgp", serviceKinds: ["llm", "tts"], transport: { baseUrl: "https://api.xiaomimimo.com/v1/chat/completions", @@ -50,10 +60,10 @@ export default { }, ], models: [ - // Desktop-exclusive — served by the account-service route, which only accepts - // OpenAI format, so supportedFormats pins them to the openai transport. - { id: "mimo-x-pro-preview", name: "MiMo-X-Pro-Preview", upstreamModelId: "xiaomi/mimo-x-pro-preview", supportedFormats: ["openai"] }, - { id: "mimo-x-flash-preview", name: "MiMo-X-Flash-Preview", upstreamModelId: "xiaomi/mimo-x-flash-preview", supportedFormats: ["openai"] }, + // Cloud API & Desktop dual-route models (prefers the desktop account quota when available) + { id: "mimo-v2.6-pro", name: "MiMo V2.6 Pro", upstreamModelId: "xiaomi/mimo-v2.6-pro", supportedFormats: ["openai"] }, + { id: "mimo-v2.6-flash", name: "MiMo V2.6 Flash", upstreamModelId: "xiaomi/mimo-v2.6-flash", supportedFormats: ["openai"] }, + { id: "mimo-v2.6-pro-ultraspeed", name: "MiMo V2.6 Pro UltraSpeed", upstreamModelId: "xiaomi/mimo-v2.6-pro-ultraspeed", supportedFormats: ["openai"] }, // Cloud API models (api.xiaomimimo.com/v1) { id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" }, { id: "mimo-v2.5", name: "MiMo V2.5" }, diff --git a/open-sse/providers/thinkingLevels.js b/open-sse/providers/thinkingLevels.js index 94897ec7..0890e4ec 100644 --- a/open-sse/providers/thinkingLevels.js +++ b/open-sse/providers/thinkingLevels.js @@ -41,6 +41,7 @@ const PATTERN_THINKING = [ { provider: "codex", pattern: "*gpt-5.6-terra*", levels: [...CODEX_GPT_5_6_LEVELS, "ultra"] }, { provider: "codex", pattern: "*gpt-5.6-luna*", levels: CODEX_GPT_5_6_LEVELS }, { pattern: "*codex*", levels: ["low", "medium", "high", "xhigh"] }, // codex cannot disable thinking + { pattern: "*mimo*v2.6*", levels: ["none", "low", "medium", "high", "xhigh"] }, // DeepSeek v4.* (Alibaba MaaS, probed live): effort low|medium|high|xhigh|max // all 200 via output_config.effort; "none" is a 400 on the anthropic route // (disable thinking instead). none kept for the picker = disable. diff --git a/open-sse/shared/mimoAccount.js b/open-sse/shared/mimoAccount.js index 996f38df..9da593a8 100644 --- a/open-sse/shared/mimoAccount.js +++ b/open-sse/shared/mimoAccount.js @@ -8,20 +8,42 @@ import { proxyAwareFetch } from "../utils/proxyFetch.js"; * Xiaomi MiMo account-session helpers (used for weekly quota). * * The weekly quota endpoint lives on the account service domain and is authorized - * by an account session cookie, NOT the sk- API key. Acquiring that cookie mirrors - * MiMo Desktop: a passToken (persisted in Desktop's cookie store) is exchanged via - * the passportapi SSO, then authorized for the `mimopc` service, and finally stamped - * by the mimo-server /api/sts callback into a `serviceToken` cookie. + * by an account session cookie, NOT the sk- API key. Acquiring that cookie is a + * 1:1 port of MiMo Desktop's ServiceTokenManager (app.asar) — the GOLD STANDARD: * - * Flow (verified against MiMo Desktop traffic): - * 1. GET {api}/api/user/xiaomi/me -> 302 to account SSO (sid=mimopc) - * 2. GET account /pass/serviceLogin?sid=passportapi&_json=true -> nonce/ssecurity - * 3. GET {location}&clientSign=... -> account-level serviceToken - * 4. GET account /pass/serviceLogin?sid=mimopc&callback=&_json=true - * 5. GET {api}/api/sts?...&ticket... -> Set-Cookie: serviceToken (mimopc scope) + * getServiceToken(sid) / refreshServiceToken(sid): + * PHASE 1: GET https://account.xiaomi.com/pass/serviceLogin + * ?_locale=zh_CN&_snsNone=true&sid=&_json=true + * Cookie: {userId, passToken, cUserId} + * -> {code, location, ssecurity, nonce, bSecondValidation, notificationUrl} + * -> code !== 0 is an error (never silent) + * PHASE 2: GET {location}&clientSign=sha1(nonce & ssecurity), follow the + * redirect chain absorbing Set-Cookie -> serviceToken + * + * sid is per-cluster (SID_BY_REGION): CN = mimopc, SGP = mimosgp. */ -const API_BASE = "https://mimo-server-cn.xiaomimimo.com"; +// Account-service cluster hosts. MiMo Desktop declares five regions +// (rn = {CN, SGP, RU, IN, EU}); the EU cluster is deployed in Amsterdam. +// Host + sid naming is unified: mimo-server- / sid = mimo +// (ams is the only non-country code). Verified live via /api/user/xiaomi/me. +const API_BASE_BY_REGION = { + cn: "https://mimo-server-cn.xiaomimimo.com", + sgp: "https://mimo-server-sgp.xiaomimimo.com", + ams: "https://mimo-server-ams.xiaomimimo.com", + ru: "https://mimo-server-ru.xiaomimimo.com", + in: "https://mimo-server-in.xiaomimimo.com", +}; +const DEFAULT_API_BASE = API_BASE_BY_REGION.sgp; + +// Cluster service sid — 1:1 with the host code: mimo. +// Unknown/absent region falls back to SGP (the international/open cluster). +const SID_BY_REGION = { cn: "mimopc", sgp: "mimosgp", ams: "mimoams", ru: "mimoru", in: "mimoin" }; +function sidForRegion(region) { + const r = String(region || "").toLowerCase(); + return SID_BY_REGION[r] || SID_BY_REGION.sgp; +} +const API_BASE = DEFAULT_API_BASE; const ACCOUNT_HOST = "account.xiaomi.com"; const API_UA = "miNative PC/Normal Windows_NT/10.0.19045 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0"; @@ -112,65 +134,106 @@ function cookieHeader(jar) { .join("; "); } +/** + * Resolve the account-service base URL for a connection. + * @param {object|null} providerSpecificData - may carry `region` ("cn"|"sgp"|"ams"|"ru"|"in") + */ +export function resolveMimoServerBase(providerSpecificData = null) { + const region = String(providerSpecificData?.region || "").toLowerCase(); + return API_BASE_BY_REGION[region] || DEFAULT_API_BASE; +} + /** * Exchange a passToken for a mimo-server service session cookie. + * Primary path mirrors the Desktop ServiceTokenManager (app.asar): + * PHASE 1: GET /pass/serviceLogin?_locale=zh_CN&_snsNone=true&sid=&_json=true + * Cookie {userId,passToken,cUserId} -> {code,location,ssecurity,nonce} + * PHASE 2: GET {location}&clientSign=sha1(nonce&ssecurity), follow the chain + * (manual, absorbing Set-Cookie) -> serviceToken + * sid is per-cluster (SID_BY_REGION): cn=mimopc, sgp=mimosgp, ams=mimoams, ru=mimoru, in=mimoin. * @returns {Promise} Cookie header value, or null on failure. */ -async function acquireServiceCookie(passJar, proxyOptions) { +async function acquireServiceCookie(passJar, proxyOptions, apiBase = DEFAULT_API_BASE, region = "sgp") { + const r = String(region || "").toLowerCase(); + // Hard constraint: CN is ALWAYS direct (ignores proxy even if set) + const effectiveProxy = r === "cn" ? null : proxyOptions; + const sid = sidForRegion(r); + const viaDesktop = await acquireViaDesktopPhases(passJar, effectiveProxy, apiBase, sid); + if (viaDesktop) console.log(`[mimoAccount] desktop 2-phase OK (sid=${sid})`); + return viaDesktop; +} + +async function acquireViaDesktopPhases(passJar, proxyOptions, apiBase, sid) { + const failLog = (reason) => console.log(`[mimoAccount] desktopPhase fail: ${reason}`); const jar = { ...passJar }; - const ck = () => cookieHeader(jar); - // 1. Unauthenticated API call -> 302 carrying the sts callback (sid=mimopc) - const r1 = await proxyAwareFetch( - `${API_BASE}/api/user/xiaomi/me`, - { redirect: "manual", headers: { "User-Agent": API_UA, Cookie: ck() } }, + // PHASE 1 — single serviceLogin call with the TARGET sid (no passportapi + // prelude; ssecurity/nonce come straight from this response). + // Desktop only sends: userId, passToken, cUserId (no extra cookies) + const p1Jar = {}; + if (jar.userId) p1Jar.userId = jar.userId; + if (jar.passToken) p1Jar.passToken = jar.passToken; + if (jar.cUserId) p1Jar.cUserId = jar.cUserId; + + const p1Url = `https://${ACCOUNT_HOST}/pass/serviceLogin?_locale=zh_CN&_snsNone=true&sid=${encodeURIComponent(sid)}&_json=true`; + const p1 = await proxyAwareFetch( + p1Url, + { headers: { Cookie: cookieHeader(p1Jar), "User-Agent": SSO_UA, Accept: "application/json" } }, proxyOptions, ); - const redirect = r1.headers.get("location"); - if (!redirect) return null; - const stsCallback = new URL(redirect).searchParams.get("callback"); - if (!stsCallback) return null; + const raw = await p1.text(); + const clean = raw.replace(/^&&&START&&&/, ""); + // Nonce > 2^53 loses precision in JSON.parse — extract raw literal for signing + const rawNonce = clean.match(/"nonce"\s*:\s*(\d+)/)?.[1]; + let j = null; + try { j = JSON.parse(clean); } catch { /* handled below */ } + if (rawNonce && j) j.nonce = rawNonce; - // 2. passportapi SSO phase 1 -> nonce + ssecurity - const sso1 = await proxyAwareFetch( - `https://${ACCOUNT_HOST}/pass/serviceLogin?sid=passportapi&_json=true`, - { headers: { Cookie: ck(), "User-Agent": SSO_UA, Accept: "application/json" } }, - proxyOptions, - ); - const j1 = JSON.parse((await sso1.text()).replace(/^&&&START&&&/, "")); - const nonce = j1.nonce || (j1.location ? new URL(j1.location).searchParams.get("nonce") : null); - if (!nonce || !j1.location) return null; + if (!j || typeof j.code !== "number" || j.code !== 0 || !j.location || !j.nonce || !j.ssecurity) { + failLog( + `phase1 sid=${sid} http=${p1.status} code=${j?.code ?? "?"} hasLoc=${!!j?.location}` + + ` secondValidation=${j?.bSecondValidation ?? "?"} notificationUrl=${j?.notificationUrl ? "present" : "no"}` + + ` body=${JSON.stringify(raw.slice(0, 200))}`, + ); + return null; + } + absorbSetCookie(jar, p1); - // 3. passportapi SSO phase 2 -> account-level serviceToken - const sso2 = await proxyAwareFetch( - `${j1.location}&clientSign=${signatureClientSign(nonce, j1.ssecurity)}`, - { redirect: "manual", headers: { Cookie: ck(), "User-Agent": SSO_UA } }, - proxyOptions, - ); - absorbSetCookie(jar, sso2); + // PHASE 2 — clientSign the redirect, follow the redirect chain server-side. + // ⚠️ CRITICAL DESKTOP SPEC (app.asar / SSO_curl.cpp line 728: cookies.clear()): + // Phase 2 MUST NOT send ANY Cookie header! The server returns 200 OK with Set-Cookie: serviceToken! + const sep = j.location.includes("?") ? "&" : "?"; + let current = `${j.location}${sep}clientSign=${signatureClientSign(rawNonce || j.nonce, j.ssecurity)}`; - // 4. mimopc SSO -> sts callback carrying a ticket - const sso3 = await proxyAwareFetch( - `https://${ACCOUNT_HOST}/pass/serviceLogin?sid=mimopc&callback=${encodeURIComponent(stsCallback)}&_json=true`, - { headers: { Cookie: ck(), "User-Agent": SSO_UA, Accept: "application/json" } }, - proxyOptions, - ); - const j3 = JSON.parse((await sso3.text()).replace(/^&&&START&&&/, "")); - absorbSetCookie(jar, sso3); - if (!j3?.location || !/\/api\/sts/.test(j3.location)) return null; + for (let hop = 0; hop < 8; hop++) { + const res = await proxyAwareFetch( + current, + { redirect: "manual", headers: { "User-Agent": SSO_UA } }, + proxyOptions, + ); + absorbSetCookie(jar, res); + const loc = res.headers.get("location"); + if (res.status >= 300 && res.status < 400 && loc) { + current = new URL(loc, current).toString(); + continue; + } + break; + } - // 5. sts callback -> Set-Cookie: serviceToken (mimopc scope) - const sts = await proxyAwareFetch( - j3.location, - { redirect: "manual", headers: { "User-Agent": API_UA, Cookie: ck() } }, - proxyOptions, - ); - absorbSetCookie(jar, sts); + const sidKey = `${sid}_serviceToken`; + if (!jar.serviceToken && jar[sidKey]) { + jar.serviceToken = jar[sidKey]; + } - const needed = ["serviceToken", "mimopc_ph", "mimopc_slh", "userId"]; - if (!jar.serviceToken) return null; + if (!jar.serviceToken) { + failLog(`phase2 no serviceToken sid=${sid} jar=[${Object.keys(jar).join(",")}]`); + return null; + } const out = {}; - for (const k of needed) if (jar[k]) out[k] = jar[k]; + for (const [k, v] of Object.entries(jar)) { + if (!v) continue; + if (k === "serviceToken" || k === "userId" || /_(ph|slh)$/.test(k)) out[k] = v; + } return cookieHeader(out); } @@ -179,13 +242,15 @@ async function acquireServiceCookie(passJar, proxyOptions) { * @param {object|null} providerSpecificData - may carry `mimoPassToken` override */ async function getServiceCookie(providerSpecificData, proxyOptions) { + const apiBase = resolveMimoServerBase(providerSpecificData); const passJar = providerSpecificData?.mimoPassToken ? { passToken: providerSpecificData.mimoPassToken, userId: providerSpecificData.mimoUserId, cUserId: providerSpecificData.mimoCUserId } : await readDesktopAccountCookies(); if (!passJar) return { cookie: null, reason: "no-pass-token" }; - // One cached session per passToken — accounts/connections rotate independently. - const key = crypto.createHash("sha256").update(passJar.passToken).digest("hex"); + // One cached session per passToken+cluster — accounts/connections rotate + // independently, and the same passToken maps to different sessions per region. + const key = crypto.createHash("sha256").update(`${apiBase}|${passJar.passToken}`).digest("hex"); const cached = _cache.get(key); if (cached && Date.now() - cached.at < COOKIE_TTL_MS) { @@ -202,8 +267,9 @@ async function getServiceCookie(providerSpecificData, proxyOptions) { const promise = (async () => { try { - return await acquireServiceCookie(passJar, proxyOptions); - } catch { + return await acquireServiceCookie(passJar, proxyOptions, apiBase, providerSpecificData?.region); + } catch (e) { + console.log(`[mimoAccount] acquire threw: ${e?.message || e} | ${String(e?.stack || "").split("\n").slice(1, 4).join(" <- ")}`); return null; // network/parse failure — callers degrade, never throw } finally { _inflight.delete(key); @@ -234,7 +300,8 @@ export async function getMimoAccountCookie(providerSpecificData = null, proxyOpt try { const { cookie } = await getServiceCookie(providerSpecificData, proxyOptions); return cookie; - } catch { + } catch (e) { + console.log(`[mimoAccount] getMimoAccountCookie threw: ${e?.message || e} | ${String(e?.stack || "").split("\n").slice(1, 4).join(" <- ")}`); return null; } } @@ -250,7 +317,7 @@ export async function getMimoAccountUsage(providerSpecificData = null, proxyOpti } try { const res = await proxyAwareFetch( - `${API_BASE}/api/user/usage`, + `${resolveMimoServerBase(providerSpecificData)}/api/user/usage`, { headers: { "User-Agent": API_UA, Cookie: cookie, Accept: "application/json" }, signal: AbortSignal.timeout(10000) }, proxyOptions, ); diff --git a/open-sse/translator/concerns/paramSupport.js b/open-sse/translator/concerns/paramSupport.js index 90ac5f5f..b5c8a926 100644 --- a/open-sse/translator/concerns/paramSupport.js +++ b/open-sse/translator/concerns/paramSupport.js @@ -14,9 +14,6 @@ const STRIP_RULES = [ { provider: "github", match: (m) => /claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m), drop: ["thinking", "reasoning_effort"] }, // Cloudflare Workers AI: content must be plain string, rejects OpenAI content-part array (#1926) { provider: "cloudflare-ai", flattenContent: true }, - // MiMo Desktop Preview models (account-service route): content must be plain string, - // rejects OpenAI content-part array. Cloud models keep their parts (mimo-v2-omni is multi-modal). - { provider: "xiaomi-mimo", match: /preview/i, flattenContent: true }, { provider: "volcengine-ark", match: /glm-5/i, clampToModelMaxOutput: true }, // VolcEngine Ark caps the Kimi family at max_tokens <= 32768, but the model's // advertised ceiling is far higher (Kimi-K2.7-Code resolves to maxOutput 262144), diff --git a/public/i18n/literals/zh-CN.json b/public/i18n/literals/zh-CN.json index 5de38ff0..178da2ae 100644 --- a/public/i18n/literals/zh-CN.json +++ b/public/i18n/literals/zh-CN.json @@ -1390,5 +1390,30 @@ "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ 风险提示:此提供商使用的订阅/OAuth 会话未获官方授权用于代理/路由器使用。账户可能被限制或封禁。使用风险自负。", "✓ Confirm Add": "✓ 确认添加", "📝 Configure providers in dashboard or use environment variables": "📝 在仪表盘中配置提供商或使用环境变量", - "🔐 OAuth required. Add now and authenticate after Apply; tool list will be discovered after first connect.": "🔐 需要 OAuth。立即添加并在应用后认证;工具列表将在首次连接后自动发现。" + "🔐 OAuth required. Add now and authenticate after Apply; tool list will be discovered after first connect.": "🔐 需要 OAuth。立即添加并在应用后认证;工具列表将在首次连接后自动发现。", + "Reading local MiMo Desktop credentials...": "正在读取本地 MiMo 桌面版凭证...", + "Desktop Plan · Local credentials": "Desktop Plan · 本地凭证", + "This account is already connected (no need to import again)": "该账号已连接(无需重复导入)", + "Untested": "未测试", + "Re-sync local credentials": "重新同步本地凭证", + "Connect with local credentials": "使用本地凭证连接", + "or": "或", + "Browser Login": "网页登录", + "No Desktop required": "无需桌面客户端", + "Weekly quota": "周额度", + "Waiting for login...": "等待登录中...", + "Reopen login window": "重新打开登录窗口", + "Choose cluster & sign in": "选择集群并登录", + "Login session expired — please retry.": "登录会话已过期,请重试。", + "Failed to save credentials": "保存凭据失败", + "Import failed": "导入失败", + "No local Desktop credentials found": "未检测到本地桌面凭证", + "You can still sign in via browser — no Desktop client needed.": "仍可通过网页登录,无需桌面客户端。", + "Select account cluster": "选择小米账号集群", + "Choose the region cluster of your Xiaomi account:": "请选择你的小米账号所在地区集群:", + "China (Mainland)": "中国大陆", + "Singapore": "新加坡", + "Europe · Amsterdam": "欧洲 · 阿姆斯特丹", + "Russia": "俄罗斯", + "India": "印度" } diff --git a/src/app/api/oauth/xiaomi-mimo/api-key/route.js b/src/app/api/oauth/xiaomi-mimo/api-key/route.js index d8ceee97..02f4fe04 100644 --- a/src/app/api/oauth/xiaomi-mimo/api-key/route.js +++ b/src/app/api/oauth/xiaomi-mimo/api-key/route.js @@ -10,17 +10,19 @@ import { createProviderConnection } from "@/models"; */ export async function POST(request) { try { - const { apiKey, uid, baseUrl, mimoPassToken, mimoUserId, mimoCUserId } = await request.json(); + const { apiKey, uid, baseUrl, mimoPassToken, mimoUserId, mimoCUserId, region } = await request.json(); - if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) { + const key = typeof apiKey === "string" ? apiKey.trim() : ""; + const sessionOnly = !key && !!mimoPassToken; + + if (!key && !mimoPassToken) { return NextResponse.json( { error: "API key is required" }, { status: 400 }, ); } - const key = apiKey.trim(); - if (!key.startsWith("sk-")) { + if (key && !key.startsWith("sk-")) { return NextResponse.json( { error: "Invalid key format — expected sk- prefix" }, { status: 400 }, @@ -29,47 +31,55 @@ export async function POST(request) { const effectiveBaseUrl = (baseUrl || "https://api.xiaomimimo.com/v1").replace(/\/+$/, ""); - // Validate the key against the models endpoint + // Validate the key against the models endpoint (skipped for session-only) let validated = false; let modelCount = 0; - try { - const resp = await fetch(`${effectiveBaseUrl}/models`, { - method: "GET", - headers: { - Authorization: `Bearer ${key}`, - "X-Mimo-Source": "mimocode-cli", - }, - signal: AbortSignal.timeout(10000), - }); - if (resp.ok) { - const data = await resp.json(); - modelCount = Array.isArray(data?.data) ? data.data.length : 0; - validated = true; + if (key) { + try { + const resp = await fetch(`${effectiveBaseUrl}/models`, { + method: "GET", + headers: { + Authorization: `Bearer ${key}`, + "X-Mimo-Source": "mimocode-cli", + }, + signal: AbortSignal.timeout(10000), + }); + if (resp.ok) { + const data = await resp.json(); + modelCount = Array.isArray(data?.data) ? data.data.length : 0; + validated = true; + } + } catch { + // Network error — still allow import (key may be valid but network blocked) } - } catch { - // Network error — still allow import (key may be valid but network blocked) } - if (!validated) { + if (key && !validated) { // Soft-fail: store the key but mark as untested console.log("[xiaomi-mimo] key validation failed, storing as untested"); } - // Dedup: if a connection with the same uid or same key already exists, update it + // Dedup: same uid, same key, or same session identity+region const { getProviderConnections, updateProviderConnection } = await import("@/models"); + const normRegion = (typeof region === "string" && region) || undefined; const existing = (await getProviderConnections()).find( (c) => c.provider === "xiaomi-mimo" && ( (uid && c.email === `${uid}@xiaomi`) || - c.accessToken === key + (key && c.accessToken === key) || + (sessionOnly && mimoUserId && + c.providerSpecificData?.mimoUserId === mimoUserId && + (normRegion ? (c.providerSpecificData?.region || "cn") === normRegion : true)) ), ); if (existing) { const updated = await updateProviderConnection(existing.id, { - accessToken: key, + accessToken: key || existing.accessToken, providerSpecificData: { ...existing.providerSpecificData, uid: uid || existing.providerSpecificData?.uid || null, - baseUrl: effectiveBaseUrl, + baseUrl: key ? effectiveBaseUrl : (existing.providerSpecificData?.baseUrl || effectiveBaseUrl), + region: normRegion || existing.providerSpecificData?.region || "cn", + authMethod: sessionOnly ? "session" : (existing.providerSpecificData?.authMethod || "api_key"), // Per-account session credential — enables multi-account rotation. mimoPassToken: mimoPassToken || existing.providerSpecificData?.mimoPassToken || null, mimoUserId: mimoUserId || existing.providerSpecificData?.mimoUserId || null, @@ -94,25 +104,29 @@ export async function POST(request) { const connection = await createProviderConnection({ provider: "xiaomi-mimo", - authType: "api_key", - accessToken: key, + // "oauth" is the official authType for imported credential connections + // ([action]/route.js) — the list card and filters key off it; never + // invent new values ("session" hid the row from the provider card). + authType: sessionOnly ? "oauth" : "api_key", + accessToken: key || null, refreshToken: null, // API keys don't expire on a fixed schedule; use a long horizon expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), email: uid ? `${uid}@xiaomi` : null, - displayName: uid ? `Xiaomi ${uid}` : "Xiaomi MiMo", + displayName: uid ? `Xiaomi ${uid}${sessionOnly ? " (Session)" : ""}` : "Xiaomi MiMo", providerSpecificData: { uid: uid || null, baseUrl: effectiveBaseUrl, - authMethod: "api_key", - provider: "API Key", + authMethod: sessionOnly ? "session" : "api_key", + provider: sessionOnly ? "Session Login" : "API Key", + region: normRegion || "cn", modelCount, // Per-account session credential — enables multi-account rotation. mimoPassToken: mimoPassToken || null, mimoUserId: mimoUserId || null, mimoCUserId: mimoCUserId || null, }, - testStatus: validated ? "active" : "untested", + testStatus: validated ? "active" : (sessionOnly ? "active" : "untested"), }); return NextResponse.json({ diff --git a/src/app/api/oauth/xiaomi-mimo/login/start/route.js b/src/app/api/oauth/xiaomi-mimo/login/start/route.js new file mode 100644 index 00000000..712f3bca --- /dev/null +++ b/src/app/api/oauth/xiaomi-mimo/login/start/route.js @@ -0,0 +1,140 @@ +import { NextResponse } from "next/server"; +import { request as httpRequest } from "node:http"; +import { beginSession, encodeSessionCookie, rewriteMimoBases, absorbSetCookies as absorbResponseCookies, originOf, loginUpstreamFetch, SESSION_COOKIE } from "@/lib/mimoLoginSession"; + +/** + * POST /api/oauth/xiaomi-mimo/login/start + * Body: { region: "cn" | "sgp" | "ams" | "ru" | "in" } + * + * Walks the first two hops of the Desktop login surface server-side + * (me -> 302 account/pass/serviceLogin -> 302 /fe/service/login) and hands + * the browser a same-origin pageUrl carrying the 9r_mimo_login session cookie. + * All subsequent account.xiaomi.com traffic flows through src/proxy.js. + * + * Egress resolution: MIMO_LOGIN_PROXY env > (region=sgp: probe common LOCAL + * HTTP proxy ports — v2rayN/clash defaults) > direct. The resolved URL rides + * the session cookie so every hop/XHR uses the same exit. + */ + +const API_UA = + "miNative PC/Normal Windows_NT/10.0.19045 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0"; +const SSO_UA = "MiClaw/1.0"; +const LOCAL_PROXY_PORTS = [10808, 10809, 7890, 7891, 1080, 1081, 8080, 8888]; + +/** First local port answering a CONNECT to account.xiaomi.com (or null). */ +function probeLocalHttpProxy(timeoutMs = 500) { + const attempts = LOCAL_PROXY_PORTS.map( + (port) => + new Promise((resolve, reject) => { + let settled = false; + const done = (v) => { + if (settled) return; + settled = true; + // Promise.any picks the first FULFILLED value — failures must reject, + // otherwise an instant ECONNREFUSED from a closed candidate port would + // "win" with null before the real proxy answers. + if (v) resolve(v); + else reject(new Error(`no-proxy-${port}`)); + }; + try { + const req = httpRequest({ + host: "127.0.0.1", + port, + method: "CONNECT", + path: "account.xiaomi.com:443", + timeout: timeoutMs, + }); + req.on("connect", (res, socket) => { + socket.destroy(); + done(res.statusCode === 200 || res.statusCode === 202 ? `http://127.0.0.1:${port}` : null); + }); + req.on("timeout", () => { req.destroy(); done(null); }); + req.on("error", () => done(null)); + req.on("response", () => done(null)); + req.end(); + } catch { + done(null); + } + }), + ); + return Promise.any(attempts).catch(() => null); +} + +async function hop(sess, url, ua) { + return loginUpstreamFetch(url, { + redirect: "manual", + headers: { "User-Agent": ua, Accept: "text/html,application/json,*/*" }, + signal: AbortSignal.timeout(15000), + }, sess); +} + +export async function POST(request) { + try { + let region = "cn"; + try { + const body = await request.json(); + const r = String(body?.region || "").toLowerCase(); + // Known MiMo Desktop clusters (cn/sgp/ams/ru/in) — default cn. + if (r === "cn" || r === "sgp" || r === "ams" || r === "ru" || r === "in") region = r; + } catch { /* empty body — default cn */ } + + const sess = beginSession(region); + + // Egress — non-CN clusters may need an overseas exit for the login page's + // geo-decided features (e.g. Google sign-in); CN is always direct. + let egress = null; + let egressSource = "direct"; + if (region !== "cn") { + const found = await probeLocalHttpProxy(); + if (found) { + egress = found; + egressSource = "local-probe"; + } + } + sess.proxyUrl = egress; + + // Hop 1: me -> account SSO (callback carries the sts callback for THIS cluster) + const meRes = await hop(sess, `${sess.upstreamBase}/api/user/xiaomi/me`, API_UA); + absorbResponseCookies(sess, meRes, `${sess.upstreamBase}/api/user/xiaomi/me`); + const ssoLoc = meRes.headers.get("location"); + if (!ssoLoc || !/account\.xiaomi\.com/.test(ssoLoc)) { + return NextResponse.json( + { error: `Unexpected me response (${meRes.status}) — no account redirect` }, + { status: 502 }, + ); + } + + // Hop 2: serviceLogin -> /fe/service/login SPA (also seeds deviceId cookies) + const loginRes = await hop(sess, ssoLoc, SSO_UA); + absorbResponseCookies(sess, loginRes, ssoLoc); + const pageLoc = loginRes.headers.get("location"); + if (!pageLoc) { + return NextResponse.json( + { error: `Unexpected serviceLogin response (${loginRes.status})` }, + { status: 502 }, + ); + } + + // Same-origin path for the SPA (middleware proxies native prefixes). + const pageUrl = new URL(pageLoc, "https://account.xiaomi.com"); + const origin = originOf(request); + // Session travels ONLY in the httpOnly cookie — never in the URL (history, + // logs, Referer). /login/status re-arms the cookie on every poll, so a + // dropped-cookie browser still recovers on the next poll cycle. + const proxiedPath = rewriteMimoBases(pageUrl.pathname + pageUrl.search, "toProxy", origin); + const egressLog = egress ? egress.replace(/\/\/[^@/]+@/, "//***@") : ""; + console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] start region=${sess.region} origin=${origin} egress=${egressSource}${egressLog ? ` (${egressLog})` : ""} page=${pageUrl.pathname}`); + + const res = NextResponse.json({ success: true, state: sess.state, pageUrl: proxiedPath, region }); + res.cookies.set(SESSION_COOKIE, encodeSessionCookie(sess), { + path: "/", + httpOnly: true, + sameSite: "lax", + maxAge: 15 * 60, + }); + return res; + } catch (error) { + console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] start error:`, error?.message || error); + return NextResponse.json({ error: error?.message || "login start failed" }, { status: 500 }); + } +} diff --git a/src/app/api/oauth/xiaomi-mimo/login/status/route.js b/src/app/api/oauth/xiaomi-mimo/login/status/route.js new file mode 100644 index 00000000..840788ef --- /dev/null +++ b/src/app/api/oauth/xiaomi-mimo/login/status/route.js @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { sessionFromRequest, readSessionIdentity, attachSessionCookie } from "@/lib/mimoLoginSession"; + +/** + * GET /api/oauth/xiaomi-mimo/login/status?state=... + * Polls the server-side login session (state lives in the httpOnly session + * cookie — route handlers and the proxy don't share module memory). When a + * passToken is in the jar, probes /api/user/xiaomi/me once to confirm the + * session works, then returns the identity for the client to persist. + */ +export async function GET(request) { + const url = new URL(request.url); + const state = url.searchParams.get("state") || ""; + const sess = sessionFromRequest(request); + if (!sess || (state && sess.state !== state)) { + return NextResponse.json({ status: "expired" }, { status: 404 }); + } + + if (sess.status !== "done") { + // AUTHORIZATION = passToken in the jar (captured during the proxied login + // XHRs). No serviceToken exchange — weekly-quota API moved; re-wire later. + if (readSessionIdentity(sess)) sess.status = "done"; + } + + if (sess.status !== "done") { + // Re-arm the session cookie on every poll — the modal may sit on the login + // form much longer than the 15min TTL, and only proxied responses used to + // refresh it (browser silently drops an expired cookie before the POST). + return attachSessionCookie(NextResponse.json({ status: "pending", region: sess.region }), sess); + } + + const id = readSessionIdentity(sess); + if (!id) { + return attachSessionCookie( + NextResponse.json({ status: "error", error: "session captured but passToken missing" }), + sess, + ); + } + + const payload = { status: "done", region: sess.region, ...id }; + // One-shot: don't let the identity linger past the client reading it. + const res = NextResponse.json(payload); + res.cookies.set("9r_mimo_login", "", { path: "/", httpOnly: true, maxAge: 0 }); + return res; +} diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 3d4b2e32..fab0b291 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -191,6 +191,9 @@ function isPublicApi(pathname) { return PUBLIC_API_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)); } +// Shared with src/proxy.js — the mimo login branch must respect dashboard auth. +export { isAuthenticated }; + export const __test__ = { isLocalRequest, isPublicLlmApi, diff --git a/src/i18n/runtime.js b/src/i18n/runtime.js index fef93328..7391a7c9 100644 --- a/src/i18n/runtime.js +++ b/src/i18n/runtime.js @@ -56,12 +56,13 @@ export function onLocaleChange(callback) { // Process text node function processTextNode(node) { - if (!node.nodeValue || !node.nodeValue.trim()) return; - + const current = node.nodeValue; + if (!current || !current.trim()) return; + // Skip if parent is script, style, code, or structural elements const parent = node.parentElement; if (!parent) return; - + // Skip if parent or any ancestor has data-i18n-skip attribute let element = parent; while (element) { @@ -70,27 +71,33 @@ function processTextNode(node) { } element = element.parentElement; } - + const tagName = parent.tagName?.toLowerCase(); - + // Skip elements that don't allow text nodes const skipTags = [ "script", "style", "code", "pre", "colgroup", "table", "thead", "tbody", "tfoot", "tr", "select", "datalist", "optgroup" ]; - + if (skipTags.includes(tagName)) return; - - // Store original text if not already stored - if (!node._originalText) { - node._originalText = node.nodeValue; + + // React reuses text nodes and rewrites their value on re-render (a + // characterData mutation, no childList event). When the current value is + // neither our last translation nor the recorded original, it is fresh + // source text — re-capture it as the new original before translating. + const isOurTranslation = node._translated != null && current === node._translated; + const isSameAsOriginal = current === node._originalText; + if (!isOurTranslation && !isSameAsOriginal) { + node._originalText = current; } - - // Use original text for translation - const original = node._originalText; - const translated = translate(original); - + if (node._originalText == null) node._originalText = current; + + // Translate from the recorded original so locale switches stay idempotent + const translated = translate(node._originalText); + node._translated = translated; + // Only update if different to avoid unnecessary DOM mutations if (translated !== node.nodeValue) { node.nodeValue = translated; @@ -130,9 +137,16 @@ export async function initRuntimeI18n() { // Process existing DOM processElement(document.body); - // Watch for new nodes + // Watch for new nodes AND in-place text rewrites. React reuses text nodes on + // re-render (only nodeValue changes → a characterData mutation with no + // childList event), so observing childList alone leaves later-updated labels + // untranslated. const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { + if (mutation.type === "characterData") { + processTextNode(mutation.target); + return; + } mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { processElement(node); @@ -146,6 +160,7 @@ export async function initRuntimeI18n() { observer.observe(document.body, { childList: true, subtree: true, + characterData: true, }); } diff --git a/src/lib/mimoLoginSession.js b/src/lib/mimoLoginSession.js new file mode 100644 index 00000000..07d458f4 --- /dev/null +++ b/src/lib/mimoLoginSession.js @@ -0,0 +1,726 @@ +/** + * Server-side Xiaomi account session login (mimics MiMo Desktop's login surface). + * + * Flow (reverse-engineered from Desktop traffic / mimoAccount.js): + * 1. GET {mimo-server}/api/user/xiaomi/me -> 302 account /pass/serviceLogin?sid=mimopc&callback={sts} + * 2. GET account /pass/serviceLogin -> 302 /fe/service/login (the SPA) + * 3. Browser (via the src/proxy.js reverse proxy) completes login on the REAL + * page (password / whatever the page offers) — every account.xiaomi.com + * request passes through the proxy; Set-Cookie lands in OUR jar (which + * travels in the httpOnly 9r_mimo_login cookie between hops). + * 4. SPA navigates to the sts callback -> rewritten to /__mimo_login/mimo/*, + * middleware takes over and follows the chain server-side: + * sts -> Set-Cookie serviceToken -> me (200 JSON = logged in). + * 5. passToken/userId/cUserId read from the jar -> stored on the connection. + * + * Edge-safe: no Node-only APIs (used from both middleware and API routes). + */ + +const ACCOUNT_HOST = "account.xiaomi.com"; +export const SESSION_COOKIE = "9r_mimo_login"; +const SESSION_TTL_MS = 15 * 60 * 1000; +const API_UA = + "miNative PC/Normal Windows_NT/10.0.19045 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0"; +const SSO_UA = "MiClaw/1.0"; +const BROWSER_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + +// Paths that belong to the 9router app itself — never proxy these upstream, +// even while a login session is active. Everything else is fair game: the +// login SPA hits evolving endpoints (/pass2/config, /v3/...), so a static +// allowlist rots fast. (Edge-safe: plain strings only.) +const APP_PREFIXES = [ + "/_next/", "/api/", "/dashboard", "/v1/", "/v1beta/", + "/login", "/landing", "/__mimo_login/", "/i18n/", "/icons/", "/providers/", +]; +const APP_FILES = new Set([ + "/favicon.svg", "/favicon.ico", "/file.svg", "/globe.svg", "/next.svg", + "/vercel.svg", "/window.svg", "/sw.js", "/robots.txt", "/manifest.webmanifest", +]); + +const APP_PATH_MATCHES = (pathname) => + APP_FILES.has(pathname) || + APP_PREFIXES.some((p) => pathname === p || pathname.startsWith(p.endsWith("/") ? p : p + "/")); + +const MIMO_BASES = { + cn: "https://mimo-server-cn.xiaomimimo.com", + sgp: "https://mimo-server-sgp.xiaomimimo.com", + ams: "https://mimo-server-ams.xiaomimimo.com", + ru: "https://mimo-server-ru.xiaomimimo.com", + in: "https://mimo-server-in.xiaomimimo.com", +}; +// Unknown/absent region falls back to SGP (the international/open cluster). +const DEFAULT_REGION = "sgp"; +// passToken-prefix -> last failure ts (60s backoff for the service exchange) +const _exchangeBackoff = new Map(); + +export function resolveMimoRegionBase(region) { + const r = String(region || "").toLowerCase(); + return MIMO_BASES[r] || MIMO_BASES[DEFAULT_REGION]; +} + +/** + * Browser-facing origin for this request. Prefer the Host header — request.url + * / nextUrl may carry the bind address (0.0.0.0), which must never leak into + * rewritten callbacks (start and proxy must agree on the exact same origin, + * and both see the same Host header). + */ +export function originOf(request) { + const proto = request.nextUrl?.protocol + || (request.headers?.get?.("x-forwarded-proto") || "http"); + const host = request.headers?.get?.("host") || request.nextUrl?.host; + return host ? `${proto}//${host}` : (request.nextUrl?.origin || "http://localhost:20131"); +} + +// The session (incl. the accumulated cookie jar) travels in the SESSION_COOKIE +// itself — Next runs route handlers and the proxy in separate bundles, so a +// module-level Map is NOT shared between them. Cookie-carried state works +// regardless of runtime topology. httpOnly + SameSite=Lax, TTL-bounded. + +export function beginSession(region) { + const normalizedRegion = String(region || "").toLowerCase(); + return { + state: crypto.randomUUID(), + region: normalizedRegion in MIMO_BASES ? normalizedRegion : DEFAULT_REGION, + proxyUrl: null, // resolved by the start route (env | local-probe for sgp | null) + jar: new Map(), // "name|domain|path" -> { name, value, domain, path } + status: "pending", + createdAt: Date.now(), + upstreamBase: resolveMimoRegionBase(region), + }; +} + +const KEEP_ON_OVERFLOW = /^(passToken|userId|cUserId|serviceToken|.*_serviceToken|.*_ph|.*_slh|deviceId)$/; +// Browser hard limit for one cookie value is 4096 bytes. base64url inflates +// ~1.34x, so the JSON payload must stay under ~2800 to be safe. +const COOKIE_JSON_BUDGET = 2800; + +function b64urlEncode(str) { + const bytes = new TextEncoder().encode(str); + let bin = ""; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function b64urlDecode(s) { + let t = s.replace(/-/g, "+").replace(/_/g, "/"); + while (t.length % 4) t += "="; + const bin = atob(t); + return new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0))); +} + +export function encodeSessionCookie(sess) { + const entries = [...sess.jar.values()].map((c) => [c.name, c.value, c.domain, c.path]); + const base = { s: sess.state, r: sess.region, t: sess.createdAt, p: sess.proxyUrl || "" }; + let payload = JSON.stringify({ ...base, j: entries }); + if (payload.length > COOKIE_JSON_BUDGET) { + // Cookie budget: drop everything but identity/session essentials. + payload = JSON.stringify({ ...base, j: entries.filter(([name]) => KEEP_ON_OVERFLOW.test(name)) }); + } + return `v2.${b64urlEncode(payload)}`; +} + +export function decodeSessionCookie(value) { + if (!value) return null; + let payload; + try { + if (value.startsWith("v2.")) { + payload = JSON.parse(b64urlDecode(value.slice(3))); + } else if (value.startsWith("v1.")) { + payload = JSON.parse(decodeURIComponent(value.slice(3))); // legacy + } else { + return null; + } + } catch { + return null; + } + if (!payload || typeof payload.t !== "number") return null; + if (Date.now() - payload.t > SESSION_TTL_MS) return null; + const jar = new Map(); + for (const raw of payload.j || []) { + if (!Array.isArray(raw) || raw.length < 4) continue; + const [name, val, domain, path] = raw; + jar.set(`${name}|${domain}|${path}`, { name, value: val, domain, path }); + } + return { + state: payload.s, + region: payload.r in MIMO_BASES ? payload.r : DEFAULT_REGION, + proxyUrl: typeof payload.p === "string" && payload.p ? payload.p : null, + jar, + status: "pending", + createdAt: payload.t, + upstreamBase: resolveMimoRegionBase(payload.r), + }; +} + +/** Read the session straight from an incoming request (any runtime). */ +export function sessionFromRequest(request) { + const raw = request.cookies?.get?.(SESSION_COOKIE)?.value + ?? parseCookieHeader(request.headers?.get?.("cookie"))?.[SESSION_COOKIE]; + return decodeSessionCookie(raw || null); +} + +function parseCookieHeader(header) { + if (!header) return null; + const out = {}; + for (const part of header.split(";")) { + const i = part.indexOf("="); + if (i < 0) continue; + out[part.slice(0, i).trim()] = part.slice(i + 1).trim(); + } + return out; +} + +/** Append the re-encoded session to any Response (proxy writes go through here). */ +export function attachSessionCookie(response, sess) { + const headers = new Headers(response.headers); + headers.append( + "Set-Cookie", + `${SESSION_COOKIE}=${encodeSessionCookie(sess)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`, + ); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +export function clearedSessionCookie() { + return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; +} + +// ---------- cookie jar (server-side) ---------- + +function jarKey(c) { + return `${c.name}|${c.domain}|${c.path}`; +} + +function domainMatch(cookieDomain, host) { + const d = String(cookieDomain || "").replace(/^\./, "").toLowerCase(); + const h = String(host || "").toLowerCase(); + return h === d || h.endsWith("." + d); +} + +/** Parse one Set-Cookie header value. Returns { cookie, expired } or null. */ +function parseSetCookie(raw, requestUrl) { + if (!raw) return null; + const parts = raw.split(";"); + const nv = /^([^=]+)=([\s\S]*)$/.exec(parts[0].trim()); + if (!nv) return null; + const name = nv[1].trim(); + const value = (nv[2] || "").trim(); + const url = new URL(requestUrl); + const cookie = { + name, + value, + domain: url.hostname, + path: (url.pathname || "/").replace(/[^/]*$/, "") || "/", + }; + let expired = value === "EXPIRED"; + for (let i = 1; i < parts.length; i++) { + const f = parts[i].trim(); + const eq = f.indexOf("="); + const k = (eq >= 0 ? f.slice(0, eq) : f).trim().toLowerCase(); + const v = eq >= 0 ? f.slice(eq + 1).trim() : ""; + if (k === "domain" && v) cookie.domain = v.replace(/^\./, ""); + else if (k === "path" && v) cookie.path = v; + else if (k === "expires") { + if (/expired/i.test(v)) expired = true; + else { + const t = Date.parse(v); + if (!Number.isNaN(t) && t <= Date.now()) expired = true; + } + } else if (k === "max-age" && Number(v) <= 0) expired = true; + } + return { cookie, expired }; +} + +export function absorbSetCookies(sess, res, requestUrl) { + for (const raw of res.headers.getSetCookie?.() || []) { + const parsed = parseSetCookie(raw, requestUrl); + if (!parsed) continue; + const key = jarKey(parsed.cookie); + if (parsed.expired || !parsed.cookie.value) sess.jar.delete(key); + else sess.jar.set(key, parsed.cookie); + } +} + +function cookieHeaderFor(sess, targetUrl) { + const u = new URL(targetUrl); + const out = []; + for (const c of sess.jar.values()) { + if (!domainMatch(c.domain, u.hostname)) continue; + if (!(u.pathname || "/").startsWith(c.path)) continue; + out.push(`${c.name}=${c.value}`); + } + return out.join("; "); +} + +/** Extract identity cookies from the account host jar. */ +export function readSessionIdentity(sess) { + const pick = (name) => { + for (const c of sess.jar.values()) { + if (c.name === name && domainMatch(c.domain, ACCOUNT_HOST)) return c.value; + } + return null; + }; + const passToken = pick("passToken"); + if (!passToken || passToken === "EXPIRED") return null; + return { passToken, userId: pick("userId"), cUserId: pick("cUserId") }; +} + +/** + * Redeem the captured passToken for a mimo-server service session using the + * BATTLE-TESTED desktop handshake (serviceLogin sid=mimopc + clientSign) that + * powers every existing CN desktop connection — instead of the interactive + * /api/sts webview callback, which rejects server-side calls (401). + * Merges the resulting serviceCookie into sess.jar, then confirms via me. + * @returns {Promise} true when the me probe answers 200 (logged in). + */ +export async function ensureServiceSession(sess) { + const id = readSessionIdentity(sess); + if (!id) return false; + const T = () => new Date().toISOString().slice(11, 23); + const log = (m) => console.log(`${T()} [mimo-login][exchange] ${m}`); + // Backoff: a failed full 5-step chain must not re-run on every 2.5s poll. + const bkKey = id.passToken.slice(0, 24); + const lastFail = _exchangeBackoff.get(bkKey); + if (lastFail && Date.now() - lastFail < 60_000) { + log("exchange in backoff (60s), skip"); + return false; + } + try { + const mod = await import("../../open-sse/shared/mimoAccount.js"); + const proxyOptions = sess.proxyUrl ? { enabled: true, url: sess.proxyUrl } : null; + log(`exchanging passToken (region=${sess.region}, egress=${sess.proxyUrl || "direct"}) ...`); + const serviceCookie = await mod.getMimoAccountCookie( + { + region: sess.region, + mimoPassToken: id.passToken, + mimoUserId: id.userId, + mimoCUserId: id.cUserId, + }, + proxyOptions, + ); + if (!serviceCookie) { + log("exchange failed: no service cookie"); + _exchangeBackoff.set(bkKey, Date.now()); + return false; + } + // Flatten "a=b; c=d" into the jar under the mimo-server host. + _exchangeBackoff.delete(bkKey); + const host = new URL(sess.upstreamBase).hostname; + for (const pair of String(serviceCookie).split(";")) { + const eq = pair.indexOf("="); + if (eq <= 0) continue; + const name = pair.slice(0, eq).trim(); + const value = pair.slice(eq + 1).trim(); + if (!name || !value) continue; + sess.jar.set(`${name}|${host}|/`, { name, value, domain: host, path: "/" }); + } + log(`serviceCookie merged, jar=[${[...sess.jar.keys()].map((k) => k.split("|")[0]).join(",").slice(0, 160)}]`); + + const meUrl = `${sess.upstreamBase}/api/user/xiaomi/me`; + const res = await fetchUpstream( + sess, + meUrl, + { method: "GET", headers: { "User-Agent": API_UA } }, + cookieHeaderFor(sess, meUrl), + ); + absorbSetCookies(sess, res, meUrl); + log(`me confirm http=${res.status}`); + return res.status === 200; + } catch (e) { + log(`exchange error: ${e?.message || e}`); + _exchangeBackoff.set(bkKey, Date.now()); + return false; + } +} + +// ---------- URL rewriting (mimo-server base <-> /__mimo_login/mimo) ---------- + +function encodingVariants(s) { + // The callback/followup params appear raw, url-encoded once, twice... + const out = [s]; + let cur = s; + for (let i = 0; i < 3; i++) { + cur = encodeURIComponent(cur); + out.push(cur); + } + return out; +} + +/** + * Rewrite mimo-server base URLs (any encoding depth) in a string. + * direction "toProxy": mimo-base -> `${origin}/__mimo_login/mimo` + * direction "toUpstream": reverse. + */ +export function rewriteMimoBases(text, direction, origin, upstreamBase = null) { + if (!text) return text; + let out = String(text); + const proxyBase = `${origin}/__mimo_login/mimo`; + const bases = [...new Set(Object.values(MIMO_BASES))]; + const fromBases = direction === "toProxy" ? bases : [proxyBase]; + const toBase = direction === "toProxy" ? proxyBase : (upstreamBase || bases[0]); + for (const fromBase of fromBases) { + const variants = [ + fromBase, + fromBase.replace("https://", "http://"), + fromBase.replace(/^https?:/, ""), + fromBase.replaceAll("/", "\\/"), + fromBase.replace("https://", "http://").replaceAll("/", "\\/"), + ]; // + protocol-relative + json escaped slashes + const toVariants = [ + toBase, + toBase.replace("https://", "http://"), + toBase, + toBase.replaceAll("/", "\\/"), + toBase.replaceAll("/", "\\/"), + ]; + for (let vIdx = 0; vIdx < variants.length; vIdx++) { + const fromList = encodingVariants(variants[vIdx]); + const toList = encodingVariants(toVariants[vIdx]); + for (let i = 0; i < fromList.length; i++) { + out = out.split(fromList[i]).join(toList[i]); + } + } + } + + // account.xiaomi.com absolute URLs: keep navigation (e.g. identity/authStart + // 2FA prompts) on our origin — every path on that host is already proxied + // natively. Reverse applies to request URLs/bodies before hitting upstream. + const acctOrigins = ["https://account.xiaomi.com", "http://account.xiaomi.com", "//account.xiaomi.com"]; + if (direction === "toProxy") { + const toL = encodingVariants(origin); + for (const a of acctOrigins) { + const fromL = encodingVariants(a); + for (let i = 0; i < fromL.length; i++) out = out.split(fromL[i]).join(toL[i]); + } + } else { + const toA = encodingVariants("https://account.xiaomi.com"); + const toAEscaped = encodingVariants("https:\\/\\/account.xiaomi.com"); + const fromL = encodingVariants(origin); + const fromLProto = encodingVariants(origin.replace(/^https?:/, "")); + const fromLEscaped = encodingVariants(origin.replaceAll("/", "\\/")); + for (let i = 0; i < fromL.length; i++) { + out = out.split(fromL[i]).join(toA[i]); + out = out.split(fromLProto[i]).join(toA[i]); + out = out.split(fromLEscaped[i]).join(toAEscaped[i]); + } + } + return out; +} + +/** Reverse the proxy rewrite in an incoming URL before hitting upstream. */ +export function deRewriteUrl(rawUrl, origin, upstreamBase = null) { + return rewriteMimoBases(rawUrl, "toUpstream", origin, upstreamBase); +} + +export function isAccountProxyPath(pathname) { + // Inverted: proxy EVERYTHING except the app's own paths (only consulted + // while a login session cookie/param is present). + return !APP_PATH_MATCHES(pathname); +} + +export function isMimoTakeoverPath(pathname) { + return pathname.startsWith("/__mimo_login/mimo/"); +} + +/** Map /__mimo_login/mimo/* back to the upstream mimo-server path. */ +export function takeoverUpstreamPath(pathname) { + return pathname.slice("/__mimo_login/mimo".length) || "/"; +} + +// ---------- upstream fetch with optional egress proxy ---------- +// +// The login page's feature set (Google sign-in etc.) is geo-decided by the +// egress IP of THESE requests. The proxy applies ONLY when the user picked +// region=sgp — the start route resolves it (via local-port probe) and rides it +// on the session cookie as sess.proxyUrl. CN sessions never proxy (null -> direct). + +let _pafPromise = null; +let _socksPromise = null; + +/** fetch-compatible wrapper over socks-proxy-agent (undici ProxyAgent has no socks support). */ +async function socksFetch(url, init, proxyUrl) { + if (!_socksPromise) { + _socksPromise = import("socks-proxy-agent") + .then((m) => m.SocksProxyAgent || m.default?.SocksProxyAgent || m.default) + .catch((e) => { + console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] socks-proxy-agent unavailable:`, e?.message || e); + return null; + }); + } + const SocksProxyAgent = await _socksPromise; + if (!SocksProxyAgent) throw new Error("socks agent unavailable"); + + const nodeUrl = new URL(url); + // Literal specifiers on both branches — webpack forbids fully dynamic import(). + const protoMod = nodeUrl.protocol === "http:" ? await import("node:http") : await import("node:https"); + const lib = protoMod.default ?? protoMod; + const { Readable } = await import("node:stream"); + + let headers = {}; + const raw = init?.headers; + if (raw instanceof Headers) for (const [k, v] of raw) headers[k] = v; + else if (raw) headers = { ...raw }; + + let body = init?.body; + if (body && typeof body !== "string" && !Buffer.isBuffer(body)) body = Buffer.from(body); + if (body) headers["content-length"] = String(Buffer.byteLength(body)); + + const agent = new SocksProxyAgent(proxyUrl); + return new Promise((resolve, reject) => { + const req = lib.request( + nodeUrl, + { method: init?.method || "GET", agent, headers, timeout: 20000 }, + (res) => { + const outHeaders = new Headers(); + for (const [k, v] of Object.entries(res.headers || {})) { + if (Array.isArray(v)) v.forEach((x) => outHeaders.append(k, String(x))); + else if (v != null) outHeaders.set(k, String(v)); + } + resolve(new Response(Readable.toWeb(res), { status: res.statusCode || 200, headers: outHeaders })); + }, + ); + const signal = init?.signal; + if (signal) { + if (signal.aborted) req.destroy(new Error("aborted")); + else signal.addEventListener("abort", () => req.destroy(new Error("aborted")), { once: true }); + } + req.on("timeout", () => req.destroy(new Error("socks fetch timeout"))); + req.on("error", reject); + if (body) req.write(body); + req.end(); + }); +} + +async function loginFetch(url, init, sessionProxyUrl = null) { + if (!sessionProxyUrl) return fetch(url, init); // direct — no agent machinery needed + const proxyOptions = { enabled: true, url: sessionProxyUrl }; + try { + if (/^socks/i.test(sessionProxyUrl)) return await socksFetch(url, init, sessionProxyUrl); + if (!_pafPromise) { + _pafPromise = import("../../open-sse/utils/proxyFetch.js") + .then((m) => m.proxyAwareFetch) + .catch((e) => { + console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] proxyAwareFetch unavailable (runtime?), direct only:`, e?.message || e); + return null; + }); + } + const paf = await _pafPromise; + if (paf) return await paf(url, init, proxyOptions); + } catch (e) { + console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] proxied fetch failed, falling back to direct:`, e?.message || e); + } + return fetch(url, init); +} + +/** Public alias — start/status routes share the same egress path. */ +export const loginUpstreamFetch = (url, init, sess = null) => loginFetch(url, init, sess?.proxyUrl || null); + +// ---------- upstream proxying ---------- + +async function fetchUpstream(sess, url, init, cookieValue) { + const headers = new Headers(init.headers); + if (cookieValue) headers.set("Cookie", cookieValue); + return loginFetch(url, { ...init, headers, redirect: "manual", signal: AbortSignal.timeout(20000) }, sess?.proxyUrl || null); +} + +function browserCookieHeader(req) { + return req.headers.get("cookie") || ""; +} + +// Headers never forwarded to the upstream account host. +const STRIP_UPSTREAM_HEADERS = new Set([ + "host", "cookie", "connection", "content-length", "transfer-encoding", + "keep-alive", "upgrade", "expect", "proxy-connection", + // Credentials for 9router itself — must never reach a third-party upstream. + "authorization", "proxy-authorization", +]); + +/** + * Proxy one account.xiaomi.com request from the browser. + * Captures Set-Cookie into the server jar, strips frame-blocking headers, + * rewrites Location/body mimo-base references back into the proxy space. + */ +export async function proxyAccountRequest(sess, req, origin) { + const u = new URL(req.url); + u.searchParams.delete("__9r_sess"); // never forward our session to upstream + const upstream = new URL(u.pathname + u.search, `https://${ACCOUNT_HOST}`); + + // The SPA's callback params were rewritten to our proxy — undo before upstream + // so signature (_sign) validation on the real callback still passes. + const upstreamStr = deRewriteUrl(upstream.toString(), origin, sess.upstreamBase); + + const headers = {}; + // Forward EVERYTHING the browser sent (minus hop-by-hop + host/cookie) so no + // custom SDK header gets silently dropped. Then fix up cross-origin fields: + // the SPA talks to account.xiaomi.com, so Origin/Referer must be rewritten + // from our origin to the account origin — keeping the original path/query + // (a wrong Referer path trips Xiaomi's login risk control, error 10025). + for (const [k, v] of req.headers) { + if (STRIP_UPSTREAM_HEADERS.has(k.toLowerCase())) continue; + headers[k] = v; + } + const ourOrigin = origin; + const fixOriginUrl = (val) => { + if (!val) return null; + try { + const u = new URL(val); + if (u.origin === ourOrigin) { + u.protocol = "https:"; + u.host = ACCOUNT_HOST; + return u.toString(); + } + if (u.hostname === ACCOUNT_HOST) return u.toString(); + return null; // some other origin — let our forced account values win + } catch { return null; } + }; + const rawOrigin = headers.origin || headers.Origin || null; + delete headers.origin; + delete headers.Origin; + // Real browsers only send Origin on XHR POSTs — preserve that shape, but + // point it at the account host (never leak localhost upstream). + if (rawOrigin) headers.Origin = `https://${ACCOUNT_HOST}`; + const fixedReferer = fixOriginUrl(headers.referer || headers.Referer); + headers.Referer = fixedReferer + ? deRewriteUrl(fixedReferer, origin, sess.upstreamBase) + : `https://${ACCOUNT_HOST}/fe/service/login`; + delete headers.referer; + + const init = { method: req.method || "GET", headers }; + if (init.method !== "GET" && init.method !== "HEAD") { + let buf = Buffer.from(await req.arrayBuffer()); + // The SPA reads callback params from location.search (which we rewrote to + // our origin) and can echo them in the POST BODY — reverse that too, or + // Xiaomi rejects with 10025 "Callback连接不合法". + const ctBody = String(headers["Content-Type"] || headers["content-type"] || ""); + if (buf.length && /urlencoded|json|text/i.test(ctBody)) { + const before = buf.toString("utf8"); + const after = rewriteMimoBases(before, "toUpstream", origin, sess.upstreamBase); + if (after !== before) { + buf = Buffer.from(after, "utf8"); + headers["Content-Length"] = String(buf.length); + } else if (/__mimo_login|localhost/.test(before)) { + // Anomaly: a local callback shape we cannot rewrite — must never reach Xiaomi. + console.log(`${new Date().toISOString().slice(11, 23)} [mimo-login] body STILL local, not matchable: ${before.slice(0, 200)}`); + } + } + init.body = buf; + } + + // Follow same-host redirects server-side (they carry Set-Cookie we must keep). + let current = upstreamStr; + let res = null; + const requestCookies = cookieHeaderFor(sess, current) || browserCookieHeader(req); + for (let hop = 0; hop < 8; hop++) { + res = await fetchUpstream(sess, current, hop === 0 ? init : { ...init, body: undefined }, requestCookies); + absorbSetCookies(sess, res, current); + const loc = res.headers.get("location"); + if (res.status >= 300 && res.status < 400 && loc) { + const nextAbs = new URL(loc, current); + if (nextAbs.hostname === ACCOUNT_HOST) { + current = nextAbs.toString(); + continue; + } + // Cross-host redirect to mimo-server: rewrite into our takeover space so + // the browser stays on our origin. + if (/mimo-server-(cn|sgp)\.xiaomimimo\.com/.test(nextAbs.hostname)) { + const proxiedLoc = rewriteMimoBases(nextAbs.toString(), "toProxy", origin); + return new Response(null, { status: res.status, headers: { Location: proxiedLoc, "Cache-Control": "no-store" } }); + } + // Any other host: pass through. + return new Response(null, { status: res.status, headers: { Location: loc } }); + } + break; + } + + return buildBrowserResponse(sess, res, origin, u.pathname, current); +} + +function buildBrowserResponse(sess, res, origin, reqPath = "", upstreamUrl = "") { + const outHeaders = new Headers(); + const pass = ["content-type", "cache-control", "etag", "last-modified", "date"]; + for (const h of pass) { + const v = res.headers.get(h); + if (v) outHeaders.set(h, v); + } + // Allow embedding in our modal (upstream often sends X-Frame-Options). + outHeaders.delete("x-frame-options"); + outHeaders.delete("content-security-policy"); + outHeaders.delete("content-security-policy-report-only"); + outHeaders.set("Cache-Control", "no-store"); + // Deliberately NOT forwarding upstream Set-Cookie to the browser: every + // proxied request already strips the browser Cookie header, so upstream + // auth lives only in the server-side jar. Replayed cookies would land on + // our origin's jar (some without HttpOnly → readable by any script here). + + const ctType = res.headers.get("content-type") || ""; + const isText = /text\/|javascript|json/.test(ctType); + if (!isText) return new Response(res.body, { status: res.status, headers: outHeaders }); + + return res.text().then((rawBody) => { + // JSON allows \/ as an escaped slash — Xiaomi backends (PHP-style) emit + // "https:\/\/mimo-server..." which defeats scheme-based string matching + // AND the SPA JSON.parses it back to a real URL (this leaked the sts + // callback straight to the browser -> cross-origin 401). Unescaping \/ to + // / inside JSON string values is semantics-preserving and stays valid. + const body = /json/.test(ctType) ? rawBody.replaceAll("\\/", "/") : rawBody; + // Surface upstream API errors (Xiaomi wraps JSON as &&&START&&&{code:...}). + if (/^\/(pass|sts)/.test(reqPath) || res.status >= 400) { + const m = body.match(/"code"\s*:\s*(-?\d+)/); + if ((m && m[1] !== "0") || res.status >= 400) { + console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] upstream ${reqPath} http=${res.status} code=${m ? m[1] : "?"} | url=${upstreamUrl.slice(0, 180)} | ${body.replace(/\s+/g, " ").slice(0, 160)}`); + } + } + const rewritten = rewriteMimoBases(body, "toProxy", origin); + return new Response(rewritten, { status: res.status, headers: outHeaders }); + }); +} + +/** + * Take over the mimo-server tail of the flow (sts -> me) server-side. + * Runs the whole redirect chain, absorbs cookies, verifies me=logged-in. + */ +export async function runTakeover(sess, upstreamUrl, origin) { + const T = () => new Date().toISOString().slice(11, 23); + const log = (m) => console.log(`${T()} [mimo-login][takeover] ${m}`); + const idSnap = () => { + const id = readSessionIdentity(sess); + const names = [...sess.jar.keys()].map((k) => k.split("|")[0]).join(","); + return `passToken=${id ? "Y" : "N"} jar=[${names.slice(0, 160)}]`; + }; + log(`sts-nav url=${upstreamUrl.slice(0, 160)} origin=${origin} | ${idSnap()}`); + const id = readSessionIdentity(sess); + // AUTHORIZATION COMPLETION = passToken captured (that IS the credential the + // connection persists for the account route). The serviceToken exchange + // (weekly quota) is intentionally NOT part of completion — its API moved; + // ensureServiceSession stays exported for when that gets re-wired. + if (id) { + sess.status = "done"; + return donePage(); + } + return pendingPage(); +} + +function htmlPage(title, lines, autoClose = false) { + const body = `${title} + +

${title}

${lines.map((l) => `

${l}

`).join("")}
+ +`; + return new Response(body, { status: 200, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } }); +} + +function donePage() { + return htmlPage("登录成功 ✅", ["账号会话已捕获,可以关闭此窗口。", "回到 9router 弹窗继续。"], true); +} + +function pendingPage() { + return htmlPage("登录未完成", ["未检测到有效会话,请重试。"]); +} + +export const __test__ = { STRIP_UPSTREAM_HEADERS, buildBrowserResponse }; diff --git a/src/proxy.js b/src/proxy.js index 566dd14c..b42dd193 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -1,6 +1,70 @@ -import { proxy as dashboardProxy } from "./dashboardGuard"; +import { proxy as dashboardProxy, isAuthenticated } from "./dashboardGuard"; +import { + sessionFromRequest, + isAccountProxyPath, + isMimoTakeoverPath, + takeoverUpstreamPath, + proxyAccountRequest, + runTakeover, + attachSessionCookie, + originOf, +} from "./lib/mimoLoginSession"; export default async function proxy(request) { + // Xiaomi account session-login proxy (src/lib/mimoLoginSession.js). + // Session state (region + accumulated cookie jar) travels in the httpOnly + // 9r_mimo_login cookie — route handlers and this proxy run in separate + // bundles, so module-level maps are NOT shared. The cookie is only set by + // the auth-gated login/start route, and the branch below ALSO requires a + // valid dashboard session: a forged 9r_mimo_login cookie (client-controlled + // header, unsigned payload) must never turn the app into an unauthenticated + // forwarder. No URL-carried session — it would leak the jar via history/logs/Referer. + const cookies = request.headers.get("cookie") || ""; + const hasSessionCookie = cookies.includes("9r_mimo_login="); + const { pathname } = request.nextUrl; + if (hasSessionCookie && !(await isAuthenticated(request))) { + // Forged or stale session cookie without dashboard auth — drop it early. + const res = await dashboardProxy(request); + const headers = new Headers(res.headers); + headers.append("Set-Cookie", "9r_mimo_login=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"); + return new Response(res.body, { status: res.status, statusText: res.statusText, headers }); + } + if (!hasSessionCookie && /^\/(fe\/|pass)/.test(pathname) && !pathname.startsWith("/_next")) { + // Anomaly: a login-flow XHR arrived without the session — the classic + // cause of silent SPA "Something went wrong" 404s. Narrow to login paths + // so unrelated unknown routes don't spam this. + console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] no-session ${pathname} (cookie header: ${cookies ? cookies.slice(0, 80) : ""})`); + } + if (hasSessionCookie) { + const sess = sessionFromRequest(request); + if (sess) { + const origin = originOf(request); + try { + if (isMimoTakeoverPath(pathname)) { + const upstreamUrl = `${sess.upstreamBase}${takeoverUpstreamPath(pathname)}${request.nextUrl.search || ""}`; + return attachSessionCookie(await runTakeover(sess, upstreamUrl, origin), sess); + } + if (isAccountProxyPath(pathname)) { + return attachSessionCookie(await proxyAccountRequest(sess, request, origin), sess); + } + } catch (e) { + console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] proxy error:`, e?.message || e); + return new Response("mimo login proxy error", { status: 502 }); + } + } else { + // Anomaly (should not happen in a healthy flow): cookie present but unparseable. + console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] session cookie undecodable — falling through (${pathname})`); + } + } + + // Cookie present but expired/invalid — clear it on the way past. + if (hasSessionCookie) { + const res = await dashboardProxy(request); + const headers = new Headers(res.headers); + headers.append("Set-Cookie", "9r_mimo_login=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"); + return new Response(res.body, { status: res.status, statusText: res.statusText, headers }); + } + return dashboardProxy(request); } diff --git a/src/shared/components/XiaomiMimoAuthModal.js b/src/shared/components/XiaomiMimoAuthModal.js index e501698f..1081953c 100644 --- a/src/shared/components/XiaomiMimoAuthModal.js +++ b/src/shared/components/XiaomiMimoAuthModal.js @@ -1,60 +1,105 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import PropTypes from "prop-types"; import { Modal, Button } from "@/shared/components"; +import { translate } from "@/i18n/runtime"; /** - * Xiaomi MiMo Auth Modal + * Xiaomi MiMo Desktop Plan auth modal. * - * Auto-imports credentials from the local Xiaomi MiMo Desktop auth.json (~/.local/share/mimocode/auth.json). - * If auto-import fails, offers a one-click browser OAuth fallback. - * Reached only via the "Connect with OAuth" button — the API-key path uses the - * standard Add API Key modal, since Xiaomi MiMo supports both auth modes. + * Desktop Plan = Xiaomi account weekly quota (mimo-v2.6 family). Two ways in: + * 1. Import local MiMo Desktop credentials (~/.local/share/mimocode/auth.json) + * 2. Server-side login — no Desktop needed; picks a MiMo account cluster + * (cn / sgp / ams / ru / in). The account session (passToken) is captured + * server-side and stored per connection. + * + * Token Plan (cloud sk- API key) uses the standard "API Key" entry point. */ -export default function XiaomiMimoAuthModal({ isOpen, onSuccess, onClose }) { - const [phase, setPhase] = useState("detecting"); // detecting | found | not-found | importing | error - const [detectResult, setDetectResult] = useState(null); - const [error, setError] = useState(null); - const [oauthUrl, setOauthUrl] = useState(null); - const [oauthState, setOauthState] = useState(null); - // Auto-detect local credentials when modal opens +const CLUSTERS = [ + { id: "cn", flag: "🇨🇳", name: "China (Mainland)", host: "mimo-server-cn" }, + { id: "sgp", flag: "🇸🇬", name: "Singapore", host: "mimo-server-sgp" }, + { id: "ams", flag: "🇪🇺", name: "Europe · Amsterdam", host: "mimo-server-ams" }, + { id: "ru", flag: "🇷🇺", name: "Russia", host: "mimo-server-ru" }, + { id: "in", flag: "🇮🇳", name: "India", host: "mimo-server-in" }, +]; + +export default function XiaomiMimoAuthModal({ isOpen, onSuccess, onClose }) { + const [phase, setPhase] = useState("detecting"); // detecting | found | not-found | importing + const [detectResult, setDetectResult] = useState(null); + const [existingConnection, setExistingConnection] = useState(null); + const [error, setError] = useState(null); + + // Server-side session login + const [showClusterModal, setShowClusterModal] = useState(false); + const [sessBusy, setSessBusy] = useState(false); + const [sessPolling, setSessPolling] = useState(false); + const [sessError, setSessError] = useState(null); + const [sessPageUrl, setSessPageUrl] = useState(null); + const [sessRegion, setSessRegion] = useState("cn"); + const sessTimerRef = useRef(null); + + const stopSessionPoll = () => { + if (sessTimerRef.current) { + clearInterval(sessTimerRef.current); + sessTimerRef.current = null; + } + setSessPolling(false); + }; + + useEffect(() => () => stopSessionPoll(), []); + + // Detect local credentials when the modal opens (non-blocking: never trap the spinner) useEffect(() => { if (!isOpen) return; - let cancelled = false; - (async () => { - setPhase("detecting"); - setError(null); - setDetectResult(null); - setOauthUrl(null); + setPhase("detecting"); + setError(null); + setDetectResult(null); + setExistingConnection(null); + setShowClusterModal(false); + setSessError(null); + const runDetect = async () => { try { - const res = await fetch("/api/oauth/xiaomi-mimo/auto-import"); + const res = await fetch("/api/oauth/xiaomi-mimo/auto-import", { + signal: AbortSignal.timeout(5000), + }); const data = await res.json(); - if (cancelled) return; if (data.found && data.apiKey) { setDetectResult(data); setPhase("found"); + + // Non-blocking: flag an already-imported account + fetch("/api/providers", { signal: AbortSignal.timeout(3000) }) + .then((r) => r.json()) + .then((provData) => { + const foundConn = (provData.connections || []).find( + (c) => + c.provider === "xiaomi-mimo" && + data.uid && + (c.email === `${data.uid}@xiaomi` || + c.providerSpecificData?.uid === data.uid || + c.providerSpecificData?.mimoUserId === data.uid), + ); + if (foundConn) setExistingConnection(foundConn); + }) + .catch(() => {}); } else { setPhase("not-found"); - setError(data.error || "Xiaomi MiMo Desktop credentials not found on this machine."); } } catch { - if (!cancelled) { - setPhase("not-found"); - setError("Failed to read local Xiaomi MiMo Desktop credentials."); - } + setPhase("not-found"); } - })(); + }; - return () => { cancelled = true; }; + runDetect(); }, [isOpen]); - // Import the auto-detected key - const handleImport = async () => { + // Import the auto-detected local desktop credentials + const handleImportLocal = async () => { if (!detectResult?.apiKey) return; setPhase("importing"); setError(null); @@ -73,11 +118,7 @@ export default function XiaomiMimoAuthModal({ isOpen, onSuccess, onClose }) { }), }); const data = await res.json(); - - if (!res.ok || !data.success) { - throw new Error(data.error || "Import failed"); - } - + if (!res.ok || !data.success) throw new Error(data.error || "Import failed"); onSuccess?.(data.connection); onClose(); } catch (err) { @@ -86,183 +127,265 @@ export default function XiaomiMimoAuthModal({ isOpen, onSuccess, onClose }) { } }; - // Start browser OAuth fallback - const handleStartOAuth = async () => { - setError(null); + // Server-side login (account.xiaomi.com) for the chosen cluster + const startSessionLogin = async (region) => { + setSessBusy(true); + setSessError(null); + setShowClusterModal(false); + setSessRegion(region); try { - const state = crypto.randomUUID(); - const res = await fetch(`/api/oauth/xiaomi-mimo/authorize?state=${state}`); + const res = await fetch("/api/oauth/xiaomi-mimo/login/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ region }), + }); const data = await res.json(); - if (data.authorizeUrl) { - setOauthUrl(data.authorizeUrl); - setOauthState(data.state); - window.open(data.authorizeUrl, "_blank", "width=600,height=700"); - } else { - throw new Error(data.error || "Failed to start OAuth"); - } - } catch (err) { - setError(err.message); - } - }; - - // Poll OAuth result - const handlePollOAuth = async () => { - if (!oauthState) return; - setError(null); - try { - const res = await fetch(`/api/oauth/xiaomi-mimo/poll-status?state=${oauthState}`); - const data = await res.json(); - - if (data.status === "done" && data.result) { - // Exchange to create the connection - const exRes = await fetch("/api/oauth/xiaomi-mimo/exchange", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ state: oauthState }), - }); - const exData = await exRes.json(); - if (exData.success) { - onSuccess?.(exData.connection); - onClose(); - } else { - throw new Error(exData.error || "Exchange failed"); + if (!res.ok || !data.pageUrl) throw new Error(data.error || "Failed to start login"); + setSessPageUrl(data.pageUrl); + window.open(data.pageUrl, "mimo-session-login", "width=500,height=760"); + setSessPolling(true); + const startedAt = Date.now(); + sessTimerRef.current = setInterval(async () => { + if (Date.now() - startedAt > 14 * 60 * 1000) { + stopSessionPoll(); + setSessError("Login timed out. Please retry."); + return; } - } else if (data.status === "error") { - throw new Error(data.error || "OAuth failed"); - } else { - setError("Authorization not completed yet. Finish in the browser, then click Check Again."); - } + try { + const sres = await fetch(`/api/oauth/xiaomi-mimo/login/status?state=${data.state}`); + const sd = await sres.json(); + if (sd.status === "pending") return; + stopSessionPoll(); + if (sd.status !== "done") { + setSessError(sd.error || "Login session expired — please retry."); + return; + } + const save = await fetch("/api/oauth/xiaomi-mimo/api-key", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + apiKey: "", + uid: sd.userId || null, + mimoPassToken: sd.passToken, + mimoUserId: sd.userId || null, + mimoCUserId: sd.cUserId || null, + region: sd.region || sessRegion, + }), + }); + const saved = await save.json(); + if (!save.ok || !saved.success) throw new Error(saved.error || "Failed to save credentials"); + onSuccess?.(saved.connection); + onClose(); + } catch (err) { + stopSessionPoll(); + setSessError(err.message); + } + }, 2500); } catch (err) { - setError(err.message); + setSessError(err.message); + } finally { + setSessBusy(false); } }; + // The server-login card, shown in both the found and not-found states + const renderServerLogin = () => ( +
+
+
+ login + Browser Login + No Desktop required +
+ Weekly quota +
+ {sessPolling ? ( +
+ + {sessPageUrl && ( + + )} +
+ ) : ( + + )} + {sessError &&

{translate(sessError)}

} +
+ ); + return ( - -
+ +
{/* Detecting */} {phase === "detecting" && (
-
- +
+ progress_activity
-

Reading local credentials...

-

- Checking ~/.local/share/mimocode/auth.json -

+

Reading local MiMo Desktop credentials...

)} - {/* Found — one-click import */} - {phase === "found" && detectResult && ( - <> -
-
- check_circle -
-

Xiaomi MiMo Desktop credentials found!

-

- UID: {detectResult.uid || "—"} · Source: {detectResult.source?.split(/[\\/]/).pop()} -

-
-
-
- - {error && ( -
-

{error}

-
- )} - -
- - -
- - )} - {/* Importing */} {phase === "importing" && (
-
- +
+ progress_activity
-

Connecting...

+

Connecting...

)} - {/* Not found — offer OAuth fallback */} + {/* Found local desktop credentials */} + {phase === "found" && detectResult && ( +
+
+ desktop_windows + Desktop Plan · Local credentials +
+ + {existingConnection ? ( +
+
+ + check_circle + +
+

This account is already connected (no need to import again)

+

+ UID: {detectResult.uid || "—"} · Status: {existingConnection.testStatus === "active" ? "Active" : "Untested"} +

+
+
+
+ ) : ( +
+
+ + check_circle + +
+

Xiaomi MiMo Desktop credentials found!

+

+ UID: {detectResult.uid || "—"} · Source: {detectResult.source?.split(/[\\/]/).pop()} +

+
+
+
+ )} + + {error && ( +
+

{translate(error)}

+
+ )} + +
+ + +
+ +
+
+
+
+
+ or +
+
+ + {renderServerLogin()} +
+ )} + + {/* No local desktop credentials */} {phase === "not-found" && ( - <> +
-
- info +
+ info
-

Local credentials not found

-

{error}

-

- Make sure Xiaomi MiMo Desktop is installed and you are signed in, then retry. - Or sign in via browser below. +

No local Desktop credentials found

+

+ You can still sign in via browser — no Desktop client needed.

- {!oauthUrl ? ( -
- +
+
+ )} + + {/* Cluster selection sub-modal */} + {showClusterModal && ( +
+
+
+
+ public +

Select account cluster

+
+ - + close +
- ) : ( + +

Choose the region cluster of your Xiaomi account:

+
-
-

- Browser opened. Complete the Xiaomi sign-in, then click{" "} - Check Again. -

-
-
- - -
+ {CLUSTERS.map((c) => ( + + ))}
- )} - + + +
+
)}
diff --git a/tests/unit/xiaomi-mimo-executor.test.js b/tests/unit/xiaomi-mimo-executor.test.js index 10c6a520..1a3c790c 100644 --- a/tests/unit/xiaomi-mimo-executor.test.js +++ b/tests/unit/xiaomi-mimo-executor.test.js @@ -1,6 +1,7 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import { XiaomiMimoExecutor, __test__ } from "../../open-sse/executors/xiaomi-mimo.js"; import { getExecutor } from "../../open-sse/executors/index.js"; +import * as mimoAccount from "../../open-sse/shared/mimoAccount.js"; const { bareModel, COOKIE_KEY } = __test__; @@ -17,22 +18,52 @@ describe("xiaomi-mimo executor", () => { expect(getExecutor("xiaomi-mimo")).toBeInstanceOf(XiaomiMimoExecutor); }); - it("routes Preview models to the account-service route regardless of transport", () => { - const expected = "https://mimo-server-cn.xiaomimimo.com/api/route/chat/completions"; - expect(ex.buildUrl("mimo-x-pro-preview", true, 0, OPENAI_T)).toBe(expected); - expect(ex.buildUrl("mimo-x-pro-preview", true, 0, CLAUDE_T)).toBe(expected); - // body.model arrives as `xiaomi/` via upstreamModelId - expect(ex.buildUrl("xiaomi/mimo-x-flash-preview", true, 0, OPENAI_T)).toBe(expected); - }); - it("keeps the sourceFormat-matched endpoint for cloud models", () => { - // Regression: a Claude client must reach /anthropic/v1/messages, not /v1/chat/completions. expect(ex.buildUrl("mimo-v2.5-pro", true, 0, CLAUDE_T)).toBe(CLAUDE_T.runtimeTransport.baseUrl); expect(ex.buildUrl("mimo-v2.5-pro", true, 0, OPENAI_T)).toBe(OPENAI_T.runtimeTransport.baseUrl); }); - it("authenticates Preview calls with the account cookie", () => { - const headers = ex.buildHeaders({ [COOKIE_KEY]: "serviceToken=abc", accessToken: "sk-x" }, true, "u", "mimo-x-pro-preview"); + it("routes v2.6 models to account route when desktop credentials are present", () => { + // No region → SGP default + const expected = "https://mimo-server-sgp.xiaomimimo.com/api/route/chat/completions"; + const credsWithToken = { providerSpecificData: { mimoPassToken: "token123" } }; + const credsWithCookie = { [COOKIE_KEY]: "serviceToken=abc" }; + + expect(ex.buildUrl("mimo-v2.6-flash", true, 0, credsWithToken)).toBe(expected); + expect(ex.buildUrl("mimo-v2.6-pro", true, 0, credsWithCookie)).toBe(expected); + expect(ex.buildUrl("xiaomi/mimo-v2.6-flash", true, 0, credsWithToken)).toBe(expected); + }); + + it("routes v2.6 models to cloud API when no desktop credentials are present", () => { + expect(ex.buildUrl("mimo-v2.6-flash", true, 0, OPENAI_T)).toBe(OPENAI_T.runtimeTransport.baseUrl); + expect(ex.buildUrl("mimo-v2.6-pro", true, 0, CLAUDE_T)).toBe(CLAUDE_T.runtimeTransport.baseUrl); + }); + + it("resolves the account-service cluster per connection region", () => { + const cn = "https://mimo-server-cn.xiaomimimo.com/api/route/chat/completions"; + const sgp = "https://mimo-server-sgp.xiaomimimo.com/api/route/chat/completions"; + const ams = "https://mimo-server-ams.xiaomimimo.com/api/route/chat/completions"; + const ru = "https://mimo-server-ru.xiaomimimo.com/api/route/chat/completions"; + const inRegion = "https://mimo-server-in.xiaomimimo.com/api/route/chat/completions"; + // default (no region) falls back to SGP (the international cluster) + expect(ex.buildUrl("mimo-v2.6-flash", true, 0, { providerSpecificData: { mimoPassToken: "t" } })).toBe(sgp); + expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "cn", mimoPassToken: "t" } })).toBe(cn); + expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "sgp", mimoPassToken: "t" } })).toBe(sgp); + expect(ex.buildUrl("mimo-v2.6-flash", true, 0, { providerSpecificData: { region: "SGP", mimoPassToken: "t" } })).toBe(sgp); + expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "ams", mimoPassToken: "t" } })).toBe(ams); + expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "ru", mimoPassToken: "t" } })).toBe(ru); + expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "in", mimoPassToken: "t" } })).toBe(inRegion); + // unknown region falls back to SGP + expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "eu", mimoPassToken: "t" } })).toBe(sgp); + }); + + it("authenticates v2.6 calls with account cookie when on account route", () => { + const headers = ex.buildHeaders( + { [COOKIE_KEY]: "serviceToken=abc", accessToken: "sk-x" }, + true, + "u", + "mimo-v2.6-flash", + ); expect(headers.Cookie).toBe("serviceToken=abc"); expect(headers.Authorization).toBeUndefined(); }); @@ -43,38 +74,55 @@ describe("xiaomi-mimo executor", () => { expect(headers.Cookie).toBeUndefined(); }); - it("fails fast when a Preview call has no account session", async () => { - await expect( - ex.execute({ model: "mimo-x-pro-preview", body: {}, stream: true, credentials: {}, log: null }), - ).rejects.toThrow(/account session unavailable/); - }); - - it("flattens content-part arrays to plain strings", () => { + it("preserves content-part arrays for multimodal inputs", () => { + const parts = [{ type: "image_url", image_url: { url: "data:image/png;base64,xyz" } }, { type: "text", text: "hi" }]; const out = ex.transformRequest( - "mimo-x-pro-preview", - { messages: [{ role: "user", content: [{ type: "text", text: "a" }, { type: "text", text: "b" }] }] }, + "mimo-v2.6-pro", + { messages: [{ role: "user", content: parts }] }, true, - {}, + { providerSpecificData: { mimoPassToken: "token" } }, ); - expect(out.messages[0].content).toBe("ab"); + expect(out.messages[0].content).toEqual(parts); }); - it("applies Preview defaults without overriding explicit values", () => { + it("bridges reasoning_effort to official output_config.effort", () => { + const creds = { providerSpecificData: { mimoPassToken: "token" } }; + const body = { + messages: [{ role: "user", content: "solve" }], + reasoning_effort: "high", + }; + const out = ex.transformRequest("mimo-v2.6-pro", body, true, creds); + expect(out.reasoning_effort).toBeUndefined(); + expect(out.output_config).toEqual({ effort: "high" }); + }); + + it("normalizes xhigh reasoning_effort to high in output_config.effort", () => { + const creds = { providerSpecificData: { mimoPassToken: "token" } }; + const body = { + messages: [{ role: "user", content: "complex" }], + reasoning_effort: "xhigh", + }; + const out = ex.transformRequest("mimo-v2.6-pro", body, true, creds); + expect(out.reasoning_effort).toBeUndefined(); + expect(out.output_config).toEqual({ effort: "high" }); + }); + + it("applies defaults without overriding explicit values", () => { + const creds = { providerSpecificData: { mimoPassToken: "token" } }; const body = { messages: [{ role: "user", content: "hi" }], temperature: 0.2 }; - const out = ex.transformRequest("mimo-x-pro-preview", body, true, {}); - expect(out.temperature).toBe(0.2); // caller's value kept - expect(out.top_p).toBe(0.95); // default filled in - expect(out.max_tokens).toBe(4096); + const out = ex.transformRequest("mimo-v2.6-pro", body, true, creds); + expect(out.temperature).toBe(0.2); + expect(out.top_p).toBe(0.95); }); - it("leaves cloud bodies free of Preview defaults", () => { + it("leaves cloud bodies free of account defaults", () => { const out = ex.transformRequest("mimo-v2.5-pro", { messages: [{ role: "user", content: "hi" }] }, true, {}); - expect(out.thinking).toBeUndefined(); - expect(out.max_tokens).toBeUndefined(); + expect(out.output_config).toBeUndefined(); + expect(out.temperature).toBeUndefined(); }); - it("strips a provider/model prefix when testing preview ids", () => { - expect(bareModel("xiaomi/mimo-x-pro-preview")).toBe("mimo-x-pro-preview"); - expect(bareModel("mimo-x-pro-preview")).toBe("mimo-x-pro-preview"); + it("strips a provider/model prefix when testing model ids", () => { + expect(bareModel("xiaomi/mimo-v2.6-pro")).toBe("mimo-v2.6-pro"); + expect(bareModel("mimo-v2.6-flash")).toBe("mimo-v2.6-flash"); }); }); diff --git a/tests/unit/xiaomi-mimo-login-session-security.test.js b/tests/unit/xiaomi-mimo-login-session-security.test.js new file mode 100644 index 00000000..b9ec79a7 --- /dev/null +++ b/tests/unit/xiaomi-mimo-login-session-security.test.js @@ -0,0 +1,40 @@ +/** + * Security invariants of the server-assisted MiMo login proxy + * (src/lib/mimoLoginSession.js): + * - credentials bound to 9router's own origin are never forwarded upstream + * - upstream Set-Cookie is never replayed onto the app's own cookie jar + */ +import { describe, it, expect } from "vitest"; +import { __test__ } from "../../src/lib/mimoLoginSession.js"; + +const { STRIP_UPSTREAM_HEADERS, buildBrowserResponse } = __test__; + +describe("mimo login proxy security", () => { + it("strips auth credentials and session cookies before forwarding upstream", () => { + for (const h of ["authorization", "proxy-authorization", "cookie", "host"]) { + expect(STRIP_UPSTREAM_HEADERS.has(h)).toBe(true); + } + }); + + it("does not replay upstream Set-Cookie onto the app origin", async () => { + const upstream = new Response("ok", { + status: 200, + headers: { + "content-type": "text/html", + "set-cookie": "userId=123; Path=/", // plain object header: visible via getSetCookie + }, + }); + const out = await buildBrowserResponse({ jar: new Map() }, upstream, "http://localhost:20128", "/pass/"); + expect(out.headers.getSetCookie()).toEqual([]); + }); + + it("keeps ordinary response headers intact", async () => { + const upstream = new Response("", { + status: 200, + headers: { "content-type": "text/html" }, + }); + const out = await buildBrowserResponse({ jar: new Map() }, upstream, "http://localhost:20128", "/fe/"); + expect(out.status).toBe(200); + expect(out.headers.get("content-type")).toBe("text/html"); + }); +});