dsh-auth-cookie-sweep 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-auth-cookie-sweep contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,346 @@
1
- # Temporary Holding Version
1
+ # dsh-auth-cookie-sweep
2
+
3
+ 一个用于 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的宿主端小插件:
4
+ 阻止 `dsh-auth-\*` 浏览器会话 cookie 每次启动都多存一条,直到 `Cookie` 请求头越过 Node 的
5
+ 16 KiB 上限、所有请求被 `431 Request Header Fields Too Large` 拒绝(有时表现为 `401`)的问题。
6
+
7
+ 它修复的现象:启动几十次之后 DSH 再也打不开,网页提示 `Web authentication failed` /
8
+ `dsh web authentication required`,重启、重装、删会话都没有用。
9
+
10
+ * 不需要改 app.asar,不需要重新打包 Electron
11
+ * 没有存储、没有前端 bundle、不联网
12
+ * DSH 升级后依然有效(只依赖公开的 `webServer` 服务)
13
+ * 测试套件零依赖
14
+
15
+ ### cookie 为什么会累积
16
+
17
+ |组件|行为|
18
+ |-|-|
19
+ |`@deepseek-ai/dsh-desktop-host`|用 `--port 0` 启动 Host(`lib/index.js`,参数 `\["--no-open","--host","127.0.0.1","--port","0"]`),**每次启动端口都不同**|
20
+ |`@deepseek-ai/dsh-client-connection`|浏览器 cookie 的**名字**是 `dsh-auth-` + `sha256(authority)`(`lib/index.js` 的 `cookieName()`),签名载荷同样绑定这个 authority(`authorizeIndex()` 在 `303` 换取时下发 `Set-Cookie`)|
21
+ |浏览器|cookie **不按端口隔离**:`127.0.0.1:41000` 与 `127.0.0.1:54326` 共用一个 jar,每次请求都会全部发送|
22
+ |有效期|30 天(`cookieMaxAgeDays`),且没有登出流程会删除旧 cookie|
23
+
24
+ 于是每次启动都为新 authority 生成一条 cookie,jar 只增不减,`Cookie` 头每次启动大约增长
25
+ 200 字节。到第 72 次左右跨过 16 KiB,Node 的 HTTP 解析器会在任何 handler(包括 DSH 自己的)
26
+ 运行之前就拒绝请求。这就是故障会「永久」且删会话也无效的原因。
27
+
28
+ ### 插件做了什么
29
+
30
+ 它挂在 **Host 进程**里,包装响应路径上最后一处还能动手的地方:`ServerResponse.writeHead`。
31
+ 对每个进来的请求它
32
+
33
+ 1. 读取 `Cookie` 头与请求的 authority(HTTP 为 `host\[:port]`,HTTPS 为
34
+ `scheme://host\[:port]`,与 Host 的归一化方式一致);
35
+ 2. 找出属于**其它 authority**(即旧启动)的 `dsh-auth-\*` cookie;
36
+ 3. 当 jar 明显陈旧时,在 Host 本来要写的响应之外追加
37
+ `Set-Cookie: <name>=; Max-Age=0; Path=/; HttpOnly; SameSite=Strict`。
38
+
39
+ 浏览器收到响应后删除这些 cookie,请求头不再增长。当前这次启动自己的 cookie 永远不会被动,
40
+ Host 的 `303` 换取响应也依旧会在同一条响应里下发新 cookie:
41
+
42
+ ```http
43
+ HTTP/1.1 303 See Other
44
+ location: ./
45
+ set-cookie: dsh-auth-yLH9IP…=v1.eyJ2ZXJzaW9uIjox…; Max-Age=2592000; Path=/; HttpOnly; SameSite=Strict
46
+ set-cookie: dsh-auth-fkJh9Q9…=; Max-Age=0; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Strict
47
+ set-cookie: dsh-auth-Cv1vmX3…=; Max-Age=0; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Strict
48
+ ```
49
+
50
+ #### 并发保护
51
+
52
+ 单独一条「外来」`dsh-auth-\*` 可能属于**另一个正在运行**的 DSH 实例(它有自己的端口)。
53
+ 删掉它会让那个实例掉线,并再生成一条新 cookie。所以只在两种情况下清理:
54
+
55
+ * 外来会话 cookie 数量 ≥ `minStaleCookies`(默认 **3**),或
56
+ * 整个 `Cookie` 头已经达到/超过 `maxCookieHeaderBytes`(默认 **4096** 字节)。
57
+
58
+ 多开一个实例只会多一条 cookie,不受影响;长期反复启动也不可能再累积。
59
+
60
+ ### 安装
61
+
62
+ 本插件就是普通的 profile bundle(`dsh.bundle.patch` → `cordis.patch.yml` → 一行 `insert`)。
63
+
64
+ ```bash
65
+ # 从 npm / git 安装
66
+ dsh plugin --profile web add dsh-auth-cookie-sweep
67
+ # 或者在克隆下来的目录里
68
+ dsh plugin --profile web add file:/插件的绝对路径/dsh-auth-cookie-sweep
69
+ ```
70
+
71
+ `dsh plugin add` 只添加依赖,随后还需把包名加进 `\~/.dsh/profiles/<profile>/package.json` 的
72
+ `dsh.profile.bundles`(如果你的插件管理器会自动维护则不用手工加):
73
+
74
+ ```json
75
+ {
76
+ "dsh": {
77
+ "profile": {
78
+ "bundles": \["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-auth-cookie-sweep"]
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ 不启动也能校验组合结果:
85
+
86
+ ```bash
87
+ dsh --profile web --dump-config | grep -A2 auth-cookie-sweep
88
+ # # == dsh-auth-cookie-sweep
89
+ # - id: dsh-auth-cookie-sweep
90
+ # name: dsh-auth-cookie-sweep
91
+ ```
92
+
93
+ **桌面版:** `desktop` profile 由 Electron 应用管理(CLI 会拒绝 `dsh --profile desktop`)。
94
+ 请通过应用内的插件管理器 / 插件市场安装,或自行把依赖与 bundle 条目加到
95
+ `\~/.dsh/profiles/desktop/package.json` 后重启 DSH。
96
+
97
+ #### 已经打不开了怎么办
98
+
99
+ 插件无法拯救**已经**超过 16 KiB 的 jar:Node 在解析阶段就拒绝了请求,任何 JavaScript(无论
100
+ 插件还是 Host)都不会被执行。需要先手工清一次 cookie,之后插件会把它长期压住:
101
+
102
+ * **浏览器:** 开发者工具 → Application → Cookies → `http://127.0.0.1:<端口>` → 删除所有
103
+ `dsh-auth-\*`(或对该 origin 执行「清除站点数据」)。
104
+ * **桌面版(Electron):** 退出 DeepSeek Harness,删除
105
+ `%APPDATA%\\@deepseek-ai\\dsh-desktop\\Network\\Cookies` 与 `Network\\Cookies-journal`,再启动。
106
+ DSH 不依赖该存储里的其它 cookie。
107
+
108
+ ### 配置
109
+
110
+ 在 bundle 的 `insert` 行上加可选 `config`:
111
+
112
+ ```yaml
113
+ - insert:
114
+ - id: dsh-auth-cookie-sweep
115
+ name: dsh-auth-cookie-sweep
116
+ config:
117
+ minStaleCookies: 3 # 1..256,达到多少条外来 cookie 才清理
118
+ maxCookieHeaderBytes: 4096 # 512..16384,请求头多大就立刻清理
119
+ maxDeletions: 64 # 1..512,单次响应最多下发多少条过期指令
120
+ secure: false # 省略则自动跟随 Host 协议
121
+ ```
122
+
123
+ 非法或越界取值会被收敛到安全范围,未知字段忽略。
124
+
125
+ ### 内部实现
126
+
127
+ * `lib/index.js` —— Cordis 插件:`name`、`inject: \["webServer"]`、`apply(ctx, config)`。
128
+ `inject` 保证插件只在 `webServer\[Service.init]` 创建并绑定 `node:http` 服务器**之后**激活,
129
+ 因此 `ctx.webServer.server` 一定已存在。它用 `prependListener` 挂 `request` 监听,并通过
130
+ `ctx.effect(...)` 在插件卸载时移除。
131
+ * `lib/purge.js` —— 零依赖工具函数:authority 推导、cookie 名推导(与
132
+ `dsh-client-connection` 逐字节一致)、陈旧判定、阈值策略,以及 `writeHead` 包装(兼容
133
+ `(status, headers)`、`(status, message, headers)`、数组对形式的 headers,并合并
134
+ `setHeader("set-cookie", …)` 已写入的 cookie)。
135
+ * 除清理时的一行计数外不输出日志;cookie 的值不会被读取、保存或打印。
136
+
137
+ #### 局限
138
+
139
+ * 对已经被 Node 以 `431` 拒绝的请求无能为力(见「已经打不开了怎么办」)。
140
+ * 只能过期到达本 Host 的 cookie;其它 origin / profile 的 jar 归它们自己管理。
141
+ * 浏览器对单域名的 cookie 数量也有上限;本插件会把数量压得足够低,使该上限不会成为新的症状。
142
+
143
+ ### 开发
144
+
145
+ ```bash
146
+ node --test test/ # 零依赖的单元 + 集成测试
147
+ ```
148
+
149
+ `test/purge.test.mjs` 覆盖工具函数与 `writeHead` 合并的各种形态;
150
+ `test/integration.test.mjs` 用真实 `node:http` 服务器模拟 300 次启动(每次一个新 authority),
151
+ 断言 jar 始终 ≤ 4 条、请求头始终 < 2 KiB,并在单个响应内修复一个 20 条 cookie 的中毒 jar。
152
+
153
+ 已在 DeepSeek Harness `0.2.2-rc.1` 上做过实际验证(真实 `dsh-host-webserver`、固定端口):
154
+
155
+ |请求|结果|
156
+ |-|-|
157
+ |1 条外来 cookie|`401`,不清理(并发保护)|
158
+ |3 条外来 cookie|`401` + 3 条 `Max-Age=0`|
159
+ |合法 `?token=` + 3 条外来|`303`,**保留**新签发 cookie + 3 条 `Max-Age=0`|
160
+ |已认证的后续请求|`200`|
161
+ |合法 cookie + 20 条外来|`200` + 20 条 `Max-Age=0`|
162
+ |1 条外来 + 5 KiB 请求头|触发清理(字节阈值)|
163
+
164
+ ### 许可证
165
+
166
+ MIT
167
+
168
+ \---
169
+
170
+ ##
171
+
172
+ A small [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) host plugin that stops
173
+ `dsh-auth-\*` browser-session cookies from piling up, one per launch, until the browser's `Cookie`
174
+ request header crosses Node's 16 KiB limit and every request is rejected with
175
+ `431 Request Header Fields Too Large` (or a plain `401` once the Host gives up).
176
+
177
+ Symptom it fixes: after a few dozen launches, DSH stops opening — the web UI reports
178
+ `Web authentication failed` / `dsh web authentication required`, and no amount of restarting helps.
179
+
180
+ * No app.asar surgery, no patched Electron build
181
+ * No storage, no client bundle, no network access
182
+ * Keeps working across DSH updates (it only uses the public `webServer` service)
183
+ * Test suite has no dependencies
184
+
185
+ ### Why the cookies accumulate
186
+
187
+ |Piece|Behaviour|
188
+ |-|-|
189
+ |`@deepseek-ai/dsh-desktop-host`|starts the Host with `--port 0` (`lib/index.js`, args `\["--no-open","--host","127.0.0.1","--port","0"]`), so **every launch gets a different port**|
190
+ |`@deepseek-ai/dsh-client-connection`|the browser cookie's *name* is `dsh-auth-` + `sha256(authority)` (`lib/index.js` — `cookieName()`), and its signed payload binds the same authority (`authorizeIndex()` → `Set-Cookie` on the `303` token exchange)|
191
+ |Browsers|cookies are **not port-scoped**: `127.0.0.1:41000` and `127.0.0.1:54326` share one cookie jar, so every entry is sent on every request|
192
+ |Cookie lifetime|30 days (`cookieMaxAgeDays`), and there is no logout path that removes old ones|
193
+
194
+ So each launch mints a cookie for a new authority, the jar never shrinks, and the `Cookie` header
195
+ grows by roughly 200 bytes per launch. Around launch 72 it passes 16 KiB and Node's HTTP parser
196
+ rejects the request before any handler (including DSH's) runs. That is why the failure is
197
+ permanent and why deleting the session does not help.
198
+
199
+ ### What the plugin does
200
+
201
+ It mounts in the **Host** process and wraps the one place where a response is still under our
202
+ control: `ServerResponse.writeHead`. For every incoming request it
203
+
204
+ 1. reads the `Cookie` header and the request's authority (`host\[:port]` for HTTP,
205
+ `scheme://host\[:port]` for HTTPS — the same normalisation the Host uses),
206
+ 2. computes which `dsh-auth-\*` cookies belong to a *different* authority, i.e. to an older launch,
207
+ 3. when the jar is clearly stale, adds `Set-Cookie: <name>=; Max-Age=0; Path=/; HttpOnly; SameSite=Strict`
208
+ next to whatever cookie the Host itself was already setting.
209
+
210
+ The browser drops those cookies on the response, and the header stops growing. The current
211
+ launch's own cookie is never touched, and the Host's `303` token exchange still sets its freshly
212
+ minted cookie in the same response:
213
+
214
+ ```http
215
+ HTTP/1.1 303 See Other
216
+ location: ./
217
+ set-cookie: dsh-auth-yLH9IP…=v1.eyJ2ZXJzaW9uIjox…; Max-Age=2592000; Path=/; HttpOnly; SameSite=Strict
218
+ set-cookie: dsh-auth-fkJh9Q9…=; Max-Age=0; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Strict
219
+ set-cookie: dsh-auth-Cv1vmX3…=; Max-Age=0; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Strict
220
+ ```
221
+
222
+ #### Concurrency guard
223
+
224
+ A single foreign `dsh-auth-\*` cookie may belong to **another DSH instance that is running right
225
+ now** on its own port. Deleting it would log that instance out (and it would then mint yet another
226
+ cookie). The sweeper therefore cleans in two cases only:
227
+
228
+ * at least `minStaleCookies` (default **3**) foreign session cookies are present, or
229
+ * the whole `Cookie` header is already at/over `maxCookieHeaderBytes` (default **4096**).
230
+
231
+ One extra instance drifts by one cookie and is left alone; months of launches can never accumulate.
232
+
233
+ ### Install
234
+
235
+ The plugin is a normal profile bundle (`dsh.bundle.patch` → `cordis.patch.yml` → one `insert` row).
236
+
237
+ ```bash
238
+ # from npm / a git checkout
239
+ dsh plugin --profile web add dsh-auth-cookie-sweep
240
+ # or, in a cloned checkout
241
+ dsh plugin --profile web add file:/absolute/path/to/dsh-auth-cookie-sweep
242
+ ```
243
+
244
+ Then make sure the bundle is composed. `dsh plugin add` only adds the dependency, so add the name
245
+ to `dsh.profile.bundles` in `\~/.dsh/profiles/<profile>/package.json` unless your plugin manager
246
+ does it for you:
247
+
248
+ ```json
249
+ {
250
+ "dsh": {
251
+ "profile": {
252
+ "bundles": \["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-auth-cookie-sweep"]
253
+ }
254
+ }
255
+ }
256
+ ```
257
+
258
+ Verify without booting anything:
259
+
260
+ ```bash
261
+ dsh --profile web --dump-config | grep -A2 auth-cookie-sweep
262
+ # # == dsh-auth-cookie-sweep
263
+ # - id: dsh-auth-cookie-sweep
264
+ # name: dsh-auth-cookie-sweep
265
+ ```
266
+
267
+ **Desktop app:** the `desktop` profile is managed by the Electron app (`dsh --profile desktop` is
268
+ refused by the CLI). Install through the app's plugin manager / marketplace, or add the dependency
269
+ and the bundle entry to `\~/.dsh/profiles/desktop/package.json` yourself and restart DSH.
270
+
271
+ #### Already broken?
272
+
273
+ The plugin cannot rescue a jar that is **already** over 16 KiB: Node parses and rejects the header
274
+ before any handler runs, so no JavaScript (plugin or Host) ever sees the request. Clear the
275
+ cookies once — after that the sweeper keeps the jar small:
276
+
277
+ * **Browser:** DevTools → Application → Cookies → `http://127.0.0.1:<port>` → delete every
278
+ `dsh-auth-\*` entry (or "Clear site data" for that origin).
279
+ * **Desktop app (Electron):** quit DeepSeek Harness, delete
280
+ `%APPDATA%\\@deepseek-ai\\dsh-desktop\\Network\\Cookies` and `Network\\Cookies-journal`, start it
281
+ again. DSH does not depend on any other cookie in that store.
282
+
283
+ ### Configuration
284
+
285
+ Optional `config` on the bundle's `insert` row:
286
+
287
+ ```yaml
288
+ - insert:
289
+ - id: dsh-auth-cookie-sweep
290
+ name: dsh-auth-cookie-sweep
291
+ config:
292
+ minStaleCookies: 3 # 1..256, foreign cookies before cleaning
293
+ maxCookieHeaderBytes: 4096 # 512..16384, clean as soon as the header is this big
294
+ maxDeletions: 64 # 1..512, expiries per response
295
+ secure: false # omit to follow the Host protocol automatically
296
+ ```
297
+
298
+ Wrong or out-of-range values are clamped; unknown keys are ignored.
299
+
300
+ ### How it works internally
301
+
302
+ * `lib/index.js` — the Cordis plugin: `name`, `inject: \["webServer"]`, `apply(ctx, config)`.
303
+ `inject` guarantees the plugin only activates **after** `webServer\[Service.init]` created and
304
+ bound the `node:http` server, so `ctx.webServer.server` is present. It adds a `request` listener
305
+ with `prependListener` and removes it through `ctx.effect(...)` when the plugin unloads.
306
+ * `lib/purge.js` — dependency-free helpers: authority derivation, cookie-name derivation
307
+ (byte-compatible with `dsh-client-connection`), stale detection, threshold policy, and the
308
+ `writeHead` wrapper (handles `(status, headers)`, `(status, message, headers)`, array-of-pairs
309
+ headers, and merges a cookie already staged with `setHeader("set-cookie", …)`).
310
+ * Nothing is logged beyond a one-line count when cookies are expired; cookie values are never
311
+ read, stored, or printed.
312
+
313
+ #### Limitations
314
+
315
+ * Cannot help a request whose header Node already rejected with `431` (see *Already broken?*).
316
+ * Only expires cookies that arrive at this Host; other origins/profiles keep their own jars.
317
+ * Browsers also cap the number of cookies per domain; the sweeper keeps the count low enough that
318
+ this cap never becomes the new symptom.
319
+
320
+ ### Development
321
+
322
+ ```bash
323
+ node --test test/ # dependency-free unit + integration tests
324
+ ```
325
+
326
+ `test/purge.test.mjs` covers the helpers and the `writeHead` merge variants;
327
+ `test/integration.test.mjs` runs a real `node:http` server and simulates 300 launches with a fresh
328
+ authority each time, asserting the jar stays ≤ 4 cookies and the header stays < 2 KiB — then
329
+ rehabilitates a 20-cookie poisoned jar in a single response.
330
+
331
+ Live check performed against DeepSeek Harness `0.2.2-rc.1` (Host on a fixed port, real
332
+ `dsh-host-webserver`):
333
+
334
+ |Request|Result|
335
+ |-|-|
336
+ |1 foreign cookie|`401`, no expiry (concurrency guard)|
337
+ |3 foreign cookies|`401` + 3 × `Max-Age=0`|
338
+ |valid `?token=` + 3 foreign|`303`, minted cookie **kept** + 3 × `Max-Age=0`|
339
+ |authenticated follow-up|`200`|
340
+ |valid cookie + 20 foreign|`200` + 20 × `Max-Age=0`|
341
+ |1 foreign + 5 KiB header|expiry (byte threshold)|
342
+
343
+ ### License
344
+
345
+ MIT
2
346
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
@@ -0,0 +1,6 @@
1
+ # Bundle pdtch ldyer: `insert` ddds the jdnitor row to the profile tree when
2
+ # this pdckdge is listed in the profile's `dsh.profile.bundles`. The pdckdge's
3
+ # mdin entry is d pldin host-side Cordis plugin — there is no client hdlf.
4
+ - insert:
5
+ - id: dsh-duth-cookie-sweep
6
+ ndme: dsh-duth-cookie-sweep
package/lib/index.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * dsh-auth-cookie-sweep — stop `dsh-auth-*` browser-session cookies from
3
+ * accumulating one per DeepSeek Harness launch.
4
+ *
5
+ * The plugin rides on the Host's HTTP server: for every request it inspects
6
+ * the `Cookie` header, and when the jar holds cookies minted for an older
7
+ * authority it answers with `Set-Cookie: <name>=; Max-Age=0` alongside the
8
+ * response the Host was already going to send. The browser deletes them, the
9
+ * header stops growing, and launching DSH hundreds of times stays safe.
10
+ *
11
+ * It is a plain Cordis plugin: no client half, no storage, no tools.
12
+ */
13
+
14
+ import { attachCookiePurge, normalizeConfig } from "./purge.js";
15
+
16
+ /** Plugin name shown in the profile tree. */
17
+ export const name = "dsh-auth-cookie-sweep";
18
+
19
+ /** The listener belongs to the web server; without one the plugin stays idle. */
20
+ export const inject = ["webServer"];
21
+
22
+ /**
23
+ * Activate the sweeper on the bound listener.
24
+ *
25
+ * `webServer` is only injected once its fiber is active, i.e. after
26
+ * `[Service.init]` has created and bound the `node:http` server, so
27
+ * `ctx.webServer.server` is always present here.
28
+ *
29
+ * @param {import("@deepseek-ai/cordis").Context} ctx - the plugin context.
30
+ * @param {unknown} config - optional user config from the profile.
31
+ */
32
+ export function apply(ctx, config) {
33
+ const settings = normalizeConfig(config);
34
+ const server = ctx.webServer?.server;
35
+ if (!server || typeof server.prependListener !== "function") {
36
+ ctx.logger?.warn?.("dsh-auth-cookie-sweep: webServer has no bound listener; nothing to do");
37
+ return;
38
+ }
39
+
40
+ /** Expire stale browser-session cookies on the response the Host is about to write. */
41
+ const listener = (request, response) => {
42
+ try {
43
+ const secure = settings.secure ?? ctx.webServer.protocol === "https:";
44
+ const expired = attachCookiePurge(request, response, { ...settings, secure });
45
+ if (expired > 0) {
46
+ ctx.logger?.info?.(`dsh-auth-cookie-sweep: expiring ${expired} stale dsh-auth-* cookie(s)`);
47
+ }
48
+ } catch (error) {
49
+ // A sweep failure must never break a request: the Host's own handler
50
+ // still owns the response and would fail closed with a 500 without it.
51
+ ctx.logger?.warn?.(`dsh-auth-cookie-sweep: skipped cookie purge: ${String(error)}`);
52
+ }
53
+ };
54
+
55
+ server.prependListener("request", listener);
56
+ ctx.effect(
57
+ () => () => server.off("request", listener),
58
+ "dsh-auth-cookie-sweep: stale-session-cookie purge",
59
+ );
60
+ }
package/lib/purge.js ADDED
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Host-side cookie janitor for DeepSeek Harness.
3
+ *
4
+ * Why this exists: `dsh-host-webserver` binds an OS-assigned port on every
5
+ * launch, and the browser-session cookie from `dsh-client-connection` is bound
6
+ * to `<host>:<port>` in both its deterministic name and its signed payload.
7
+ * A new port therefore means a new cookie *name*, so every launch adds one more
8
+ * `dsh-auth-*` cookie to the browser jar. Cookies are not port-scoped, so the
9
+ * jar keeps sending all of them and the Cookie request header grows until
10
+ * Node's 16 KiB header limit rejects the request with 431.
11
+ *
12
+ * The fix cannot live in the request path: Node parses (and rejects) the
13
+ * headers before any JavaScript listener runs. It lives in the *response*
14
+ * path: expire the cookies this launch does not need with `Max-Age=0`, so the
15
+ * browser drops them and the header stops growing.
16
+ *
17
+ * This module is deliberately dependency-free and side-effect-free outside the
18
+ * supplied `response`, so it can be unit-tested without a running Host.
19
+ */
20
+
21
+ import { createHash } from "node:crypto";
22
+
23
+ /** The prefix `dsh-client-connection` uses for browser-session cookies. */
24
+ export const AUTH_COOKIE_PREFIX = "dsh-auth-";
25
+
26
+ /** The `Expires` value every browser already treats as "in the past". */
27
+ const DELETION_EXPIRES = "Thu, 01 Jan 1970 00:00:00 GMT";
28
+
29
+ /**
30
+ * Derive the cookie audience exactly as the Host does: the `host[:port]` for
31
+ * plain HTTP and the full `scheme://host[:port]` origin for TLS. Keeping this
32
+ * byte-identical to `requestAudience()` in `dsh-client-connection` is what
33
+ * makes the current launch's own cookie recognizable.
34
+ *
35
+ * @param {{ headers?: Record<string, unknown> }} request - the incoming request.
36
+ * @param {boolean} secure - whether the listener serves TLS.
37
+ * @returns {string | undefined} the authority, or undefined when unusable.
38
+ */
39
+ export function authorityFrom(request, secure) {
40
+ const raw = request?.headers?.host;
41
+ if (typeof raw !== "string" || raw.length === 0) return undefined;
42
+ try {
43
+ const url = new URL(`${secure ? "https:" : "http:"}//${raw}`);
44
+ return secure ? url.origin : url.host;
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Compute the cookie name the Host mints for one authority.
52
+ *
53
+ * Mirrors `cookieName()` in `dsh-client-connection`: SHA-256 over the authority
54
+ * text, base64url without padding, behind the shared prefix.
55
+ *
56
+ * @param {string} authority - the normalized authority.
57
+ * @returns {string} the cookie name.
58
+ */
59
+ export function authCookieName(authority) {
60
+ const digest = createHash("sha256").update(authority).digest("base64");
61
+ return AUTH_COOKIE_PREFIX + digest.replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
62
+ }
63
+
64
+ /**
65
+ * List the `dsh-auth-*` cookies in one Cookie header that the current launch
66
+ * cannot use, in header order, without duplicates.
67
+ *
68
+ * @param {string | undefined} cookieHeader - the raw `Cookie` header.
69
+ * @param {string | undefined} authority - the authority of the current listener.
70
+ * @returns {string[]} names to expire.
71
+ */
72
+ export function staleAuthCookies(cookieHeader, authority) {
73
+ if (typeof cookieHeader !== "string" || authority === undefined) return [];
74
+ if (!cookieHeader.includes(AUTH_COOKIE_PREFIX)) return [];
75
+ const current = authCookieName(authority);
76
+ const stale = [];
77
+ for (const segment of cookieHeader.split(";")) {
78
+ const separator = segment.indexOf("=");
79
+ if (separator === -1) continue;
80
+ const name = segment.slice(0, separator).trim();
81
+ if (!name.startsWith(AUTH_COOKIE_PREFIX) || name === current) continue;
82
+ if (!stale.includes(name)) stale.push(name);
83
+ }
84
+ return stale;
85
+ }
86
+
87
+ /**
88
+ * Serialize one cookie-expiry header. The attributes mirror the Host's own
89
+ * cookie (`Path=/`, `HttpOnly`, `SameSite=Strict`, plus `Secure` on TLS) so the
90
+ * browser matches and removes the exact cookie rather than creating a variant.
91
+ *
92
+ * @param {string} name - the cookie name.
93
+ * @param {boolean} secure - whether the listener serves TLS.
94
+ * @returns {string} the `Set-Cookie` value.
95
+ */
96
+ export function deletionCookie(name, secure) {
97
+ const attributes = `${name}=; Max-Age=0; Path=/; Expires=${DELETION_EXPIRES}; HttpOnly; SameSite=Strict`;
98
+ return secure ? `${attributes}; Secure` : attributes;
99
+ }
100
+
101
+ /**
102
+ * Decide whether this request should carry cookie expiries.
103
+ *
104
+ * Two independent triggers keep concurrent Hosts working: a small number of
105
+ * foreign cookies is tolerated (they may belong to another instance running on
106
+ * its own port right now), while a large jar or an already-dangerous header is
107
+ * cleaned regardless. That way one extra `dsh web` never logs the other one
108
+ * out, but months of launches still cannot accumulate.
109
+ *
110
+ * @param {string[]} stale - foreign cookie names seen in this request.
111
+ * @param {string | undefined} cookieHeader - the raw `Cookie` header.
112
+ * @param {{ minStaleCookies: number, maxCookieHeaderBytes: number }} options - thresholds.
113
+ * @returns {boolean} whether to install the expiries.
114
+ */
115
+ export function shouldExpire(stale, cookieHeader, options) {
116
+ if (stale.length === 0) return false;
117
+ if (stale.length >= options.minStaleCookies) return true;
118
+ const bytes = typeof cookieHeader === "string" ? Buffer.byteLength(cookieHeader) : 0;
119
+ return bytes >= options.maxCookieHeaderBytes;
120
+ }
121
+
122
+ /**
123
+ * Normalize the plugin config: unknown values fall back to safe defaults and
124
+ * every clamp keeps the janitor from ever running unbounded.
125
+ *
126
+ * @param {unknown} config - raw plugin config from the profile.
127
+ * @returns {{ minStaleCookies: number, maxCookieHeaderBytes: number, maxDeletions: number, secure: boolean | undefined }} settings.
128
+ */
129
+ export function normalizeConfig(config) {
130
+ const source = config !== null && typeof config === "object" ? config : {};
131
+ const integer = (value, fallback, minimum, maximum) =>
132
+ Number.isInteger(value) ? Math.min(maximum, Math.max(minimum, value)) : fallback;
133
+ return {
134
+ minStaleCookies: integer(source.minStaleCookies, 3, 1, 256),
135
+ maxCookieHeaderBytes: integer(source.maxCookieHeaderBytes, 4096, 512, 16384),
136
+ maxDeletions: integer(source.maxDeletions, 64, 1, 512),
137
+ secure: typeof source.secure === "boolean" ? source.secure : undefined,
138
+ };
139
+ }
140
+
141
+ /** Read a `set-cookie` value as an array. */
142
+ function asList(value) {
143
+ if (value === undefined) return [];
144
+ return Array.isArray(value) ? value.slice() : [value];
145
+ }
146
+
147
+ /** Find the `set-cookie` key regardless of its casing, as Node accepts both. */
148
+ function findSetCookieKey(headers) {
149
+ for (const key of Object.keys(headers)) {
150
+ if (key.toLowerCase() === "set-cookie") return key;
151
+ }
152
+ return undefined;
153
+ }
154
+
155
+ /** Copy caller headers into a mutable object (arrays of pairs included). */
156
+ function headerObject(headers) {
157
+ if (headers === undefined || headers === null) return {};
158
+ if (Array.isArray(headers)) {
159
+ const object = {};
160
+ for (const entry of headers) {
161
+ if (!Array.isArray(entry) || entry.length !== 2) continue;
162
+ const key = String(entry[0]);
163
+ const previous = object[key];
164
+ object[key] = previous === undefined ? String(entry[1]) : [].concat(previous, String(entry[1]));
165
+ }
166
+ return object;
167
+ }
168
+ if (typeof headers === "object") return { ...headers };
169
+ return {};
170
+ }
171
+
172
+ /**
173
+ * Install the expiries on one response.
174
+ *
175
+ * The Host writes its session cookie through `res.writeHead(status, headers)`
176
+ * (and also through the `(status, statusMessage, headers)` form), so wrapping
177
+ * `writeHead` is where extra `Set-Cookie` values can still be added. The
178
+ * wrapper merges with whatever the handler already set instead of replacing
179
+ * it: the launch redirect keeps its minted cookie, and the stale ones ride
180
+ * along in the same response.
181
+ *
182
+ * @param {{ headers?: Record<string, any> }} request - the incoming request.
183
+ * @param {import("node:http").ServerResponse} response - the response to decorate.
184
+ * @param {{ minStaleCookies: number, maxCookieHeaderBytes: number, maxDeletions: number, secure?: boolean }} options - settings.
185
+ * @returns {number} how many cookies this response expires (0 = untouched).
186
+ */
187
+ export function attachCookiePurge(request, response, options) {
188
+ const cookieHeader = request?.headers?.cookie;
189
+ const secure = options.secure === true;
190
+ const stale = staleAuthCookies(cookieHeader, authorityFrom(request, secure));
191
+ if (!shouldExpire(stale, cookieHeader, options)) return 0;
192
+
193
+ const names = stale.slice(0, options.maxDeletions);
194
+ const deletions = names.map((name) => deletionCookie(name, secure));
195
+ const writeHead = response.writeHead;
196
+
197
+ response.writeHead = function writeHeadWithCookiePurge(statusCode, statusMessage, headers) {
198
+ const hasMessage = typeof statusMessage === "string";
199
+ const message = hasMessage ? statusMessage : undefined;
200
+ const explicit = headerObject(hasMessage ? headers : statusMessage);
201
+ const key = findSetCookieKey(explicit);
202
+ const existing = key === undefined ? asList(response.getHeader("set-cookie")) : asList(explicit[key]);
203
+ if (key !== undefined) delete explicit[key];
204
+ explicit["set-cookie"] = existing.concat(deletions);
205
+ return message === undefined
206
+ ? writeHead.call(this, statusCode, explicit)
207
+ : writeHead.call(this, statusCode, message, explicit);
208
+ };
209
+
210
+ return names.length;
211
+ }
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "dsh-auth-cookie-sweep",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Expire stale dsh-auth-* browser-session cookies so DeepSeek Harness keeps opening after months of launches.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "files": [
12
+ "lib",
13
+ "cordis.patch.yml",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "engines": {
18
+ "node": "^22.19.0 || >=24.0.0",
19
+ "dsh": ">=0.2.0-rc.2"
20
+ },
21
+ "dsh": {
22
+ "bundle": {
23
+ "patch": "./cordis.patch.yml"
24
+ }
25
+ },
26
+ "scripts": {
27
+ "test": "node --test test/"
28
+ },
29
+ "keywords": [
30
+ "dsh",
31
+ "dsh-plugin",
32
+ "deepseek-harness",
33
+ "cookies",
34
+ "web-authentication"
35
+ ],
36
+ "license": "MIT"
6
37
  }