Compare commits

..

3 Commits

14 changed files with 160 additions and 54 deletions

View File

@@ -1175,6 +1175,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

View File

@@ -0,0 +1 @@
- fix(providers): refresh Trae's stale Referer/Origin and forward user timezone so imported connections stop failing with 401 (#12190)

View File

@@ -1 +0,0 @@
- fix(oauth): align codebuddy-cn OAuth User-Agent with the chat/usage CLI version to avoid WAF false positives (#12702)

View File

@@ -1,4 +1,3 @@
import { CODEBUDDY_CN_USER_AGENT } from "@/lib/oauth/constants/oauth";
import type { RegistryEntry } from "../../shared.ts";
/**
@@ -21,7 +20,7 @@ export const codebuddy_cnProvider: RegistryEntry = {
authType: "oauth",
authHeader: "bearer",
headers: {
"User-Agent": CODEBUDDY_CN_USER_AGENT,
"User-Agent": "CLI/2.108.1 CodeBuddy/2.108.1",
"X-Product": "SaaS",
"X-IDE-Type": "CLI",
"X-IDE-Name": "CLI",

View File

@@ -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<string, string> {
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",

View File

@@ -14,8 +14,6 @@
* packs, "Bonus Pack N" for bonus packs (soonest-expiring first).
*/
import { CODEBUDDY_CN_USER_AGENT } from "@/lib/oauth/constants/oauth";
const USAGE_URL = "https://copilot.tencent.com/v2/billing/meter/get-user-resource";
interface TencentAccount {
@@ -132,7 +130,7 @@ export async function getCodeBuddyCnUsage(
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": CODEBUDDY_CN_USER_AGENT,
"User-Agent": "CLI/2.108.1 CodeBuddy/2.108.1",
"X-Product": "SaaS",
"X-IDE-Type": "CLI",
"X-IDE-Name": "CLI",

View File

@@ -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 ───────────────────────────────────────────────────────

View File

@@ -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",
},
],
});
}

View File

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

View File

@@ -106,21 +106,12 @@ export const QODER_CONFIG = {
// CodeBuddy CN (Tencent — copilot.tencent.com) OAuth Configuration
// (Custom Device-Auth Flow: POST stateUrl → open authUrl → GET pollUrl?state=).
// No client_id/secret — the upstream CLI ships none.
//
// CODEBUDDY_CN_USER_AGENT is the single source of truth for the CLI/CodeBuddy version
// string. It MUST stay identical across OAuth (this file), chat completions
// (open-sse/config/providers/registry/codebuddy-cn/index.ts) and usage/quota
// (open-sse/services/usage/codebuddy-cn.ts) — a mismatched version string across a
// single account's auth vs. chat calls is exactly the kind of internally-inconsistent
// client fingerprint Tencent's WAF flags as anomalous (#12702).
export const CODEBUDDY_CN_USER_AGENT = "CLI/2.108.1 CodeBuddy/2.108.1";
export const CODEBUDDY_CN_CONFIG = {
baseUrl: "https://copilot.tencent.com",
stateUrl: "https://copilot.tencent.com/v2/plugin/auth/state",
tokenUrl: "https://copilot.tencent.com/v2/plugin/auth/token",
refreshUrl: "https://copilot.tencent.com/v2/plugin/auth/token/refresh",
userAgent: CODEBUDDY_CN_USER_AGENT,
userAgent: "CLI/2.63.2 CodeBuddy/2.63.2",
platform: "CLI",
pollInterval: 5000,
};

View File

@@ -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,

View File

@@ -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({

View File

@@ -585,38 +585,3 @@ test("codebuddy-cn is treated as a managed dual-auth provider (oauth + apikey ac
"codebuddy-cn must be admitted by the dual-auth gate"
);
});
test("#12702: codebuddy-cn presents the same CLI/CodeBuddy version across OAuth, chat and usage calls", async () => {
// A mismatched version string across a single account's auth vs. chat calls is exactly the
// kind of internally-inconsistent client fingerprint Tencent's WAF flags as anomalous
// (code 11128 "request illegal" / "blocked by security policy"). All three surfaces must
// read from the same CODEBUDDY_CN_USER_AGENT constant so they can never drift apart again.
const oauthUserAgent = CODEBUDDY_CN_CONFIG.userAgent;
const chatUserAgent = REGISTRY["codebuddy-cn"].headers?.["User-Agent"];
assert.equal(
oauthUserAgent,
chatUserAgent,
`codebuddy-cn OAuth User-Agent (${oauthUserAgent}) must match the chat User-Agent (${chatUserAgent})`
);
const { CODEBUDDY_CN_USER_AGENT } = await import("../../src/lib/oauth/constants/oauth.ts");
assert.equal(oauthUserAgent, CODEBUDDY_CN_USER_AGENT);
const origFetch = globalThis.fetch;
let capturedUserAgent: string | undefined;
globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
capturedUserAgent = (init?.headers as Record<string, string> | undefined)?.["User-Agent"];
return new Response(JSON.stringify({}), { status: 200 });
}) as typeof fetch;
try {
const { getCodeBuddyCnUsage } = await import("../../open-sse/services/usage/codebuddy-cn.ts");
await getCodeBuddyCnUsage("ACCESS_TOKEN", undefined, undefined);
assert.equal(
capturedUserAgent,
CODEBUDDY_CN_USER_AGENT,
"codebuddy-cn usage/quota User-Agent must match the shared constant"
);
} finally {
globalThis.fetch = origFetch;
}
});

View File

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