From fa038e069d46dd8713ec6bf93243209e6f5e4fd7 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 31 May 2026 10:31:40 -0300 Subject: [PATCH] Merge pull request #2964 from S0yora/feat/trae-solo-provider feat(oauth): add Trae SOLO provider --- open-sse/config/providerRegistry.ts | 21 + open-sse/executors/index.ts | 5 +- open-sse/executors/trae.ts | 481 ++++++++++++++++++ scripts/ad-hoc/diag-trae-auth.mjs | 83 +++ scripts/ad-hoc/smoke-trae.mjs | 61 +++ .../dashboard/providers/[id]/page.tsx | 10 + src/app/api/oauth/trae/import/route.ts | 130 +++++ src/app/authorize/parseCallback.ts | 97 ++++ src/app/authorize/route.ts | 84 +++ src/lib/oauth/constants/oauth.ts | 16 +- src/lib/oauth/providers/trae.ts | 73 ++- src/shared/components/TraeAuthModal.tsx | 334 ++++++++++++ src/shared/components/index.tsx | 1 + src/shared/components/lobeProviderIcons.ts | 4 + src/shared/constants/providers.ts | 32 +- src/shared/validation/schemas.ts | 10 + tests/unit/oauth-trae.test.ts | 22 +- tests/unit/trae-executor.test.ts | 455 +++++++++++++++++ 18 files changed, 1884 insertions(+), 35 deletions(-) create mode 100644 open-sse/executors/trae.ts create mode 100644 scripts/ad-hoc/diag-trae-auth.mjs create mode 100644 scripts/ad-hoc/smoke-trae.mjs create mode 100644 src/app/api/oauth/trae/import/route.ts create mode 100644 src/app/authorize/parseCallback.ts create mode 100644 src/app/authorize/route.ts create mode 100644 src/shared/components/TraeAuthModal.tsx create mode 100644 tests/unit/trae-executor.test.ts diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 212902ab15..429d8143e5 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1132,6 +1132,27 @@ export const REGISTRY: Record = { ], }, + trae: { + id: "trae", + alias: "tr", + format: "openai", + executor: "trae", + baseUrl: "https://core-normal.trae.ai/api/remote/v1", + authType: "oauth", + authHeader: "bearer", + defaultContextLength: 272000, + models: [ + { id: "auto", name: "Auto (Code · Server Picks)" }, + { id: "work", name: "Work (Auto · fast)" }, + { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, + { id: "gemini-3-flash-solo", name: "Gemini 3 Flash" }, + { id: "minimax-m2.7", name: "MiniMax M2.7" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "gpt-5.4", name: "GPT 5.4" }, + { id: "gpt-5.2", name: "GPT 5.2" }, + ], + }, + cursor: { id: "cursor", alias: "cu", diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 8993ab95c8..bce527ce08 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -5,6 +5,7 @@ import { QoderExecutor } from "./qoder.ts"; import { KiroExecutor } from "./kiro.ts"; import { CodexExecutor } from "./codex.ts"; import { CursorExecutor } from "./cursor.ts"; +import { TraeExecutor } from "./trae.ts"; import { DefaultExecutor } from "./default.ts"; import { BedrockExecutor } from "./bedrock.ts"; import { GlmExecutor } from "./glm.ts"; @@ -58,6 +59,7 @@ const executors = { bedrock: new BedrockExecutor(), codex: new CodexExecutor(), cursor: new CursorExecutor(), + trae: new TraeExecutor(), glm: new GlmExecutor("glm"), "glm-cn": new GlmExecutor("glm-cn"), glmt: new GlmExecutor("glmt"), @@ -110,7 +112,7 @@ const executors = { "veoaifree-web": new VeoAIFreeWebExecutor(), "veo-free": new VeoAIFreeWebExecutor(), // Alias "duckduckgo-web": new DuckDuckGoWebExecutor(), - "ddgw": new DuckDuckGoWebExecutor(), // Alias + ddgw: new DuckDuckGoWebExecutor(), // Alias "t3-web": new T3ChatWebExecutor(), t3chat: new T3ChatWebExecutor(), // Alias "inner-ai": new InnerAiExecutor(), @@ -153,6 +155,7 @@ export { QoderExecutor } from "./qoder.ts"; export { KiroExecutor } from "./kiro.ts"; export { CodexExecutor } from "./codex.ts"; export { CursorExecutor } from "./cursor.ts"; +export { TraeExecutor } from "./trae.ts"; export { DefaultExecutor } from "./default.ts"; export { BedrockExecutor } from "./bedrock.ts"; export { GlmExecutor } from "./glm.ts"; diff --git a/open-sse/executors/trae.ts b/open-sse/executors/trae.ts new file mode 100644 index 0000000000..47b306fc93 --- /dev/null +++ b/open-sse/executors/trae.ts @@ -0,0 +1,481 @@ +/** + * TraeExecutor — talks to Trae's remote agent API (solo_agent_remote). + * + * Flow (reverse-engineered from solo.trae.ai web client): + * 1. POST {base}/chat_sessions → { 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). + * The JWT is stored as credentials.accessToken; identity fields (web_id, + * biz_user_id, user_unique_id, scope, tenant, region) live in providerSpecificData. + * + * Model selection: model="auto" → server picks; otherwise model is the upstream + * `name` from GET {base}/models (e.g. gpt-5.2, gemini-3.1-pro, kimi-k2.5). + */ + +import { BaseExecutor, mergeUpstreamExtraHeaders } from "./base.ts"; +import { PROVIDERS } from "../config/constants.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; + +type JsonRecord = Record; +type ChatMessage = { role?: string; content?: unknown }; + +const STREAM_TIMEOUT_MS = parseInt(process.env.TRAE_STREAM_TIMEOUT_MS || "300000", 10); + +function flattenQuery(messages: ChatMessage[]): string { + const parts: string[] = []; + 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 as JsonRecord).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); + } + const text = parts.join("\n\n"); + // Trae expects query as a JSON-encoded string of typed content blocks. + return JSON.stringify([{ type: "text", data: { content: text } }]); +} + +export class TraeExecutor extends BaseExecutor { + constructor() { + super("trae", PROVIDERS["trae"]); + } + + private base(): string { + return (this.config.baseUrl || "https://core-normal.trae.ai/api/remote/v1").replace(/\/$/, ""); + } + + buildHeaders(credentials): Record { + const token = (credentials.accessToken as string) || ""; + const psd = (credentials.providerSpecificData as JsonRecord) || {}; + return { + Authorization: `Cloud-IDE-JWT ${token}`, + "Content-Type": "application/json", + "X-Trae-Client-Type": "web", + "X-Preferenced-Language": (psd.appLanguage as string) || "en", + "x-user-region": (psd.userRegion as string) || "US", + Referer: "https://solo.trae.ai/", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", + }; + } + + /** + * SOLO exposes two session modes (the toggle on solo.trae.ai): + * - "code" (default): full model picker — `auto` plus named models + * (gpt-5.4, kimi-k2.5, gemini-3.1-pro, …). + * - "work": a single, faster "auto" agent with no model picker. + * We surface "work" as its own model id (`trae/work`) so callers can opt into + * the fast lane; any other model id runs in "code" mode. "work" forces the + * auto strategy with an empty model_name, since it has no model selection. + */ + private resolveMode(model: string): { + mode: "code" | "work"; + strategy: "auto" | "manual"; + modelName: string; + } { + 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 }; + } + + private commonParams(psd: JsonRecord, mode: "code" | "work", sessionId?: string): string { + const cp: JsonRecord = { + language: "en-us", + app_language: (psd.appLanguage as string) || "en", + quality: "stable", + app_version: (psd.appVersion as string) || "1.0.0.1229", + web_id: (psd.webId as string) || "", + user_identity: (psd.userIdentity as string) || "Free", + is_freshman: "0", + biz_user_id: (psd.bizUserId as string) || "", + user_unique_id: (psd.userUniqueId as string) || "", + scope: (psd.scope as string) || "marscode-us", + tenant: (psd.tenant as string) || "marscode", + region: (psd.region as string) || "US-East", + aiRegion: (psd.aiRegion as string) || (psd.region as string) || "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. */ + private async createSession( + headers: Record, + query: string, + model: string, + psd: JsonRecord, + signal?: AbortSignal | null + ): Promise<{ sessionId: string; messageId: string }> { + 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 fetch(`${this.base()}/chat_sessions`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: signal || undefined, + }); + 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. + */ + private async streamEvents( + headers: Record, + sessionId: string, + replyTo: string, + onEvent: (ev: string | null, data: JsonRecord) => boolean, + signal?: AbortSignal | null + ): Promise { + const url = `${this.base()}/chat_sessions/${sessionId}/events?reply_to_message_id=${encodeURIComponent(replyTo)}`; + const ctrl = new AbortController(); + // If the caller's signal is already aborted, abort upfront so we don't open + // a network request the consumer no longer wants. + 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 fetch(url, { method: "GET", headers, signal: ctrl.signal }); + 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: string | null = null; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let nl: number; + // SSE frames are separated by lines; process complete lines only. + 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: JsonRecord; + 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, upstreamExtraHeaders }) { + const headers = this.buildHeaders(credentials as JsonRecord); + mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders as Record | null); + const psd = ((credentials as JsonRecord).providerSpecificData as JsonRecord) || {}; + const reqBody = body as { messages?: ChatMessage[] }; + const query = flattenQuery(reqBody.messages || []); + const responseId = `chatcmpl-trae-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + + const errResponse = (status: number, message: string) => + new Response( + JSON.stringify({ + error: { message: sanitizeErrorMessage(message), type: "api_error", code: "" }, + }), + { status, headers: { "Content-Type": "application/json" } } + ); + + let session: { sessionId: string; messageId: string }; + try { + session = await this.createSession( + headers, + query, + model as string, + psd, + signal as AbortSignal + ); + } catch (err) { + return { + response: errResponse(502, err instanceof Error ? err.message : String(err)), + url: this.base(), + headers, + transformedBody: body, + }; + } + + // Shared per-turn state: plan_item thoughts (cumulative, longest wins). + const order: string[] = []; + const thoughts: Record = {}; + let sent = 0; + let usage: JsonRecord | null = null; + let errorEvent: JsonRecord | null = null; + const renderNewText = (data: JsonRecord): string => { + const pid = data.id as string | undefined; + if (!pid) return ""; + if (!(pid in thoughts)) order.push(pid); + const t = (data.thought as string) || ""; + 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: JsonRecord) => + controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`)); + let roleEmitted = false; + try { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }); + roleEmitted = true; + 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 as AbortSignal + ); + void roleEmitted; + 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 as AbortSignal + ); + } catch (err) { + return { + response: errResponse(502, err instanceof Error ? 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: JsonRecord = { + 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, + }; + } + + /** + * Headless refresh of the 14-day Cloud-IDE-JWT using the long-lived (~7 month) + * RefreshToken captured during /authorize. Mirrors the desktop client's call to + * POST {apiHost}/cloudide/api/v3/trae/oauth/ExchangeToken + * { ClientID, RefreshToken, ClientSecret: "-", UserID: "" } + * The response uses the same envelope as GetUserToken: + * { ResponseMetadata: { Error?: { Code, Message } }, Result: { Token, RefreshToken, + * TokenExpireAt, RefreshExpireAt, TokenExpireDuration, UserID, TenantID } } + * On Error.Code === "RefreshTokenInvalid" the caller must re-authorize via + * the browser flow — we throw so the connection is marked unusable. + */ + async refreshCredentials(credentials) { + const psd = (credentials?.providerSpecificData as JsonRecord) || {}; + const refreshToken = credentials?.refreshToken as string | undefined; + if (!refreshToken) return null; + const host = ((psd.host as string) || "https://api-us-east.trae.ai").replace(/\/$/, ""); + const clientId = (psd.clientId as string) || "en1oxy7wnw8j9n"; + const url = `${host}/cloudide/api/v3/trae/oauth/ExchangeToken`; + const body = { ClientID: clientId, RefreshToken: refreshToken, ClientSecret: "-", UserID: "" }; + + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const text = await res.text(); + if (!res.ok) { + throw new Error(`Trae ExchangeToken HTTP ${res.status}: ${text.slice(0, 200)}`); + } + let parsed; + try { + parsed = JSON.parse(text); + } catch { + throw new Error("Trae ExchangeToken: response was not JSON"); + } + const errCode = parsed?.ResponseMetadata?.Error?.Code; + if (errCode) { + // Surface invalid-refresh to the caller — BaseExecutor.execute swallows the + // refresh exception, but the next request will hit 401 and trigger fallback; + // we also leave the (stale) accessToken in place so observability shows why. + throw new Error(`Trae ExchangeToken error: ${errCode}`); + } + const result = parsed?.Result; + if (!result?.Token) { + throw new Error("Trae ExchangeToken: response missing Result.Token"); + } + return { + accessToken: result.Token as string, + refreshToken: (result.RefreshToken as string) || refreshToken, + expiresAt: result.TokenExpireAt + ? new Date(Number(result.TokenExpireAt)).toISOString() + : undefined, + }; + } +} + +export default TraeExecutor; diff --git a/scripts/ad-hoc/diag-trae-auth.mjs b/scripts/ad-hoc/diag-trae-auth.mjs new file mode 100644 index 0000000000..fcacb0b3fd --- /dev/null +++ b/scripts/ad-hoc/diag-trae-auth.mjs @@ -0,0 +1,83 @@ +// Trae SOLO auth diagnostic — probes the live API with your token across several +// Authorization variants, so we can see which one (if any) the server accepts. +// +// Usage: +// node scripts/ad-hoc/diag-trae-auth.mjs +// TRAE_TOKEN=eyJ... node scripts/ad-hoc/diag-trae-auth.mjs +// +// Paste ONLY the token value (no "Cloud-IDE-JWT " prefix). The token is never +// printed in full; only its length + last 6 chars are shown for sanity. + +const token = (process.argv[2] || process.env.TRAE_TOKEN || "").trim(); +if (!token) { + console.error("No token. Pass it as arg or set TRAE_TOKEN."); + process.exit(1); +} +console.log(`token: len=${token.length} …${token.slice(-6)}`); + +const BASE = "https://core-normal.trae.ai/api/remote/v1"; +const MODELS = `${BASE}/models?functions=solo_agent_remote,solo_work_remote`; + +const commonHeaders = { + "Content-Type": "application/json", + "X-Trae-Client-Type": "web", + "X-Preferenced-Language": "en", + "x-user-region": "US", + Referer: "https://solo.trae.ai/", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", +}; + +// Each variant = a set of auth-bearing headers to try. +const variants = [ + { + name: "Authorization: Cloud-IDE-JWT ", + headers: { Authorization: `Cloud-IDE-JWT ${token}` }, + }, + { name: "Authorization: Bearer ", headers: { Authorization: `Bearer ${token}` } }, + { name: "Authorization: (raw)", headers: { Authorization: token } }, + { name: "Cloud-IDE-JWT: (header)", headers: { "Cloud-IDE-JWT": token } }, + { name: "x-cloud-ide-jwt: ", headers: { "x-cloud-ide-jwt": token } }, + { name: "x-cloud-ide-token: ", headers: { "x-cloud-ide-token": token } }, +]; + +async function probe(label, url, method, headers) { + try { + const res = await fetch(url, { + method, + headers: { ...commonHeaders, ...headers }, + body: + method === "POST" + ? JSON.stringify({ mode: "code", env: "remote", origin: "web" }) + : undefined, + }); + const text = await res.text(); + const snippet = text.replace(/\s+/g, " ").slice(0, 160); + const ok = res.ok && !/"code"\s*:\s*1001/.test(text) && !/not able to authenticate/i.test(text); + console.log(`${ok ? "✅" : "❌"} [${res.status}] ${label}\n ${snippet}`); + return ok; + } catch (e) { + console.log(`💥 ${label} → ${e?.message || e}`); + return false; + } +} + +console.log(`\n=== GET ${MODELS} ===`); +let anyOk = false; +for (const v of variants) { + const ok = await probe(v.name, MODELS, "GET", v.headers); + anyOk = anyOk || ok; +} + +console.log(`\n=== POST ${BASE}/chat_sessions (only the first auth variant, sanity) ===`); +await probe(variants[0].name, `${BASE}/chat_sessions`, "POST", variants[0].headers); + +console.log( + anyOk + ? "\n→ At least one variant authenticated. Tell me which ✅ line, I'll set the executor to it." + : "\n→ No header-only variant worked. The web client likely authenticates via a COOKIE, not a\n" + + " bearer header. Next step: in DevTools → Network, right-click a request to\n" + + " core-normal.trae.ai → Copy → Copy as cURL, and paste it here (redact the token). That\n" + + " shows the exact auth header/cookie the server actually accepts." +); diff --git a/scripts/ad-hoc/smoke-trae.mjs b/scripts/ad-hoc/smoke-trae.mjs new file mode 100644 index 0000000000..fc6f68d6b8 --- /dev/null +++ b/scripts/ad-hoc/smoke-trae.mjs @@ -0,0 +1,61 @@ +// End-to-end smoke test: drives TraeExecutor against the real Trae API with your +// JWT from trae_solo.env (kept outside the repo), printing content + usage. +// Run: node --import tsx/esm scripts/ad-hoc/smoke-trae.mjs + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const envPath = path.resolve(__dirname, "../../../trae_solo.env"); +if (!fs.existsSync(envPath)) { + console.error(`Не найден ${envPath}. Положи туда TRAE_TOKEN= и TRAE_WEB_ID= и т.д.`); + process.exit(1); +} +const cfg = Object.fromEntries( + fs + .readFileSync(envPath, "utf8") + .split("\n") + .filter((l) => l && !l.startsWith("#") && l.includes("=")) + .map((l) => { + const i = l.indexOf("="); + return [l.slice(0, i), l.slice(i + 1)]; + }) +); + +const { TraeExecutor } = await import("../../open-sse/executors/trae.ts"); +const ex = new TraeExecutor(); + +const credentials = { + accessToken: cfg.TRAE_TOKEN, + providerSpecificData: { + webId: cfg.TRAE_WEB_ID || "", + bizUserId: cfg.TRAE_BIZ_USER_ID || "", + userUniqueId: cfg.TRAE_USER_UNIQUE_ID || "", + scope: cfg.TRAE_SCOPE || "marscode-us", + tenant: cfg.TRAE_TENANT || "marscode", + region: cfg.TRAE_REGION || "US-East", + aiRegion: cfg.TRAE_AIREGION || cfg.TRAE_REGION || "US-East", + appLanguage: cfg.TRAE_APP_LANGUAGE || "en", + appVersion: cfg.TRAE_APP_VERSION || "1.0.0.1229", + }, +}; + +const model = process.argv[2] || "auto"; +const prompt = process.argv.slice(3).join(" ") || "Ответь одним словом: столица Франции?"; + +console.log(`[smoke] model=${model} prompt=${JSON.stringify(prompt)}`); +const { response } = await ex.execute({ + model, + body: { messages: [{ role: "user", content: prompt }] }, + stream: false, + credentials, +}); +const text = await response.text(); +if (response.status !== 200) { + console.error(`[smoke] HTTP ${response.status}\n${text}`); + process.exit(1); +} +const json = JSON.parse(text); +console.log("content:", JSON.stringify(json.choices?.[0]?.message?.content)); +console.log("usage: ", json.usage); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 4271047502..6ecd6f130e 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -27,6 +27,7 @@ import { OAuthModal, KiroOAuthWrapper, CursorAuthModal, + TraeAuthModal, Toggle, Select, ProxyConfigModal, @@ -4708,6 +4709,15 @@ export default function ProviderDetailPage() { setShowOAuthModal(false); }} /> + ) : providerId === "trae" ? ( + { + setShowOAuthModal(false); + }} + /> ) : ( ), and + * pastes it here. JWT lifetime is ~14 days; re-import on expiry. + * + * Request body (JSON): + * accessToken — required, the Cloud-IDE-JWT + * webId — optional, common_params.web_id + * bizUserId — optional, common_params.biz_user_id + * userUniqueId — optional, common_params.user_unique_id + * scope — optional, default "marscode-us" + * tenant — optional, default "marscode" + * region — optional, default "US-East" + */ +async function requireOAuthImportAuth(request: Request) { + if (!(await isAuthRequired(request))) return null; + if (await isAuthenticated(request)) return null; + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); +} + +export async function POST(request: Request) { + const authResponse = await requireOAuthImportAuth(request); + if (authResponse) return authResponse; + + let rawBody; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json( + { + error: { + message: "Invalid request", + details: [{ field: "body", message: "Invalid JSON body" }], + }, + }, + { status: 400 } + ); + } + + try { + const validation = validateBody(traeImportSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const { accessToken, webId, bizUserId, userUniqueId, scope, tenant, region } = validation.data; + + const connection: any = await createProviderConnection({ + provider: "trae", + authType: "oauth", + accessToken, + refreshToken: null, + // Trae JWTs we've observed expire ~14 days after issuance; expose that + // hint to the dashboard so the user gets a heads-up before they break. + expiresAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(), + providerSpecificData: { + webId: webId || "", + bizUserId: bizUserId || "", + userUniqueId: userUniqueId || "", + scope: scope || "marscode-us", + tenant: tenant || "marscode", + region: region || "US-East", + aiRegion: region || "US-East", + appLanguage: "en", + appVersion: "1.0.0.1229", + userRegion: "US", + userIdentity: "Free", + authMethod: "imported", + }, + testStatus: "active", + }); + + return NextResponse.json({ + success: true, + connection: { id: connection.id, provider: connection.provider }, + }); + } catch (error: any) { + console.error("Trae import token error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +/** + * GET /api/oauth/trae/import + * Returns field metadata so a generic dashboard UI can render the paste form. + */ +export async function GET(request: Request) { + const authResponse = await requireOAuthImportAuth(request); + if (authResponse) return authResponse; + + return NextResponse.json({ + provider: "trae", + method: "import_token", + instructions: + "Sign in to solo.trae.ai, then copy the JWT sent in the 'Authorization: Cloud-IDE-JWT ' header (DevTools → Network → any POST to core-normal.trae.ai). Paste it as accessToken. Optionally provide webId/bizUserId/userUniqueId for full identity propagation.", + requiredFields: [ + { + name: "accessToken", + label: "Access Token (Cloud-IDE-JWT)", + description: "JWT from Authorization header on solo.trae.ai requests.", + type: "textarea", + required: true, + }, + { name: "webId", label: "Web ID", description: "common_params.web_id", type: "text" }, + { + name: "bizUserId", + label: "Biz User ID", + description: "common_params.biz_user_id", + type: "text", + }, + { + name: "userUniqueId", + label: "User Unique ID", + description: "common_params.user_unique_id (often equals bizUserId)", + type: "text", + }, + { name: "scope", label: "Scope", description: "default: marscode-us", type: "text" }, + { name: "tenant", label: "Tenant", description: "default: marscode", type: "text" }, + { name: "region", label: "Region", description: "default: US-East", type: "text" }, + ], + }); +} diff --git a/src/app/authorize/parseCallback.ts b/src/app/authorize/parseCallback.ts new file mode 100644 index 0000000000..ff86d767ca --- /dev/null +++ b/src/app/authorize/parseCallback.ts @@ -0,0 +1,97 @@ +/** + * Pure parser for the Trae SOLO /authorize callback query string. Extracted + * from route.ts so it can be unit-tested without touching the DB layer. + * + * Returns the credential bundle that the route hands to createProviderConnection, + * or a structured error if the payload is missing/malformed. + */ +export type ParsedTraeCallback = { + ok: true; + record: { + provider: "trae"; + authType: "oauth"; + accessToken: string; + refreshToken: string | null; + expiresAt: string | null; + email: string | null; + providerSpecificData: { + userId: string; + tenantId: string; + bizUserId: string; + userUniqueId: string; + webId: string; + scope: "marscode-us"; + tenant: "marscode"; + region: string; + aiRegion: string; + host: string; + screenName: string | null; + clientId: string; + refreshExpireAt: number | null; + authMethod: "oauth_callback"; + }; + testStatus: "active"; + }; +}; + +export type ParseError = { ok: false; error: string }; + +export function parseTraeCallbackQuery(q: URLSearchParams): ParsedTraeCallback | ParseError { + const userJwtRaw = q.get("userJwt"); + if (!userJwtRaw) return { ok: false, error: "Missing userJwt in callback" }; + + let userJwt: Record; + try { + userJwt = JSON.parse(userJwtRaw); + } catch { + return { ok: false, error: "Malformed userJwt payload" }; + } + + const token = userJwt.Token as string | undefined; + if (!token) return { ok: false, error: "userJwt.Token missing" }; + const refresh = (userJwt.RefreshToken as string) || q.get("refreshToken") || null; + const tokenExpiresAtMs = Number(userJwt.TokenExpireAt) || 0; + const refreshExpiresAtMs = Number(userJwt.RefreshExpireAt || q.get("refreshExpireAt")) || 0; + + let info: Record = {}; + const userInfoRaw = q.get("userInfo"); + if (userInfoRaw) { + try { + info = JSON.parse(userInfoRaw); + } catch { + // userInfo is best-effort metadata — fall back to defaults silently. + } + } + + const userId = (info.UserID as string) || ""; + const region = (info.Region as string) || "US-East"; + + return { + ok: true, + record: { + provider: "trae", + authType: "oauth", + accessToken: token, + refreshToken: refresh, + expiresAt: tokenExpiresAtMs ? new Date(tokenExpiresAtMs).toISOString() : null, + email: (info.NonPlainTextEmail as string) || null, + providerSpecificData: { + userId, + tenantId: (info.TenantID as string) || "", + bizUserId: userId, + userUniqueId: userId, + webId: userId, + scope: "marscode-us", + tenant: "marscode", + region, + aiRegion: (info.AIRegion as string) || region, + host: q.get("host") || "https://api-us-east.trae.ai", + screenName: (info.ScreenName as string) || null, + clientId: (userJwt.ClientID as string) || "en1oxy7wnw8j9n", + refreshExpireAt: refreshExpiresAtMs || null, + authMethod: "oauth_callback", + }, + testStatus: "active", + }, + }; +} diff --git a/src/app/authorize/route.ts b/src/app/authorize/route.ts new file mode 100644 index 0000000000..b2a0954af6 --- /dev/null +++ b/src/app/authorize/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; +import { createProviderConnection } from "@/models"; +import { parseTraeCallbackQuery } from "./parseCallback"; + +/** + * GET /authorize + * + * Loopback callback for the Trae SOLO desktop OAuth flow (auth_from=solo). + * Trae's authorize server validates that auth_callback_url ends with the + * literal `/authorize` path — any other path makes the page short-circuit + * to "Login Failed". So this handler lives at the app root, not under + * /api/oauth/trae/. The provider tag is implicit (only Trae uses /authorize). + * + * Receives the redirect from https://www.trae.ai/authorization after the user + * confirms login. Trae's auth server packs the entire credential set into + * query parameters (no separate token-exchange HTTP call exists): + * + * userJwt — JSON string with { ClientID, Token, RefreshToken, TokenExpireAt, + * RefreshExpireAt, TokenExpireDuration } + * userInfo — JSON string with { UserID, TenantID, Region, AIRegion, ... } + * refreshToken, loginTraceID, host, refreshExpireAt, userRegion, scope — flat fields + * + * We parse the bundle, persist a connection via `createProviderConnection` + * (which encrypts the token), and return an HTML page that postMessages the + * opening window before closing itself — that's how TraeAuthModal knows + * the import succeeded. + * + * State validation: the caller passes its UUID as `login_trace_id` in the + * authorize URL; Trae echoes it back as `loginTraceID`. The modal verifies + * the echoed state before trusting the postMessage. + */ +function htmlClose(message: Record): NextResponse { + // Embedding values: only emit the small/sanitized status payload — never the + // raw token. We post to the loopback origin pair (localhost + 127.0.0.1) on + // this same port rather than "*": Trae forces the callback onto 127.0.0.1, + // but the dashboard opener is usually on localhost, so a single + // window.location.origin target would silently drop the message. Restricting + // to the two known loopback hosts keeps it secure (CWE-359) and working. + const safe = JSON.stringify({ + type: "trae-oauth-callback", + ...message, + }).replace(/ +

