@karoc/dsh-smoothly-opencode-session 0.1.0 → 0.1.1

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/CHANGELOG.md CHANGED
@@ -11,7 +11,97 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
11
  > `pnpm release:check` (also run by `prepublishOnly`) blocks publishing until
12
12
  > every item passes. See [CONTRIBUTING.md](CONTRIBUTING.md).
13
13
 
14
- ## [0.1.0] - 2026-09-14
14
+ ## [Unreleased]
15
+
16
+ ## [0.1.1] - 2026-09-19
17
+
18
+ ### Fixed
19
+
20
+ - **Security/correctness (defect R1) — host gate for the fetch injection.**
21
+ 0.1.0 attached the header to *any* host reached while a session stream was
22
+ active (proven: requests to `example.com` and `api.deepseek.com` received
23
+ `x-opencode-session`). Injection is now gated by an allow list of hosts
24
+ (`hosts`, default `['https://opencode.ai']`): protocol, host/subdomain and an
25
+ explicit port must match, unparsable URLs never match, and the gate is a
26
+ conjunction with the existing conditions (already-present header wins; no
27
+ store → nothing). A blocked `(provider, host)` pair warns once, and the
28
+ startup log prints the effective gate.
29
+ - **Teardown requests now run inside the store.** `withStore.return()` wrapped
30
+ the downstream iterator's `return` outside the store while `throw` ran
31
+ inside; both now run inside, so requests issued during streamed-response
32
+ teardown carry the header too (visible behavior change).
33
+ - **Teardown failures are no longer silent** (implementation-audit finding).
34
+ The same `return()` path swallowed every error thrown by the downstream
35
+ stream's teardown; `withStore` now reports it through an optional sink and
36
+ the plugin logs it (`ctx.logger.warn`, one line). Consumption still completes
37
+ so aborted streams stay robust — the failure is merely visible now.
38
+ - **release tooling — postpublish false failure.** `scripts/post-publish-check.mjs`
39
+ gave up after 60s of index polling and exited 1 even though npm had already
40
+ returned `PUT 200` for the upload (this package's own 0.1.0 release,
41
+ 2026-09-19: the version only became visible ~4 minutes after the upload).
42
+ The script now mirrors the fixed `dsh-proxy` implementation: ~5 minutes of
43
+ polling (3s × 100) with sparse progress lines, a package-document fallback
44
+ that treats "package live, version entry not indexed yet" as published
45
+ (exit 0 with a do-not-re-publish note), exit 1 only when the package
46
+ document is absent too, and dist-tag polling on the same cadence. Not part
47
+ of the published tarball (`scripts/` sits outside `files`).
48
+ - **documentation — release date.** The `[0.1.0]` entry was dated 2026-09-14
49
+ (the day the plugin was built and installed locally); the actual npm release
50
+ happened 2026-09-19, which is the date Keep a Changelog wants.
51
+ - **release tooling — grading, registry resolution and coverage** (B1-B7, then
52
+ hardened after the implementation audit). One canonical
53
+ `scripts/post-publish-check.mjs` now ships in every Smoothly plugin repo:
54
+ tarball verification fetches (20s timeout, `response.ok`, gzip magic) with a
55
+ `spawnSync` curl fallback for proxy-only networks; HTTP 404/other 4xx and a
56
+ non-gzip body are fatal after one retry, while 5xx/407, transport failures
57
+ and unreadable tar streams are never fatal; `EXPECTED` entries derive from
58
+ `package.json` `files`; the published `lib/index.js` is compared by sha256
59
+ (difference = warning). `dist-tags` is advisory only. The version document is
60
+ now reused from the polling round instead of being re-probed, and every
61
+ answered-but-unusable tarball response gets exactly one retry. `release-check`
62
+ resolves the registry base from the environment and npm config files (printed
63
+ with its source) and never reads a probe failure as "safe to publish":
64
+ a definite 404 is required, and
65
+ `DSH_RELEASE_ALLOW_REGISTRY_UNREACHABLE=1` is the deliberate override.
66
+ `scripts/test-post-publish.mjs` is an offline stub-registry fixture with 15
67
+ scenarios (including the fail-open branches: 503, 407, 403, truncated gzip,
68
+ a hanging endpoint, and a version document without a tarball URL) that assert
69
+ exit codes, warning text and tarball request counts. Neither script ships in
70
+ the tarball.
71
+
72
+ ### Added
73
+
74
+ - **`hosts`** — the host allow list (see Fixed above), including a documented
75
+ `['*']` escape hatch that disables the gate, start-up reporting of the
76
+ effective rules and of dropped entries, and a one-time warning naming both
77
+ ways out when a provider's stream targets a host outside the gate.
78
+ - **`debugRequests`** — request-level records written at the real fetch moment
79
+ (`kind`, `reason`, `host`, `provider`, `valueHash`, `valueLen`). Values are
80
+ reduced to a 12-hex SHA-256 prefix unless `debug: true` is also set. The
81
+ stream-level record now means "entered the injection flow" only; the
82
+ request-level records are what prove what was attached.
83
+ - **`discoveryFallback`** (default off) — a process-stable UUID for bare
84
+ discovery requests: only `GET` requests whose path ends in `/models` and
85
+ whose host passes the gate.
86
+ - **Integration test suite** (`tests/integration.test.ts`) driving the real
87
+ `apply()` through a fake cordis context: gate matrix, R1 regression for seven
88
+ URL shapes, discovery cross cases, concurrent-session interleaving,
89
+ same-session second stream, teardown requests, one-time warning, startup
90
+ logs, and a wire-level check against a real local HTTP server. Unit tests
91
+ cover `normalizeHosts` / `hostAllowed` exhaustively (IDN, trailing dot,
92
+ suffix attacks, ports, protocol mismatch, unparsable targets) and the
93
+ teardown-error sink (42 tests total).
94
+ - **README**: verifiable retirement procedure (read the version DSH actually
95
+ loads — pnpm's strict layout path — grep its `dist/`, determine whether the
96
+ injection is keyed on the provider id or the base URL, then decide).
97
+
98
+ ### Changed
99
+
100
+ - README negative guarantees reworded to be falsifiable: the gate applies to
101
+ the **initial** request URL; redirect targets are not re-gated (measured on
102
+ Node 24: undici keeps custom headers across origins).
103
+
104
+ ## [0.1.0] - 2026-09-19
15
105
 
