diff --git a/README.md b/README.md index 2b8e354..31081b7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ > 面向 AI 编程协作者:开始任何工作前,请先阅读本目录的 `AI_AGENTS.md`,并遵循其中的同步/检查/上报流程。

- version + version platform license stack @@ -27,14 +27,14 @@ A hot-plug package manager for [DeepSeek Harness (dsh)](https://github.com/deeps | | | |---|---| -| **Release** | **v1.0.0** | -| Releases page | | -| Windows installer | `DSH-Hotplug-Hub-win-x64-setup-v1.0.0.exe` | -| Windows portable | `DSH-Hotplug-Hub-win-x64-portable-v1.0.0.zip` | -| Linux installer | `DSH-Hotplug-Hub-linux-x64-setup-v1.0.0.sh` | -| Linux portable | `DSH-Hotplug-Hub-linux-x64-portable-v1.0.0.tar.gz` | -| macOS installer | `DSH-Hotplug-Hub-macos-x64-setup-v1.0.0.command` | -| macOS portable | `DSH-Hotplug-Hub-macos-x64-portable-v1.0.0.zip` | +| **Release** | **v1.0.1** | +| Releases page | | +| Windows installer | `DSH-Hotplug-Hub-win-x64-setup-v1.0.1.exe` | +| Windows portable | `DSH-Hotplug-Hub-win-x64-portable-v1.0.1.zip` | +| Linux installer | `DSH-Hotplug-Hub-linux-x64-setup-v1.0.1.sh` | +| Linux portable | `DSH-Hotplug-Hub-linux-x64-portable-v1.0.1.tar.gz` | +| macOS installer | `DSH-Hotplug-Hub-macos-x64-setup-v1.0.1.command` | +| macOS portable | `DSH-Hotplug-Hub-macos-x64-portable-v1.0.1.zip` | ## Key Features @@ -61,22 +61,22 @@ A hot-plug package manager for [DeepSeek Harness (dsh)](https://github.com/deeps ### Option 1 — Installer (recommended) -1. Download `DSH-Hotplug-Hub-win-x64-setup-v1.0.0.exe` from the Releases page. +1. Download `DSH-Hotplug-Hub-win-x64-setup-v1.0.1.exe` from the Releases page. 2. Double-click and choose an install location (default `%LOCALAPPDATA%\Programs\DseamWorld`). 3. The installer auto-creates desktop / start-menu shortcuts and launches when finished. Silent install: ```powershell -DSH-Hotplug-Hub-win-x64-setup-v1.0.0.exe --silent -DSH-Hotplug-Hub-win-x64-setup-v1.0.0.exe --silent --dir "D:\MyApps\DseamWorld" +DSH-Hotplug-Hub-win-x64-setup-v1.0.1.exe --silent +DSH-Hotplug-Hub-win-x64-setup-v1.0.1.exe --silent --dir "D:\MyApps\DseamWorld" ``` ### Option 2 — Portable -- **Windows**: unzip `DSH-Hotplug-Hub-win-x64-portable-v1.0.0.zip`, then double-click `DSH-Hotplug-Hub.exe` (WebView2 runtime DLLs are included in the same directory). -- **Linux**: `tar -xzf DSH-Hotplug-Hub-linux-x64-portable-v1.0.0.tar.gz`, then run `./dsh-hotplug-hub`. -- **macOS**: unzip `DSH-Hotplug-Hub-macos-x64-portable-v1.0.0.zip`, then double-click `Start-DSH-Hotplug-Hub.command`. +- **Windows**: unzip `DSH-Hotplug-Hub-win-x64-portable-v1.0.1.zip`, then double-click `DSH-Hotplug-Hub.exe` (WebView2 runtime DLLs are included in the same directory). +- **Linux**: `tar -xzf DSH-Hotplug-Hub-linux-x64-portable-v1.0.1.tar.gz`, then run `./dsh-hotplug-hub`. +- **macOS**: unzip `DSH-Hotplug-Hub-macos-x64-portable-v1.0.1.zip`, then double-click `Start-DSH-Hotplug-Hub.command`. ### Option 3 — From source @@ -169,14 +169,14 @@ Full change history: [`开发文档/开发历史.md`](开发文档/开发历史. | | | |---|---| -| **正式版** | **v1.0.0** | -| 发布页 | | -| Windows 安装版 | `DSH-Hotplug-Hub-win-x64-setup-v1.0.0.exe` | -| Windows 便携版 | `DSH-Hotplug-Hub-win-x64-portable-v1.0.0.zip` | -| Linux 安装版 | `DSH-Hotplug-Hub-linux-x64-setup-v1.0.0.sh` | -| Linux 便携版 | `DSH-Hotplug-Hub-linux-x64-portable-v1.0.0.tar.gz` | -| macOS 安装版 | `DSH-Hotplug-Hub-macos-x64-setup-v1.0.0.command` | -| macOS 便携版 | `DSH-Hotplug-Hub-macos-x64-portable-v1.0.0.zip` | +| **正式版** | **v1.0.1** | +| 发布页 | | +| Windows 安装版 | `DSH-Hotplug-Hub-win-x64-setup-v1.0.1.exe` | +| Windows 便携版 | `DSH-Hotplug-Hub-win-x64-portable-v1.0.1.zip` | +| Linux 安装版 | `DSH-Hotplug-Hub-linux-x64-setup-v1.0.1.sh` | +| Linux 便携版 | `DSH-Hotplug-Hub-linux-x64-portable-v1.0.1.tar.gz` | +| macOS 安装版 | `DSH-Hotplug-Hub-macos-x64-setup-v1.0.1.command` | +| macOS 便携版 | `DSH-Hotplug-Hub-macos-x64-portable-v1.0.1.zip` | ## 主要功能 @@ -203,22 +203,22 @@ Full change history: [`开发文档/开发历史.md`](开发文档/开发历史. ### 方式一:安装包(推荐) -1. 在 Release 页面下载 `DSH-Hotplug-Hub-win-x64-setup-v1.0.0.exe`。 +1. 在 Release 页面下载 `DSH-Hotplug-Hub-win-x64-setup-v1.0.1.exe`。 2. 双击运行,选择安装位置(默认 `%LOCALAPPDATA%\Programs\DseamWorld`)。 3. 安装包自动创建桌面 / 开始菜单快捷方式,完成后直接启动。 静默安装: ```powershell -DSH-Hotplug-Hub-win-x64-setup-v1.0.0.exe --silent -DSH-Hotplug-Hub-win-x64-setup-v1.0.0.exe --silent --dir "D:\MyApps\DseamWorld" +DSH-Hotplug-Hub-win-x64-setup-v1.0.1.exe --silent +DSH-Hotplug-Hub-win-x64-setup-v1.0.1.exe --silent --dir "D:\MyApps\DseamWorld" ``` ### 方式二:便携版 -- **Windows**:解压 `DSH-Hotplug-Hub-win-x64-portable-v1.0.0.zip`,双击 `DSH-Hotplug-Hub.exe`(WebView2 运行时 DLL 已内置同目录)。 -- **Linux**:`tar -xzf DSH-Hotplug-Hub-linux-x64-portable-v1.0.0.tar.gz`,运行 `./dsh-hotplug-hub`。 -- **macOS**:解压 `DSH-Hotplug-Hub-macos-x64-portable-v1.0.0.zip`,双击 `Start-DSH-Hotplug-Hub.command`。 +- **Windows**:解压 `DSH-Hotplug-Hub-win-x64-portable-v1.0.1.zip`,双击 `DSH-Hotplug-Hub.exe`(WebView2 运行时 DLL 已内置同目录)。 +- **Linux**:`tar -xzf DSH-Hotplug-Hub-linux-x64-portable-v1.0.1.tar.gz`,运行 `./dsh-hotplug-hub`。 +- **macOS**:解压 `DSH-Hotplug-Hub-macos-x64-portable-v1.0.1.zip`,双击 `Start-DSH-Hotplug-Hub.command`。 ### 方式三:源码安装 diff --git a/dsh-hotplug-hub/dsh-memory-hub/lib/store.mjs b/dsh-hotplug-hub/dsh-memory-hub/lib/store.mjs index 66332f1..5d16d2d 100644 --- a/dsh-hotplug-hub/dsh-memory-hub/lib/store.mjs +++ b/dsh-hotplug-hub/dsh-memory-hub/lib/store.mjs @@ -272,7 +272,8 @@ export class MemoryStore { try { return fn() } finally { - releaseLock(nodeFsPort, lockPath, { pid: process.pid, fd: a.fd }) + // 审计修复:传入 refresh 句柄清理心跳(Worker 线程),否则释放后仍持续写锁文件。 + releaseLock(nodeFsPort, lockPath, { pid: process.pid, fd: a.fd, refresh: a.refresh }) } } diff --git a/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/CONTRACT.md b/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/CONTRACT.md index 71200f6..8c412dc 100644 --- a/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/CONTRACT.md +++ b/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/CONTRACT.md @@ -17,6 +17,7 @@ | `PACK_ID_RE` | `^[a-z0-9][a-z0-9._-]{0,63}$`(大小写不敏感) | 包 id / CLI id:字母数字开头,允许 `. _ -`,1..64 字符 | | `PLUGIN_NAME_RE` | `^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$` | npm 包名(可 scoped) | | `EXACT_VERSION_RE` | `^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$` | 精确版本号(**须再经 semver 双检**:`semver.valid(v) !== null`,拒绝 `1.02.3` 等) | +| `REPO_RE` | `^[0-9A-Za-z][0-9A-Za-z._-]*\/[0-9A-Za-z][0-9A-Za-z._-]*$` | GitHub 仓库 `owner/repo`(两段、字母数字开头;拒绝前导 `. -` 与 `..` 段,防 URL/git clone 路径穿越) | | `RESERVED_WIN_NAMES` | `CON PRN AUX NUL COM1..COM9 LPT1..LPT9` | Windows 保留设备名(任何单段名均拒绝,含首段匹配) | | 长度预算 | id ≤ 64;patch id ≤ 64;source.path ≤ 4096;source.repo ≤ 512;source.ref ≤ 256 | | @@ -25,6 +26,7 @@ - 尾随 `.` 或空格拒绝; - 插件名每个 `/` 段均过 Windows 安全名检查; - `source.path` 必须绝对、拒绝 UNC、拒绝 `.`/`..` 段; +- `source.repo` 必须匹配 `REPO_RE`(`owner/repo` 两段、字母数字开头;拒绝空白/元字符/`..` 段); - `source.ref` 拒绝 `..`、纯点、控制字符、超长(阶段 2 起允许合法 `/`)。 ## 2. 错误码与结果契约 diff --git a/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/contracts/constants.js b/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/contracts/constants.js index 619d076..da5c89d 100644 --- a/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/contracts/constants.js +++ b/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/contracts/constants.js @@ -7,6 +7,9 @@ const os = require('os'); const PACK_ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i; const PLUGIN_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +// GitHub 仓库 owner/repo 格式:两段、各段字母数字开头(拒绝前导 . - 与 .. 段, +// 防止 repo 进入 URL/git clone 时产生路径穿越或畸形 URL)。 +const REPO_RE = /^[0-9A-Za-z][0-9A-Za-z._-]*\/[0-9A-Za-z][0-9A-Za-z._-]*$/; // Windows 保留设备名(N43:CON/NUL/COM1… 全部拒绝) const RESERVED_WIN_NAMES = new Set([ @@ -45,9 +48,10 @@ const LOCK_REFRESH_MS = 10000; const LAUNCH_ALIVE_CHECK_MS = 500; const LAUNCH_WAIT_TIMEOUT_MS = 120000; -// 崩溃循环判定(3 次 / 30s 窗口) +// 崩溃循环判定:连续 CRASH_LOOP_THRESHOLD 次非零退出(retries 连续失败计数, +// 成功即清零;与 classifyStateSignals / healplan 触发文案一致)。历史曾声明 +// "30s 窗口"语义(CRASH_LOOP_WINDOW_MS),但从未被实现或消费——已删除该死常量。 const CRASH_LOOP_THRESHOLD = 3; -const CRASH_LOOP_WINDOW_MS = 30000; // 自愈默认重试预算 const DEFAULT_RETRY_BUDGET = 3; @@ -133,6 +137,7 @@ module.exports = { PACK_ID_RE, PLUGIN_NAME_RE, EXACT_VERSION_RE, + REPO_RE, RESERVED_WIN_NAMES, GITHUB_MIRRORS, SCHEMA_VERSION, @@ -148,7 +153,6 @@ module.exports = { LAUNCH_ALIVE_CHECK_MS, LAUNCH_WAIT_TIMEOUT_MS, CRASH_LOOP_THRESHOLD, - CRASH_LOOP_WINDOW_MS, DEFAULT_RETRY_BUDGET, STATE_FILE, RUN_LOG_FILE, diff --git a/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/fs/lock-heartbeat.js b/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/fs/lock-heartbeat.js new file mode 100644 index 0000000..b290b99 --- /dev/null +++ b/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/fs/lock-heartbeat.js @@ -0,0 +1,43 @@ +'use strict'; +// fs/lock-heartbeat.js — 锁 token 心跳(Worker 线程,独立事件循环) +// +// 背景(P1 修复):主线程持锁期间若执行阻塞式 spawnSync(npm/git 安装 >30s), +// 事件循环冻结,setInterval 无法触发 → token 时间戳不再刷新 → 第二写者按 +// "存活 pid + token 超龄"判定陈旧并接管锁,破坏并发写互斥。 +// 本 Worker 运行在独立线程,其事件循环与主线程互不阻塞,故即使主线程卡在 +// spawnSync,token 仍按 refreshMs 周期刷新,锁的陈旧判定契约(CONTRACT.md §5) +// 得以维持。 +// +// 审计修复(P1b,跨线程覆盖竞态):此前每 tick 用 fs.openSync(lockPath, 'r+') +// 按【路径】重开锁文件写 token——release 后主线程 unlink 旧锁、另一进程在同路径 +// 重建新锁,若本 Worker 尚未被 terminate(terminate 异步、存在窗口),下一次 tick +// 会打开【新持有者的锁文件】并用旧 pid 覆盖其 token,击穿跨进程互斥(已实证复现)。 +// +// 根因修复:Worker 不按路径重开,改经【主线程传入的持有 fd】写——该 fd 由 +// acquireLock 用 'wx' 独占创建、绑定获取锁时创建的固定 inode;release 后主线程 +// unlink + 重建同名文件,本 fd 仍指向旧的(已 unlink)inode,写入不影响新持有者。 +// +// 同步停止握手:主线程 releaseLock 先 Atomics.store(ctrl[0]=1) 请求停止并 +// Atomics.notify 唤醒本 Worker,再 Atomics.wait(ctrl[1]==1) 等待确认;本 Worker +// 仅在 while 条件(ctrl[0]==0)为真时写,收到停止请求后【绝不再写】,随后置 +// ctrl[1]=1 确认并退出。主线程在收到确认前不关闭 fd → 不存在"写已关闭/被复用 fd" +// 的窗口(fd 复用竞态一并消除)。 +const { workerData } = require('worker_threads'); +const fs = require('fs'); + +const { fd, pid, refreshMs, ctrl } = workerData; +const c = new Int32Array(ctrl); // c[0]=停止请求(主线程写);c[1]=已停止确认(本 Worker 写) + +while (Atomics.load(c, 0) === 0) { + try { + fs.ftruncateSync(fd, 0); + fs.writeSync(fd, `${pid}\n${Date.now()}\n`, 0, 'utf8'); + } catch (_) { /* 刷新失败:下次重试;陈旧接管兜底 */ } + // 阻塞至 refreshMs 或主线程请求停止(Atomics.notify 立即唤醒) + Atomics.wait(c, 0, 0, refreshMs); +} + +// 确认停止并退出(主线程据此决定关闭 fd 的时机) +Atomics.store(c, 1, 1); +Atomics.notify(c, 1); +process.exit(0); diff --git a/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/fs/lock.js b/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/fs/lock.js index a30c961..cb508fc 100644 --- a/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/fs/lock.js +++ b/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/fs/lock.js @@ -24,7 +24,7 @@ // | EEXIST + pid 死 | 立即接管 | // | EEXIST + EACCES/EPERM(他用户) | 等待至超时,不接管 | // | token 缺失/损坏 | 按文件 mtime 判陈旧 | -const { dirname } = require('path'); +const { dirname, join } = require('path'); const { LOCK_WAIT_MS, LOCK_STALE_MS, @@ -160,6 +160,60 @@ function checkV1DirectoryLock(fsPort, lockPath, opts) { return { held: false }; } +/** + * 启动锁 token 心跳。 + * P1 修复:优先用 Worker 线程(独立事件循环)——主线程执行阻塞式 spawnSync 时 + * setInterval 无法触发,token 会陈旧被第二写者接管;Worker 线程不受主线程阻塞影响。 + * Worker 不可用(环境限制)时回退主线程 setInterval(尽力而为,阻塞期间仍可能陈旧)。 + * + * P1b 修复(跨线程覆盖竞态):Worker 经【主线程传入的持有 fd】写 token,绝不按路径 + * 重开——按路径重开会在 release→reacquire 窗口打开新持有者的锁文件并用旧 pid 覆盖其 + * token。fd 绑定获取锁时创建的 inode,release 后重建同名文件也不受影响。 + * 同步停止握手:返回 heartbeat 句柄含 stop(),其置停止请求 + 等 Worker 确认后再让 + * 调用方关 fd(见 releaseLock)。 + * @param {object} fsPort fs 端口(回退路径用) + * @param {number} pid 持有者 pid + * @param {number} refreshMs 刷新周期(0=不刷新,返回 null) + * @param {number} fd 主线程已打开的锁 fd(Worker 经此写;回退路径重写 token 用) + * @returns {{stop: Function}|object|null} heartbeat 句柄或 interval 句柄 + */ +function startHeartbeat(fsPort, pid, refreshMs, fd) { + if (!(refreshMs > 0)) return null; + try { + const { Worker } = require('worker_threads'); + const ctrl = new SharedArrayBuffer(8); // 2×Int32:c[0]=停止请求,c[1]=已停止确认 + const worker = new Worker(join(__dirname, 'lock-heartbeat.js'), { + workerData: { fd, pid, refreshMs, ctrl } + }); + if (typeof worker.unref === 'function') worker.unref(); + const c = new Int32Array(ctrl); + return { + stop() { + // 请求停止并等待 Worker 确认:确认后 Worker 绝不再写,调用方可安全关 fd。 + Atomics.store(c, 0, 1); + Atomics.notify(c, 0); + const deadline = Date.now() + 1000; + while (Atomics.load(c, 1) === 0 && Date.now() < deadline) { + // Worker 收到 notify 后应立即置 c[1]=1;此处短暂自旋兜底(Worker 崩溃/未启动) + Atomics.wait(c, 1, 0, 50); + } + try { worker.terminate(); } catch (_) { /* 忽略 */ } + } + }; + } catch (_) { + // Worker 不可用(worker_threads 缺失/创建失败)→ 回退主线程 setInterval + if (typeof setInterval !== 'function') return null; + const timer = setInterval(() => { + try { + fsPort.ftruncateSync(fd, 0); + fsPort.writeSync(fd, formatToken(pid, Date.now()), 0, 'utf8'); + } catch (_) { /* 刷新失败:陈旧接管兜底 */ } + }, refreshMs); + if (typeof timer.unref === 'function') timer.unref(); + return { stop() { clearInterval(timer); } }; + } +} + /** * 获取文件锁。 * @param {object} fsPort fs 端口 @@ -210,19 +264,15 @@ function acquireLock(fsPort, lockPath, opts = {}) { try { if (fsPort.existsSync(lockPath)) fsPort.unlinkSync(lockPath); } catch (_) { /* 忽略 */ } return { ok: false, error: makeError('ERR_LOCK_ACQUIRE', `写入锁 token 失败 ${lockPath}:${writeErr.message}`) }; } - // 持锁期刷新:经已打开 fd 重写 token(防止长任务被误判陈旧) - let refresh = null; - if (refreshMs > 0 && typeof setInterval === 'function') { - refresh = setInterval(() => { - try { - fsPort.ftruncateSync(fd, 0); - fsPort.writeSync(fd, formatToken(pid, Date.now()), 0, 'utf8'); - } catch (_) { /* 刷新失败:下次仍会尝试;陈旧接管兜底 */ } - }, refreshMs); - if (typeof refresh.unref === 'function') refresh.unref(); - } + // 持锁期刷新:Worker 线程心跳(独立事件循环)——主线程阻塞 spawnSync 时 + // setInterval 无法触发,token 会陈旧被第二写者接管(P1);Worker 不受影响。 + const refresh = startHeartbeat(fsPort, pid, refreshMs, fd); const release = () => releaseLock(fsPort, lockPath, { owner, pid, fd, refresh }); - return { ok: true, fd, owner, token, release }; + // 审计修复:返回 refresh 句柄——此前调用方(pipeline/index 等)直接 releaseLock + // {owner,pid,fd} 而不传 refresh,导致持锁期 setInterval 定时器在释放后泄漏、 + // 每 10s 对已关闭 fd 写 token(EBADF 被吞)。返回后调用方须在 releaseLock 时 + // 一并传入 refresh 以清理定时器。 + return { ok: true, fd, owner, token, release, refresh }; } catch (e) { if (fd !== null) { try { fsPort.closeSync(fd); } catch (_) { /* 忽略 */ } @@ -297,7 +347,15 @@ function releaseLock(fsPort, lockPath, opts = {}) { const owner = opts.owner || `pid-${pid}`; try { if (opts.refresh) { - try { clearInterval(opts.refresh); } catch (_) { /* 忽略 */ } + // P1b 修复:refresh 统一为 heartbeat/interval 句柄(含 stop())。 + // stop() 先停 Worker 并等其确认(heartbeat)或 clearInterval(interval), + // 确保返回后不再有 token 写,调用方随后关 fd / unlink 才安全。 + // 兼容旧形态(裸 Worker/interval)以防调用方仍传旧值。 + try { + if (typeof opts.refresh.stop === 'function') opts.refresh.stop(); + else if (typeof opts.refresh.terminate === 'function') opts.refresh.terminate(); + else clearInterval(opts.refresh); + } catch (_) { /* 忽略 */ } } const token = readToken(fsPort, lockPath); if (token && token.pid !== pid) { diff --git a/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/ids.js b/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/ids.js index 2aed13d..423f6e1 100644 --- a/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/ids.js +++ b/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/ids.js @@ -10,6 +10,7 @@ const { PACK_ID_RE, PLUGIN_NAME_RE, EXACT_VERSION_RE, + REPO_RE, RESERVED_WIN_NAMES, MAX_ID_LENGTH, MAX_SOURCE_PATH_LENGTH @@ -214,6 +215,12 @@ function validateSourceRepo(repo) { if (CONTROL_CHAR_RE.test(repo)) { return { ok: false, error: makeError('ERR_ASSEMBLY_FIELD', 'source.repo 不得包含控制字符') }; } + // 审计修复:repo 必须是 owner/repo 格式(两段、字母数字开头、拒绝 .. 段/空白/元字符)。 + // 此前仅查长度+控制字符,`../../etc/passwd`、含空格或 `?query` 的串会进入 + // codeload/git clone URL 拼装,产生畸形 URL 或路径穿越风险。 + if (!REPO_RE.test(repo)) { + return { ok: false, error: makeError('ERR_ASSEMBLY_FIELD', `source.repo 必须是 owner/repo 格式:${JSON.stringify(repo)}`) }; + } return { ok: true }; } diff --git a/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/index.mjs b/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/index.mjs index b2f0128..6a3a2d3 100644 --- a/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/index.mjs +++ b/dsh-hotplug-hub/dsh-memory-hub/vendor-shared/index.mjs @@ -25,10 +25,10 @@ export const { // contracts/constants export const { - PACK_ID_RE, PLUGIN_NAME_RE, EXACT_VERSION_RE, RESERVED_WIN_NAMES, GITHUB_MIRRORS, + PACK_ID_RE, PLUGIN_NAME_RE, EXACT_VERSION_RE, REPO_RE, RESERVED_WIN_NAMES, GITHUB_MIRRORS, SCHEMA_VERSION, HOTPACK_VERSION, MAX_ID_LENGTH, MAX_PATCH_ID_LENGTH, MAX_SOURCE_PATH_LENGTH, RUNLOG_MAX_BYTES, LOCK_WAIT_MS, LOCK_STALE_MS, LOCK_POLL_MS, LOCK_REFRESH_MS, - LAUNCH_ALIVE_CHECK_MS, LAUNCH_WAIT_TIMEOUT_MS, CRASH_LOOP_THRESHOLD, CRASH_LOOP_WINDOW_MS, + LAUNCH_ALIVE_CHECK_MS, LAUNCH_WAIT_TIMEOUT_MS, CRASH_LOOP_THRESHOLD, DEFAULT_RETRY_BUDGET, STATE_FILE, RUN_LOG_FILE, PATCH_FILE, PROFILE_MANIFEST, SNAPSHOT_INLINE_MAX_BYTES, PROFILES_DIR, STORE_DIR, MEMORY_DIR, HOTPLUG_DIR, PATCH_LOCK_FILE, resolveDshRoot, dshRootPaths, defaultRoots, diff --git a/dsh-hotplug-hub/lib/client.js b/dsh-hotplug-hub/lib/client.js index 705b675..8d376f9 100644 --- a/dsh-hotplug-hub/lib/client.js +++ b/dsh-hotplug-hub/lib/client.js @@ -206,13 +206,17 @@ window.__ModuleLoader__.load({ // 市场抓取来源通道:'github'=官方,其余为镜像站域名(与 lib/core/paths.js GITHUB_MIRRORS 一致) const MARKET_SOURCE_OPTIONS = ["github", "ghfast.top", "gh-proxy.com", "ghproxy.net", "mirror.ghproxy.com", "ghproxy.cc", "gh-proxy.net"]; const MARKET_DETAIL_CONCURRENCY = 6; + // 离线示例目录:仅作展示(无真实插件清单 manifest)。审计修复:此前未标 importable, + // 渲染层 `installable = p.importable !== false` 把它们当成可导入,doMarketInstall 用 + // plugins:[] 硬拼 manifest → parseHotpack 非空校验必失败(离线「导入」恒报错)。 + // 现显式标 importable:false,按钮正确显示「不可导入」且禁用。 const CATALOG = [ - { id: "pack-research", name: "科研插座包", tags: ["科研", "论文", "文献"], desc: "文献检索、综述、论文写作、引用与审稿建议", plugins: 4, accent: "#0e7c6b" }, - { id: "pack-video", name: "视频制作插座包", tags: ["视频", "剪辑", "字幕"], desc: "脚本、分镜、剪辑清单、字幕与封面生成", plugins: 4, accent: "#b45309" }, - { id: "pack-social", name: "自媒体插座包", tags: ["自媒体", "选题", "文案"], desc: "热点选题、拆解、文案与发布清单", plugins: 3, accent: "#8b5e3c" }, - { id: "pack-kaoyan", name: "考研冲刺插座包", tags: ["考研", "学习", "闪卡"], desc: "背诵计划、真题梳理、中日双语文法和闪卡导出", plugins: 3, accent: "#1d5f9e" }, - { id: "pack-fullstack", name: "全栈开发插座包", tags: ["开发", "全栈", "DevOps"], desc: "脚手架、代码评审、测试和安全检查", plugins: 4, accent: "#5b5488" }, - { id: "pack-notes", name: "知识管理插座包", tags: ["笔记", "知识库", "整理"], desc: "网页收藏、笔记整理、书摘提取与双链", plugins: 3, accent: "#237a57" } + { id: "pack-research", name: "科研插座包", tags: ["科研", "论文", "文献"], desc: "文献检索、综述、论文写作、引用与审稿建议", plugins: 4, accent: "#0e7c6b", importable: false, importError: "离线示例包,需联网搜索真实插件包" }, + { id: "pack-video", name: "视频制作插座包", tags: ["视频", "剪辑", "字幕"], desc: "脚本、分镜、剪辑清单、字幕与封面生成", plugins: 4, accent: "#b45309", importable: false, importError: "离线示例包,需联网搜索真实插件包" }, + { id: "pack-social", name: "自媒体插座包", tags: ["自媒体", "选题", "文案"], desc: "热点选题、拆解、文案与发布清单", plugins: 3, accent: "#8b5e3c", importable: false, importError: "离线示例包,需联网搜索真实插件包" }, + { id: "pack-kaoyan", name: "考研冲刺插座包", tags: ["考研", "学习", "闪卡"], desc: "背诵计划、真题梳理、中日双语文法和闪卡导出", plugins: 3, accent: "#1d5f9e", importable: false, importError: "离线示例包,需联网搜索真实插件包" }, + { id: "pack-fullstack", name: "全栈开发插座包", tags: ["开发", "全栈", "DevOps"], desc: "脚手架、代码评审、测试和安全检查", plugins: 4, accent: "#5b5488", importable: false, importError: "离线示例包,需联网搜索真实插件包" }, + { id: "pack-notes", name: "知识管理插座包", tags: ["笔记", "知识库", "整理"], desc: "网页收藏、笔记整理、书摘提取与双链", plugins: 3, accent: "#237a57", importable: false, importError: "离线示例包,需联网搜索真实插件包" } ]; // AI 服务商预设(与后端 lib/core/ai.js AI_PROVIDERS 注册表一致的默认值; // 后端为权威,此处仅作 UI 快捷填充。label 只写平台名,模型名由模型输入框体现, @@ -427,7 +431,19 @@ window.__ModuleLoader__.load({ if (!prev) return prev; return { ...prev, entries: prev.entries.map((x) => (x.id === e.id ? { ...x, ...entry, detailPending: false } : x)) }; }); - } catch { /* 单条详情失败:保留列表占位,标记不可导入,不阻塞其他条目 */ } + } catch { + // 审计修复:catch 原为空转——注释声称"标记不可导入"却未实现,导致 + // detailPending 永远为 true、卡片永久「加载中…」。现显式标记失败态。 + setMarketData((prev) => { + if (!prev) return prev; + return { + ...prev, + entries: prev.entries.map((x) => (x.id === e.id + ? { ...x, detailPending: false, importable: false, importError: "详情加载失败,可稍后重试" } + : x)), + }; + }); + } } }; Array.from({ length: Math.min(MARKET_DETAIL_CONCURRENCY, pending.length) }, () => worker()); @@ -437,16 +453,14 @@ window.__ModuleLoader__.load({ const doMarketRefresh = () => { setMarketPage(1); loadMarket(marketParams(1, true)); }; const doMarketMore = () => { const next = marketPage + 1; setMarketPage(next); loadMarket(marketParams(next, false)); }; const doMarketInstall = (pack) => { - const manifest = pack.manifest || { - hotpack: "1.0", - id: pack.id, - name: pack.name, - version: "1.0.0", - description: pack.description || pack.desc || "", - tags: pack.tags || pack.topics || [], - plugins: [] - }; - run("import", () => api.importPack(JSON.stringify(manifest))).then((result) => { + // 审计修复:无真实 manifest 的条目(离线 CATALOG 示例 / 详情抓取失败的仓库) + // 此前用 plugins:[] 硬拼 manifest → parseHotpack 非空校验必失败。现显式拒绝, + // 不再发起注定失败的 import(渲染层已用 importable:false 禁用按钮,此为双保险)。 + if (!pack.manifest) { + say("error", (pack.importError || t("marketUnavailable"))); + return; + } + run("import", () => api.importPack(JSON.stringify(pack.manifest))).then((result) => { if (result && result.ok) { say("success", t("importDone") + pack.name); setTab("hub"); diff --git a/dsh-hotplug-hub/lib/core/ai-session.js b/dsh-hotplug-hub/lib/core/ai-session.js index c36f23e..316fecd 100644 --- a/dsh-hotplug-hub/lib/core/ai-session.js +++ b/dsh-hotplug-hub/lib/core/ai-session.js @@ -10,10 +10,19 @@ * - 消息内容为 LLM 原文(产物 JSON/闲聊文本),不含凭据。 */ -import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { + closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, + renameSync, rmSync, unlinkSync, writeFileSync, +} from 'node:fs' import { join } from 'node:path' +import { writeFileAtomic } from '../../vendor-shared/index.mjs' import { hotplugRoot } from './paths.js' +// node:fs 直连端口(shared writeFileAtomic 契约所需方法;与 patch.js 的 nodeFsPort 一致) +const atomicFsPort = { + mkdirSync, openSync, writeFileSync, fsyncSync, closeSync, renameSync, existsSync, unlinkSync, +} + /** 会话消息保留上限(超出裁掉最旧的;防 token 爆炸)。 */ export const SESSION_MAX_MESSAGES = 16 /** 单条消息内容截断上限(防超大上下文)。 */ @@ -79,16 +88,16 @@ export function loadSession(id) { } /** - * 保存会话(原子写:临时文件 + rename 替换,避免半写)。 + * 保存会话(统一走 shared writeFileAtomic:随机 tmp + O_EXCL + fsync + rename)。 + * 审计修复:此前自写"伪原子"——writeFileSync(tmp)→rmSync(file)→writeFileSync(file) + * →rmSync(tmp),.tmp 是死代码、先删原文件再直写存在丢文件窗口且无 rename 语义。 + * 现收敛到单一真源 writeFileAtomic,失败不留下半写文件、原文件在 rename 成功前始终完好。 * @param {object} session {id, persona, messages, pack, turn, createdAt, updatedAt} * @returns {boolean} 是否成功 */ export function saveSession(session) { if (!session || typeof session.id !== 'string' || session.id.trim() === '') return false try { - mkdirSync(sessionsDir(), { recursive: true }) - const file = sessionPath(session.id) - const tmp = file + '.tmp' const payload = JSON.stringify({ id: session.id, persona: session.persona, @@ -98,11 +107,8 @@ export function saveSession(session) { createdAt: session.createdAt, updatedAt: session.updatedAt || new Date().toISOString(), }) - writeFileSync(tmp, payload, 'utf8') - rmSync(file, { force: true }) - writeFileSync(file, payload, 'utf8') - rmSync(tmp, { force: true }) - return true + const r = writeFileAtomic(atomicFsPort, sessionPath(session.id), payload, { errorCode: 'ERR_LOG_WRITE' }) + return r.ok } catch { return false } diff --git a/dsh-hotplug-hub/lib/core/hotpack.js b/dsh-hotplug-hub/lib/core/hotpack.js index b63d88f..63aa6f8 100644 --- a/dsh-hotplug-hub/lib/core/hotpack.js +++ b/dsh-hotplug-hub/lib/core/hotpack.js @@ -24,13 +24,15 @@ function adapt(pack) { export function parseHotpack(input) { const r = sharedParseHotpack(input, SHARED_OPTS) - if (!r.ok) return { ok: false, error: r.message } + // 审计修复:保留 shared 的 CLI 域错误码(code)——此前只透传 message,错误码被 + // 网关归一为 ERR_HOTPLUG_FAILED/exit 1,32 码契约从不透传(ERR_ASSEMBLY_* 应 exit 3)。 + if (!r.ok) return { ok: false, code: r.code, error: r.message } return { ok: true, pack: adapt(r.pack) } } /** .dshpack.json(规划格式)→ hotpack v1 转换(vendor-shared 单一桥接 + 展示适配)。 */ export function dshpackToHotpack(text) { const r = sharedDshpackToHotpack(text, SHARED_OPTS) - if (!r.ok) return { ok: false, error: r.message } + if (!r.ok) return { ok: false, code: r.code, error: r.message } return { ok: true, pack: adapt(r.pack) } } diff --git a/dsh-hotplug-hub/lib/core/market.js b/dsh-hotplug-hub/lib/core/market.js index 2310bcf..dd2fcf7 100644 --- a/dsh-hotplug-hub/lib/core/market.js +++ b/dsh-hotplug-hub/lib/core/market.js @@ -185,7 +185,9 @@ export function rawFileUrls(repo, ref, path, sources) { export async function searchMarketRepos(topic, q, page, sources) { // 选中的来源通道同时发起,取第一个成功响应("哪个快用哪个") const res = await raceFetch(apiSearchUrls(topic, q, page, sources), 20000, { Accept: 'application/vnd.github+json' }, 15000) - if (!res.ok) return { ok: false, error: 'HTTP ' + (res.status ?? 0) || '网络请求失败' } + // 审计修复:原 'HTTP ' + (res.status ?? 0) || '网络请求失败' 因 + 优先级高于 ||, + // 后半段为永不可达死代码,网络失败(status=0)时错误地显示 "HTTP 0"。 + if (!res.ok) return { ok: false, error: res.status ? 'HTTP ' + res.status : '网络请求失败' } try { const json = JSON.parse(res.text) if (!Array.isArray(json.items)) return { ok: false, error: json.message ?? '响应结构异常' } diff --git a/dsh-hotplug-hub/lib/core/patch.js b/dsh-hotplug-hub/lib/core/patch.js index c46b15c..bc653fb 100644 --- a/dsh-hotplug-hub/lib/core/patch.js +++ b/dsh-hotplug-hub/lib/core/patch.js @@ -79,36 +79,42 @@ export function appendPatchBlock(pack) { if (!r.ok) return { ok: false, error: r.error.message } return { ok: true } } finally { - releaseLock(nodeFsPort, lockPath, { pid: process.pid, fd: a.fd }) + releaseLock(nodeFsPort, lockPath, { pid: process.pid, fd: a.fd, refresh: a.refresh }) } } /** * 移除 hotplug 分节块(按 marker 匹配,不按 id 内容——迁移规则 §9)。 * 兼容旧内联形态(`- insert: # hotplug:`):按行手术移除。 + * 审计修复:返回统一为 {ok, removed?, error?}(与 appendPatchBlock/shared 一致); + * 此前锁获取失败静默 return false,调用方 unmountPack 丢弃返回值——deactivate 会 + * 在 patch 块实际未移除的情况下仍返回成功,破坏"停用即卸载"契约。 + * @returns {{ok: boolean, removed?: boolean, error?: string}} */ export function removePatchBlock(packId) { const path = patchPath() const lockPath = patchLockPath() const a = acquireLock(nodeFsPort, lockPath, { waitMs: 10000, refreshMs: 5000 }) - if (!a.ok) return false + if (!a.ok) return { ok: false, error: `patch 锁获取失败:${a.error.message}` } try { - if (!existsSync(path)) return false + if (!existsSync(path)) return { ok: true, removed: false } // 1) 契约形态(## / # 单行 marker) const r = sharedRemovePatchBlock(nodeFsPort, path, 'hotplug', packId) - if (r.ok && r.removed) return true + if (!r.ok) return { ok: false, error: r.error.message } + if (r.removed) return { ok: true, removed: true } // 2) 旧内联形态(- insert: # hotplug:) const lines = readFileSync(path, 'utf8').split('\n') const markerRe = new RegExp(`#\\s*hotplug:${packId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`) const start = lines.findIndex((line) => markerRe.test(line)) - if (start === -1) return false + if (start === -1) return { ok: true, removed: false } let end = start + 1 while (end < lines.length && (lines[end].startsWith(' ') || lines[end].startsWith('\t') || lines[end].trim() === '')) end++ lines.splice(start, end - start) const w = writeFileAtomic(nodeFsPort, path, lines.join('\n'), { errorCode: 'ERR_INSTALL_FAILED' }) - return w.ok + if (!w.ok) return { ok: false, error: w.error.message } + return { ok: true, removed: true } } finally { - releaseLock(nodeFsPort, lockPath, { pid: process.pid, fd: a.fd }) + releaseLock(nodeFsPort, lockPath, { pid: process.pid, fd: a.fd, refresh: a.refresh }) } } @@ -184,7 +190,8 @@ export function removeBundles(names) { // ---------- 激活 / 卸载 ---------- export async function unmountPack(pack) { - removePatchBlock(pack.id) + const removed = removePatchBlock(pack.id) + if (!removed.ok) return { ok: false, error: removed.error } const bundleNames = bundlePkgNames(pack) removeBundles(bundleNames) const manifest = readJson(manifestPath()) @@ -207,21 +214,36 @@ export async function unmountPack(pack) { return { ok: true } } +/** + * 回滚一次失败的挂载:以 unmountPack 作为逆操作(link / bundles / patch / npm 全撤销)。 + * 只撤销已写入的部分——unmountPack 各项均为"存在才移除"的幂等语义,故半挂载安全。 + * @param {object} pack + */ +async function rollbackMount(pack) { + try { await unmountPack(pack) } catch { /* 回滚失败不覆盖主错误(尽力而为) */ } +} + export async function mountPack(pack) { const steps = [] + // 审计修复:挂载失败统一回滚(此前 gateway 只撤 patch+bundles,link: 依赖/junction + // 与 ensureNpm 装入的 npm 包残留,形成半挂载)。现在 mount = 全有或全无。 + const fail = async (error) => { + await rollbackMount(pack) + return { ok: false, error, steps } + } for (const entry of pack.plugins) { const ensured = await ensureEntry(entry) - if (!ensured.ok) return { ok: false, error: ensured.error, steps } + if (!ensured.ok) return fail(ensured.error) steps.push({ id: entry.id, name: entry.name, status: ensured.status, detail: ensured.detail }) } const manifest = readJson(manifestPath()) - if (manifest === null) return { ok: false, error: 'profile package.json 不可读', steps } + if (manifest === null) return fail('profile package.json 不可读') for (const entry of pack.plugins.filter((item) => item.source.type !== 'npm')) { const linked = linkEntryIntoProfile(entry) - if (!linked.ok) return { ok: false, error: linked.error, steps } + if (!linked.ok) return fail(linked.error) } addBundles(bundlePkgNames(pack)) const patched = appendPatchBlock(pack) - if (!patched.ok) return { ok: false, error: patched.error, steps } + if (!patched.ok) return fail(patched.error) return { ok: true, steps, restartNeeded: true } } diff --git a/dsh-hotplug-hub/lib/core/status.js b/dsh-hotplug-hub/lib/core/status.js index 35458c7..735525a 100644 --- a/dsh-hotplug-hub/lib/core/status.js +++ b/dsh-hotplug-hub/lib/core/status.js @@ -8,7 +8,7 @@ import { readJson, readPackManifest, listPackIds, readState, writeJsonSafe } fro import { runCli } from './run-cli.js' import { parseHotpack } from './hotpack.js' import { installedVersion, npmModuleDir, storeDirOf } from './ensure.js' -import { patchMarker } from './patch.js' +import { findPatchBlock } from '../../vendor-shared/index.mjs' export function statusSync() { const state = readState() @@ -52,7 +52,9 @@ export function statusSync() { home: homeDir(), profile: { name: profileName(), dir: profileDir() }, activePack: state.activePack ?? null, - activePatchOk: state.activePack ? patchText.includes(patchMarker(state.activePack)) : true, + // 审计修复:改用 shared findPatchBlock(识别 `#`/`##` 两种 marker 形态)—— + // 此前 includes('## hotplug:') 对旧单 # marker 误报 activePatchOk=false。 + activePatchOk: state.activePack ? findPatchBlock(patchText, 'hotplug', state.activePack).found : true, packs, store: { dir: storeRoot(), entries: storeEntries }, } diff --git a/dsh-hotplug-hub/lib/gateway.js b/dsh-hotplug-hub/lib/gateway.js index b516458..fcbe269 100644 --- a/dsh-hotplug-hub/lib/gateway.js +++ b/dsh-hotplug-hub/lib/gateway.js @@ -12,7 +12,8 @@ import { readPackManifest, readState, writeState } from './core/state.js' import { statusSync, importPackSync, previewPack, checkAsync } from './core/status.js' import { marketListAsync, marketDetailAsync } from './core/market.js' import { aiAssemble, aiChat as aiChatCore } from './core/ai.js' -import { mountPack, unmountPack, removePatchBlock, removeBundles, bundlePkgNames } from './core/patch.js' +import { mountPack, unmountPack, removePatchBlock } from './core/patch.js' +import { exitCodeForCode } from '../vendor-shared/index.mjs' /** 网关通用失败码(RPC 域,非 CLI ERROR_CODES 表)。 */ export const RPC_ERROR_CODE = 'ERR_HOTPLUG_FAILED' @@ -20,16 +21,30 @@ export const RPC_ERROR_CODE = 'ERR_HOTPLUG_FAILED' /** * RPC 结果归一化:失败统一 {ok:false, code, message, exitCode}; * 兼容保留 error 字段(迁移期 client 可回退读取)。 + * 审计修复:exitCode 只由 code 推导(shared exitCodeForCode)——此前 result 未带 + * exitCode 时一律归一为 1,导致 hotpack 校验错误(ERR_ASSEMBLY_*,应为 exit 3)被 + * 压成 exit 1,32 码退出码契约从不透传。 * @param {object} result * @returns {object} */ export function normalizeRpc(result) { - if (!result || result.ok !== false) return result + if (!result) return result + if (result.ok !== false) { + // 审计修复:成功信封统一补 code:'OK' / exitCode:0——此前只有 aiAssemble/aiChat + // 手写 {code:'OK', data, exitCode:0},其余方法(status/importPack/activate…)裸返回, + // RPC 信封 {ok, code, exitCode} 不统一;现在所有成功响应统一携带 OK/0。 + return { + ...result, + code: result.code === undefined ? 'OK' : result.code, + exitCode: result.exitCode === undefined ? 0 : result.exitCode, + } + } + const code = typeof result.code === 'string' && result.code !== '' ? result.code : RPC_ERROR_CODE return { ...result, - code: typeof result.code === 'string' ? result.code : RPC_ERROR_CODE, + code, message: typeof result.message === 'string' ? result.message : (typeof result.error === 'string' ? result.error : '操作失败'), - exitCode: typeof result.exitCode === 'number' ? result.exitCode : 1, + exitCode: typeof result.exitCode === 'number' ? result.exitCode : exitCodeForCode(code), } } @@ -86,12 +101,8 @@ class HotplugGateway extends TypertRemoteService { } } const mounted = await mountPack(manifest) - if (!mounted.ok) { - // 挂载失败:把已经写进去的部分尽量还原(patch 块 + bundles),避免半挂载。 - removePatchBlock(manifest.id) - removeBundles(bundlePkgNames(manifest)) - return { ok: false, error: mounted.error, steps: mounted.steps } - } + // mountPack 已事务化:失败时内部回滚 link/bundles/patch/npm,无需此处二次清理。 + if (!mounted.ok) return { ok: false, error: mounted.error, steps: mounted.steps } const next = readState() next.activePack = packId next.history = [...(next.history ?? []), { event: 'activate', packId, at: new Date().toISOString() }].slice(-64) diff --git a/dsh-hotplug-hub/test/audit-fixes.test.mjs b/dsh-hotplug-hub/test/audit-fixes.test.mjs new file mode 100644 index 0000000..b8f9b05 --- /dev/null +++ b/dsh-hotplug-hub/test/audit-fixes.test.mjs @@ -0,0 +1,103 @@ +// test/audit-fixes.test.mjs — 架构审计回归:统一契约 / 原子写 / 错误码透传 / 旧 marker 兼容 +// +// 覆盖审计修复(每条均曾真实失败): +// 1. normalizeRpc:exitCode 只由 code 推导(ERR_ASSEMBLY_* → exit 3,不再一律 1) +// 2. core/hotpack.parseHotpack:保留 shared 的 CLI 域错误码(不再吞 code) +// 3. core/ai-session.saveSession:统一 shared writeFileAtomic(无 .tmp 残留、往返一致) +// 4. core/status.statusSync:旧单 # marker 正确识别为 activePatchOk=true +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { join } from 'node:path' +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { normalizeRpc } from '../lib/gateway.js' +import { parseHotpack } from '../lib/core/hotpack.js' +import { saveSession, loadSession, sessionsDir } from '../lib/core/ai-session.js' +import { statusSync } from '../lib/core/status.js' +import { applyIsolatedEnv, isolatedDsh } from './helpers.mjs' + +let restoreEnv = null +let iso = null + +beforeEach(() => { + iso = isolatedDsh() + restoreEnv = applyIsolatedEnv(iso.dshHome) +}) +afterEach(() => { if (restoreEnv) restoreEnv(); if (iso) iso.cleanup() }) + +describe('normalizeRpc:exitCode 只由 code 推导(审计修复)', () => { + it('ERR_ASSEMBLY_FIELD → exit 3(此前被压成 1)', () => { + const r = normalizeRpc({ ok: false, code: 'ERR_ASSEMBLY_FIELD', message: '字段非法' }) + expect(r.code).toBe('ERR_ASSEMBLY_FIELD') + expect(r.exitCode).toBe(3) + }) + it('无 code 时回退 RPC_ERROR_CODE + exit 1', () => { + const r = normalizeRpc({ ok: false, error: 'x' }) + expect(r.exitCode).toBe(1) + }) +}) + +describe('core/hotpack.parseHotpack:保留 shared 错误码(审计修复)', () => { + it('非法 JSON → code=ERR_ASSEMBLY_INVALID_JSON', () => { + const r = parseHotpack('{ not json') + expect(r.ok).toBe(false) + expect(r.code).toBe('ERR_ASSEMBLY_INVALID_JSON') + }) + it('非对象输入 → code=ERR_ASSEMBLY_FIELD', () => { + const r = parseHotpack(null) + expect(r.ok).toBe(false) + expect(r.code).toBe('ERR_ASSEMBLY_FIELD') + }) +}) + +describe('core/ai-session.saveSession:统一原子写(审计修复)', () => { + it('保存→读取往返一致,且无 .tmp 残留', () => { + const ok = saveSession({ id: 's1', persona: 'maid', messages: [{ role: 'user', content: 'hi' }], pack: null, turn: 1 }) + expect(ok).toBe(true) + const loaded = loadSession('s1') + expect(loaded.id).toBe('s1') + expect(loaded.messages).toEqual([{ role: 'user', content: 'hi' }]) + expect(loaded.turn).toBe(1) + // 无 .tmp 残留(此前 writeFileSync(tmp) + rmSync(file) + writeFileSync(file) 留下死代码路径) + const files = readdirSync(sessionsDir()) + expect(files.some((f) => f.endsWith('.tmp'))).toBe(false) + expect(files).toContain('s1.json') + }) + + it('覆盖写入:旧内容被替换,仍无残留', () => { + saveSession({ id: 's2', messages: [{ role: 'user', content: 'v1' }], turn: 1 }) + saveSession({ id: 's2', messages: [{ role: 'user', content: 'v2' }], turn: 2 }) + const loaded = loadSession('s2') + expect(loaded.messages[0].content).toBe('v2') + expect(loaded.turn).toBe(2) + expect(readdirSync(sessionsDir()).some((f) => f.endsWith('.tmp'))).toBe(false) + }) +}) + +describe('core/status.statusSync:旧单 # marker 兼容(审计修复)', () => { + function seedActivePackWithPatch(patchText) { + const hub = join(iso.dshHome, 'hotplug-hub') + mkdirSync(join(hub, 'packs', 'pack.test'), { recursive: true }) + writeFileSync(join(hub, 'state.json'), JSON.stringify({ version: 1, activePack: 'pack.test', history: [] })) + writeFileSync(join(hub, 'packs', 'pack.test', 'hotpack.json'), JSON.stringify({ + hotpack: '1.0', id: 'pack.test', name: 'Test', version: '1.0.0', description: '', tags: [], + plugins: [{ id: 'a', name: 'pkg-a', source: { type: 'npm' }, version: '1.0.0', config: {} }], + })) + writeFileSync(join(iso.profile, 'cordis.patch.yml'), patchText) + } + + it('旧单 # marker 仍识别为 activePatchOk=true(此前误报 false)', () => { + seedActivePackWithPatch('# hotplug:pack.test\n- insert:\n - id: hp-x\n name: \'x\'\n config: {}\n') + const status = statusSync() + expect(status.activePack).toBe('pack.test') + expect(status.activePatchOk).toBe(true) + }) + + it('契约 ## marker 仍为 true', () => { + seedActivePackWithPatch('## hotplug:pack.test\n- insert:\n - id: hp-x\n name: \'x\'\n config: {}\n') + expect(statusSync().activePatchOk).toBe(true) + }) + + it('patch 缺失 marker 时为 false', () => { + seedActivePackWithPatch('## desktop:other\n- insert: []\n') + expect(statusSync().activePatchOk).toBe(false) + }) +}) diff --git a/dsh-hotplug-hub/test/gateway.test.mjs b/dsh-hotplug-hub/test/gateway.test.mjs index 07f5ebb..a8e931e 100644 --- a/dsh-hotplug-hub/test/gateway.test.mjs +++ b/dsh-hotplug-hub/test/gateway.test.mjs @@ -16,9 +16,13 @@ beforeEach(() => { afterEach(() => { if (restoreEnv) restoreEnv(); if (iso) iso.cleanup() }) describe('normalizeRpc(R-v5-10)', () => { - it('成功结果原样透传', () => { - const ok = { ok: true, data: 1 } - expect(normalizeRpc(ok)).toBe(ok) + it('成功结果统一补 code:"OK"/exitCode:0(审计修复:RPC 信封统一)', () => { + const r = normalizeRpc({ ok: true, data: 1 }) + expect(r.code).toBe('OK') + expect(r.exitCode).toBe(0) + expect(r.data).toBe(1) + // 已带 code/exitCode 的成功结果保留原值 + expect(normalizeRpc({ ok: true, code: 'OK', data: 2, exitCode: 0 }).code).toBe('OK') }) it('失败统一 {ok, code, message, exitCode};error 兼容保留', () => { diff --git a/dsh-hotplug-hub/test/market-net.test.mjs b/dsh-hotplug-hub/test/market-net.test.mjs index 1bfb0c8..1e60bac 100644 --- a/dsh-hotplug-hub/test/market-net.test.mjs +++ b/dsh-hotplug-hub/test/market-net.test.mjs @@ -99,6 +99,19 @@ describe('searchMarketRepos', () => { const r = await searchMarketRepos('dsh-plugin', '', 1, ['github']) expect(r.ok).toBe(false) }) + + it('searchMarketRepos:HTTP 错误 → "HTTP ";网络失败(status=0) → "网络请求失败"(审计修复:运算符优先级死代码)', async () => { + // status=404:确定性结算 → 错误消息应为 HTTP 404 + stubFetch([{ match: () => true, status: 404, text: '' }]) + const r404 = await searchMarketRepos('dsh-plugin', '', 1, ['github']) + expect(r404.ok).toBe(false) + expect(r404.error).toBe('HTTP 404') + // status=0:网络层失败(curl 兜底也失败)→ 错误消息应为「网络请求失败」而非「HTTP 0」 + stubFetch([{ match: () => true, status: 0, text: '' }]) + const r0 = await searchMarketRepos('dsh-plugin', '', 1, ['github']) + expect(r0.ok).toBe(false) + expect(r0.error).toBe('网络请求失败') + }) }) describe('fetchRepoDetail / marketListAsync(全链路桩)', () => { diff --git a/dsh-hotplug-hub/test/patch.test.mjs b/dsh-hotplug-hub/test/patch.test.mjs index eb6d90f..a3e1ecc 100644 --- a/dsh-hotplug-hub/test/patch.test.mjs +++ b/dsh-hotplug-hub/test/patch.test.mjs @@ -53,10 +53,10 @@ describe('appendPatchBlock / removePatchBlock(锁内分节合并)', () => { const r2 = appendPatchBlock(pack) expect(r2.ok).toBe(false) expect(r2.error).toContain('已存在') - // 移除(契约形态) - expect(removePatchBlock('pack.test')).toBe(true) + // 移除(契约形态)——返回统一 {ok, removed} 形状(审计修复:锁失败不再静默 false) + expect(removePatchBlock('pack.test')).toEqual({ ok: true, removed: true }) expect(readFileSync(iso.profile + '/cordis.patch.yml', 'utf8')).not.toContain('hotplug:pack.test') - expect(removePatchBlock('pack.test')).toBe(false) + expect(removePatchBlock('pack.test')).toEqual({ ok: true, removed: false }) }) it('移除保留其它块与注释(分节语义)', () => { @@ -72,7 +72,7 @@ describe('appendPatchBlock / removePatchBlock(锁内分节合并)', () => { it('旧内联形态(- insert: # hotplug:pack.test)移除兼容(迁移规则 §9)', () => { writeFileSync(join(iso.profile, 'cordis.patch.yml'), '# 顶部注释\n- insert: # hotplug:pack.test\n - id: hp-old\n name: \'old\'\n config: {}\n') - expect(removePatchBlock('pack.test')).toBe(true) + expect(removePatchBlock('pack.test')).toEqual({ ok: true, removed: true }) const text = readFileSync(join(iso.profile, 'cordis.patch.yml'), 'utf8') expect(text).toContain('# 顶部注释') expect(text).not.toContain('hotplug:pack.test') @@ -122,4 +122,19 @@ describe('mountPack / unmountPack 对称回滚(H-9,path 源,零 spawn)', expect(Array.isArray(m.steps)).toBe(true) expect(m.steps[0].status).toBe('reused') }) + + it('挂载失败(appendPatchBlock 拒绝已存在块)→ 回滚已 link 的依赖(审计修复:无半挂载)', async () => { + const okDir = join(iso.dshHome, 'plugin-src', 'pkg-ok') + mkdirSync(okDir, { recursive: true }) + writeFileSync(join(okDir, 'package.json'), JSON.stringify({ name: 'pkg-ok', version: '1.0.0' })) + const pack = samplePack({ plugins: [{ id: 'ok', name: 'pkg-ok', source: { type: 'path', path: okDir }, config: {} }] }) + // 预置同名 patch 块,使 appendPatchBlock 在 link 之后失败,验证回滚撤销 link: 依赖与 junction + writeFileSync(join(iso.profile, 'cordis.patch.yml'), '## hotplug:pack.test\n- insert: []\n') + const m = await mountPack(pack) + expect(m.ok).toBe(false) + // 审计修复断言:失败后 profile 无 link: 残留(此前仅撤 patch+bundles,link 残留) + const manifest = JSON.parse(readFileSync(join(iso.profile, 'package.json'), 'utf8')) + expect(manifest.dependencies?.['pkg-ok']).toBeUndefined() + expect(existsSync(join(iso.profile, 'node_modules', 'pkg-ok'))).toBe(false) + }) }) diff --git a/dsh-hotplug-hub/vendor-shared/CONTRACT.md b/dsh-hotplug-hub/vendor-shared/CONTRACT.md index 71200f6..8c412dc 100644 --- a/dsh-hotplug-hub/vendor-shared/CONTRACT.md +++ b/dsh-hotplug-hub/vendor-shared/CONTRACT.md @@ -17,6 +17,7 @@ | `PACK_ID_RE` | `^[a-z0-9][a-z0-9._-]{0,63}$`(大小写不敏感) | 包 id / CLI id:字母数字开头,允许 `. _ -`,1..64 字符 | | `PLUGIN_NAME_RE` | `^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$` | npm 包名(可 scoped) | | `EXACT_VERSION_RE` | `^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$` | 精确版本号(**须再经 semver 双检**:`semver.valid(v) !== null`,拒绝 `1.02.3` 等) | +| `REPO_RE` | `^[0-9A-Za-z][0-9A-Za-z._-]*\/[0-9A-Za-z][0-9A-Za-z._-]*$` | GitHub 仓库 `owner/repo`(两段、字母数字开头;拒绝前导 `. -` 与 `..` 段,防 URL/git clone 路径穿越) | | `RESERVED_WIN_NAMES` | `CON PRN AUX NUL COM1..COM9 LPT1..LPT9` | Windows 保留设备名(任何单段名均拒绝,含首段匹配) | | 长度预算 | id ≤ 64;patch id ≤ 64;source.path ≤ 4096;source.repo ≤ 512;source.ref ≤ 256 | | @@ -25,6 +26,7 @@ - 尾随 `.` 或空格拒绝; - 插件名每个 `/` 段均过 Windows 安全名检查; - `source.path` 必须绝对、拒绝 UNC、拒绝 `.`/`..` 段; +- `source.repo` 必须匹配 `REPO_RE`(`owner/repo` 两段、字母数字开头;拒绝空白/元字符/`..` 段); - `source.ref` 拒绝 `..`、纯点、控制字符、超长(阶段 2 起允许合法 `/`)。 ## 2. 错误码与结果契约 diff --git a/dsh-hotplug-hub/vendor-shared/contracts/constants.js b/dsh-hotplug-hub/vendor-shared/contracts/constants.js index 619d076..da5c89d 100644 --- a/dsh-hotplug-hub/vendor-shared/contracts/constants.js +++ b/dsh-hotplug-hub/vendor-shared/contracts/constants.js @@ -7,6 +7,9 @@ const os = require('os'); const PACK_ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i; const PLUGIN_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +// GitHub 仓库 owner/repo 格式:两段、各段字母数字开头(拒绝前导 . - 与 .. 段, +// 防止 repo 进入 URL/git clone 时产生路径穿越或畸形 URL)。 +const REPO_RE = /^[0-9A-Za-z][0-9A-Za-z._-]*\/[0-9A-Za-z][0-9A-Za-z._-]*$/; // Windows 保留设备名(N43:CON/NUL/COM1… 全部拒绝) const RESERVED_WIN_NAMES = new Set([ @@ -45,9 +48,10 @@ const LOCK_REFRESH_MS = 10000; const LAUNCH_ALIVE_CHECK_MS = 500; const LAUNCH_WAIT_TIMEOUT_MS = 120000; -// 崩溃循环判定(3 次 / 30s 窗口) +// 崩溃循环判定:连续 CRASH_LOOP_THRESHOLD 次非零退出(retries 连续失败计数, +// 成功即清零;与 classifyStateSignals / healplan 触发文案一致)。历史曾声明 +// "30s 窗口"语义(CRASH_LOOP_WINDOW_MS),但从未被实现或消费——已删除该死常量。 const CRASH_LOOP_THRESHOLD = 3; -const CRASH_LOOP_WINDOW_MS = 30000; // 自愈默认重试预算 const DEFAULT_RETRY_BUDGET = 3; @@ -133,6 +137,7 @@ module.exports = { PACK_ID_RE, PLUGIN_NAME_RE, EXACT_VERSION_RE, + REPO_RE, RESERVED_WIN_NAMES, GITHUB_MIRRORS, SCHEMA_VERSION, @@ -148,7 +153,6 @@ module.exports = { LAUNCH_ALIVE_CHECK_MS, LAUNCH_WAIT_TIMEOUT_MS, CRASH_LOOP_THRESHOLD, - CRASH_LOOP_WINDOW_MS, DEFAULT_RETRY_BUDGET, STATE_FILE, RUN_LOG_FILE, diff --git a/dsh-hotplug-hub/vendor-shared/fs/lock-heartbeat.js b/dsh-hotplug-hub/vendor-shared/fs/lock-heartbeat.js new file mode 100644 index 0000000..b290b99 --- /dev/null +++ b/dsh-hotplug-hub/vendor-shared/fs/lock-heartbeat.js @@ -0,0 +1,43 @@ +'use strict'; +// fs/lock-heartbeat.js — 锁 token 心跳(Worker 线程,独立事件循环) +// +// 背景(P1 修复):主线程持锁期间若执行阻塞式 spawnSync(npm/git 安装 >30s), +// 事件循环冻结,setInterval 无法触发 → token 时间戳不再刷新 → 第二写者按 +// "存活 pid + token 超龄"判定陈旧并接管锁,破坏并发写互斥。 +// 本 Worker 运行在独立线程,其事件循环与主线程互不阻塞,故即使主线程卡在 +// spawnSync,token 仍按 refreshMs 周期刷新,锁的陈旧判定契约(CONTRACT.md §5) +// 得以维持。 +// +// 审计修复(P1b,跨线程覆盖竞态):此前每 tick 用 fs.openSync(lockPath, 'r+') +// 按【路径】重开锁文件写 token——release 后主线程 unlink 旧锁、另一进程在同路径 +// 重建新锁,若本 Worker 尚未被 terminate(terminate 异步、存在窗口),下一次 tick +// 会打开【新持有者的锁文件】并用旧 pid 覆盖其 token,击穿跨进程互斥(已实证复现)。 +// +// 根因修复:Worker 不按路径重开,改经【主线程传入的持有 fd】写——该 fd 由 +// acquireLock 用 'wx' 独占创建、绑定获取锁时创建的固定 inode;release 后主线程 +// unlink + 重建同名文件,本 fd 仍指向旧的(已 unlink)inode,写入不影响新持有者。 +// +// 同步停止握手:主线程 releaseLock 先 Atomics.store(ctrl[0]=1) 请求停止并 +// Atomics.notify 唤醒本 Worker,再 Atomics.wait(ctrl[1]==1) 等待确认;本 Worker +// 仅在 while 条件(ctrl[0]==0)为真时写,收到停止请求后【绝不再写】,随后置 +// ctrl[1]=1 确认并退出。主线程在收到确认前不关闭 fd → 不存在"写已关闭/被复用 fd" +// 的窗口(fd 复用竞态一并消除)。 +const { workerData } = require('worker_threads'); +const fs = require('fs'); + +const { fd, pid, refreshMs, ctrl } = workerData; +const c = new Int32Array(ctrl); // c[0]=停止请求(主线程写);c[1]=已停止确认(本 Worker 写) + +while (Atomics.load(c, 0) === 0) { + try { + fs.ftruncateSync(fd, 0); + fs.writeSync(fd, `${pid}\n${Date.now()}\n`, 0, 'utf8'); + } catch (_) { /* 刷新失败:下次重试;陈旧接管兜底 */ } + // 阻塞至 refreshMs 或主线程请求停止(Atomics.notify 立即唤醒) + Atomics.wait(c, 0, 0, refreshMs); +} + +// 确认停止并退出(主线程据此决定关闭 fd 的时机) +Atomics.store(c, 1, 1); +Atomics.notify(c, 1); +process.exit(0); diff --git a/dsh-hotplug-hub/vendor-shared/fs/lock.js b/dsh-hotplug-hub/vendor-shared/fs/lock.js index a30c961..cb508fc 100644 --- a/dsh-hotplug-hub/vendor-shared/fs/lock.js +++ b/dsh-hotplug-hub/vendor-shared/fs/lock.js @@ -24,7 +24,7 @@ // | EEXIST + pid 死 | 立即接管 | // | EEXIST + EACCES/EPERM(他用户) | 等待至超时,不接管 | // | token 缺失/损坏 | 按文件 mtime 判陈旧 | -const { dirname } = require('path'); +const { dirname, join } = require('path'); const { LOCK_WAIT_MS, LOCK_STALE_MS, @@ -160,6 +160,60 @@ function checkV1DirectoryLock(fsPort, lockPath, opts) { return { held: false }; } +/** + * 启动锁 token 心跳。 + * P1 修复:优先用 Worker 线程(独立事件循环)——主线程执行阻塞式 spawnSync 时 + * setInterval 无法触发,token 会陈旧被第二写者接管;Worker 线程不受主线程阻塞影响。 + * Worker 不可用(环境限制)时回退主线程 setInterval(尽力而为,阻塞期间仍可能陈旧)。 + * + * P1b 修复(跨线程覆盖竞态):Worker 经【主线程传入的持有 fd】写 token,绝不按路径 + * 重开——按路径重开会在 release→reacquire 窗口打开新持有者的锁文件并用旧 pid 覆盖其 + * token。fd 绑定获取锁时创建的 inode,release 后重建同名文件也不受影响。 + * 同步停止握手:返回 heartbeat 句柄含 stop(),其置停止请求 + 等 Worker 确认后再让 + * 调用方关 fd(见 releaseLock)。 + * @param {object} fsPort fs 端口(回退路径用) + * @param {number} pid 持有者 pid + * @param {number} refreshMs 刷新周期(0=不刷新,返回 null) + * @param {number} fd 主线程已打开的锁 fd(Worker 经此写;回退路径重写 token 用) + * @returns {{stop: Function}|object|null} heartbeat 句柄或 interval 句柄 + */ +function startHeartbeat(fsPort, pid, refreshMs, fd) { + if (!(refreshMs > 0)) return null; + try { + const { Worker } = require('worker_threads'); + const ctrl = new SharedArrayBuffer(8); // 2×Int32:c[0]=停止请求,c[1]=已停止确认 + const worker = new Worker(join(__dirname, 'lock-heartbeat.js'), { + workerData: { fd, pid, refreshMs, ctrl } + }); + if (typeof worker.unref === 'function') worker.unref(); + const c = new Int32Array(ctrl); + return { + stop() { + // 请求停止并等待 Worker 确认:确认后 Worker 绝不再写,调用方可安全关 fd。 + Atomics.store(c, 0, 1); + Atomics.notify(c, 0); + const deadline = Date.now() + 1000; + while (Atomics.load(c, 1) === 0 && Date.now() < deadline) { + // Worker 收到 notify 后应立即置 c[1]=1;此处短暂自旋兜底(Worker 崩溃/未启动) + Atomics.wait(c, 1, 0, 50); + } + try { worker.terminate(); } catch (_) { /* 忽略 */ } + } + }; + } catch (_) { + // Worker 不可用(worker_threads 缺失/创建失败)→ 回退主线程 setInterval + if (typeof setInterval !== 'function') return null; + const timer = setInterval(() => { + try { + fsPort.ftruncateSync(fd, 0); + fsPort.writeSync(fd, formatToken(pid, Date.now()), 0, 'utf8'); + } catch (_) { /* 刷新失败:陈旧接管兜底 */ } + }, refreshMs); + if (typeof timer.unref === 'function') timer.unref(); + return { stop() { clearInterval(timer); } }; + } +} + /** * 获取文件锁。 * @param {object} fsPort fs 端口 @@ -210,19 +264,15 @@ function acquireLock(fsPort, lockPath, opts = {}) { try { if (fsPort.existsSync(lockPath)) fsPort.unlinkSync(lockPath); } catch (_) { /* 忽略 */ } return { ok: false, error: makeError('ERR_LOCK_ACQUIRE', `写入锁 token 失败 ${lockPath}:${writeErr.message}`) }; } - // 持锁期刷新:经已打开 fd 重写 token(防止长任务被误判陈旧) - let refresh = null; - if (refreshMs > 0 && typeof setInterval === 'function') { - refresh = setInterval(() => { - try { - fsPort.ftruncateSync(fd, 0); - fsPort.writeSync(fd, formatToken(pid, Date.now()), 0, 'utf8'); - } catch (_) { /* 刷新失败:下次仍会尝试;陈旧接管兜底 */ } - }, refreshMs); - if (typeof refresh.unref === 'function') refresh.unref(); - } + // 持锁期刷新:Worker 线程心跳(独立事件循环)——主线程阻塞 spawnSync 时 + // setInterval 无法触发,token 会陈旧被第二写者接管(P1);Worker 不受影响。 + const refresh = startHeartbeat(fsPort, pid, refreshMs, fd); const release = () => releaseLock(fsPort, lockPath, { owner, pid, fd, refresh }); - return { ok: true, fd, owner, token, release }; + // 审计修复:返回 refresh 句柄——此前调用方(pipeline/index 等)直接 releaseLock + // {owner,pid,fd} 而不传 refresh,导致持锁期 setInterval 定时器在释放后泄漏、 + // 每 10s 对已关闭 fd 写 token(EBADF 被吞)。返回后调用方须在 releaseLock 时 + // 一并传入 refresh 以清理定时器。 + return { ok: true, fd, owner, token, release, refresh }; } catch (e) { if (fd !== null) { try { fsPort.closeSync(fd); } catch (_) { /* 忽略 */ } @@ -297,7 +347,15 @@ function releaseLock(fsPort, lockPath, opts = {}) { const owner = opts.owner || `pid-${pid}`; try { if (opts.refresh) { - try { clearInterval(opts.refresh); } catch (_) { /* 忽略 */ } + // P1b 修复:refresh 统一为 heartbeat/interval 句柄(含 stop())。 + // stop() 先停 Worker 并等其确认(heartbeat)或 clearInterval(interval), + // 确保返回后不再有 token 写,调用方随后关 fd / unlink 才安全。 + // 兼容旧形态(裸 Worker/interval)以防调用方仍传旧值。 + try { + if (typeof opts.refresh.stop === 'function') opts.refresh.stop(); + else if (typeof opts.refresh.terminate === 'function') opts.refresh.terminate(); + else clearInterval(opts.refresh); + } catch (_) { /* 忽略 */ } } const token = readToken(fsPort, lockPath); if (token && token.pid !== pid) { diff --git a/dsh-hotplug-hub/vendor-shared/ids.js b/dsh-hotplug-hub/vendor-shared/ids.js index 2aed13d..423f6e1 100644 --- a/dsh-hotplug-hub/vendor-shared/ids.js +++ b/dsh-hotplug-hub/vendor-shared/ids.js @@ -10,6 +10,7 @@ const { PACK_ID_RE, PLUGIN_NAME_RE, EXACT_VERSION_RE, + REPO_RE, RESERVED_WIN_NAMES, MAX_ID_LENGTH, MAX_SOURCE_PATH_LENGTH @@ -214,6 +215,12 @@ function validateSourceRepo(repo) { if (CONTROL_CHAR_RE.test(repo)) { return { ok: false, error: makeError('ERR_ASSEMBLY_FIELD', 'source.repo 不得包含控制字符') }; } + // 审计修复:repo 必须是 owner/repo 格式(两段、字母数字开头、拒绝 .. 段/空白/元字符)。 + // 此前仅查长度+控制字符,`../../etc/passwd`、含空格或 `?query` 的串会进入 + // codeload/git clone URL 拼装,产生畸形 URL 或路径穿越风险。 + if (!REPO_RE.test(repo)) { + return { ok: false, error: makeError('ERR_ASSEMBLY_FIELD', `source.repo 必须是 owner/repo 格式:${JSON.stringify(repo)}`) }; + } return { ok: true }; } diff --git a/dsh-hotplug-hub/vendor-shared/index.mjs b/dsh-hotplug-hub/vendor-shared/index.mjs index b2f0128..6a3a2d3 100644 --- a/dsh-hotplug-hub/vendor-shared/index.mjs +++ b/dsh-hotplug-hub/vendor-shared/index.mjs @@ -25,10 +25,10 @@ export const { // contracts/constants export const { - PACK_ID_RE, PLUGIN_NAME_RE, EXACT_VERSION_RE, RESERVED_WIN_NAMES, GITHUB_MIRRORS, + PACK_ID_RE, PLUGIN_NAME_RE, EXACT_VERSION_RE, REPO_RE, RESERVED_WIN_NAMES, GITHUB_MIRRORS, SCHEMA_VERSION, HOTPACK_VERSION, MAX_ID_LENGTH, MAX_PATCH_ID_LENGTH, MAX_SOURCE_PATH_LENGTH, RUNLOG_MAX_BYTES, LOCK_WAIT_MS, LOCK_STALE_MS, LOCK_POLL_MS, LOCK_REFRESH_MS, - LAUNCH_ALIVE_CHECK_MS, LAUNCH_WAIT_TIMEOUT_MS, CRASH_LOOP_THRESHOLD, CRASH_LOOP_WINDOW_MS, + LAUNCH_ALIVE_CHECK_MS, LAUNCH_WAIT_TIMEOUT_MS, CRASH_LOOP_THRESHOLD, DEFAULT_RETRY_BUDGET, STATE_FILE, RUN_LOG_FILE, PATCH_FILE, PROFILE_MANIFEST, SNAPSHOT_INLINE_MAX_BYTES, PROFILES_DIR, STORE_DIR, MEMORY_DIR, HOTPLUG_DIR, PATCH_LOCK_FILE, resolveDshRoot, dshRootPaths, defaultRoots, diff --git a/launcher/app/pipeline.js b/launcher/app/pipeline.js index 6953b92..b73316f 100644 --- a/launcher/app/pipeline.js +++ b/launcher/app/pipeline.js @@ -74,7 +74,7 @@ async function runPipeline(core, command, args) { return result; } finally { if (lock) { - core.infra.lock.releaseLock(fsPort, lockPath, { owner: lock.owner, pid: process.pid, fd: lock.fd }); + core.infra.lock.releaseLock(fsPort, lockPath, { owner: lock.owner, pid: process.pid, fd: lock.fd, refresh: lock.refresh }); core._activeLock = null; } } diff --git a/launcher/app/stages-heal.js b/launcher/app/stages-heal.js index d2a6df7..cbe606d 100644 --- a/launcher/app/stages-heal.js +++ b/launcher/app/stages-heal.js @@ -30,11 +30,15 @@ async function stageHeal(core, state, args) { } const profileDir = path.join(core.config.roots.profilesRoot, id); // C7 修复:heal 执行前确保 profile 目录存在(此前从未 launch 的 id 直接 heal 时, - // reinstall 的 npm install 以缺失目录为 cwd → 误导性的 ENOENT/ERR_INSTALL_FAILED) - try { - fsPort.mkdirSync(profileDir, { recursive: true }); - } catch (e) { - return errResult(makeError('ERR_INSTALL_DEP', `无法创建 profile 目录 ${profileDir}:${e.message}`)); + // reinstall 的 npm install 以缺失目录为 cwd → 误导性的 ENOENT/ERR_INSTALL_FAILED)。 + // 审计修复:mkdirSync 是持久副作用,必须只在执行模式(--yes)下做——预览(无 --yes) + // 契约承诺"零持久副作用",此前无条件 mkdirSync 违反该契约。 + if (yes) { + try { + fsPort.mkdirSync(profileDir, { recursive: true }); + } catch (e) { + return errResult(makeError('ERR_INSTALL_DEP', `无法创建 profile 目录 ${profileDir}:${e.message}`)); + } } const ctx = buildHealContext(core, state, id, profileDir); const run = await core.infra.heal.runHeal(core, planned.actions, ctx, { dryRun: !yes }); diff --git a/launcher/app/stages.js b/launcher/app/stages.js index d7d5fe0..7e71eda 100644 --- a/launcher/app/stages.js +++ b/launcher/app/stages.js @@ -199,7 +199,11 @@ async function stageLaunch(core, state, args) { // C6 修复:失败时 pid 置 null(无存活进程),避免残留旧 pid 误导 status。 state.launch = { ...state.launch, - lastExit: launched.error.childExitCode !== undefined ? launched.error.childExitCode : (launched.error.exitCode || 1), + // 审计修复:无 childExitCode(spawn/timeout/detach 等基础设施失败,子进程从未 + // 启动/退出)时 lastExit 记 null,而非 fallback 到 ERR_ 契约码 8——后者会让 + // classifyStateSignals 误判为"非零退出→CRASH_LOOP"(基础设施失败应走 HARNESS_FIX)。 + // null 与"detach 存活中 lastExit:null"语义一致,classifyStateSignals 视为无信号。 + lastExit: launched.error.childExitCode !== undefined ? launched.error.childExitCode : null, retries: (state.launch.retries || 0) + 1, pid: null }; diff --git a/launcher/domain/classify.js b/launcher/domain/classify.js index cb46dc3..50aa9e6 100644 --- a/launcher/domain/classify.js +++ b/launcher/domain/classify.js @@ -60,10 +60,17 @@ function classifySignal(signal) { // 无法修复缺失的 dsh 可执行文件)——映射独立动作 HARNESS_FIX。 return { code: 'ERR_LAUNCH_SPAWN', action: 'HARNESS_FIX', suggest: '可执行文件不存在,检查安装或 PATH' }; } + // 审计修复:EACCES/EPERM(权限不足)此前一律归 HARNESS_FIX——reprobe-harness + // 无法修复权限类错误,正确动作是 INSTALL_FAIL(检查目录权限/重装)。 + if (c === 'EACCES' || c === 'EPERM') { + return { code: 'ERR_LAUNCH_SPAWN', action: 'INSTALL_FAIL', suggest: '可执行文件权限不足,检查目录权限后重试' }; + } return { code: 'ERR_LAUNCH_SPAWN', action: 'HARNESS_FIX', suggest: `spawn 失败:${(signal.err && signal.err.message) || '未知错误'}` }; } case 'exit': { - if (signal.exitCode !== 0) { + // 审计修复:exitCode 为 null/undefined 时应视为"无信号"(与 classifyStateSignals + // 将 lastExit===null 视为无信号一致),而非 `null !== 0` 被误判为非零退出。 + if (signal.exitCode != null && signal.exitCode !== 0) { return { code: 'ERR_LAUNCH_EXIT', action: 'CRASH_LOOP', suggest: '启动后非零退出,检查 run.jsonl 日志' }; } return null; @@ -119,13 +126,14 @@ function classifyEntries(entries) { * 基于 state.launch 的状态驱动分类(C3 修复:CRASH_LOOP 真实可达)。 * * 背景:classifyEntries 只能从行式日志产生 stderr/log 信号,进程退出码从不写入 - * run.jsonl,导致 CRASH_LOOP 在 heal 链路上永远不可达(CRASH_LOOP_THRESHOLD/WINDOW - * 常量定义后无人使用)。本函数把 state.launch 的退出信息合成结构化信号: - * - lastExit 非 0:若 retries 已超过窗口阈值 → CRASH_LOOP(动作触发); + * run.jsonl,导致 CRASH_LOOP 在 heal 链路上永远不可达。本函数把 state.launch 的 + * 退出信息合成结构化信号: + * - lastExit 非 0:若 retries 达到 CRASH_LOOP_THRESHOLD(连续失败计数,成功即清零) + * → CRASH_LOOP(动作触发); * - 否则返回 null(单次崩溃不足,由调用方决定是否等待更多证据)。 * - * 窗口语义(与 healplan.CRASH_LOOP 触发文案一致):CRASH_LOOP_THRESHOLD 次 - * 非零退出(retries 计数)即判定崩溃循环。 + * 语义(与 healplan.CRASH_LOOP 触发文案一致):连续 CRASH_LOOP_THRESHOLD 次 + * 非零退出(retries 计数)即判定崩溃循环;不依赖时间窗口。 * * @param {object} state 当前 state(含 launch.lastExit/retries) * @returns {Array<{code:string, action:string, suggest:string}>} diff --git a/launcher/domain/healplan.js b/launcher/domain/healplan.js index 423442b..2483f0b 100644 --- a/launcher/domain/healplan.js +++ b/launcher/domain/healplan.js @@ -57,7 +57,7 @@ const ACTIONS = { }, CRASH_LOOP: { code: 'CRASH_LOOP', - trigger: `启动后 ${CRASH_LOOP_THRESHOLD} 次在窗口内崩溃`, + trigger: `启动后连续 ${CRASH_LOOP_THRESHOLD} 次非零退出`, steps: [{ type: 'rollback-snapshot' }, { type: 'disable-recent' }], verify: '重启后存活时间显著增加', rollback: '恢复被禁用插件', @@ -99,6 +99,9 @@ const ACTIONS = { * @returns {{ok: boolean, actions: Array}} */ function planActions(classification, context = {}) { + // 审计修复:`context = {}` 只覆盖 undefined;显式传 null 时 context.dryRun 抛 + // TypeError。改为空对象兜底(null/undefined 一视同仁)。 + context = context || {}; const items = Array.isArray(classification) ? classification : [classification]; const codes = new Set(); for (const c of items) { diff --git a/launcher/domain/manifest.js b/launcher/domain/manifest.js index 0c46af5..118479a 100644 --- a/launcher/domain/manifest.js +++ b/launcher/domain/manifest.js @@ -57,7 +57,10 @@ function buildManifest(pack, plugins, storeRoot) { */ function versionSpec(ver) { if (typeof ver !== 'string' || ver.length === 0) return 'latest'; - if (semver.valid(ver)) return `^${ver}`; // 精确版 → 兼容范围 + const valid = semver.valid(ver); + // 审计修复:用归一化后的 semver.valid(ver) 加 ^——此前 `^${ver}` 对 'v1.2.3' + // 产出非规范 `^v1.2.3`(semver.valid 容忍 v 前缀但原样拼接未归一化)。 + if (valid) return `^${valid}`; // 精确版(含 v 前缀)→ 归一化 + ^ if (semver.validRange(ver)) return ver; // 已是范围(^1.0.0/>=1.0.0/1.x) if (/^(latest|next|beta|alpha|rc)$/.test(ver)) return ver; // 已知 tag 原样 return 'latest'; // 非法串 → 兜底 latest diff --git a/launcher/domain/resolve.js b/launcher/domain/resolve.js index 97a3ffb..4982668 100644 --- a/launcher/domain/resolve.js +++ b/launcher/domain/resolve.js @@ -5,14 +5,17 @@ const semver = require('semver'); const { EXACT_VERSION_RE } = require('../contracts/constants'); const { makeError } = require('../contracts/errors'); +const { isValidSemverString } = require('./ids'); /** - * 是否为精确版本号(C2 修复:正则 + semver.valid 双条件,与 ids.validateVersion 一致)。 + * 是否为精确版本号(审计修复:收敛到 shared ids.isValidSemverString 单一真源—— + * 此前本地用 `semver.valid(v) !== null` 重复实现同一判定,与零依赖内建实现语义等价 + * 却分叉维护;现与 ids.validateVersion 共用同一判定)。 * @param {unknown} v * @returns {boolean} */ function isExactVersion(v) { - return typeof v === 'string' && EXACT_VERSION_RE.test(v) && semver.valid(v) !== null; + return typeof v === 'string' && EXACT_VERSION_RE.test(v) && isValidSemverString(v); } /** @@ -42,6 +45,17 @@ function resolveVersion(name, version, registry) { if (isExactVersion(version)) { return { resolvedVersion: version, pinned: true, source: 'exact' }; } + // 审计修复:非空垃圾版本串(既非精确版也非合法范围,如 'garbage'/'latest')在 + // 有 registry 数据时,此前被 `range = '*'` 兜底静默 pin 最高版——与注释声称的 + // "unresolved + warning"矛盾。现提前判定,垃圾串一律 unresolved,绝不进入 registry 匹配。 + if (typeof version === 'string' && version.length > 0 && !semver.validRange(version)) { + return { + resolvedVersion: null, + pinned: false, + source: 'unresolved', + warning: `非法版本串,无法 pin ${name}@${version}` + }; + } if (Array.isArray(available) && available.length > 0) { const range = version && semver.validRange(version) ? version : '*'; let best = semver.maxSatisfying(available, range); @@ -80,17 +94,6 @@ function resolveVersion(name, version, registry) { warning: `无 registry 数据,无法 pin ${name}@${version}` }; } - // C2 修复:既非精确版也非合法范围的垃圾版本串('garbage'/'latest' 等)—— - // 不得标 pinned:true source:'exact'(曾导致 install 把垃圾 spec 交给 npm、 - // conflicts 将其按"缺版本"处理),改为 unresolved + warning。 - if (typeof version === 'string' && version.length > 0) { - return { - resolvedVersion: null, - pinned: false, - source: 'unresolved', - warning: `非法版本串,无法 pin ${name}@${version}` - }; - } return { resolvedVersion: null, pinned: false, source: 'unresolved', warning: `缺少版本,无法 pin ${name}` }; } diff --git a/launcher/index.js b/launcher/index.js index 62e7daa..cfdee33 100644 --- a/launcher/index.js +++ b/launcher/index.js @@ -43,7 +43,8 @@ async function main() { core.infra.lock.releaseLock(core.ports.fs, core._activeLock.lockPath, { owner: core._activeLock.owner, pid: process.pid, - fd: core._activeLock.fd + fd: core._activeLock.fd, + refresh: core._activeLock.refresh }); } } catch (_) { /* 释放失败不影响退出 */ } diff --git a/launcher/test/audit-fixes.test.js b/launcher/test/audit-fixes.test.js new file mode 100644 index 0000000..b279310 --- /dev/null +++ b/launcher/test/audit-fixes.test.js @@ -0,0 +1,64 @@ +'use strict'; +// test/audit-fixes.test.js — 架构审计回归:契约漂移 / 死代码 / 健壮性(每条曾真实失败) +// +// 1. resolveVersion:垃圾版本串在有 registry 数据时不再静默 pin 最高版(曾 pin 最高版) +// 2. versionSpec:'v1.2.3' 归一化为 '^1.2.3'(曾产出非规范 '^v1.2.3') +// 3. planActions:显式传 null context 不再抛 TypeError(默认参数只覆盖 undefined) +// 4. classifySignal:exit exitCode=null/undefined 视为"无信号"(曾误判 CRASH_LOOP) +const { resolveVersion } = require('../domain/resolve'); +const { versionSpec } = require('../domain/manifest'); +const { planActions } = require('../domain/healplan'); +const { classifySignal, classifyStateSignals } = require('../domain/classify'); + +describe('resolveVersion:垃圾版本串不被 registry 兜底 pin 最高版(审计修复)', () => { + const registry = { availableVersions: () => ['1.0.0', '2.0.0'] }; + it('garbage 版本串 → unresolved + warning(此前 pin 2.0.0)', () => { + const r = resolveVersion('pkg', 'garbage!@#', registry); + expect(r.resolvedVersion).toBeNull(); + expect(r.pinned).toBe(false); + expect(r.source).toBe('unresolved'); + expect(r.warning).toContain('非法版本串'); + }); + it('精确版仍直接 pin', () => { + const r = resolveVersion('pkg', '1.2.3', registry); + expect(r).toEqual({ resolvedVersion: '1.2.3', pinned: true, source: 'exact' }); + }); + it('合法范围仍走 registry 匹配', () => { + const r = resolveVersion('pkg', '^1.0.0', registry); + expect(r.pinned).toBe(true); + expect(r.resolvedVersion).toBe('1.0.0'); + }); +}); + +describe('versionSpec:v 前缀归一化(审计修复)', () => { + it("versionSpec('v1.2.3') → '^1.2.3'(此前 '^v1.2.3')", () => { + expect(versionSpec('v1.2.3')).toBe('^1.2.3'); + }); + it('精确版 → ^;范围 → 原样;tag → 原样;垃圾 → latest', () => { + expect(versionSpec('1.2.3')).toBe('^1.2.3'); + expect(versionSpec('^1.0.0')).toBe('^1.0.0'); + expect(versionSpec('latest')).toBe('latest'); + expect(versionSpec('garbage')).toBe('latest'); + expect(versionSpec(undefined)).toBe('latest'); + }); +}); + +describe('planActions:null context 健壮性(审计修复)', () => { + it('planActions(classification, null) 不抛 TypeError', () => { + const r = planActions([{ action: 'CRASH_LOOP' }], null); + expect(r.ok).toBe(true); + expect(r.actions).toHaveLength(1); + expect(r.actions[0].code).toBe('CRASH_LOOP'); + expect(r.actions[0].dryRun).toBe(true); + }); +}); + +describe('classifySignal:exit 无退出码视为无信号(审计修复)', () => { + it('exitCode null/undefined → null(此前误判 CRASH_LOOP)', () => { + expect(classifySignal({ kind: 'exit', exitCode: null })).toBeNull(); + expect(classifySignal({ kind: 'exit', exitCode: undefined })).toBeNull(); + }); + it('classifyStateSignals:lastExit null 仍为无信号(回归不变)', () => { + expect(classifyStateSignals({ launch: { lastExit: null, retries: 3 } })).toEqual([]); + }); +}); diff --git a/launcher/test/qa3-classify-extra.test.js b/launcher/test/qa3-classify-extra.test.js index 4fe260b..cf11c5d 100644 --- a/launcher/test/qa3-classify-extra.test.js +++ b/launcher/test/qa3-classify-extra.test.js @@ -58,19 +58,24 @@ describe('QA3 classify 分类穷尽(审计 J 强化)', () => { } }); - it('spawn-error 多信号优先级:spawn-error → HARNESS_FIX(C3 修复:harness 归因)', () => { + it('spawn-error 多信号优先级:ENOENT → HARNESS_FIX;EACCES → INSTALL_FAIL(审计修复:权限归因)', () => { const c1 = classifySignal({ kind: 'spawn-error', err: Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }); expect(c1.action).toBe('HARNESS_FIX'); - const c2 = classifySignal({ kind: 'spawn-error', err: new Error('EACCES') }); - expect(c2.action).toBe('HARNESS_FIX'); + // EACCES/EPERM(权限不足)→ INSTALL_FAIL(reprobe-harness 无法修复权限) + const c2 = classifySignal({ kind: 'spawn-error', err: Object.assign(new Error('EACCES'), { code: 'EACCES' }) }); + expect(c2.action).toBe('INSTALL_FAIL'); expect(c2.code).toBe('ERR_LAUNCH_SPAWN'); + // 无 code 的未知 spawn 错误 → 保守 HARNESS_FIX + const c3 = classifySignal({ kind: 'spawn-error', err: new Error('EACCES') }); + expect(c3.action).toBe('HARNESS_FIX'); + expect(c3.code).toBe('ERR_LAUNCH_SPAWN'); }); - it('exit 信号:非零 → CRASH_LOOP;零 → null;exitCode 为 null(信号退出)→ CRASH_LOOP', () => { + it('exit 信号:非零 → CRASH_LOOP;零/null/undefined → null(审计修复:null=无退出码,与 classifyStateSignals 一致,非信号退出)', () => { expect(classifySignal({ kind: 'exit', exitCode: 1 }).action).toBe('CRASH_LOOP'); expect(classifySignal({ kind: 'exit', exitCode: 0 })).toBeNull(); - expect(classifySignal({ kind: 'exit', exitCode: null }).action).toBe('CRASH_LOOP'); - expect(classifySignal({ kind: 'exit', exitCode: undefined }).action).toBe('CRASH_LOOP'); + expect(classifySignal({ kind: 'exit', exitCode: null })).toBeNull(); + expect(classifySignal({ kind: 'exit', exitCode: undefined })).toBeNull(); }); it('log 信号:error 级 + U+FFFD → UTF8_CORRUPTION;error 级 + 规则行 → 对应动作;info 级 → null', () => { diff --git a/launcher/test/stages-heal.test.js b/launcher/test/stages-heal.test.js index ada62f2..ddbdd3c 100644 --- a/launcher/test/stages-heal.test.js +++ b/launcher/test/stages-heal.test.js @@ -64,6 +64,10 @@ describe('app/stages-heal.js stageHeal', () => { // 预览零持久副作用:phase/history/dirty 均未变 expect(JSON.stringify(state)).toBe(before); expect(state.phase).toBe('LAUNCHED'); + // 审计修复回归:预览(无 --yes)不得创建 profile 目录(mkdirSync 副作用守卫)。 + // 此前无条件 mkdirSync 违反"预览零持久副作用"契约——现用磁盘断言钉死。 + const profileDir = path.join(core.config.roots.profilesRoot, 'demo'); + expect(fs.existsSync(profileDir)).toBe(false); }); it('有信号 + --yes + 快照 → ok,phase=HEALING,history 追加(C7)', async () => { diff --git a/packages/shared-core/CONTRACT.md b/packages/shared-core/CONTRACT.md index 71200f6..8c412dc 100644 --- a/packages/shared-core/CONTRACT.md +++ b/packages/shared-core/CONTRACT.md @@ -17,6 +17,7 @@ | `PACK_ID_RE` | `^[a-z0-9][a-z0-9._-]{0,63}$`(大小写不敏感) | 包 id / CLI id:字母数字开头,允许 `. _ -`,1..64 字符 | | `PLUGIN_NAME_RE` | `^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$` | npm 包名(可 scoped) | | `EXACT_VERSION_RE` | `^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$` | 精确版本号(**须再经 semver 双检**:`semver.valid(v) !== null`,拒绝 `1.02.3` 等) | +| `REPO_RE` | `^[0-9A-Za-z][0-9A-Za-z._-]*\/[0-9A-Za-z][0-9A-Za-z._-]*$` | GitHub 仓库 `owner/repo`(两段、字母数字开头;拒绝前导 `. -` 与 `..` 段,防 URL/git clone 路径穿越) | | `RESERVED_WIN_NAMES` | `CON PRN AUX NUL COM1..COM9 LPT1..LPT9` | Windows 保留设备名(任何单段名均拒绝,含首段匹配) | | 长度预算 | id ≤ 64;patch id ≤ 64;source.path ≤ 4096;source.repo ≤ 512;source.ref ≤ 256 | | @@ -25,6 +26,7 @@ - 尾随 `.` 或空格拒绝; - 插件名每个 `/` 段均过 Windows 安全名检查; - `source.path` 必须绝对、拒绝 UNC、拒绝 `.`/`..` 段; +- `source.repo` 必须匹配 `REPO_RE`(`owner/repo` 两段、字母数字开头;拒绝空白/元字符/`..` 段); - `source.ref` 拒绝 `..`、纯点、控制字符、超长(阶段 2 起允许合法 `/`)。 ## 2. 错误码与结果契约 diff --git a/packages/shared-core/contracts/constants.js b/packages/shared-core/contracts/constants.js index 619d076..da5c89d 100644 --- a/packages/shared-core/contracts/constants.js +++ b/packages/shared-core/contracts/constants.js @@ -7,6 +7,9 @@ const os = require('os'); const PACK_ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i; const PLUGIN_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +// GitHub 仓库 owner/repo 格式:两段、各段字母数字开头(拒绝前导 . - 与 .. 段, +// 防止 repo 进入 URL/git clone 时产生路径穿越或畸形 URL)。 +const REPO_RE = /^[0-9A-Za-z][0-9A-Za-z._-]*\/[0-9A-Za-z][0-9A-Za-z._-]*$/; // Windows 保留设备名(N43:CON/NUL/COM1… 全部拒绝) const RESERVED_WIN_NAMES = new Set([ @@ -45,9 +48,10 @@ const LOCK_REFRESH_MS = 10000; const LAUNCH_ALIVE_CHECK_MS = 500; const LAUNCH_WAIT_TIMEOUT_MS = 120000; -// 崩溃循环判定(3 次 / 30s 窗口) +// 崩溃循环判定:连续 CRASH_LOOP_THRESHOLD 次非零退出(retries 连续失败计数, +// 成功即清零;与 classifyStateSignals / healplan 触发文案一致)。历史曾声明 +// "30s 窗口"语义(CRASH_LOOP_WINDOW_MS),但从未被实现或消费——已删除该死常量。 const CRASH_LOOP_THRESHOLD = 3; -const CRASH_LOOP_WINDOW_MS = 30000; // 自愈默认重试预算 const DEFAULT_RETRY_BUDGET = 3; @@ -133,6 +137,7 @@ module.exports = { PACK_ID_RE, PLUGIN_NAME_RE, EXACT_VERSION_RE, + REPO_RE, RESERVED_WIN_NAMES, GITHUB_MIRRORS, SCHEMA_VERSION, @@ -148,7 +153,6 @@ module.exports = { LAUNCH_ALIVE_CHECK_MS, LAUNCH_WAIT_TIMEOUT_MS, CRASH_LOOP_THRESHOLD, - CRASH_LOOP_WINDOW_MS, DEFAULT_RETRY_BUDGET, STATE_FILE, RUN_LOG_FILE, diff --git a/packages/shared-core/fs/lock-heartbeat.js b/packages/shared-core/fs/lock-heartbeat.js new file mode 100644 index 0000000..b290b99 --- /dev/null +++ b/packages/shared-core/fs/lock-heartbeat.js @@ -0,0 +1,43 @@ +'use strict'; +// fs/lock-heartbeat.js — 锁 token 心跳(Worker 线程,独立事件循环) +// +// 背景(P1 修复):主线程持锁期间若执行阻塞式 spawnSync(npm/git 安装 >30s), +// 事件循环冻结,setInterval 无法触发 → token 时间戳不再刷新 → 第二写者按 +// "存活 pid + token 超龄"判定陈旧并接管锁,破坏并发写互斥。 +// 本 Worker 运行在独立线程,其事件循环与主线程互不阻塞,故即使主线程卡在 +// spawnSync,token 仍按 refreshMs 周期刷新,锁的陈旧判定契约(CONTRACT.md §5) +// 得以维持。 +// +// 审计修复(P1b,跨线程覆盖竞态):此前每 tick 用 fs.openSync(lockPath, 'r+') +// 按【路径】重开锁文件写 token——release 后主线程 unlink 旧锁、另一进程在同路径 +// 重建新锁,若本 Worker 尚未被 terminate(terminate 异步、存在窗口),下一次 tick +// 会打开【新持有者的锁文件】并用旧 pid 覆盖其 token,击穿跨进程互斥(已实证复现)。 +// +// 根因修复:Worker 不按路径重开,改经【主线程传入的持有 fd】写——该 fd 由 +// acquireLock 用 'wx' 独占创建、绑定获取锁时创建的固定 inode;release 后主线程 +// unlink + 重建同名文件,本 fd 仍指向旧的(已 unlink)inode,写入不影响新持有者。 +// +// 同步停止握手:主线程 releaseLock 先 Atomics.store(ctrl[0]=1) 请求停止并 +// Atomics.notify 唤醒本 Worker,再 Atomics.wait(ctrl[1]==1) 等待确认;本 Worker +// 仅在 while 条件(ctrl[0]==0)为真时写,收到停止请求后【绝不再写】,随后置 +// ctrl[1]=1 确认并退出。主线程在收到确认前不关闭 fd → 不存在"写已关闭/被复用 fd" +// 的窗口(fd 复用竞态一并消除)。 +const { workerData } = require('worker_threads'); +const fs = require('fs'); + +const { fd, pid, refreshMs, ctrl } = workerData; +const c = new Int32Array(ctrl); // c[0]=停止请求(主线程写);c[1]=已停止确认(本 Worker 写) + +while (Atomics.load(c, 0) === 0) { + try { + fs.ftruncateSync(fd, 0); + fs.writeSync(fd, `${pid}\n${Date.now()}\n`, 0, 'utf8'); + } catch (_) { /* 刷新失败:下次重试;陈旧接管兜底 */ } + // 阻塞至 refreshMs 或主线程请求停止(Atomics.notify 立即唤醒) + Atomics.wait(c, 0, 0, refreshMs); +} + +// 确认停止并退出(主线程据此决定关闭 fd 的时机) +Atomics.store(c, 1, 1); +Atomics.notify(c, 1); +process.exit(0); diff --git a/packages/shared-core/fs/lock.js b/packages/shared-core/fs/lock.js index a30c961..cb508fc 100644 --- a/packages/shared-core/fs/lock.js +++ b/packages/shared-core/fs/lock.js @@ -24,7 +24,7 @@ // | EEXIST + pid 死 | 立即接管 | // | EEXIST + EACCES/EPERM(他用户) | 等待至超时,不接管 | // | token 缺失/损坏 | 按文件 mtime 判陈旧 | -const { dirname } = require('path'); +const { dirname, join } = require('path'); const { LOCK_WAIT_MS, LOCK_STALE_MS, @@ -160,6 +160,60 @@ function checkV1DirectoryLock(fsPort, lockPath, opts) { return { held: false }; } +/** + * 启动锁 token 心跳。 + * P1 修复:优先用 Worker 线程(独立事件循环)——主线程执行阻塞式 spawnSync 时 + * setInterval 无法触发,token 会陈旧被第二写者接管;Worker 线程不受主线程阻塞影响。 + * Worker 不可用(环境限制)时回退主线程 setInterval(尽力而为,阻塞期间仍可能陈旧)。 + * + * P1b 修复(跨线程覆盖竞态):Worker 经【主线程传入的持有 fd】写 token,绝不按路径 + * 重开——按路径重开会在 release→reacquire 窗口打开新持有者的锁文件并用旧 pid 覆盖其 + * token。fd 绑定获取锁时创建的 inode,release 后重建同名文件也不受影响。 + * 同步停止握手:返回 heartbeat 句柄含 stop(),其置停止请求 + 等 Worker 确认后再让 + * 调用方关 fd(见 releaseLock)。 + * @param {object} fsPort fs 端口(回退路径用) + * @param {number} pid 持有者 pid + * @param {number} refreshMs 刷新周期(0=不刷新,返回 null) + * @param {number} fd 主线程已打开的锁 fd(Worker 经此写;回退路径重写 token 用) + * @returns {{stop: Function}|object|null} heartbeat 句柄或 interval 句柄 + */ +function startHeartbeat(fsPort, pid, refreshMs, fd) { + if (!(refreshMs > 0)) return null; + try { + const { Worker } = require('worker_threads'); + const ctrl = new SharedArrayBuffer(8); // 2×Int32:c[0]=停止请求,c[1]=已停止确认 + const worker = new Worker(join(__dirname, 'lock-heartbeat.js'), { + workerData: { fd, pid, refreshMs, ctrl } + }); + if (typeof worker.unref === 'function') worker.unref(); + const c = new Int32Array(ctrl); + return { + stop() { + // 请求停止并等待 Worker 确认:确认后 Worker 绝不再写,调用方可安全关 fd。 + Atomics.store(c, 0, 1); + Atomics.notify(c, 0); + const deadline = Date.now() + 1000; + while (Atomics.load(c, 1) === 0 && Date.now() < deadline) { + // Worker 收到 notify 后应立即置 c[1]=1;此处短暂自旋兜底(Worker 崩溃/未启动) + Atomics.wait(c, 1, 0, 50); + } + try { worker.terminate(); } catch (_) { /* 忽略 */ } + } + }; + } catch (_) { + // Worker 不可用(worker_threads 缺失/创建失败)→ 回退主线程 setInterval + if (typeof setInterval !== 'function') return null; + const timer = setInterval(() => { + try { + fsPort.ftruncateSync(fd, 0); + fsPort.writeSync(fd, formatToken(pid, Date.now()), 0, 'utf8'); + } catch (_) { /* 刷新失败:陈旧接管兜底 */ } + }, refreshMs); + if (typeof timer.unref === 'function') timer.unref(); + return { stop() { clearInterval(timer); } }; + } +} + /** * 获取文件锁。 * @param {object} fsPort fs 端口 @@ -210,19 +264,15 @@ function acquireLock(fsPort, lockPath, opts = {}) { try { if (fsPort.existsSync(lockPath)) fsPort.unlinkSync(lockPath); } catch (_) { /* 忽略 */ } return { ok: false, error: makeError('ERR_LOCK_ACQUIRE', `写入锁 token 失败 ${lockPath}:${writeErr.message}`) }; } - // 持锁期刷新:经已打开 fd 重写 token(防止长任务被误判陈旧) - let refresh = null; - if (refreshMs > 0 && typeof setInterval === 'function') { - refresh = setInterval(() => { - try { - fsPort.ftruncateSync(fd, 0); - fsPort.writeSync(fd, formatToken(pid, Date.now()), 0, 'utf8'); - } catch (_) { /* 刷新失败:下次仍会尝试;陈旧接管兜底 */ } - }, refreshMs); - if (typeof refresh.unref === 'function') refresh.unref(); - } + // 持锁期刷新:Worker 线程心跳(独立事件循环)——主线程阻塞 spawnSync 时 + // setInterval 无法触发,token 会陈旧被第二写者接管(P1);Worker 不受影响。 + const refresh = startHeartbeat(fsPort, pid, refreshMs, fd); const release = () => releaseLock(fsPort, lockPath, { owner, pid, fd, refresh }); - return { ok: true, fd, owner, token, release }; + // 审计修复:返回 refresh 句柄——此前调用方(pipeline/index 等)直接 releaseLock + // {owner,pid,fd} 而不传 refresh,导致持锁期 setInterval 定时器在释放后泄漏、 + // 每 10s 对已关闭 fd 写 token(EBADF 被吞)。返回后调用方须在 releaseLock 时 + // 一并传入 refresh 以清理定时器。 + return { ok: true, fd, owner, token, release, refresh }; } catch (e) { if (fd !== null) { try { fsPort.closeSync(fd); } catch (_) { /* 忽略 */ } @@ -297,7 +347,15 @@ function releaseLock(fsPort, lockPath, opts = {}) { const owner = opts.owner || `pid-${pid}`; try { if (opts.refresh) { - try { clearInterval(opts.refresh); } catch (_) { /* 忽略 */ } + // P1b 修复:refresh 统一为 heartbeat/interval 句柄(含 stop())。 + // stop() 先停 Worker 并等其确认(heartbeat)或 clearInterval(interval), + // 确保返回后不再有 token 写,调用方随后关 fd / unlink 才安全。 + // 兼容旧形态(裸 Worker/interval)以防调用方仍传旧值。 + try { + if (typeof opts.refresh.stop === 'function') opts.refresh.stop(); + else if (typeof opts.refresh.terminate === 'function') opts.refresh.terminate(); + else clearInterval(opts.refresh); + } catch (_) { /* 忽略 */ } } const token = readToken(fsPort, lockPath); if (token && token.pid !== pid) { diff --git a/packages/shared-core/ids.js b/packages/shared-core/ids.js index 2aed13d..423f6e1 100644 --- a/packages/shared-core/ids.js +++ b/packages/shared-core/ids.js @@ -10,6 +10,7 @@ const { PACK_ID_RE, PLUGIN_NAME_RE, EXACT_VERSION_RE, + REPO_RE, RESERVED_WIN_NAMES, MAX_ID_LENGTH, MAX_SOURCE_PATH_LENGTH @@ -214,6 +215,12 @@ function validateSourceRepo(repo) { if (CONTROL_CHAR_RE.test(repo)) { return { ok: false, error: makeError('ERR_ASSEMBLY_FIELD', 'source.repo 不得包含控制字符') }; } + // 审计修复:repo 必须是 owner/repo 格式(两段、字母数字开头、拒绝 .. 段/空白/元字符)。 + // 此前仅查长度+控制字符,`../../etc/passwd`、含空格或 `?query` 的串会进入 + // codeload/git clone URL 拼装,产生畸形 URL 或路径穿越风险。 + if (!REPO_RE.test(repo)) { + return { ok: false, error: makeError('ERR_ASSEMBLY_FIELD', `source.repo 必须是 owner/repo 格式:${JSON.stringify(repo)}`) }; + } return { ok: true }; } diff --git a/packages/shared-core/index.mjs b/packages/shared-core/index.mjs index b2f0128..6a3a2d3 100644 --- a/packages/shared-core/index.mjs +++ b/packages/shared-core/index.mjs @@ -25,10 +25,10 @@ export const { // contracts/constants export const { - PACK_ID_RE, PLUGIN_NAME_RE, EXACT_VERSION_RE, RESERVED_WIN_NAMES, GITHUB_MIRRORS, + PACK_ID_RE, PLUGIN_NAME_RE, EXACT_VERSION_RE, REPO_RE, RESERVED_WIN_NAMES, GITHUB_MIRRORS, SCHEMA_VERSION, HOTPACK_VERSION, MAX_ID_LENGTH, MAX_PATCH_ID_LENGTH, MAX_SOURCE_PATH_LENGTH, RUNLOG_MAX_BYTES, LOCK_WAIT_MS, LOCK_STALE_MS, LOCK_POLL_MS, LOCK_REFRESH_MS, - LAUNCH_ALIVE_CHECK_MS, LAUNCH_WAIT_TIMEOUT_MS, CRASH_LOOP_THRESHOLD, CRASH_LOOP_WINDOW_MS, + LAUNCH_ALIVE_CHECK_MS, LAUNCH_WAIT_TIMEOUT_MS, CRASH_LOOP_THRESHOLD, DEFAULT_RETRY_BUDGET, STATE_FILE, RUN_LOG_FILE, PATCH_FILE, PROFILE_MANIFEST, SNAPSHOT_INLINE_MAX_BYTES, PROFILES_DIR, STORE_DIR, MEMORY_DIR, HOTPLUG_DIR, PATCH_LOCK_FILE, resolveDshRoot, dshRootPaths, defaultRoots, diff --git a/packages/shared-core/test/ids.test.js b/packages/shared-core/test/ids.test.js index 69d40bd..a7f25bf 100644 --- a/packages/shared-core/test/ids.test.js +++ b/packages/shared-core/test/ids.test.js @@ -127,10 +127,23 @@ describe('validateSourcePath', () => { describe('validateSourceRepo / validateSourceRef', () => { it('repo 基本校验', () => { expect(validateSourceRepo('owner/repo').ok).toBe(true); + expect(validateSourceRepo('owner/repo.sub-1').ok).toBe(true); expect(validateSourceRepo('').ok).toBe(false); expect(validateSourceRepo('x'.repeat(513)).ok).toBe(false); expect(validateSourceRepo('a\u0000b').ok).toBe(false); }); + it('repo 必须是 owner/repo 格式(审计修复:拒绝穿越/空白/元字符/前导点)', () => { + expect(validateSourceRepo('../../etc/passwd').ok).toBe(false); + expect(validateSourceRepo('a/../b').ok).toBe(false); + expect(validateSourceRepo('a/..').ok).toBe(false); + expect(validateSourceRepo('.owner/repo').ok).toBe(false); + expect(validateSourceRepo('owner/.repo').ok).toBe(false); + expect(validateSourceRepo('a b/c').ok).toBe(false); + expect(validateSourceRepo('owner/repo?query').ok).toBe(false); + expect(validateSourceRepo('owner').ok).toBe(false); // 缺 / + expect(validateSourceRepo('a/b/c').ok).toBe(false); // 多段 + expect(validateSourceRepo('owner/repo&x').ok).toBe(false); + }); it('ref 拒绝 .. / 纯点 / 空段 / 控制字符 / 超长;H-10 允许合法 /', () => { expect(validateSourceRef('main').ok).toBe(true); expect(validateSourceRef('v1.0.0').ok).toBe(true); diff --git a/packages/shared-core/test/lock.test.js b/packages/shared-core/test/lock.test.js index 0a2443c..fc65124 100644 --- a/packages/shared-core/test/lock.test.js +++ b/packages/shared-core/test/lock.test.js @@ -65,6 +65,67 @@ describe('acquire / release(单进程)', () => { expect(fs.existsSync(lock)).toBe(false); }); + it('acquireLock 返回 refresh 句柄,releaseLock 传入后清理定时器(审计修复:防定时器泄漏)', () => { + const dir = tempDir(); + const lock = path.join(dir, '.lock'); + // refreshMs>0 时 acquireLock 应返回 refresh 句柄(此前不返回,调用方直接 releaseLock + // 无法清理 setInterval → 释放后仍每 10s 对已关闭 fd 写 token 泄漏) + const a = acquireLock(nodeFs, lock, { waitMs: 500, refreshMs: 20 }); + expect(a.ok).toBe(true); + expect(a.refresh).toBeTruthy(); + expect(typeof a.release).toBe('function'); + // 经 releaseLock 传入 refresh 释放 → 定时器被清理、锁文件删除 + const r = releaseLock(nodeFs, lock, { owner: a.owner, pid: process.pid, fd: a.fd, refresh: a.refresh }); + expect(r.ok).toBe(true); + expect(fs.existsSync(lock)).toBe(false); + // 释放后再次获取成功(无残留定时器干扰) + const b = acquireLock(nodeFs, lock, { waitMs: 200, refreshMs: 0 }); + expect(b.ok).toBe(true); + releaseLock(nodeFs, lock, { owner: b.owner, pid: process.pid, fd: b.fd, refresh: b.refresh }); + }); + + it('持锁方阻塞(真实 spawnSync)期间 token 仍被 Worker 心跳刷新(P1:不被陈旧接管)', () => { + const dir = tempDir(); + const lock = path.join(dir, '.lock'); + // 短 stale 窗口 + 短 refresh 周期,快速复现阻塞期陈旧 + const staleMs = 200; + const refreshMs = 30; + const a = acquireLock(nodeFs, lock, { waitMs: 500, staleMs, refreshMs, pollMs: 20 }); + expect(a.ok).toBe(true); + // 主线程阻塞 > staleMs(真实 spawnSync 冻结事件循环,setInterval 无法触发) + spawnSync(process.execPath, ['-e', 'const s=Date.now(); while(Date.now()-s<400){}'], { stdio: 'ignore' }); + const token = readToken(nodeFs, lock); + expect(token).not.toBeNull(); + // P1 断言:Worker 线程在阻塞期间持续刷新 token → 仍新鲜(旧 setInterval 实现会陈旧) + expect(isStale(token, Date.now(), staleMs)).toBe(false); + releaseLock(nodeFs, lock, { owner: a.owner, pid: process.pid, fd: a.fd, refresh: a.refresh }); + // 释放后 Worker 已终止、锁文件删除 + expect(fs.existsSync(lock)).toBe(false); + }); + + it('P1b:Worker 经持有 fd 写,释放后重建的锁文件不被旧 Worker 覆盖(跨线程覆盖竞态回归)', () => { + const dir = tempDir(); + const lock = path.join(dir, '.lock'); + // A 持锁,Worker 心跳每 20ms 刷新,注入 pid=11111 + const a = acquireLock(nodeFs, lock, { waitMs: 500, refreshMs: 20, pid: 11111 }); + expect(a.ok).toBe(true); + // 模拟竞态窗口:B 在 A 释放后立即接管(关 A 的 fd、删旧锁、写 B token)。 + // 故意【不】先 stop A 的 Worker——制造"旧 Worker 仍在运行"的最坏窗口。 + // 旧实现(每 tick fs.openSync(lockPath,'r+') 按路径重开)会打开 B 的新锁文件 + // 并用 11111 覆盖其 token;新实现(经持有 fd 写旧 inode)不影响 B 的锁文件。 + nodeFs.closeSync(a.fd); + nodeFs.unlinkSync(lock); + nodeFs.writeFileSync(lock, formatToken(22222, Date.now())); + // 阻塞 > 数个 refreshMs 周期(真实 spawnSync 冻结主线程,让 Worker 有机会跑多个 tick) + spawnSync(process.execPath, ['-e', 'const s=Date.now(); while(Date.now()-s<200){}'], { stdio: 'ignore' }); + const token = readToken(nodeFs, lock); + expect(token).not.toBeNull(); + expect(token.pid).toBe(22222); // B 的 token 未被旧 Worker 覆盖(P1b 修复) + // 清理:停止 A 的 Worker(其 fd 已关,写走 EBADF 分支,不影响 stop 语义) + if (a.refresh && typeof a.refresh.stop === 'function') a.refresh.stop(); + if (fs.existsSync(lock)) fs.unlinkSync(lock); + }); + it('持锁期间二次获取失败(互斥)', () => { const dir = tempDir(); const lock = path.join(dir, '.lock'); diff --git a/packages/shared-core/test/net.test.js b/packages/shared-core/test/net.test.js index 0605476..e001a56 100644 --- a/packages/shared-core/test/net.test.js +++ b/packages/shared-core/test/net.test.js @@ -79,16 +79,25 @@ describe('httpsGetText(本地 TLS 服务)', () => { hasOpenssl = true; } catch { hasOpenssl = false; } if (!hasOpenssl) return; - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shared-tls-')); - cert = path.join(dir, 'cert.pem'); - key = path.join(dir, 'key.pem'); - // CN=127.0.0.1 + SAN IP(Node 对 IP 校验 SAN;CN 回退已废弃) try { - execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-keyout', key, '-out', cert, - '-days', '1', '-nodes', '-subj', '/CN=127.0.0.1', '-addext', 'subjectAltName=IP:127.0.0.1'], { stdio: 'ignore' }); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shared-tls-')); + cert = path.join(dir, 'cert.pem'); + key = path.join(dir, 'key.pem'); + // 审计修复:`openssl req` 依赖系统 openssl.cnf(Windows 默认指向 + // C:\Program Files\Common Files\ssl\openssl.cnf,Anaconda 等环境该文件缺失会 + // 令证书生成失败且未被兜底 catch 捕获 → 整组崩溃)。现内嵌最小 openssl.cnf 并用 + // -config 显式指定(不依赖系统配置),含 SAN IP(Node 对 IP 校验 SAN;CN 回退已废弃)。 + const cnf = path.join(dir, 'openssl.cnf'); + fs.writeFileSync(cnf, [ + '[req]', 'distinguished_name = dn', 'x509_extensions = v3', 'prompt = no', '', + '[dn]', 'CN = 127.0.0.1', '', + '[v3]', 'subjectAltName = IP:127.0.0.1', '' + ].join('\n')); + execFileSync('openssl', ['req', '-x509', '-config', cnf, '-newkey', 'rsa:2048', + '-keyout', key, '-out', cert, '-days', '1', '-nodes'], { stdio: 'ignore' }); } catch { - execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-keyout', key, '-out', cert, - '-days', '1', '-nodes', '-subj', '/CN=127.0.0.1'], { stdio: 'ignore' }); + // 证书生成失败(openssl 缺失/损坏/无可用配置)→ 整组优雅跳过,不再崩溃套件 + hasOpenssl = false; } }); diff --git a/release/DSH-Hotplug-Hub-Setup.exe b/release/DSH-Hotplug-Hub-Setup.exe index 32599ca..652f842 100644 Binary files a/release/DSH-Hotplug-Hub-Setup.exe and b/release/DSH-Hotplug-Hub-Setup.exe differ diff --git a/release/embedded/dseam-skillmcp-0.8.1-pre.tgz b/release/embedded/dseam-skillmcp-0.8.1-pre.tgz index d9eba74..4ec7276 100644 Binary files a/release/embedded/dseam-skillmcp-0.8.1-pre.tgz and b/release/embedded/dseam-skillmcp-0.8.1-pre.tgz differ diff --git a/release/src/PatchContract.cs b/release/src/PatchContract.cs index 23ac13f..1359f4a 100644 --- a/release/src/PatchContract.cs +++ b/release/src/PatchContract.cs @@ -184,12 +184,17 @@ public static string ReadTokenText(string lockPath) } } - /// 释放补丁锁(校验 token pid == 自己)。 + /// 释放补丁锁(校验 token pid == 自己;仅当拥有该锁时才删除锁文件)。 + /// m7(安全审计):此前把 File.Delete 放在 finally 里,导致"拒绝释放他人锁" + /// 的 return 分支仍会执行删除——进程 A 的陈旧锁被 B 接管后,A 若再调用本方法 + /// 会误删 B 的有效锁,破坏跨进程互斥。现改为:token pid 不匹配时仅关闭自己 + /// 的 fd,绝不删除锁文件(与 JS releaseLock 的"不匹配即拒绝 unlink"一致)。 public static void ReleasePatchLock(FileStream handle, string profileDir) { + string lockPath = PatchLockPath(profileDir); + bool refuse = false; try { - string lockPath = PatchLockPath(profileDir); if (File.Exists(lockPath)) { string token = ReadTokenText(lockPath); @@ -198,15 +203,15 @@ public static void ReleasePatchLock(FileStream handle, string profileDir) { int pid; if (int.TryParse(parts[0].Trim(), out pid) && pid != Process.GetCurrentProcess().Id) - return; // token pid 不匹配:拒绝释放他人锁 + refuse = true; // token pid 不匹配:拒绝释放他人锁 } } } catch { /* 读取失败按可释放处理 */ } - finally + try { if (handle != null) handle.Close(); } catch { } + if (!refuse) { - try { if (handle != null) handle.Close(); } catch { } - try { File.Delete(PatchLockPath(profileDir)); } catch { } + try { File.Delete(lockPath); } catch { } } } diff --git a/release/tests/PatchContractTests.cs b/release/tests/PatchContractTests.cs index 7703189..ca3e64f 100644 --- a/release/tests/PatchContractTests.cs +++ b/release/tests/PatchContractTests.cs @@ -161,6 +161,18 @@ public static int Run() Check(heldBlocked, "v1 目录锁(存活持有者)不迁移,等待至超时"); Directory.Delete(v1Dir, true); + // m7(安全审计):ReleasePatchLock 不得删除他人锁(token pid != 自己) + // 此前 finally 无条件 File.Delete,跨进程互斥可被误删打破 + Console.WriteLine("-- ⑥ ReleasePatchLock 拒绝释放他人锁(m7) --"); + string foreignDir = TmpDir("pc-release-foreign-"); + Directory.CreateDirectory(foreignDir); + File.WriteAllText(Path.Combine(foreignDir, ".dsh-patch.lock"), + (Process.GetCurrentProcess().Id + 1) + "\n" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + "\n"); + PatchContract.ReleasePatchLock(null, foreignDir); + Check(File.Exists(Path.Combine(foreignDir, ".dsh-patch.lock")), + "ReleasePatchLock 拒绝释放他人锁(token pid != 自己 → 锁文件保留)"); + Directory.Delete(foreignDir, true); + // m6(安全审计):AssertShellSafeArg 字符集与 JS assertShellSafe 对齐 Console.WriteLine("-- ⑤ 参数安全字符集(m6:≡ JS assertShellSafe) --"); Check(SafeArg("my-tag_1", "tag"), "接受 my-tag_1"); diff --git a/vendor/dseam-skillmcp/lib/patch-editor.js b/vendor/dseam-skillmcp/lib/patch-editor.js index e5db560..744d8c4 100644 --- a/vendor/dseam-skillmcp/lib/patch-editor.js +++ b/vendor/dseam-skillmcp/lib/patch-editor.js @@ -8,11 +8,11 @@ * `/.dsh-patch.lock`(与 launcher/hotplug/C# 同一把锁); * - 标记之外的内容逐字节保留,永不整文件覆盖。 */ -import { readFile, rename, rm, writeFile } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import { existsSync, openSync, writeFileSync, readFileSync, renameSync, unlinkSync, mkdirSync, statSync, lstatSync, readdirSync, closeSync, fsyncSync, copyFileSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; import { parseDocument, stringify } from "yaml"; -import { findPatchBlock, patchMarker, acquireLock, releaseLock } from "../vendor-shared/index.mjs"; +import { findPatchBlock, patchMarker, acquireLock, releaseLock, writeFileAtomic as sharedWriteFileAtomic } from "../vendor-shared/index.mjs"; export const PANEL_MCP_BLOCK_BEGIN = "# >>> dseam-skillmcp:mcp:begin"; export const PANEL_MCP_BLOCK_END = "# <<< dseam-skillmcp:mcp:end"; /** 契约单行 marker(CONTRACT.md §4;owner=dseam-skillmcp, id=mcp)。 */ @@ -190,24 +190,16 @@ const nodeFsPort = { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, lstatSync, openSync, closeSync, fsyncSync, renameSync, unlinkSync, rmSync, readdirSync, copyFileSync, }; -/** 同目录临时文件 + rename 原子写;Windows 上 rename 覆盖失败时退化为 rm+rename。 */ +/** + * 原子写(审计修复:收敛到 vendor-shared 单一真源 writeFileAtomic)。 + * 此前本地重实现一份:用 Math.random() 生成可预测临时名、无 O_EXCL 独占创建 + * (缺 A2 防符号链接预置劫持)、且与共享实现分叉维护。现以共享实现为唯一实现, + * 仅保留本模块的 async 签名(node:fs 直连端口 nodeFsPort 由下方提供)。 + */ export async function writeFileAtomic(path, content) { - const temp = join(dirname(path), ".dseam-skillmcp-tmp-" + process.pid + "-" + Math.random().toString(36).slice(2, 8)); - try { - await writeFile(temp, content, "utf8"); - try { - await rename(temp, path); - } - catch (error) { - if (error === null || typeof error !== "object" || !["EPERM", "EEXIST", "EACCES"].includes(error.code ?? "")) - throw error; - await rm(path, { force: true }); - await rename(temp, path); - } - } - finally { - await rm(temp, { force: true }).catch(() => { }); - } + const w = sharedWriteFileAtomic(nodeFsPort, path, content, { errorCode: "ERR_INSTALL_FAILED" }); + if (!w.ok) + throw w.error; } /** * 以共享文件锁 `/.dsh-patch.lock` 执行 fn(CONTRACT.md §5 四写者协议; @@ -222,7 +214,9 @@ export async function withPatchLock(path, fn) { return await fn(); } finally { - releaseLock(nodeFsPort, lockPath, { pid: process.pid, fd: a.fd }); + // 审计修复:必须传入 refresh 句柄——锁心跳(Worker 线程)否则在释放后泄漏, + // 持续对已删除/已关闭的锁文件写 token(与 launcher pipeline/index 同款修复)。 + releaseLock(nodeFsPort, lockPath, { pid: process.pid, fd: a.fd, refresh: a.refresh }); } } /** diff --git a/vendor/dseam-skillmcp/vendor-shared/CONTRACT.md b/vendor/dseam-skillmcp/vendor-shared/CONTRACT.md index 71200f6..8c412dc 100644 --- a/vendor/dseam-skillmcp/vendor-shared/CONTRACT.md +++ b/vendor/dseam-skillmcp/vendor-shared/CONTRACT.md @@ -17,6 +17,7 @@ | `PACK_ID_RE` | `^[a-z0-9][a-z0-9._-]{0,63}$`(大小写不敏感) | 包 id / CLI id:字母数字开头,允许 `. _ -`,1..64 字符 | | `PLUGIN_NAME_RE` | `^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$` | npm 包名(可 scoped) | | `EXACT_VERSION_RE` | `^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$` | 精确版本号(**须再经 semver 双检**:`semver.valid(v) !== null`,拒绝 `1.02.3` 等) | +| `REPO_RE` | `^[0-9A-Za-z][0-9A-Za-z._-]*\/[0-9A-Za-z][0-9A-Za-z._-]*$` | GitHub 仓库 `owner/repo`(两段、字母数字开头;拒绝前导 `. -` 与 `..` 段,防 URL/git clone 路径穿越) | | `RESERVED_WIN_NAMES` | `CON PRN AUX NUL COM1..COM9 LPT1..LPT9` | Windows 保留设备名(任何单段名均拒绝,含首段匹配) | | 长度预算 | id ≤ 64;patch id ≤ 64;source.path ≤ 4096;source.repo ≤ 512;source.ref ≤ 256 | | @@ -25,6 +26,7 @@ - 尾随 `.` 或空格拒绝; - 插件名每个 `/` 段均过 Windows 安全名检查; - `source.path` 必须绝对、拒绝 UNC、拒绝 `.`/`..` 段; +- `source.repo` 必须匹配 `REPO_RE`(`owner/repo` 两段、字母数字开头;拒绝空白/元字符/`..` 段); - `source.ref` 拒绝 `..`、纯点、控制字符、超长(阶段 2 起允许合法 `/`)。 ## 2. 错误码与结果契约 diff --git a/vendor/dseam-skillmcp/vendor-shared/contracts/constants.js b/vendor/dseam-skillmcp/vendor-shared/contracts/constants.js index 619d076..da5c89d 100644 --- a/vendor/dseam-skillmcp/vendor-shared/contracts/constants.js +++ b/vendor/dseam-skillmcp/vendor-shared/contracts/constants.js @@ -7,6 +7,9 @@ const os = require('os'); const PACK_ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i; const PLUGIN_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +// GitHub 仓库 owner/repo 格式:两段、各段字母数字开头(拒绝前导 . - 与 .. 段, +// 防止 repo 进入 URL/git clone 时产生路径穿越或畸形 URL)。 +const REPO_RE = /^[0-9A-Za-z][0-9A-Za-z._-]*\/[0-9A-Za-z][0-9A-Za-z._-]*$/; // Windows 保留设备名(N43:CON/NUL/COM1… 全部拒绝) const RESERVED_WIN_NAMES = new Set([ @@ -45,9 +48,10 @@ const LOCK_REFRESH_MS = 10000; const LAUNCH_ALIVE_CHECK_MS = 500; const LAUNCH_WAIT_TIMEOUT_MS = 120000; -// 崩溃循环判定(3 次 / 30s 窗口) +// 崩溃循环判定:连续 CRASH_LOOP_THRESHOLD 次非零退出(retries 连续失败计数, +// 成功即清零;与 classifyStateSignals / healplan 触发文案一致)。历史曾声明 +// "30s 窗口"语义(CRASH_LOOP_WINDOW_MS),但从未被实现或消费——已删除该死常量。 const CRASH_LOOP_THRESHOLD = 3; -const CRASH_LOOP_WINDOW_MS = 30000; // 自愈默认重试预算 const DEFAULT_RETRY_BUDGET = 3; @@ -133,6 +137,7 @@ module.exports = { PACK_ID_RE, PLUGIN_NAME_RE, EXACT_VERSION_RE, + REPO_RE, RESERVED_WIN_NAMES, GITHUB_MIRRORS, SCHEMA_VERSION, @@ -148,7 +153,6 @@ module.exports = { LAUNCH_ALIVE_CHECK_MS, LAUNCH_WAIT_TIMEOUT_MS, CRASH_LOOP_THRESHOLD, - CRASH_LOOP_WINDOW_MS, DEFAULT_RETRY_BUDGET, STATE_FILE, RUN_LOG_FILE, diff --git a/vendor/dseam-skillmcp/vendor-shared/fs/lock-heartbeat.js b/vendor/dseam-skillmcp/vendor-shared/fs/lock-heartbeat.js new file mode 100644 index 0000000..b290b99 --- /dev/null +++ b/vendor/dseam-skillmcp/vendor-shared/fs/lock-heartbeat.js @@ -0,0 +1,43 @@ +'use strict'; +// fs/lock-heartbeat.js — 锁 token 心跳(Worker 线程,独立事件循环) +// +// 背景(P1 修复):主线程持锁期间若执行阻塞式 spawnSync(npm/git 安装 >30s), +// 事件循环冻结,setInterval 无法触发 → token 时间戳不再刷新 → 第二写者按 +// "存活 pid + token 超龄"判定陈旧并接管锁,破坏并发写互斥。 +// 本 Worker 运行在独立线程,其事件循环与主线程互不阻塞,故即使主线程卡在 +// spawnSync,token 仍按 refreshMs 周期刷新,锁的陈旧判定契约(CONTRACT.md §5) +// 得以维持。 +// +// 审计修复(P1b,跨线程覆盖竞态):此前每 tick 用 fs.openSync(lockPath, 'r+') +// 按【路径】重开锁文件写 token——release 后主线程 unlink 旧锁、另一进程在同路径 +// 重建新锁,若本 Worker 尚未被 terminate(terminate 异步、存在窗口),下一次 tick +// 会打开【新持有者的锁文件】并用旧 pid 覆盖其 token,击穿跨进程互斥(已实证复现)。 +// +// 根因修复:Worker 不按路径重开,改经【主线程传入的持有 fd】写——该 fd 由 +// acquireLock 用 'wx' 独占创建、绑定获取锁时创建的固定 inode;release 后主线程 +// unlink + 重建同名文件,本 fd 仍指向旧的(已 unlink)inode,写入不影响新持有者。 +// +// 同步停止握手:主线程 releaseLock 先 Atomics.store(ctrl[0]=1) 请求停止并 +// Atomics.notify 唤醒本 Worker,再 Atomics.wait(ctrl[1]==1) 等待确认;本 Worker +// 仅在 while 条件(ctrl[0]==0)为真时写,收到停止请求后【绝不再写】,随后置 +// ctrl[1]=1 确认并退出。主线程在收到确认前不关闭 fd → 不存在"写已关闭/被复用 fd" +// 的窗口(fd 复用竞态一并消除)。 +const { workerData } = require('worker_threads'); +const fs = require('fs'); + +const { fd, pid, refreshMs, ctrl } = workerData; +const c = new Int32Array(ctrl); // c[0]=停止请求(主线程写);c[1]=已停止确认(本 Worker 写) + +while (Atomics.load(c, 0) === 0) { + try { + fs.ftruncateSync(fd, 0); + fs.writeSync(fd, `${pid}\n${Date.now()}\n`, 0, 'utf8'); + } catch (_) { /* 刷新失败:下次重试;陈旧接管兜底 */ } + // 阻塞至 refreshMs 或主线程请求停止(Atomics.notify 立即唤醒) + Atomics.wait(c, 0, 0, refreshMs); +} + +// 确认停止并退出(主线程据此决定关闭 fd 的时机) +Atomics.store(c, 1, 1); +Atomics.notify(c, 1); +process.exit(0); diff --git a/vendor/dseam-skillmcp/vendor-shared/fs/lock.js b/vendor/dseam-skillmcp/vendor-shared/fs/lock.js index a30c961..cb508fc 100644 --- a/vendor/dseam-skillmcp/vendor-shared/fs/lock.js +++ b/vendor/dseam-skillmcp/vendor-shared/fs/lock.js @@ -24,7 +24,7 @@ // | EEXIST + pid 死 | 立即接管 | // | EEXIST + EACCES/EPERM(他用户) | 等待至超时,不接管 | // | token 缺失/损坏 | 按文件 mtime 判陈旧 | -const { dirname } = require('path'); +const { dirname, join } = require('path'); const { LOCK_WAIT_MS, LOCK_STALE_MS, @@ -160,6 +160,60 @@ function checkV1DirectoryLock(fsPort, lockPath, opts) { return { held: false }; } +/** + * 启动锁 token 心跳。 + * P1 修复:优先用 Worker 线程(独立事件循环)——主线程执行阻塞式 spawnSync 时 + * setInterval 无法触发,token 会陈旧被第二写者接管;Worker 线程不受主线程阻塞影响。 + * Worker 不可用(环境限制)时回退主线程 setInterval(尽力而为,阻塞期间仍可能陈旧)。 + * + * P1b 修复(跨线程覆盖竞态):Worker 经【主线程传入的持有 fd】写 token,绝不按路径 + * 重开——按路径重开会在 release→reacquire 窗口打开新持有者的锁文件并用旧 pid 覆盖其 + * token。fd 绑定获取锁时创建的 inode,release 后重建同名文件也不受影响。 + * 同步停止握手:返回 heartbeat 句柄含 stop(),其置停止请求 + 等 Worker 确认后再让 + * 调用方关 fd(见 releaseLock)。 + * @param {object} fsPort fs 端口(回退路径用) + * @param {number} pid 持有者 pid + * @param {number} refreshMs 刷新周期(0=不刷新,返回 null) + * @param {number} fd 主线程已打开的锁 fd(Worker 经此写;回退路径重写 token 用) + * @returns {{stop: Function}|object|null} heartbeat 句柄或 interval 句柄 + */ +function startHeartbeat(fsPort, pid, refreshMs, fd) { + if (!(refreshMs > 0)) return null; + try { + const { Worker } = require('worker_threads'); + const ctrl = new SharedArrayBuffer(8); // 2×Int32:c[0]=停止请求,c[1]=已停止确认 + const worker = new Worker(join(__dirname, 'lock-heartbeat.js'), { + workerData: { fd, pid, refreshMs, ctrl } + }); + if (typeof worker.unref === 'function') worker.unref(); + const c = new Int32Array(ctrl); + return { + stop() { + // 请求停止并等待 Worker 确认:确认后 Worker 绝不再写,调用方可安全关 fd。 + Atomics.store(c, 0, 1); + Atomics.notify(c, 0); + const deadline = Date.now() + 1000; + while (Atomics.load(c, 1) === 0 && Date.now() < deadline) { + // Worker 收到 notify 后应立即置 c[1]=1;此处短暂自旋兜底(Worker 崩溃/未启动) + Atomics.wait(c, 1, 0, 50); + } + try { worker.terminate(); } catch (_) { /* 忽略 */ } + } + }; + } catch (_) { + // Worker 不可用(worker_threads 缺失/创建失败)→ 回退主线程 setInterval + if (typeof setInterval !== 'function') return null; + const timer = setInterval(() => { + try { + fsPort.ftruncateSync(fd, 0); + fsPort.writeSync(fd, formatToken(pid, Date.now()), 0, 'utf8'); + } catch (_) { /* 刷新失败:陈旧接管兜底 */ } + }, refreshMs); + if (typeof timer.unref === 'function') timer.unref(); + return { stop() { clearInterval(timer); } }; + } +} + /** * 获取文件锁。 * @param {object} fsPort fs 端口 @@ -210,19 +264,15 @@ function acquireLock(fsPort, lockPath, opts = {}) { try { if (fsPort.existsSync(lockPath)) fsPort.unlinkSync(lockPath); } catch (_) { /* 忽略 */ } return { ok: false, error: makeError('ERR_LOCK_ACQUIRE', `写入锁 token 失败 ${lockPath}:${writeErr.message}`) }; } - // 持锁期刷新:经已打开 fd 重写 token(防止长任务被误判陈旧) - let refresh = null; - if (refreshMs > 0 && typeof setInterval === 'function') { - refresh = setInterval(() => { - try { - fsPort.ftruncateSync(fd, 0); - fsPort.writeSync(fd, formatToken(pid, Date.now()), 0, 'utf8'); - } catch (_) { /* 刷新失败:下次仍会尝试;陈旧接管兜底 */ } - }, refreshMs); - if (typeof refresh.unref === 'function') refresh.unref(); - } + // 持锁期刷新:Worker 线程心跳(独立事件循环)——主线程阻塞 spawnSync 时 + // setInterval 无法触发,token 会陈旧被第二写者接管(P1);Worker 不受影响。 + const refresh = startHeartbeat(fsPort, pid, refreshMs, fd); const release = () => releaseLock(fsPort, lockPath, { owner, pid, fd, refresh }); - return { ok: true, fd, owner, token, release }; + // 审计修复:返回 refresh 句柄——此前调用方(pipeline/index 等)直接 releaseLock + // {owner,pid,fd} 而不传 refresh,导致持锁期 setInterval 定时器在释放后泄漏、 + // 每 10s 对已关闭 fd 写 token(EBADF 被吞)。返回后调用方须在 releaseLock 时 + // 一并传入 refresh 以清理定时器。 + return { ok: true, fd, owner, token, release, refresh }; } catch (e) { if (fd !== null) { try { fsPort.closeSync(fd); } catch (_) { /* 忽略 */ } @@ -297,7 +347,15 @@ function releaseLock(fsPort, lockPath, opts = {}) { const owner = opts.owner || `pid-${pid}`; try { if (opts.refresh) { - try { clearInterval(opts.refresh); } catch (_) { /* 忽略 */ } + // P1b 修复:refresh 统一为 heartbeat/interval 句柄(含 stop())。 + // stop() 先停 Worker 并等其确认(heartbeat)或 clearInterval(interval), + // 确保返回后不再有 token 写,调用方随后关 fd / unlink 才安全。 + // 兼容旧形态(裸 Worker/interval)以防调用方仍传旧值。 + try { + if (typeof opts.refresh.stop === 'function') opts.refresh.stop(); + else if (typeof opts.refresh.terminate === 'function') opts.refresh.terminate(); + else clearInterval(opts.refresh); + } catch (_) { /* 忽略 */ } } const token = readToken(fsPort, lockPath); if (token && token.pid !== pid) { diff --git a/vendor/dseam-skillmcp/vendor-shared/ids.js b/vendor/dseam-skillmcp/vendor-shared/ids.js index 2aed13d..423f6e1 100644 --- a/vendor/dseam-skillmcp/vendor-shared/ids.js +++ b/vendor/dseam-skillmcp/vendor-shared/ids.js @@ -10,6 +10,7 @@ const { PACK_ID_RE, PLUGIN_NAME_RE, EXACT_VERSION_RE, + REPO_RE, RESERVED_WIN_NAMES, MAX_ID_LENGTH, MAX_SOURCE_PATH_LENGTH @@ -214,6 +215,12 @@ function validateSourceRepo(repo) { if (CONTROL_CHAR_RE.test(repo)) { return { ok: false, error: makeError('ERR_ASSEMBLY_FIELD', 'source.repo 不得包含控制字符') }; } + // 审计修复:repo 必须是 owner/repo 格式(两段、字母数字开头、拒绝 .. 段/空白/元字符)。 + // 此前仅查长度+控制字符,`../../etc/passwd`、含空格或 `?query` 的串会进入 + // codeload/git clone URL 拼装,产生畸形 URL 或路径穿越风险。 + if (!REPO_RE.test(repo)) { + return { ok: false, error: makeError('ERR_ASSEMBLY_FIELD', `source.repo 必须是 owner/repo 格式:${JSON.stringify(repo)}`) }; + } return { ok: true }; } diff --git a/vendor/dseam-skillmcp/vendor-shared/index.mjs b/vendor/dseam-skillmcp/vendor-shared/index.mjs index b2f0128..6a3a2d3 100644 --- a/vendor/dseam-skillmcp/vendor-shared/index.mjs +++ b/vendor/dseam-skillmcp/vendor-shared/index.mjs @@ -25,10 +25,10 @@ export const { // contracts/constants export const { - PACK_ID_RE, PLUGIN_NAME_RE, EXACT_VERSION_RE, RESERVED_WIN_NAMES, GITHUB_MIRRORS, + PACK_ID_RE, PLUGIN_NAME_RE, EXACT_VERSION_RE, REPO_RE, RESERVED_WIN_NAMES, GITHUB_MIRRORS, SCHEMA_VERSION, HOTPACK_VERSION, MAX_ID_LENGTH, MAX_PATCH_ID_LENGTH, MAX_SOURCE_PATH_LENGTH, RUNLOG_MAX_BYTES, LOCK_WAIT_MS, LOCK_STALE_MS, LOCK_POLL_MS, LOCK_REFRESH_MS, - LAUNCH_ALIVE_CHECK_MS, LAUNCH_WAIT_TIMEOUT_MS, CRASH_LOOP_THRESHOLD, CRASH_LOOP_WINDOW_MS, + LAUNCH_ALIVE_CHECK_MS, LAUNCH_WAIT_TIMEOUT_MS, CRASH_LOOP_THRESHOLD, DEFAULT_RETRY_BUDGET, STATE_FILE, RUN_LOG_FILE, PATCH_FILE, PROFILE_MANIFEST, SNAPSHOT_INLINE_MAX_BYTES, PROFILES_DIR, STORE_DIR, MEMORY_DIR, HOTPLUG_DIR, PATCH_LOCK_FILE, resolveDshRoot, dshRootPaths, defaultRoots,