@noob-stupid/dsh-plugin-console 0.5.30 → 0.5.32

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.
Files changed (45) hide show
  1. package/README.md +39 -7
  2. package/README.zh.md +40 -8
  3. package/SECURITY.md +2 -2
  4. package/lib/client.js +17 -3
  5. package/lib/index.js +141 -141
  6. package/lib/server/domain/ai-run.js +1 -1
  7. package/lib/server/domain/ai.js +39 -11
  8. package/lib/server/domain/auto-disable.js +226 -0
  9. package/lib/server/domain/auto-preflight-run.js +184 -0
  10. package/lib/server/domain/auto-preflight.js +345 -0
  11. package/lib/server/domain/compat-state.js +57 -8
  12. package/lib/server/domain/compat.js +7 -3
  13. package/lib/server/domain/components.js +1 -1
  14. package/lib/server/domain/dep-source.js +1 -1
  15. package/lib/server/domain/framework.js +65 -6
  16. package/lib/server/domain/install-job.js +1 -1
  17. package/lib/server/domain/install.js +1 -1
  18. package/lib/server/domain/jobs.js +1 -1
  19. package/lib/server/domain/market.js +1 -1
  20. package/lib/server/domain/patch-yaml-check.js +151 -0
  21. package/lib/server/domain/patch.js +1 -1
  22. package/lib/server/domain/presets.js +1 -1
  23. package/lib/server/domain/release-source.js +3 -3
  24. package/lib/server/domain/repoland.js +1 -1
  25. package/lib/server/domain/runtime.js +1 -1
  26. package/lib/server/domain/safe-boot.js +520 -0
  27. package/lib/server/domain/skills.js +1 -1
  28. package/lib/server/domain/sources.js +1 -1
  29. package/lib/server/domain/suite.js +1 -1
  30. package/lib/server/infra/exec.js +2 -2
  31. package/lib/server/infra/fsx.js +3 -3
  32. package/lib/server/infra/http.js +1 -1
  33. package/lib/server/infra/httpd.js +1 -1
  34. package/lib/server/infra/mask.js +1 -1
  35. package/lib/server/infra/paths.js +1 -1
  36. package/lib/server/infra/semver.js +1 -1
  37. package/lib/server/routes/compat.js +240 -11
  38. package/lib/server/routes/components.js +1 -1
  39. package/lib/server/routes/framework-preflight.js +14 -1
  40. package/lib/server/routes/framework-upgrade.js +16 -17
  41. package/lib/server/routes/framework.js +18 -2
  42. package/lib/server/routes/github-login.js +2 -2
  43. package/lib/server/routes/index.js +8 -1
  44. package/lib/server/routes/safe-boot.js +139 -0
  45. package/package.json +1 -1
@@ -8,20 +8,45 @@
8
8
  // 触发源不再是我们的升级按钮,而是环境指纹变化 —— 官方桌面端自带升级器 / 官方只发桌面端 /
9
9
  // 手动 pnpm 操作,都会在这里被发现(见 domain/compat-state.js 头注释)。
10
10
  //
11
+ // 0.5.32 加法(用户 2026-09-29 诉求「走别的更新通道改了框架后仍要自动守门 / 能挽救改完打不开」):
12
+ // · D1:指纹变化 → **自动跑一次只读预检**(不下载/不安装/不改补丁),落成「兼容清单 + 隔离计划 +
13
+ // 变更摘要」;同一指纹只自动跑一次(以指纹为 key 持久化),失败/超时如实记录且可重试,
14
+ // **绝不阻塞状态查询**(超时降级为「未完成,可手动重跑」);
15
+ // · D2:每次确认服务正常(状态查询成功 + 指纹未变 + 补丁结构合法)落一份 last-known-good 快照到
16
+ // `dshHome()/plugin-console/safe-boot/`(**不是** profile 目录),原子写 + sha256 + 保留 N 份;
17
+ // · D4:检测到桌面端宿主形态时,把**非用户手动设定**的运行模式自动拨回观察者(用户手动优先)。
18
+ //
11
19
  // declare-installed 存在的理由(2026-09-24 用户实测):第三方插件管理器与 pnpm 都只认清单里的
12
20
  // 依赖,手铺安装的包在它们眼里不存在(看不见 + 会被还原)。老规矩:先 plan 再 apply。
13
21
 
14
- import { dirname } from 'node:path'
15
- import { compareFingerprint, computeFingerprint, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode } from '../domain/compat-state.js'
22
+ import { existsSync, readFileSync } from 'node:fs'
23
+ import { dirname, join } from 'node:path'
24
+ import { applyEvidenceDisable } from '../domain/auto-disable.js'
25
+ import { DEFAULT_TIMEOUT_MS, autoPreflightView, runAutoPreflight } from '../domain/auto-preflight.js'
26
+ import { runAutoPreflightScan } from '../domain/auto-preflight-run.js'
16
27
  import { NOTICE_MODES, detectCompat, readCompatAck, readCompatGate, writeCompatAck, writeCompatGate } from '../domain/compat.js'
17
- import { currentFrameworkVersion } from '../domain/framework.js'
28
+ import { compareFingerprint, computeFingerprint, planDesktopHostSwitch, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode } from '../domain/compat-state.js'
29
+ import { currentFrameworkVersion, detectHostShape } from '../domain/framework.js'
18
30
  import { applyDependencyBackfill, planDependencyBackfill } from '../domain/manifest.js'
19
- import { preferredRegistries } from '../domain/sources.js'
31
+ import { isProtectedModule } from '../domain/runtime.js'
32
+ import { listSnapshots, readSafeBootState, validatePatchYaml, writeSnapshot } from '../domain/safe-boot.js'
20
33
  import { reconcileLockfile } from '../domain/selfupdate.js'
