# 群聊会话运行身份对齐技术方案(定稿 V1.3,已实施) > 日期:2026-11-04 状态:**定稿(V1.3)/ 已实施(0.0.429)** > 目标读者:Mao 后端与容器运行时维护者 > 关联材料:调研 `docs/research/2026-11-04-group-chat-runtime-identity-gap.md`;初稿已被本文件取代(修订对照见附录 B) > 实施一句话:**一次发布(后端 + 一个可空字段迁移)修正「技能/工具路径与执行容器不一致」;容量优化作为独立第二阶段;隔离边界一行不动。** > 修订历史:2026-11-04 初稿 → 2026-11-04 定稿 → 2026-11-04 评审补充 V1.1 → 2026-11-04 复核修正 V1.2 → 2026-11-04 实施回写 V1.3。定稿吸收 13 项自查/评审修订(对照表见附录 B);V1.1 吸收代码评审发现的 4 项 P1 + 6 项 P2(对照见附录 C);V1.2 针对「跨成员诊断落点」「重试身份读取通路」「publicUrl 注入点」「微信严重度」「生产库名」等 8 项复核结论做修正(对照见附录 D);V1.3 按实现落点回写 3 处(归档抑制调用侧、publicUrl 经桥接层注入、WS/桌面重试按 owner 的边界说明,对照见附录 E),**不改变 D1–D10 任何决策**。 > 确认记录:2026-11-04 杨佳毅确认 —— ①「技能集合随发言者变化」可接受;②D4「产物归运行身份」及其跨成员限制面(§7.1)可接受。**0.0.429 已实施**(worktree 分支开发 + 自测 + 子代理代码评审,含评审修正与 V1.3 回写)。 --- ## 1. 背景与问题(摘要) 飞书群 / 飞书话题 / 钉钉群会话中,**非会话归属成员**发言的那一轮:提示词里的技能路径按「会话归属用户」拼装,工具执行却在「发言者」的容器里,两侧路径空间不同,导致技能文档读取被沙箱拒绝(`Path escape attempt`)、技能 CLI 缺失、任务不可执行。 根因一句话:**同一会话存在两套身份(归属 / 运行),执行侧已按运行身份收窄,资源侧没有跟上。** 规模:全站 21 个群会存在「非归属成员执行」历史(近 7 天 5 个,最大一个 10 个执行用户);90 个用户中 89 个跟随全局 docker。详细取证见调研文档。 与初稿思路的对应:**V1 = 思路 1(运行身份对齐)+ 思路 3(可解释性);V2 = 思路 2(共享/去重)的替代实现**;初稿的「agent 级共享视图挂载」经评估废弃(理由见 §6.3)。 ## 2. 目标与非目标 ### 2.1 目标(验收口径) | 编号 | 目标 | 验收 | |---|---|---| | G1 | 群聊中任一成员发言,技能文档与技能 CLI 可用 | 发言者容器内 `read_file` 技能 `SKILL.md` 成功;CLI 可按技能文档自装到 `deps/{发言者}` 并执行 | | G2 | 身份语义唯一且可解释 | 执行期资源一律取「运行身份」;提示词在身份不一致时显式声明;越界错误文案可被模型正确理解 | | G3 | 隔离边界不扩大 | 不挂载他人目录、不放开沙箱允许根;个人技能/凭据/依赖仍严格按人 | | G4 | 回归零影响 | host 用户、私聊、web 会话、定时任务、LOCAL、子代理恢复行为可预期(见 §5.8) | | G5 | 可运营 | 一个开关可整体回退到「按会话归属执行」;回退无需改代码 | | G6 | 容量可控 | 技能视图数据不随群成员数线性增长(第二阶段,见 §6) | ### 2.2 非目标(明确不做,避免范围蔓延) 1. **不做「用某成员账号代操作」**:本次事故里「用王昭文账号改 Apollo」的诉求属于**凭据委派**产品能力,需独立立项(显式授权、可审计、有时效)。本方案只保证「在谁的环境执行、说得清楚」。 2. 不做「群聊共享归属者技能/凭据」; 3. 不做「每会话一个容器」; 4. 不改容器挂载清单、镜像、沙箱允许根(V1 零容器侧变更); 5. 不改 MCP 身份(见 §7 已知差异)。 ## 3. 关键决策(终态) | 决策 | 内容 | 理由 | |---|---|---| | **D1** | 定义运行身份 `runtimeUserId = executionUserId ?? session.userId`,并由 `MAO_RUNTIME_IDENTITY` 开关决定是否启用「执行侧优先」(默认启用)。落为 `AgentExecutionContext.runtimeUserId` | 单一事实源;可回退 | | **D2** | 运行身份**持久化**到 `session.last_runtime_user_id`(每次 `execute` 更新) | 记忆抽取、崩溃恢复、子代理继承需要同一事实源,避免三套临时机制 | | **D3** | 技能同步、技能清单、提示词路径、上传目录提示、`dump_user_messages`、`open_web_page` 缓存、生成产物、发送工具统一取运行身份 | 凡「宿主侧写/拼、容器侧读」的路径必须同身份 | | **D4** | 生成产物(`generate_image` / `edit_image`)归属运行身份 | 与「谁执行、谁可见、谁计费」一致;发送工具才能读到 | | **D5** | 记忆注入与**抽取**必须同源(运行身份) | 只改注入会造成「B 的记忆写进 A」,比不改更糟 | | **D6** | 压缩归档**不拆分**:写入与目录保持会话归属身份;非归属身份执行时**不注入归档回读提示** | 会话级数据不做身份级碎片化;避免误导模型读不到 | | **D7** | 技能同步失败(视图物化失败)→ 本轮从可用技能清单剔除并告警 | 不允许「清单有技能、路径不存在」的组合把错误暴露给模型 | | **D8** | 提示词身份段仅在 `runtimeUserId != session.userId` 时渲染;PathSandbox 对「他人运行目录」给出专属诊断文案 | 提升可解释性;普通会话前缀零变化 | | **D9** | 子代理 / 崩溃恢复继承「本次执行身份」;**定时任务、手动压缩、侧任务保持会话归属** | 恢复的是同一次执行;定时任务没有「发言者」语义 | | **D10** | 第二阶段容量优化采用 **内容寻址 + 硬链接**,**废弃** agent 级共享视图挂载方案 | 不动挂载/沙箱/registry,收益等价,风险低一个量级(对比见 §6.3) | ## 4. 身份模型与生效范围 ``` sessionOwnerUserId = session.userId // 归属:列表、权限、审计、流式订阅、通知 runtimeUserId = 开关启用 ? (executionUserId ?? session.userId) : session.userId // 执行:容器、文件、技能、CLI、依赖、凭据缓存、记忆 executionUserId = 触发人(不变) // 审批规则、LLM 计费/预算(维持现状) ``` | 资源 | 身份 | 阶段 | |---|---|---| | 技能同步 / 视图 / 会话技能软链 | runtimeUser | V1 | | 提示词技能路径 / 上传目录提示 / 用户命令 | runtimeUser | V1 | | docker-only 工具(shell / 文件 / 终端 / MCP 之外) | runtimeUser(现路由即 executionUserId,开关关闭时回落 owner) | V1 | | `dump_user_messages` / `open_web_page` 缓存 / 生成产物 / 发送工具 | runtimeUser | V1 | | 记忆注入 + 抽取 | runtimeUser | V1 | | 压缩归档写入/目录 | **owner(不变)**;非 owner 运行时不注入回读提示 | V1 | | 子代理 / 崩溃恢复 | 继承 last_runtime_user_id | V1 | | 定时任务 / 手动压缩 / 侧任务 | owner(不变) | — | | MCP server 列表与容器寻址 | **owner(不变)**,列为已知差异 | 后续单独评审 | | 审批规则 / LLM 计费 / 预算 | executionUserId(不变) | — | --- ## 5. V1 实施范围(精确改动清单) ### 5.0 实施顺序(按此顺序落码,每步可编译、可测) 1. 迁移 + `Session.lastRuntimeUserId` + 类型三处(编译通过,空跑迁移); 2. 开关配置 + `buildContext` 身份解析 + `touchRuntimeUser`(失败仅告警不阻断)+ **execute 入口全量审计**(按 §5.2 终态表逐入口定 `executionUserId`;此时行为尚未改变,仅落字段); 3. 技能同步目标/清单/提示词路径(**修复生效点**)+ 同步失败剔除; 4. 工具身份表、记忆同源、归档提示抑制、子代理/恢复继承; 5. 可解释性(提示词身份段 + PathSandbox 诊断 + 宿主侧产物诊断/URL 文案 + 重试与钉钉第二入口身份读取(§5.11 #17)); 6. 单测补齐 → 手工冒烟 → 发布。 ### 5.1 数据模型 新增迁移 `backend-ts/db/migration/V183__session_last_runtime_user.sql`: ```sql ALTER TABLE `session` ADD COLUMN `last_runtime_user_id` BIGINT NULL COMMENT '最近一次执行的运行身份(= executionUserId ?? user_id);用于记忆抽取、崩溃恢复与子代理继承' AFTER `memory_injection_disabled`; ``` - `Session` 类型加 `lastRuntimeUserId?: number | null`(`domain/types.ts`、`session/types.ts`、`harness/deps.ts` 三处,与 `memoryInjectionDisabled` 同模式); - 行映射走 `SELECT *` 自动 camelCase(与 `memoryInjectionDisabled` 同机制),无需改 `insert/updateById` 字段表; - 不做数据回填:首轮执行即写入;缺失时一律回落 `session.userId`。 ### 5.2 运行身份解析与持久化 | 位置 | 变更 | |---|---| | `app-config.ts` | 新增 `app.harness.runtimeIdentityMode`:读 `MAO_RUNTIME_IDENTITY`,取值 `execution`(默认)/`owner`,非法值回落默认;写入 `.env.example` 注释与 `skills/mao-cli/reference/config.md`(环境变量权威表,AGENTS.md 要求 mao-cli 同步;`DEPLOY.md` 现为索引表,顺带提及) | | `SessionService` | 新增 `touchRuntimeUser(sessionId, userId)`(内部 `sessionRepo.updateFields(id, { lastRuntimeUserId })`) | | `HarnessService.execute` | 把 `executionUserId` 透传给 `buildContext`(新增第 5 个可选参数) | | `HarnessService.buildContext` | 加载 session 后立即计算:`runtimeUserId = mode==='owner' ? session.userId : (executionUserId ?? session.userId)`;写入 `context.runtimeUserId`;随后 `touchRuntimeUser(sessionId, runtimeUserId)`(先落身份再同步技能/构建,崩溃恢复窗口最小);**`touchRuntimeUser` 失败(DB 异常)仅告警不阻断本轮**——身份以 `context.runtimeUserId` 内存值为准,落库失败不影响执行;保持 `context.executionUserId` 语义不变 | | `AgentExecutionContext` | 新增 `runtimeUserId?: number \| null` | 说明:`touchRuntimeUser` 放在 `buildContext`(全执行链路唯一咽喉),而不是 `execute`——`buildContext` 同时覆盖恢复、手动压缩、侧任务、子代理等路径,语义统一为「最近一次执行的运行身份」。自动压缩不经过 `buildContext` 二次调用(request_start 在 buildContext 内联、mid_loop 复用执行中 context),天然继承运行身份,不存在「压缩把身份打回 owner」问题。已知副作用:`requestCompaction`/`executeSideFirstMessage` 也经此咽喉,会把 `last_runtime_user_id` 覆写成 owner——侧任务写自己的会话行无碍;「B 发言后 owner 手动压缩」会改崩溃恢复身份,可接受(§10 风险 12)。 调用点约定(终态,即 §5.0 第 2 步「execute 入口全量审计」的结果——D1 的正确性依赖此表穷尽,新增执行入口必须显式决策): | 调用点 | executionUserId | 说明 | |---|---|---| | 飞书入站 `execute`(主路径) | 传触发人 | `feishu/agent-inbound-handler.ts:701-706`,主路径 | | 飞书失败卡「重试」`execute` | 传 `session.lastRuntimeUserId ?? null` | `agent-inbound-handler.ts:313-317`(`retryExecution`/`runRetry`)。重试基于会话历史续跑、stubContext `senderId: null` 拿不到原发言者,与崩溃恢复同源取持久化身份——失败轮的 `touchRuntimeUser` 已在 buildContext 落过发言者;若期间 owner 触发过手动压缩会覆写,可接受(§10 风险 12)。**读取通路**:adapter 需新增 `getLastRuntimeUserId(sessionId)`(`runRetry` 不加载会话行,现有 adapter 无此方法),见 §5.11 #17 | | 钉钉入站 `execute`(主路径) | 传触发人 | `dingtalk/agent-inbound-handler.ts:293-297` | | 钉钉第二入口 `execute` | 传 `session.lastRuntimeUserId ?? null`(与飞书重试同款) | `dingtalk/agent-inbound-handler.ts:340`(`runRetry`);同需 adapter 读取通路(§5.11 #17) | | 微信入站 `execute` | 不传(owner) | `weixin/agent-inbound-handler.ts:208`。仓内无群会话分支(无 chat_type/group 处理),1:1 下 owner==发言者恒成立;若后续微信支持群需同步改造 | | WS / 定时任务 / OpenAPI(`executeFromEvent`) | 不传(owner) | `harness-service.ts:111-115`;调用方 `streaming-ws-handler.ts`、`scheduled-task.service.ts`、`open-run.service.ts`。无「发言者」语义,回落 owner 正确。**V1.3 补充边界**:桌面/WS 失败重试(`streaming-ws-handler.runRetryExecution` → `executeFromEvent`)同样按 owner 续跑——桌面/WS 会话恒为单用户(owner == 发言者),语义正确;子代理重试走 `manager.buildRetryContext` → `executePrepared`,上下文由 `buildSubContext` 沿 `lastRuntimeUserId` 构建,不受此影响;群会话重试只走飞书/钉钉失败卡(上两行) | | `crash-recovery-runner` | 传 `session.lastRuntimeUserId ?? null` | 恢复同一次执行(§5.8) | | `background-subagent-manager.buildSubContext` | 传 `child.lastRuntimeUserId ?? parent.lastRuntimeUserId ?? parent.userId` | 子代理继承(§5.8)。child 字段在 spawn 时已按 §5.8 落值;升级前已存在的子会话可能为空,故回落父会话身份而非 owner | | `requestCompaction`(手动压缩) | 不传 | owner | | `executeSideFirstMessage`(侧任务) | 不传 | owner(写自己的侧会话行) | 极端窗口(已知、可接受):若失败发生在 `buildContext` 的 `touchRuntimeUser` **之前**(如会话行读取即失败),`last_runtime_user_id` 仍是上一位发言者的值,重试会沿用该身份;该情形本身极罕见,不再增加兜底复杂度。 ### 5.3 技能同步与失败语义 `SkillSyncService`: 1. 参数语义改为「运行身份」(`syncToSession(agent, runtimeUserId, sessionId)`,`getUserSkillNames/getUserSkillDocuments/getRemovedSkillNames` 同步改名),类型不变; 2. `syncState` 键改为 `${agentId}:${userId}:${sessionId}`(把 userId 作为 key 分量统一升级 `getSyncState` 的**全部 3 个调用点**:`syncToSession`、`getRemovedSkillNames`、`writeSyncZip`),消除群聊内不同发言者互相清链; 3. **返回值**:`Promise<{ failed: string[] }>`(本轮实际不可读且已摘链的技能名集合:视图物化失败、链接失败,或源下线 `ensureView` 返回 null;V1.3 明确);单技能失败仍只影响该技能,不中断其余; 4. 目录布局不变(V1):`{runtimeRoot}/{runtimeUserId}/{sessionId}/skills/{name} → skill-views/{runtimeUserId}/{name}`(系统)/ `userskills/{runtimeUserId}/{name}`(个人)。 `HarnessService.buildContext`: 5. 调 `syncToSession(agent, runtimeUserId, sessionId)` 后,把 `failed` 中的技能名从 `availableSkillNames` 剔除(同时不影响 `availableSkillDocs` 的全局文档表); 6. `availableSkillNames/Docs` 的用户技能来源改为 `getUserSkillNames/Documents(runtimeUserId)`; 7. 日志统一带 `session=`, `owner=`, `runtimeUser=` 三个字段。 ### 5.4 提示词(`prompt-engine.ts`) 统一 helper:`const runtimeUserId = context.runtimeUserId ?? context.userId`。 | 位置 | 变更 | |---|---| | `buildSkillCatalog` | 云端技能路径取 `runtimeUserId`;LOCAL 分支不变 | | `hasUserSkill` / 用户命令查表 | 取 `runtimeUserId` | | `incomingFileHint` | 上传目录取 `runtimeUserId`(附已知限制:其他成员历史消息里的 `@{...}@` 引用指向其本人 runtime,跨成员不可读,见 §10) | | 新增身份段(仅 `runtimeUserId != session.userId` 渲染) | 见 §5.9 | ### 5.5 工具派发(`tool-invocation.ts` / `agent-loop.ts` / `tool-dispatcher.ts`) 1. `ToolInvocation` 新增 `runtimeUserId: number | null`,由 `agent-loop.dispatchTool` 从 `context.runtimeUserId` 填充;`dispatchInvocation → dispatchFullOutcome` 增加同名形参(默认 `null`,兼容旧调用); 2. `dispatchFullOutcome` 顶部统一取值: ```ts // runtimeIdentity 来自 ToolInvocation.runtimeUserId const runtimeUserId = runtimeIdentity ?? executionUserId ?? userId; const runtimeToolUserId = RUNTIME_SCOPED_TOOLS.has(toolName) ? runtimeUserId : userId; ``` 3. `RUNTIME_SCOPED_TOOLS = { dump_user_messages, open_web_page, generate_image, edit_image, feishu_send_image, feishu_send_file, dingtalk_send_image, dingtalk_send_file, send_wechat_image, send_wechat_file }`(微信两个发送工具为**一致性/防御性**补入:它们在 SERVER_ONLY 分支按 `userId` 读字节,但微信当前恒 owner==发言者(§5.2),本次不改变现状行为,统一绑定运行身份并为后续微信群聊铺路;现存缺陷在 feishu/dingtalk send 的宿主回退分支); 4. docker-only 分支路由改用 `runtimeUserId`(原为 `executionUserId ?? userId`);SERVER_ONLY 分支 `callTool(..., runtimeToolUserId, ...)`;宿主回退分支 `toolUserId = (toolName === 'shell' || RUNTIME_SCOPED_TOOLS.has(toolName)) ? runtimeUserId : userId`。注意两类发送工具落在不同分支、须分别回归:`send_wechat_*` 在 SERVER_ONLY(改 `runtimeToolUserId`),feishu/dingtalk send 在宿主回退(改 `toolUserId`);工具构造与注册处(`tool-registry.ts`)对本条无需改,`callTool → BaseTool.execute` 透传链已确认(publicUrl 注入对注册处的改动见 §5.9 第 3 条、§5.11 #18); 5. 保持 owner 的工具不变:`task_*`、定时任务系列、`ask_user_questions`、`web_search`、`http_*`。 ### 5.6 记忆(`harness-service.loadMemories` / `task-terminal.service`) 1. `loadMemories(session, runtimeUserId)`:用 `runtimeUserId` 调 `listForInjection`(定时任务时 `runtimeUserId==owner`,语义正确); 2. `task-terminal.dispatchMemoryExtraction`:`userId: session.lastRuntimeUserId ?? session.userId`; 3. `memoryInjectionDisabled` 开关不变。 ### 5.7 压缩归档(`session-compaction-orchestrator` / `session-history-loader`) 1. `writeArchive(..., context.userId, ...)` **保持不变**(归档目录仍归会话归属身份); 2. 抑制在**调用侧**(`session-history-loader.applyHistory`)实现,`compaction-archive.service.buildArchiveHint` 与 orchestrator 均不改签名:`(context.runtimeUserId ?? context.userId) !== context.userId` 时跳过 `buildArchiveHint`、不注入提示;两侧一致时行为与改造前逐字节相同(V1.3 实施回写); 3. 效果:A 的轮次照旧可用归档回读;B 的轮次不出现「指向 A 目录」的误导提示。跨身份回读能力**明确不承诺**(D6)。 ### 5.8 子代理 / 崩溃恢复 / 侧任务 | 对象 | 变更 | |---|---| | 子代理创建(`subagent-invocation.service.create`) | `child.lastRuntimeUserId = parent.lastRuntimeUserId ?? parent.userId`(spawn 与 followup 都以父会话当前值刷新) | | 子代理执行(`background-subagent-manager.buildSubContext`) | `buildContext(child.id, undefined, undefined, undefined, child.lastRuntimeUserId ?? null)`;子代理技能仍清空(现状) | | 崩溃恢复(`crash-recovery-runner.runRecovery`) | `execute(..., current.lastRuntimeUserId ?? null)` | | 侧任务 | 保持 owner(非目标) | ### 5.9 可解释性 1. **提示词身份段**(`prompt-engine`,仅不一致时输出): ``` ## 执行身份 - 当前执行身份:用户 #67 秦翌翔(本条消息的发送者;有显示名时展示)。shell、文件、技能、CLI 与凭据都属于该身份。 - 会话归属:用户 #17(本群会话的创建者)。其个人技能、token 缓存与 CLI 依赖不在当前执行环境内,不要假设可用,也不要尝试读取其运行目录。 - 已知限制:MCP 工具仍绑定会话归属者一侧(其容器与凭据),其文件/数据视图不代表当前执行身份。 - 若任务必须使用某位成员自己的账号/凭据,请让该成员本人 @ 机器人触发,或明确告知用户需要其本人操作。 ``` 2. **PathSandbox 身份诊断**(容器侧):`PathSandboxOptions` 新增 `identity?: { runtimeRoot; uploadDir; userId }`,`ContainerToolExecutor` 构造时传入;`resolve()` 抛错前判定——路径落在 `{runtimeRoot}/{其他 uid}` 或 `{uploadDir}/{其他 uid}` 时按类别给专属文案: - runtime:`该路径属于其他用户的会话运行目录(当前执行身份 {uid}),本地不可访问: `; - uploads:`该文件属于其他成员的产物目录(当前执行身份 {uid}),本地不可访问;如需转发,请让该成员本人操作,或改用公开 URL 方式: `(容器内**不给具体 URL**——origin 需查库,容器拿不到)。 其余越界保持原文案。覆盖 `read_file/write_file/edit_file/glob_search/grep_search/handleReadBytes`。 生效路径(评审补充):容器内代码经 dist ro 挂载与宿主机同源,新文案靠 runtimeBuild 漂移检测→下次空闲自动重启进程加载;容器 busy 会延后、`autoUpgrade`(V178)被关时不自动生效。§8.2 须主动构造跨成员读并断言新文案出现,否则无法区分「已修复」与「代码未加载」。 3. **产物读字节的跨成员诊断**(宿主机侧 `send-file-bytes.ts`):关键修正是**判定必须放在向容器分发 `read_bytes` 之前**(词法预检),host 与 docker 用户走同一条文案;否则 docker 用户(89/90)会先命中容器侧错误,拿不到含 URL 的可执行提示(附录 D #1)。`loadSendFileBytes` 按类别抛出**可执行**文案—— - `uploads/{其他 uid}`:`该文件属于其他成员的产物目录(当前执行身份 {uid}),本地路径不可读;该文件有公开 URL,请改用其转发: {publicUrl}`。`publicUrl` 由宿主持 `upload.baseUrl` 推导 origin + `/uploads/{uid}/{file}` 现成拼出(复用 `toStaticUploadBase`;`/uploads/` GET 为公开静态路由,jwt 白名单 + UUID 文件名)——**不要求模型自行换算**:系统提示词与 send-* 工具描述均无 uploads 公开 base URL(评审实证;仅 `generate_image`/`edit_image` 的返回里带 `image_url`,跨轮或压缩后可能只剩路径); - `runtime/{其他 uid}`(shellOutput / userMessages / webPages):无公开路由,抛 `该文件属于其他成员的会话运行目录(当前执行身份 {uid}),本地路径不可读且无公开 URL;请让该成员本人操作,或重新生成到共享位置(如群共享工作区): `; - `edit_image` 沿用同一预检并传 `pathPolicyHint`:uploads 分支的 URL 仅标注「可用于 send_* 转发」、出路改走 hint;runtime 分支的 hint 替代默认出路建议——它不接受 URL(见 §7.1),不能再引导「请改用其转发」造成无效重试(V1.3 实施细化)。 接线(V1.3 实施回写):不在 `tool-registry.ts` 传 baseUrl,而是 `SendBytesSupport` 增可选钩子 `foreignPathMessage(src, userId, hint)`(`send-file-bytes.ts` 在容器分发前调用),由 `DockerSendBytesBridge`(`read-bytes-bridge.ts`)实现,origin 通过 `uploadOriginProvider` 注入(`app-container-runtime.ts` 装配,复用 `toStaticUploadBase` + `contextPath`,失败降级为不带 URL);`SendBytesCallContext` 另加 `pathPolicyHint`(`edit_image` 用它追加「改图请重新生成,或由该成员本人操作」)。见 §5.11 #18。 映射规则(V1.3 实施回写):写进提示词「执行身份」段(仅运行身份 != 会话归属时渲染,不占普通会话的前缀 token)——说明产物按执行身份隔离、`/uploads/{他人 uid}/{file}` 公开可读、可直接把他人本地路径交给 `send_*`,系统会在报错中给出可用 URL。 ### 5.10 运营开关 - `MAO_RUNTIME_IDENTITY=execution|owner`(默认 `execution`),重启生效(蓝绿 ~30s); - `owner` 语义 = 全部执行期资源按会话归属(群聊里即「以建会话者身份执行」),用于紧急回退;审批/计费仍按触发人; - 开关为**唯一分叉点**(仅影响 §5.2 的 runtimeUserId 计算),其余代码单一路径; - 稳定运行一个版本后,评估是否移除开关(保留需在 DEPLOY.md 维护)。 ### 5.11 改动文件清单 | # | 文件 | 变更 | |---|---|---| | 1 | `backend-ts/db/migration/V183__session_last_runtime_user.sql` | 新增可空列 | | 2 | `src/domain/types.ts`、`src/session/types.ts`、`src/harness/deps.ts` | `lastRuntimeUserId` 字段 | | 3 | `src/session/session.service.ts` | `touchRuntimeUser` | | 4 | `src/config/app-config.ts`、`.env.example`、`skills/mao-cli/reference/config.md`、`DEPLOY.md` | 身份开关(config.md 为环境变量权威表;DEPLOY.md 为索引,顺带提及) | | 5 | `src/harness/core/harness-service.ts` | 身份解析/持久化、同步目标、清单剔除、记忆 | | 6 | `src/harness/core/agent-execution-context.ts` | `runtimeUserId` | | 7 | `src/harness/skill/skill-sync-service.ts` | 语义/state key/返回值 | | 8 | `src/harness/core/prompt-engine.ts` | 路径/命令/身份段 | | 9 | `src/harness/tool/tool-invocation.ts`、`src/harness/core/agent-loop.ts`、`src/harness/tool/tool-dispatcher.ts` | runtimeUserId 穿透、工具身份表、docker 路由 | | 10 | `src/harness/core/session-history-loader.ts`(调用侧抑制;`session-compaction-orchestrator.ts`/`compaction-archive.service.ts` 不改) | 归档提示抑制 | | 11 | `src/session/task-terminal.service.ts` | 记忆抽取身份 | | 12 | `src/harness/delegate/subagent-invocation.service.ts`、`background-subagent-manager.ts` | 子代理身份继承 | | 13 | `src/harness/core/crash-recovery-runner.ts` | 恢复身份 | | 14 | `src/harness/safety/path-sandbox.ts`、`src/harness/container/container-tool-executor.ts`、`src/harness/tool/impl/send-file-bytes.ts`、`src/harness/tool/impl/edit-image-tool.ts` | 身份诊断(§5.9 第 2、3 条) | | 15 | 测试与文档 | 见 §8、CHANGELOG | | 16 | `src/session/ws/streaming-ws-handler.ts` | 第二处 `syncToSession`(:793,web 路径 userId=WS 认证用户=owner,语义不变);随 SkillSyncService 签名/state 键改造一并 review,不改行为。另确认 `tool-registry.ts` 对本条无需改(透传链已验) | | 17 | `src/feishu/agent-inbound-handler.ts`、`src/dingtalk/agent-inbound-handler.ts`(adapter 接口)+ `src/app-feishu-handler.ts`、`src/dingtalk/runtime.ts`(adapter 实现) | 重试身份的读取通路:新增 `getLastRuntimeUserId(sessionId)`(实现 `(await sessionService.getSession(id))?.lastRuntimeUserId ?? null`),供两个 `runRetry` 透传(§5.2) | | 18 | `src/harness/tool/impl/send-file-bytes.ts`、`src/harness/container/read-bytes-bridge.ts`、`src/app-container-runtime.ts`(V1.3 实施回写:`tool-registry.ts` 不改) | publicUrl 注入:`SendBytesSupport.foreignPathMessage` 钩子 + `uploadOriginProvider` 装配(§5.9 第 3 条);`SendBytesCallContext.pathPolicyHint` 供 `edit_image` 追加操作提示 | --- ## 6. 第二阶段(V2):技能视图容量优化(内容寻址 + 硬链接) > 独立发布、独立回滚;不改变 §5 的任何行为。 ### 6.1 问题 V1 后每个新发言者首次执行会物化一份技能视图(现网均值约 10MB/用户;53 个用户 333MB)。大群多发言者会线性增长。 ### 6.2 方案 ``` {skillViewsDir}/.content/{fingerprint}/{skillName}/ ← 不可变内容仓(复制一次,按指纹分目录) {skillViewsDir}/{userId}/{skillName}/ ← 用户视图(硬链接到内容仓;路径不变) ``` - 指纹沿用现有 `computeSkillTreeFingerprint`;内容仓缺失时从源全量复制一次(暂存 + 原子 rename); - 用户视图按原有「暂存 + 原子 rename」换入,文件改为硬链接(同文件系统;失败回落拷贝); - 内容仓写入后不再修改(新指纹 = 新目录);容器内视图仍为 ro 挂载,宿主机不写视图内容; - GC:低频任务清理「无引用且超过 N 天」的内容仓目录;已存在的视图硬链接继续持有数据,不受目录项删除影响; - 收益:系统技能磁盘占用从「用户数 × 体积」降为「当前指纹 × 体积 + 链接元数据」;物化耗时下降。 ### 6.3 为什么废弃「agent 级共享视图挂载」 | 维度 | agent 级挂载(初稿) | 内容寻址 + 硬链接(定稿) | |---|---|---| | 挂载清单 / registry / runtime-agent env / 沙箱允许根 | 全部要改 | **零改动** | | 容器重建 | 新增「agent 集合变化」触发一类重建 | 无 | | 隔离边界 | 新增一类跨用户只读挂载(需额外评审) | **不变** | | 磁盘收益 | 等价 | 等价 | | 路径稳定性 | 略好(agent 级共享路径) | 不变(V1 已保证身份一致) | | 实现/回滚风险 | 高 | 低 | 结论:容量目标用 V2 达成;不为非功能收益去动隔离与挂载主链路。 --- ## 7. 明确不做与已知差异 | 项 | 结论 | 说明 | |---|---|---| | agent 级共享视图挂载 | **不做** | 见 §6.3 | | MCP 身份 | **保持 owner** | 群聊里 MCP stdio 仍在归属者容器;仅影响同时使用 MCP 的群会话。加一条 warn 日志(`mcp session runtimeUser != owner`)便于观测,后续单独评审 | | 会话级共享归档目录 | **不做** | 归档保持 owner;非归属身份不注入回读提示(D6) | | 「用某人账号代操作」 | **不做**(独立需求) | 凭据委派需要显式授权与审计,另立项 | | 前端 / 进度卡身份提示 | 不做 | 提示词身份段已让模型能解释;UI 文案后续可选 | | 容器镜像 / 挂载 / 沙箱允许根 | **不改** | V1 零容器侧变更,无需重建容器 | ### 7.1 跨成员可见性矩阵(D4「产物归运行身份」的直接后果) > 判定标准:一条**本地文件路径**产在身份 X 的目录下(`uploads/{X}`、`runtime/{X}`、`users/{X}`、`deps/{X}`、容器 `/tmp`),身份 Y(Y≠X)能否在后续轮次使用它。 > 结论:docker 模式下「按本地路径引用他成员产物」的超集都不可用,`edit_image` 是其中唯一连 URL 兜底都没有的工具;host 用户(全局 1 个)的只读工具不受影响(非 strict 沙箱);有 4 条不受限通道兜底(见下)。 **受限(按本地路径)** | # | 场景 | 行为 | 备注 | |---|---|---|---| | 1 | `edit_image`(他人产物/中间图/蒙版) | ❌ 本地路径越界;且不接受 http(s) URL | 唯一「本地 + URL 双不可用」;跨成员改图不可用,引导重新生成或本人操作 | | 2 | `read_file` / `write_file` / `edit_file` / `glob_search` / `grep_search` / `shell`(docker) | ❌ 他人 `uploads/{uid}`、`runtime/{uid}`、`HOME/{uid}` 越界或不存在 | 容器只挂本人目录;host 用户读绝对路径不受限(现状,不收紧) | | 3 | `feishu_send_image/file`、`dingtalk_*`、`send_wechat_*`(本地路径入参) | ❌ 他人 `uploads/{uid}` 路径被拒 | **同参数传 http(s) URL 可用**(宿主机抓取;`/uploads/` GET 为公开静态路由) | | 4 | 会话中间产物按路径引用:`dump_user_messages`(userMessages)、`open_web_page` 全文(webPages)、web 输入框上传(incoming)、`shellOutput` | ❌ 他成员轮次拿路径回读失败 | 均落 `runtime/{uid}/{sid}`;**当轮无问题**(读写同身份) | | 5 | 容器内 `/tmp` | ❌ 按用户持久挂载(`{tmpDir}/{uid}` → `/tmp`) | 跨成员互不可见 | | 6 | `deps/{uid}` 已装 CLI、`users/{uid}` HOME 凭据/登录态/缓存、`userskills/{uid}`、`skill-views/{uid}` | ❌ | 按身份隔离属设计预期:各自安装、各自授权 | | 7 | 压缩归档目录(`runtime/{owner}/{sid}/compaction`) | ❌ 非 owner 不注入回读提示 | D6 已定:归档保持 owner,不拆分 | **不受限(可用通道)** | 通道 | 说明 | |---|---| | 群共享工作区 `workspace/feishu-chat/{botId}/{chatId}` | 每成员容器都挂(`extraWorkspaces`);**群内协作产物首选落地处** | | `share/` 公共共享目录 | 全员 rw | | http(s) URL | 发送工具宿主机抓取;`/uploads/{uid}/...` GET 公开可读(jwt 白名单,UUID 文件名) | | 飞书群入站文件 | 落群共享工作区 `chat-files/{date}/`,非成员私有的 incoming | **产品含义(需知悉)**:典型冲突场景是「B 说:把刚才那张图改成蓝色」——A 触发生成的图在 `uploads/{A}`,B 的轮次:① 读不到(`read_file`);② 改不了(`edit_image`);③ 但可以按 URL 转发(`send_*`)。V1 靠提示词身份段 + §5.9 诊断文案让模型主动解释,并给出「重新生成 / 请 A 本人操作 / 存到群工作区」三条出路;不新增共享读通道(与 D4 矛盾,如需另立项)。 --- ## 8. 测试计划 ### 8.1 单测 | 模块 | 用例 | |---|---| | `harness-service.spec` | `buildContext(...,67)` → `syncToSession` 收到 67、`touchRuntimeUser` 收到 67;`execute` 透传 67;B 轮次技能清单不含归属者个人技能;同步 `failed` 的技能被剔除;`loadMemories` 用 67 | | `prompt-engine.spec` | 技能路径/上传目录取 runtimeUserId;身份段仅在 `runtimeUserId != userId` 渲染;普通会话提示词字节不变 | | `skill-sync-service.spec` | 状态键按(agent, user, session)隔离;切换发言者各自建链、互不清链;视图失败返回 `failed` 且链接被摘 | | `tool-dispatcher.spec` | `RUNTIME_SCOPED_TOOLS` 走 runtimeUserId;`task_*`/定时任务走 owner;docker 分支路由 runtimeUserId;开关关闭时全部回落 owner | | `session-compaction-orchestrator.spec` / `session-history-loader.spec` | 写入仍用 owner;`runtimeUserId != owner` 时 hint 为 null | | `task-terminal.service.spec` | 抽取用 `lastRuntimeUserId`,缺失回落 `session.userId` | | `background-subagent-manager.spec` | 子会话继承 `lastRuntimeUserId`;`buildSubContext` 传入该身份 | | `crash-recovery-runner.spec` | 恢复以 `lastRuntimeUserId` 执行,缺失回落 owner | | `path-sandbox.spec` | 他人 runtime 目录 → 专属文案;普通越界文案不变;host 侧不启用诊断 | | `app-config.spec` | `MAO_RUNTIME_IDENTITY` 解析与非法值回落 | ### 8.2 集成 / 手工冒烟(上线前必做) 1. 指定测试群:A(docker)建会话,B(docker)发言;或运维 host 账号建会话 + docker 用户发言; 2. B 发言轮次:提示词技能路径为 `runtime/B/{sid}/skills/...`;`read_file` SKILL.md 成功;`docker logs mao-user-B` 无 `Path escape`; 3. B 的 `skill-views/B` 出现对应技能副本;`deps/B/npm/bin` 可按技能文档自装 CLI 并执行; 4. A 再发言:路径/凭据/CLI 不受 B 影响; 5. `session.last_runtime_user_id` 随轮次在 A/B 间切换; 6. 压缩一次长会话:A 轮次有归档提示且路径可读;B 轮次无归档提示; 7. B 触发 `dump_user_messages` → 返回路径可被 B 的 `read_file` 读取;B 发送容器内文件成功。 8. 【评审补充】重试路径:让 B 的轮次失败 → 点失败卡「重试」→ 验证仍以 B 身份执行(提示词技能路径 `runtime/B`、`[skill-sync]` 日志 `runtimeUser=B`、`last_runtime_user_id` 仍为 B)。 9. 【评审补充·V1.2 修正】跨成员诊断(两类断言分开):① `read_file` A 的 `uploads/{A}` 产物 → 断言**容器侧** uploads 专属文案(不含 URL;同时证明 runtimeBuild 新代码已加载);② `feishu_send_image`(或 dingtalk)传 A 的 `uploads/{A}` **本地路径** → 断言**宿主侧**文案并带完整 `publicUrl` → 用该 URL 重试发送成功;③ 对 `runtime/{A}` 下文件断言「无公开 URL、让本人操作/重新生成」文案(不再要求「先复制」)。 10. 【评审补充·V1.2 修正】`send_wechat_image/file`:在**微信私聊会话**发送自己 `uploads/{uid}` 的产物成功(回归 SERVER_ONLY 分支改造;微信当前无群场景,双 docker 群聊冒烟里验证不了它)。 ### 8.3 回归矩阵 | 场景 | 期望 | |---|---| | host 用户(唯一 1 个) | 行为不变(host 侧本无容器隔离) | | 飞书/钉钉私聊 | owner == 发言者,路径/提示零变化 | | web / 桌面会话 | executionUserId 为空 → runtimeUserId = owner | | LOCAL 模式 | 路径仍为 `~/.mao/runtime/{sid}`,零变化 | | 定时任务 | owner 执行;`last_runtime_user_id` 更新为 owner | | 手动压缩 | owner;归档提示按 owner 正常 | | 崩溃恢复 | 以崩溃前 `lastRuntimeUserId` 恢复;缺失回落 owner | | 子代理 | 继承父轮次身份;技能仍清空 | | 侧任务 | owner(现状) | --- ## 9. 上线、验证与回滚 ### 9.1 上线前 1. `cd backend-ts && npm test` 全绿;`npm run build` 通过; 2. 迁移在本地/E2E 库执行验证(`V183` 可空列,无数据依赖); 3. 生产 `backend-ts/.env` 确认未显式设置 `MAO_RUNTIME_IDENTITY`(默认 `execution`)。 ### 9.2 发布 生产走**一键脚本**(在 `/data`,不在仓库内,见工作区 AGENTS.md):`bash /data/deploy-mao.sh --backend-only`(内部 = `git pull --ff-only --autostash` → 构建后端 → `backend-ts/restart.sh` 蓝绿,并同步 `skills/mao-cli`)。手工等价路径:`cd /opt/mao && git pull` → `bash scripts/restart-backend.sh`(= 构建 + `backend-ts/restart.sh`,不 pull、不同步 mao-cli)。两条都无需重建容器、无需前端发布。注意:`deploy-mao.sh` 不在仓库内,别按「仓库路径」去找。 ### 9.3 发布后 30 分钟检查点 | 检查 | 命令/位置 | 期望 | |---|---|---| | 技能路径 | `docker logs mao-user-{B} 2>&1 \| grep "Path escape"` | 无新增 | | 同步日志 | 后端日志 `[skill-sync]` | 含 `owner=17 runtimeUser=67` 且无失败 | | 身份持久化 | `SELECT id,user_id,last_runtime_user_id FROM session WHERE id={sid}` | 与最近发言者一致 | | 视图/依赖 | `ls skill-views/{B}`、`deps/{B}/npm/bin` | 按需物化/安装 | | 后端错误率 | 日志 ERROR 计数 | 无异常上升 | ### 9.4 回滚 1. **秒级止血**:`.env` 设 `MAO_RUNTIME_IDENTITY=owner` → `bash /data/deploy-mao.sh --backend-only`(或手工 `bash scripts/restart-backend.sh`);群聊回到「按会话归属执行」; 2. **代码回滚**:revert 提交后重新部署;`V183` 列保留(可空,不影响旧代码); 3. 回滚不涉及数据修复:技能视图/软链为运行期产物,自动重建。 --- ## 10. 风险与应对 | # | 风险 | 影响 | 应对 | |---|---|---|---| | 1 | 同一群会话的可用技能集合随发言者变化 | 行为差异被误报为 bug | 提示词身份段 + 产品/客服问答口径 + 开关可回退 | | 2 | 每个新发言者首轮物化 ~10MB 视图 | 大群磁盘增长 | V2 内容寻址+硬链接;监控 `du -sh skill-views` | | 3 | 新发言者首次需安装技能 CLI | 首轮耗时 | 技能文档已内置安装流程;后续评估镜像预装高频 CLI | | 4 | 生成产物归运行身份 | 其他成员无法直接 `edit_image` 该文件 | 文档说明;需要共享时发到群/工作区 | | 5 | 其他成员历史消息里的 `@{...}@` 上传引用不可读 | 个别轮次读文件失败 | 已知限制(V1);后续按「上传落会话工作区」另行设计 | | 6 | MCP 容器身份仍是 owner | 使用 MCP 的群会话文件视图不一致 | 已知差异;warn 日志观测;单独评审 | | 7 | `last_runtime_user_id` 被误用于全局回退 | 定时任务以他人身份执行 | 代码注释 + 评审约束:仅用于恢复/派生/抽取 | | 8 | `owner` 模式在群聊等于「以归属者身份执行」 | 应急模式的安全语义弱于执行侧 | 仅应急、限时使用、需审计;DEPLOY.md 写明 | | 9 | 蓝绿期间旧实例不写新列 | 恢复回落 owner | 可接受(仅窗口期) | | 10 | 记忆写入归属变化 | 群聊记忆归发言者 | 符合 D5;产品需知晓 | | 11 | 提示词/send 工具描述无 uploads 公开 URL 映射 | 「改用 http(s) URL」建议模型无法执行 | V1.1 吸收:诊断文案按类别给出可执行指引 + 提示词补映射规则;**V1.2 修正落点**:URL 文案必须在宿主侧、于容器分发前判定(§5.9 第 3 条 / 附录 D #1) | | 12 | 飞书重试无原发言者(stubContext `senderId: null`);owner 手动压缩会覆写 `last_runtime_user_id` | 重试/恢复身份漂移到 owner | V1.1 吸收:重试传 `lastRuntimeUserId ?? null`(与崩溃恢复同源,§5.2);覆写场景可接受。V1.2 补:读取走 adapter 新增方法(§5.11 #17) | | 13 | syncState 为内存 Map,部署即清空 | 发布后首轮全量重同步(指纹命中仅换链) | 低峰发布;监控 `[skill-sync]` 日志量与首轮延迟 | | 14 | 容器侧新诊断文案懒生效(runtimeBuild 漂移→进程重启;busy 延后、autoUpgrade 可关) | 跨成员报错仍是旧文案 | §8.2 冒烟断言新文案出现;autoUpgrade 关闭时运维手工重启容器 | | 15 | 提示词前缀随发言者切换失效 | prompt cache 命中率略降(同一执行内无影响) | 已知,接受 | | 16 | 钉钉第二入口 / 微信入站身份语义 | 非 owner 场景下身份漂移 | V1.2 定论:钉钉第二入口与飞书重试同款(`lastRuntimeUserId`);微信不传(恒 owner==发言者,无群分支),后续支持群时再改 | --- ## 11. 工作量 | 阶段 | 内容 | 估时 | |---|---|---| | V1 实现 | §5.1–5.10(18 个文件,含评审补充的重试/wechat/诊断/入口审计/重试读取通路/publicUrl 注入) | 2–2.5 人日 | | V1 测试 | 单测 + 冒烟 + 回归 | 0.5–1 人日 | | V2 | 内容寻址 + 硬链接 + GC | 1–1.5 人日 | | 合计 | 含文档与发布 | 4–5 人日 | --- ## 12. 代码位置索引(当前代码) | 主题 | 位置 | |---|---| | 会话归属创建 | `backend-ts/src/app-feishu-messaging.ts`(会话工厂) | | 运行身份注入 | `backend-ts/src/harness/core/harness-service.ts`(`execute` / `buildContext`) | | 技能同步 | `backend-ts/src/harness/skill/skill-sync-service.ts` | | 技能视图 | `backend-ts/src/harness/skill/skill-view.service.ts` | | 提示词 | `backend-ts/src/harness/core/prompt-engine.ts` | | 工具派发 | `backend-ts/src/harness/tool/tool-dispatcher.ts`、`tool-invocation.ts`、`harness/core/agent-loop.ts` | | docker 路由 | `backend-ts/src/harness/container/docker-tool-router.ts` | | 容器沙箱 | `backend-ts/src/harness/container/container-tool-executor.ts`、`harness/safety/path-sandbox.ts` | | 挂载清单 | `backend-ts/src/harness/container/container-mount-planner.ts`、`container-registry.ts` | | 压缩归档 | `backend-ts/src/harness/core/session-compaction-orchestrator.ts`、`session-history-loader.ts`、`compaction-archive.service.ts` | | 记忆 | `backend-ts/src/harness/core/harness-service.ts`(`loadMemories`)、`session/task-terminal.service.ts` | | 发送工具字节 | `backend-ts/src/harness/tool/impl/send-file-bytes.ts`、`harness/container/read-bytes-bridge.ts` | | MCP 容器寻址 | `backend-ts/src/harness/container/routing-mcp-client-manager.ts`、`app-container-runtime.ts`(`mcpRuntimeProbe`) | | 子代理 | `backend-ts/src/harness/delegate/subagent-invocation.service.ts`、`background-subagent-manager.ts` | | 崩溃恢复 | `backend-ts/src/harness/core/crash-recovery-runner.ts` | | 迁移目录 | `backend-ts/db/migration/`(最新 V182,本次新增 V183) | --- ## 附录 A:生产验收脚本 ```bash # 1) 测试群准备:A(docker) 建会话,B(docker) 发言 # 记录 sid;用 B 发言触发任一需要技能的任务 # 2) 路径与挂载事实 ls -l /opt/mao-data/runtime///skills/ | head ls -d /opt/mao-data/skill-views// /opt/mao-data/deps/ # 3) 容器日志(应无输出) docker logs mao-user- 2>&1 | grep "Path escape" # 4) 身份持久化(应为 B) # 库名:生产为 workbench_mao(取自 /data/mao/backend-ts/.env;.env.example 的 mao 是自托管默认) mysql -h -u workbench_mao \ -e "SELECT id,user_id,last_runtime_user_id FROM session WHERE id=" # 5) 回退演练(可选,低峰) # .env: MAO_RUNTIME_IDENTITY=owner → bash /data/deploy-mao.sh --backend-only # (或手工:cd /opt/mao && bash scripts/restart-backend.sh) # 验证群聊以 A 身份执行;验证后改回 execution 重新部署 # 注意:deploy-mao.sh 在 /data(仓库外);蓝绿由 backend-ts/restart.sh 完成 ``` ## 附录 B:定稿修订对照(相对初稿) | # | 初稿问题 | 定稿处理 | |---|---|---| | 1 | 记忆只改注入、抽取仍按 owner → B 的记忆写进 A | D5:注入与抽取同源,新增 `last_runtime_user_id` | | 2 | 压缩归档按身份拆分 → 回读缺段 | D6:归档不拆,仅抑制非 owner 提示 | | 3 | 同步失败仍列技能 → 继续撞墙 | D7:失败技能本轮剔除 + 告警 | | 4 | 缺隐私验收 | §8.1 增加「B 轮次不含归属者个人技能」 | | 5 | 恢复/子代理/侧任务三套临时方案 | D2 + D9:一个持久化字段统一 | | 6 | 初稿 P2 为省磁盘动挂载/沙箱,风险错配 | D10:换内容寻址+硬链接(§6) | | 7 | 初稿 P2 触发源需给 ToolInvocation 加 agentId,侵入大 | 废弃 agent 挂载,无需 agentId 穿透(仅保留 runtimeUserId) | | 8 | 「用某人账号代操作」诉求未说明 | §2.2 明确非目标,独立立项 | | 9 | 技能集合随发言者变化被当实现细节 | §10 风险 1 + 提示词身份段 + 产品确认项 | | 10 | 生成产物归属含糊 | D4 定为运行身份,影响与限制写入 §7/§10 | | 11 | `incoming` 改动收益低 | 保留(与运行身份一致),跨成员引用列为已知限制(§10 风险 5) | | 12 | 工作量偏乐观、缺实施顺序 | §11 重估;§5 顺序即实施顺序;本次一次发布 | | 13 | 「产物归运行身份」的跨成员限制未量化,`edit_image` 被当成孤例 | 新增 §7.1 可见性矩阵(7 类受限 / 4 条可用通道)+ §5.9 第 3 条产物读字节诊断;明确 `edit_image` 是唯一无 URL 兜底的工具 | --- ## 附录 C:评审补充对照(V1.1,相对定稿 V1.0) > 2026-11-04 代码评审(五路核实:harness-service / 技能同步与提示词 / 工具派发 / 容器沙箱 / 压缩子代理恢复迁移)发现的缺口与吸收情况。决策 D1–D10 不变。 | # | 评审发现 | 严重度 | V1.1 处理 | |---|---|---|---| | 1 | 飞书失败卡「重试」`execute`(`agent-inbound-handler.ts:313-317`)不传 `executionUserId`,重试轮以 owner 身份跑——恰好在身份不匹配的失败轮上复现 bug;钉钉 `:340` 同理 | P1 | §5.2 入口表补全并规定重试传 `lastRuntimeUserId ?? null`(stubContext 无原发言者,与崩溃恢复同源);§8.2 加重试冒烟;§10 风险 12/16 | | 2 | `RUNTIME_SCOPED_TOOLS` 漏 `send_wechat_image/file`(§7.1 却提到它们);它们在 SERVER_ONLY 分支按 owner 读字节,B 连自己 `uploads/{B}` 产物都发不出 | P1 | §5.5 清单补入 + 分支差异说明(wechat 走 SERVER_ONLY,feishu/dingtalk 走宿主回退,分别回归);§8.2 补 wechat 冒烟。**V1.2 修正**:微信当前无群场景(§5.2),降为 P2 一致性/防御性,见附录 D #4 | | 3 | §5.9「转发请改用 http(s) URL」不可执行:提示词无 uploads 公开 base URL(实证),且 `runtime/` 下产物无公开路由 | P1 | §5.9 第 3 条重写为分类可执行文案(uploads→宿主持拼 `publicUrl`;runtime→先复制到 uploads/群工作区)+ 提示词补 uploads 路径↔URL 映射规则;§10 风险 11。**V1.2 修正**:判定改到宿主侧、容器分发前;runtime 分支改为「本人操作/重新生成」(他人 runtime 不可复制),见附录 D #1/#6 | | 4 | §9.2/§9.4/附录 A 引用不存在的 `deploy-mao.sh --backend-only` | P1 | 改为 `scripts/restart-backend.sh`(蓝绿在 `scripts/lib/blue-green.sh`,无 `--backend-only`)。**V1.2 修正**:脚本存在于 `/data/deploy-mao.sh`(仓库外、支持 `--backend-only`),见附录 D #8 | | 5 | 容器侧诊断文案生效路径未述(runtimeBuild 漂移→进程懒重启;busy 延后、autoUpgrade=V178 可关) | P2 | §5.9 第 2 条补生效路径;§8.2 断言新文案出现 | | 6 | §5.11 漏 `streaming-ws-handler.ts:793` 第二处 `syncToSession`(语义正确但需显式 review) | P2 | 清单补第 16 项 | | 7 | `touchRuntimeUser` 失败语义未定(DB 写失败会废掉本轮) | P2 | §5.2 规定仅告警不阻断,身份以内存值为准 | | 8 | syncState 为内存 Map,部署后清空→首轮全量重同步 | P2 | §10 风险 13(低峰发布+监控) | | 9 | 环境变量文档漏 `skills/mao-cli/reference/config.md`(AGENTS.md 要求 mao-cli 同步;DEPLOY.md 已只是索引) | P2 | §5.2/§5.11 补 config.md | | 10 | 工作量偏乐观 | P2 | §11 V1 实现改 2–2.5 人日,合计 4–5 人日 | | 11 | 附录 A 库名 `workbench_mao` 与 `.env.example` 的 `mao` 不一致 | P3 | 改为 `` 并标注待核对。**V1.2 修正**:生产实为 `workbench_mao`,已写回,见附录 D #5 | | 12 | 提示词身份段只有原始 user id;prompt cache 前缀随发言者切换失效未入风险表 | P3 | §5.9 身份段补显示名;§10 风险 15 | --- ## 附录 D:V1.2 复核修正对照(相对 V1.1) > 2026-11-04 复核:把 V1.1 的每条断言回代码逐条核对(7 处执行入口全量 grep、容器 `read_bytes` 链路、两个 adapter 接口、tool-registry 依赖、生产 `backend-ts/.env` 实值)。8 项修正如下,D1–D10 不变。 | # | 复核发现 | 严重度 | V1.2 处理 | |---|---|---|---| | 1 | §5.9 诊断落点错误:docker 用户读他人 `uploads` 先命中容器(#2 只覆盖 runtimeRoot、容器拿不到 origin),宿主 #3 文案根本走不到 → 89/90 用户拿不到含 URL 的可执行提示;§8.2 冒烟 9 原断言(read_file uploads → 新文案)落空 | P1 | #3 改为「向容器分发 `read_bytes` **之前**做宿主侧词法预检」(host/docker 同文案、带完整 publicUrl);#2 判定扩到 uploads 树(容器内不给 URL) | | 2 | 重试身份无读取通路:`FeishuSessionAdapter`/`DingtalkSessionAdapter` 无方法可读会话行,`runRetry` 不加载 session | P2 | 新增 `getLastRuntimeUserId(sessionId)` + 两处 adapter 接线(§5.11 #17) | | 3 | publicUrl 注入点缺失:`getUploadBaseUrl` 只传给了 generate/edit image;发送工具与 `SendBytesCallContext` 均无 | P2 | §5.11 #18(tool-registry + send-file-bytes 接线) | | 4 | 附录 C #2 严重度不成立:微信恒 owner==发言者(§5.2 自述)、无群分支、`send_wechat_*` 只对 weixin 会话注册 | P2 | 降为「一致性/防御性」,§5.5 与附录 C #2 已改;冒烟 10 改为微信私聊回归 | | 5 | 附录 A 库名退化:生产 `.env` 实为 `workbench_mao`,`.env.example` 的 `mao` 只是自托管默认 | P2 | 写回 `workbench_mao` + 来源注明 | | 6 | #3 runtime 分支「先复制到本人 uploads/群工作区」对 docker 用户不可执行(他人 runtime 完全不可读) | P3 | 改为「让该成员本人操作,或重新生成到共享位置」 | | 7 | 「模型拿到本地路径无法推知 URL」表述过强:generate/edit 返回里本就带 `image_url` | P3 | 精确为「提示词/工具描述无映射规则;跨轮或压缩后可能只剩路径」 | | 8 | 部署口径:「仓库内不存在 deploy-mao.sh」属实但易误导——脚本在 `/data/deploy-mao.sh`(支持 `--backend-only`、含 pull 与 mao-cli 同步) | P3 | §9.2/§9.4/附录 A 改为「一键脚本 `/data/deploy-mao.sh --backend-only`,或手工 `scripts/restart-backend.sh`」 | 随文小修正:§5.2 子代理行补 `parent.lastRuntimeUserId` 回落、补「失败早于 touchRuntimeUser」极端窗口说明、钉钉第二入口定为「与飞书重试同款」;§5.3 明确 `getSyncState` 全部 3 个调用点随 key 升级;§11 文件数 16→18。 --- ## 附录 E:V1.3 实施回写对照(相对 V1.2) > 2026-11-04 实现完成、自测与子代理代码评审后,按**真实落点**回写 3 处;仅修正「改哪里/怎么接」,不改任何决策与行为约定。 | # | 原计划落点 | 实施落点 | 原因 | |---|---|---|---| | 1 | §5.7:`buildArchiveHint` 增入参 `runtimeUserId`;§5.11 #10 含 `session-compaction-orchestrator.ts` | 抑制放在调用侧 `session-history-loader.applyHistory`:身份不一致时跳过 `buildArchiveHint`;归档服务与 orchestrator 不改 | 归档写入/目录本就归 owner(D6),只需控制「提示是否注入」;改服务签名会让所有调用点都承担身份概念,调用侧一行判断即可,且单测更聚焦 | | 2 | §5.9 第 3 条接线 / §5.11 #18:`tool-registry.ts` 把 `getUploadBaseUrl`、`uploadContextPath` 传进发送工具与 `SendBytesCallContext` | `SendBytesSupport.foreignPathMessage` 钩子 + `DockerSendBytesBridge` 实现 + `app-container-runtime` 注入 `uploadOriginProvider`;`SendBytesCallContext` 只加 `pathPolicyHint`;`tool-registry.ts` 不改 | origin 解析属于容器桥接层(宿主侧唯一同时掌握上传根与配置的装配点);发送工具只需「分发前问一句」,避免把 baseUrl 透传进 6+ 个发送工具的构造签名 | | 3 | §5.2 调用点表只列 `executeFromEvent`(WS/定时/OpenAPI)为 owner | 同结论,补边界说明:桌面/WS 失败重试也经 `executeFromEvent` 按 owner 续跑(单用户会话语义正确);子代理重试经 `buildRetryContext` 沿 `lastRuntimeUserId` | 7 个执行入口审计时容易漏掉「重试」这一层;写明后新增入口只需对照此表决策 |