UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

751 lines • 33.7 kB
import { readFile, writeFile, rename, mkdir } from "node:fs/promises"; import { existsSync, rmSync, readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { randomBytes } from "node:crypto"; import { ValidationError } from "../utils/errors.js"; import { assertAllowedTokenHost, OAUTH_PROVIDERS, redactTokens } from "./oauth-flow.js"; import { setOAuthStatus, listOAuthStatus, clearOAuthStatus, } from "./panel-secrets.js"; const OPENAI_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const OPENAI_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"; const KIMI_CODE_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"; /** * Kimi Code's OAuth token endpoint, per REGION (#2534). * * The old constant was `https://api.kimi.com/oauth/token`, which does not exist * — it answers 404 from nginx, so EVERY refresh failed and a Connect landing * inside the 15-minute token's refresh skew (which is most of them) reported * "Kimi Code OAuth refresh failed (404)". The token host is a DIFFERENT host * from the coding API host: `auth.kimi.*`, not `api.kimi.*`, and the path is * `/api/oauth/token`, not `/oauth/token`. * * Verified against both endpoints with the client_id below and this function's * exact form-encoded body: a bogus refresh_token returns * `400 {"error":"invalid_grant"}`, which is the endpoint accepting the grant * TYPE and rejecting only the token — the proof that the request shape is right. * (The same probe sent as JSON returns `unsupported_grant_type`; the body here * is URLSearchParams, so this path was always correctly encoded.) */ const KIMI_OAUTH_TOKEN_URL_MAINLAND = "https://auth.kimi.com/api/oauth/token"; const KIMI_OAUTH_TOKEN_URL_GLOBAL = "https://auth.kimi.ai/api/oauth/token"; export const GLM_CODE_DEFAULT_BASE = "https://api.z.ai/api/coding/paas/v4"; export const KIMI_CODE_DEFAULT_BASE = "https://api.kimi.com/coding/v1"; export const MOONSHOT_DEFAULT_BASE = "https://api.moonshot.ai/v1"; export const MINIMAX_DEFAULT_BASE = "https://api.minimax.io/v1"; export const ATLASCLOUD_DEFAULT_BASE = "https://api.atlascloud.ai/v1"; const TOKEN_REFRESH_SKEW_MS = 120_000; function codexAuthPath(home = homedir()) { const root = process.env.CODEX_HOME || join(home, ".codex"); return join(root, "auth.json"); } /** * Every place kimi-code may keep its device-code OAuth, most current first. * * EXPORTED because it must have exactly one implementation. It previously had two: * this one, and an independent copy in orchestrator/backend-readiness.ts that still * looked only in the legacy ~/.kimi. The result was a user signed in with the current * CLI getting credentials resolved here and {auth:false, ready:false} reported there — * so the panel told them they were not signed in and could switch away from Kimi. * Divergence was the bug; one function is the fix. * * KIMI_CODE_HOME is the override the current CLI documents; KIMI_SHARE_DIR is the * legacy name this codebase advertised. Both are honoured, current first, because * dropping the old one would silently break anyone already setting it. */ export function kimiCodeAuthCandidates(home = homedir()) { const codeHome = process.env.KIMI_CODE_HOME?.trim(); const shareDir = process.env.KIMI_SHARE_DIR?.trim(); if (codeHome) return [join(codeHome, "credentials", "kimi-code.json")]; if (shareDir) return [join(shareDir, "credentials", "kimi-code.json")]; return [ join(home, ".kimi-code", "credentials", "kimi-code.json"), // kimi-code CLI (current) join(home, ".kimi", "credentials", "kimi-code.json"), // legacy kimi-cli (`kimi migrate`) ]; } function kimiCodeAuthPath(home = homedir()) { const candidates = kimiCodeAuthCandidates(home); // Falls back to the first candidate so a not-found error names the CURRENT path. return candidates.find((p) => existsSync(p)) ?? candidates[0]; } function grokAuthPath(home = homedir()) { return join(home, ".grok", "auth.json"); } function copilotAuthPath(home = homedir()) { return join(home, ".comfyui-mcp", "copilot-auth.json"); } function jwtExpMs(token) { const parts = token.split("."); if (parts.length < 2) return null; try { const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); return typeof payload.exp === "number" && Number.isFinite(payload.exp) ? payload.exp * 1000 : null; } catch { return null; } } function decodeJwtPayload(token) { const parts = token.split("."); if (parts.length < 2) return null; try { return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); } catch { return null; } } function jwtChatgptAccountId(idToken) { if (!idToken?.trim()) return null; const payload = decodeJwtPayload(idToken); if (!payload) return null; return (payload["https://api.openai.com/auth"]?.chatgpt_account_id?.trim() || payload.chatgpt_account_id?.trim() || null); } /** Best-effort email claim from an id_token, for a human-readable account label. */ function jwtEmailClaim(idToken) { if (!idToken?.trim()) return null; const payload = decodeJwtPayload(idToken); return payload?.email?.trim() || null; } async function atomicWriteJson(path, data) { const dir = dirname(path); // Landing a fresh OAuth sign-in may be the FIRST write to this provider's // credential directory (e.g. ~/.grok didn't exist before Grok sign-in) — // unlike the resolve* paths above, which only ever re-write an existing // file. Ensure the parent dir exists so this stays a drop-in atomic writer // for both "update in place" and "create for the first time" callers. await mkdir(dir, { recursive: true, mode: 0o700 }); const tmp = join(dir, `.auth-${randomBytes(8).toString("hex")}.tmp`); // 0600 on the TEMP file so it is never world-readable even briefly, and so // `rename` carries that mode to the destination — this both creates new token // files 0600 AND prevents an in-place refresh from downgrading a pre-existing // 0600 file (e.g. ~/.codex/auth.json) to the umask default. This helper only // ever writes credential/token files, so 0600 is universally correct here. await writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); await rename(tmp, path); } function tokenExpiring(expiryMs, nowMs, skewMs = TOKEN_REFRESH_SKEW_MS) { if (expiryMs == null) return false; return nowMs >= expiryMs - skewMs; } async function refreshOpenAICodexTokens(refreshToken, deps) { const fetchFn = deps.fetch ?? fetch; const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: OPENAI_CODEX_CLIENT_ID, }); const res = await fetchFn(OPENAI_OAUTH_TOKEN_URL, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded", }, body: body.toString(), signal: AbortSignal.timeout(30_000), }); const text = await res.text(); if (!res.ok) { const hint = res.status === 401 || res.status === 400 ? " Run `codex login` to re-authenticate." : ""; throw new ValidationError(`OpenAI Codex OAuth refresh failed (${res.status}): ${redactTokens(text).slice(0, 300)}.${hint}`); } let payload; try { payload = JSON.parse(text); } catch { // A 2xx-but-malformed body must never reach the caller raw — JSON.parse's // SyntaxError can embed a snippet of the offending text (which may carry // refresh_token material echoed back by the IdP) in its own message. throw new ValidationError("OpenAI Codex OAuth refresh returned a malformed (non-JSON) response."); } const access = payload.access_token?.trim(); if (!access) { throw new ValidationError("OpenAI Codex OAuth refresh response missing access_token."); } return { access_token: access, refresh_token: payload.refresh_token?.trim() }; } function kimiRegionFromHost(url) { try { return new URL(url).hostname.toLowerCase().endsWith("kimi.ai") ? "global" : "mainland"; } catch { return null; // malformed override — no signal } } function kimiRegion(authPath, baseUrlOverride) { if (baseUrlOverride) { const fromOverride = kimiRegionFromHost(baseUrlOverride); if (fromOverride) return fromOverride; } const regionFile = join(dirname(dirname(authPath)), "region"); if (existsSync(regionFile)) { try { const region = readFileSync(regionFile, "utf8").trim().toLowerCase(); // Match on "cn" rather than the exact string: the CLI's value is // `mainland-cn`, and anything else it may write for the global region // (`global`, `intl`, …) is not enumerable from here. A non-empty region // that is not mainland is global. if (region) return region.includes("cn") ? "mainland" : "global"; } catch (err) { // An existing-but-unreadable region file is NOT the same as no region file // (gate r2/r3 P1), and there is no second source to fall back on. Guessing // mainland here sends a GLOBAL install's refresh_token to auth.kimi.com, // which fails as `invalid_grant` — an error that points at the token and // says nothing about the region, leaving the user to debug the wrong // thing. Refuse instead, and name both the file and the way past it. // // Same rule the crash-log reader follows for a log it cannot open: a // signal that was never read must not be reported as a known-good default. // Only reachable on the OAuth path (KIMI_API_KEY returns before this) and // only when the file EXISTS but cannot be read, which is an already-broken // install directory rather than a normal state. throw new ValidationError(`Kimi Code region file ${regionFile} exists but could not be read (${err instanceof Error ? err.message : String(err)}), so the mainland/global region is unknown and a refresh could be sent to the wrong host. ` + `Fix that file's permissions, or set COMFYUI_MCP_KIMI_BASE_URL ` + `(${KIMI_CODE_DEFAULT_BASE} or ${KIMI_CODE_GLOBAL_BASE}) to pin the region explicitly.`); } } return "mainland"; } const KIMI_CODE_GLOBAL_BASE = "https://api.kimi.ai/coding/v1"; function kimiOAuthTokenUrl(region) { return region === "global" ? KIMI_OAUTH_TOKEN_URL_GLOBAL : KIMI_OAUTH_TOKEN_URL_MAINLAND; } function kimiCodingBase(region) { return region === "global" ? KIMI_CODE_GLOBAL_BASE : KIMI_CODE_DEFAULT_BASE; } async function refreshKimiCodeTokens(refreshToken, tokenUrl, deps) { const fetchFn = deps.fetch ?? fetch; const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: KIMI_CODE_CLIENT_ID, }); const res = await fetchFn(tokenUrl, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded", }, body: body.toString(), signal: AbortSignal.timeout(30_000), }); const text = await res.text(); if (!res.ok) { const hint = res.status === 401 || res.status === 400 ? " Re-run `kimi` / Kimi Code login." : ""; throw new ValidationError(`Kimi Code OAuth refresh failed (${res.status}): ${redactTokens(text).slice(0, 300)}.${hint}`); } let payload; try { payload = JSON.parse(text); } catch { // See refreshOpenAICodexTokens's matching catch: a 2xx-but-malformed body // must never reach the caller raw via JSON.parse's own SyntaxError. throw new ValidationError("Kimi Code OAuth refresh returned a malformed (non-JSON) response."); } const access = payload.access_token?.trim(); if (!access) { throw new ValidationError("Kimi Code OAuth refresh response missing access_token."); } const nowSec = (deps.now?.() ?? Date.now()) / 1000; const expiresIn = Number(payload.expires_in); return { ...payload, access_token: access, refresh_token: payload.refresh_token?.trim() || refreshToken, expires_at: typeof payload.expires_at === "number" && Number.isFinite(payload.expires_at) ? payload.expires_at : Number.isFinite(expiresIn) && expiresIn > 0 ? nowSec + expiresIn : undefined, }; } /** Grok (xAI) OAuth refresh — mirrors `refreshOpenAICodexTokens`. Host-allowlist-checked * against the provider config in oauth-flow.ts before any network call. */ async function refreshGrokTokens(refreshToken, deps) { const cfg = OAUTH_PROVIDERS.grok; if (!cfg) throw new ValidationError("Grok OAuth is not configured."); assertAllowedTokenHost(cfg.tokenUrl, cfg.apiHostAllowlist); const fetchFn = deps.fetch ?? fetch; const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: cfg.clientId, }); const res = await fetchFn(cfg.tokenUrl, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded", }, body: body.toString(), signal: AbortSignal.timeout(30_000), }); const text = await res.text(); if (!res.ok) { const hint = res.status === 401 || res.status === 400 ? " Re-run Grok sign-in from the panel." : ""; throw new ValidationError(`Grok OAuth refresh failed (${res.status}): ${redactTokens(text).slice(0, 300)}.${hint}`); } let payload; try { payload = JSON.parse(text); } catch { // See refreshOpenAICodexTokens's matching catch: a 2xx-but-malformed body // must never reach the caller raw via JSON.parse's own SyntaxError. throw new ValidationError("Grok OAuth refresh returned a malformed (non-JSON) response."); } const access = payload.access_token?.trim(); if (!access) { throw new ValidationError("Grok OAuth refresh response missing access_token."); } return { access_token: access, refresh_token: payload.refresh_token?.trim(), expires_in: typeof payload.expires_in === "number" ? payload.expires_in : undefined, }; } /** * Resolve ChatGPT/Codex subscription OAuth from ~/.codex/auth.json (written by `codex login`). * Refreshes expired access tokens in place. Use with the Codex Responses backend — not api.openai.com. */ export async function resolveOpenAICodexOAuth(deps = {}) { const home = deps.home ?? homedir(); const path = codexAuthPath(home); if (!existsSync(path)) { throw new ValidationError("ChatGPT OAuth requires ~/.codex/auth.json. Run `codex login` once, then pick the ChatGPT (direct OAuth) backend."); } const raw = JSON.parse(await readFile(path, "utf8")); const tokens = raw.tokens; if (!tokens?.access_token?.trim() && !tokens?.refresh_token?.trim()) { throw new ValidationError("No Codex OAuth tokens in ~/.codex/auth.json. Run `codex login`."); } const nowMs = deps.now?.() ?? Date.now(); let accessToken = tokens.access_token?.trim() ?? ""; const expMs = accessToken ? jwtExpMs(accessToken) : null; const needsRefresh = !accessToken || tokenExpiring(expMs, nowMs); if (needsRefresh) { const refreshToken = tokens.refresh_token?.trim(); if (!refreshToken) { throw new ValidationError("Codex access token expired and refresh_token is missing. Run `codex login`."); } const refreshed = await refreshOpenAICodexTokens(refreshToken, deps); accessToken = refreshed.access_token; raw.tokens = { ...tokens, access_token: accessToken, refresh_token: refreshed.refresh_token ?? refreshToken, }; raw.last_refresh = new Date(nowMs).toISOString(); await atomicWriteJson(path, raw); } const accountId = tokens.account_id?.trim() || jwtChatgptAccountId(tokens.id_token) || ""; if (!accountId) { throw new ValidationError("Codex OAuth is missing chatgpt account_id. Run `codex login` again to refresh ~/.codex/auth.json."); } return { accessToken, accountId, authMode: raw.auth_mode, }; } /** * Resolve Grok (xAI) OAuth from ~/.grok/auth.json (written by the in-panel sign-in * via `persistOAuthResult`). Refreshes expired access tokens in place — mirrors * `resolveOpenAICodexOAuth`. */ export async function resolveGrokOAuth(deps = {}) { const home = deps.home ?? homedir(); const path = grokAuthPath(home); if (!existsSync(path)) { throw new ValidationError("Grok OAuth requires signing in via the panel's Connections tab first."); } let creds = JSON.parse(await readFile(path, "utf8")); if (!creds.access_token?.trim() && !creds.refresh_token?.trim()) { throw new ValidationError("No Grok OAuth tokens in ~/.grok/auth.json. Sign in again from the panel."); } const nowMs = deps.now?.() ?? Date.now(); const expMs = typeof creds.expires_at === "number" ? creds.expires_at * 1000 : null; const needsRefresh = !creds.access_token?.trim() || tokenExpiring(expMs, nowMs); if (needsRefresh) { const refreshToken = creds.refresh_token?.trim(); if (!refreshToken) { throw new ValidationError("Grok access token expired and refresh_token is missing. Sign in again from the panel."); } const refreshed = await refreshGrokTokens(refreshToken, deps); const nowSec = Math.floor(nowMs / 1000); creds = { access_token: refreshed.access_token, refresh_token: refreshed.refresh_token ?? refreshToken, expires_in: refreshed.expires_in, expires_at: typeof refreshed.expires_in === "number" ? nowSec + refreshed.expires_in : undefined, }; await atomicWriteJson(path, creds); } const accessToken = creds.access_token?.trim(); if (!accessToken) { throw new ValidationError("Grok OAuth access_token is missing after refresh."); } return { accessToken }; } /** * Resolve the GitHub Copilot `ghu_` device-code token from * ~/.comfyui-mcp/copilot-auth.json (written by the in-panel sign-in via * `persistOAuthResult`). Mirrors `resolveGrokOAuth`'s file-resolution shape, * but WITHOUT refresh — GitHub's device-code `ghu_` token has no refresh_token * (see OAUTH_PROVIDERS.copilot's scopes/kind: device_code, read:user only); a * revoked/expired ghu_ can only be fixed by re-running Copilot sign-in from the * panel, never by a background refresh call. The short-lived Copilot API * bearer this token exchanges for (copilot-backend.ts) is a SEPARATE, higher- * frequency concern this function knows nothing about. */ export async function resolveCopilotOAuth(deps = {}) { const home = deps.home ?? homedir(); const path = copilotAuthPath(home); if (!existsSync(path)) { throw new ValidationError("GitHub Copilot OAuth requires signing in via the panel's Connections tab first (experimental — ToS risk; opt in from the experimental row)."); } const raw = JSON.parse(await readFile(path, "utf8")); const ghuToken = raw.access_token?.trim(); if (!ghuToken) { throw new ValidationError("No GitHub Copilot token in ~/.comfyui-mcp/copilot-auth.json. Sign in again from the panel."); } return { ghuToken }; } /** * Shared shape for the simple OpenAI-compatible API-key providers (GLM, * Moonshot, and any future one in the openai-provider-registry): read the first * non-empty key from a precedence list (throw with an actionable message if * none), then resolve the base URL from an optional override env (trailing slash * stripped) or the provider default. The `resolve*` fns below are thin, * type-narrowing wrappers so their public signatures and error messages are * unchanged. */ function resolveKeyedCredentials(opts) { let apiKey; for (const key of opts.envKeys) { const v = process.env[key]?.trim(); if (v) { apiKey = v; break; } } if (!apiKey) { throw new ValidationError(opts.missingMessage); } const baseUrl = process.env[opts.baseUrlEnv]?.trim().replace(/\/$/, "") || opts.defaultBaseUrl; return { apiKey, baseUrl }; } /** GLM Coding Plan API key (Z.AI). Env: ZAI_API_KEY, GLM_API_KEY, or ZHIPUAI_API_KEY. */ export function resolveGlmCodeCredentials() { return resolveKeyedCredentials({ envKeys: ["ZAI_API_KEY", "GLM_API_KEY", "ZHIPUAI_API_KEY", "ZHIPU_API_KEY"], missingMessage: "GLM Code API requires ZAI_API_KEY (or GLM_API_KEY / ZHIPUAI_API_KEY) from your Z.AI Coding Plan.", baseUrlEnv: "COMFYUI_MCP_GLM_BASE_URL", defaultBaseUrl: GLM_CODE_DEFAULT_BASE, }); } /** * Moonshot platform API key (Kimi K3). Env: MOONSHOT_API_KEY. This is the * general Moonshot/Kimi PLATFORM key (api.moonshot.ai), distinct from the * Kimi Code coding subscription resolved by `resolveKimiCodeOAuth` above. */ export function resolveMoonshotCredentials() { return resolveKeyedCredentials({ envKeys: ["MOONSHOT_API_KEY"], missingMessage: "Moonshot (Kimi K3) requires MOONSHOT_API_KEY from platform.kimi.ai (https://platform.kimi.ai/console/api-keys).", baseUrlEnv: "COMFYUI_MCP_MOONSHOT_BASE_URL", defaultBaseUrl: MOONSHOT_DEFAULT_BASE, }); } /** * MiniMax platform API key. Env: MINIMAX_API_KEY. OpenAI-compatible * /v1/chat/completions at the global endpoint (https://api.minimax.io/v1); * set COMFYUI_MCP_MINIMAX_BASE_URL to the China-region endpoint * (https://api.minimaxi.com/v1) or any other OpenAI-compatible host. Auth is a * plain Bearer key — the OpenAI-compatible route needs no GroupId. */ export function resolveMiniMaxCredentials() { return resolveKeyedCredentials({ envKeys: ["MINIMAX_API_KEY"], missingMessage: "MiniMax requires MINIMAX_API_KEY from platform.minimax.io (https://platform.minimax.io/console/api-keys).", baseUrlEnv: "COMFYUI_MCP_MINIMAX_BASE_URL", defaultBaseUrl: MINIMAX_DEFAULT_BASE, }); } /** Atlas Cloud OpenAI-compatible LLM API. Env: ATLASCLOUD_API_KEY. */ export function resolveAtlasCloudCredentials() { return resolveKeyedCredentials({ envKeys: ["ATLASCLOUD_API_KEY"], missingMessage: "Atlas Cloud requires ATLASCLOUD_API_KEY from https://www.atlascloud.ai/console/api-keys.", baseUrlEnv: "COMFYUI_MCP_ATLASCLOUD_BASE_URL", defaultBaseUrl: ATLASCLOUD_DEFAULT_BASE, }); } /** * Resolve credentials for a simple OpenAI-compatible api-key provider by its * registry id (see services/openai-provider-registry). This is the one place * that marries a `simpleKeyAuth` registry id to its resolver, so the generic * backend factory in orchestrator/index.ts stays provider-agnostic. `kimi` is * intentionally NOT here — its OAuth dual-auth path uses resolveKimiCodeOAuth. */ export function resolveOpenAiKeyCredentials(id) { if (id === "glm") return resolveGlmCodeCredentials(); if (id === "moonshot") return resolveMoonshotCredentials(); if (id === "minimax") return resolveMiniMaxCredentials(); if (id === "atlascloud") return resolveAtlasCloudCredentials(); throw new ValidationError(`No OpenAI api-key credential resolver for provider "${id}".`); } /** * Resolve Kimi Code subscription OAuth from ~/.kimi-code/credentials/kimi-code.json * (legacy ~/.kimi honored as a fallback). Falls back to KIMI_API_KEY when set * (pay-per-token / CI). */ export async function resolveKimiCodeOAuth(deps = {}) { const baseOverride = process.env.COMFYUI_MCP_KIMI_BASE_URL?.trim().replace(/\/$/, "") || undefined; // BEFORE any region resolution: a pay-per-token / CI key needs no OAuth, so it // must not be able to fail on an unreadable region file it never consults. // Keeps this path byte-identical to its previous behaviour. const apiKey = process.env.KIMI_API_KEY?.trim(); if (apiKey) { return { accessToken: apiKey, baseUrl: baseOverride ?? KIMI_CODE_DEFAULT_BASE }; } const home = deps.home ?? homedir(); const path = kimiCodeAuthPath(home); // One region decision drives BOTH hosts, so a global install cannot refresh at // auth.kimi.ai and then talk to the mainland coding API (#2534, gate r2). const region = kimiRegion(path, baseOverride); const baseUrl = baseOverride ?? kimiCodingBase(region); if (!existsSync(path)) { throw new ValidationError("Kimi Code OAuth requires ~/.kimi-code/credentials/kimi-code.json (run `kimi login`) or KIMI_API_KEY."); } let creds = JSON.parse(await readFile(path, "utf8")); const nowMs = deps.now?.() ?? Date.now(); const nowSec = nowMs / 1000; const expirySec = typeof creds.expires_at === "number" && Number.isFinite(creds.expires_at) ? creds.expires_at : creds.access_token ? (jwtExpMs(creds.access_token) ?? 0) / 1000 : null; const needsRefresh = !creds.access_token?.trim() || (expirySec != null && nowSec >= expirySec - TOKEN_REFRESH_SKEW_MS / 1000); if (needsRefresh) { const refreshToken = creds.refresh_token?.trim(); if (!refreshToken) { throw new ValidationError("Kimi Code access token expired and refresh_token is missing. Re-run Kimi Code login."); } creds = await refreshKimiCodeTokens(refreshToken, kimiOAuthTokenUrl(region), deps); await atomicWriteJson(path, creds); } const accessToken = creds.access_token?.trim(); if (!accessToken) { throw new ValidationError("Kimi Code OAuth access_token is missing after refresh."); } return { accessToken, baseUrl }; } /** Best-effort delete of a provider's native token file. Never throws — a * missing file, missing directory, or permission wobble is not fatal to * clearing the (separate) status mirror. */ function deleteNativeTokenFile(path) { try { rmSync(path, { force: true }); } catch { /* best-effort */ } } function nativeTokenFilePath(providerId, home) { if (providerId === "codex") return codexAuthPath(home); if (providerId === "grok") return grokAuthPath(home); if (providerId === "copilot") return copilotAuthPath(home); return null; } /** * Persist the tokens an OAuth flow (`runLoopbackPKCE` / `pollDeviceToken` in * oauth-flow.ts) returned: writes the NATIVE token file in the shape that * provider's resolver reads (codex → `resolveOpenAICodexOAuth`, grok → * `resolveGrokOAuth`, copilot → the raw `{access_token, token_type}` GitHub * Copilot expects), derives a human-readable account label, and records a * STATUS-ONLY mirror entry via `setOAuthStatus` for the panel UI. * * SECURITY: only `account_label` (plus timestamps/flags) reaches the mirror — * never `access_token`/`refresh_token`/`id_token`. See panel-secrets.ts. */ export async function persistOAuthResult(providerId, tokens, deps = {}) { const home = deps.home ?? homedir(); const nowMs = deps.now?.() ?? Date.now(); const experimental = providerId === "copilot"; let accountLabel; let expiresAt; if (providerId === "codex") { const accountId = jwtChatgptAccountId(tokens.id_token) || ""; const email = jwtEmailClaim(tokens.id_token); accountLabel = email || accountId || "ChatGPT account"; const auth = { auth_mode: "chatgpt", tokens: { access_token: tokens.access_token, refresh_token: tokens.refresh_token, account_id: accountId || undefined, id_token: tokens.id_token, }, last_refresh: new Date(nowMs).toISOString(), }; await atomicWriteJson(codexAuthPath(home), auth); const expMs = jwtExpMs(tokens.access_token); expiresAt = expMs != null ? Math.floor(expMs / 1000) : undefined; } else if (providerId === "grok") { const email = jwtEmailClaim(tokens.id_token); accountLabel = email || "xAI account"; const expiresIn = tokens.expires_in; const expAt = typeof expiresIn === "number" ? Math.floor(nowMs / 1000) + expiresIn : undefined; const auth = { access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_in: expiresIn, expires_at: expAt, }; await atomicWriteJson(grokAuthPath(home), auth); expiresAt = expAt; } else if (providerId === "copilot") { accountLabel = "GitHub Copilot"; const tokenType = typeof tokens.raw?.token_type === "string" ? tokens.raw.token_type : "bearer"; await atomicWriteJson(copilotAuthPath(home), { access_token: tokens.access_token, token_type: tokenType, }); } else { throw new ValidationError(`persistOAuthResult: unknown OAuth provider "${providerId}".`); } setOAuthStatus({ provider: providerId, account_label: accountLabel, obtained_at: nowMs, expires_at: expiresAt, experimental, }); return { account_label: accountLabel }; } /** * Best-effort: synthesize a status record from a provider's NATIVE CLI auth * file when the panel mirror has none — e.g. the user ran `codex login` (or * grok's CLI auth) long before the panel existed. Without this the credentials * card shows "Sign in with ChatGPT (Codex)" to someone who is already signed * in, pushing a pointless second login. Status only — token material is read * to derive a label/expiry and never returned. */ function nativeCliStatus(providerId, home = homedir()) { const path = nativeTokenFilePath(providerId, home); if (!path || !existsSync(path)) return null; try { const raw = JSON.parse(readFileSync(path, "utf8")); // codex nests under `tokens`; grok/copilot are flat. const tokens = raw.tokens ?? raw; const access = tokens.access_token?.trim(); const refresh = tokens.refresh_token?.trim(); if (!access && !refresh) return null; // A refresh token means the CLI can renew indefinitely — treat as signed in // even if the current access token is stale. Access-only: honor its exp. const expMs = access ? jwtExpMs(access) : null; if (!refresh && expMs !== null && expMs <= Date.now()) return null; const email = jwtEmailClaim(tokens.id_token); const obtained = (() => { try { return statSync(path).mtimeMs; } catch { return Date.now(); } })(); return { provider: providerId, account_label: email ? `${email} (CLI)` : "CLI session", obtained_at: Math.round(obtained), ...(refresh || expMs === null ? {} : { expires_at: expMs }), ...(providerId === "copilot" ? { experimental: true } : {}), }; } catch { return null; // unreadable/foreign file shape — stay silent, never block status } } /** All in-panel OAuth sign-ins' status (for the UI) — status only, never tokens. * Merges the panel mirror with DETECTED native CLI auth (mirror wins) so a * CLI-authenticated provider never asks the user to sign in a second time. * `home` is injectable so tests never read the developer's real logins. */ export function readOAuthStatus(home = homedir()) { // `home` goes to BOTH halves. It used to reach only `nativeCliStatus` below, // while the mirror read fell through to the real home — so the promise in this // docstring held for the detection half and not the mirror half (#859). const mirror = listOAuthStatus(home); const seen = new Set(mirror.map((r) => r.provider)); for (const providerId of ["codex", "grok", "copilot"]) { if (seen.has(providerId)) continue; const detected = nativeCliStatus(providerId, home); if (detected) mirror.push(detected); } return mirror; } /** Sign a provider out: deletes its native token file (best-effort) and the * status mirror entry. */ export function clearOAuth(providerId, deps = {}) { const home = deps.home ?? homedir(); const path = nativeTokenFilePath(providerId, home); if (path) deleteNativeTokenFile(path); clearOAuthStatus(providerId); } export const __testing = { nativeCliStatus, OPENAI_CODEX_CLIENT_ID, KIMI_CODE_CLIENT_ID, GLM_CODE_DEFAULT_BASE, KIMI_CODE_DEFAULT_BASE, MOONSHOT_DEFAULT_BASE, MINIMAX_DEFAULT_BASE, ATLASCLOUD_DEFAULT_BASE, codexAuthPath, kimiCodeAuthPath, grokAuthPath, copilotAuthPath, jwtExpMs, jwtChatgptAccountId, jwtEmailClaim, refreshOpenAICodexTokens, refreshKimiCodeTokens, refreshGrokTokens, }; //# sourceMappingURL=code-provider-auth.js.map