dsh-request-privacy
为 DeepSeek Harness 提供三个相互独立、默认隐私优先的开关,控制 Provider 请求可能携带的身份 Header 与诊断字段:
x-deepseek-harness-user-id # 跨 Session 稳定的匿名 Harness ID
x-deepseek-harness-session-id # 当前 Session 标识
dsh_session_log # 完整会话日志(请求体顶层字段,非 Header)
| 开关 | 默认值 | 说明 |
|---|
sendUserIdHeader | false | 关闭时,线路上完全不存在该 Header。默认关闭,因为不向自定义 baseURL / gateway 暴露长期标识正是本插件的目的。 |
sendSessionIdHeader | true | 关闭时,线路上完全不存在该 Header,但 Harness 内部的 Session ID 不受任何影响。默认开启,保持官方行为。 |
sendSessionLog | false | 关闭时,dsh_session_log 字段在请求发出前被移除;本地会话日志不受任何影响。默认关闭,理由见 §5。 |
三者完全独立,全部组合都受支持且经过真实线路验证。
1. 安装
dsh plugin --profile web add @stringbean4313/dsh-request-privacy
npm 页面:https://www.npmjs.com/package/@stringbean4313/dsh-request-privacy
前置条件:已安装 DeepSeek Harness(@deepseek-ai/dsh)。插件本身零运行时依赖,
只需要宿主提供 @deepseek-ai/cordis、@deepseek-ai/schemastery、@deepseek-ai/dsh-settings
(已声明为可选 peer,pnpm 不会另行安装,直接用宿主那一份)。
兼容性:0.1.5 / 0.1.6 与 0.1.7 起两代设置系统都支持,实测版本见下表。
| 宿主 | 设置界面 | 本插件的行为 |
|---|
0.1.5-rc.1 … 0.1.6-alpha.2 | settingsScope + settings.plugin.item | 用 installSection 注册自己的 namespace,渲染自带的折叠卡片 |
0.1.7-alpha.1 起(含 0.1.7-rc.2) | configForms + settingsSchema | 设置按 profile entry id(request-privacy)寻址;插件把三个字段标为 volatile,并在 Plugins 页的 plugins.row.config 座位注册自带卡片 |
0.2.0-rc.2(安装源码核对) | 同一套 configForms / plugins.row.config 契约 | volatile 字段在运行时是提供 get() 的稳定引用;1.2.2 每次请求读取当前值,兼容旧宿主的普通布尔值 |
0.1.7 不会自动生成设置页。 上游确实把 autoGenerate 一路打通了,但
dsh-settings 自己的 README 写明「no shipped client does so yet」——实测 0.1.7-rc.2
的 291 个包里没有任何浏览器组件读它。所以那一代必须自带卡片,否则用户完全没有界面。
另外条目只有在字段带 volatile 时才会被 describe() 收录
(volatileForm() 无 meta.volatile 即返回 undefined),只声明 Config 是不够的。
两代的 Header / 会话日志策略完全一致:开关值都从宿主配置解析,与设置界面的实现无关。
⚠️ 0.1.7 的破坏性变更:该版本删除了 settingsScope、settings.plugin.item 以及
settings.installSection,并把 settings.yaml 并入 profile patch(启动时迁移一次)。
本插件 1.2.0 及更早版本在 0.1.7 上会导致 Web 界面完全无法加载(事故记录见
AGENTS.md 的「红线」与 §5),1.2.1 起修复。
1.2.2 的修复:1.2.1 的无静态依赖客户端仍符合当前宿主契约,但未读取新宿主
的 volatile 引用,导致策略固定为默认值。1.2.2 在每次请求解析这三个引用,
不修改 Header 控制点、Session ID 或扩展的联合 accept() 事务。
升级宿主后请按 §8 重新复核。
从本地构建产物安装(本机约定:产物位于 ../.artifacts/):
npm pack --ignore-scripts --pack-destination ../.artifacts
dsh plugin --profile web add ../.artifacts/stringbean4313-dsh-request-privacy-1.2.2.tgz
package.json 里的 dsh.bundle.patch 会让 profile 自动把本插件追加进
dsh.profile.bundles 并应用 cordis.patch.yml,无需手工编辑配置。
重启网关后生效(bundle 列表在启动时读取):
dsh web --no-open
确认装上了:~/.dsh/profiles/web/package.json 的 dependency 与
dsh.profile.bundles 都应使用 @stringbean4313/dsh-request-privacy。旧宿主在
设置 → 插件显示折叠卡片;当前宿主在侧栏 Plugins 的该 bundle 下,
打开 request-privacy 行的配置。
从旧无 scope 部署迁移:移除 dependency、bundle 列表与手写 patch 中的旧
dsh-request-privacy 包名,避免两个 bundle 同时插入同一条目。新 bundle 名、
Host 条目的 name 与浏览器模块 id 都是 @stringbean4313/dsh-request-privacy;
条目 id 必须保留 request-privacy,设置表单按此 id 寻址,卡片 slot key 为
@stringbean4313/dsh-request-privacy#request-privacy。若旧策略只保存在
settings.yaml 或 settings.yaml.imported 的 deepseek-request-privacy 节,
把三个布尔字段转移到 request-privacy 的 profile patch config;
当前宿主不会自动把这个第三方 namespace 映射到不同的 entry id。
2. 配置
2.1 composition 配置(cordis.yml / cordis.patch.yml)
作为 base 层,用户设置会覆盖它:
- id: request-privacy
name: '@stringbean4313/dsh-request-privacy'
config:
sendUserIdHeader: false
sendSessionIdHeader: true
sendSessionLog: false
2.2 用户设置($DSH_HOME/settings.yaml)
仅适用于 0.1.5 / 0.1.6。 0.1.7 起 settings.yaml 已被上游移除,其内容在启动时
迁移进 profile patch 一次;那一代请改用 §2.1 的 composition 配置或 §2.3 的设置页。
默认 Harness home 为 ~/.dsh,因此通常是 ~/.dsh/settings.yaml:
deepseek-request-privacy:
sendUserIdHeader: false
sendSessionIdHeader: true
sendSessionLog: false
优先级:Schema 默认值 → composition base → 用户 settings 文档(Harness 原生分层,本插件不自定义规则)。
0.1.7 保持了同一套三层模型(base / user / 解析值),只是用户层的落点从 settings.yaml
换成了 profile patch。
2.3 Web 设置界面
两代的界面来源不同,但开关语义完全一致:
| 宿主 | 界面 |
|---|
| 0.1.5 / 0.1.6 | 本插件注册 namespace 并渲染自带的折叠卡片「DeepSeek request privacy」,位于 设置 → 插件 → Plugin configuration |
| 0.1.7 起 | 本插件在侧栏 Plugins 页该插件条目的配置座位(plugins.row.config)渲染同一套开关;文案取自 Config 字段的 description 与本插件自己的字典 |
0.1.5 / 0.1.6 上的卡片与官方插件卡(Shell / Agent loop / Subagent)一致,默认折叠成一行,
只占一行高度,并在折叠状态下直接显示当前策略,例如
用户 ID 已拦截 · 会话 ID 不拦截 · 会话日志 已拦截——不展开也能一眼看出本机在发什么。
点击该行展开三个独立 toggle:
设置卡
折叠是刻意的:隐私卡被"查看"的次数远多于被"编辑"的次数,
而折叠行已经回答了最关键的问题——现在到底发出去了什么。
0.1.5 / 0.1.6 的卡片直接读写同一个 settings 真值(ctx.settingsScope),不使用 browser-local 存储。
- 改开关 → 立即写入,下一次 Provider 请求即生效;进行中的 streaming 不受影响。
- 卡片上的 Reset 是取消该字段的用户覆盖,重新继承 composition base,不是写入固定值。
- 外部编辑
settings.yaml 后,由 dsh-settings-file 的 watch 机制热发布。
- 当前宿主把 volatile 编辑提交到运行中的稳定引用,无需重新应用插件;本插件每次
请求读取
get(),因此下一次请求采用新策略,正在发送的请求不受影响。
3. 启用 / 禁用 / 卸载
# 临时禁用(保留安装):在 profile 的 cordis.patch.yml 中 disable
- id: request-privacy
disabled: true
# 卸载
dsh plugin --profile web remove @stringbean4313/dsh-request-privacy
卸载后:transport 恢复原状、settings namespace 注销、Web 卡片消失。插件不修改任何官方包,
因此没有需要回滚的补丁。
4. 架构与理由
4.1 为什么控制点在 transport,而不是 adapter
本机 Harness 0.1.5-rc.1(包 0.1.5-rc.2)的事实,均已核对安装源码:
@deepseek-ai/dsh-llm-deepseek 在私有 request() 方法内内联构造 Header 对象,
该方法同时负责请求序列化、Files API 图片管线、重试与 SSE 翻译,随后直接调用裸
fetch()(lib/index.js:1660、lib/index.js:1770)。
- 该版本没有 Cordis HTTP transport seam(不存在
ctx.http)。
DeepSeekAdapter / DeepSeekAdapterOptions 虽然公开导出,但 Header 构造不可覆盖:
重写 request() 等于重抄整个管线。
sessionId 在同一 adapter 内被使用两次:Header(:1666)和 DeepSeek request
extensions(:1757)。在 GenerateOptions 层剥离它会同时破坏请求体——这正是必须避免的回归。
因此在"不复制官方 adapter、不接管 provider route、不改官方包"的前提下,唯一能真正决定
什么字节离开本机的控制点就是网络出口。这也是隐私承诺应当被enforce和验证的位置。
4.2 具体做法
两个 Header:插件用 ctx.effect() 安装一个常驻、无状态的 fetch 包装:
- 每次请求读取一次策略快照,因此并发请求之间不可能互相影响。
- 只处理已经携带这两个 Header 的请求。全仓库核对:只有
dsh-llm-deepseek 会设置
x-deepseek-harness-*,所以这个判据本身就精确限定在本插件的目标上,同时天然支持任意自定义 baseURL。
- 不携带目标 Header 的请求(进程内所有非 Harness 请求)原样透传。
- 删除 Header 采用移除字段,绝不写入空串、
"0" 或随机值。
- 不触碰
body / signal / duplex / 返回的 Response。
- 插件 dispose 时恢复被它替换的 transport;若之后有别的层再次包装 transport,则不会误拆别人的包装。
dsh_session_log 字段:控制点在官方公开的请求扩展注册表
(ctx.deepseekLlmApiExtensions.prepare),而不是在线上做请求体改写。
官方 adapter 在 HTTP 发送前会把所有已注册的顶层字段合成一个事务;包装这个公开方法,
就能在字段进入请求体之前把它摘掉——不需要解析或重写(可能几百 MB 的)JSON 请求体,
也不触碰任何其他字段。该包装同样是常驻、无状态、每次请求读一次策略、dispose 可撤销。
有一处细节值得记录:Cordis 的 service proxy 每次读取都会重新包装函数值,
所以"读回来的 prepare 是否还是我装进去的那个"永远不可能是 ===。
本插件用符号键标记自己的包装函数来判定,否则 dispose 时的还原会静默失效——
这正是端到端测试发现并修正的一个真实缺陷。
4.3 明确不做的事
- 不读取、不记录 API Key。
- 不把两个 ID 写入日志、prompt 或 Session event(诊断只输出布尔值与 Header 名)。
- 不创建替代 tracking ID,不修改请求体来"补偿"被移除的 Header。
- 不修改
authorization / content-type / accept / user-agent / x-deepseek-harness-compact,
也不使用"删除所有 x-deepseek-harness-*"这类宽泛规则。
5. 会话日志上传(dsh_session_log)
对应上游讨论 deepseek-ai/deepseek-harness#6754。
本机核对结果如下(均已对安装源码与上游 master 逐一验证,非转述):
| 你当前安装的 0.1.5-rc.3 | 上游 master / 0.1.6 |
|---|
session-log-deepseek 的 enabled 默认值 | false(z.boolean().default(false)) | true(z.boolean().default(true)) |
apply() 是否按 enabled 短路 | 是(if (config.enabled !== true) return) | 是 |
| 是否有大小上限 | 没有 | 有,maxBytes 默认 8 MiB |
| 你的 composition 是否设置过 | 否(@deepseek-ai/dsh-base 只挂了插件、未给 config) | — |
结论:在你当前安装的版本上,这项上传默认就是关闭的,现在并没有日志被发出去。
讨论中描述的"默认开启 + 无上限 + 每次请求携带完整日志导致 TRANSPORT 死循环"是 0.1.6
起才出现的组合;上游同时补了 8 MiB 上限,但把默认值留在了开启。
5.1 本插件提供的开关
sendSessionLog(默认 false)在请求扩展注册表处摘掉 dsh_session_log 字段,
因此无论官方插件的默认值是什么、无论你升级到哪个版本,该字段都不会进入发往
Provider 的请求。这是面向升级的保险,而不是对当前状态的补救。
5.2 建议同时做的 composition 配置(彻底版)
上面的开关保证"不发出",但官方 provider 仍会构建那份 payload。
若日志很大,这会白白消耗内存与 CPU。彻底的做法是让官方插件根本不注册该字段——
这正是讨论里给出的官方 workaround,写进 profile 的 cordis.patch.yml:
- id: session-log-deepseek
config:
enabled: false
本插件在第一次真正摘除字段时会打印一次 warning 提示这一行;不会每次请求都刷屏。
说明:本插件的开关不会删除你本地的会话日志,也不会阻止 telemetry 等其他子系统。
它只决定这一个字段是否进入 Provider 请求。
5.3 一处诚实的取舍
摘掉字段时,本插件不修改官方的"接受(accept)"事务。该事务是所有已注册字段共享的
一次联合调用——官方的 dsh_plugin_packages 字段走的就是同一个 accept()——
替换它会连带扣掉本插件根本没有检查过的字段的确认。代价是
session-log-deepseek 会把这次贡献视为已处理并推进自己的水位线;§5.2 的配置可以避免这一情形。
6. 已知边界:匿名 ID 的本地解析(可选增强,未实现)
sendUserIdHeader=false 时,线路上一定没有 x-deepseek-harness-user-id。
但官方 adapter 在构造 Header 之前会无条件调用一次 resolveUserId()
(lib/index.js:1625),因此 $DSH_HOME/.anonymous-user-id 仍可能由 Provider 路径创建。
要消除这一本地副作用,只能控制 adapter 实例的 resolveUserId——这要求接管
deepseek-official route:需要在 composition 中移除官方 @deepseek-ai/dsh-llm-deepseek,
并由本插件重新承担其配置 glue 与 llm-deepseek settings namespace(Web「模型」页依赖它)。
代价(上游兼容性、命名空间所有权、升级维护面)明显高于收益,故有意不做。
注意:该文件在默认组合下本来就会被 telemetry 与 feedback 创建;本插件既不阻止也不依赖它。
7. 验证
npm run build # tsc + strip client export
npm test # 真实线路、settings、扩展注册表、React 与 fiber 生命周期回归
node --test test/wire.test.mjs # 20 项:真实 loopback HTTP 线路捕获
node --test test/settings.test.mjs # 7 项:真实 Cordis settings seam + file provider
node --test test/session-log.test.mjs # 10 项:真实扩展注册表 + 字段摘除与还原
node --test test/client.test.mjs # 11 项:真实 React 渲染设置卡(折叠态 + 展开态)
node --test test/boot-safety.test.mjs # 真实 Cordis fiber 生命周期
node --test test/generation-compat.test.mjs # 旧布尔配置与实时 volatile 引用策略
node test/e2e-harness.mjs # 真实 Harness 端到端(隔离 DSH_HOME + mock endpoint)
线路测试不 mock adapter 内部函数:真实的 DeepSeekAdapter 向真实的 loopback 监听器
发起真实 fetch,断言直接读取服务端收到的 Header。端到端测试用隔离的 $DSH_HOME 启动
真实 Harness,跑真实 agent 任务,并由 mock endpoint 记录实际到达的 Header。
端到端套件内含 7 个场景,其中 3 个在真实请求体里核对 dsh_session_log:
把官方 session-log-deepseek 打开(用讨论里的 composition 写法),再分别以默认、显式关闭、
显式开启三种策略运行,断言字段在请求体中确实 absent / absent / present。
该套件每次都从当前工作树重新 npm pack,避免"测的是旧产物"。
8. 升级风险与重新验证方法
0.1.7 复核结果(1.2.1 已逐条核对 0.1.7-rc.2 的安装源码):
| 上游事实 | 0.1.7 状态 |
|---|
官方 adapter 用裸 fetch() | ✅ 仍是 await fetch(${messagesApiRoot(baseURL)}/messages),无 Cordis HTTP seam |
只有 dsh-llm-deepseek 设置 x-deepseek-harness-* | ✅ 同名、同三处(user-id / session-id / compact) |
ctx.deepseekLlmApiExtensions.prepare 仍是公开联合事务 | ✅ ctx.get("deepseekLlmApiExtensions")?.prepare(request) |
| 扩展字段仍落在请求体顶层 | ✅ body: extensions.payload |
settings.installSection 仍存在 | ❌ 已删除,改为 settings.configure({ auto });设置按 profile entry id 寻址,schema 取自该条目自己的 Config |
客户端 settingsScope / settings.plugin.item | ❌ 已删除,改为 configForms + settingsSchema,卡片座位改为 plugins.row.config |
Config 字段需要 volatile 才会被 describe() 收录 | ⚠️ 0.1.5/0.1.6 的 schemastery 3.18.2 没有 .volatile(),故本插件运行时探测后再调用 |
| 设置页是否由 schema 自动渲染 | ❌ 不是。autoGenerate 无任何浏览器消费者,第三方必须自带卡片 |
因此两个执行控制点在 0.1.7 上原样成立,失效的只有设置界面——这正是 1.2.0 事故的成因。
端点路径已从 /chat/completions 变为 /messages,但本插件的关卡按 Header 名匹配、与 URL 无关,
故不受影响。
0.2.0-rc.2 安装源码核对:configForms.get(entryId) 的 form 仍提供
getSnapshot / subscribe / set / unset,whileServed 仍按 Host entry id
控制卡片注册;plugins.row.config 仍以 <bundle 包名>#<row id> 寻址。
官方 adapter 仍用裸 fetch 设置相同的两个 Header,并从
deepseekLlmApiExtensions.prepare 的 { fields, accept } 构造顶层扩展。
新增差异在 schemastery:volatile 字段解析成 { get() },而不是普通布尔值。
这就是 1.2.2 所修复的策略读取问题;运行时保证仍须用下面的验证实际确认。
插件依赖的公开契约:ctx.effect、ctx.settings.installSection、settings namespace
校验规则、ctx.deepseekLlmApiExtensions.prepare 扩展事务、以及"官方 adapter 用裸 fetch
且只在携带目标 Header 时设置它们"。
升级 Harness 后,如果官方 adapter 改为复用 Headers 实例或迁移到正式 HTTP seam,
Header 删除逻辑本身仍然成立(它按名匹配、与容器形状无关)。请重新运行:
npm test # 含 boot-safety 与 generation-compat 两套回归
node test/e2e-harness.mjs # 0.1.5 线
DSH_BIN=/path/to/dsh/lib/bin.js node test/e2e-harness.mjs # 指定其它宿主版本
端到端套件按真实请求路径分别返回 /chat/completions 或 /messages 的 SSE,
每个场景直接写 profile patch 的 llm-deepseek 与 request-privacy config,
不再依赖已移除的 settings.yaml;同时要求真实 agent 成功退出。
若 dsh-llm-deepseek 未来引入正式 Header seam,应把控制点迁移过去,并删除 fetch 包装。
9. 许可
MIT