Merge pull request #2964 from S0yora/feat/trae-solo-provider

feat(oauth): add Trae SOLO provider
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-05-31 10:31:40 -03:00
committed by diegosouzapw
parent bc46eba8f6
commit fa038e069d
18 changed files with 1884 additions and 35 deletions

View File

@@ -1132,6 +1132,27 @@ export const REGISTRY: Record<string, RegistryEntry> = {
],
},
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",

View File

@@ -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";

481
open-sse/executors/trae.ts Normal file
View File

@@ -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 <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<string, unknown>;
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<string, string> {
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<string, string>,
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<string, string>,
sessionId: string,
replyTo: string,
onEvent: (ev: string | null, data: JsonRecord) => boolean,
signal?: AbortSignal | null
): Promise<void> {
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<string, string> | 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<string, string> = {};
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;

View File

@@ -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 <YOUR_CLOUD_IDE_JWT>
// 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 <t>",
headers: { Authorization: `Cloud-IDE-JWT ${token}` },
},
{ name: "Authorization: Bearer <t>", headers: { Authorization: `Bearer ${token}` } },
{ name: "Authorization: <t> (raw)", headers: { Authorization: token } },
{ name: "Cloud-IDE-JWT: <t> (header)", headers: { "Cloud-IDE-JWT": token } },
{ name: "x-cloud-ide-jwt: <t>", headers: { "x-cloud-ide-jwt": token } },
{ name: "x-cloud-ide-token: <t>", 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."
);

View File

@@ -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);

View File

