diff --git a/AGENTS.md b/AGENTS.md index 0ff0b8e..950e56e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ billion-context/ │ ├── session-id.ts # Session ID generation │ ├── persist.ts # On-disk session persistence (kernel StateStore) │ ├── update.ts # Auto-update: checks npm, auto-installs latest -│ ├── launcher.ts # `bili ` launchers (pi/codex/claude/omp/opencode/hermes) +│ ├── launcher.ts # `bili ` launchers (pi/codex/claude/omp/opencode/hermes/dsh/trae) │ ├── client-config.ts # READ-only discovery of each client's upstream config │ ├── mitm.ts / ca.ts # Cert-MITM proxying + lazily generated root CA │ ├── mcp.ts # Plugin-in-launcher MCP shell (spawn-time injection) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 979faff..a5901e2 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -464,6 +464,7 @@ Full command surface (`bili --help` prints an abridged version). Precedence ever | `bili opencode [opts --] [args]` | Proxy + **opencode** | | `bili hermes [opts --] [args]` | Proxy + **hermes-agent** (`/bili/` rewrite) | | `bili dsh [opts --] [args]` | Proxy + **deepseek-harness** (non-loopback upstreams via proxy envs, loopback via `/bili/` rewrite — #535; args like `--profile web "task"` pass through) | +| `bili trae [opts --] [args]` | Proxy + **Trae CLI** (ByteDance, closed Go binary) — cert-MITM via `HTTPS_PROXY` + `SSL_CERT_FILE`; model host from `TRAE_CLI_API_HOST` or the default enterprise gateway (#655) | | `bili test pi` | Non-polluting end-to-end smoke test of the pi path | | `bili export [session] [--full] [--output FILE]` | List persisted sessions / export one as a Markdown handoff — see [Sessions & Migration](#sessions--migration) | | `bili update` | Check for & install a newer version now (bypasses the 3-minute throttle) | diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index 2f5e48e..a2ff131 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -460,6 +460,7 @@ | `bili opencode [opts --] [args]` | 代理 + **opencode** | | `bili hermes [opts --] [args]` | 代理 + **hermes-agent**(`/bili/` 重写) | | `bili dsh [opts --] [args]` | 代理 + **deepseek-harness**(`/bili/` 重写;`--profile web "task"` 等参数原样透传) | +| `bili trae [opts --] [args]` | 代理 + **Trae CLI**(字节跳动,闭源 Go 二进制)—— 证书 MITM(`HTTPS_PROXY` + `SSL_CERT_FILE`);模型主机取 `TRAE_CLI_API_HOST` 或默认企业网关(#655) | | `bili test pi` | 无污染的 pi 链路端到端冒烟测试 | | `bili export [session] [--full] [--output FILE]` | 列出持久化会话 / 把一个会话导出为 Markdown 交接文档 —— 见[会话与迁移](#会话与迁移) | | `bili update` | 立即检查并安装新版本(绕过 3 分钟节流) | diff --git a/README.md b/README.md index 13e66f7..9f45bf2 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ rewrite until dsh gains a settings-path env or an upstream loopback opt-out. Overlay dirs created by older versions are left in place and never merged back into the real home. -### Option 1 — Launcher (`bili pi` / `bili codex` / `bili claude` / `bili omp` / `bili opencode` / `bili hermes` / `bili dsh`) +### Option 1 — Launcher (`bili pi` / `bili codex` / `bili claude` / `bili omp` / `bili opencode` / `bili hermes` / `bili dsh` / `bili trae`) The launcher wraps a client in one command: it starts a proxy on an independent port (a fresh instance is always spawned — a port is never @@ -177,6 +177,7 @@ bili omp # pi-style, file-free (#535): env + extens bili opencode # MITM for HTTPS + temp opencode.json (/bili/ for HTTP) + thin /acp plugin bili hermes # file-free (#535): hermes proxy env (HTTPS_PROXY + HERMES_CA_BUNDLE) — https via CONNECT MITM, http via absolute-form forward proxy; real ~/.hermes untouched bili dsh # deepseek-harness: non-loopback upstreams ride proxy envs (https MITM, http absolute-form), loopback keeps the overlay DSH_HOME (~/.dsh-bili) rewrite (#535), built-in deepseek route via DEEPSEEK_BASE_URL, native /acp command injected via --patch +bili trae # Trae CLI (ByteDance, closed Go binary, no base-URL override) — cert-MITM via HTTPS_PROXY + SSL_CERT_FILE, model host from TRAE_CLI_API_HOST or the default enterprise gateway (#655) bili pi --mitm-domain api.foo.com # add a domain to the MITM whitelist ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 9ee2451..f5f8f64 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -84,7 +84,7 @@ npm install -g billion-context bili 永不拥有用户数据:每个被启动的客户端都跑在**真实 home** 上,运行期写入落在用户预期的位置。把客户端指向代理时,启动器按优先级选择——**优先 env 变量**(hermes/dsh/codex 的代理/CA env;pi/omp 的 `BILI_PROVIDER_REWRITES` URL 清单,由扩展加载时经 `registerProvider` 消费),其次 **CLI 参数或扩展 API**(codex `-c key=value`、opencode 插件),最后才是**生成文件**——目前仅剩 opencode 的临时 `opencode.json`(退出即删)和 dsh 的回环例外:dsh 的 fetch 栈对回环目标无条件绕过代理 env,所以本地上游保留持久 `~/.dsh-bili` overlay 改写,直到 dsh 提供 settings-path env 或上游支持回环 opt-out。旧版本创建的 overlay 目录原地保留,绝不合并回真实 home。 -### 方式 1 —— 启动器(`bili pi` / `bili codex` / `bili claude` / `bili omp` / `bili opencode` / `bili hermes` / `bili dsh`) +### 方式 1 —— 启动器(`bili pi` / `bili codex` / `bili claude` / `bili omp` / `bili opencode` / `bili hermes` / `bili dsh` / `bili trae`) 启动器把客户端包进一条命令:在独立端口拉起一个代理(总是全新实例,绝不复用端口),再按客户端支持的机制把它指向代理 —— 能吃代理/CA 环境变量的走**证书 MITM**,不吃的走隔离的**`/bili/` 配置重写**。真实配置文件从不被修改;客户端自己的配置只被**读取**,用来发现它实际连接的 HTTPS 上游主机,把这些主机加入 MITM 白名单 —— 代理只 TLS 终结它们,其余流量盲透传。 @@ -96,6 +96,7 @@ bili omp # pi 同款,file-free(#535):环境变量 + bili opencode # HTTPS 走 MITM + 临时 opencode.json(HTTP 走 /bili/)+ 轻量 /acp 插件 bili hermes # file-free(#535):hermes 代理环境变量(HTTPS_PROXY + HERMES_CA_BUNDLE)—— https 走 CONNECT MITM,http 走绝对形式转发;真实 ~/.hermes 不动 bili dsh # deepseek-harness:非回环上游走代理 env(https MITM、http absolute-form),回环上游保留 overlay DSH_HOME(~/.dsh-bili)改写(#535),内置 deepseek 路由走 DEEPSEEK_BASE_URL,经 --patch 注入原生 /acp 命令 +bili trae # Trae CLI(字节跳动,闭源 Go 二进制,无 base-URL 覆盖)—— 证书 MITM(HTTPS_PROXY + SSL_CERT_FILE),模型主机取 TRAE_CLI_API_HOST 或默认企业网关(#655) bili pi --mitm-domain api.foo.com # 向 MITM 白名单追加域名 ``` diff --git a/src/cli.ts b/src/cli.ts index 193d4b8..c379a80 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -67,6 +67,7 @@ Usage: bili opencode [opts --] [args] start a proxy + launch opencode against it (cert-MITM) bili hermes [opts --] [args] start a proxy + launch hermes-agent against it (/bili/ rewrite) bili dsh [opts --] [args] start a proxy + launch deepseek-harness against it (/bili/ rewrite) + bili trae [opts --] [args] start a proxy + launch Trae CLI against it (cert-MITM) bili test pi non-polluting pi smoke test through the proxy bili export [session] [--full] list sessions / export one as a Markdown handoff (--full includes original messages; --output FILE) @@ -81,12 +82,12 @@ Usage: bili --version print version bili --help show this help -Launcher (bili pi / bili codex / bili claude / bili omp / bili opencode / bili hermes / bili dsh): +Launcher (bili pi / bili codex / bili claude / bili omp / bili opencode / bili hermes / bili dsh / bili trae): Brings up a proxy on an independent port (a fresh instance every launch), then runs the client pointed at it via HTTPS_PROXY + the proxy's MITM CA — no config-file edits. Discovered HTTPS upstream domains are auto-whitelisted for MITM so the proxy TLS-terminates exactly the hosts the - client uses; HTTP / localhost providers go direct. pi/claude trust the CA - via NODE_EXTRA_CA_CERTS, codex via SSL_CERT_FILE. Proxy killed on client exit. + client uses; HTTP / localhost providers go direct. pi/claude trust the CA + via NODE_EXTRA_CA_CERTS, codex/trae via SSL_CERT_FILE. Proxy killed on client exit. bili flags (-F, --mitm-domain, --port, ...) must precede the client name; everything after the client name is passed through to the client. bili pi # launch pi through the proxy @@ -96,8 +97,9 @@ Launcher (bili pi / bili codex / bili claude / bili omp / bili opencode / bili h bili claude # launch claude through the proxy bili omp # launch omp through the proxy (pi-based; /bili/ rewrite) bili hermes # launch hermes-agent through the proxy (/bili/ rewrite of ~/.hermes/config.yaml) - bili dsh --profile web "task" # launch deepseek-harness through the proxy (/bili/ rewrite of ~/.dsh/settings.yaml) - bili test pi # quick end-to-end check of the pi path + bili dsh --profile web "task" # launch deepseek-harness through the proxy (/bili/ rewrite of ~/.dsh/settings.yaml) + bili trae # launch Trae CLI through the proxy (cert-MITM; model host via TRAE_CLI_API_HOST or --mitm-domain) + bili test pi # quick end-to-end check of the pi path bili --mitm-domain api.foo.com pi # add a domain to the MITM whitelist (flags precede the client) bili -F http://127.0.0.1:7897 codex # route bili's upstream through a proxy (gost-style -F) diff --git a/src/client-config.ts b/src/client-config.ts index 47eb1a5..a84ec81 100644 --- a/src/client-config.ts +++ b/src/client-config.ts @@ -93,6 +93,10 @@ export interface DshConfig { baseUrls: string[]; } +export interface TraeConfig { + modelApiHost?: string; +} + export interface ClientConfig { claude?: ClaudeSettings; codex?: CodexConfig; @@ -102,6 +106,7 @@ export interface ClientConfig { opencode?: OpencodeConfig; hermes?: HermesConfig; dsh?: DshConfig; + trae?: TraeConfig; } export function nonEmpty(s: unknown): s is string { @@ -188,6 +193,33 @@ export function readDshConfig(dshHome: string): DshConfig { return { baseUrls: parseDshSettingsYaml(text) }; } +/** Default model API gateways for Trae CLI (ByteDance). The CLI is a Go + * binary that honors HTTPS_PROXY (Go net/http) and resolves its API host + * from TRAE_CLI_API_HOST (chatmodel.resolveBaseURL); without it the + * enterprise gateway is console.enterprise.trae.cn. These hosts are + * cert-MITM'd so `bili trae` can compress the model traffic. */ +export const TRAE_DEFAULT_MODEL_HOSTS = [ + "console.enterprise.trae.cn", + "www.trae.cn", +]; + +/** Trae CLI keeps its config under TRAE_CONFIG_DIR (default ~/.trae): + * traecli.yaml, skills, session state. */ +export function resolveTraeHome(env: NodeJS.ProcessEnv): string { + const h = os.homedir(); + return nonEmpty(env.TRAE_CONFIG_DIR) ? env.TRAE_CONFIG_DIR! + : path.join(h, ".trae"); +} + +export function readTraeConfig(env: NodeJS.ProcessEnv): TraeConfig { + const result: TraeConfig = {}; + const host = nonEmpty(env.TRAE_CLI_API_HOST) + ? env.TRAE_CLI_API_HOST!.replace(/^https?:\/\//i, "").replace(/\/+$/, "") + : undefined; + if (host) result.modelApiHost = host; + return result; +} + export function readClaudeSettings(homeDir: string, cwd: string, env: NodeJS.ProcessEnv = process.env): ClaudeSettings { const files = [ path.join(homeDir, ".claude", "settings.json"), @@ -579,6 +611,7 @@ export function loadClientConfig(env: NodeJS.ProcessEnv, cwd: string): ClientCon config.opencode = readOpencodeConfig(resolveOpencodeConfigFile(env)); config.hermes = readHermesConfig(resolveHermesHome(env)); config.dsh = readDshConfig(resolveDshHome(env)); + config.trae = readTraeConfig(env); return config; } @@ -586,7 +619,7 @@ export function loadClientConfig(env: NodeJS.ProcessEnv, cwd: string): ClientCon * launched client's own declarations are authoritative (#436: launching * `bili omp` with omp's models.yml declaring 131072 must not be overridden by * another client's larger declaration for the same model id). */ -export type ModelWindowScope = "claude" | "codex" | "pi" | "omp" | "opencode" | "hermes" | "dsh"; +export type ModelWindowScope = "claude" | "codex" | "pi" | "omp" | "opencode" | "hermes" | "dsh" | "trae"; /** Collect per-model context windows from client configs the launcher can * read (pi models.json, omp models.yml, opencode opencode.json, codex diff --git a/src/discover.ts b/src/discover.ts index d58b904..3b55d86 100644 --- a/src/discover.ts +++ b/src/discover.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { loadClientConfig, resolvePiHome, nonEmpty, type ClientConfig } from "./client-config.js"; +import { loadClientConfig, resolvePiHome, resolveTraeHome, TRAE_DEFAULT_MODEL_HOSTS, nonEmpty, type ClientConfig } from "./client-config.js"; const TTL_MS = 2000; @@ -43,6 +43,10 @@ export function extractHttpsHosts(config: ClientConfig): string[] { if (config.zcode) { for (const prov of Object.values(config.zcode.providers)) push(prov.baseURL); } + if (config.trae) { + const hosts = nonEmpty(config.trae.modelApiHost) ? [config.trae.modelApiHost] : TRAE_DEFAULT_MODEL_HOSTS; + for (const h of hosts) push(`https://${h}`); + } return out; } @@ -56,6 +60,7 @@ function configFilePaths(env: NodeJS.ProcessEnv): string[] { path.join(codexHome, "config.toml"), path.join(resolvePiHome(env), "models.json"), path.join(zcodeHome, "v2", "config.json"), + path.join(resolveTraeHome(env), "traecli.yaml"), ]; } diff --git a/src/launcher.ts b/src/launcher.ts index 1af2596..7915d84 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -43,7 +43,7 @@ import { selfPackageRoot, isBiliPiEntry, ompPluginLoadedFrom } from "./plugin-in function selfDistFile(name: string): string { return path.join(selfPackageRoot(), "dist", name); } -import { nonEmpty, resolvePiHome, resolveOmpHome, resolveDshHome, resolveCodexHome, loadClientConfig, collectModelWindows, type ClientConfig, type CodexConfig, resolveOpencodeConfigFile, type OpencodeConfig, type OpencodeProvider, type HermesConfig, type HermesProvider } from "./client-config.js"; +import { nonEmpty, resolvePiHome, resolveOmpHome, resolveDshHome, resolveCodexHome, resolveTraeHome, readTraeConfig, TRAE_DEFAULT_MODEL_HOSTS, type TraeConfig, loadClientConfig, collectModelWindows, type ClientConfig, type CodexConfig, resolveOpencodeConfigFile, type OpencodeConfig, type OpencodeProvider, type HermesConfig, type HermesProvider } from "./client-config.js"; import { loadRoutes, resolveConfiguredContextLimit, lookupContextLimit, type ProviderRoutes } from "./config.js"; import { contextFromRegistry } from "./registry.js"; @@ -76,6 +76,10 @@ export { parseDshSettingsYaml, resolveDshHome, resolveCodexHome, + resolveTraeHome, + readTraeConfig, + TRAE_DEFAULT_MODEL_HOSTS, + type TraeConfig, resolveOpencodeConfigFile, readOpencodeConfig, type OpencodeConfig, @@ -83,9 +87,9 @@ export { } from "./client-config.js"; export const LAUNCHER_DEFAULT_HOST = "127.0.0.1"; -export const LAUNCH_CLIENTS = ["pi", "codex", "claude", "omp", "opencode", "hermes", "dsh", "pi-test"] as const; +export const LAUNCH_CLIENTS = ["pi", "codex", "claude", "omp", "opencode", "hermes", "dsh", "trae", "pi-test"] as const; export type ClientName = (typeof LAUNCH_CLIENTS)[number]; -export type BaseClientName = "claude" | "codex" | "pi" | "omp" | "opencode" | "hermes" | "dsh"; +export type BaseClientName = "claude" | "codex" | "pi" | "omp" | "opencode" | "hermes" | "dsh" | "trae"; const HEALTH_PATH = "/__bili/health"; const HEALTH_POLL_INTERVAL_MS = 200; @@ -359,6 +363,24 @@ export function discoverRoutes(client: ClientName, config: ClientConfig): Discov // Unparseable endpoint: skip. } } + } else if (client === "trae") { + // #655: Trae CLI is a closed Go binary (no base-URL override) that + // honors HTTPS_PROXY; the model API host is TRAE_CLI_API_HOST or the + // default enterprise gateway. Whitelist the host(s) for cert-MITM so + // the proxy can compress the model traffic. No /bili/ rewrite (the + // scheme is hardcoded https). + const hosts = nonEmpty(config.trae?.modelApiHost) + ? [config.trae!.modelApiHost!] + : TRAE_DEFAULT_MODEL_HOSTS; + for (const h of hosts) { + // MITM whitelist matches the port-less SNI hostname (isMitmHost), so + // reduce host:port to its host or the entry never matches. + const host = h.split(":", 2)[0]!.toLowerCase(); + if (host && !httpsSeen.has(host)) { + httpsSeen.add(host); + httpsDomains.push(host); + } + } } else { for (const [name, prov] of Object.entries(config.codex?.providers ?? {})) { classify(prov.baseUrl, `model_providers.${name}.base_url`); @@ -409,6 +431,12 @@ export function buildCodexEnv(origin: string, caPath: string, baseEnv: NodeJS.Pr return { ...baseEnv, HTTPS_PROXY: origin, SSL_CERT_FILE: caPath, BILLION_CONTEXT_PROXY: origin }; } +export function buildTraeEnv(origin: string, caPath: string, baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + // #655: trae is a Go binary like codex — the CA rides SSL_CERT_FILE (the + // combined bundle, since it replaces Go's system trust store). + return { ...baseEnv, HTTPS_PROXY: origin, SSL_CERT_FILE: caPath, BILLION_CONTEXT_PROXY: origin }; +} + export function buildCodexArgs( origin: string, httpRewrites: HttpRewrite[], @@ -616,7 +644,7 @@ function isPrivateIPv4(host: string): boolean { * understands) is the sane default. `BILI_LAUNCHER_PLUGIN=1` forces plugin * mode regardless of the upstream. */ export function launcherInjectMcp(env: NodeJS.ProcessEnv, base: string, codexUpstream?: string): boolean { - if (base === "pi" || base === "omp" || base === "opencode" || base === "hermes" || base === "dsh") return false; + if (base === "pi" || base === "omp" || base === "opencode" || base === "hermes" || base === "dsh" || base === "trae") return false; if (env.BILI_LAUNCHER_PLUGIN === "0") return false; if (base === "codex" && env.BILI_LAUNCHER_PLUGIN === undefined && codexUpstream !== undefined && isPrivateUpstreamHost(codexUpstream)) { return false; @@ -1787,6 +1815,12 @@ export function resolveClientCommand( ); return { command: process.execPath, prefixArgs: [cli] }; } + if (client === "trae") { + const traeBin = resolveOnPath("traecli", env) + ?? resolveOnPath("trae-cli", env) + ?? resolveOnPath("trae", env); + return { command: traeBin ?? "traecli", prefixArgs: [] }; + } const resolved = resolveOnPath(client, env); return { command: resolved ?? client, prefixArgs: [] }; } @@ -2017,6 +2051,13 @@ export async function runLaunch(params: RunLaunchParams, deps: LauncherDeps = {} // settings rewrite above), so it exists on every profile dsh boots. const dshAcpPatch = writeDshAcpPatch(dshHomeDir); if (dshAcpPatch) clientArgs = dshArgsWithPatch(clientArgs, dshAcpPatch); + } else if (base === "trae") { + // #655: cert-MITM like codex/qoder (Go binary honors HTTPS_PROXY; CA + // via SSL_CERT_FILE combined bundle). No budget env (the CLI manages + // its own context window) and no transport forcing — the wire is the + // proprietary /api/ide/v2/llm_raw_chat, recognized as OpenAI by the + // proxy. + env = buildTraeEnv(origin, resolveCombinedCaPath(process.env), stripInheritedProxy(process.env)); } else if (base === "codex") { // Per-spawn conversation id for the MCP shell's headless // self-registration (codex provides no session id of its own). diff --git a/src/server.ts b/src/server.ts index 3554ae2..941657c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -116,7 +116,7 @@ export function logUnrecognizedPath(log: (level: string, msg: string) => void, u const n = (unrecognizedPathCounts.get(key) ?? 0) + 1; unrecognizedPathCounts.set(key, n); if (n <= 3) { - log("warn", `unrecognized path ${key} — not a known protocol (/chat/completions, /v1/messages, /responses, /responses/compact); forwarding unchanged`); + log("warn", `unrecognized path ${key} — not a known protocol (/chat/completions, /llm_raw_chat, /v1/messages, /responses, /responses/compact); forwarding unchanged`); } else if (n === 4) { log("info", `unrecognized path ${key}: forwarding unchanged; further occurrences suppressed`); } @@ -982,7 +982,7 @@ async function handle( protocol = route?.explicitProtocol ?? (req.method === "POST" && bodyBuffer.length > 0 - ? urlPath.endsWith("/chat/completions") + ? urlPath.endsWith("/chat/completions") || urlPath.endsWith("/llm_raw_chat") ? "openai" : urlPath.endsWith("/v1/messages") || urlPath.endsWith("/messages") ? "anthropic" @@ -2917,7 +2917,7 @@ function logUpstreamProxyDecision(opts: ProxyOptions, upstreamUrl: string | unde * checks in handleRequest; returns null when unknown (no rewrite). */ function inferWireProtocol(path: string): "openai" | "responses" | null { const p = path.split("?", 2)[0]; - if (p.endsWith("/chat/completions")) return "openai"; + if (p.endsWith("/chat/completions") || p.endsWith("/llm_raw_chat")) return "openai"; if (p.endsWith("/responses") || p.endsWith("/responses/compact")) return "responses"; return null; } diff --git a/tests/discover.test.ts b/tests/discover.test.ts index d8bf82e..ae1d06b 100644 --- a/tests/discover.test.ts +++ b/tests/discover.test.ts @@ -11,6 +11,7 @@ import { import { parseZcodeConfig, readZcodeConfig, + TRAE_DEFAULT_MODEL_HOSTS, type ClientConfig, } from "../src/client-config.ts"; @@ -119,6 +120,11 @@ test("extractHttpsHosts: empty config → []", () => { assert.deepEqual(extractHttpsHosts({}), []); }); +test("extractHttpsHosts: trae → default model hosts; modelApiHost replaces them (#655)", () => { + assert.deepEqual(extractHttpsHosts({ trae: {} }), TRAE_DEFAULT_MODEL_HOSTS); + assert.deepEqual(extractHttpsHosts({ trae: { modelApiHost: "my-relay.example.com" } }), ["my-relay.example.com"]); +}); + async function withTempHome(fn: (home: string, env: NodeJS.ProcessEnv) => Promise): Promise { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "bili-disc-")); const savedHome = process.env.HOME; diff --git a/tests/launcher.test.ts b/tests/launcher.test.ts index 46134db..a067d4d 100644 --- a/tests/launcher.test.ts +++ b/tests/launcher.test.ts @@ -40,6 +40,10 @@ import { parseDshSettingsYaml, readDshConfig, resolveDshHome, + resolveTraeHome, + readTraeConfig, + buildTraeEnv, + TRAE_DEFAULT_MODEL_HOSTS, prepareDshHome, writeDshAcpPatch, dshArgsWithPatch, @@ -73,6 +77,7 @@ test("isLaunchClient: pi/claude/codex/omp/opencode/pi-test true, others false", assert.equal(isLaunchClient("opencode"), true); assert.equal(isLaunchClient("hermes"), true); assert.equal(isLaunchClient("dsh"), true); + assert.equal(isLaunchClient("trae"), true); assert.equal(isLaunchClient("pi-test"), true); assert.equal(isLaunchClient("start"), false); assert.equal(isLaunchClient(""), false); @@ -2454,3 +2459,138 @@ test("runLaunch claude: CLAUDE_CODE_AUTO_COMPACT_WINDOW injected (built-in table fs.rmSync(home, { recursive: true, force: true }); } }); + +test("resolveTraeHome: TRAE_CONFIG_DIR override > ~/.trae", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-trae-home-")); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + process.env.HOME = home; + if (prevUserProfile !== undefined) process.env.USERPROFILE = home; + try { + assert.equal(resolveTraeHome({ TRAE_CONFIG_DIR: "/tmp/trae-cfg" }), "/tmp/trae-cfg"); + assert.equal(resolveTraeHome({}), path.join(home, ".trae")); + } finally { + process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("readTraeConfig: TRAE_CLI_API_HOST (scheme + trailing slash stripped)", () => { + assert.deepEqual(readTraeConfig({}), {}); + assert.deepEqual(readTraeConfig({ TRAE_CLI_API_HOST: "https://my-relay.example.com:8443/" }), { modelApiHost: "my-relay.example.com:8443" }); + assert.deepEqual(readTraeConfig({ TRAE_CLI_API_HOST: "my-relay.example.com" }), { modelApiHost: "my-relay.example.com" }); + assert.deepEqual(readTraeConfig({ TRAE_CLI_API_HOST: " " }), {}); +}); + +test("discoverRoutes: trae → default MITM hosts, no rewrites (#655)", () => { + const routes = discoverRoutes("trae", {}); + assert.deepEqual(routes.httpsDomains, TRAE_DEFAULT_MODEL_HOSTS); + assert.deepEqual(routes.httpRewrites, []); + assert.deepEqual(routes.httpsRewrites, []); + assert.deepEqual(routes.httpEnvRoutes, []); +}); + +test("discoverRoutes: trae modelApiHost replaces the default map", () => { + const config: ClientConfig = { trae: { modelApiHost: "my-relay.example.com" } }; + const routes = discoverRoutes("trae", config); + assert.deepEqual(routes.httpsDomains, ["my-relay.example.com"]); +}); + +test("discoverRoutes: trae modelApiHost with :port → hostname only (MITM is SNI-based, #655)", () => { + const config: ClientConfig = { trae: { modelApiHost: "my-relay.example.com:8443" } }; + const routes = discoverRoutes("trae", config); + assert.deepEqual(routes.httpsDomains, ["my-relay.example.com"]); +}); + +test("buildTraeEnv: HTTPS_PROXY + SSL_CERT_FILE + BILLION_CONTEXT_PROXY, baseEnv preserved", () => { + const env = buildTraeEnv("http://127.0.0.1:8787", "/tmp/ca.pem", { FOO: "bar" }); + assert.equal(env.HTTPS_PROXY, "http://127.0.0.1:8787"); + assert.equal(env.SSL_CERT_FILE, "/tmp/ca.pem"); + assert.equal(env.BILLION_CONTEXT_PROXY, "http://127.0.0.1:8787"); + assert.equal(env.FOO, "bar"); +}); + +test("resolveClientCommand: trae resolves `traecli`, falls back to `trae-cli` then `trae`", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bili-trae-bin-")); + try { + const env: NodeJS.ProcessEnv = { PATH: dir }; + assert.deepEqual(resolveClientCommand("trae", env), { command: "traecli", prefixArgs: [] }); + fs.writeFileSync(path.join(dir, "trae"), ""); + assert.deepEqual(resolveClientCommand("trae", env), { command: path.join(dir, "trae"), prefixArgs: [] }); + fs.writeFileSync(path.join(dir, "trae-cli"), ""); + assert.deepEqual(resolveClientCommand("trae", env), { command: path.join(dir, "trae-cli"), prefixArgs: [] }); + fs.writeFileSync(path.join(dir, "traecli"), ""); + assert.deepEqual(resolveClientCommand("trae", env), { command: path.join(dir, "traecli"), prefixArgs: [] }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runLaunch trae: cert-MITM envs (SSL_CERT_FILE combined bundle), no budget/transport, default MITM whitelist (#655)", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-trae-launch-")); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const prevBin = process.env.BILI_CLIENT_BIN; + const prevNoProxy = process.env.NO_PROXY; + process.env.HOME = home; + if (prevUserProfile !== undefined) process.env.USERPROFILE = home; + const fakeTrae = path.join(home, "fake-traecli"); + fs.writeFileSync(fakeTrae, ""); + process.env.BILI_CLIENT_BIN = fakeTrae; + process.env.NO_PROXY = "localhost,.corp"; + + const clientEnvs: (NodeJS.ProcessEnv | undefined)[] = []; + const proxyEnvs: (NodeJS.ProcessEnv | undefined)[] = []; + const spawnImpl: SpawnFn = (cmd, args, opts) => { + const env = (opts as { env?: NodeJS.ProcessEnv } | undefined)?.env; + if (cmd === fakeTrae) { + clientEnvs.push(env); + const child = makeFakeChild(0); + const orig = child.on.bind(child); + (child as { on: SpawnChild["on"] }).on = (event, listener) => { + orig(event, listener); + if (event === "exit") setTimeout(() => listener(0, null), 0); + return child; + }; + return child; + } + proxyEnvs.push(env); + return makeFakeChild(42424); + }; + const fetchImpl = async () => ({ ok: true }); + const prevExit = process.exit; + process.exit = (() => undefined) as typeof process.exit; + + try { + await runLaunch( + { client: "trae", clientArgs: [], overrides: {} }, + { fetchImpl, spawnImpl, sleep: () => Promise.resolve() }, + ); + assert.equal(clientEnvs.length, 1); + const seenEnv = clientEnvs[0]!; + const origin = seenEnv.BILLION_CONTEXT_PROXY; + assert.ok(/^http:\/\/127\.0\.0\.1:\d+$/.test(String(origin)), `origin: ${origin}`); + assert.equal(seenEnv.HTTPS_PROXY, origin); + assert.ok(String(seenEnv.SSL_CERT_FILE).endsWith(path.join("billion-context", "ca", "combined-ca.pem")), String(seenEnv.SSL_CERT_FILE)); + assert.equal(seenEnv.NODE_EXTRA_CA_CERTS, undefined, "trae uses SSL_CERT_FILE, not NODE_EXTRA_CA_CERTS"); + assert.equal(seenEnv.HTTP_PROXY, undefined, "inherited HTTP_PROXY stripped"); + assert.equal(seenEnv.NO_PROXY, undefined, "inherited NO_PROXY stripped"); + assert.ok(proxyEnvs.length > 0, "proxy child spawned"); + const mitm = String(proxyEnvs[0]!.BILI_MITM_DOMAINS).split(","); + for (const h of TRAE_DEFAULT_MODEL_HOSTS) { + assert.ok(mitm.includes(h), `whitelist has ${h}: ${mitm.join(",")}`); + } + } finally { + process.exit = prevExit; + process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + if (prevBin === undefined) delete process.env.BILI_CLIENT_BIN; + else process.env.BILI_CLIENT_BIN = prevBin; + if (prevNoProxy === undefined) delete process.env.NO_PROXY; + else process.env.NO_PROXY = prevNoProxy; + fs.rmSync(home, { recursive: true, force: true }); + } +});