From ba5377c7fdf406815b6a3831b4612c8e124e858d Mon Sep 17 00:00:00 2001 From: ework-agent Date: Wed, 9 Sep 2026 10:23:58 +0800 Subject: [PATCH] feat: add qoder client support (bili qoder) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qoder's model endpoint scheme is hardcoded https with no base-URL override env, so /bili/ rewrites cannot reach it — cert-MITM is the only route (qoder's built-in undici honors HTTPS_PROXY + NODE_EXTRA_CA_CERTS). - client-config: QoderConfig, qoderIsCnSite (QODERCLI_SITE / CN-prefixed envs / on-disk config dir), resolveQoderHome, readQoderConfig (settings.json model, QODER_MODEL_SERVER_HOST), QODER_DEFAULT_MODEL_HOSTS - launcher: bili qoder (proxy mode) — HTTPS_PROXY + NODE_EXTRA_CA_CERTS + BILLION_CONTEXT_PROXY, default model hosts whitelisted for MITM (or the QODER_MODEL_SERVER_HOST override), QODER_MODEL_TRANSPORT=http forced (legacy fallback wire unverified), #321 budget alignment via QODER_AUTOCOMPACT_WINDOW / QODERCN_AUTOCOMPACT_WINDOW, qoder/qodercli binary resolution - discover: qoder hosts in MITM domain auto-discovery - cli/README/AGENTS: help + docs Fixes #653 --- AGENTS.md | 2 +- README.md | 7 +- src/cli.ts | 13 ++- src/client-config.ts | 88 ++++++++++++++- src/discover.ts | 10 +- src/launcher.ts | 98 ++++++++++++++++- tests/discover.test.ts | 6 + tests/launcher.test.ts | 245 +++++++++++++++++++++++++++++++++++++++++ 8 files changed, 454 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0ff0b8e6..398959e9 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/qoder) │ ├── 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/README.md b/README.md index 13e66f71..196342a5 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 qoder`) 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 @@ -176,8 +176,9 @@ bili claude # launch claude through the proxy bili omp # pi-style, file-free (#535): env + extension registerProvider + compaction cancel, real ~/.omp untouched 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 pi --mitm-domain api.foo.com # add a domain to the MITM whitelist + 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 qoder # qoder: model endpoint is hardcoded https (no /bili/ rewrite possible) — cert-MITM via HTTPS_PROXY + NODE_EXTRA_CA_CERTS, default model hosts whitelisted (#653) + bili pi --mitm-domain api.foo.com # add a domain to the MITM whitelist ``` ### Option 2 — URL change (`/bili/` prefix) diff --git a/src/cli.ts b/src/cli.ts index 193d4b8c..607ef5ad 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 qoder [opts --] [args] start a proxy + launch qoder 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,13 @@ 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 qoder): 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/qoder trust + the CA via NODE_EXTRA_CA_CERTS, codex 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 +98,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 qoder # launch qoder through the proxy (cert-MITM; model endpoint is hardcoded https, so no /bili/ rewrite) + 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 47eb1a5b..1632069e 100644 --- a/src/client-config.ts +++ b/src/client-config.ts @@ -93,6 +93,16 @@ export interface DshConfig { baseUrls: string[]; } +export interface QoderConfig { + /** Model qoder runs: settings.json `model` (gemini-cli-style `model.name` + * or bare string) — budget alignment (#321 pattern, #653). */ + model?: string; + /** `QODER_MODEL_SERVER_HOST` (undocumented env, scheme/trailing-slash + * stripped) — when set, qoder talks to this host INSTEAD of the + * binary's static default map, so it replaces the MITM whitelist. */ + modelServerHost?: string; +} + export interface ClientConfig { claude?: ClaudeSettings; codex?: CodexConfig; @@ -102,6 +112,81 @@ export interface ClientConfig { opencode?: OpencodeConfig; hermes?: HermesConfig; dsh?: DshConfig; + qoder?: QoderConfig; +} + +/** qoder's default model-inference hosts, hardcoded in the binary (no config + * file to discover from): prod + regional (US/SG/JP) + the CN gateway. + * daily/test variants are deliberately NOT included. */ +export const QODER_DEFAULT_MODEL_HOSTS = [ + "api2-v2.qoder.sh", + "api1.qoder.sh", + "api2.qoder.sh", + "api3.qoder.sh", + "gateway.qoder.com.cn", +]; + +/** CN-site detection rule (#653 open question 4): the launcher picks the + * `QODER_` vs `QODERCN_` env prefix by, in order — (1) `QODERCLI_SITE=cn`, + * (2) a CN-prefixed config env being set (`QODERCN_CONFIG_DIR` / + * `QODERCN_CLI_HOME`), (3) only the CN config dir existing on disk (the CN + * package defaults to `~/.qoder-cn`, the intl one to `~/.qoder`), else intl. + * Known edge: with BOTH packages installed, a CN launch is detected as intl + * (degrades to no budget injection / wrong-prefix transport env — never + * breaks the launch). */ +export function qoderIsCnSite(env: NodeJS.ProcessEnv = process.env): boolean { + const site = env.QODERCLI_SITE?.trim().toLowerCase(); + if (site === "cn") return true; + if (nonEmpty(env.QODERCN_CONFIG_DIR) || nonEmpty(env.QODERCN_CLI_HOME)) return true; + const h = os.homedir(); + const cnDir = path.join(h, ".qoder-cn"); + const intlDir = path.join(h, ".qoder"); + try { + if (fs.existsSync(cnDir) && !fs.existsSync(intlDir)) return true; + } catch {} + return false; +} + +/** qoder's config root: `QODER_CONFIG_DIR`/`QODERCN_CONFIG_DIR` (full path) + * > `QODER_CLI_HOME`/`QODERCN_CLI_HOME` + dir name > `~/.qoder` (intl) / + * `~/.qoder-cn` (CN); `QODER_CONFIG_DIR_NAME`/`QODERCN_CONFIG_DIR_NAME` + * override the dir name. The site prefix family is chosen by qoderIsCnSite. */ +export function resolveQoderHome(env: NodeJS.ProcessEnv = process.env): string { + const h = os.homedir(); + const cn = qoderIsCnSite(env); + const configDir = cn ? env.QODERCN_CONFIG_DIR : env.QODER_CONFIG_DIR; + if (nonEmpty(configDir)) return configDir!; + const cliHomeEnv = cn ? env.QODERCN_CLI_HOME : env.QODER_CLI_HOME; + const cliHome = nonEmpty(cliHomeEnv) ? cliHomeEnv! : h; + const dirNameEnv = cn ? env.QODERCN_CONFIG_DIR_NAME : env.QODER_CONFIG_DIR_NAME; + const dirName = nonEmpty(dirNameEnv) ? dirNameEnv! : (cn ? ".qoder-cn" : ".qoder"); + return path.join(cliHome, dirName); +} + +/** Read-only discovery of qoder's `/settings.json` (gemini-cli + * style): the selected model for budget alignment. qoder has no local model + * catalog (server-driven), so no windows are collected here. The model host + * override env is read too — it decides which host the MITM whitelist must + * carry. */ +export function readQoderConfig(qoderHome: string, env: NodeJS.ProcessEnv = process.env): QoderConfig { + const result: QoderConfig = {}; + const obj = readJsonObject(path.join(qoderHome, "settings.json")); + const model = obj?.model; + if (typeof model === "string" && model.trim().length > 0) { + result.model = model.trim(); + } else if (model && typeof model === "object" && !Array.isArray(model)) { + const name = (model as Record).name; + if (nonEmpty(name)) result.model = name!.trim(); + } + const cn = qoderIsCnSite(env); + const host = nonEmpty(cn ? env.QODERCN_MODEL_SERVER_HOST : env.QODER_MODEL_SERVER_HOST) + ? (cn ? env.QODERCN_MODEL_SERVER_HOST : env.QODER_MODEL_SERVER_HOST)! + : undefined; + if (host) { + const bare = host.trim().replace(/^https?:\/\//i, "").replace(/\/+$/, ""); + if (bare.length > 0) result.modelServerHost = bare; + } + return result; } export function nonEmpty(s: unknown): s is string { @@ -579,6 +664,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.qoder = readQoderConfig(resolveQoderHome(env), env); return config; } @@ -586,7 +672,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" | "qoder"; /** 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 d58b9044..76f66697 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, resolveQoderHome, nonEmpty, QODER_DEFAULT_MODEL_HOSTS, type ClientConfig } from "./client-config.js"; const TTL_MS = 2000; @@ -43,6 +43,13 @@ export function extractHttpsHosts(config: ClientConfig): string[] { if (config.zcode) { for (const prov of Object.values(config.zcode.providers)) push(prov.baseURL); } + if (config.qoder) { + // qoder's model hosts are binary-hardcoded (no config file), so the + // discovery set is the static default map — replaced entirely by an + // explicit QODER_MODEL_SERVER_HOST (qoder's own resolution order). + const hosts = nonEmpty(config.qoder.modelServerHost) ? [config.qoder.modelServerHost] : QODER_DEFAULT_MODEL_HOSTS; + for (const h of hosts) push(`https://${h}`); + } return out; } @@ -56,6 +63,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(resolveQoderHome(env), "settings.json"), ]; } diff --git a/src/launcher.ts b/src/launcher.ts index 1af25962..f4af2e95 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, loadClientConfig, collectModelWindows, type ClientConfig, type CodexConfig, resolveOpencodeConfigFile, type OpencodeConfig, type OpencodeProvider, type HermesConfig, type HermesProvider, qoderIsCnSite, QODER_DEFAULT_MODEL_HOSTS } from "./client-config.js"; import { loadRoutes, resolveConfiguredContextLimit, lookupContextLimit, type ProviderRoutes } from "./config.js"; import { contextFromRegistry } from "./registry.js"; @@ -80,12 +80,17 @@ export { readOpencodeConfig, type OpencodeConfig, type OpencodeProvider, + readQoderConfig, + resolveQoderHome, + qoderIsCnSite, + QODER_DEFAULT_MODEL_HOSTS, + type QoderConfig, } 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", "qoder", "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" | "qoder"; const HEALTH_PATH = "/__bili/health"; const HEALTH_POLL_INTERVAL_MS = 200; @@ -359,6 +364,21 @@ export function discoverRoutes(client: ClientName, config: ClientConfig): Discov // Unparseable endpoint: skip. } } + } else if (client === "qoder") { + // #653: qoder's model endpoint scheme is hardcoded https with no + // base-URL override env, so /bili/ rewrites cannot reach it — cert + // MITM is the only route (qoder honors HTTPS_PROXY + + // NODE_EXTRA_CA_CERTS). Whitelist is the binary's static host map + // (prod + regional + CN gateway); an explicit QODER_MODEL_SERVER_HOST + // REPLACES it (qoder's own resolution order: env > static map). + const hosts = nonEmpty(config.qoder?.modelServerHost) ? [config.qoder!.modelServerHost!] : QODER_DEFAULT_MODEL_HOSTS; + for (const host of hosts) { + const h = host.toLowerCase(); + if (h && !httpsSeen.has(h)) { + httpsSeen.add(h); + httpsDomains.push(h); + } + } } else { for (const [name, prov] of Object.entries(config.codex?.providers ?? {})) { classify(prov.baseUrl, `model_providers.${name}.base_url`); @@ -543,6 +563,42 @@ export function buildClaudeEnv( return env; } +/** #653: qoder's model endpoint scheme is hardcoded https (no base-URL + * override env), so the launcher can only route it via cert MITM: its + * built-in undici stack honors HTTPS_PROXY, and NODE_EXTRA_CA_CERTS is + * ADDITIVE (unlike codex's SSL_CERT_FILE), so the plain root CA suffices. + * No base-URL rewrite of any kind. */ +export function buildQoderEnv(origin: string, caPath: string, baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return { ...baseEnv, HTTPS_PROXY: origin, NODE_EXTRA_CA_CERTS: caPath, BILLION_CONTEXT_PROXY: origin }; +} + +/** + * #653: qoder's auto-compact window is a single env knob — + * `QODER_AUTOCOMPACT_WINDOW` (`QODERCN_` prefix on the CN site) caps the + * effective context window (`min(modelWindow, env)`), so injecting bili's + * window is always safe (same #321 pattern as claude). + * + * Returns {} (no injection) when: no model resolvable, the user already set + * an explicit auto-compact window (shell-exported env var), or bili resolves + * no window for the model. qoder's model catalog is server-driven, so its + * model names are usually absent from bili's configured limits and the + * models.dev registry — the common outcome is no injection, with the user's + * own `QODER_AUTOCOMPACT_WINDOW` as the fallback (issue #653 open question 3). + */ +export async function resolveQoderBudgetEnv(opts: { + model: string | undefined; + userAutoCompactWindow: string | undefined; + windowKey: string; + routes: ProviderRoutes; + upstreamUrl: string | undefined; +}): Promise { + const { model, userAutoCompactWindow, windowKey, routes, upstreamUrl } = opts; + if (!model || nonEmpty(userAutoCompactWindow)) return {}; + const window = await resolveLauncherWindow(model, routes, upstreamUrl); + if (!window) return {}; + return { [windowKey]: String(window) }; +} + // --- Launcher plugin mode (#162): inject the MCP shell + session hooks as // spawn-time flags, never touching host config files on disk. --- @@ -616,7 +672,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 === "qoder") 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 +1843,12 @@ export function resolveClientCommand( ); return { command: process.execPath, prefixArgs: [cli] }; } + if (client === "qoder") { + // npm bin names: `qoder` (primary) with `qodercli` as the alternate + // registration (both packages ship either). + const resolved = resolveOnPath("qoder", env) ?? resolveOnPath("qodercli", env); + return { command: resolved ?? "qoder", prefixArgs: [] }; + } const resolved = resolveOnPath(client, env); return { command: resolved ?? client, prefixArgs: [] }; } @@ -1887,6 +1949,10 @@ export async function runLaunch(params: RunLaunchParams, deps: LauncherDeps = {} console.error( "bili: direct-URL mode — claude's ANTHROPIC_BASE_URL is overridden to the proxy; a pre-configured relay is bypassed unless BILI_CLAUDE_UPSTREAM= is set. OAuth-subscription traffic requires the default MITM mode.", ); + } else if (base === "qoder") { + console.error( + "bili: BILI_LAUNCHER_DIRECT has no effect for qoder — its model endpoint scheme is hardcoded https with no base-URL override env, so qoder always runs in cert-MITM mode.", + ); } } const codexUpstream = base === "codex" ? codexUpstreamUrl(config.codex) : undefined; @@ -2017,6 +2083,30 @@ 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 === "qoder") { + // #653: cert-MITM only — the model endpoint scheme is hardcoded https + // (no base-URL override env), so /bili/ rewrites cannot reach it. + // qoder's undici stack honors HTTPS_PROXY + NODE_EXTRA_CA_CERTS + // (additive, so the plain root CA suffices). Proxy vars are fully + // stripped (same contract as hermes). QODER_MODEL_TRANSPORT=http + // forces the OpenAI chat-completions wire: the default transport is a + // server feature-gate whose `legacy` fallback wire is unverified + // (#653 open question 1). The env prefix family follows the CN-site + // detection (qoderIsCnSite). + env = buildQoderEnv(origin, ca, stripInheritedProxy(process.env)); + const qoderPrefix = qoderIsCnSite(process.env) ? "QODERCN" : "QODER"; + env[`${qoderPrefix}_MODEL_TRANSPORT`] = "http"; + const qoderBudget = await resolveQoderBudgetEnv({ + model: nonEmpty(process.env[`${qoderPrefix}_MODEL`]) ? process.env[`${qoderPrefix}_MODEL`] : config.qoder?.model, + userAutoCompactWindow: process.env[`${qoderPrefix}_AUTOCOMPACT_WINDOW`], + windowKey: `${qoderPrefix}_AUTOCOMPACT_WINDOW`, + routes: biliRoutes, + upstreamUrl: `https://${config.qoder?.modelServerHost ?? QODER_DEFAULT_MODEL_HOSTS[0]}`, + }); + Object.assign(env, qoderBudget); + if (qoderBudget[`${qoderPrefix}_AUTOCOMPACT_WINDOW`] !== undefined) { + console.error(`bili: qoder budget aligned — ${qoderPrefix}_AUTOCOMPACT_WINDOW=${qoderBudget[`${qoderPrefix}_AUTOCOMPACT_WINDOW`]}`); + } } 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/tests/discover.test.ts b/tests/discover.test.ts index d8bf82e9..f506f797 100644 --- a/tests/discover.test.ts +++ b/tests/discover.test.ts @@ -11,6 +11,7 @@ import { import { parseZcodeConfig, readZcodeConfig, + QODER_DEFAULT_MODEL_HOSTS, type ClientConfig, } from "../src/client-config.ts"; @@ -119,6 +120,11 @@ test("extractHttpsHosts: empty config → []", () => { assert.deepEqual(extractHttpsHosts({}), []); }); +test("extractHttpsHosts: qoder → default model hosts; modelServerHost replaces them (#653)", () => { + assert.deepEqual(extractHttpsHosts({ qoder: {} }), QODER_DEFAULT_MODEL_HOSTS); + assert.deepEqual(extractHttpsHosts({ qoder: { modelServerHost: "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 46134db0..bff1f609 100644 --- a/tests/launcher.test.ts +++ b/tests/launcher.test.ts @@ -55,6 +55,12 @@ import { resolveLauncherWindow, resolveCodexBudgetArgs, resolveClaudeBudgetEnv, + resolveQoderBudgetEnv, + buildQoderEnv, + readQoderConfig, + resolveQoderHome, + qoderIsCnSite, + QODER_DEFAULT_MODEL_HOSTS, codexUpstreamUrl, readClaudeSettings, type SpawnChild, @@ -73,6 +79,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("qoder"), true); assert.equal(isLaunchClient("pi-test"), true); assert.equal(isLaunchClient("start"), false); assert.equal(isLaunchClient(""), false); @@ -2454,3 +2461,241 @@ test("runLaunch claude: CLAUDE_CODE_AUTO_COMPACT_WINDOW injected (built-in table fs.rmSync(home, { recursive: true, force: true }); } }); + +test("qoderIsCnSite: QODERCLI_SITE and CN-prefixed envs decide the site", () => { + assert.equal(qoderIsCnSite({ QODERCLI_SITE: "cn" }), true); + assert.equal(qoderIsCnSite({ QODERCLI_SITE: "CN " }), true); + assert.equal(qoderIsCnSite({ QODERCLI_SITE: "intl" }), false); + assert.equal(qoderIsCnSite({ QODERCN_CONFIG_DIR: "/tmp/qcn" }), true); + assert.equal(qoderIsCnSite({ QODERCN_CLI_HOME: "/tmp/qcn" }), true); + assert.equal(qoderIsCnSite({ QODER_CONFIG_DIR: "/tmp/q" }), false); +}); + +test("qoderIsCnSite: on-disk config dirs only break the tie (no dirs → intl)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-qoder-site-")); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + process.env.HOME = home; + if (prevUserProfile !== undefined) process.env.USERPROFILE = home; + try { + assert.equal(qoderIsCnSite({}), false, "no config dirs → intl"); + fs.mkdirSync(path.join(home, ".qoder-cn")); + assert.equal(qoderIsCnSite({}), true, "only .qoder-cn present → cn"); + fs.mkdirSync(path.join(home, ".qoder")); + assert.equal(qoderIsCnSite({}), false, "both present → intl (default)"); + } 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("resolveQoderHome: env override > CLI_HOME+dir name > site default", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-qoder-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(resolveQoderHome({ QODER_CONFIG_DIR: "/tmp/qc" }), "/tmp/qc"); + assert.equal(resolveQoderHome({ QODERCLI_SITE: "cn", QODERCN_CONFIG_DIR: "/tmp/qcn" }), "/tmp/qcn"); + assert.equal(resolveQoderHome({ QODER_CLI_HOME: "/tmp/parent" }), path.join("/tmp/parent", ".qoder")); + assert.equal(resolveQoderHome({ QODER_CONFIG_DIR_NAME: ".qoder-x" }), path.join(home, ".qoder-x")); + assert.equal( + resolveQoderHome({ QODERCLI_SITE: "cn", QODERCN_CLI_HOME: "/tmp/parent", QODERCN_CONFIG_DIR_NAME: ".qcn" }), + path.join("/tmp/parent", ".qcn"), + ); + assert.equal(resolveQoderHome({}), path.join(home, ".qoder")); + assert.equal(resolveQoderHome({ QODERCLI_SITE: "cn" }), path.join(home, ".qoder-cn")); + } 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("readQoderConfig: settings.json model (string + object) and model server host env", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-qoder-cfg-")); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + process.env.HOME = home; + if (prevUserProfile !== undefined) process.env.USERPROFILE = home; + const dir = path.join(home, ".qoder"); + fs.mkdirSync(dir, { recursive: true }); + try { + assert.deepEqual(readQoderConfig(dir, {}), {}); + fs.writeFileSync(path.join(dir, "settings.json"), JSON.stringify({ model: { name: "qwen3-max" } })); + assert.deepEqual(readQoderConfig(dir, {}), { model: "qwen3-max" }); + fs.writeFileSync(path.join(dir, "settings.json"), JSON.stringify({ model: "qwen3-max", mcpServers: {} })); + assert.deepEqual(readQoderConfig(dir, {}), { model: "qwen3-max" }); + fs.writeFileSync(path.join(dir, "settings.json"), "not-json{"); + assert.deepEqual(readQoderConfig(dir, {}), {}); + fs.writeFileSync(path.join(dir, "settings.json"), JSON.stringify({ model: { name: "qwen3-max" } })); + const withHost = readQoderConfig(dir, { QODER_MODEL_SERVER_HOST: "https://my-relay.example.com:8443/" }); + assert.equal(withHost.model, "qwen3-max"); + assert.equal(withHost.modelServerHost, "my-relay.example.com:8443"); + const cnHost = readQoderConfig(dir, { QODERCLI_SITE: "cn", QODERCN_MODEL_SERVER_HOST: "cn-relay.example.com" }); + assert.equal(cnHost.modelServerHost, "cn-relay.example.com"); + const mixed = readQoderConfig(dir, { QODERCLI_SITE: "cn", QODER_MODEL_SERVER_HOST: "intl.example.com" }); + assert.equal(mixed.modelServerHost, undefined, "intl-prefixed host ignored on CN site"); + } 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("discoverRoutes: qoder → default MITM hosts, no rewrites (#653)", () => { + const routes = discoverRoutes("qoder", {}); + assert.deepEqual(routes.httpsDomains, QODER_DEFAULT_MODEL_HOSTS); + assert.deepEqual(routes.httpRewrites, []); + assert.deepEqual(routes.httpsRewrites, []); + assert.deepEqual(routes.httpEnvRoutes, []); +}); + +test("discoverRoutes: qoder modelServerHost replaces the default map", () => { + const config: ClientConfig = { qoder: { model: "m", modelServerHost: "my-relay.example.com" } }; + const routes = discoverRoutes("qoder", config); + assert.deepEqual(routes.httpsDomains, ["my-relay.example.com"]); +}); + +test("buildQoderEnv: HTTPS_PROXY + NODE_EXTRA_CA_CERTS + BILLION_CONTEXT_PROXY, baseEnv preserved", () => { + const env = buildQoderEnv("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.NODE_EXTRA_CA_CERTS, "/tmp/ca.pem"); + assert.equal(env.BILLION_CONTEXT_PROXY, "http://127.0.0.1:8787"); + assert.equal(env.FOO, "bar"); +}); + +test("resolveQoderBudgetEnv: injects the site-prefixed window key from bili's chain", async () => { + registrySetForTest({}); + try { + assert.deepEqual( + await resolveQoderBudgetEnv({ model: "claude-sonnet-4-5", userAutoCompactWindow: undefined, windowKey: "QODER_AUTOCOMPACT_WINDOW", routes: {}, upstreamUrl: "https://api2-v2.qoder.sh" }), + { QODER_AUTOCOMPACT_WINDOW: "200000" }, + ); + const routes = { "https://relay.example.com": { models: { "qoder-x": { context: 123456 } } } }; + assert.deepEqual( + await resolveQoderBudgetEnv({ model: "qoder-x", userAutoCompactWindow: undefined, windowKey: "QODERCN_AUTOCOMPACT_WINDOW", routes, upstreamUrl: "https://relay.example.com" }), + { QODERCN_AUTOCOMPACT_WINDOW: "123456" }, + ); + registrySetForTest({ "anthropic/bili-fallback-model": { limit: { context: 333333 } } }); + assert.deepEqual( + await resolveQoderBudgetEnv({ model: "bili-fallback-model", userAutoCompactWindow: undefined, windowKey: "QODER_AUTOCOMPACT_WINDOW", routes: {}, upstreamUrl: "https://api.anthropic.com" }), + { QODER_AUTOCOMPACT_WINDOW: "333333" }, + ); + } finally { + registryResetForTest(); + } +}); + +test("resolveQoderBudgetEnv: no injection when user self-aligned or unresolvable", async () => { + registrySetForTest({}); + try { + assert.deepEqual(await resolveQoderBudgetEnv({ model: "claude-sonnet-4-5", userAutoCompactWindow: "300000", windowKey: "QODER_AUTOCOMPACT_WINDOW", routes: {}, upstreamUrl: "https://api2-v2.qoder.sh" }), {}); + assert.deepEqual(await resolveQoderBudgetEnv({ model: undefined, userAutoCompactWindow: undefined, windowKey: "QODER_AUTOCOMPACT_WINDOW", routes: {}, upstreamUrl: "https://api2-v2.qoder.sh" }), {}); + assert.deepEqual(await resolveQoderBudgetEnv({ model: "qoder-nonexistent-model-xyz", userAutoCompactWindow: undefined, windowKey: "QODER_AUTOCOMPACT_WINDOW", routes: {}, upstreamUrl: "https://api2-v2.qoder.sh" }), {}); + } finally { + registryResetForTest(); + } +}); + +test("resolveClientCommand: qoder resolves `qoder`, falls back to `qodercli`", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bili-qoder-bin-")); + try { + const env: NodeJS.ProcessEnv = { PATH: dir }; + assert.deepEqual(resolveClientCommand("qoder", env), { command: "qoder", prefixArgs: [] }); + fs.writeFileSync(path.join(dir, "qodercli"), ""); + assert.deepEqual(resolveClientCommand("qoder", env), { command: path.join(dir, "qodercli"), prefixArgs: [] }); + fs.writeFileSync(path.join(dir, "qoder"), ""); + assert.deepEqual(resolveClientCommand("qoder", env), { command: path.join(dir, "qoder"), prefixArgs: [] }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runLaunch qoder: cert-MITM envs, transport forced, budget aligned, default MITM whitelist (#653)", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-qoder-launch-")); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const prevBin = process.env.BILI_CLIENT_BIN; + const prevModel = process.env.QODER_MODEL; + const prevWindow = process.env.QODER_AUTOCOMPACT_WINDOW; + const prevTransport = process.env.QODER_MODEL_TRANSPORT; + const prevNoProxy = process.env.NO_PROXY; + process.env.HOME = home; + if (prevUserProfile !== undefined) process.env.USERPROFILE = home; + delete process.env.QODER_MODEL; + delete process.env.QODER_AUTOCOMPACT_WINDOW; + delete process.env.QODER_MODEL_TRANSPORT; + const qoderDir = path.join(home, ".qoder"); + fs.mkdirSync(qoderDir, { recursive: true }); + fs.writeFileSync(path.join(qoderDir, "settings.json"), JSON.stringify({ model: { name: "claude-sonnet-4-5" } })); + const fakeQoder = path.join(home, "fake-qoder"); + fs.writeFileSync(fakeQoder, ""); + process.env.BILI_CLIENT_BIN = fakeQoder; + 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 === fakeQoder) { + 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: "qoder", 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.NODE_EXTRA_CA_CERTS).endsWith(path.join("billion-context", "ca", "root-ca.pem")), String(seenEnv.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.equal(seenEnv.QODER_MODEL_TRANSPORT, "http"); + assert.equal(seenEnv.QODER_AUTOCOMPACT_WINDOW, "200000", "budget aligned from built-in table"); + assert.ok(proxyEnvs.length > 0, "proxy child spawned"); + const mitm = String(proxyEnvs[0]!.BILI_MITM_DOMAINS).split(","); + for (const h of QODER_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 (prevModel === undefined) delete process.env.QODER_MODEL; + else process.env.QODER_MODEL = prevModel; + if (prevWindow === undefined) delete process.env.QODER_AUTOCOMPACT_WINDOW; + else process.env.QODER_AUTOCOMPACT_WINDOW = prevWindow; + if (prevTransport === undefined) delete process.env.QODER_MODEL_TRANSPORT; + else process.env.QODER_MODEL_TRANSPORT = prevTransport; + if (prevNoProxy === undefined) delete process.env.NO_PROXY; + else process.env.NO_PROXY = prevNoProxy; + fs.rmSync(home, { recursive: true, force: true }); + } +});