@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 +91 -1
- package/README.md +80 -15
- package/README.zh.md +65 -11
- package/cordis.patch.yml +25 -6
- package/lib/index.js +302 -57
- package/package.json +1 -1
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
|
-
## [
|
|
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**:
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
84
|
-
`ctx.logger` (the dsh process console)
|
|
85
|
-
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
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).
|
|
166
|
-
|
|
167
|
-
- **Discovery probes are untouched**
|
|
168
|
-
|
|
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
|
-
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
|
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
|
|
146
|
-
|
|
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
|
|
23
|
-
# via ctx.logger
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
# (
|
|
27
|
-
#
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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"
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
*
|
|
30
|
-
* -
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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.
|
|
39
|
-
*
|
|
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
|
|
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
|
|
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
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
116
|
-
* AsyncLocalStorage store. Async generators
|
|
117
|
-
* inherit the store as long as the generator body
|
|
118
|
-
* inside `als.run`, which is
|
|
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
|
|
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("[
|
|
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
|
|
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(
|
|
369
|
+
ctx.logger.warn("[%s] globalThis.fetch is unavailable; cannot inject %s", name, SESSION_HEADER);
|
|
157
370
|
return;
|
|
158
371
|
}
|
|
159
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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, {
|
|
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.
|
|
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",
|