dsh-instruction-memory 1.0.8 → 1.0.10

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/README.md CHANGED
@@ -64,20 +64,81 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。
64
64
  > 首次启动新版本时会自动迁移到上述位置,并把原文件改名为 `*.migrated` 保留。
65
65
 
66
66
  该文件可手工编辑,改完点设置页的「重新载入」即可读回。每次保存先写临时文件再原子替换,
67
- 进程崩溃不会留下损坏的半截 `memory.json`;保存时上一版会保留为 `memory.json.bak`,
68
- 若 `memory.json` 解析失败会自动临时回退到该备份并明确提示。若手工编辑后的文件有条目为空
69
- 或超过 200 条上限,设置页会明确提示哪些条目未能载入(它们将在下次保存时从文件中移除),
70
- 不会静默丢弃。
67
+ 进程崩溃不会留下损坏的半截 `memory.json`;保存前把上一版**复制**为 `memory.json.bak`,
68
+ 若 `memory.json` 解析失败会自动临时回退到该备份并明确提示。
69
+
70
+ > **备份为什么是"复制"而不是"改名"**:曾经的做法是先把 `memory.json` 改名成 `.bak`、
71
+ > 再改名临时文件顶上。这两步之间 `memory.json` 是不存在的——只要第二步失败(文件被占用、
72
+ > 磁盘满、杀软扫描),主路径就一直空着,下次启动找不到文件就会**建一份空存储**,用户的
73
+ > 记忆看起来凭空消失,而真实数据其实好好躺在 `.bak` 里。改为复制后主文件在原子替换成功
74
+ > 前始终待在原位;同时启动时若发现 `memory.json` 缺失而 `.bak` 可用,会**从备份恢复并写回**
75
+ > 主路径,而不是从零开始。
76
+
77
+ 若手工编辑后的文件有条目为空或超过 200 条上限,设置页会明确提示哪些条目未能载入
78
+ (它们将在下次保存时从文件中移除),不会静默丢弃。导入时若某条超出长度上限
79
+ (标题 120 / 内容 6000 / 适用场景 200 字符),会明确提示**有几条、哪个字段**被截断,
80
+ 而不是悄悄改短。
71
81
 
72
82
  设置页支持**导出 / 导入**:导出为自带说明的 JSON 文件;导入采用**合并语义**——按 id 与
73
- 「标题+内容」去重,只新增、绝不覆盖或清空现有条目。
83
+ 「标题+内容」去重,只新增、绝不覆盖或清空现有条目。请求体上限由条目上限推导
84
+ (`MAX_ENTRIES × (MAX_CONTENT + MAX_TITLE + MAX_WHEN + 200) × 2 + 100000`),保证**本插件
85
+ 导出的文件一定能被自己导回**——早先写死的 1,000,000 小于满配导出(200 × 6000 = 1.2 M 字符),
86
+ 导致满配记忆导出后无法导入。
87
+
88
+ ## 无 Web 服务器时依然生效
89
+
90
+ `systemPrompt` 是本插件唯一的硬依赖。**Web 服务器不是前置条件**:设置页那部分路由通过
91
+ 非阻塞的 `ctx.inject(['webServer'], …)` 子 fiber 注册,服务出现时自动挂上;没有 Web 服务器
92
+ 的 profile(`headless`、`tui`)里,系统提示词注入照常工作,只是设置页不可用。
93
+
94
+ > 这里曾声明 `inject = ['systemPrompt', 'webServer']`。声明式依赖是**前置条件**,于是没有
95
+ > Web 服务器的 profile 里整行插件永远停在 pending、`apply()` 根本不执行——用户同时失去设置页
96
+ > **和**记忆注入,而记忆本身根本不需要 Web 服务器。非阻塞注册同时把最初那个竞态修得更彻底:
97
+ > 回调在服务发布时就会执行,而不只是在它"抢在插件挂载之前"发布时才生效。
98
+
99
+ ## 诊断日志
100
+
101
+ 插件此前**只有失败才有输出**:每次载入、保存、注入成功都不留任何记录,只有失败分支走
102
+ `console.error`。于是"记忆到底有没有在干活"完全无法从日志回答。现在它挂载时写一行
103
+ `info`,过程细节写 `debug`(走宿主的 `ctx.logger`):
104
+
105
+ ```
106
+ [instruction-memory] mounted: store=<DSH_HOME>\instruction-memory\memory.json from=memory.json entries=2 enabled=true injected=437 chars
107
+ ```
108
+
109
+ 挂载行含**数据来源**(`memory.json` / `memory.json.bak (main file missing)` / `legacy cwd file (migrated)`
110
+ / `first run (empty store created)`)、条目数、总开关状态、实际注入字符数,以及读取错误。
111
+
112
+ **这是纯日志,绝不影响注入内容**:同一份存储在"有 logger"和"没有 logger"下渲染出的文本
113
+ **逐字节相同**(`verify.mjs` 有断言锁定这一点)。`debug` 级别由宿主日志级别过滤;
114
+ 没有 logger 服务的环境下需要 `DSH_INSTRUCTION_MEMORY_DEBUG=1` 才会输出到标准错误。
115
+
116
+ ## 请求来源防护(Host / Origin)
117
+
118
+ 设置页路由之所以要求 `content-type: application/json`,是因为 HTML 表单无法伪造 JSON body,
119
+ 而跨源 `fetch` 带 JSON 会先在 preflight 失败。**这两条防线都建立在"浏览器认为这是跨源"之上,
120
+ DNS 重绑定恰好推翻了它**:`evil.com` 解析到 `127.0.0.1` 之后,页面与服务器在浏览器眼里是
121
+ **同源**——不发 preflight、JSON 也合法,于是任何网页都能改写用户那份"注入到此后每一轮
122
+ 对话"的长期指令。此时唯一无法伪造的就是浏览器发出的 `Host` 头。
123
+
124
+ 因此当服务器绑定在回环地址(默认)时,路由**要求 `Host` 是回环**,另外校验 `Origin`(若存在)
125
+ 与 `Host` 同源。命中即 **403**,并记一行日志:
126
+
127
+ ```
128
+ [instruction-memory] route rejected a request: Host 头不是回环地址(可能是 DNS 重绑定攻击):evil.com:8080
129
+ ```
130
+
131
+ - 绑定 `--host 0.0.0.0`(主动对所有网卡开放)时 Host 校验**自动关闭**——那种情况下无法预知合法
132
+ Host,强行限制只会打断所有正常客户端;`Origin` 校验仍然生效。
133
+ - 无 `Host` 的 HTTP/1.1 请求由 Node 的 HTTP 解析器先以 400 拒掉,根本到不了路由。
134
+ - 端口不参与 `Origin` 比对(反向代理可能改写端口),主机名才是重绑定攻击的对象。
74
135
 
75
136
  ## 兼容性
76
137
 
77
138
  `dsh.compatibility.dsh` 与 `dsh.engines.dsh` 声明为同一串:
78
139
 
79
140
  ```
80
- >=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.2.0-0 || >=0.2.0-0
141
+ >=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
81
142
  ```
