dsh-instruction-memory 1.0.15 → 1.0.16
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 +53 -0
- package/README.md +14 -6
- package/lib/client.js +30 -2
- package/lib/index.js +194 -32
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,59 @@
|
|
|
7
7
|
> 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
|
|
8
8
|
> 这些历史 tag **不应回填**——npm 上的版本号已被占用。
|
|
9
9
|
|
|
10
|
+
## [1.0.16] — 2026-10-05
|
|
11
|
+
|
|
12
|
+
一次定向缺陷修复 + 宿主范围扩展。每项修复都有对应回归断言(`npm test` 五套件全绿:
|
|
13
|
+
lint、host-range、smoke、contract、verify)。
|
|
14
|
+
|
|
15
|
+
### 安全
|
|
16
|
+
|
|
17
|
+
- **修复 IPv6 回环被错误 403 的缺陷**:经 `http://[::1]:端口` 打开设置页时,保存 /
|
|
18
|
+
删除 / 导入 / SSE 实时刷新此前全部不可用。两个成因:① `hostnameOf('::1')`
|
|
19
|
+
把裸 IPv6 字面量按最后一个冒号截断成 `:`,永远匹配不上回环名单(名单里的
|
|
20
|
+
`::1` 是死条目);② WHATWG URL 解析器给出的 `Origin` 主机名**保留方括号**
|
|
21
|
+
(`[::1]`),而 `Host` 解析去掉方括号(`::1`),同源比较永不相等。现新增
|
|
22
|
+
IP 规范化比较(8 段零填充展开),同时覆盖 IPv4-mapped 地址的两种拼写
|
|
23
|
+
(`::ffff:127.0.0.1` 与 URL 规范化后的 `::ffff:7f00:1` 视为同一地址);
|
|
24
|
+
DNS 重绑定等真正的跨站请求在 IPv6 上同样被拒。默认 `--host 127.0.0.1` 不受
|
|
25
|
+
此问题影响。
|
|
26
|
+
- 绑定侧的回环判定同步走规范化比较:宿主绑定到 `::ffff:127.0.0.1` 这类等价
|
|
27
|
+
地址时,Host 防护不再被错误关闭。
|
|
28
|
+
|
|
29
|
+
### 缺陷修复
|
|
30
|
+
|
|
31
|
+
- **跨进程写锁补上持有者令牌(TOCTOU)**:锁文件 `memory.json.lock` 现在写入
|
|
32
|
+
本次写入的随机令牌,释放时仅当文件仍属自己才删除。此前一个超时 2 秒、"无锁
|
|
33
|
+
照写"的慢持有者,其 `finally` 可能把新持有者刚接管的锁删掉,静默放跑第三个
|
|
34
|
+
写者。过期死锁(超 10 秒)的接管策略不变。
|
|
35
|
+
- **SSE 首帧等待 boot 完成**:在宿主首次磁盘读取尚未结束时打开的设置页,不再
|
|
36
|
+
先收到一帧空存储、再收到真实快照(一次"没有任何指令"的闪烁)。
|
|
37
|
+
- **SSE 写出异常的连接现在完整清理**:广播时 `res.write` 抛错(对端已消失)会
|
|
38
|
+
注销并结束该响应,而不是只从广播集合删除、留下半注册的流;其他健康流不受
|
|
39
|
+
影响。
|
|
40
|
+
- **编辑注入字数上限时不再被实时推送打断**:输入框聚焦期间,其他窗口/进程的
|
|
41
|
+
SSE 快照不再覆盖正在输入的数字;失焦后恢复与已提交值同步。
|
|
42
|
+
- **删除不存在的 id 不再产生空写盘**:不再 bump `rev`、不写文件、不推送回声帧
|
|
43
|
+
(删除一个已被另一窗口删掉的条目时不再制造无意义的写入)。
|
|
44
|
+
|
|
45
|
+
### 样式
|
|
46
|
+
|
|
47
|
+
- 修复错误状态红点的 CSS 变量名笔误:`--dsh-alias-state-error-primary` →
|
|
48
|
+
`--dsw-alias-state-error-primary`(此前该点不跟随宿主主题,恒取兜底色);
|
|
49
|
+
verify 套件新增全文件变量前缀扫描,杜绝同类笔误再发布。
|
|
50
|
+
|
|
51
|
+
### 兼容性
|
|
52
|
+
|
|
53
|
+
- **声明范围扩展到 `0.2.1-alpha.1`(2026-10-03 发布)**:新增
|
|
54
|
+
`>=0.2.1-alpha.0 <0.3.0-0` 枚举段。该版本此前在**宿主门禁**(includePrerelease)
|
|
55
|
+
与市场判定下可通过、但在 npm/pnpm 的默认 peer 解析下会报 ERESOLVE;现两种
|
|
56
|
+
模式一致放行。三处声明(peerDependencies / dsh.engines / dsh.compatibility)
|
|
57
|
+
仍由 host-range 测试钉死逐字节一致。对 npm 上全部 30 个已发布版本穷举:
|
|
58
|
+
范围内 15 个、两模式零分歧;契约矩阵对最近 10 个版本 12/12 通过。
|
|
59
|
+
**0.2.1-alpha.1 仅有源码契约层证据,未做真机启动实测。**
|
|
60
|
+
- README 兼容性统计按当前 registry 更新(28/13 → 30/15),cordis 已发布版本
|
|
61
|
+
数 6 → 7(新增 `4.0.5-alpha.1`,已核验含 `ctx.inject`/`ctx.effect`)。
|
|
62
|
+
|
|
10
63
|
## [1.0.15] — 2026-10-03
|
|
11
64
|
|
|
12
65
|
本轮为一次系统性缺陷清查后的批量修复,每一项都有对应的回归测试(`npm test` 五套件全绿:
|
package/README.md
CHANGED
|
@@ -259,24 +259,28 @@ GUI 与路由本就同主机不同端口),`none`(用户直接导航)放
|
|
|
259
259
|
同一串范围(三处逐字节一致,由测试锁定):
|
|
260
260
|
|
|
261
261
|
```
|
|
262
|
-
>=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.0-0
|
|
262
|
+
>=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.1-alpha.0 <0.3.0-0 || >=0.2.0-0
|
|
263
263
|
```
|
|
264
264
|
|
|
265
265
|
**验证范围与证据等级**——三者不可混为一谈,下面每一行都标了它到底证明了什么:
|
|
266
266
|
|
|
267
267
|
| 等级 | 证明什么 | 覆盖 |
|
|
268
268
|
| --- | --- | --- |
|
|
269
|
-
| ① **真实启动** | 在该宿主版本上真的把 DSH 跑起来:插件装载、注入真的进入**组装后的提示**、插件 HTTP 路由可应答、客户端设置页模块被下发(Windows / Node 22) | `0.1.3-alpha.2` · `0.1.5-alpha.1` · `0.1.5-alpha.2` · `0.1.5-rc.1` · `0.1.5-rc.2` · **`0.2.0-rc.1`** ⁽*⁾ |
|
|
270
|
-
| ② **契约核验** | 抓该版本**真实发布的源码**,逐个核验本插件实际用到的每个符号:`systemPrompt.section()`、`assemble()` 是否求值**函数型** `text`、`FILE_REFERENCE=900` / `TOOL_BASH=1000` 仍在(决定 order 950 是否空档)、重名注册是否抛错、`webServer.register()` / `get host()`、`ctx.inject()` / `ctx.effect()`、`dsh.client.inject` 两包在该版本是否存在且声明了 `dsh.client` | 声明范围接住的**全部
|
|
269
|
+
| ① **真实启动** | 在该宿主版本上真的把 DSH 跑起来:插件装载、注入真的进入**组装后的提示**、插件 HTTP 路由可应答、客户端设置页模块被下发(Windows / Node 22) | `0.1.3-alpha.2` · `0.1.5-alpha.1` · `0.1.5-alpha.2` · `0.1.5-rc.1` · `0.1.5-rc.2` · **`0.2.0-rc.1`** ⁽*⁾ · **`0.2.0-rc.2`** ⁽**⁾ |
|
|
270
|
+
| ② **契约核验** | 抓该版本**真实发布的源码**,逐个核验本插件实际用到的每个符号:`systemPrompt.section()`、`assemble()` 是否求值**函数型** `text`、`FILE_REFERENCE=900` / `TOOL_BASH=1000` 仍在(决定 order 950 是否空档)、重名注册是否抛错、`webServer.register()` / `get host()`、`ctx.inject()` / `ctx.effect()`、`dsh.client.inject` 两包在该版本是否存在且声明了 `dsh.client` | 声明范围接住的**全部 15 个已发布版本**(`0.1.3-alpha.2` → `0.2.1-alpha.1`) |
|
|
271
271
|
| ③ **真实执行** | 把插件**真的挂载**进该版本的 `@deepseek-ai/cordis`,跑装载 / 路由注册 / 卸载共 9 项断言 | cordis `4.0.2` 与 `4.0.4`(各 9/9) |
|
|
272
272
|
|
|
273
273
|
⁽*⁾ **`0.2.0-rc.1` 的①档有一条要说明**:插件装载、注入进入组装后的提示、HTTP 路由可应答三项**均已实测**(路由 `HTTP 200`;`injection.registered=true`、`chars=437`、**`pulls=38`**——即 0.2.0-rc.1 的 agent loop 真的求值了 38 次函数型 `text`)。第四项"客户端模块被下发"**没有直接抓取**:首页需要鉴权(`/` 返回 `401`),我无法取到 `window.__DSH_BOOT__`。这一项的支撑是间接但强的两条:① `dsh-client-modules` 在合成阶段对 `dsh.client` 声明做硬校验,不合格会抛 `ClientPackageCompositionError` 直接导致启动失败——宿主正常启动即说明声明通过了 0.2.0-rc.1 的校验;② 本插件用到的三个客户端契约包(`-slots` / `-renderer` / `-settings`)相对升级前真正在跑的 `0.1.7-rc.2` **逐字节未变**。**未做人工视觉确认**(没有打开设置页看)。
|
|
274
274
|
|
|
275
|
-
|
|
275
|
+
⁽**⁾ **`0.2.0-rc.2` 的①档**:桌面版 `0.2.0-rc.2`(私有发行版 `dsh-desktop-runtime`,见仓库 `dsh-memory-verification/DSH-VERSION-COMPAT.md` §12)上完成过路由 200 + `registered=true` + 注入段落真实出现在会话提示中的活体实测;网页版宿主 `0.2.0-rc.2` 上对本插件路由的 `state` 探测同样返回 200。同样**未做设置页人工视觉确认**。
|
|
276
276
|
|
|
277
|
-
|
|
277
|
+
此外 `host-range-test.mjs` 的固定矩阵覆盖 `0.1.6-alpha.2`、`0.1.7-rc.2`、`0.2.0-rc.1`、`0.2.0-rc.2` 与 `0.2.1-alpha.1`,并会**读取本机实际安装的宿主版本**做实时断言。
|
|
278
278
|
|
|
279
|
-
|
|
279
|
+
**关于 1.0.10 新增的 `ctx.inject`。** 这是新版唯一的硬依赖增量:`inject` 去掉了 `webServer`,设置页路由改为非阻塞的 `ctx.inject(['webServer'], …)` 子 fiber(原因见上)。为此逐个核验了 **cordis 全部 7 个已发布版本**(`4.0.1-rc.1` → `4.0.5-alpha.1`)——**每一个都具备 `ctx.inject`**(其中 4.0.2 / 4.0.4 另做了真实挂载执行,4.0.5-alpha.1 为源码符号核验),因此声明范围内不存在因它而失效的宿主版本。
|
|
280
|
+
|
|
281
|
+
**关于声明范围是否"超范围承诺"。** 已对全部 **30 个**已发布宿主版本穷举核验(2026-10-05,npm 官方 registry):范围内 **15 个**版本的 API 面**全部齐备,超范围承诺 0 项**,且默认与 `includePrerelease` 两种语义在这 15 个版本上零分歧。范围外的 15 个版本(`0.0.1-rc.1` → `0.1.2-rc.1`)API 面**同样齐备**,但它们**从未真实启动验证过**,所以下限维持不动、不向外放宽——不把没验证过的东西写进声明。
|
|
282
|
+
|
|
283
|
+
**关于 `0.2.1-alpha.1`(2026-10-03 发布,1.0.16 起覆盖)。** 它是前述"未来 minor 预发布版本在默认语义下接不住"的**第一个真实发生案例**:1.0.15 的范围在宿主门禁与市场判定(均用 `includePrerelease`)下放行它,但在 npm/pnpm 默认 peer 解析下拒绝(ERESOLVE);其耦合面(9 个契约点)经最近 10 版本矩阵核验全部齐备,cordis 为 `4.0.5-alpha.1`。1.0.16 新增 `>=0.2.1-alpha.0 <0.3.0-0` 枚举段后两种语义一致放行。**该版本仅有契约层证据,未做真机启动实测。**
|
|
280
284
|
|
|
281
285
|
**`0.2.0-rc.1` 的核验口径**(2026-09-29 增补):基准取 npm 上**真实发布的 `0.1.7-rc.2`**(升级前本机真正在跑的版本),逐文件 SHA256 与 `0.2.0-rc.1` 比对。本插件的 6 个耦合点里 **6 个逐字节未变**(`dsh-system-prompt` 4/4、`dsh-client-ui-slots` 4/4、`-renderer` 12/12、`-settings` 11/11、`dsh-host-webserver` 3/3、`dsh-client-modules` 10/10);变动的只有 `dsh-agent-loop`(注入通路 `preStep` / `assemble` 逐行等同)与 `dsh-app-boot`(兼容门禁函数逐字节等同)。方法带**阴性对照**:同法比对 `0.1.6-alpha.2` 得到的哈希**不同**,证明这套比对确实能检出变化,而不是恒真。
|
|
282
286
|
|
|
@@ -295,6 +299,10 @@ major.minor.patch 且自身带预发布标签的比较符"。这条规则是**
|
|
|
295
299
|
⚠️ **一个必须知道的上限(注意它是"模式相关"的,别当成普适结论):未声明的未来 minor 的预发布版本,在 npm 的默认语义下无法被任何范围覆盖。**
|
|
296
300
|
`0.1.8-rc.1` 这类版本 tuple 与所有比较符都不同,在 **npm 默认语义**下**永远匹配不到**——与
|
|
297
301
|
`^0.1.0` 无法匹配 `0.1.8-rc.1` 是同一个限制。正式版(`0.1.8`、`0.2.0`、`1.0.0` 等)不受影响。
|
|
302
|
+
这个限制不再只是假设:**`0.2.1-alpha.1`(2026-10-03 发布)就是第一个真实撞上它的版本**,
|
|
303
|
+
1.0.16 已通过新增 `0.2.1` 枚举段闭合;下一个未声明 tuple(如 `0.2.2-rc.1`、`0.3.0-rc.1`)
|
|
304
|
+
仍会重演同一现象,需要发一版加一行——host-range 的实时守卫会在本机升级到该宿主时
|
|
305
|
+
直接让测试失败并给出可直接套用的修复行。
|
|
298
306
|
|
|
299
307
|
**但在 `includePrerelease: true` 下这条上限不成立。** 实测同一串范围:`0.1.8-rc.1` 在默认语义下 `false`、
|
|
300
308
|
在 `includePrerelease: true` 下 **`true`**(已用 DSH 自带的 semver 7.8.5 验证)。而这个区别是**有后果**的,
|
package/lib/client.js
CHANGED
|
@@ -75,7 +75,7 @@ window.__ModuleLoader__.load({
|
|
|
75
75
|
.im-dot { width:7px; height:7px; border-radius:50%; flex:none; background:var(--dsw-alias-label-secondary,#8a9099); }
|
|
76
76
|
.im-dot-on { background:var(--dsw-alias-state-success-primary,#18a058); }
|
|
77
77
|
.im-dot-off { background:var(--dsw-alias-state-warn-primary,#d97706); }
|
|
78
|
-
.im-dot-err { background:var(--
|
|
78
|
+
.im-dot-err { background:var(--dsw-alias-state-error-primary,#d94838); }
|
|
79
79
|
.im-body-open { max-height:none; }
|
|
80
80
|
.im-more { margin-top:4px; font-size:11.5px; color:var(--dsw-alias-brand-primary,#3b6cff); cursor:pointer; user-select:none; }
|
|
81
81
|
.im-search-meta { margin-top:5px; }
|
|
@@ -485,6 +485,18 @@ window.__ModuleLoader__.load({
|
|
|
485
485
|
}
|
|
486
486
|
}
|
|
487
487
|
|
|
488
|
+
/**
|
|
489
|
+
* What the budget input should show after a snapshot arrives.
|
|
490
|
+
*
|
|
491
|
+
* A push from another window used to reset budgetText unconditionally
|
|
492
|
+
* inside absorb(), so editing the injection-size field while any other
|
|
493
|
+
* panel saved wiped the half-typed number. While the input is focused the
|
|
494
|
+
* user's draft wins; the committed value re-syncs on blur.
|
|
495
|
+
*/
|
|
496
|
+
function nextBudgetText(currentText, incoming, focused) {
|
|
497
|
+
return focused ? currentText : String(incoming)
|
|
498
|
+
}
|
|
499
|
+
|
|
488
500
|
async function callHost(method, args) {
|
|
489
501
|
// A hung host used to pin the panel on 保存中… forever; fail visibly
|
|
490
502
|
// after a generous timeout instead.
|
|
@@ -613,10 +625,18 @@ window.__ModuleLoader__.load({
|
|
|
613
625
|
const [busy, setBusy] = React.useState(false)
|
|
614
626
|
const [draft, setDraft] = React.useState(null)
|
|
615
627
|
const [budgetText, setBudgetText] = React.useState('')
|
|
628
|
+
// Latest live value of the draft, because the SSE onmessage closure below
|
|
629
|
+
// is created once and must not read a stale first-render budgetText.
|
|
630
|
+
const budgetTextRef = React.useRef('')
|
|
631
|
+
budgetTextRef.current = budgetText
|
|
616
632
|
const [showPreview, setShowPreview] = React.useState(false)
|
|
617
633
|
const [query, setQuery] = React.useState('')
|
|
618
634
|
const [undo, setUndo] = React.useState(null)
|
|
619
635
|
const importInputRef = React.useRef(null)
|
|
636
|
+
// Whether the budget input is being edited right now. A live SSE push
|
|
637
|
+
// from another window must not replace the number under the cursor;
|
|
638
|
+
// the ref (not state) is read inside absorb so no re-render is needed.
|
|
639
|
+
const budgetFocusedRef = React.useRef(false)
|
|
620
640
|
// The latest snapshot this panel has absorbed. The SSE effect runs once
|
|
621
641
|
// (empty deps), so it cannot read the `view` state of a later render —
|
|
622
642
|
// the ref is how its frames are compared against what is on screen.
|
|
@@ -668,7 +688,11 @@ window.__ModuleLoader__.load({
|
|
|
668
688
|
}
|
|
669
689
|
setView(normalized.snapshot)
|
|
670
690
|
viewRef.current = normalized.snapshot
|
|
671
|
-
|
|
691
|
+
// Keep the user's in-progress budget edit when a push arrives while
|
|
692
|
+
// the input is focused; re-sync only once editing is not active. The
|
|
693
|
+
// ref is the live draft — absorb may run from the SSE closure created
|
|
694
|
+
// on the first render.
|
|
695
|
+
setBudgetText(nextBudgetText(budgetTextRef.current, normalized.snapshot.data.budgetChars, budgetFocusedRef.current))
|
|
672
696
|
if (!normalized.ok && normalized.message) {
|
|
673
697
|
setError(normalized.message)
|
|
674
698
|
setNotice('')
|
|
@@ -858,8 +882,11 @@ window.__ModuleLoader__.load({
|
|
|
858
882
|
value: budgetText,
|
|
859
883
|
disabled: busy,
|
|
860
884
|
title: t('budget.unitHint'),
|
|
885
|
+
onFocus: () => { budgetFocusedRef.current = true },
|
|
861
886
|
onChange: (ev) => setBudgetText(ev.target.value),
|
|
862
887
|
onBlur: () => {
|
|
888
|
+
// Editing is over: later pushes may sync the field again.
|
|
889
|
+
budgetFocusedRef.current = false
|
|
863
890
|
const parsed = Number(budgetText)
|
|
864
891
|
if (!Number.isFinite(parsed) || parsed < limits.minBudget || parsed > limits.maxBudget) {
|
|
865
892
|
setBudgetText(String(data.budgetChars))
|
|
@@ -1108,6 +1135,7 @@ window.__ModuleLoader__.load({
|
|
|
1108
1135
|
exports.__sameSnapshot = sameSnapshot
|
|
1109
1136
|
exports.__confirmDiscard = confirmDiscard
|
|
1110
1137
|
exports.__importSizeVerdict = importSizeVerdict
|
|
1138
|
+
exports.__nextBudgetText = nextBudgetText
|
|
1111
1139
|
exports.__strings = STRINGS
|
|
1112
1140
|
exports.__ns = NS
|
|
1113
1141
|
exports.__dictionaryT = dictionaryT
|
package/lib/index.js
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
* plugin's fiber, so unmounting the row removes all of them.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { copyFile, mkdir,
|
|
13
|
+
import { copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
14
14
|
import { watch } from 'node:fs'
|
|
15
|
+
import { randomUUID } from 'node:crypto'
|
|
16
|
+
import { isIP } from 'node:net'
|
|
15
17
|
import { homedir } from 'node:os'
|
|
16
18
|
import { basename, dirname, join } from 'node:path'
|
|
17
19
|
|
|
@@ -326,6 +328,13 @@ const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1', '::ffff:127
|
|
|
326
328
|
*
|
|
327
329
|
* `127.0.0.1:8080` -> `127.0.0.1`; `[::1]:8080` -> `::1`; `localhost` -> `localhost`.
|
|
328
330
|
* Exported so the parsing itself can be pinned by tests.
|
|
331
|
+
*
|
|
332
|
+
* HTTP requires IPv6 literals in Host to wear brackets, so the bracketed
|
|
333
|
+
* form is the one real traffic uses; the bare form still has to parse instead
|
|
334
|
+
* of being chopped at the last colon (the old code turned a bare `::1` into
|
|
335
|
+
* `:`, which never matched the loopback set). A bare value carrying MORE than
|
|
336
|
+
* one colon is an IPv6 literal with no port — with at most one colon it is
|
|
337
|
+
* host:port.
|
|
329
338
|
*/
|
|
330
339
|
export function hostnameOf(value) {
|
|
331
340
|
if (typeof value !== 'string') return ''
|
|
@@ -333,12 +342,100 @@ export function hostnameOf(value) {
|
|
|
333
342
|
if (trimmed === '') return ''
|
|
334
343
|
if (trimmed.startsWith('[')) {
|
|
335
344
|
const end = trimmed.indexOf(']')
|
|
336
|
-
return end === -1 ? trimmed : trimmed.slice(1, end)
|
|
345
|
+
return end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)
|
|
337
346
|
}
|
|
347
|
+
// Unbracketed IPv6 literal: the colons are part of the address, not a port.
|
|
348
|
+
if (trimmed.indexOf(':') !== trimmed.lastIndexOf(':')) return trimmed
|
|
338
349
|
const colon = trimmed.lastIndexOf(':')
|
|
339
350
|
return colon === -1 ? trimmed : trimmed.slice(0, colon)
|
|
340
351
|
}
|
|
341
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Normalise a hostname the same way on BOTH sides of the Origin comparison.
|
|
355
|
+
*
|
|
356
|
+
* `new URL('http://[::1]:8080').hostname` keeps the brackets (`[::1]`), while
|
|
357
|
+
* `hostnameOf('[::1]:8080')` strips them (`::1`) — comparing the two raw forms
|
|
358
|
+
* rejected every same-origin request that reached the GUI over IPv6 loopback.
|
|
359
|
+
* Strip one surrounding pair of brackets here so both sides spell an IPv6
|
|
360
|
+
* literal identically.
|
|
361
|
+
*/
|
|
362
|
+
const normalizeHostname = (value) => (
|
|
363
|
+
typeof value === 'string' && value.startsWith('[') && value.endsWith(']') && value.length >= 2
|
|
364
|
+
? value.slice(1, -1)
|
|
365
|
+
: value
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Expand an IPv6 literal to eight lower-cased zero-padded groups, translating a
|
|
370
|
+
* trailing embedded IPv4 suffix to two hex groups. Returns null for anything
|
|
371
|
+
* that is not a valid literal.
|
|
372
|
+
*
|
|
373
|
+
* Needed because the two sides of the Origin check spell the SAME address
|
|
374
|
+
* differently: a browser sends Host `[::ffff:127.0.0.1]` while the URL parser
|
|
375
|
+
* canonicalises Origin to `[::ffff:7f00:1]` — text comparison says these are
|
|
376
|
+
* different; address comparison must say they are the same.
|
|
377
|
+
*/
|
|
378
|
+
export function canonicalIp(value) {
|
|
379
|
+
if (typeof value !== 'string') return null
|
|
380
|
+
let addr = normalizeHostname(value.trim().toLowerCase())
|
|
381
|
+
const percent = addr.indexOf('%')
|
|
382
|
+
if (percent !== -1) addr = addr.slice(0, percent)
|
|
383
|
+
if (isIP(addr) === 4) {
|
|
384
|
+
return 'v4:' + addr.split('.').map((part) => Number(part)).join('.')
|
|
385
|
+
}
|
|
386
|
+
if (isIP(addr) !== 6) return null
|
|
387
|
+
|
|
388
|
+
// Translate a trailing dotted-quad (e.g. ::ffff:127.0.0.1) into two groups.
|
|
389
|
+
const v4 = /(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(addr)
|
|
390
|
+
if (v4 !== null) {
|
|
391
|
+
const octets = v4[1].split('.').map((part) => Number(part))
|
|
392
|
+
if (octets.some((octet) => octet > 255)) return null
|
|
393
|
+
const prefix = addr.slice(0, v4.index)
|
|
394
|
+
const hi = (octets[0] << 8 | octets[1]).toString(16)
|
|
395
|
+
const lo = (octets[2] << 8 | octets[3]).toString(16)
|
|
396
|
+
// Keep the trailing ':' of the prefix as the separator before the two
|
|
397
|
+
// translated groups; it may also be the second ':' of a '::' compression,
|
|
398
|
+
// which must survive (so do not strip a leading colon afterwards).
|
|
399
|
+
addr = (prefix.endsWith(':') ? prefix : prefix + ':') + hi + ':' + lo
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const halves = addr.split('::')
|
|
403
|
+
if (halves.length > 2) return null
|
|
404
|
+
if (halves.length === 1) {
|
|
405
|
+
const groups = halves[0].split(':')
|
|
406
|
+
if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/i.test(group))) return null
|
|
407
|
+
return groups.map((group) => parseInt(group, 16).toString(16).padStart(4, '0')).join(':')
|
|
408
|
+
}
|
|
409
|
+
const [leftRaw, rightRaw] = halves
|
|
410
|
+
const left = leftRaw === '' ? [] : leftRaw.split(':')
|
|
411
|
+
const right = rightRaw === '' ? [] : rightRaw.split(':')
|
|
412
|
+
const missing = 8 - left.length - right.length
|
|
413
|
+
if (missing < 0) return null
|
|
414
|
+
const groups = left.concat(Array(missing).fill('0'), right)
|
|
415
|
+
if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/i.test(group))) return null
|
|
416
|
+
return groups.map((group) => parseInt(group, 16).toString(16).padStart(4, '0')).join(':')
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Whether two hostnames are the same endpoint: exact text or same IP literal. */
|
|
420
|
+
const sameHostname = (a, b) => {
|
|
421
|
+
if (a === b) return true
|
|
422
|
+
const ca = canonicalIp(a)
|
|
423
|
+
const cb = canonicalIp(b)
|
|
424
|
+
return ca !== null && cb !== null && ca === cb
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Loopback by exact name or by equivalent IP spelling (mapped/hex forms). */
|
|
428
|
+
const isLoopbackName = (name) => {
|
|
429
|
+
if (LOOPBACK_HOSTNAMES.has(name)) return true
|
|
430
|
+
const canonical = canonicalIp(name)
|
|
431
|
+
if (canonical === null) return false
|
|
432
|
+
for (const known of LOOPBACK_HOSTNAMES) {
|
|
433
|
+
const knownCanonical = canonicalIp(known)
|
|
434
|
+
if (knownCanonical !== null && knownCanonical === canonical) return true
|
|
435
|
+
}
|
|
436
|
+
return false
|
|
437
|
+
}
|
|
438
|
+
|
|
342
439
|
/**
|
|
343
440
|
* Why the settings-page route checks `Host`, `Origin` and `Sec-Fetch-Site` at
|
|
344
441
|
* all, when it already refuses any content-type that is not
|
|
@@ -380,7 +477,7 @@ export function requestOriginVerdict(headers, guarded) {
|
|
|
380
477
|
const requestHost = read('host').trim().toLowerCase()
|
|
381
478
|
const requestHostname = hostnameOf(requestHost)
|
|
382
479
|
|
|
383
|
-
if (guarded && !
|
|
480
|
+
if (guarded && !isLoopbackName(requestHostname)) {
|
|
384
481
|
return { ok: false, reason: 'Host 头不是回环地址(可能是 DNS 重绑定攻击):' + (requestHost === '' ? '(缺失)' : requestHost) }
|
|
385
482
|
}
|
|
386
483
|
|
|
@@ -393,14 +490,19 @@ export function requestOriginVerdict(headers, guarded) {
|
|
|
393
490
|
if (origin !== '') {
|
|
394
491
|
let originHostname = ''
|
|
395
492
|
try {
|
|
396
|
-
|
|
493
|
+
// URL.hostname keeps the brackets on an IPv6 literal; normalizeHostname
|
|
494
|
+
// takes them off so it can meet the bracket-free Host parsing on equal
|
|
495
|
+
// terms.
|
|
496
|
+
originHostname = normalizeHostname(new URL(origin).hostname.toLowerCase())
|
|
397
497
|
} catch {
|
|
398
498
|
originHostname = ''
|
|
399
499
|
}
|
|
400
500
|
// Compare hostnames, not host:port: a proxy may rewrite the port, and the
|
|
401
501
|
// hostname is the part rebinding actually attacks. `Origin: null`
|
|
402
502
|
// (sandboxed iframe, file://) parses to no hostname and is refused.
|
|
403
|
-
|
|
503
|
+
// Canonical IP comparison covers the two spellings of one address:
|
|
504
|
+
// ::1 vs [::1], and ::ffff:127.0.0.1 vs ::ffff:7f00:1.
|
|
505
|
+
if (originHostname === '' || !sameHostname(originHostname, requestHostname)) {
|
|
404
506
|
return { ok: false, reason: 'Origin 与 Host 不同源:' + origin }
|
|
405
507
|
}
|
|
406
508
|
}
|
|
@@ -692,23 +794,25 @@ export function apply(ctx) {
|
|
|
692
794
|
/* ---------------- external-change watching (SSE) ---------------- */
|
|
693
795
|
|
|
694
796
|
/**
|
|
695
|
-
* Live subscribers to the settings page's event stream. Each entry
|
|
696
|
-
* `send(payload)` writer
|
|
797
|
+
* Live subscribers to the settings page's event stream. Each entry maps a
|
|
798
|
+
* stream's `send(payload)` writer to its own teardown, so a write that fails
|
|
799
|
+
* (the socket went away) can dispose THAT stream instead of just dropping
|
|
800
|
+
* it from the broadcast set while its response stays open.
|
|
697
801
|
*/
|
|
698
|
-
const
|
|
699
|
-
/** Per-connection teardown, so unmounting the plugin can end every stream. */
|
|
700
|
-
const sseCleanups = new Set()
|
|
802
|
+
const sseStreams = new Map()
|
|
701
803
|
let storeWatcher = null
|
|
702
804
|
let watchDebounce = null
|
|
703
805
|
|
|
704
806
|
const pushSnapshot = () => {
|
|
705
|
-
if (
|
|
807
|
+
if (sseStreams.size === 0) return
|
|
706
808
|
const payload = 'data: ' + JSON.stringify(snapshot()) + '\n\n'
|
|
707
|
-
for (const send of Array.from(
|
|
809
|
+
for (const [send, cleanup] of Array.from(sseStreams)) {
|
|
708
810
|
try {
|
|
709
811
|
send(payload)
|
|
710
812
|
} catch {
|
|
711
|
-
|
|
813
|
+
// The response is dead: run its full teardown (end + unsubscribe),
|
|
814
|
+
// not just a set delete that leaves the stream half-registered.
|
|
815
|
+
cleanup()
|
|
712
816
|
}
|
|
713
817
|
}
|
|
714
818
|
}
|
|
@@ -788,15 +892,14 @@ export function apply(ctx) {
|
|
|
788
892
|
}
|
|
789
893
|
|
|
790
894
|
const disposeStreams = () => {
|
|
791
|
-
for (const cleanup of Array.from(
|
|
895
|
+
for (const cleanup of Array.from(sseStreams.values())) {
|
|
792
896
|
try {
|
|
793
897
|
cleanup()
|
|
794
898
|
} catch {
|
|
795
899
|
// The response is already gone.
|
|
796
900
|
}
|
|
797
901
|
}
|
|
798
|
-
|
|
799
|
-
sseClients.clear()
|
|
902
|
+
sseStreams.clear()
|
|
800
903
|
}
|
|
801
904
|
|
|
802
905
|
/* ---------------- storage ---------------- */
|
|
@@ -807,7 +910,8 @@ export function apply(ctx) {
|
|
|
807
910
|
|
|
808
911
|
/**
|
|
809
912
|
* Cross-process write lock: a `memory.json.lock` marker created exclusively
|
|
810
|
-
* (`wx`),
|
|
913
|
+
* (`wx`) holding a random owner token, kept for the duration of one atomic
|
|
914
|
+
* replace, and removed afterwards ONLY BY ITS OWNER.
|
|
811
915
|
*
|
|
812
916
|
* The in-process diskQueue serialises this plugin's own writes, but two DSH
|
|
813
917
|
* processes sharing a `$DSH_HOME` (e.g. `web` and `tui` profiles running at
|
|
@@ -821,19 +925,32 @@ export function apply(ctx) {
|
|
|
821
925
|
* write proceeds anyway (disclosed in the debug log). A lock older than
|
|
822
926
|
* LOCK_STALE_MS is treated as crashed-holder debris and stolen, so a killed
|
|
823
927
|
* process cannot wedge every later save behind a stale marker.
|
|
928
|
+
*
|
|
929
|
+
* The token matters: stealing used to be a plain unlink, and the slow
|
|
930
|
+
* holder's `finally` then unlinked the lock the NEW owner had just created —
|
|
931
|
+
* a classic TOCTOU that silently unlocked the third writer. The marker
|
|
932
|
+
* carries this attempt's UUID, and release deletes it only when the file
|
|
933
|
+
* still carries that UUID, so a finished holder can never remove a lock it
|
|
934
|
+
* no longer owns.
|
|
935
|
+
*
|
|
936
|
+
* @returns `{ path, token }` while held, or `null` when the wait elapsed
|
|
937
|
+
* (the save proceeds unlocked by design).
|
|
824
938
|
*/
|
|
825
939
|
const acquireStoreLock = async () => {
|
|
826
940
|
const lockPath = storePath + '.lock'
|
|
941
|
+
const token = randomUUID()
|
|
827
942
|
const deadline = Date.now() + LOCK_WAIT_MS
|
|
828
943
|
for (;;) {
|
|
829
944
|
try {
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
return lockPath
|
|
945
|
+
await writeFile(lockPath, token, { flag: 'wx' })
|
|
946
|
+
return { path: lockPath, token }
|
|
833
947
|
} catch (err) {
|
|
834
948
|
if (err.code !== 'EEXIST') throw err
|
|
835
949
|
const info = await stat(lockPath).catch(() => null)
|
|
836
950
|
if (info !== null && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
|
|
951
|
+
// Crashed-holder debris. It may be recreated by a live process
|
|
952
|
+
// between this unlink and the next wx attempt — that case simply
|
|
953
|
+
// loops and re-evaluates freshness instead of forcing its way in.
|
|
837
954
|
await unlink(lockPath).catch(() => {})
|
|
838
955
|
continue
|
|
839
956
|
}
|
|
@@ -843,6 +960,21 @@ export function apply(ctx) {
|
|
|
843
960
|
}
|
|
844
961
|
}
|
|
845
962
|
|
|
963
|
+
/**
|
|
964
|
+
* Release a lock only when THIS attempt still owns it. Reading the marker
|
|
965
|
+
* back and comparing the token closes the window in which a finished slow
|
|
966
|
+
* holder deleted a lock the next owner had already taken over.
|
|
967
|
+
*/
|
|
968
|
+
const releaseStoreLock = async (lock) => {
|
|
969
|
+
if (lock === null) return
|
|
970
|
+
try {
|
|
971
|
+
const owner = await readFile(lock.path, 'utf8').catch(() => null)
|
|
972
|
+
if (owner === lock.token) await unlink(lock.path).catch(() => {})
|
|
973
|
+
} catch {
|
|
974
|
+
// Cleanup must never throw into the save's finally.
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
846
978
|
const writeToDisk = async () => {
|
|
847
979
|
// Atomic replace (write a sibling temp file, then rename) with a rolling
|
|
848
980
|
// one-version backup: a crash mid-write must never leave a truncated
|
|
@@ -859,12 +991,12 @@ export function apply(ctx) {
|
|
|
859
991
|
// any step leaves the previous store readable at its own path.
|
|
860
992
|
const tmpPath = storePath + '.tmp'
|
|
861
993
|
const bakPath = storePath + '.bak'
|
|
862
|
-
let
|
|
994
|
+
let lock = null
|
|
863
995
|
let previousRev = null
|
|
864
996
|
try {
|
|
865
997
|
await mkdir(dirname(storePath), { recursive: true })
|
|
866
|
-
|
|
867
|
-
if (
|
|
998
|
+
lock = await acquireStoreLock()
|
|
999
|
+
if (lock === null) {
|
|
868
1000
|
logDebug('store lock still held by another process; writing anyway (last write wins)')
|
|
869
1001
|
}
|
|
870
1002
|
// The rev is bumped before serialising so the file on disk and the state
|
|
@@ -912,7 +1044,7 @@ export function apply(ctx) {
|
|
|
912
1044
|
state.storageError = '保存失败:' + describeError(err)
|
|
913
1045
|
return false
|
|
914
1046
|
} finally {
|
|
915
|
-
|
|
1047
|
+
await releaseStoreLock(lock)
|
|
916
1048
|
}
|
|
917
1049
|
}
|
|
918
1050
|
|
|
@@ -1104,11 +1236,19 @@ export function apply(ctx) {
|
|
|
1104
1236
|
// Load the store, then register the section if anything must be injected.
|
|
1105
1237
|
// Every entry point awaits this, so a request that arrives before the disk
|
|
1106
1238
|
// read settles can never overwrite the file with an empty in-memory state.
|
|
1239
|
+
//
|
|
1240
|
+
// `bootSettled` flips only after the read lands. The SSE connect frame is
|
|
1241
|
+
// gated on it: a stream opened in the gap between apply() and the read
|
|
1242
|
+
// finishing must wait, or its first frame would describe the EMPTY boot
|
|
1243
|
+
// state and the panel would flash "no instructions" before the real
|
|
1244
|
+
// snapshot arrived (POST /state already awaited boot; the stream did not).
|
|
1245
|
+
let bootSettled = false
|
|
1107
1246
|
const boot = readFromDisk()
|
|
1108
1247
|
.catch((err) => {
|
|
1109
1248
|
state.storageError = '读取失败:' + describeError(err)
|
|
1110
1249
|
})
|
|
1111
1250
|
.then(() => {
|
|
1251
|
+
bootSettled = true
|
|
1112
1252
|
syncSection()
|
|
1113
1253
|
// The one always-on line: it answers "is memory mounted, where does it
|
|
1114
1254
|
// live, and how much is being injected?" without any further digging.
|
|
@@ -1301,6 +1441,13 @@ export function apply(ctx) {
|
|
|
1301
1441
|
const deleteEntry = (id, baseRev) => enqueue(async () => {
|
|
1302
1442
|
if (state.versionRejected) return versionRefusal()
|
|
1303
1443
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
1444
|
+
// Deleting an id that is not there changes nothing, so it must not touch
|
|
1445
|
+
// disk, bump rev, or push an echo frame — otherwise two panels deleting
|
|
1446
|
+
// the same entry one after another would manufacture a pointless write
|
|
1447
|
+
// (and an external process watching the file would wake for nothing).
|
|
1448
|
+
if (!state.data.entries.some((entry) => entry.id === id)) {
|
|
1449
|
+
return { ok: true, saved: false, message: null, snapshot: snapshot() }
|
|
1450
|
+
}
|
|
1304
1451
|
const saved = await commit(() => {
|
|
1305
1452
|
state.data.entries = state.data.entries.filter((entry) => entry.id !== id)
|
|
1306
1453
|
})
|
|
@@ -1487,23 +1634,38 @@ export function apply(ctx) {
|
|
|
1487
1634
|
// restarted) instead of hammering the route.
|
|
1488
1635
|
res.write('retry: 5000\n\n')
|
|
1489
1636
|
const send = (payload) => { res.write(payload) }
|
|
1490
|
-
|
|
1491
|
-
// A snapshot on connect, so a late subscriber synchronises immediately
|
|
1492
|
-
// instead of waiting for the next change.
|
|
1493
|
-
send('data: ' + JSON.stringify(snapshot()) + '\n\n')
|
|
1637
|
+
let closed = false
|
|
1494
1638
|
const cleanup = () => {
|
|
1495
|
-
|
|
1496
|
-
|
|
1639
|
+
closed = true
|
|
1640
|
+
sseStreams.delete(send)
|
|
1497
1641
|
try {
|
|
1498
1642
|
res.end()
|
|
1499
1643
|
} catch {
|
|
1500
1644
|
// The response is already gone.
|
|
1501
1645
|
}
|
|
1502
1646
|
}
|
|
1503
|
-
|
|
1647
|
+
sseStreams.set(send, cleanup)
|
|
1504
1648
|
req.on('close', cleanup)
|
|
1505
1649
|
req.on('error', cleanup)
|
|
1506
1650
|
res.on('error', cleanup)
|
|
1651
|
+
// A snapshot on connect, so a late subscriber synchronises immediately
|
|
1652
|
+
// instead of waiting for the next change. It must wait for boot: a stream
|
|
1653
|
+
// opened while the initial disk read is still in flight would otherwise be
|
|
1654
|
+
// handed the empty boot state as its first frame, flashing "no
|
|
1655
|
+
// instructions" before the loaded store arrives.
|
|
1656
|
+
const sendConnectFrame = () => {
|
|
1657
|
+
if (closed) return
|
|
1658
|
+
try {
|
|
1659
|
+
send('data: ' + JSON.stringify(snapshot()) + '\n\n')
|
|
1660
|
+
} catch {
|
|
1661
|
+
cleanup()
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
if (bootSettled) {
|
|
1665
|
+
sendConnectFrame()
|
|
1666
|
+
} else {
|
|
1667
|
+
boot.then(sendConnectFrame, sendConnectFrame)
|
|
1668
|
+
}
|
|
1507
1669
|
}
|
|
1508
1670
|
|
|
1509
1671
|
const handler = (req, res) => {
|
|
@@ -1613,7 +1775,7 @@ export function apply(ctx) {
|
|
|
1613
1775
|
// disabled for all non-loopback binds and the trade-off is documented in
|
|
1614
1776
|
// the README.
|
|
1615
1777
|
const boundHost = typeof webServer.host === 'string' ? webServer.host.trim().toLowerCase() : ''
|
|
1616
|
-
guardHost = boundHost === '' ||
|
|
1778
|
+
guardHost = boundHost === '' || isLoopbackName(boundHost)
|
|
1617
1779
|
try {
|
|
1618
1780
|
routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
|
|
1619
1781
|
logDebug('settings route registered at ' + API_PREFIX + ' (boundHost=' + boundHost + ', hostGuard=' + guardHost + ')')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-instruction-memory",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.16",
|
|
4
4
|
"description": "User-only instruction memory for DSH: long-term instructions maintained in the settings UI and auto-injected into every conversation — the model has no write access. 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;模型没有任何写入口。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"node": ">=20"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@deepseek-ai/dsh-system-prompt": ">=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.0-0"
|
|
52
|
+
"@deepseek-ai/dsh-system-prompt": ">=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.1-alpha.0 <0.3.0-0 || >=0.2.0-0"
|
|
53
53
|
},
|
|
54
54
|
"publishConfig": {
|
|
55
55
|
"access": "public"
|
|
@@ -72,10 +72,10 @@
|
|
|
72
72
|
"platform": "web"
|
|
73
73
|
},
|
|
74
74
|
"engines": {
|
|
75
|
-
"dsh": ">=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.0-0"
|
|
75
|
+
"dsh": ">=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.1-alpha.0 <0.3.0-0 || >=0.2.0-0"
|
|
76
76
|
},
|
|
77
77
|
"compatibility": {
|
|
78
|
-
"dsh": ">=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.0-0"
|
|
78
|
+
"dsh": ">=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.1-alpha.0 <0.3.0-0 || >=0.2.0-0"
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
}
|