@noob-stupid/dsh-plugin-console 0.5.30 → 0.5.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -7
- package/README.zh.md +40 -8
- package/SECURITY.md +2 -2
- package/lib/client.js +17 -3
- package/lib/index.js +141 -141
- package/lib/server/domain/ai-run.js +1 -1
- package/lib/server/domain/ai.js +39 -11
- 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 +7 -3
- package/lib/server/domain/components.js +1 -1
- package/lib/server/domain/dep-source.js +1 -1
- package/lib/server/domain/framework.js +65 -6
- package/lib/server/domain/install-job.js +1 -1
- package/lib/server/domain/install.js +1 -1
- package/lib/server/domain/jobs.js +1 -1
- package/lib/server/domain/market.js +1 -1
- package/lib/server/domain/patch-yaml-check.js +151 -0
- package/lib/server/domain/patch.js +1 -1
- package/lib/server/domain/presets.js +1 -1
- package/lib/server/domain/release-source.js +3 -3
- package/lib/server/domain/repoland.js +1 -1
- package/lib/server/domain/runtime.js +1 -1
- package/lib/server/domain/safe-boot.js +520 -0
- package/lib/server/domain/skills.js +1 -1
- package/lib/server/domain/sources.js +1 -1
- package/lib/server/domain/suite.js +1 -1
- package/lib/server/infra/exec.js +2 -2
- package/lib/server/infra/fsx.js +3 -3
- package/lib/server/infra/http.js +1 -1
- package/lib/server/infra/httpd.js +1 -1
- package/lib/server/infra/mask.js +1 -1
- package/lib/server/infra/paths.js +1 -1
- package/lib/server/infra/semver.js +1 -1
- package/lib/server/routes/compat.js +240 -11
- package/lib/server/routes/components.js +1 -1
- package/lib/server/routes/framework-preflight.js +14 -1
- package/lib/server/routes/framework-upgrade.js +16 -17
- package/lib/server/routes/framework.js +18 -2
- package/lib/server/routes/github-login.js +2 -2
- package/lib/server/routes/index.js +8 -1
- package/lib/server/routes/safe-boot.js +139 -0
- package/package.json +1 -1
package/lib/server/domain/ai.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// L1 · domain —— ai.js(AI 赋能的纯逻辑部分:任务表与视图、DeepSeek 密钥与配置、占位符/Python/VLM 解析、写入白名单与运行白名单、内置计划模板、计划 JSON 解析;分层 Step 7 从 lib/index.js 搬出,只搬移未改逻辑。注:aiEmpowerPlan/Execute、runAiStep、aiRepair 吃运行上下文,留到 Step 8)
|
|
2
|
-
//
|
|
2
|
+
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
3
3
|
|
|
4
4
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
5
5
|
import { dirname, join, basename, resolve } from 'node:path'
|
|
@@ -126,15 +126,41 @@ function resolvePlaceholders(value, jobOrEnv) {
|
|
|
126
126
|
.replaceAll('${python}', python)
|
|
127
127
|
.replaceAll('${scripts}', join(dirname(python), 'Scripts'))
|
|
128
128
|
.replaceAll('${node}', process.execPath)
|
|
129
|
+
.replaceAll('${asciiData}', openVikingDataRoot())
|
|
129
130
|
.replaceAll('${deepseekKey}', readDeepSeekKey())
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
/** 解析 Python 解释器:候选按平台分开,**不含任何开发机专有路径**。
|
|
134
|
+
* win32:用户级安装目录(AppData\Local\Programs\Python)+ PATH 上的 python.exe;
|
|
135
|
+
* POSIX:/usr/bin/python3、/usr/local/bin/python3、PATH 上的 python3/python。
|
|
136
|
+
* 原实现第一条写死开发机的绝对路径(`<盘符>:\python314\python.exe`),且三个候选全是 Windows 形态 ——
|
|
137
|
+
* 非 Windows 上永远解析不到,`${python}` 会退化成 Windows 专有名字。
|
|
138
|
+
* 解析不到时返回**本平台的命令名**(由执行阶段如实报错),不在这里抛。 */
|
|
132
139
|
function resolvePythonPath() {
|
|
133
|
-
const
|
|
140
|
+
const win = process.platform === 'win32'
|
|
141
|
+
const candidates = win
|
|
142
|
+
? [
|
|
143
|
+
join(homedir(), 'AppData', 'Local', 'Programs', 'Python', 'Python314', 'python.exe'),
|
|
144
|
+
'python.exe',
|
|
145
|
+
]
|
|
146
|
+
: ['/usr/bin/python3', '/usr/local/bin/python3', 'python3', 'python']
|
|
134
147
|
for (const c of candidates) {
|
|
135
148
|
if (existsSync(c)) return c
|
|
136
149
|
}
|
|
137
|
-
return 'python.exe'
|
|
150
|
+
return win ? 'python.exe' : 'python3'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** OpenViking 数据根:**必须纯 ASCII**(home 含非 ASCII 字符时,各语言的 Rust/原生向量库
|
|
154
|
+
* 写盘会报 UnicodeDecodeError)。三种来源,**不含任何写死的盘符**:
|
|
155
|
+
* ① 环境变量 DSH_OPENVIKING_DATA_ROOT(用户显式指定,最高优先)
|
|
156
|
+
* ② win32 历史默认:D:/OpenVikingData(保持既有实例的数据位置不变)
|
|
157
|
+
* ③ 其他平台:${home}/.openviking/ascii-data
|
|
158
|
+
* 原实现无条件返回 'D:/OpenVikingData':非 Windows 上它是个**相对路径**,会被写进 CWD
|
|
159
|
+
* (服务进程的工作目录),在别人的机器上根本不是"ASCII 数据根"。 */
|
|
160
|
+
function openVikingDataRoot() {
|
|
161
|
+
const override = process.env.DSH_OPENVIKING_DATA_ROOT
|
|
162
|
+
if (typeof override === 'string' && override.trim() !== '') return override.trim()
|
|
163
|
+
return process.platform === 'win32' ? 'D:/OpenVikingData' : join(homedir(), '.openviking', 'ascii-data')
|
|
138
164
|
}
|
|
139
165
|
|
|
140
166
|
/** 写入路径白名单:只允许 DSH profile、~/.dsh、~/.openviking、~/.cache/openviking、ASCII 数据根。 */
|
|
@@ -148,13 +174,13 @@ function isAllowedWritePath(p, profileDir) {
|
|
|
148
174
|
norm(homedir()) + '/.local/share/openviking',
|
|
149
175
|
norm(homedir()) + '/.dsh/repos',
|
|
150
176
|
norm(profileDir),
|
|
151
|
-
|
|
177
|
+
norm(openVikingDataRoot()),
|
|
152
178
|
]
|
|
153
179
|
return roots.some((root) => target === root || target.startsWith(root + '/'))
|
|
154
180
|
}
|
|
155
181
|
|
|
156
|
-
/** run-cmd 可执行文件白名单(按 basename
|
|
157
|
-
const ALLOWED_RUN_FILES = new Set(['curl.exe', 'curl', 'git.exe', 'git', 'node.exe', 'node', 'python.exe', 'python', 'py.exe', 'py', 'gh.exe', 'gh', 'npm.cmd', 'npm', 'ov.cmd', 'ov'])
|
|
182
|
+
/** run-cmd 可执行文件白名单(按 basename;含 POSIX 名 python3,否则 ${python}=python3 会被自己拒掉)。 */
|
|
183
|
+
const ALLOWED_RUN_FILES = new Set(['curl.exe', 'curl', 'git.exe', 'git', 'node.exe', 'node', 'python.exe', 'python3', 'python', 'py.exe', 'py', 'gh.exe', 'gh', 'npm.cmd', 'npm', 'ov.cmd', 'ov'])
|
|
158
184
|
|
|
159
185
|
const DESTRUCTIVE_RE = /(^|\s)(rm\s+(-[a-zA-Z]*r)|rmdir\s+\/s|del\s+\/s|format\s+[a-zA-Z]:|Remove-Item\b|taskkill\s+\/im|reg\s+delete|schtasks\s+\/delete|shutdown|cipher\s+\/w)/iu
|
|
160
186
|
|
|
@@ -178,7 +204,7 @@ function builtinPlanFor(source) {
|
|
|
178
204
|
.replace(/\/$/u, '')
|
|
179
205
|
if (norm !== 'openviking' && norm !== 'volcengine/openviking') return null
|
|
180
206
|
const home = homedir()
|
|
181
|
-
const storage = /^[\x00-\x7F]+$/u.test(home) ? `${home}/.openviking/data` :
|
|
207
|
+
const storage = /^[\x00-\x7F]+$/u.test(home) ? `${home}/.openviking/data` : openVikingDataRoot()
|
|
182
208
|
const vlm = resolveVlmForOpenViking()
|
|
183
209
|
// 注意:路径必须在此处(JSON.stringify 之前)使用真实值,由 stringify 统一转义;
|
|
184
210
|
// 不能在 content 中保留 ${home} 占位符——写文件时替换会把反斜杠路径变成非法 JSON 转义。
|
|
@@ -209,10 +235,12 @@ function aiEmpowerPresetFor(source) {
|
|
|
209
235
|
const common = [
|
|
210
236
|
'## 本机环境事实(务必遵守)',
|
|
211
237
|
'- 网络:pypi.org 不通、清华镜像 403;pip 必须用 `--index-url https://mirrors.aliyun.com/pypi/simple/`。npm registry.npmjs.org 可能黑洞,pnpm 用 `--registry https://registry.npmmirror.com`。huggingface.co 被墙,模型文件一律用 `https://hf-mirror.com/<同一路径>`(执行器会自动把 huggingface.co 换成 hf-mirror.com,计划里也可直接写 hf-mirror)。',
|
|
212
|
-
// 0.5.28 改错:这条原本把它写成"
|
|
238
|
+
// 0.5.28 改错:这条原本把它写成"本机用户名含非 ASCII 字符"的**个案**,读起来像只在
|
|
213
239
|
// 那台机器上成立。真正的规则是通用的:只要**写盘路径**里出现非 ASCII 字符,各语言的
|
|
214
240
|
// Rust/原生向量库就可能报 UnicodeDecodeError —— 与用户名具体是不是中文无关。
|
|
215
|
-
|
|
241
|
+
// 2026-09-29 改错:ASCII 数据根不再写死某个盘符,改用 `${asciiData}` 占位符
|
|
242
|
+
// (由 resolvePlaceholders 解析成"本平台/本用户"的 ASCII 数据根,见 openVikingDataRoot)。
|
|
243
|
+
'- 用户名/路径含非 ASCII 字符时:任何写盘路径若含非 ASCII 字符,各语言的 Rust/原生向量库会报 UnicodeDecodeError——服务数据目录必须纯 ASCII,用 `${asciiData}` 引用控制台给出的 ASCII 数据根(可用环境变量 DSH_OPENVIKING_DATA_ROOT 覆盖)。',
|
|
216
244
|
'- 凭据复用:DSH 的 DeepSeek 密钥在 `${home}/.dsh/.credentials.yaml`(YAML 行 `DEEPSEEK_API_KEY: sk-...`);模型选择在 `${home}/.dsh/settings.yaml` 的 `agent-default-model`(provider deepseek-official)。对外 OpenAI 兼容接口:api_base `https://api.deepseek.com`,模型名 `deepseek-v4-flash-vision-exp`。',
|
|
217
245
|
'- 步骤解释用中文;每个动作一个步骤;步骤数尽量少;不得包含任何破坏性命令;服务类组件必须配 start-service + wait-health。',
|
|
218
246
|
]
|
|
@@ -222,8 +250,8 @@ function aiEmpowerPresetFor(source) {
|
|
|
222
250
|
'## OpenViking 已验证的部署事实(v0.4.17,Python 3.14)',
|
|
223
251
|
'- 安装:`pip install openviking[local-embed]`(含 llama-cpp-python 0.3.35,cp310-abi3 轮子可用;无需加 --force-reinstall)。',
|
|
224
252
|
'- 嵌入模型:`bge-small-zh-v1.5-f16.gguf`(47MB,512 维中文)下载到 `${home}/.cache/openviking/models/`(写 download 步骤;URL 写 huggingface.co 会被执行器自动换 hf-mirror.com)。',
|
|
225
|
-
'- ov.conf 路径 `${home}/.openviking/ov.conf`,内容:vlm = { provider: openai, api_base: https://api.deepseek.com, model: deepseek-v4-flash-vision-exp, api_key: 从 `${home}/.dsh/.credentials.yaml` 读取并内联 };embedding.dense = { provider: local, model: bge-small-zh-v1.5-f16, model_path: <上面的 gguf 路径> };server = { host: 127.0.0.1, port: 1933 }(不要写 root_api_key,dev 免鉴权模式自动启用);storage.workspace = `${home}` 含非 ASCII 时写
|
|
226
|
-
'- 启动:start-service,file=`${python}`,args=`["-m","openviking_cli.server_bootstrap"]`,cwd=`${home}/.openviking`,healthUrl=`http://127.0.0.1:1933/health`,port=1933。服务器启动首次会建 `
|
|
253
|
+
'- ov.conf 路径 `${home}/.openviking/ov.conf`,内容:vlm = { provider: openai, api_base: https://api.deepseek.com, model: deepseek-v4-flash-vision-exp, api_key: 从 `${home}/.dsh/.credentials.yaml` 读取并内联 };embedding.dense = { provider: local, model: bge-small-zh-v1.5-f16, model_path: <上面的 gguf 路径> };server = { host: 127.0.0.1, port: 1933 }(不要写 root_api_key,dev 免鉴权模式自动启用);storage.workspace = `${home}` 含非 ASCII 时写 `${asciiData}`,否则 `${home}/.openviking/data`。',
|
|
254
|
+
'- 启动:start-service,file=`${python}`,args=`["-m","openviking_cli.server_bootstrap"]`,cwd=`${home}/.openviking`,healthUrl=`http://127.0.0.1:1933/health`,port=1933。服务器启动首次会建 `${asciiData}` 数据目录。',
|
|
227
255
|
'- 部署完成后惯例:重启 DSH 会话后 mcp__openviking__* 工具出现,`${home}/.openviking/pending` 的积压消息会在新会话启动时自动回放——把这写进总结。',
|
|
228
256
|
].join('\n')
|
|
229
257
|
}
|
|
@@ -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 }
|