mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(oauth): add Trae SOLO provider (work/code modes)
Full SOLO remote-agent integration against solo.trae.ai's reverse-engineered API (upgrades the previous import_token stub). - open-sse/executors/trae.ts: streaming executor for the solo_agent_remote API (POST /chat_sessions + GET /events SSE -> OpenAI chat.completions), accumulating plan_item thoughts and mapping token_usage. Headless Cloud-IDE-JWT refresh via the ExchangeToken endpoint. - Session mode via model id: `trae/work` runs the fast work-mode auto agent; `trae/auto` and named models (gpt-5.4, kimi-k2.5, gemini-3.1-pro, ...) run in code mode. - Provider registry entry (models + 272k context) and executor wiring. - Two credential paths: browser /authorize loopback callback (captures the JWT + long-lived refresh token) and manual Cloud-IDE-JWT paste via POST /api/oauth/trae/import (Zod-validated). - TraeAuthModal dashboard UI wired into the provider detail page. - mapTokens/providerSpecificData carry the SOLO common_params identity fields. - Tests: executor (stream/non-stream/error/refresh/work-mode + callback parser) and updated oauth-trae provider tests.
This commit is contained in:
@@ -1121,6 +1121,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",
|
||||
|
||||
@@ -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";
|
||||
@@ -57,6 +58,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"),
|
||||
@@ -109,7 +111,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(),
|
||||
@@ -150,6 +152,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";
|
||||
|
||||
471
open-sse/executors/trae.ts
Normal file
471
open-sse/executors/trae.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* 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) => (p && typeof p === "object" ? String((p as JsonRecord).text ?? "") : ""))
|
||||
.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();
|
||||
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)) return; // consumer signalled completion
|
||||
} 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;
|
||||
77
scripts/diag-trae-auth.mjs
Normal file
77
scripts/diag-trae-auth.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
// 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/diag-trae-auth.mjs <YOUR_CLOUD_IDE_JWT>
|
||||
// TRAE_TOKEN=eyJ... node scripts/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."
|
||||
);
|
||||
61
scripts/smoke-trae.mjs
Normal file
61
scripts/smoke-trae.mjs
Normal file
@@ -0,0 +1,61 @@
|
||||
// End-to-end smoke test: dergaет TraeExecutor против реального Trae с твоим
|
||||
// JWT из ../trae_solo.env, выводит content + usage.
|
||||
// Запуск: node --import tsx/esm scripts/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);
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
OAuthModal,
|
||||
KiroOAuthWrapper,
|
||||
CursorAuthModal,
|
||||
TraeAuthModal,
|
||||
Toggle,
|
||||
Select,
|
||||
ProxyConfigModal,
|
||||
@@ -4688,6 +4689,15 @@ export default function ProviderDetailPage() {
|
||||
setShowOAuthModal(false);
|
||||
}}
|
||||
/>
|
||||
) : providerId === "trae" ? (
|
||||
<TraeAuthModal
|
||||
isOpen={showOAuthModal}
|
||||
reauthConnection={reauthConnection}
|
||||
onSuccess={handleOAuthSuccess}
|
||||
onClose={() => {
|
||||
setShowOAuthModal(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<OAuthModal
|
||||
isOpen={showOAuthModal}
|
||||
|
||||
130
src/app/api/oauth/trae/import/route.ts
Normal file
130
src/app/api/oauth/trae/import/route.ts
Normal 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" },
|
||||
],
|
||||
});
|
||||
}
|
||||
97
src/app/authorize/parseCallback.ts
Normal file
97
src/app/authorize/parseCallback.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
}
|
||||
70
src/app/authorize/route.ts
Normal file
70
src/app/authorize/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
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. The opener trusts origin === self anyway.
|
||||
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>
|
||||
try { if (window.opener) window.opener.postMessage(${safe}, "*"); } 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" });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
319
src/shared/components/TraeAuthModal.tsx
Normal file
319
src/shared/components/TraeAuthModal.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
"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;
|
||||
if (traceIdRef.current && m.loginTraceId && 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 "Login
|
||||
Failed", click "Open solo.trae.ai", 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 <token></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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
@@ -2988,17 +2988,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>, {
|
||||
@@ -3021,7 +3023,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
|
||||
@@ -3032,7 +3035,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]
|
||||
|
||||
@@ -1646,6 +1646,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"),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
455
tests/unit/trae-executor.test.ts
Normal file
455
tests/unit/trae-executor.test.ts
Normal 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));
|
||||
});
|
||||
Reference in New Issue
Block a user