@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
|
@@ -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 {
|
|
15
|
-
import {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
24
|
-
|
|
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.
|
|
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
|
-
|
|
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, {
|
|
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 },
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// L2 · routes —— **安全启动材料**恢复入口(0.5.32 加法 D3-①;服务在跑时用这条)
|
|
2
|
+
// POST /plugin-console/safe-boot { action: 'list' | 'restore-last-good' | 'disable-suspects' | 'mark-good', ... }
|
|
3
|
+
//
|
|
4
|
+
// 为什么要有它(用户 2026-09-29 诉求:「能挽救改完打不开的局面」):
|
|
5
|
+
// 走了别的更新通道(官方桌面端更新器 / 手动 pnpm / npx 缓存变化)把框架换坏之后,
|
|
6
|
+
// 用户需要**在服务还能应答的时候**有一条一键出路;服务起不来时则走 `scripts/safe-boot.mjs`
|
|
7
|
+
// (同一个 domain 模块,同一套判据)。
|
|
8
|
+
//
|
|
9
|
+
// 白名单动作(**只认这四个字符串**,绝不接受任何命令串/路径):
|
|
10
|
+
// · list 列快照(不返回补丁正文)
|
|
11
|
+
// · restore-last-good 把补丁恢复成快照(改前再备份一份当前状态 + 读回核实 + 严格 YAML 闸门)
|
|
12
|
+
// · disable-suspects **只**给点名的可疑行追加 `disabled: true`(不删任何行);点名来源:
|
|
13
|
+
// 请求里的 rowIds,或 D1 自动预检落下的隔离计划(`?usePlan=1` 默认)
|
|
14
|
+
// · mark-good 把某份快照标记为「良好」(永不随保留策略删除)
|
|
15
|
+
//
|
|
16
|
+
// 一律返回**可复制的命令**与影响面摘要;不做任何未点名的改动。
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from 'node:fs'
|
|
19
|
+
import { readStore } from '../domain/auto-preflight.js'
|
|
20
|
+
import { readCompatMode } from '../domain/compat-state.js'
|
|
21
|
+
import { CORE_PATCH_ROW_IDS } from '../domain/patch.js'
|
|
22
|
+
import { readSafeBootState, disableSuspects, listSnapshots, markSnapshotGood, restoreLastGood, snapshotView, validatePatchYaml } from '../domain/safe-boot.js'
|
|
23
|
+
import { sendError, sendJson } from '../infra/httpd.js'
|
|
24
|
+
import { findPatchPath, pluginRoot } from '../infra/paths.js'
|
|
25
|
+
|
|
26
|
+
const ACTIONS = ['list', 'restore-last-good', 'disable-suspects', 'mark-good']
|
|
27
|
+
const PREFLIGHT_FILE_HINT = 'D1 自动预检的结论(同一份记录,面板与离线脚本共用)'
|
|
28
|
+
|
|
29
|
+
function patchPathOf(rc) {
|
|
30
|
+
try { return findPatchPath(rc.ctx) } catch { return null }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function pluginVersionOf() {
|
|
34
|
+
try { return JSON.parse(readFileSync(`${pluginRoot()}/package.json`, 'utf8')).version ?? null } catch { return null }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 最近一次「done」的自动预检记录(按当前指纹取;取不到就退回最新一条 done)。 */
|
|
38
|
+
function latestPlan() {
|
|
39
|
+
try {
|
|
40
|
+
const records = readStore().records
|
|
41
|
+
return records.find((r) => r.state === 'done' && Array.isArray(r.suspects) && r.suspects.length > 0)
|
|
42
|
+
?? records.find((r) => r.state === 'done')
|
|
43
|
+
?? null
|
|
44
|
+
} catch { return null }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 可复制命令(用户能直接粘到终端;服务起不来时就是这一条救回来)。 */
|
|
48
|
+
function commands({ id = null } = {}) {
|
|
49
|
+
const script = 'node scripts/safe-boot.mjs'
|
|
50
|
+
return {
|
|
51
|
+
list: `${script} --list`,
|
|
52
|
+
restore: id === null ? `${script} --restore-last-good` : `${script} --restore-last-good --id ${id}`,
|
|
53
|
+
disable: `${script} --disable-suspects`,
|
|
54
|
+
markGood: id === null ? `${script} --mark-good` : `${script} --mark-good --id ${id}`,
|
|
55
|
+
note: { zh: '服务打不开时在插件目录下执行(无需服务在跑)', en: 'Run this in the plugin directory when the service cannot start (no running server needed)' },
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function routeSafeBoot(req, res, rc) {
|
|
60
|
+
const body = rc.body ?? {}
|
|
61
|
+
const action = typeof body.action === 'string' ? body.action.trim() : ''
|
|
62
|
+
const patchPath = patchPathOf(rc)
|
|
63
|
+
if (!ACTIONS.includes(action)) {
|
|
64
|
+
sendError(res, 400, `未知动作 ${action === '' ? '(空)' : action}(可选:${ACTIONS.join(' / ')})—— 本接口只认白名单动作,不接受命令串`)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
const state = readSafeBootState()
|
|
68
|
+
if (action === 'list') {
|
|
69
|
+
const plan = latestPlan()
|
|
70
|
+
sendJson(res, 200, {
|
|
71
|
+
ok: true,
|
|
72
|
+
action,
|
|
73
|
+
patchPath,
|
|
74
|
+
patch: patchPath === null ? null : { exists: true, check: (() => { try { return validatePatchYaml(readFileSync(patchPath, 'utf8')) } catch { return null } })() },
|
|
75
|
+
snapshots: listSnapshots(),
|
|
76
|
+
lastGoodId: state.lastGoodId,
|
|
77
|
+
lastAutoId: state.lastAutoId,
|
|
78
|
+
mode: readCompatMode().mode,
|
|
79
|
+
lastPlan: plan === null ? null : { at: plan.at, key: plan.key, state: plan.state, suspects: (plan.suspects ?? []).map((s) => s.rowId), affectedCount: (plan.affected ?? []).length, note: PREFLIGHT_FILE_HINT },
|
|
80
|
+
commands: commands(),
|
|
81
|
+
pluginVersion: pluginVersionOf(),
|
|
82
|
+
})
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
if (action === 'mark-good') {
|
|
86
|
+
const id = typeof body.id === 'string' && body.id !== '' ? body.id : null
|
|
87
|
+
const result = markSnapshotGood({ id })
|
|
88
|
+
if (result.ok !== true) { sendError(res, 404, result.error); return }
|
|
89
|
+
sendJson(res, 200, { ok: true, action, snapshot: result.snapshot, note: result.note, commands: commands({ id: result.snapshot?.id ?? null }) })
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
if (action === 'restore-last-good') {
|
|
93
|
+
const id = typeof body.id === 'string' && body.id !== '' ? body.id : null
|
|
94
|
+
const result = restoreLastGood({ patchPath, id })
|
|
95
|
+
if (result.ok !== true) { sendError(res, 409, result.error, { code: result.code, snapshot: result.snapshot ?? null, commands: commands({ id }) }); return }
|
|
96
|
+
sendJson(res, 200, {
|
|
97
|
+
ok: true, action, ...result,
|
|
98
|
+
impact: {
|
|
99
|
+
patchPath: result.patchPath,
|
|
100
|
+
changed: result.changed === true,
|
|
101
|
+
backupPath: result.backupPath ?? null,
|
|
102
|
+
sha256: result.sha256 ?? null,
|
|
103
|
+
snapshotId: result.snapshot?.id ?? null,
|
|
104
|
+
snapshotAt: result.snapshot?.at ?? null,
|
|
105
|
+
enabledCount: result.snapshot?.enabledCount ?? null,
|
|
106
|
+
disabledCount: result.snapshot?.disabledCount ?? null,
|
|
107
|
+
},
|
|
108
|
+
commands: commands({ id: result.snapshot?.id ?? null }),
|
|
109
|
+
next: { zh: '重启服务让补丁生效(观察者模式下本控制台不代你重启)', en: 'Restart the service for the patch to take effect (Observer mode does not restart it for you)' },
|
|
110
|
+
})
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
// disable-suspects
|
|
114
|
+
const plan = latestPlan()
|
|
115
|
+
const explicit = Array.isArray(body.rowIds) ? body.rowIds.filter((x) => typeof x === 'string') : null
|
|
116
|
+
const usePlan = body.usePlan !== false
|
|
117
|
+
const rowIds = explicit !== null && explicit.length > 0 ? explicit : (usePlan && plan !== null ? (plan.suspects ?? []).map((s) => s.rowId) : [])
|
|
118
|
+
if (rowIds.length === 0) {
|
|
119
|
+
sendJson(res, 200, {
|
|
120
|
+
ok: true, action, changed: false, added: [], skipped: [], backupPath: null,
|
|
121
|
+
reason: 'no-suspects',
|
|
122
|
+
note: { zh: '没有点名的可疑行(自动预检尚未跑出结论,或它没点名任何行)—— 需要的话请在 rowIds 里显式给出', en: 'No named suspect rows (auto-preflight has no conclusion yet, or named none) — pass rowIds explicitly if needed' },
|
|
123
|
+
coreRowIds: [...CORE_PATCH_ROW_IDS].slice(0, 8),
|
|
124
|
+
commands: commands(),
|
|
125
|
+
})
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
const result = disableSuspects({ patchPath, rowIds })
|
|
129
|
+
if (result.ok !== true) { sendError(res, 409, result.error, { code: result.code, problems: result.problems ?? null, commands: commands() }); return }
|
|
130
|
+
sendJson(res, 200, {
|
|
131
|
+
ok: true, action, ...result,
|
|
132
|
+
source: explicit !== null && explicit.length > 0 ? 'request' : 'auto-preflight-plan',
|
|
133
|
+
planAt: plan?.at ?? null,
|
|
134
|
+
impact: { requested: rowIds.length, added: result.added ?? [], skipped: result.skipped ?? [], backupPath: result.backupPath ?? null, prefixUnchanged: result.prefixSame === true },
|
|
135
|
+
commands: commands(),
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export { ACTIONS, commands, latestPlan, routeSafeBoot }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noob-stupid/dsh-plugin-console",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.32",
|
|
4
4
|
"description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|