dsh-cookie-janitor
Stops the DeepSeek Harness desktop shell from bricking itself with
desktop welcome: Web authentication failed. The session cookie pile is not cleaned up by upstream — this plugin collapses it back to one entry on every launch.
English summary · 问题 · 根因 · 修法 · 安装 · 验证 · 回滚 · 边界
问题
DeepSeek Harness 桌面版重启若干次之后,启动必定失败:
desktop welcome: Web authentication failed
弹窗只给两个建议:重装 或 禁用插件。两条都不解决——重装不清 cookie,禁用插件更不对症, 因为坏掉的东西跟插件无关。
删掉 Network/Cookies 能救回来一次,但过 70 多次重启就会原样复发。这个插件就是来断这条复发的。
根因
不是推测,是逐环对着 app.asar 里的代码核出来的:
| 环节 | 代码位置(@deepseek-ai/...) |
事实 |
|---|---|---|
| 端口随机 | dsh-desktop-host/lib/index.js:234-240 |
启动参数写死 --port 0,每次启动拿一个临时端口 |
| 名字吃端口 | dsh-client-connection/lib/index.js:307-316 |
requestAudience() 对明文 HTTP 返回 url.host,含端口 |
| 名字 = 哈希 | 同文件 :335-337 |
cookieName = "dsh-auth-" + base64url(sha256(authority)) |
| cookie 不按端口隔离 | 同文件 :347-350 |
只发 Max-Age / Path=/ / Expires / HttpOnly / SameSite=Strict,无 Domain,无端口维度 |
| 生命周期 30 天 | 同文件 :1017 |
cookieMaxAgeDays 默认 30 |
| 无人回收 | 全库 | 搜不到任何 cookies.remove;主 jar 没有任何 prune 逻辑 |
| 撞上限 | Node 默认 | max-http-header-size = 16384 字节 |
| 报错的点 | lib/main.js:8884-8886 |
net.fetch(url, {credentials:"include"}) → 非 2xx 就抛 desktop welcome: Web authentication failed |
| 弹窗在误导 | lib/main.js:8378 |
if (response === 2) await operations.disablePlugins() —— 这是任何 fatal 错误的共用兜底文案,它根本没读 HTTP 状态码 |
结果:每启动一次,就在 127.0.0.1 下新签一条 dsh-auth-<hash>,旧的永不回收。
因为 cookie 不区分端口,它们全部堆在同一个域下,越积越多。
实测边界(对活着的 host 直发不同大小的 Cookie 头):
| Cookie 头长度 | 服务端响应 |
|---|---|
| 8128 B | 401 Unauthorized |
| 15128 B | 401 Unauthorized |
| 17128 B | 431 Request Header Fields Too Large |
| 20128 B | 431 Request Header Fields Too Large |
单条 name=value ≈ 226 B,加 "; " 分隔符 228 B;再加请求行 + Host + Cookie: 约 100 B。
→ 第 71 条还塞得下,第 72 条越界。(流传的「70 条」差两条,量级是对的。)
注意:撞 431 的不是转发链路。
forwardWebRequest只塞单条hostCookie。 只有启动握手那一发credentials: "include"会把整个 jar 的 cookie 全带上, 所以这个故障的形态固定是「启动即失败」,跑起来之后反而一切正常。
修法
关键洞察:每次启动只有一条 cookie 是有意义的 —— 本进程即将为自己 authority 签发的那条。
所以插件不「清空」,只做「收敛」:凡是「正在签发新 cookie」的响应,顺手对请求里带过来的
其他所有 dsh-auth-* 名字下发 Max-Age=0。
set-cookie: dsh-auth-<hashA>=; Max-Age=0; Expires=Thu, 01 Jan 1970 ...
set-cookie: dsh-auth-<hashB>=; Max-Age=0; Expires=Thu, 01 Jan 1970 ...
set-cookie: dsh-auth-<hashC>=v1.xxx; Max-Age=2592000; Path=/ ... ← 当前这条,排在后面
同一个响应里对一个名字下发多次时后者覆盖前者,所以即使名字算错、把当前这条也列进了删除列表, 紧跟其后的真值也会把它救回来。顺序本身就是安全网。
两个挂载点:
| 挂载点 | 触发时机 | 作用 |
|---|---|---|
connection.authorizeIndex / connection.authorizeConnect |
每次启动的认证握手 | 拿响应自身的 set-cookie 名字做 keep,清掉其余全部 |
connection/request waterfall |
运行期每个已认证 /api 请求 |
用 base64url(sha256(host)) 反推当前名,必须真的出现在请求里才动手 |
第二条的守卫很重要:算出来的名字若不在请求头里(说明上游改了命名算法),直接跳过—— 宁可不清,也不清空。代码里没有「清空」这条路径。
安装
前置
- DeepSeek Harness 桌面版(遇到了这个 bug 才需要它;HTTPS 部署下 authority 不含端口,本来就不会堆积)
- 能写到
$DSH_HOME(默认~/.dsh)和当前 profile 目录
1. 放进 local-plugins
git clone https://github.com/dududreamer/dsh-cookie-janitor.git
mkdir -p "$DSH_HOME/local-plugins"
cp -r dsh-cookie-janitor "$DSH_HOME/local-plugins/"
Windows PowerShell:
git clone https://github.com/dududreamer/dsh-cookie-janitor.git
New-Item -ItemType Directory -Force "$env:USERPROFILE\.dsh\local-plugins" | Out-Null
Copy-Item .\dsh-cookie-janitor "$env:USERPROFILE\.dsh\local-plugins\" -Recurse
2. 链接进 profile 的 node_modules
# Windows(Junction,无需管理员)
$scope = "$env:USERPROFILE\.dsh\profiles\desktop\node_modules"
New-Item -ItemType Junction -Path "$scope\dsh-cookie-janitor" `
-Target "$env:USERPROFILE\.dsh\local-plugins\dsh-cookie-janitor"
# macOS / Linux
ln -s "$DSH_HOME/local-plugins/dsh-cookie-janitor" \
"$DSH_HOME/profiles/desktop/node_modules/dsh-cookie-janitor"
3. 在 profile 补丁层激活
编辑 $DSH_HOME/profiles/desktop/cordis.patch.yml,在末尾追加:
- insert:
- id: dsh-cookie-janitor
name: dsh-cookie-janitor
config: {}
在 profile 的 package.json 里声明依赖,避免将来 pnpm install 把链接清掉:
"dependencies": {
"dsh-cookie-janitor": "file:../../local-plugins/dsh-cookie-janitor"
}
4. 生效
profile 若为 patchReload: live,改完补丁层当场热加载,不用重启(而重启恰恰是这个故障的触发点)。
验证
插件把每次动作追加到 <tmpdir>/dsh-cookie-janitor.log:
2026-11-01T09:11:18.656Z apply loaded (pid 37868)
2026-11-01T09:11:19.676Z authorize: collapsing 39 stale cookie(s), keeping the one being issued
2026-11-01T09:11:30.173Z apply loaded (pid 14520)
2026-11-01T09:11:31.120Z authorize: collapsing 1 stale cookie(s), keeping the one being issued
第一次清 39 条(那时 jar 里约 41 条,离 71 的悬崖差 30 次重启), 下一次启动只清 1 条——稳态达成,之后永远只清 1 条,再也不可能撞到 16384。
⚠️ 不要用
Network/Cookies的文件大小判断有没有修好。 SQLite 只把页标记为空闲,文件不会立刻缩小(实测最终从 32768 收缩到 20480 是几十次启动之后的事)。 看日志里的条数。
想关掉日志:启动宿主前设 DSH_COOKIE_JANITOR_LOG=off。
回滚
两步,任意一步单独做都能让它停摆:
- 删掉 profile
cordis.patch.yml末尾的- insert: dsh-cookie-janitor段; - 删掉
node_modules/dsh-cookie-janitor链接。
边界
- 只解决本机明文 HTTP 下 cookie 按端口堆积。HTTPS 的 authority 是 origin(不含端口),本来就不会堆积, 插件在那里也不会做任何事。
- 上游若改了命名算法,waterfall 侧会静默停手(届时需要同步
currentName()); 授权侧不受影响,它用的是响应自带的set-cookie名字,不猜。 - 真正的断根在上游:把
requestAudience里的端口去掉,或者给 cookie 加 prune。 这个插件是那次改动落地之前,本机可用的等效方案。
更详细的证据链与实测数据见 docs/root-cause.md。
English summary
The desktop shell binds its Web host to an ephemeral port (--port 0). For a plain HTTP listener
that authority is host:port, so session cookies are named dsh-auth-<base64url(sha256(host:port))>.
Cookies are not scoped by port, so every launch mints a new entry next to all previous ones while
sharing Path=/ and a 30-day lifetime. At ~228 bytes per pair, the accumulated Cookie header
exceeds Node's default 16384-byte max-http-header-size around the 71st launch, the handshake gets
431, and the shell reports it as desktop welcome: Web authentication failed.
This plugin never wipes the jar. On the response that mints a new session cookie (and on every
authenticated API response afterwards) it appends Max-Age=0 for every other dsh-auth-* name the
client sent, ordering the deletions before the live set-cookie. A same-name overwrite later in the
same response wins, so the live cookie survives even if the name derivation is wrong. On the API path
it also refuses to act when the computed name is absent from the request — the failure mode is
"do nothing", never "delete everything".
No comments yet. Be the first to write one.