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`,并遵循其中的同步/检查/上报流程。
-
+
@@ -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