82
143
 
83
144
  **实测范围**:以下 5 个宿主版本已在 **Windows / Node 22** 上逐个真实启动验证(每个版本都确认了
@@ -85,7 +146,8 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。
85
146
 
86
147
  `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`
87
148
 
88
- 此外 `0.1.6-alpha.2` 完成了完整的装载与兼容性判定核验(`host-range-test.mjs` 的固定矩阵覆盖它)。
149
+ 此外 `0.1.6-alpha.2` 与 `0.1.7-rc.2` 完成了完整的装载与兼容性判定核验(`host-range-test.mjs`
150
+ 的固定矩阵覆盖它们,且该脚本会**读取本机实际安装的宿主版本**做实时断言)。
89
151
 
90
152
  **为什么写成分段形式**:node-semver 规定"候选版本带预发布标签时,区间里必须存在同一个
91
153
  major.minor.patch 且自身带预发布标签的比较符"。这条规则是**集合级**的——一个 `||` 分支最多
@@ -98,16 +160,39 @@ major.minor.patch 且自身带预发布标签的比较符"。这条规则是**
98
160
  在把 `0.1.3` 当 token 时会放行 `0.1.3-alpha.0/1`。
99
161
 
100
162
  ⚠️ **一个必须知道的上限**:**未声明的未来 minor 的预发布版本,无法被任何范围覆盖。**
101
- `0.1.7-rc.1` 这类版本 tuple 与所有比较符都不同,在 npm 语义下**永远匹配不到**——与
102
- `^0.1.0` 无法匹配 `0.1.7-rc.1` 是同一个限制。正式版(`0.1.7`、`0.2.0`、`1.0.0` 等)不受影响。
103
- DSH 发布新的预发布 minor 时需追加一段,例如 `|| >=0.1.7-alpha.0 <0.2.0-0`;
104
- `host-range-test.mjs` 的矩阵会把这类缺口暴露出来。
105
-
106
- **历史修复说明**:1.0.7 及以前,范围末段是 `>=0.1.5-alpha.1`。它使宿主 `0.1.6-alpha.2`
107
- 在 npm **默认**语义下被判为**不兼容**(预发布门禁要求 tuple 相同:`0.1.5 ≠ 0.1.6`,
108
- `>=` 的大小比较根本走不到)。插件市场因为传入 `includePrerelease: true` 而侥幸放行,但任何
109
- 标准 semver 判定都会拒绝——包括 `npm` / `pnpm` 的依赖解析。现已改为逐段枚举,
110
- 两种判定模式结果一致。
163
+ `0.1.8-rc.1` 这类版本 tuple 与所有比较符都不同,在 npm 语义下**永远匹配不到**——与
164
+ `^0.1.0` 无法匹配 `0.1.8-rc.1` 是同一个限制。正式版(`0.1.8`、`0.2.0`、`1.0.0` 等)不受影响。
165
+
166
+ **这个限制无法用代码消除,但它的后果已经被消掉了。** 范围现在是**生成出来的**:
167
+ `host-range-test.mjs` 顶部的 `COVERED_LINES` 是唯一事实来源,package.json 里那串长字符串必须
168
+ 逐字节等于由它生成的结果(有专门的漂移断言),所以手改长串导致"枚举漏了一段"这类错误不可能
169
+ 再发生——DSH 发新 minor 时只需在列表末尾加一行。
170
+
171
+ 同时**实时守卫**(直接读本机实际安装的宿主版本)在发现未覆盖的宿主时,会打印**精确到行**的
172
+ 修复配方,并且在打印前**先验证该配方真的能让那个版本被放行**:
173
+
174
+ ```
175
+ FAIL LIVE GUARD: the installed host 0.1.8-rc.1 is admitted by both modes -> ...
176
+ Fix: append { floor: '0.1.8-alpha.0', below: '0.2.0-0' } to COVERED_LINES
177
+ and set the preceding line's below to '0.1.8-0'
178
+ [verified: that edit admits 0.1.8-rc.1]
179
+ ```
180
+
181
+ 这份配方本身也有测试:`host-range-test.mjs` 会拿 `0.1.8-rc.1` 走一遍"确认当前范围拒收 →
182
+ 套用配方 → 确认放行 → 确认下方版本仍被拒收 → 确认所有已覆盖版本仍被放行"。配方失效会先失败在
183
+ 测试里,而不是失败在用户的安装上。
184
+
185
+ **历史修复说明**(同一个 bug 出现过两次,值得记下来):
186
+
187
+ - 1.0.7 及以前,范围末段是 `>=0.1.5-alpha.1`,使宿主 `0.1.6-alpha.2` 在 npm **默认**语义下
188
+ 被判为**不兼容**(预发布门禁要求 tuple 相同:`0.1.5 ≠ 0.1.6`,`>=` 的大小比较根本走不到)。
189
+ - 1.0.8 改为逐段枚举,但只枚举到 `0.1.6`。DSH 随后发布 `0.1.7-rc.2`,**同一个假阴性再次出现**:
190
+ 范围里最后一个预发布 tuple 是 `0.1.6`,而 `0.1.7 ≠ 0.1.6`。1.0.9 补上 `0.1.7` 段。
191
+ - 1.0.10 把枚举抽成 `COVERED_LINES` 单一事实来源 + 漂移/可达/递增断言,并让实时守卫给出
192
+ **自验证**的修复配方——这个 bug 的**成因**(手改长串)从此被消掉。
193
+
194
+ 两次都因为插件市场传入 `includePrerelease: true` 而侥幸放行,但任何标准 semver 判定
195
+ (`npm` / `pnpm` 的依赖解析)都会拒绝。现在 `host-range-test.mjs` 同时具备固定矩阵与实时守卫。
111
196
 
112
197
  **未验证范围**:macOS / Linux、`headless` 与 `tui` profile、以及上表之外的其他 DSH 版本均未实测。
113
198
 
package/lib/client.js CHANGED
@@ -122,7 +122,19 @@ window.__ModuleLoader__.load({
122
122
  body: JSON.stringify({ method, args: args === undefined ? null : args }),
123
123
  signal: controller !== null ? controller.signal : undefined,
124
124
  })
125
- if (!response.ok) throw new Error('宿主返回 HTTP ' + response.status)
125
+ if (!response.ok) {
126
+ // 413/415 carry a readable reason in the JSON body. Reporting only the
127
+ // status code left the user staring at "宿主返回 HTTP 413" with no
128
+ // hint that the import file was simply too large.
129
+ let detail = ''
130
+ try {
131
+ const body = await response.json()
132
+ if (body !== null && typeof body === 'object' && typeof body.message === 'string') detail = body.message
133
+ } catch (err) {
134
+ // A non-JSON error body leaves the status code as the only clue.
135
+ }
136
+ throw new Error('宿主返回 HTTP ' + response.status + (detail === '' ? '' : ':' + detail))
137
+ }
126
138
  return await response.json()
127
139
  } catch (err) {
128
140
  if (controller !== null && controller.signal.aborted) throw new Error('请求宿主超时(15 秒无响应)')
package/lib/index.js CHANGED
@@ -10,36 +10,64 @@
10
10
  * plugin's fiber, so unmounting the row removes all of them.
11
11
  */
12
12
 
13
- import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
13
+ import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
14
14
  import { homedir } from 'node:os'
15
15
  import { dirname, join } from 'node:path'
16
16
 
17
17
  export const name = 'instruction-memory'
18
18
 
19
19
  /**
20
- * Hard dependencies, declared rather than probed.
20
+ * `systemPrompt` is the one hard dependency: without it there is nothing to
21
+ * inject into. `webServer` deliberately is NOT declared here any more.
21
22
  *
22
- * This row mounts early — before the web server has published — so a one-shot
23
- * `ctx.get('webServer')` at apply time resolves `undefined` and the route is
24
- * lost for the life of the process. Declaring it here makes Cordis hold the row
25
- * until the service exists.
23
+ * It used to be, to fix a real race — this row mounts early, before the web
24
+ * server has published, so a one-shot `ctx.get('webServer')` at apply time
25
+ * resolved `undefined` and the route was lost for the life of the process. But
26
+ * declaring it made the web server a *precondition for the whole plugin*: in a
27
+ * profile that has no web server at all (headless, tui) the row stays pending
28
+ * forever, `apply()` never runs, and the user silently loses BOTH the settings
29
+ * page and the prompt injection — even though `apply()` has always been able to
30
+ * serve memory without any web server. Memory must not be gated on the UI.
31
+ *
32
+ * The route is now registered from a non-blocking `ctx.inject(['webServer'], …)`
33
+ * child fiber, which solves the original race properly: the callback runs
34
+ * whenever the service is published, not only if it beat this row to it.
26
35
  *
27
36
  * The harness `fs` service is deliberately NOT used. Its default base is the
28
37
  * host process cwd, which is exactly what `@deepseek-ai/dsh-home-paths` warns
29
38
  * against: a user relaunching DSH from another directory would silently get a
30
39
  * second, empty store. Plugin-owned data belongs under the harness home.
31
40
  */
32
- export const inject = ['systemPrompt', 'webServer']
41
+ export const inject = ['systemPrompt']
33
42
 
34
43
  const STORE_DIR_NAME = 'instruction-memory'
35
44
  const STORE_FILE_NAME = 'memory.json'
36
45
  /** Pre-release location, kept only so existing installs can migrate off it. */
37
46
  const LEGACY_FILE_NAME = '.dsh-instruction-memory.json'
38
47
  const SECTION_NAME = 'instruction-memory'
39
- const SECTION_ORDER = 900
48
+ /**
49
+ * Placement of the injected instruction block among the system prompt's
50
+ * ordered sections. Sections are concatenated ascending; equal orders fall
51
+ * back to code-unit name order, which is not something a plugin can reason
52
+ * about — so this value must not collide with a reserved slot.
53
+ *
54
+ * `@deepseek-ai/dsh-system-prompt` owns a central `SECTION_ORDERS` table
55
+ * (0.1.7-rc.2): HARNESS_IDENTITY -1000, DEPLOYMENT_PERSONA_PREFIX 0,
56
+ * PLAN_POLICY 500, TEAM_POLICY 600, PTC_ONLY 800, FILE_REFERENCE **900**,
57
+ * TOOL_BASH 1000, … STRUCTURED_OUTPUT 9900, HARNESS_SOURCE 10000,
58
+ * WEB_SURFACE 10100, DEPLOYMENT_PERSONA_SUFFIX 10200.
59
+ *
60
+ * This was 900 through 1.0.8 — an exact tie with FILE_REFERENCE. It never
61
+ * threw (a tie only degrades to dictionary order), and it happened to sort
62
+ * the way we wanted: `instruction-memory` > `file-reference` on code units
63
+ * ('i' 105 > 'f' 102), so the block landed just after the file references.
64
+ * That is an accident of the name, not a decision, and renaming the section
65
+ * would silently move it. 950 states the intent outright: in the gap between
66
+ * FILE_REFERENCE (900) and TOOL_BASH (1000) — after the file-reference block,
67
+ * before the tool descriptions, clear of every reserved value.
68
+ */
69
+ const SECTION_ORDER = 950
40
70
  const API_PREFIX = '/instruction-memory/api'
41
- /** Request body ceiling for the settings-page route; anything larger is rejected. */
42
- const MAX_BODY_CHARS = 1_000_000
43
71
 
44
72
  /**
45
73
  * Resolve this plugin's data file under the harness home.
@@ -62,6 +90,21 @@ const MAX_TITLE = 120
62
90
  const MAX_CONTENT = 6000
63
91
  const MAX_WHEN = 200
64
92
 
93
+ /**
94
+ * Request body ceiling for the settings-page route; anything larger is refused
95
+ * with a clean 413.
96
+ *
97
+ * Derived from the entry limits instead of hard-coded, because the two must not
98
+ * drift apart. A fixed 1,000,000 sat BELOW the largest legal payload — 200
99
+ * entries carrying 6,000 characters each is already 1.2 M characters of body —
100
+ * so a maximal store could be exported by this plugin and then never imported
101
+ * back into it: the route rejected the plugin's own export with 413. The factor
102
+ * of two covers JSON string escaping (a `"` or a newline costs two characters);
103
+ * the flat slack covers the `{method,args:{payload:{app,…,data:{entries:[…]}}}}`
104
+ * wrapper.
105
+ */
106
+ export const MAX_BODY_CHARS = MAX_ENTRIES * (MAX_CONTENT + MAX_TITLE + MAX_WHEN + 200) * 2 + 100_000
107
+
65
108
  /* ------------------------------------------------------------------ *
66
109
  * Pure helpers (no ctx, fully unit-testable)
67
110
  * ------------------------------------------------------------------ */
@@ -122,6 +165,28 @@ export function sanitizeEntry(raw) {
122
165
  }
123
166
  }