34
+ import { listEntries } from '../domain/runtime.js'
35
+ import { preferredRegistries } from '../domain/sources.js'
21
36
  import { sendError, sendJson } from '../infra/httpd.js'
22
- import { baseDirOf, profileDirOf, resolvePackageJson } from '../infra/paths.js'
23
- import { existsSync } from 'node:fs'
24
- import { join } from 'node:path'
37
+ import { baseDirOf, dshHome, findPatchPath, pluginRoot, profileDirOf, resolvePackageJson } from '../infra/paths.js'
38
+ import { preflightRoots } from './framework-preflight.js'
39
+
40
+ const PREFLIGHT_TIMEOUT_ENV = 'DSH_AUTO_PREFLIGHT_TIMEOUT_MS'
41
+ /** 自动预检的预算:到点降级成「未完成,可手动重跑」,状态查询照常返回(env 可调)。 */
42
+ function preflightTimeoutMs() {
43
+ const raw = Number(process.env[PREFLIGHT_TIMEOUT_ENV])
44
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS
45
+ }
46
+
47
+ function pluginVersionOf() {
48
+ try { return JSON.parse(readFileSync(join(pluginRoot(), 'package.json'), 'utf8')).version ?? null } catch { return null }
49
+ }
25
50
 
26
51
  /** 框架运行时根(含 .pnpm 的那层):换了安装根(npx 缓存 ↔ 桌面端自带运行时)指纹必变。
27
52
  * `@deepseek-ai/dsh` 既可能解析到 `<root>/node_modules/@deepseek-ai/dsh`(顶层投影),
@@ -60,28 +85,217 @@ function currentFingerprint(rc) {
60
85
  })
61
86
  }
62
87
 
88
+ /** 桌面端宿主形态(复用既有判据 `detectHostShape`;`rc.deps` 可注入替身,生产路径即真判据)。 */
89
+ function hostShapeOf(rc) {
90
+ const detect = rc?.deps?.detectHostShape ?? detectHostShape
91
+ try { return detect() } catch { return { hosted: false, kind: 'standalone', reasons: [] } }
92
+ }
93
+
94
+ /** D4 只在「启动 / 首次状态查询」判定一次(与用户要求的触发时机一致);之后的状态查询不重复判定。
95
+ * `resetHostSwitchChecked()` 只给测试用:让"首次判定"这件事可被独立驱动(生产路径不调用它)。 */
96
+ let hostSwitchChecked = false
97
+ function resetHostSwitchChecked() { hostSwitchChecked = false }
98
+
99
+ /** D4:桌面端宿主启动 → 非用户手动设定的模式自动拨回观察者(幂等;已是 observer 时零写盘)。 */
100
+ function maybeSwitchToObserver(rc, { force = false } = {}) {
101
+ if (hostSwitchChecked && force !== true) return { switched: false, reason: 'already-checked', note: null }
102
+ hostSwitchChecked = true
103
+ try {
104
+ const current = readCompatMode()
105
+ const verdict = planDesktopHostSwitch(current, { hosted: hostShapeOf(rc).hosted === true })
106
+ if (verdict.action !== 'switch-to-observer') return { switched: false, reason: verdict.reason, note: null }
107
+ const written = writeCompatMode('observer', { source: 'auto:desktop-host', reason: verdict.note.zh })
108
+ return { switched: written.ok === true && written.changed === true, reason: verdict.reason, note: verdict.note }
109
+ } catch (error) {
110
+ return { switched: false, reason: `判定失败:${error instanceof Error ? error.message : String(error)}`, note: null }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * D1:指纹变化 → 后台跑一次只读预检(**不 await**:状态查询该返回什么还返回什么)。
116
+ * 节流闸门在 `runAutoPreflight` 内部**同步**写下的 pending 占位 —— 同指纹的第二次查询会看到
117
+ * pending/done,因此「同一指纹只自动跑一次」与「绝不阻塞」两件事同时成立。
118
+ */
119
+ function scheduleAutoPreflight(rc, { current, previous, reasons, profileDir, patchPath }) {
120
+ const scan = rc?.deps?.autoPreflightScan ?? runAutoPreflightScan
121
+ let roots = []
122
+ const entries = (() => { try { return listEntries(rc.ctx) } catch { return [] } })()
123
+ try { roots = preflightRoots(rc.ctx, profileDir, { listEntries: () => entries, resolvePackageJson, pluginRoot }) } catch { roots = [] }
124
+ const timeoutMs = preflightTimeoutMs()
125
+ void runAutoPreflight({
126
+ current,
127
+ previous,
128
+ timeoutMs,
129
+ scan: ({ current: cur, previous: prev }) => scan({
130
+ profileDir,
131
+ patchPath,
132
+ current: cur,
133
+ previous: prev,
134
+ presetRoots: [join(dshHome(), '.agent-presets')],
135
+ roots,
136
+ reasons,
137
+ previousPackages: [],
138
+ networkAllowed: false,
139
+ }),
140
+ }).then((result) => {
141
+ // D5:预检**出结论之后**再看要不要自动禁用(有确证证据 + 开关开着 → 禁前快照 → 写盘 → 留记录)
142
+ if (result?.ok === true && result.record !== null && result.record !== undefined) maybeAutoDisable({ rc, current, previous, reasons, patchPath, record: result.record })
143
+ }).catch(() => {})
144
+ return { scheduled: true, timeoutMs, roots: roots.length }
145
+ }
146
+
147
+ /**
148
+ * D5 的接线:把 D1 预检结论里的**确证证据**交给自动禁用入口(`domain/auto-disable.js`)。
149
+ * 开关(`compat-gate.json#autoDisableOnEvidence`,默认启用)关闭时只记录候选、不写盘。
150
+ * 结果**只留在内存里给下一次状态查询用**(一次性提示;不新增任何常驻 UI/文件)。
151
+ */
152
+ let lastAutoDisable = null
153
+ function maybeAutoDisable({ rc, current, previous, reasons, patchPath, record }) {
154
+ try {
155
+ const evidence = record.evidence ?? null
156
+ const hasEvidence = Array.isArray(evidence?.rows) && evidence.rows.some((r) => r.code === 'package-unresolvable' || r.code === 'file-target-missing')
157
+ const hasBoot = Array.isArray(evidence?.bootRows) && evidence.bootRows.length > 0
158
+ if (!hasEvidence && !hasBoot) return null
159
+ if (typeof patchPath !== 'string' || patchPath === '' || !existsSync(patchPath)) return null
160
+ const gate = readCompatGate()
161
+ const plan = rc?.deps?.autoDisableApply ?? applyEvidenceDisable
162
+ const result = plan({
163
+ patchPath,
164
+ affected: Array.isArray(evidence?.rows) ? evidence.rows : [],
165
+ bootRows: Array.isArray(evidence?.bootRows) ? evidence.bootRows : [],
166
+ enabled: gate.autoDisableOnEvidence === true,
167
+ uncertainty: evidence?.uncertainty ?? null,
168
+ fingerprint: current,
169
+ frameworkVersion: current?.frameworkVersion ?? null,
170
+ reasons,
171
+ isProtected: isProtectedModule,
172
+ })
173
+ lastAutoDisable = { at: Date.now(), snapshotId: result?.snapshot?.id ?? null, ...result }
174
+ return result
175
+ } catch (error) {
176
+ lastAutoDisable = { at: Date.now(), ok: false, disabled: [], error: error instanceof Error ? error.message : String(error) }
177
+ return null
178
+ }
179
+ }
180
+
181
+ /** 一次性提示文案(中英双语短句;**不新增常驻 UI**:由客户端沿用既有的瞬时 note 通道显示)。
182
+ * ★ 开关是**读时判据**:关掉之后,之前那次"已自动禁用 N 行"的提示**不许再冒出来**
183
+ * (否则用户会以为刚又被禁了一次;实测就是这么发现的)。 */
184
+ function autoDisableNotice(entry = lastAutoDisable, { enabled = null } = {}) {
185
+ const on = enabled === null ? readCompatGate().autoDisableOnEvidence === true : enabled === true
186
+ if (entry === null || entry === undefined) return null
187
+ if (on !== true && entry.code === 'auto-disabled') return null
188
+ const added = Array.isArray(entry.disabled) ? entry.disabled : []
189
+ const restore = 'node scripts/safe-boot.mjs --restore-last-good'
190
+ if (added.length > 0) {
191
+ return {
192
+ at: entry.at ?? null,
193
+ zh: `已自动禁用 ${added.length} 行(确证解析不到 / 上次把服务搞挂):${added.join('、')};如需恢复:${restore}`,
194
+ en: `Auto-disabled ${added.length} row(s) (confirmed unresolvable / crashed the service last time): ${added.join(', ')}; to restore: ${restore}`,
195
+ disabled: added, snapshotId: entry.snapshotId ?? null, restoreCommand: restore, kind: 'auto-disabled',
196
+ }
197
+ }
198
+ if (entry.code === 'switch-off') {
199
+ return {
200
+ at: entry.at ?? null,
201
+ zh: `检测到需禁用的行,但自动禁用开关已关闭:${(entry.candidates ?? []).join('、')}(保持原样,等你手动处理)`,
202
+ en: `Rows need disabling, but the auto-disable switch is off: ${(entry.candidates ?? []).join(', ')} (left untouched)`,
203
+ disabled: [], candidates: entry.candidates ?? [], restoreCommand: restore, kind: 'switch-off',
204
+ }
205
+ }
206
+ return null
207
+ }
208
+
209
+ /**
210
+ * D2:确认服务正常后落一份 last-known-good 快照。
211
+ * 「确认正常」的判据(不吹牛):状态查询跑到了这一步 **且** 指纹与基线一致(环境没变)
212
+ * **且** 补丁通过结构校验(不是写坏的)。任一不满足就如实说明,不写快照。
213
+ */
214
+ function maybeWriteSnapshot({ current, changed, patchPath }) {
215
+ try {
216
+ if (typeof patchPath !== 'string' || patchPath === '' || !existsSync(patchPath)) return { written: false, reason: 'no-patch-file' }
217
+ const patchText = readFileSync(patchPath, 'utf8')
218
+ if (changed === true) return { written: false, reason: 'environment-changed' }
219
+ const check = validatePatchYaml(patchText)
220
+ if (check.ok !== true) return { written: false, reason: 'patch-not-yaml-valid', problems: check.problems.slice(0, 3) }
221
+ const result = writeSnapshot({
222
+ patchPath,
223
+ patchText,
224
+ fingerprint: current,
225
+ frameworkVersion: current?.frameworkVersion ?? null,
226
+ reasons: [],
227
+ kind: 'auto',
228
+ pluginVersion: pluginVersionOf(),
229
+ note: `自动快照(模式 ${readCompatMode().mode},服务响应正常,环境未变)`,
230
+ })
231
+ if (result.ok !== true) return { written: false, reason: 'write-failed', error: result.error }
232
+ return { written: result.written === true, reason: result.written === true ? null : (result.reason ?? 'unchanged'), snapshot: result.snapshot ?? null }
233
+ } catch (error) {
234
+ return { written: false, reason: 'error', error: error instanceof Error ? error.message : String(error) }
235
+ }
236
+ }
237
+
63
238
  async function routeCompatStatusGet(req, res, rc) {
239
+ // D4:桌面端宿主启动 → 自动拨回观察者(只在启动/首次查询判定一次;用户手动拨过的机器一动不动)
240
+ const auto = maybeSwitchToObserver(rc)
64
241
  const mode = readCompatMode()
65
242
  const current = currentFingerprint(rc)
66
243
  const previous = readFingerprint()
67
244
  const { changed, reasons, baselineAt } = compareFingerprint(current, previous)
245
+ const patchPath = (() => { try { return findPatchPath(rc.ctx) } catch { return null } })()
246
+
247
+ // D1:变了就自动跑一次只读预检(后台,不阻塞;同一指纹只跑一次)
248
+ const autoRun = changed === true ? scheduleAutoPreflight(rc, { current, previous, reasons, profileDir: profileDirOf(rc.ctx), patchPath }) : null
249
+
250
+ // D2:确认服务正常 → 落 last-known-good 快照(节流:指纹与补丁都没变时零写盘)
251
+ const snapshot = maybeWriteSnapshot({ current, changed, patchPath })
252
+
253
+ const autoPreflight = autoPreflightView({ changed, current })
68
254
  sendJson(res, 200, {
69
255
  ok: true,
70
256
  mode: mode.mode,
71
- modeSource: mode.source,
257
+ modeSource: mode.fileSource,
72
258
  modeChangedAt: mode.at,
73
259
  modeReason: mode.reason ?? null,
260
+ // D4 加法字段:本次是否自动拨回了观察者 + 一句短句(中英双语);没自动改就是 null
261
+ modeAuto: auto.switched === true ? { switched: true, note: auto.note, at: Date.now() } : null,
74
262
  frameworkVersion: current.frameworkVersion,
75
263
  fingerprint: { current, previous, changed, reasons, baselineAt },
76
264
  rollback: readRollbackRecord(),
265
+ // D1 加法字段:自动预检的状态/结论(面板只放一句短句,长文进详情)
266
+ autoPreflight: { ...autoPreflight, scheduled: autoRun !== null, scheduledAt: autoRun === null ? null : Date.now(), timeoutMs: autoRun?.timeoutMs ?? preflightTimeoutMs() },
267
+ // D5 加法字段:自动禁用的开关状态 + **本次一次性提示**(客户端走既有瞬时 note 通道;
268
+ // 服务端不新增任何常驻面板元素/按钮/文件)
269
+ autoDisable: {
270
+ onEvidence: readCompatGate().autoDisableOnEvidence === true,
271
+ switch: { file: 'compat-gate.json', key: 'autoDisableOnEvidence', route: '/plugin-console/compat-ack', body: { autoDisableOnEvidence: false } },
272
+ notice: autoDisableNotice(),
273
+ restore: { route: { path: '/plugin-console/safe-boot', body: { action: 'restore-last-good' } }, command: 'node scripts/safe-boot.mjs --restore-last-good' },
274
+ logHint: '记录落在 dshHome()/plugin-console/auto-disable.log(追加式 JSONL)',
275
+ },
276
+ // D2 加法字段:安全启动材料(last-known-good 快照 + 恢复入口提示)
277
+ safeBoot: {
278
+ snapshot: { written: snapshot.written === true, reason: snapshot.reason ?? null, id: snapshot.snapshot?.id ?? null, at: snapshot.snapshot?.at ?? null, sha256: snapshot.snapshot?.patchSha256 ?? null },
279
+ snapshots: listSnapshots().slice(0, 5),
280
+ lastGoodId: readSafeBootState().lastGoodId,
281
+ command: { list: 'node scripts/safe-boot.mjs --list', restore: 'node scripts/safe-boot.mjs --restore-last-good', disable: 'node scripts/safe-boot.mjs --disable-suspects' },
282
+ route: { action: 'restore-last-good | disable-suspects | list | mark-good', path: '/plugin-console/safe-boot' },
283
+ },
77
284
  })
78
285
  }
79
286
 
80
287
  async function routeCompatMode(req, res, rc) {
81
288
  const wanted = rc.body?.mode
82
- const result = writeCompatMode(wanted)
289
+ // 用户手动拨动 → 来源记 `manual`(此后**永不自动改**:D4 的"不抢用户意图"就靠这一笔)
290
+ const result = writeCompatMode(wanted, { source: 'manual', reason: '用户手动切换' })
83
291
  if (!result.ok) { sendError(res, 400, result.error); return }
84
- sendJson(res, 200, { ok: true, mode: result.mode, note: result.mode === 'observer' ? '观察者模式:只做预检与门控,不接管框架升级' : '托管模式:框架升级与回滚由本控制台负责' })
292
+ sendJson(res, 200, {
293
+ ok: true,
294
+ mode: result.mode,
295
+ source: result.source,
296
+ changed: result.changed === true,
297
+ note: result.mode === 'observer' ? '观察者模式:只做预检与门控,不接管框架升级' : '托管模式:框架升级与回滚由本控制台负责',
298
+ })
85
299
  }
86
300
 
87
301
  async function routeCompatStamp(req, res, rc) {
@@ -143,6 +357,21 @@ async function routeDeclareInstalled(req, res, rc) {
143
357
  */
144
358
  async function routeCompatAck(req, res, rc) {
145
359
  const body = rc.body ?? {}
360
+ // D5 加法:自动禁用(有确证证据时)的开关——同一把锁(compat-gate.json)里再记一个布尔。
361
+ // 它与既有的 `autoDisable`(我们升级时)是两个独立开关;这里只动 `autoDisableOnEvidence`,
362
+ // **不碰** 既有字段(一个字都不改旧语义)。
363
+ if (typeof body.autoDisableOnEvidence === 'boolean') {
364
+ const gate = writeCompatGate({ autoDisableOnEvidence: body.autoDisableOnEvidence })
365
+ sendJson(res, 200, {
366
+ ok: true,
367
+ autoDisableOnEvidence: gate.autoDisableOnEvidence,
368
+ autoDisable: gate.autoDisable,
369
+ note: gate.autoDisableOnEvidence === true
370
+ ? '指纹变化 / 启动失败记录给出确证证据时,会自动禁用被点名的行(禁前先存快照,可一条命令回滚)'
371
+ : '已关闭自动禁用:只在面板/接口里报告点名行,改由你手动点「只禁可疑行」',
372
+ })
373
+ return
374
+ }
146
375
  if (typeof body.mode === 'string') {
147
376
  const mode = body.mode.trim().toLowerCase()
148
377
  if (!NOTICE_MODES.includes(mode)) {
@@ -167,4 +396,4 @@ async function routeCompatAck(req, res, rc) {
167
396
  const ack = writeCompatAck(version)
168
397
  sendJson(res, 200, { ok: true, ack, noticeMode: readCompatGate().noticeMode })
169
398
  }
170
- export { routeCompatAck, routeCompatMode, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled }
399
+ export { hostShapeOf, maybeSwitchToObserver, maybeWriteSnapshot, preflightTimeoutMs, resetHostSwitchChecked, routeCompatAck, routeCompatMode, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled, scheduleAutoPreflight }
@@ -109,7 +109,7 @@ async function routeRepoLandConfig(req, res, rc) {
109
109
  const body = rc.body
110
110
  const dir = typeof body.dir === 'string' ? body.dir.trim() : ''
111
111
  if (dir === '' || !/^[A-Za-z]:[\\/]/.test(dir) && !/^\\\\/.test(dir)) {
112
- // 0.5.28 改错:示例原本写成本机真实目录 `D:\dsh\repos`(把开发者的目录当成了通用示例)。
112
+ // 0.5.28 改错:示例原本写成本机真实目录(把开发者的目录当成了通用示例)。
113
113
  // 换成中性的任意绝对路径示例,并说明"任意绝对路径"——判据是形状(盘符/UNC),不是具体目录。
114
114
  sendError(res, 400, '保存路径不合法:请提供绝对路径(任意绝对路径均可,如 C:\\repos 或 \\\\server\\share\\repos)')
115
115
  return
@@ -24,6 +24,14 @@ import { baseDirOf, dshHome, findPatchPath, pluginRoot, profileDirOf, resolvePac
24
24
 
25
25
  const REGISTRY = 'https://registry.npmmirror.com'
26
26
 
27
+ /** `preflightRoots` 的默认依赖(0.5.32:把这段抽成可注入形式,让"指纹变化自动预检"与离线脚本
28
+ * 复用**同一套扫描面**推导,而不是各写一份 —— 两套推导迟早会分叉,而分叉的表现是"自动预检看不见
29
+ * 用户装的那个包",属于静默漏报)。生产路径不传 deps,行为与抽取前一字不差。 */
30
+ const defaultListEntries = listEntries
31
+ const defaultResolvePackageJson = resolvePackageJson
32
+ const defaultIsFrameworkOwned = isFrameworkOwnedPackage
33
+ const defaultPluginRoot = pluginRoot
34
+
27
35
  /** 预检的两个扫描面:用户预设目录 + profile 内每个已装插件包。
28
36
  * 框架自带包(解析到 profile 之外,即 npx 缓存里的 @deepseek-ai/*)标成 kind='framework':
29
37
  * 仍然扫(能暴露框架自身的契约不一致),但**只报告不改**——改它等于污染框架安装树。
@@ -36,7 +44,12 @@ function userPresetsRoot(home = undefined) {
36
44
  return join(home ?? dshHome(), '.agent-presets')
37
45
  }
38
46
 
39
- function preflightRoots(ports, profileDir) {
47
+ function preflightRoots(ports, profileDir, deps = {}) {
48
+ const userPresetsRoot = deps.userPresetsRoot ?? ((home = undefined) => join(home ?? dshHome(), '.agent-presets'))
49
+ const listEntries = deps.listEntries ?? defaultListEntries
50
+ const resolvePackageJson = deps.resolvePackageJson ?? defaultResolvePackageJson
51
+ const isFrameworkOwnedPackage = deps.isFrameworkOwnedPackage ?? defaultIsFrameworkOwned
52
+ const pluginRoot = deps.pluginRoot ?? defaultPluginRoot
40
53
  const roots = [{ root: userPresetsRoot(), kind: 'preset' }]
41
54
  // 控制台自身排除在外:它是工具、不是会话消息生产方,而它的报错文案/文档里本身就含
42
55
  // `kind: 'plugin'`、`.volatile(` 这类字样 —— 发版门槛自扫时正是这些字串报出了无意义条目。
@@ -1,7 +1,6 @@
1
1
  // L2 · routes —— 框架升级(POST /framework-upgrade)
2
- // 分层 Step 8b:从 lib/index.js 的 handle() 原样搬出(只搬移未改逻辑)。
3
- // 注:这个 handler 661 行(内置大段 PowerShell 脚本模板),是守卫「单文件 ≤ 600 行」的受限例外(见守卫 SIZE_EXCEPTIONS);
4
- // 待抽包阶段把脚本模板挪进 domain/framework-script.js 后撤销例外。
2
+ // 分层 Step 8b:从 lib/index.js 的 handle() 原样搬出(只搬移未改逻辑)。这个 handler 内置大段
3
+ // PowerShell 脚本模板,是守卫「单文件 ≤ 600 行」的受限例外(见 SIZE_EXCEPTIONS),待抽包阶段撤销。
5
4
 
6
5
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
7
6
  import { writeFile } from 'node:fs/promises'
@@ -11,7 +10,7 @@ import { tmpdir } from 'node:os'
11
10
  import { pathToFileURL } from 'node:url'
12
11
  import { createRequire } from 'node:module'
13
12
  import { readCompatMode, writeCompatMode } from '../domain/compat-state.js'
14
- import { FRAMEWORK_BACKUP_ROOT, backupProfileSnapshot, checkpointFrameworkTree, preflightDisableIncompatible, relaunchPrelude, resolveDshBin, resolveFrameworkRootNodeModules, resolveFrameworkUpgradePlan, shellHostedRefusal } from '../domain/framework.js'
13
+ import { FRAMEWORK_BACKUP_ROOT, backupProfileSnapshot, checkpointFrameworkTree, platformUpgradeRefusal, preflightDisableIncompatible, relaunchPrelude, resolveDshBin, resolveFrameworkRootNodeModules, resolveFrameworkUpgradePlan, shellHostedRefusal } from '../domain/framework.js'
15
14
  import { fwIntegrityCheck } from '../infra/fw-integrity-check.js'
16
15
  import { CORE_PATCH_ROW_IDS } from '../domain/patch.js'
17
16
  import { migrateAgentConfigsForUpgrade } from '../domain/presets.js'
@@ -80,12 +79,15 @@ async function routeFrameworkUpgrade(req, res, rc) {
80
79
  } catch {}
81
80
  }
82
81
  // 拉元数据 → 候选版本列表 → 本次目标(helper 里同时做「用户自选版本」的白名单校验)
83
- const plan = await resolveFrameworkUpgradePlan({ current, wanted: body.version, fetchJson: fetchJsonUrl })
82
+ const plan = await resolveFrameworkUpgradePlan({ current, wanted: body.version, fetchJson: rc.deps.fetchJson ?? fetchJsonUrl })
84
83
  const { latest, next, alpha, versions, tagDefault, registryError } = plan
85
84
  if (plan.rejected !== null) {
86
85
  sendError(res, 400, `升级目标 ${plan.rejected} 不在可选列表内(只接受比当前 ${current ?? '?'} 更新的已发布版本;降级请用「回滚到上一版」)`)
87
86
  return
88
87
  }
88
+ // 平台守卫(2026-09-29 改错):本通道 = schtasks.exe + PowerShell,只有 win32 有;判据与文案见
89
+ // domain/framework.js#platformUpgradeRefusal。放在入参校验之后、任何副作用之前 —— 绝不假成功。
90
+ const platformRefusal = platformUpgradeRefusal(); if (platformRefusal !== null) { sendError(res, 501, platformRefusal.error, platformRefusal.details); return }
89
91
  const target = plan.target
90
92
  const profileDir = dirname(findPatchPath(ctx))
91
93
  // 1) 升级前打包备份现有配置(patch / profile package.json / 插件清单)
@@ -138,17 +140,16 @@ async function routeFrameworkUpgrade(req, res, rc) {
138
140
  steps.push(`另有 ${preflight.skipped.length} 行无法预判(受保护行/包信息读不到),保持启用,由升级后的启动失败隔离兜底`)
139
141
  }
140
142
  }
141
- // 2) 自动升级框架:运行中的服务锁着 dsh/lib 目录(EBUSY 无法原地替换),
142
- // 因此生成独立升级脚本(kill 服务 → npm 升级 → 失败回滚 → 拉起服务),后台执行。
143
- // 与 /restart 同款自守护模式:端口无监听会自动拉起,不会留下"服务起不来"。
143
+ // 2) 自动升级框架:运行中的服务锁着 dsh/lib 目录(EBUSY 无法原地替换)→ 生成独立升级脚本
144
+ // (kill 服务 → npm 升级 → 失败回滚 → 拉起服务)后台执行;与 /restart 同款自守护,不会留下"服务起不来"。
144
145
  if (hasUpdate && dshDir !== null) {
145
146
  const port = webPort(ctx)
146
147
  const nodePath = process.execPath
147
148
  const binPath = resolveDshBin()
148
149
  const npmCli = join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js')
149
150
  // 事故教训:npm 的 lib/cli.js 是「函数模块」(module.exports = (process) => validateEngines(...)),
150
- // 直跑 `node lib/cli.js` 只加载函数定义、不执行命令——立即退出 exit 0(假成功,曾导致
151
- // "npm 退出码 0 但版本未更新")。唯一正确入口是 bin/npm-cli.js(它调用 cli.js 函数)。
151
+ // 直跑它只加载函数定义、不执行命令——立即退出 exit 0(假成功,"npm 退出码 0 但版本未更新")。
152
+ // 唯一正确入口是 bin/npm-cli.js(它调用 cli.js 函数)。
152
153
  const corepackJs = join(dirname(process.execPath), 'node_modules', 'corepack', 'dist', 'corepack.js')
153
154
  // 预检(事故教训):corepack(pnpm 通道)/ dsh bin.js 缺失时**拒绝生成升级脚本**(不 kill 服务)——
154
155
  // 避免"kill 后升级/拉起双双失败,服务永久 down"
@@ -640,16 +641,14 @@ async function routeFrameworkUpgrade(req, res, rc) {
640
641
  setFwCheckCache(null)
641
642
  // UTF-8 BOM:powershell 5.1 按 ANSI 读无 BOM 文件,中文会乱码——加 BOM 保证解析正确。
642
643
  // 执行方式:schtasks 一次性计划任务(Task Scheduler 启动,独立于服务进程树/Job Object)——
643
- // 服务进程被杀时,其子进程(execFile/detached 的 powershell)会被 Job/进程树连带杀死,
644
- // 曾多次卡死在「停止服务」之后;计划任务彻底脱离,杀服务绝对影响不到升级脚本。
644
+ // 服务进程被杀时其子进程(execFile/detached 的 powershell)会被 Job 连带杀死,
645
+ // 计划任务彻底脱离,杀服务影响不到升级脚本。
645
646
  writeFile(ps1, `\uFEFF${lines}`, 'utf8').then(
646
647
  () => {
647
648
  const ps1Posix = ps1.replace(/\\/gu, '/')
648
- // 无引号 /tr(重要):Task Scheduler 对带引号命令的解析会把 Command 拆坏成
649
- // `"powershell ... -File \"`(非有效可执行文件)——任务显示 Ready、/run 报 SUCCESS
650
- // 但永不执行(曾导致升级/重启脚本反复"已启动"却不动作、服务不停止)。实测无引号
651
- // 格式(exe 与脚本路径均无空格时)任务正常执行、脚本完整跑通。仅当脚本路径含空格
652
- // 时才退回带引号格式(schtasks 引号解析在服务 execFile 上下文不可靠,此时宁可用它)。
649
+ // 无引号 /tr(重要):Task Scheduler 会把带引号命令的 Command 拆坏成 `"powershell … -File \"`
650
+ // (非有效可执行文件)——任务显示 Ready、/run 报 SUCCESS 但永不执行(曾导致脚本反复"已启动"
651
+ // 却不动作)。实测无引号格式(exe 与脚本路径均无空格时)正常;仅脚本路径含空格时才退回带引号格式。
653
652
  const tr = / /.test(ps1Posix)
654
653
  ? `"powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \\"${ps1Posix}\\""`
655
654
  : `C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ${ps1Posix}`
@@ -8,7 +8,7 @@ import { dirname, join, resolve } from 'node:path'
8
8
  import { tmpdir } from 'node:os'
9
9
  import { createRequire } from 'node:module'
10
10
  import { readCompatGate, writeCompatGate } from '../domain/compat.js'
11
- import { cleanupStaleFwTasks, currentFrameworkVersion, pickFrameworkTarget, relaunchPrelude, resolveDshBin, resolveFrameworkRootNodeModules, shellHostedRefusal } from '../domain/framework.js'
11
+ import { cleanupStaleFwTasks, currentFrameworkVersion, pickFrameworkTarget, platformUpgradeRefusal, relaunchPrelude, resolveDshBin, resolveFrameworkRootNodeModules, shellHostedRefusal } from '../domain/framework.js'
12
12
  import { readGithubAuth } from '../domain/install.js'
13
13
  import { webPort } from '../domain/runtime.js'
14
14
  import { fetchJsonUrl } from '../infra/http.js'
@@ -32,6 +32,21 @@ function refuseWhenShellHosted(res) {
32
32
  return true
33
33
  }
34
34
 
35
+ /**
36
+ * 平台守卫(2026-09-29 改错,与宿主守卫同形):一键升级/回滚脚本整体依赖 Windows 机制 ——
37
+ * `schtasks.exe` 计划任务 + PowerShell + `Get-NetTCPConnection`;非 Windows 上 execFile 必然 ENOENT,
38
+ * 而旧实现会先写一个「脚本已启动」的状态文件、再去 exec 不存在的 powershell.exe,**最后仍回 ok:true**
39
+ * (用户以为在跑,实际零动作)。判据与文案见 domain/framework.js#platformUpgradeRefusal。
40
+ * 命中时:不写状态文件、不 spawn、不建计划任务,直接 501 + 手动升级出路。
41
+ * 返回 true = 已拒绝(调用方直接 return);false = win32,原路径一字不改。
42
+ */
43
+ function refuseWhenPlatformUnsupported(res) {
44
+ const refusal = platformUpgradeRefusal()
45
+ if (refusal === null) return false
46
+ sendError(res, 501, refusal.error, refusal.details)
47
+ return true
48
+ }
49
+
35
50
  async function routeFrameworkUpgradeStatusGet(req, res, rc) {
36
51
  const ctx = rc.ctx
37
52
  const url = rc.url
@@ -355,6 +370,7 @@ async function routeFrameworkRollback(req, res, rc) {
355
370
  sendError(res, 409, '没有可用的框架全树回滚点(framework-rollback.json 缺失或 checkpoint 已清理)')
356
371
  return
357
372
  }
373
+ if (refuseWhenPlatformUnsupported(res)) return // 平台守卫(改错):回滚脚本同样是 schtasks+PowerShell,非 win32 一律拒绝而不是假成功(放在只读状态校验之后、任何写盘/exec 之前)
358
374
  const port = webPort(ctx)
359
375
  const nodePath = process.execPath
360
376
  const taskName = `DSH-FW-Rollback-${process.pid}`
@@ -576,4 +592,4 @@ async function routeFrameworkStatusClear(req, res, rc) {
576
592
  }
577
593
  sendJson(res, 200, { ok: true, removed, status: 'idle', message: null })
578
594
  }
579
- export { refuseWhenShellHosted, routeFrameworkStatusClear, routeFrameworkUpgradeStatusGet, routeFrameworkRelaunch, routeFrameworkCheck, routeCompatGate, routeCheckUpdate, routeFrameworkRollback, routeRestart }
595
+ export { refuseWhenShellHosted, refuseWhenPlatformUnsupported, routeFrameworkStatusClear, routeFrameworkUpgradeStatusGet, routeFrameworkRelaunch, routeFrameworkCheck, routeCompatGate, routeCheckUpdate, routeFrameworkRollback, routeRestart }
@@ -149,8 +149,8 @@ function shortOpenError(error) {
149
149
  * POST /plugin-console/github-open-login —— 唤起 dsh-github-login 的设备码登录窗口。
150
150
  *
151
151
  * 为什么失败也回 200(而不是 500):这个能力是**外挂**的,装没装、装的是不是带 exe 的版本、
152
- * 当前 profile 有没有把它挂到 webServer 上,全都不由本插件决定(2026-09-20 实测:本机虽然装了
153
- * 该插件、exe 也在 D:\dsh\dsh-github-login\dist\,但当前 profile 下 GET /github-auth/status 仍是
152
+ * 当前 profile 有没有把它挂到 webServer 上,全都不由本插件决定(2026-09-20 实测:某个 profile 里虽然装了
153
+ * 该插件、exe 也在,但当前 profile 下 GET /github-auth/status 仍是
154
154
  * 404)。这些都是"这个通道现在不可用",不是"服务端出错":
155
155
  * · 回 500 → 前端弹一句看不懂的报错,还看不出"该走 token 兜底"这条正路;
156
156
  * · 回 200 + started:false + reason → 前端照原样把原因念给用户,并自动展开 token 粘贴面板。
@@ -11,11 +11,13 @@ import { pnpmRemove, readExtraBundleRows, runInstallJob } from '../domain/instal
11
11
  import { isProtectedModule, listEntries, webPort } from '../domain/runtime.js'
12
12
  import { DEFAULT_SEARCH } from '../domain/sources.js'
13
13
  import { runSuiteInstallJob } from '../domain/suite.js'
14
+ import { fetchJsonUrl } from '../infra/http.js'
14
15
  import { readBody, sendError } from '../infra/httpd.js'
15
16
  import { routeAiConsent, routeAiEmpowerCancel, routeAiEmpowerList, routeAiEmpowerPlan, routeAiEmpowerRun, routeAiEmpowerStatus } from './ai.js'
16
17
  import { routeRunSuggested } from './actions.js'
17
18
  import { routeComponentAutostart, routeComponentStart, routeComponentStatus, routeComponentStop, routeComponents, routeRepoClone, routeRepoLandConfig, routeRepoList, routeRepoOpen, routeRepoRemove } from './components.js'
18
19
  import { routeCompatAck, routeCompatMode, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled } from './compat.js'
20
+ import { routeSafeBoot } from './safe-boot.js'
19
21
  import { routePatchAuditGet } from './patch-audit.js'
20
22
  import { routePresetAuditGet } from './preset-audit.js'
21
23
  import { routeFrameworkUpgrade } from './framework-upgrade.js'
@@ -53,7 +55,9 @@ const ROUTES_EARLY = [
53
55
  ]
54
56
 
55
57
  function routeDeps() {
56
- return { runInstallJob, runSuiteInstallJob, webPort, readExtraBundleRows, detectAdoptablePending, listEntries, detectCompat, detectFrameworkUpgrade, DEFAULT_SEARCH, aiEmpowerExecute, aiEmpowerPlan, backupProfileSnapshot, currentFrameworkVersion, frameworkCompatReportFor, isProtectedModule, pnpmRemove, preflightDisableIncompatible, rowIdModuleMap }
58
+ // fetchJson:registry 元数据取数的注入缝(默认就是真通道)。测试要断言「非 win32 拒绝且零副作用」
59
+ // 时用它喂固定元数据,免得把网络与 CI 稳定性绑在一起(与其余 deps 同一种注入风格)。
60
+ return { runInstallJob, runSuiteInstallJob, webPort, readExtraBundleRows, detectAdoptablePending, listEntries, detectCompat, detectFrameworkUpgrade, DEFAULT_SEARCH, aiEmpowerExecute, aiEmpowerPlan, backupProfileSnapshot, currentFrameworkVersion, frameworkCompatReportFor, isProtectedModule, pnpmRemove, preflightDisableIncompatible, rowIdModuleMap, fetchJson: fetchJsonUrl }
57
61
  }
58
62
 
59
63
  async function handle(ctx, req, res) {
@@ -111,6 +115,9 @@ const ROUTES = [
111
115
  { methods: ['POST'], path: `${ROUTE_PREFIX}/compat-stamp`, handler: routeCompatStamp },
112
116
  { methods: ['POST'], path: `${ROUTE_PREFIX}/compat-ack`, handler: routeCompatAck },
113
117
  { methods: ['POST'], path: `${ROUTE_PREFIX}/declare-installed`, handler: routeDeclareInstalled },
118
+ // 安全启动材料恢复入口(2026-09-29 加法 D3-①,0.5.32):白名单动作 list / restore-last-good /
119
+ // disable-suspects / mark-good;只认动作名,绝不接受命令串;服务起不来时走 scripts/safe-boot.mjs
120
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/safe-boot`, handler: routeSafeBoot },
114
121
  { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-rollback`, handler: routeFrameworkRollback },
115
122
  { methods: ['POST'], path: `${ROUTE_PREFIX}/restart`, handler: routeRestart },
116
123
  { methods: ['POST'], path: `${ROUTE_PREFIX}/toggle`, handler: routeToggle },