@@ -27,6 +27,7 @@ import {
OAuthModal,
KiroOAuthWrapper,
CursorAuthModal,
TraeAuthModal,
Toggle,
Select,
ProxyConfigModal,
@@ -4708,6 +4709,15 @@ export default function ProviderDetailPage() {
setShowOAuthModal(false);
}}
/>
) : providerId === "trae" ? (
<TraeAuthModal
isOpen={showOAuthModal}
reauthConnection={reauthConnection}
onSuccess={handleOAuthSuccess}
onClose={() => {
setShowOAuthModal(false);
}}
/>
) : (
<OAuthModal
isOpen={showOAuthModal}

View File

@@ -0,0 +1,130 @@
import { NextResponse } from "next/server";
import { createProviderConnection } from "@/models";
import { traeImportSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
/**
* POST /api/oauth/trae/import
*
* Persist a pasted Trae SOLO Cloud-IDE-JWT (plus optional identity fields).
* No public OAuth round-trip exists for solo.trae.ai — the user signs in via
* the web client, copies the JWT (Authorization: Cloud-IDE-JWT <token>), 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 <token>' 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" },
],
});
}

View File

@@ -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<string, unknown>;
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<string, unknown> = {};
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",
},
};
}

View File

@@ -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<string, unknown>): 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(/</g, "\\u003c");
return new NextResponse(
`<!doctype html><html><body style="font:16px sans-serif;padding:40px">
<h2 style="margin:0 0 8px">Trae authorization ${message.success ? "✓" : "failed"}</h2>
<p>${message.success ? "You can close this window." : "Return to the dashboard."}</p>
<script>
(function () {
try {
if (!window.opener) return;
var msg = ${safe};
var loc = window.location;
var targets = [loc.origin];
var alt = loc.hostname === "127.0.0.1" ? "localhost" : loc.hostname === "localhost" ? "127.0.0.1" : null;
if (alt) targets.push(loc.protocol + "//" + alt + (loc.port ? ":" + loc.port : ""));
targets.forEach(function (t) { try { window.opener.postMessage(msg, t); } catch (e) {} });
} catch (e) {}
})();
setTimeout(function () { window.close(); }, ${message.success ? 800 : 4000});
</script>
</body></html>`,
{ 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" });
}
}

View File

@@ -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 <token>` (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

View File

@@ -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 <token>` 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",
},

View File

@@ -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<string | null>(null);
const [importing, setImporting] = useState(false);
const [authorizing, setAuthorizing] = useState(false);
const popupRef = useRef<Window | null>(null);
const traceIdRef = useRef<string | null>(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<string, string> = { 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 (
<Modal isOpen={isOpen} title="Connect Trae SOLO" onClose={onClose}>
<div className="flex flex-col gap-4">
{/* Primary path: browser-based OAuth via trae.ai/authorization */}
<div className="bg-emerald-50 dark:bg-emerald-900/20 p-4 rounded-lg border border-emerald-200 dark:border-emerald-800">
<p className="text-sm text-emerald-900 dark:text-emerald-200 mb-2">
Authorize via <span className="font-mono">trae.ai</span> in a popup. The popup will
close itself once the token has been imported.
</p>
<p className="text-xs text-emerald-800 dark:text-emerald-300 mb-3">
<strong>Important:</strong> you must be logged in to{" "}
<span className="font-mono">trae.ai</span> in <em>this</em> browser first. The authorize
page only confirms an existing session it cannot sign you in. If you see &quot;Login
Failed&quot;, click &quot;Open solo.trae.ai&quot;, sign in, then return here.
</p>
<div className="flex gap-2">
<Button onClick={handleAuthorizeWithBrowser} disabled={authorizing} fullWidth>
{authorizing ? "Waiting for trae.ai…" : "Authorize with Browser"}
</Button>
<Button
onClick={() => window.open("https://solo.trae.ai/", "_blank", "noopener,noreferrer")}
variant="ghost"
fullWidth
>
Open solo.trae.ai
</Button>
</div>
</div>
<div className="flex items-center gap-3 text-xs text-text-muted">
<span className="flex-1 border-t border-border" />
or paste a token manually
<span className="flex-1 border-t border-border" />
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-200">
Sign in to <span className="font-mono">solo.trae.ai</span>, open DevTools Network,
send any chat message, and copy the JWT from the{" "}
<span className="font-mono">Authorization: Cloud-IDE-JWT &lt;token&gt;</span> request
header. JWT lifetime is ~14 days. Optional identity fields come from{" "}
<span className="font-mono">common_params</span> in the same request body.
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Access Token (Cloud-IDE-JWT) <span className="text-red-500">*</span>
</label>
<textarea
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
placeholder="eyJhbGciOiJSUzI1NiIs..."
rows={3}
className="w-full px-3 py-2 text-sm font-mono border border-border rounded-lg bg-background focus:outline-none focus:border-primary resize-none"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium mb-1">
Web ID <span className="text-text-muted text-xs">optional</span>
</label>
<Input
value={webId}
onChange={(e) => setWebId(e.target.value)}
placeholder="76428..."
className="font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Biz User ID <span className="text-text-muted text-xs">optional</span>
</label>
<Input
value={bizUserId}
onChange={(e) => setBizUserId(e.target.value)}
placeholder="76428..."
className="font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
User Unique ID <span className="text-text-muted text-xs">optional</span>
</label>
<Input
value={userUniqueId}
onChange={(e) => setUserUniqueId(e.target.value)}
placeholder="76428..."
className="font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Scope</label>
<Input value={scope} onChange={(e) => setScope(e.target.value)} className="text-sm" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Tenant</label>
<Input value={tenant} onChange={(e) => setTenant(e.target.value)} className="text-sm" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Region</label>
<Input value={region} onChange={(e) => setRegion(e.target.value)} className="text-sm" />
</div>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<div className="flex gap-2">
<Button onClick={handleImportToken} fullWidth disabled={importing || !accessToken.trim()}>
{importing ? "Importing…" : "Import Token"}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -26,6 +26,7 @@ export { default as KiroAuthModal } from "./KiroAuthModal";
export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper";
export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
export { default as CursorAuthModal } from "./CursorAuthModal";
export { default as TraeAuthModal } from "./TraeAuthModal";
export { default as SegmentedControl } from "./SegmentedControl";
export { default as Breadcrumbs } from "./Breadcrumbs";
export { default as EmptyState } from "./EmptyState";

View File

@@ -146,6 +146,8 @@ import TencentColorIcon from "@lobehub/icons/es/Tencent/components/Color";
import TencentMonoIcon from "@lobehub/icons/es/Tencent/components/Mono";
import TogetherMonoIcon from "@lobehub/icons/es/Together/components/Mono";
import TopazLabsMonoIcon from "@lobehub/icons/es/TopazLabs/components/Mono";
import TraeColorIcon from "@lobehub/icons/es/Trae/components/Color";
import TraeMonoIcon from "@lobehub/icons/es/Trae/components/Mono";
import UpstageColorIcon from "@lobehub/icons/es/Upstage/components/Color";
import UpstageMonoIcon from "@lobehub/icons/es/Upstage/components/Mono";
import YiColorIcon from "@lobehub/icons/es/Yi/components/Color";
@@ -276,6 +278,7 @@ const LOBE_ICON_COMPONENTS = {
Tencent: { mono: TencentMonoIcon, color: TencentColorIcon },
Together: { mono: TogetherMonoIcon, color: TogetherColorIcon },
TopazLabs: { mono: TopazLabsMonoIcon },
Trae: { mono: TraeMonoIcon, color: TraeColorIcon },
Upstage: { mono: UpstageMonoIcon, color: UpstageColorIcon },
V0: { mono: V0MonoIcon },
Venice: { mono: VeniceMonoIcon, color: VeniceColorIcon },
@@ -421,6 +424,7 @@ const LOBE_PROVIDER_ALIASES = {
tencent: "Tencent",
together: "Together",
topaz: "TopazLabs",
trae: "Trae",
triton: "Nvidia",
upstage: "Upstage",
v0: "V0",

View File

@@ -193,7 +193,7 @@ export const OAUTH_PROVIDERS = {
textIcon: "TR",
website: "https://trae.ai",
authHint:
"Trae is an AI-native IDE by ByteDance. Sign in inside Trae and paste your API token or use OAuth device flow here.",
"Trae is an AI-native IDE by ByteDance (SOLO remote agent). Authorize via trae.ai in the popup, or sign in at solo.trae.ai and paste the Cloud-IDE-JWT (sent as 'Authorization: Cloud-IDE-JWT <token>', ~14-day lifetime) as the access token; web_id/biz_user_id/user_unique_id/scope/tenant/region propagate via providerSpecificData. No headless refresh for pasted tokens — re-paste on expiry.",
},
"kimi-coding": {
id: "kimi-coding",
@@ -3002,17 +3002,19 @@ function getOrCreateIdToAlias(): Record<string, string> {
}
export function getProviderById(id: string) {
return (NOAUTH_PROVIDERS as Record<string, any>)[id]
?? (OAUTH_PROVIDERS as Record<string, any>)[id]
?? (APIKEY_PROVIDERS as Record<string, any>)[id]
?? (WEB_COOKIE_PROVIDERS as Record<string, any>)[id]
?? (LOCAL_PROVIDERS as Record<string, any>)[id]
?? (SEARCH_PROVIDERS as Record<string, any>)[id]
?? (AUDIO_ONLY_PROVIDERS as Record<string, any>)[id]
?? (UPSTREAM_PROXY_PROVIDERS as Record<string, any>)[id]
?? (CLOUD_AGENT_PROVIDERS as Record<string, any>)[id]
?? (SYSTEM_PROVIDERS as Record<string, any>)[id]
?? undefined;
return (
(NOAUTH_PROVIDERS as Record<string, any>)[id] ??
(OAUTH_PROVIDERS as Record<string, any>)[id] ??
(APIKEY_PROVIDERS as Record<string, any>)[id] ??
(WEB_COOKIE_PROVIDERS as Record<string, any>)[id] ??
(LOCAL_PROVIDERS as Record<string, any>)[id] ??
(SEARCH_PROVIDERS as Record<string, any>)[id] ??
(AUDIO_ONLY_PROVIDERS as Record<string, any>)[id] ??
(UPSTREAM_PROXY_PROVIDERS as Record<string, any>)[id] ??
(CLOUD_AGENT_PROVIDERS as Record<string, any>)[id] ??
(SYSTEM_PROVIDERS as Record<string, any>)[id] ??
undefined
);
}
export const AI_PROVIDERS = new Proxy({} as Record<string, any>, {
@@ -3035,7 +3037,8 @@ export const AI_PROVIDERS = new Proxy({} as Record<string, any>, {
},
});
export type AiProviderId = keyof typeof NOAUTH_PROVIDERS
export type AiProviderId =
| keyof typeof NOAUTH_PROVIDERS
| keyof typeof OAUTH_PROVIDERS
| keyof typeof APIKEY_PROVIDERS
| keyof typeof WEB_COOKIE_PROVIDERS
@@ -3046,7 +3049,8 @@ export type AiProviderId = keyof typeof NOAUTH_PROVIDERS
| keyof typeof CLOUD_AGENT_PROVIDERS
| keyof typeof SYSTEM_PROVIDERS;
export type AiProviderDefinition = (typeof NOAUTH_PROVIDERS)[keyof typeof NOAUTH_PROVIDERS]
export type AiProviderDefinition =
| (typeof NOAUTH_PROVIDERS)[keyof typeof NOAUTH_PROVIDERS]
| (typeof OAUTH_PROVIDERS)[keyof typeof OAUTH_PROVIDERS]
| (typeof APIKEY_PROVIDERS)[keyof typeof APIKEY_PROVIDERS]
| (typeof WEB_COOKIE_PROVIDERS)[keyof typeof WEB_COOKIE_PROVIDERS]

View File

@@ -1651,6 +1651,16 @@ export const cursorImportSchema = z.object({
machineId: z.string().trim().optional(),
});
export const traeImportSchema = z.object({
accessToken: z.string().trim().min(1, "Cloud-IDE-JWT access token is required"),
webId: z.string().trim().optional(),
bizUserId: z.string().trim().optional(),
userUniqueId: z.string().trim().optional(),
scope: z.string().trim().optional(),
tenant: z.string().trim().optional(),
region: z.string().trim().optional(),
});
export const kiroImportSchema = z.object({
refreshToken: z.string().trim().min(1, "Refresh token is required"),
region: z.string().trim().default("us-east-1"),

View File

@@ -87,12 +87,28 @@ test("trae mapTokens preserves machineId in providerSpecificData (#2658)", () =>
assert.equal(mapped.providerSpecificData.authMethod, "imported");
});
test("trae mapTokens defaults expiresIn to 86400 when not provided", () => {
test("trae mapTokens defaults expiresIn to the ~14-day Cloud-IDE-JWT lifetime when not provided", () => {
const provider = PROVIDERS.trae;
const mapped = provider.mapTokens({ accessToken: "trae-token-2" });
assert.equal(mapped.expiresIn, 86400);
// SOLO Cloud-IDE-JWTs live ~14 days (TRAE_CONFIG.tokenLifetimeDays).
assert.equal(mapped.expiresIn, TRAE_CONFIG.tokenLifetimeDays * 24 * 60 * 60);
});
test("trae mapTokens enriches providerSpecificData with SOLO identity defaults", () => {
const provider = PROVIDERS.trae;
const mapped = provider.mapTokens({
accessToken: "tk",
webId: "WID",
bizUserId: "BUID",
});
assert.equal(mapped.providerSpecificData.webId, "WID");
assert.equal(mapped.providerSpecificData.bizUserId, "BUID");
// Identity defaults required by the SOLO common_params payload.
assert.equal(mapped.providerSpecificData.scope, "marscode-us");
assert.equal(mapped.providerSpecificData.tenant, "marscode");
assert.equal(mapped.providerSpecificData.region, "US-East");
});
test("trae mapTokens returns an object even when called with empty tokens", () => {
@@ -101,7 +117,7 @@ test("trae mapTokens returns an object even when called with empty tokens", () =
assert.ok(mapped && typeof mapped === "object");
assert.equal(mapped.refreshToken, null);
assert.equal(mapped.expiresIn, 86400);
assert.equal(mapped.expiresIn, TRAE_CONFIG.tokenLifetimeDays * 24 * 60 * 60);
});
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,455 @@
import test from "node:test";
import assert from "node:assert/strict";
// Import the executor directly (not via executors/index.ts) — index pulls in
// the entire provider registry and DB layer which is slow and unnecessary for
// the unit-level behavior we want to exercise here.
const { TraeExecutor } = await import("../../open-sse/executors/trae.ts");
// ─── Helpers ────────────────────────────────────────────────────────────────
/** Build a Response whose body streams the given SSE frames (event/data pairs). */
function sseResponse(frames: Array<{ event: string; data: unknown }>): Response {
const enc = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
for (const f of frames) {
controller.enqueue(enc.encode(`event: ${f.event}\n`));
controller.enqueue(enc.encode(`data: ${JSON.stringify(f.data)}\n\n`));
}
controller.close();
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
function jsonResponse(obj: unknown, status = 200): Response {
return new Response(JSON.stringify(obj), {
status,
headers: { "Content-Type": "application/json" },
});
}
/**
* Install a mock fetch dispatching by URL:
* POST /chat_sessions → session create
* GET /chat_sessions/{}/events → SSE frames
* Returns { calls, restore }.
*/
function installMockFetch({
sessionBody,
frames,
sessionStatus = 200,
}: {
sessionBody?: unknown;
frames?: Array<{ event: string; data: unknown }>;
sessionStatus?: number;
} = {}) {
const calls: { sessionBody?: any; sessionHeaders?: Record<string, string>; eventsUrl?: string } =
{};
const original = globalThis.fetch;
globalThis.fetch = (async (input: any, init: any = {}) => {
const url = typeof input === "string" ? input : input.url;
if (url.includes("/chat_sessions") && url.includes("/events")) {
calls.eventsUrl = url;
return sseResponse(frames ?? [{ event: "done", data: { status: "completed" } }]);
}
if (url.endsWith("/chat_sessions")) {
calls.sessionBody = init.body ? JSON.parse(init.body) : undefined;
calls.sessionHeaders = init.headers;
return jsonResponse(
sessionBody ?? {
code: 0,
data: { chat_session_id: "sess1", status: 2, message_id: "msg1" },
message: "success",
},
sessionStatus
);
}
throw new Error(`unexpected fetch ${url}`);
}) as typeof fetch;
return { calls, restore: () => (globalThis.fetch = original) };
}
const CREDS = {
accessToken: "JWT.test.token",
providerSpecificData: {
webId: "WID",
bizUserId: "BUID",
userUniqueId: "UUID",
scope: "marscode-us",
tenant: "marscode",
region: "US-East",
},
};
async function readAll(res: Response): Promise<string> {
return await res.text();
}
// ─── Tests ──────────────────────────────────────────────────────────────────
// Registration (`getExecutor("trae")` returns a TraeExecutor) is covered by
// typecheck + a server-startup smoke test. Importing executors/index.ts here
// would pull in the DB layer and leave async handles open at process exit.
test("buildHeaders uses Cloud-IDE-JWT auth scheme + web client headers", () => {
const ex = new TraeExecutor();
const h = ex.buildHeaders(CREDS);
assert.equal(h.Authorization, "Cloud-IDE-JWT JWT.test.token");
assert.equal(h["X-Trae-Client-Type"], "web");
});
test("non-stream: accumulates plan_item.thought and maps usage", async () => {
const { calls, restore } = installMockFetch({
frames: [
{ event: "metadata", data: { message_id: "m" } },
{ event: "plan_item", data: { id: "p1", thought: "Па" } },
{ event: "plan_item", data: { id: "p1", thought: "Париж" } },
// trailing finish plan_item with empty thought must NOT wipe accumulated text
{ event: "plan_item", data: { id: "p2", thought: "" } },
{
event: "token_usage",
data: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 },
},
{ event: "done", data: { status: "completed" } },
],
});
try {
const ex = new TraeExecutor();
const { response } = await ex.execute({
model: "auto",
body: { messages: [{ role: "user", content: "столица Франции?" }] },
stream: false,
credentials: CREDS,
});
const json = JSON.parse(await readAll(response));
assert.equal(json.choices[0].message.content, "Париж");
assert.equal(json.choices[0].finish_reason, "stop");
assert.deepEqual(json.usage, { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 });
// query is JSON-encoded content blocks; model "auto" → auto strategy
assert.equal(calls.sessionBody.initial_message.model_selection_strategy, "auto");
assert.match(calls.sessionBody.initial_message.query, /столица Франции/);
} finally {
restore();
}
});
test("manual model → manual strategy + model_name passed through", async () => {
const { calls, restore } = installMockFetch({
frames: [{ event: "done", data: { status: "completed" } }],
});
try {
const ex = new TraeExecutor();
await ex.execute({
model: "gpt-5.2",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: CREDS,
});
assert.equal(calls.sessionBody.initial_message.model_selection_strategy, "manual");
assert.equal(calls.sessionBody.initial_message.model_name, "gpt-5.2");
} finally {
restore();
}
});
test('model "work" → work session mode, auto strategy, empty model_name', async () => {
const { calls, restore } = installMockFetch({
frames: [{ event: "done", data: { status: "completed" } }],
});
try {
const ex = new TraeExecutor();
await ex.execute({
model: "work",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: CREDS,
});
const im = calls.sessionBody.initial_message;
assert.equal(calls.sessionBody.mode, "work");
assert.equal(im.model_selection_strategy, "auto");
assert.equal(im.model_name, "");
// common_params.solo_chat_mode must follow the session mode
assert.equal(JSON.parse(im.common_params).solo_chat_mode, "work");
} finally {
restore();
}
});
test('model "auto" stays in code mode (solo_chat_mode=code)', async () => {
const { calls, restore } = installMockFetch({
frames: [{ event: "done", data: { status: "completed" } }],
});
try {
const ex = new TraeExecutor();
await ex.execute({
model: "auto",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: CREDS,
});
assert.equal(calls.sessionBody.mode, "code");
assert.equal(
JSON.parse(calls.sessionBody.initial_message.common_params).solo_chat_mode,
"code"
);
} finally {
restore();
}
});
test("stream: emits OpenAI chunks with deltas, finish_reason stop and [DONE]", async () => {
const { restore } = installMockFetch({
frames: [
{ event: "plan_item", data: { id: "p1", thought: "крас" } },
{ event: "plan_item", data: { id: "p1", thought: "красный" } },
{ event: "done", data: { status: "completed" } },
],
});
try {
const ex = new TraeExecutor();
const { response } = await ex.execute({
model: "auto",
body: { messages: [{ role: "user", content: "цвет" }] },
stream: true,
credentials: CREDS,
});
const text = await readAll(response);
// role chunk first, then content deltas, then finish, then DONE
assert.match(text, /"delta":\{"role":"assistant"\}/);
assert.match(text, /"content":"крас"/);
assert.match(text, /"content":"ный"/); // incremental delta after "крас"
assert.match(text, /"finish_reason":"stop"/);
assert.match(text, /data: \[DONE\]/);
} finally {
restore();
}
});
test("upstream error event surfaces as 502 (non-stream)", async () => {
const { restore } = installMockFetch({
frames: [{ event: "error", data: { code: 4001, message: "config item is empty" } }],
});
try {
const ex = new TraeExecutor();
const { response } = await ex.execute({
model: "deepseek-v3.1",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: CREDS,
});
assert.equal(response.status, 502);
const json = JSON.parse(await readAll(response));
assert.match(json.error.message, /4001/);
// error responses must not leak stack traces
assert.ok(!json.error.message.includes("at /"));
} finally {
restore();
}
});
test("session create failure returns 502", async () => {
const { restore } = installMockFetch({ sessionBody: "nope", sessionStatus: 500 });
try {
const ex = new TraeExecutor();
const { response } = await ex.execute({
model: "auto",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: CREDS,
});
assert.equal(response.status, 502);
} finally {
restore();
}
});
// ─── refreshCredentials ────────────────────────────────────────────────────
function installRefreshMock(opts: { result?: any; errorCode?: string; httpStatus?: number } = {}) {
const calls: { url?: string; body?: any } = {};
const original = globalThis.fetch;
globalThis.fetch = (async (input: any, init: any = {}) => {
const url = typeof input === "string" ? input : input.url;
calls.url = url;
calls.body = init.body ? JSON.parse(init.body) : undefined;
const payload = opts.errorCode
? { ResponseMetadata: { Error: { Code: opts.errorCode, Message: "bad" } } }
: { ResponseMetadata: {}, Result: opts.result };
return new Response(JSON.stringify(payload), {
status: opts.httpStatus ?? 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
return { calls, restore: () => (globalThis.fetch = original) };
}
test("refreshCredentials posts ExchangeToken with ClientID/RefreshToken and parses Result", async () => {
const newToken = "NEW_TOKEN_eyJhbGc";
const newRefresh = "NEW_REFRESH";
const expMs = Date.UTC(2027, 0, 1, 12, 0, 0); // arbitrary future timestamp
const { calls, restore } = installRefreshMock({
result: { Token: newToken, RefreshToken: newRefresh, TokenExpireAt: expMs },
});
try {
const ex = new TraeExecutor();
const out = await ex.refreshCredentials({
...CREDS,
refreshToken: "OLD_REFRESH",
providerSpecificData: {
...CREDS.providerSpecificData,
host: "https://api-us-east.trae.ai",
clientId: "en1oxy7wnw8j9n",
},
});
assert.equal(calls.url, "https://api-us-east.trae.ai/cloudide/api/v3/trae/oauth/ExchangeToken");
assert.deepEqual(calls.body, {
ClientID: "en1oxy7wnw8j9n",
RefreshToken: "OLD_REFRESH",
ClientSecret: "-",
UserID: "",
});
assert.equal(out?.accessToken, newToken);
assert.equal(out?.refreshToken, newRefresh);
assert.equal(out?.expiresAt, new Date(expMs).toISOString());
} finally {
restore();
}
});
test("refreshCredentials returns null when no refresh token is stored", async () => {
// No fetch should happen; guard catches missing credential up front.
const original = globalThis.fetch;
let called = false;
globalThis.fetch = (async () => {
called = true;
return new Response("{}");
}) as typeof fetch;
try {
const ex = new TraeExecutor();
const out = await ex.refreshCredentials({ ...CREDS, refreshToken: undefined });
assert.equal(out, null);
assert.equal(called, false);
} finally {
globalThis.fetch = original;
}
});
test("refreshCredentials throws on RefreshTokenInvalid so the next call surfaces auth failure", async () => {
const { restore } = installRefreshMock({ errorCode: "RefreshTokenInvalid" });
try {
const ex = new TraeExecutor();
await assert.rejects(
ex.refreshCredentials({ ...CREDS, refreshToken: "BAD" }),
/RefreshTokenInvalid/
);
} finally {
restore();
}
});
test("refreshCredentials keeps the old refresh token when upstream omits a new one", async () => {
// Trae occasionally returns just Token + TokenExpireAt without rotating
// RefreshToken — we must preserve the existing one rather than null it out.
const { restore } = installRefreshMock({
result: { Token: "NEW", TokenExpireAt: Date.UTC(2027, 0, 1) },
});
try {
const ex = new TraeExecutor();
const out = await ex.refreshCredentials({ ...CREDS, refreshToken: "STAYS" });
assert.equal(out?.refreshToken, "STAYS");
} finally {
restore();
}
});
// ─── /authorize callback parser ────────────────────────────────────────────
//
// The HTTP route in src/app/authorize/route.ts is a thin wrapper: parser +
// createProviderConnection + HTML response. The parser is pure and is what we
// unit-test here — DB persistence and the HTML shell are exercised manually
// during the live OAuth flow.
const { parseTraeCallbackQuery } = await import("../../src/app/authorize/parseCallback.ts");
test("parseTraeCallbackQuery extracts the full credential bundle from the Trae callback", () => {
const userJwt = JSON.stringify({
ClientID: "en1oxy7wnw8j9n",
Token: "ACCESS_TOKEN_eyJ",
RefreshToken: "REFRESH",
TokenExpireAt: Date.UTC(2026, 5, 6),
RefreshExpireAt: Date.UTC(2026, 11, 19),
TokenExpireDuration: 1209600000,
});
const userInfo = JSON.stringify({
UserID: "76428",
TenantID: "abc",
Region: "US-East",
AIRegion: "US",
ScreenName: "tester",
NonPlainTextEmail: "u***r@example.com",
});
const q = new URLSearchParams({
isRedirect: "true",
scope: "solo",
loginTraceID: "trace-123",
host: "https://api-us-east.trae.ai",
userJwt,
userInfo,
});
const result = parseTraeCallbackQuery(q);
assert.equal(result.ok, true);
if (!result.ok) return;
const rec = result.record;
assert.equal(rec.provider, "trae");
assert.equal(rec.authType, "oauth");
assert.equal(rec.accessToken, "ACCESS_TOKEN_eyJ");
assert.equal(rec.refreshToken, "REFRESH");
assert.equal(rec.email, "u***r@example.com");
assert.equal(rec.expiresAt, new Date(Date.UTC(2026, 5, 6)).toISOString());
assert.equal(rec.providerSpecificData.userId, "76428");
assert.equal(rec.providerSpecificData.tenantId, "abc");
assert.equal(rec.providerSpecificData.region, "US-East");
assert.equal(rec.providerSpecificData.clientId, "en1oxy7wnw8j9n");
assert.equal(rec.providerSpecificData.host, "https://api-us-east.trae.ai");
assert.equal(rec.providerSpecificData.authMethod, "oauth_callback");
// common_params identity must mirror UserID for all three id fields
assert.equal(rec.providerSpecificData.bizUserId, "76428");
assert.equal(rec.providerSpecificData.userUniqueId, "76428");
assert.equal(rec.providerSpecificData.webId, "76428");
});
test("parseTraeCallbackQuery returns an error when userJwt is missing", () => {
const result = parseTraeCallbackQuery(new URLSearchParams({ scope: "solo" }));
assert.equal(result.ok, false);
if (result.ok) return;
assert.match(result.error, /Missing userJwt/);
});
test("parseTraeCallbackQuery returns an error when userJwt is not valid JSON", () => {
const result = parseTraeCallbackQuery(new URLSearchParams({ userJwt: "not-json{{{" }));
assert.equal(result.ok, false);
if (result.ok) return;
assert.match(result.error, /Malformed userJwt/);
});
test("parseTraeCallbackQuery falls back to flat refreshToken/refreshExpireAt when nested ones are absent", () => {
// Real Trae callbacks always nest the bundle inside userJwt, but the spec also
// exposes flat duplicates. Confirm we fall back cleanly if only the flat copies
// are present (defensive — protects against schema drift).
const userJwt = JSON.stringify({ ClientID: "en1oxy7wnw8j9n", Token: "T" });
const q = new URLSearchParams({
userJwt,
refreshToken: "FLAT_REFRESH",
refreshExpireAt: String(Date.UTC(2027, 0, 1)),
});
const result = parseTraeCallbackQuery(q);
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.record.refreshToken, "FLAT_REFRESH");
assert.equal(result.record.providerSpecificData.refreshExpireAt, Date.UTC(2027, 0, 1));
});