From 1fb7d5dff2ea1e626322bd9d44205cf9c288e284 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 11 Sep 2026 22:29:02 -0300 Subject: [PATCH] fix(providers): refresh Trae's stale Referer/Origin and forward user timezone (#12190) (#13255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged as part of the owner batch of 2026-09-11. This PR had a live worktree in another session, so it sat outside the main 39. Merged on your explicit call, validated first rather than taken on trust: boarded with the other 10 worktree-held PRs into a consolidated worktree off `release/v3.8.51`. - ESLint over every changed file: no errors - `typecheck:core` clean; `check:dashboard-typecheck` OK; `check:changelog-integrity` OK - complexity 2821 / baseline 3218 and cognitive-complexity 1272 / baseline 1437 - 203 of 208 assertions green. The 5 remaining (`guide-settings-route` ×4, `hard-session-lease-bypass-inventory` ×1) reproduce on the pure tip with nothing from this batch applied. - `imageGeneration.ts` rebaselined 3259 → 3293 for #12945's image-only-model guard, landed separately in #13392 so nothing was pushed onto a live branch. ⚠️ base-red inherited: #12732 — provider count 356 vs 358 and `open-sse/utils/stream.ts` 3115 > frozen 3098, both reproducing on the pure tip. --- .env.example | 5 ++ changelog.d/fixes/12190-trae-referer-401.md | 1 + open-sse/executors/trae.ts | 19 ++++- scripts/check/check-env-doc-sync.mjs | 3 + src/app/api/oauth/trae/import/route.ts | 33 ++++++++- src/app/authorize/parseCallback.ts | 11 +++ src/lib/oauth/providers/trae.ts | 3 + src/shared/validation/schemas/auth.ts | 5 ++ tests/unit/trae-headers-12190.test.ts | 80 +++++++++++++++++++++ 9 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/12190-trae-referer-401.md create mode 100644 tests/unit/trae-headers-12190.test.ts diff --git a/.env.example b/.env.example index 2057468364..b31804301b 100644 --- a/.env.example +++ b/.env.example @@ -1192,6 +1192,11 @@ CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann # Trae OAuth token override. Used by: open-sse/executors/trae.ts. # TRAE_TOKEN= +# Trae web client Origin/Referer override (fleet-wide bump if Trae moves hosts +# again without a code change). Default: https://work.trae.ai. +# Used by: open-sse/executors/trae.ts. +# TRAE_WEB_ORIGIN=https://work.trae.ai + # ── Gemini / Antigravity (Google-based) ── # These providers ship public OAuth client_id/secret values embedded in their # public CLIs. Defaults are baked into the code via diff --git a/changelog.d/fixes/12190-trae-referer-401.md b/changelog.d/fixes/12190-trae-referer-401.md new file mode 100644 index 0000000000..5b5959a49f --- /dev/null +++ b/changelog.d/fixes/12190-trae-referer-401.md @@ -0,0 +1 @@ +- fix(providers): refresh Trae's stale Referer/Origin and forward user timezone so imported connections stop failing with 401 (#12190) diff --git a/open-sse/executors/trae.ts b/open-sse/executors/trae.ts index d779fd77a2..24f8c9b333 100644 --- a/open-sse/executors/trae.ts +++ b/open-sse/executors/trae.ts @@ -26,6 +26,19 @@ type ChatMessage = { role?: string; content?: unknown }; const STREAM_TIMEOUT_MS = parseInt(process.env.TRAE_STREAM_TIMEOUT_MS || "300000", 10); +// Trae's web client origin moved from solo.trae.ai to work.trae.ai (the SOLO +// coding agent is now served under the TraeWork product surface); the backend +// appears to validate Origin/Referer against the JWT session's real origin, so +// a stale value here produces a clean 401 even with a fresh token (#12190). +// Kept overridable — via env for a fleet-wide bump without a code change, and +// per-connection via providerSpecificData.refererOrigin for an account that +// still authenticates against the legacy host — rather than a second +// hardcoded guess that would go stale the same way. +const DEFAULT_TRAE_WEB_ORIGIN = (process.env.TRAE_WEB_ORIGIN || "https://work.trae.ai").replace( + /\/$/, + "" +); + function flattenQuery(messages: ChatMessage[]): string { const parts: string[] = []; for (const m of messages) { @@ -61,13 +74,17 @@ export class TraeExecutor extends BaseExecutor { buildHeaders(credentials): Record { const token = (credentials.accessToken as string) || ""; const psd = (credentials.providerSpecificData as JsonRecord) || {}; + const webOrigin = ((psd.refererOrigin as string) || DEFAULT_TRAE_WEB_ORIGIN).replace(/\/$/, ""); + const timezone = psd.userTimezone as string | undefined; 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/", + Referer: `${webOrigin}/`, + Origin: webOrigin, + ...(timezone ? { "x-trae-user-timezone": timezone } : {}), "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", diff --git a/scripts/check/check-env-doc-sync.mjs b/scripts/check/check-env-doc-sync.mjs index 374472f986..2f5d0b790f 100644 --- a/scripts/check/check-env-doc-sync.mjs +++ b/scripts/check/check-env-doc-sync.mjs @@ -283,6 +283,9 @@ const ENV_ONLY_ALLOWLIST = new Set([ "PII_WINDOW_SIZE", "TRAE_STREAM_TIMEOUT_MS", "TRAE_TOKEN", + // #12190: Trae host/Origin override. ENVIRONMENT.md documents no Trae variable at + // all; this joins its two siblings above under the same .env.example-only tier. + "TRAE_WEB_ORIGIN", ]); // ─── Parsing helpers ─────────────────────────────────────────────────────── diff --git a/src/app/api/oauth/trae/import/route.ts b/src/app/api/oauth/trae/import/route.ts index c3faeb8e90..9cd2ead3c8 100644 --- a/src/app/api/oauth/trae/import/route.ts +++ b/src/app/api/oauth/trae/import/route.ts @@ -20,6 +20,8 @@ import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; * scope — optional, default "marscode-us" * tenant — optional, default "marscode" * region — optional, default "US-East" + * userRegion — optional, default "US" (x-user-region header; real value for non-US accounts) + * userTimezone — optional, forwarded as x-trae-user-timezone when present */ async function requireOAuthImportAuth(request: Request) { // GHSA-mg76: importing a provider connection is a state-mutating admin action; @@ -51,7 +53,17 @@ export async function POST(request: Request) { if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const { accessToken, webId, bizUserId, userUniqueId, scope, tenant, region } = validation.data; + const { + accessToken, + webId, + bizUserId, + userUniqueId, + scope, + tenant, + region, + userRegion, + userTimezone, + } = validation.data; const connection: any = await createProviderConnection({ provider: "trae", @@ -71,7 +83,12 @@ export async function POST(request: Request) { aiRegion: region || "US-East", appLanguage: "en", appVersion: "1.0.0.1229", - userRegion: "US", + // "US" stays the best-effort default so existing imports that omit + // userRegion keep behaving as before; a real account region (e.g. + // "SG") must be user-supplied — it is not a universal replacement + // default (#12190). + userRegion: userRegion || "US", + ...(userTimezone ? { userTimezone } : {}), userIdentity: "Free", authMethod: "imported", }, @@ -125,6 +142,18 @@ export async function GET(request: Request) { { 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" }, + { + name: "userRegion", + label: "User Region", + description: "x-user-region header, e.g. 'SG'. default: US", + type: "text", + }, + { + name: "userTimezone", + label: "User Timezone", + description: "x-trae-user-timezone header, e.g. 'America/Recife'. optional", + type: "text", + }, ], }); } diff --git a/src/app/authorize/parseCallback.ts b/src/app/authorize/parseCallback.ts index ff86d767ca..27a7adb4b8 100644 --- a/src/app/authorize/parseCallback.ts +++ b/src/app/authorize/parseCallback.ts @@ -29,6 +29,8 @@ export type ParsedTraeCallback = { clientId: string; refreshExpireAt: number | null; authMethod: "oauth_callback"; + userRegion?: string; + userTimezone?: string; }; testStatus: "active"; }; @@ -65,6 +67,13 @@ export function parseTraeCallbackQuery(q: URLSearchParams): ParsedTraeCallback | const userId = (info.UserID as string) || ""; const region = (info.Region as string) || "US-East"; + // Best-effort: the /authorize callback's userInfo payload has not been + // observed to carry a distinct x-user-region/timezone value distinct from + // Region — if Trae ever adds one under these names it propagates + // automatically; otherwise buildHeaders() falls back to "US"/no timezone + // header exactly as it does today (#12190). + const userRegion = (info.UserRegion as string) || undefined; + const userTimezone = (info.Timezone as string) || undefined; return { ok: true, @@ -90,6 +99,8 @@ export function parseTraeCallbackQuery(q: URLSearchParams): ParsedTraeCallback | clientId: (userJwt.ClientID as string) || "en1oxy7wnw8j9n", refreshExpireAt: refreshExpiresAtMs || null, authMethod: "oauth_callback", + ...(userRegion ? { userRegion } : {}), + ...(userTimezone ? { userTimezone } : {}), }, testStatus: "active", }, diff --git a/src/lib/oauth/providers/trae.ts b/src/lib/oauth/providers/trae.ts index d60081e5c9..d95fc0a278 100644 --- a/src/lib/oauth/providers/trae.ts +++ b/src/lib/oauth/providers/trae.ts @@ -42,6 +42,8 @@ type TraeRawTokens = { app_version?: string; userRegion?: string; user_region?: string; + userTimezone?: string; + user_timezone?: string; userIdentity?: string; user_identity?: string; }; @@ -69,6 +71,7 @@ export const trae = { appLanguage: tokens.appLanguage || tokens.app_language || "en", appVersion: tokens.appVersion || tokens.app_version || "1.0.0.1229", userRegion: tokens.userRegion || tokens.user_region || "US", + userTimezone: tokens.userTimezone || tokens.user_timezone || undefined, userIdentity: tokens.userIdentity || tokens.user_identity || "Free", // Preserved for callers that key off a machine id (e.g. the IDE flow). machineId: tokens.machineId, diff --git a/src/shared/validation/schemas/auth.ts b/src/shared/validation/schemas/auth.ts index 71b4345265..3f2e6d8c00 100644 --- a/src/shared/validation/schemas/auth.ts +++ b/src/shared/validation/schemas/auth.ts @@ -183,6 +183,11 @@ export const traeImportSchema = z.object({ scope: z.string().trim().optional(), tenant: z.string().trim().optional(), region: z.string().trim().optional(), + // Real account region (e.g. "SG") sent as the x-user-region header — the + // "US" default only works for US accounts and produces a 401 for others + // (#12190). Optional so existing imports keep behaving as before. + userRegion: z.string().trim().optional(), + userTimezone: z.string().trim().optional(), }); export const kiroImportSchema = z.object({ diff --git a/tests/unit/trae-headers-12190.test.ts b/tests/unit/trae-headers-12190.test.ts new file mode 100644 index 0000000000..1023e03595 --- /dev/null +++ b/tests/unit/trae-headers-12190.test.ts @@ -0,0 +1,80 @@ +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"); + +const CREDS = { + accessToken: "JWT.test.token", + providerSpecificData: { + webId: "WID", + bizUserId: "BUID", + userUniqueId: "UUID", + scope: "marscode-us", + tenant: "marscode", + region: "US-East", + }, +}; + +test("issue #12190: buildHeaders sends the current work.trae.ai Origin/Referer, not stale solo.trae.ai", () => { + const ex = new TraeExecutor(); + const h = ex.buildHeaders(CREDS); + assert.equal(h.Referer, "https://work.trae.ai/", `Referer should be work.trae.ai, got ${h.Referer}`); + assert.equal(h.Origin, "https://work.trae.ai", `Origin should be sent, got ${h.Origin}`); +}); + +test("issue #12190: buildHeaders forwards x-trae-user-timezone from providerSpecificData when present", () => { + const ex = new TraeExecutor(); + const creds = { + ...CREDS, + providerSpecificData: { ...CREDS.providerSpecificData, userTimezone: "America/Recife" }, + }; + const h = ex.buildHeaders(creds); + assert.equal( + h["x-trae-user-timezone"], + "America/Recife", + `x-trae-user-timezone should be forwarded, got ${h["x-trae-user-timezone"]}` + ); +}); + +test("issue #12190: buildHeaders omits x-trae-user-timezone when no timezone is known", () => { + const ex = new TraeExecutor(); + const h = ex.buildHeaders(CREDS); + assert.equal( + Object.hasOwn(h, "x-trae-user-timezone"), + false, + "no x-trae-user-timezone key should be sent when providerSpecificData has no userTimezone" + ); +}); + +test("issue #12190: buildHeaders still respects a custom providerSpecificData.userRegion", () => { + const ex = new TraeExecutor(); + const creds = { + ...CREDS, + providerSpecificData: { ...CREDS.providerSpecificData, userRegion: "SG" }, + }; + const h = ex.buildHeaders(creds); + assert.equal(h["x-user-region"], "SG", `x-user-region should respect a custom region, got ${h["x-user-region"]}`); +}); + +test("issue #12190: buildHeaders defaults x-user-region to US when none is set", () => { + const ex = new TraeExecutor(); + const h = ex.buildHeaders(CREDS); + assert.equal(h["x-user-region"], "US"); +}); + +test("issue #12190: buildHeaders lets a per-connection refererOrigin override the default web origin", () => { + const ex = new TraeExecutor(); + const creds = { + ...CREDS, + providerSpecificData: { + ...CREDS.providerSpecificData, + refererOrigin: "https://solo.trae.ai", + }, + }; + const h = ex.buildHeaders(creds); + assert.equal(h.Referer, "https://solo.trae.ai/"); + assert.equal(h.Origin, "https://solo.trae.ai"); +});