16
106
  ### Added
17
107
 
package/README.md CHANGED
@@ -19,9 +19,10 @@ one upstream backend and keeps OpenCode's prompt cache warm across its turns
19
19
  retries and process restarts (by default the DSH session id itself — the
20
20
  same identity the official DeepSeek adapter already sends as
21
21
  `x-deepseek-harness-session-id`).
22
- - **Leaves everything else untouched**: other providers, requests that already
23
- carry the header, and requests without a session id pass through exactly as
24
- before.
22
+ - **Leaves everything else untouched**: a request whose **initial URL** is not
23
+ an allowed host is never modified (host gate, default `https://opencode.ai`),
24
+ as are requests that already carry the header, other providers, and calls
25
+ without a session id. See [hosts](#hosts).
25
26
 
26
27
  ## How it works
27
28
 
@@ -70,6 +71,18 @@ Provider route keys whose requests receive the header. Defaults to the pi-ai
70
71
  catalog ids `opencode` and `opencode-go`. If you serve OpenCode under a custom
71
72
  provider route key (e.g. `opencode-go-self` in `llm-pi-ai`), add that key.
72
73
 
74
+ ### hosts
75
+
76
+ The host gate: a request is only modified when its target host is allowed
77
+ (default `['https://opencode.ai']`, subdomains included). Entries are `host`
78
+ (implicitly `https`) or `scheme://host[:port]`; a leading `*.` is ignored; IDN
79
+ entries normalize to punycode; unusable entries are reported in the startup log
80
+ instead of being ignored silently. `['*']` disables the gate entirely — use it
81
+ only for a mirror you fully trust. If you reach OpenCode through a reverse
82
+ proxy or a mirror, list that host here (write the full scheme, and note that a
83
+ bare `host` entry means `https`) or the header will silently not be attached;
84
+ the plugin warns once per blocked provider/host pair.
85
+
73
86
  ### mode
74
87
 
75
88
  - `session-id` (default) — header value = the DSH session id of the model
@@ -80,11 +93,34 @@ provider route key (e.g. `opencode-go-self` in `llm-pi-ai`), add that key.
80
93
 
81
94
  ### debug / debugFile
82
95
 
83
- - `debug: true` — log every streamed call that receives the header via
84
- `ctx.logger` (the dsh process console).
85
- - `debugFile: <absolute path>` — append one JSON line
86
- (`{"ts","provider","model","session","header","value"}`) per streamed call
87
- that receives the header. Handy when the dsh console is not visible.
96
+ - `debug: true` — log every streamed call that **enters the injection flow**
97
+ via `ctx.logger` (the dsh process console), and reveal raw values in
98
+ request-level records.
99
+ - `debugFile: <absolute path>` — append one JSON line per streamed call
100
+ (`kind: "stream"`, with `provider`/`model`/`session`/`value`) and, when
101
+ `debugRequests` is on, per injected or host-blocked request
102
+ (`kind: "inject" | "skip"`). The stream-level record only means the call
103
+ entered the injection flow; the request-level records are what prove what was
104
+ actually attached. The file is append-only (no rotation; roughly one record
105
+ per injected request) and written fire-and-forget, so a short-lived process
106
+ can lose its tail.
107
+
108
+ ### debugRequests
109
+
110
+ `debugRequests: true` writes one request-level record **at the real fetch
111
+ moment**: `{"ts","kind","reason","host","provider","valueHash","valueLen"}`
112
+ with `reason` in `session` / `discovery` / `host-not-allowed` /
113
+ `already-present`. By default the value is reduced to a 12-hex-character
114
+ SHA-256 prefix; the raw value appears only when `debug: true` is set as well
115
+ (the file then contains session identifiers).
116
+
117
+ ### discoveryFallback
118
+
119
+ `discoveryFallback: true` (default off) attaches a **process-stable UUID** to
120
+ bare discovery requests that carry no session id: only `GET` requests whose
121
+ path ends in `/models` and whose host passes the gate. The Models page listing
122
+ works without this today; enable it only if your OpenCode endpoint starts
123
+ rejecting that listing.
88
124
 
89
125
  To override configuration in a profile without editing this package, add a
90
126
  patch entry with the **same id** to the profile's own `cordis.patch.yml` (it
@@ -138,18 +174,21 @@ restored on unload, so no other provider is affected.
138
174
  src/index.ts host plugin: llm/stream listener + fetch shim
139
175
  cordis.patch.yml bundle layer (inserts the plugin row with defaults)
140
176
  scripts/ release gate + post-publish verification
141
- tests/ node --test unit tests for the pure helpers
177
+ tests/ node --test unit + fake-cordis integration tests
142
178
  lib/ built output (npm package entry)
143
179
  ```
144
180
 
145
181
  ## Build & test
146
182
 
147
183
  ```sh
148
- pnpm install # installs dev deps (tsdown, @deepseek-ai/cordis types, @types/node)
149
- pnpm bundle # emits lib/index.js
184
+ npm install # installs dev deps (tsdown, @deepseek-ai/cordis types, @types/node)
185
+ npm run bundle # emits lib/index.js
150
186
  npm test # node --test tests/*.test.ts (runs TypeScript directly)
151
187
  ```
152
188
 
189
+ `pnpm` works too (`pnpm bundle`, `pnpm test`); this checkout documents the npm
190
+ path because pnpm is not always on PATH.
191
+
153
192
  ## Notes / limitations
154
193
 
155
194
  - **Scope:** only chat/streaming requests inside an `llm/stream` call receive
@@ -160,12 +199,38 @@ npm test # node --test tests/*.test.ts (runs TypeScript directly)
160
199
  a future DSH version swaps its network stack, the header silently stops
161
200
  being sent (the 400 comes back) — uninstall then. This is an external-plugin
162
201
  stopgap until the provider adapter itself (pi-ai) sends the header.
202
+ - **Redirects are not re-gated:** the gate applies to the initial request URL.
203
+ Node's fetch follows redirects and (measured on Node 24) keeps custom headers
204
+ across origins, so a request that starts on an allowed host and redirects
205
+ elsewhere carries the header to the redirect target.
163
206
  - **Not the official fix:** the DSH maintainers' position is that provider
164
207
  particularities belong in the pi-ai package (see discussion #5495 and
165
- earendil-works/pi #9326). Once that ships and DSH upgrades to it, this
166
- plugin can be removed.
167
- - **Discovery probes are untouched** (requests without a session id pass
168
- through).
208
+ earendil-works/pi #9326). This plugin is the stopgap until that ships — see
209
+ the retirement procedure below before removing it.
210
+ - **Discovery probes are untouched** unless `discoveryFallback` is enabled.
211
+
212
+ ### Retirement (verifiable procedure)
213
+
214
+ Do not retire this plugin on faith; verify, in this order:
215
+
216
+ 1. Read the version DSH actually loads — under pnpm's strict layout the real
217
+ path is `<dsh-root>/packages/llm/llm-pi-ai/node_modules/@earendil-works/pi-ai/package.json`
218
+ (a flat `<dsh-root>/node_modules/@earendil-works/pi-ai/package.json` is the
219
+ fallback; the package does not export `package.json`, so `require.resolve`
220
+ will not find it). If the path cannot be located, keep the plugin and
221
+ re-check after the next DSH upgrade.
222
+ 2. If that version's `dist/` contains `x-opencode-session`
223
+ (`grep -rl x-opencode-session <that package>/dist`), determine what its
224
+ injection is keyed on: the provider id (`opencode` / `opencode-go`) or the
225
+ base URL (`opencode.ai`).
226
+ 3. Outcomes: keyed on the base URL → this plugin is redundant, uninstall it;
227
+ keyed on the provider id only → a custom route such as `opencode-go-self` is
228
+ still not covered, so either keep the plugin or migrate that route to the
229
+ built-in `opencode-go` provider first.
230
+ 4. No new version / not found yet → keep the plugin, and re-run this procedure
231
+ after DSH bumps its `@earendil-works/pi-ai` dependency
232
+ (`npm view "@earendil-works/pi-ai@<declared-range>" version` reports the
233
+ newest version that range allows).
169
234
 
170
235
  ## License
171
236
 
package/README.zh.md CHANGED
@@ -16,7 +16,9 @@ OpenCode 的提示词缓存在该会话各轮之间保持命中(上游跟踪
16
16
  - **保住缓存/亲和收益**:取值按**会话**唯一、且在该会话各轮、压缩、重试与进程重启
17
17
  之间保持稳定(默认直接复用 DSH 会话 id——官方 DeepSeek 适配器已作为
18
18
  `x-deepseek-harness-session-id` 发送的同一个身份)。
19
- - **其余一概不动**:其他提供方、已自带该头的请求、以及没有会话 id 的请求,原样放行。
19
+ - **其余一概不动**:**初始 URL** 不在允许主机内的请求永不被修改(host 门控,默认
20
+ `https://opencode.ai`);已自带该头的请求、其他提供方、以及没有会话 id 的调用同样
21
+ 原样放行。见 [hosts](#hosts)。
20
22
 
21
23
  ## 工作原理
22
24
 
@@ -60,6 +62,15 @@ options 加头;插件改为:
60
62
  如果你用自定义提供方路由键提供 OpenCode(例如 `llm-pi-ai` 里的
61
63
  `opencode-go-self`),把该键加进来。
62
64
 
65
+ ### hosts
66
+
67
+ host 门控:只有目标主机在允许列表内(默认 `['https://opencode.ai']`,含子域)的请求
68
+ 才会被修改。条目可写 `host`(默认按 `https` 处理)或 `scheme://host[:port]`;开头的
69
+ `*.` 会被忽略;IDN 条目会规范化为 punycode;不可用条目会在启动日志里报告而不是被
70
+ 静默忽略。`['*']` 完全关闭门控——只对完全信任的镜像使用。如果你通过反向代理或镜像
71
+ 访问 OpenCode,必须把该主机写进来(写全 scheme;裸 `host` 条目意味着 `https`),
72
+ 否则头会静默不加;插件会按「提供方/host」对被挡下的情况各告警一次。
73
+
63
74
  ### mode
64
75
 
65
76
  - `session-id`(默认)——头值 = 该次模型调用的 DSH 会话 id。每会话唯一,
@@ -68,9 +79,26 @@ options 加头;插件改为:
68
79
 
69
80
  ### debug / debugFile
70
81
 
71
- - `debug: true`——把每个收到该头的流式调用经 `ctx.logger`(dsh 进程控制台)打日志。
72
- - `debugFile: <绝对路径>`——每个收到该头的流式调用向该文件追加一行 JSON
73
- (`{"ts","provider","model","session","header","value"}`)。dsh 控制台不可见时很有用。
82
+ - `debug: true`——把每个**进入注入流程**的流式调用经 `ctx.logger`(dsh 进程控制台)
83
+ 打日志,并在请求级记录里显示原始值。
84
+ - `debugFile: <绝对路径>`——每次流式调用追加一行 JSON(`kind: "stream"`,含
85
+ `provider`/`model`/`session`/`value`);开启 `debugRequests` 时,每个被注入或被
86
+ host 门控挡下的请求也各追加一行(`kind: "inject" | "skip"`)。流级记录只表示
87
+ "进入了注入流程",**只有请求级记录能证明实际是否带上了头**。该文件只追加、不轮转
88
+ (条数约等于注入请求数),且是 fire-and-forget——短命进程可能丢尾部记录。
89
+
90
+ ### debugRequests
91
+
92
+ `debugRequests: true` 在**真实 fetch 时刻**写入请求级记录:
93
+ `{"ts","kind","reason","host","provider","valueHash","valueLen"}`,`reason` 取
94
+ `session` / `discovery` / `host-not-allowed` / `already-present`。默认只写值的
95
+ 12 位十六进制 SHA-256 前缀;只有同时开 `debug: true` 才写原始值(此时文件含会话标识)。
96
+
97
+ ### discoveryFallback
98
+
99
+ `discoveryFallback: true`(默认关)会为一个**没有会话 id 的纯发现请求**附加
100
+ **进程内稳定 UUID**:仅限 `GET` 且路径以 `/models` 结尾、且 host 通过门控的请求。
101
+ 今天的 Models 页列表不需要它;只有当你的 OpenCode 端点开始拒绝该列表时才开启。
74
102
 
75
103
  想在不改本包的情况下按 profile 覆盖配置:在 profile 自己的 `cordis.patch.yml` 里加一条
76
104
  **同 id** 的 patch 条目(它整体替换 `config`,所以需要的键都要重写)。patch 条目用
@@ -121,29 +149,55 @@ dsh plugin --profile web remove @karoc/dsh-smoothly-opencode-session
121
149
  src/index.ts 宿主插件:llm/stream 监听 + fetch shim
122
150
  cordis.patch.yml bundle 层(插入带默认配置的插件行)
123
151
  scripts/ 发布门禁 + 发布后校验
124
- tests/ node --test 纯函数单元测试
152
+ tests/ node --test 单元测试 + fake cordis 集成测试
125
153
  lib/ 构建产物(npm 包入口)
126
154
  ```
127
155
 
128
156
  ## 构建与测试
129
157
 
130
158
  ```sh
131
- pnpm install # 安装开发依赖(tsdown、@deepseek-ai/cordis 类型、@types/node)
132
- pnpm bundle # 产出 lib/index.js
159
+ npm install # 安装开发依赖(tsdown、@deepseek-ai/cordis 类型、@types/node)
160
+ npm run bundle # 产出 lib/index.js
133
161
  npm test # node --test tests/*.test.ts(直接运行 TypeScript)
134
162
  ```
135
163
 
164
+ `pnpm` 同样可用(`pnpm bundle`、`pnpm test`);本仓库文档写 npm 路径,因为 pnpm 不总在
165
+ PATH 上。
166
+
136
167
  ## 说明 / 限制
137
168
 
138
169
  - **范围**:只有 `llm/stream` 调用内的聊天/流式请求会收到该头。Models 页使用的一次性
139
- 模型列表(`GET <baseURL>/models`)是独立流程,不会收到该头;如果你的 OpenCode
140
- 端点连那个列表也拒绝,那是另一个问题。
170
+ 模型列表(`GET <baseURL>/models`)是独立流程,默认不会收到该头(除非开启
171
+ `discoveryFallback`)。
172
+ - **重定向不再过门控**:门控判定的是**初始请求 URL**。Node 的 fetch 会跟随重定向,且
173
+ (在 Node 24 实测)跨 origin 保留自定义头——因此从允许主机开始、再跳到别处的请求,
174
+ 会把该头带到重定向目标。
141
175
  - **实现依赖**:注入依赖 Node 的全局 `fetch`。如果未来某个 DSH 版本换掉网络栈,
142
176
  该头会静默不再发送(400 复现)——届时卸载即可。这是提供方适配器(pi-ai)自身
143
177
  发送该头之前的过渡方案。
144
178
  - **不是官方修复**:DSH 维护者的立场是提供方特殊性应归 pi-ai 包处理(见 discussion
145
- #5495 与 earendil-works/pi #9326)。等它发版并被 DSH 升级后,本插件即可移除。
146
- - **发现探测不受影响**(没有会话 id 的请求原样放行)。
179
+ #5495 与 earendil-works/pi #9326)。本插件是它上线前的过渡方案——移除前请先走下面的
180
+ 退役流程。
181
+ - **发现探测默认不受影响**(没有会话 id 的请求原样放行)。
182
+
183
+ ### 退役(可核验流程)
184
+
185
+ 不要凭感觉退役本插件,按顺序核验:
186
+
187
+ 1. 读 DSH **实际加载**的版本——pnpm 严格布局下真实路径是
188
+ `<dsh 根>/packages/llm/llm-pi-ai/node_modules/@earendil-works/pi-ai/package.json`
189
+ (扁平布局 `<dsh 根>/node_modules/@earendil-works/pi-ai/package.json` 是备选;该包
190
+ 不导出 `package.json`,所以 `require.resolve` 找不到它)。定位不到就保持插件,
191
+ 等下次 DSH 升级后再查。
192
+ 2. 若该版本 `dist/` 里含 `x-opencode-session`
193
+ (`grep -rl x-opencode-session <该包>/dist`),判断它的注入是**按提供方 id**
194
+ (`opencode` / `opencode-go`)还是**按 base URL**(`opencode.ai`)触发。
195
+ 3. 结局:按 base URL → 本插件已冗余,卸载;只按提供方 id → 自定义路由
196
+ `opencode-go-self` 仍不被覆盖,要么继续用本插件,要么先把该路由迁到内置
197
+ `opencode-go`。
198
+ 4. 没有新版本 / 仍未命中 → 继续用本插件;等 DSH 升了 `@earendil-works/pi-ai` 依赖后
199
+ 重跑本流程(`npm view "@earendil-works/pi-ai@<声明的范围>" version` 可查该范围允许的
200
+ 最新版本)。
147
201
 
148
202
  ## 许可证
149
203
 
package/cordis.patch.yml CHANGED
@@ -11,6 +11,13 @@
11
11
  # ['opencode', 'opencode-go']. Add your own route key
12
12
  # when OpenCode is served under a custom provider name
13
13
  # (e.g. opencode-go-self).
14
+ # hosts: [string] host gate. Entries are `host` (implicitly https) or
15
+ # `scheme://host[:port]`; a leading `*.` is ignored.
16
+ # Defaults to ['https://opencode.ai'] (subdomains
17
+ # included). `['*']` DISABLES the gate (any host gets
18
+ # the header while a session stream is active) — use
19
+ # only for a mirror you fully trust. Unusable entries
20
+ # are reported in the startup log.
14
21
  # mode: 'session-id' | 'uuid'
15
22
  # 'session-id' (default): header value = the DSH
16
23
  # session id that already travels with each model call
@@ -19,12 +26,24 @@
19
26
  # 'uuid': a process-stable random uuid derived per DSH
20
27
  # session id (opaque; resets when the process
21
28
  # restarts).
22
- # debug: true|false log every streamed call that receives the header
23
- # via ctx.logger (the dsh process console).
24
- # debugFile: path optional absolute path; when set, every streamed
25
- # call that receives the header appends one JSON line
26
- # ({"ts","provider","model","session","header","value"})
27
- # to that file handy when the dsh console is hidden.
29
+ # debug: true|false log every streamed call that ENTERS the injection
30
+ # flow via ctx.logger, and reveal raw values in
31
+ # request-level records.
32
+ # debugFile: path optional absolute path; each streamed call appends
33
+ # one JSON line (kind:"stream"), and when
34
+ # debugRequests is on, each injected or host-blocked
35
+ # request appends one too (kind:"inject"|"skip").
36
+ # Append-only, no rotation. Values are hashed unless
37
+ # debug is also true.
38
+ # debugRequests: true|false
39
+ # write one request-level record at the real fetch
40
+ # moment ({"ts","kind","reason","host","provider",
41
+ # "valueHash","valueLen"}). This is what proves whether
42
+ # the header was actually attached.
43
+ # discoveryFallback: true|false
44
+ # (default false) inject a process-stable UUID into bare
45
+ # discovery requests: only GET whose path ends in
46
+ # /models and whose host passes the gate.
28
47
  - insert:
29
48
  - id: dsh-smoothly-opencode-session
30
49
  name: '@karoc/dsh-smoothly-opencode-session'
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import { randomUUID } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import { appendFile } from "node:fs/promises";
4
4
  //#region src/index.ts
5
5
  /**
@@ -8,42 +8,49 @@ import { appendFile } from "node:fs/promises";
8
8
  * External DeepSeek Harness HOST plugin: attaches the OpenCode-required
9
9
  * `x-opencode-session` request header to model calls routed to OpenCode /
10
10
  * OpenCode Go provider routes. Since 2026-09-05 OpenCode's relay rejects any
11
- * inference request that lacks the header (400 MissingSessionID); the value
12
- * is a stable per-conversation id, which is also what pins a conversation to
13
- * one upstream backend and keeps the prompt cache warm across its turns.
11
+ * inference request that lacks the header (400 MissingSessionID); the value is
12
+ * a stable per-conversation id, which is also what pins a conversation to one
13
+ * upstream backend and keeps the prompt cache warm across its turns.
14
14
  *
15
15
  * Mechanism (each piece verified against the official contracts):
16
16
  * - `llm/stream` is a documented waterfall ("Waterfall around every
17
- * streaming model call", api-catalog), signature
18
- * `(options: GenerateOptions, next: () => AsyncIterable<StreamChunk>)`.
19
- * LOOP-built options are deep-frozen: listeners read, never rewrite.
20
- * - The header therefore cannot be added by mutating options. This plugin
21
- * observes provider + sessionId at the waterfall, then drives the
22
- * downstream stream's pulls inside an AsyncLocalStorage store while one
23
- * patched `globalThis.fetch` merges the header onto the outbound request
24
- * (unless it already carries one an existing value always wins).
25
- * - Registrations are fiber-scoped (ctx.on listener + ctx.effect disposer),
26
- * so stopping / updating / unloading the plugin restores the original
27
- * fetch and removes the listener.
17
+ * streaming model call"); LOOP-built options are deep-frozen, so listeners
18
+ * read, never rewrite. This plugin observes provider + sessionId there,
19
+ * drives the downstream stream's pulls inside an AsyncLocalStorage store,
20
+ * and merges the header in one patched `globalThis.fetch`.
21
+ * - Injection is gated by a HOST ALLOW LIST (default `https://opencode.ai`):
22
+ * a request is modified only when it is (a) inside a matched session
23
+ * stream and (b) addressed at an allowed host. A request that already
24
+ * carries the header is never modified (an existing value always wins),
25
+ * and a request whose URL cannot be parsed is never modified.
26
+ * - Both registrations are fiber-scoped (`ctx.on` listener + `ctx.effect`
27
+ * disposer), so stopping / updating / unloading the plugin restores the
28
+ * original `fetch` and removes the listener.
28
29
  *
29
- * Scope notes / honest limitations:
30
- * - Requests NOT routed to a configured OpenCode provider, or carrying no
31
- * session id (some auxiliary hand-built calls), pass through untouched.
32
- * - The header attaches to chat/streaming requests inside an `llm/stream`
33
- * call. The one-shot model listing used by the Models page
34
- * (`GET <baseURL>/models`) is a separate flow and does not receive the
35
- * header.
30
+ * Negative guarantees (what this plugin does NOT do):
31
+ * - A request whose INITIAL url is not an allowed host is never modified.
32
+ * Redirect targets are NOT re-gated: Node's fetch follows redirects and
33
+ * (measured on Node 24) keeps custom headers across origins, so a request
34
+ * that starts on an allowed host and redirects elsewhere carries the
35
+ * header to the redirect target.
36
+ * - The one-shot model listing used by the Models page
37
+ * (`GET <baseURL>/models`) carries no session id; it is untouched unless
38
+ * `discoveryFallback` is explicitly enabled, in which case ONLY `GET`
39
+ * requests whose path ends in `/models` receive a process-stable fallback
40
+ * UUID.
36
41
  * - Injection rides on Node's global `fetch`. If a future dsh version swaps
37
42
  * its network stack, the header silently stops being sent (the 400 comes
38
- * back) — uninstall then. This is an external-plugin stopgap until the
39
- * provider adapter itself (pi-ai) sends the header.
43
+ * back) — uninstall then.
44
+ *
45
+ * This is an external-plugin stopgap: the long-term fix belongs in the
46
+ * provider adapter (pi-ai). See the README "Notes / limitations" section for
47
+ * the verifiable retirement procedure.
40
48
  */
41
49
  /** Plugin display metadata (cordis diagnostics). */
42
50
  const name = "dsh-smoothly-opencode-session";
43
51
  /**
44
52
  * The `llm/stream` waterfall lives on the abstract `llm` service (dsh-llm).
45
- * Injecting it keeps this plugin PENDING until that service exists, so the
46
- * waterfall is already registered by its provider when we listen.
53
+ * Injecting it keeps this plugin PENDING until that service exists.
47
54
  */
48
55
  const inject = ["llm"];
49
56
  const SESSION_HEADER = "x-opencode-session";
@@ -54,21 +61,125 @@ const SESSION_HEADER = "x-opencode-session";
54
61
  * `opencode-go-self`) add it through config.
55
62
  */
56
63
  const DEFAULT_PROVIDERS = ["opencode", "opencode-go"];
64
+ /** Default host gate: OpenCode's own endpoints (subdomains included). */
65
+ const DEFAULT_HOSTS = ["https://opencode.ai"];
66
+ /** Normalize one configured host entry; undefined when unusable. */
67
+ function ruleFromEntry(entry) {
68
+ const stripped = entry.startsWith("*.") ? entry.slice(2) : entry;
69
+ const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(stripped) ? stripped : `https://${stripped}`;
70
+ try {
71
+ const url = new URL(candidate);
72
+ const host = url.hostname.toLowerCase().replace(/\.$/, "");
73
+ if (host === "") return void 0;
74
+ return url.port === "" ? {
75
+ protocol: url.protocol,
76
+ host
77
+ } : {
78
+ protocol: url.protocol,
79
+ host,
80
+ port: url.port
81
+ };
82
+ } catch {
83
+ return;
84
+ }
85
+ }
86
+ const DEFAULT_POLICY = {
87
+ rules: [ruleFromEntry(DEFAULT_HOSTS[0])],
88
+ wildcard: false
89
+ };
90
+ /**
91
+ * Normalize the configured host entries:
92
+ * - absent/empty → the default rule;
93
+ * - `*` → the gate is disabled (wildcard);
94
+ * - `*.example.com` → `example.com` (subdomains match anyway);
95
+ * - `host` without a scheme → `https://host`;
96
+ * - IDN entries normalize to punycode and trailing dots are dropped (via URL);
97
+ * - unusable entries are REPORTED (`dropped`) rather than silently ignored;
98
+ * - if every provided entry is unusable, the default rule applies.
99
+ */
100
+ function normalizeHosts(input) {
101
+ const entries = Array.isArray(input) && input.length > 0 ? input : DEFAULT_HOSTS;
102
+ const dropped = [];
103
+ const rules = [];
104
+ let wildcard = false;
105
+ for (const raw of entries) {
106
+ const entry = String(raw).trim();
107
+ if (entry === "") continue;
108
+ if (entry === "*") {
109
+ wildcard = true;
110
+ continue;
111
+ }
112
+ const rule = ruleFromEntry(entry);
113
+ if (rule === void 0) dropped.push(entry);
114
+ else rules.push(rule);
115
+ }
116
+ if (wildcard) return {
117
+ policy: {
118
+ rules: [],
119
+ wildcard: true
120
+ },
121
+ dropped
122
+ };
123
+ if (rules.length === 0) return {
124
+ policy: DEFAULT_POLICY,
125
+ dropped
126
+ };
127
+ return {
128
+ policy: {
129
+ rules,
130
+ wildcard: false
131
+ },
132
+ dropped
133
+ };
134
+ }
135
+ /**
136
+ * Whether a request target passes the host gate. Protocol must match the rule;
137
+ * the host must equal the rule host or be a subdomain of it; an explicit port
138
+ * must match. An unparsable target never passes; `wildcard` passes everything.
139
+ */
140
+ function hostAllowed(target, policy) {
141
+ if (policy.wildcard) return true;
142
+ let url;
143
+ try {
144
+ url = target instanceof URL ? target : new URL(String(target));
145
+ } catch {
146
+ return false;
147
+ }
148
+ const host = url.hostname.toLowerCase().replace(/\.$/, "");
149
+ if (host === "") return false;
150
+ for (const rule of policy.rules) {
151
+ if (rule.protocol !== url.protocol) continue;
152
+ if (host !== rule.host && !host.endsWith(`.${rule.host}`)) continue;
153
+ if (rule.port !== void 0 && rule.port !== url.port) continue;
154
+ return true;
155
+ }
156
+ return false;
157
+ }
57
158
  /** Resolve row config against code defaults (missing keys are never required). */
58
159
  function resolveConfig(config = {}) {
59
160
  const providers = Array.isArray(config.providers) && config.providers.length > 0 ? config.providers.map((value) => String(value)) : [...DEFAULT_PROVIDERS];
161
+ const { policy, dropped } = normalizeHosts(config.hosts);
60
162
  return {
61
163
  providers: new Set(providers),
164
+ hosts: policy,
165
+ droppedHosts: dropped,
62
166
  mode: config.mode === "uuid" ? "uuid" : "session-id",
63
167
  debug: config.debug === true,
64
- ...typeof config.debugFile === "string" && config.debugFile.length > 0 ? { debugFile: config.debugFile } : {}
168
+ ...typeof config.debugFile === "string" && config.debugFile.length > 0 ? { debugFile: config.debugFile } : {},
169
+ debugRequests: config.debugRequests === true,
170
+ discoveryFallback: config.discoveryFallback === true
65
171
  };
66
172
  }
173
+ /** Human-readable description of the effective host gate (startup log). */
174
+ function describeHosts(policy) {
175
+ if (policy.wildcard) return "* (gate DISABLED)";
176
+ return policy.rules.map((rule) => `${rule.protocol}//${rule.host}${rule.port === void 0 ? "" : `:${rule.port}`}`).join(", ");
177
+ }
67
178
  /**
68
179
  * Derive the opaque header value for one DSH session id.
69
180
  * `session-id` mode returns the raw id (unique per conversation, stable across
70
181
  * turns, compaction, retries and restarts); `uuid` mode returns a process-
71
- * stable random uuid derived once per session id (opaque, resets on restart).
182
+ * stable random uuid derived once per session id.
72
183
  */
73
184
  function headerValueFor(sessionId, mode, table) {
74
185
  const raw = String(sessionId);
@@ -91,33 +202,129 @@ function hasSessionHeader(input, init) {
91
202
  return false;
92
203
  }
93
204
  }
205
+ /** Short, non-reversible fingerprint of an injected value. */
206
+ function hashValue(value) {
207
+ return createHash("sha256").update(value).digest("hex").slice(0, 12);
208
+ }
209
+ /** The request URL as text, when derivable from the fetch input. */
210
+ function requestUrl(input) {
211
+ if (typeof input === "string") return input;
212
+ if (typeof URL !== "undefined" && input instanceof URL) return input.href;
213
+ if (typeof Request !== "undefined" && input instanceof Request) return input.url;
214
+ }
215
+ /** The request host (lowercase, no trailing dot), when the URL is parsable. */
216
+ function requestHost(input) {
217
+ const raw = requestUrl(input);
218
+ if (raw === void 0) return void 0;
219
+ try {
220
+ const host = new URL(raw).hostname.toLowerCase().replace(/\.$/, "");
221
+ return host === "" ? void 0 : host;
222
+ } catch {
223
+ return;
224
+ }
225
+ }
226
+ /** The effective request method (init wins, then a Request's own method). */
227
+ function requestMethod(input, init) {
228
+ if (init?.method !== void 0) return String(init.method).toUpperCase();
229
+ if (typeof Request !== "undefined" && input instanceof Request) return input.method.toUpperCase();
230
+ return "GET";
231
+ }
232
+ /** Request headers to merge into, honoring fetch's own precedence rules. */
233
+ function baseHeaders(input, init) {
234
+ return new Headers(init?.headers ?? (typeof Request !== "undefined" && input instanceof Request ? input.headers : void 0));
235
+ }
94
236
  /**
95
- * Build a patched fetch that injects the header while a store is active.
96
- * Header precedence mirrors native fetch: when `init.headers` is present it
97
- * wins; otherwise a Request's own headers are the base. A request that already
98
- * carries the header is never modified (an existing value always wins).
237
+ * Build a patched fetch. Decision order (the gate is a conjunction, never a
238
+ * bypass of the existing semantics):
239
+ * 1. the request already carries the header untouched (existing value wins);
240
+ * 2. inside a session stream inject only when the host gate allows it;
241
+ * 3. no store + discoveryFallback → inject the fallback value only for `GET`
242
+ * requests whose path ends in `/models` and whose host is allowed;
243
+ * 4. anything else → untouched.
99
244
  */
100
- function patchFetch(original, als) {
245
+ function patchFetch(original, als, options = {}) {
246
+ const policy = options.hosts ?? DEFAULT_POLICY;
101
247
  return function patchedFetch(input, init) {
248
+ const passthrough = () => original.apply(this, [input, init]);
249
+ const host = requestHost(input);
250
+ if (hasSessionHeader(input, init)) {
251
+ const state = als.getStore();
252
+ if (state !== void 0) options.trace?.({
253
+ kind: "skip",
254
+ reason: "already-present",
255
+ host,
256
+ provider: state.provider
257
+ });
258
+ return passthrough();
259
+ }
102
260
  const state = als.getStore();
103
- if (state !== void 0 && !hasSessionHeader(input, init)) {
104
- const headers = new Headers(init?.headers ?? (typeof Request !== "undefined" && input instanceof Request ? input.headers : void 0));
261
+ if (state !== void 0) {
262
+ if (!hostAllowed(requestUrl(input) ?? "", policy)) {
263
+ options.onHostBlocked?.(state.provider, host ?? "(unparsable)");
264
+ options.trace?.({
265
+ kind: "skip",
266
+ reason: "host-not-allowed",
267
+ host,
268
+ provider: state.provider
269
+ });
270
+ return passthrough();
271
+ }
272
+ const headers = baseHeaders(input, init);
105
273
  headers.set(SESSION_HEADER, state.value);
274
+ options.trace?.({
275
+ kind: "inject",
276
+ reason: "session",
277
+ host,
278
+ provider: state.provider,
279
+ valueHash: hashValue(state.value),
280
+ valueLen: state.value.length,
281
+ ...options.revealValues === true ? { value: state.value } : {}
282
+ });
106
283
  return original.call(this, input, {
107
284
  ...init,
108
285
  headers
109
286
  });
110
287
  }
111
- return original.apply(this, arguments);
288
+ if (options.discoveryFallback === true && options.discoveryValue !== void 0) {
289
+ const raw = requestUrl(input);
290
+ let path;
291
+ try {
292
+ path = raw === void 0 ? void 0 : new URL(raw).pathname;
293
+ } catch {
294
+ path = void 0;
295
+ }
296
+ if (requestMethod(input, init) === "GET" && path !== void 0 && path.endsWith("/models") && hostAllowed(raw ?? "", policy)) {
297
+ const headers = baseHeaders(input, init);
298
+ headers.set(SESSION_HEADER, options.discoveryValue);
299
+ options.trace?.({
300
+ kind: "inject",
301
+ reason: "discovery",
302
+ host,
303
+ valueHash: hashValue(options.discoveryValue),
304
+ valueLen: options.discoveryValue.length,
305
+ ...options.revealValues === true ? { value: options.discoveryValue } : {}
306
+ });
307
+ return original.call(this, input, {
308
+ ...init,
309
+ headers
310
+ });
311
+ }
312
+ }
313
+ return passthrough();
112
314
  };
113
315
  }
114
316
  /**
115
- * Wrap a downstream async iterable so every pull executes inside an
116
- * AsyncLocalStorage store. Async generators and the promises they create
117
- * inherit the store as long as the generator body is driven from a pull made
118
- * inside `als.run`, which is exactly what this wrapper does per `next()`.
317
+ * Wrap a downstream async iterable so every operation `next`, `return` and
318
+ * `throw` alike — executes inside an AsyncLocalStorage store. Async generators
319
+ * and the promises they create inherit the store as long as the generator body
320
+ * is driven from a call made inside `als.run`, which is what this wrapper does
321
+ * per operation (so teardown-time requests are covered too).
322
+ *
323
+ * A teardown (`return`) failure is reported through `onTeardownError` instead
324
+ * of being silently discarded: an aborted stream may legitimately already be
325
+ * torn down, so consumption still completes, but the failure is never invisible.
119
326
  */
120
- function withStore(iterable, store, als) {
327
+ function withStore(iterable, store, als, onTeardownError) {
121
328
  const iterator = typeof iterable[Symbol.asyncIterator] === "function" ? iterable[Symbol.asyncIterator]() : iterable;
122
329
  return {
123
330
  [Symbol.asyncIterator]() {
@@ -128,8 +335,12 @@ function withStore(iterable, store, als) {
128
335
  },
129
336
  async return(value) {
130
337
  if (typeof iterator.return === "function") try {
131
- return await iterator.return(value);
132
- } catch {}
338
+ return await als.run(store, () => iterator.return(value));
339
+ } catch (error) {
340
+ try {
341
+ onTeardownError?.(error);
342
+ } catch {}
343
+ }
133
344
  return {
134
345
  done: true,
135
346
  value
@@ -141,35 +352,63 @@ function withStore(iterable, store, als) {
141
352
  }
142
353
  };
143
354
  }
144
- /** Fire-and-forget append of one debug record; failures only log a warning. */
355
+ /** Fire-and-forget append of one JSON record; failures only log a warning. */
145
356
  function recordDebug(ctx, file, entry) {
146
357
  appendFile(file, `${JSON.stringify(entry)}\n`, "utf8").catch((error) => {
147
- ctx.logger.warn("[dsh-smoothly-opencode-session] debugFile write failed: %s", error?.message ?? String(error));
358
+ ctx.logger.warn("[%s] debugFile write failed: %s", name, error?.message ?? String(error));
148
359
  });
149
360
  }
150
361
  function apply(ctx, config = {}) {
151
- const { providers, mode, debug, debugFile } = resolveConfig(config);
362
+ const resolved = resolveConfig(config);
152
363
  const als = new AsyncLocalStorage();
153
364
  const uuidBySession = /* @__PURE__ */ new Map();
365
+ const discoveryValue = randomUUID();
366
+ const blockedPairs = /* @__PURE__ */ new Set();
154
367
  const originalFetch = globalThis.fetch;
155
368
  if (typeof originalFetch !== "function") {
156
- ctx.logger.warn(`[${name}] globalThis.fetch is unavailable; cannot inject ${SESSION_HEADER}`);
369
+ ctx.logger.warn("[%s] globalThis.fetch is unavailable; cannot inject %s", name, SESSION_HEADER);
157
370
  return;
158
371
  }
159
- const patched = patchFetch(originalFetch, als);
372
+ const trace = (record) => {
373
+ if (!resolved.debugRequests && resolved.debugFile === void 0) return;
374
+ const entry = {
375
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
376
+ ...record
377
+ };
378
+ if (resolved.debugFile !== void 0) recordDebug(ctx, resolved.debugFile, entry);
379
+ if (resolved.debugRequests) ctx.logger.info("[%s] %s %s (%s)%s", name, record.kind, record.host ?? "(no host)", record.reason, record.provider === void 0 ? "" : ` provider=${record.provider}`);
380
+ };
381
+ const patched = patchFetch(originalFetch, als, {
382
+ hosts: resolved.hosts,
383
+ discoveryFallback: resolved.discoveryFallback,
384
+ discoveryValue,
385
+ trace,
386
+ revealValues: resolved.debug,
387
+ onHostBlocked: (provider, host) => {
388
+ const key = `${provider}|${host}`;
389
+ if (blockedPairs.has(key)) return;
390
+ blockedPairs.add(key);
391
+ ctx.logger.warn("[%s] provider \"%s\" stream targeted a host outside the gate (%s); header NOT attached. Add it to `hosts` (write the full scheme, e.g. https://mirror.example) or set hosts: [\"*\"] to disable the gate.", name, provider, host);
392
+ }
393
+ });
160
394
  ctx.effect(() => {
161
395
  globalThis.fetch = patched;
162
- ctx.logger.info("[%s] active for providers [%s] with mode %s", name, [...providers].join(", "), mode);
396
+ ctx.logger.info("[%s] active for providers [%s] with mode %s", name, [...resolved.providers].join(", "), resolved.mode);
397
+ ctx.logger.info("[%s] host gate: %s", name, describeHosts(resolved.hosts));
398
+ if (resolved.hosts.wildcard) ctx.logger.warn("[%s] host gate is DISABLED (hosts: [\"*\"]) — while a session stream is active, ANY host receives the header", name);
399
+ if (resolved.droppedHosts.length > 0) ctx.logger.warn("[%s] ignored unusable hosts entries: %s", name, resolved.droppedHosts.join(", "));
400
+ if (resolved.discoveryFallback) ctx.logger.info("[%s] discoveryFallback is ON: GET .../models requests get a process-stable id", name);
163
401
  return () => {
164
402
  if (globalThis.fetch === patched) globalThis.fetch = originalFetch;
165
403
  };
166
404
  }, `${name}.fetch-patch`);
167
405
  ctx.on("llm/stream", (options, next) => {
168
406
  if (options === void 0 || options === null || typeof options !== "object") return next();
169
- if (!providers.has(String(options.provider))) return next();
407
+ const provider = String(options.provider);
408
+ if (!resolved.providers.has(provider)) return next();
170
409
  const sessionId = options.sessionId;
171
410
  if (sessionId === void 0 || sessionId === null) return next();
172
- const value = headerValueFor(String(sessionId), mode, uuidBySession);
411
+ const value = headerValueFor(String(sessionId), resolved.mode, uuidBySession);
173
412
  if (value === void 0) return next();
174
413
  let downstream;
175
414
  try {
@@ -179,19 +418,25 @@ function apply(ctx, config = {}) {
179
418
  }
180
419
  if (downstream === void 0 || downstream === null) return downstream;
181
420
  if (typeof downstream[Symbol.asyncIterator] !== "function") return downstream;
182
- if (debug || debugFile !== void 0) {
421
+ if (resolved.debug || resolved.debugFile !== void 0) {
183
422
  const entry = {
184
423
  ts: (/* @__PURE__ */ new Date()).toISOString(),
185
- provider: options.provider,
424
+ kind: "stream",
425
+ provider,
186
426
  model: options.model,
187
427
  session: String(sessionId),
188
428
  header: SESSION_HEADER,
189
429
  value
190
430
  };
191
- if (debugFile !== void 0) recordDebug(ctx, debugFile, entry);
192
- if (debug) ctx.logger.info("[%s] streaming provider \"%s\" with %s=%s", name, options.provider, SESSION_HEADER, value);
431
+ if (resolved.debugFile !== void 0) recordDebug(ctx, resolved.debugFile, entry);
432
+ if (resolved.debug) ctx.logger.info("[%s] streaming provider \"%s\" with %s=%s", name, provider, SESSION_HEADER, value);
193
433
  }
194
- return withStore(downstream, { value }, als);
434
+ return withStore(downstream, {
435
+ value,
436
+ provider
437
+ }, als, (error) => {
438
+ ctx.logger.warn("[%s] teardown of provider \"%s\" stream failed: %s", name, provider, String(error?.message ?? error).split("\n")[0]);
439
+ });
195
440
  }, { prepend: true });
196
441
  }
197
442
  var src_default = {
@@ -200,4 +445,4 @@ var src_default = {
200
445
  apply
201
446
  };
202
447
  //#endregion
203
- export { apply, src_default as default, hasSessionHeader, headerValueFor, inject, name, patchFetch, resolveConfig, withStore };
448
+ export { apply, src_default as default, describeHosts, hasSessionHeader, headerValueFor, hostAllowed, inject, name, normalizeHosts, patchFetch, resolveConfig, withStore };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karoc/dsh-smoothly-opencode-session",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Smoothly OpenCode Session (Smoothly OCS) — 思磨力 OpenCode 会话头: external DeepSeek Harness host plugin that attaches the OpenCode-required x-opencode-session header to model calls routed to OpenCode / OpenCode Go provider routes (stable per-conversation id; fixes 400 MissingSessionID, in effect for OpenCode Go since 2026-09-05).",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",