@noob-stupid/dsh-plugin-console 0.5.31 → 0.5.33

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.
@@ -0,0 +1,251 @@
1
+ // L1 · domain —— settings-api-scan.js(「已删除 dsh-settings API」源码扫描;0.5.33 改错时从 compat.js 搬出)
2
+ // 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
3
+ //
4
+ // 为什么单独成模块:判据从"源码里出现过这个名字"改成"这个名字**真的从
5
+ // `@deepseek-ai/dsh-settings` 绑定进来**"——要先剥注释/字符串字面量,再解析 import/require 的
6
+ // 绑定子句,逻辑比原先的按行子串匹配长得多,而 compat.js 已经 544 行(守卫上限 600)。
7
+ // 搬移保持对外契约不变:`REMOVED_SETTINGS_SYMBOLS` / `referencesRemovedSymbol` /
8
+ // `scanSettingsApiUsage` 三个名字照旧由 compat.js 导出。
9
+ //
10
+ // ── 0.5.33 改错(真机误报,证据充分)────────────────────────────────────────────────
11
+ // 现症:`dshmarket` 被判 `check:'fail'`(= 「源码仍引用 0.1.2 起已删除的 dsh-settings API」),
12
+ // 于是**框架升级预扫会把这个好插件自动禁用**。逐条核对它的三处命中:
13
+ // · dshmarket/lib/settings.js:45 / :53 / :73 —— 全在**注释**里;
14
+ // · dshmarket/lib/routes.js:3136 `settingsNamespace: settingsNamespaceState(),` —— 一个
15
+ // **HTTP 载荷对象的属性名**(值是自己的局部函数 `settingsNamespaceState`,与本包无关);
16
+ // · 该包对 `@deepseek-ai/dsh-settings` 的 **import 零命中**(它早已把那两个 helper 内联)。
17
+ // 旧判据(`text.includes(sym)` → v0.3.35 换成标识符边界 + 排除局部定义)挡不住这两类:
18
+ // 注释里的名字与对象键上的名字都不是"引用",但它们都在**代码行**上、也不是本地定义。
19
+ //
20
+ // 新判据(更严格,不放宽任何真引用):
21
+ // ① 先掩掉注释、字符串/模板字面量、正则字面量(等长空格替换,保留换行与列偏移);
22
+ // 唯一保留的是**模块说明符字符串**(`from 'x'` / `require('x')` / `import('x')`),
23
+ // 否则没法知道某个绑定来自哪个包。
24
+ // ② 只在**真的对这个包**的 import/require 绑定子句里取名字:
25
+ // `import { settingsNamespace } from '@deepseek-ai/dsh-settings'`、
26
+ // `import settingsNamespace from …`、`import * as s from …` 后的 `s.settingsNamespace`、
27
+ // `const { settingsNamespace } = require('@deepseek-ai/dsh-settings')`、
28
+ // `const { installSettingsSection } = await import('@deepseek-ai/dsh-settings')`。
29
+ // ③ 对象键、局部同名函数、别的模块的同名导入、注释与字符串里的名字 —— 一律不算。
30
+
31
+ import { readdirSync, readFileSync } from 'node:fs'
32
+ import { join } from 'node:path'
33
+
34
+ /** 0.1.2-rc.1 起被官方删除的 dsh-settings API(命中即"实际不兼容")。 */
35
+ const REMOVED_SETTINGS_SYMBOLS = ['settingsNamespace', 'installSettingsSection']
36
+
37
+ /** 这些 API 原本来自哪个包(只有真的从它绑定进来的名字才算引用)。 */
38
+ const SETTINGS_MODULE = '@deepseek-ai/dsh-settings'
39
+
40
+ const SPECIFIER_RE = `['"]${SETTINGS_MODULE.replace(/[/\\^$*+?.()|[\]{}]/gu, '\\$&')}['"]`
41
+
42
+ /**
43
+ * 把注释、字符串/模板字面量、正则字面量掩成**等长空格**(换行/回车保留,行号与列偏移不变)。
44
+ * `keepSpecifier` 为真时保留"模块说明符"字符串的原样内容(`from 'x'` / `require('x')` / `import('x')`)。
45
+ *
46
+ * 这是**掩码**而不是删除:后续判据仍然按整份源码做正则,位置对得上才好排查。
47
+ */
48
+ function maskLiterals(text, keepSpecifier) {
49
+ const src = String(text ?? '')
50
+ const out = src.split('')
51
+ const blank = (from, to) => {
52
+ for (let k = from; k < to; k += 1) if (out[k] !== '\n' && out[k] !== '\r') out[k] = ' '
53
+ }
54
+ const isWordChar = (ch) => ch !== undefined && /[A-Za-z0-9_$]/u.test(ch)
55
+ const wordBefore = (index) => {
56
+ let j = index
57
+ while (j > 0 && (src[j - 1] === ' ' || src[j - 1] === '\t')) j -= 1
58
+ let k = j
59
+ while (k > 0 && isWordChar(src[k - 1])) k -= 1
60
+ return src.slice(k, j)
61
+ }
62
+ const prevMeaningful = (index) => {
63
+ for (let k = index - 1; k >= 0; k -= 1) {
64
+ const ch = src[k]
65
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') continue
66
+ return ch
67
+ }
68
+ return ''
69
+ }
70
+ /** 当前位置若是 `word(` 的第一个实参,返回 `word`(用于 require(...) / import(...) 的说明符判定)。 */
71
+ const callWordBefore = (index) => {
72
+ let k = index - 1
73
+ while (k >= 0 && (src[k] === ' ' || src[k] === '\t')) k -= 1
74
+ if (k < 0 || src[k] !== '(') return ''
75
+ k -= 1
76
+ while (k >= 0 && (src[k] === ' ' || src[k] === '\t')) k -= 1
77
+ const end = k + 1
78
+ while (k >= 0 && isWordChar(src[k])) k -= 1
79
+ return src.slice(k + 1, end)
80
+ }
81
+ // 正则字面量的判定沿用仓库既有启发式(tests/test-architecture-guard.mjs 的 stripCode 同款):
82
+ // 前一个有效字符说明这里不可能是除法,或者前一个词是 return/typeof 这类关键字。
83
+ const REGEX_PREV = new Set(['(', ',', '=', ':', '[', '!', '&', '|', '?', '{', '}', ';', '\n', ''])
84
+ const REGEX_KEYWORDS = new Set(['return', 'typeof', 'case', 'in', 'of', 'do', 'else', 'void', 'delete', 'new', 'instanceof', 'yield', 'await'])
85
+ let i = 0
86
+ while (i < src.length) {
87
+ const c = src[i]
88
+ const n = src[i + 1]
89
+ if (c === '/' && n === '/') {
90
+ const end = src.indexOf('\n', i)
91
+ const to = end < 0 ? src.length : end
92
+ blank(i, to)
93
+ i = to
94
+ continue
95
+ }
96
+ if (c === '/' && n === '*') {
97
+ const end = src.indexOf('*/', i + 2)
98
+ const to = end < 0 ? src.length : end + 2
99
+ blank(i, to)
100
+ i = to
101
+ continue
102
+ }
103
+ if (c === "'" || c === '"' || c === '`') {
104
+ let j = i + 1
105
+ while (j < src.length && src[j] !== c) {
106
+ if (src[j] === '\\') j += 1
107
+ j += 1
108
+ }
109
+ const end = j < src.length ? j + 1 : src.length
110
+ // 说明符字符串保留:紧跟在 `from` 之后,或作为 require(...) / import(...) 的第一个实参。
111
+ const asCallArgument = /^(?:require|import)$/u.test(callWordBefore(i))
112
+ const isSpecifier = keepSpecifier === true && c !== '`' && (wordBefore(i) === 'from' || asCallArgument)
113
+ if (!isSpecifier) blank(i + 1, end - 1)
114
+ i = end
115
+ continue
116
+ }
117
+ if (c === '/') {
118
+ const pm = prevMeaningful(i)
119
+ if (REGEX_PREV.has(pm) || REGEX_KEYWORDS.has(wordBefore(i))) {
120
+ let j = i + 1
121
+ let inClass = false
122
+ while (j < src.length) {
123
+ const ch = src[j]
124
+ if (ch === '\\') { j += 2; continue }
125
+ if (ch === '[') inClass = true
126
+ else if (ch === ']') inClass = false
127
+ else if (ch === '/' && !inClass) { j += 1; break }
128
+ else if (ch === '\n') break
129
+ j += 1
130
+ }
131
+ blank(i, j)
132
+ i = j
133
+ continue
134
+ }
135
+ }
136
+ i += 1
137
+ }
138
+ return out.join('')
139
+ }
140
+
141
+ /** 从一个绑定子句里取出**被导入的名字**(`a as b` 取 `a`;默认导入取它自己;`*` 交给命名空间那条路)。 */
142
+ function importedNamesOf(clause) {
143
+ const names = new Set()
144
+ const text = String(clause ?? '')
145
+ const brace = text.match(/\{([^}]*)\}/u)
146
+ if (brace !== null) {
147
+ for (const part of brace[1].split(',')) {
148
+ const seg = part.trim()
149
+ if (seg === '') continue
150
+ const imported = seg.split(/\s+as\s+/u)[0].trim()
151
+ if (/^[A-Za-z_$][\w$]*$/u.test(imported)) names.add(imported)
152
+ }
153
+ }
154
+ const rest = brace === null ? text : text.replace(brace[0], ' ')
155
+ for (const seg of rest.split(',')) {
156
+ const t = seg.trim()
157
+ if (t === '' || t === '*' || /\s/u.test(t)) continue
158
+ if (/^[A-Za-z_$][\w$]*$/u.test(t)) names.add(t)
159
+ }
160
+ return names
161
+ }
162
+
163
+ /**
164
+ * 从源码里取出「真的从 `@deepseek-ai/dsh-settings` 绑定进来的名字」。
165
+ * 返回 `{ named, namespaces }`:`named` 是直接绑定的符号,`namespaces` 是包级命名空间
166
+ * (`import * as s` / `const s = require(…)`),它们的成员访问 `s.settingsNamespace` 也算引用。
167
+ */
168
+ function settingsApiBindings(text) {
169
+ // 掩码只做一次:注释/字符串/正则变空格,模块说明符字符串保留 —— 判据据此认包。
170
+ const code = maskLiterals(text, true)
171
+ const named = new Set()
172
+ const namespaces = new Set()
173
+ // ① ESM 命名空间导入:`import * as s from '<mod>'`
174
+ for (const m of code.matchAll(new RegExp(`\\bimport\\s*\\*\\s*as\\s+([A-Za-z_$][\\w$]*)\\s+from\\s*${SPECIFIER_RE}`, 'gu'))) {
175
+ namespaces.add(m[1])
176
+ }
177
+ // ② ESM 具名/默认导入:`import { a, b as c } from '<mod>'` / `import d from '<mod>'`
178
+ // 子句里不允许再出现 import/from,长度封顶 2000 —— 挡住"跨到上一条 import 语句"的假匹配。
179
+ for (const m of code.matchAll(new RegExp(`\\bimport\\s+(?!\\*)((?:(?!\\bimport\\b|\\bfrom\\b)[\\s\\S]){0,2000}?)\\s+from\\s*${SPECIFIER_RE}`, 'gu'))) {
180
+ for (const name of importedNamesOf(m[1])) named.add(name)
181
+ }
182
+ // ③ CJS / 动态导入的解构绑定:`const { a } = require('<mod>')` / `= await import('<mod>')`
183
+ const callRe = `(?:require|import)\\s*\\(\\s*${SPECIFIER_RE}\\s*\\)`
184
+ for (const m of code.matchAll(new RegExp(`(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*(?:await\\s+)?${callRe}`, 'gu'))) {
185
+ for (const name of importedNamesOf(`{${m[1]}}`)) named.add(name)
186
+ }
187
+ // ④ CJS / 动态导入的命名空间绑定:`const s = require('<mod>')`
188
+ for (const m of code.matchAll(new RegExp(`(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*(?:await\\s+)?${callRe}`, 'gu'))) {
189
+ namespaces.add(m[1])
190
+ }
191
+ return { named, namespaces }
192
+ }
193
+
194
+ /** 命中的已删除符号列表(空数组 = 干净)。判据见文件头:只有真绑定/真成员访问才算。 */
195
+ function settingsApiReferences(text) {
196
+ const { named, namespaces } = settingsApiBindings(text)
197
+ const found = []
198
+ for (const sym of REMOVED_SETTINGS_SYMBOLS) {
199
+ if (named.has(sym)) { found.push(sym); continue }
200
+ for (const ns of namespaces) {
201
+ if (new RegExp(`(?<![\\w$.])${ns}\\s*\\.\\s*${sym}(?![\\w$])`, 'u').test(maskLiterals(text, true))) { found.push(sym); break }
202
+ }
203
+ }
204
+ return found
205
+ }
206
+
207
+ /** 源码是否**引用**了某个已删除符号(对外契约不变;判据已收紧到"真绑定/真成员访问")。 */
208
+ function referencesRemovedSymbol(text, sym) {
209
+ return settingsApiReferences(text).includes(sym)
210
+ }
211
+
212
+ /**
213
+ * 扫描已安装包源码,检测对已删除的 dsh-settings API 的引用。
214
+ *
215
+ * 2026-09-04 教训:0.1.2-rc.1 起 settingsNamespace / installSettingsSection 已删除,
216
+ * 静态声明检查判 pass 是假通过;真实兼容性只有模块 import 时见分晓,而 loader 单行失败
217
+ * = 整个服务启动崩溃。
218
+ *
219
+ * 扫描预算(未改):限深 3 层、上限 120 个文件、单文件读前 400KB。
220
+ */
221
+ function scanSettingsApiUsage(pkgDir) {
222
+ const found = []
223
+ const seen = new Set()
224
+ const walk = (dir, depth) => {
225
+ if (depth > 3) return
226
+ let entries = []
227
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
228
+ for (const entry of entries) {
229
+ const full = join(dir, entry.name)
230
+ if (entry.isDirectory()) {
231
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
232
+ walk(full, depth + 1)
233
+ continue
234
+ }
235
+ if (!entry.isFile() || !/\.(?:js|mjs|cjs)$/u.test(entry.name)) continue
236
+ if (seen.has(full)) continue
237
+ seen.add(full)
238
+ if (seen.size > 120) return
239
+ try {
240
+ const text = readFileSync(full, 'utf8').slice(0, 400000)
241
+ for (const sym of settingsApiReferences(text)) {
242
+ if (!found.includes(sym)) found.push(sym)
243
+ }
244
+ } catch {}
245
+ }
246
+ }
247
+ walk(pkgDir, 0)
248
+ return found
249
+ }
250
+
251
+ export { REMOVED_SETTINGS_SYMBOLS, SETTINGS_MODULE, maskLiterals, settingsApiBindings, settingsApiReferences, referencesRemovedSymbol, scanSettingsApiUsage }
@@ -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 }
@@ -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(` 这类字样 —— 发版门槛自扫时正是这些字串报出了无意义条目。
@@ -17,6 +17,7 @@ import { routeAiConsent, routeAiEmpowerCancel, routeAiEmpowerList, routeAiEmpowe
17
17
  import { routeRunSuggested } from './actions.js'
18
18
  import { routeComponentAutostart, routeComponentStart, routeComponentStatus, routeComponentStop, routeComponents, routeRepoClone, routeRepoLandConfig, routeRepoList, routeRepoOpen, routeRepoRemove } from './components.js'
19
19
  import { routeCompatAck, routeCompatMode, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled } from './compat.js'
20
+ import { routeSafeBoot } from './safe-boot.js'
20
21
  import { routePatchAuditGet } from './patch-audit.js'
21
22
  import { routePresetAuditGet } from './preset-audit.js'
22
23
  import { routeFrameworkUpgrade } from './framework-upgrade.js'
@@ -114,6 +115,9 @@ const ROUTES = [
114
115
  { methods: ['POST'], path: `${ROUTE_PREFIX}/compat-stamp`, handler: routeCompatStamp },
115
116
  { methods: ['POST'], path: `${ROUTE_PREFIX}/compat-ack`, handler: routeCompatAck },
116
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 },
117
121
  { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-rollback`, handler: routeFrameworkRollback },