Trae authorization ${message.success ? "✓" : "failed"}

+

${message.success ? "You can close this window." : "Return to the dashboard."}

+ + `, + { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } } + ); +} + +export async function GET(request: Request) { + const url = new URL(request.url); + const q = url.searchParams; + const parsed = parseTraeCallbackQuery(q); + if (!parsed.ok) { + return htmlClose({ success: false, error: parsed.error }); + } + try { + const connection: any = await createProviderConnection(parsed.record); + return htmlClose({ + success: true, + connectionId: connection.id, + loginTraceId: q.get("loginTraceID") || null, + }); + } catch (err: any) { + console.error("[trae callback] error:", err); + return htmlClose({ success: false, error: "Internal error during callback" }); + } +} diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 9d4f29c731..ea10329cc6 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -348,9 +348,19 @@ export const TRAE_CONFIG = { chatEndpoint: "/v1/chat/completions", // Trae website — users retrieve their token here after signing in webUrl: "https://trae.ai", - // Token storage note for users — no automated extraction path is available - // because Trae does not expose a public SQLite / keychain location yet. - tokenNote: "Sign in to Trae IDE, then copy your API token from the account settings.", + // SOLO remote agent base — the executor's real upstream. Also set as the + // provider registry baseUrl, which is the source of truth at request time. + soloApiEndpoint: "https://core-normal.trae.ai/api/remote/v1", + // SOLO model catalogue endpoint (relative to soloApiEndpoint). + modelsEndpoint: "/models?functions=solo_agent_remote,solo_work_remote", + // Authorization scheme: `Authorization: Cloud-IDE-JWT ` (RS256). + authScheme: "Cloud-IDE-JWT", + // Observed Cloud-IDE-JWT lifetime — drives default expiry hints. + tokenLifetimeDays: 14, + // Token storage note — solo.trae.ai exposes no public SQLite/keychain path, + // so the token is captured via the /authorize flow or pasted manually. + tokenNote: + "Authorize via trae.ai in the popup, or sign in to solo.trae.ai and paste the Cloud-IDE-JWT from the Authorization header (~14-day lifetime).", }; // Windsurf / Devin CLI Configuration diff --git a/src/lib/oauth/providers/trae.ts b/src/lib/oauth/providers/trae.ts index ddc45134c4..d60081e5c9 100644 --- a/src/lib/oauth/providers/trae.ts +++ b/src/lib/oauth/providers/trae.ts @@ -1,31 +1,76 @@ import { TRAE_CONFIG } from "../constants/oauth"; /** - * Trae IDE OAuth Provider (Import Token) + * Trae SOLO OAuth provider — token-import flow (no public OAuth client). * - * Trae is an AI-native IDE by ByteDance. Authentication relies on a personal - * API token that the user copies from the Trae account settings page and pastes - * into the OmniRoute connection form. + * ByteDance has not published a public OAuth client_id/secret or a device-code + * flow for third-party integrations, so the credential is the Cloud-IDE-JWT + * captured either via the browser /authorize popup (src/app/authorize/route.ts) + * or by signing in to solo.trae.ai and pasting the token sent in the + * `Authorization: Cloud-IDE-JWT ` header (~14-day lifetime). * - * Why import_token and not device_code / authorization_code: - * ByteDance has not published a public OAuth client_id/secret for the Trae - * IDE, nor documented a device-code or browser-redirect flow for third-party - * integrations. The authHint in providers.ts (see IDE_PROVIDER_IDS) confirms - * that "paste your API token" is the supported onboarding path. + * mapTokens enriches providerSpecificData with the identity fields the SOLO + * remote agent requires inside its `common_params` payload (web_id / + * biz_user_id / user_unique_id / scope / tenant / region). The dedicated import + * route and /authorize callback also build this record directly; mapTokens keeps + * the device-code / exchange code paths consistent if Trae ever exposes one. * * TODO(trae-auth): if ByteDance publishes a public OAuth application for Trae, * upgrade flowType to "device_code" or "authorization_code_pkce" and embed * the client credentials via resolvePublicCred() (Hard Rule #11). - * Reference: https://docs.trae.ai (check for OAuth / CLI integration docs) */ +type TraeRawTokens = { + accessToken?: string; + access_token?: string; + expiresIn?: number; + refreshToken?: string | null; + machineId?: string; + webId?: string; + web_id?: string; + bizUserId?: string; + biz_user_id?: string; + userUniqueId?: string; + user_unique_id?: string; + scope?: string; + tenant?: string; + region?: string; + aiRegion?: string; + ai_region?: string; + appLanguage?: string; + app_language?: string; + appVersion?: string; + app_version?: string; + userRegion?: string; + user_region?: string; + userIdentity?: string; + user_identity?: string; +}; + export const trae = { config: TRAE_CONFIG, flowType: "import_token", - mapTokens: (tokens: { accessToken: string; expiresIn?: number; machineId?: string }) => ({ - accessToken: tokens.accessToken, - refreshToken: null, - expiresIn: tokens.expiresIn || 86400, + mapTokens: (tokens: TraeRawTokens) => ({ + accessToken: tokens.accessToken || tokens.access_token, + // Pasted/solo JWTs have no refresh token; the /authorize callback persists + // its own RefreshToken directly (see parseCallback.ts) rather than via here. + refreshToken: tokens.refreshToken ?? null, + // Default to the observed ~14-day Cloud-IDE-JWT lifetime when the caller did + // not supply an explicit expiry, so connection cooldown / expiry hints behave + // sensibly out of the box (TRAE_CONFIG.tokenLifetimeDays). + expiresIn: tokens.expiresIn || TRAE_CONFIG.tokenLifetimeDays * 24 * 60 * 60, providerSpecificData: { + webId: tokens.webId || tokens.web_id || "", + bizUserId: tokens.bizUserId || tokens.biz_user_id || "", + userUniqueId: tokens.userUniqueId || tokens.user_unique_id || "", + scope: tokens.scope || "marscode-us", + tenant: tokens.tenant || "marscode", + region: tokens.region || "US-East", + aiRegion: tokens.aiRegion || tokens.ai_region || tokens.region || "US-East", + appLanguage: tokens.appLanguage || tokens.app_language || "en", + appVersion: tokens.appVersion || tokens.app_version || "1.0.0.1229", + userRegion: tokens.userRegion || tokens.user_region || "US", + userIdentity: tokens.userIdentity || tokens.user_identity || "Free", + // Preserved for callers that key off a machine id (e.g. the IDE flow). machineId: tokens.machineId, authMethod: "imported", }, diff --git a/src/shared/components/TraeAuthModal.tsx b/src/shared/components/TraeAuthModal.tsx new file mode 100644 index 0000000000..94d1508787 --- /dev/null +++ b/src/shared/components/TraeAuthModal.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Modal from "./Modal"; +import Button from "./Button"; +import Input from "./Input"; + +const TRAE_CLIENT_ID = "en1oxy7wnw8j9n"; + +function uuid(): string { + const c = (globalThis.crypto || (globalThis as any).crypto) as Crypto | undefined; + if (c?.randomUUID) return c.randomUUID(); + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (ch) => { + const r = (Math.random() * 16) | 0; + return (ch === "x" ? r : (r & 0x3) | 0x8).toString(16); + }); +} + +function randomHex(bytes: number): string { + const buf = new Uint8Array(bytes); + (globalThis.crypto || (globalThis as any).crypto).getRandomValues(buf); + return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function randomDigits(n: number): string { + let s = ""; + while (s.length < n) s += Math.floor(Math.random() * 1e10).toString(); + return s.slice(0, n); +} + +function buildTraeAuthorizeUrl(callbackUrl: string, traceId: string): string { + // Per-attempt machine/device IDs — Trae doesn't tie tokens to a specific + // machine_id, but the field is required and is echoed into provider metadata. + const machineId = randomHex(32); + const deviceId = randomDigits(19); + const params = new URLSearchParams({ + login_version: "1", + auth_from: "solo", + login_channel: "native_ide", + plugin_version: "2.3.24254", + auth_type: "local", + client_id: TRAE_CLIENT_ID, + redirect: "0", + login_trace_id: traceId, + auth_callback_url: callbackUrl, + machine_id: machineId, + device_id: deviceId, + x_device_id: deviceId, + x_machine_id: machineId, + x_device_brand: "Mac14,7", + x_device_type: "mac", + x_os_version: "macOS 26.4.1", + x_env: "", + x_app_version: "0.1.7", + x_app_type: "stable", + // Match what the SOLO desktop client sends: hide_saas_login=true. With + // auth_from=solo, trae.ai's authorize page is *only* a confirmation gate + // for an already-cookied session — so leaving the SaaS sign-in surface + // visible (false) causes the server to short-circuit to "Login Failed". + // The user must be signed in on trae.ai in the same browser; we surface + // that in the modal copy and provide an "Open solo.trae.ai" button. + hide_saas_login: "true", + }); + return `https://www.trae.ai/authorization?${params.toString()}`; +} + +type TraeAuthModalProps = { + isOpen: boolean; + onSuccess?: () => void; + onClose: () => void; + reauthConnection?: unknown; +}; + +/** + * Trae SOLO Auth Modal — paste the Cloud-IDE-JWT from solo.trae.ai. + * + * Trae has no public OAuth or local credential store like Cursor: the user + * signs in to solo.trae.ai in a browser, copies the JWT sent in the + * Authorization header (Cloud-IDE-JWT scheme), and pastes it here. JWT + * lifetime is ~14 days; re-import on expiry. + */ +export default function TraeAuthModal({ + isOpen, + onSuccess, + onClose, + reauthConnection: _, +}: TraeAuthModalProps) { + const [accessToken, setAccessToken] = useState(""); + const [webId, setWebId] = useState(""); + const [bizUserId, setBizUserId] = useState(""); + const [userUniqueId, setUserUniqueId] = useState(""); + const [scope, setScope] = useState("marscode-us"); + const [tenant, setTenant] = useState("marscode"); + const [region, setRegion] = useState("US-East"); + const [error, setError] = useState(null); + const [importing, setImporting] = useState(false); + const [authorizing, setAuthorizing] = useState(false); + const popupRef = useRef(null); + const traceIdRef = useRef(null); + + // Listen for postMessage from the callback page (window.opener.postMessage). + // Only act on messages with our type tag, and verify the loginTraceId matches + // the one we sent in the authorize URL — protects against unrelated postMessages. + useEffect(() => { + if (!isOpen) return; + const onMessage = (ev: MessageEvent) => { + const m = ev.data as { + type?: string; + success?: boolean; + error?: string; + loginTraceId?: string; + } | null; + if (!m || m.type !== "trae-oauth-callback") return; + // Accept this window's origin OR the sibling loopback host: the dashboard + // usually runs on localhost while Trae forces the callback onto 127.0.0.1, + // so they are different origins by design. Restrict to that known pair + // (never wildcard), then rely on the random loginTraceId for CSRF. + const here = window.location; + const altHost = + here.hostname === "127.0.0.1" + ? "localhost" + : here.hostname === "localhost" + ? "127.0.0.1" + : null; + const allowedOrigins = new Set([here.origin]); + if (altHost) + allowedOrigins.add(`${here.protocol}//${altHost}${here.port ? `:${here.port}` : ""}`); + if (!allowedOrigins.has(ev.origin)) return; + if (!traceIdRef.current || m.loginTraceId !== traceIdRef.current) return; + setAuthorizing(false); + if (m.success) { + onSuccess?.(); + onClose(); + } else { + setError(m.error || "Authorization failed"); + } + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [isOpen, onSuccess, onClose]); + + const handleAuthorizeWithBrowser = () => { + setError(null); + setAuthorizing(true); + const traceId = uuid(); + traceIdRef.current = traceId; + // Trae's authorize endpoint validates two things about auth_callback_url: + // 1. host must be a loopback IP (127.0.0.1) — "localhost" hostname gets + // rejected with "Login Failed". + // 2. path must end with `/authorize` — any other path (e.g. our earlier + // "/api/oauth/trae/callback") also short-circuits to "Login Failed". + // The receiving handler therefore lives at the app root (src/app/authorize). + const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80"); + const callbackUrl = `http://127.0.0.1:${port}/authorize`; + const authUrl = buildTraeAuthorizeUrl(callbackUrl, traceId); + const w = window.open(authUrl, "trae-oauth", "width=520,height=720"); + if (!w) { + setAuthorizing(false); + setError("Popup blocked — allow popups for this site, or paste the token manually below."); + return; + } + popupRef.current = w; + // If the user closes the popup without completing, drop the spinner. + const poll = setInterval(() => { + if (w.closed) { + clearInterval(poll); + setAuthorizing((prev) => { + if (prev) setError("Authorization window was closed before completing."); + return false; + }); + } + }, 700); + }; + + const handleImportToken = async () => { + if (!accessToken.trim()) { + setError("Access token is required."); + return; + } + setImporting(true); + setError(null); + try { + const body: Record = { accessToken: accessToken.trim() }; + if (webId.trim()) body.webId = webId.trim(); + if (bizUserId.trim()) body.bizUserId = bizUserId.trim(); + if (userUniqueId.trim()) body.userUniqueId = userUniqueId.trim(); + if (scope.trim()) body.scope = scope.trim(); + if (tenant.trim()) body.tenant = tenant.trim(); + if (region.trim()) body.region = region.trim(); + + const res = await fetch("/api/oauth/trae/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error( + typeof data.error === "string" ? data.error : data.error?.message || "Import failed" + ); + } + onSuccess?.(); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setImporting(false); + } + }; + + return ( + +
+ {/* Primary path: browser-based OAuth via trae.ai/authorization */} +
+

+ Authorize via trae.ai in a popup. The popup will + close itself once the token has been imported. +

+

+ Important: you must be logged in to{" "} + trae.ai in this browser first. The authorize + page only confirms an existing session — it cannot sign you in. If you see "Login + Failed", click "Open solo.trae.ai", sign in, then return here. +

+
+ + +
+
+ +
+ + or paste a token manually + +
+ +
+

+ Sign in to solo.trae.ai, open DevTools → Network, + send any chat message, and copy the JWT from the{" "} + Authorization: Cloud-IDE-JWT <token> request + header. JWT lifetime is ~14 days. Optional identity fields come from{" "} + common_params in the same request body. +

+
+ +
+ +