# DSH Staff 流程与状态机 本文是员工制插件的「运行时说明书」:三条主链路的流程图、四组状态机的合法迁移、自进化闭环的口径,以及界面规范。 改代码前先对照本文;改完若行为与本文不一致,改的是本文而不是绕过去。 --- ## 一、术语 | 术语 | 含义 | 存放位置 | | --- | --- | --- | | 员工(employee) | 名册里一条编制:姓名、岗位、职责、模型、绑定工位 | `~/.dsh/staff/roster.json` | | 工位(station) | 员工绑定的 teammate 名(`record.member`)。teammate 是**会话内实体**,会话结束即消失 | roster 的 `member` 字段 + 运行时 Team | | 群(group) | 全局消息流 + 成员名单 + 可选秘书 | `groups.json` | | 私聊(dm) | 老板与某员工的对话流,每员工保留最近 200 条 | `dms.json` | | 坏工位(broken) | 投递失败且被判定为不可修复的工位记录 | `broken-members.json` | **关键事实**:工位不是持久资源。名册只存名字,真正的 teammate 活在某个会话里。这条事实决定了「工位三态」和「自愈」两套机制。 --- ## 二、三条主链路 ### 2.1 入职 → 落座 → 派活 ``` 老板 插件 宿主 │ staff_hire(name,post) │ │ ├─────────────────────────▶│ 写 roster(文件锁+原子写) │ │ │ spawn_teammate(可选) │ │ ├───────────────────────────▶│ 会话内出现 teammate │ │ 记 record.member │ │ staff_assign(task) │ │ ├─────────────────────────▶│ 写 todos[] │ │ staff_dispatch │ │ ├─────────────────────────▶│ 逐条 deliverToMember │ │ ├───────────────────────────▶│ teams.sendMessage ``` 约束: 1. 写盘走「文件锁 → 临时文件 → 原子 rename → .bak」,坏档进 `.corrupt` 并拒绝覆盖写(`STAFF_STORE_CORRUPT`)。 2. 派活只记账不唤醒;唤醒是 `staff_dispatch` / `wake` / 输入框 `@姓名` 的职责。 3. 投递前必须确认工位在场(`listMembers` 取证),不能凭名册里的名字假设它活着。 ### 2.2 发消息(输入框 @ 路由) ``` 输入 "@产品决策组 明天十点评审" │ ├─ 解析:只认 @ + 快照已知名字 + 分隔符(空白 / :/ ,/ 、)+ 正文 │ 无正文不拦截;带附件不改投;最长名字优先 │ ├─ 群 → group-send:写群消息流 → 投递给成员(正文含 @ 本群成员则点名) └─ 员工 → dm:写私聊 → 工位自愈 → 投递 → 员工用 staff_reply 写回 │ └─ 成功:清草稿,当前会话不落这条消息 失败:保留草稿 + 「@ 投递失败:<原因>」 ``` ### 2.3 群消息 → 秘书分派 → 回报 ``` 老板 → 群消息流(seq 递增) → 投递给全体成员或点名成员 │ 秘书收到时附带「你是秘书」指令:判断该谁做 → group_send 点名 → staff_reply 回报 └ 成员回复 → staff_reply(groupId) → 回到群消息流 → 界面 15s 轮询可见 ``` --- ## 三、状态机 ### 3.1 工位三态(v2.1 新增) | 状态 | 判定 | 可投递 | 界面表现 | 处置 | | --- | --- | --- | --- | --- | | `online` | `member` 在**任一**会话的成员表里 | 是 | 绿点 + 「在线待命」 | 直接投 | | `missing` | 有 `member`,但所有会话都找不到 | 否 | 橙点 + 「工位缺失:发消息或点 ↑ 会自动重建」 | 发消息 / `wake` / 档案页「重建工位并上线」→ `reviveMember` | | `unbound` | 没有 `member` | 否 | 灰点 + 「未绑定工位」 | 先 `staff_hire(member=...)` 绑定 | 迁移: ``` unbound ──staff_hire(member)──▶ missing ──spawn 成功──▶ online ▲ │ └──── 所在会话结束 ─────┘ missing ──reviveMember 失败──▶ 保持 missing(不写 broken,除非判定永久不可用) ``` 判定口径: - 在线名单遍历**全部会话根**汇总。只看 `roots()[0]` 会把待在别的会话里的员工误判为离线。 - 单个会话 `listMembers` 抛错只跳过该会话,不影响其余会话结论。 - 宿主没有 `agentTeams` 服务时整体降级:有 `member` 的判 `missing`,不出现 `online`。 - `online` 布尔字段保留,语义等于 `station === 'online'`,供旧前端使用。 ### 3.2 任务四态 ``` todo ──开始做──▶ doing ──完成──▶ done │ │ └──阻塞──▶ blocked ◀──阻塞──┘ │ └──解除阻塞──▶ doing ``` - 只有 `done` 从「未完成任务」里消失;`blocked` 计入待办但不计入逾期告警以外的高优提醒。 - 每次迁移写 `note`(证据/原因),供 `staff_dashboard` 汇总。 ### 3.3 群消息与序号 - 每条消息一个自增 `seq`,按天分桶(6 点为界)生成 `dayHistory`。 - 删群即删消息,不可恢复;单条消息不单独删。 - 序号不连续**只报警告**:历史上曾因删档/迁移造成断号(真实数据里「故事工坊」即 1,2,4,…),不构成存储损坏。 ### 3.4 投递结果 | 结果 | 含义 | 副作用 | | --- | --- | --- | | `ok:true` | 已投递 | 写私聊/群流,必要时 `repaired:true` | | `ok:false, reason` | 未投递 | 保留草稿,不写成功记录;可重试 | | `failed`(wake) | 重建失败 | 回传诊断文本,指引换会话或改绑定 | --- ## 四、自进化闭环 任何一次界面/流程改进都走同一条环路,不允许「改完就说好了」: ``` ┌──────────── 观察(真实数据 / 老板反馈 / 日志)────────────┐ │ │ ▼ │ ① 反馈入账:把现象写成可验证的断言(如「跨会话员工显示离线」) │ │ │ ▼ │ ② 候选方案:先只读定位根因(读源码/取证),再写最小改动 │ │ │ ▼ │ ③ 测试先行:新增用例先失败(红),改完转绿;再跑全量矩阵 │ │ │ ▼ │ ④ 发布:index.js 需重启 DSH;client.js 需 Ctrl+R │ │ │ ▼ │ ⑤ 回滚口径:sha256 留档 + 上一版文件在 .bak;行为不符即回退 │ └──────────────────────────────────────────────────────────┘ ``` 落地清单(每次都要有): 1. 结论文件:改了什么、为什么、证据在哪(sha256、用例编号、通过数)。 2. 矩阵:宿主侧 `E:/yuangong/.qa-tmp/verify/run-all.mjs` 全绿;客户端侧各 harness 全绿。 3. 真实数据兼容:`E:/yuangong/.qa-tmp/lead/real-data-compat.mjs` 只读跑一遍,确认没把线上数据带坏。 4. 每日工作记录落盘并回读。 **反模式**:先改 UI 再找理由;用例只测新分支不跑旧矩阵;把「doctor 警告」当「存储损坏」;用「大概」「应该」代替取证。 --- ## 五、界面规范 ### 5.1 设计令牌(client.js 顶部 `T`) | 令牌 | 用途 | | --- | --- | | `T.layer1` / `T.layer2` | 面板底色 / 内嵌块底色 | | `T.overlay` | 悬停与浮层遮罩 | | `T.border1` / `T.border2` | 分隔线 / 控件描边 | | `T.brand` | 主色:主按钮、未读角标、置顶竖条 | | `T.label1` / `T.label2` | 主文本 / 次要文本 | | `T.ok` / `T.warn` / `T.danger` / `T.idle` | 在线 / 待处理 / 危险 / 未绑定 | 新增颜色一律走令牌,不在组件里写死;确需固定值(头像白字、遮罩透明度)集中写在模块顶部。 ### 5.2 字号与间距 | 场景 | 字号 | 备注 | | --- | --- | --- | | 姓名、档案标题 | 13 / 20 | 600 字重 | | 次要说明、状态 | 11~12 | 行高 1.2~1.75 | | 折叠箭头 | 9 | 唯一允许低于 11 的位置,仅作图形符号 | | 卡片内边距 | 8×10 | 圆角 10 | | 浮层卡片 | `min(1100px,94vw) × min(740px,88vh)` | 全屏遮罩 0.32 | ### 5.3 状态点与文案 | 工位态 | 点色 | 提示文案 | | --- | --- | --- | | online | `T.ok` | 在线待命 | | missing | `T.warn` | 工位缺失:发消息或点 ↑ 会自动重建 | | unbound | `T.idle` | 未绑定工位 | 文案要求:说清「现在是什么」「点哪里」「会发生什么」。禁止只写「离线」「异常」这类没有下一步的词。 ### 5.4 列表分组 `群聊 → 在线员工 → 待重建工位(默认展开) → 未绑定工位(默认收起)` 排序键:未读降序 → 有待办 → 在线 → 最后消息时间降序 → 姓名(`zh-Hans-CN`)。置顶:当前会话成员带 2px 主色竖条。 ### 5.5 空态与失败 | 场景 | 文案 | | --- | --- | | 无任何会话 | 还没有会话。点「+ 员工」或「+ 群」。 | | 检索无匹配 | 没有匹配的会话 | | 无未完成任务 | 暂无 | | 首屏未就绪 | 5 行骨架(`aria-busy`)+ 检索框禁用、占位「加载中…」;不显示「还没有会话」,避免把加载中误读成空数据 | | 接口不可用 | 「员工接口不可用,正在自动重试…」+「立即重试」按钮 | | 投递失败 | 保留草稿 + 「@ 投递失败:<服务端原因>」 | | 工位重建成功 | 「已私聊「X」(工位已重建)」 | ### 5.6 提示分级(notice) | 级别 | 颜色 | 符号 | 触发场景 | | --- | --- | --- | --- | | ok | `T.ok` | ✓ | 全量成功、信息确认 | | warn | `T.warn` | ! | 部分成功(有人上线有人失败)、自检 0 人、快照过期等「可继续但需注意」 | | error | `T.danger` | × | 整体失败、校验不通过 | 规则:颜色之外必须有符号前缀(色弱可读);长文案 `whiteSpace: pre-wrap` 换行不撑破面板;历史调用点只传 `ok` 布尔时按 `ok:false → error` 归一,不要求一次性改完所有调用点。 ### 5.7 自检(doctor)三级口径 | 级别 | 是否不通过 | 典型触发 | | --- | --- | --- | | problems | 是 | 存储损坏且无备份、todos 非数组、缺 id/text、任务号重复、群成员不在名册、消息序号重复或倒序 | | warnings | 否 | 已从备份恢复、进行中无派发记录、完成无完成时间、群成员已离职、消息序号断档/首条非 1(历史迁移) | | infos | 否 | 未绑定 teammate、teammate 绑定检查跳过 | 输出字段 `{ problems, warnings, infos, text }`;只有 `problems > 0` 才算「自检不通过」。断档属于历史事实,不是损坏——把提醒当损坏会让真实数据永远红着,最后没人再看告警。 --- ## 六、验收入口 | 入口 | 覆盖 | | --- | --- | | `node E:/yuangong/.qa-tmp/verify/run-all.mjs` | 宿主 14 条用例(含快照契约、工位三态、自愈) | | `node E:/yuangong/.qa-tmp/lead/at-routing-harness.cjs` | @ 路由解析与投递 | | `node E:/yuangong/.qa-tmp/lead/station-ui-harness.cjs` | 工位三态归一、状态点、分组与档案页文案 | | `node E:/yuangong/.qa-tmp/lead/notice-skeleton-harness.cjs` | 提示三级归一/配色/符号、首屏骨架、失败重试、检索禁用 | | `node E:/yuangong/.qa-tmp/lead/selection-inplace-harness.cjs` | 选中写输入框、不跳页、缺服务不崩 | | `node E:/yuangong/.qa-tmp/lead/send-failure-harness.cjs` | 发送失败保草稿 | | `node E:/yuangong/.qa-tmp/verify/command-rows-harness.cjs` | 「/」菜单行数与描述 | | `node E:/yuangong/.qa-tmp/lead/real-data-compat.mjs` | 真实数据只读兼容(8/8;断号已按 warn 输出,不再冒充损坏) | --- ## 七、案例库(真实缺陷复盘) 本章收录三条已修复的真实缺陷。每则固定四段:**现象 → 根因 → 修法 → 证据**。证据一律给到「文件:行号 / 用例编号 / 实测输出」,不允许只写「已修复」。案例库的价值在于把一次性排障变成可复用判定口径:下次遇到同类现象,先对照这里,而不是重新猜。 ### 7.1 跨会话误判「在线」 **现象**:员工明明在另一个会话里待命,员工中心却把他归到「工位缺失」一档(见 3.1 三态),老板以为人没了,点「重建工位」白等一轮。 **根因**:在线名单只取了第一个会话根 `roots()[0]`。teammate 是会话内实体(见一章「关键事实」),员工可能挂在任意一个会话根下;只看首个根,等价于「只承认当前会话里的人活着」。代码里保留了这条反面注释作为路标:`index.js:2295`。 **修法**:在线判定改为遍历**全部会话根**汇总成员表(`index.js:2302` 起);单个会话 `listMembers` 抛错只跳过该会话,不影响其余会话结论;宿主无 `agentTeams` 服务时整体降级为 `missing`,不伪造 `online`。 **证据**: - 判定口径已固化在 3.1:遍历全部会话根 / 单会话异常跳过 / 无服务降级,三条都对得上代码。 - 回归用例 `14-station-state.mjs` 覆盖跨会话在线与 `missing`/`unbound` 区分,输出 `RESULT station-state PASS 8/8`。 - 反例只留在注释里,防止后人「顺手优化」回 `roots()[0]`。 ### 7.2 doctor 把历史断号报成存储损坏(real-data 7/8 → 8/8) **现象**:真实数据上跑只读兼容,矩阵停在 7/8,唯一失败项是 doctor——它把群「故事工坊」的消息序号断档(1,2,4,5,6,7,8,10,11,13,14,15)当成存储损坏报 error。数据本身读写完全正常。 **根因**:把「序号不连续」和「存储损坏」混成一档。断号是历史删档/迁移留下的**事实**,不是**故障**;判成 error 的直接后果是真实数据永远亮红,告警疲劳,真损坏反而没人看。 **修法**:doctor 改三级口径(5.7)——断档、首条非 1、已从备份恢复等归 `warnings`,只有 `problems > 0` 才算自检不通过;群序号口径在 3.3 同步写明「只报警告」。真实数据兼容脚本的断言改为「doctor 输出里不得出现 `[error]`」(`real-data-compat.mjs:35`)。 **证据**: - 本轮实跑:`RESULT real-data-compat PASS 8/8`;doctor 输出 `problems: 0, warnings: 1, infos: 0`,断档以 `[warn]` 呈现并附「历史删除或迁移造成,不影响读写」。 - 只读项「四份数据 sha256 未变 — changed=[]」通过,证明这次改的是判定,不是数据。 ### 7.3 首屏 `loadingFirst` 暂时性死区(TDZ) **现象**:首屏渲染直接抛 `ReferenceError: Cannot access 'loadingFirst' before initialization`,面板白屏;浏览器里只看得到空白,堆栈里才露出 TDZ。 **根因**:`const loadingFirst` 的声明写在检索框**使用点之后**。`const`/`let` 在块内受暂时性死区约束,首屏同步 render 会先命中使用点,此时变量尚未初始化,只能抛错。隐蔽点在于:只要走到「已就绪」分支就不触发,人手点一遍未必复现。 **修法**:把 `loadingFirst` 上移到快照计算之后、所有使用点之前(`client.js:796`);使用点在 `client.js:1125 / 1127 / 1135 / 1147`,全部位于声明之后。 **证据**: - `notice-skeleton-harness.cjs` 覆盖「未就绪时检索框禁用并提示加载中」「加载中渲染骨架列表」,输出 `RESULT notice-skeleton PASS 26/26`。 - `command-deeplink-harness.cjs` 用 vm + 迷你 React 同步渲染面板,首屏路径不抛错,输出 `ALL_SCENARIOS_OK`。 - 教训:组件函数体里的 `const` 顺序就是正确性;靠浏览器手点检验不出来,必须让 harness 走同步首屏渲染。 --- ## 八、度量指标 自进化闭环(4 章)要可比的数,不靠感觉。以下五项是每次交付都要报的口径,缺一项都算交付不完整。 | 指标 | 口径定义 | 当前基线 | 采集方式 | | --- | --- | --- | --- | | 缺陷分级分布 | `error / warn / info` 三档计数,对应 doctor 的 `problems / warnings / infos` | 真实数据 0 / 1 / 0 | `staff_doctor` 返回字段(5.7) | | 宿主回归矩阵 | 用例条数 / 断言总数 / 通过率 | 14 条 / 117 项检查 / 14 条全 PASS | `node .qa-tmp/verify/run-all.mjs`,逐条 `RESULT PASS m/m` 汇总 | | 客户端 harness | 走真实 `client.js` 的端到端套件数 | 8 套全绿 | at-routing / station-ui / notice-skeleton / selection-inplace / send-failure / command-rows / command-deeplink / command-row-stability | | 真实数据兼容 | 只读跑真实库的通过率 | 8/8 | `node .qa-tmp/lead/real-data-compat.mjs` | | 交付凭据 | 源码 sha256 与跑批前后一致性 | 每次交付附 `pluginSha256` | `run-all.mjs` 写入 `last-run/matrix.json`;跑批中途源码被改会打 `[warn]` | 口径说明: 1. **通过率按断言算**(m/n),不按用例条数算——一条用例 15 项检查挂 1 项仍是用例 FAIL,但通过率要能看出损失面。 2. **117 项检查**是 14 条用例断言之和(5+5+13+5+9+12+6+12+15+8+6+6+5+10);用例增删后此数必须同步更新本节,否则指标会自我漂移。 3. **sha256 是交付的一部分**,不是附录:没有 sha 的「已修复」无法确认跑的是哪一版源码,回滚时也无从比对。 4. 客户端 8 套只统计走真实 `client.js` 的 harness;`real-data-compat` 单列为真实数据兼容,避免重复计数。 5. 缺陷分级只用于判断「是否不通过」,不用于压数字:把 warn 压成 info 让面板变绿,属于篡改度量口径。 --- ## 九、并行协作规约 多个 teammate 同时改同一插件时,靠口头协调必出错。本章是硬规约,不是建议。 ### 9.1 同一文件只允许一个写入者 - 一份文件在同一时间窗内只能有一个 owner。`index.js`、`client.js`、`docs/流程与状态机.md` 是高频冲突面,默认**按文件切分**,不按「功能点」切分。 - 需要两人改同一文件时,改为「一人写、一人审」:写入者持锁,审查者只读并给行号级意见。 - 发现别人正在写同一文件且未登记,先停手、发消息对齐,不要「先改了再说」——并发写会产生半成品源码,症状是 `SyntaxError: Identifier 'xxx' has already been declared` 这类假缺陷。 ### 9.2 写作用域登记在任务板 - 派活时在任务板写清 `writeScopes`(本次预计改动的文件);只读调研明确写「只读」。 - claim 时核对写作用域是否与他人重叠;重叠必须先协商,不允许并发写。 - 写作用域泄漏(实际改了未登记的文件)视为违规,交付时必须主动说明,不能埋进汇总里。 ### 9.3 Lead 负责合并验收与全量矩阵 - 成员只跑与自己改动相关的用例,够用即止;**全量矩阵由 Lead 跑**,避免多人并发跑批互相污染临时目录。 - Lead 验收顺序:单点用例 → 宿主 14 条 → 客户端 8 套 → 真实数据 8/8 → sha256 留档。 - 合入以 Lead 的矩阵结果为准:成员自测通过不等于交付通过。 ### 9.4 冲突与中断处置 - 跑批期间源码被改动,`run-all.mjs` 会记录首尾 sha256 不一致并打 `[warn]`;该次结果必须重跑,不能当作用例缺陷去改代码。 - 成员被中断时交接三件套:已确认结论 / 下一步 / 真正阻塞点;不重放已完成的旧内容。 - 文档类改动只在文末追加章节,不重排既有编号——编号是别人的引用锚点,重排等于制造悬空引用。 --- ## 十、安全边界(第 4 轮同步 · 2026-10-24) 本章同步第 4 轮落盘的四类边界行为,每条都带源码锚点,改代码前先对照。结论先给:**名字当目录名、离职当隔离、碰撞按名册、token 只挡跨站。** ### 10.1 员工名白名单(`//4.1.24.0–4.1.24.3`) 工位目录 `E:/yuangong/<姓名>` 与知识库 `<工位>/知识库` 都由姓名直接拼路径(`staffWorkspacePath` index.js:514、`staffKbPath` :524)。名字一旦带路径语义,知识库就能被写到工作区之外——`name='../../../x'` 已实测复现越界写盘。所以员工名是**安全入口**,不是显示字段。 | 类别 | 允许 / 拒绝规则 | 依据 | | --- | --- | --- | | 长度与空白 | 去空白后非空、原始长度 ≤ 40、首尾不得有空白 | `MEMBER_NAME_MAX=40`;`text !== text.trim()` 直接拒 | | 路径分隔与保留字符 | 不得含 `\ / : * ? " < > \|` | 会改变目录解析或产生非法文件名 | | 控制字符与通配 | 不得含 `\u0000–\u001f` | 终端/日志截断与通配误匹配 | | 上跳 | 不得为 `.`,不得含 `..` | 相对路径上跳 | | 尾部点 | 不得以 `.` 结尾 | Win32 传统 API 会 trim、Node 不 trim,两边解析出不同目录(qa P2-2) | | 设备名 | 不得为 `CON/PRN/AUX/NUL/COM1-9/LPT1-9`(可带扩展名) | Node 走 verbatim 路径能建,但备份/同步/解压工具会按设备解析(qa P2-1) | 拒绝文案(原文照抄,前端与用例按此断言): 1. 入册校验:`员工名「X」不合法:名字会用作目录名,不能包含 / \ : * ? " < > | 、不能含 ..、长度不超过 40 字` 2. 解析越界:`员工名「X」解析后越出工位根目录,已拒绝` 3. 大小写碰撞(见 10.2):`已有员工「Y」与本名仅大小写不同,会共用工位目录互相覆盖;请换一个名字` 注入点与失败面: - **入册即校验**(`staff_hire` `//11.0.0` index.js:778):脏名字不会先存进名册、再在归档时炸。 - **每次拼路径再校验**(`//4.1.24.1` :518):`path.resolve` 之后判是否仍在工位根目录之下,兜住任何未预料的拼接写法——字符白名单之外的双保险,不单靠正则。 - **归档遇非法名不抛异常**:`archiveToKb` 捕获后返回 `{ok:false, reason}`(`//4.1.25.1` :537),避免宿主把工具层错误当 500。 ### 10.2 大小写碰撞(`//11.1.2`) **风险只对大小写不敏感的文件系统成立**:Windows/NTFS 默认如此(macOS 默认 APFS 卷同样不敏感),`Ann` 与 `ann` 会落到同一个工位目录,后写者覆盖前者的知识库——qa 实测 `case.txt` 被覆盖。在区分大小写的文件系统(典型 Linux ext4)上两个名字本可共存。 实现口径:入册时做同形名查重(`name.toLowerCase()` 逐条比对名册,index.js:795–802),仅大小写不同即拒绝。它**不做运行期文件系统探测,一律拒绝**。取舍是明账:Linux 上少一个可用名,换「同一份名册换台机器不会行为突变」——名册会被同步和备份,按运行环境分支才是真风险。文案见 10.1 第 3 条。 ### 10.3 离职隔离矩阵 离职 = `status: 'archived'`(`staff_fire` index.js:1036),**不删记录、不删任务、不删群成员**。档案保留是为了回看,隔离靠每个投递入口自己判 active——不靠一处总闸。 | 入口 | 源码锚点 | 行为 | 结果 / 文案 | | --- | --- | --- | --- | | 私聊 `dm` | `//22.2.3.1` index.js:2916 | 抛错,消息不落盘 | `员工「X」已离职,不能私聊;需要继续使用请重新 staff_hire` | | 群发·点名 | `//19.1.0` :1497 | 锁内、落盘前抛错 | `员工「X」已离职,不能点名` | | 群发·全员 | `//19.2.1` :1511–1515 | 从成员表过滤出 active,离职者不进 targets | 静默,发言照常可见 | | 群发·秘书 | `//19.2.2` :1516–1521 | `secretaryUsable=false`,回落投在职全员 | 静默回落,不打死局 | | 派活 `staff_dispatch` | index.js:1686 | 跳过该员工 | 汇总行 `X:已离职` | | 上线 `wake` | index.js:2676 | 直接 `continue`,不出现在 results | 静默(未绑定工位才会记 skipped) | | 自动驾驶 `autopilot` | `//22.3.1.1` :2955–2956 | 认领条件含 `status==='active'` | 永不认领 | | 工位绑定 `staff-bind-session` | `//4.2.3.1` :2840 | 抛错 | `员工「X」已离职,不能绑定工位` | | 入群 `group_create` / `group_update` | index.js:1308 / :1424 | 成员列表整体校验 | `以下人员未入职或已离职:A、B` | | 知识库归档 `kb-archive` | `//4.2.1` :2815–2830 | **不拦,照常归档** | 见下 | `kb-archive` 是唯一放行口,理由三条: 1. 归档只写本机文件(`<工位>/知识库/…`),不投递、不唤醒 teammate、不产生对外消息——不构成「让离职员工继续接活」。 2. 离职员工的结论仍有复盘价值,封掉等于把历史证据一起删;而 `dm`/`staff_dispatch`/`wake` 已进不来,没有第二条写入口。 3. 归档对象按名册解析(未指定 `employee` 时按 `exec.agent` 反查),拿不到名册项直接报 `无法确定归档对象`,不会凭空造目录。 口径补充:已离职成员**仍留在群 members 里**(`//22.1.4.3` :2444),快照单列 `inactiveMembers`(:2445)供前端提示,doctor 只给 `[warn]`(:2070)。「隔离」是投递面拦截,不是数据面删除。 ### 10.4 应用层 token 的真实边界 依据:`E:/yuangong/.qa-tmp/design/security-token-plan.md` §0.2(:19–28)、§5.3(:231–235)。 一句话:**token 关掉的是「浏览器里的跨站页面」和「第三方脚本误用」,关不掉「同一用户的本机进程」。** | 攻击面 | 现状 | 加 token 后 | | --- | --- | --- | | T1 非本机网络访问 | `isLocalRemote`(index.js:2147)挡在 403 | 不变 | | T2 浏览器跨站页面(别的站点 JS 打 127.0.0.1) | 靠 Origin/Sec-Fetch-Site,但「无 Origin」直接放行(`isSameOriginHeader` :2183、`isAllowedFetchSite` :2198 空值即 true) | 实质收口:自定义头 `x-staff-token` 触发 CORS 预检,预检被 Origin 校验拒,跨站页面拿不到 token | | T3 同用户本机进程(node 脚本、curl、别的宿主) | 完全开放,可自称任意 sessionId 驱动全部 action | **不构成边界**:它也能 `GET /staff-api/client-ping` 读到 token,只多一步 | | T4 Team 内 agent 走工具面 | 不经 HTTP,本就不进 `checkLocalRequest` | 不受影响,身份仍由 `exec.agent` | 所以对外只说两句:本期 token 提高的是 T3 的**门槛**,不能宣称「本机进程也防住了」;中期正解是**下线写路由**——`hire/fire/group-*/dm/kb-archive` 改走 Host IPC 或工具面(工具面本就有 `exec.agent` 身份),HTTP 只留 snapshot 与 ping 两个只读接口,那一步才把 T3 真正纳入边界。在此之前任何「同机隔离已完成」的表述都属越界承诺。 兜底口径:若产品要求同机其他用户也不可访问,把监听从 `0.0.0.0/127.0.0.1` 收到仅当前用户可连的命名管道即可,但写面不收口,同用户进程照样能驱动(§5.3 第 3 条)。 --- ## 十一、HTTP 访问令牌(第 5 轮同步 · 2026-10-24) 本章对应 `index.js` 的 `//22.0.12` 系列与三条路由的接入点(`//22.1.0.1` / `//22.0.12.4` / `//22.2.2`)。结论先给:**令牌真实关掉的是「浏览器跨站页面」,关不掉「同一用户的本机进程」;当前默认 `warn`,只记日志不拦。** ### 11.1 威胁模型(口径不能松) | 攻击面 | 令牌是否收口 | 说明 | | --- | --- | --- | | T2 浏览器里的跨站页面(其他站点 JS 打 127.0.0.1) | **是** | 自定义头 `x-staff-token` 触发 CORS 预检,预检被 Origin 校验拒;响应体同源不可读,跨站页面拿不到 token | | T3 同用户的本机进程(node 脚本、curl、别的宿主) | **否** | 它也能 `GET /staff-api/client-ping` 读到 token,只多一步;不构成进程边界 | | T1 非本机网络访问 | 不涉及 | 仍由 `isLocalRemote`(index.js:2163)挡在 403 | | T4 Team 内 agent 走工具面 | 不涉及 | 工具面不经 HTTP,也就不进 `checkTokenGuard` | 一句可复核的话:同用户进程一条 `curl http://127.0.0.1:/staff-api/client-ping` 就能拿到 token。所以对外的准确说法是「把跨站页面这条路堵死」,不是「本机进程也防住了」。威胁模型全表见 10.4。 ### 11.2 三态与默认值 | 模式 | 生成 / 下发 | 校验失败时 | 典型用途 | | --- | --- | --- | --- | | `off` | 不生成、不下发 | 不校验 | 完全回退到加 token 之前,行为字节级不变 | | `warn`(**默认**) | 生成并下发 | 只记日志与计数,**放行** | 灰度观察期,先看日志再收紧 | | `enforce` | 生成并下发 | `401`,请求不进业务逻辑 | 强制校验 | - 配置只有一个环境变量 `DSH_STAFF_TOKEN_MODE`,取值 `off|warn|enforce`;**未设置或取值非法一律回落 `warn`**(index.js:2286–2291)。 - 判定在模块初始化时读一次(`tokenModeFromEnv()`),改环境变量需重启 DSH 生效。 ### 11.3 token 的生成、下发、存储 - **生成**:首次需要时 `randomBytes(32).toString('hex')`,64 个十六进制字符(32 字节);进程内唯一,重启即换新、旧 token 自然失效(`//22.0.12.1` index.js:2293–2301)。 - **下发**:只有 `GET /staff-api/client-ping` 的响应体带 `token` 字段(`//22.2.2` :2589–2592);`off` 模式不下发,响应体保持三字段 `{ok,at,source}`,老客户端字节级不变。 - **存储**:进程内存 `httpAuth`(`token/createdAt/mode/issued/rejected`),**不落盘、不进 roster.json**,也不出现在快照与日志正文里。 - **比较**:定长 `timingSafeEqual`,长度不同直接返回 false(`//22.0.12.2` :2303–2312)。 ### 11.4 三条路由的校验顺序 | 路由 | 校验顺序 | 是否要令牌 | | --- | --- | --- | | `GET /staff-api/snapshot` | 来源 → Host → Origin → Sec-Fetch-Site → **令牌** → 读数据(`//22.1.0.1` :2403–2408) | 要 | | `POST /staff-api/action` | 来源 → Host → Origin → Sec-Fetch-Site → method(405) → Content-Type(415) → **令牌(401)** → body(400) → action → 归属(**尚未实现**)(`//22.0.12.4` :2619–2625) | 要 | | `GET /staff-api/client-ping` | 来源 → Host → Origin → Sec-Fetch-Site(无令牌) | **不要**,唯一免令牌入口,且只下发 | 顺序不是随意的,两条理由: 1. 令牌放在 method / Content-Type 之后:`405`、`415` 的既有语义不变,别让「方法错」被报成「没令牌」。 2. 令牌放在 `readBody` 之前:无凭证的调用者拿不到请求体处理结果,也省掉一次解析开销。 ### 11.5 401 响应体 固定三段,不回显堆栈(`sendJsonError` index.js:2250–2253): ``` { ok: false, code: 'UNAUTHORIZED', error: '缺少访问令牌' | '访问令牌无效' } ``` - 区分口径:没带该头或值为空 → `缺少访问令牌`;带了但不匹配 → `访问令牌无效`。 - 每次失败 `httpAuth.rejected += 1`,并记 `log('warn','HTTP 令牌校验失败',{mode,route,hasToken,code})`(`//22.0.12.3` :2313–2334)。 - **warn 模式下也会记日志和计数**,但返回 `{ok:true,warn:true}` 放行。日志里 `mode=warn` 就是在提醒:这不是「校验没跑」,是「跑过了但没拦」。 ### 11.6 回滚方式 1. 设 `DSH_STAFF_TOKEN_MODE=off` 并重启 DSH:不生成、不下发、不校验,行为回到加 token 之前。 2. 客户端无需配套改动:off 时 ping 不返回 token,前端就不带 `x-staff-token`(client.js:63–66 注释、138 `staffTokenHeaders`、172 token 赋值);票据侧对应 149 票据头、168 ping `?sessionId=`、174–177 票据三字段(均按 2691 行版本)。 3. 回滚后仍受来源 / Host / Origin / Sec-Fetch-Site 四道闸保护(A-12),不要误解成「裸奔」。 ### 11.7 已知未做(不得写成已完成) 1. **默认仍是 `warn`**:当前所有 `/staff-api` 请求不带 token 也能通过,只留日志;要 `401` 必须显式设 `DSH_STAFF_TOKEN_MODE=enforce`。 2. **归属校验 L1/L2/L3 尚未实现**:token 只证明「能读到下发的 token」,不证明「你是谁」;`sessionId` 仍由请求自报,服务端未做会话归属绑定。 3. **二次确认头未实现**:高危动作(离职、删群等)没有二次确认。 4. **工具面口径未同步**:工具调用不经 HTTP,也就不经 `checkTokenGuard`;HTTP 与工具两条路的鉴权口径目前不一致。 ## 十二、会话归属与二次确认(第 8 轮同步 · 2026-10-24) 本章对应 `index.js` 的 `//22.7.x` 系列(归属段 2336–2575、action 路由接入 2879–2940、confirm 路由 2959–3024;第 9 轮已按 3608 行版本刷新全章行号)。先给结论:本期把「你在哪个会话」从**请求自报**改成**短期票据**(默认 10 分钟、进程内存),把高危动作从**一次点击**改成**120 秒一次性挑战码**;默认 `warn`,只记日志不拦。它真正关掉的是「A 会话页面驱动 B 会话的写操作」与「误点 / 跨站自动提交高危动作」;**不构成对同用户本机进程的鉴权边界**,宿主也没有「当前前台会话」的权威状态可依。 ### 12.1 威胁模型(三种攻击面必须分开说) | 攻击面 | 是否收口 | 依据(文件:行号) | | --- | --- | --- | | 跨会话驱动:A 会话页面用自身票据打 B 会话的动作 | **是** | 票据 sid 与自报 sid 不一致 → `403 SESSION_MISMATCH`(index.js:2923–2926);主体判定只认票据 sid(index.js:2417–2448) | | 跨站自动提交高危动作(离职 / 删群 / 踢人 / 解绑 / 归档) | **是** | 挑战码必须走 query 一次性码,120 秒过期(index.js:2504–2557、2959–3024) | | T3 同用户的本机进程(node 脚本、curl、别的宿主) | **否** | 一条 `GET /staff-api/client-ping?sessionId=X` 就签发一张票(index.js:2834–2845),拿到票即可打 action | | T1 非本机网络访问 | 不涉及 | 仍由 `isLocalRemote` 挡在 403(index.js:2163) | | T4 Team 内 agent 走工具面 | 不涉及 | 工具调用不经 HTTP,也就不进票据 / 归属 / 确认三段(口径见 12.6 第 7 条) | 一句可复核的话:本机执行 `curl "http://127.0.0.1:/staff-api/client-ping?sessionId=<任意会话>"` 就能领票,再用这张票驱动写动作。所以对外准确表述是「把跨会话与跨站驱动这两条路堵死」,**不是**「本机进程也防住了」。 ### 12.2 会话票据:生成、下发、生命周期 - **生成**:`mintSessionTicket(sid)`(index.js:2380–2386)用 `randomBytes(16).toString('hex')` 出 32 个十六进制字符,存 `{sid, exp, used:false}`,有效期 `SESSION_TICKET_TTL_MS` 默认 10 分钟(index.js:2353)。 - **下发**:只有 `GET /staff-api/client-ping` 会签票,响应体带 `ticket / ticketExpiresAt / ticketSessionId`(index.js:2834–2845);`sessionId` 走 query 传参(`readQueryParam` index.js:2401),避免新增自定义头而触发 CORS 预检。 - **校验**:`readSessionTicket(req)`(index.js:2389–2398)三种失败各自有码:未带票 `SESSION_TICKET_REQUIRED`(2391)、票据不存在 `SESSION_TICKET_INVALID`(2393)、已过期则删除并回 `SESSION_TICKET_EXPIRED`(2394–2396)。 - **存储**:两个进程内 Map(index.js:2357–2358),**不落盘、不进 roster.json、不进快照**(index.js:2360);重启即全清。 - **容量上限**:票据与挑战码两个 Map 共用 `TICKET_SWEEP_LIMIT = 2000` 条硬上限(index.js:2355),`trimTicketMap` 两层清理(index.js:2363–2375):**先删过期条目(exp <= now),仍超上限再按 Map 插入顺序删最旧**;`sweepTicketMaps` 在每次签票(2382)与签码(2524)前调用(index.js:2376–2378)。设上限是因为 ping 换票匿名可重复,不封顶会被「无限 ping」把进程内存顶爆。 - **TTL 可覆盖**:`ttlFromEnv`(index.js:2349–2352)读 `DSH_STAFF_TICKET_TTL_MS`(默认 600000)与 `DSH_STAFF_CONFIRM_TTL_MS`(默认 120000),只接受大于 0 的整数(常量见 index.js:2353–2354)。留这个口子是为**可测性**:TTL 硬编码时「已过期」分支在测试里永远走不到,没人会为一条用例等 10 分钟;压到毫秒级才能真的覆盖 `SESSION_TICKET_EXPIRED` 与挑战码过期回 428 两条路径。 - **一次性口径要说准**:`used` 标记只在二次确认消费时置位(index.js:2556);**action 路径的票在 TTL 内可复用**,不是一次性凭证。 ### 12.3 归属分级 L0–L3(矩阵与判定顺序) | 级别 | 动作 | 放行条件 | 依据 | | --- | --- | --- | --- | | L0 | `whoami` | 直接放行,且跳过票据 / 自报一致性检查 | index.js:2452–2466、2478、2921–2923 | | L1 | `kb-archive` | `payload.employee === selfName` | index.js:2486–2491 | | L2 | `group-send`、`history(kind='group')` | `payload.id` 或 `payload.groupId` 存在,且 `selfName` 在该群 `members` 内 | index.js:2492–2499、2467–2472 | | L3 | `hire / fire / group-create / group-delete / group-kick / group-update / staff-bind-session / dm / wake / probe-create-staff-session` | 仅 admin(在 `roots()` 内且非 `emp- / staff-` 工位) | index.js:2452–2466、2484 | 判定链(index.js:2417–2448、2475–2500):票据 sid 为唯一主体来源 → `body.sessionId` 仅记录为 `declaredSid`(2440)→ `emp- / staff-` 前缀判工位(2437)→ `isAdmin = isRoot && !isStaffStation`(2446)→ 分级判定。几个容易写错的点: 1. `dm` 是 **L3** 不是 L1;`history` 按 `payload.kind` 动态分级(index.js:2467–2472)。 2. 未登记动作回落 **L3**,fail-closed(index.js:2473)。 3. 票据与自报不一致才判 `SESSION_MISMATCH`,条件是「非 L0 + 票据可用 + 两侧都有值 + 不相等」(index.js:2923–2926)。 4. 拒绝路径 `ownershipDeny`(index.js:2561–2575):`warn` 只计数加日志后放行,`enforce` 才回 403 `FORBIDDEN_OWNERSHIP`;模式由 `DSH_STAFF_OWNERSHIP_MODE` 决定,非法值回落 `warn`(index.js:2341–2345)。 5. `probe-create-staff-session` 还要 `DSH_STAFF_DEBUG='1'`,否则 `FORBIDDEN_DEBUG_ACTION`(index.js:2480–2482)。 ### 12.4 二次确认:120 秒一次性挑战码 | 环节 | 事实 | 依据 | | --- | --- | --- | | 适用范围 | 固定集合 `staff-fire / group-delete / group-kick / staff-bind-session / kb-archive`;`group-update` 仅当带 `workspace` 或 `secretary` 时纳入 | index.js:2504–2512 | | 目标归一 | `groupId > id > name > employee > member` 取第一个有值的 | index.js:2513–2520 | | 签发 | key 为 `sid` + `action` + `target` 三者拼接,`randomBytes(16)`,TTL 120 秒(默认值,可用环境变量覆盖) | index.js:2522–2529、2354 | | 校验 | 无挑战且**没带**确认头 → `428 CONFIRM_REQUIRED`;无挑战但**带了**头(跨动作 / 跨目标 / 已被清理)→ `403 CONFIRM_INVALID`;挑战在表内但已过期 → 删表回 `428`;头为空 / 不符 / 已用 → `403 CONFIRM_INVALID`;成功即置 `used=true` 单次消费 | index.js:2531–2557(2539–2540、2542–2545、2552–2554、2556) | | **危险字段声明 `fields`** | `GET /staff-api/confirm?action&target&fields=`:`fields=workspace,secretary` 声明本次真实改动的危险字段;`fields=-` 表示明确没有;**不传**时 `group-update` 保守按「要确认」处理。**当前 UI 没有 `fields=-` 触达路径**——`group-update` 只有 client.js:1464、1686 两处调用,都带 `workspace` | index.js:3040–3055(三态与执行侧回比见 12.9) | | 端点 | `GET /staff-api/confirm`:off 下直接 `{ok:true,required:false,disabled:true}`;缺 `action` → 400 `INVALID_ACTION`;不需要确认 → `required:false`;票据不可用 enforce 回 401、否则 428;成功回 `challenge / expiresAt` | index.js:2959–3024(2982–2985、2986–2991、3007–3010、3011–3020、3021–3022) | **为什么必须有 `fields`**:confirm 路由只拿到 `action` 与 `target`(index.js:3034–3037),而「`group-update` 要不要确认」取决于这次请求**有没有改** `workspace` / `secretary`(index.js:2533–2538)。缺这个参数,取码侧只能猜:猜「不需要」→ 回 `required:false`,客户端直接提交,执行侧却按「要确认」回 428,这条路**永久不可用**(第 6 轮复验抓到的 P1-1,源码注释 index.js:3040–3043 已记录);猜「需要」→ 连只改名这种没有危险字段的场景也要多走一次确认,属协议层的无谓打扰(当前 UI 的 `group-update` 两处调用都带 `workspace`,实际还没走到这条分支)。所以协议把判定依据交给客户端显式声明:老客户端不传 `fields` 时,服务端保守构造 `workspace=''` 让它走确认(index.js:3053–3055),宁可多一步,不允许死锁。 顺序有讲究:action 路由里**先归属判定(2923–2931),再二次确认(2932–2938),最后才进 `handleAction`(2940–2942)**——先确认有没有权限,再要挑战码,避免给无权者发码。 ### 12.5 错误码与状态机 | 码 | 状态 | 触发点 | 依据 | | --- | --- | --- | --- | | `SESSION_TICKET_REQUIRED` | 401(enforce)/ 放行(warn) | 请求未带票据头 | index.js:2391、2884–2892 | | `SESSION_TICKET_INVALID` | 401(enforce)/ 放行(warn) | 票据不在表内(重启后必现) | index.js:2393 | | `SESSION_TICKET_EXPIRED` | 401(enforce)/ 放行(warn) | 超过票据 TTL(默认 10 分钟,可用环境变量覆盖) | index.js:2394–2396 | | `SESSION_MISMATCH` | 403 | 票据 sid 与 `body.sessionId` 都有值且不等(L0 除外) | index.js:2923–2926 | | `FORBIDDEN_OWNERSHIP` | 403(仅 enforce) | 分级判定不放行 | index.js:2475–2500、2561–2575 | | `CONFIRM_REQUIRED` | 428(**服务端没有消费任何东西**,可立刻重新取码重试) | 三支:① 表里没有该挑战且确认头为空(`!item` + `given === ''`);② 挑战仍在表里但已过期,先删表再回 428;③ 挑战在表里但确认头为空(`given === ''`) | index.js:2538–2540、2542–2545、2549–2551 | | `CONFIRM_INVALID` | 403(**手里这张码不可信**,必须重新取码,原样重发不会成功) | ① 表里没有匹配挑战(跨动作 / 跨目标 / 已被清理)却**带了**码;② 挑战在表里但码不符或已被消费 | index.js:2539、2552–2554 | | `SESSION_UNKNOWN` | 400 | enforce 下自报会话在宿主里查不到 | index.js:3204–3210 | | `FORBIDDEN_DEBUG_ACTION` | 403 | 调试动作未开 `DSH_STAFF_DEBUG` | index.js:2480–2482 | | `INVALID_ACTION` | 400 | confirm 路由缺 `action` | index.js:2988–2991 | **428 与 403 的差别决定了重试策略,不能写反**:428 = 服务端什么都没消费,客户端可立刻去打 `GET /staff-api/confirm` 签一张新挑战(index.js:3021)再重试同一动作;403 = 手里这张码不可信(跨动作 / 跨目标 / 过期被清理 / 已消费),必须重新取码,原样重发不会成功。判反的代价就是上一轮 P2-8 那类死锁:客户端只在 428 分支去取码(client.js:356–357),把「该确认」判成 403 就永远等不到取码,一路重试失败到 TTL 到期(源码注释 index.js:2536–2538、2546–2548)。 两个 TTL 都由环境变量兜底:`DSH_STAFF_TICKET_TTL_MS`(默认 600000,即 10 分钟)与 `DSH_STAFF_CONFIRM_TTL_MS`(默认 120000,即 120 秒),解析在 `ttlFromEnv`(index.js:2349–2352,只接受正整数),常量在 index.js:2353–2354。 为什么必须留这个口子:TTL 硬编码时「已过期」分支在测试里永远走不到——没人会为一条用例等 10 分钟;把它压到毫秒级才能真的覆盖 `SESSION_TICKET_EXPIRED`(`17-ownership.mjs:239` 就是把票据 TTL 设成 1ms)与挑战码过期回 428 两条路径。压不下去的 TTL,等于过期语义从未被验证过。 ### 12.6 已知与未落地(不得写成已完成) 1. **token 不证明会话身份,票据也不证明**:票据同样是「客户端自报 `sessionId` → 服务端签一张票」,服务端只是把自报值记下来(index.js:2834–2845)。源码注释自己写明:同机进程能自换码,本层是「防误点 / 防跨站驱动 / 留审计」,**不是对抗本机进程的鉴权**(index.js:2502–2503)。 2. **Host 没有「当前前台会话」的权威状态**:`client-ping` 处的注释直接引 `design/session-attribution.md` 说明这一点(index.js:2834–2836);宿主侧 `sessionController` 只注册了 `sessionId` 资源字段(`dsh-api-session-controller/lib/index.js:2953–2954`,转引自 E:/yuangong/.qa-tmp/design/session-attribution.md:75),没有 conversation 服务(同文件 :43)。因此票据绑定的是客户端声明值,不是 Host 的判定结果。 3. **`SESSION_UNKNOWN` 会泄露会话存在性**:enforce 下「自报会话查不到」回 400(index.js:3204–3210),查得到则继续处理;攻击者可以对任意 id 领票试打,用 400 / 非 400 的差异做**存在性探测**。warn 下更隐蔽:记一条日志后仍回落 `roots()[0]`(index.js:3214),静默接受。本期选择「拒绝优于猜测」,代价就是这条侧信道,属已知取舍。 4. **B1(`currentInitiator`)未落地**:在插件源码 `E:/DSH/plugins/dsh-staff` 全目录检索 `currentInitiator` **零命中**,命中全在宿主包里;设计文档也给了结论——HTTP handler 与 `setInterval` 不在 initiator 边界内,取不到值(E:/yuangong/.qa-tmp/design/session-attribution.md:48–50),B1 只能用在工具 `execute` 侧(同文件 :121–123、:157)。所以本期归属仍以票据为准,不是「已拿到真实会话归属」。 5. **前端已接票据与确认(第 7 轮核对)**:`client.js` 维护 `staffSessionTicket` 并在动作请求带 `x-staff-session-ticket`(client.js:149);ping 带 `?sessionId=`(client.js:167);取码走 `fetchConfirmChallenge(action, payload)`(client.js:281–301),并按 payload 声明危险字段——`dangerFieldsOf` 有 `workspace/secretary` 就拼逗号、没有就回 `-`(client.js:272–280);428 时换码重发一次、且只重发一次(client.js:354–357)。仍受限的部分:票据只存页面内存、跨标签不共享;取码遇 401 只换票重试一次(client.js:292–296)。 6. **默认 `warn`**:归属、票据、确认三段在非 enforce 下都只记日志放行(index.js:2561–2575、2884–2892、3011–3020),线上默认不拦;要真拦必须显式设 `DSH_STAFF_OWNERSHIP_MODE=enforce`。 7. **工具面仍未同步**:与 11.7 第 4 条同源——工具调用不经 HTTP,也就没有票据、归属、确认三道闸。 ### 12.7 验收与回滚 - **入口**:17 条后端用例的注册表在 `E:/yuangong/.qa-tmp/verify/run-all.mjs:11`(`const CASES = [...]`,01-storage-corrupt … 17-ownership);本章对应项是 `['ownership','17-ownership.mjs']`(run-all.mjs:30),支持按 id 过滤(run-all.mjs:35),全量跑直接 `node E:/yuangong/.qa-tmp/verify/run-all.mjs`。 - **本轮实测**(第 10 轮门禁全量跑):`node E:/yuangong/.qa-tmp/verify/gate.cjs` → 20 套 `PASS 20 / FAIL 0`,其中 `ownership` 41/41;用例文件 `17-ownership.mjs` 共 250 行、39 处 `r.check(...)`;被测源码 `index.js` sha256 = `9802e13b2e63f4057f35eb3077455114ca7c33199fb1d48185ec984ff40a920e`(3608 行)。 - **覆盖清单**:假宿主两会话 + 员工工位;票据签发 / 缺票 / 过期(TTL 压到 1ms)/ 无效;L0–L3 分级 + 14 个动作矩阵;`SESSION_MISMATCH`;挑战码 120 秒与单次消费(428 / 403),跨动作复用同一张码 → 403 `CONFIRM_INVALID`;`group-update` 的 `fields` 三条(声明改 workspace → 要确认;`fields=-` → 不弹;不传 → 保守要确认;三条都是协议层用例,UI 暂无 `fields=-` 路径;P3-9/P3-10 后的三态与执行侧回比另见 12.9.6);`wake` 非法 scope → 400 `INVALID_SCOPE`;`whoami` 只认票据(`claimedSessionId='s-admin'` 而 `declaredSessionId='s-other'`);enforce 下自报会话不存在 → 400 `SESSION_UNKNOWN`;enforce 下快照仍 200;票据不写进 `roster.json`。 - **回滚**:把 `DSH_STAFF_OWNERSHIP_MODE` 调回 `warn`(或未设置,默认即 `warn`,index.js:2341–2345)即回到「只记日志」;票据与挑战码 Map 随之失效,重启进程全清(index.js:2360)。回滚不影响 token 与来源校验,不要误解成「裸奔」。 - **一致性**:契约侧对应 `CONTRACT.md` 的 A-15;本章与 A-14(令牌)是两层不同的防线,不得互相替代表述。 ### 12.8 whoami 的 memberCount 与 declaredSessionId 语义(第 8 轮同步) - **只回人数、不回身份(qa P2-1 收口)**:`revealIdentities = ownership.mode === 'enforce'`(index.js:3334);非 enforce 时成员明细恒空(3342、3350)、`roots` 回 `[]`(3364),只留数字 `memberCount`(3365)。根因在 `client-ping` 用自报 `sessionId` 换票、warn 下人人可换,不遮蔽的话 whoami 就是「任意会话 → 成员名 + 全进程会话 id」的 oracle(注释 3330–3333)。 - **人数只认票据 sid(qa P2-9 收口)**:`countSession` 只取 `principal.sid`(index.js:3339–3340),无票据时自报 `sessionId` 换不到人数,`memberCount` 恒 0(3343);「1 人 vs 0 人」这条存在性侧信道因此只在持票时才成立(注释 3335–3336)。 - **`off` 是唯一的例外,属显式降低防护**:`DSH_STAFF_OWNERSHIP_MODE=off` 是关闭防护的调试模式,此时 `countSession` 允许回落请求级 `sessionId`(index.js:3341、注释 3337–3338),否则自检按钮在 off 下永远显示 0 人。这是调试可用性取舍,不是绕过归属判定的漏洞,别写成「已修」;**`off` 仅限本机调试、不得用于生产**(V-18.62)。契约侧对应 `CONTRACT.md` A-17。 - **`declaredSessionId` 不再恒 null(qa P3-12)**:`declared` 先取请求体顶层的 `principal.declaredSid`(入口 index.js:2920 的 `parsed.sessionId`),再回落 `payload.sessionId`(index.js:3324–3326),两条入口都覆盖(注释 3321–3323,V-18.63);旧实现只读 `payload`,而客户端真实形状是顶层,排障时会误判成「客户端没报」。 ### 12.9 声明字段与执行字段必须一致(第 9 轮同步 · qa P3-9 / P3-10) 12.4 的 `fields` 只解决「取码侧猜不猜得出要不要确认」;本节解决第二个问题——**取码时声明改 A、执行时实际改了 A+B**。不管的话,老板在弹框里确认的是「改秘书」,实际被执行的是「改工作区」,照样 200 通过(P3-9 复现实测)。 #### 12.9.1 `fields` 三态(P3-10) | 请求形态 | `declaredFields` | 语义 | | --- | --- | --- | | 没有 `fields` 参数 | `null` | 老客户端 / 未声明 → 保守按「要确认」;`group-update` 时构造 `workspace=''` 让它走确认 | | `fields=`(空值) | `null` | 声明了却没给字段,**同样按未声明**(不是「没有危险字段」) | | `fields=-` | `[]` | 明确声明本次没有危险字段 | | `fields=workspace,secretary` | `['workspace','secretary']` | 声明本次真实改动的危险字段 | 实现:`index.js:3044–3055`(`//22.7.8.3`),空值/不存在 → `null`(3049–3051),`-` → `[]`,其余按逗号切并 `trim`/去空。 参数存在性判定改用 `hasQueryParam`(`index.js:2415–2430`,`//22.7.0.4`):按 `&` 切段、逐段 `decodeURIComponent` 后与 key **精确相等**,不再用 `url.indexOf('fields=')`——后者会把 `xfields=best`、`notfields=` 当成「客户端声明了」(P3-10 实测,`V-18.19` / `V-18.19.1`)。必须另做存在性判断的原因是 `readQueryParam` 把「没这个参数」与「参数值为空」都返回 `''`(注释 2415–2416),只靠值区分不了 `fields=` 空值。 #### 12.9.2 挑战码记录「确认的是什么」 `mintConfirmChallenge` 增加第四参 `declaredFields`(`index.js:2553`,注释 2550–2552 `//22.7.3.5`),把三态结果原样写进挑战码记录(2558 `confirmChallenges.set(... fields: declaredFields)`);`confirm` 路由把三态结果传进来(3073)。于是「老板确认的字段集合」跟着码走,不靠执行请求自证。 #### 12.9.3 执行侧回比:声明没覆盖的危险字段 → 作废该码 + 428 `checkConfirm` 在「码有效且未消费」之后、置 `used=true` 之前回比(`index.js:2586–2602`,`//22.7.3.6`): 1. `dangerFieldsOfPayload(action, payload)` 求真实载荷里的危险字段(`index.js:2432–2442`,`//22.7.0.5`;`DANGER_PAYLOAD_FIELDS = ['workspace','secretary']`,只对 `group-update` 生效)。 2. 与挑战码里的 `item.fields` 求差集;差集非空 → **先 `confirmChallenges.delete(key)` 作废这张码**,再回 `428 CONFIRM_REQUIRED`(删码 2593、文案 2599):`确认内容与本次改动不一致(确认的是「X」,本次还改了「Y」),请重新确认`(2599)。作废是先做的一步,避免同一张码被反复拿来试不同载荷(`V-18.19.3`)。 3. `item.fields` 为 `null`(未声明)→ 整段跳过,保持宽松(注释 2587–2589)。 4. 覆盖一致 → 继续单次消费(2604–2606)。 #### 12.9.4 为什么字段不一致用 428,而不是 403 客户端只在 `428` 分支自动取码并重发**一次**(`client.js:360–374`);字段不一致的本质是「内容变了、需要重新确认」,回 428 能自愈。回 403 则客户端一律不重试(`client.js:375–377`),老板会卡在「确认了却永远失败」——这正是 P2-8 那类死锁的翻版。 但「码已消费 / 码不符」仍然是 403(`index.js:2583–2585`):那张码本身不可信,重试没有意义。两类不能混——428 是「重新取码就能过」,403 是「这张码作废、得从头确认」。 #### 12.9.5 兼容口径:未声明就不校验 只有 `Array.isArray(item.fields)` 才做回比,`null` 直接放行。原因写在代码注释里:否则升级前后老客户端会永久 428(2587–2589)。代价是**老客户端仍可超范围执行**——这是已知残余风险,不是漏修,别写成「已修」。 #### 12.9.6 验收锚点 | 用例 | 断言 | | --- | --- | | `V-18.18` | `fields=` 空值 → 按未声明保守 `required=true`(P3-10 已修) | | `V-18.19` | 参数名子串 `xfields=` 不再被当成声明 → `required=true` | | `V-18.19.1` | `notfields=` 与真 `fields=-` 共存 → 只认精确 key,`required=false` | | `V-18.19.2` | 声明 `secretary` 却改 `workspace` → 428 `CONFIRM_REQUIRED`,文案点明不一致(P3-9 已修) | | `V-18.19.3` | 不一致后该码被作废:同一张码再用 → 403 `CONFIRM_INVALID`,蒙不过一次 | | `V-18.19.4` | 声明 `workspace` 且确实改 `workspace` → 不再被确认层拦(428/403 都不是) | | `V-18.42` | 原「声明字段错配仍 200」翻转为 428(P3-9 已修) | | `V-19.14` | 取码回 `required:false` → 不带码重发一次并成功(不再误报「二次确认失败」,P3-11) | | `V-19.14.1` | `required:false` 后执行仍 428 → 抛「确认状态不一致」、不循环、不含误导文案 | 入口:`node .qa-tmp/verify/run-all.mjs ownership-attack`、`node .qa-tmp/verify/run-all.mjs client-ticket`;全量 20 套见 `gate.cjs`。 > 行号按 `index.js` 3608 行版本核对。12.4 表内其余行号仍是上一版,整章刷新时按本节方法(锚点注释编号 + 行号双锚)统一校正;`//22.7.x` 注释编号不随行号漂移,始终可用。 ## 十三、安全边界总表:谁能调什么(第 8 轮同步 · 2026-10-24) 本章是**合订视图**,不新增任何机制:把 A-12(本机准入)、A-14(访问令牌)、A-15(会话归属与二次确认)、路径白名单、离职隔离五层收进一张表,回答「谁能调什么、凭什么、拦不住什么」。行号对应 `index.js`(3608 行,sha256 = `9802e13b2e63f4057f35eb3077455114ca7c33199fb1d48185ec984ff40a920e`);`client.js` 2691 行(sha256 `81bdfd17bf15…a21ecb7`)。机制细节见十一、十二章,本章只做汇总与交叉核对。 ### 13.1 先看三条结论 1. **本机同用户进程能 ping 换票,token 不是硬边界。** `client-ping` 是唯一免令牌入口(index.js:2817–2848):本机进程直接 `GET /staff-api/client-ping` 就能拿到 32 字节 hex 令牌(2832–2833),再带 `?sessionId=<任意值>` 换一张 10 分钟票据(2837–2845)。准入层也挡不住它——`Origin` 不存在时视为通过(2203),`Sec-Fetch-Site` 不存在时视为通过(2217),只要 Host 头写 `127.0.0.1:<端口>` 就能过(2234–2237)。源码注释自己写明:令牌只管浏览器跨站页面(2281–2285),票据是「防误点 / 防跨站驱动 / 留审计」,**不是对抗本机进程的鉴权**(2336–2340、2502–2503)。 2. **默认 `warn`:三层闸门都只记日志放行。** 令牌在 `warn` 下返回 `{ok:true,warn:true}`(2332);归属与确认统一走 `ownershipDeny`,非 `enforce` 时返回 `false` 不拦(2572);票据缺失在 `warn` 下只记日志(2885–2892)。所以下表所有「✗ 拒绝」只有在 `DSH_STAFF_OWNERSHIP_MODE=enforce`(归属/票据/确认)与 `DSH_STAFF_HTTP_TOKEN=enforce`(令牌)下才真正生效。 3. **五层不是五堵并列的墙,挡的是五个不同面向。** 准入(A-12)挡跨站请求;令牌(A-14)挡无凭证调用;归属与确认(同属 A-15,实现上是两个独立机制)分别挡越权动作与误点 / 跨站驱动;路径白名单挡越界写盘;离职隔离挡档案残留。**工具面(不经 HTTP 的 `staff_*` / `group_*` 调用)这五层一道都不过**,见 12.6 第 7 条。 ### 13.2 各层落点:五层六行(A-15 内含归属与确认两段) A-15 在实现上是两个独立机制(分级判定与挑战码),故下表拆成两行;对外口径仍是「五层」。 | 层 | 拦什么 | 明确不拦什么 | 依据 | | --- | --- | --- | --- | | 准入(A-12) | 非本机来源、Host 非白名单(防 DNS rebinding)、Origin 跨源、`Origin: null`、`Sec-Fetch-Site` 跨站、写路由非 JSON 的 Content-Type | 本机任意进程;缺 `Origin` / 缺 `Sec-Fetch-Site` 的请求视为通过;不校验调用者身份 | index.js:2159–2247(`isLocalRemote` 2163–2166、Host 2194–2197、Origin 2201–2212、Fetch-Site 2215–2219、JSON 2223–2226、统一入口 2230–2247) | | 令牌(A-14) | 无 `x-staff-token` 或令牌不符的调用(`enforce` 下 401) | 本机进程:令牌可用 `client-ping` 自取;不绑定会话、不绑定用户、不防重放 | index.js:2281–2334(模式 2286–2292、常量时间比较 2304–2312、校验 2315–2334) | | 会话归属(A-15) | 越级动作、跨会话驱动(票据 sid 与自报 sid 不一致 → `SESSION_MISMATCH`)、L3 动作的非管理员调用 | 本机进程:票据由自己声明 `sessionId` 换来,等同冒用该 sid;不证明真实身份 | index.js:2336–2575(票据 2380–2398、主体解析 2417–2448、分级 2452–2474、判定 2475–2500) | | 二次确认 | 五个固定高危动作 + 带 `workspace`/`secretary` 的 `group-update`(无挑战码 428、码无效 403、单次消费、120 秒) | 本机进程:挑战码同样能自取;防的是误点与跨站自动提交,不是鉴权 | index.js:2502–2557(集合 2504、触发 2505–2512、目标 2513–2520、签发 2522–2529、校验 2531–2557) | | 路径白名单 | 员工名里的路径分隔符、`..`、控制字符、Windows 保留设备名、原型链关键字;解析后越出工位根的写入 | 只约束「员工名 → 路径」这一步;`STAFF_HOME` 之内的符号链接/junction 不解析(`path.resolve` 是纯词法,未实测链接场景) | index.js:491–530(`STAFF_HOME` 492、`isValidMemberName` 496–512、`assertMemberName` 513–517、`staffWorkspacePath` 518–527、`staffKbPath` 528–530;调用点 544、782、3302) | | 离职隔离 | 已归档员工:派活、建群、进群、被点名、全员投递、派发、绑工位、私聊、列为群成员告警 | 不删除历史任务与群成员记录;不吊销其既有会话(会话本身仍在内存注册表) | 归档 1013–1066(`status='archived'` 1040);列表过滤 856–858;派活 943;建群 1313;进群 1429;点名 1501;全员投递 1515–1520;派发 1690;doctor 2074;绑工位 3299–3300;私聊 3401–3408(在职校验 3408) | ### 13.3 五个主体 × 可调动作 五个主体的判定依据全部来自 `resolvePrincipal`(index.js:2417–2448):`sid` 只取票据(2420),`isStaffStation` 看 `emp-`/`staff-` 前缀(2437),`isRoot` 看是否在 `agents.roots()` 里(2443),`isAdmin = isRoot && !isStaffStation`(2446),`selfName` 来自 `agentTeams.membership`(2430–2434)。 | 主体 | 判定依据 | 能否拿令牌/票据 | 键事实 | | --- | --- | --- | --- | | ① 浏览器同源页面(老板窗口) | 本机来源 + Host 白名单 + 同源 Origin + `same-origin`;带 token 与票据 | 能(页面加载先 ping) | 根会话时 `isAdmin=true`,除确认类外全放行 | | ② 本机同用户进程 | 同为 127.0.0.1,无 Origin / 无 Fetch-Site 也算过 | **能,且无需凭证** | `client-ping` 自取 token(2832–2833)→ 带 `?sessionId=` 换票(2837–2845)→ 以任意 sid 身份调 `action` | | ③ 员工工位会话 | `sid` 以 `emp-`/`staff-` 开头 | 需 ping 时自报 | 即使在 `roots()` 里也不算管理员(2446);L1 只动自己、L2 只动自己所在群、L3 全拒 | | ④ 根会话 | `sid` 在 `agents.roots()` 且非工位前缀 | 同① | `isAdmin=true`(2446)→ 跳过分级拒绝(2483) | | ⑤ 调试/运维脚本 | 两种形态:HTTP 探针需 `DSH_STAFF_DEBUG=1`(2480–2482);本地直接调工具则完全不经 HTTP | 走 HTTP 时同②;走工具时不需要 | 工具面无票据/归属/确认(12.6 第 7 条) | 动作矩阵如下。✓ = 允许;✗ = 拒绝(仅在 enforce 下真正返回错误码);「确认」列表示该动作是否有 120 秒单次挑战码。 | 动作 | 级别 | ① 浏览器根会话 | ③ 员工工位会话 | 其他非根会话 | ② 本机进程 | 确认 | 依据 | | --- | --- | --- | --- | --- | --- | --- | --- | | `whoami` | L0 | ✓ | ✓ | ✓ | ✓ | — | 2453;L0 不受 sid 一致性约束 2921–2926 | | `kb-archive` | L1 | ✓ | ✓ 本人 | ✗ 无 selfName | ✓ 冒用任意 sid | 是 | 2454、2486–2491、2504 | | `history` | L1/L2 | ✓ | ✓ 本人 / 本群 | ✗ | ✓ | — | 2467–2474(`kind=group` → L2,否则 L1) | | `group-send` | L2 | ✓ | ✓ 仅本群 | ✗ | ✓ | — | 2455、2492–2499 | | `wake` | L3 | ✓ | ✗ | ✗ | ✓ | — | 2456、2483–2484;非法 `scope` 另回 400 `INVALID_SCOPE` 3217–3231 | | `hire` | L3 | ✓ | ✗ | ✗ | ✓ | — | 2457 | | `group-create` | L3 | ✓ | ✗ | ✗ | ✓ | — | 2458 | | `group-delete` | L3 | ✓ | ✗ | ✗ | ✓ | 是 | 2459、2504 | | `group-kick` | L3 | ✓ | ✗ | ✗ | ✓ | 是 | 2460、2504 | | `group-update` | L3 | ✓ | ✗ | ✗ | ✓ | 仅改 `workspace`/`secretary` | 2461、2508–2510 | | `staff-bind-session` | L3 | ✓ | ✗ | ✗ | ✓ | 是 | 2462、2504 | | `staff-fire` | L3 | ✓ | ✗ | ✗ | ✓ | 是 | 2463、2504 | | `dm` | L3 | ✓ | ✗ | ✗ | ✓ | — | 2464 | | `probe-create-staff-session` | L3 | ✓ 需 `DSH_STAFF_DEBUG=1` | ✗ | ✗ | ✓ 需同环境变量 | — | 2465、2480–2482 | | 未登记动作 | L3 | ✓ | ✗ | ✗ | ✓ | — | fail-closed,回落 L3 2473 | 四条 HTTP 路由的守卫链差异(同一张表看不出这个,单独列): | 路由 | 准入 | 令牌 | 票据 | 归属 | 确认 | 依据 | | --- | --- | --- | --- | --- | --- | --- | | `GET /staff-api/snapshot` | 是 | 是 | 否 | 否 | 否 | 注册 2634、准入 2639、令牌 2645 | | `GET /staff-api/client-ping` | 是 | **否(唯一免令牌)** | 签发 | 否 | 否 | 2817–2848 | | `POST /staff-api/action` | 是 | 是 | 是(`enforce` 下 401) | 是 | 是 | 2853–2955 | | `GET /staff-api/confirm` | 是 | 是 | 换码要票 | 否 | 签发挑战码 | 2956–3024 | `action` 路由内部顺序是硬约束,改动必须整链复核:准入(2856)→ POST(2861–2864)→ Content-Type(2865–2871)→ 令牌(2874–2878)→ 票据(2884–2892,**必须在 readBody 之前**,否则客户端换票重放会变成重复投递,见 2881–2883)→ 读体(2895,64KB)→ JSON(2904–2910)→ action 非空(2911–2915)→ 主体解析(2920)→ sid 一致性(2921–2926)→ 分级(2927–2931)→ 二次确认(2932–2938)→ 执行(2940)。 ### 13.4 失败码总表 | 码 | 状态 | 触发条件 | 所属层 | 依据 | | --- | --- | --- | --- | --- | | `FORBIDDEN_REMOTE` | 403 | 来源地址不是 127.0.0.1 / ::1 / ::ffff:127.0.0.1 | 准入 | 2231–2233 | | `FORBIDDEN_HOST` | 403 | Host 头不在 `127.0.0.1` / `localhost` / `[::1]` 白名单(端口不限) | 准入 | 2234–2237 | | `FORBIDDEN_ORIGIN` | 403 | Origin 存在但跨源,或为 `null` | 准入 | 2238–2241 | | `FORBIDDEN_FETCH_SITE` | 403 | `Sec-Fetch-Site` 存在但不是 `same-origin` / `none` | 准入 | 2242–2245 | | `METHOD_NOT_ALLOWED` | 405 | `action` 非 POST、`confirm` 非 GET | 协议 | 2861–2864、2968–2971 | | `UNSUPPORTED_MEDIA_TYPE` | 415 | 写路由 Content-Type 不是 `application/json`(防 text/plain 简单请求) | 协议 | 2865–2871 | | `UNAUTHORIZED` | 401 | 缺 `x-staff-token` 或令牌不符(`enforce` 下) | 令牌 | 2323–2325 | | `SESSION_TICKET_REQUIRED` | 401 | 缺 `x-staff-session-ticket` | 归属 | 2391 | | `SESSION_TICKET_INVALID` | 401 | 票据不在表内(伪造或已被清理) | 归属 | 2393 | | `SESSION_TICKET_EXPIRED` | 401 | 票据超时(默认 10 分钟,`DSH_STAFF_TICKET_TTL_MS` 可覆盖) | 归属 | 2394–2396、2353 | | `SESSION_MISMATCH` | 403 | 票据 sid 与请求体 `sessionId` 不一致(L0 动作豁免) | 归属 | 2924 | | `FORBIDDEN_OWNERSHIP` | 403 | 动作级别判定的默认拒绝码(L3 非管理员、L1 非本人、L2 非群成员) | 归属 | 2477、2484–2499、2929 | | `FORBIDDEN_DEBUG_ACTION` | 403 | 调试动作未开 `DSH_STAFF_DEBUG` | 归属 | 2480–2482 | | `CONFIRM_REQUIRED` | 428 | 需要确认但没带确认码;或挑战码已过期被清(换码时缺票也回这个) | 确认 | 2540、2544、2550、3018 | | `CONFIRM_INVALID` | 403 | 带了确认头但没有匹配挑战(跨动作 / 跨目标 / 已清理),或码不符 / 已消费 | 确认 | 2539、2552–2553 | | `SESSION_UNKNOWN` | 400 | `enforce` 下自报会话在宿主里查不到 | 归属 | 3206–3210 | | `INVALID_SCOPE` | 400 | `wake` 的 `scope` 不合法(kind 非 employee/group,或缺 name/id) | 业务 | 3226–3228 | | `INVALID_ACTION` | 400 | `action` 路由缺 `action` 字段,或 `confirm` 缺 `action` 参数 | 协议 | 2913、2988–2991 | | `INVALID_BODY` / `INVALID_JSON` | 400 | 读体失败(超 64KB)或请求体不是合法 JSON | 协议 | 2899、2908 | 两个 TTL 都由环境变量兜底(`DSH_STAFF_TICKET_TTL_MS` 默认 600000、`DSH_STAFF_CONFIRM_TTL_MS` 默认 120000,见 2349–2354);两个 Map 各有 2000 条硬上限,先删过期再按插入顺序删最旧(2355、2363–2378),且只存内存、不落盘(2360)。 ### 13.5 这张表不承诺什么(诚实清单) 1. **不是身份认证。** token、票据、挑战码三者都不证明「你是你」:票据绑的是客户端自报的 `sessionId`(2420、2837–2845),本机同用户进程可自取可伪造。真正的隔离只有「别的用户 / 别的机器进不来」这一条。 2. **`warn` 是默认值。** 不上 `enforce` 时上表所有拒绝只存在于日志里(2332、2572、2885–2892)。写文档、做验收都不能把 warn 下的行为当成「已拦截」。 3. **`SESSION_UNKNOWN` 泄露存在性。** 对任意 sid 领票试打,`400 / 非 400` 能反推会话是否存在(3206–3210);`warn` 下更隐蔽,记日志后回落 `roots()[0]`(3214)。 4. **工具面无闸。** 不经 HTTP 的调用没有准入、令牌、归属、确认(12.6 第 7 条);「五层」只覆盖 Web 面。 5. **离职隔离是业务入口过滤,不是会话吊销。** 归档只改花名册记录(1040),已存在的工位会话与群成员记录仍在(2750、1515–1520),只是不能再被派活/投递/绑定。 6. **路径白名单是词法防护。** `staffWorkspacePath` 用 `path.resolve` 判前缀(522–525),不解析符号链接/junction;工位根目录内若存在指向外部的链接,写入会跟随。此条未实测,按已知限制登记。 7. **`fields` 是客户端自证。** `confirm` 路由只拿得到 `action + target`(2992–2995),本次是否真改 `workspace`/`secretary` 由客户端用 `fields` 声明;老客户端不传时按「要确认」保守处理(2999–3000)。这是可用性与严谨性的折中,不是服务端独立判定。 ### 13.6 验收与复验 - **回归入口**:`node E:/yuangong/.qa-tmp/verify/run-all.mjs ownership` → 第 8 轮实测 `PASS 41/41`、743ms、exit=0(用例 `17-ownership.mjs`,250 行 / 39 处 `r.check`)。本章不新增用例,只做交叉核对;用例注册见 `run-all.mjs:11`、`:30`。 - **手工复验**:`curl -s -H 'Host: 127.0.0.1:<端口>' 'http://127.0.0.1:<端口>/staff-api/client-ping?sessionId=whatever'` 能同时拿到 `token` 与 `ticket`(2817–2848),这一条就是 13.1 第 1 点最直接的证据。 - **一致性**:本章与 `E:/yuangong/.qa-tmp/CONTRACT.md` 的 A-12(准入)、A-14(令牌)、A-15(归属与确认)对应;三处口径若有冲突,以代码为准并同步修契约。 > 口径提醒:写「本机进程可绕过」不等于「这套机制没用」——它挡的是跨站页面、误点、越权与越界写盘四类真实风险,只是**不挡同用户的本地进程**。任何把它说成「已实现本地鉴权」的表述都是错的。 ## 十四、交互规范与 P2 存量清零台账(第 10 轮建立 · 第 11 轮同步 · 2026-10-24) 本章回答两个问题:交互层现在为什么这么写(视觉常量、焦点、菜单、确认层、文案、窄窗),以及审计 P2 清单里**哪些真的关了、哪些是明确保留、哪些还没做**。第五章写「长什么样」,本章写「为什么这么写」和「欠账在哪」。 写作基线:`client.js` 2745 行(sha256 `c64e684897a6…7bd9a7`,mtime 2026-10-24 16:44:38)、`index.js` 3608 行(sha256 `9802e13b2e63…f40a920e`,16:01:17)。对应验收用例:`21-ui-p2.mjs`(自报 16 条)、`22-ui-p2b.mjs`(20 条,311 行,sha256 `0c75a03fd60c…58a48c`)、第 11 轮新增 `24-focus-a11y.mjs`(18 条,275 行,sha256 `9ab7f7adeea9…91ecebb`),注册在 `gate.cjs:40`、`gate.cjs:41`、`gate.cjs:42`。**行号漂移说明**:14.2 焦点段与表 A 的 P3-18/P3-19 两行已按 2745 行版本重取锚点;14.1、14.3–14.6 的既有锚点仍是第 10 轮 2691 行基线,实测已漂移(`HIT` 401→404、`COPY` 410→413、`confirmAsk` 1297→1302、`leftWidth` 1715→1762、确认层 2185→2232),整批重刷登记在 14.8 未覆盖第 ⑦ 项。 审计原始清单在 `E:/yuangong/.qa-tmp/review/ui-audit-round7.md`(P0 无、P1×3、P2×11);复验在第 10 轮 `E:/yuangong/.qa-tmp/review/round10-recheck.md`。本章不重复审计正文,只落「结论 + 代码锚点 + 保留理由」。 ### 14.1 三个常量表:T / HIT / COPY | 常量 | 落点 | 解决什么 | 为什么单独建表 | | --- | --- | --- | --- | | `T`(设计令牌) | client.js:383–399 | 颜色、遮罩、边框全部走宿主主题变量 | 写死色值在暗色主题下不可读:`labelOnBrand`(392)改成 `var(--dsw-alias-label-primary-foreground)`,注释 389–391 记录「原先 10 处写死白字」 | | `HIT`(统一命中区) | client.js:401–408 | minWidth/minHeight 28、border-box、inline-flex、padding 4×6 | **刻意不放进 `T`**:`T` 只允许 `var(--…)` 值,`20-ui-tokens` 会判违规(注释 401–403);也不用负 margin 骗高度 | | `COPY`(文案单一来源) | client.js:410–421 | 同一故障产品内只有一种说法 | 原来一处「员工接口不可用,正在自动重试…」、一处「员工制:接口不可用」、一处「立即刷新」,用户不知道是否等价(注释 410–412) | `HIT` 的使用点共 6 处:907(成员条移出)、1143(状态带 ×)、1448(× / 返回对话)、1732(左栏立即重试)、2075(提示条 ×)、2102(陈旧条立即重试)。`Object.assign({ all: 'unset', … }, HIT)` 的顺序不能反,否则 `all: unset` 会把命中区抹掉。 ### 14.2 焦点可见与键盘可达 - `focusRingOn`(586–593)/ `focusRingOff`(594–598):面板内 18 个按钮都是 `all: 'unset'`、4 个输入框 `outline: 'none'`,Tab 走一圈看不出焦点落在哪。统一补 `2px solid T.brand` + `offset 2`,且**不注入全局样式表**——插件不能改宿主全局 CSS。 - 焦点轮廓用捕获阶段挂在容器上(内联 2236–2237、浮层 2247–2248),这样新增按钮不需要逐个记得加 `onFocus`。 - 会话行补了 `focused` 状态与 `hovered` 取并集(645–647),渲染条件 `(hovered || focused) && !active`(653):`blur` 移入本行动作按钮时不能收起,否则键盘用户永远点不到那两个按钮;两个动作按钮的 `onFocus` / `onBlur` 在 662 / 671。 **确认层焦点管理(P3-19,第 11 轮补齐)** - 初始焦点到「取消」:`useEffect` 在 `confirmState` 置位后执行(client.js:1325–1336),先记下打开前的 `document.activeElement`(1329),再 `safeFocus(confirmCancelRef.current || confirmCardRef.current)`(1330)。**不用 **`autoFocus`** 属性**:宿主的精简渲染器可能忽略它(注释 1314–1315),属性写了不等于焦点到了。 - 卡片内 Tab 自循环:`trapConfirmTab`(1344–1357)只取「取消 / 确定」两个 ref(1348),先 `preventDefault`(1349),`Shift+Tab` 反向(1352),索引取模轮转(1353–1355);取不到可聚焦节点时直接放行——宁可让 Tab 逃出卡片,也不把键盘锁死在弹层里。入口是卡片 `onKeyDown` 的 Tab 分支(2254)。 - **三条关闭路径都还原焦点**:Esc(2253)、点遮罩(2240)、点「取消」(2268)都只调 `setConfirmState(null)`,还焦点统一写在 `useEffect` 的 cleanup 里(1331–1335,`safeFocus(origin)` 在 1334);「确定」走 `closeConfirm({ runOk: true })`(1337–1343、2275),内部同样是先清状态、再执行回调。所以不是四条路径各写一遍恢复逻辑(注释 2238–2239、2252–2253),新增关闭入口只要清状态就自动还焦点。 - **ref 缺失时降级**:`safeFocus`(1320–1324)对「node 为空」「没有 focus 方法」「focus() 抛错」三种情况静默跳过,确认/取消动作照常执行;卡片以 `tabIndex -1` + `confirmCardRef`(2244–2245)作为兜底落点,桩环境建不出真实按钮时焦点至少仍在弹层内,不会落到背后的页面上。 ### 14.3 菜单外部关闭与 ESC 分层 | 菜单 | 外部关闭实现 | 关键点 | | --- | --- | --- | | 消息气泡右键菜单 | `pointerdown` + `scroll` 双监听(748–763,`bubbleMenuRef` 747) | 两个监听都用 capture;`node.contains(event.target)`(752)排除菜单内部;`scroll` 也要关,否则滚动后菜单浮在错误位置 | | 主面板会话菜单 | 1280–1295(`menuRef` 1233) | 同 `contains` 判定(1284);卸载时两个 remove 都执行(1292–1293) | | 列表右键菜单 | 容器 2107–2112 | `role: 'menu'`(2111)+ 菜单项 `role: 'menuitem'` 配对;`ref: menuRef`(2110)复用主面板的关闭逻辑 | ESC 是**退出最上面一层**(注释 1254–1263),顺序写死在 1254–1276:`defaultPrevented` 先放行(1260)→ `confirmState`(1264)→ `dialog`(1265)→ `menu`(1266)→ 内联面板先回对话页、否则关面板(1268–1272)。输入框里还有两层更前面的归属:@ 候选层(997–999)与草稿非空(999–1000),都靠 `preventDefault` 让面板级监听放行。 ### 14.4 确认层:只留一套范式 `confirmAsk`(1297–1311)是唯一入口,`window.confirm` 只出现在 1303 的降级分支(`typeof window.confirm !== 'function'`,宿主没有对话框时);全文件执行 `window.confirm` 的只有这 1 处,另 1 处是注释。自绘层 `confirmLayer` 在 2185–2227: - 遮罩 2186–2191:`rgba(0,0,0,0.32)`(2188)、`zIndex 9800`、点遮罩关闭(2191)。 - 卡片 2193–2206:`role='dialog'`(2194)、`aria-modal='true'`(2195)、`aria-label` 取标题(2196)、`onClick` 阻止冒泡(2197)。 - Esc 由卡片自己吃掉(2198–2201):`preventDefault` + `stopPropagation` 同时做(2200),否则会冒泡到 window 把整个员工中心关掉。 - 尺寸 420 / `maxWidth 90vw`(2204);确认按钮配色 `confirmState.danger ? T.danger : T.brand`(2224),文案 `confirmState.confirmText`(2226)。 六个调用点全部走同一个入口:1608(让此人离职)、1656(解散此群)、1830(详情页离职)、2001(移出本群)、2012(`onKick`)、2019(`onDeleteGroup`)。 ### 14.5 空态与失败文案:句末不带句号、有下一步就给动作 统一口径写在注释 1703–1704:**句末不带句号;有下一步就给动作;没有动作就说清状态**。裸词(「暂无」「不在任何群」「(未填写)」)零命中。 | 场景 | 现文案 | 落点 | | --- | --- | --- | | 左栏无任何会话 | 还没有会话,点「+ 员工」或「+ 群」新建 | 1708(`emptyText` 1702–1709) | | 检索无匹配 | 没有匹配的会话 | 1709 | | 聊天区无消息 | 还没有消息,说第一句吧 | 925 | | 未选会话 | 选择左侧会话开始 | 2050 | | 职务未填 | 未填写 | 1805(注释 1804) | | 无未完成任务 | 暂无未完成任务 | 1813(注释 1812:只写「暂无」丢了对象) | | 不在任何群 | 还没有加入任何群 | 1818(注释 1817:结论式否定改成状态陈述) | 故障说法收敛到 `COPY` 三处:状态带 `failed ? COPY.statusFailed : COPY.statusLoading`(1098–1103,注释 1099–1100 同时删掉了加载/故障态的 0.5 透明度)、左栏失败条 `COPY.apiDown + ',' + COPY.apiDownNote`(1727–1733,`aria-label` 用 `COPY.retry` 1731)、陈旧条 `title: COPY.staleHint` + `COPY.staleNote`(2082–2105,2080–2081 说明陈旧是数据状态、必须挂到刷新成功)。 ### 14.6 对比度、窄窗与 resize 节流 - 全文件只剩 3 处 `opacity`:骨架行 604(`dim ? 0.5 : 0.75`,首屏例外)、发送按钮禁用 1013(0.6)、全部上线 busy 1406(0.6)。未绑定组不再整组降透明,改由行组件把姓名降为 `T.label2`(注释 1571,`renderRow` 1572–1574)。 - 视口宽度进状态(1206–1209),拖窗 / 分屏 / 改缩放后左栏跟着重排;`readViewportWidth`(423–429)在初始渲染与 resize 回调里是同一个口径(注释 423–424)。 - resize 用 rAF 合并(1212–1231):同一帧多次 resize 只重算一次(守卫 1221),宿主没有 `requestAnimationFrame` 就直接算(1220,功能不退化),卸载时 `cancelAnimationFrame` 撤掉未执行的帧(1228),避免 setState 打到已销毁组件。 - 左栏宽度:`safeViewportWidth < 640 ? 200 : 280`(1715,`leftWidth` 1710–1717),注释记录了审计原状态「280 时 320px 视口下聊天区只剩约 20px」。 ### 14.7 P2 存量清零台账 台账分三类:**已关项**、**保留项**(有意不改,写清理由与重开条件)、**未关项**(本轮新发现,排下一轮)。保留项不是「已修」,写「已完成」是错的。 **表 A · 已关项**(依据列给的是代码锚点或断言编号) | 编号 | 审计原判 | 关闭依据 | 状态 | | --- | --- | --- | --- | | P1-1 | 悬停动作键盘不可达 | `hovered`/`focused` 取并集(645–647、653)、焦点轮廓 586–593、`onFocus`/`onBlur` 662/671 | 已关 | | P1-2 | 状态带拖动误开面板 | 位移标记 `moved`(1071 初始化、1077 阈值 ≥4px)、`suppressClick` 1083–1087、`onMouseDown` 1126 | 已关 | | P1-3 | 280px 左栏在 320 视口崩塌 | `leftWidth` 1715 + 视口状态 1206–1209 + resize 1212–1231 | 已关(见保留项 R3) | | P2-1 | 字号八档 | `20-ui-tokens.mjs` 允许集合 `FONT_SIZES`(9–20)+ 反假绿阈值 `MIN_FONT_VALUES`;用例 9/9 | 已关 | | P2-2 | 圆角十档 | 同上 `RADII` 允许集合 + `MIN_RADIUS_VALUES`;用例 9/9 | 已关 | | P2-3 | 硬编码白字 | `'#fff'` 0 处、`labelOnBrand` 392、`MAX_BIZ_HEX` 限 3 处业务色 | 已关 | | P2-4 | 点击区 < 24×24 | `HIT` 401–408 + 6 处使用(907/1143/1448/1732/2075/2102);21 号 1a/1b/1c | 已关(行内 20px 见 R1) | | P2-5 | 无障碍几乎为零 | `aria-label` 23 处(输入框 987、悬停动作 659/669、关闭类按钮 1447/2073/2101);21 号 4a/4b | 已关 | | P2-6 | 同一故障三套文案 | `COPY` 410–421 单一来源,状态带 1102 / 左栏 1727–1733 / 陈旧条 2082–2105;21 号 2a/2b/2c/2d | 已关(缩略见 R2) | | P2-7 | 空态标点与语气不齐 | 1702–1709、1805、1813、1818、925、2050;22 号 3a–3c | 已关 | | P2-8 | 两套二次确认范式 | `confirmAsk` 1297–1311 收口到自绘层 2185–2227;`window.confirm` 仅 1303 降级 | 已关 | | P2-9 | 长文本无截断 | `textOverflow` 8 处 + 任务清单 `maxHeight 260` 内部滚动(1809–1811);21 号 3a–3e | 已关 | | P2-10 | 菜单只能鼠标移出关闭 | 气泡 748–763、主面板 1280–1295(`pointerdown` + `scroll`);22 号 2a–2c | 已关 | | P2-11 | 叠加 opacity 低对比 | 全文件 `opacity` 仅 3 处(604/1013/1406);未绑定组改 `T.label2`(1571);22 号 4a–4c | 已关 | | P3-18 | StaffBar 同行 4 个胶囊仍约 24.8 高 | 四胶囊 style 均补 `minHeight 28` + `boxSizing border-box`(client.js:849 设工作区、858 新任务、867 一键上线、875 成员;注释 847 记录第 11 轮补因);`24-focus-a11y` 1a–1e(四别名命中一次、28、border-box、fontSize 12、padding 4px 10–12px) | 已关 | | P3-19 | 确认层没有 `autoFocus` / 焦点陷阱 | 焦点管理见 14.2:refs 1316–1319、`safeFocus` 1321–1324、`useEffect` 1327–1336、`closeConfirm` 1339–1343、`trapConfirmTab` 1345–1357;卡片 `ref` 2244 / `tabIndex -1` 2245 / `aria-modal` 2247、`onKeyDown` Tab 2254、取消 ref 2267、确定 2274;`24-focus-a11y` 2a–2c / 3a–3d / 4a–4b / 5a–5b / 6a–6b | 已关 | **表 B · 保留项**(有意保留,别当已修) | 编号 | 保留项 | 为什么保留 | 什么条件下重开 | | --- | --- | --- | --- | | R1 | 行内 20×20 图标不算点击区缺陷 | `21-ui-p2` 的 1c 只卡「可点且 `padding: 0 Npx`」的窄命中区(当前 0 处),20×20 小图标列在 INFO「已知偏差(lead 有意保留,不计入断言)」;这些图标在会话行内(663/672),撑到 28 会把两行信息挤成三行,且键盘路径已由 `focused` + 焦点轮廓覆盖 | 出现「点不中」的真实反馈,或触屏 / 高 DPI 成为主场景 → 改 28 并让行高自适应,同时更新 1c 的判定口径 | | R2 | `statusFailed`(员工制:接口不可用)与 `apiDown`(员工接口不可用)属同一故障的缩略写法 | `21-ui-p2` 的 2a 注释写死「状态带是同一故障的缩略版,不算第二种说法」(lead 2026-10-24 裁定,qa 复核一致):归并规则是去掉「员工制:」前缀、去掉「员工」与标点后两者必须同 base 词。状态带只有一行且要交代子系统归属,全称会与左栏失败条在同一屏重复 | 状态带改多行 / 改纯图标,或产品文案去掉「员工制」提法 → 收敛回 `COPY.apiDown` 全称 | | R3 | 320px 视口下聊天区仍约 120px | 左栏已从 280 降到 200(1715),320−200−边框≈120:比审计时的约 20px 可读,但仍偏窄。再往下压需要抽屉式/可折叠左栏,属结构改动而非本轮 P2 范围 | 引入左栏折叠或抽屉(< 400 视口),或触屏最小宽度口径确定 → 把 640 阈值下移并新增 < 360 分支 | **表 C · 未关项**(本轮新发现,明确排期,不混进表 A) | 编号 | 项 | 现状(实测) | 排期 | | --- | --- | --- | --- | | P3-20 | `22-ui-p2b` 有 3 处断言不敏感 | 变异 m2(3b 名单不含 925 聊天区空态)、m5(1a 用 `role` 或 `aria-modal` 并集)、m6(6a 只查 rAF 存在)未被打红 | 第 11 轮 fang-yan 补齐后按新 sha 复打 9 个变异 | ### 14.8 验收锚点与未覆盖 - **门禁**:`node E:/yuangong/.qa-tmp/verify/gate.cjs` → 22 套 `PASS 22`、`FAIL 0`(宿主矩阵 1 + 客户端套件 18 + 真实数据只读 1 + 只读探针 2;`SUITES` 在 `gate.cjs:24–44`,22 号注册在 `gate.cjs:41`、24 号在 `gate.cjs:42`、25 号在 `gate.cjs:44`)。判定口径仍是退出码 0 = PASS(`gate.cjs:3` 的 G-01.2),跑批前后 sha 护栏在 `gate.cjs:223`(G-09.0)。第 11 轮实跑:`run-all` 24762ms、`client.js sha256 前12=c64e684897a6`、`index.js 前12=9802e13b2e63`。 - **用例**:`21-ui-p2` 16/16(17 条 id 中 `0-未捕获异常` 只在异常时记录);`22-ui-p2b` 20/20;`24-focus-a11y` 18/18(275 行,sha256 `9ab7f7adeea9…91ecebb`,断言 id 1a–1e / 2a–2c / 3a–3d / 4a–4b / 5a–5b / 6a–6b);探针 `E:/yuangong/.qa-tmp/qa-round10/probe-esc.cjs` 13/13、`probe-ui10.cjs` 7/7(第 10 轮复验报告 `round10-recheck.md:31–32` 原记录)。 - **后端矩阵**:`run-all.mjs`(137 行)24 条用例、断言合计 396(`last-run/matrix.json` 24 条全 pass、`shaChanged=false`、`pluginSha256=9802e13b…`;宿主 20 条 333 + 客户端 4 条 63)。 - **未覆盖(照实登记)**:①真实渲染像素未量——28 是 CSS `min-height` 下限,不是量出来的可点高度;②触屏、200% 缩放、RTL、高对比模式未测;③确认层的键盘全流程与读屏未测;④气泡菜单只有源码级证据,无真机操作记录;⑤宿主全局 CSS 与 `zIndex` 叠放未测;⑥`22-ui-p2b` 三处不敏感断言已由第 11 轮修正,但仍待按新 sha 复打 9 个变异才算收敛(表 C P3-20);⑦14.1、14.3–14.6 与表 A 其余行(P1-1、P2-3 至 P2-9)的既有锚点仍是第 10 轮 `client.js` 2691 行基线,实测已漂移(`HIT` 401→404、`COPY` 410→413、`confirmAsk` 1297→1302、`leftWidth` 1715→1762、确认层 2185→2232),本轮只重取 14.2 焦点段与表 A 两行,其余待第 12 轮整批重刷。 > 台账口径:保留项写「为什么保留 + 什么条件下重开」,未关项单独成表。把 R1–R3 说成「已修复」、或把表 C 混进表 A,都是对台账的篡改。