124
167
 
168
+ /**
169
+ * Which fields of a raw entry had to be cut to fit the stored limits.
170
+ *
171
+ * `sanitizeEntry` silently enforces MAX_TITLE / MAX_CONTENT / MAX_WHEN, which
172
+ * is fine for the editor (its inputs carry the same maxLength) but wrong for
173
+ * import and hand-edited payloads: a 7,000-character instruction came back
174
+ * stored as 6,000 with no indication that anything was lost, contradicting the
175
+ * plugin's stated rule of never discarding user text without saying so. Callers
176
+ * use this to disclose the cut; the sanitizer itself stays pure and unchanged.
177
+ *
178
+ * @returns the names of the fields that were shortened, in field order.
179
+ */
180
+ export function truncatedFields(raw) {
181
+ if (raw === null || typeof raw !== 'object') return []
182
+ const cut = []
183
+ const unlimited = Number.MAX_SAFE_INTEGER
184
+ if (typeof raw.title === 'string' && singleLine(raw.title, unlimited) !== singleLine(raw.title, MAX_TITLE)) cut.push('标题')
185
+ if (typeof raw.content === 'string' && cleanText(raw.content, unlimited) !== cleanText(raw.content, MAX_CONTENT)) cut.push('内容')
186
+ if (typeof raw.when === 'string' && singleLine(raw.when, unlimited) !== singleLine(raw.when, MAX_WHEN)) cut.push('适用场景')
187
+ return cut
188
+ }
189
+
125
190
  /**
126
191
  * Load a parsed memory file. Returns the sanitized data plus how many array
127
192
  * items were discarded (blank entries, or valid ones past the cap). The count
@@ -154,11 +219,107 @@ function normalizeData(raw) {
154
219
  return { data, dropped }
155
220
  }
156
221
 
222
+ /**
223
+ * A structural copy of the store, shallow per entry.
224
+ *
225
+ * The mutating commands edit `state.data` in place (push, filter, field
226
+ * assignment), so holding a plain reference is not enough to undo one — the
227
+ * "previous" value would already have changed with it. Entries are flat
228
+ * objects, so one spread per entry is a complete copy, and at the 200-entry cap
229
+ * that is a trivial cost on a path that is already doing a disk write.
230
+ */
231
+ function cloneData(data) {
232
+ return {
233
+ version: 1,
234
+ enabled: data.enabled === true,
235
+ budgetChars: data.budgetChars,
236
+ entries: (Array.isArray(data.entries) ? data.entries : []).map((entry) => ({ ...entry })),
237
+ }
238
+ }
239
+
157
240
  const droppedEntriesMessage = (count) =>