118
122
  { methods: ['POST'], path: `${ROUTE_PREFIX}/restart`, handler: routeRestart },
119
123
  { methods: ['POST'], path: `${ROUTE_PREFIX}/toggle`, handler: routeToggle },
@@ -327,7 +327,9 @@ async function routeAdaptUnlock(req, res, rc) {
327
327
  const fwVer = typeof pending.frameworkVersion === 'string' ? pending.frameworkVersion : '?'
328
328
  const check = pkg !== null ? checkPluginFrameworkCompat(pkg, fwVer, pkgDir) : { decision: 'unknown', reason: '无法读取包信息' }
329
329
  if (check.decision === 'fail') {
330
- sendError(res, 409, `适配校验未通过:${check.reason}`)
330
+ // 0.5.33 改错 ①-2:文案必须写清「为什么不行 + 出路」——旧文案只有一句"适配校验未通过",
331
+ // 用户在界面上根本不知道下一步该做什么(而按钮此前又只在 adoptable 时出现,容易变成对不上的承诺)。
332
+ sendError(res, 409, `适配校验未通过:${String(check.reason ?? '适配校验未通过')}。出路:先点「检测更新」把它更新到兼容当前框架 ${fwVer} 的版本(更新完成会自动重跑源码扫描,通过即解锁);若它已不再需要,可在补丁里删掉/禁用这一行。本次未改动补丁与适配门清单。`)
331
333
  return
332
334
  }
333
335
  await removeDisableBlock(patchPath, rowId)