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),

267 lines • 14.1 kB
// GitHub Copilot chat backend — Task 5b of the in-panel OAuth plan. // EXPERIMENTAL, ISOLATED, ToS-risk (the user explicitly accepted this when // asking for it): a failure anywhere in this module must surface only as a // normal turn/prepare error on THIS backend — it can never affect // Grok/Codex/Claude/other backends. See OAUTH_PROVIDERS.copilot // (experimental: true, in oauth-flow.ts) and oauth-bridge.ts's // allow_experimental gate, which is what makes a `~/.comfyui-mcp/copilot- // auth.json` ghu_ token exist in the first place. // // REUSE vs COPILOT-SPECIFIC: // Copilot's chat endpoint is OpenAI chat-completions compatible, so this // backend is a THIN subclass of OllamaBackend's "openai" dialect — the same // pattern as KimiBackend (kimi-backend.ts) and the generic api-key factory // (services/openai-provider-registry + makeOpenAiKeyBackend), NOT the // Grok/Codex Responses-API adapter (grok-backend.ts's GrokDirectBackend), // because Copilot is a real /chat/completions endpoint, not /responses. The // inherited chatStream/readOpenAiSse/run/runTurn/dispatch/buildModelTools all // apply unchanged. The ONLY Copilot-specific parts, all isolated to this file: // 1. the ghu_ → short-lived Copilot bearer exchange // (GET https://api.github.com/copilot_internal/v2/token) // 2. the "editor identity" headers GitHub requires on BOTH the exchange and // every chat/completions call (Editor-Version / Editor-Plugin-Version / // User-Agent / Copilot-Integration-Id) — GitHub 403s the exchange // WITHOUT them even on a fully licensed Copilot account (this is the // documented `github-copilot-token-exchange-needs-editor-headers` gotcha) // 3. the token source: ~/.comfyui-mcp/copilot-auth.json via // `resolveCopilotOAuth` (code-provider-auth.ts), mirroring // `resolveGrokOAuth` but WITHOUT refresh — ghu_ has no refresh_token; a // dead/revoked one can only be fixed by re-running Copilot sign-in. // // UNVERIFIED AGAINST THE LIVE api.githubcopilot.com CONTRACT (flagged per the // task brief — Task 8 validates live with a real, licensed account): // - the exact exchange response shape ({token, expires_at, endpoints:{api}}) // - the chat/completions and /models paths and their exact request/response // shapes // - whether the signed-in account is Copilot-licensed at all (a 403 from the // exchange is ambiguous between "no license" and "bad/stale editor // headers" — see the skill note above) // - the default model slug (Copilot's catalog changes over time; utility // models share a rate-limit bucket per the `copilot-utility-models- // shared-rate-limit` gotcha, so a busy account may 429 even with a // correct slug — this backend does not special-case that; it surfaces the // 429 like any other HTTP error) // Both base URLs AND the default model are overridable via env so a live // mismatch is a config flip, not a code change. import { ValidationError } from "../utils/errors.js"; import { logger } from "../utils/logger.js"; import { errorText } from "./error-text.js"; import { COPILOT_CAPABILITIES } from "./agent-backend.js"; import { OllamaBackend } from "./ollama-backend.js"; import { resolveCopilotOAuth, } from "../services/code-provider-auth.js"; import { assertAllowedTokenHost, OAUTH_PROVIDERS, redactTokens } from "../services/oauth-flow.js"; function msgOf(err) { return errorText(err); } /** GET endpoint that exchanges the long-lived `ghu_` token for a short-lived * Copilot API bearer. Overridable — the exact host/path is UNVERIFIED * offline (see the module doc above). */ export const COPILOT_TOKEN_EXCHANGE_URL = process.env.COMFYUI_MCP_COPILOT_TOKEN_URL?.trim() || "https://api.github.com/copilot_internal/v2/token"; /** Base URL for Copilot's OpenAI-compatible chat/completions + /models. * Overridable — a live exchange response's `endpoints.api` (when present) * wins over this default for the REST of that token's lifetime (individual * vs. business accounts may route to different hosts per the exchange * response, per research). */ export const COPILOT_API_BASE = process.env.COMFYUI_MCP_COPILOT_API_BASE?.trim().replace(/\/+$/, "") || "https://api.githubcopilot.com"; /** UNVERIFIED against the live Copilot model catalog (slugs change — see the * module doc). Override with COMFYUI_MCP_COPILOT_MODEL, or rely on the * inherited listModels() (a live GET /models probe) to surface the account's * real, currently-provisioned slugs. */ export const COPILOT_DEFAULT_MODEL = process.env.COMFYUI_MCP_COPILOT_MODEL?.trim() || "gpt-4.1"; /** * GitHub's VS Code Copilot Chat "editor identity" — REQUIRED on both the * token exchange and every chat/completions call, or GitHub 403s even a fully * licensed account (see the `github-copilot-token-exchange-needs-editor- * headers` gotcha). Exact version numbers are not strictly validated by * GitHub — kept as a plausible, recognizable VS Code Copilot Chat identity * rather than an app-branded User-Agent (which IS rejected). */ const EDITOR_IDENTITY_HEADERS = { "Editor-Version": "vscode/1.95.0", "Editor-Plugin-Version": "copilot-chat/0.22.0", "User-Agent": "GitHubCopilotChat/0.22.0", "Copilot-Integration-Id": "vscode-chat", }; // Uses the shared `redactTokens` from oauth-flow.ts (single source of truth — // this file used to carry a local `redactCopilotTokens` copy; its extra // ghp_/`token\s+` rules were folded into the exported superset, no coverage // lost) to strip token-shaped material from provider error text before it // can reach a thrown message or a log line. Never logs/throws the raw ghu_ or // the exchanged short-lived bearer. /** `github.com` / `githubcopilot.com` — the same allowlist the OAuth engine * already enforces for Copilot's device-code flow (oauth-flow.ts), reused * here for the token-exchange + chat/completions DATA calls. */ function copilotApiHostAllowlist() { return OAUTH_PROVIDERS.copilot?.apiHostAllowlist ?? ["github.com", "githubcopilot.com"]; } /** Append a "re-run Copilot sign-in" hint to a 401/403 error message exactly * once (idempotent — never double-appends if the message already carries a * hint, e.g. from `exchangeCopilotToken` below). */ function withCopilotAuthHint(err) { const message = msgOf(err); if (/\b(401|403)\b/.test(message) && !/re-run copilot sign-in/i.test(message)) { return new Error(`${message} Re-run Copilot sign-in from the panel.`); } return err instanceof Error ? err : new Error(message); } /** * Step 1 of the two-step Copilot contract: exchange the long-lived `ghu_` * token for a short-lived Copilot API bearer. Host-allowlisted before any * network call; any error body is redacted before it can reach a thrown * message. A 401/403 here is the PRIMARY "you're not signed in / not * licensed" failure mode, so it gets the explicit re-sign-in hint. */ async function exchangeCopilotToken(ghuToken, fetchFn) { assertAllowedTokenHost(COPILOT_TOKEN_EXCHANGE_URL, copilotApiHostAllowlist()); const res = await fetchFn(COPILOT_TOKEN_EXCHANGE_URL, { method: "GET", headers: { Authorization: `token ${ghuToken}`, Accept: "application/json", ...EDITOR_IDENTITY_HEADERS, }, signal: AbortSignal.timeout(15_000), }); const text = await res.text(); if (!res.ok) { const hint = res.status === 401 || res.status === 403 ? " Re-run Copilot sign-in from the panel (this also happens if the signed-in account has no active Copilot subscription)." : ""; throw new ValidationError(`GitHub Copilot token exchange failed (${res.status}): ${redactTokens(text).slice(0, 300)}.${hint}`); } let payload; try { payload = JSON.parse(text); } catch { throw new ValidationError("GitHub Copilot token exchange returned an unparseable response."); } const token = payload.token?.trim(); if (!token) { throw new ValidationError("GitHub Copilot token exchange response is missing 'token'."); } const expiresAtMs = typeof payload.expires_at === "number" && Number.isFinite(payload.expires_at) ? payload.expires_at * 1000 : Date.now() + 20 * 60_000; // conservative fallback if the field is absent/renamed // The exchange response's endpoints.api can route to a per-account host, but a // server-supplied host is UNTRUSTED — if it isn't allowlisted, DROP it and // fall back to the default base rather than dialing an off-allowlist host with // the live bearer. (The default base itself is re-checked in // ensureFreshCopilotToken before setOpenAiAuth, so a bad env override there is // rejected outright — see below.) const advertised = payload.endpoints?.api?.trim().replace(/\/+$/, ""); let apiBase = COPILOT_API_BASE; if (advertised) { try { assertAllowedTokenHost(advertised, copilotApiHostAllowlist()); apiBase = advertised; } catch { logger.warn(`[copilot-backend] token exchange advertised a non-allowlisted api host — ignoring it and using the default base.`); } } return { token, expiresAtMs, apiBase }; } /** Refresh 60s before the bearer's reported expiry — matches the skew used * elsewhere in code-provider-auth.ts (TOKEN_REFRESH_SKEW_MS-style guard). */ const COPILOT_TOKEN_REFRESH_SKEW_MS = 60_000; /** * GitHub Copilot chat — OpenAI-compatible chat/completions + the same 6-tool * router as Ollama/GLM/Kimi. See the module doc above for the full contract, * the reuse rationale, and the unverified-offline items. */ export class CopilotBackend extends OllamaBackend { capabilities = COPILOT_CAPABILITIES; ghuToken = null; copilotBearer = null; copilotBearerExpiresAtMs = 0; resolveOAuth; fetchFn; constructor(deps = {}) { super({ ...deps, backendId: "copilot", api: "openai", host: COPILOT_API_BASE, // placeholder until the first exchange resolves endpoints.api apiKey: "pending-oauth", // never dialed with this value — ensureFreshCopilotToken() runs first model: deps.model ?? COPILOT_DEFAULT_MODEL, }); this.resolveOAuth = deps.resolveCopilotOAuth ?? resolveCopilotOAuth; this.fetchFn = deps.fetch ?? fetch; } /** Adds GitHub's required editor-identity headers on top of the inherited * Bearer-token header — applies to every chat/completions and /models call * (OllamaBackend's "openai" dialect reads authHeaders() for both). */ authHeaders() { const base = super.authHeaders(); if (!base.authorization) return base; return { ...base, ...EDITOR_IDENTITY_HEADERS }; } /** * Exchange (or reuse a cached, still-fresh) short-lived Copilot bearer. * Called from prepare() (session start) AND before EVERY turn (see run() * below) so a long-lived panel session survives the token's short, * GitHub-controlled lifetime. The short-lived bearer is held ONLY in this * instance's memory — never written to disk (only the ghu_ persists, in * copilot-auth.json, and this function never rewrites that file). */ async ensureFreshCopilotToken() { const nowMs = Date.now(); if (this.copilotBearer && nowMs < this.copilotBearerExpiresAtMs - COPILOT_TOKEN_REFRESH_SKEW_MS) { return; } if (!this.ghuToken) { const creds = await this.resolveOAuth(); this.ghuToken = creds.ghuToken; } const { token, expiresAtMs, apiBase } = await exchangeCopilotToken(this.ghuToken, this.fetchFn); // FINAL GATE before the live bearer is ever attached to a host: apiBase is // either the allowlist-checked exchange endpoint or COPILOT_API_BASE (which // may be a COMFYUI_MCP_COPILOT_API_BASE env override). An off-allowlist // override must be REJECTED here — never silently dialed — so the inherited // ollama-backend prepare()/chatStream()/listModels() (which attach the // bearer to this.host) can only ever hit an allowlisted host. assertAllowedTokenHost(apiBase, copilotApiHostAllowlist()); this.copilotBearer = token; this.copilotBearerExpiresAtMs = expiresAtMs; this.setOpenAiAuth(apiBase, token); } async prepare() { await this.ensureFreshCopilotToken(); try { await super.prepare(); // GET {apiBase}/models reachability check + connectTools() } catch (err) { throw withCopilotAuthHint(err); } logger.info(`[copilot-backend] ready (experimental — see task-5b report for the unverified contract items)`); } /** * Wrap the incoming turn channel so the Copilot bearer is refreshed BEFORE * every turn, not just once at session start — a panel session can easily * outlive the short-lived exchange token. Best-effort: a refresh failure * mid-session is logged and the turn proceeds with whatever token is * cached; if that token is actually stale the ensuing chat/completions call * 401s and is surfaced through OllamaBackend's own turn-error path (a * normal `result:false` + assistant error — never an unhandled rejection * that could park the panel's turn-gate). */ async *wrapChannel(channel) { for await (const turn of channel) { try { await this.ensureFreshCopilotToken(); } catch (err) { logger.warn(`[copilot-backend] token refresh before turn failed (${msgOf(err)}) — attempting the turn with the existing token.`); } yield turn; } } async *run(opts) { yield* super.run({ ...opts, channel: this.wrapChannel(opts.channel) }); } } //# sourceMappingURL=copilot-backend.js.map