Repository navigation
feat(oauth): add Trae SOLO provider - #2964
Conversation
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.
There was a problem hiding this comment.
Code Review
This pull request adds comprehensive support for the Trae SOLO provider, introducing the TraeExecutor for API interactions, a dedicated token import route, a loopback callback handler for the desktop OAuth flow, and the TraeAuthModal frontend component. The code review identifies several critical improvements: resolving potential resource leaks by cancelling the stream reader in TraeExecutor, addressing security vulnerabilities by validating origins in postMessage and message event listeners, and handling already-aborted signals and mixed-type message content arrays more defensively. Additionally, the reviewer points out a style guide violation, noting that the loose diagnostic and smoke test scripts must be moved to a subfolder such as scripts/ad-hoc/.
| } catch { | ||
| data = { _raw: payload }; | ||
| } | ||
| if (onEvent(ev, data)) return; // consumer signalled completion |
There was a problem hiding this comment.
When onEvent returns true (signalling completion), the function returns immediately, but the active stream reader is never cancelled. This can leave the underlying connection open and cause resource leaks. We should explicitly cancel the reader before returning.
if (onEvent(ev, data)) {
await reader.cancel().catch(() => {});
return;
}There was a problem hiding this comment.
Applied in 6ff3238 — the SSE reader is now cancelled before returning. Thanks!
| <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) {} |
There was a problem hiding this comment.
Using "*" as the target origin in postMessage is a security vulnerability (CWE-359) because any origin can intercept the message if the popup is opened or hijacked by a malicious site. Since the opener is on the same origin, we should restrict the target origin to window.location.origin.
| try { if (window.opener) window.opener.postMessage(${safe}, "*"); } catch (e) {} | |
| try { if (window.opener) window.opener.postMessage(${safe}, window.location.origin); } catch (e) {} |
There was a problem hiding this comment.
The popup callback intentionally runs on 127.0.0.1 — Trae's authorize server rejects any non-loopback auth_callback_url — while the dashboard usually runs on localhost. They're different origins by design, so pinning to window.location.origin silently drops the postMessage and the modal never closes. Fixed in 6ff3238: it now posts to the known loopback origin pair (localhost + 127.0.0.1 on the same port) instead of a single origin — no wildcard, and the random loginTraceId stays as the CSRF guard.
| if (!m || m.type !== "trae-oauth-callback") return; | ||
| if (traceIdRef.current && m.loginTraceId && m.loginTraceId !== traceIdRef.current) return; |
There was a problem hiding this comment.
This event listener has two security issues:
- It lacks origin validation, allowing any external site/iframe to post messages to this window and potentially spoof successful authorization.
- The state/traceId validation is bypassed if
traceIdRef.currentis null (e.g., before the authorization flow is initiated), which could allow session fixation or pre-auth spoofing.
We should validate the origin and strictly enforce that traceIdRef.current is set and matches the incoming loginTraceId.
| if (!m || m.type !== "trae-oauth-callback") return; | |
| if (traceIdRef.current && m.loginTraceId && m.loginTraceId !== traceIdRef.current) return; | |
| if (ev.origin !== window.location.origin) return; | |
| if (!m || m.type !== "trae-oauth-callback") return; | |
| if (!traceIdRef.current || m.loginTraceId !== traceIdRef.current) return; |
There was a problem hiding this comment.
Same loopback nuance: the dashboard (localhost) and the popup callback (127.0.0.1) are different origins by design, so ev.origin !== window.location.origin would reject the legitimate message. Fixed in 6ff3238 — the listener now accepts that known loopback origin pair and strictly requires a matching loginTraceId (kept your strict-traceId tightening — good catch).
| @@ -0,0 +1,77 @@ | |||
| // Trae SOLO auth diagnostic — probes the live API with your token across several | |||
There was a problem hiding this comment.
According to the repository style guide (Section 1), all maintenance, debugging, generation, or experimental scripts must be placed strictly inside one of the scripts/ subfolders (e.g., scripts/ad-hoc/ for one-shot/experimental scripts). Loose scripts should never be placed in the top-level scripts/ folder. Please move this script to scripts/ad-hoc/diag-trae-auth.mjs.
References
- ALL maintenance, debugging, generation, or experimental scripts MUST be placed strictly inside one of the scripts/ subfolders. NEVER dump loose scripts in the top-level scripts/ folder. (link)
There was a problem hiding this comment.
Done in 6ff3238 — moved to scripts/ad-hoc/
| @@ -0,0 +1,61 @@ | |||
| // End-to-end smoke test: dergaет TraeExecutor против реального Trae с твоим | |||
There was a problem hiding this comment.
According to the repository style guide (Section 1), all maintenance, debugging, generation, or experimental scripts must be placed strictly inside one of the scripts/ subfolders (e.g., scripts/ad-hoc/ for one-shot/experimental scripts). Loose scripts should never be placed in the top-level scripts/ folder. Please move this script to scripts/ad-hoc/smoke-trae.mjs.
References
- ALL maintenance, debugging, generation, or experimental scripts MUST be placed strictly inside one of the scripts/ subfolders. NEVER dump loose scripts in the top-level scripts/ folder. (link)
There was a problem hiding this comment.
Done in 6ff3238 — moved to scripts/ad-hoc/
| else if (Array.isArray(m.content)) { | ||
| content = m.content | ||
| .map((p) => (p && typeof p === "object" ? String((p as JsonRecord).text ?? "") : "")) | ||
| .join(""); | ||
| } |
There was a problem hiding this comment.
In flattenQuery, if m.content is an array, the current mapping assumes all elements are objects with a text property. However, in some OpenAI-compatible payloads, elements of the content array can be plain strings. We should handle string elements defensively to avoid mapping them to empty strings.
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("");
}There was a problem hiding this comment.
Applied in 6ff3238 — array content elements that are plain strings are now handled.
| 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 }); |
There was a problem hiding this comment.
If the passed signal is already aborted when streamEvents is called, the abort event listener will not be triggered. We should check signal?.aborted upfront and abort the controller immediately to prevent unnecessary network requests.
const ctrl = new AbortController();
if (signal?.aborted) {
ctrl.abort();
}
const timer = setTimeout(() => ctrl.abort(new Error("trae stream timeout")), STREAM_TIMEOUT_MS);
const onAbort = () => ctrl.abort();
if (signal) signal.addEventListener("abort", onAbort, { once: true });There was a problem hiding this comment.
Applied in 6ff3238 — aborts upfront when the caller's signal is already aborted.
…anup, scripts) - Restrict the /authorize postMessage and the modal listener to the loopback origin pair (localhost + 127.0.0.1) instead of "*"/single-origin. The dashboard runs on localhost while Trae forces the callback onto 127.0.0.1, so a single window.location.origin target silently dropped the success message (popup never closed). Addresses CWE-359 without breaking the cross-loopback flow. - TraeExecutor: cancel the SSE reader on completion, abort upfront when the caller signal is already aborted, and accept string elements in array message content. - Move ad-hoc dev scripts to scripts/ad-hoc/ per the repo style guide.
feat(oauth): add Trae SOLO provider
…provider feat(oauth): add Trae SOLO provider
…provider feat(oauth): add Trae SOLO provider
…provider feat(oauth): add Trae SOLO provider
…provider feat(oauth): add Trae SOLO provider
…provider feat(oauth): add Trae SOLO provider
…provider feat(oauth): add Trae SOLO provider
Summary
Adds a fully working Trae SOLO provider (ByteDance's remote agent at solo.trae.ai), replacing the previous placeholder that only registered the provider name without a way to actually route requests.
open-sse/executors/trae.ts) against Trae'ssolo_agent_remoteAPI — creates a chat session (POST /chat_sessions), consumes theGET /eventsSSE stream, and translates it into standard OpenAIchat.completions(streaming + non-streaming), with token-usage mapping.trae.aiand captures the token automatically (with a refresh token), or pasting theCloud-IDE-JWTtoken manually. Both persist an encrypted connection.trae/workruns the fast work-mode agent;trae/autoand the named models (e.g.kimi-k2.5,gpt-5.4,gemini-3.1-pro) run in code mode.@lobehub/icons).Related Issues
Validation
npm run lintclean (0 warnings)npm run typecheck:corecleanTests Added Or Updated
tests/unit/trae-executor.test.ts(new) —buildHeaders, streaming and non-streaming responses,plan_itemthought accumulation, usage mapping, upstream-error → 502, session-create failure,refreshCredentials(ExchangeToken happy path / no-token / invalid-refresh / token preservation), work vs code mode selection, and the/authorizecallback parser.tests/unit/oauth-trae.test.ts(updated) — provider shape,TRAE_CONFIGfields, andmapTokensupdated for the SOLO identity fields and ~14-day token lifetime.Coverage Notes
New production code lives in
open-sse/(executor) andsrc/(oauth provider, import route,/authorizecallback parser, dashboard modal). The executor and the pure callback parser are unit-tested directly; the HTTP route around the parser is a thin wrapper (DB persist + HTML shell) exercised during the live OAuth flow. No touched file should see a coverage regression.Reviewer Notes
Authorization: Cloud-IDE-JWT <jwt>scheme.ExchangeTokenendpoint. The connection keeps working until the refresh token itself expires (months) — then re-authorize via the popup./authorizelives at the app root, not under/api/oauth/trae/— Trae's authorize server only accepts a callback URL whose host is loopback127.0.0.1and whose path ends in exactly/authorize; anything else short-circuits to "Login Failed". CSRF is handled via thelogin_trace_idechoed back and verified by the modal./modelsendpoint to enumerate them, andauto/workalways work regardless.OAUTH_PROVIDERS, so it surfaces in the dashboard automatically.