From 1418e30e8f617e9f814ac7d35fcec92081c6706a Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Thu, 12 Nov 2026 15:03:55 +0800 Subject: [PATCH] feat: BILI_PLUGIN_BYPASS env force-switch for the x-bili-plugin-bypass wire lane (#6220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin bypass decision (handle.ts) previously honored only the per-request x-bili-plugin-bypass header that the opencode-acp host plugin stamps for legacy sessions. There was no user-side override: once opencode auto-disabled bili for those sessions, an operator could not force one side or the other per machine. BILI_PLUGIN_BYPASS (env-only, tri-state, owner ruling in the #6220 thread): - unset/auto: per-request header decides (zero behavior change) - 1/on: force bypass for ALL inbound traffic, raw passthrough, no compression, no session state — the escape valve; first hit warns that traffic reaches the model UNCOMPRESSED if nothing in-process is actually compressing - 0/off: ignore any inbound bypass header, including opencode legacy ones; first hit warns that those sessions' pre-existing ref space lives in the in-process acp store, so their history degrades to a read-only archive - unrecognized values: treated as unset (auto) + one warning Each forced state logs one loud warn-once line (module-level warn-once set, test seam _resetPluginBypassForceWarnForTest). Knob resolver lives in src/knobs.ts (pure, env-injectable) per the knob placement discipline; documented in env-only.yaml (generated docs/configuration.md + zh), docs/lifecycle.md §2.9, and covered by tests/plugin-bypass.test.ts (resolver unit + 4 end-to-end proxy cases incl. the warn-once assertions). --- docs/configuration.md | 1 + docs/configuration.zh-CN.md | 1 + docs/lifecycle.md | 1 + src/knobs.ts | 20 ++++++ src/server/handle.ts | 31 ++++++++- tests/plugin-bypass.test.ts | 96 +++++++++++++++++++++++++- website/config-reference/env-only.yaml | 8 +++ 7 files changed, 155 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 80df727a77..463d33a359 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -415,6 +415,7 @@ This index is generated from `website/config-reference/*.yaml` — edit the seed | `BILI_PARENT_PID` | number | unset | — | Spawn channel: parent process id for lifecycle supervision. | | `BILI_STRICT_PORT` | number | unset | — | Spawn channel: exact-port requirement (squatters refused loudly instead of hopping). | | `BILI_UPGRADE_REPLACEMENT` | boolean-ish | unset | — | Spawn channel: marks a self-restart replacement — it must own exactly the freed port or exit without hopping (#3044). | +| `BILI_PLUGIN_BYPASS` | string | unset (auto) | — | Force switch for the x-bili-plugin-bypass wire decision (#6220). Unset/auto = the inbound per-request header decides (legacy opencode acp sessions bypass, everyone else is compressed — zero behavior change). 1/on = FORCE bypass for ALL inbound traffic, raw passthrough with no compression or session state: the escape valve when an in-process compressor is loaded but bili still manages those sessions; if nothing in-process (e.g. opencode acp) is actually compressing, traffic reaches the model UNCOMPRESSED. 0/off = ignore any inbound bypass header, including opencode legacy ones: bili takes over those sessions, but their pre-existing ref space lives in the in-process acp store, so their history degrades to a read-only archive. Each forced state emits one loud startup-of-traffic warn; unrecognized values are treated as unset (auto) with one warning. Env-only by design (owner decision in #6220). | | `BILI_OPENCODE_ACP_SPEC` | string | unset | — | Spawn channel: ACP spec marker for opencode-native lanes. | | `BILI_LAUNCHER_MODEL_WINDOWS` | string | unset | — | Launcher channel: model-window overrides passed into spawned clients. | | `BILI_LAUNCHER_LANE` | string | unset | — | Launcher channel: lane identity of the spawning bili process. | diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md index 9bdefbe4dc..3e33f6d97f 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.md @@ -415,6 +415,7 @@ | `BILI_PARENT_PID` | number | unset | — | 派生通道:父进程 id,用于生命周期监管。 | | `BILI_STRICT_PORT` | number | unset | — | 派生通道:精确端口要求(被占用时响亮拒绝而非跳端口)。 | | `BILI_UPGRADE_REPLACEMENT` | boolean-ish | unset | — | 派生通道:标记自重启替换进程——必须独占被释放的精确端口,否则直接退出而非跳端口(#3044)。 | +| `BILI_PLUGIN_BYPASS` | string | unset (auto) | — | x-bili-plugin-bypass 透传判决的强制开关(#6220)。缺省/auto = 由每请求头决定(opencode 旧版 acp 会话透传、其余压缩,行为零变化)。1/on = 强制所有流量透传、不压缩不记状态:用于“进程内已有压缩器但 bili 仍在管理这些会话”的逃生阀;若进程内实际没有压缩器(如 opencode acp),流量会以**未压缩**状态直达模型。0/off = 无视一切入站透传头(含 opencode 旧版):bili 接管这些会话,但其既有引用空间在进程内 acp 存储里,历史降级为只读归档。每种强制态首次命中时打一条醒目警告;无法识别的取值按缺省(auto)处理并警告一次。按 owner 决定(#6220)env-only。 | | `BILI_OPENCODE_ACP_SPEC` | string | unset | — | 派生通道:opencode 原生车道的 ACP 规格标记。 | | `BILI_LAUNCHER_MODEL_WINDOWS` | string | unset | — | 启动器通道:传入被派生客户端的模型窗口覆盖。 | | `BILI_LAUNCHER_LANE` | string | unset | — | 启动器通道:派生方 bili 进程的车道身份。 | diff --git a/docs/lifecycle.md b/docs/lifecycle.md index f7109a291a..212dbd1229 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -218,6 +218,7 @@ | `BILI_AUTO_MODE_FORGE` | autoModeForge.enabled,默认 on——auto-mode 分类判定本地 forge,零出账/计费(`=0` 恢复纯透传,留给付真实判定费的用户,#2740 owner 决定) | | `BILI_DSH_CONTEXT_TAB` | dsh.contextTab 的 env 层(#4237):`=0` 隐藏 DSH 顶栏「上下文」标签页(压缩及其余入口全部不受影响);其他值强制开并覆盖文件层;缺省按文件层(默认 true);下次页面加载 / dsh 重启生效 | | `BILI_DSH_MAX_TESTED_VERSION` | dsh native lane「高于已测最高版」启动提示的上限(#4545):从注入包上方最近的 node_modules/@deepseek-ai/dsh/package.json 读运行中的构建,**严格高于**上限才弹一条启动提示,建议先回滚;默认 0.2.0-rc.2(CI e2e 锚点),新版本验证通过后抬高;空串 = 关闭检查;只提示不拦截 | +| `BILI_PLUGIN_BYPASS` | x-bili-plugin-bypass 判决的强制开关(#6220):缺省/auto 按每请求头走(零行为变化);`1` 强制全流量原始透传(逃生阀,无进程内压缩器时流量**未压缩**直达模型);`0` 无视一切 bypass 头(opencode 旧版会话被 bili 接管,既有历史降级只读归档);无法识别的取值按缺省处理 + 一次警告;每种强制态首次命中打一条醒目 warn;env-only(owner 在 #6220 明确拍板,无文件键) | ## 3. 非环境变量的 BILI_* 标识符(勿与上表混淆) diff --git a/src/knobs.ts b/src/knobs.ts index c9649ceeeb..24f3d844c1 100644 --- a/src/knobs.ts +++ b/src/knobs.ts @@ -1068,3 +1068,23 @@ export function resolveStorageWalJournalSizeLimit(env: NodeJS.ProcessEnv = proce return WAL_JOURNAL_SIZE_LIMIT; } +/** BILI_PLUGIN_BYPASS — #6220: tri-state force switch for the + * x-bili-plugin-bypass wire lane (decision site: src/server/handle.ts). + * "on" (1/true/on) — force bypass: EVERY inbound model request is raw + * passthrough, all lanes, no compression, no state. + * "off" (0/false/off) — never bypass: ignore any inbound + * x-bili-plugin-bypass header (incl. opencode legacy). + * "auto" (unset/empty/auto) — honor the header per request (today's behavior). + * "invalid" — set to an unrecognized value: behaves as "auto"; the read site + * warns once so a typo can't silently sit on the surface. + * Env-only by owner ruling in the #6220 thread (explicit escape valve, not a + * per-machine tunable — no config-file twin, per reference/env-vars.md hard + * rule 1). Pure: env in, decision out (never throws). */ +export function resolvePluginBypass(env: NodeJS.ProcessEnv = process.env): "on" | "off" | "auto" | "invalid" { + const raw = env.BILI_PLUGIN_BYPASS?.trim().toLowerCase(); + if (raw === "1" || raw === "true" || raw === "on") return "on"; + if (raw === "0" || raw === "false" || raw === "off") return "off"; + if (raw === undefined || raw === "" || raw === "auto") return "auto"; + return "invalid"; +} + diff --git a/src/server/handle.ts b/src/server/handle.ts index 95e5d5d171..006d5fd6f8 100644 --- a/src/server/handle.ts +++ b/src/server/handle.ts @@ -65,7 +65,7 @@ import { resolveCompatDropFields } from "../compat-drop.js"; import { unwrapCommandcodeBody, type CommandcodeEnvelopeMeta } from "../commandcode-wire.js"; import { DecompressedTooLargeError, decodeRequestBody, IngestBodyTooLargeError } from "../content-encoding.js"; import { matchModelEndpoint } from "../model-endpoints.js"; -import { autoModeForgeEnabled, dshAuthoredCompactionEnabled, imageRescaleEnabled, limitsDecodedBytes as knobLimitsDecodedBytes, limitsIngestBytes as knobLimitsIngestBytes, noInjectTool as knobNoInjectTool, rawDumpDir as knobRawDumpDir } from "../knobs.js"; +import { autoModeForgeEnabled, dshAuthoredCompactionEnabled, imageRescaleEnabled, limitsDecodedBytes as knobLimitsDecodedBytes, limitsIngestBytes as knobLimitsIngestBytes, noInjectTool as knobNoInjectTool, rawDumpDir as knobRawDumpDir, resolvePluginBypass } from "../knobs.js"; import { buildForgeResponse, classifierForgeVariant, estimateCjkTokens, isAutoModeClassifier, requestText, summarizeAction } from "../auto-mode-forge.js"; import { extractConversationId, isV1BackgroundSideRequest, isV1InternalRequest, unwrapV1Internal } from "../v1internal.js"; import { anthropicBetaContextWindow, BILI_HOP_HEADER, capRegistryWindowByStandard, expandedContextSuffixWindow, launcherContextWindow, launcherMaxOutput, noteUnresolvedModelSession, UNRESOLVED_MODEL_SESSION_THRESHOLD, windowSourceLogged } from "./context-window.js"; @@ -114,6 +114,17 @@ function earlyHoldCredHeaders(req: http.IncomingMessage): Record // window sizing falls back to config.toml model_context_window. const codexConfigWindowWarned = new Set(); +// #6220: warn-once per forced state for BILI_PLUGIN_BYPASS ("on" / "off" / +// "invalid") — the first request that sees a forced state gets the loud risk +// line, later requests stay at debug level. +const pluginBypassForceWarned = new Set(); + +/** Test seam: clear the #6220 warn-once set so a suite can assert the warns + * from a clean slate (same pattern as _resetNoOutputCeilingWarningsForTest). */ +export function _resetPluginBypassForceWarnForTest(): void { + pluginBypassForceWarned.clear(); +} + // #5608 v3: weakest VERIFIED common-prefix ratio (verifiedPrefix / chainDepth) // at which a byte-exact MISS may still weakly adopt the closest tracked chain. // Floor set just below the lowest production telemetry reading in #5608's @@ -637,7 +648,23 @@ async function handleRequest( // plugin stamps this header per request for sessions with acp state on // disk; acp owns their context in-process, so binding/injecting/compressing // here would double-manage it. Raw forward, zero state touched. - if (headerValue(req, BILI_PLUGIN_BYPASS_HEADER) === "1") { + // #6220: BILI_PLUGIN_BYPASS force switch (env, tri-state) overrides the + // per-request header decision: "on" force-bypasses ALL inbound traffic + // (escape valve); "off" ignores the header everywhere; unset/auto = the + // header-based behavior below. + const bypassMode = resolvePluginBypass(); + if (bypassMode === "on" && !pluginBypassForceWarned.has("on")) { + pluginBypassForceWarned.add("on"); + log("warn", `BILI_PLUGIN_BYPASS=on — forced: EVERY inbound model request is now raw passthrough (no compression, no session state, any lane). If nothing in-process (e.g. opencode acp) is actually compressing, your traffic reaches the model UNCOMPRESSED. Unset BILI_PLUGIN_BYPASS to restore per-request header behavior (#6220).`); + } else if (bypassMode === "off" && !pluginBypassForceWarned.has("off")) { + pluginBypassForceWarned.add("off"); + log("warn", `BILI_PLUGIN_BYPASS=off — forced: the inbound ${BILI_PLUGIN_BYPASS_HEADER} header is ignored on every request; opencode legacy sessions will now be managed by bili, but their pre-existing ref space lives in the in-process acp store, so their history degrades to a read-only archive. Unset BILI_PLUGIN_BYPASS to restore per-request header behavior (#6220).`); + } else if (bypassMode === "invalid" && !pluginBypassForceWarned.has("invalid")) { + pluginBypassForceWarned.add("invalid"); + log("warn", `BILI_PLUGIN_BYPASS is set to an unrecognized value — treating it as unset (auto). Use 1 (force bypass all traffic), 0 (never bypass) or auto/unset (#6220).`); + } + const bypass = bypassMode === "on" || (bypassMode !== "off" && headerValue(req, BILI_PLUGIN_BYPASS_HEADER) === "1"); + if (bypass) { log("debug", `bypass: ${req.method ?? "?"} ${maskUrlForLog(req.url ?? "")} — raw passthrough (legacy in-process compression)`); await forward(req, res, opts, scrubCompatDrop(scrubAnthropicPck(protocol, bodyBuffer, log), compatDropPaths, log), null, core, config, log, route, instanceId, undefined, undefined, undefined, googleModel ?? vertexModel); return; diff --git a/tests/plugin-bypass.test.ts b/tests/plugin-bypass.test.ts index 90a70dfe0c..fec8499a02 100644 --- a/tests/plugin-bypass.test.ts +++ b/tests/plugin-bypass.test.ts @@ -12,6 +12,9 @@ import type { ProxyOptions } from "../src/config.ts"; import { SessionStore, _setStoreForTest } from "../src/persist.ts"; import { _setForTest as setRegistryForTest } from "../src/registry.ts"; import { _resetPluginStateForTest } from "../src/plugin.ts"; +import { resolvePluginBypass } from "../src/knobs.ts"; +import { setLogCapture } from "../src/logger.ts"; +import { _resetPluginBypassForceWarnForTest } from "../src/server/handle.ts"; import { closeServers } from "./server-close-guard.ts"; interface Harness { @@ -49,7 +52,11 @@ async function startHarness(): Promise { compress: { injectTool: true, injectNudge: true }, promptCache: { routing: "auto" }, sessionHeader: "x-acp-session", - log: false, + // log: true + logFile: "off" (established pattern, e.g. tests/codex-responses-ws.test.ts): + // engine logging live (engine log is a no-op when log: false) but no log file + // opened; the warn-capture test observes the stream via setLogCapture. + log: true, + logFile: "off", debug: false, passthrough: false, autoUpdate: false, @@ -81,10 +88,33 @@ beforeEach(async () => { h = await startHarness(); }); afterEach(async () => { + delete process.env["BILI_PLUGIN_BYPASS"]; + setLogCapture(null); await h?.close(); h = undefined; }); +async function post(model: string, withHeader = false) { + const before = h!.captured.length; + const headers: Record = { "content-type": "application/json" }; + if (withHeader) headers["x-bili-plugin-bypass"] = "1"; + const body = { model, stream: false, messages: [{ role: "user", content: "env switch probe" }] }; + const resp = await fetch(`http://127.0.0.1:${h!.proxyPort}/bili/http://127.0.0.1:${h!.upstreamPort}/v1/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + assert.equal(resp.status, 200); + assert.equal(h!.captured.length, before + 1, `exactly one upstream request per post (before=${before} after=${h!.captured.length})`); + const entry = h!.captured[before]; + if (!entry) throw new Error(`captured[${before}] missing; length=${h!.captured.length}`); + return JSON.parse(entry.body) as { tools?: unknown[] }; +} + +function toolNames(seen: { tools?: unknown[] }): string[] { + return (seen.tools ?? []).map((t) => (t as { function?: { name?: string } }).function?.name ?? "?"); +} + describe("plugin bypass header (#920)", () => { it("bypassed requests are forwarded verbatim — no tool injection, no nudge", { timeout: 60_000 }, async () => { const body = { model: "test-model", stream: false, messages: [{ role: "user", content: "hello legacy session" }] }; @@ -114,3 +144,67 @@ describe("plugin bypass header (#920)", () => { assert.ok(names.includes("compress"), `expected compress in ${JSON.stringify(names)}`); }); }); + +describe("BILI_PLUGIN_BYPASS force switch (#6220)", () => { + beforeEach(() => { + // the warn-once set is process-global (first request that sees a + // forced state pays the loud line) — start each test clean + _resetPluginBypassForceWarnForTest(); + }); + it("resolver: tri-state + invalid classification", () => { + assert.equal(resolvePluginBypass({}), "auto"); + assert.equal(resolvePluginBypass({ BILI_PLUGIN_BYPASS: "" }), "auto"); + assert.equal(resolvePluginBypass({ BILI_PLUGIN_BYPASS: "auto" }), "auto"); + for (const v of ["1", "true", "on", " ON ", "On"]) assert.equal(resolvePluginBypass({ BILI_PLUGIN_BYPASS: v }), "on"); + for (const v of ["0", "false", "off", " OFF ", "Off"]) assert.equal(resolvePluginBypass({ BILI_PLUGIN_BYPASS: v }), "off"); + for (const v of ["banana", "2", "yes", "bypass"] ) assert.equal(resolvePluginBypass({ BILI_PLUGIN_BYPASS: v }), "invalid"); + }); + + it("=1 forces bypass even WITHOUT the header (raw passthrough, no tools)", { timeout: 60_000 }, async () => { + process.env["BILI_PLUGIN_BYPASS"] = "1"; + const seen = await post("test-model"); + assert.equal(seen.tools, undefined); + }); + + it("=0 ignores the header — plugin pipeline runs (tools injected)", { timeout: 60_000 }, async () => { + process.env["BILI_PLUGIN_BYPASS"] = "0"; + const seen = await post("test-model", true); + assert.ok(toolNames(seen).includes("compress"), `expected compress in ${JSON.stringify(toolNames(seen))}`); + }); + + it("=auto and unrecognized values keep per-request header behavior", { timeout: 120_000 }, async () => { + process.env["BILI_PLUGIN_BYPASS"] = "auto"; + let seen = await post("test-model"); + assert.ok(toolNames(seen).includes("compress"), "no header + auto => no bypass, pipeline should run"); + seen = await post("test-model", true); + assert.equal(seen.tools, undefined, "header + auto => bypass"); + + // unrecognized value behaves like auto: header still decides + process.env["BILI_PLUGIN_BYPASS"] = "banana"; + seen = await post("test-model", true); + assert.equal(seen.tools, undefined, "header + invalid env => bypass (auto semantics)"); + }); + + it("forced states warn loudly, once each; invalid warns about the typo", { timeout: 120_000 }, async () => { + const lines: { level: string; msg: string }[] = []; + setLogCapture((level, msg) => { lines.push({ level, msg }); }); + + process.env["BILI_PLUGIN_BYPASS"] = "1"; + await post("test-model"); + await post("test-model"); // second request: must not repeat the warn + const onWarns = lines.filter((l) => l.level === "warn" && l.msg.includes("BILI_PLUGIN_BYPASS=on")); + assert.equal(onWarns.length, 1, `expected exactly one forced-on warn, got ${JSON.stringify(onWarns)}`); + assert.ok(onWarns[0]!.msg.includes("UNCOMPRESSED"), "forced-on warn must name the no-compression risk"); + + process.env["BILI_PLUGIN_BYPASS"] = "0"; + await post("test-model", true); + const offWarns = lines.filter((l) => l.level === "warn" && l.msg.includes("BILI_PLUGIN_BYPASS=off")); + assert.equal(offWarns.length, 1); + assert.ok(offWarns[0]!.msg.includes("read-only archive"), "forced-off warn must name the legacy-history risk"); + + process.env["BILI_PLUGIN_BYPASS"] = "banana"; + await post("test-model", true); + const invalidWarns = lines.filter((l) => l.level === "warn" && l.msg.includes("unrecognized value")); + assert.equal(invalidWarns.length, 1); + }); +}); diff --git a/website/config-reference/env-only.yaml b/website/config-reference/env-only.yaml index bcf15edb15..4922689bac 100644 --- a/website/config-reference/env-only.yaml +++ b/website/config-reference/env-only.yaml @@ -266,6 +266,14 @@ entries: group: envonly desc_en: "Spawn channel: marks a self-restart replacement — it must own exactly the freed port or exit without hopping (#3044)." desc_zh: "派生通道:标记自重启替换进程——必须独占被释放的精确端口,否则直接退出而非跳端口(#3044)。" + - key: BILI_PLUGIN_BYPASS + type: string + default: "unset (auto)" + kind: env-only + audience: user + group: envonly + desc_en: "Force switch for the x-bili-plugin-bypass wire decision (#6220). Unset/auto = the inbound per-request header decides (legacy opencode acp sessions bypass, everyone else is compressed — zero behavior change). 1/on = FORCE bypass for ALL inbound traffic, raw passthrough with no compression or session state: the escape valve when an in-process compressor is loaded but bili still manages those sessions; if nothing in-process (e.g. opencode acp) is actually compressing, traffic reaches the model UNCOMPRESSED. 0/off = ignore any inbound bypass header, including opencode legacy ones: bili takes over those sessions, but their pre-existing ref space lives in the in-process acp store, so their history degrades to a read-only archive. Each forced state emits one loud startup-of-traffic warn; unrecognized values are treated as unset (auto) with one warning. Env-only by design (owner decision in #6220)." + desc_zh: "x-bili-plugin-bypass 透传判决的强制开关(#6220)。缺省/auto = 由每请求头决定(opencode 旧版 acp 会话透传、其余压缩,行为零变化)。1/on = 强制所有流量透传、不压缩不记状态:用于“进程内已有压缩器但 bili 仍在管理这些会话”的逃生阀;若进程内实际没有压缩器(如 opencode acp),流量会以**未压缩**状态直达模型。0/off = 无视一切入站透传头(含 opencode 旧版):bili 接管这些会话,但其既有引用空间在进程内 acp 存储里,历史降级为只读归档。每种强制态首次命中时打一条醒目警告;无法识别的取值按缺省(auto)处理并警告一次。按 owner 决定(#6220)env-only。" - key: BILI_OPENCODE_ACP_SPEC type: string default: "unset"