@noob-stupid/dsh-plugin-console 0.5.31 → 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.
- package/README.md +32 -0
- package/README.zh.md +32 -0
- package/lib/client.js +15 -1
- package/lib/server/domain/auto-disable.js +226 -0
- package/lib/server/domain/auto-preflight-run.js +184 -0
- package/lib/server/domain/auto-preflight.js +345 -0
- package/lib/server/domain/compat-state.js +57 -8
- package/lib/server/domain/compat.js +6 -2
- package/lib/server/domain/patch-yaml-check.js +151 -0
- package/lib/server/domain/safe-boot.js +520 -0
- package/lib/server/routes/compat.js +240 -11
- package/lib/server/routes/framework-preflight.js +14 -1
- package/lib/server/routes/index.js +4 -0
- package/lib/server/routes/safe-boot.js +139 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -311,8 +311,40 @@ The mode decides **who leads framework upgrades** — nothing else:
|
|
|
311
311
|
|
|
312
312
|
- **Observer (default)**: the console only preflights, gates, and guards the rollback point. It does not take over upgrades.
|
|
313
313
|
- **Managed**: framework upgrade and rollback are owned by this console. It switches to managed automatically when you upgrade through this console, and records the reason.
|
|
314
|
+
- **Switches back to Observer automatically (0.5.32)**: when the **desktop-host shape** is detected (the existing `detectHostShape()` criteria), a mode that was **not set by hand** (`compat-mode.json` `source` is `auto:*`; a missing field counts as `manual`) is switched back to Observer on startup / first status query, with a one-line notice. **Once you switch it by hand it is never changed automatically again**; when it is already Observer nothing is written.
|
|
314
315
|
|
|
315
316
|
**Boot-failure quarantine is not affected by the mode.** When the service cannot start, the console locates the culprit from the boot log and disables it (broken presets are renamed to `.broken-*`; with no clear culprit it falls back to safe mode so the service can start). A console that cannot boot cannot gate anything — so this rescue path always runs.
|
|
317
|
+
|
|
318
|
+
**Auto-disable on confirmed evidence (0.5.32)**: when the fingerprint-triggered auto-preflight, or a
|
|
319
|
+
boot-failure record, provides **confirmed evidence** (the row's package resolves on **none** of the
|
|
320
|
+
resolution bases, a `file://` target truly does not exist, or the boot log names the row), the console
|
|
321
|
+
writes `disabled: true` for those rows **itself** (no click needed). Safety rails: **only rows with
|
|
322
|
+
evidence**; `@deepseek-ai/*` (framework-owned) and protected/core rows are **never** auto-disabled;
|
|
323
|
+
a **safe-boot snapshot is written first**, so any auto-disable is one command away from a rollback;
|
|
324
|
+
when there is nothing to write, **not a single byte is written**; every auto-disable leaves a record in
|
|
325
|
+
`dshHome()/plugin-console/auto-disable.log` (time / row id / evidence / restore command).
|
|
326
|
+
|
|
327
|
+
Prefer "report only, let me click" (enabled by default) — replace `<host:port>` with your instance
|
|
328
|
+
(the default `dsh web` profile listens on `127.0.0.1:3080`):
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
curl -X POST http://<host:port>/plugin-console/compat-ack \
|
|
332
|
+
-H 'Content-Type: application/json' -d '{"autoDisableOnEvidence":false}'
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
**How to roll an auto-disable back**: the route `POST /plugin-console/safe-boot` with
|
|
336
|
+
`{"action":"restore-last-good"}` (the current state is backed up first), or — when the service will not
|
|
337
|
+
start — from the plugin directory:
|
|
338
|
+
|
|
339
|
+
```bash
|
|
340
|
+
node scripts/safe-boot.mjs --list # see which snapshots exist
|
|
341
|
+
node scripts/safe-boot.mjs --restore-last-good # restore the newest good snapshot byte-for-byte
|
|
342
|
+
node scripts/safe-boot.mjs --disable-suspects # or: append disabled: true to named rows only (nothing deleted)
|
|
343
|
+
node scripts/safe-boot.mjs --mark-good # mark a snapshot as good (never removed by retention)
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Safe-boot snapshots live under `dshHome()/plugin-console/safe-boot/` (**not** in the profile directory),
|
|
347
|
+
keeping the newest 3 plus the one you marked as good.
|
|
316
348
|
## Framework compatibility
|
|
317
349
|
|
|
318
350
|
Measured on isolated instances (fresh `DSH_HOME`, real HTTP probes) — **supported: framework ≥ `0.1.5-rc.2`**, no workarounds needed.
|
package/README.zh.md
CHANGED
|
@@ -248,8 +248,40 @@ git clone https://github.com/Noob-stupid/dsh-plugin-gating-hub /tmp/dsh-plugin-c
|
|
|
248
248
|
|
|
249
249
|
- **观察者(默认)**:只做预检、门控与回滚点守护,不接管框架升级。
|
|
250
250
|
- **托管**:框架升级与回滚由本控制台负责。**走本控制台升级时会自动切到托管**并记下原因。
|
|
251
|
+
- **自动切回观察者(0.5.32)**:检测到**桌面端宿主形态**(复用既有 `detectHostShape()` 判据)时,
|
|
252
|
+
若当前模式**不是用户手动设定的**(`compat-mode.json` 的 `source` 为 `auto:*`;缺字段按 `manual` 兼容),
|
|
253
|
+
启动/首次状态查询会把它自动拨回观察者并给一行提示。**你手动拨过一次之后,它永不自动改**;
|
|
254
|
+
已经是观察者时零写盘。
|
|
251
255
|
|
|
252
256
|
**启动失败隔离不受模式影响。** 服务起不来时,控制台会从启动日志定位肇事插件并禁用它(预设文件改名 `.broken-*`;找不到明确肇事者则进安全模式先让服务起来)。**起不来的控制台什么都门控不了** —— 所以这条救火链路永远生效。
|
|
257
|
+
|
|
258
|
+
**有确证证据时会自动禁用(0.5.32)**:环境指纹变化触发的自动预检、或启动失败记录给出**确证证据**时
|
|
259
|
+
(行引用的包**在所有解析基准上都解析不到**、`file://` 目标确实不存在、启动失败日志**点名**该行),
|
|
260
|
+
控制台会**自动**给这些行写 `disabled: true`(不再等你点)。安全栏:**只禁有证据的行**;
|
|
261
|
+
`@deepseek-ai/*`(框架自带)与受保护/核心行**永不自动禁用**;**禁前先写一份"安全启动快照"**,
|
|
262
|
+
所以任何一次自动禁用都能一条命令回滚;没有可写内容时**一个字节都不写**;每次自动禁用都在
|
|
263
|
+
`dshHome()/plugin-console/auto-disable.log` 留一条记录(时间 / 行 id / 证据 / 恢复命令)。
|
|
264
|
+
|
|
265
|
+
想改成"只报告、等我自己点"(默认启用)—— `<host:port>` 换成你实例的地址
|
|
266
|
+
(默认 `dsh web` profile 监听 `127.0.0.1:3080`):
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
curl -X POST http://<host:port>/plugin-console/compat-ack \
|
|
270
|
+
-H 'Content-Type: application/json' -d '{"autoDisableOnEvidence":false}'
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
**怎么恢复(把自动禁用回滚)**:接口 `POST /plugin-console/safe-boot` + `{"action":"restore-last-good"}`
|
|
274
|
+
(改前会再备份一份当前状态);或服务打不开时,在插件目录下跑:
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
node scripts/safe-boot.mjs --list # 先看清有哪些快照
|
|
278
|
+
node scripts/safe-boot.mjs --restore-last-good # 整份逐字节恢复到最近的良好快照
|
|
279
|
+
node scripts/safe-boot.mjs --disable-suspects # 或:只给点名的可疑行追加 disabled: true(不删任何行)
|
|
280
|
+
node scripts/safe-boot.mjs --mark-good # 把某份快照标记为"良好"(永不随保留策略删除)
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
安全启动快照落在 `dshHome()/plugin-console/safe-boot/`(**不在** profile 目录里),保留最近 3 份 +
|
|
284
|
+
永不删你标记为"良好"的那份。
|
|
253
285
|
## 框架兼容性
|
|
254
286
|
|
|
255
287
|
在隔离实例(全新 `DSH_HOME` + 真实 HTTP 探针)实测:**支持框架 ≥ `0.1.5-rc.2`**,无需任何变通。
|
package/lib/client.js
CHANGED
|
@@ -5486,7 +5486,21 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
|
|
|
5486
5486
|
// 2026-09-27 加法:依赖锁体检结果(null = 还没体检过;不自动跑,用户点「体检」才打接口)
|
|
5487
5487
|
const [lockFix, setLockFix] = react.useState(null);
|
|
5488
5488
|
const load = async () => {
|
|
5489
|
-
try {
|
|
5489
|
+
try {
|
|
5490
|
+
const data = await call("/plugin-console/compat-status");
|
|
5491
|
+
if (data !== null) {
|
|
5492
|
+
setStatus(data);
|
|
5493
|
+
// 0.5.32 加法(D1/D4/D5):**瞬时提示**,沿用既有 note 通道(自动消失、无新组件、无按钮)。
|
|
5494
|
+
// 文案由服务端下发(中英双语短句):「已自动切观察者」/「框架变化,已自动预检」/「已自动禁用 N 行」。
|
|
5495
|
+
// 语言选择复用既有 i18n:t() 在中文下取键名本身(sentinel),在英文下取字典值。
|
|
5496
|
+
const isEn = t("safetyUnknown") !== "safetyUnknown";
|
|
5497
|
+
const pick = (pair) => (pair === null || pair === undefined ? null : (isEn ? (pair.en || pair.zh) : (pair.zh || pair.en)));
|
|
5498
|
+
const auto = data.autoDisable && data.autoDisable.notice ? pick(data.autoDisable.notice) : null;
|
|
5499
|
+
if (typeof auto === "string" && auto !== "") setNote({ text: auto });
|
|
5500
|
+
else if (typeof data.autoPreflight?.note?.zh === "string" && data.autoPreflight.changed === true && data.autoPreflight.scheduled !== true) setNote({ text: pick(data.autoPreflight.note) });
|
|
5501
|
+
else if (data.modeAuto) setNote({ text: pick(data.modeAuto.note) });
|
|
5502
|
+
}
|
|
5503
|
+
} catch {}
|
|
5490
5504
|
};
|
|
5491
5505
|
// 依赖锁体检:**只读**接口,不打任何写操作
|
|
5492
5506
|
const lockCheck = async () => {
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// L1 · domain —— auto-disable.js(把「自动禁用」的**触发源**从"我们升级"扩到"指纹变化 / 启动失败记录";
|
|
2
|
+
// 0.5.32 加法 D5,用户 2026-09-29 明确要求「我们本来就有自动禁用,要的是扩展」)
|
|
3
|
+
//
|
|
4
|
+
// 现状(0.5.31)自动禁用只有两个触发点,**都绑在我们自己的动作上**:
|
|
5
|
+
// ① `healPatchSafety`(判"模块确证缺失",随 GET /state 每 ≤2min 跑一次)
|
|
6
|
+
// ② 我们升级流程里的 `preflightDisableIncompatible`
|
|
7
|
+
// 于是**别人**改框架(官方桌面端更新器 / 手动 pnpm / npx 缓存变化)时,两者都不触发 ——
|
|
8
|
+
// 用户看到的是一堆报错,而不是"肇事行已被关掉、服务能起来"。
|
|
9
|
+
//
|
|
10
|
+
// 本模块把触发源接到 D1 的预检清单与启动失败记录上,**但必须带上 0.5.30 已立的安全栏**:
|
|
11
|
+
// ① **只禁有证据的行**:`package-unresolvable`(解析器在所有基准上都解析不到)/ `file-target-missing`
|
|
12
|
+
// (`file://` 目标确实不存在)/ 启动失败日志**点名**的行。**解析不确定**(没有基准、解析器抛错、
|
|
13
|
+
// 子路径解析不到)一律**只报告不写盘**。
|
|
14
|
+
// ② **`@deepseek-ai/*`(框架自带)永不自动禁用**、**受保护/核心行永不自动禁用**
|
|
15
|
+
// (`framework.js:483/493` 那两类判据:`CORE_PATCH_ROW_IDS` + `isProtected`)。
|
|
16
|
+
// ③ **禁前先写 last-known-good 快照**:任何一次自动禁用都能一键 / 一条命令回滚。
|
|
17
|
+
// ④ **幂等零写盘** + **每次留一条可读记录**(时间 / 行 id / 证据 / 恢复命令)。
|
|
18
|
+
// ⑤ **开关**:`compat-gate.json#autoDisableOnEvidence`(默认启用;关闭 = 退回"只报告 + 等你点")。
|
|
19
|
+
//
|
|
20
|
+
// 分层:L1 domain —— 无 ctx;落盘复用 `domain/safe-boot.js` 的同一套原语(备份 + 原子写 + 读回核实)。
|
|
21
|
+
|
|
22
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
23
|
+
import { dirname, join } from 'node:path'
|
|
24
|
+
import { CORE_PATCH_ROW_IDS } from './patch.js'
|
|
25
|
+
import { parsePatchRows } from './patch-composition-audit.js'
|
|
26
|
+
import { disableSuspects, ensureSnapshotForCurrentState } from './safe-boot.js'
|
|
27
|
+
import { dshHome } from '../infra/paths.js'
|
|
28
|
+
|
|
29
|
+
/** 自动禁用的可读记录(追加式 JSONL:每次自动禁用一条,永不覆盖历史)。 */
|
|
30
|
+
const autoDisableLog = (home = undefined) => join(home ?? dshHome(), 'plugin-console', 'auto-disable.log')
|
|
31
|
+
|
|
32
|
+
/** 有证据的行必须落在这些 `code` 上(其余一律只报告)。 */
|
|
33
|
+
const EVIDENCE_CODES = {
|
|
34
|
+
'package-unresolvable': '包在所有解析基准上都解析不到(确证缺失)',
|
|
35
|
+
'file-target-missing': 'file:// 目标文件确实不存在(确证缺失)',
|
|
36
|
+
'boot-log': '启动失败日志点名了这一行',
|
|
37
|
+
'boot-quarantine': '升级脚本留下的启动失败隔离记录点名了这一行',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 明确**不算**证据的形态(写在这里是为了让"为什么没禁"可查,而不是静默放过)。 */
|
|
41
|
+
const NON_EVIDENCE_CODES = {
|
|
42
|
+
'subpath-unresolvable': '子路径解析不到(父包还在,可能只是导出变了)',
|
|
43
|
+
'row-without-name': '行既没有 name 也没有 config(形态可疑但无证据)',
|
|
44
|
+
'legacy-source-kind': '源码里的旧形状(可以自动改,不需要禁用)',
|
|
45
|
+
'legacy-source-kind-unresolved': '旧形状无法安全改写(需要人工过一眼)',
|
|
46
|
+
'schemastery-volatile': '依赖副本陈旧(对齐依赖即可,不需要禁用)',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 框架自带包(`@deepseek-ai/*`)—— 永不由本模块自动禁用。 */
|
|
50
|
+
const isFrameworkOwnedName = (name) => {
|
|
51
|
+
const text = String(name ?? '')
|
|
52
|
+
if (text.startsWith('@deepseek-ai/')) return true
|
|
53
|
+
// 裸包名形态(补丁行写 `dsh-xxx` 而不是全名)也要认:框架内置包在补丁里两种写法都出现过
|
|
54
|
+
return /^dsh-[a-z0-9-]+$/iu.test(text)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 从「D1 预检清单 + 启动失败记录」里挑出**有确证证据、且过得了安全栏**的行(纯函数,不写盘)。
|
|
59
|
+
* @returns {{ disable: Array<{rowId,moduleName,evidence,detail}>, skipped: Array<{rowId,reason,evidence?}>, hasEvidence: boolean }}
|
|
60
|
+
*/
|
|
61
|
+
function planEvidenceDisable({
|
|
62
|
+
affected = [], bootRows = [], patchText = '', coreRowIds = CORE_PATCH_ROW_IDS,
|
|
63
|
+
isProtected = null, uncertainty = null, maxRows = 20,
|
|
64
|
+
} = {}) {
|
|
65
|
+
const rows = parsePatchRows(patchText)
|
|
66
|
+
const byId = new Map(rows.map((r) => [r.id, r]))
|
|
67
|
+
const disable = []
|
|
68
|
+
const skipped = []
|
|
69
|
+
const pushSkip = (rowId, reason, evidence = null) => {
|
|
70
|
+
if (typeof rowId !== 'string' || rowId === '') return
|
|
71
|
+
if (disable.some((d) => d.rowId === rowId)) return
|
|
72
|
+
if (skipped.some((s) => s.rowId === rowId)) return
|
|
73
|
+
skipped.push({ rowId, reason, evidence })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 解析面**不确定**(一个基准都没有 / 解析器建不起来)→ 整批只报告,绝不写盘
|
|
77
|
+
if (typeof uncertainty === 'string' && uncertainty !== '') {
|
|
78
|
+
for (const item of affected) if (item?.rowId) pushSkip(item.rowId, `resolution-uncertain: ${uncertainty}`, item.code ?? null)
|
|
79
|
+
return { disable, skipped, hasEvidence: false }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ① D1 预检清单里的**确证**证据
|
|
83
|
+
for (const item of (Array.isArray(affected) ? affected : [])) {
|
|
84
|
+
const rowId = typeof item?.rowId === 'string' ? item.rowId : null
|
|
85
|
+
if (rowId === null) continue
|
|
86
|
+
const code = item.code ?? null
|
|
87
|
+
if (EVIDENCE_CODES[code] === undefined) { pushSkip(rowId, `not-evidence: ${NON_EVIDENCE_CODES[code] ?? code ?? 'unknown'}`, code); continue }
|
|
88
|
+
if (coreRowIds.has(rowId)) { pushSkip(rowId, 'core-row', code); continue }
|
|
89
|
+
if (rowId === 'plugin-console') { pushSkip(rowId, 'self', code); continue }
|
|
90
|
+
const row = byId.get(rowId) ?? null
|
|
91
|
+
if (row === null) { pushSkip(rowId, 'not-in-patch', code); continue }
|
|
92
|
+
if (row.disabled === true) { pushSkip(rowId, 'already-disabled', code); continue }
|
|
93
|
+
const moduleName = typeof item.moduleName === 'string' && item.moduleName !== '' ? item.moduleName : (row.name ?? null)
|
|
94
|
+
if (moduleName === null) { pushSkip(rowId, 'module-unknown', code); continue }
|
|
95
|
+
if (isFrameworkOwnedName(moduleName)) { pushSkip(rowId, 'framework-owned', code); continue }
|
|
96
|
+
if (typeof isProtected === 'function' && isProtected(moduleName) === true) { pushSkip(rowId, 'protected-module', code); continue }
|
|
97
|
+
if (disable.length >= maxRows) { pushSkip(rowId, 'row-limit', code); continue }
|
|
98
|
+
disable.push({ rowId, moduleName, evidence: code, detail: EVIDENCE_CODES[code] })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ② 启动失败记录点名的行(计划里的 `rows`:分析器已剔除核心行/不可开关行)
|
|
102
|
+
for (const rowId of (Array.isArray(bootRows) ? bootRows : [])) {
|
|
103
|
+
if (typeof rowId !== 'string' || rowId === '') continue
|
|
104
|
+
if (coreRowIds.has(rowId) || rowId === 'plugin-console') { pushSkip(rowId, 'core-row', 'boot-log'); continue }
|
|
105
|
+
const row = byId.get(rowId) ?? null
|
|
106
|
+
if (row === null) { pushSkip(rowId, 'not-in-patch', 'boot-log'); continue }
|
|
107
|
+
if (row.disabled === true) { pushSkip(rowId, 'already-disabled', 'boot-log'); continue }
|
|
108
|
+
const moduleName = row.name ?? null
|
|
109
|
+
if (moduleName !== null && isFrameworkOwnedName(moduleName)) { pushSkip(rowId, 'framework-owned', 'boot-log'); continue }
|
|
110
|
+
if (moduleName !== null && typeof isProtected === 'function' && isProtected(moduleName) === true) { pushSkip(rowId, 'protected-module', 'boot-log'); continue }
|
|
111
|
+
if (disable.length >= maxRows) { pushSkip(rowId, 'row-limit', 'boot-log'); continue }
|
|
112
|
+
disable.push({ rowId, moduleName, evidence: 'boot-log', detail: EVIDENCE_CODES['boot-log'] })
|
|
113
|
+
}
|
|
114
|
+
return { disable, skipped, hasEvidence: disable.length > 0 }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 追加一条可读记录(JSONL;失败不抛 —— 它不能把自动禁用本身带崩)。 */
|
|
118
|
+
function appendDisableLog(entry, { home = undefined, appendFile = (p, d) => appendFileSync(p, d, 'utf8'), mkdir = (d) => mkdirSync(d, { recursive: true }) } = {}) {
|
|
119
|
+
try {
|
|
120
|
+
const file = autoDisableLog(home)
|
|
121
|
+
mkdir(dirname(file))
|
|
122
|
+
appendFile(file, `${JSON.stringify(entry)}\n`)
|
|
123
|
+
return { ok: true, file }
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 读记录(新→旧;损坏行跳过)。 */
|
|
130
|
+
function readDisableLog({ home = undefined, limit = 50, readFile = (p) => readFileSync(p, 'utf8') } = {}) {
|
|
131
|
+
try {
|
|
132
|
+
return readFile(autoDisableLog(home))
|
|
133
|
+
.split(/\r?\n/u)
|
|
134
|
+
.filter((l) => l.trim() !== '')
|
|
135
|
+
.map((l) => { try { return JSON.parse(l) } catch { return null } })
|
|
136
|
+
.filter((x) => x !== null)
|
|
137
|
+
.slice(-limit)
|
|
138
|
+
.reverse()
|
|
139
|
+
} catch { return [] }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 有证据就自动禁用(D5 的落盘入口)。**顺序是关键**:
|
|
144
|
+
* 计划(纯判定)→ **禁前快照**(当前状态有可回滚材料)→ 复用 `disableSuspects`
|
|
145
|
+
* (改前备份 + 严格 YAML 闸门 + 原子写 + 读回核实)→ 追加可读记录。
|
|
146
|
+
* 任一步失败都如实返回,**绝不部分静默**;`enabled !== true` 时只报告不写盘(退回"等你点")。
|
|
147
|
+
*/
|
|
148
|
+
function applyEvidenceDisable({
|
|
149
|
+
patchPath, patchText = null, affected = [], bootRows = [], enabled = true,
|
|
150
|
+
fingerprint = null, frameworkVersion = null, reasons = [], home = undefined,
|
|
151
|
+
isProtected = null, uncertainty = null, coreRowIds = CORE_PATCH_ROW_IDS,
|
|
152
|
+
readFile = (p) => readFileSync(p, 'utf8'), exists = existsSync, ...rest
|
|
153
|
+
} = {}) {
|
|
154
|
+
try {
|
|
155
|
+
if (typeof patchPath !== 'string' || patchPath === '') return { ok: false, code: 'no-patch-path', disabled: [], skipped: [] }
|
|
156
|
+
// 补丁不存在 → 如实拒绝(**绝不创建**:凭一份"没读到的补丁"去写 disabled 块等于凭空造文件)
|
|
157
|
+
if (typeof patchText !== 'string' && exists(patchPath) !== true) {
|
|
158
|
+
return { ok: false, code: 'patch-missing', disabled: [], skipped: [], error: `补丁文件不存在:${patchPath}` }
|
|
159
|
+
}
|
|
160
|
+
const text = typeof patchText === 'string' ? patchText : readFile(patchPath)
|
|
161
|
+
const plan = planEvidenceDisable({ affected, bootRows, patchText: text, coreRowIds, isProtected, uncertainty })
|
|
162
|
+
if (plan.disable.length === 0) {
|
|
163
|
+
return { ok: true, code: 'no-evidence', disabled: [], skipped: plan.skipped, snapshot: null, note: '没有确证证据可禁用(只报告不写盘)' }
|
|
164
|
+
}
|
|
165
|
+
if (enabled !== true) {
|
|
166
|
+
return {
|
|
167
|
+
ok: true, code: 'switch-off', disabled: [], skipped: plan.skipped, snapshot: null,
|
|
168
|
+
candidates: plan.disable.map((d) => d.rowId),
|
|
169
|
+
note: '自动禁用开关已关闭:只报告,等你手动点「只禁可疑行」(或离线脚本 --disable-suspects)',
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// ③ 禁前先写 last-known-good 快照:没有可回滚材料就不动手
|
|
173
|
+
const snap = ensureSnapshotForCurrentState({
|
|
174
|
+
patchPath, patchText: text, fingerprint, frameworkVersion, reasons, home,
|
|
175
|
+
note: `自动禁用前的安全快照(点名 ${plan.disable.length} 行:${plan.disable.map((d) => d.rowId).join('、')})`,
|
|
176
|
+
readFile,
|
|
177
|
+
})
|
|
178
|
+
if (snap.ok !== true) {
|
|
179
|
+
return { ok: false, code: 'snapshot-failed', disabled: [], skipped: plan.skipped, snapshot: snap, error: `禁前快照失败,已放弃自动禁用:${snap.error ?? '未知原因'}` }
|
|
180
|
+
}
|
|
181
|
+
const result = disableSuspects({
|
|
182
|
+
patchPath, rowIds: plan.disable.map((d) => d.rowId), home, coreRowIds,
|
|
183
|
+
moduleNameOf: (rowId) => plan.disable.find((d) => d.rowId === rowId)?.moduleName ?? null,
|
|
184
|
+
isProtected, isFrameworkOwned: null, readFile,
|
|
185
|
+
})
|
|
186
|
+
const entry = {
|
|
187
|
+
at: Date.now(),
|
|
188
|
+
kind: 'auto-disable',
|
|
189
|
+
source: 'evidence',
|
|
190
|
+
added: result.ok === true ? (result.added ?? []) : [],
|
|
191
|
+
skipped: result.skipped ?? plan.skipped,
|
|
192
|
+
evidence: plan.disable.map((d) => ({ rowId: d.rowId, moduleName: d.moduleName, code: d.evidence, detail: d.detail })),
|
|
193
|
+
snapshotId: snap.id ?? null,
|
|
194
|
+
backupPath: result.backupPath ?? null,
|
|
195
|
+
patchPath,
|
|
196
|
+
frameworkVersion: frameworkVersion ?? fingerprint?.frameworkVersion ?? null,
|
|
197
|
+
fingerprint: fingerprint ?? null,
|
|
198
|
+
restoreCommand: 'node scripts/safe-boot.mjs --restore-last-good',
|
|
199
|
+
result: { ok: result.ok === true, code: result.code ?? null, error: result.error ?? null },
|
|
200
|
+
}
|
|
201
|
+
const logged = appendDisableLog(entry, { home })
|
|
202
|
+
if (result.ok !== true) {
|
|
203
|
+
return { ok: false, code: result.code ?? 'disable-failed', disabled: [], skipped: result.skipped ?? plan.skipped, snapshot: snap, error: result.error ?? '自动禁用失败', log: logged }
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
ok: true, code: result.changed === true ? 'auto-disabled' : 'noop', disabled: result.added ?? [], skipped: result.skipped ?? plan.skipped,
|
|
207
|
+
snapshot: snap, backupPath: result.backupPath ?? null, log: logged, prefixSame: result.prefixSame === true,
|
|
208
|
+
note: result.changed === true
|
|
209
|
+
? `已自动禁用 ${result.added.length} 行(有确证证据;禁前快照 ${snap.id},可一条命令回滚)`
|
|
210
|
+
: '点名行都已处于禁用状态(幂等零写盘)',
|
|
211
|
+
}
|
|
212
|
+
} catch (error) {
|
|
213
|
+
return { ok: false, code: 'auto-disable-failed', disabled: [], skipped: [], error: `自动禁用失败:${error instanceof Error ? error.message : String(error)}` }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export {
|
|
218
|
+
EVIDENCE_CODES,
|
|
219
|
+
NON_EVIDENCE_CODES,
|
|
220
|
+
appendDisableLog,
|
|
221
|
+
applyEvidenceDisable,
|
|
222
|
+
autoDisableLog,
|
|
223
|
+
isFrameworkOwnedName,
|
|
224
|
+
planEvidenceDisable,
|
|
225
|
+
readDisableLog,
|
|
226
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// L1 · domain —— auto-preflight-run.js(**只读**预检运行器:指纹变化后现场跑一次扫描;0.5.32 加法 D1)
|
|
2
|
+
//
|
|
3
|
+
// 与 `routes/framework-preflight.js` 的分工:
|
|
4
|
+
// · 那条路由是**用户手动点**的完整预检(目标版本可指定、会打 registry 抓契约、可能写补丁);
|
|
5
|
+
// · 本模块是**指纹变化自动触发**的**只读**预检 —— 不下载、不安装、不改补丁、不碰 loader,
|
|
6
|
+
// 只用「内置规则库 + 本地文件 + 补丁体检 + 启动失败日志分析」产出三样东西:
|
|
7
|
+
// ① 兼容清单(受影响行) ② 隔离计划(建议禁用哪些行、为什么) ③ 变更摘要(版本 from→to / 包增减 / 指纹 reasons)
|
|
8
|
+
// 网络:自动模式下**一律跳过外部 registry**(自动触发不该偷偷联网)—— 结论完全由本地判据得出,
|
|
9
|
+
// 这也是「离线脚本能在服务没跑时用同一套判据」的前提。
|
|
10
|
+
//
|
|
11
|
+
// 分层:L1 domain —— 不出现 ctx(loader 条目 / 扫描根由调用方注入);IO 与纯判据分离,离线可断言。
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import { affectedRows, planSuspectRows, summarizeDelta } from './auto-preflight.js'
|
|
17
|
+
import { loadContractRules, summarizeContractRules } from './contract-rules.js'
|
|
18
|
+
import { collectProducerTargets, scanProducerFiles } from './format-scan.js'
|
|
19
|
+
import { CORE_PATCH_ROW_IDS } from './patch.js'
|
|
20
|
+
import { classifyPatchRows, parsePatchRows } from './patch-composition-audit.js'
|
|
21
|
+
import { collectPresetAudit } from './preset-audit.js'
|
|
22
|
+
import { planQuarantine, readQuarantineRecord } from './quarantine.js'
|
|
23
|
+
import { makePackageResolver } from '../infra/package-resolve.js'
|
|
24
|
+
|
|
25
|
+
/** 扫不到规则库时的兜底规则(与 routes/framework-preflight.js 的兜底一字不差)。 */
|
|
26
|
+
const DEFAULT_RULES = { producerKindRequired: false, forbiddenKinds: ['plugin'], pluginPrefix: 'plugin:', renames: {} }
|
|
27
|
+
|
|
28
|
+
/** 自动模式下的固定说明(如实告诉用户"这份结论没打网络")。 */
|
|
29
|
+
const OFFLINE_NOTE = {
|
|
30
|
+
zh: '自动预检是只读且不联网的:结论来自内置规则库与本地文件;目标版本的 registry 契约未核对(可手动跑完整预检)',
|
|
31
|
+
en: 'Auto-preflight is read-only and offline: conclusions come from the built-in rule pack and local files; the target-version registry contract was not checked (run the full preflight manually)',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `<pnpmRoot>/.pnpm` 下的 `@deepseek-ai+*` 实体名(去版本后缀)—— 用于变更摘要的「新增/消失的包」。
|
|
36
|
+
* ★ 只用于**摘要展示**,绝不用于「包在不在」的判定:pnpm 会截断超长目录名(见 infra/package-resolve.js 头注释),
|
|
37
|
+
* 所以解析性判定一律走 Node 解析器(本文件里的 `resolve`)。
|
|
38
|
+
*/
|
|
39
|
+
function packageNamesOf(pnpmRoot, readDir = (dir) => readdirSync(dir)) {
|
|
40
|
+
if (typeof pnpmRoot !== 'string' || pnpmRoot === '') return []
|
|
41
|
+
let names = []
|
|
42
|
+
try { names = readDir(join(pnpmRoot, '.pnpm')) } catch { return [] }
|
|
43
|
+
const out = new Set()
|
|
44
|
+
for (const name of names) {
|
|
45
|
+
if (typeof name !== 'string' || !name.startsWith('@deepseek-ai+')) continue
|
|
46
|
+
const at = name.indexOf('@', 1)
|
|
47
|
+
out.add(at === -1 ? name : name.slice(0, at))
|
|
48
|
+
}
|
|
49
|
+
return [...out].sort()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `file://` URL → 路径(离线安全;解析失败抛给调用方,由调用方转成 false)。 */
|
|
53
|
+
function fileUrlToPath(url) {
|
|
54
|
+
return fileURLToPath(String(url ?? ''))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 跑一次只读预检(**同步**:全部是本地文件扫描,调用方用 withTimeout 包裹即可)。
|
|
59
|
+
* @param {object} input
|
|
60
|
+
* @param {string|null} input.profileDir profile 目录(补丁所在目录)
|
|
61
|
+
* @param {string|null} input.patchPath cordis.patch.yml 绝对路径
|
|
62
|
+
* @param {object|null} input.current 当前指纹(frameworkVersion / pnpmEntities / pnpmRoot)
|
|
63
|
+
* @param {object|null} input.previous 基线指纹(可能为 null)
|
|
64
|
+
* @param {string[]} input.presetRoots 预设扫描根(通常 `<dshHome>/.agent-presets`)
|
|
65
|
+
* @param {Array<{root:string,kind:string,moduleName?:string|null}>} input.roots 生产方扫描根
|
|
66
|
+
* @param {string[]} input.reasons 指纹变更原因(直接来自 compareFingerprint)
|
|
67
|
+
* @param {string[]} input.previousPackages 上一次记录的框架包名(没有则空数组 → 摘要如实标 rationaleAvailable=false)
|
|
68
|
+
* @param {boolean} input.networkAllowed 是否允许打网络(自动预检一律 false)
|
|
69
|
+
* @param {Array} [input.targets] 直接给扫描目标(测试注入;缺省由 roots 推导)
|
|
70
|
+
* @param {boolean} [input.includeBootLog] 是否把 profile 下的 `.dsh-boot.log` 纳入启动失败分析(缺省 true)
|
|
71
|
+
*/
|
|
72
|
+
function runAutoPreflightScan(input = {}) {
|
|
73
|
+
const {
|
|
74
|
+
profileDir = null, patchPath = null, current = null, previous = null,
|
|
75
|
+
presetRoots = [], roots = [], reasons = [], previousPackages = [],
|
|
76
|
+
networkAllowed = false, targets = null, includeBootLog = true,
|
|
77
|
+
readFile = (p) => readFileSync(p, 'utf8'), existsFile = existsSync, readDir = (dir) => readdirSync(dir),
|
|
78
|
+
} = input
|
|
79
|
+
|
|
80
|
+
// ① 补丁体检(只读):行 → 包可解析性(blocker / warning / 改名建议)
|
|
81
|
+
const patchText = typeof patchPath === 'string' && existsFile(patchPath) ? readFile(patchPath) : ''
|
|
82
|
+
const rows = parsePatchRows(patchText)
|
|
83
|
+
const bases = [profileDir, current?.pnpmRoot]
|
|
84
|
+
.map((b) => (typeof b === 'string' && b !== '' ? b : null))
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
const resolve = bases.length > 0 ? makePackageResolver(bases) : null
|
|
87
|
+
const patchReport = resolve === null
|
|
88
|
+
? { blockers: [], warnings: [], resolvable: [], patchOnly: [], skipped: '没有可用的解析基准(profile 目录与框架运行时根都拿不到)' }
|
|
89
|
+
: classifyPatchRows(rows, {
|
|
90
|
+
resolve: (name) => resolve(name),
|
|
91
|
+
existsFile: (url) => { try { return existsFile(fileUrlToPath(url)) } catch { return false } },
|
|
92
|
+
candidates: [],
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// ② 生产方源码扫描(会话格式契约):规则来自**数据化规则库**(对我们没发过升级包的框架版本同样有效)
|
|
96
|
+
const rulePack = (() => { try { return loadContractRules() } catch { return null } })()
|
|
97
|
+
const hasRules = Array.isArray(rulePack?.rules) && rulePack.rules.length > 0
|
|
98
|
+
const scanTargets = Array.isArray(targets) ? targets : collectProducerTargets(roots)
|
|
99
|
+
const scan = scanProducerFiles({ targets: scanTargets, rules: hasRules ? rulePack.rules : DEFAULT_RULES, targetVersion: current?.frameworkVersion ?? null })
|
|
100
|
+
const scanSafe = { ...scan, files: (scan.files ?? []).slice(0, 40), findings: (scan.findings ?? []).slice(0, 40) }
|
|
101
|
+
|
|
102
|
+
// ③ 预设声明行体检(只读;失败如实记 error,不假装"零发现")
|
|
103
|
+
let presetAudit = null
|
|
104
|
+
try {
|
|
105
|
+
presetAudit = collectPresetAudit({ presetsRoot: presetRoots[0] ?? null, patchPath })
|
|
106
|
+
} catch (error) {
|
|
107
|
+
presetAudit = { ok: false, error: error instanceof Error ? error.message : String(error), blockers: 0, stale: [], missing: [], orphan: [], targetMissing: [] }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ④ 受影响行 + 隔离计划(只**建议**,绝不写盘)
|
|
111
|
+
const affected = affectedRows({ patchReport, scan, rows })
|
|
112
|
+
const plan = planSuspectRows(affected, { coreRowIds: CORE_PATCH_ROW_IDS, toggleableById: new Map() })
|
|
113
|
+
|
|
114
|
+
// ⑤ 我们的升级脚本若曾留下隔离记录,把它并进计划(旧记录仍有效)。
|
|
115
|
+
// 别人升级时没有这份记录 —— 这正是 0.5.32 补的缺口:兜底输入换成「指纹变化自动预检」的结论。
|
|
116
|
+
let bootQuarantine = null
|
|
117
|
+
try { bootQuarantine = readQuarantineRecord() } catch { bootQuarantine = null }
|
|
118
|
+
if (bootQuarantine !== null && Array.isArray(bootQuarantine.rows)) {
|
|
119
|
+
for (const rowId of bootQuarantine.rows) {
|
|
120
|
+
if (typeof rowId !== 'string' || rowId === '') continue
|
|
121
|
+
if (plan.suspects.some((s) => s.rowId === rowId)) continue
|
|
122
|
+
plan.suspects.push({ rowId, moduleName: null, reason: 'boot-quarantine', detail: '升级脚本留下的启动失败隔离记录', line: null })
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ⑥ 启动失败日志(若存在):把「谁把服务搞挂了」也变成点名行
|
|
127
|
+
let bootPlan = null
|
|
128
|
+
if (includeBootLog === true) {
|
|
129
|
+
try {
|
|
130
|
+
const logPath = join(dirname(String(patchPath ?? join(profileDir ?? '.', 'x'))), '.dsh-boot.log')
|
|
131
|
+
if (existsFile(logPath)) {
|
|
132
|
+
bootPlan = planQuarantine({ logText: readFile(logPath), candidates: rows.map((r) => ({ rowId: r.id, moduleName: r.name, toggleable: true })) })
|
|
133
|
+
for (const rowId of bootPlan.rows ?? []) {
|
|
134
|
+
if (!plan.suspects.some((s) => s.rowId === rowId)) plan.suspects.push({ rowId, moduleName: null, reason: 'boot-log', detail: '启动失败日志点名', line: null })
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch { bootPlan = null }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const delta = summarizeDelta({
|
|
141
|
+
current,
|
|
142
|
+
previous,
|
|
143
|
+
currentPackages: packageNamesOf(current?.pnpmRoot ?? null, readDir),
|
|
144
|
+
previousPackages,
|
|
145
|
+
})
|
|
146
|
+
const blockers = affected.filter((a) => a.severity === 'blocker').length
|
|
147
|
+
return {
|
|
148
|
+
ok: true,
|
|
149
|
+
at: Date.now(),
|
|
150
|
+
targetVersion: current?.frameworkVersion ?? null,
|
|
151
|
+
previousVersion: previous?.frameworkVersion ?? null,
|
|
152
|
+
reasons: Array.isArray(reasons) ? reasons : [],
|
|
153
|
+
summary: {
|
|
154
|
+
frameworkVersionFrom: previous?.frameworkVersion ?? null,
|
|
155
|
+
frameworkVersionTo: current?.frameworkVersion ?? null,
|
|
156
|
+
versionChanged: (previous?.frameworkVersion ?? null) !== (current?.frameworkVersion ?? null),
|
|
157
|
+
blockers,
|
|
158
|
+
affectedCount: affected.length,
|
|
159
|
+
suspectCount: plan.suspects.length,
|
|
160
|
+
packagesAdded: delta.packagesAdded,
|
|
161
|
+
packagesRemoved: delta.packagesRemoved,
|
|
162
|
+
note: blockers > 0 ? `预检发现 ${blockers} 行受影响` : '预检未发现受影响行',
|
|
163
|
+
},
|
|
164
|
+
affected,
|
|
165
|
+
suspects: plan.suspects,
|
|
166
|
+
skipped: plan.skipped,
|
|
167
|
+
delta,
|
|
168
|
+
// D5(自动禁用的扩展触发源)要用的两样东西:确证证据(affected 的行/码)与启动失败点名行。
|
|
169
|
+
// 这里是**只读**产出,是否落盘由 routes 层按开关决定(domain 不做"要不要动用户文件"的判断)。
|
|
170
|
+
evidence: {
|
|
171
|
+
uncertainty: resolve === null ? '没有可用的解析基准(profile 目录与框架运行时根都拿不到)' : (typeof patchReport.skipped === 'string' ? patchReport.skipped : null),
|
|
172
|
+
rows: affected.filter((a) => a.rowId !== null).map((a) => ({ rowId: a.rowId, moduleName: a.moduleName ?? null, code: a.code ?? null, severity: a.severity })),
|
|
173
|
+
bootRows: (bootPlan?.rows ?? []),
|
|
174
|
+
},
|
|
175
|
+
patch: { patchPath, rows: rows.length, blockers: (patchReport.blockers ?? []).length, warnings: (patchReport.warnings ?? []).length, skipped: patchReport.skipped ?? null },
|
|
176
|
+
scan: scanSafe,
|
|
177
|
+
rules: hasRules ? summarizeContractRules(rulePack) : { source: 'unavailable', version: null, updatedAt: null, total: 0, blockers: 0, autoDetected: 0, ids: [], error: '规则库不可用:本次扫描只用内置兜底规则(结论偏保守)' },
|
|
178
|
+
presetAudit,
|
|
179
|
+
bootPlan,
|
|
180
|
+
network: { allowed: networkAllowed === true, note: networkAllowed === true ? null : OFFLINE_NOTE },
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export { DEFAULT_RULES, OFFLINE_NOTE, fileUrlToPath, packageNamesOf, runAutoPreflightScan }
|