@yangdcm/dsh-expert-team 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,71 @@
3
3
  本包遵循[语义化版本](https://semver.org/lang/zh-CN/)。dsh 宿主版本线的对应关系写在
4
4
  `package.json` 的 `engines.dsh` 与 `dsh.compatibility` 里,插件市场按它判断"这个插件跟你的宿主兼不兼容"。
5
5
 
6
+ ## 1.2.1
7
+
8
+ **卸载回收覆盖历史副本**(1.2.0 的补丁)
9
+
10
+ - `/team uninstall` 此前只遍历登记清单,而 **1.2.0 之前铺下的 skill/preset 没有登记过**
11
+ (那时还没有清单)—— 从 1.1.x 升上来的用户跑它只会得到"没有需要回收的副本",
12
+ 而 `$DSH_HOME/skills/expert-team` 里的旧副本仍在(`gate:sync` 会一直报漂移)。
13
+ 现在除清单外还会看两个众所周知的自举落点,判据仍是"有戳或身份对得上",
14
+ 用户自己写的同名内容照样不动。
15
+ ## 1.2.0
16
+
17
+ **安全加固(本机来源守卫)+ 自举安装收口 + 设置接进宿主命名空间**
18
+
19
+ ### 设置接进宿主命名空间
20
+
21
+ - 插件加载时把设置注册为宿主命名空间 `expert-team`(`ctx.inject(['settings'], …)` 作为优雅降级边界):
22
+ - 出现在 **设置 → 插件 → 插件配置**,由宿主按我们给的 schema 渲染 —— schema 由
23
+ `lib/settings.js` 的 `SETTINGS_SPEC` **生成**(字段/范围/默认值都是单一真源,不抄第二份);
24
+ - 随插件市场的**备份与恢复**一起走(市场只备份"profile 插件清单 + 设置",自造 JSON 不在内);
25
+ - 改动**即时生效**:`scope.watch()` → 用同一份 `config` 重算容量上限/轮次上限/档位门,不必重启。
26
+ - `base` 用**当前生效设置**填充 ⇒ 面板一打开看到的就是真正在用的值,**不需要任何一次性迁移**;
27
+ 优先级不变:`schema 默认 < 我们传的 base < 官方面板用户层`。
28
+ - **诚实边界**:`roster.defaultRoles`(角色 id 数组或 null)**不上**官方面板 —— 它在 schemastery 里的
29
+ 表达方式跨版本没把握,一旦 schema 建错宿主会拒绝注册、面板里什么都看不到。该字段继续由浮层设置
30
+ 页签与 `$DSH_HOME/expert-team/settings.json` 负责;写入时**每个字段只进一个存储**
31
+ (可表达的进宿主、表达不了的进文件),不制造"一个字段两个家"。
32
+ - 宿主没有 settings 服务、或 `@deepseek-ai/schemastery` 解析不到(例如在仓库里跑测试)⇒ 完全回退到
33
+ `settings.json`,行为与 1.1.x 一致;失败原因会被记下并出声,不静默。
34
+ - `POST /settings` 响应新增 `settingsSource: 'host' | 'file'`,便于排查"这次改到底写哪儿了"。
35
+
36
+ ### 自举安装收口
37
+
38
+ - **skill 改为运行时注册**(`ctx.skills.register`):默认**一个文件都不再写到 `$DSH_HOME/skills`**,
39
+ 相对资源用 `resourceBase` 指回包内目录。宿主没有 skill 注册表时才回退到复制(行为与 1.1.x 相同)。
40
+ 宿主优先级是 项目条目 > 运行时条目 > 用户根,因此运行时注册还能压过历史遗留的旧副本。
41
+ - **preset 仍走复制**(宿主 `dsh-agent-presets` 没有"运行时加扫描根"的 API,roots 只来自配置),
42
+ 但现在:① 带**版本戳** `.expert-team-version`,升级后**整目录重铺**(旧实现"存在即 return",
43
+ 于是插件升级后运行时永远停在旧副本,且没有任何报错);② 登记到
44
+ `$DSH_HOME/expert-team/installed.json`;③ 只在目标**确属本插件产物**时才覆盖
45
+ (用户自己写的同名 preset 不认、不动)。
46
+ - 新增 **`/team uninstall`**:回收本插件铺到 `$DSH_HOME` 的副本(skill/preset 与清单本身),
47
+ **只删带我们戳或身份可判定为本插件产物的目录**,用户内容一律保留并如实列出;幂等。
48
+ 它不碰工作区里的 `team/` 运行目录,也不碰插件包本身(那由插件市场卸载)。
49
+ - `scripts/check-sync.mjs`:skill 那一份副本改为**可选目标** —— 1.2.0 起它默认不存在,
50
+ 再按硬性目标比对就会在正常安装上误报漂移。
51
+
52
+ ### 安全加固
53
+
54
+ **安全加固:浮层路由的本机来源守卫 + `/file` 走宿主策略**
55
+
56
+ - 11 条浮层路由(`/state` `/artifact` `/codeidx` `/file` `/decide` `/plan` `/plan/approve`
57
+ `/plan/discard` `/task` `/metrics/refresh` `/settings`)此前只校验 HTTP 方法,现在在**唯一的注册入口**
58
+ `registerLocal` 上统一套 `localOnly` 守卫:
59
+ - `Host` 非回环 ⇒ 403 —— 挡 **DNS rebinding**(攻击页把自己的域名解析到 127.0.0.1 后即为同源)
60
+ - **写方法**的 `Origin` 非回环 ⇒ 403 —— 挡跨站写入;GET 刻意放行,因为浏览器不会把无 CORS 头的
61
+ 响应体交给跨站页面,而放行它能保住 `/team canvas --watch` 生成的 `file://` 页面(`Origin: null`)
62
+ - `socket.remoteAddress` 非回环 ⇒ 403 —— 挡局域网(宿主配 `host: 0.0.0.0` 时)
63
+ - **写方法** `content-type` 非 `application/json` ⇒ 415 —— 挡 form / text-plain 这类不触发预检的简单请求
64
+ - 三条规则都**只在相应头部存在时生效**:浏览器必然带 Host、POST 必然带 Origin,而本地非浏览器
65
+ 客户端本就能自造头部(插件没有鉴权模型,这一点写在 README 的已知限制里)
66
+ - `/file`:有宿主 `ctx.fs` 时改走宿主策略(`resolve` + `readText`),**策略拒绝时如实报 403,
67
+ 不再退回裸 `readFile`**;响应新增 `source: 'host-fs' | 'node-fs'` 便于排查
68
+ - 新增棘轮:`routes-shared.test.mjs` 断言 `lib/command.js` 里真正调用宿主 `register` 的地方只有
69
+ `registerLocal` 内部那一处、11 条路由全部声明 `methods` —— 新加路由不可能忘记加固
70
+
6
71
  ## 1.1.1
7
72
 
8
73
  **兼容性声明 + 可移植性修复**(不含功能变更)
package/README.en.md CHANGED
@@ -74,8 +74,23 @@ cd ~/.dsh/profiles/web
74
74
  pnpm install && dsh web
75
75
  ```
76
76
 
77
- > The first `/team` run idempotently bootstraps the `expert-team` skill and the
78
- > 「专家团模式」 preset into `$DSH_HOME/skills/` and `$DSH_HOME/.agent-presets/`.
77
+ > **The skill is never copied to disk**: when the plugin loads it registers the `expert-team` skill as a
78
+ > **runtime entry** in the host skill registry (relative resources resolve through `resourceBase` back into
79
+ > the package), so nothing appears under `$DSH_HOME/skills/` — uninstalling stays clean. Only when the host
80
+ > has no skill registry does it fall back to copying.
81
+ >
82
+ > **The 「专家团模式」 preset is still copied** into `$DSH_HOME/.agent-presets/` (the host offers no
83
+ > runtime API to add a preset scan root), but it carries a version stamp and is re-materialised in full on
84
+ > upgrade instead of silently going stale. `/team uninstall` reclaims the copies this plugin laid down —
85
+ > it only removes directories carrying our stamp, and never touches content you authored yourself.
86
+ >
87
+ > **Where settings live**: on load the plugin registers its settings as the host namespace `expert-team`,
88
+ > so they appear under **Settings → Plugins → Plugin configuration** and take effect immediately (limits,
89
+ > round caps and the tier gate are recomputed in-process — no restart), and they travel with the plugin
90
+ > market's **backup and restore**. **Honest boundary**: the default roster (an array of role ids) is *not*
91
+ > on the official page — its value type cannot be expressed reliably in the host schema, so it stays with
92
+ > the overlay's settings tab and `$DSH_HOME/expert-team/settings.json`. On a host with no settings service
93
+ > every setting falls back to that file.
79
94
 
80
95
  ## Quick start
81
96
 
@@ -128,12 +143,18 @@ npm run check:name # verify no placeholder package name is left behind
128
143
 
129
144
  ## Known limitations
130
145
 
131
- - **The local HTTP routes do not yet validate Origin/Host**: the 11 overlay routes (including
132
- `/file`, which can only read workspace files) currently check the HTTP method only. Keep `dsh web`
133
- bound to `127.0.0.1`; same-origin hardening is planned.
146
+ - **The local routes are guarded, but that is not authentication**: all 11 overlay routes now check
147
+ `Host` (blocks DNS rebinding), the `Origin` of write requests (blocks cross-site writes), and the
148
+ client address (blocks non-loopback clients); write requests must also send `application/json`
149
+ (blocks form / text-plain "simple requests" that never trigger a preflight). `/file` now goes through
150
+ the host `ctx.fs` policy and reports 403 instead of falling back to a raw read when the policy denies it.
151
+ **Residual risk, stated plainly**: a local non-browser process can forge any header, and dsh plugins
152
+ have no authentication model — so do not expose `dsh web` to an untrusted network (with
153
+ `host: 0.0.0.0` the guard only stops clients without a loopback address).
134
154
  - **Web profile only**: the overlay and routes need `webServer`. The command and artifacts still work without it.
135
155
  - **Preset drift**: the bundled 「专家团模式」 is a copy of the official `standard` preset plus the role
136
- tools; upstream preset restructuring needs a matching update here.
156
+ tools; upstream preset restructuring needs a matching update here. Upgrades re-materialise it by version
157
+ stamp, and `/team uninstall` reclaims it (a same-named preset you authored yourself is left alone).
137
158
  - Calls `git status --porcelain` (read-only) to judge artifact freshness.
138
159
 
139
160
  ## License
package/README.md CHANGED
@@ -71,8 +71,19 @@ cd ~/.dsh/profiles/web
71
71
  pnpm install && dsh web
72
72
  ```
73
73
 
74
- > 首次 `/team` 会幂等地把 `expert-team` skill 与「专家团模式」preset 自举到
75
- > `$DSH_HOME/skills/` `$DSH_HOME/.agent-presets/`。
74
+ > **skill 不落地**:插件加载时就把 `expert-team` skill 作为**运行时条目**注册进宿主的 skill 注册表
75
+ > (相对资源用 `resourceBase` 指回包内目录),所以 `$DSH_HOME/skills/` 下不会出现副本 ——
76
+ > 卸载即干净。宿主没有 skill 注册表时才会回退为复制到 `$DSH_HOME/skills/`。
77
+ >
78
+ > **「专家团模式」preset 仍会复制**到 `$DSH_HOME/.agent-presets/`(宿主没有"运行时加扫描根"的
79
+ > API),但带版本戳:升级后整目录重铺,不会静默停在旧版本。用 `/team uninstall` 可回收本插件
80
+ > 铺下的副本(只删带我们版本戳的目录,用户自己写的同名内容一律保留)。
81
+ >
82
+ > **设置在哪改**:插件加载时把设置注册成宿主命名空间 `expert-team` ⇒ 出现在
83
+ > **设置 → 插件 → 插件配置**,改动**即时生效**(上限/轮次/档位门在进程内重算,不必重启),
84
+ > 并且随插件市场的**备份与恢复**一起走。**诚实边界**:`默认班底`(角色 id 数组)不在官方页面里
85
+ > —— 它的取值类型在宿主 schema 里表达不可靠,继续由浮层设置页签与
86
+ > `$DSH_HOME/expert-team/settings.json` 负责;宿主没有 settings 服务时,全部设置退回该文件。
76
87
 
77
88
  ## 快速上手
78
89
 
@@ -81,6 +92,7 @@ pnpm install && dsh web
81
92
  /team --persist 重构订单模块 # 持久化活团队:成员可反复指挥、跨会话恢复
82
93
  /team --no-code 评审现有 API 设计 # 只产出计划/评审/测试工件,不改代码
83
94
  /team --confirm 大改版需求 # 先建 run、不自动派工,浮层点「执行」才开工
95
+ /team uninstall # 回收本插件铺到 $DSH_HOME 的副本(skill 默认走运行时注册,本就不落地)
84
96
  /team status # 所有 run 的阶段、成员、模型计划、实时违规
85
97
  /team resume <run-id> # 跨会话恢复
86
98
  ```
@@ -123,11 +135,16 @@ dsh 安装里插件自带的 Config schema(dsh 路径自动探测,可用 `DS
123
135
 
124
136
  ## 已知限制
125
137
 
126
- - **本地 HTTP 路由尚未做同源/Host 校验**:浮层用的 11 条路由(含只能读工作区文件的 `/file`)
127
- 目前只校验 HTTP 方法。请把 dsh web 保持监听 `127.0.0.1`;同源加固在计划中。
138
+ - **本机来源守卫已就位,但不是鉴权**:11 条浮层路由现在统一校验 Host(挡 DNS rebinding)、
139
+ 写方法的 Origin(挡跨站写入)、客户端地址(挡局域网),写方法还要求 `application/json`
140
+ (挡 form / text-plain 这类不触发预检的"简单请求");`/file` 改走宿主 `ctx.fs` 策略,
141
+ 读被策略拒绝时如实报 403 而**不退回裸读**。
142
+ **残余风险如实说明**:本地非浏览器进程本来就能自造任意请求头,而 dsh 插件没有鉴权模型 ——
143
+ 所以别把 `dsh web` 暴露到不可信网络(宿主配置 `host: 0.0.0.0` 时,守卫只挡住"没有回环地址"的客户端)。
128
144
  - **仅 web profile**:浮层与路由依赖 `webServer`;无浮层时命令与工件仍然可用。
129
145
  - **preset 漂移**:随包的「专家团模式」是官方 `standard` preset 的拷贝 + 角色工具,
130
- 宿主若调整内置 preset 结构,需要同步更新。
146
+ 宿主若调整内置 preset 结构,需要同步更新。升级插件时会按版本戳整目录重铺;
147
+ `/team uninstall` 可回收它(用户自己写的同名 preset 不会被碰)。
131
148
  - 会调用 `git status --porcelain`(只读)用于工件新鲜度判断。
132
149
 
133
150
  ## License
@@ -57,6 +57,7 @@ export const USAGE = [
57
57
  ' /team settle [<run>] 清算冷启动后「没人做」的在飞任务(→ pending + attempt+1 + 留痕)',
58
58
  ' /team wait [<run>] 查看在飞任务进展(不阻塞;无进展超 5 分钟会提示)',
59
59
  ' /team clear [<run>] 清理一个或全部 run',
60
+ ' /team uninstall 回收本插件铺到 $DSH_HOME 的副本(skill 现默认走运行时注册,本就不落地)',
60
61
  ].join('\n');
61
62
 
62
63
  // ── command parsing ────────────────────────────────────────────────────────
@@ -117,6 +118,8 @@ export function parseTeamCommand(rawInput) {
117
118
  }
118
119
  if (lower === 'clear') return { kind: 'clear' };
119
120
  if (lower.startsWith('clear ')) return { kind: 'clear', run: input.slice(6).trim() };
121
+ // 卸载收口:把本插件铺到 $DSH_HOME 的副本(历史 skill/preset)收回来
122
+ if (lower === 'uninstall' || lower === '卸载') return { kind: 'uninstall' };
120
123
  // L3-2′ 可清算:显式清算冷启动后的搁浅任务(回 pending + attempt+1 + 留痕)
121
124
  if (lower === 'settle') return { kind: 'settle', run: null };
122
125
  if (lower.startsWith('settle ')) return { kind: 'settle', run: input.slice(7).trim() };
package/lib/command.js CHANGED
@@ -27,7 +27,7 @@ import { createWriteTracer, formatConflict } from './write-tracer.js';
27
27
  import { createLoopGuard } from './loop-guard.js';
28
28
  // C 线第 17 项:派工即回写(派工成功后由**代码**把任务置 in_progress + owner,不靠模型记得改)。
29
29
  import { createDispatchLedger } from './dispatch-ledger.js';
30
- import { readRequestBody, withRoute } from './routes/shared.js';
30
+ import { readRequestBody, withRoute, localOnly } from './routes/shared.js';
31
31
  import { renderMetrics } from './metrics/render.js';
32
32
  // E 线 E1/E2:首产物耗时(`first-runnable` − `run:started`)与收尾预算(实现期 vs 冻结后收尾)。
33
33
  // 时钟算术**只此一份** —— 两处各写一遍等于故意埋一次「一个事实多份拷贝」(本仓头号返工源)。
@@ -71,6 +71,9 @@ import {
71
71
  import { USAGE, resolveProfile, parseTeamCommand } from './command-parse.js';
72
72
  // F 线:设置控制台的 spec / 校验 / 合并(纯函数模块,UI 表单结构也由它生成)。
73
73
  import { SETTINGS_GROUPS, SETTINGS_SPEC, defaultSettings, normalizeSettings, mergeSettings, settingsSchema, compilePolicy, policyBlockFrom } from './settings.js';
74
+ // 宿主设置命名空间接线(1.2.0):设置进官方面板 + 随市场备份恢复 + 改动即时生效。
75
+ // 模块内部用**动态 import** 取 schemastery,因此本仓在没有 profile 的环境里照样能跑测试。
76
+ import { installHostSettings, hostValues, hostScope, hostSettingsNote, updateHostSettings, pickFileOnly, pickHostExpressible, HOST_SETTINGS_NAMESPACE, buildHostSchema, hostBase, hostSchemaPaths } from './host-settings.js';
74
77
 
75
78
  /**
76
79
  * 工件写入的受控入口(批 2-1)。
@@ -87,6 +90,13 @@ import { SETTINGS_GROUPS, SETTINGS_SPEC, defaultSettings, normalizeSettings, mer
87
90
  let WRITER_IDENTITY = null;
88
91
  const writerEvents = (e) => { try { pushActivityEvent(e); } catch { /* 观测不该影响写入 */ } };
