dsh插件装了没反应:改了请求体,发出去还是原文(Request 三参数坑)
清平文坊
2026年11月08日 00:03
AI大模型

现象

插件装了,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