diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index c9340c10..7191facd 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -21,7 +21,6 @@ import { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; import { MimoFreeExecutor } from "./mimo-free.js"; import { CodeBuddyExecutor } from "./codebuddy-cn.js"; import { CodeBuddyIntlExecutor } from "./codebuddy-intl.js"; -import { WorkBuddyExecutor } from "./workbuddy.js"; import TraeExecutor from "./trae.js"; import ZedExecutor from "./zed.js"; import WindsurfExecutor from "./windsurf.js"; @@ -56,7 +55,6 @@ const executors = { mmf: new MimoFreeExecutor(), // Alias for mimo-free "codebuddy-cn": new CodeBuddyExecutor(), "codebuddy-intl": new CodeBuddyIntlExecutor(), - workbuddy: new WorkBuddyExecutor(), trae: new TraeExecutor(), zed: new ZedExecutor(), windsurf: new WindsurfExecutor(), @@ -99,7 +97,6 @@ export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; export { MimoFreeExecutor } from "./mimo-free.js"; export { CodeBuddyExecutor } from "./codebuddy-cn.js"; export { CodeBuddyIntlExecutor } from "./codebuddy-intl.js"; -export { WorkBuddyExecutor } from "./workbuddy.js"; export { default as TraeExecutor } from "./trae.js"; export { default as ZedExecutor } from "./zed.js"; export { default as WindsurfExecutor } from "./windsurf.js"; diff --git a/open-sse/executors/trae.js b/open-sse/executors/trae.js index 347d5307..59f29be3 100644 --- a/open-sse/executors/trae.js +++ b/open-sse/executors/trae.js @@ -1,22 +1,339 @@ -import { DefaultExecutor } from "./default.js"; +import { BaseExecutor } from "./base.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { PROVIDERS } from "../config/providers.js"; -// Trae executor — inject x-cloudide-token (raw access token) + Authorization Bearer. -// Mirrors trae_account.rs request_trae_json header set. -export default class TraeExecutor extends DefaultExecutor { +// Trae executor — SOLO remote agent API. +// +// Flow: +// 1. POST {base}/chat_sessions → { code:0, data:{ chat_session_id, message_id } } +// 2. GET {base}/chat_sessions/{id}/events?reply_to_message_id={message_id} +// → text/event-stream. Assistant text streams in `plan_item` events under +// the `thought` field (cumulative per plan-item id). `token_usage` carries +// usage; `done` ends the turn; `error` carries upstream errors. +// +// Auth: header `Authorization: Cloud-IDE-JWT ` (RS256, ~14-day lifetime). +// Identity fields for common_params live in credentials.providerSpecificData. + +const STREAM_TIMEOUT_MS = parseInt(process.env.TRAE_STREAM_TIMEOUT_MS || "300000", 10); +const TRAE_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; + +function flattenQuery(messages) { + const parts = []; + for (const m of messages) { + let content = ""; + if (typeof m.content === "string") content = m.content; + else if (Array.isArray(m.content)) { + content = m.content + .map((p) => { + if (typeof p === "string") return p; + if (p && typeof p === "object") return String(p.text ?? ""); + return ""; + }) + .join(""); + } + if (m.role === "system") parts.push(`[System]\n${content}`); + else if (m.role === "assistant") parts.push(`[Assistant]\n${content}`); + else parts.push(content); + } + // Trae expects query as a JSON-encoded string of typed content blocks. + return JSON.stringify([{ type: "text", data: { content: parts.join("\n\n") } }]); +} + +export default class TraeExecutor extends BaseExecutor { constructor() { - super("trae"); + super("trae", PROVIDERS.trae); + } + + base() { + return (this.config.baseUrl || "https://core-normal.trae.ai/api/remote/v1").replace(/\/$/, ""); } buildHeaders(credentials, stream = true) { - const headers = super.buildHeaders(credentials, stream); - const token = credentials?.accessToken; - if (token) { - // Raw token (no Bearer prefix) on x-cloudide-token — matches official client. - headers["x-cloudide-token"] = token; - headers["Authorization"] = `Bearer ${token}`; - } - return headers; + const token = credentials?.accessToken || ""; + const psd = credentials?.providerSpecificData || {}; + return { + Authorization: `Cloud-IDE-JWT ${token}`, + "Content-Type": "application/json", + "X-Trae-Client-Type": "web", + "X-Preferenced-Language": psd.appLanguage || "en", + "x-user-region": psd.userRegion || "US", + Referer: "https://solo.trae.ai/", + "User-Agent": TRAE_UA, + Accept: stream ? "text/event-stream" : "application/json", + }; } - // TODO verify: if Chat is JSON-RPC shaped, override transformRequest here. + // SOLO session modes: "code" (model picker) vs "work" (fast auto lane). + resolveMode(model) { + const m = (model || "").trim().toLowerCase(); + if (m === "work" || m === "auto-work" || m === "solo-work") { + return { mode: "work", strategy: "auto", modelName: "" }; + } + const auto = !m || m === "auto"; + return { mode: "code", strategy: auto ? "auto" : "manual", modelName: auto ? "" : model }; + } + + // common_params is a JSON-encoded string embedded inside initial_message. + commonParams(psd, mode, sessionId) { + const cp = { + language: "en-us", + app_language: psd.appLanguage || "en", + quality: "stable", + app_version: psd.appVersion || "1.0.0.1229", + web_id: psd.webId || "", + user_identity: psd.userIdentity || "Free", + is_freshman: "0", + biz_user_id: psd.bizUserId || "", + user_unique_id: psd.userUniqueId || "", + scope: psd.scope || "marscode-us", + tenant: psd.tenant || "marscode", + region: psd.region || "US-East", + aiRegion: psd.aiRegion || psd.region || "US-East", + is_privacy_mode: 0, + privacy_mode: "off", + solo_chat_mode: mode, + }; + if (sessionId) cp.biz_session_id = sessionId; + return JSON.stringify(cp); + } + + // POST /chat_sessions — creates a session and submits the first turn. + async createSession(headers, query, model, psd, signal) { + const { mode, strategy, modelName } = this.resolveMode(model); + const body = { + mode, + environment_id: "default", + initial_message: { + chat_session_id: "", + content: [], + query, + model_name: modelName, + agent_type: "solo_agent_remote", + model_selection_strategy: strategy, + common_params: this.commonParams(psd, mode), + }, + env: "remote", + auto_create_project: false, + origin: "web", + }; + const res = await proxyAwareFetch(`${this.base()}/chat_sessions`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal, + }, null); + const text = await res.text(); + if (!res.ok) throw new Error(`[${res.status}] ${text}`); + const json = JSON.parse(text); + if (json?.code !== 0) throw new Error(`Trae create_session: ${JSON.stringify(json)}`); + return { sessionId: json.data.chat_session_id, messageId: json.data.message_id }; + } + + // GET /events SSE → invoke onEvent(eventType, dataObj) per frame. + // Resolves when `done`/`error` arrives, the stream ends, or timeout fires. + async streamEvents(headers, sessionId, replyTo, onEvent, signal) { + const url = `${this.base()}/chat_sessions/${sessionId}/events?reply_to_message_id=${encodeURIComponent(replyTo)}`; + const ctrl = new AbortController(); + if (signal?.aborted) ctrl.abort(); + const timer = setTimeout(() => ctrl.abort(new Error("trae stream timeout")), STREAM_TIMEOUT_MS); + const onAbort = () => ctrl.abort(); + if (signal) signal.addEventListener("abort", onAbort, { once: true }); + try { + const res = await proxyAwareFetch(url, { method: "GET", headers, signal: ctrl.signal }, null); + if (!res.ok || !res.body) throw new Error(`[${res.status}] events stream failed`); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + let ev = null; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let nl; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl).replace(/\r$/, ""); + buf = buf.slice(nl + 1); + if (line.startsWith("event:")) ev = line.slice(6).trim(); + else if (line.startsWith("data:")) { + const payload = line.slice(5).trim(); + let data; + try { data = JSON.parse(payload); } catch { data = { _raw: payload }; } + if (onEvent(ev, data)) { + await reader.cancel().catch(() => {}); + return; + } + } else if (line === "") ev = null; + } + } + } finally { + clearTimeout(timer); + if (signal) signal.removeEventListener("abort", onAbort); + } + } + + async execute({ model, body, stream, credentials, signal }) { + const headers = this.buildHeaders(credentials, stream !== false); + const psd = credentials?.providerSpecificData || {}; + const query = flattenQuery(body?.messages || []); + const responseId = `chatcmpl-trae-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + + const errResponse = (status, message) => new Response( + JSON.stringify({ error: { message, type: "api_error", code: "" } }), + { status, headers: { "Content-Type": "application/json" } } + ); + + let session; + try { + session = await this.createSession(headers, query, model, psd, signal); + } catch (err) { + return { response: errResponse(502, err?.message ? String(err.message) : String(err)), url: this.base(), headers, transformedBody: body }; + } + + // Shared per-turn state: plan_item thoughts (cumulative, longest wins). + const order = []; + const thoughts = {}; + let sent = 0; + let usage = null; + let errorEvent = null; + const renderNewText = (data) => { + const pid = data.id; + if (!pid) return ""; + if (!(pid in thoughts)) order.push(pid); + const t = data.thought || ""; + if (t.length >= (thoughts[pid] || "").length) thoughts[pid] = t; + const full = order.map((i) => thoughts[i]).join(""); + const piece = full.slice(sent); + sent = full.length; + return piece; + }; + + if (stream !== false) { + const enc = new TextEncoder(); + const sse = new ReadableStream({ + start: async (controller) => { + const emit = (obj) => controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`)); + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }); + try { + await this.streamEvents(headers, session.sessionId, session.messageId, (ev, data) => { + if (ev === "error") { errorEvent = data; return true; } + if (ev === "token_usage") usage = data; + if (ev === "plan_item") { + const piece = renderNewText(data); + if (piece) { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content: piece }, finish_reason: null }], + }); + } + } + return ev === "done"; + }, signal); + if (errorEvent) { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [], + error: { message: `trae ${errorEvent.code || ""}: ${errorEvent.message || ""}`, type: "api_error" }, + }); + } else { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }); + if (usage) { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [], + usage: { + prompt_tokens: usage.prompt_tokens || 0, + completion_tokens: usage.completion_tokens || 0, + total_tokens: usage.total_tokens || 0, + }, + }); + } + } + controller.enqueue(enc.encode("data: [DONE]\n\n")); + controller.close(); + } catch (err) { + controller.error(err); + } + }, + }); + return { + response: new Response(sse, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + }), + url: this.base(), + headers, + transformedBody: body, + }; + } + + // Non-streaming: drive to completion, return chat.completion JSON. + try { + await this.streamEvents(headers, session.sessionId, session.messageId, (ev, data) => { + if (ev === "error") { errorEvent = data; return true; } + if (ev === "token_usage") usage = data; + if (ev === "plan_item") renderNewText(data); + return ev === "done"; + }, signal); + } catch (err) { + return { response: errResponse(502, err?.message ? String(err.message) : String(err)), url: this.base(), headers, transformedBody: body }; + } + if (errorEvent) { + return { response: errResponse(502, `trae ${errorEvent.code || ""}: ${errorEvent.message || ""}`), url: this.base(), headers, transformedBody: body }; + } + const content = order.map((i) => thoughts[i]).join(""); + const out = { + id: responseId, + object: "chat.completion", + created, + model, + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + }; + if (usage) { + out.usage = { + prompt_tokens: usage.prompt_tokens || 0, + completion_tokens: usage.completion_tokens || 0, + total_tokens: usage.total_tokens || 0, + }; + } + return { + response: new Response(JSON.stringify(out), { status: 200, headers: { "Content-Type": "application/json" } }), + url: this.base(), + headers, + transformedBody: body, + }; + } + + // Refresh hook placeholder — Cloud-IDE-JWT is long-lived (~14d); refresh via + // ExchangeToken (refresh→access) is wired in services/tokenRefresh/providers.js. + async refreshCredentials() { + return null; + } } diff --git a/open-sse/executors/windsurf.js b/open-sse/executors/windsurf.js index d9b19ec3..8526df17 100644 --- a/open-sse/executors/windsurf.js +++ b/open-sse/executors/windsurf.js @@ -1,40 +1,586 @@ -import { DefaultExecutor } from "./default.js"; +import { BaseExecutor } from "./base.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { PROVIDERS } from "../config/providers.js"; +import { randomUUID } from "node:crypto"; -// Windsurf chat = Codeium binary protobuf gRPC-Web. -// The .proto schema for exa.server_pb.ServerService is NOT in either source -// repo, so request/response encode+decode cannot be implemented truthfully. -// Auth, headers, quota are wired; the chat payload is intentionally a hard -// failure rather than a fabricated protobuf body. -export class WindsurfExecutor extends DefaultExecutor { +// WindsurfExecutor — Codeium gRPC-web chat. +// +// Wire protocol: gRPC-web over HTTPS (Content-Type: application/grpc-web+proto). +// Service: exa.language_server_pb.LanguageServerService +// Method: GetChatMessage (unary request → streamed CompletionChunk frames) +// +// Auth: credentials.accessToken = Codeium apiKey (sk-ws-... or Firebase-derived) +// — placed in Metadata.api_key protobuf field of every request + Bearer header. + +const WS_BASE_URL = "https://server.codeium.com"; +const WS_SERVICE = "exa.language_server_pb.LanguageServerService"; +const WS_METHOD_CHAT = "GetChatMessage"; +const WS_CHAT_URL = `${WS_BASE_URL}/${WS_SERVICE}/${WS_METHOD_CHAT}`; + +const WS_IDE_NAME = "windsurf"; +const WS_IDE_VERSION = "3.14.0"; +const WS_EXT_VERSION = "3.14.0"; +const WS_LOCALE = "en-US"; + +// ─── Model alias map (catalog name → Windsurf wire name) ───────────────────── +const MODEL_ALIAS_MAP = { + // ── Cognition SWE ─────────────────────────────────────────────────────── + "swe-1.6-fast": "swe-1-6-fast", + "swe-1.6": "swe-1-6", + "swe-1.5-fast": "swe-1-5-fast", + "swe-1.5": "swe-1-5", + // ── Claude Opus 4.7 — effort-tiered ───────────────────────────────────── + "claude-opus-4.7-max": "claude-opus-4-7-max", + "claude-opus-4.7-xhigh": "claude-opus-4-7-xhigh", + "claude-opus-4.7-high": "claude-opus-4-7-high", + "claude-opus-4.7-medium": "claude-opus-4-7-medium", + "claude-opus-4.7-low": "claude-opus-4-7-low", + "claude-opus-4.7-review": "opus-4-7-review", + // ── Claude Opus/Sonnet 4.6 ────────────────────────────────────────────── + "claude-sonnet-4.6-thinking-1m": "claude-sonnet-4-6-thinking-1m", + "claude-sonnet-4.6-1m": "claude-sonnet-4-6-1m", + "claude-sonnet-4.6-thinking": "claude-sonnet-4-6-thinking", + "claude-sonnet-4.6": "claude-sonnet-4-6", + "claude-opus-4.6-thinking": "claude-opus-4-6-thinking", + "claude-opus-4.6": "claude-opus-4-6", + // ── Claude 4.5 ────────────────────────────────────────────────────────── + "claude-opus-4.5-thinking": "MODEL_CLAUDE_4_5_OPUS_THINKING", + "claude-opus-4.5": "MODEL_CLAUDE_4_5_OPUS", + "claude-sonnet-4.5-thinking": "MODEL_PRIVATE_3", + "claude-sonnet-4.5": "MODEL_PRIVATE_2", + "claude-haiku-4.5": "MODEL_PRIVATE_11", + // ── GPT-5.5 ───────────────────────────────────────────────────────────── + "gpt-5.5-xhigh-fast": "gpt-5-5-xhigh-priority", + "gpt-5.5-high-fast": "gpt-5-5-high-priority", + "gpt-5.5-medium-fast": "gpt-5-5-medium-priority", + "gpt-5.5-low-fast": "gpt-5-5-low-priority", + "gpt-5.5-none-fast": "gpt-5-5-none-priority", + "gpt-5.5-xhigh": "gpt-5-5-xhigh", + "gpt-5.5-high": "gpt-5-5-high", + "gpt-5.5-medium": "gpt-5-5-medium", + "gpt-5.5-low": "gpt-5-5-low", + "gpt-5.5-none": "gpt-5-5-none", + "gpt-5.5-review": "gpt-5-5-review", + "gpt-5.5": "gpt-5-5-medium", + // ── GPT-5.4 ───────────────────────────────────────────────────────────── + "gpt-5.4-xhigh-fast": "gpt-5-4-xhigh-priority", + "gpt-5.4-high-fast": "gpt-5-4-high-priority", + "gpt-5.4-medium-fast": "gpt-5-4-medium-priority", + "gpt-5.4-low-fast": "gpt-5-4-low-priority", + "gpt-5.4-none-fast": "gpt-5-4-none-priority", + "gpt-5.4-xhigh": "gpt-5-4-xhigh", + "gpt-5.4-high": "gpt-5-4-high", + "gpt-5.4-medium": "gpt-5-4-medium", + "gpt-5.4-low": "gpt-5-4-low", + "gpt-5.4-none": "gpt-5-4-none", + "gpt-5.4-mini-xhigh": "gpt-5-4-mini-xhigh", + "gpt-5.4-mini-high": "gpt-5-4-mini-high", + "gpt-5.4-mini-medium": "gpt-5-4-mini-medium", + "gpt-5.4-mini-low": "gpt-5-4-mini-low", + "gpt-5.4": "gpt-5-4-medium", + // ── GPT-5.3-Codex ─────────────────────────────────────────────────────── + "gpt-5.3-codex-xhigh-fast": "gpt-5-3-codex-xhigh-priority", + "gpt-5.3-codex-high-fast": "gpt-5-3-codex-high-priority", + "gpt-5.3-codex-medium-fast": "gpt-5-3-codex-medium-priority", + "gpt-5.3-codex-low-fast": "gpt-5-3-codex-low-priority", + "gpt-5.3-codex-xhigh": "gpt-5-3-codex-xhigh", + "gpt-5.3-codex-high": "gpt-5-3-codex-high", + "gpt-5.3-codex-medium": "gpt-5-3-codex-medium", + "gpt-5.3-codex-low": "gpt-5-3-codex-low", + "gpt-5.3-codex": "gpt-5-3-codex-medium", + // ── GPT-5.2 ───────────────────────────────────────────────────────────── + "gpt-5.2-xhigh": "MODEL_GPT_5_2_XHIGH", + "gpt-5.2-high": "MODEL_GPT_5_2_HIGH", + "gpt-5.2-medium": "MODEL_GPT_5_2_MEDIUM", + "gpt-5.2-low": "MODEL_GPT_5_2_LOW", + "gpt-5.2-none": "MODEL_GPT_5_2_NONE", + "gpt-5.2": "MODEL_GPT_5_2_MEDIUM", + // ── GPT-5 ─────────────────────────────────────────────────────────────── + "gpt-5": "gpt-5", + // ── GPT-4.1 / 4o ──────────────────────────────────────────────────────── + "gpt-4.1": "MODEL_CHAT_GPT_4_1_2025_04_14", + "gpt-4.1-mini": "gpt-4.1-mini", + "gpt-4o": "MODEL_CHAT_GPT_4O_2024_08_06", + // ── Gemini ────────────────────────────────────────────────────────────── + "gemini-3.1-pro-high": "gemini-3-1-pro-high", + "gemini-3.1-pro-low": "gemini-3-1-pro-low", + "gemini-3.1-pro": "gemini-3-1-pro-high", + "gemini-3.0-flash-high": "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH", + "gemini-3.0-flash-medium": "MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM", + "gemini-3.0-flash-low": "MODEL_GOOGLE_GEMINI_3_0_FLASH_LOW", + "gemini-3.0-flash-minimal": "MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL", + "gemini-3.0-flash": "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH", + "gemini-2.5-pro": "MODEL_GOOGLE_GEMINI_2_5_PRO", + // ── Others ────────────────────────────────────────────────────────────── + "deepseek-v4": "deepseek-v4", + "kimi-k2.6": "kimi-k2-6", + "kimi-k2.5": "kimi-k2-5", + "glm-5.1": "glm-5-1", +}; + +export function resolveWsModelId(model) { + return MODEL_ALIAS_MAP[model] ?? model; +} + +// ─── Minimal protobuf encoder ──────────────────────────────────────────────── +// Wire types: 0 = varint, 2 = length-delimited. + +function encodeVarint(value) { + const bytes = []; + let v = value >>> 0; + while (v > 0x7f) { + bytes.push((v & 0x7f) | 0x80); + v >>>= 7; + } + bytes.push(v & 0x7f); + return new Uint8Array(bytes); +} + +function concatBytes(arrays) { + const total = arrays.reduce((n, a) => n + a.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const a of arrays) { + out.set(a, off); + off += a.length; + } + return out; +} + +const TEXT_ENC = new TextEncoder(); +const TEXT_DEC = new TextDecoder(); + +function encodeField(fieldNum, payload) { + const tag = encodeVarint((fieldNum << 3) | 2); + const len = encodeVarint(payload.length); + return concatBytes([tag, len, payload]); +} + +function encodeString(fieldNum, value) { + return encodeField(fieldNum, TEXT_ENC.encode(value)); +} + +function encodeMessage(fieldNum, msg) { + return encodeField(fieldNum, msg); +} + +// ─── Protobuf message builders ─────────────────────────────────────────────── + +function buildMetadata(apiKey, sessionId) { + return concatBytes([ + encodeString(1, apiKey), + encodeString(2, WS_IDE_NAME), + encodeString(3, WS_IDE_VERSION), + encodeString(4, WS_EXT_VERSION), + encodeString(5, sessionId), + encodeString(6, WS_LOCALE), + ]); +} + +function buildModelOrAlias(model) { + return encodeString(1, model); +} + +function buildChatMessage(msg) { + const parts = [encodeString(1, msg.role), encodeString(2, msg.content)]; + if (msg.toolCallId) parts.push(encodeString(3, msg.toolCallId)); + return concatBytes(parts); +} + +export function buildGetChatMessageRequest(apiKey, model, messages) { + const sessionId = randomUUID(); + const cascadeId = randomUUID(); + + const parts = [ + encodeMessage(1, buildMetadata(apiKey, sessionId)), // metadata + encodeString(2, cascadeId), // cascade_id + encodeMessage(3, buildModelOrAlias(model)), // model_or_alias + ]; + + for (const msg of messages) { + parts.push(encodeMessage(4, buildChatMessage(msg))); // repeated messages + } + + return concatBytes(parts); +} + +// ─── gRPC-web framing ──────────────────────────────────────────────────────── + +export function grpcWebFrame(payload) { + const frame = new Uint8Array(5 + payload.length); + frame[0] = 0x00; // no compression + const view = new DataView(frame.buffer); + view.setUint32(1, payload.length, false); // big-endian length + frame.set(payload, 5); + return frame; +} + +// ─── Protobuf response decoder ─────────────────────────────────────────────── +// CompletionChunk (oneof): +// field 1 → ContentChunk { field 1: string text } +// field 2 → ToolCallChunk (skipped) +// field 3 → DoneChunk { field 1: UsageStats{ field1: prompt, field2: completion } } +// field 4 → ErrorChunk { field 1: string message } + +function readVarint(buf, offset) { + let result = 0; + let shift = 0; + while (offset < buf.length) { + const b = buf[offset++]; + result |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + } + return [result >>> 0, offset]; +} + +function decodeStringField(buf, targetField) { + let offset = 0; + while (offset < buf.length) { + let tag; + [tag, offset] = readVarint(buf, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + if (wireType === 2) { + let len; + [len, offset] = readVarint(buf, offset); + const payload = buf.slice(offset, offset + len); + offset += len; + if (fieldNum === targetField) return TEXT_DEC.decode(payload); + } else if (wireType === 0) { + let v; + [v, offset] = readVarint(buf, offset); + } else if (wireType === 1) { + offset += 8; + } else if (wireType === 5) { + offset += 4; + } else { + break; + } + } + return null; +} + +function decodeDoneChunk(buf) { + // DoneChunk: field 1 = UsageStats (nested) + // UsageStats: field 1 = prompt_tokens (varint), field 2 = completion_tokens (varint) + let offset = 0; + let usageBytes = null; + while (offset < buf.length) { + let tag; + [tag, offset] = readVarint(buf, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + if (wireType === 2) { + let len; + [len, offset] = readVarint(buf, offset); + if (fieldNum === 1) usageBytes = buf.slice(offset, offset + len); + offset += len; + } else if (wireType === 0) { + let v; + [v, offset] = readVarint(buf, offset); + } else { + break; + } + } + if (!usageBytes) return [0, 0]; + let promptTokens = 0; + let completionTokens = 0; + offset = 0; + while (offset < usageBytes.length) { + let tag; + [tag, offset] = readVarint(usageBytes, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + if (wireType === 0) { + let v; + [v, offset] = readVarint(usageBytes, offset); + if (fieldNum === 1) promptTokens = v; + else if (fieldNum === 2) completionTokens = v; + } else if (wireType === 2) { + let len; + [len, offset] = readVarint(usageBytes, offset); + offset += len; + } else { + break; + } + } + return [promptTokens, completionTokens]; +} + +export function decodeCompletionChunk(buf) { + let offset = 0; + while (offset < buf.length) { + let tag; + [tag, offset] = readVarint(buf, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + + if (wireType === 2) { + let len; + [len, offset] = readVarint(buf, offset); + const payload = buf.slice(offset, offset + len); + offset += len; + + if (fieldNum === 1) { + const text = decodeStringField(payload, 1); + if (text !== null) return { kind: "content", text }; + } else if (fieldNum === 3) { + const usage = decodeDoneChunk(payload); + return { kind: "done", promptTokens: usage[0], completionTokens: usage[1] }; + } else if (fieldNum === 4) { + const msg = decodeStringField(payload, 1); + return { kind: "error", message: msg ?? "unknown windsurf error" }; + } + // field 2 = ToolCallChunk — not yet handled; skip + } else if (wireType === 0) { + let v; + [v, offset] = readVarint(buf, offset); + } else if (wireType === 1) { + offset += 8; + } else if (wireType === 5) { + offset += 4; + } else { + break; + } + } + return { kind: "unknown" }; +} + +// ─── OpenAI messages → Windsurf wire ───────────────────────────────────────── + +function openAIMessagesToWs(messages) { + const out = []; + for (const m of messages) { + const role = String(m.role || "user"); + let content = ""; + if (typeof m.content === "string") { + content = m.content; + } else if (Array.isArray(m.content)) { + for (const part of m.content) { + if (part && typeof part === "object" && part.type === "text") { + content += String(part.text || ""); + } + } + } + out.push({ role, content, toolCallId: m.tool_call_id }); + } + return out; +} + +// ─── WindsurfExecutor ──────────────────────────────────────────────────────── + +export class WindsurfExecutor extends BaseExecutor { constructor() { - super("windsurf"); + super("windsurf", PROVIDERS.windsurf || { id: "windsurf", baseUrl: WS_CHAT_URL }); + } + + buildUrl() { + return WS_CHAT_URL; } buildHeaders(credentials, stream = true) { - const headers = { - "Content-Type": "application/proto", - "Connect-Protocol-Version": "1", - ideName: "Windsurf", - extensionName: "codeium.windsurf", - ...(this.config.headers || {}), + const token = credentials?.accessToken || credentials?.apiKey || ""; + return { + "Content-Type": "application/grpc-web+proto", + Accept: "application/grpc-web+proto", + // Codeium apiKey also goes in Metadata.api_key (protobuf field) — see request body. + ...(token ? { Authorization: `Bearer ${token}` } : {}), + "User-Agent": `windsurf/${WS_IDE_VERSION}`, + "X-Grpc-Web": "1", }; - // apiKey from RegisterUser (sk-ws-..., Firebase-derived, or Devin ide_token). - const token = credentials?.apiKey || credentials?.accessToken; - if (token) headers["Authorization"] = `Bearer ${token}`; - return headers; } - // TODO(proto): implement once Codeium server_pb .proto is recovered. - // - encode request: chat history + model + system → protobuf bytes - // - decode response: stream protobuf frames → OpenAI-shaped chunks - async execute() { - throw new Error( - "Windsurf chat (Codeium protobuf) not yet implemented — needs .proto schema. Auth/quota wired." - ); + // Request body is built manually in execute() — requires model + messages. + transformRequest() { + return null; } + async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders, proxyOptions = null }) { + const apiKey = credentials?.accessToken || credentials?.apiKey || ""; + const wsModel = resolveWsModelId(model); + + const b = body ?? {}; + const rawMessages = Array.isArray(b.messages) ? b.messages : []; + let wsMessages = openAIMessagesToWs(rawMessages); + if (wsMessages.length === 0) { + wsMessages.push({ role: "user", content: "" }); + } + + const protoPayload = buildGetChatMessageRequest(apiKey, wsModel, wsMessages); + const framedPayload = grpcWebFrame(protoPayload); + + const url = this.buildUrl(); + const headers = this.buildHeaders(credentials); + if (upstreamExtraHeaders) Object.assign(headers, upstreamExtraHeaders); + + log?.debug?.("WS", `Windsurf → ${wsModel} (${wsMessages.length} messages)`); + + const upstream = await proxyAwareFetch(url, { + method: "POST", + headers, + body: framedPayload, + signal, + }, proxyOptions); + + if (!upstream.ok && upstream.status !== 200) { + return { response: upstream, url, headers, transformedBody: protoPayload }; + } + + const sseResponse = this.transformToSSE(upstream, model); + return { response: sseResponse, url, headers, transformedBody: protoPayload }; + } + + // Convert a gRPC-web binary response into an OpenAI-compatible SSE stream. + transformToSSE(upstream, model) { + const responseId = `chatcmpl-ws-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + const executor = this; + + const sseStream = new ReadableStream({ + async start(controller) { + const enc = new TextEncoder(); + let roleEmitted = false; + let totalText = ""; + let promptTokens = 0; + let completionTokens = 0; + let hadError = null; + + const emit = (data) => controller.enqueue(enc.encode(data)); + + try { + let pending = new Uint8Array(0); + const reader = upstream.body?.getReader(); + + const handleFrame = (flag, payload) => { + if (flag === 0x80) { + // Trailer frame — contains grpc-status, grpc-message + const trailer = TEXT_DEC.decode(payload); + const statusMatch = /grpc-status:\s*(\d+)/i.exec(trailer); + if (statusMatch && statusMatch[1] !== "0") { + const msgMatch = /grpc-message:\s*(.+)/i.exec(trailer); + hadError = msgMatch + ? decodeURIComponent(msgMatch[1].trim()) + : `gRPC status ${statusMatch[1]}`; + } + return; + } + if (flag !== 0x00) return; // skip unknown flags + + const chunk = executor.constructor.decodeCompletionChunk + ? executor.constructor.decodeCompletionChunk(payload) + : decodeCompletionChunk(payload); + + if (chunk.kind === "content" && chunk.text) { + totalText += chunk.text; + if (!roleEmitted) { + emit(`data: ${JSON.stringify({ + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }], + })}\n\n`); + roleEmitted = true; + } + emit(`data: ${JSON.stringify({ + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { content: chunk.text }, finish_reason: null }], + })}\n\n`); + } else if (chunk.kind === "done") { + promptTokens = chunk.promptTokens; + completionTokens = chunk.completionTokens; + } else if (chunk.kind === "error") { + hadError = chunk.message; + } + }; + + const drainFrames = () => { + let offset = 0; + while (offset + 5 <= pending.length) { + const flag = pending[offset]; + const len = + (pending[offset + 1] << 24) | + (pending[offset + 2] << 16) | + (pending[offset + 3] << 8) | + pending[offset + 4]; + if (len < 0 || offset + 5 + len > pending.length) break; + handleFrame(flag, pending.slice(offset + 5, offset + 5 + len)); + offset += 5 + len; + } + if (offset > 0) pending = pending.slice(offset); + }; + + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + pending = pending.length === 0 ? value : concatBytes([pending, value]); + drainFrames(); + } + } finally { + reader.releaseLock(); + } + } + drainFrames(); + + if (hadError) { + emit(`data: ${JSON.stringify({ + error: { message: hadError, type: "windsurf_error", code: "upstream_error" }, + })}\n\n`); + emit("data: [DONE]\n\n"); + controller.close(); + return; + } + + // Unary fallback: nothing streamed but text decoded → emit as one chunk. + if (!roleEmitted && totalText) { + emit(`data: ${JSON.stringify({ + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }], + })}\n\n`); + emit(`data: ${JSON.stringify({ + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { content: totalText }, finish_reason: null }], + })}\n\n`); + } + + const finishPayload = { + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }; + if (promptTokens > 0 || completionTokens > 0) { + finishPayload.usage = { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }; + } + emit(`data: ${JSON.stringify(finishPayload)}\n\n`); + emit("data: [DONE]\n\n"); + } catch (err) { + const msg = err?.message ? String(err.message) : String(err); + emit(`data: ${JSON.stringify({ + error: { message: `Windsurf stream error: ${msg}`, type: "windsurf_error" }, + })}\n\n`); + emit("data: [DONE]\n\n"); + } + + controller.close(); + }, + }); + + return new Response(sseStream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } + + // apiKey is long-lived (Firebase-derived or Devin ide_token); refresh handled out-of-band. async refreshCredentials() { - // Windsurf apiKey is long-lived (like cursor); refresh handled out-of-band. return null; } } diff --git a/open-sse/executors/workbuddy.js b/open-sse/executors/workbuddy.js deleted file mode 100644 index b306b18d..00000000 --- a/open-sse/executors/workbuddy.js +++ /dev/null @@ -1,31 +0,0 @@ -import { DefaultExecutor } from "./default.js"; - -/** - * WorkBuddyExecutor — talks to https://www.codebuddy.cn/v2/chat/completions - * - * WorkBuddy is a B2B/enterprise skin of CodeBuddy CN (same codebuddy.cn - * OpenAI-compatible gateway). Behavior mirrors CodeBuddyExecutor: - * gateway rejects non-stream requests, and reasoning must be surfaced via - * OpenAI-style reasoning_effort + reasoning_summary:"auto" (vendor-native - * thinking shapes are not honored by the unified gateway). - */ -export class WorkBuddyExecutor extends DefaultExecutor { - constructor() { - super("workbuddy"); - } - - transformRequest(model, body, stream, credentials) { - const transformed = super.transformRequest(model, body, stream, credentials); - transformed.stream = true; - - const eff = transformed.reasoning_effort; - if (eff === "none" || eff === "off") { - delete transformed.reasoning_effort; - } else if (eff) { - transformed.reasoning_summary = "auto"; - } - return transformed; - } -} - -export default WorkBuddyExecutor; diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index f083d79d..7f3867e8 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -100,10 +100,11 @@ import p97 from "./xiaomi-tokenplan.js"; import p98 from "./youcom.js"; import p99 from "./alims-intl.js"; import p100 from "./codebuddy-intl.js"; -import p101 from "./workbuddy.js"; -import p102 from "./trae.js"; +// Temporarily hidden — no tool calling support (trae SOLO agent / windsurf gRPC skip ToolCallChunk). +// Re-enable by uncommenting both the import and the array entry below. +// import p102 from "./trae.js"; import p103 from "./zed.js"; -import p104 from "./windsurf.js"; +// import p104 from "./windsurf.js"; export default [ p0, @@ -207,8 +208,7 @@ export default [ p98, p99, p100, - p101, - p102, + // p102, // trae — hidden, no tool calling p103, - p104, + // p104, // windsurf — hidden, no tool calling ]; diff --git a/open-sse/providers/registry/trae.js b/open-sse/providers/registry/trae.js index d99ace61..2b4ace60 100644 --- a/open-sse/providers/registry/trae.js +++ b/open-sse/providers/registry/trae.js @@ -1,7 +1,8 @@ // Trae (ByteDance marscode) provider registry entry. -// Auth + exchange URLs verified from cockpit-tools/src-tauri/src/modules/trae_oauth.rs. -// Region origins verified from trae_account.rs lines 63-66. -// Chat endpoint path /cloudide/api/v3/trae/Chat is GUESSED (TODO verify upstream). +// Chat = SOLO remote agent API: +// POST {base}/chat_sessions → {data:{chat_session_id, message_id}} +// GET {base}/chat_sessions/{id}/events?reply_to_message_id=... → SSE +// Auth: Authorization: Cloud-IDE-JWT export default { id: "trae", alias: "tr", @@ -20,21 +21,19 @@ export default { notice: { signupUrl: "https://www.trae.ai" }, }, transport: { - // IDE flow (cockpit-tools verified): x-cloudide-token auth, OpenAI-shaped SSE. - baseUrl: "https://api.marscode.com/cloudide/api/v3/trae/Chat", + // SOLO remote agent base — verified working chat endpoint. + baseUrl: "https://core-normal.trae.ai/api/remote/v1", format: "openai", headers: { - "x-app-version": "3.5.54", - "x-app-type": "stable", - "x-env": "production", - "client_id": "ono9krqynydwx5", - "User-Agent": "Trae/1.0.0 antigravity-cockpit-tools", + "X-Trae-Client-Type": "web", + "X-Preferenced-Language": "en", + "Referer": "https://solo.trae.ai/", }, - // Auth: x-cloudide-token + Authorization: Bearer — injected by executor buildHeaders. + // Auth: Cloud-IDE-JWT scheme on Authorization — injected by executor buildHeaders. auth: { combined: true, - header: "x-cloudide-token", - scheme: "raw", + header: "Authorization", + scheme: "Cloud-IDE-JWT", }, usage: { url: "https://api.marscode.com/cloudide/api/v3/trae/GetUserInfo", @@ -61,7 +60,7 @@ export default { // Trae refresh uses custom JSON body, not OAuth form — handled by refresh.js, not config-driven. refresh: { encoding: "json" }, }, - // Model catalog sourced from OmniRoute (IDE flow, core-normal.trae.ai). + // Model catalog (IDE flow, core-normal.trae.ai). models: [ { id: "auto", name: "Auto (Server Picks)" }, { id: "work", name: "Work (Fast)" }, diff --git a/open-sse/providers/registry/windsurf.js b/open-sse/providers/registry/windsurf.js index 9d4222c8..f0b23fa8 100644 --- a/open-sse/providers/registry/windsurf.js +++ b/open-sse/providers/registry/windsurf.js @@ -1,6 +1,7 @@ // Windsurf provider registry — Firebase+Codeium+Devin auth chain. -// Chat transport is Codeium protobuf gRPC-Web: endpoint + schema are GUESS, -// the cockpit-tools source only documents auth/quota (SeatManagement) paths. +// Chat = Codeium gRPC-web protobuf: +// POST {base} Content-Type: application/grpc-web+proto +// Service: exa.language_server_pb.LanguageServerService / GetChatMessage export default { id: "windsurf", alias: "ws", @@ -17,19 +18,16 @@ export default { hasOAuth: true, authModes: ["oauth", "apikey"], - // TODO(chat): Codeium ServerService protobuf schema unknown — endpoint is a guess. transport: { - // GUESS: Codeium chat lives under /exa.server_pb.ServerService/GetChatMessage. - baseUrl: "https://server.codeium.com/exa.server_pb.ServerService/GetChatMessage", - format: "windsurf", + baseUrl: "https://server.codeium.com/exa.language_server_pb.LanguageServerService/GetChatMessage", + format: "openai", headers: { - "Content-Type": "application/proto", - "Connect-Protocol-Version": "1", - "ideName": "Windsurf", - "extensionName": "codeium.windsurf", + "Content-Type": "application/grpc-web+proto", + "Accept": "application/grpc-web+proto", + "X-Grpc-Web": "1", }, - // Bearer of apiKey (sk-ws-... / Firebase-derived / Devin session) — Connect-Protocol scheme unverified. - auth: { combined: true, header: "Authorization" }, + // apiKey (sk-ws-... or Firebase-derived) as Bearer + in protobuf Metadata.api_key. + auth: { combined: true, header: "Authorization", scheme: "Bearer" }, }, // Auth chain (4 terminal paths, all yield apiKey): @@ -52,9 +50,8 @@ export default { }, // Catalog verified against model_configs_v2.bin from Devin CLI (2026.5.x). - // Source: OmniRoute registry (guanxiaol/WindsurfPoolAPI). Dot-notation ids; the - // executor MODEL_ALIAS_MAP would map these to Windsurf modelUid once proto chat - // is implemented. contextLength dropped — 9router schema uses id+name only. + // Dot-notation ids; the executor MODEL_ALIAS_MAP maps these to Windsurf modelUid. + // contextLength dropped — 9router schema uses id+name only. models: [ // Cognition / SWE { id: "swe-1.6-fast", name: "SWE-1.6 Fast" }, diff --git a/open-sse/providers/registry/workbuddy.js b/open-sse/providers/registry/workbuddy.js deleted file mode 100644 index 8adffe7f..00000000 --- a/open-sse/providers/registry/workbuddy.js +++ /dev/null @@ -1,73 +0,0 @@ -export default { - id: "workbuddy", - // Short model prefix (wb/glm-5.2). WorkBuddy is a B2B/enterprise skin of - // CodeBuddy CN (same codebuddy.cn backend), so models mirror codebuddy-cn. - alias: "wb", - uiAlias: "wb", - hidden: false, - priority: 90, - display: { - name: "WorkBuddy", - icon: "smart_toy", - color: "#006EFF", - website: "https://www.codebuddy.cn", - notice: { - signupUrl: "https://www.codebuddy.cn", - }, - }, - category: "oauth", - authModes: ["oauth", "apikey"], - hasOAuth: true, - transport: { - // Same OpenAI-compatible gateway as codebuddy-cn; platform=workbuddy is - // distinguished at the OAuth layer, not the chat endpoint. - baseUrl: "https://www.codebuddy.cn/v2/chat/completions", - forceStream: true, - thinkingFormat: "openai", - headers: { - "User-Agent": "CLI/2.108.1 CodeBuddy/2.108.1", - "X-Product": "SaaS", - "X-IDE-Type": "CLI", - "X-IDE-Name": "CLI", - "x-requested-with": "XMLHttpRequest", - "x-codebuddy-request": "1", - }, - auth: { - combined: true, - header: "Authorization", - scheme: "bearer", - }, - }, - models: [ - { id: "glm-5.2", name: "GLM-5.2" }, - { id: "glm-5.1", name: "GLM-5.1" }, - { id: "glm-5.0", name: "GLM-5.0" }, - { id: "glm-5.0-turbo", name: "GLM-5.0-Turbo" }, - { id: "glm-5v-turbo", name: "GLM-5v-Turbo" }, - { id: "glm-4.7", name: "GLM-4.7" }, - { id: "minimax-m3", name: "MiniMax-M3" }, - { id: "minimax-m2.7", name: "MiniMax-M2.7" }, - { id: "kimi-k2.7", name: "Kimi-K2.7-Code" }, - { id: "kimi-k2.6", name: "Kimi-K2.6" }, - { id: "kimi-k2.5", name: "Kimi-K2.5" }, - { id: "hy3-preview", name: "Hy3 Preview" }, - { id: "deepseek-v4-pro", name: "DeepSeek-V4-Pro" }, - { id: "deepseek-v4-flash", name: "DeepSeek-V4-Flash" }, - { id: "deepseek-v3-2-volc", name: "DeepSeek-V3.2" }, - ], - oauth: { - // Same codebuddy.cn host as codebuddy-cn; only platform param differs - // (workbuddy vs CLI). Prefix /v2/plugin matches cockpit-tools Rust. - baseUrl: "https://www.codebuddy.cn", - stateUrl: "https://www.codebuddy.cn/v2/plugin/auth/state", - tokenUrl: "https://www.codebuddy.cn/v2/plugin/auth/token", - refreshUrl: "https://www.codebuddy.cn/v2/plugin/auth/token/refresh", - userAgent: "CLI/2.63.2 CodeBuddy/2.63.2", - platform: "workbuddy", - pollInterval: 5000, - }, - features: { - usage: true, - usageApikey: true, - }, -}; diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index c8cced77..634fd633 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -14,7 +14,6 @@ import { refreshCopilotToken, refreshCodebuddyToken, refreshCodebuddyIntlToken, - refreshWorkbuddyToken, refreshTraeToken, refreshZedToken, refreshWindsurfToken, @@ -35,7 +34,6 @@ export { refreshCopilotToken, refreshCodebuddyToken, refreshCodebuddyIntlToken, - refreshWorkbuddyToken, refreshTraeToken, refreshZedToken, refreshWindsurfToken, @@ -149,7 +147,6 @@ const REFRESH_HANDLERS = { gcli: (c, log) => refreshXaiToken(c.refreshToken, log), "codebuddy-cn": (c, log) => refreshCodebuddyToken(c.refreshToken, log), "codebuddy-intl": (c, log) => refreshCodebuddyIntlToken(c.refreshToken, log), - workbuddy: (c, log) => refreshWorkbuddyToken(c.refreshToken, log), trae: (c, log) => refreshTraeToken(c.refreshToken, c, log), zed: () => refreshZedToken(), windsurf: (c, log) => refreshWindsurfToken(c, log), diff --git a/open-sse/services/tokenRefresh/providers.js b/open-sse/services/tokenRefresh/providers.js index d82bfdeb..7c13ae77 100644 --- a/open-sse/services/tokenRefresh/providers.js +++ b/open-sse/services/tokenRefresh/providers.js @@ -31,10 +31,68 @@ export async function refreshXaiToken(refreshToken, log) { }, log); } +// Per-provider refresh variants for the generic path. Keys not listed fall back +// to the default form-encoded OAuth2 refresh with client_id + client_secret. +const REFRESH_PROFILES = { + claude: { + bodyFormat: "json", + includeClientSecret: false, + url: () => OAUTH_ENDPOINTS.anthropic.token, + dedupKey: "claude", + }, + qwen: { + url: () => OAUTH_ENDPOINTS.qwen.token, + dedupKey: "qwen", + parse: (tokens) => tokens.resource_url ? { providerSpecificData: { resourceUrl: tokens.resource_url } } : {}, + }, + iflow: { + url: () => OAUTH_ENDPOINTS.iflow.token, + dedupKey: "iflow", + extraHeaders: (creds, cfg) => ({ + Authorization: `Basic ${btoa(`${cfg.clientId}:${cfg.clientSecret}`)}`, + }), + }, + github: { + url: () => OAUTH_ENDPOINTS.github.token, + dedupKey: "github", + includeClientSecret: (cfg) => !!cfg?.clientSecret, + }, + kimi: { + dedupKey: "kimi", + extraHeaders: (creds) => buildKimiHeaders(creds?.providerSpecificData?.deviceId), + }, +}; + +function resolveRefreshUrl(provider, config, profile) { + if (profile?.url) { + try { return profile.url(); } catch { /* fall through */ } + } + return config?.refreshUrl || PROVIDER_OAUTH[provider]?.tokenUrl || null; +} + +function buildRefreshBody(profile, config, refreshToken) { + const fmt = profile?.bodyFormat === "json" ? "json" : "form"; + const includeSecret = profile?.includeClientSecret === undefined + ? true + : typeof profile.includeClientSecret === "function" + ? profile.includeClientSecret(config) + : profile.includeClientSecret; + const payload = { + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: config.clientId, + }; + if (includeSecret && config.clientSecret) payload.client_secret = config.clientSecret; + if (fmt === "json") return { format: "json", body: JSON.stringify(payload) }; + return { format: "form", body: new URLSearchParams(payload) }; +} + export async function refreshAccessToken(provider, refreshToken, credentials, log) { const config = PROVIDERS[provider]; + const profile = REFRESH_PROFILES[provider] || {}; + const url = resolveRefreshUrl(provider, config, profile); - if (!config || !config.refreshUrl) { + if (!config || !url) { log?.warn?.("TOKEN_REFRESH", `No refresh URL configured for provider: ${provider}`); return null; } @@ -44,21 +102,17 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo return null; } - return dedupRefresh(provider, refreshToken, async () => { + const dedupKey = profile.dedupKey || provider; + + return dedupRefresh(dedupKey, refreshToken, async () => { try { - const response = await fetch(config.refreshUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: config.clientId, - client_secret: config.clientSecret, - }), - }); + const { format: bodyFormat, body } = buildRefreshBody(profile, config, refreshToken); + const headers = { + "Content-Type": bodyFormat === "json" ? "application/json" : "application/x-www-form-urlencoded", + Accept: "application/json", + ...(profile.extraHeaders ? (profile.extraHeaders(credentials, config) || {}) : {}), + }; + const response = await fetch(url, { method: "POST", headers, body }); if (!response.ok) { const errorText = await response.text(); @@ -81,6 +135,7 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in, + ...(profile.parse ? (profile.parse(tokens) || {}) : {}), }; } catch (error) { log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, { @@ -92,82 +147,14 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo } // CLIProxyAPI DeviceFlowClient.RefreshToken: form body (no client_secret) + X-Msh-* headers +// Delegate to refreshAccessToken("kimi", ...) — profile carries the X-Msh headers. export async function refreshKimiToken(refreshToken, credentials, log) { - const config = PROVIDERS.kimi; - if (!config?.refreshUrl || !config?.clientId) { - log?.warn?.("TOKEN_REFRESH", "No Kimi refresh URL/clientId configured"); - return null; - } - if (!refreshToken) return null; - - return dedupRefresh("kimi", refreshToken, async () => { - try { - const headers = { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - ...buildKimiHeaders(credentials?.providerSpecificData?.deviceId), - }; - const response = await fetch(config.refreshUrl, { - method: "POST", - headers, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: config.clientId, - }), - }); - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", `Failed to refresh token for kimi`, { - status: response.status, - error: errorText, - }); - return null; - } - const tokens = await response.json(); - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Error refreshing token for kimi`, { error: error.message }); - return null; - } - }, log); + return refreshAccessToken("kimi", refreshToken, credentials, log); } +// Claude OAuth: JSON body, client_id only. Delegate to refreshAccessToken("claude", ...). export async function refreshClaudeOAuthToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("claude", refreshToken, async () => { - try { - const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.claude.clientId, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", { status: response.status, error: errorText }); - return null; - } - - const tokens = await response.json(); - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in }); - return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`); - return null; - } - }, log); + return refreshAccessToken("claude", refreshToken, {}, log); } export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) { @@ -204,58 +191,9 @@ export async function refreshGoogleToken(refreshToken, clientId, clientSecret, l }, log); } +// Qwen: form body + clientId, surfaces resource_url. Delegate to refreshAccessToken("qwen", ...). export async function refreshQwenToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("qwen", refreshToken, async () => { - const endpoint = OAUTH_ENDPOINTS.qwen.token; - - try { - const response = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.qwen.clientId, - }), - }); - - if (response.status === 200) { - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - providerSpecificData: tokens.resource_url - ? { resourceUrl: tokens.resource_url } - : undefined, - }; - } else { - const errorText = await response.text().catch(() => ""); - log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, { - status: response.status, - error: errorText, - }); - } - } catch (error) { - log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, { - error: error.message, - }); - } - - log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token"); - return null; - }, log); + return refreshAccessToken("qwen", refreshToken, {}, log); } export function classifyOAuthRefreshError(errorText = "", status = 0) { @@ -480,95 +418,14 @@ export async function refreshKiroToken(refreshToken, providerSpecificData, log, }, log); } +// iFlow: Basic Auth + client_id+client_secret in body. Delegate to refreshAccessToken("iflow", ...). export async function refreshIflowToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("iflow", refreshToken, async () => { - const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`); - - const response = await fetch(OAUTH_ENDPOINTS.iflow.token, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - Authorization: `Basic ${basicAuth}`, - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.iflow.clientId, - client_secret: PROVIDERS.iflow.clientSecret, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh iFlow token", { - status: response.status, - error: errorText, - }); - return null; - } - - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed iFlow token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - }; - }, log); + return refreshAccessToken("iflow", refreshToken, {}, log); } +// GitHub: optional client_secret. Delegate to refreshAccessToken("github", ...). export async function refreshGitHubToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("github", refreshToken, async () => { - const params = { - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.github.clientId, - }; - if (PROVIDERS.github.clientSecret) { - params.client_secret = PROVIDERS.github.clientSecret; - } - - const response = await fetch(OAUTH_ENDPOINTS.github.token, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams(params), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh GitHub token", { - status: response.status, - error: errorText, - }); - return null; - } - - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - }; - }, log); + return refreshAccessToken("github", refreshToken, {}, log); } export async function refreshCopilotToken(githubAccessToken, log) { @@ -720,60 +577,8 @@ export async function refreshCodebuddyIntlToken(refreshToken, log) { }, log); } -export async function refreshWorkbuddyToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("workbuddy", refreshToken, async () => { - const oauth = PROVIDER_OAUTH["workbuddy"] || {}; - const response = await fetch(oauth.refreshUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - "User-Agent": oauth.userAgent, - "X-Requested-With": "XMLHttpRequest", - "X-Domain": "www.codebuddy.cn", - "X-Refresh-Token": refreshToken, - "X-Auth-Refresh-Source": "plugin", - "X-Product": "SaaS", - }, - body: "{}", - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh WorkBuddy token", { - status: response.status, - error: errorText, - }); - return null; - } - - const data = await response.json(); - if (data.code !== 0 || !data.data?.accessToken) { - log?.error?.("TOKEN_REFRESH", "WorkBuddy token refresh returned no token", { - code: data.code, - msg: data.msg, - }); - return null; - } - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed WorkBuddy token", { - hasNewAccessToken: !!data.data.accessToken, - hasNewRefreshToken: !!data.data.refreshToken, - expiresIn: data.data.expiresIn, - }); - - return { - accessToken: data.data.accessToken, - refreshToken: data.data.refreshToken || refreshToken, - expiresIn: data.data.expiresIn, - }; - }, log); -} - // Trae refresh — POST ExchangeToken with JSON body {ClientID, RefreshToken, ClientSecret, UserID}. // Response: {Result: {AccessToken, RefreshToken, TokenType, ExpiresAt}}. -// Source: cockpit-tools/src-tauri/src/modules/trae_oauth.rs (TRAE_EXCHANGE_TOKEN_PATH). export async function refreshTraeToken(refreshToken, credentials, log) { if (!refreshToken) return null; const oauth = PROVIDER_OAUTH.trae || {}; diff --git a/open-sse/services/usage/grok-cli.js b/open-sse/services/usage/grok-cli.js index 865baeb0..8fcddee0 100644 --- a/open-sse/services/usage/grok-cli.js +++ b/open-sse/services/usage/grok-cli.js @@ -198,6 +198,21 @@ export function parseGrokCliBilling(billing, user = null) { }; } + // SuperGrok weekly shared-pool usage (subscription tier). creditUsagePercent is + // the single total used %; productUsage is a breakdown legend, NOT independent + // quotas — never split it into separate bars. + const usedPct = unwrapVal( + config.creditUsagePercent ?? config.credit_usage_percent ?? root.creditUsagePercent, + NaN, + ); + if (Number.isFinite(usedPct) && usedPct >= 0) { + quotas["Weekly SuperGrok"] = makeQuota({ + used: Math.max(0, Math.min(100, usedPct)), + total: 100, + resetAt: periodEnd, + }); + } + // Opportunistic richer credit envelopes (future / other account types) const creditBags = [ root.credits, diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index eb4a7940..f85bbfa1 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -1,10 +1,10 @@ import { NextResponse } from "next/server"; -import { - getProvider, - generateAuthData, - exchangeTokens, - requestDeviceCode, - pollForToken +import { + getProvider, + generateAuthData, + exchangeTokens, + requestDeviceCode, + pollForToken } from "@/lib/oauth/providers"; import { createProviderConnection } from "@/models"; import { @@ -18,7 +18,24 @@ import { registerXaiSession, getXaiSessionStatus, clearXaiSession, + startTraeProxy, + stopTraeProxy, + registerTraeSession, + getTraeSessionStatus, + clearTraeSession, + startWindsurfProxy, + stopWindsurfProxy, + registerWindsurfSession, + getWindsurfSessionStatus, + clearWindsurfSession, + startZedProxy, + stopZedProxy, + registerZedSession, + getZedSessionStatus, + clearZedSession, } from "@/lib/oauth/utils/server"; +import { detectIdeInstalled } from "@/lib/oauth/utils/ideDetect"; +import { ZED_HOSTED_CONFIG } from "@/lib/oauth/constants/oauth"; async function completeXaiManualCode(code, state) { const session = state ? getXaiSessionStatus(state) : null; @@ -77,13 +94,34 @@ export async function GET(request, { params }) { const reservedParams = new Set(["redirect_uri"]); const meta = {}; searchParams.forEach((value, key) => { if (!reservedParams.has(key)) meta[key] = value; }); + // Zed: derive native_app_port from the local callback URL so the RSA keypair + // is bound to the port the proxy is actually listening on. + if (provider === "zed") { + try { const p = new URL(redirectUri).port; if (p) meta.nativeAppPort = p; } catch { /* ignore */ } + } const authData = await generateAuthData(provider, redirectUri, Object.keys(meta).length ? meta : undefined); return NextResponse.json(authData); } if (action === "start-proxy") { + // Trae/Windsurf/Zed use a dynamic-port local callback server (singleton session, + // state is registered separately via /register-session after /authorize). + if (provider === "trae") { + const result = await startTraeProxy(); + return NextResponse.json(result); + } + if (provider === "windsurf") { + const result = await startWindsurfProxy(); + return NextResponse.json(result); + } + if (provider === "zed") { + // Prefer ZED_HOSTED_CONFIG.defaultNativeAppPort (58443) so the browser redirect + // matches what Zed expects; falls back to a random port if it's busy. + const result = await startZedProxy(searchParams.get("native_app_port") || ZED_HOSTED_CONFIG.defaultNativeAppPort); + return NextResponse.json(result); + } if (!["codex", "xai"].includes(provider)) { - return NextResponse.json({ error: "Proxy only supported for codex/xai" }, { status: 400 }); + return NextResponse.json({ error: "Proxy only supported for codex/xai/trae/windsurf/zed" }, { status: 400 }); } const appPort = searchParams.get("app_port"); if (!appPort) { @@ -105,18 +143,24 @@ export async function GET(request, { params }) { } if (action === "poll-status") { - if (!["codex", "xai"].includes(provider)) { - return NextResponse.json({ error: "Poll only supported for codex/xai" }, { status: 400 }); - } const state = searchParams.get("state"); if (!state) { return NextResponse.json({ error: "Missing state" }, { status: 400 }); } - const session = provider === "xai" ? getXaiSessionStatus(state) : getCodexSessionStatus(state); + let session; + if (provider === "trae") session = getTraeSessionStatus(state); + else if (provider === "windsurf") session = getWindsurfSessionStatus(state); + else if (provider === "zed") session = getZedSessionStatus(state); + else if (provider === "xai") session = getXaiSessionStatus(state); + else if (provider === "codex") session = getCodexSessionStatus(state); + else return NextResponse.json({ error: "Poll only supported for codex/xai/trae/windsurf/zed" }, { status: 400 }); if (!session) return NextResponse.json({ status: "unknown" }); if (session.status === "done" || session.status === "error") { const payload = { ...session }; - if (provider === "xai") clearXaiSession(state); + if (provider === "trae") clearTraeSession(state); + else if (provider === "windsurf") clearWindsurfSession(state); + else if (provider === "zed") clearZedSession(state); + else if (provider === "xai") clearXaiSession(state); else clearCodexSession(state); return NextResponse.json(payload); } @@ -124,14 +168,24 @@ export async function GET(request, { params }) { } if (action === "stop-proxy") { - if (!["codex", "xai"].includes(provider)) { - return NextResponse.json({ error: "Proxy only supported for codex/xai" }, { status: 400 }); - } - if (provider === "xai") stopXaiProxy(); - else stopCodexProxy(); + if (provider === "trae") stopTraeProxy(); + else if (provider === "windsurf") stopWindsurfProxy(); + else if (provider === "zed") stopZedProxy(); + else if (provider === "xai") stopXaiProxy(); + else if (provider === "codex") stopCodexProxy(); + else return NextResponse.json({ error: "Proxy only supported for codex/xai/trae/windsurf/zed" }, { status: 400 }); return NextResponse.json({ success: true }); } + if (action === "ide-status") { + // Detect whether the IDE is installed locally (used by import-token UX). + if (provider !== "trae" && provider !== "windsurf") { + return NextResponse.json({ error: "ide-status only supported for trae/windsurf" }, { status: 400 }); + } + const status = await detectIdeInstalled(provider); + return NextResponse.json(status); + } + if (action === "device-code") { const providerData = getProvider(provider); if (providerData.flowType !== "device_code") { @@ -158,6 +212,7 @@ export async function GET(request, { params }) { "kimi-coding", "kilocode", "codebuddy-cn", + "codebuddy-intl", "qoder", "grok-cli", ]; @@ -196,9 +251,54 @@ export async function POST(request, { params }) { return NextResponse.json({ error: "Invalid or empty request body" }, { status: 400 }); } + if (action === "register-session") { + // Register proxy session out of URL query (state) + body (codeVerifier). + // Zed's codeVerifier encodes the RSA private key — must stay out of URL/logs. + const state = searchParams.get("state") || body?.state; + if (!state) return NextResponse.json({ error: "Missing state" }, { status: 400 }); + let ok = false; + if (provider === "trae") ok = registerTraeSession({ state }); + else if (provider === "windsurf") ok = registerWindsurfSession({ state }); + else if (provider === "zed") ok = registerZedSession({ state, codeVerifier: body?.codeVerifier }); + else return NextResponse.json({ error: "register-session only supported for trae/windsurf/zed" }, { status: 400 }); + return NextResponse.json({ success: ok }); + } + if (action === "exchange") { const { code, redirectUri, codeVerifier, state, meta } = body; + // Trae/Windsurf: code is either a raw callback URL or a pasted token. + // exchangeTokens() handles both paths; no PKCE, skip codex JWT extraction. + if (provider === "trae" || provider === "windsurf") { + const token = typeof code === "string" ? code.trim() : ""; + if (!token) { + return NextResponse.json({ error: "Missing token or callback URL" }, { status: 400 }); + } + try { + const tokenData = await exchangeTokens(provider, token, null, null, state); + const connection = await createProviderConnection({ + provider, + authType: provider === "windsurf" ? "api_key" : "oauth", + ...tokenData, + expiresAt: tokenData.expiresIn + ? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString() + : null, + testStatus: "active", + }); + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + displayName: connection.displayName, + } + }); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: 500 }); + } + } + // Detect if "code" is actually a raw JWT access token (starts with eyJ) if (code && code.startsWith("eyJ") && code.includes(".")) { const { extractCodexAccountInfo } = await import("@/lib/oauth/providers"); @@ -280,7 +380,7 @@ export async function POST(request, { params }) { } // Providers that don't use PKCE for device code - const noPkceProviders = ["github", "kimi", "kimi-coding", "kilocode", "codebuddy-cn"]; + const noPkceProviders = ["github", "kimi", "kimi-coding", "kilocode", "codebuddy-cn", "codebuddy-intl"]; let result; if (noPkceProviders.includes(provider)) { // kimi needs extraData._kimiDeviceId for stable X-Msh-Device-Id (CLIProxyAPI parity) diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index e07745e3..26c9d010 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -14,6 +14,7 @@ import { resolveCopilotModels } from "open-sse/services/copilotModels.js"; import { resolveClinepassModels } from "open-sse/services/clinepassModels.js"; import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js"; import { resolveCursorModels } from "open-sse/services/cursorModels.js"; +import { resolveZedModels } from "open-sse/shared/zedAuth.js"; import { updateProviderCredentials } from "@/sse/services/tokenRefresh"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js"; @@ -104,7 +105,23 @@ const LIVE_MODEL_RESOLVERS = { providerSpecificData: conn.providerSpecificData || {}, }, { log: console }); return result?.models?.length ? { models: result.models } : null; - } + }, + zed: async (conn) => { + const result = await resolveZedModels({ + accessToken: conn.accessToken, + providerSpecificData: conn.providerSpecificData || {}, + }); + if (!result?.models?.length) return null; + return { + models: result.models + .filter((m) => !m.isDisabled) + .map((m) => ({ + id: m.id, + name: m.name, + capabilities: m.supportsTools ? { tools: true } : undefined, + })), + }; + }, }; const parseOpenAIStyleModels = (data) => { diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js index 53015258..d66e32c5 100644 --- a/src/lib/oauth/constants/oauth.js +++ b/src/lib/oauth/constants/oauth.js @@ -117,6 +117,9 @@ export const GITLAB_CONFIG = { ...PROVIDER_OAUTH["gitlab"] }; // CodeBuddy (Tencent) OAuth Configuration (Browser OAuth Polling Flow) export const CODEBUDDY_CONFIG = { ...PROVIDER_OAUTH["codebuddy-cn"] }; +// CodeBuddy International — same shape as CN, .ai domain (mirror of codebuddy-cn). +export const CODEBUDDY_INTL_CONFIG = { ...PROVIDER_OAUTH["codebuddy-intl"] }; + // Kimchi OAuth Configuration (Browser token callback flow) export const KIMCHI_CONFIG = { ...PROVIDER_OAUTH["kimchi"] }; @@ -124,6 +127,77 @@ export const KIMCHI_CONFIG = { ...PROVIDER_OAUTH["kimchi"] }; // Endpoint: cli-chat-proxy.grok.com — same client_id as xai, different flow + scopes export const GROK_CLI_CONFIG = { ...PROVIDER_OAUTH["grok-cli"] }; +// Trae (ByteDance marscode) OAuth — authorization_code flow with local callback. +// 1) POST GetLoginGuidance {loginTraceID} → {Result.LoginHost} +// 2) Browser opens ${loginHost}/authorization?client_id=...&login_trace_id=...&auth_callback_url=${cb} +// 3) Redirect → ${cb}?refreshToken=...&loginHost=...&isRedirect=true +// 4) POST ExchangeToken {ClientID, RefreshToken, ClientSecret:"-"} → {Result.AccessToken, ExpiresAt} +// 5) POST GetUserInfo (x-cloudide-token) → email/name +export const TRAE_CONFIG = { + clientId: "ono9krqynydwx5", + clientSecret: "-", + loginGuidanceUrls: [ + "https://api.marscode.com/cloudide/api/v3/trae/GetLoginGuidance", + "https://api.trae.ai/cloudide/api/v3/trae/GetLoginGuidance", + "https://www.trae.ai/cloudide/api/v3/trae/GetLoginGuidance", + ], + apiOrigins: [ + "https://api.marscode.com", + "https://api.trae.ai", + "https://www.trae.ai", + "https://www.marscode.com", + ], + exchangeTokenPath: "/cloudide/api/v3/trae/oauth/ExchangeToken", + getUserInfoPath: "/cloudide/api/v3/trae/GetUserInfo", + authorizationPath: "/authorization", + callbackPath: "/callback", + minAppVersion: "3.5.54", + defaultAppVersion: "3.5.54", + defaultAppType: "stable", + defaultPluginVersion: "local", + // service machine id is derived at runtime; device_id "0" is the stable default + defaultDeviceId: "0", + userAgent: "Trae/1.0.0 antigravity-cockpit-tools", + webUrl: "https://www.trae.ai", + authScheme: "Cloud-IDE-JWT", + tokenLifetimeDays: 14, + oauthTimeoutMs: 600_000, +}; + +// Windsurf / Devin CLI OAuth — authorization_code (implicit) flow with local callback. +// 1) Browser opens windsurf.com/windsurf/signin?response_type=token&client_id=...&redirect_uri=${cb} +// 2) Redirect → ${cb}?access_token=${firebaseJWT}&state=... +// 3) POST RegisterUser {firebase_id_token} → {apiKey, apiServerUrl, name} +// 4) POST GetOneTimeAuthToken → GetCurrentUser (best-effort email/plan) +export const WINDSURF_CONFIG = { + clientId: "3GUryQ7ldAeKEuD2obYnppsnmj58eP5u", + authBaseUrl: "https://www.windsurf.com", + signInPath: "/windsurf/signin", + registerApiBaseUrl: "https://register.windsurf.com", + registerPath: "/exa.seat_management_pb.SeatManagementService/RegisterUser", + oneTimeAuthPath: "/exa.seat_management_pb.SeatManagementService/GetOneTimeAuthToken", + currentUserPath: "/exa.seat_management_pb.SeatManagementService/GetCurrentUser", + planStatusPath: "/exa.seat_management_pb.SeatManagementService/GetPlanStatus", + userStatusPath: "/exa.seat_management_pb.SeatManagementService/GetUserStatus", + defaultApiServerUrl: "https://server.codeium.com", + firebaseApiKey: "AIzaSyDsOl-1XpT5err0Tcn0TFFod1H8gVGIycY", + callbackPath: "/windsurf-auth-callback", + userAgent: "antigravity-cockpit-tools", + oauthTimeoutMs: 600_000, +}; + +// Zed hosted LLM aggregator — RSA keypair native-app auth (NOT OAuth). +// Client generates ephemeral RSA-2048 keypair; user signs in at zed.dev/native_app_signin; +// Zed redirects to local callback with access_token RSA-encrypted against our public key. +// See open-sse/shared/zedAuth.js for the keypair/decrypt helpers. +export const ZED_HOSTED_CONFIG = { + webBaseUrl: "https://zed.dev", + cloudBaseUrl: "https://cloud.zed.dev", + llmBaseUrl: "https://cloud.zed.dev", + defaultNativeAppPort: 58443, + oauthTimeoutMs: 600_000, +}; + // OAuth timeout (5 minutes) export const OAUTH_TIMEOUT = 300000; @@ -147,6 +221,10 @@ export const PROVIDERS = { CLINEPASS: "clinepass", GITLAB: "gitlab", CODEBUDDY: "codebuddy-cn", + CODEBUDDY_INTL: "codebuddy-intl", KIMCHI: "kimchi", GROK_CLI: "grok-cli", + TRAE: "trae", + WINDSURF: "windsurf", + ZED: "zed", }; diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index e995567e..8adfe8c4 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -1,1718 +1 @@ -/** - * OAuth Provider Configurations and Handlers - * Centralized DRY approach for all OAuth providers - */ - -// Ensure outbound fetch respects HTTP(S)_PROXY/ALL_PROXY in Node runtime -import "open-sse/index.js"; -import crypto from "crypto"; - -import { generatePKCE, generateState } from "./utils/pkce"; -import { - CLAUDE_CONFIG, - CODEX_CONFIG, - GEMINI_CONFIG, - QWEN_CONFIG, - QODER_CONFIG, - IFLOW_CONFIG, - ANTIGRAVITY_CONFIG, - GITHUB_CONFIG, - KIRO_CONFIG, - assertValidAwsRegion, - CURSOR_CONFIG, - KIMI_CONFIG, - KILOCODE_CONFIG, - CLINE_CONFIG, - CLINEPASS_CONFIG, - GITLAB_CONFIG, - CODEBUDDY_CONFIG, - KIMCHI_CONFIG, - GROK_CLI_CONFIG, - getOAuthClientMetadata, -} from "./constants/oauth"; -import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "./constants/xai"; -import { - validateXaiOAuthEndpoint, - decodeXaiIdTokenEmail, - extractEmailFromAccessToken, - extractCodexAccountInfo, - fetchKiroProfileArn, -} from "./providerHelpers"; - -export { extractCodexAccountInfo, fetchKiroProfileArn }; - -// Inlined from services/xai.js to keep web route bundle free of `open` (CLI-only) package -let cachedXaiDiscovery = null; - -async function discoverXaiEndpoints() { - if (cachedXaiDiscovery) return cachedXaiDiscovery; - try { - const res = await fetch(XAI_CONFIG.discoveryUrl, { headers: { Accept: "application/json" } }); - if (res.ok) { - const data = await res.json(); - cachedXaiDiscovery = { - authorizeUrl: validateXaiOAuthEndpoint(data.authorization_endpoint, "authorization_endpoint"), - tokenUrl: validateXaiOAuthEndpoint(data.token_endpoint, "token_endpoint"), - }; - return cachedXaiDiscovery; - } - } catch { /* fall through to static fallback */ } - cachedXaiDiscovery = { authorizeUrl: XAI_CONFIG.authorizeUrl, tokenUrl: XAI_CONFIG.tokenUrl }; - return cachedXaiDiscovery; -} - -// Provider configurations -const PROVIDERS = { - claude: { - config: CLAUDE_CONFIG, - flowType: "authorization_code_pkce", - buildAuthUrl: (config, redirectUri, state, codeChallenge) => { - const params = new URLSearchParams({ - code: "true", - client_id: config.clientId, - response_type: "code", - redirect_uri: redirectUri, - scope: config.scopes.join(" "), - code_challenge: codeChallenge, - code_challenge_method: config.codeChallengeMethod, - state: state, - }); - return `${config.authorizeUrl}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri, codeVerifier, state) => { - // Parse code - may contain state after # - let authCode = code; - let codeState = ""; - if (authCode.includes("#")) { - const parts = authCode.split("#"); - authCode = parts[0]; - codeState = parts[1] || ""; - } - - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - code: authCode, - state: codeState || state, - grant_type: "authorization_code", - client_id: config.clientId, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - }), - }, - - codex: { - config: CODEX_CONFIG, - flowType: "authorization_code_pkce", - fixedPort: CODEX_CONFIG.fixedPort, - callbackPath: CODEX_CONFIG.callbackPath, - buildAuthUrl: (config, redirectUri, state, codeChallenge) => { - const params = { - response_type: "code", - client_id: config.clientId, - redirect_uri: redirectUri, - scope: config.scope, - code_challenge: codeChallenge, - code_challenge_method: config.codeChallengeMethod, - ...config.extraParams, - state: state, - }; - const queryString = Object.entries(params) - .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) - .join("&"); - return `${config.authorizeUrl}?${queryString}`; - }, - exchangeToken: async (config, code, redirectUri, codeVerifier) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: config.clientId, - code: code, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - }, - mapTokens: (tokens) => { - const info = extractCodexAccountInfo(tokens.id_token); - const mapped = { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - idToken: tokens.id_token, - expiresIn: tokens.expires_in, - lastRefreshAt: new Date().toISOString(), - }; - const email = info.email || extractEmailFromAccessToken(tokens.access_token); - if (email) mapped.email = email; - if (info.chatgptAccountId || info.chatgptPlanType) { - mapped.providerSpecificData = { - chatgptAccountId: info.chatgptAccountId, - chatgptPlanType: info.chatgptPlanType, - }; - } - return mapped; - }, - }, - - xai: { - config: XAI_CONFIG, - flowType: "authorization_code_pkce", - fixedPort: XAI_CONFIG.loopbackPort, - callbackPath: XAI_CONFIG.callbackPath, - pkceVerifierBytes: XAI_PKCE_VERIFIER_BYTES, - prepareConfig: async (config) => { - const endpoints = await discoverXaiEndpoints(); - return { - ...config, - authorizeUrl: endpoints.authorizeUrl, - tokenUrl: endpoints.tokenUrl, - }; - }, - buildAuthUrl: (config, redirectUri, state, codeChallenge) => { - // Mirror CLIProxyAPI BuildAuthorizeURL: includes nonce, plan, referrer - const nonce = crypto.randomBytes(16).toString("hex"); - const params = { - response_type: "code", - client_id: config.clientId, - redirect_uri: redirectUri, - scope: config.scope, - code_challenge: codeChallenge, - code_challenge_method: config.codeChallengeMethod, - state, - nonce, - plan: "generic", - referrer: "cli-proxy-api", - }; - const qs = Object.entries(params) - .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) - .join("&"); - return `${config.authorizeUrl}?${qs}`; - }, - exchangeToken: async (config, code, redirectUri, codeVerifier) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: config.clientId, - code, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - }), - }); - if (!response.ok) { - const error = await response.text(); - throw new Error(`xAI token exchange failed: ${error}`); - } - return await response.json(); - }, - mapTokens: (tokens) => { - const mapped = { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - }; - const email = decodeXaiIdTokenEmail(tokens.id_token); - if (email) mapped.email = email; - if (tokens.id_token) { - mapped.providerSpecificData = { idToken: tokens.id_token }; - } - return mapped; - }, - }, - - // Grok CLI / Grok Build — device code flow to auth.x.ai, inference on cli-chat-proxy.grok.com - "grok-cli": { - config: GROK_CLI_CONFIG, - flowType: "device_code", - requestDeviceCode: async (config) => { - const body = new URLSearchParams({ - client_id: config.clientId, - scope: config.scope, - }); - // Official CLI sends referrer=grok-build - if (config.referrer) body.set("referrer", config.referrer); - - const response = await fetch(config.deviceCodeUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", - }, - body, - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Grok CLI device code request failed: ${error}`); - } - - return await response.json(); - }, - pollToken: async (config, deviceCode) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", - }, - body: new URLSearchParams({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - device_code: deviceCode, - client_id: config.clientId, - }), - }); - - let data; - try { - data = await response.json(); - } catch { - const text = await response.text(); - data = { error: "invalid_response", error_description: text }; - } - - // Device flow: 400 + authorization_pending is expected while user authorizes - const pending = - data?.error === "authorization_pending" || - data?.error === "slow_down"; - return { - ok: response.ok || pending, - data, - }; - }, - postExchange: async (tokens) => { - // Best-effort user profile from cli-chat-proxy (non-fatal) - try { - const res = await fetch("https://cli-chat-proxy.grok.com/v1/user", { - headers: { - Authorization: `Bearer ${tokens.access_token}`, - Accept: "application/json", - "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", - "x-xai-token-auth": "xai-grok-cli", - "x-grok-client-version": "0.2.93", - }, - }); - if (res.ok) return { user: await res.json() }; - } catch { - /* ignore */ - } - return { user: null }; - }, - mapTokens: (tokens, extra) => { - const email = - decodeXaiIdTokenEmail(tokens.id_token) || - extractEmailFromAccessToken(tokens.access_token) || - extra?.user?.email || - null; - const userId = - extra?.user?.userId || - extra?.user?.principalId || - null; - const displayName = [extra?.user?.firstName, extra?.user?.lastName] - .filter(Boolean) - .join(" ") - .trim() || null; - - const expiresAt = tokens.expires_in - ? new Date(Date.now() + tokens.expires_in * 1000).toISOString() - : null; - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || null, - expiresIn: tokens.expires_in, - // Surface an absolute expiry so the proactive refresh path - // (shouldRefreshCredentials / checkAndRefreshToken) can refresh the - // xAI token before it silently expires ~40-45 min after login. - // Without this, only the reactive 401 path in chatCore would refresh, - // causing intermittent "token expired" failures for Grok CLI. - expiresAt, - scope: tokens.scope, - // Top-level for dashboard connection cards - email: email || undefined, - displayName: displayName || undefined, - // Mirror identity into providerSpecificData so GrokCliExecutor can set - // x-email / x-userid without depending on top-level credential shape. - providerSpecificData: { - authMethod: "device_code", - idToken: tokens.id_token || null, - email: email || null, - userId, - hasGrokCodeAccess: extra?.user?.hasGrokCodeAccess ?? null, - subscriptionTier: extra?.user?.subscriptionTier ?? null, - }, - }; - }, - }, - - "gemini-cli": { - config: GEMINI_CONFIG, - flowType: "authorization_code", - buildAuthUrl: (config, redirectUri, state) => { - const params = new URLSearchParams({ - client_id: config.clientId, - response_type: "code", - redirect_uri: redirectUri, - scope: config.scopes.join(" "), - state: state, - access_type: "offline", - prompt: "consent", - }); - return `${config.authorizeUrl}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: config.clientId, - client_secret: config.clientSecret, - code: code, - redirect_uri: redirectUri, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - }, - postExchange: async (tokens) => { - // Fetch user info - const userInfoRes = await fetch(`${GEMINI_CONFIG.userInfoUrl}?alt=json`, { - headers: { Authorization: `Bearer ${tokens.access_token}` }, - }); - const userInfo = userInfoRes.ok ? await userInfoRes.json() : {}; - - // Fetch project ID - let projectId = ""; - try { - const projectRes = await fetch( - "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", - { - method: "POST", - headers: { - Authorization: `Bearer ${tokens.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - metadata: getOAuthClientMetadata(), - mode: 1, - }), - } - ); - if (projectRes.ok) { - const data = await projectRes.json(); - projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; - } - } catch (e) { - console.log("Failed to fetch project ID:", e); - } - - return { userInfo, projectId }; - }, - mapTokens: (tokens, extra) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - email: extra?.userInfo?.email, - projectId: extra?.projectId, - }), - }, - - antigravity: { - config: ANTIGRAVITY_CONFIG, - flowType: "authorization_code", - buildAuthUrl: (config, redirectUri, state) => { - const params = new URLSearchParams({ - client_id: config.clientId, - response_type: "code", - redirect_uri: redirectUri, - scope: config.scopes.join(" "), - state: state, - access_type: "offline", - prompt: "consent", - }); - return `${config.authorizeUrl}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: config.clientId, - client_secret: config.clientSecret, - code: code, - redirect_uri: redirectUri, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - }, - postExchange: async (tokens) => { - // Numeric enums matching Antigravity binary ClientMetadata - const loadHeaders = { - "Authorization": `Bearer ${tokens.access_token}`, - "Content-Type": "application/json", - "User-Agent": ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent, - "X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient, - "Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata, - "x-request-source": "local", - }; - const metadata = getOAuthClientMetadata(); - - // Fetch user info - const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, { - headers: { - Authorization: `Bearer ${tokens.access_token}`, - "x-request-source": "local", - }, - }); - const userInfo = userInfoRes.ok ? await userInfoRes.json() : {}; - - // Load Code Assist to get project ID and tier - let projectId = ""; - let tierId = "legacy-tier"; - try { - const loadRes = await fetch(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, { - method: "POST", - headers: loadHeaders, - body: JSON.stringify({ metadata }), - }); - if (loadRes.ok) { - const data = await loadRes.json(); - projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; - if (Array.isArray(data.allowedTiers)) { - for (const tier of data.allowedTiers) { - if (tier.isDefault && tier.id) { - tierId = tier.id.trim(); - break; - } - } - } - } - } catch (e) { - console.log("Failed to load code assist:", e); - } - - // Fire-and-forget onboarding — does not block DB save - if (projectId) { - const doOnboard = async () => { - for (let i = 0; i < 10; i++) { - try { - const onboardRes = await fetch(ANTIGRAVITY_CONFIG.onboardUserEndpoint, { - method: "POST", - headers: loadHeaders, - body: JSON.stringify({ tierId, metadata }), - }); - if (onboardRes.ok) { - const result = await onboardRes.json(); - if (result.done === true) break; - } - } catch (e) { - break; - } - await new Promise(resolve => setTimeout(resolve, 5000)); - } - }; - doOnboard().catch(() => {}); - } - - return { userInfo, projectId }; - }, - mapTokens: (tokens, extra) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - email: extra?.userInfo?.email, - projectId: extra?.projectId, - }), - }, - - iflow: { - config: IFLOW_CONFIG, - flowType: "authorization_code", - buildAuthUrl: (config, redirectUri, state) => { - const params = new URLSearchParams({ - loginMethod: config.extraParams.loginMethod, - type: config.extraParams.type, - redirect: redirectUri, - state: state, - client_id: config.clientId, - }); - return `${config.authorizeUrl}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri) => { - // Create Basic Auth header - const basicAuth = Buffer.from( - `${config.clientId}:${config.clientSecret}` - ).toString("base64"); - - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - Authorization: `Basic ${basicAuth}`, - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code: code, - redirect_uri: redirectUri, - client_id: config.clientId, - client_secret: config.clientSecret, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - }, - postExchange: async (tokens) => { - // Fetch user info (MUST succeed to get API key) - const userInfoRes = await fetch( - `${IFLOW_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`, - { - headers: { - Accept: "application/json", - }, - } - ); - - if (!userInfoRes.ok) { - const errorText = await userInfoRes.text(); - throw new Error(`Failed to fetch user info: ${errorText}`); - } - - const result = await userInfoRes.json(); - if (!result.success) { - throw new Error(`User info request failed: ${result.message || 'Unknown error'}`); - } - - const userInfo = result.data || {}; - - // Validate API key (critical for iFlow) - if (!userInfo.apiKey || userInfo.apiKey.trim() === "") { - throw new Error("Empty API key returned from iFlow"); - } - - // Validate email/phone - const email = userInfo.email?.trim() || userInfo.phone?.trim(); - if (!email) { - throw new Error("Missing account email/phone in user info"); - } - - return { userInfo }; - }, - mapTokens: (tokens, extra) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - apiKey: extra?.userInfo?.apiKey, - email: extra?.userInfo?.email || extra?.userInfo?.phone, - displayName: extra?.userInfo?.nickname || extra?.userInfo?.name, - }), - }, - - qoder: { - config: QODER_CONFIG, - flowType: "device_code", - // Qoder uses a custom device flow: PKCE + nonce + machine_id are generated - // locally, the user lands on qoder.com/device/selectAccounts in the - // browser, and we poll openapi.qoder.sh until a `dt-...` token appears. - requestDeviceCode: async (config) => { - const { QoderService } = await import("@/lib/oauth/services/qoder"); - const flow = new QoderService().initiateDeviceFlow(); - // Match the device_code shape the rest of the OAuthModal expects - // (device_code, user_code, verification_uri[_complete], interval). - // The poll endpoint identifies us by nonce+verifier, not by a - // server-issued device_code, so we plumb our own values through: - // device_code = nonce (modal forwards as deviceCode on poll) - // codeVerifier = our PKCE verifier (route forwards as codeVerifier) - return { - device_code: flow.nonce, - user_code: flow.nonce.slice(0, 8).toUpperCase(), - verification_uri: config.loginUrl, - verification_uri_complete: flow.verificationUriComplete, - expires_in: 300, - interval: 2, - codeVerifier: flow.codeVerifier, - _qoderNonce: flow.nonce, - _qoderMachineId: flow.machineId, - }; - }, - pollToken: async (config, deviceCode, codeVerifier, extraData) => { - const { QoderService } = await import("@/lib/oauth/services/qoder"); - const svc = new QoderService(); - const nonce = deviceCode || extraData?._qoderNonce; - const verifier = codeVerifier || extraData?._qoderVerifier; - if (!nonce || !verifier) { - return { - ok: false, - data: { error: "invalid_request", error_description: "Missing nonce/verifier" }, - }; - } - let result; - try { - result = await svc.pollDeviceToken({ nonce, codeVerifier: verifier }); - } catch (err) { - return { - ok: false, - data: { error: "poll_failed", error_description: err.message }, - }; - } - if (result.status === "pending") { - return { ok: false, data: { error: "authorization_pending" } }; - } - // Best-effort profile lookup so we have a name/email to display. - const userInfo = await svc.fetchUserInfo(result.accessToken); - // expireTime is a Unix-ms timestamp from QoderService.parseExpiry, - // which already falls back to "now + 30 days" when the upstream - // omits expiry. Floor to a sane minimum (1 day) so a stale or - // skewed upstream timestamp doesn't truncate the stored token below - // something useful. - const minSeconds = 24 * 60 * 60; - const remainingSeconds = Math.floor((result.expireTime - Date.now()) / 1000); - const expiresIn = Math.max(minSeconds, remainingSeconds); - return { - ok: true, - data: { - access_token: result.accessToken, - refresh_token: result.refreshToken, - expires_in: expiresIn, - _qoderUserId: result.userId, - _qoderMachineId: extraData?._qoderMachineId || "", - _qoderName: userInfo.name, - _qoderEmail: userInfo.email, - _qoderOrganizationId: userInfo.organizationId, - }, - }; - }, - mapTokens: (tokens) => { - const rawEmail = (tokens._qoderEmail || "").trim(); - const displayName = (tokens._qoderName || "").trim() || null; - const userId = tokens._qoderUserId || ""; - // Dedup in createProviderConnection requires a non-empty email. When - // fetchUserInfo silently fails (returns ""), fall back to a stable - // synthetic identifier derived from userId so re-logins update the - // existing row instead of accumulating "Account N" duplicates. - const email = rawEmail || (userId ? `qoder-user-${userId}` : null); - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || null, - expiresIn: tokens.expires_in, - email, - displayName, - providerSpecificData: { - authMethod: "device", - userId, - machineId: tokens._qoderMachineId || "", - organizationId: tokens._qoderOrganizationId || "", - }, - }; - }, - }, - - qwen: { - config: QWEN_CONFIG, - flowType: "device_code", - requestDeviceCode: async (config, codeChallenge) => { - const response = await fetch(config.deviceCodeUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - client_id: config.clientId, - scope: config.scope, - code_challenge: codeChallenge, - code_challenge_method: config.codeChallengeMethod, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Device code request failed: ${error}`); - } - - return await response.json(); - }, - pollToken: async (config, deviceCode, codeVerifier) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - client_id: config.clientId, - device_code: deviceCode, - code_verifier: codeVerifier, - }), - }); - - return { - ok: response.ok, - data: await response.json(), - }; - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - providerSpecificData: { resourceUrl: tokens.resource_url }, - }), - }, - - github: { - config: GITHUB_CONFIG, - flowType: "device_code", - requestDeviceCode: async (config) => { - const response = await fetch(config.deviceCodeUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - client_id: config.clientId, - scope: config.scopes, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Device code request failed: ${error}`); - } - - return await response.json(); - }, - pollToken: async (config, deviceCode) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - client_id: config.clientId, - device_code: deviceCode, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }), - }); - - // Handle response properly - if not ok, try to get error as text first - let data; - try { - data = await response.json(); - } catch (e) { - // If response is not JSON, get as text - const text = await response.text(); - data = { error: "invalid_response", error_description: text }; - } - - return { - ok: response.ok, - data: data, - }; - }, - postExchange: async (tokens) => { - // Get Copilot token using GitHub access token - const copilotRes = await fetch(GITHUB_CONFIG.copilotTokenUrl, { - headers: { - Authorization: `Bearer ${tokens.access_token}`, - Accept: "application/json", - "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, - "User-Agent": GITHUB_CONFIG.userAgent, - }, - }); - const copilotToken = copilotRes.ok ? await copilotRes.json() : {}; - - // Get user info from GitHub - const userRes = await fetch(GITHUB_CONFIG.userInfoUrl, { - headers: { - Authorization: `Bearer ${tokens.access_token}`, - Accept: "application/json", - "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, - "User-Agent": GITHUB_CONFIG.userAgent, - }, - }); - const userInfo = userRes.ok ? await userRes.json() : {}; - - return { copilotToken, userInfo }; - }, - mapTokens: (tokens, extra) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - name: extra?.userInfo?.login || extra?.userInfo?.name, - displayName: extra?.userInfo?.name || extra?.userInfo?.login, - email: extra?.userInfo?.email || null, - providerSpecificData: { - copilotToken: extra?.copilotToken?.token, - copilotTokenExpiresAt: extra?.copilotToken?.expires_at, - githubUserId: extra?.userInfo?.id, - githubLogin: extra?.userInfo?.login, - githubName: extra?.userInfo?.name, - githubEmail: extra?.userInfo?.email, - }, - }), - }, - - kiro: { - config: KIRO_CONFIG, - flowType: "device_code", - // Kiro uses AWS SSO OIDC - requires client registration first - requestDeviceCode: async (config, codeChallenge, options = {}) => { - const trimmedRegion = typeof options.region === "string" ? options.region.trim() : ""; - const region = trimmedRegion || "us-east-1"; - assertValidAwsRegion(region); - const trimmedStartUrl = typeof options.startUrl === "string" ? options.startUrl.trim() : ""; - const startUrl = trimmedStartUrl || config.startUrl; - const authMethod = options.authMethod === "idc" ? "idc" : "builder-id"; - const registerClientUrl = `https://oidc.${region}.amazonaws.com/client/register`; - const deviceAuthUrl = `https://oidc.${region}.amazonaws.com/device_authorization`; - - // Step 1: Register client with AWS SSO OIDC - const registerRes = await fetch(registerClientUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - clientName: config.clientName, - clientType: config.clientType, - scopes: config.scopes, - grantTypes: config.grantTypes, - issuerUrl: config.issuerUrl, - }), - }); - - if (!registerRes.ok) { - const error = await registerRes.text(); - throw new Error(`Client registration failed: ${error}`); - } - - const clientInfo = await registerRes.json(); - - // Step 2: Request device authorization - const deviceRes = await fetch(deviceAuthUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - clientId: clientInfo.clientId, - clientSecret: clientInfo.clientSecret, - startUrl, - }), - }); - - if (!deviceRes.ok) { - const error = await deviceRes.text(); - throw new Error(`Device authorization failed: ${error}`); - } - - const deviceData = await deviceRes.json(); - - // Return combined data for polling - return { - device_code: deviceData.deviceCode, - user_code: deviceData.userCode, - verification_uri: deviceData.verificationUri, - verification_uri_complete: deviceData.verificationUriComplete, - expires_in: deviceData.expiresIn, - interval: deviceData.interval || 5, - // Store client credentials for token exchange - _clientId: clientInfo.clientId, - _clientSecret: clientInfo.clientSecret, - _region: region, - _authMethod: authMethod, - _startUrl: startUrl, - }; - }, - pollToken: async (config, deviceCode, codeVerifier, extraData) => { - const region = extraData?._region || "us-east-1"; - assertValidAwsRegion(region); - const tokenUrl = `https://oidc.${region}.amazonaws.com/token`; - const response = await fetch(tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - clientId: extraData?._clientId, - clientSecret: extraData?._clientSecret, - deviceCode: deviceCode, - grantType: "urn:ietf:params:oauth:grant-type:device_code", - }), - }); - - let data; - try { - data = await response.json(); - } catch (e) { - const text = await response.text(); - data = { error: "invalid_response", error_description: text }; - } - - // AWS SSO OIDC returns camelCase - if (data.accessToken) { - return { - ok: true, - data: { - access_token: data.accessToken, - refresh_token: data.refreshToken, - expires_in: data.expiresIn, - profile_arn: data?.profileArn || null, - // Store client credentials for refresh - _clientId: extraData?._clientId, - _clientSecret: extraData?._clientSecret, - _region: extraData?._region, - _authMethod: extraData?._authMethod, - _startUrl: extraData?._startUrl, - }, - }; - } - - return { - ok: false, - data: { - error: data.error || "authorization_pending", - error_description: data.error_description || data.message, - }, - }; - }, - mapTokens: (tokens) => { - const email = extractEmailFromAccessToken(tokens.access_token); - const mapped = { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - email, - providerSpecificData: { - profileArn: tokens?.profile_arn || null, - clientId: tokens._clientId, - clientSecret: tokens._clientSecret, - region: tokens._region || "us-east-1", - authMethod: tokens._authMethod || "builder-id", - startUrl: tokens._startUrl || KIRO_CONFIG.startUrl, - }, - }; - return mapped; - }, - }, - - cursor: { - config: CURSOR_CONFIG, - flowType: "import_token", - // Cursor uses import token flow - tokens are extracted from local SQLite database - // No OAuth flow needed, handled by /api/oauth/cursor/import route - mapTokens: (tokens) => ({ - accessToken: tokens.accessToken, - refreshToken: null, // Cursor doesn't have public refresh endpoint - expiresIn: tokens.expiresIn || 86400, - providerSpecificData: { - machineId: tokens.machineId, - authMethod: "imported", - }, - }), - }, - - // Kimi Code device flow (CLIProxyAPI internal/auth/kimi). Id is `kimi`; - // `kimi-coding` remains an alias key so old UI/API routes still resolve. - kimi: { - config: KIMI_CONFIG, - flowType: "device_code", - requestDeviceCode: async (config) => { - const { buildKimiHeaders } = await import("open-sse/config/appConstants.js"); - const deviceId = crypto.randomUUID(); - const headers = { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - ...buildKimiHeaders(deviceId), - }; - const response = await fetch(config.deviceCodeUrl, { - method: "POST", - headers, - body: new URLSearchParams({ client_id: config.clientId }), - }); - if (!response.ok) { - const error = await response.text(); - throw new Error(`Device code request failed: ${error}`); - } - const data = await response.json(); - const authorizeDeviceUrl = config.authorizeDeviceUrl || "https://www.kimi.com/code/authorize_device"; - return { - device_code: data.device_code, - user_code: data.user_code, - verification_uri: data.verification_uri || authorizeDeviceUrl, - verification_uri_complete: - data.verification_uri_complete || - `${authorizeDeviceUrl}?user_code=${data.user_code}`, - expires_in: data.expires_in, - interval: data.interval || 5, - _kimiDeviceId: deviceId, - }; - }, - pollToken: async (config, deviceCode, _codeVerifier, extraData) => { - const { buildKimiHeaders } = await import("open-sse/config/appConstants.js"); - const deviceId = extraData?._kimiDeviceId; - const headers = { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - ...buildKimiHeaders(deviceId), - }; - const response = await fetch(config.tokenUrl, { - method: "POST", - headers, - body: new URLSearchParams({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - client_id: config.clientId, - device_code: deviceCode, - }), - }); - let data; - try { - data = await response.json(); - } catch { - data = { error: "invalid_response", error_description: "non-json token response" }; - } - // CLIProxyAPI: Kimi returns 200 for pending states with error field - if (data.error === "authorization_pending" || data.error === "slow_down") { - return { ok: true, data }; - } - if (data.access_token && deviceId) data._kimiDeviceId = deviceId; - return { ok: response.ok || !!data.access_token || !!data.error, data }; - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - providerSpecificData: { - authMethod: "device_code", - ...(tokens._kimiDeviceId ? { deviceId: tokens._kimiDeviceId } : {}), - }, - }), - }, - kilocode: { - config: KILOCODE_CONFIG, - flowType: "device_code", - requestDeviceCode: async (config) => { - const response = await fetch(config.initiateUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - }); - if (!response.ok) { - if (response.status === 429) { - throw new Error("Too many pending authorization requests. Please try again later."); - } - const error = await response.text(); - throw new Error(`Device auth initiation failed: ${error}`); - } - const data = await response.json(); - return { - device_code: data.code, - user_code: data.code, - verification_uri: data.verificationUrl, - verification_uri_complete: data.verificationUrl, - expires_in: data.expiresIn || 300, - interval: 3, - }; - }, - pollToken: async (config, deviceCode) => { - const response = await fetch(`${config.pollUrlBase}/${deviceCode}`); - if (response.status === 202) return { ok: false, data: { error: "authorization_pending" } }; - if (response.status === 403) return { ok: false, data: { error: "access_denied", error_description: "Authorization denied by user" } }; - if (response.status === 410) return { ok: false, data: { error: "expired_token", error_description: "Authorization code expired" } }; - if (!response.ok) return { ok: false, data: { error: "poll_failed", error_description: `Poll failed: ${response.status}` } }; - const data = await response.json(); - if (data.status === "approved" && data.token) { - // Fetch profile to get orgId for X-Kilocode-OrganizationID header - let orgId = null; - try { - const profileRes = await fetch(`${config.apiBaseUrl}/api/profile`, { - headers: { "Authorization": `Bearer ${data.token}` } - }); - if (profileRes.ok) { - const profile = await profileRes.json(); - orgId = profile.organizations?.[0]?.id || null; - } - } catch {} - return { ok: true, data: { access_token: data.token, _userEmail: data.userEmail, _orgId: orgId } }; - } - return { ok: false, data: { error: "authorization_pending" } }; - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: null, - expiresIn: null, - email: tokens._userEmail, - ...(tokens._orgId ? { providerSpecificData: { orgId: tokens._orgId } } : {}), - }), - }, - - cline: { - config: CLINE_CONFIG, - flowType: "authorization_code", - buildAuthUrl: (config, redirectUri) => { - const params = new URLSearchParams({ - client_type: "extension", - callback_url: redirectUri, - redirect_uri: redirectUri, - }); - return `${config.authorizeUrl}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri) => { - try { - // Cline encodes token data as base64 in the code param - let base64 = code; - const padding = 4 - (base64.length % 4); - if (padding !== 4) base64 += "=".repeat(padding); - const decoded = Buffer.from(base64, "base64").toString("utf-8"); - const lastBrace = decoded.lastIndexOf("}"); - if (lastBrace === -1) throw new Error("No JSON found in decoded code"); - const tokenData = JSON.parse(decoded.substring(0, lastBrace + 1)); - return { - access_token: tokenData.accessToken, - refresh_token: tokenData.refreshToken, - email: tokenData.email, - firstName: tokenData.firstName, - lastName: tokenData.lastName, - expires_at: tokenData.expiresAt, - }; - } catch (e) { - const response = await fetch(config.tokenExchangeUrl, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify({ grant_type: "authorization_code", code, client_type: "extension", redirect_uri: redirectUri }), - }); - if (!response.ok) { - const error = await response.text(); - throw new Error(`Cline token exchange failed: ${error}`); - } - const data = await response.json(); - return { - access_token: data.data?.accessToken || data.accessToken, - refresh_token: data.data?.refreshToken || data.refreshToken, - email: data.data?.userInfo?.email || "", - expires_at: data.data?.expiresAt || data.expiresAt, - }; - } - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_at - ? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000) - : 3600, - email: tokens.email, - providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName }, - }), - }, - clinepass: { - config: CLINEPASS_CONFIG, - flowType: "authorization_code", - buildAuthUrl: (config, redirectUri) => { - const params = new URLSearchParams({ - client_type: "extension", - callback_url: redirectUri, - redirect_uri: redirectUri, - }); - return `${config.authorizeUrl}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri) => { - try { - // Cline encodes token data as base64 in the code param - let base64 = code; - const padding = 4 - (base64.length % 4); - if (padding !== 4) base64 += "=".repeat(padding); - const decoded = Buffer.from(base64, "base64").toString("utf-8"); - const lastBrace = decoded.lastIndexOf("}"); - if (lastBrace === -1) throw new Error("No JSON found in decoded code"); - const tokenData = JSON.parse(decoded.substring(0, lastBrace + 1)); - return { - access_token: tokenData.accessToken, - refresh_token: tokenData.refreshToken, - email: tokenData.email, - firstName: tokenData.firstName, - lastName: tokenData.lastName, - expires_at: tokenData.expiresAt, - }; - } catch (e) { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify({ grant_type: "authorization_code", code, client_type: "extension", redirect_uri: redirectUri }), - }); - if (!response.ok) { - const error = await response.text(); - throw new Error(`ClinePass token exchange failed: ${error}`); - } - const data = await response.json(); - return { - access_token: data.data?.accessToken || data.accessToken, - refresh_token: data.data?.refreshToken || data.refreshToken, - email: data.data?.userInfo?.email || "", - expires_at: data.data?.expiresAt || data.expiresAt, - }; - } - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_at - ? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000) - : 3600, - email: tokens.email, - providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName }, - }), - }, - // GitLab Duo - Authorization Code Flow with PKCE - // Supports two login modes via loginMode metadata: "oauth" (default) or "pat" - gitlab: { - config: GITLAB_CONFIG, - flowType: "authorization_code_pkce", - buildAuthUrl: (config, redirectUri, state, codeChallenge, meta = {}) => { - const baseUrl = meta.baseUrl || config.defaultBaseUrl; - const clientId = meta.clientId || ""; - const params = new URLSearchParams({ - client_id: clientId, - redirect_uri: redirectUri, - response_type: "code", - state, - scope: config.scope, - code_challenge: codeChallenge, - code_challenge_method: config.codeChallengeMethod, - }); - return `${baseUrl}${config.authorizeUrlPath}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri, codeVerifier, state, meta = {}) => { - const baseUrl = meta.baseUrl || config.defaultBaseUrl; - const clientId = meta.clientId || ""; - const clientSecret = meta.clientSecret || ""; - const body = new URLSearchParams({ - client_id: clientId, - grant_type: "authorization_code", - code, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - }); - if (clientSecret) body.set("client_secret", clientSecret); - const response = await fetch(`${baseUrl}${config.tokenUrlPath}`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, - body: body.toString(), - }); - if (!response.ok) throw new Error(`GitLab token exchange failed: ${await response.text()}`); - const tokens = await response.json(); - // Fetch user info - const userRes = await fetch(`${baseUrl}${config.userInfoUrlPath}`, { - headers: { Authorization: `Bearer ${tokens.access_token}` }, - }); - const user = userRes.ok ? await userRes.json() : {}; - return { ...tokens, _user: user, _baseUrl: baseUrl, _clientId: clientId }; - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - providerSpecificData: { - username: tokens._user?.username || "", - email: tokens._user?.email || tokens._user?.public_email || "", - name: tokens._user?.name || "", - baseUrl: tokens._baseUrl, - clientId: tokens._clientId, - authKind: "oauth", - }, - }), - }, - - // CodeBuddy (Tencent) - Browser OAuth Polling Flow - // 1. POST stateUrl → get { state, authUrl } - // 2. Open authUrl in browser - // 3. Poll tokenUrl with state until success (code 0) or timeout - "codebuddy-cn": { - config: CODEBUDDY_CONFIG, - flowType: "device_code", - requestDeviceCode: async (config) => { - const response = await fetch(`${config.stateUrl}?platform=${config.platform}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - "User-Agent": config.userAgent, - "X-Requested-With": "XMLHttpRequest", - "X-Domain": "copilot.tencent.com", - "X-No-Authorization": "true", - "X-No-User-Id": "true", - "X-Product": "SaaS", - }, - body: "{}", - }); - if (!response.ok) throw new Error(`CodeBuddy state request failed: ${await response.text()}`); - const data = await response.json(); - if (data.code !== 0 || !data.data?.state || !data.data?.authUrl) { - throw new Error(`CodeBuddy state error: ${data.msg || "missing state/authUrl"}`); - } - return { - device_code: data.data.state, - verification_uri: data.data.authUrl, - user_code: "", - interval: config.pollInterval / 1000, - _isCodeBuddy: true, - }; - }, - pollToken: async (config, deviceCode) => { - // CodeBuddy polls the token endpoint via GET with the state as a query - // param (not POST/body) — matches the official CLI's /v2/plugin/auth/token?state=... - const response = await fetch(`${config.tokenUrl}?state=${encodeURIComponent(deviceCode)}`, { - method: "GET", - headers: { - Accept: "application/json", - "User-Agent": config.userAgent, - "X-Requested-With": "XMLHttpRequest", - "X-Domain": "copilot.tencent.com", - "X-No-Authorization": "true", - "X-No-User-Id": "true", - "X-No-Enterprise-Id": "true", - "X-No-Department-Info": "true", - "X-Product": "SaaS", - }, - }); - if (!response.ok) return { ok: false, data: { error: "request_failed" } }; - const data = await response.json(); - // code 11217 = pending (RetryFetchToken), code 0 = success - if (data.code === 0 && data.data?.accessToken) { - return { - ok: true, - data: { - access_token: data.data.accessToken, - refresh_token: data.data.refreshToken || "", - token_type: data.data.tokenType || "Bearer", - expires_in: data.data.expiresIn, - }, - }; - } - if (data.code === 11217) return { ok: true, data: { error: "authorization_pending" } }; - return { ok: false, data: { error: data.msg || "unknown_error" } }; - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in || 86400, - providerSpecificData: {}, - }), - }, - - kimchi: { - config: KIMCHI_CONFIG, - flowType: "browser_token", - buildAuthUrl: (config, redirectUri, state) => { - const baseUrl = (config.webAppUrl || "https://app.kimchi.dev").replace(/\/+$/, ""); - const params = new URLSearchParams({ - callback: redirectUri, - state, - }); - return `${baseUrl}/cli-auth?${params.toString()}`; - }, - exchangeToken: async (config, token) => { - const accessToken = String(token || "").trim(); - if (!accessToken) { - throw new Error("Missing Kimchi token"); - } - - const validationUrl = config.validationUrl || "https://api.cast.ai/v1/llm/openai/supported-providers"; - const validationRes = await fetch(validationUrl, { - method: "GET", - headers: { - Accept: "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }); - if (!validationRes.ok) { - throw new Error(`Kimchi token validation failed: ${validationRes.status}`); - } - - let userInfo = {}; - if (config.userInfoUrl) { - try { - const userRes = await fetch(config.userInfoUrl, { - method: "GET", - headers: { - Accept: "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }); - if (userRes.ok) { - userInfo = await userRes.json(); - } - } catch { - userInfo = {}; - } - } - - return { - access_token: accessToken, - token_type: "Bearer", - _kimchiUser: userInfo, - }; - }, - mapTokens: (tokens) => { - const user = tokens._kimchiUser || {}; - const userId = user.id ? String(user.id) : ""; - const username = user.username || ""; - const email = user.email || (userId ? `kimchi-user-${userId}` : null); - return { - accessToken: tokens.access_token, - refreshToken: null, - email, - displayName: user.name || username || null, - providerSpecificData: { - authMethod: "browser_token", - userId, - username, - }, - }; - }, - }, -}; - -/** - * Get provider handler - */ -export function getProvider(name) { - // Legacy kimi-coding → kimi (dual-auth merge) - const key = name === "kimi-coding" ? "kimi" : name; - const provider = PROVIDERS[key]; - if (!provider) { - throw new Error(`Unknown provider: ${name}`); - } - return provider; -} - -/** - * Get all provider names - */ -export function getProviderNames() { - return Object.keys(PROVIDERS); -} - -/** - * Generate auth data for a provider - * @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl) - */ -export async function generateAuthData(providerName, redirectUri, meta) { - const provider = getProvider(providerName); - const config = provider.prepareConfig - ? await provider.prepareConfig(provider.config, meta || {}) - : provider.config; - const { codeVerifier, codeChallenge, state } = generatePKCE(provider.pkceVerifierBytes); - - let authUrl; - if (provider.flowType === "device_code") { - // Device code flow doesn't have auth URL upfront - authUrl = null; - } else if (provider.flowType === "authorization_code_pkce") { - authUrl = provider.buildAuthUrl(config, redirectUri, state, codeChallenge, meta || {}); - } else { - authUrl = provider.buildAuthUrl(config, redirectUri, state, undefined, meta || {}); - } - - return { - authUrl, - state, - codeVerifier, - codeChallenge, - redirectUri, - flowType: provider.flowType, - fixedPort: provider.fixedPort, - callbackPath: provider.callbackPath || "/callback", - }; -} - -/** - * Exchange code for tokens - * @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl) - */ -export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state, meta) { - const provider = getProvider(providerName); - const config = provider.prepareConfig - ? await provider.prepareConfig(provider.config, meta || {}) - : provider.config; - - const tokens = await provider.exchangeToken(config, code, redirectUri, codeVerifier, state, meta || {}); - - let extra = null; - if (provider.postExchange) { - extra = await provider.postExchange(tokens); - } - - return provider.mapTokens(tokens, extra); -} - -/** - * Request device code (for device_code flow) - */ -export async function requestDeviceCode(providerName, codeChallenge, options) { - const provider = getProvider(providerName); - if (provider.flowType !== "device_code") { - throw new Error(`Provider ${providerName} does not support device code flow`); - } - return await provider.requestDeviceCode(provider.config, codeChallenge, options || {}); -} - -/** - * Poll for token (for device_code flow) - * @param {string} providerName - Provider name - * @param {string} deviceCode - Device code from requestDeviceCode - * @param {string} codeVerifier - PKCE code verifier (optional for some providers) - * @param {object} extraData - Extra data from device code response (e.g. clientId/clientSecret for Kiro) - */ -export async function pollForToken(providerName, deviceCode, codeVerifier, extraData) { - const provider = getProvider(providerName); - if (provider.flowType !== "device_code") { - throw new Error(`Provider ${providerName} does not support device code flow`); - } - - const result = await provider.pollToken(provider.config, deviceCode, codeVerifier, extraData); - - if (result.ok) { - // For device code flows, success is only when we have an access token - if (result.data.access_token) { - // Call postExchange to get additional data (copilotToken, userInfo, etc.) - let extra = null; - if (provider.postExchange) { - extra = await provider.postExchange(result.data); - } - const tokens = provider.mapTokens(result.data, extra); - // Kiro IDC/Builder-ID tokens lack profileArn; resolve it to avoid 403 - if (providerName === "kiro" && !tokens.providerSpecificData?.profileArn) { - const profileArn = await fetchKiroProfileArn(tokens.accessToken); - if (profileArn) tokens.providerSpecificData.profileArn = profileArn; - } - return { success: true, tokens }; - } else { - // Check if it's still pending authorization - if (result.data.error === 'authorization_pending' || result.data.error === 'slow_down') { - // This is not a failure, just still waiting - return { - success: false, - error: result.data.error, - errorDescription: result.data.error_description || result.data.message, - pending: result.data.error === 'authorization_pending' - }; - } else { - // Actual error - return { - success: false, - error: result.data.error || 'no_access_token', - errorDescription: result.data.error_description || result.data.message || 'No access token received' - }; - } - } - } - - return { success: false, error: result.data.error, errorDescription: result.data.error_description }; -} - -// Run-once guard across the process lifetime -let codexBackfillDone = false; - -// Backfill email + chatgpt account info for existing codex OAuth connections missing them -export async function backfillCodexEmails() { - if (codexBackfillDone) return; - codexBackfillDone = true; - try { - const { getProviderConnections, updateProviderConnection } = await import("@/lib/localDb"); - const connections = await getProviderConnections(); - const targets = connections.filter((c) => { - if (c.provider !== "codex" || c.authType !== "oauth" || !c.idToken) return false; - const hasEmail = !!c.email; - const hasAccountInfo = !!c.providerSpecificData?.chatgptAccountId; - return !hasEmail || !hasAccountInfo; - }); - for (const conn of targets) { - const info = extractCodexAccountInfo(conn.idToken); - if (!info.email && !info.chatgptAccountId) continue; - const patch = {}; - if (!conn.email && info.email) patch.email = info.email; - if (info.chatgptAccountId || info.chatgptPlanType) { - patch.providerSpecificData = { - ...(conn.providerSpecificData || {}), - chatgptAccountId: info.chatgptAccountId, - chatgptPlanType: info.chatgptPlanType, - }; - } - if (Object.keys(patch).length) { - await updateProviderConnection(conn.id, patch); - } - } - } catch (err) { - codexBackfillDone = false; - console.log("backfillCodexEmails failed:", err?.message || err); - } -} +export * from "./providers/index.js"; diff --git a/src/lib/oauth/providers/_shared.js b/src/lib/oauth/providers/_shared.js new file mode 100644 index 00000000..e08f5965 --- /dev/null +++ b/src/lib/oauth/providers/_shared.js @@ -0,0 +1,14 @@ +// Shared helpers used across provider entry files (currently trae + windsurf). + +export function extractJsonPath(root, paths) { + for (const path of paths) { + let cur = root; + for (const key of path) { + if (cur == null || typeof cur !== "object") { cur = undefined; break; } + cur = cur[key]; + } + if (typeof cur === "string" && cur.trim()) return cur.trim(); + if (typeof cur === "number") return String(cur); + } + return null; +} diff --git a/src/lib/oauth/providers/antigravity.js b/src/lib/oauth/providers/antigravity.js new file mode 100644 index 00000000..6cf28e81 --- /dev/null +++ b/src/lib/oauth/providers/antigravity.js @@ -0,0 +1,122 @@ +import { ANTIGRAVITY_CONFIG, getOAuthClientMetadata } from "../constants/oauth.js"; + +const antigravity = { + config: ANTIGRAVITY_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri, state) => { + const params = new URLSearchParams({ + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: config.scopes.join(" "), + state: state, + access_type: "offline", + prompt: "consent", + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + client_secret: config.clientSecret, + code: code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + postExchange: async (tokens) => { + // Numeric enums matching Antigravity binary ClientMetadata + const loadHeaders = { + "Authorization": `Bearer ${tokens.access_token}`, + "Content-Type": "application/json", + "User-Agent": ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent, + "X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient, + "Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata, + "x-request-source": "local", + }; + const metadata = getOAuthClientMetadata(); + + // Fetch user info + const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + "x-request-source": "local", + }, + }); + const userInfo = userInfoRes.ok ? await userInfoRes.json() : {}; + + // Load Code Assist to get project ID and tier + let projectId = ""; + let tierId = "legacy-tier"; + try { + const loadRes = await fetch(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, { + method: "POST", + headers: loadHeaders, + body: JSON.stringify({ metadata }), + }); + if (loadRes.ok) { + const data = await loadRes.json(); + projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; + if (Array.isArray(data.allowedTiers)) { + for (const tier of data.allowedTiers) { + if (tier.isDefault && tier.id) { + tierId = tier.id.trim(); + break; + } + } + } + } + } catch (e) { + console.log("Failed to load code assist:", e); + } + + // Fire-and-forget onboarding — does not block DB save + if (projectId) { + const doOnboard = async () => { + for (let i = 0; i < 10; i++) { + try { + const onboardRes = await fetch(ANTIGRAVITY_CONFIG.onboardUserEndpoint, { + method: "POST", + headers: loadHeaders, + body: JSON.stringify({ tierId, metadata }), + }); + if (onboardRes.ok) { + const result = await onboardRes.json(); + if (result.done === true) break; + } + } catch (e) { + break; + } + await new Promise(resolve => setTimeout(resolve, 5000)); + } + }; + doOnboard().catch(() => {}); + } + + return { userInfo, projectId }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + email: extra?.userInfo?.email, + projectId: extra?.projectId, + }), +}; + +export default antigravity; diff --git a/src/lib/oauth/providers/claude.js b/src/lib/oauth/providers/claude.js new file mode 100644 index 00000000..498b6b81 --- /dev/null +++ b/src/lib/oauth/providers/claude.js @@ -0,0 +1,60 @@ +import { CLAUDE_CONFIG } from "../constants/oauth.js"; + +const claude = { + config: CLAUDE_CONFIG, + flowType: "authorization_code_pkce", + buildAuthUrl: (config, redirectUri, state, codeChallenge) => { + const params = new URLSearchParams({ + code: "true", + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: config.scopes.join(" "), + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + state: state, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri, codeVerifier, state) => { + // Parse code - may contain state after # + let authCode = code; + let codeState = ""; + if (authCode.includes("#")) { + const parts = authCode.split("#"); + authCode = parts[0]; + codeState = parts[1] || ""; + } + + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + code: authCode, + state: codeState || state, + grant_type: "authorization_code", + client_id: config.clientId, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + }), +}; + +export default claude; diff --git a/src/lib/oauth/providers/cline.js b/src/lib/oauth/providers/cline.js new file mode 100644 index 00000000..5c94357b --- /dev/null +++ b/src/lib/oauth/providers/cline.js @@ -0,0 +1,62 @@ +import { CLINE_CONFIG } from "../constants/oauth.js"; + +const cline = { + config: CLINE_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri) => { + const params = new URLSearchParams({ + client_type: "extension", + callback_url: redirectUri, + redirect_uri: redirectUri, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + try { + // Cline encodes token data as base64 in the code param + let base64 = code; + const padding = 4 - (base64.length % 4); + if (padding !== 4) base64 += "=".repeat(padding); + const decoded = Buffer.from(base64, "base64").toString("utf-8"); + const lastBrace = decoded.lastIndexOf("}"); + if (lastBrace === -1) throw new Error("No JSON found in decoded code"); + const tokenData = JSON.parse(decoded.substring(0, lastBrace + 1)); + return { + access_token: tokenData.accessToken, + refresh_token: tokenData.refreshToken, + email: tokenData.email, + firstName: tokenData.firstName, + lastName: tokenData.lastName, + expires_at: tokenData.expiresAt, + }; + } catch (e) { + const response = await fetch(config.tokenExchangeUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ grant_type: "authorization_code", code, client_type: "extension", redirect_uri: redirectUri }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`Cline token exchange failed: ${error}`); + } + const data = await response.json(); + return { + access_token: data.data?.accessToken || data.accessToken, + refresh_token: data.data?.refreshToken || data.refreshToken, + email: data.data?.userInfo?.email || "", + expires_at: data.data?.expiresAt || data.expiresAt, + }; + } + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_at + ? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000) + : 3600, + email: tokens.email, + providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName }, + }), +}; + +export default cline; diff --git a/src/lib/oauth/providers/clinepass.js b/src/lib/oauth/providers/clinepass.js new file mode 100644 index 00000000..a49a2119 --- /dev/null +++ b/src/lib/oauth/providers/clinepass.js @@ -0,0 +1,62 @@ +import { CLINEPASS_CONFIG } from "../constants/oauth.js"; + +const clinepass = { + config: CLINEPASS_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri) => { + const params = new URLSearchParams({ + client_type: "extension", + callback_url: redirectUri, + redirect_uri: redirectUri, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + try { + // Cline encodes token data as base64 in the code param + let base64 = code; + const padding = 4 - (base64.length % 4); + if (padding !== 4) base64 += "=".repeat(padding); + const decoded = Buffer.from(base64, "base64").toString("utf-8"); + const lastBrace = decoded.lastIndexOf("}"); + if (lastBrace === -1) throw new Error("No JSON found in decoded code"); + const tokenData = JSON.parse(decoded.substring(0, lastBrace + 1)); + return { + access_token: tokenData.accessToken, + refresh_token: tokenData.refreshToken, + email: tokenData.email, + firstName: tokenData.firstName, + lastName: tokenData.lastName, + expires_at: tokenData.expiresAt, + }; + } catch (e) { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ grant_type: "authorization_code", code, client_type: "extension", redirect_uri: redirectUri }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`ClinePass token exchange failed: ${error}`); + } + const data = await response.json(); + return { + access_token: data.data?.accessToken || data.accessToken, + refresh_token: data.data?.refreshToken || data.refreshToken, + email: data.data?.userInfo?.email || "", + expires_at: data.data?.expiresAt || data.expiresAt, + }; + } + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_at + ? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000) + : 3600, + email: tokens.email, + providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName }, + }), +}; + +export default clinepass; diff --git a/src/lib/oauth/providers/codebuddy-cn.js b/src/lib/oauth/providers/codebuddy-cn.js new file mode 100644 index 00000000..82be6b51 --- /dev/null +++ b/src/lib/oauth/providers/codebuddy-cn.js @@ -0,0 +1,80 @@ +import { CODEBUDDY_CONFIG } from "../constants/oauth.js"; + +// CodeBuddy (Tencent) - Browser OAuth Polling Flow +// 1. POST stateUrl → get { state, authUrl } +// 2. Open authUrl in browser +// 3. Poll tokenUrl with state until success (code 0) or timeout +const codebuddyCn = { + config: CODEBUDDY_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const response = await fetch(`${config.stateUrl}?platform=${config.platform}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": config.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "copilot.tencent.com", + "X-No-Authorization": "true", + "X-No-User-Id": "true", + "X-Product": "SaaS", + }, + body: "{}", + }); + if (!response.ok) throw new Error(`CodeBuddy state request failed: ${await response.text()}`); + const data = await response.json(); + if (data.code !== 0 || !data.data?.state || !data.data?.authUrl) { + throw new Error(`CodeBuddy state error: ${data.msg || "missing state/authUrl"}`); + } + return { + device_code: data.data.state, + verification_uri: data.data.authUrl, + user_code: "", + interval: config.pollInterval / 1000, + _isCodeBuddy: true, + }; + }, + pollToken: async (config, deviceCode) => { + // CodeBuddy polls the token endpoint via GET with the state as a query + // param (not POST/body) — matches the official CLI's /v2/plugin/auth/token?state=... + const response = await fetch(`${config.tokenUrl}?state=${encodeURIComponent(deviceCode)}`, { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": config.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "copilot.tencent.com", + "X-No-Authorization": "true", + "X-No-User-Id": "true", + "X-No-Enterprise-Id": "true", + "X-No-Department-Info": "true", + "X-Product": "SaaS", + }, + }); + if (!response.ok) return { ok: false, data: { error: "request_failed" } }; + const data = await response.json(); + // code 11217 = pending (RetryFetchToken), code 0 = success + if (data.code === 0 && data.data?.accessToken) { + return { + ok: true, + data: { + access_token: data.data.accessToken, + refresh_token: data.data.refreshToken || "", + token_type: data.data.tokenType || "Bearer", + expires_in: data.data.expiresIn, + }, + }; + } + if (data.code === 11217) return { ok: true, data: { error: "authorization_pending" } }; + return { ok: false, data: { error: data.msg || "unknown_error" } }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in || 86400, + providerSpecificData: {}, + }), +}; + +export default codebuddyCn; diff --git a/src/lib/oauth/providers/codebuddy-intl.js b/src/lib/oauth/providers/codebuddy-intl.js new file mode 100644 index 00000000..e82e430f --- /dev/null +++ b/src/lib/oauth/providers/codebuddy-intl.js @@ -0,0 +1,74 @@ +import { CODEBUDDY_INTL_CONFIG } from "../constants/oauth.js"; + +// CodeBuddy International — mirrors codebuddy-cn flow against the .ai domain. +const codebuddyIntl = { + config: CODEBUDDY_INTL_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const response = await fetch(`${config.stateUrl}?platform=${config.platform}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": config.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "www.codebuddy.ai", + "X-No-Authorization": "true", + "X-No-User-Id": "true", + "X-Product": "SaaS", + }, + body: "{}", + }); + if (!response.ok) throw new Error(`CodeBuddy Intl state request failed: ${await response.text()}`); + const data = await response.json(); + if (data.code !== 0 || !data.data?.state || !data.data?.authUrl) { + throw new Error(`CodeBuddy Intl state error: ${data.msg || "missing state/authUrl"}`); + } + return { + device_code: data.data.state, + verification_uri: data.data.authUrl, + user_code: "", + interval: config.pollInterval / 1000, + _isCodeBuddy: true, + }; + }, + pollToken: async (config, deviceCode) => { + const response = await fetch(`${config.tokenUrl}?state=${encodeURIComponent(deviceCode)}`, { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": config.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "www.codebuddy.ai", + "X-No-Authorization": "true", + "X-No-User-Id": "true", + "X-No-Enterprise-Id": "true", + "X-No-Department-Info": "true", + "X-Product": "SaaS", + }, + }); + if (!response.ok) return { ok: false, data: { error: "request_failed" } }; + const data = await response.json(); + if (data.code === 0 && data.data?.accessToken) { + return { + ok: true, + data: { + access_token: data.data.accessToken, + refresh_token: data.data.refreshToken || "", + token_type: data.data.tokenType || "Bearer", + expires_in: data.data.expiresIn, + }, + }; + } + if (data.code === 11217) return { ok: true, data: { error: "authorization_pending" } }; + return { ok: false, data: { error: data.msg || "unknown_error" } }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in || 86400, + providerSpecificData: {}, + }), +}; + +export default codebuddyIntl; diff --git a/src/lib/oauth/providers/codex.js b/src/lib/oauth/providers/codex.js new file mode 100644 index 00000000..be4cbe65 --- /dev/null +++ b/src/lib/oauth/providers/codex.js @@ -0,0 +1,69 @@ +import { CODEX_CONFIG } from "../constants/oauth.js"; +import { extractCodexAccountInfo, extractEmailFromAccessToken } from "../providerHelpers.js"; + +const codex = { + config: CODEX_CONFIG, + flowType: "authorization_code_pkce", + fixedPort: CODEX_CONFIG.fixedPort, + callbackPath: CODEX_CONFIG.callbackPath, + buildAuthUrl: (config, redirectUri, state, codeChallenge) => { + const params = { + response_type: "code", + client_id: config.clientId, + redirect_uri: redirectUri, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + ...config.extraParams, + state: state, + }; + const queryString = Object.entries(params) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join("&"); + return `${config.authorizeUrl}?${queryString}`; + }, + exchangeToken: async (config, code, redirectUri, codeVerifier) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + code: code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + mapTokens: (tokens) => { + const info = extractCodexAccountInfo(tokens.id_token); + const mapped = { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + idToken: tokens.id_token, + expiresIn: tokens.expires_in, + lastRefreshAt: new Date().toISOString(), + }; + const email = info.email || extractEmailFromAccessToken(tokens.access_token); + if (email) mapped.email = email; + if (info.chatgptAccountId || info.chatgptPlanType) { + mapped.providerSpecificData = { + chatgptAccountId: info.chatgptAccountId, + chatgptPlanType: info.chatgptPlanType, + }; + } + return mapped; + }, +}; + +export default codex; diff --git a/src/lib/oauth/providers/cursor.js b/src/lib/oauth/providers/cursor.js new file mode 100644 index 00000000..a6aab0dc --- /dev/null +++ b/src/lib/oauth/providers/cursor.js @@ -0,0 +1,19 @@ +import { CURSOR_CONFIG } from "../constants/oauth.js"; + +const cursor = { + config: CURSOR_CONFIG, + flowType: "import_token", + // Cursor uses import token flow - tokens are extracted from local SQLite database + // No OAuth flow needed, handled by /api/oauth/cursor/import route + mapTokens: (tokens) => ({ + accessToken: tokens.accessToken, + refreshToken: null, // Cursor doesn't have public refresh endpoint + expiresIn: tokens.expiresIn || 86400, + providerSpecificData: { + machineId: tokens.machineId, + authMethod: "imported", + }, + }), +}; + +export default cursor; diff --git a/src/lib/oauth/providers/gemini-cli.js b/src/lib/oauth/providers/gemini-cli.js new file mode 100644 index 00000000..1e4d3d0b --- /dev/null +++ b/src/lib/oauth/providers/gemini-cli.js @@ -0,0 +1,85 @@ +import { GEMINI_CONFIG, getOAuthClientMetadata } from "../constants/oauth.js"; + +const geminiCli = { + config: GEMINI_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri, state) => { + const params = new URLSearchParams({ + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: config.scopes.join(" "), + state: state, + access_type: "offline", + prompt: "consent", + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + client_secret: config.clientSecret, + code: code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + postExchange: async (tokens) => { + // Fetch user info + const userInfoRes = await fetch(`${GEMINI_CONFIG.userInfoUrl}?alt=json`, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + const userInfo = userInfoRes.ok ? await userInfoRes.json() : {}; + + // Fetch project ID + let projectId = ""; + try { + const projectRes = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + metadata: getOAuthClientMetadata(), + mode: 1, + }), + } + ); + if (projectRes.ok) { + const data = await projectRes.json(); + projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; + } + } catch (e) { + console.log("Failed to fetch project ID:", e); + } + + return { userInfo, projectId }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + email: extra?.userInfo?.email, + projectId: extra?.projectId, + }), +}; + +export default geminiCli; diff --git a/src/lib/oauth/providers/github.js b/src/lib/oauth/providers/github.js new file mode 100644 index 00000000..518652f4 --- /dev/null +++ b/src/lib/oauth/providers/github.js @@ -0,0 +1,98 @@ +import { GITHUB_CONFIG } from "../constants/oauth.js"; + +const github = { + config: GITHUB_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const response = await fetch(config.deviceCodeUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: config.clientId, + scope: config.scopes, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Device code request failed: ${error}`); + } + + return await response.json(); + }, + pollToken: async (config, deviceCode) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: config.clientId, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + // Handle response properly - if not ok, try to get error as text first + let data; + try { + data = await response.json(); + } catch (e) { + // If response is not JSON, get as text + const text = await response.text(); + data = { error: "invalid_response", error_description: text }; + } + + return { + ok: response.ok, + data: data, + }; + }, + postExchange: async (tokens) => { + // Get Copilot token using GitHub access token + const copilotRes = await fetch(GITHUB_CONFIG.copilotTokenUrl, { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, + "User-Agent": GITHUB_CONFIG.userAgent, + }, + }); + const copilotToken = copilotRes.ok ? await copilotRes.json() : {}; + + // Get user info from GitHub + const userRes = await fetch(GITHUB_CONFIG.userInfoUrl, { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, + "User-Agent": GITHUB_CONFIG.userAgent, + }, + }); + const userInfo = userRes.ok ? await userRes.json() : {}; + + return { copilotToken, userInfo }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + name: extra?.userInfo?.login || extra?.userInfo?.name, + displayName: extra?.userInfo?.name || extra?.userInfo?.login, + email: extra?.userInfo?.email || null, + providerSpecificData: { + copilotToken: extra?.copilotToken?.token, + copilotTokenExpiresAt: extra?.copilotToken?.expires_at, + githubUserId: extra?.userInfo?.id, + githubLogin: extra?.userInfo?.login, + githubName: extra?.userInfo?.name, + githubEmail: extra?.userInfo?.email, + }, + }), +}; + +export default github; diff --git a/src/lib/oauth/providers/gitlab.js b/src/lib/oauth/providers/gitlab.js new file mode 100644 index 00000000..eacc0fa9 --- /dev/null +++ b/src/lib/oauth/providers/gitlab.js @@ -0,0 +1,64 @@ +import { GITLAB_CONFIG } from "../constants/oauth.js"; + +// GitLab Duo - Authorization Code Flow with PKCE +// Supports two login modes via loginMode metadata: "oauth" (default) or "pat" +const gitlab = { + config: GITLAB_CONFIG, + flowType: "authorization_code_pkce", + buildAuthUrl: (config, redirectUri, state, codeChallenge, meta = {}) => { + const baseUrl = meta.baseUrl || config.defaultBaseUrl; + const clientId = meta.clientId || ""; + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + state, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + }); + return `${baseUrl}${config.authorizeUrlPath}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri, codeVerifier, state, meta = {}) => { + const baseUrl = meta.baseUrl || config.defaultBaseUrl; + const clientId = meta.clientId || ""; + const clientSecret = meta.clientSecret || ""; + const body = new URLSearchParams({ + client_id: clientId, + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }); + if (clientSecret) body.set("client_secret", clientSecret); + const response = await fetch(`${baseUrl}${config.tokenUrlPath}`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body: body.toString(), + }); + if (!response.ok) throw new Error(`GitLab token exchange failed: ${await response.text()}`); + const tokens = await response.json(); + // Fetch user info + const userRes = await fetch(`${baseUrl}${config.userInfoUrlPath}`, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + const user = userRes.ok ? await userRes.json() : {}; + return { ...tokens, _user: user, _baseUrl: baseUrl, _clientId: clientId }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + providerSpecificData: { + username: tokens._user?.username || "", + email: tokens._user?.email || tokens._user?.public_email || "", + name: tokens._user?.name || "", + baseUrl: tokens._baseUrl, + clientId: tokens._clientId, + authKind: "oauth", + }, + }), +}; + +export default gitlab; diff --git a/src/lib/oauth/providers/grok-cli.js b/src/lib/oauth/providers/grok-cli.js new file mode 100644 index 00000000..2bd853b2 --- /dev/null +++ b/src/lib/oauth/providers/grok-cli.js @@ -0,0 +1,130 @@ +import { GROK_CLI_CONFIG } from "../constants/oauth.js"; +import { decodeXaiIdTokenEmail, extractEmailFromAccessToken } from "../providerHelpers.js"; + +// Grok CLI / Grok Build — device code flow to auth.x.ai, inference on cli-chat-proxy.grok.com +const grokCli = { + config: GROK_CLI_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const body = new URLSearchParams({ + client_id: config.clientId, + scope: config.scope, + }); + // Official CLI sends referrer=grok-build + if (config.referrer) body.set("referrer", config.referrer); + + const response = await fetch(config.deviceCodeUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", + }, + body, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Grok CLI device code request failed: ${error}`); + } + + return await response.json(); + }, + pollToken: async (config, deviceCode) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: deviceCode, + client_id: config.clientId, + }), + }); + + let data; + try { + data = await response.json(); + } catch { + const text = await response.text(); + data = { error: "invalid_response", error_description: text }; + } + + // Device flow: 400 + authorization_pending is expected while user authorizes + const pending = + data?.error === "authorization_pending" || + data?.error === "slow_down"; + return { + ok: response.ok || pending, + data, + }; + }, + postExchange: async (tokens) => { + // Best-effort user profile from cli-chat-proxy (non-fatal) + try { + const res = await fetch("https://cli-chat-proxy.grok.com/v1/user", { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", + "x-xai-token-auth": "xai-grok-cli", + "x-grok-client-version": "0.2.93", + }, + }); + if (res.ok) return { user: await res.json() }; + } catch { + /* ignore */ + } + return { user: null }; + }, + mapTokens: (tokens, extra) => { + const email = + decodeXaiIdTokenEmail(tokens.id_token) || + extractEmailFromAccessToken(tokens.access_token) || + extra?.user?.email || + null; + const userId = + extra?.user?.userId || + extra?.user?.principalId || + null; + const displayName = [extra?.user?.firstName, extra?.user?.lastName] + .filter(Boolean) + .join(" ") + .trim() || null; + + const expiresAt = tokens.expires_in + ? new Date(Date.now() + tokens.expires_in * 1000).toISOString() + : null; + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || null, + expiresIn: tokens.expires_in, + // Surface an absolute expiry so the proactive refresh path + // (shouldRefreshCredentials / checkAndRefreshToken) can refresh the + // xAI token before it silently expires ~40-45 min after login. + // Without this, only the reactive 401 path in chatCore would refresh, + // causing intermittent "token expired" failures for Grok CLI. + expiresAt, + scope: tokens.scope, + // Top-level for dashboard connection cards + email: email || undefined, + displayName: displayName || undefined, + // Mirror identity into providerSpecificData so GrokCliExecutor can set + // x-email / x-userid without depending on top-level credential shape. + providerSpecificData: { + authMethod: "device_code", + idToken: tokens.id_token || null, + email: email || null, + userId, + hasGrokCodeAccess: extra?.user?.hasGrokCodeAccess ?? null, + subscriptionTier: extra?.user?.subscriptionTier ?? null, + }, + }; + }, +}; + +export default grokCli; diff --git a/src/lib/oauth/providers/iflow.js b/src/lib/oauth/providers/iflow.js new file mode 100644 index 00000000..7dcb32aa --- /dev/null +++ b/src/lib/oauth/providers/iflow.js @@ -0,0 +1,91 @@ +import { IFLOW_CONFIG } from "../constants/oauth.js"; + +const iflow = { + config: IFLOW_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri, state) => { + const params = new URLSearchParams({ + loginMethod: config.extraParams.loginMethod, + type: config.extraParams.type, + redirect: redirectUri, + state: state, + client_id: config.clientId, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + // Create Basic Auth header + const basicAuth = Buffer.from( + `${config.clientId}:${config.clientSecret}` + ).toString("base64"); + + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + Authorization: `Basic ${basicAuth}`, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: code, + redirect_uri: redirectUri, + client_id: config.clientId, + client_secret: config.clientSecret, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + postExchange: async (tokens) => { + // Fetch user info (MUST succeed to get API key) + const userInfoRes = await fetch( + `${IFLOW_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`, + { + headers: { + Accept: "application/json", + }, + } + ); + + if (!userInfoRes.ok) { + const errorText = await userInfoRes.text(); + throw new Error(`Failed to fetch user info: ${errorText}`); + } + + const result = await userInfoRes.json(); + if (!result.success) { + throw new Error(`User info request failed: ${result.message || 'Unknown error'}`); + } + + const userInfo = result.data || {}; + + // Validate API key (critical for iFlow) + if (!userInfo.apiKey || userInfo.apiKey.trim() === "") { + throw new Error("Empty API key returned from iFlow"); + } + + // Validate email/phone + const email = userInfo.email?.trim() || userInfo.phone?.trim(); + if (!email) { + throw new Error("Missing account email/phone in user info"); + } + + return { userInfo }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + apiKey: extra?.userInfo?.apiKey, + email: extra?.userInfo?.email || extra?.userInfo?.phone, + displayName: extra?.userInfo?.nickname || extra?.userInfo?.name, + }), +}; + +export default iflow; diff --git a/src/lib/oauth/providers/index.js b/src/lib/oauth/providers/index.js new file mode 100644 index 00000000..8449ecef --- /dev/null +++ b/src/lib/oauth/providers/index.js @@ -0,0 +1,241 @@ +// Ensure outbound fetch respects HTTP(S)_PROXY/ALL_PROXY in Node runtime +import "open-sse/index.js"; + +import { generatePKCE } from "../utils/pkce.js"; +import { extractCodexAccountInfo, fetchKiroProfileArn } from "../providerHelpers.js"; + +import claude from "./claude.js"; +import codex from "./codex.js"; +import xai from "./xai.js"; +import grokCli from "./grok-cli.js"; +import geminiCli from "./gemini-cli.js"; +import antigravity from "./antigravity.js"; +import iflow from "./iflow.js"; +import qoder from "./qoder.js"; +import qwen from "./qwen.js"; +import github from "./github.js"; +import kiro from "./kiro.js"; +import cursor from "./cursor.js"; +import kimi from "./kimi.js"; +import kilocode from "./kilocode.js"; +import cline from "./cline.js"; +import clinepass from "./clinepass.js"; +import gitlab from "./gitlab.js"; +import codebuddyCn from "./codebuddy-cn.js"; +import codebuddyIntl from "./codebuddy-intl.js"; +import kimchi from "./kimchi.js"; +import trae from "./trae.js"; +import windsurf from "./windsurf.js"; +import zed from "./zed.js"; + +// Provider configurations +const PROVIDERS = { + claude, + codex, + xai, + "grok-cli": grokCli, + "gemini-cli": geminiCli, + antigravity, + iflow, + qoder, + qwen, + github, + kiro, + cursor, + kimi, + kilocode, + cline, + clinepass, + gitlab, + "codebuddy-cn": codebuddyCn, + "codebuddy-intl": codebuddyIntl, + kimchi, + trae, + windsurf, + zed, +}; + +export { PROVIDERS }; + +// Re-export helpers that other files import from this path +export { extractCodexAccountInfo, fetchKiroProfileArn }; + +/** + * Get provider handler + */ +export function getProvider(name) { + // Legacy kimi-coding → kimi (dual-auth merge) + const key = name === "kimi-coding" ? "kimi" : name; + const provider = PROVIDERS[key]; + if (!provider) { + throw new Error(`Unknown provider: ${name}`); + } + return provider; +} + +/** + * Get all provider names + */ +export function getProviderNames() { + return Object.keys(PROVIDERS); +} + +/** + * Generate auth data for a provider + * @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl) + */ +export async function generateAuthData(providerName, redirectUri, meta) { + const provider = getProvider(providerName); + const config = provider.prepareConfig + ? await provider.prepareConfig(provider.config, meta || {}) + : provider.config; + const { codeVerifier: pkceVerifier, codeChallenge, state: pkceState } = generatePKCE(provider.pkceVerifierBytes); + // Trae uses loginTraceID (set by prepareConfig) as the callback matcher, not PKCE state. + const state = config.loginTraceID || pkceState; + // Zed: codeVerifier carries the encoded RSA private key (from prepareConfig), not a PKCE verifier. + const codeVerifier = config.privateKeyVerifier || pkceVerifier; + + let authUrl; + if (provider.flowType === "device_code") { + // Device code flow doesn't have auth URL upfront + authUrl = null; + } else if (provider.flowType === "authorization_code_pkce") { + authUrl = provider.buildAuthUrl(config, redirectUri, state, codeChallenge, meta || {}); + } else { + authUrl = provider.buildAuthUrl(config, redirectUri, state, undefined, meta || {}); + } + + return { + authUrl, + state, + codeVerifier, + codeChallenge, + redirectUri, + flowType: provider.flowType, + fixedPort: provider.fixedPort, + callbackPath: provider.callbackPath || "/callback", + }; +} + +/** + * Exchange code for tokens + * @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl) + */ +export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state, meta) { + const provider = getProvider(providerName); + const config = provider.prepareConfig + ? await provider.prepareConfig(provider.config, meta || {}) + : provider.config; + + const tokens = await provider.exchangeToken(config, code, redirectUri, codeVerifier, state, meta || {}); + + let extra = null; + if (provider.postExchange) { + extra = await provider.postExchange(tokens); + } + + return provider.mapTokens(tokens, extra); +} + +/** + * Request device code (for device_code flow) + */ +export async function requestDeviceCode(providerName, codeChallenge, options) { + const provider = getProvider(providerName); + if (provider.flowType !== "device_code") { + throw new Error(`Provider ${providerName} does not support device code flow`); + } + return await provider.requestDeviceCode(provider.config, codeChallenge, options || {}); +} + +/** + * Poll for token (for device_code flow) + * @param {string} providerName - Provider name + * @param {string} deviceCode - Device code from requestDeviceCode + * @param {string} codeVerifier - PKCE code verifier (optional for some providers) + * @param {object} extraData - Extra data from device code response (e.g. clientId/clientSecret for Kiro) + */ +export async function pollForToken(providerName, deviceCode, codeVerifier, extraData) { + const provider = getProvider(providerName); + if (provider.flowType !== "device_code") { + throw new Error(`Provider ${providerName} does not support device code flow`); + } + + const result = await provider.pollToken(provider.config, deviceCode, codeVerifier, extraData); + + if (result.ok) { + // For device code flows, success is only when we have an access token + if (result.data.access_token) { + // Call postExchange to get additional data (copilotToken, userInfo, etc.) + let extra = null; + if (provider.postExchange) { + extra = await provider.postExchange(result.data); + } + const tokens = provider.mapTokens(result.data, extra); + // Kiro IDC/Builder-ID tokens lack profileArn; resolve it to avoid 403 + if (providerName === "kiro" && !tokens.providerSpecificData?.profileArn) { + const profileArn = await fetchKiroProfileArn(tokens.accessToken); + if (profileArn) tokens.providerSpecificData.profileArn = profileArn; + } + return { success: true, tokens }; + } else { + // Check if it's still pending authorization + if (result.data.error === 'authorization_pending' || result.data.error === 'slow_down') { + // This is not a failure, just still waiting + return { + success: false, + error: result.data.error, + errorDescription: result.data.error_description || result.data.message, + pending: result.data.error === 'authorization_pending' + }; + } else { + // Actual error + return { + success: false, + error: result.data.error || 'no_access_token', + errorDescription: result.data.error_description || result.data.message || 'No access token received' + }; + } + } + } + + return { success: false, error: result.data.error, errorDescription: result.data.error_description }; +} + +// Run-once guard across the process lifetime +let codexBackfillDone = false; + +// Backfill email + chatgpt account info for existing codex OAuth connections missing them +export async function backfillCodexEmails() { + if (codexBackfillDone) return; + codexBackfillDone = true; + try { + const { getProviderConnections, updateProviderConnection } = await import("@/lib/localDb"); + const connections = await getProviderConnections(); + const targets = connections.filter((c) => { + if (c.provider !== "codex" || c.authType !== "oauth" || !c.idToken) return false; + const hasEmail = !!c.email; + const hasAccountInfo = !!c.providerSpecificData?.chatgptAccountId; + return !hasEmail || !hasAccountInfo; + }); + for (const conn of targets) { + const info = extractCodexAccountInfo(conn.idToken); + if (!info.email && !info.chatgptAccountId) continue; + const patch = {}; + if (!conn.email && info.email) patch.email = info.email; + if (info.chatgptAccountId || info.chatgptPlanType) { + patch.providerSpecificData = { + ...(conn.providerSpecificData || {}), + chatgptAccountId: info.chatgptAccountId, + chatgptPlanType: info.chatgptPlanType, + }; + } + if (Object.keys(patch).length) { + await updateProviderConnection(conn.id, patch); + } + } + } catch (err) { + codexBackfillDone = false; + console.log("backfillCodexEmails failed:", err?.message || err); + } +} diff --git a/src/lib/oauth/providers/kilocode.js b/src/lib/oauth/providers/kilocode.js new file mode 100644 index 00000000..c74d7bbc --- /dev/null +++ b/src/lib/oauth/providers/kilocode.js @@ -0,0 +1,60 @@ +import { KILOCODE_CONFIG } from "../constants/oauth.js"; + +const kilocode = { + config: KILOCODE_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const response = await fetch(config.initiateUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (!response.ok) { + if (response.status === 429) { + throw new Error("Too many pending authorization requests. Please try again later."); + } + const error = await response.text(); + throw new Error(`Device auth initiation failed: ${error}`); + } + const data = await response.json(); + return { + device_code: data.code, + user_code: data.code, + verification_uri: data.verificationUrl, + verification_uri_complete: data.verificationUrl, + expires_in: data.expiresIn || 300, + interval: 3, + }; + }, + pollToken: async (config, deviceCode) => { + const response = await fetch(`${config.pollUrlBase}/${deviceCode}`); + if (response.status === 202) return { ok: false, data: { error: "authorization_pending" } }; + if (response.status === 403) return { ok: false, data: { error: "access_denied", error_description: "Authorization denied by user" } }; + if (response.status === 410) return { ok: false, data: { error: "expired_token", error_description: "Authorization code expired" } }; + if (!response.ok) return { ok: false, data: { error: "poll_failed", error_description: `Poll failed: ${response.status}` } }; + const data = await response.json(); + if (data.status === "approved" && data.token) { + // Fetch profile to get orgId for X-Kilocode-OrganizationID header + let orgId = null; + try { + const profileRes = await fetch(`${config.apiBaseUrl}/api/profile`, { + headers: { "Authorization": `Bearer ${data.token}` } + }); + if (profileRes.ok) { + const profile = await profileRes.json(); + orgId = profile.organizations?.[0]?.id || null; + } + } catch {} + return { ok: true, data: { access_token: data.token, _userEmail: data.userEmail, _orgId: orgId } }; + } + return { ok: false, data: { error: "authorization_pending" } }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: null, + expiresIn: null, + email: tokens._userEmail, + ...(tokens._orgId ? { providerSpecificData: { orgId: tokens._orgId } } : {}), + }), +}; + +export default kilocode; diff --git a/src/lib/oauth/providers/kimchi.js b/src/lib/oauth/providers/kimchi.js new file mode 100644 index 00000000..7920f85e --- /dev/null +++ b/src/lib/oauth/providers/kimchi.js @@ -0,0 +1,75 @@ +import { KIMCHI_CONFIG } from "../constants/oauth.js"; + +const kimchi = { + config: KIMCHI_CONFIG, + flowType: "browser_token", + buildAuthUrl: (config, redirectUri, state) => { + const baseUrl = (config.webAppUrl || "https://app.kimchi.dev").replace(/\/+$/, ""); + const params = new URLSearchParams({ + callback: redirectUri, + state, + }); + return `${baseUrl}/cli-auth?${params.toString()}`; + }, + exchangeToken: async (config, token) => { + const accessToken = String(token || "").trim(); + if (!accessToken) { + throw new Error("Missing Kimchi token"); + } + + const validationUrl = config.validationUrl || "https://api.cast.ai/v1/llm/openai/supported-providers"; + const validationRes = await fetch(validationUrl, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }); + if (!validationRes.ok) { + throw new Error(`Kimchi token validation failed: ${validationRes.status}`); + } + + let userInfo = {}; + if (config.userInfoUrl) { + try { + const userRes = await fetch(config.userInfoUrl, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }); + if (userRes.ok) { + userInfo = await userRes.json(); + } + } catch { + userInfo = {}; + } + } + + return { + access_token: accessToken, + token_type: "Bearer", + _kimchiUser: userInfo, + }; + }, + mapTokens: (tokens) => { + const user = tokens._kimchiUser || {}; + const userId = user.id ? String(user.id) : ""; + const username = user.username || ""; + const email = user.email || (userId ? `kimchi-user-${userId}` : null); + return { + accessToken: tokens.access_token, + refreshToken: null, + email, + displayName: user.name || username || null, + providerSpecificData: { + authMethod: "browser_token", + userId, + username, + }, + }; + }, +}; + +export default kimchi; diff --git a/src/lib/oauth/providers/kimi.js b/src/lib/oauth/providers/kimi.js new file mode 100644 index 00000000..613fc4e3 --- /dev/null +++ b/src/lib/oauth/providers/kimi.js @@ -0,0 +1,81 @@ +import crypto from "crypto"; +import { KIMI_CONFIG } from "../constants/oauth.js"; + +// Kimi Code device flow (CLIProxyAPI internal/auth/kimi). Id is `kimi`; +// `kimi-coding` remains an alias key so old UI/API routes still resolve. +const kimi = { + config: KIMI_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const { buildKimiHeaders } = await import("open-sse/config/appConstants.js"); + const deviceId = crypto.randomUUID(); + const headers = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + ...buildKimiHeaders(deviceId), + }; + const response = await fetch(config.deviceCodeUrl, { + method: "POST", + headers, + body: new URLSearchParams({ client_id: config.clientId }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`Device code request failed: ${error}`); + } + const data = await response.json(); + const authorizeDeviceUrl = config.authorizeDeviceUrl || "https://www.kimi.com/code/authorize_device"; + return { + device_code: data.device_code, + user_code: data.user_code, + verification_uri: data.verification_uri || authorizeDeviceUrl, + verification_uri_complete: + data.verification_uri_complete || + `${authorizeDeviceUrl}?user_code=${data.user_code}`, + expires_in: data.expires_in, + interval: data.interval || 5, + _kimiDeviceId: deviceId, + }; + }, + pollToken: async (config, deviceCode, _codeVerifier, extraData) => { + const { buildKimiHeaders } = await import("open-sse/config/appConstants.js"); + const deviceId = extraData?._kimiDeviceId; + const headers = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + ...buildKimiHeaders(deviceId), + }; + const response = await fetch(config.tokenUrl, { + method: "POST", + headers, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: config.clientId, + device_code: deviceCode, + }), + }); + let data; + try { + data = await response.json(); + } catch { + data = { error: "invalid_response", error_description: "non-json token response" }; + } + // CLIProxyAPI: Kimi returns 200 for pending states with error field + if (data.error === "authorization_pending" || data.error === "slow_down") { + return { ok: true, data }; + } + if (data.access_token && deviceId) data._kimiDeviceId = deviceId; + return { ok: response.ok || !!data.access_token || !!data.error, data }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + providerSpecificData: { + authMethod: "device_code", + ...(tokens._kimiDeviceId ? { deviceId: tokens._kimiDeviceId } : {}), + }, + }), +}; + +export default kimi; diff --git a/src/lib/oauth/providers/kiro.js b/src/lib/oauth/providers/kiro.js new file mode 100644 index 00000000..993cabeb --- /dev/null +++ b/src/lib/oauth/providers/kiro.js @@ -0,0 +1,151 @@ +import { KIRO_CONFIG, assertValidAwsRegion } from "../constants/oauth.js"; +import { extractEmailFromAccessToken } from "../providerHelpers.js"; + +const kiro = { + config: KIRO_CONFIG, + flowType: "device_code", + // Kiro uses AWS SSO OIDC - requires client registration first + requestDeviceCode: async (config, codeChallenge, options = {}) => { + const trimmedRegion = typeof options.region === "string" ? options.region.trim() : ""; + const region = trimmedRegion || "us-east-1"; + assertValidAwsRegion(region); + const trimmedStartUrl = typeof options.startUrl === "string" ? options.startUrl.trim() : ""; + const startUrl = trimmedStartUrl || config.startUrl; + const authMethod = options.authMethod === "idc" ? "idc" : "builder-id"; + const registerClientUrl = `https://oidc.${region}.amazonaws.com/client/register`; + const deviceAuthUrl = `https://oidc.${region}.amazonaws.com/device_authorization`; + + // Step 1: Register client with AWS SSO OIDC + const registerRes = await fetch(registerClientUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + clientName: config.clientName, + clientType: config.clientType, + scopes: config.scopes, + grantTypes: config.grantTypes, + issuerUrl: config.issuerUrl, + }), + }); + + if (!registerRes.ok) { + const error = await registerRes.text(); + throw new Error(`Client registration failed: ${error}`); + } + + const clientInfo = await registerRes.json(); + + // Step 2: Request device authorization + const deviceRes = await fetch(deviceAuthUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + clientId: clientInfo.clientId, + clientSecret: clientInfo.clientSecret, + startUrl, + }), + }); + + if (!deviceRes.ok) { + const error = await deviceRes.text(); + throw new Error(`Device authorization failed: ${error}`); + } + + const deviceData = await deviceRes.json(); + + // Return combined data for polling + return { + device_code: deviceData.deviceCode, + user_code: deviceData.userCode, + verification_uri: deviceData.verificationUri, + verification_uri_complete: deviceData.verificationUriComplete, + expires_in: deviceData.expiresIn, + interval: deviceData.interval || 5, + // Store client credentials for token exchange + _clientId: clientInfo.clientId, + _clientSecret: clientInfo.clientSecret, + _region: region, + _authMethod: authMethod, + _startUrl: startUrl, + }; + }, + pollToken: async (config, deviceCode, codeVerifier, extraData) => { + const region = extraData?._region || "us-east-1"; + assertValidAwsRegion(region); + const tokenUrl = `https://oidc.${region}.amazonaws.com/token`; + const response = await fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + clientId: extraData?._clientId, + clientSecret: extraData?._clientSecret, + deviceCode: deviceCode, + grantType: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + let data; + try { + data = await response.json(); + } catch (e) { + const text = await response.text(); + data = { error: "invalid_response", error_description: text }; + } + + // AWS SSO OIDC returns camelCase + if (data.accessToken) { + return { + ok: true, + data: { + access_token: data.accessToken, + refresh_token: data.refreshToken, + expires_in: data.expiresIn, + profile_arn: data?.profileArn || null, + // Store client credentials for refresh + _clientId: extraData?._clientId, + _clientSecret: extraData?._clientSecret, + _region: extraData?._region, + _authMethod: extraData?._authMethod, + _startUrl: extraData?._startUrl, + }, + }; + } + + return { + ok: false, + data: { + error: data.error || "authorization_pending", + error_description: data.error_description || data.message, + }, + }; + }, + mapTokens: (tokens) => { + const email = extractEmailFromAccessToken(tokens.access_token); + const mapped = { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + email, + providerSpecificData: { + profileArn: tokens?.profile_arn || null, + clientId: tokens._clientId, + clientSecret: tokens._clientSecret, + region: tokens._region || "us-east-1", + authMethod: tokens._authMethod || "builder-id", + startUrl: tokens._startUrl || KIRO_CONFIG.startUrl, + }, + }; + return mapped; + }, +}; + +export default kiro; diff --git a/src/lib/oauth/providers/qoder.js b/src/lib/oauth/providers/qoder.js new file mode 100644 index 00000000..fa46a92d --- /dev/null +++ b/src/lib/oauth/providers/qoder.js @@ -0,0 +1,102 @@ +import { QODER_CONFIG } from "../constants/oauth.js"; + +const qoder = { + config: QODER_CONFIG, + flowType: "device_code", + // Qoder uses a custom device flow: PKCE + nonce + machine_id are generated + // locally, the user lands on qoder.com/device/selectAccounts in the + // browser, and we poll openapi.qoder.sh until a `dt-...` token appears. + requestDeviceCode: async (config) => { + const { QoderService } = await import("@/lib/oauth/services/qoder"); + const flow = new QoderService().initiateDeviceFlow(); + // Match the device_code shape the rest of the OAuthModal expects + // (device_code, user_code, verification_uri[_complete], interval). + // The poll endpoint identifies us by nonce+verifier, not by a + // server-issued device_code, so we plumb our own values through: + // device_code = nonce (modal forwards as deviceCode on poll) + // codeVerifier = our PKCE verifier (route forwards as codeVerifier) + return { + device_code: flow.nonce, + user_code: flow.nonce.slice(0, 8).toUpperCase(), + verification_uri: config.loginUrl, + verification_uri_complete: flow.verificationUriComplete, + expires_in: 300, + interval: 2, + codeVerifier: flow.codeVerifier, + _qoderNonce: flow.nonce, + _qoderMachineId: flow.machineId, + }; + }, + pollToken: async (config, deviceCode, codeVerifier, extraData) => { + const { QoderService } = await import("@/lib/oauth/services/qoder"); + const svc = new QoderService(); + const nonce = deviceCode || extraData?._qoderNonce; + const verifier = codeVerifier || extraData?._qoderVerifier; + if (!nonce || !verifier) { + return { + ok: false, + data: { error: "invalid_request", error_description: "Missing nonce/verifier" }, + }; + } + let result; + try { + result = await svc.pollDeviceToken({ nonce, codeVerifier: verifier }); + } catch (err) { + return { + ok: false, + data: { error: "poll_failed", error_description: err.message }, + }; + } + if (result.status === "pending") { + return { ok: false, data: { error: "authorization_pending" } }; + } + // Best-effort profile lookup so we have a name/email to display. + const userInfo = await svc.fetchUserInfo(result.accessToken); + // expireTime is a Unix-ms timestamp from QoderService.parseExpiry, + // which already falls back to "now + 30 days" when the upstream + // omits expiry. Floor to a sane minimum (1 day) so a stale or + // skewed upstream timestamp doesn't truncate the stored token below + // something useful. + const minSeconds = 24 * 60 * 60; + const remainingSeconds = Math.floor((result.expireTime - Date.now()) / 1000); + const expiresIn = Math.max(minSeconds, remainingSeconds); + return { + ok: true, + data: { + access_token: result.accessToken, + refresh_token: result.refreshToken, + expires_in: expiresIn, + _qoderUserId: result.userId, + _qoderMachineId: extraData?._qoderMachineId || "", + _qoderName: userInfo.name, + _qoderEmail: userInfo.email, + _qoderOrganizationId: userInfo.organizationId, + }, + }; + }, + mapTokens: (tokens) => { + const rawEmail = (tokens._qoderEmail || "").trim(); + const displayName = (tokens._qoderName || "").trim() || null; + const userId = tokens._qoderUserId || ""; + // Dedup in createProviderConnection requires a non-empty email. When + // fetchUserInfo silently fails (returns ""), fall back to a stable + // synthetic identifier derived from userId so re-logins update the + // existing row instead of accumulating "Account N" duplicates. + const email = rawEmail || (userId ? `qoder-user-${userId}` : null); + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || null, + expiresIn: tokens.expires_in, + email, + displayName, + providerSpecificData: { + authMethod: "device", + userId, + machineId: tokens._qoderMachineId || "", + organizationId: tokens._qoderOrganizationId || "", + }, + }; + }, +}; + +export default qoder; diff --git a/src/lib/oauth/providers/qwen.js b/src/lib/oauth/providers/qwen.js new file mode 100644 index 00000000..e8048654 --- /dev/null +++ b/src/lib/oauth/providers/qwen.js @@ -0,0 +1,56 @@ +import { QWEN_CONFIG } from "../constants/oauth.js"; + +const qwen = { + config: QWEN_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config, codeChallenge) => { + const response = await fetch(config.deviceCodeUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: config.clientId, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Device code request failed: ${error}`); + } + + return await response.json(); + }, + pollToken: async (config, deviceCode, codeVerifier) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: config.clientId, + device_code: deviceCode, + code_verifier: codeVerifier, + }), + }); + + return { + ok: response.ok, + data: await response.json(), + }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + providerSpecificData: { resourceUrl: tokens.resource_url }, + }), +}; + +export default qwen; diff --git a/src/lib/oauth/providers/trae.js b/src/lib/oauth/providers/trae.js new file mode 100644 index 00000000..5e7df52b --- /dev/null +++ b/src/lib/oauth/providers/trae.js @@ -0,0 +1,263 @@ +import crypto from "crypto"; +import { TRAE_CONFIG } from "../constants/oauth.js"; +import { extractJsonPath } from "./_shared.js"; + +// ─────────────────────────────────────────────────────────────────────────── +// Trae (ByteDance marscode) OAuth helpers +// ─────────────────────────────────────────────────────────────────────────── + +// Per-login device context. No IDE access in 9router, so use stable defaults. +function buildTraeDeviceContext() { + return { + plugin_version: TRAE_CONFIG.defaultPluginVersion, + machine_id: crypto.randomUUID(), + device_id: TRAE_CONFIG.defaultDeviceId, + x_device_brand: "unknown", + x_device_type: "unknown", + x_os_version: "unknown", + x_env: "", + x_app_version: TRAE_CONFIG.defaultAppVersion, + x_app_type: TRAE_CONFIG.defaultAppType, + }; +} + +// POST GetLoginGuidance → { Result: { LoginHost } } +async function fetchTraeLoginGuidance(loginTraceId) { + const body = JSON.stringify({ loginTraceID: loginTraceId, login_trace_id: loginTraceId }); + let lastErr = "no successful response"; + for (const url of TRAE_CONFIG.loginGuidanceUrls) { + try { + const res = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": TRAE_CONFIG.userAgent, + }, + body, + }); + if (!res.ok) { lastErr = `${url} HTTP ${res.status}`; continue; } + const data = await res.json(); + const loginHost = extractJsonPath(data, [ + ["Result", "LoginHost"], ["Result", "loginHost"], ["Result", "LoginURL"], + ["result", "loginHost"], ["data", "Result", "LoginHost"], ["data", "loginHost"], + ["LoginHost"], ["loginHost"], + ]); + if (loginHost) return loginHost; + lastErr = `${url} missing LoginHost`; + } catch (e) { lastErr = `${url} ${e.message}`; } + } + throw new Error(`Trae GetLoginGuidance failed: ${lastErr}`); +} + +// Build the browser verification URL the user opens to sign in. +function buildTraeVerificationUrl(loginHost, loginTraceId, callbackUrl, ctx) { + const url = new URL(loginHost.startsWith("http") ? loginHost : `https://${loginHost.replace(/^\/+/, "")}`); + url.pathname = TRAE_CONFIG.authorizationPath; + const p = new URLSearchParams(); + p.set("login_version", "1"); + p.set("auth_from", "trae"); + p.set("login_channel", "native_ide"); + p.set("plugin_version", ctx.plugin_version); + p.set("auth_type", "local"); + p.set("client_id", TRAE_CONFIG.clientId); + p.set("redirect", "0"); + p.set("login_trace_id", loginTraceId); + p.set("auth_callback_url", callbackUrl); + p.set("machine_id", ctx.machine_id); + p.set("device_id", ctx.device_id); + p.set("x_device_id", ctx.device_id); + p.set("x_machine_id", ctx.machine_id); + p.set("x_device_brand", ctx.x_device_brand); + p.set("x_device_type", ctx.x_device_type); + p.set("x_os_version", ctx.x_os_version); + p.set("x_env", ctx.x_env); + p.set("x_app_version", ctx.x_app_version); + p.set("x_app_type", ctx.x_app_type); + url.search = p.toString(); + return url.toString(); +} + +// Parse the Trae OAuth callback (query string or full URL). +// Expected: ?isRedirect=true&refreshToken=...&loginHost=...[&x-cloudide-token=...] +function parseTraeCallback(raw) { + const text = String(raw || "").trim(); + let queryStr = text; + if (text.includes("?")) queryStr = text.slice(text.indexOf("?") + 1); + if (text.startsWith("#")) queryStr = text.slice(1); + const params = Object.fromEntries(new URLSearchParams(queryStr)); + const pick = (keys) => { + for (const k of keys) { const v = params[k]; if (v && String(v).trim()) return String(v).trim(); } + return null; + }; + const err = pick(["error", "error_code", "errorCode"]); + if (err) { + const desc = pick(["error_description", "error_desc", "message"]); + throw new Error(desc ? `Trae auth failed: ${err} (${desc})` : `Trae auth failed: ${err}`); + } + const refreshToken = pick(["refreshToken", "refresh_token", "RefreshToken"]); + if (!refreshToken) throw new Error("Trae callback missing refreshToken"); + const loginHost = pick(["loginHost", "login_host", "LoginHost", "host", "consoleHost"]); + if (!loginHost) throw new Error("Trae callback missing loginHost"); + const cloudideToken = pick(["x-cloudide-token", "xCloudideToken", "accessToken", "access_token", "token"]); + return { refreshToken, loginHost, cloudideToken }; +} + +// Allowed API origins for ExchangeToken/GetUserInfo — hardcoded HTTPS allowlist only. +// loginHost from the callback is intentionally NOT honored (SSRF guard: a callback +// attacker could otherwise point this at internal hosts/cloud metadata). +function traeApiOrigins() { + return [...TRAE_CONFIG.apiOrigins]; +} + +// POST ExchangeToken {ClientID, RefreshToken, ClientSecret, UserID} → {Result:{AccessToken,RefreshToken,ExpiresAt}} +async function fetchTraeExchangeToken(refreshToken, cloudideToken) { + const body = JSON.stringify({ + ClientID: TRAE_CONFIG.clientId, + RefreshToken: refreshToken, + ClientSecret: TRAE_CONFIG.clientSecret, + UserID: "", + }); + let lastErr = "no successful response"; + for (const origin of traeApiOrigins()) { + const url = `${origin.replace(/\/$/, "")}${TRAE_CONFIG.exchangeTokenPath}`; + try { + const headers = { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": TRAE_CONFIG.userAgent, + }; + if (cloudideToken) headers["x-cloudide-token"] = cloudideToken; + const res = await fetch(url, { method: "POST", headers, body }); + const text = await res.text(); + if (!res.ok) { lastErr = `${url} HTTP ${res.status}`; continue; } + let data; try { data = JSON.parse(text); } catch { lastErr = `${url} invalid JSON`; continue; } + const accessToken = extractJsonPath(data, [ + ["Result", "AccessToken"], ["Result", "accessToken"], ["result", "access_token"], ["accessToken"], + ]); + if (!accessToken) { + const msg = extractJsonPath(data, [["message"], ["msg"], ["error"], ["Result", "Message"]]) || "missing AccessToken"; + lastErr = `${url} ${msg}`; + continue; + } + return { + accessToken, + refreshToken: extractJsonPath(data, [["Result", "RefreshToken"], ["result", "refresh_token"], ["refreshToken"]]) || refreshToken, + expiresIn: null, // ExchangeToken returns ExpiresAt (absolute), converted below + expiresAt: extractJsonPath(data, [["Result", "ExpiresAt"], ["Result", "expiresAt"], ["result", "expires_at"], ["expiresAt"]]), + }; + } catch (e) { lastErr = `${url} ${e.message}`; } + } + throw new Error(`Trae ExchangeToken failed: ${lastErr}`); +} + +// POST GetUserInfo with x-cloudide-token → identity fields used by SOLO common_params. +async function fetchTraeUserInfo(accessToken) { + for (const origin of traeApiOrigins()) { + const url = `${origin.replace(/\/$/, "")}${TRAE_CONFIG.getUserInfoPath}`; + try { + const res = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": TRAE_CONFIG.userAgent, + "x-cloudide-token": accessToken, + }, + body: JSON.stringify({}), + }); + if (!res.ok) continue; + const data = await res.json(); + return { + email: extractJsonPath(data, [ + ["Result", "NonPlainTextEmail"], ["Result", "Email"], ["Result", "email"], + ["email"], ["data", "email"], + ]), + name: extractJsonPath(data, [ + ["Result", "ScreenName"], ["Result", "Nickname"], ["Result", "Name"], + ["result", "nickname"], ["nickname"], ["name"], + ]), + aiRegion: extractJsonPath(data, [["Result", "AIRegion"], ["Result", "aiRegion"], ["aiRegion"]]), + region: extractJsonPath(data, [["Result", "Region"], ["Result", "region"], ["region"]]), + tenant: extractJsonPath(data, [["Result", "TenantID"], ["Result", "tenantId"], ["tenantId"]]), + userId: extractJsonPath(data, [["Result", "UserID"], ["Result", "userId"], ["userId"]]), + }; + } catch { /* try next origin */ } + } + return { email: null, name: null }; +} + +// Map AIRegion (e.g. "SG", "US") → SOLO scope used in common_params. +function traeScopeForRegion(aiRegion) { + const r = (aiRegion || "").toLowerCase(); + if (r === "sg" || r.includes("singapore")) return "marscode-sg"; + if (r === "cn" || r.includes("cn") || r.includes("china")) return "marscode-cn"; + return "marscode-us"; +} + +// Trae — browser OAuth: GetLoginGuidance → verification URL +// → local callback (refreshToken+loginHost) → ExchangeToken → GetUserInfo. +// state === config.loginTraceID so the proxy can match the callback. +const trae = { + config: TRAE_CONFIG, + flowType: "authorization_code", + callbackPath: TRAE_CONFIG.callbackPath, + prepareConfig: async (config) => { + const loginTraceID = crypto.randomUUID(); + const loginHost = await fetchTraeLoginGuidance(loginTraceID); + return { ...config, loginTraceID, loginHost }; + }, + buildAuthUrl: (config, redirectUri, state) => { + const ctx = buildTraeDeviceContext(); + const traceId = config.loginTraceID || state; + return buildTraeVerificationUrl(config.loginHost, traceId, redirectUri, ctx); + }, + exchangeToken: async (config, code) => { + const trimmed = String(code || "").trim(); + // Paste-token mode: raw Cloud-IDE-JWT (no refresh exchange) + const looksCallback = /[?=&]/.test(trimmed) && (trimmed.includes("refreshToken") || trimmed.includes("refresh_token")); + if (!looksCallback) { + // Strip "Cloud-IDE-JWT " / "Bearer " prefix users paste from the Authorization header + const clean = trimmed.replace(/^(Cloud-IDE-JWT|Bearer)\s+/i, ""); + return { accessToken: clean, refreshToken: null, expiresIn: TRAE_CONFIG.tokenLifetimeDays * 24 * 60 * 60, _authMethod: "imported" }; + } + const { refreshToken, cloudideToken } = parseTraeCallback(trimmed); + return { ...(await fetchTraeExchangeToken(refreshToken, cloudideToken)), _authMethod: "oauth" }; + }, + postExchange: async (tokens) => { + const userInfo = await fetchTraeUserInfo(tokens.accessToken); + return { userInfo }; + }, + mapTokens: (tokens, extra) => { + const expiresIn = tokens.expiresIn + || (tokens.expiresAt ? Math.max(60, Number(tokens.expiresAt) - Math.floor(Date.now() / 1000)) : TRAE_CONFIG.tokenLifetimeDays * 24 * 60 * 60); + const ui = extra?.userInfo || {}; + const aiRegion = ui.aiRegion || "US-East"; + // SOLO common_params defaults — identity fields web_id/biz_user_id are not + // exposed by GetUserInfo; empty strings are accepted upstream (verified). + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresIn, + email: ui.email || undefined, + displayName: ui.name || undefined, + providerSpecificData: { + authMethod: tokens._authMethod || "oauth", + aiRegion, + region: ui.region || aiRegion, + tenant: ui.tenant || "marscode", + userId: ui.userId || "", + scope: traeScopeForRegion(aiRegion), + webId: "", + bizUserId: "", + userUniqueId: "", + appLanguage: "en", + appVersion: TRAE_CONFIG.defaultAppVersion, + userRegion: aiRegion === "SG" ? "SG" : "US", + userIdentity: "Free", + }, + }; + }, +}; + +export default trae; diff --git a/src/lib/oauth/providers/windsurf.js b/src/lib/oauth/providers/windsurf.js new file mode 100644 index 00000000..b7635832 --- /dev/null +++ b/src/lib/oauth/providers/windsurf.js @@ -0,0 +1,132 @@ +import { WINDSURF_CONFIG } from "../constants/oauth.js"; +import { extractJsonPath } from "./_shared.js"; + +// ─────────────────────────────────────────────────────────────────────────── +// Windsurf OAuth helpers +// ─────────────────────────────────────────────────────────────────────────── + +async function windsurfSeatRequest(baseUrl, path, body) { + const url = `${baseUrl.replace(/\/$/, "")}${path}`; + const res = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": WINDSURF_CONFIG.userAgent, + }, + body: JSON.stringify(body), + }); + const text = await res.text(); + if (!res.ok) throw new Error(`Windsurf ${path} HTTP ${res.status}: ${text.slice(0, 200)}`); + try { return JSON.parse(text); } catch { throw new Error(`Windsurf ${path} invalid JSON`); } +} + +// Parse Windsurf callback (query string or full URL): ?access_token=...&state=... +function parseWindsurfCallback(raw, expectedState) { + const text = String(raw || "").trim(); + let queryStr = text; + if (text.includes("?")) queryStr = text.slice(text.indexOf("?") + 1); + if (text.startsWith("#")) queryStr = text.slice(1); + const params = Object.fromEntries(new URLSearchParams(queryStr)); + const pick = (keys) => { + for (const k of keys) { const v = params[k]; if (v && String(v).trim()) return String(v).trim(); } + return null; + }; + const err = pick(["error"]); + if (err) { + const desc = pick(["error_description"]); + throw new Error(desc ? `Windsurf auth failed: ${err} (${desc})` : `Windsurf auth failed: ${err}`); + } + const accessToken = pick(["access_token", "token"]); + if (!accessToken) throw new Error("Windsurf callback missing access_token"); + const state = pick(["state"]); + if (expectedState && state && state !== expectedState) { + throw new Error("Windsurf callback state mismatch"); + } + return { firebaseIdToken: accessToken }; +} + +// POST RegisterUser {firebase_id_token} → {apiKey, apiServerUrl, name} +async function fetchWindsurfRegisterUser(firebaseIdToken) { + const data = await windsurfSeatRequest(WINDSURF_CONFIG.registerApiBaseUrl, WINDSURF_CONFIG.registerPath, { + firebase_id_token: firebaseIdToken, + }); + const apiKey = extractJsonPath(data, [["apiKey"], ["api_key"]]); + if (!apiKey) throw new Error("Windsurf RegisterUser missing apiKey"); + const apiServerUrl = extractJsonPath(data, [["apiServerUrl"], ["api_server_url"]]) || WINDSURF_CONFIG.defaultApiServerUrl; + const name = extractJsonPath(data, [["name"]]); + return { apiKey, apiServerUrl, name }; +} + +// Best-effort: GetOneTimeAuthToken → GetCurrentUser → email/name. +async function fetchWindsurfUserInfo(apiServerUrl, firebaseIdToken) { + try { + const authRes = await windsurfSeatRequest(apiServerUrl, WINDSURF_CONFIG.oneTimeAuthPath, { firebaseIdToken }); + const authToken = extractJsonPath(authRes, [["authToken"], ["auth_token"]]); + if (!authToken) return { email: null, name: null }; + const userRes = await windsurfSeatRequest(apiServerUrl, WINDSURF_CONFIG.currentUserPath, { + authToken, + includeSubscription: true, + }); + const user = userRes.user || userRes; + return { + email: extractJsonPath(user, [["email"]]), + name: extractJsonPath(user, [["name"]]), + }; + } catch { return { email: null, name: null }; } +} + +// Windsurf — browser OAuth: windsurf.com/signin → +// local callback (firebase JWT) → RegisterUser → apiKey (used as credential). +const windsurf = { + config: WINDSURF_CONFIG, + flowType: "authorization_code", + callbackPath: WINDSURF_CONFIG.callbackPath, + buildAuthUrl: (config, redirectUri, state) => { + const params = new URLSearchParams({ + response_type: "token", + client_id: config.clientId, + redirect_uri: redirectUri, + state, + prompt: "login", + redirect_parameters_type: "query", + workflow: "onboarding", + }); + return `${config.authBaseUrl}${config.signInPath}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri, codeVerifier, state) => { + const trimmed = String(code || "").trim(); + const looksCallback = trimmed.includes("?") || trimmed.includes("access_token="); + if (!looksCallback) { + // Paste-token mode: sk-ws-... apiKey OR firebase JWT (eyJ...). Strip "Bearer " if pasted. + const clean = trimmed.replace(/^Bearer\s+/i, ""); + if (clean.startsWith("sk-ws-")) { + return { accessToken: clean, refreshToken: null, expiresIn: null, apiServerUrl: config.defaultApiServerUrl, firebaseIdToken: null, _authMethod: "imported" }; + } + const reg = await fetchWindsurfRegisterUser(clean); + return { accessToken: reg.apiKey, refreshToken: null, expiresIn: null, apiServerUrl: reg.apiServerUrl, firebaseIdToken: clean, _authMethod: "imported" }; + } + const { firebaseIdToken } = parseWindsurfCallback(trimmed, state); + const reg = await fetchWindsurfRegisterUser(firebaseIdToken); + return { accessToken: reg.apiKey, refreshToken: null, expiresIn: null, apiServerUrl: reg.apiServerUrl, firebaseIdToken, _authMethod: "oauth" }; + }, + postExchange: async (tokens) => { + if (!tokens.firebaseIdToken) return { userInfo: { email: null, name: null } }; + const info = await fetchWindsurfUserInfo(tokens.apiServerUrl, tokens.firebaseIdToken); + return { userInfo: info }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.accessToken, + refreshToken: null, + expiresIn: null, + email: extra?.userInfo?.email || undefined, + displayName: extra?.userInfo?.name || undefined, + providerSpecificData: { + authMethod: tokens._authMethod || "oauth", + apiServerUrl: tokens.apiServerUrl, + firebaseIdToken: tokens.firebaseIdToken, + }, + }), +}; + +export default windsurf; diff --git a/src/lib/oauth/providers/xai.js b/src/lib/oauth/providers/xai.js new file mode 100644 index 00000000..6cf8b76d --- /dev/null +++ b/src/lib/oauth/providers/xai.js @@ -0,0 +1,96 @@ +import crypto from "crypto"; +import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "../constants/xai.js"; +import { validateXaiOAuthEndpoint, decodeXaiIdTokenEmail } from "../providerHelpers.js"; + +// Inlined from services/xai.js to keep web route bundle free of `open` (CLI-only) package +let cachedXaiDiscovery = null; + +async function discoverXaiEndpoints() { + if (cachedXaiDiscovery) return cachedXaiDiscovery; + try { + const res = await fetch(XAI_CONFIG.discoveryUrl, { headers: { Accept: "application/json" } }); + if (res.ok) { + const data = await res.json(); + cachedXaiDiscovery = { + authorizeUrl: validateXaiOAuthEndpoint(data.authorization_endpoint, "authorization_endpoint"), + tokenUrl: validateXaiOAuthEndpoint(data.token_endpoint, "token_endpoint"), + }; + return cachedXaiDiscovery; + } + } catch { /* fall through to static fallback */ } + cachedXaiDiscovery = { authorizeUrl: XAI_CONFIG.authorizeUrl, tokenUrl: XAI_CONFIG.tokenUrl }; + return cachedXaiDiscovery; +} + +const xai = { + config: XAI_CONFIG, + flowType: "authorization_code_pkce", + fixedPort: XAI_CONFIG.loopbackPort, + callbackPath: XAI_CONFIG.callbackPath, + pkceVerifierBytes: XAI_PKCE_VERIFIER_BYTES, + prepareConfig: async (config) => { + const endpoints = await discoverXaiEndpoints(); + return { + ...config, + authorizeUrl: endpoints.authorizeUrl, + tokenUrl: endpoints.tokenUrl, + }; + }, + buildAuthUrl: (config, redirectUri, state, codeChallenge) => { + // Mirror CLIProxyAPI BuildAuthorizeURL: includes nonce, plan, referrer + const nonce = crypto.randomBytes(16).toString("hex"); + const params = { + response_type: "code", + client_id: config.clientId, + redirect_uri: redirectUri, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + state, + nonce, + plan: "generic", + referrer: "cli-proxy-api", + }; + const qs = Object.entries(params) + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join("&"); + return `${config.authorizeUrl}?${qs}`; + }, + exchangeToken: async (config, code, redirectUri, codeVerifier) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`xAI token exchange failed: ${error}`); + } + return await response.json(); + }, + mapTokens: (tokens) => { + const mapped = { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + }; + const email = decodeXaiIdTokenEmail(tokens.id_token); + if (email) mapped.email = email; + if (tokens.id_token) { + mapped.providerSpecificData = { idToken: tokens.id_token }; + } + return mapped; + }, +}; + +export default xai; diff --git a/src/lib/oauth/providers/zed.js b/src/lib/oauth/providers/zed.js new file mode 100644 index 00000000..3343976a --- /dev/null +++ b/src/lib/oauth/providers/zed.js @@ -0,0 +1,62 @@ +import { ZED_HOSTED_CONFIG } from "../constants/oauth.js"; +import { + createZedNativeAuthData, + parseZedCallbackPayload, + decryptZedAccessToken, + fetchZedAuthenticatedUser, + resolveZedOrganizationId, +} from "open-sse/shared/zedAuth.js"; + +// Zed — RSA keypair native-app flow (NOT OAuth). prepareConfig generates a fresh +// keypair; buildAuthUrl returns the native_app_signin URL; exchangeToken decrypts +// the RSA-encrypted access token from the local callback. +const zed = { + config: ZED_HOSTED_CONFIG, + flowType: "authorization_code", + callbackPath: "/", + prepareConfig: async (config, meta) => { + // native_app_port is the local callback port (passed via meta from start-proxy). + const nativeAppPort = Number(meta?.nativeAppPort) || ZED_HOSTED_CONFIG.defaultNativeAppPort; + const auth = createZedNativeAuthData(config, { nativeAppPort }); + return { ...config, ...auth }; + }, + buildAuthUrl: (config, redirectUri, state) => config.authUrl, + exchangeToken: async (config, code, redirectUri, codeVerifier, state) => { + // code = raw callback URL/query; codeVerifier = encoded private key verifier. + const { userId, encryptedAccessToken } = parseZedCallbackPayload(code); + const accessToken = decryptZedAccessToken(encryptedAccessToken, codeVerifier); + return { accessToken, userId, systemId: config.systemId }; + }, + postExchange: async (tokens) => { + const credentials = { + accessToken: tokens.accessToken, + providerSpecificData: { userId: tokens.userId, systemId: tokens.systemId }, + }; + let userInfo = null; + try { + userInfo = await fetchZedAuthenticatedUser(credentials, { config: ZED_HOSTED_CONFIG }); + } catch { /* best-effort */ } + const organizationId = resolveZedOrganizationId(credentials, userInfo); + return { + userInfo, + organizationId, + email: userInfo?.email || null, + name: userInfo?.name || userInfo?.display_name || null, + }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.accessToken, + refreshToken: null, + expiresIn: null, + email: extra?.email || undefined, + displayName: extra?.name || undefined, + providerSpecificData: { + authMethod: "oauth", + userId: tokens.userId, + systemId: tokens.systemId, + organizationId: extra?.organizationId || "", + }, + }), +}; + +export default zed; diff --git a/src/lib/oauth/utils/ideDetect.js b/src/lib/oauth/utils/ideDetect.js new file mode 100644 index 00000000..a1200bcf --- /dev/null +++ b/src/lib/oauth/utils/ideDetect.js @@ -0,0 +1,57 @@ +import fs from "fs/promises"; +import { exec } from "child_process"; +import { promisify } from "util"; +import path from "path"; +import os from "os"; + +const execAsync = promisify(exec); + +// Install paths per provider per platform — Trae and standard Windsurf IDE locations. +const IDE_PATHS = { + trae: { + darwin: ["/Applications/Trae.app"], + win32: [ + path.join(process.env.LOCALAPPDATA || "", "Programs", "Trae", "Trae.exe"), + path.join(process.env.ProgramFiles || "", "Trae", "Trae.exe"), + ], + linux: ["/usr/bin/trae", "/usr/local/bin/trae", "/opt/trae", "/opt/Trae"], + }, + windsurf: { + darwin: ["/Applications/Windsurf.app"], + win32: [ + path.join(process.env.LOCALAPPDATA || "", "Programs", "Windsurf", "Windsurf.exe"), + path.join(process.env.ProgramFiles || "", "Windsurf", "Windsurf.exe"), + ], + linux: ["/usr/bin/windsurf", "/usr/local/bin/windsurf", "/opt/windsurf", "/opt/Windsurf"], + }, +}; + +const IDE_BINARIES = { + trae: "trae", + windsurf: "windsurf", +}; + +async function pathExists(p) { + try { await fs.access(p); return true; } catch { return false; } +} + +async function checkBinary(bin) { + try { + const cmd = os.platform() === "win32" ? `where ${bin}` : `which ${bin}`; + await execAsync(cmd, { windowsHide: true }); + return true; + } catch { return false; } +} + +// Returns { installed: boolean, path: string|null } for the given provider's IDE. +export async function detectIdeInstalled(providerId) { + const platform = os.platform(); + const paths = IDE_PATHS[providerId]; + if (!paths) return { installed: false, path: null }; + for (const p of paths[platform] || []) { + if (p && await pathExists(p)) return { installed: true, path: p }; + } + const bin = IDE_BINARIES[providerId]; + if (bin && await checkBinary(bin)) return { installed: true, path: bin }; + return { installed: false, path: null }; +} diff --git a/src/lib/oauth/utils/server.js b/src/lib/oauth/utils/server.js index b11a9a44..56eb67b1 100644 --- a/src/lib/oauth/utils/server.js +++ b/src/lib/oauth/utils/server.js @@ -1,6 +1,16 @@ import http from "http"; import { URL } from "url"; -import { CODEX_CONFIG } from "../constants/oauth.js"; +import { CODEX_CONFIG, TRAE_CONFIG, WINDSURF_CONFIG, ZED_HOSTED_CONFIG } from "../constants/oauth.js"; + +// Loopback origin guard for local callback proxies. +// Legit OAuth redirects are top-level navigations (no `Origin` header); a cross-site +// page issuing `fetch(..., {mode:"no-cors"})` to scan + hit 127.0.0.1 always sends +// `Origin: https://attacker`. Reject any non-loopback Origin to block login-CSRF. +function isLoopbackOrigin(origin) { + if (!origin) return true; // navigation redirect — allow + return /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin); +} + /** * Start a local HTTP server to receive OAuth callback @@ -424,3 +434,324 @@ export function stopXaiProxy() { } } +// ─────────────────────────────────────────────────────────────────────────── +// Trae dynamic-port proxy. Singleton session (one connect at a time per provider). +// Callback path = /callback with params refreshToken + loginHost. +// ─────────────────────────────────────────────────────────────────────────── + +let traeProxyServer = null; +let traeProxyTimeout = null; +let traeProxyPort = null; +let traeSession = null; + +export function registerTraeSession({ state }) { + if (!state) return false; + traeSession = { state, status: "pending", createdAt: Date.now() }; + return true; +} +export function getTraeSessionStatus(state) { + if (!traeSession) return null; + if (state && traeSession.state !== state) return null; + return traeSession; +} +export function clearTraeSession(state) { + if (!state || (traeSession && traeSession.state === state)) traeSession = null; +} + +export function startTraeProxy() { + return new Promise((resolve) => { + if (traeProxyServer) { + resolve({ success: true, port: traeProxyPort, callbackUrl: `http://127.0.0.1:${traeProxyPort}${TRAE_CONFIG.callbackPath}` }); + return; + } + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + if (url.pathname !== TRAE_CONFIG.callbackPath && url.pathname !== "/auth/callback") { + res.writeHead(404); + res.end("Not found"); + return; + } + const session = traeSession; + if (!session) { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, "No active Trae login session")); + return; + } + // Anti-CSRF: reject cross-origin fetches (legit redirects send no Origin), + // and reject state mismatch when state is present. + if (!isLoopbackOrigin(req.headers.origin)) { + res.writeHead(403, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, "Cross-origin callback rejected")); + return; + } + const cbState = url.searchParams.get("state"); + if (cbState && session.state && cbState !== session.state) { + session.status = "error"; + session.error = "Trae callback state mismatch"; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, session.error)); + stopTraeProxy(); + return; + } + // Pass the raw callback query to exchangeTokens → parseTraeCallback + const rawCallback = `${url.pathname}?${url.searchParams.toString()}`; + try { + const { exchangeTokens } = await import("../providers.js"); + const { createProviderConnection } = await import("@/models"); + const tokenData = await exchangeTokens("trae", rawCallback); + const connection = await createProviderConnection({ + provider: "trae", + authType: "oauth", + ...tokenData, + expiresAt: tokenData.expiresIn + ? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString() + : null, + testStatus: "active", + }); + session.status = "done"; + session.connectionId = connection.id; + session.email = connection.email; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(true, "You can close this window.")); + } catch (err) { + session.status = "error"; + session.error = err.message; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, err.message)); + } finally { + stopTraeProxy(); + } + }); + server.listen(0, "127.0.0.1", () => { + traeProxyServer = server; + traeProxyPort = server.address().port; + traeProxyTimeout = setTimeout(() => stopTraeProxy(), TRAE_CONFIG.oauthTimeoutMs); + resolve({ success: true, port: traeProxyPort, callbackUrl: `http://127.0.0.1:${traeProxyPort}${TRAE_CONFIG.callbackPath}` }); + }); + server.on("error", (err) => resolve({ success: false, reason: err.message })); + }); +} + +export function stopTraeProxy() { + if (traeProxyTimeout) { clearTimeout(traeProxyTimeout); traeProxyTimeout = null; } + if (traeProxyServer) { traeProxyServer.close(); traeProxyServer = null; } + traeProxyPort = null; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Windsurf dynamic-port proxy. Singleton session. +// Callback path = /windsurf-auth-callback with params access_token (firebase JWT) + state. +// ─────────────────────────────────────────────────────────────────────────── + +let windsurfProxyServer = null; +let windsurfProxyTimeout = null; +let windsurfProxyPort = null; +let windsurfSession = null; + +export function registerWindsurfSession({ state }) { + if (!state) return false; + windsurfSession = { state, status: "pending", createdAt: Date.now() }; + return true; +} +export function getWindsurfSessionStatus(state) { + if (!windsurfSession) return null; + if (state && windsurfSession.state !== state) return null; + return windsurfSession; +} +export function clearWindsurfSession(state) { + if (!state || (windsurfSession && windsurfSession.state === state)) windsurfSession = null; +} + +export function startWindsurfProxy() { + return new Promise((resolve) => { + if (windsurfProxyServer) { + resolve({ success: true, port: windsurfProxyPort, callbackUrl: `http://127.0.0.1:${windsurfProxyPort}${WINDSURF_CONFIG.callbackPath}` }); + return; + } + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + if (url.pathname !== WINDSURF_CONFIG.callbackPath) { + res.writeHead(404); + res.end("Not found"); + return; + } + const session = windsurfSession; + if (!session) { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, "No active Windsurf login session")); + return; + } + // Anti-CSRF: reject cross-origin fetches, and require state present + matching. + if (!isLoopbackOrigin(req.headers.origin)) { + res.writeHead(403, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, "Cross-origin callback rejected")); + return; + } + const cbState = url.searchParams.get("state"); + if (!cbState || !session.state || cbState !== session.state) { + session.status = "error"; + session.error = "Windsurf callback state mismatch"; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, session.error)); + stopWindsurfProxy(); + return; + } + const rawCallback = `${url.pathname}?${url.searchParams.toString()}`; + try { + const { exchangeTokens } = await import("../providers.js"); + const { createProviderConnection } = await import("@/models"); + const tokenData = await exchangeTokens("windsurf", rawCallback, null, null, session.state); + const connection = await createProviderConnection({ + provider: "windsurf", + authType: "api_key", + ...tokenData, + testStatus: "active", + }); + session.status = "done"; + session.connectionId = connection.id; + session.email = connection.email; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(true, "You can close this window.")); + } catch (err) { + session.status = "error"; + session.error = err.message; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, err.message)); + } finally { + stopWindsurfProxy(); + } + }); + server.listen(0, "127.0.0.1", () => { + windsurfProxyServer = server; + windsurfProxyPort = server.address().port; + windsurfProxyTimeout = setTimeout(() => stopWindsurfProxy(), WINDSURF_CONFIG.oauthTimeoutMs); + resolve({ success: true, port: windsurfProxyPort, callbackUrl: `http://127.0.0.1:${windsurfProxyPort}${WINDSURF_CONFIG.callbackPath}` }); + }); + server.on("error", (err) => resolve({ success: false, reason: err.message })); + }); +} + +export function stopWindsurfProxy() { + if (windsurfProxyTimeout) { clearTimeout(windsurfProxyTimeout); windsurfProxyTimeout = null; } + if (windsurfProxyServer) { windsurfProxyServer.close(); windsurfProxyServer = null; } + windsurfProxyPort = null; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Zed RSA native-app proxy. Singleton session. +// Callback: GET http://127.0.0.1:/?user_id=...&access_token= +// The proxy decrypts the access token using the private key stored in session.codeVerifier. +// ─────────────────────────────────────────────────────────────────────────── + +let zedProxyServer = null; +let zedProxyTimeout = null; +let zedProxyPort = null; +let zedSession = null; + +export function registerZedSession({ state, codeVerifier }) { + if (!state || !codeVerifier) return false; + zedSession = { state, codeVerifier, status: "pending", createdAt: Date.now() }; + return true; +} +export function getZedSessionStatus(state) { + if (!zedSession) return null; + if (state && zedSession.state !== state) return null; + return zedSession; +} +export function clearZedSession(state) { + if (!state || (zedSession && zedSession.state === state)) zedSession = null; +} + +export function startZedProxy(preferredPort = 0) { + return new Promise((resolve) => { + if (zedProxyServer) { + resolve({ success: true, port: zedProxyPort, callbackUrl: `http://127.0.0.1:${zedProxyPort}/` }); + return; + } + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + // Log path + redacted params (access_token is the RSA-encrypted credential). + const redacted = Object.fromEntries(url.searchParams); + for (const k of ["access_token", "user_id", "code_verifier", "state"]) { + if (redacted[k]) redacted[k] = ""; + } + console.log("[Zed proxy]", req.method, url.pathname, JSON.stringify(redacted)); + if (url.pathname !== "/" && url.pathname !== "/callback") { + res.writeHead(404); + res.end("Not found"); + return; + } + const session = zedSession; + if (!session) { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, "No active Zed login session")); + return; + } + // Anti-CSRF: Zed tokens are RSA-encrypted to our keypair so they can't be + // forged cross-site, but still reject cross-origin fetches for defense-in-depth. + if (!isLoopbackOrigin(req.headers.origin)) { + res.writeHead(403, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, "Cross-origin callback rejected")); + return; + } + // Pass raw callback path+query to exchangeTokens → parseZedCallbackPayload. + // codeVerifier carries the encoded RSA private key for decryption. + const rawCallback = url.search ? `${url.pathname}?${url.searchParams.toString()}` : url.pathname; + try { + const { exchangeTokens } = await import("../providers.js"); + const { createProviderConnection } = await import("@/models"); + const tokenData = await exchangeTokens("zed", rawCallback, null, session.codeVerifier, session.state); + const connection = await createProviderConnection({ + provider: "zed", + authType: "oauth", + ...tokenData, + testStatus: "active", + }); + session.status = "done"; + session.connectionId = connection.id; + session.email = connection.email; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(true, "You can close this window.")); + } catch (err) { + session.status = "error"; + session.error = err.message; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, err.message)); + } finally { + stopZedProxy(); + } + }); + const tryPort = Number(preferredPort) || 0; + server.on("error", (err) => { + // If the preferred port (e.g. 58443) is busy, fall back to a random port. + if (err.code === "EADDRINUSE" && tryPort !== 0) { + console.log(`[Zed proxy] port ${tryPort} busy, falling back to random`); + server.listen(0, "127.0.0.1", () => { + zedProxyServer = server; + zedProxyPort = server.address().port; + zedProxyTimeout = setTimeout(() => stopZedProxy(), ZED_HOSTED_CONFIG.oauthTimeoutMs); + console.log(`[Zed proxy] listening on random port ${zedProxyPort}`); + resolve({ success: true, port: zedProxyPort, callbackUrl: `http://127.0.0.1:${zedProxyPort}/` }); + }); + } else { + console.log(`[Zed proxy] listen error: ${err.message}`); + resolve({ success: false, reason: err.message }); + } + }); + server.listen(tryPort, "127.0.0.1", () => { + zedProxyServer = server; + zedProxyPort = server.address().port; + zedProxyTimeout = setTimeout(() => { console.log("[Zed proxy] timeout, stopping"); stopZedProxy(); }, ZED_HOSTED_CONFIG.oauthTimeoutMs); + console.log(`[Zed proxy] listening on port ${zedProxyPort}`); + resolve({ success: true, port: zedProxyPort, callbackUrl: `http://127.0.0.1:${zedProxyPort}/` }); + }); + }); +} + +export function stopZedProxy() { + console.log(`[Zed proxy] stopping (port ${zedProxyPort || "-"})`); + if (zedProxyTimeout) { clearTimeout(zedProxyTimeout); zedProxyTimeout = null; } + if (zedProxyServer) { zedProxyServer.close(); zedProxyServer = null; } + zedProxyPort = null; +} + diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index f734f6ab..808a7a8b 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -5,6 +5,31 @@ import PropTypes from "prop-types"; import { Modal, Button, Input } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +// Providers using the dynamic-port local callback proxy. +// Browser OAuth: popup → auto callback → auto exchange → poll-status. +const PROXY_OAUTH_PROVIDERS = new Set(["trae", "windsurf", "zed"]); + +// Providers offering a paste-token fallback (import-token flow). +// UX warns if the IDE (which issues the token) is not installed. +const PASTE_TOKEN_PROVIDERS = { + trae: { + label: "Cloud-IDE-JWT", + instructions: + "Sign in at trae.ai (or solo.trae.ai), open DevTools → Network, copy the Cloud-IDE-JWT token from any request's Authorization header (~14-day lifetime).", + placeholder: "Paste Cloud-IDE-JWT here...", + ideName: "Trae", + ideOptional: true, // token can be grabbed from DevTools without the IDE + }, + windsurf: { + label: "Windsurf API key", + instructions: + "In the Windsurf/VS Code IDE, run the \"Windsurf: Provide Auth Token\" command, then copy the displayed sk-ws-... key.", + placeholder: "Paste sk-ws-... key here...", + ideName: "Windsurf", + ideOptional: false, + }, +}; + /** * OAuth Modal Component * - Localhost: Auto callback via popup message @@ -18,6 +43,10 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, const [isDeviceCode, setIsDeviceCode] = useState(false); const [deviceData, setDeviceData] = useState(null); const [polling, setPolling] = useState(false); + // trae/windsurf: choose between browser OAuth (proxy) and paste-token (import) + const [authMode, setAuthMode] = useState("browser"); // "browser" | "paste-token" + const [pasteToken, setPasteToken] = useState(""); + const [ideStatus, setIdeStatus] = useState(null); const popupRef = useRef(null); const pollingAbortRef = useRef(false); const openedRef = useRef(false); @@ -150,12 +179,50 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, setPolling(false); }, [provider, onSuccess]); + // Trae/Windsurf proxy OAuth flow: dynamic-port local callback → auto exchange. + const startProxyFlow = useCallback(async (providerId) => { + // 1. Start the local callback server (returns a dynamic port + callback URL). + const startRes = await fetch(`/api/oauth/${providerId}/start-proxy`); + const startData = await startRes.json(); + if (!startRes.ok || !startData.success || !startData.callbackUrl) { + throw new Error(startData.reason || startData.error || `Failed to start ${providerId} callback server`); + } + // 2. Build the authorize URL with redirect_uri = proxy callback URL. + const authorizeUrl = new URL(`/api/oauth/${providerId}/authorize`, window.location.origin); + authorizeUrl.searchParams.set("redirect_uri", startData.callbackUrl); + const authRes = await fetch(authorizeUrl); + const authData = await authRes.json(); + if (!authRes.ok) throw new Error(authData.error); + // 3. Register the session so the proxy can match the incoming callback. + // Zed also passes code_verifier (encodes the RSA private key for decrypt); + // sent via POST body so the private key never lands in URL/query logs. + const regBody = { state: authData.state }; + if (authData.codeVerifier) regBody.codeVerifier = authData.codeVerifier; + await fetch(`/api/oauth/${providerId}/register-session`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(regBody), + }); + // 4. Open popup; proxy auto-exchanges on callback, modal polls poll-status. + setAuthData({ ...authData, proxyProvider: providerId }); + setStep("waiting"); + popupRef.current = window.open(authData.authUrl, "oauth_popup", "width=600,height=700"); + if (!popupRef.current) setStep("input"); // popup blocked → fall back to manual paste + }, []); + // Start OAuth flow const startOAuthFlow = useCallback(async () => { if (!provider) return; try { setError(null); + // Trae/Windsurf: proxy OAuth (browser mode) — handled by dedicated flow. + // Paste-token mode is handled by handleManualSubmit (no /authorize call). + if (PROXY_OAUTH_PROVIDERS.has(provider) && authMode === "browser") { + await startProxyFlow(provider); + return; + } + // Device code flow providers (must match oauth providers with flowType: "device_code") const deviceCodeProviders = [ "github", @@ -165,6 +232,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, "kimi-coding", "kilocode", "codebuddy-cn", + "codebuddy-intl", "qoder", "grok-cli", ]; @@ -329,7 +397,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, setError(err.message); setStep("error"); } - }, [provider, isLocalhost, startPolling, oauthMeta, idcConfig]); + }, [provider, isLocalhost, startPolling, oauthMeta, idcConfig, authMode, startProxyFlow]); // Reset state and start OAuth when modal opens useEffect(() => { @@ -343,7 +411,17 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, setIsDeviceCode(false); setDeviceData(null); setPolling(false); + setAuthMode("browser"); + setPasteToken(""); + setIdeStatus(null); pollingAbortRef.current = false; + // Best-effort IDE detection for paste-token providers (Trae/Windsurf) + if (PASTE_TOKEN_PROVIDERS[provider]) { + fetch(`/api/oauth/${provider}/ide-status`) + .then((r) => r.json()) + .then((data) => setIdeStatus(data)) + .catch(() => setIdeStatus({ installed: false, path: null })); + } startOAuthFlow(); } else if (!isOpen) { // Abort polling and cleanup proxy when modal closes @@ -353,13 +431,26 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, fetch("/api/oauth/codex/stop-proxy").catch(() => {}); } else if (provider === "xai") { fetch("/api/oauth/xai/stop-proxy").catch(() => {}); + } else if (provider === "trae") { + fetch("/api/oauth/trae/stop-proxy").catch(() => {}); + } else if (provider === "windsurf") { + fetch("/api/oauth/windsurf/stop-proxy").catch(() => {}); + } else if (provider === "zed") { + fetch("/api/oauth/zed/stop-proxy").catch(() => {}); } } }, [isOpen, provider, startOAuthFlow]); - // Fixed-port server-side mode: poll status (proxy auto-exchanges + saves DB) + // Server-side proxy mode (codex/xai fixed-port + trae/windsurf dynamic-port): + // poll status until the proxy auto-exchanges and saves the connection. useEffect(() => { - const pollProvider = authData?.codexServerSide ? "codex" : authData?.xaiServerSide ? "xai" : null; + const pollProvider = authData?.codexServerSide + ? "codex" + : authData?.xaiServerSide + ? "xai" + : authData?.proxyProvider + ? authData.proxyProvider + : null; if (!pollProvider || !authData?.state) return; if (callbackProcessedRef.current) return; let cancelled = false; @@ -487,8 +578,38 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, try { setError(null); + // Paste-token mode (Trae/Windsurf): token goes straight to /exchange + if (authMode === "paste-token" && PASTE_TOKEN_PROVIDERS[provider]) { + const token = pasteToken.trim(); + if (!token) throw new Error("Missing token"); + const res = await fetch(`/api/oauth/${provider}/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: token }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + setStep("success"); + onSuccess?.(); + return; + } + const input = callbackUrl.trim(); + // Trae/Windsurf proxy flow fallback (popup blocked): paste the full callback URL + if (PROXY_OAUTH_PROVIDERS.has(provider) && input) { + const res = await fetch(`/api/oauth/${provider}/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: input, state: authData?.state }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + setStep("success"); + onSuccess?.(); + return; + } + // Detect raw JWT access token (starts with eyJ) — skip URL parsing if (input.startsWith("eyJ") && input.includes(".")) { await exchangeTokens(input, null); @@ -538,6 +659,12 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, fetch("/api/oauth/codex/stop-proxy").catch(() => {}); } else if (provider === "xai") { fetch("/api/oauth/xai/stop-proxy").catch(() => {}); + } else if (provider === "trae") { + fetch("/api/oauth/trae/stop-proxy").catch(() => {}); + } else if (provider === "windsurf") { + fetch("/api/oauth/windsurf/stop-proxy").catch(() => {}); + } else if (provider === "zed") { + fetch("/api/oauth/zed/stop-proxy").catch(() => {}); } onClose(); }, [onClose, provider]); @@ -556,8 +683,82 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, return (
- {/* Waiting + Manual Input combined (non-device-code) */} - {(step === "waiting" || step === "input") && !isDeviceCode && ( + {/* Trae/Windsurf: browser OAuth (proxy) + paste-token fallback */} + {PROXY_OAUTH_PROVIDERS.has(provider) && (step === "waiting" || step === "input" || step === "error") && ( + <> +
+ + +
+ + {authMode === "browser" && ( + <> + {step === "waiting" && ( +
+ progress_activity + Waiting for browser authorization… +
+ )} + {step === "input" && ( +
+

+ Popup was blocked. After authorizing in the browser, paste the full callback URL here: +

+ setCallbackUrl(e.target.value)} + placeholder="http://127.0.0.1:.../callback?..." + className="font-mono text-xs" + /> +
+ + +
+
+ )} + + )} + + {authMode === "paste-token" && ( +
+ {ideStatus && !ideStatus.installed && ( +
+ {PASTE_TOKEN_PROVIDERS[provider].ideName} IDE not detected. + {PASTE_TOKEN_PROVIDERS[provider].ideOptional + ? " You can still grab the token from DevTools." + : ` Install ${PASTE_TOKEN_PROVIDERS[provider].ideName} IDE to get the token, or use "Sign in with browser".`} +
+ )} +

{PASTE_TOKEN_PROVIDERS[provider].instructions}

+ setPasteToken(e.target.value)} + placeholder={PASTE_TOKEN_PROVIDERS[provider].placeholder} + className="font-mono text-xs" + /> +
+ + +
+
+ )} + + )} + + {/* Waiting + Manual Input combined (non-device-code, non-proxy) */} + {(step === "waiting" || step === "input") && !isDeviceCode && !PROXY_OAUTH_PROVIDERS.has(provider) && ( <> {/* Option A: Auto via popup */}
diff --git a/tests/unit/grok-cli-usage.test.js b/tests/unit/grok-cli-usage.test.js index 0c52a2cd..84414b90 100644 --- a/tests/unit/grok-cli-usage.test.js +++ b/tests/unit/grok-cli-usage.test.js @@ -113,6 +113,43 @@ describe("parseGrokCliBilling", () => { expect(parsed.exhausted).toBe(false); }); + it("maps creditUsagePercent to a single Weekly SuperGrok bar (not productUsage)", () => { + const parsed = parseGrokCliBilling( + { + config: { + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-07-17T12:42:26.494595+00:00", + end: "2026-07-24T12:42:26.494595+00:00", + }, + creditUsagePercent: 99.0, + onDemandCap: { val: 0 }, + onDemandUsed: { val: 0 }, + productUsage: [ + { product: "GrokBuild", usagePercent: 97.0 }, + { product: "GrokImagine", usagePercent: 2.0 }, + ], + isUnifiedBillingUser: true, + prepaidBalance: { val: 0 }, + billingPeriodStart: "2026-07-17T12:42:26.494595+00:00", + billingPeriodEnd: "2026-07-24T12:42:26.494595+00:00", + }, + }, + { subscriptionTier: "XPremiumPlus", hasGrokCodeAccess: true }, + ); + // Single shared-pool bar from creditUsagePercent + expect(parsed.quotas["Weekly SuperGrok"]).toMatchObject({ + used: 99, + total: 100, + remainingPercentage: 1, + resetAt: "2026-07-24T12:42:26.494Z", + unlimited: false, + }); + // productUsage must NOT become independent quota bars + expect(Object.keys(parsed.quotas)).toEqual(["Weekly SuperGrok"]); + expect(parsed.exhausted).toBe(false); + }); + it("maps current monthly fields and snake-case subscription tier", () => { const parsed = parseGrokCliBilling({ monthlyLimit: { val: 1000 }, diff --git a/tests/unit/token-refresh-generic.test.js b/tests/unit/token-refresh-generic.test.js new file mode 100644 index 00000000..d4507c2e --- /dev/null +++ b/tests/unit/token-refresh-generic.test.js @@ -0,0 +1,146 @@ +/** + * Generic OAuth2 token refresh — config-driven profiles. + * + * Verifies refreshAccessToken() handles the 5 foldable providers + * (qwen, iflow, github, kimi, claude) via a REFRESH_PROFILES table, + * while preserving the legacy generic path for unknown providers. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const originalFetch = global.fetch; + +function mockFetchOnce(payload, { ok = true, status = 200 } = {}) { + const fn = vi.fn().mockResolvedValue({ + ok, + status, + json: () => Promise.resolve(payload), + text: () => Promise.resolve(JSON.stringify(payload)), + }); + global.fetch = fn; + return fn; +} + +describe("refreshAccessToken — config-driven profiles", () => { + beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); global.fetch = originalFetch; }); + afterEach(() => { global.fetch = originalFetch; }); + + it("qwen: form body + clientId, surfaces resource_url as providerSpecificData", async () => { + const fm = mockFetchOnce({ + access_token: "qw-acc", + refresh_token: "qw-refresh-rotated", + expires_in: 7200, + resource_url: "https://dashscope.aliyuncs.com", + }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + const out = await refreshAccessToken("qwen", "qw-old-refresh", {}, console); + + expect(out).toEqual({ + accessToken: "qw-acc", + refreshToken: "qw-refresh-rotated", + expiresIn: 7200, + providerSpecificData: { resourceUrl: "https://dashscope.aliyuncs.com" }, + }); + const [url, init] = fm.mock.calls[0]; + expect(init.method).toBe("POST"); + expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); + const body = new URLSearchParams(init.body); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("qw-old-refresh"); + expect(body.get("client_id")).toBeTruthy(); + }); + + it("iflow: Basic Auth header from clientId:clientSecret, form body keeps client_secret", async () => { + const fm = mockFetchOnce({ access_token: "if-acc", refresh_token: "if-rot", expires_in: 3600 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + await refreshAccessToken("iflow", "if-old", {}, console); + + const [, init] = fm.mock.calls[0]; + expect(init.headers["Authorization"]).toMatch(/^Basic /); + const body = new URLSearchParams(init.body); + expect(body.get("client_id")).toBeTruthy(); + expect(body.get("client_secret")).toBeTruthy(); + }); + + it("github: omits client_secret when config has none", async () => { + const fm = mockFetchOnce({ access_token: "gh-acc", expires_in: 28800 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + const out = await refreshAccessToken("github", "gh-old", {}, console); + + const body = new URLSearchParams(fm.mock.calls[0][1].body); + expect(body.get("client_secret")).toBeNull(); + expect(out.accessToken).toBe("gh-acc"); + expect(out.refreshToken).toBe("gh-old"); + }); + + it("kimi: merges X-Msh-* headers from credentials.providerSpecificData.deviceId", async () => { + const fm = mockFetchOnce({ access_token: "km-acc", expires_in: 86400 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + await refreshAccessToken("kimi", "km-old", { + providerSpecificData: { deviceId: "dev-xyz" }, + }, console); + + const headers = fm.mock.calls[0][1].headers; + // Kimi's buildKimiHeaders must contribute at least one X-Msh- header + const mshKeys = Object.keys(headers).filter((k) => k.toLowerCase().startsWith("x-msh-")); + expect(mshKeys.length).toBeGreaterThan(0); + }); + + it("claude: JSON body, client_id only (no client_secret)", async () => { + const fm = mockFetchOnce({ access_token: "cl-acc", refresh_token: "cl-rot", expires_in: 3600 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + await refreshAccessToken("claude", "cl-old", {}, console); + + const [, init] = fm.mock.calls[0]; + expect(init.headers["Content-Type"]).toBe("application/json"); + const parsed = JSON.parse(init.body); + expect(parsed.grant_type).toBe("refresh_token"); + expect(parsed.client_id).toBeTruthy(); + expect(parsed).not.toHaveProperty("client_secret"); + }); + + it("returns null on non-ok response", async () => { + mockFetchOnce({ error: "invalid_grant" }, { ok: false, status: 400 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + const out = await refreshAccessToken("qwen", "dead", {}, console); + expect(out).toBeNull(); + }); + + it("returns null when refreshToken missing", async () => { + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + const out = await refreshAccessToken("qwen", "", {}, console); + expect(out).toBeNull(); + }); + + it("dedupes concurrent calls with same refresh token (same dedupKey)", async () => { + const fm = mockFetchOnce({ access_token: "dd-acc", expires_in: 3600 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + const creds = { providerSpecificData: { deviceId: "d" } }; + await Promise.all([ + refreshAccessToken("kimi", "dup-refresh", creds, console), + refreshAccessToken("kimi", "dup-refresh", creds, console), + ]); + expect(fm).toHaveBeenCalledTimes(1); + }); +}); + +describe("refreshAccessToken — legacy generic path (no profile)", () => { + beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); global.fetch = originalFetch; }); + afterEach(() => { global.fetch = originalFetch; }); + + it("still works for an unprofiled provider via config.refreshUrl/clientId/clientSecret", async () => { + const fm = mockFetchOnce({ access_token: "gen-acc", expires_in: 3600 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + await refreshAccessToken("cline", "gen-old", {}, console); + + const body = new URLSearchParams(fm.mock.calls[0][1].body); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("client_id")).toBeTruthy(); + }); +}); diff --git a/tests/unit/windsurf-executor.test.js b/tests/unit/windsurf-executor.test.js new file mode 100644 index 00000000..db8864f9 --- /dev/null +++ b/tests/unit/windsurf-executor.test.js @@ -0,0 +1,198 @@ +import { describe, it, expect } from "vitest"; +import { + resolveWsModelId, + buildGetChatMessageRequest, + grpcWebFrame, + decodeCompletionChunk, + default as WindsurfExecutor, +} from "open-sse/executors/windsurf.js"; +import { PROVIDERS } from "open-sse/config/providers.js"; + +// ─── Protobuf helpers for building expected wire bytes in tests ────────────── + +function encodeVarint(value) { + const bytes = []; + let v = value >>> 0; + while (v > 0x7f) { bytes.push((v & 0x7f) | 0x80); v >>>= 7; } + bytes.push(v & 0x7f); + return new Uint8Array(bytes); +} +function encodeLenField(fieldNum, payload) { + const tag = encodeVarint((fieldNum << 3) | 2); + const len = encodeVarint(payload.length); + const out = new Uint8Array(tag.length + len.length + payload.length); + out.set(tag, 0); out.set(len, tag.length); out.set(payload, tag.length + len.length); + return out; +} +function encodeStringField(fieldNum, str) { + return encodeLenField(fieldNum, new TextEncoder().encode(str)); +} + +describe("windsurf MODEL_ALIAS_MAP", () => { + it("maps SWE models to snake-case wire names", () => { + expect(resolveWsModelId("swe-1.6-fast")).toBe("swe-1-6-fast"); + expect(resolveWsModelId("swe-1.5")).toBe("swe-1-5"); + }); + it("maps Claude 4.5 to MODEL_PRIVATE_* aliases", () => { + expect(resolveWsModelId("claude-sonnet-4.5")).toBe("MODEL_PRIVATE_2"); + expect(resolveWsModelId("claude-opus-4.5")).toBe("MODEL_CLAUDE_4_5_OPUS"); + }); + it("applies default effort level for bare gpt-5.x ids", () => { + expect(resolveWsModelId("gpt-5.5")).toBe("gpt-5-5-medium"); + expect(resolveWsModelId("gpt-5.4")).toBe("gpt-5-4-medium"); + }); + it("passes through unknown ids as-is", () => { + expect(resolveWsModelId("custom-model")).toBe("custom-model"); + }); +}); + +describe("grpcWebFrame", () => { + it("prepends a 5-byte header: 0x00 flag + big-endian length", () => { + const payload = new Uint8Array([1, 2, 3, 4, 5]); + const frame = grpcWebFrame(payload); + expect(frame[0]).toBe(0x00); + const view = new DataView(frame.buffer); + expect(view.getUint32(1, false)).toBe(5); // big-endian length + expect(Array.from(frame.slice(5))).toEqual([1, 2, 3, 4, 5]); + }); + it("encodes empty payload as a 5-byte frame", () => { + const frame = grpcWebFrame(new Uint8Array(0)); + expect(frame.length).toBe(5); + expect(frame[0]).toBe(0x00); + }); +}); + +describe("buildGetChatMessageRequest", () => { + it("emits metadata (field 1), cascade_id (2), model (3), messages (4+)", () => { + const payload = buildGetChatMessageRequest("sk-ws-test", "swe-1.6", [ + { role: "user", content: "hello" }, + ]); + expect(payload.length).toBeGreaterThan(10); + // First byte 0x0a = field 1, wire type 2 (length-delimited) → metadata present + expect(payload[0]).toBe(0x0a); + }); + + it("embeds the apiKey inside the metadata sub-message", () => { + const payload = buildGetChatMessageRequest("sk-ws-secret", "gpt-5", []); + // The metadata bytes are the first length-delimited field — should contain the key. + const asString = new TextDecoder().decode(payload); + expect(asString).toContain("sk-ws-secret"); + // And the IDE identification fields. + expect(asString).toContain("windsurf"); + expect(asString).toContain("3.14.0"); + }); + + it("appends one field-4 message per chat message", () => { + // Proper top-level protobuf field counter (byte 0x22 collides with content bytes). + const countField = (buf, target) => { + let offset = 0; + let count = 0; + while (offset < buf.length) { + let result = 0, shift = 0; + while (offset < buf.length) { + const b = buf[offset++]; + result |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + } + const fieldNum = result >>> 3; + const wireType = result & 0x07; + if (wireType === 2) { + let len = 0, ls = 0; + while (offset < buf.length) { + const b = buf[offset++]; + len |= (b & 0x7f) << ls; + if ((b & 0x80) === 0) break; + ls += 7; + } + if (fieldNum === target) count++; + offset += len; + } else if (wireType === 0) { + while (offset < buf.length) { + const b = buf[offset++]; + if ((b & 0x80) === 0) break; + } + } else if (wireType === 1) { + offset += 8; + } else if (wireType === 5) { + offset += 4; + } else { + break; + } + } + return count; + }; + const one = buildGetChatMessageRequest("k", "m", [{ role: "user", content: "a" }]); + const two = buildGetChatMessageRequest("k", "m", [ + { role: "user", content: "a" }, + { role: "assistant", content: "b" }, + ]); + expect(countField(one, 4)).toBe(1); + expect(countField(two, 4)).toBe(2); + }); +}); + +describe("decodeCompletionChunk", () => { + it("decodes a ContentChunk (field 1 → text)", () => { + const chunk = encodeLenField(1, encodeStringField(1, "hello world")); + const decoded = decodeCompletionChunk(chunk); + expect(decoded).toEqual({ kind: "content", text: "hello world" }); + }); + + it("decodes an ErrorChunk (field 4 → message)", () => { + const chunk = encodeLenField(4, encodeStringField(1, "quota exhausted")); + const decoded = decodeCompletionChunk(chunk); + expect(decoded).toEqual({ kind: "error", message: "quota exhausted" }); + }); + + it("decodes a DoneChunk (field 3 → UsageStats with prompt/completion tokens)", () => { + // UsageStats: field 1 = prompt_tokens (varint), field 2 = completion_tokens (varint) + const usage = new Uint8Array([...encodeVarint((1 << 3) | 0), ...encodeVarint(42), ...encodeVarint((2 << 3) | 0), ...encodeVarint(99)]); + const doneChunk = encodeLenField(3, encodeLenField(1, usage)); + const decoded = decodeCompletionChunk(doneChunk); + expect(decoded.kind).toBe("done"); + expect(decoded.promptTokens).toBe(42); + expect(decoded.completionTokens).toBe(99); + }); + + it("returns { kind: 'unknown' } for empty buffer", () => { + expect(decodeCompletionChunk(new Uint8Array(0))).toEqual({ kind: "unknown" }); + }); +}); + +describe("WindsurfExecutor class", () => { + it("constructor wires config from PROVIDERS.windsurf", () => { + const ex = new WindsurfExecutor(); + expect(ex.provider).toBe("windsurf"); + expect(ex.config).toBeDefined(); + expect(ex.config.baseUrl).toContain("server.self-serve.windsurf.com"); + expect(typeof ex.execute).toBe("function"); + }); + + it("buildHeaders emits grpc-web+proto + Bearer token", () => { + const ex = new WindsurfExecutor(); + const h = ex.buildHeaders({ accessToken: "sk-ws-abc" }); + expect(h["Content-Type"]).toBe("application/grpc-web+proto"); + expect(h.Accept).toBe("application/grpc-web+proto"); + expect(h["X-Grpc-Web"]).toBe("1"); + expect(h.Authorization).toBe("Bearer sk-ws-abc"); + expect(h["User-Agent"]).toMatch(/^windsurf\//); + }); + + it("buildHeaders omits Authorization when no token", () => { + const ex = new WindsurfExecutor(); + const h = ex.buildHeaders({}); + expect(h.Authorization).toBeUndefined(); + }); + + it("buildUrl returns the GetChatMessage endpoint", () => { + const ex = new WindsurfExecutor(); + expect(ex.buildUrl()).toBe("https://server.self-serve.windsurf.com/exa.language_server_pb.LanguageServerService/GetChatMessage"); + }); + + it("PROVIDERS.windsurf baseUrl is the chat endpoint (registry in sync)", () => { + expect(PROVIDERS.windsurf.baseUrl).toBe( + "https://server.self-serve.windsurf.com/exa.language_server_pb.LanguageServerService/GetChatMessage" + ); + }); +});