89
92
  let ARTIFACT = createArtifactWriter({ fsPort: nodeFsPort(), onEvent: writerEvents, identity: () => WRITER_IDENTITY });
93
+ /**
94
+ * 已被接管的宿主 `ctx.fs`(若宿主提供)。**只在读路径(`/file`)里用**。
95
+ *
96
+ * 为什么单独留一个引用:工件写入从批 2-1 起就走宿主策略了,但浮层的文件预览一直在用裸
97
+ * `readFile` —— 同一台机器上两套读规则,等于绕开宿主的 fs 策略(观察策略/沙箱)。
98
+ */
99
+ let ADOPTED_HOST_FS = null;
90
100
 
91
101
  /** 让宿主 `ctx.fs` 接管写入(若可用);返回是否接管成功。 */
92
102
  function adoptHostFs(ctx) {
@@ -94,6 +104,7 @@ function adoptHostFs(ctx) {
94
104
  const hostFs = ctx && typeof ctx.get === 'function' ? ctx.get('fs') : null;
95
105
  if (hostFs && typeof hostFs.writeText === 'function' && typeof hostFs.resolve === 'function') {
96
106
  ARTIFACT = createArtifactWriter({ fsPort: cordisFsPort(hostFs), onEvent: writerEvents, identity: () => WRITER_IDENTITY });
107
+ ADOPTED_HOST_FS = hostFs;
97
108
  return true;
98
109
  }
99
110
  } catch { /* 宿主 fs 不可用则保持回退实现 */ }
@@ -441,6 +452,12 @@ const THIS_DIR = dirname(fileURLToPath(import.meta.url));
441
452
  // Single source of truth: the skill + its assets ship inside the bundle and are
442
453
  // copied verbatim into the user skill root on demand.
443
454
  const SKILL_SRC = new URL('../skills/expert-team/', import.meta.url);
455
+ /**
456
+ * 包内 skill 目录的**规范化绝对路径**(去掉 URL→路径留下的尾部分隔符)。
457
+ * 尾斜杠看着无害,但它会被原样塞进 `resourceBase.path` 传给宿主,也可能让按字符串比较路径的
458
+ * 消费方(以及我们自己的测试)判不等 —— 归一化一次,比在每个使用点各修一遍可靠。
459
+ */
460
+ const SKILL_DIR = fileURLToPath(SKILL_SRC).replace(/[\\/]+$/, '');
444
461
  const TEMPLATES_SRC = new URL('../skills/expert-team/assets/templates/', import.meta.url);
445
462
  // The `expert-team` agent preset (role subagent tool instances) ships alongside
446
463
  // the skill and is copied into the user preset root so the roster can discover it.
@@ -499,10 +516,28 @@ function loadSettingsSync() {
499
516
  return SETTINGS_CACHE;
500
517
  }
501
518
 
502
- /** 取当前设置(读失败也返回默认,不抛)。 */
519
+ /**
520
+ * 取当前设置(读失败也返回默认,不抛)。
521
+ *
522
+ * **"设置"这一档的唯一读取口**:宿主命名空间可用时,以它的解析值为准
523
+ * (构成为 `schema 默认 < 我们传入的 base < 官方面板用户层`),再用文件补上宿主表达不了的字段
524
+ * (如 `roster.defaultRoles` —— 见 host-settings.js 里为什么它不上官方面板)。
525
+ * 宿主不可用时,与 1.1.x 完全一致:直接读 `settings.json`。
526
+ */
503
527
  function currentSettings() {
504
- return (SETTINGS_CACHE || loadSettingsSync()).settings;
528
+ const file = (SETTINGS_CACHE || loadSettingsSync()).settings;
529
+ const host = hostValues();
530
+ if (!host) return file;
531
+ const merged = mergeSettings(file, host);
532
+ if (merged.ok) return merged.value;
533
+ // 宿主给了我们认不出的键(schema 漂移)⇒ 如实出声一次,然后按文件值走,绝不静默改语义
534
+ if (!HOST_SETTINGS_WARNED) {
535
+ HOST_SETTINGS_WARNED = true;
536
+ console.warn('[expert-team] 宿主设置里有本插件认不出的键,已退回 settings.json:' + merged.errors.slice(0, 3).join(';'));
537
+ }
538
+ return file;
505
539
  }
540
+ let HOST_SETTINGS_WARNED = false;
506
541
 
507
542
  /** 设置 → 容量上限的**基础值**(优先级最低的一档:config > env > 设置 > 内置默认)。 */
508
543
  function limitsBaseFromSettings(s = currentSettings()) {
@@ -514,6 +549,28 @@ function roundLimitsBaseFromSettings(s = currentSettings()) {
514
549
  return { maxReviewRounds: s.gates.maxReviewRounds, maxTestRounds: s.gates.maxTestRounds };
515
550
  }
516
551
 
552
+ /** 插件加载时传给 `apply(ctx, config)` 的 config(重算时要沿用,不能丢覆写)。 */
553
+ let PLUGIN_CONFIG = {};
554
+
555
+ /**
556
+ * 用**当前设置**重算三处派生值:容量上限、轮次上限、档位门。
557
+ *
558
+ * 为什么需要它:这三处在 1.1.x 里只在 `apply()` 时算一次,于是"设置改了要重启才生效"。
559
+ * 现在官方面板/浮层改设置都会走到这里 ⇒ 不重启即生效。用 `PLUGIN_CONFIG` 而不是空对象,
560
+ * 是为了让 `config` 覆写(优先级最高的一档)在重算后依然压得住设置值。
561
+ */
562
+ function reapplySettingsDerived() {
563
+ try {
564
+ const s = currentSettings();
565
+ resolveLimits(PLUGIN_CONFIG, limitsBaseFromSettings(s));
566
+ resolveRoundLimits(PLUGIN_CONFIG, roundLimitsBaseFromSettings(s));
567
+ resolveTierGate(PLUGIN_CONFIG, s.gates);
568
+ return true;
569
+ } catch (e) {
570
+ console.warn('[expert-team] 重算设置派生值失败(保持上一次生效值):' + String(e && e.message ? e.message : e));
571
+ return false;
572
+ }
573
+ }
517
574
 
518
575
  function teamRoot(cwd) {
519
576
  return join(cwd, 'team');
@@ -666,36 +723,213 @@ async function copyDir(src, dest) {
666
723
  }
667
724
  }
668
725
 
726
+ // ── 自举安装的收口(2026-09-14)─────────────────────────────────────────────
727
+ //
728
+ // 背景:skill 与「专家团模式」preset 一直靠"首次 /team 时复制到 $DSH_HOME"就位。两个真问题:
729
+ // ① **静默过期**:旧实现是"目标存在就直接 return",于是插件升级后运行时永远停在旧副本
730
+ // (改了 skill 却在真实会话里不生效,且没有任何报错 —— 本仓最隐蔽的一类假完成);
731
+ // ② **卸载残留**:插件市场卸载只删 node_modules,$DSH_HOME 下那份副本会留下:
732
+ // 一个孤儿 skill,以及一个"选中就报错"的 preset。
733
+ // 现在:skill 改为**运行时注册**(一个文件都不写,见 registerRuntimeSkill);preset 因为宿主
734
+ // 没有"运行时加扫描根"的 API(`dsh-agent-presets` 的 roots 只来自配置),仍走复制,但加
735
+ // **版本戳**(升级即整目录重铺)并**登记到清单**,由 `/team uninstall` 精确回收 ——
736
+ // 且只回收带我们戳(或身份可判定为本插件产物)的目录,绝不删用户自己写的同名内容。
737
+
738
+ /** 本插件版本,用作副本的版本戳(从包内 package.json 读,避免 ESM import JSON 的兼容问题)。 */
739
+ const PLUGIN_VERSION = (() => {
740
+ try {
741
+ return String(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '0.0.0');
742
+ } catch { return '0.0.0'; }
743
+ })();
744
+ /** 版本戳文件名:目录里出现它,就说明这个目录是**本插件铺的**。 */
745
+ const INSTALL_STAMP = '.expert-team-version';
746
+ /** 已铺副本的登记表(供 `/team uninstall` 精确回收)。 */
747
+ function installedManifestPath() { return join(dshHome(), 'expert-team', 'installed.json'); }
748
+
749
+ /** 解析 SKILL.md 的 YAML frontmatter(只支持本文件实际使用的单行标量)。 */
750
+ function parseSkillMarkdown(text) {
751
+ const src = String(text ?? '');
752
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(src);
753
+ if (!m) return { meta: {}, body: src };
754
+ const meta = {};
755
+ for (const line of m[1].split(/\r?\n/)) {
756
+ const kv = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
757
+ if (!kv) continue;
758
+ let v = kv[2].trim();
759
+ if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) v = v.slice(1, -1);
760
+ if (v) meta[kv[1]] = v;
761
+ }
762
+ return { meta, body: src.slice(m[0].length) };
763
+ }
764
+
765
+ /** 组装"运行时 skill"注册对象;读不到就返回 null(调用方回退到复制)。 */
766
+ function buildSkillRegistration() {
767
+ const { meta, body } = parseSkillMarkdown(readFileSync(new URL('SKILL.md', SKILL_SRC), 'utf8'));
768
+ if (!meta.name || !body.trim()) return null;
769
+ const reg = {
770
+ name: meta.name,
771
+ description: meta.description || '',
772
+ content: body,
773
+ source: 'runtime',
774
+ resourceBase: { kind: 'directory', path: SKILL_DIR },
775
+ metadata: { plugin: '@yangdcm/dsh-expert-team', version: PLUGIN_VERSION },
776
+ };
777
+ if (meta.whenToUse) reg.whenToUse = meta.whenToUse;
778
+ return reg;
779
+ }
780
+
781
+ /** 运行时 skill 是否注册成功(成功 ⇒ 不再往 $DSH_HOME 写任何 skill 文件)。 */
782
+ let RUNTIME_SKILL_REGISTERED = false;
783
+
784
+ /**
785
+ * 把 skill 作为**运行时条目**注册进宿主的 skill 注册表。
786
+ * 宿主优先级为 项目条目 > 运行时条目 > 用户根 ⇒ 运行时注册还能压过 `$DSH_HOME/skills` 里的历史副本。
787
+ */
788
+ function registerRuntimeSkill(ctx) {
789
+ try {
790
+ const skills = ctx && typeof ctx.get === 'function' ? ctx.get('skills') : null;
791
+ if (!skills || typeof skills.register !== 'function') return false;
792
+ const reg = buildSkillRegistration();
793
+ if (!reg) return false;
794
+ const dispose = skills.register(reg);
795
+ RUNTIME_SKILL_REGISTERED = true;
796
+ if (typeof ctx.effect === 'function') ctx.effect(() => dispose);
797
+ console.log('[expert-team] skill 已作为运行时条目注册(不再写入 $DSH_HOME/skills)');
798
+ return true;
799
+ } catch (e) {
800
+ console.warn('[expert-team] 运行时注册 skill 失败,回退到自举复制:' + String(e && e.message ? e.message : e));
801
+ return false;
802
+ }
803
+ }
804
+
805
+ async function pathExists(p) { try { await stat(p); return true; } catch { return false; } }
806
+ async function readTextSafe(p) { try { return await readFile(p, 'utf8'); } catch { return null; } }
807
+
808
+ /** 记下"这个目录是本插件铺的"(卸载据此精确回收)。 */
809
+ async function recordInstalled(kind, dir) {
810
+ const p = installedManifestPath();
811
+ try {
812
+ let cur = {};
813
+ try { cur = JSON.parse(await readFile(p, 'utf8')) || {}; } catch { cur = {}; }
814
+ cur[kind] = { path: dir, version: PLUGIN_VERSION, at: new Date().toISOString() };
815
+ await mkdir(dirname(p), { recursive: true });
816
+ await ARTIFACT.must(p, JSON.stringify(cur, null, 2) + '\n');
817
+ } catch (e) {
818
+ // 登记失败不该让安装本身失败,但**绝不能静默** —— 清单缺失会让 `/team uninstall` 回收不到
819
+ // 这次铺的副本(那正是"卸载残留"这个坑本身)。如实告警并给出路径。
820
+ console.warn(`[expert-team] 未能登记已铺副本(${kind}: ${dir})→ /team uninstall 可能回收不到它:${String(e && e.message ? e.message : e)}`);
821
+ }
822
+ }
823
+ async function readInstalled() {
824
+ try { return JSON.parse(await readFile(installedManifestPath(), 'utf8')) || {}; } catch { return {}; }
825
+ }
826
+
827
+ /**
828
+ * 这个目录是否可以安全地当成"本插件的副本"来覆盖/删除。
829
+ * 判据两条,任一成立即可:① 带我们的版本戳;② 身份可判定为本插件产物
830
+ * (1.2.0 之前铺的副本没有戳 —— 不认它们,升级时就永远刷不掉旧 skill)。
831
+ */
832
+ async function isOwnedCopy(dir, kind) {
833
+ if ((await readTextSafe(join(dir, INSTALL_STAMP))) !== null) return true;
834
+ if (kind === 'skill') {
835
+ const md = await readTextSafe(join(dir, 'SKILL.md'));
836
+ if (md === null) return false;
837
+ return parseSkillMarkdown(md).meta.name === 'expert-team';
838
+ }
839
+ const yml = await readTextSafe(join(dir, 'agent.cordis.yml'));
840
+ return yml !== null && /tool-subagent-pm/.test(yml);
841
+ }
842
+
843
+ /**
844
+ * `/team uninstall`:回收本插件铺到 `$DSH_HOME` 的副本(skill / preset)。
845
+ * 安全规则:**只回收"本插件的副本"** —— 用户自己写的同名 preset/skill 不带我们的戳、
846
+ * 身份也对不上,绝不会被删。工作区里的 `team/` 运行目录与插件包本身都不动。
847
+ */
848
+ async function uninstallInstalled() {
849
+ const rec = await readInstalled();
850
+ const removed = [];
851
+ const skipped = [];
852
+ const seen = new Set();
853
+ /**
854
+ * 回收一个候选目录(幂等;只删"本插件的副本")。
855
+ * 为什么要 `seen`:清单里的路径与下面两个**历史默认路径**可能重合,重复 rm 会把
856
+ * "已回收"报两遍,也会让第二次那条 `pathExists` 失败被误记成"保留"。
857
+ */
858
+ const reclaim = async (kind, dir) => {
859
+ if (!dir || seen.has(dir)) return;
860
+ seen.add(dir);
861
+ if (!(await pathExists(dir))) return;
862
+ if (await isOwnedCopy(dir, kind)) {
863
+ try { await rm(dir, { recursive: true, force: true }); removed.push(`${kind}:${dir}`); }
864
+ catch (e) { skipped.push(`${kind}:${dir}(删除失败:${String(e && e.message ? e.message : e)})`); }
865
+ } else {
866
+ skipped.push(`${kind}:${dir}(不是本插件的副本 ⇒ 保留)`);
867
+ }
868
+ };
869
+
870
+ for (const [kind, info] of Object.entries(rec)) await reclaim(kind, info && info.path);
871
+
872
+ // 1.2.0 之前的副本**没有登记过**(那时还没有清单)—— 而它们恰恰是这个命令最该处理的历史遗留。
873
+ // 所以除了清单,还要看两个众所周知的自举落点;判据仍是 `isOwnedCopy`(无戳时按身份判定),
874
+ // 用户自己写的同名 skill/preset 依旧不会被删。
875
+ await reclaim('skill', join(dshHome(), 'skills', 'expert-team'));
876
+ await reclaim('preset', join(dshHome(), '.agent-presets', 'expert-team'));
877
+
878
+ try { await rm(installedManifestPath(), { force: true }); } catch { /* 清单删不掉就留着 */ }
879
+ const lines = ['# /team uninstall', ''];
880
+ lines.push(removed.length ? '已回收:' : '没有需要回收的副本。');
881
+ for (const r of removed) lines.push('- ' + r);
882
+ if (skipped.length) { lines.push('', '保留(未删除):'); for (const s of skipped) lines.push('- ' + s); }
883
+ lines.push(
884
+ '',
885
+ '说明:skill 现在默认走**运行时注册**,本来就不写盘;这里回收的是历史版本留下的副本,',
886
+ '或宿主没有 skill 注册表时的回退副本。',
887
+ '本命令不碰工作区里的 `team/` 运行目录,也不碰插件包本身(那由插件市场卸载)。',
888
+ );
889
+ return { kind: 'success', text: lines.join('\n') };
890
+ }
891
+
669
892
  async function ensureSkillInstalled() {
893
+ // 运行时注册成功 ⇒ 一个文件都不写(默认路径;也是"卸载即干净"的前提)
894
+ if (RUNTIME_SKILL_REGISTERED) return SKILL_DIR;
670
895
  const target = join(dshHome(), 'skills', 'expert-team');
671
- try {
672
- await readFile(join(target, 'SKILL.md'));
673
- return target; // already present
674
- } catch {
675
- /* missing → copy from bundle */
896
+ if (await pathExists(target)) {
897
+ if (!(await isOwnedCopy(target, 'skill'))) {
898
+ console.warn('[expert-team] $DSH_HOME/skills/expert-team 已存在且不像本插件的产物,未覆盖');
899
+ return target;
900
+ }
901
+ const fresh = (await readTextSafe(join(target, INSTALL_STAMP)))?.trim() === PLUGIN_VERSION
902
+ && ((await readTextSafe(join(target, 'SKILL.md'))) || '').trim().length > 0;
903
+ if (fresh) return target;
904
+ // 旧版本副本:整目录重铺(逐个覆盖会留下新版本已删除的文件)
905
+ try { await rm(target, { recursive: true, force: true }); } catch { /* 删不掉就试着覆盖 */ }
676
906
  }
677
907
  try {
678
- await copyDir(fileURLToPath(SKILL_SRC), target);
908
+ await copyDir(SKILL_DIR, target);
909
+ await ARTIFACT.must(join(target, INSTALL_STAMP), PLUGIN_VERSION + '\n');
910
+ await recordInstalled('skill', target);
679
911
  return target;
680
- } catch (err) {
681
- return null; // best-effort; the agent can still run from the bundle path if present
682
- }
912
+ } catch { return null; }
683
913
  }
684
914
 
685
915
  async function ensurePresetInstalled() {
686
916
  const target = join(dshHome(), '.agent-presets', 'expert-team');
687
- try {
688
- await readFile(join(target, 'agent.cordis.yml'));
689
- return target; // already present
690
- } catch {
691
- /* missing → copy from bundle */
917
+ if (await pathExists(target)) {
918
+ if (!(await isOwnedCopy(target, 'preset'))) {
919
+ console.warn('[expert-team] $DSH_HOME/.agent-presets/expert-team 已存在且不是本插件所铺,未覆盖(用户的定制优先)');
920
+ return target;
921
+ }
922
+ const fresh = (await readTextSafe(join(target, INSTALL_STAMP)))?.trim() === PLUGIN_VERSION
923
+ && ((await readTextSafe(join(target, 'agent.cordis.yml'))) || '').trim().length > 0;
924
+ if (fresh) return target;
925
+ try { await rm(target, { recursive: true, force: true }); } catch { /* 删不掉就试着覆盖 */ }
692
926
  }
693
927
  try {
694
928
  await copyDir(fileURLToPath(PRESET_SRC), target);
929
+ await ARTIFACT.must(join(target, INSTALL_STAMP), PLUGIN_VERSION + '\n');
930
+ await recordInstalled('preset', target);
695
931
  return target;
696
- } catch (err) {
697
- return null; // best-effort
698
- }
932
+ } catch { return null; }
699
933
  }
700
934
 
701
935
  // ── scaffold ───────────────────────────────────────────────────────────────
@@ -4163,6 +4397,7 @@ async function executeTeamCommand(ctx, invocation) {
4163
4397
  case 'resume': return resumeRun(ctx, invocation, cwd, c.run);
4164
4398
  case 'rules': return rulesRun(cwd, c.action, c.rule);
4165
4399
  case 'clear': return clearRuns(cwd, c.run);
4400
+ case 'uninstall': return uninstallInstalled();
4166
4401
  case 'settle': return settleRuns(cwd, c.run, ctx);
4167
4402
  case 'wait': return waitRun(cwd, c.run);
4168
4403
  case 'create': return createRun(ctx, invocation, cwd, c);
@@ -4174,9 +4409,12 @@ async function executeTeamCommand(ctx, invocation) {
4174
4409
  /** 并发写追踪器:**进程级单例**(跨工具调用累积;`_live` 暴露以便单测重置与断言)。 */
4175
4410
  const WRITE_TRACER = createWriteTracer();
4176
4411
 
4177
- export const _live = { pushActivity, phaseAccountingViolations, loggedPhases, authorityViolations, WRITE_TRACER, createWriteTracer, formatConflict, summarizeTool, parseLogLine, roleOfSub, mapRoleToSub, membersFromState, buildRoleSubMap, resolveSubRoles, childSessionTiming, SUB_HEADER_CACHE, workflowEventIndex, workflowChildLabels, workflowChildMeta, workflowRuns, WF_EVENT_CACHE, rememberSessionRun, sessionRunFor, runOwnerSession, SESSION_RUNS, parseTeamCommand, deriveMemberEntries, schemaViolations, runHealth, RUN_STALL_MS, scaffoldFingerprint, SCAFFOLD_REQUIRED, strandedTasks, settleStranded, IN_FLIGHT_STATUSES, normalizeCoverage, SCHEMA_WARN_SEEN, pushActivityEvent, DEFAULT_LIMITS, LIMITS, resolveLimits, capacityViolations, DEFAULT_ROUND_LIMITS, ROUND_LIMITS, ROUND_LIMIT_ENV, resolveRoundLimits, ROUND_LIMIT_OF_KIND, roundOf, isQualityTask, normTitle, roundLimitViolations, reworkLoopWriteGuard, mutateTasks, readStandingRules, appendStandingRule, rulesRun, scopeOverlapWarnings, applyTaskStatus, waitRun, eventFamily, verdictFromToken, normalizeRoleName, truncateCodepoints, filterRunScopedSubs, runCreatedAtMs, runLogTail, liveFiles, LIVE_FILES_CACHE, DEFAULT_ROLES, resolveTierGate, TIER_GATE_ENV, snapshotRun, settingsPath, loadSettingsSync, currentSettings, limitsBaseFromSettings, roundLimitsBaseFromSettings, effectiveTierGate: () => TIER_GATE };
4412
+ export const _live = { pushActivity, phaseAccountingViolations, loggedPhases, authorityViolations, WRITE_TRACER, createWriteTracer, formatConflict, summarizeTool, parseLogLine, roleOfSub, mapRoleToSub, membersFromState, buildRoleSubMap, resolveSubRoles, childSessionTiming, SUB_HEADER_CACHE, workflowEventIndex, workflowChildLabels, workflowChildMeta, workflowRuns, WF_EVENT_CACHE, rememberSessionRun, sessionRunFor, runOwnerSession, SESSION_RUNS, parseTeamCommand, deriveMemberEntries, schemaViolations, runHealth, RUN_STALL_MS, scaffoldFingerprint, SCAFFOLD_REQUIRED, strandedTasks, settleStranded, IN_FLIGHT_STATUSES, normalizeCoverage, SCHEMA_WARN_SEEN, pushActivityEvent, DEFAULT_LIMITS, LIMITS, resolveLimits, capacityViolations, DEFAULT_ROUND_LIMITS, ROUND_LIMITS, ROUND_LIMIT_ENV, resolveRoundLimits, ROUND_LIMIT_OF_KIND, roundOf, isQualityTask, normTitle, roundLimitViolations, reworkLoopWriteGuard, mutateTasks, readStandingRules, appendStandingRule, rulesRun, scopeOverlapWarnings, applyTaskStatus, waitRun, eventFamily, verdictFromToken, normalizeRoleName, truncateCodepoints, filterRunScopedSubs, runCreatedAtMs, runLogTail, liveFiles, LIVE_FILES_CACHE, DEFAULT_ROLES, resolveTierGate, TIER_GATE_ENV, snapshotRun, settingsPath, loadSettingsSync, currentSettings, limitsBaseFromSettings, roundLimitsBaseFromSettings, effectiveTierGate: () => TIER_GATE, ensureSkillInstalled, ensurePresetInstalled, uninstallInstalled, buildSkillRegistration, parseSkillMarkdown, PLUGIN_VERSION, INSTALL_STAMP, runtimeSkillRegistered: () => RUNTIME_SKILL_REGISTERED, installHostSettings, hostValues, hostScope, hostSettingsNote, updateHostSettings, pickFileOnly, pickHostExpressible, buildHostSchema, hostBase, hostSchemaPaths, reapplySettingsDerived, currentSettings, mergeSettings };
4178
4413
 
4179
4414
  export function apply(ctx, config) {
4415
+ // 留一份 config:设置在运行时改变(官方面板 / 浮层)时要**用同一份 config** 重算上限与档位门,
4416
+ // 否则 config 覆写会在重算时被悄悄丢掉("改了设置,用户配的 config 上限没了"这种伤最难查)。
4417
+ PLUGIN_CONFIG = config || {};
4180
4418
  // F 线:先读设置(同步、一次性)—— 它是**最低优先级**的来源(config > env > 设置 > 内置默认)。
4181
4419
  const settingsRepairs = loadSettingsSync().repaired;
4182
4420
  if (settingsRepairs.length) {
@@ -4193,6 +4431,15 @@ export function apply(ctx, config) {
4193
4431
  // 机会式探测而非 inject —— 见 ARTIFACT 声明处的说明(inject 缺失服务会让整条命令挂不上)。
4194
4432
  const hostFsAdopted = adoptHostFs(ctx);
4195
4433
  if (hostFsAdopted) console.log('[expert-team] 工件写入已接管宿主 ctx.fs 版本栅栏');
4434
+ // skill 走**运行时注册**(不写盘):这是默认路径,也是"卸载即干净"的前提。
4435
+ // 宿主没有 skill 注册表时返回 false,`/team` 会退回自举复制(行为与从前一致)。
4436
+ registerRuntimeSkill(ctx);
4437
+ // 设置注册到宿主命名空间:官方面板可改、随市场的备份/恢复走、改动即时生效(watch → 重算)。
4438
+ // `base` 用**当前生效设置**填充 ⇒ 面板一打开看到的就是真正在用的值,不需要任何一次性迁移。
4439
+ installHostSettings(ctx, {
4440
+ base: currentSettings(),
4441
+ onResolved: () => reapplySettingsDerived(),
4442
+ });
4196
4443
  void loadSessionRuns(); // session→run memory for overlay auto-select
4197
4444
  ctx.commands.register({
4198
4445
  name: 'team',
@@ -4378,9 +4625,24 @@ export function apply(ctx, config) {
4378
4625
  try {
4379
4626
  ctx.inject(['webServer'], (wctx) => {
4380
4627
  wctx.effect(() => {
4381
- const dispose = wctx.webServer.register({
4628
+ /**
4629
+ * 注册一条**只服务本机同源**的路由。
4630
+ *
4631
+ * 为什么要有这层包装:这 11 条路由以前各自决定要不要校验来源,实际结果是**一条都没校验**
4632
+ * (只校验了 HTTP 方法)。把守卫放在唯一的注册入口上,"新加一条路由"就不可能忘记加固 ——
4633
+ * routes-shared.test.mjs 里还有一条棘轮盯着这里不许再出现裸的 register。
4634
+ *
4635
+ * methods 走行内字段:它决定守卫对哪些方法做 Origin / content-type 校验,且**不会**被传给
4636
+ * 宿主(宿主只认 kind/path/handler,多传字段可能被 schema 拒)。
4637
+ */
4638
+ const registerLocal = (row = {}) => {
4639
+ const { methods = ['GET'], ...rest } = row;
4640
+ return wctx.webServer.register({ ...rest, handler: localOnly(rest.handler, { methods }) });
4641
+ };
4642
+ const dispose = registerLocal({
4382
4643
  kind: 'exact',
4383
4644
  path: '/plugins/dsh-expert-team/state',
4645
+ methods: ['GET'],
4384
4646
  handler: async (req, res) => {
4385
4647
  const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
4386
4648
  try {
@@ -4642,9 +4904,10 @@ export function apply(ctx, config) {
4642
4904
 
4643
4905
  // Artifact content route (client overlay right-side preview). Resolves
4644
4906
  // cwd from sessionId (or cwd param) + run; serves only known artifacts.
4645
- const disposeArtifact = wctx.webServer.register({
4907
+ const disposeArtifact = registerLocal({
4646
4908
  kind: 'exact',
4647
4909
  path: '/plugins/dsh-expert-team/artifact',
4910
+ methods: ['GET'],
4648
4911
  // 试点路由:改走 `lib/routes/shared.js` 的 `withRoute`(消掉本条自己的
4649
4912
  // json 助手 / 405 判断 / 500 兜底三份样板)。行为逐字保持:参数校验仍由本函数负责。
4650
4913
  handler: withRoute(async ({ res, json, query }) => {
@@ -4662,9 +4925,10 @@ export function apply(ctx, config) {
4662
4925
 
4663
4926
  // Code index search route (Qoder-style): instant keyword hits from
4664
4927
  // team/CODEINDEX.json. Builds lazily if the index is missing.
4665
- const disposeCodeidx = wctx.webServer.register({
4928
+ const disposeCodeidx = registerLocal({
4666
4929
  kind: 'exact',
4667
4930
  path: '/plugins/dsh-expert-team/codeidx',
4931
+ methods: ['GET'],
4668
4932
  handler: async (req, res) => {
4669
4933
  const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
4670
4934
  try {
@@ -4684,9 +4948,10 @@ export function apply(ctx, config) {
4684
4948
 
4685
4949
  // Read-only file preview route (A①: click a code-index hit → preview).
4686
4950
  // Path must be a RELATIVE workspace path (no `..`, no absolute).
4687
- const disposeFileView = wctx.webServer.register({
4951
+ const disposeFileView = registerLocal({
4688
4952
  kind: 'exact',
4689
4953
  path: '/plugins/dsh-expert-team/file',
4954
+ methods: ['GET'],
4690
4955
  handler: async (req, res) => {
4691
4956
  const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
4692
4957
  try {
@@ -4695,8 +4960,23 @@ export function apply(ctx, config) {
4695
4960
  const ws = (url.searchParams.get('workspace') ?? '').trim();
4696
4961
  const rel = (url.searchParams.get('path') ?? '').trim();
4697
4962
  if (!ws || !rel || rel.startsWith('/') || rel.includes('..') || !/^[\w@./-]+$/.test(rel)) { json(400, { ok: false, error: 'bad params' }); return; }
4698
- const text = await readFile(join(ws, rel), 'utf8');
4699
- json(200, { ok: true, path: rel, text: text.slice(0, 120000) });
4963
+ const abs = join(ws, rel);
4964
+ // 有宿主 fs 就走宿主策略;**读被拒时如实报 403,绝不退回裸 readFile** ——
4965
+ // 退回等于"策略拒绝 + 我们绕开它",比压根不做策略校验更糟。
4966
+ if (ADOPTED_HOST_FS) {
4967
+ try {
4968
+ const resolved = await ADOPTED_HOST_FS.resolve(abs);
4969
+ const text = await ADOPTED_HOST_FS.readText(resolved);
4970
+ json(200, { ok: true, path: rel, text: String(text).slice(0, 120000), source: 'host-fs' });
4971
+ } catch (e) {
4972
+ const msg = String(e && e.message ? e.message : e);
4973
+ const notFound = (e && e.code === 'ENOENT') || /ENOENT|not found/i.test(msg);
4974
+ json(notFound ? 404 : 403, { ok: false, error: notFound ? 'not found' : 'read denied by host fs policy: ' + msg });
4975
+ }
4976
+ return;
4977
+ }
4978
+ const text = await readFile(abs, 'utf8');
4979
+ json(200, { ok: true, path: rel, text: text.slice(0, 120000), source: 'node-fs' });
4700
4980
  } catch (e) {
4701
4981
  json(404, { ok: false, error: String(e && e.code === 'ENOENT' ? 'not found' : (e && e.message ? e.message : e)) });
4702
4982
  }
@@ -4707,9 +4987,10 @@ export function apply(ctx, config) {
4707
4987
  // Human-in-loop decision route: the user makes a choice in the overlay
4708
4988
  // (方案确认门 / 模糊选择 / quality-gate 升级). Records it into RUN.log +
4709
4989
  // DECISIONS.md and clears STATE.pendingDecision so the lead can proceed.
4710
- const disposeDecide = wctx.webServer.register({
4990
+ const disposeDecide = registerLocal({
4711
4991
  kind: 'exact',
4712
4992
  path: '/plugins/dsh-expert-team/decide',
4993
+ methods: ['POST'],
4713
4994
  handler: async (req, res) => {
4714
4995
  const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
4715
4996
  try {
@@ -4854,9 +5135,10 @@ export function apply(ctx, config) {
4854
5135
  const state = await readJsonSafe(join(dir, 'STATE.json'));
4855
5136
  return state ? { dir, state, cwd, run } : null;
4856
5137
  };
4857
- const disposePlan = wctx.webServer.register({
5138
+ const disposePlan = registerLocal({
4858
5139
  kind: 'exact',
4859
5140
  path: '/plugins/dsh-expert-team/plan',
5141
+ methods: ['POST'],
4860
5142
  handler: async (req, res) => {
4861
5143
  const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
4862
5144
  try {
@@ -4906,9 +5188,10 @@ export function apply(ctx, config) {
4906
5188
  });
4907
5189
  wctx.effect(() => disposePlan);
4908
5190
 
4909
- const disposePlanApprove = wctx.webServer.register({
5191
+ const disposePlanApprove = registerLocal({
4910
5192
  kind: 'exact',
4911
5193
  path: '/plugins/dsh-expert-team/plan/approve',
5194
+ methods: ['POST'],
4912
5195
  handler: async (req, res) => {
4913
5196
  const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
4914
5197
  try {
@@ -4969,9 +5252,10 @@ export function apply(ctx, config) {
4969
5252
  });
4970
5253
  wctx.effect(() => disposePlanApprove);
4971
5254
 
4972
- const disposePlanDiscard = wctx.webServer.register({
5255
+ const disposePlanDiscard = registerLocal({
4973
5256
  kind: 'exact',
4974
5257
  path: '/plugins/dsh-expert-team/plan/discard',
5258
+ methods: ['POST'],
4975
5259
  handler: async (req, res) => {
4976
5260
  const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
4977
5261
  try {
@@ -4998,9 +5282,10 @@ export function apply(ctx, config) {
4998
5282
 
4999
5283
  // Overlay task operation: change one task's status (writes TASKS.json
5000
5284
  // + RUN.log). Same trust level as decide.
5001
- const disposeTaskOp = wctx.webServer.register({
5285
+ const disposeTaskOp = registerLocal({
5002
5286
  kind: 'exact',
5003
5287
  path: '/plugins/dsh-expert-team/task',
5288
+ methods: ['POST'],
5004
5289
  handler: async (req, res) => {
5005
5290
  const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
5006
5291
  try {
@@ -5025,9 +5310,10 @@ export function apply(ctx, config) {
5025
5310
 
5026
5311
  // ── GET /state 不再聚合(2026-09-13):显式刷新入口。浮层要刷新指标时用 **POST**,
5027
5312
  // 不要用 GET —— 一个 GET 写盘既破坏幂等,又给 run 工件引入并发写面。
5028
- const disposeMetrics = wctx.webServer.register({
5313
+ const disposeMetrics = registerLocal({
5029
5314
  kind: 'exact',
5030
5315
  path: '/plugins/dsh-expert-team/metrics/refresh',
5316
+ methods: ['POST'],
5031
5317
  handler: async (req, res) => {
5032
5318
  const json = (code, body) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
5033
5319
  try {
@@ -5051,9 +5337,10 @@ export function apply(ctx, config) {
5051
5337
  // **诚实边界**:`roster` / `gates` 两类设置是在 `apply()` 时解析进内存的
5052
5338
  // (容量上限、轮次上限、档位门),所以改完**要重启 `dsh web` 才生效**;
5053
5339
  // `display` 类是浮层每次拉设置时读的,即时生效。响应里如实分开说,不假装"立刻全部生效"。
5054
- const disposeSettings = wctx.webServer.register({
5340
+ const disposeSettings = registerLocal({
5055
5341
  kind: 'exact',
5056
5342
  path: '/plugins/dsh-expert-team/settings',
5343
+ methods: ['GET', 'POST'],
5057
5344
  handler: withRoute(async ({ req, res, json, body }) => {
5058
5345
  const method = String((req && req.method) || 'GET').toUpperCase();
5059
5346
  if (method === 'GET') {
@@ -5068,24 +5355,37 @@ export function apply(ctx, config) {
5068
5355
  json(400, { ok: false, errors: merged.errors, settings: merged.value });
5069
5356
  return;
5070
5357
  }
5358
+ // 宿主命名空间可用 ⇒ 可表达的字段写**宿主**(官方面板与浮层共用同一份真源,
5359
+ // 且随市场备份/恢复);宿主表达不了的字段(如 roster.defaultRoles)继续写 settings.json。
5360
+ // 两边都只写自己拥有的字段 —— 同一个字段在两个存储里各留一份,是这个仓最忌讳的形态。
5361
+ const viaHost = await updateHostSettings(patch);
5362
+ const fileOnly = pickFileOnly(patch);
5363
+ const writeFile = !viaHost.ok || Object.keys(fileOnly).length > 0;
5071
5364
  try {
5072
- // `$DSH_HOME/expert-team/` 在全新环境里可能还不存在(受控写入口不会替你建目录)。
5073
- await mkdir(dirname(settingsPath()), { recursive: true });
5074
- await ARTIFACT.must(settingsPath(), JSON.stringify(merged.value, null, 2) + '\n');
5365
+ if (writeFile) {
5366
+ // `$DSH_HOME/expert-team/` 在全新环境里可能还不存在(受控写入口不会替你建目录)。
5367
+ await mkdir(dirname(settingsPath()), { recursive: true });
5368
+ const forFile = viaHost.ok ? pickFileOnly(merged.value) : merged.value;
5369
+ await ARTIFACT.must(settingsPath(), JSON.stringify(forFile, null, 2) + '\n');
5370
+ }
5075
5371
  } catch (e) {
5076
5372
  json(500, { ok: false, errors: [`设置写入失败:${String((e && e.message) || e)}`], settings: currentSettings() });
5077
5373
  return;
5078
5374
  }
5079
5375
  const touched = new Set(Object.keys(patch && typeof patch === 'object' ? patch : {}).flatMap((k) => (k.includes('.') ? [k.split('.')[0]] : [k])));
5080
5376
  SETTINGS_CACHE = { settings: merged.value, repaired: [], at: new Date().toISOString() };
5081
- const needsRestart = touched.has('roster') || touched.has('gates');
5377
+ if (!viaHost.ok) reapplySettingsDerived(); // 文件路径:与 1.1.x 一样在进程内重算
5378
+ const needsRestart = !viaHost.ok && (touched.has('roster') || touched.has('gates'));
5082
5379
  json(200, {
5083
5380
  ok: true,
5084
5381
  settings: merged.value,
5085
5382
  needsRestart,
5086
- note: needsRestart
5087
- ? '已保存。`编制` / `门禁` 两类设置在插件加载时解析 ⇒ **重启 dsh web 后生效**;`显示` 类即时生效。'
5088
- : '已保存并即时生效。',
5383
+ settingsSource: viaHost.ok ? 'host' : 'file',
5384
+ note: viaHost.ok
5385
+ ? `已保存到宿主设置(命名空间 ${HOST_SETTINGS_NAMESPACE}):官方面板与浮层共用同一份,且随插件市场的备份/恢复一起走;上限与档位门已在进程内重算,**无需重启**。`
5386
+ : needsRestart
5387
+ ? '已保存。`编制` / `门禁` 两类设置在插件加载时解析 ⇒ **重启 dsh web 后生效**;`显示` 类即时生效。'
5388
+ : '已保存并即时生效。',
5089
5389
  });
5090
5390
  }, { methods: ['GET', 'POST'] }),
5091
5391
  });
@@ -0,0 +1,216 @@
1
+ // 宿主设置命名空间接线(1.2.0)
2
+ //
3
+ // ── 为什么要有它(三条具体收益,不是"架构更漂亮")────────────────────────────
4
+ // ① 设置出现在 **设置 → 插件 → 插件配置**:由宿主按我们给的 schema 渲染,插件不必自己画表单;
5
+ // ② 随插件市场的**备份与恢复**一起走 —— 市场只备份"profile 插件清单 + 设置",自造的
6
+ // `settings.json` 不在它的备份范围里;
7
+ // ③ 值改变能**立刻生效**:`scope.watch()` 一到,重算上限/轮次/档位门,不必重启。
8
+ //
9
+ // ── 为什么用**动态 import**,而不是像 dshmarket 那样静态 import schemastery ──────────
10
+ // dshmarket 那样写是对的:它的包只会在 profile 里被加载,`@deepseek-ai/schemastery`
11
+ // 在 profile 的上一级 node_modules 里解析得到。但**本仓的 76 个测试文件都从仓库路径**
12
+ // import `lib/command.js` —— 仓库不在任何 profile 里,静态 import 会直接
13
+ // ERR_MODULE_NOT_FOUND,把整套测试带崩(这正是"零依赖、CI 免 install"的代价)。
14
+ // 动态 import 失败 ⇒ 优雅回退到 settings.json,行为与 1.1.x 一致;成功 ⇒ 上一个微任务后注册完成。
15
+ //
16
+ // ── 优先级(未变):config > env > **设置** > 内置默认 ──────────────────────────
17
+ // 宿主可用时,"设置"这一档 = 宿主命名空间的解析值,其构成为
18
+ // `schema 默认 < 我们传入的 base < 用户在官方面板的覆盖`;base 用**当前生效设置**填充,
19
+ // 所以面板一打开看到的就是真正在用的值(不需要任何一次性迁移)。
20
+ // 宿主不可用(或 schemastery 解析不到)时,"设置"这一档 = `$DSH_HOME/expert-team/settings.json`。
21
+
22
+ import { SETTINGS_GROUPS, SETTINGS_SPEC, flatSpec } from './settings.js';
23
+
24
+ /** 注册到宿主的命名空间(宿主对命名空间有文法要求:小写字母/数字/短横线)。 */
25
+ export const HOST_SETTINGS_NAMESPACE = 'expert-team';
26
+
27
+ /**
28
+ * 能上官方面板的类型。
29
+ *
30
+ * `roles` 刻意**不上**:它的取值是"角色 id 数组或 null",在 schemastery 里要表达成
31
+ * `union([const(null), array(string)])`,跨版本行为没把握;一旦 schema 建错,宿主会拒绝注册
32
+ * 甚至让整条插件挂不上。这个字段继续由浮层的设置页签 + settings.json 负责,
33
+ * 官方页面只显示其余三类(bool / int / enum)。少一个开关,好过"注册失败、面板里什么都不出现"。
34
+ */
35
+ const HOST_SCHEMA_TYPES = new Set(['bool', 'int', 'enum']);
36
+
37
+ /** 宿主 schema 覆盖的扁平路径(`identity.profile` 这种)。 */
38
+ export function hostSchemaPaths() {
39
+ return Object.entries(flatSpec())
40
+ .filter(([, item]) => HOST_SCHEMA_TYPES.has(item.type))
41
+ .map(([path]) => path);
42
+ }
43
+
44
+ /**
45
+ * 由我们的 `SETTINGS_SPEC` 生成嵌套的 schemastery schema。
46
+ *
47
+ * 单一真源:字段、取值范围、默认值全部来自 `lib/settings.js` 的 spec —— 不在这里抄第二份。
48
+ * 代价是必须**容忍 schemastery 的 API 差异**:只有确实存在的方法才调用(`.min/.max/.default`)。
49
+ *
50
+ * @param z - schemastery 的默认导出(由调用方动态 import 得到)。
51
+ */
52
+ export function buildHostSchema(z) {
53
+ const shape = {};
54
+ for (const group of SETTINGS_GROUPS) {
55
+ const inner = {};
56
+ for (const [key, item] of Object.entries(SETTINGS_SPEC[group].items)) {
57
+ if (!HOST_SCHEMA_TYPES.has(item.type)) continue;
58
+ let field;
59
+ if (item.type === 'bool') field = z.boolean();
60
+ else if (item.type === 'int') {
61
+ field = z.number();
62
+ if (item.min != null && typeof field.min === 'function') field = field.min(item.min);
63
+ if (item.max != null && typeof field.max === 'function') field = field.max(item.max);
64
+ } else field = z.union(item.values.map((v) => z.const(v)));
65
+ if (typeof field.default === 'function') field = field.default(item.default);
66
+ inner[key] = field;
67
+ }
68
+ if (Object.keys(inner).length) shape[group] = z.object(inner);
69
+ }
70
+ return z.object(shape);
71
+ }
72
+
73
+ /** 从"当前生效设置"里挑出宿主能表达的那些(用作 `register(..., { base })`)。 */
74
+ export function hostBase(settings) {
75
+ const out = {};
76
+ for (const path of hostSchemaPaths()) {
77
+ const [group, key] = path.split('.');
78
+ const value = settings && settings[group] ? settings[group][key] : undefined;
79
+ if (value === undefined || value === null) continue;
80
+ out[group] = out[group] || {};
81
+ out[group][key] = value;
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * 把补丁裁成"宿主能表达"的嵌套对象。
88
+ * 补丁同时支持嵌套(`{roster:{maxTasks:10}}`)与扁平(`{'roster.maxTasks':10}`),与
89
+ * `mergeSettings` 同口径 —— 否则同一份补丁在两处会有两种解释。
90
+ */
91
+ export function pickHostExpressible(patch) {
92
+ const flat = {};
93
+ const put = (path, value) => {
94
+ const item = flatSpec()[path];
95
+ if (!item || !HOST_SCHEMA_TYPES.has(item.type)) return;
96
+ flat[path] = value;
97
+ };
98
+ if (patch && typeof patch === 'object' && !Array.isArray(patch)) {
99
+ for (const [k, v] of Object.entries(patch)) {
100
+ if (k.includes('.')) { put(k, v); continue; }
101
+ if (!SETTINGS_SPEC[k] || !v || typeof v !== 'object' || Array.isArray(v)) continue;
102
+ for (const [k2, v2] of Object.entries(v)) put(`${k}.${k2}`, v2);
103
+ }
104
+ }
105
+ const out = {};
106
+ for (const [path, value] of Object.entries(flat)) {
107
+ const [group, key] = path.split('.');
108
+ out[group] = out[group] || {};
109
+ out[group][key] = value;
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /** 补丁里**宿主表达不了**的那部分(继续由 settings.json 负责)。 */
115
+ export function pickFileOnly(patch) {
116
+ const expressible = pickHostExpressible(patch);
117
+ const out = {};
118
+ const put = (path, value) => {
119
+ const item = flatSpec()[path];
120
+ if (!item || HOST_SCHEMA_TYPES.has(item.type)) return;
121
+ const [group, key] = path.split('.');
122
+ out[group] = out[group] || {};
123
+ out[group][key] = value;
124
+ };
125
+ if (patch && typeof patch === 'object' && !Array.isArray(patch)) {
126
+ for (const [k, v] of Object.entries(patch)) {
127
+ if (k.includes('.')) { put(k, v); continue; }
128
+ if (!SETTINGS_SPEC[k] || !v || typeof v !== 'object' || Array.isArray(v)) continue;
129
+ for (const [k2, v2] of Object.entries(v)) put(`${k}.${k2}`, v2);
130
+ }
131
+ }
132
+ void expressible;
133
+ return out;
134
+ }
135
+
136
+ // ── 运行时状态(进程内单例;插件卸载时清空)──────────────────────────────────
137
+
138
+ /** 已注册的宿主作用域;null = 宿主没有 settings 服务(或还没注册完)。 */
139
+ let HOST_SCOPE = null;
140
+ /** 最近一次从宿主读到的解析值。 */
141
+ let HOST_VALUES = null;
142
+ /** 注册失败的原因(供 `/team status` 与诊断如实显示)。 */
143
+ let HOST_NOTE = '';
144
+
145
+ /**
146
+ * 把设置注册进宿主命名空间。**同步返回**,注册本身在下一次微任务完成(动态 import)。
147
+ *
148
+ * @param ctx - 插件上下文(用 `ctx.inject(['settings'], …)` 作为优雅降级边界:宿主没有该服务时回调不执行)
149
+ * @param options.base - 当前生效设置(决定面板初始值)
150
+ * @param options.onResolved - 拿到(或更新)宿主解析值时的回调;用于重算上限/档位
151
+ * @param options.loadSchema - **仅供测试**注入 schema 加载器(默认动态 import schemastery)。
152
+ * 仓库里解析不到 schemastery,没有这个接缝就只能测到"回退"那一半,成功路径永远没被测过。
153
+ * @returns 是否成功挂上了 inject(不代表注册已完成)
154
+ */
155
+ export function installHostSettings(ctx, { base, onResolved, loadSchema } = {}) {
156
+ if (!ctx || typeof ctx.inject !== 'function') return false;
157
+ const load = typeof loadSchema === 'function' ? loadSchema : () => import('@deepseek-ai/schemastery');
158
+ try {
159
+ ctx.inject(['settings'], (scoped) => {
160
+ const service = scoped && scoped.settings;
161
+ if (!service || typeof service.register !== 'function') return;
162
+ load().then((mod) => {
163
+ const z = (mod && mod.default) ? mod.default : mod;
164
+ const scope = service.register(HOST_SETTINGS_NAMESPACE, buildHostSchema(z), { base: hostBase(base) });
165
+ HOST_SCOPE = scope;
166
+ const push = () => {
167
+ try {
168
+ HOST_VALUES = typeof scope.get === 'function' ? scope.get() : null;
169
+ if (onResolved) onResolved(HOST_VALUES);
170
+ } catch (e) {
171
+ HOST_NOTE = '读取宿主设置失败:' + String(e && e.message ? e.message : e);
172
+ }
173
+ };
174
+ try { if (typeof scope.watch === 'function') scope.watch(() => push()); } catch { /* watch 缺失不该影响注册 */ }
175
+ push();
176
+ if (typeof scoped.effect === 'function') {
177
+ scoped.effect(() => () => { HOST_SCOPE = null; HOST_VALUES = null; });
178
+ }
179
+ console.log(`[expert-team] 设置已注册到宿主命名空间「${HOST_SETTINGS_NAMESPACE}」(官方面板可改,且随市场备份/恢复)`);
180
+ }).catch((e) => {
181
+ HOST_NOTE = 'schemastery 不可解析,保持 settings.json:' + String(e && e.message ? e.message : e);
182
+ console.warn('[expert-team] ' + HOST_NOTE);
183
+ });
184
+ });
185
+ return true;
186
+ } catch (e) {
187
+ HOST_NOTE = 'inject(settings) 失败:' + String(e && e.message ? e.message : e);
188
+ console.warn('[expert-team] ' + HOST_NOTE);
189
+ return false;
190
+ }
191
+ }
192
+
193
+ /** 宿主作用域是否已就绪。 */
194
+ export function hostScope() { return HOST_SCOPE; }
195
+ /** 最近一次宿主解析值(null = 未就绪)。 */
196
+ export function hostValues() { return HOST_VALUES; }
197
+ /** 诊断文案(为什么没用上宿主设置)。 */
198
+ export function hostSettingsNote() { return HOST_NOTE; }
199
+
200
+ /**
201
+ * 把补丁写进宿主命名空间(只写它表达得了的那部分)。
202
+ * @returns `{ ok: true, written: 命中字段数 }`,或 `{ ok: false, reason }`
203
+ */
204
+ export async function updateHostSettings(patch) {
205
+ if (!HOST_SCOPE || typeof HOST_SCOPE.update !== 'function') return { ok: false, reason: 'no-host-scope' };
206
+ const expressible = pickHostExpressible(patch);
207
+ const groups = Object.keys(expressible);
208
+ const count = groups.reduce((n, g) => n + Object.keys(expressible[g]).length, 0);
209
+ if (count === 0) return { ok: false, reason: 'nothing-expressible' };
210
+ try {
211
+ await HOST_SCOPE.update(expressible);
212
+ return { ok: true, written: count };
213
+ } catch (e) {
214
+ return { ok: false, reason: 'update-failed', error: String(e && e.message ? e.message : e) };
215
+ }
216
+ }
@@ -81,3 +81,137 @@ export function withRoute(handler, { methods = ['GET'] } = {}) {
81
81
  }
82
82
  };
83
83
  }
84
+
85
+ // ── 本机来源守卫(安全加固 · 2026-09-14)────────────────────────────────────
86
+ //
87
+ // 为什么要有它:这 11 条路由由 dsh 的 webServer 暴露在本机 HTTP 端口上,此前**只校验
88
+ // HTTP 方法**。于是有三条真实可达的攻击路径:
89
+ // ① **DNS rebinding**:恶意页面把自己的域名解析到 127.0.0.1,浏览器就把它当同源 ⇒
90
+ // 可读 `/state`、`/file`(工作区任意文本)、并写入 `/task`、`/plan`、`/settings`。
91
+ // ② **跨站简单请求(CSRF)**:跨站 `fetch` 带 `text/plain`、或 `<form>` 提交,都不触发
92
+ // CORS 预检 ⇒ 写操作照常执行(响应读不到,但**副作用已经发生**)。
93
+ // ③ **非回环暴露**:宿主配置 `host: 0.0.0.0` 时,同网段任何机器都能打这些接口。
94
+ //
95
+ // 判定规则(每条都对应上面一条攻击路径):
96
+ // · `Host` 头存在且主机名不是回环 ⇒ 403。**这是挡 DNS rebinding 的那一条**:浏览器一定会带
97
+ // Host,而攻击页带的 Host 是它自己的域名。
98
+ // · `Origin` 头存在且不是回环来源 ⇒ **只在变更方法上** 403。GET 放行是有意的:浏览器不会把
99
+ // 没有 CORS 头的响应体交给跨站页面(我们从不发 `Access-Control-Allow-Origin`),所以跨站
100
+ // GET 读不到任何东西;而放行它才能保住 `/team canvas --watch` 生成的本地 HTML 文件——
101
+ // 那个页面以 `file://` 打开,发请求时带的是 `Origin: null`。
102
+ // · `req.socket.remoteAddress` 存在且不是回环 ⇒ 403(挡局域网)。
103
+ // · 变更方法上 `content-type` 存在且不是 JSON ⇒ 415。浏览器的 form 与"简单请求"只能发
104
+ // urlencoded / multipart / text-plain,JSON 必须走预检,而我们不回应预检。
105
+ //
106
+ // **三条规则都只在"头存在"时生效** —— 这是刻意的,不是宽松:
107
+ // · 浏览器(也就是这三条攻击路径里唯一的攻击载体)**必然**带上 Host,POST 必然带 Origin;
108
+ // · 本地非浏览器客户端本来就能自造任意头,任何头校验都拦不住它 —— 这类客户端需要的是
109
+ // 鉴权,而 dsh 插件没有鉴权模型(市场的同源检查也只是回环+同源)。这一点如实写进 README。
110
+ // · 副作用:单元测试里的假 req 没有 headers/socket,因此不受影响(不必为测试开口子)。
111
+ //
112
+ // 事实来源:`dshmarket` 对 install/restart 这类端点就是这么做的("只接受同源 POST"、
113
+ // "重启要求客户端直接来自环回地址、拒绝代理转发请求")。
114
+
115
+ /** 回环主机名(Host 头 / Origin 里的主机名都按这个判)。 */
116
+ const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
117
+
118
+ /** `Host: 127.0.0.1:3080` → `127.0.0.1`;`[::1]:3080` → `[::1]`;解析不出返回 ''。 */
119
+ export function hostnameOf(hostHeader) {
120
+ const raw = String(hostHeader == null ? '' : hostHeader).trim().toLowerCase();
121
+ if (!raw) return '';
122
+ if (raw.startsWith('[')) { // IPv6 字面量:[::1]:3080
123
+ const end = raw.indexOf(']');
124
+ return end === -1 ? raw : raw.slice(0, end + 1);
125
+ }
126
+ const colon = raw.lastIndexOf(':');
127
+ return colon === -1 ? raw : raw.slice(0, colon);
128
+ }
129
+
130
+ /** Host 头是否是回环地址。 */
131
+ export function isLoopbackHost(hostHeader) {
132
+ const h = hostnameOf(hostHeader);
133
+ return h !== '' && LOOPBACK_HOSTNAMES.has(h);
134
+ }
135
+
136
+ /**
137
+ * 来源(Origin)是否可信:`http://127.0.0.1:3080`、`http://localhost:3080`、`https://[::1]` 可信;
138
+ * `null`(file:// 页面)与一切外站**不可信**。
139
+ */
140
+ export function isLoopbackOrigin(origin) {
141
+ const raw = String(origin == null ? '' : origin).trim();
142
+ if (!raw || raw.toLowerCase() === 'null') return false;
143
+ try {
144
+ const u = new URL(raw);
145
+ return LOOPBACK_HOSTNAMES.has(u.hostname.toLowerCase());
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+
151
+ /** socket 地址是否是回环(`127.x.x.x`、`::1`、IPv4-mapped `::ffff:127.x.x.x`)。 */
152
+ export function isLoopbackAddress(addr) {
153
+ const a = String(addr == null ? '' : addr).trim().toLowerCase();
154
+ if (!a) return false;
155
+ if (a === '::1') return true;
156
+ const v4 = a.startsWith('::ffff:') ? a.slice(7) : a;
157
+ return /^127\./.test(v4);
158
+ }
159
+
160
+ /**
161
+ * 本机来源守卫:返回 `null` 表示放行,否则返回 `{ code, error }`(由调用方写响应)。
162
+ *
163
+ * @param req - 请求对象(`headers` / `socket.remoteAddress` 缺失时按"非浏览器客户端"处理)
164
+ * @param options.methods - 该路由允许的方法;**变更方法**才做 Origin 与 content-type 校验
165
+ */
166
+ export function guardLocalRequest(req, { methods = ['GET'] } = {}) {
167
+ const headers = (req && req.headers) || null;
168
+ const method = String((req && req.method) || 'GET').toUpperCase();
169
+ const mutating = !['GET', 'HEAD', 'OPTIONS'].includes(method);
170
+
171
+ if (headers) {
172
+ const host = headers.host ?? headers[':authority'];
173
+ if (host != null && String(host).trim() !== '' && !isLoopbackHost(host)) {
174
+ return { code: 403, error: 'forbidden: non-loopback Host' };
175
+ }
176
+ const origin = headers.origin;
177
+ if (mutating && origin != null && String(origin).trim() !== '' && !isLoopbackOrigin(origin)) {
178
+ return { code: 403, error: 'forbidden: cross-origin write' };
179
+ }
180
+ if (mutating) {
181
+ const ct = headers['content-type'];
182
+ if (ct != null && String(ct).trim() !== '' && !/^application\/json\b/i.test(String(ct).trim())) {
183
+ return { code: 415, error: 'unsupported media type: expected application/json' };
184
+ }
185
+ }
186
+ }
187
+
188
+ const addr = req && req.socket ? req.socket.remoteAddress : null;
189
+ if (addr != null && String(addr).trim() !== '' && !isLoopbackAddress(addr)) {
190
+ return { code: 403, error: 'forbidden: non-loopback client' };
191
+ }
192
+
193
+ // 方法校验放在守卫里一起做:路由注册处只包一层就不会漏(见 routes-guard 棘轮测试)。
194
+ const allow = new Set(methods.map((m) => String(m).toUpperCase()));
195
+ if (!allow.has(method)) return { code: 405, error: '' };
196
+
197
+ return null;
198
+ }
199
+
200
+ /**
201
+ * 给任意处理器套上"只服务本机同源"的守卫。**注册每一路由时都要用它包一层** ——
202
+ * `routes-shared.test.mjs` 有一条棘轮断言:`lib/command.js` 里不允许出现裸的 `handler:`。
203
+ *
204
+ * @param handler - `(req, res) => Promise<void> | void`
205
+ * @param options.methods - 允许的方法(默认 `['GET']`)
206
+ */
207
+ export function localOnly(handler, { methods = ['GET'] } = {}) {
208
+ return async function guarded(req, res) {
209
+ const denied = guardLocalRequest(req, { methods });
210
+ if (denied) {
211
+ if (denied.code === 405) { res.writeHead(405); res.end(); return; }
212
+ json(res, denied.code, { ok: false, error: denied.error });
213
+ return;
214
+ }
215
+ return handler(req, res);
216
+ };
217
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yangdcm/dsh-expert-team",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "dsh「专家团」bundle:一句自然语言自动组建/持久化一支 12 角色多智能体团队,共享工作区协议 + 阶段门控编排 + 结构化交接 + 质量门禁/自动调度,实现者直接改代码并产出持久工件;带 live 团队浮层(质量门禁/覆盖率/工件预览)。 · Role-based multi-agent expert team for DeepSeek Harness: one sentence in, a staged and gated team delivery out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -83,7 +83,7 @@
83
83
  "check:name": "node scripts/rename-package.mjs --check",
84
84
  "test:regression": "node regression.test.mjs",
85
85
  "test:e2e": "node e2e.test.mjs",
86
- "test:all": "node smoke.test.mjs && node regression.test.mjs && node e2e.test.mjs && node flow.test.mjs && node plan-decide.test.mjs && node plan-discard.test.mjs && node models-honesty.test.mjs && node evidence-gate.test.mjs && node sync-gate.test.mjs && node artifact-writer.test.mjs && node dag.test.mjs && node metrics.test.mjs && node broken-chain.test.mjs && node schema-warn.test.mjs && node template-copy.test.mjs && node task-binding.test.mjs && node role-merge.test.mjs && node wf-recovery.test.mjs && node panorama.test.mjs && node run-ownership.test.mjs && node role-identity.test.mjs && node command-parse.test.mjs && node member-registry.test.mjs && node schema-check.test.mjs && node run-health.test.mjs && node cordis-fs-port.test.mjs && node scaffold-fingerprint.test.mjs && node preset-lint.test.mjs && node stranded-tasks.test.mjs && node agent-scope-guard.test.mjs && node schema-warn-noise.test.mjs && node tool-card-status.test.mjs && node dag-status.test.mjs && node capacity-limits.test.mjs && node task-cas.test.mjs && node write-lock.test.mjs && node card-stall.test.mjs && node multi-session-and-confirm.test.mjs && node standing-rules.test.mjs && node scope-overlap.test.mjs && node task-cancel-protect.test.mjs && node wait-run.test.mjs && node rework-loop.test.mjs && node client-css-integrity.test.mjs && node interception.test.mjs && node mutation-catalog.test.mjs && node write-bypass-ratchet.test.mjs && node asi-hazards.test.mjs && node dispatch-contract.test.mjs && node closure-ledger.test.mjs && node rework-nature.test.mjs && node loop-guard.test.mjs && node vocab-consistency.test.mjs && node state-no-write.test.mjs && node routes-shared.test.mjs && node metrics-render.test.mjs && node metrics-collect.test.mjs && node first-runnable.test.mjs && node closing-budget.test.mjs && node scan-single-source.test.mjs && node tier.test.mjs && node dispatch-ledger.test.mjs && node tier-gate.test.mjs && node tier-badge.test.mjs && node validate-module.test.mjs && node settings.test.mjs && node settings-page.test.mjs && node policy.test.mjs && node phase-accounting.test.mjs && node log-parse-module.test.mjs && node command-parse-module.test.mjs && node authority.test.mjs && node write-tracer.test.mjs && node token-accounting.test.mjs && node lead-toolface.test.mjs",
86
+ "test:all": "node bootstrap.test.mjs && node host-settings.test.mjs && node smoke.test.mjs && node regression.test.mjs && node e2e.test.mjs && node flow.test.mjs && node plan-decide.test.mjs && node plan-discard.test.mjs && node models-honesty.test.mjs && node evidence-gate.test.mjs && node sync-gate.test.mjs && node artifact-writer.test.mjs && node dag.test.mjs && node metrics.test.mjs && node broken-chain.test.mjs && node schema-warn.test.mjs && node template-copy.test.mjs && node task-binding.test.mjs && node role-merge.test.mjs && node wf-recovery.test.mjs && node panorama.test.mjs && node run-ownership.test.mjs && node role-identity.test.mjs && node command-parse.test.mjs && node member-registry.test.mjs && node schema-check.test.mjs && node run-health.test.mjs && node cordis-fs-port.test.mjs && node scaffold-fingerprint.test.mjs && node preset-lint.test.mjs && node stranded-tasks.test.mjs && node agent-scope-guard.test.mjs && node schema-warn-noise.test.mjs && node tool-card-status.test.mjs && node dag-status.test.mjs && node capacity-limits.test.mjs && node task-cas.test.mjs && node write-lock.test.mjs && node card-stall.test.mjs && node multi-session-and-confirm.test.mjs && node standing-rules.test.mjs && node scope-overlap.test.mjs && node task-cancel-protect.test.mjs && node wait-run.test.mjs && node rework-loop.test.mjs && node client-css-integrity.test.mjs && node interception.test.mjs && node mutation-catalog.test.mjs && node write-bypass-ratchet.test.mjs && node asi-hazards.test.mjs && node dispatch-contract.test.mjs && node closure-ledger.test.mjs && node rework-nature.test.mjs && node loop-guard.test.mjs && node vocab-consistency.test.mjs && node state-no-write.test.mjs && node routes-shared.test.mjs && node metrics-render.test.mjs && node metrics-collect.test.mjs && node first-runnable.test.mjs && node closing-budget.test.mjs && node scan-single-source.test.mjs && node tier.test.mjs && node dispatch-ledger.test.mjs && node tier-gate.test.mjs && node tier-badge.test.mjs && node validate-module.test.mjs && node settings.test.mjs && node settings-page.test.mjs && node policy.test.mjs && node phase-accounting.test.mjs && node log-parse-module.test.mjs && node command-parse-module.test.mjs && node authority.test.mjs && node write-tracer.test.mjs && node token-accounting.test.mjs && node lead-toolface.test.mjs",
87
87
  "test:toolcard": "node tool-card-status.test.mjs",
88
88
  "test:flow": "node flow.test.mjs",
89
89
  "test:decide": "node plan-decide.test.mjs",
@@ -133,6 +133,8 @@
133
133
  "test:wait": "node wait-run.test.mjs",
134
134
  "test:loop": "node rework-loop.test.mjs",
135
135
  "test:tokens": "node token-accounting.test.mjs",
136
- "test:toolface": "node lead-toolface.test.mjs"
136
+ "test:toolface": "node lead-toolface.test.mjs",
137
+ "test:bootstrap": "node bootstrap.test.mjs",
138
+ "test:host-settings": "node host-settings.test.mjs"
137
139
  }
138
140
  }