现象
插件装了,doctor 四行全绿:profile-install ok、arm-section registered、transport 4/4、gate-audit 命中 12 / 未命中 0。
事情看起来是成功的——直到你看模型回的是原文反射,而不是被改写过的版本。
更奇怪的是审计日志:
{ "changed": true, "promptSha256": "…", "bytes": 3716 }
审计说改了,网络里发的是旧的。
先定位:谁在说谎
三处都能打到同一个点,按成本从低到高:
1. 假 fetch 断言字节。把 fetchImpl 换成一个只记录 await req.arrayBuffer() 的桩,断言它收到的字节里有没有你注入的标记串。这一步是关键:返回值层面永远看不出问题,因为包装后的 Request 语义完全合法。
2. 抓包。小流量下 mitmproxy -p 8899 + 代理,看实际 POST body。
3. 在 transport 里打点,打印 req.method、req.url、req.headers.get('content-length')、await req.clone().text() 的长度。
前两步一跑,问题立刻现形:发出去的 body 长度等于原文长度。
根因
旧代码是这么重建请求的:
const next = new Request(url, request, { body: JSON.stringify(payload) });
Request 构造器只接受两个参数:(input, init)。第三个参数被静默丢弃。
更糟的是第二个参数:request 是一个 Request 实例,把它当 init 传进去时,引擎只读取它身上存在的那几个 init 字段(method、headers、body…)。而 Request 实例的 body 是一个只读流,取不到可用的 body 时,构造器就退回到原请求的 body。
结果:
- 没有抛错;
- next.body 仍是原始流;
- 你的 payload 从来没被序列化进请求;
- 因为你确实构造了一个新对象并把它交给了运输层,审计里写 changed: true 也是"诚实"的——它记录的是"我构造了新请求",不是"新请求里是我的字节"。
这类 bug 的特征:类型正确、接口正确、日志正确、行为错误。
修法
两参数、显式重建,不要试图在旧 Request 上打补丁:
function rebuild(original, payload) {
const body = JSON.stringify(payload);
const headers = new Headers(original.headers);
headers.delete("content-length"); // 长度会变,交给运行时重算
headers.set("content-type", "application/json");
return new Request(original.url, {
method: original.method,
headers,
body,
signal: original.signal, // 取消语义要继承
duplex: "half", // Node 下流式 body 需要
// 凭据不要从 original 里"顺手带":agent / dispatcher 属于 init,
// 原请求上的这些字段取不出来,必须由调用方显式传
});
}
四个容易漏的点:
- content-length 必须删。不删的话,某些网关会按旧长度截断 body,你会看到 JSON 尾部被吃掉。
- signal 要继承,否则用户取消请求后,你的重发还在跑。
- Node 里 duplex: "half" 不写会直接抛错(流式 body 必须声明)。
- 原请求上的 agent / dispatcher / keepalive 属于 init 侧配置,从 Request 实例上取不到,必须由包装层持有并显式传入。
为什么之前一直没被发现
因为断言写在了返回值上:
const out = await transport.send(req);
expect(out.status).toBe(200); // 永远通过
改成断言字节:
const fakeFetch = async (req) => {
const bytes = Buffer.from(await req.arrayBuffer());
seen.push(bytes.toString("utf8"));
return new Response("{}", { status: 200 });
};
四种协议各来一条端到端:openai(messages)、anthropic(system + messages)、responses(instructions)、gemini(systemInstruction + contents)。四条全绿,才算真的"发出去了"。这套断言现在是回归测试的一部分:test/compat-transport-send.test.js。
顺带一个同类坑:死参数
同一个 transport 里还有一处:
function attachLiveTransport({ fetchImpl }) {
const original = globalThis.fetch; // 写死全局
// …
}
fetchImpl 只被 typeof 检查了一下,从未用于发包。宿主或测试注入自己的 fetch 实现时,请求静默走真网络——本地看着一切正常,实际打到了线上。
修法两条:
- 发包走传入实现:const send = fetchImpl ?? globalThis.fetch;
- dispose() 只在 globalThis.fetch === wrapped 时还原,避免把别人的补丁一起掀掉。
判据清单
遇到"改了没生效",按这个顺序问四句:
1. 我的改写有没有真的进入 init.body?(断言字节,不看返回值)
2. content-length 清了吗?
3. signal / duplex / agent 传了吗?
4. 发包用的是我传进去的实现,还是全局 fetch?
四条都能答上来,这类 bug 基本绝迹。
11 条根因的完整清单(含没挂上 4 条、没命中 4 条):https://www.bilibili.com/read/cv53802358
源码与 CHANGELOG:https://github.com/xqpsec/dsh-jailbreak
本文对应 dsh-jailbreak v1.3.6(回归 215/215)。源码、issue、更新在 GitHub:github.com/xqpsec/dsh-jailbreak(GitHub 搜 dsh-jailbreak 也行)。
装完还是没反应,把四行 doctor 输出贴到评论区:问题随时问的群 t.me/llm_jalibreak,只发版本更新的频道 t.me/jalibreak_llm。
视频版(11 条根因逐条复现):BV1ZW4E6XE9z