158
241
  count > 0
159
242
  ? '存储文件中有 ' + count + ' 条条目未能载入(内容为空或超过 ' + MAX_ENTRIES + ' 条上限);下次保存时它们将从文件中移除'
160
243
  : null
161
244
 
245
+ /**
246
+ * Hostnames a loopback-bound server may legitimately be reached by.
247
+ *
248
+ * `localhost` is included for people who open the GUI that way; the IPv4-mapped
249
+ * form covers a dual-stack listener reporting the client as `::ffff:127.0.0.1`.
250
+ */
251
+ const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1', '::ffff:127.0.0.1'])
252
+
253
+ /**
254
+ * The hostname part of a `Host` header, lower-cased and without its port.
255
+ *
256
+ * `127.0.0.1:8080` -> `127.0.0.1`; `[::1]:8080` -> `::1`; `localhost` -> `localhost`.
257
+ * Exported so the parsing itself can be pinned by tests.
258
+ */
259
+ export function hostnameOf(value) {
260
+ if (typeof value !== 'string') return ''
261
+ const trimmed = value.trim().toLowerCase()
262
+ if (trimmed === '') return ''
263
+ if (trimmed.startsWith('[')) {
264
+ const end = trimmed.indexOf(']')
265
+ return end === -1 ? trimmed : trimmed.slice(1, end)
266
+ }
267
+ const colon = trimmed.lastIndexOf(':')
268
+ return colon === -1 ? trimmed : trimmed.slice(0, colon)
269
+ }
270
+
271
+ /**
272
+ * Why the settings-page route checks `Host` and `Origin` at all, when it already
273
+ * refuses any content-type that is not `application/json`.
274
+ *
275
+ * The content-type rule stops a cross-site form post, and no CORS headers are
276
+ * sent so a cross-origin `fetch` with a JSON body dies in preflight. Both
277
+ * defences assume the browser sees the request as *cross-origin*. DNS
278
+ * rebinding removes that assumption: a page served from `evil.com` whose DNS
279
+ * is re-pointed at 127.0.0.1 is, to the browser, same-origin with the server it
280
+ * then talks to — so no preflight happens, the JSON content-type is allowed,
281
+ * and the plugin would happily rewrite the user's long-lived instructions,
282
+ * which are injected into every later prompt. The one thing the attacker cannot
283
+ * change is the `Host` header the browser sends: it still carries `evil.com`.
284
+ *
285
+ * So when the server is bound to loopback (the default) the route requires a
286
+ * loopback `Host`. An operator who binds `0.0.0.0` has deliberately published
287
+ * the whole server on every interface, where no Host value can be known in
288
+ * advance — the check is disabled there rather than breaking every legitimate
289
+ * client, and that trade-off is documented in the README.
290
+ */
291
+ export function requestOriginVerdict(headers, guarded) {
292
+ const read = (name) => {
293
+ if (headers === null || typeof headers !== 'object') return ''
294
+ const value = headers[name]
295
+ return typeof value === 'string' ? value : ''
296
+ }
297
+ const requestHost = read('host').trim().toLowerCase()
298
+ const requestHostname = hostnameOf(requestHost)
299
+
300
+ if (guarded && !LOOPBACK_HOSTNAMES.has(requestHostname)) {
301
+ return { ok: false, reason: 'Host 头不是回环地址(可能是 DNS 重绑定攻击):' + (requestHost === '' ? '(缺失)' : requestHost) }
302
+ }
303
+
304
+ const origin = read('origin').trim()
305
+ if (origin !== '') {
306
+ let originHostname = ''
307
+ try {
308
+ originHostname = new URL(origin).hostname.toLowerCase()
309
+ } catch {
310
+ originHostname = ''
311
+ }
312
+ // Compare hostnames, not host:port: a proxy may rewrite the port, and the
313
+ // hostname is the part rebinding actually attacks. `Origin: null`
314
+ // (sandboxed iframe, file://) parses to no hostname and is refused.
315
+ if (originHostname === '' || originHostname !== requestHostname) {
316
+ return { ok: false, reason: 'Origin 与 Host 不同源:' + origin }
317
+ }
318
+ }
319
+
320
+ return { ok: true, reason: null }
321
+ }
322
+
162
323
  function formatEntry(entry, tag) {
163
324
  const lines = ['[' + tag + '|优先级 ' + priorityLabel(entry.priority) + '] ' + entry.title]
164
325
  if (entry.mode === 'auto' && entry.when !== '') lines.push('适用场景:' + entry.when)
@@ -261,7 +422,6 @@ export function buildBlock(data) {
261
422
 
262
423
  export function apply(ctx) {
263
424
  const systemPrompt = ctx.systemPrompt
264
- const webServer = ctx.get('webServer')
265
425
 
266
426
  const state = {
267
427
  data: { version: 1, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [] },
@@ -269,6 +429,10 @@ export function apply(ctx) {
269
429
  storageDisplay: null,
270
430
  storageError: null,
271
431
  storageWarning: null,
432
+ /** A failed rolling-backup refresh, shown only when no load warning wins. */
433
+ backupError: null,
434
+ /** Which branch of the load actually produced the current data (diagnostics). */
435
+ loadedFrom: null,
272
436
  savedAt: null,
273
437
  migratedFrom: null,
274
438
  sectionRegistered: false,
@@ -283,6 +447,56 @@ export function apply(ctx) {
283
447
  return String(err)
284
448
  }
285
449
 
450
+ /* ---------------- diagnostics ---------------- */
451
+
452
+ /**
453
+ * The plugin used to be completely silent on success: every load, save and
454
+ * injection produced no record at all, and only failures reached
455
+ * `console.error`. "Is memory actually doing anything?" was therefore
456
+ * unanswerable from logs — verifying it meant substituting route snapshots
457
+ * and transcript forensics for real evidence.
458
+ *
459
+ * This adds an opt-in diagnostic channel on the harness logger. It is
460
+ * log-only: it never touches what gets injected, so the rendered block stays
461
+ * byte-identical with it on or off.
462
+ *
463
+ * info — one line per mount (where the store is, how much is injected)
464
+ * debug — per-operation detail, including the ones the route guards reject
465
+ *
466
+ * With no logger service (the offline harnesses) debug needs
467
+ * DSH_INSTRUCTION_MEMORY_DEBUG=1; info always falls back to console.error so
468
+ * a mount failure is never swallowed.
469
+ */
470
+ const logger = (() => {
471
+ try {
472
+ const candidate = typeof ctx.logger === 'function' ? ctx.logger('instruction-memory') : ctx.logger
473
+ if (candidate !== null && candidate !== undefined && typeof candidate.info === 'function') return candidate
474
+ } catch {
475
+ // A context without a logger service must never break the plugin.
476
+ }
477
+ return null
478
+ })()
479
+
480
+ const debugToConsole = (() => {
481
+ const raw = typeof process.env.DSH_INSTRUCTION_MEMORY_DEBUG === 'string'
482
+ ? process.env.DSH_INSTRUCTION_MEMORY_DEBUG.trim().toLowerCase()
483
+ : ''
484
+ return raw === '1' || raw === 'true' || raw === 'yes'
485
+ })()
486
+
487
+ const logInfo = (message) => {
488
+ if (logger !== null) logger.info(message)
489
+ else console.error('[instruction-memory] ' + message)
490
+ }
491
+
492
+ const logDebug = (message) => {
493
+ if (logger !== null) {
494
+ if (typeof logger.debug === 'function') logger.debug(message)
495
+ return
496
+ }
497
+ if (debugToConsole) console.error('[instruction-memory] ' + message)
498
+ }
499
+
286
500
  const sectionText = () => {
287
501
  try {
288
502
  return buildBlock(state.data)
@@ -304,6 +518,7 @@ export function apply(ctx) {
304
518
  sectionDispose = systemPrompt.section({ name: SECTION_NAME, order: SECTION_ORDER, text: sectionText })
305
519
  state.sectionRegistered = true
306
520
  state.sectionError = null
521
+ logDebug('prompt section registered: order=' + SECTION_ORDER + ' chars=' + sectionText().length)
307
522
  } catch (err) {
308
523
  sectionDispose = null
309
524
  state.sectionRegistered = false
@@ -318,6 +533,7 @@ export function apply(ctx) {
318
533
  sectionDispose = null
319
534
  try {
320
535
  dispose()
536
+ logDebug('prompt section disposed (nothing to inject)')
321
537
  } catch (err) {
322
538
  console.error('[instruction-memory] section dispose failed:', err)
323
539
  }
@@ -350,16 +566,33 @@ export function apply(ctx) {
350
566
  // one-version backup: a crash mid-write must never leave a truncated
351
567
  // memory.json, and an edit the user regrets must still be recoverable
352
568
  // from memory.json.bak. The .bak holds the previous good version.
569
+ //
570
+ // The backup is a COPY, not a rename. Renaming the live file out of the way
571
+ // first opened a window in which memory.json did not exist: if the rename
572
+ // that followed failed (a lock, a full disk, an indexer holding the file)
573
+ // the primary path stayed missing, and the next boot — finding no
574
+ // memory.json — materialised an EMPTY store and silently discarded the
575
+ // user's memory while their real data sat untouched in .bak. Copying keeps
576
+ // the primary in place until the atomic replace succeeds, so a failure at
577
+ // any step leaves the previous store readable at its own path.
353
578
  const tmpPath = storePath + '.tmp'
354
579
  const bakPath = storePath + '.bak'
355
580
  try {
356
581
  await mkdir(dirname(storePath), { recursive: true })
357
- await writeFile(tmpPath, serialize(), 'utf8')
582
+ const text = serialize()
583
+ await writeFile(tmpPath, text, 'utf8')
358
584
  try {
359
- await rename(storePath, bakPath)
585
+ await copyFile(storePath, bakPath)
586
+ state.backupError = null
360
587
  } catch (err) {
361
- // No previous version on the very first save; anything else is real.
362
- if (err.code !== 'ENOENT') throw err
588
+ // ENOENT is simply the very first save: there is no previous version
589
+ // to roll. Any other failure means the promised rolling backup did not
590
+ // happen — non-fatal for this save, because the primary is still intact
591
+ // and the replace below is the real gate, but it must be disclosed
592
+ // rather than swallowed.
593
+ state.backupError = err.code === 'ENOENT'
594
+ ? null
595
+ : '上一版未能保留为 memory.json.bak:' + describeError(err)
363
596
  }
364
597
  try {
365
598
  await rename(tmpPath, storePath)
@@ -371,6 +604,9 @@ export function apply(ctx) {
371
604
  state.storageDisplay = storePath
372
605
  state.storageError = null
373
606
  state.savedAt = Date.now()
607
+ logDebug('store written: ' + storePath + ' bytes=' + text.length
608
+ + ' entries=' + state.data.entries.length
609
+ + (state.backupError === null ? ' backup=refreshed' : ' backup=FAILED'))
374
610
  return true
375
611
  } catch (err) {
376
612
  state.storageError = '保存失败:' + describeError(err)
@@ -387,6 +623,7 @@ export function apply(ctx) {
387
623
  state.storagePath = storePath
388
624
  state.storageDisplay = storePath
389
625
  state.storageWarning = null
626
+ state.loadedFrom = null
390
627
 
391
628
  // 1. The canonical location under the harness home.
392
629
  try {
@@ -395,6 +632,7 @@ export function apply(ctx) {
395
632
  const loaded = normalizeData(parsed)
396
633
  state.data = loaded.data
397
634
  state.storageWarning = droppedEntriesMessage(loaded.dropped)
635
+ state.loadedFrom = 'memory.json'
398
636
  }
399
637
  state.storageError = null
400
638
  return
@@ -414,6 +652,7 @@ export function apply(ctx) {
414
652
  state.storageWarning = (droppedEntriesMessage(loaded.dropped) || '')
415
653
  + (loaded.dropped > 0 ? ';' : '')
416
654
  + 'memory.json 解析失败(' + describeError(err) + '),已临时回退到最近备份 memory.json.bak;损坏文件已保留,下次保存时会覆盖它'
655
+ state.loadedFrom = 'memory.json.bak (main file corrupt)'
417
656
  return
418
657
  }
419
658
  } catch {
@@ -428,6 +667,31 @@ export function apply(ctx) {
428
667
  }
429
668
  }
430
669
 
670
+ // 1b. The primary file is gone but a rolling backup survives. That is the
671
+ // state an interrupted save leaves behind (the backup step used to
672
+ // rename the primary out of the way before the replace), and it is also
673
+ // what a stray delete looks like. Falling through to "first run" here
674
+ // would materialise an EMPTY store and silently present the user with
675
+ // no memory at all while their previous version sat right there in
676
+ // .bak — so recover it, and promote the recovered data back to the
677
+ // canonical path so the next boot finds it normally.
678
+ try {
679
+ const backup = await readStoreFile(storePath + '.bak')
680
+ if (backup !== null) {
681
+ const loaded = normalizeData(backup)
682
+ state.data = loaded.data
683
+ const promoted = await writeToDisk()
684
+ state.storageWarning = (droppedEntriesMessage(loaded.dropped) || '')
685
+ + (loaded.dropped > 0 ? ';' : '')
686
+ + 'memory.json 不存在,已从最近备份 memory.json.bak 恢复'
687
+ + (promoted ? ',并已写回 memory.json' : ';写回 memory.json 失败,下次保存时会重试')
688
+ state.loadedFrom = 'memory.json.bak (main file missing)'
689
+ return
690
+ }
691
+ } catch {
692
+ // An unusable backup must never block a fresh store; fall through.
693
+ }
694
+
431
695
  // 2. Migrate the pre-release location (host process cwd). This is the only
432
696
  // reason the legacy name is still known to this plugin.
433
697
  const legacyPath = join(process.cwd(), LEGACY_FILE_NAME)
@@ -442,6 +706,7 @@ export function apply(ctx) {
442
706
  // Keep the original as evidence instead of deleting user data.
443
707
  await rename(legacyPath, legacyPath + '.migrated').catch(() => {})
444
708
  }
709
+ state.loadedFrom = 'legacy cwd file (migrated)'
445
710
  return
446
711
  }
447
712
  } catch (err) {
@@ -452,6 +717,7 @@ export function apply(ctx) {
452
717
  // 3. First run: materialise an empty store so the path is real and the
453
718
  // settings page can show it.
454
719
  await writeToDisk()
720
+ if (state.loadedFrom === null) state.loadedFrom = 'first run (empty store created)'
455
721
  }
456
722
 
457
723
  /* ---------------- boot ---------------- */
@@ -465,6 +731,15 @@ export function apply(ctx) {
465
731
  })
466
732
  .then(() => {
467
733
  syncSection()
734
+ // The one always-on line: it answers "is memory mounted, where does it
735
+ // live, and how much is being injected?" without any further digging.
736
+ const chars = sectionText().length
737
+ logInfo('mounted: store=' + storePath
738
+ + ' from=' + String(state.loadedFrom)
739
+ + ' entries=' + state.data.entries.length
740
+ + ' enabled=' + (state.data.enabled === true)
741
+ + ' injected=' + (chars > 0 ? chars + ' chars' : 'nothing')
742
+ + (state.storageError !== null ? ' error=' + state.storageError : ''))
468
743
  })
469
744
 
470
745
  /**
@@ -496,7 +771,7 @@ export function apply(ctx) {
496
771
  storage: {
497
772
  path: state.storageDisplay !== null ? state.storageDisplay : state.storagePath,
498
773
  error: state.storageError,
499
- warning: state.storageWarning,
774
+ warning: state.storageWarning !== null ? state.storageWarning : state.backupError,
500
775
  savedAt: state.savedAt,
501
776
  migratedFrom: state.migratedFrom,
502
777
  },
@@ -520,46 +795,78 @@ export function apply(ctx) {
520
795
 
521
796
  /* ---------------- commands (shared by route and callers) ---------------- */
522
797
 
798
+ /**
799
+ * Apply an in-memory mutation, persist it, and UNDO it when the write fails.
800
+ *
801
+ * Every mutating command used to edit `state.data`, re-register the prompt
802
+ * section, and only then find out that the disk write had failed. The panel
803
+ * said 保存失败 and memory.json was correctly left alone — but the injected
804
+ * system prompt kept carrying the unsaved edit for the rest of the process
805
+ * lifetime, so the model went on obeying an instruction the user had just
806
+ * been told was not saved. The successful write is the commit point: fail it
807
+ * and the in-memory state, the prompt section and the panel roll back
808
+ * together.
809
+ *
810
+ * @returns whether the store was persisted.
811
+ */
812
+ const commit = async (mutate) => {
813
+ const previousData = cloneData(state.data)
814
+ const previousWarning = state.storageWarning
815
+ mutate()
816
+ syncSection()
817
+ if (await writeToDisk()) {
818
+ // A successful save has rewritten the file to match memory, so a
819
+ // dropped-entries warning from load time ("they will be removed on the
820
+ // next save") has played out and must not keep showing.
821
+ state.storageWarning = null
822
+ return true
823
+ }
824
+ logDebug('commit rolled back: the disk write failed, in-memory state restored')
825
+ state.data = previousData
826
+ state.storageWarning = previousWarning
827
+ syncSection()
828
+ return false
829
+ }
830
+
523
831
  const saveEntry = (raw) => enqueue(async () => {
524
832
  const entry = sanitizeEntry(raw)
525
833
  if (entry === null) return { ok: false, saved: false, message: '标题与指令内容不能同时为空', snapshot: snapshot() }
526
834
  entry.updatedAt = Date.now()
527
- const list = state.data.entries
528
- const index = list.findIndex((candidate) => candidate.id === entry.id)
529
- if (index >= 0) list[index] = entry
530
- else {
531
- if (list.length >= MAX_ENTRIES) {
532
- return { ok: false, saved: false, message: '最多保存 ' + MAX_ENTRIES + ' 条指令', snapshot: snapshot() }
533
- }
534
- list.push(entry)
835
+ // Resolved before the mutation so a refusal can return a response instead
836
+ // of being swallowed as an early exit inside it.
837
+ const replacesExisting = state.data.entries.some((candidate) => candidate.id === entry.id)
838
+ if (!replacesExisting && state.data.entries.length >= MAX_ENTRIES) {
839
+ return { ok: false, saved: false, message: '最多保存 ' + MAX_ENTRIES + ' 条指令', snapshot: snapshot() }
535
840
  }
536
- syncSection()
537
- const saved = await writeToDisk()
538
- // A successful save has rewritten the file to match memory, so a
539
- // dropped-entries warning from load time ("they will be removed on the
540
- // next save") has played out and must not keep showing.
541
- if (saved) state.storageWarning = null
542
- return settle(null, saved)
841
+ const cut = truncatedFields(raw)
842
+ const saved = await commit(() => {
843
+ const list = state.data.entries
844
+ const index = list.findIndex((candidate) => candidate.id === entry.id)
845
+ if (index >= 0) list[index] = entry
846
+ else list.push(entry)
847
+ })
848
+ const note = cut.length > 0
849
+ ? '已保存;' + cut.join('、') + '超过长度上限(标题 ' + MAX_TITLE + ' / 内容 ' + MAX_CONTENT + ' / 适用场景 ' + MAX_WHEN + ' 字符),超出部分已被截断'
850
+ : null
851
+ return settle(note, saved)
543
852
  })
544
853
 
545
854
  const deleteEntry = (id) => enqueue(async () => {
546
- state.data.entries = state.data.entries.filter((entry) => entry.id !== id)
547
- syncSection()
548
- const saved = await writeToDisk()
549
- if (saved) state.storageWarning = null
855
+ const saved = await commit(() => {
856
+ state.data.entries = state.data.entries.filter((entry) => entry.id !== id)
857
+ })
550
858
  return settle(null, saved)
551
859
  })
552
860
 
553
861
  const setOptions = (patch) => enqueue(async () => {
554
- if (patch !== null && typeof patch === 'object') {
555
- if (typeof patch.enabled === 'boolean') state.data.enabled = patch.enabled
556
- if (typeof patch.budgetChars === 'number' && Number.isFinite(patch.budgetChars)) {
557
- state.data.budgetChars = clampBudget(patch.budgetChars)
862
+ const saved = await commit(() => {
863
+ if (patch !== null && typeof patch === 'object') {
864
+ if (typeof patch.enabled === 'boolean') state.data.enabled = patch.enabled
865
+ if (typeof patch.budgetChars === 'number' && Number.isFinite(patch.budgetChars)) {
866
+ state.data.budgetChars = clampBudget(patch.budgetChars)
867
+ }
558
868
  }
559
- }
560
- syncSection()
561
- const saved = await writeToDisk()
562
- if (saved) state.storageWarning = null
869
+ })
563
870
  return settle(null, saved)
564
871
  })
565
872
 
@@ -596,10 +903,15 @@ export function apply(ctx) {
596
903
  // Merge, never replace: importing must not be able to wipe what is here.
597
904
  // Duplicates (same id, or same title+content) and invalid items are
598
905
  // skipped and counted, not silently dropped.
906
+ //
907
+ // The whole plan is computed BEFORE the store is touched, so the merge and
908
+ // its counters cannot disagree with what actually gets appended.
599
909
  const existing = state.data.entries
600
910
  const seenIds = new Set(existing.map((entry) => entry.id))
601
911
  const seenBodies = new Set(existing.map((entry) => entry.title + '\u0000' + entry.content))
602
- let imported = 0
912
+ const accepted = []
913
+ const cutFields = new Set()
914
+ let truncatedCount = 0
603
915
  let skipped = 0
604
916
  for (const candidate of rawEntries) {
605
917
  const entry = sanitizeEntry(candidate)
@@ -607,23 +919,36 @@ export function apply(ctx) {
607
919
  skipped += 1
608
920
  continue
609
921
  }
610
- if (existing.length >= MAX_ENTRIES) {
922
+ if (existing.length + accepted.length >= MAX_ENTRIES) {
611
923
  skipped += 1
612
924
  continue
613
925
  }
926
+ const cut = truncatedFields(candidate)
927
+ if (cut.length > 0) {
928
+ // sanitizeEntry enforces the limits silently, which is fine for the
929
+ // editor (its inputs carry the same maxLength) but wrong here: an
930
+ // imported 7,000-character instruction would come back as 6,000 with
931
+ // nothing said about the loss.
932
+ truncatedCount += 1
933
+ for (const field of cut) cutFields.add(field)
934
+ }
614
935
  entry.updatedAt = Date.now()
615
- existing.push(entry)
936
+ accepted.push(entry)
616
937
  seenIds.add(entry.id)
617
938
  seenBodies.add(entry.title + '\u0000' + entry.content)
618
- imported += 1
619
939
  }
620
- if (imported === 0) {
621
- return { ok: true, saved: true, message: '没有新增条目:' + skipped + ' 条已存在或无效', snapshot: snapshot() }
940
+ if (accepted.length === 0) {
941
+ // Nothing was written to disk, so claiming saved:true would be a lie the
942
+ // panel cannot distinguish from a real save.
943
+ return { ok: true, saved: false, message: '没有新增条目:' + skipped + ' 条已存在或无效', snapshot: snapshot() }
622
944
  }
623
- syncSection()
624
- const saved = await writeToDisk()
625
- if (saved) state.storageWarning = null
626
- return settle('导入完成:新增 ' + imported + ' 条,跳过 ' + skipped + ' 条(已存在或无效)', saved)
945
+ const saved = await commit(() => {
946
+ state.data.entries.push(...accepted)
947
+ })
948
+ const truncated = truncatedCount > 0
949
+ ? ';其中 ' + truncatedCount + ' 条超过长度上限,' + [...cutFields].join('、') + '已被截断'
950
+ : ''
951
+ return settle('导入完成:新增 ' + accepted.length + ' 条,跳过 ' + skipped + ' 条(已存在或无效)' + truncated, saved)
627
952
  })
628
953
 
629
954
  const dispatch = async (method, args) => {
@@ -652,84 +977,119 @@ export function apply(ctx) {
652
977
 
653
978
  /* ---------------- same-origin JSON route for the Client half ---------------- */
654
979
 
980
+ // Which Host values this route accepts. Set when the route is registered,
981
+ // from the address the web server is actually bound to (see the long note on
982
+ // requestOriginVerdict). Until then the route does not exist at all, so the
983
+ // default only matters if something registers it out of band.
984
+ let guardHost = true
985
+
655
986
  let routeDispose = null
656
- if (webServer !== undefined && typeof webServer.register === 'function') {
657
- const handler = (req, res) => {
658
- if (req.method !== 'POST') {
659
- res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
660
- res.end(JSON.stringify({ ok: false, message: 'only POST is supported' }))
661
- return
662
- }
663
- // The settings page always posts application/json. Enforcing it turns
664
- // the anti-CSRF coincidence (a plain HTML form cannot fabricate a JSON
665
- // body) into a hard rule: a cross-site or malformed post now gets a
666
- // readable 415 instead of a confusing parse failure.
667
- const contentType = String(
668
- (req.headers !== null && typeof req.headers === 'object') ? (req.headers['content-type'] || '') : '',
669
- )
670
- if (contentType.indexOf('application/json') === -1) {
671
- res.writeHead(415, { 'content-type': 'application/json; charset=utf-8' })
672
- res.end(JSON.stringify({ ok: false, message: 'content-type 必须是 application/json' }))
673
- return
987
+ const handler = (req, res) => {
988
+ if (req.method !== 'POST') {
989
+ res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
990
+ res.end(JSON.stringify({ ok: false, message: 'only POST is supported' }))
991
+ return
992
+ }
993
+ // Origin/Host guard, before anything else is read. See
994
+ // requestOriginVerdict() for why the content-type rule below is not enough
995
+ // on its own.
996
+ const verdict = requestOriginVerdict(req.headers, guardHost)
997
+ if (!verdict.ok) {
998
+ logInfo('route rejected a request: ' + verdict.reason)
999
+ res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
1000
+ res.end(JSON.stringify({ ok: false, message: '请求来源被拒绝:' + verdict.reason }))
1001
+ return
1002
+ }
1003
+ // The settings page always posts application/json. Enforcing it turns
1004
+ // the anti-CSRF coincidence (a plain HTML form cannot fabricate a JSON
1005
+ // body) into a hard rule: a cross-site or malformed post now gets a
1006
+ // readable 415 instead of a confusing parse failure.
1007
+ const contentType = String(
1008
+ (req.headers !== null && typeof req.headers === 'object') ? (req.headers['content-type'] || '') : '',
1009
+ )
1010
+ if (contentType.indexOf('application/json') === -1) {
1011
+ res.writeHead(415, { 'content-type': 'application/json; charset=utf-8' })
1012
+ res.end(JSON.stringify({ ok: false, message: 'content-type 必须是 application/json' }))
1013
+ return
1014
+ }
1015
+ // A call aborted mid-flight (settings page closed, tab navigated away)
1016
+ // makes these streams emit 'error'. A server stream with no error
1017
+ // listener turns that into an uncaught exception — i.e. it would take
1018
+ // the whole host process down over one dead HTTP request.
1019
+ req.on('error', () => {})
1020
+ res.on('error', () => {})
1021
+ let body = ''
1022
+ // Over the ceiling we stop accumulating but keep draining, so the
1023
+ // response can go out as a clean 413 instead of a destroyed socket the
1024
+ // Client can only report as a generic network failure.
1025
+ let overflow = false
1026
+ req.setEncoding('utf8')
1027
+ req.on('data', (chunk) => {
1028
+ if (overflow) return
1029
+ body += chunk
1030
+ if (body.length > MAX_BODY_CHARS) {
1031
+ overflow = true
1032
+ body = ''
674
1033
  }
675
- // A call aborted mid-flight (settings page closed, tab navigated away)
676
- // makes these streams emit 'error'. A server stream with no error
677
- // listener turns that into an uncaught exception — i.e. it would take
678
- // the whole host process down over one dead HTTP request.
679
- req.on('error', () => {})
680
- res.on('error', () => {})
681
- let body = ''
682
- // Over the ceiling we stop accumulating but keep draining, so the
683
- // response can go out as a clean 413 instead of a destroyed socket the
684
- // Client can only report as a generic network failure.
685
- let overflow = false
686
- req.setEncoding('utf8')
687
- req.on('data', (chunk) => {
688
- if (overflow) return
689
- body += chunk
690
- if (body.length > MAX_BODY_CHARS) {
691
- overflow = true
692
- body = ''
1034
+ })
1035
+ req.on('end', () => {
1036
+ void (async () => {
1037
+ if (overflow) {
1038
+ res.writeHead(413, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
1039
+ res.end(JSON.stringify({ ok: false, message: '请求体超过 ' + MAX_BODY_CHARS + ' 字符上限' }))
1040
+ return
693
1041
  }
694
- })
695
- req.on('end', () => {
696
- void (async () => {
697
- if (overflow) {
698
- res.writeHead(413, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
699
- res.end(JSON.stringify({ ok: false, message: '请求体超过 ' + MAX_BODY_CHARS + ' 字符上限' }))
700
- return
701
- }
702
- let result
703
- try {
704
- const payload = body === '' ? {} : JSON.parse(body)
705
- result = await dispatch(payload.method, payload.args === undefined ? null : payload.args)
706
- } catch (err) {
707
- result = { ok: false, saved: false, message: describeError(err), snapshot: snapshot() }
708
- }
709
- res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
710
- res.end(JSON.stringify(result))
711
- })()
712
- })
1042
+ let result
1043
+ try {
1044
+ const payload = body === '' ? {} : JSON.parse(body)
1045
+ result = await dispatch(payload.method, payload.args === undefined ? null : payload.args)
1046
+ } catch (err) {
1047
+ result = { ok: false, saved: false, message: describeError(err), snapshot: snapshot() }
1048
+ }
1049
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
1050
+ res.end(JSON.stringify(result))
1051
+ })()
1052
+ })
1053
+ }
1054
+
1055
+ // Register the settings-page route from a NON-BLOCKING child fiber. A web
1056
+ // server must not be a precondition for this plugin: in a profile without one
1057
+ // (headless, tui) a blocking dependency would keep the whole row pending,
1058
+ // losing the prompt injection along with the settings page. The callback also
1059
+ // fixes the original race properly — it runs whenever the service is
1060
+ // published, not only if it happened to beat this row to the mount.
1061
+ ctx.inject(['webServer'], (scope) => {
1062
+ const webServer = scope.webServer
1063
+ if (webServer === undefined || typeof webServer.register !== 'function') {
1064
+ console.error('[instruction-memory] webServer published without a register(); the settings page cannot reach the host')
1065
+ return
713
1066
  }
1067
+ // The Host allowlist only makes sense when the operator bound the server to
1068
+ // loopback. `0.0.0.0` means they deliberately published it on every
1069
+ // interface, where no Host value can be known in advance — enforcing a
1070
+ // loopback allowlist there would break every legitimate client, so the
1071
+ // check is disabled and the trade-off is documented in the README.
1072
+ const boundHost = typeof webServer.host === 'string' ? webServer.host.trim().toLowerCase() : '127.0.0.1'
1073
+ guardHost = boundHost !== '0.0.0.0'
714
1074
  try {
715
1075
  routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
1076
+ logDebug('settings route registered at ' + API_PREFIX + ' (boundHost=' + boundHost + ', hostGuard=' + guardHost + ')')
716
1077
  } catch (err) {
717
1078
  console.error('[instruction-memory] route registration failed:', err)
718
1079
  routeDispose = null
719
1080
  }
720
- } else {
721
- console.error('[instruction-memory] webServer unavailable: settings page will not be able to reach the host')
722
- }
723
-
724
- ctx.effect(() => () => {
725
- if (routeDispose !== null) {
726
- const dispose = routeDispose
727
- routeDispose = null
728
- try {
729
- dispose()
730
- } catch (err) {
731
- console.error('[instruction-memory] route dispose failed:', err)
1081
+ // The child fiber is owned by this one, so unmounting the plugin still
1082
+ // disposes the route even though it was registered here.
1083
+ scope.effect(() => () => {
1084
+ if (routeDispose !== null) {
1085
+ const dispose = routeDispose
1086
+ routeDispose = null
1087
+ try {
1088
+ dispose()
1089
+ } catch (err) {
1090
+ console.error('[instruction-memory] route dispose failed:', err)
1091
+ }
732
1092
  }
733
- }
1093
+ })
734
1094
  })
735
1095
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-instruction-memory",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
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",
@@ -61,16 +61,16 @@
61
61
  },
62
62
  "client": {
63
63
  "inject": [
64
- "@deepseek-ai/dsh-client-runtime",
65
- "@deepseek-ai/dsh-client-ui-slots"
64
+ "@deepseek-ai/dsh-client-ui-renderer",
65
+ "@deepseek-ai/dsh-client-ui-settings"
66
66
  ],
67
67
  "platform": "web"
68
68
  },
69
69
  "engines": {
70
- "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.2.0-0 || >=0.2.0-0"
70
+ "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"
71
71
  },
72
72
  "compatibility": {
73
- "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.2.0-0 || >=0.2.0-0"
73
+ "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"
74
74
  }
75
75
  }
76
76
  }