From a44fd6949be73be88350bbac64e8a2d045032d36 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 6 Sep 2026 15:16:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9D=83=E9=99=90=E6=A1=A3=E4=BD=8D?= =?UTF-8?q?=E4=BD=93=E7=B3=BB=EF=BC=88=E4=B8=89=E6=A1=A3=20plan/workspace/?= =?UTF-8?q?full=20+=20=E5=9B=9B=E6=A1=A5=20from=5Fsession=5Fid=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L2 核心改动:sessions 表补 permission_mode / permission_enforcement 两列 (sqlite + pg 同步),三桥 lib/permission-mode.js 翻译档位到平台原生配置, homeagent advisory 模式提示词告知模型实际强制力。四桥全部携带 from_session_id 供 relay 去重与会话回溯。 FromHuman / ToHuman 判据已加入心跳 payload 与 notify/mail.go。 --- docs/PLAN.md | 253 ++++++++++++++++ gateway/internal/db/migrate.go | 15 + gateway/internal/db/migrations/init.sql | 16 + .../internal/db/migrations/init_sqlite.sql | 24 +- gateway/internal/handler/agents.go | 37 ++- gateway/internal/handler/forward.go | 2 +- gateway/internal/handler/mail.go | 112 +++++-- gateway/internal/handler/me.go | 68 ++++- gateway/internal/handler/permission.go | 60 +++- gateway/internal/models/models.go | 35 +++ gateway/internal/models/permission_mode.go | 142 +++++++++ .../internal/models/permission_mode_test.go | 160 ++++++++++ gateway/internal/notify/mail.go | 27 ++ gateway/internal/repo/permission_mode.go | 148 ++++++++++ gateway/internal/repo/repo.go | 121 ++++---- gateway/internal/scheduler/calendar.go | 16 + .../dsh-mail-bridge/lib/permission-mode.d.ts | 33 +++ .../dsh-mail-bridge/lib/permission-mode.js | 274 ++++++++++++++++++ plugins/dsh-mail-bridge/src/index.ts | 183 +++++++++++- .../test/permission-mode.test.mjs | 246 ++++++++++++++++ plugins/homeagent-mail-bridge/bounded.go | 125 ++++++++ plugins/homeagent-mail-bridge/bounded_test.go | 162 +++++++++++ plugins/homeagent-mail-bridge/plugin.go | 143 +++++++-- plugins/homeagent-mail-bridge/tools.go | 3 +- plugins/opencode-mail-bridge/index.js | 97 +++++-- .../lib/permission-mode.js | 274 ++++++++++++++++++ .../test/permission-mode.test.mjs | 246 ++++++++++++++++ plugins/pi-mail-bridge/lib/permission-mode.js | 274 ++++++++++++++++++ plugins/pi-mail-bridge/src/index.mjs | 45 ++- plugins/pi-mail-bridge/src/pool.mjs | 46 ++- plugins/pi-mail-bridge/src/turn.mjs | 6 +- .../test/permission-mode.test.mjs | 246 ++++++++++++++++ 32 files changed, 3462 insertions(+), 177 deletions(-) create mode 100644 gateway/internal/models/permission_mode.go create mode 100644 gateway/internal/models/permission_mode_test.go create mode 100644 gateway/internal/repo/permission_mode.go create mode 100644 plugins/dsh-mail-bridge/lib/permission-mode.d.ts create mode 100644 plugins/dsh-mail-bridge/lib/permission-mode.js create mode 100644 plugins/dsh-mail-bridge/test/permission-mode.test.mjs create mode 100644 plugins/homeagent-mail-bridge/bounded.go create mode 100644 plugins/homeagent-mail-bridge/bounded_test.go create mode 100644 plugins/opencode-mail-bridge/lib/permission-mode.js create mode 100644 plugins/opencode-mail-bridge/test/permission-mode.test.mjs create mode 100644 plugins/pi-mail-bridge/lib/permission-mode.js create mode 100644 plugins/pi-mail-bridge/test/permission-mode.test.mjs diff --git a/docs/PLAN.md b/docs/PLAN.md index dbb9fa1..b5edb46 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1401,6 +1401,174 @@ MVP 计划(Phase 1-6)已全部落地并在 systemd 部署态实测通过。 点下去命中谁」,而这次最严重的 bug 恰好只有 `elementFromPoint` 能发现。 它不进 `npm test` —— 要一个跑着的浏览器加一个活的 Gateway。 +### 7.11 权限档位(plan / workspace / full) + +人在派活时声明「这条任务允许 Agent 动手到什么程度」,插件把它翻译成平台原生的 +沙箱/审批配置。三档: + +| 档位 | 语义 | 权限询问 | +|---|---|---| +| `plan` | 只读:查资料、读代码、出方案,一个字都不许写 | **不产生** —— 直接拒绝,模型该把方案写在回信里 | +| `workspace` | 本目录内可动手,越界要问人(**默认档**) | 越界时产生 | +| `full` | 自动放行 | **不产生** —— 已声明全权,再问是噪音 | + +#### 为什么需要它 + +此前根本没有权限模型:能不能跑 bash 完全由各平台自己的本地配置决定 +(dsh 的 `settings.yaml`、opencode 的 `opencode.jsonc`、pi 硬编码守卫三个工具名)。 +发件人对此毫无控制也毫不知情 —— 派一件「只是看一下」的活,对方可能直接改文件。 + +#### 架构:AgentMail 声明,平台执行,插件只翻译 + +**不让插件按工具名自己猜着拦**:那会同时违反 `I-1`(平台原生信号是唯一真相来源) +与 `I-4`(插件只搬运不决策),而且四个插件对「workspace 到底管什么」必然各猜一套 —— +同一封 workspace 档的邮件在 A 平台被拦、在 B 平台放行。 + +平台原生能力调研(决定了整个架构): + +| 平台 | 原生机制 | 能否只读 | 能否管目录边界 | +|---|---|---|---| +| dsh | `setSandboxMode(session, mode)`,三档写死 | 能(真沙箱) | 能(真沙箱) | +| opencode | `session.create({permission:[…]})`,action ∈ allow/ask/deny | 能 | 能(glob) | +| pi | 只有 `tool_call` 钩子 `{block:true}` | 能(按工具名) | write/edit 能查 `input.path`,**bash 不能** | +| homeagent | **无任何拦截点** | 不能 | 不能 | + +dsh 原生三档(`read-only` / `workspace-write` / `danger-full-access`)与 +plan/workspace/full **一一对应** —— 不是巧合,是同一个问题的同一个答案。 + +#### 五条设计决定 + +1. **档位挂 `sessions.permission_mode`,不挂每封邮件**。与配额同理:它是**任务**的 + 属性。续谈的信若也能带档位,每封新信都会悄悄改掉对方正在遵守的规则 —— + 而 plan 档的会话里模型已被告知「只许看」,第二封信改成 full 是在一段已有 + 上下文里换规则。新建时设,续谈忽略,对话页里显式编辑。 +2. **Agent 不能自己指定档位,新会话从父会话继承**(`ModeAtMost(父档, 请求档)`)。 + 否则发一封 `mode=full` 的信就自我提权了。继承保证 plan 档派不出 full 档子任务 —— + 与 `hop_limit` 同形:约束必须沿链条传递。 +3. **平台表达不出精确档位时向更严取整,并如实上报实际强制力**。pi 的 bash 在 + workspace 档只能退回「每条都问人」。不定这条规则,四个插件会朝不同方向取整, + 而往宽松取整是静默失效(人以为收紧了,实际没有)。 +4. **`agents.mode_enforcement`(心跳自报 native/advisory)**。homeagent 是 advisory —— + 发件人以为 plan 档管住了它,实际管不住。两个字段(要求档位 / 实际强制力)都要 + 上界面,差异可见才符合 `I-5`。 +5. **只有 workspace 档需要人**,这一条直接决定「找不到人类时怎么办」:plan 档当场 + 拒绝、full 档自动放行,两者都不问人,所以只有 workspace 档会走到「这条链上有没有 + 人类」,找不到就是 409。 + +#### 顺带修掉的两处死代码 + +- **`permission.go` 的管理员兜底吃掉了 409 分支**。原顺序是 `req.To → 会话 owner → + 第一个 active admin → 判 IsHuman`,第三步让第四步永远为真,`NearestHumanInThread` + 与那段 409 从未被执行。实测确认:pi 给自己新开会话派活跑 bash,权限邮件 + `to_name=jianf`,点同意后真跑了。而那段 409 的注释本身就在论证兜底是错的 + (「管理员对这条 Agent 链的上下文一无所知」)—— 两条策略互相矛盾,先执行的那条 + 把后写的那条变成了死代码。删掉兜底后 `repo.FirstAdminUsername` 也随之失去唯一 + 调用点,一并删除。 +- **`repo.ListSessions` 从初始提交就是坏的**:SELECT 9 列、`Scan` 11 个参数,零调用点。 + 与 `ListSessionsFor` 当年真出过的事故同一个坑(加了预算两列没加进 Scan, + `/me/sessions` 整个 500)。留着就得给它也加档位两列,等于维护一个坏且没人用的 + 东西,删掉更诚实。 + +#### 实测发现(opencode 的六条,不实测就会做出「看起来对但管不住」的东西) + +`session.create({permission:[…]})` 确实生效,但: + +1. **规则是 `findLast` 胜出** → **deny 必须放前面、allow 放后面**。反了的话连 + 本该允许的路径也被拒(第一版就写反了,模型自己报「按规则本该通过但实际被拒」)。 +2. **pattern 匹配 worktree 相对路径**(`patterns:[relative(y.worktree, file)]`)→ + 写 `/tmp/**` 这种绝对 pattern **永远匹配不上**。这条最隐蔽:配置看着对,全不生效。 +3. **write / edit / patch 共用 `edit` 一个权限名**。 +4. **全 deny 让工具从模型清单里消失**(模型自述「I don't have a bash tool available + in this session」),部分 deny 则工具保留、越界调用才报错。plan 档用前者更好: + 模型不会浪费轮次去试。 +5. **task(子代理)能绕过父会话权限** —— 实测中模型发现自己没 write,**主动委派给 + 一个带 write 的子代理写成了**。plan/workspace 必须 `task deny *`。 +6. **bash 能绕过 edit 的路径限制** —— 模型用 shell 重定向写成了本该被 deny 的文件。 + 所以 workspace 档必须同时管 bash,只管 edit 没用。 + +另:opencode 原生有 `plan_enter` / `plan_exit` 权限项,与我们的 plan 档**撞名但语义 +不同**(那是它自己的计划模式开关),不碰。 + +附带收益:dsh 本机配的是 `danger-full-access` → `approval: "never"`,而 +`ApprovalService.decide()` 里 `if (effectivePolicy === "never") return "rejected"` +**在 waterfall 之前短路** —— 所以整个「权限转邮件」链路在 dsh 上从未真正跑起来过。 +按档位下发 `sandbox/mode` 后,workspace 档的会话才会拿到 `approval: ask`。 + +#### 已完成 + +- [x] `models/permission_mode.go`:三档常量、`NormalizePermissionMode`(非法值 + fail-closed 到默认档而非 full)、`ModeAtMost`(继承与取整共用一个判据)、 + `ModeNeedsHuman`、native/advisory 强制力。12 例测试 + 2 组负向对照 +- [x] schema 三处同步:`sessions.permission_mode`(旧库默认 workspace,不追授全权)、 + `sessions.permission_enforcement`(旧库默认 advisory,不替没自报的插件宣称 + 「档位在这里是被强制的」)、`agents.mode_enforcement` +- [x] `repo/permission_mode.go`:读写 + `InheritedMode` 继承 + `AgentModeEnforcement` +- [x] 读路径三处加列:`GetSessionByID` / `ListSessionsFor` / `ListContactsFor` +- [x] `me.go` 人发信可指定档位(人是权限的源头);非法值报 400 而不是静默用默认档 + —— 他以为给了 plan 实际拿到 workspace,比报错更坏 +- [x] `permission.go` 按档位决定这次询问该不该存在;删管理员兜底,409 恢复可达 +- [x] 日历会话给人类创建者设 owner(否则删掉兜底后,人建的提醒触发时 Agent 的 + 权限询问会因发件人是 `calendar` 而在线索上找不到人类 → 误伤成 409) +- [x] SSE 与补拉路径下发 `permission_mode` / `permission_enforcement` + (补拉路径必须有:否则 plan 档的任务在插件重启后悄悄变成 workspace 档) +- [x] `lib/permission-mode.js` 四平台翻译表(三方逐字节相同,已纳入 + `check-shared-libs.sh`)。32 例测试 + 4 组负向对照,六条实测结论逐条钉死 + +#### P0:判据错误(不修则以上代码失效) + +- [ ] **`parentMailID == nil` 不等于「新建会话」** —— 省略 session 位复用默认会话时 + 它也是 nil。实测:第一封 `max_rounds=7` → 第二封省略该字段 → **预算被冲成 20**。 + 这是**预存 bug**(配额那段注释正在论证这不该发生,守卫写错了),而我的 + `permission_mode` 抄了同一个守卫 —— 第二封信会静默把 plan 档改成 workspace。 + 修法:`resolveTarget` 返回 `created bool`,只有真新建才设预算与档位 +- [ ] **四个插件 `send_mail` 补传 `from_session_id`** —— 否则 `InheritedMode` 永远走 + 回落分支,继承是假的。这是唯一可靠来源:一个 Agent 可同时有多条活跃会话, + 服务端猜不出它此刻属于哪条 + +#### P1:三条建会话路径漏设档位 + +- [ ] `forward.go` 转发 —— `LoadForwardSource` 已返回源邮件(含 `SessionID`), + 用 `InheritedMode(&src.SessionID, 默认档)`。不修则 plan 档转发出去就升到 workspace +- [ ] `calendar_events` 加列 `permission_mode`(schema 三处同步)+ 日历投递接线: + - 人建日程可指定;**Agent 建日程用它当时所处会话的档位定死,不许自选** + - 投递时新建会话 → 用事件档位;**复用会话 → `ModeAtMost(会话现档, 事件档)`** + 取更严,不能因复用而提权 + - 堵住提权路径:plan 档的 Agent 建一个日程,触发时新会话拿默认档 workspace —— + 它绕过 plan 档去写文件了,只是延迟了几分钟 +- [ ] adopt 接管平台会话 —— 无父会话,用默认档,在 `AdoptPlatformSession` 内显式写入 + 而不是靠 DB 默认值 + +#### P2:数据出不去 + +- [ ] `GetSessionMails`(会话视图)与 `ListSentBy`(发件箱)的 `models.Mail` 补两列 —— + 只改了 `ListInbox`,前端要显示档位徽标时这两条路径拿不到值 +- [ ] `PUT /sessions/{id}/permission` 端点 —— 已在 `me.go` 注释里引用但未实现, + 对话页要靠它改档 + +#### P3:测试 + +- [ ] `repo/permission_mode_test.go`:继承、取更严、脏值回落 +- [ ] handler 档位判定 + 409 可达性(负向对照:恢复管理员兜底 → 用例必须失败) +- [ ] `notify` 两个新字段(挂真实 SSE 客户端读帧,沿用 `notify_test.go` 现有手法) +- [ ] 预算不被冲的回归用例(钉住 P0 第一项) + +#### P4:插件接线 + +- [ ] dsh:`setSandboxMode` + `setPolicy`;附带让上一轮那个 `tools/post-execute` + 修复第一次真正可测(此前因 `approval:"never"` 短路而永远走不到) +- [ ] opencode:`session.create({permission})`,按六条实测结论下发 +- [ ] pi:`piGuardedTools` 按档位决定拦哪些工具;plan 档直接拒绝不问人 +- [ ] homeagent:Go 对应物 `permission_mode.go` + advisory 提示词 + (**必须如实说「这个平台无法强制这一档」** —— 假装是强制的会让模型以为越界 + 会被拦,于是不必自己小心,那比做不到本身更危险) + +#### P5:前端与文档 + +- [ ] types / API / 卡片档位徽标 / 对话页档位选择器 / 新建邮件档位选择 + (两个字段成对显示:要求档位 + 实际强制力) +- [ ] `docs/PLUGIN-CONTRACT.md` 新章节 + 平台差异表补一行 + + --- 已知取舍,尚未处理: @@ -1415,6 +1583,91 @@ MVP 计划(Phase 1-6)已全部落地并在 systemd 部署态实测通过。 --- +### 7.12 全面修正:让平台真正可用(本轮) + +本轮起因是排查附件链路,结果连带挖出四类问题。它们的共同形状是**静默成功** —— +请求返回 200、日志干净、界面看着正常,而实际的事没有发生。这类 bug 能活很久, +因为没人会去核对一个成功的请求。 + +#### A. 附件链路 + +| # | 问题 | 状态 | +|---|---|---| +| A-1 | homeagent 按**顶层** `attachment_id` 解上传响应,而服务端返回 `{"attachment":{…}}` → 三个字段全零值,模型看到 `id= filename= size=0KB` | 已修 | +| A-2 | homeagent 的 `send_mail` **根本没声明** `attachment_ids` 参数,提示词还教模型传 `attachments:[{…}]` | 已修 | +| A-3 | `size/1024` 让 800 字节的附件显示成 `0KB` | 已修(`formatSize`) | +| A-4 | 挂载失败(403/409)时**邮件已入库、已通知、预算已扣** —— 收件方收到一封没有附件的邮件,发件方收到 4xx 以为没发出去 | 本轮修 | +| A-5 | 磁盘上 7 个 blob 没有任何库记录指向,GC 永远扫不到(它只按库记录走) | 本轮修 | + +A-1 + A-2 叠起来意味着 **homeagent 的附件发送从来没成功过一次**。 +没有任何一层报错:HTTP 200、文件落盘、库里登记,只是那个 id 是空串, +24 小时后 GC 把没人引用的文件清掉,现场不留痕迹。 + +#### B. 请求解析:未知字段必须报错 + +A-2 之所以能活那么久,根因在服务端:`json.Decoder` 默认**忽略未知字段**。 + +``` +$ curl -X POST /mail/send -d '{…,"attachments":[{"attachment_id":"598f100e…"}]}' + HTTP 200 {"mail_id":"2a64fdc8…", …} +$ sqlite3 "SELECT COUNT(*) FROM attachments WHERE mail_id='2a64fdc8…'" + 0 +``` + +这是 `I-5`(失败必须当场可见)在请求解析层的落点。改法:`Decode` 打开 +`DisallowUnknownFields`,并把**本端点接受的字段一并列出来** —— 只说「不认识 x」 +的话,调用方仍要去翻服务端源码才知道对的拼法,而拼错字段名恰恰是最容易犯、 +最难自查的错。 + +心跳是唯一的例外(`DecodeLenient`):那条路径的职责是「我还活着」,插件比服务端新、 +多带一个字段时,代价不该是整个心跳体(含会话快照与模型目录)被丢掉。但**必须在 +响应里回报** `unknown_fields`,否则又变成一次静默忽略。 + +严格化的爆炸半径已逐个核对(前端 30 个写端点 + 四桥所有 payload + demo 脚本 + +三种嵌套结构),只有一处真的会被打破:**日历创建端点缺 `status` 字段**, +而前端 `CalendarEventEditor` 无条件发它。顺带把 `status` 的取值也校验上 —— +此前 update 端点接受任意字符串,写进库就成了一个调度器不认识的状态。 + +#### C. 人 / Agent 的区分在读路径上缺失 + +`addr-verify` 报的两项失败追下去是**数据完整性问题而非显示问题**: + +前端用 `mail.to_workspace ? ws : ''` 当「这一方是不是 Agent」的判据。 +而 `to_workspace` 为空的 Agent 收件人有 **25/118 封**(人给 homeagent 发信、 +权限决策回信、Agent 间转发…都不带 path 位),于是 `pi` 被渲染成裸名字、 +会话别名跟到了人身上。 + +判据本身选错了。服务端早就有 `IsHumanUser`,也已经在收件箱列表路径上算过 +`from_human`,但另外**四个读路径**(单封读、会话读、发件箱、会话内单封读)都没带。 +补 `from_human` + 新增 `to_human`,前端改用它。 + +> 不能用 `session.from_agent` 代替:它的语义是「谁发起了这条会话」, +> 实测有 45 封邮件的收件 Agent 不等于 `from_agent`。 + +#### D. 「Agent 间不自动转发」没有写进契约 + +代码四桥齐全(`lib/relay-policy.js` + `relay_policy.go`),但 `docs/PLUGIN-CONTRACT.md` +里**只有共用模块表的一句括注**,没有规范条款。更糟的是 `B-3.4` 仍无条件写着 +「提示词里写明回信由插件自动发」—— 与规则直接矛盾。照文档实现的新插件会做错。 + +连带三处: + +- SSE `new_mail` 的字段表缺 `from_human` / `in_reply_to` / + `permission_mode` / `permission_enforcement`(四个都已在下发,文档没跟上) +- `deploy/remote-agent-demo.py` **无条件回信**,两个这样的 demo 对上就是 + ping-pong,只有会话预算能刹住 +- 该脚本指向的 `docs/PLUGIN-GUIDE.md` 已在 `289f37f` 删除 + +#### 验收 + +- [ ] A-4:附件挂载失败时邮件**不入库**(回滚),预算不扣,`relay_key` 归还 +- [ ] A-5:`blob.Store` 可枚举 + GC 反向扫盘,一次跑掉 7 个孤儿 +- [ ] B:`attachments` 这类拼错字段名返回 400 且列出正确字段;日历创建带 `status` 仍 200 +- [ ] C:`GET /mail/{id}` 返回 `from_human` / `to_human`;`addr-verify` 两项转绿 +- [ ] D:契约文档有 `B-5.6` 条款;demo 按 `from_human` 决定是否回信 + +--- + ## 文件清单(完整) ``` diff --git a/gateway/internal/db/migrate.go b/gateway/internal/db/migrate.go index bc47647..0078b61 100644 --- a/gateway/internal/db/migrate.go +++ b/gateway/internal/db/migrate.go @@ -95,6 +95,21 @@ var sqliteAddColumns = []struct{ table, column, ddl string }{ // 旧库也给 20:之前的 max_rounds 默认是 10 但那是终身额度,语义不同, // 不能直接搬过来当单任务预算。 {"agents", "default_rounds", "ALTER TABLE agents ADD COLUMN default_rounds INTEGER NOT NULL DEFAULT 20"}, + // 会话级权限档位(plan / workspace / full)。 + // + // 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」, + // 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权, + // 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。 + {"sessions", "permission_mode", "ALTER TABLE sessions ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'workspace'"}, + // 接收平台实际做到的强制力(native / advisory),由插件心跳自报后落到会话上。 + // + // 旧库默认 'advisory':没自报过的插件,我们不能替它宣称「档位在这里是被强制的」。 + // 保守方向是承认做不到,而不是假装做到了。 + {"sessions", "permission_enforcement", "ALTER TABLE sessions ADD COLUMN permission_enforcement TEXT NOT NULL DEFAULT 'advisory'"}, + // Agent 自报的档位强制能力(native / advisory),随心跳更新。 + // 与 sessions.permission_enforcement 的区别:这里是平台的能力,那里是 + // 某条会话建立时的事实快照 —— 插件升级后能力会变,已结束的会话不该被改写。 + {"agents", "mode_enforcement", "ALTER TABLE agents ADD COLUMN mode_enforcement TEXT NOT NULL DEFAULT 'advisory'"}, } // sqliteAddIndexes 是建表后才能建的索引(依赖上面补的列)。 diff --git a/gateway/internal/db/migrations/init.sql b/gateway/internal/db/migrations/init.sql index 032a2ed..e94e1ea 100644 --- a/gateway/internal/db/migrations/init.sql +++ b/gateway/internal/db/migrations/init.sql @@ -94,6 +94,22 @@ ALTER TABLE sessions ADD COLUMN IF NOT EXISTS alias_source TEXT NOT NULL DEFAULT ALTER TABLE sessions ADD COLUMN IF NOT EXISTS max_rounds INTEGER NOT NULL DEFAULT 0; ALTER TABLE sessions ADD COLUMN IF NOT EXISTS used_rounds INTEGER NOT NULL DEFAULT 0; +-- 会话级权限档位(plan / workspace / full)。 +-- +-- 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」, +-- 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权, +-- 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。 +ALTER TABLE sessions ADD COLUMN IF NOT EXISTS permission_mode TEXT NOT NULL DEFAULT 'workspace'; + +-- 接收平台实际做到的强制力(native / advisory),由插件心跳自报后落到会话上。 +-- 旧库默认 'advisory':没自报过的插件,不能替它宣称「档位在这里是被强制的」。 +ALTER TABLE sessions ADD COLUMN IF NOT EXISTS permission_enforcement TEXT NOT NULL DEFAULT 'advisory'; + +-- Agent 自报的档位强制能力(native / advisory),随心跳更新。 +-- 与 sessions.permission_enforcement 的区别:这里是平台当下的能力, +-- 那里是某条会话建立时的事实快照 —— 插件升级后能力会变,已结束的会话不该被改写。 +ALTER TABLE agents ADD COLUMN IF NOT EXISTS mode_enforcement TEXT NOT NULL DEFAULT 'advisory'; + CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id); -- Mails table diff --git a/gateway/internal/db/migrations/init_sqlite.sql b/gateway/internal/db/migrations/init_sqlite.sql index 9dce5e4..6903229 100644 --- a/gateway/internal/db/migrations/init_sqlite.sql +++ b/gateway/internal/db/migrations/init_sqlite.sql @@ -63,6 +63,17 @@ CREATE TABLE IF NOT EXISTS agents ( -- max_rounds 保留列但不再参与判断。 max_rounds INTEGER NOT NULL DEFAULT 0, used_rounds INTEGER NOT NULL DEFAULT 0, + + -- mode_enforcement 是该平台插件自报的权限档位强制能力(native / advisory), + -- 随心跳上报(与模型目录同一条通道 —— I-1:平台自己说的才算)。 + -- + -- 为什么要存:发件人在派活前得知道 plan 档在对方那儿到底算不算。 + -- homeagent 的核心没有工具调用拦截点,档位只能写进提示词 —— + -- 把这个事实藏起来比做不到本身更危险。 + -- + -- 默认 advisory 而不是 native:没自报过的插件,我们不能替它宣称 + -- 「档位在这里是被强制的」。 + mode_enforcement TEXT NOT NULL DEFAULT 'advisory', last_seen DATETIME, created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')) ); @@ -115,7 +126,18 @@ CREATE TABLE IF NOT EXISTS sessions ( -- Agent 全局配额仍然生效(两者都要过):否则 Agent 自己 .new 开一串会话, -- 每条都是全新预算,全局上限就形同虚设。 max_rounds INTEGER NOT NULL DEFAULT 0, - used_rounds INTEGER NOT NULL DEFAULT 0 + used_rounds INTEGER NOT NULL DEFAULT 0, + + -- 会话级权限档位(plan / workspace / full)。 + -- 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」, + -- 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权, + -- 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。 + permission_mode TEXT NOT NULL DEFAULT 'workspace', + + -- 接收平台实际做到的强制力(native / advisory)。 + -- 旧库默认 'advisory':没自报过的插件,我们不能替它宣称 + -- 「档位在这里是被强制的」。保守方向是承认做不到,而不是假装做到了。 + permission_enforcement TEXT NOT NULL DEFAULT 'advisory' ); CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(session_alias); diff --git a/gateway/internal/handler/agents.go b/gateway/internal/handler/agents.go index 0db62d7..5842901 100644 --- a/gateway/internal/handler/agents.go +++ b/gateway/internal/handler/agents.go @@ -47,15 +47,26 @@ type heartbeatRequest struct { // 空数组 = 平台确实一个模型都拿不到。拿不到目录时必须省略: // 清空目录会让配置页变成空白,管理员以为该平台没有任何可用模型。 Models []repo.CatalogModel `json:"models"` + + // ModeEnforcement 是插件自报的权限档位强制能力:native / advisory。 + // + // 为什么走心跳而不是注册:能力会在运行中变。DSH 的沙箱模式被改成 + // danger-full-access 时,它就从 native 退化成了 advisory(实测: + // approval:"never" 会在 waterfall 之前短路,approval/request 根本不触发)。 + // 只在注册时报一次的话,发件人看到的是上次重启时的能力快照。 + // + // 与模型目录同一条通道(I-1:平台自己说的才算)。 + // 省略 = 本次不上报,保留现有值(与 PlatformSessions / Models 同约定)。 + ModeEnforcement string `json:"mode_enforcement"` } // POST /api/v1/agent/register // // 两种认证方式: -// 1. Authorization: Bearer —— 密钥认证(推荐)。 -// 密钥未绑定时用本请求的 name 落定;已绑定时 name 必须与之一致, -// 否则等于拿别人的密钥冒充新身份。 -// 2. body 里带 secret —— 旧方式,兼容保留。 +// 1. Authorization: Bearer —— 密钥认证(推荐)。 +// 密钥未绑定时用本请求的 name 落定;已绑定时 name 必须与之一致, +// 否则等于拿别人的密钥冒充新身份。 +// 2. body 里带 secret —— 旧方式,兼容保留。 func RegisterAgent(w http.ResponseWriter, r *http.Request) { var req registerRequest if !DecodeBody(w, r, &req) { @@ -161,9 +172,14 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) { // 可选的平台会话快照。解不开就当作没带:心跳的主职责是「我还活着」, // 不该因为上报体格式不对就把 Agent 判成离线。 + // + // 但**未知字段必须回报**(resp["unknown_fields"]):这是全站唯一一处宽容 + // 解码的端点,若还静默忽略,插件把 `models` 拼成 `modles` 就永远没人知道 —— + // 而那与 `attachments` vs `attachment_ids` 是同一种事故形状。 var req heartbeatRequest + var unknownFields []string if r.ContentLength > 0 { - _ = Decode(r, &req) + unknownFields, _ = DecodeLenient(r, &req) } syncedSessions := -1 // -1 = 本次未上报 if req.PlatformSessions != nil { @@ -183,6 +199,12 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) { } } + // 档位强制能力:省略时不动(保留现有值)。 + // 写失败不影响心跳本身 —— 心跳的主职责是「我还活着」。 + if req.ModeEnforcement != "" { + _ = repo.SetAgentModeEnforcement(r.Context(), agentName, req.ModeEnforcement) + } + // 心跳回传该 Agent 的累计统计与新任务默认预算。 // // 不再回传「剩余额度」:额度属于具体任务(会话)而不属于 Agent, @@ -212,6 +234,11 @@ func HeartbeatAgent(w http.ResponseWriter, r *http.Request) { resp["allowed_models"] = allowed resp["models_unrestricted"] = len(allowed) == 0 } + // 未知字段回报:只有真的出现时才带这一项,正常心跳的响应不多一个空数组。 + // 插件看到它就知道自己上报的某个字段服务端根本没收。 + if len(unknownFields) > 0 { + resp["unknown_fields"] = unknownFields + } JSON(w, http.StatusOK, resp) } diff --git a/gateway/internal/handler/forward.go b/gateway/internal/handler/forward.go index 2c1170d..5171134 100644 --- a/gateway/internal/handler/forward.go +++ b/gateway/internal/handler/forward.go @@ -127,7 +127,7 @@ func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor, subject := forwardSubject(req.Subject, src.Subject) // 转发按目标地址寻址,不带 reply_to:它是一条新线索,不该并进原会话 - sessionID, _, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias, agentLimiterKey(isAgent, actor)) + sessionID, _, _, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias, agentLimiterKey(isAgent, actor)) if err != nil { writeErr(w, err, "Failed to resolve session") return diff --git a/gateway/internal/handler/mail.go b/gateway/internal/handler/mail.go index e5b5d9e..662900a 100644 --- a/gateway/internal/handler/mail.go +++ b/gateway/internal/handler/mail.go @@ -37,6 +37,16 @@ type sendMailRequest struct { // 它由平台生成,模型伪造不出,而唯一约束保证同一条上游消息只能免费转一次。 Relay string `json:"relay"` // "" | "permission" | "summary" RelayKey string `json:"relay_key"` // 上游消息 id;relay 非空时必填 + + // FromSessionID 是发信时模型所处的邮件会话 id(即「这活是谁派给我的」)。 + // + // **只用于权限档位继承**:Agent 新开一条会话时,新会话不得比它所在 + // 的那条会话更宽松。注意这里**没有** permission_mode 字段 —— 那是有意的: + // 让 Agent 自己指定档位等于发一封 mode=full 的信就能提权。 + // + // 省略时回落到默认档(不是 full)。插件担不担得起传这个值不影响安全下限: + // 没传 = 拿默认档,不会因此拿到更大的权限。 + FromSessionID string `json:"from_session_id"` } // resolveTarget 根据三维地址 name@path.session 决定投递的会话。 @@ -52,18 +62,28 @@ type sendMailRequest struct { // byAgent 非空时表示这是 Agent 发起的投递,新建会话要过速率限制: // 往返预算按会话计,Agent 用 .new 开一串会话就等于绕过预算。 // 人类不受此限(手工点「新建邮件」的频率天然受限,加限制只会在批量派活时误伤)。 -func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, subject, alias string, byAgent string) (uuid.UUID, *uuid.UUID, error) { +// resolveTarget 依据地址的 session 位定位(或新建)会话。 +// +// 返回值:会话 id / 父邮件 id(仅 reply_to 路径非 nil)/ **created** / 错误。 +// +// created 为真**仅**表示这次调用真的新建了一条会话。它存在的理由是: +// `parentMailID == nil` 曾被当作「新建会话」的判据,而那是错的 —— +// 省略 session 位复用默认会话时 parentMailID 也是 nil。实测后果: +// 第一封信 `max_rounds=7`,第二封信省略该字段,会话预算被静默改成 20。 +// 「只在新建时生效」的字段(往返预算、权限档位)必须靠这个返回值判断, +// 否则每封新信都在改写对方正在遵守的规则。 +func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, subject, alias string, byAgent string) (uuid.UUID, *uuid.UUID, bool, error) { if replyTo != "" { replyID, err := uuid.Parse(replyTo) if err != nil { - return uuid.Nil, nil, errBadRequest("Invalid reply_to UUID") + return uuid.Nil, nil, false, errBadRequest("Invalid reply_to UUID") } mail, err := repo.GetMailByID(r.Context(), replyID) if err != nil { - return uuid.Nil, nil, errNotFound("Parent mail not found") + return uuid.Nil, nil, false, errNotFound("Parent mail not found") } repo.TouchSession(r.Context(), mail.SessionID) - return mail.SessionID, &replyID, nil + return mail.SessionID, &replyID, false, nil } switch addr.Mode() { @@ -73,17 +93,17 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub var aliasPtr *string if a := strings.TrimSpace(alias); a != "" { if err := validateSessionAlias(a); err != nil { - return uuid.Nil, nil, err + return uuid.Nil, nil, false, err } if _, err := repo.FindSessionByAlias(r.Context(), a); err == nil { - return uuid.Nil, nil, errConflict(fmt.Sprintf( + return uuid.Nil, nil, false, errConflict(fmt.Sprintf( "会话别名 %q 已被占用;若要接着该会话谈请用 %s@%s.%s", a, addr.Name, addr.Path, a)) } aliasPtr = &a } // Agent 主动开新线索要过速率限制 if ok, retry := repo.AllowNewSession(r.Context(), byAgent); !ok { - return uuid.Nil, nil, errRateLimited(fmt.Sprintf( + return uuid.Nil, nil, false, errRateLimited(fmt.Sprintf( "新建会话过于频繁(1 小时内已开 %d 条)。请在已有会话里继续,或 %d 秒后再试。", repo.SessionRateLimit(), retry)) } @@ -93,7 +113,7 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub if err != nil { // 建失败要把名额还回去:那次新建实际上没有发生 repo.ReleaseNewSession(r.Context(), byAgent) - return id, nil, err + return id, nil, false, err } // `.new` 是一次性动作:它建完会话就用完了,之后要再投进这条会话只能靠 // `name@path.<别名>`。未命名会话既查不到(FindNamedSessionFor 的 @@ -105,29 +125,32 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub // 只能用 reply_to 续谈,比整封退回轻。 _, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject)) } - return id, nil, nil + return id, nil, true, nil case models.SessionDefault: // 默认会话「从未通信则建立」也会产生新会话,但一个 name@path 只有一条, // 不构成暴开的手段,因此不计入速率限制。 - id, err := repo.FindOrCreateDefaultSession(r.Context(), addr.Name, addr.Path, fromAgent, subject) + // + // created 必须区分「这次建了」与「复用了既有的那条」:两者在这里都返回 + // parentMailID == nil,靠它判断会把续谈误当新建(预算与档位被静默改写)。 + id, created, err := repo.FindOrCreateDefaultSessionCreated(r.Context(), addr.Name, addr.Path, fromAgent, subject) if err != nil { - return id, nil, err + return id, nil, false, err } // 默认会话同样需要可寻址的别名:省略 session 位能投进来,但要**指名** // 投进这一条(而不是「该 name@path 当前的默认会话」)仍然只能靠别名。 // 已有别名时 EnsureSessionAlias 直接返回,复用旧会话不会被改名。 _, _ = repo.EnsureSessionAlias(r.Context(), id, repo.AutoAliasFor(addr.Name, subject)) - return id, nil, nil + return id, nil, created, nil default: // models.SessionNamed id, err := repo.FindNamedSessionFor(r.Context(), addr.Name, addr.Path, addr.Session) if err == nil { repo.TouchSession(r.Context(), id) - return id, nil, nil + return id, nil, false, nil } if !errors.Is(err, repo.ErrSessionNotFound) { - return uuid.Nil, nil, err + return uuid.Nil, nil, false, err } // 本侧没有这条别名 —— 再看平台会话镜像。 @@ -138,13 +161,15 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub // // 命中就**接管**它:本侧建一条会话并绑定 platform_id,插件收到投递 // 事件时据此 resume 那条平台会话而不是新建。 + // 接管**是**新建本侧会话(绑定了 platform_id 的那条), + // 所以 created 为真:它此前没有档位与预算,需要按这次投递定下来。 if adopted, aErr := adoptFromPlatform(r, addr, fromAgent, subject, byAgent); aErr == nil { - return adopted, nil, nil + return adopted, nil, true, nil } else if !errors.Is(aErr, repo.ErrSessionNotFound) { - return uuid.Nil, nil, aErr + return uuid.Nil, nil, false, aErr } - return uuid.Nil, nil, errNotFound(fmt.Sprintf( + return uuid.Nil, nil, false, errNotFound(fmt.Sprintf( "无法送达:会话 %q 不存在于 %s@%s。若要新建会话请用 %s@%s.new,投递默认会话请省略 session 位", addr.Session, addr.Name, addr.Path, addr.Name, addr.Path)) } @@ -236,18 +261,56 @@ func SendMail(w http.ResponseWriter, r *http.Request) { return } + // 附件可挂性必须在**建邮件之前**校验。 + // + // 原先只在 CreateMail 之后调 attachAll,于是附件不合法时返回 403/409, + // 但那封邮件已入库、已通知收件人、已扣预算(生产实测两封探针邮件均如此)。 + // 发件方看到 4xx 会重试,收件方于是收到两封。 + if !checkAttachable(w, r, attachIDs, agentName) { + return + } + // 可达性:收件人必须存在且未停用。Agent 侧同样要查 —— // 模型拿到 200 就会当作「话已传到」并停手等对方,而那封信永远不会有人读。 if !checkDeliverable(w, r, append([]models.Address{to}, ccList...)) { return } - sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, agentName, req.Subject, req.SessionAlias, agentName) + sessionID, parentMailID, created, err := resolveTarget(r, to, req.ReplyTo, agentName, req.Subject, req.SessionAlias, agentName) if err != nil { writeErr(w, err, "Failed to resolve session") return } + // Agent 新开的会话继承权限档位,**不得自行抬档**。 + // + // req 里根本没有 permission_mode 字段 —— 这是有意的:Agent 能指定档位 + // 就等于发一封 mode=full 的信给自己提权。档位由发信方当前所处的会话 + // (也就是「这活是谁派给我的」)推导,且只能同档或更严。 + // + // 这保证 plan 档的任务派不出 full 档的子任务 —— 与 hop_limit 防自激同形: + // 约束必须沿着链条传递下去,否则一跳之后就失效了。 + // 判据是 `created`:省略 session 位复用默认会话时 parentMailID 也是 nil, + // 用后者会让每一封续谈的信重新“继承”一次 —— 而那条会话的档位可能已经 + // 被人在对话页里改过,重继承等于把人的修改静默回滚。 + if created { + // 发信方自己那条会话的档位是上限。插件没传 from_session_id 时 + // 回落到默认档 —— 不会因为没传而拿到更大的权限。 + var parent *uuid.UUID + if req.FromSessionID != "" { + if pid, pErr := uuid.Parse(req.FromSessionID); pErr == nil { + parent = &pid + } + } + mode := repo.InheritedMode(r.Context(), parent, models.DefaultPermissionMode) + if _, sErr := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); sErr != nil { + Error(w, http.StatusInternalServerError, "Failed to set permission mode") + return + } + _ = repo.SetSessionEnforcement(r.Context(), sessionID, + repo.AgentModeEnforcement(r.Context(), to.Name)) + } + // 配额在建邮件之前扣:否则邮件已入库再报 403,收件方会看到一封发件方以为发失败的邮件。 // 只限制主动发信,不限制收信(卡住收信只会让邮件凭空消失)。 // @@ -373,6 +436,19 @@ func SendMail(w http.ResponseWriter, r *http.Request) { } if !attachAll(w, r, mailID, attachIDs, agentName) { + // 走到这里说明碰上了 checkAttachable 之后的竞态窗口(另一个请求把同一个 + // 附件挂走了)。必须回滚已产生的副作用,否则收件方会拿到一封没有附件的 + // 邮件,而发件方以为整次请求失败了。 + // + // 三件事都要退:邮件本身、本次往返预算、relay 幂等键。 + // 错误均忽略:响应已由 attachAll 写出,回滚失败只能记日志。 + _ = repo.DeleteMailByID(r.Context(), mailID) + if !relayFree { + repo.RefundSessionBudget(r.Context(), sessionID) + } + if relay != "" { + _ = repo.ReleaseRelay(r.Context(), agentName, relayKey) + } return } diff --git a/gateway/internal/handler/me.go b/gateway/internal/handler/me.go index 486567d..06c5de1 100644 --- a/gateway/internal/handler/me.go +++ b/gateway/internal/handler/me.go @@ -14,8 +14,8 @@ import ( // ---------- /me:当前登录人类用户的邮箱(全部路由需 UserAuth) ---------- type meSendMailRequest struct { - To string `json:"to"` // name@path.session - CC string `json:"cc"` // 多个 name@path.session + To string `json:"to"` // name@path.session + CC string `json:"cc"` // 多个 name@path.session Subject string `json:"subject"` Body string `json:"body"` ReplyTo string `json:"reply_to"` @@ -30,6 +30,16 @@ type meSendMailRequest struct { // 仅在本次投递【新建】会话时生效;续谈已有会话请用 // PUT /sessions/{id}/budget(对话页里可随时改)。 MaxRounds *int `json:"max_rounds"` + + // PermissionMode 声明本任务允许 Agent 动手到什么程度:plan / workspace / full。 + // + // 与 MaxRounds 同理,**仅在本次投递【新建】会话时生效**:续谈已有会话若也接受 + // 这个字段,每封新信都会悄悄改掉对方正在遵守的规则 —— 而 plan 档的会话里 + // 模型已经被告知「只许看」,第二封信把它改成 full 是在一段已有上下文里换规则。 + // 续谈请用 PUT /sessions/{id}/permission(对话页里可随时改)。 + // + // 省略时用 models.DefaultPermissionMode(workspace)。 + PermissionMode string `json:"permission_mode"` } // POST /api/v1/me/mail/send @@ -65,6 +75,11 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) { return } + // 附件可挂性必须在**建邮件之前**校验(与 Agent 侧同理,见 mail.go)。 + if !checkAttachable(w, r, attachIDs, user.Username) { + return + } + // human@ 是兼容别名,人类发信时解析为自己 to = resolveHumanAlias(to, user.Username) for i := range ccList { @@ -82,7 +97,24 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) { return } - sessionID, parentMailID, err := resolveTarget(r, to, req.ReplyTo, user.Username, req.Subject, req.SessionAlias, "") + // 纯输入校验必须在建会话【之前】做完。 + // + // 原来两项校验都在 resolveTarget 之后:请求返回 400,但 `.new` 已经建好了 + // 会话、占掉了新建速率名额、并留下一条谁也不会再用的空线索。实测发 5 封 + // 非法请求就攒下 5 条垃圾会话。校验不依赖会话,本来就该先做。 + rounds := -1 + if req.MaxRounds != nil { + if *req.MaxRounds < 0 { + Error(w, http.StatusBadRequest, "max_rounds 不能为负") + return + } + rounds = *req.MaxRounds + } + if !validPermissionModeInput(w, req.PermissionMode) { + return + } + + sessionID, parentMailID, created, err := resolveTarget(r, to, req.ReplyTo, user.Username, req.Subject, req.SessionAlias, "") if err != nil { writeErr(w, err, "Failed to resolve session") return @@ -95,21 +127,30 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) { // // 没显式给就用【收件 Agent 的默认值】。默认值挂在 Agent 上而不是全站一个数: // 跑测试的小工具与重构整个模块的 Agent,合理来回数差一个量级。 - if parentMailID == nil { - rounds := 0 - if req.MaxRounds != nil { - if *req.MaxRounds < 0 { - Error(w, http.StatusBadRequest, "max_rounds 不能为负") - return - } - rounds = *req.MaxRounds - } else { + // 判据是 `created` 而不是 `parentMailID == nil`:后者在「省略 session 位复用 + // 默认会话」时也成立,于是第二封信会把对方正在遵守的预算改写成默认值 + //(实测:max_rounds=7 的会话被第二封省略该字段的信改成 20)。 + if created { + if rounds < 0 { rounds = repo.DefaultRoundsFor(r.Context(), to.Name) } if _, err := repo.SetSessionBudget(r.Context(), sessionID, rounds); err != nil { Error(w, http.StatusInternalServerError, "Failed to set session budget") return } + + // 权限档位同样只在新建时定。人可以直接指定(不继承)—— 人就是权限的源头, + // 而 Agent 侧的 SendMail 走 InheritedMode 不得自行抬档。 + mode := models.NormalizePermissionMode(req.PermissionMode) + if _, err := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); err != nil { + Error(w, http.StatusInternalServerError, "Failed to set permission mode") + return + } + // 强制力是事实快照:按收件 Agent 当下自报的能力定死。 + // 收件方是人类时也走这里 —— AgentModeEnforcement 查不到就返回 advisory, + // 而人的收件箱本来不执行任何档位,这个值对他无意义也无害。 + _ = repo.SetSessionEnforcement(r.Context(), sessionID, + repo.AgentModeEnforcement(r.Context(), to.Name)) } // 人类侧不产生改名提议(人直接有改名按钮,用不着向自己提议), @@ -124,6 +165,9 @@ func MeSendMail(w http.ResponseWriter, r *http.Request) { } if !attachAll(w, r, mailID, attachIDs, user.Username) { + // 竞态窗口(见 mail.go 同位置):回滚那封已入库的邮件。 + // 人类发信不扣会话预算、也不走 relay,所以只需退邮件本身。 + _ = repo.DeleteMailByID(r.Context(), mailID) return } diff --git a/gateway/internal/handler/permission.go b/gateway/internal/handler/permission.go index 4a54171..baf6b0e 100644 --- a/gateway/internal/handler/permission.go +++ b/gateway/internal/handler/permission.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/agentmail/gateway/internal/middleware" + "github.com/agentmail/gateway/internal/models" "github.com/agentmail/gateway/internal/repo" "github.com/agentmail/gateway/internal/sse" "github.com/google/uuid" @@ -98,7 +99,47 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) { sessionID = id } - // 决策人:显式指定优先,否则取会话 owner + // 权限档位决定这次询问该不该存在。 + // + // 只有 workspace 档需要人: + // - plan 档 → 409。该档的语义就是「这轮不动手」,没什么可问人的, + // 模型该做的是把方案写在回信里。 + // - full 档 → 409。已经声明全权,再问一遍只是噪音;插件本不该发这封信, + // 发了说明它没按档位翻译,报错比静默接受好。 + // + // 这也是为什么下面不再有「退回第一个管理员」的兜底: + // 既然只有一档需要人,那一档里找不到人就是 409,没有中间形态。 + mode := repo.SessionPermissionMode(r.Context(), sessionID) + if !models.ModeNeedsHuman(mode) { + if relayKey != "" { + _ = repo.ReleaseRelay(r.Context(), agentName, relayKey) + } + detail := "本会话的权限档位是 " + mode + ",不产生权限询问。" + suggestion := "" + if mode == models.ModePlan { + suggestion = "plan 档只允许读与查。请不要尝试写入或执行命令," + + "把方案、需要人工执行的步骤写在回信里。如需动手,请请发件人把档位改成 workspace。" + } else { + suggestion = "full 档下工具调用无需审批,插件不应该转发权限询问。" + + "这通常意味着插件没按会话档位配置平台的审批策略。" + } + JSON(w, http.StatusConflict, map[string]interface{}{ + "error": "本会话不接受权限询问(档位 " + mode + ")", + "detail": detail, + "suggestion": suggestion, + "permission_mode": mode, + }) + return + } + + // 决策人:显式指定优先,否则取会话 owner,再否则沿线索找最近的人类。 + // + // **不再退回第一个管理员**。那段兜底让下面的 409 分支永远不可达: + // decider 空 → 填上管理员 → IsHumanUser 通过 → NearestHumanInThread 根本不会被调用。 + // 实测:pi 给自己新开会话派活跑 bash,权限邮件 to_name=jianf,而那条链上 + // 没有任何人类参与过。而且那段 409 自己的注释就在论证兜底是错的: + // 「管理员对这条 Agent 链的上下文一无所知」。两条策略互相矛盾, + // 先执行的那条把后写的那条变成了死代码。 decider := req.To if decider == "" || decider == "human" { owner, err := repo.SessionOwnerUsername(r.Context(), sessionID) @@ -106,15 +147,6 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) { decider = owner } } - if decider == "" { - // 会话无归属(Agent 自发起)时退回默认管理员 - admin, err := repo.FirstAdminUsername(r.Context()) - if err != nil || admin == "" { - Error(w, http.StatusConflict, "无法确定决策人,请在请求中指定 to") - return - } - decider = admin - } // 关键防线:decider 必须是人类用户。 // @@ -130,7 +162,13 @@ func RequestPermission(w http.ResponseWriter, r *http.Request) { decider = human } else { // 整条任务链上没有人类:Agent → Agent → Agent,中间没有任何人介入。 - // 此时把权限请求转给管理员毫无意义 —— 管理员对这条 Agent 链的上下文一无所知, + // + // 这条分支曾经**永远不可达**:上游有一段「退回第一个管理员」的兜底, + // 把 decider 填成 admin,IsHumanUser 于是通过,这里根本不会被调用。 + // 实测:pi 给自己新开会话派活跑 bash → 权限邮件 to_name=jianf。 + // 那段兜底已删(参见上面的档位判定)。 + // + // 为什么不该转给管理员:管理员对这条 Agent 链的上下文一无所知, // 既不知道这个 bash 命令在做什么,也不知道拒绝后 Agent 该怎么绕过去。 // // 正确做法:直接拒绝,让 Agent 收到明确的错误信息,由它自己决定下一步: diff --git a/gateway/internal/models/models.go b/gateway/internal/models/models.go index 5da6aa8..00f6dd6 100644 --- a/gateway/internal/models/models.go +++ b/gateway/internal/models/models.go @@ -25,6 +25,14 @@ type Agent struct { UsedRounds int `json:"used_rounds"` LastSeen *time.Time `json:"last_seen"` CreatedAt time.Time `json:"created_at"` + + // ModeEnforcement 是该平台插件自报的权限档位强制能力(native / advisory), + // 随心跳上报(与模型目录同一条通道,见 I-1:平台自己说的才算)。 + // + // 为什么要存:发件人在派活前得知道 plan 档在对方那儿到底算不算。 + // homeagent 的核心没有工具调用拦截点,档位只能写进提示词 —— + // 把这个事实藏起来比做不到本身更危险。 + ModeEnforcement string `json:"mode_enforcement"` } // Workspace 是 Agent 管理的项目工作区 @@ -60,6 +68,14 @@ type Session struct { // 所以在写信时给、在对话页里随时调。 MaxRounds int `json:"max_rounds"` UsedRounds int `json:"used_rounds"` + + // PermissionMode 声明本任务允许 Agent 动手到什么程度: + // plan / workspace / full。空值按 DefaultPermissionMode 处理。 + PermissionMode string `json:"permission_mode"` + + // PermissionEnforcement 记录接收平台是否真正强制了权限档位: + // native / advisory。它描述执行事实,不与 PermissionMode 混为一谈。 + PermissionEnforcement string `json:"permission_enforcement"` } // User 是人类用户(多用户账号体系) @@ -173,6 +189,25 @@ type Mail struct { // `GET /mail/inbox` 补投 —— 那条路径上没有这个字段的话,补投的邮件会被 // 保守当成 Agent 来信,于是人发的那封失去自动回信。 FromHuman bool `json:"from_human"` + + // ToHuman 表示收件方是人类用户而不是 Agent(判据与 FromHuman 同源: + // to_name 是否存在于 users 表)。 + // + // 前端拼地址时靠它决定「要不要带 path 与会话位」:人只写名字, + // Agent 才拼 `name@path.session`。此前靠 `to_workspace` 是否为空的启发式 —— + // 但对 Agent 而言 to_workspace 存的是 Agent 名而不是路径(历史遗留), + // 那条启发式在「Agent 名恰好为空」时会猜错。显式布尔胜过猜。 + ToHuman bool `json:"to_human"` + + // PermissionMode / PermissionEnforcement 是所属会话的权限档位与实际强制力。 + // + // **补拉路径必须有它们**(与 FromHuman 同一个理由):SSE 事件里叫 + // `permission_mode` / `permission_enforcement`,而插件重启后走 + // `GET /mail/inbox` 补投 —— 那条路径上没有这两个字段的话,补投的邮件 + // 会拿不到档位,插件只能回落默认档 —— 于是一条 plan 档的任务在重启后 + // 惄惄变成了 workspace 档。 + PermissionMode string `json:"permission_mode"` + PermissionEnforcement string `json:"permission_enforcement"` } // PermissionRequest 是 Agent 向人类发起的权限请求 diff --git a/gateway/internal/models/permission_mode.go b/gateway/internal/models/permission_mode.go new file mode 100644 index 0000000..d6f766d --- /dev/null +++ b/gateway/internal/models/permission_mode.go @@ -0,0 +1,142 @@ +package models + +// ─── 权限档位 ─── +// +// 三档描述「这条任务允许 Agent 动手到什么程度」。**AgentMail 声明,平台执行, +// 插件只做翻译** —— 不能让插件按工具名自己猜着拦,那会同时违反 I-1(平台原生 +// 信号是唯一真相来源)与 I-4(插件只搬运不决策),而且四个插件对「workspace +// 到底管什么」必然各猜一套。 +// +// 档位与 DSH 原生的三档沙箱一一对应(read-only / workspace-write / +// danger-full-access,见 @deepseek-ai/dsh-sandbox-policy)—— 那不是巧合, +// 是同一个问题的同一个答案。 +const ( + // ModePlan 只读:查资料、读代码、出方案,一个字都不许写。 + // + // 危险操作**直接拒绝**,不产生权限邮件 —— plan 档的语义就是「这轮不动手」, + // 没什么可问人的。模型该做的是把方案写在回信里。 + ModePlan = "plan" + + // ModeWorkspace 本目录内可动手,越界要问人。默认档。 + // + // 「本目录」= 会话的 workspace(三维地址的 path 位)。越界的定义是 + // 写到那个目录之外,或跑一条无法判定影响范围的命令。 + ModeWorkspace = "workspace" + + // ModeFull 自动放行,不问人。 + // + // 不产生权限邮件:既然已经声明了全权,再问一遍只是噪音。 + ModeFull = "full" +) + +// DefaultPermissionMode 是没有显式指定时的档位。 +// +// 选 workspace 而不是 full:默认值应当是「多数任务够用且出错代价可控」的那一档。 +// 一个默认全权的系统里,「我忘了收紧」与「我确实需要全权」在数据上无法区分。 +const DefaultPermissionMode = ModeWorkspace + +// PermissionModes 是全部合法档位,按宽松程度递增排列。 +// +// 顺序有意义:ModeAtMost 靠它做「向更严取整」。 +var PermissionModes = []string{ModePlan, ModeWorkspace, ModeFull} + +// ValidPermissionMode 判断是不是合法档位。 +func ValidPermissionMode(m string) bool { + for _, v := range PermissionModes { + if v == m { + return true + } + } + return false +} + +// NormalizePermissionMode 把外部输入收敛成合法档位。 +// +// 空串 → 默认档;非法值 → 默认档(**不是** ModeFull)。 +// 拼错一个档位名不该换来比预期更大的权限。 +func NormalizePermissionMode(m string) string { + if ValidPermissionMode(m) { + return m + } + return DefaultPermissionMode +} + +// modeRank 是档位的宽松程度序号,越大越宽松。 +// +// 只接已经归一化过的档位 —— 调用方负责先跑 NormalizePermissionMode。 +// 让它自己处理非法值会造出两套语义:曾经这里把未知值当 rank 0(plan), +// 而 NormalizePermissionMode 把它归到 workspace,于是同一个脏值在不同函数里 +// 含义不同,ModeAtMost 也因此不可交换(单元测试当场抓到)。 +func modeRank(m string) int { + for i, v := range PermissionModes { + if v == m { + return i + } + } + // 归一化后不可能走到这里;防御性地返回默认档的序号。 + return modeRank(DefaultPermissionMode) +} + +// ModeAtMost 返回 a 与 b 里更严的那一档。 +// +// 两个用途: +// - 子会话继承:Agent 派活时子会话不得比父会话宽松(plan 档派不出 full 档子任务) +// - 平台取整:平台表达不出精确档位时向更严的方向取整 +// +// 为什么必须是同一个函数:这两处若各写一遍,早晚有一处会写成「取更宽松」。 +// +// **先归一化再比较**:两个脏值都变成默认档,于是结果与参数顺序无关(可交换), +// 也与 NormalizePermissionMode / ModeNeedsHuman 对同一个脏值的理解一致。 +func ModeAtMost(a, b string) string { + na := NormalizePermissionMode(a) + nb := NormalizePermissionMode(b) + if modeRank(na) <= modeRank(nb) { + return na + } + return nb +} + +// ModeNeedsHuman 这一档会不会产生权限邮件(即需不需要人来点头)。 +// +// 只有 workspace 档需要人。这一点直接决定了「找不到人类时怎么办」: +// plan 档当场拒绝、full 档自动放行,两者都不问人,所以**只有 workspace 档 +// 会走到「这条链上有没有人类」这个问题**,找不到就是 409。 +// +// 这也是为什么 permission.go 里那段「退回第一个管理员」的兜底必须删掉: +// 它让 409 分支永远不可达(实测:pi 给自己派活跑 bash,权限邮件发给了 jianf), +// 而那段 409 的注释本身就在论证兜底是错的 —— 管理员对这条 Agent 链一无所知。 +func ModeNeedsHuman(m string) bool { + return NormalizePermissionMode(m) == ModeWorkspace +} + +// ─── 强制力 ─── +// +// 档位是「要求什么」,强制力是「平台实际做到了什么」。两者必须分开记录并且 +// 都对人可见(I-5:失败必须可见)—— 否则发件人以为 plan 档管住了 homeagent, +// 而 homeagent 的核心根本没有工具调用拦截点。 +const ( + // EnforcementNative 平台有原生拦截点,档位被真正执行。 + EnforcementNative = "native" + + // EnforcementAdvisory 平台没有拦截点,档位只写进提示词。 + // + // 模型至少知道「这活只让你看不让你动」,但没有任何机制阻止它动手。 + // 这不是缺陷掩饰 —— 是把「做不到」如实标出来,让发件人自己决定要不要派。 + EnforcementAdvisory = "advisory" +) + +// ValidEnforcement 判断强制力取值是否合法。 +func ValidEnforcement(e string) bool { + return e == EnforcementNative || e == EnforcementAdvisory +} + +// NormalizeEnforcement 收敛强制力取值。 +// +// 空串或非法值 → advisory。**保守方向是 advisory 而不是 native**: +// 没自报过的插件,我们不能替它宣称「档位在这里是被强制的」。 +func NormalizeEnforcement(e string) string { + if ValidEnforcement(e) { + return e + } + return EnforcementAdvisory +} diff --git a/gateway/internal/models/permission_mode_test.go b/gateway/internal/models/permission_mode_test.go new file mode 100644 index 0000000..a32afb8 --- /dev/null +++ b/gateway/internal/models/permission_mode_test.go @@ -0,0 +1,160 @@ +package models + +// 权限档位的判据测试。 +// +// 为什么值得单独一组测试:`ModeAtMost` 被两处调用(子会话继承 / 平台向更严取整), +// 两处若各写一遍必有一处写成「取更宽松」。而 `NormalizePermissionMode` 的保守 +// 取向(非法值 → workspace 而非 full)是安全属性,拼错一个档位名不该换来更大权限。 + +import "testing" + +func TestValidPermissionMode(t *testing.T) { + for _, m := range []string{ModePlan, ModeWorkspace, ModeFull} { + if !ValidPermissionMode(m) { + t.Fatalf("%q 应当合法", m) + } + } + for _, m := range []string{"", "PLAN", "readonly", "danger-full-access", "workspace-write"} { + if ValidPermissionMode(m) { + t.Fatalf("%q 不该合法", m) + } + } +} + +// 非法值必须落到 workspace,不能落到 full。 +// 拼错一个档位名换来全权是最不该有的失败方向。 +func TestNormalizePermissionMode_FailsClosed(t *testing.T) { + for _, in := range []string{"", "full-access", "plan ", "FULL", "无", "workspace-write"} { + got := NormalizePermissionMode(in) + if got != DefaultPermissionMode { + t.Fatalf("NormalizePermissionMode(%q) = %q,应当是默认档 %q", in, got, DefaultPermissionMode) + } + } + if DefaultPermissionMode == ModeFull { + t.Fatal("默认档不能是 full —— 「我忘了收紧」与「我确实需要全权」会无法区分") + } +} + +func TestNormalizePermissionMode_KeepsValid(t *testing.T) { + for _, m := range []string{ModePlan, ModeWorkspace, ModeFull} { + if got := NormalizePermissionMode(m); got != m { + t.Fatalf("合法档位应原样返回:%q → %q", m, got) + } + } +} + +// ModeAtMost 取更严的一档 —— 子会话继承与平台取整共用这一个判据。 +func TestModeAtMost(t *testing.T) { + cases := []struct{ a, b, want string }{ + {ModePlan, ModeFull, ModePlan}, + {ModeFull, ModePlan, ModePlan}, + {ModeWorkspace, ModeFull, ModeWorkspace}, + {ModeFull, ModeWorkspace, ModeWorkspace}, + {ModePlan, ModeWorkspace, ModePlan}, + {ModeWorkspace, ModePlan, ModePlan}, + {ModeFull, ModeFull, ModeFull}, + {ModePlan, ModePlan, ModePlan}, + {ModeWorkspace, ModeWorkspace, ModeWorkspace}, + } + for _, c := range cases { + if got := ModeAtMost(c.a, c.b); got != c.want { + t.Fatalf("ModeAtMost(%q,%q) = %q,want %q", c.a, c.b, got, c.want) + } + } +} + +// 未知值归到默认档(workspace),而不是最严的 plan。 +// +// 为什么不是 plan:脏数据的含义应该在整个包里只有一个 —— +// NormalizePermissionMode / ModeNeedsHuman 都把它当默认档,ModeAtMost +// 若单独把它当 plan,同一个脏值就有两种语义,且 ModeAtMost 不可交换 +// (单元测试当场抓到过)。一致比“局部更严”重要:默认档本身已经是安全的。 +func TestModeAtMost_UnknownFallsToDefault(t *testing.T) { + if got := ModeAtMost("garbage", ModeFull); got != DefaultPermissionMode { + t.Fatalf("未知档位应归默认档,得到 %q", got) + } + if got := ModeAtMost(ModeFull, "garbage"); got != DefaultPermissionMode { + t.Fatalf("未知档位应归默认档,得到 %q", got) + } + // 脏值不得抬升权限:与 plan 相遇时仍然是 plan 胜出。 + if got := ModeAtMost("garbage", ModePlan); got != ModePlan { + t.Fatalf("脏值不该把 plan 抬成更宽松的档,得到 %q", got) + } +} + +// ModeAtMost 必须可交换:两处调用点传参顺序不同,结果不能不同。 +func TestModeAtMost_Commutative(t *testing.T) { + all := append([]string{"garbage", ""}, PermissionModes...) + for _, a := range all { + for _, b := range all { + if ModeAtMost(a, b) != ModeAtMost(b, a) { + t.Fatalf("ModeAtMost 不可交换:(%q,%q)=%q 但 (%q,%q)=%q", + a, b, ModeAtMost(a, b), b, a, ModeAtMost(b, a)) + } + } + } +} + +// 只有 workspace 档需要人 —— 这一条直接决定「找不到人类时怎么办」。 +// +// plan 档当场拒绝、full 档自动放行,两者都不问人,所以只有 workspace 档会 +// 走到「这条链上有没有人类」这个问题,找不到就是 409。permission.go 里那段 +// 「退回第一个管理员」的兜底正因此必须删掉:它让 409 分支永远不可达。 +func TestModeNeedsHuman(t *testing.T) { + if ModeNeedsHuman(ModePlan) { + t.Fatal("plan 档不该问人:语义就是这轮不动手,直接拒绝即可") + } + if !ModeNeedsHuman(ModeWorkspace) { + t.Fatal("workspace 档必须问人:越界时需要人点头") + } + if ModeNeedsHuman(ModeFull) { + t.Fatal("full 档不该问人:已声明全权,再问一遍只是噪音") + } +} + +func TestModeNeedsHuman_NormalizesInput(t *testing.T) { + // 脏数据走默认档(workspace)→ 需要人。宁可多问一次,不可静默放行。 + if !ModeNeedsHuman("garbage") { + t.Fatal("认不出的档位应当按默认档处理,即需要人") + } + if !ModeNeedsHuman("") { + t.Fatal("空档位应当按默认档处理,即需要人") + } +} + +// ─── 强制力 ─── + +func TestValidEnforcement(t *testing.T) { + if !ValidEnforcement(EnforcementNative) || !ValidEnforcement(EnforcementAdvisory) { + t.Fatal("native / advisory 都应合法") + } + for _, e := range []string{"", "NATIVE", "none", "enforced"} { + if ValidEnforcement(e) { + t.Fatalf("%q 不该合法", e) + } + } +} + +// 保守方向是 advisory:没自报过的插件,不能替它宣称档位在那里是被强制的。 +func TestNormalizeEnforcement_FailsClosed(t *testing.T) { + for _, in := range []string{"", "garbage", "NATIVE", "native "} { + if got := NormalizeEnforcement(in); got != EnforcementAdvisory { + t.Fatalf("NormalizeEnforcement(%q) = %q,应当是 advisory", in, got) + } + } + if got := NormalizeEnforcement(EnforcementNative); got != EnforcementNative { + t.Fatalf("显式 native 应原样保留,得到 %q", got) + } +} + +// PermissionModes 的顺序是 ModeAtMost 的依据,不能被随手改动。 +func TestPermissionModesOrder(t *testing.T) { + if len(PermissionModes) != 3 { + t.Fatalf("档位应当是三个,得到 %d 个", len(PermissionModes)) + } + if PermissionModes[0] != ModePlan || + PermissionModes[1] != ModeWorkspace || + PermissionModes[2] != ModeFull { + t.Fatalf("PermissionModes 必须按宽松程度递增排列(plan < workspace < full),得到 %v", PermissionModes) + } +} diff --git a/gateway/internal/notify/mail.go b/gateway/internal/notify/mail.go index 2dde1f8..bf1cbcd 100644 --- a/gateway/internal/notify/mail.go +++ b/gateway/internal/notify/mail.go @@ -121,6 +121,21 @@ func Recipients(ctx context.Context, m Mail) { // 生产实测 pi 与 dsh 互相客套 6 轮直到撞上 hop 上限。 fromHuman, _ := repo.IsHumanUser(ctx, m.From) + // 会话级权限档位与强制力。 + // + // 为什么跑在 payload 外:一封邮件可能推给十几个参与方(收件人 + 拄送), + // 而档位是**会话**的属性,每个人都一样 —— 放进闭包里就是每个参与方 + // 查一次库。platformFor 那个坑(会话级的值推给所有人)教过的是反面: + // 会话级与参与方级的字段必须分清楚。档位确实是会话级的。 + perm, permErr := repo.GetSessionPermission(ctx, m.SessionID) + if permErr != nil { + // 查不到时给默认档 + advisory:不能因为一次查询失败就让插件以为自己拿到了全权。 + perm = repo.SessionPermission{ + Mode: models.DefaultPermissionMode, + Enforcement: models.EnforcementAdvisory, + } + } + payload := func(role, workspace, forName string) map[string]interface{} { p := map[string]interface{}{ "mail_id": m.MailID.String(), @@ -160,6 +175,18 @@ func Recipients(ctx context.Context, m Mail) { // 插件据此不再对 Agent → Agent 的信说「回信不用你自己发」: // 那句话在那种情形下是假的,而它让模型以为自己只需要「把话说完」。 "from_human": fromHuman, + // permission_mode 声明本任务允许动手到什么程度:plan / workspace / full。 + // + // 插件必须把它**翻译成平台原生的沙箱/审批配置**,而不是自己按工具名猜着拦: + // 那会同时违反 I-1(平台原生信号是唯一真相来源)与 I-4(插件只搬运不决策), + // 而且四个插件对「workspace 到底管什么」必然各猜一套。 + "permission_mode": perm.Mode, + // permission_enforcement 是建会话时快照的**事实**:native / advisory。 + // + // 与 permission_mode 成对下发:前者是要求,后者是对方平台实际做得到。 + // 只给前者会让人以为 plan 档把 homeagent 管住了 —— 它的核心没有 + // 工具调用拦截点,档位在那里只能写进提示词。 + "permission_enforcement": perm.Enforcement, } if m.Origin != "" { p["origin"] = m.Origin diff --git a/gateway/internal/repo/permission_mode.go b/gateway/internal/repo/permission_mode.go new file mode 100644 index 0000000..0b07468 --- /dev/null +++ b/gateway/internal/repo/permission_mode.go @@ -0,0 +1,148 @@ +package repo + +// 会话级权限档位的读写。 +// +// ## 为什么档位挂在会话上而不是每封邮件上 +// +// 与配额同一个理由(见 quota.go 的注释):档位是**任务**的属性。 +// 「这件事只许你看不许你动」描述的是任务性质,不是某一封信的性质。 +// +// 如果续谈的邮件也能带档位,每封新信都会悄悄改掉对方正在遵守的规则 —— +// 而 plan 档的会话里模型已经被告知「只许看」,第二封信把它改成 full, +// 是在一段已有上下文里换规则。人不一定意识到自己改了。 +// +// 所以:**新建会话时设,续谈时忽略该字段,在对话页里显式编辑。** +// +// ## 为什么 Agent 不能自己指定档位 +// +// 否则 Agent 发一封 mode=full 的信就给自己提权了。Agent 派活时子会话的档位 +// 由 InheritedMode 从父会话推导,且**只能同档或更严**(models.ModeAtMost)。 +// 这保证 plan 档的任务派不出 full 档的子任务 —— 与 hop_limit 一个形状。 + +import ( + "context" + "fmt" + + "github.com/agentmail/gateway/internal/db" + "github.com/agentmail/gateway/internal/models" + "github.com/google/uuid" +) + +// SessionPermission 是一条会话的档位与实际强制力。 +// +// 两个字段必须一起返回:档位是「要求什么」,强制力是「平台实际做到了什么」。 +// 只给前者会让人以为 plan 档管住了 homeagent(它的核心没有工具调用拦截点)。 +type SessionPermission struct { + Mode string `json:"permission_mode"` + Enforcement string `json:"permission_enforcement"` +} + +// GetSessionPermission 读一条会话的档位与强制力。 +// +// 读出来的值一律过 Normalize:库里可能有历史脏数据(手工改库、旧版本写入), +// 而调用方拿到一个认不出的档位时的行为无法预期。归一化在这里做一次, +// 后续所有判断就都能假定值是合法的。 +func GetSessionPermission(ctx context.Context, id uuid.UUID) (SessionPermission, error) { + var p SessionPermission + err := db.DB.QueryRowContext(ctx, + `SELECT COALESCE(NULLIF(permission_mode, ''), 'workspace'), + COALESCE(NULLIF(permission_enforcement, ''), 'advisory') + FROM sessions WHERE session_id = $1`, id).Scan(&p.Mode, &p.Enforcement) + if err != nil { + return SessionPermission{}, err + } + p.Mode = models.NormalizePermissionMode(p.Mode) + p.Enforcement = models.NormalizeEnforcement(p.Enforcement) + return p, nil +} + +// SessionPermissionMode 只取档位,读不到时回落默认档。 +// +// 供投递路径使用:那里拿不到档位也得继续走(不能因为查询失败就拒收邮件), +// 但回落必须是默认档而不是 full —— 查询失败不该换来更大的权限。 +func SessionPermissionMode(ctx context.Context, id uuid.UUID) string { + p, err := GetSessionPermission(ctx, id) + if err != nil { + return models.DefaultPermissionMode + } + return p.Mode +} + +// SetSessionPermissionMode 设置会话档位。 +// +// 非法档位一律收敛成默认档而不是报错:这个函数的调用方包括人在界面上操作, +// 而界面传来一个拼错的值时,静默用默认档比让整次操作失败更合理 —— +// 默认档本身是安全的。 +func SetSessionPermissionMode(ctx context.Context, id uuid.UUID, mode string) (SessionPermission, error) { + m := models.NormalizePermissionMode(mode) + tag, err := db.DB.ExecContext(ctx, + `UPDATE sessions SET permission_mode = $2, updated_at = NOW() WHERE session_id = $1`, + id, m) + if err != nil { + return SessionPermission{}, err + } + if n, _ := tag.RowsAffected(); n == 0 { + return SessionPermission{}, fmt.Errorf("会话 %s 不存在", id) + } + return GetSessionPermission(ctx, id) +} + +// SetSessionEnforcement 记录接收平台实际做到的强制力。 +// +// 由投递路径在建会话时按收件 Agent 的自报能力写入 —— 它是**事实快照** +// 而不是配置:插件升级后能力会变,但已结束的会话不该被改写成「其实当时 +// 是被强制的」。所以不跟着 agents.mode_enforcement 走,而是建会话时定死。 +func SetSessionEnforcement(ctx context.Context, id uuid.UUID, enforcement string) error { + e := models.NormalizeEnforcement(enforcement) + _, err := db.DB.ExecContext(ctx, + `UPDATE sessions SET permission_enforcement = $2 WHERE session_id = $1`, id, e) + return err +} + +// AgentModeEnforcement 取某个 Agent 自报的档位强制力。 +// +// Agent 不存在或没自报过时返回 advisory:不能替一个没说过话的插件宣称 +// 「档位在它那里是被强制的」。保守方向是承认做不到。 +func AgentModeEnforcement(ctx context.Context, agentName string) string { + var e string + err := db.DB.QueryRowContext(ctx, + `SELECT COALESCE(NULLIF(mode_enforcement, ''), 'advisory') + FROM agents WHERE agent_name = $1`, agentName).Scan(&e) + if err != nil { + return models.EnforcementAdvisory + } + return models.NormalizeEnforcement(e) +} + +// SetAgentModeEnforcement 落库 Agent 心跳自报的档位强制力。 +// +// 走心跳而不是注册:注册只在插件启动时发生一次,而能力可能因为配置变化 +// (比如 DSH 的 sandbox 被换成 danger-full-access)而改变。与模型目录上报 +// 同一条通道 —— I-1:平台自己说的才算。 +func SetAgentModeEnforcement(ctx context.Context, agentName, enforcement string) error { + e := models.NormalizeEnforcement(enforcement) + _, err := db.DB.ExecContext(ctx, + `UPDATE agents SET mode_enforcement = $2 WHERE agent_name = $1`, agentName, e) + return err +} + +// InheritedMode 推导子会话应当继承的档位。 +// +// parentSessionID 为 nil(人直接发起、或没有父会话可依据)时返回 requested +// 归一化后的值;有父会话时取**父档位与请求档位里更严的那一个**。 +// +// 为什么必须取更严:Agent 派活时若能给子会话一个更宽松的档位,plan 档的 +// 任务就能通过「派给自己一条 full 档子会话」来提权,档位形同虚设。 +// 这与 hop_limit 防自激的形状一样 —— 约束必须沿着链条传递下去。 +func InheritedMode(ctx context.Context, parentSessionID *uuid.UUID, requested string) string { + req := models.NormalizePermissionMode(requested) + if parentSessionID == nil { + return req + } + parent, err := GetSessionPermission(ctx, *parentSessionID) + if err != nil { + // 父会话查不到时按默认档与请求档取更严 —— 不能因为查询失败而放宽。 + return models.ModeAtMost(models.DefaultPermissionMode, req) + } + return models.ModeAtMost(parent.Mode, req) +} diff --git a/gateway/internal/repo/repo.go b/gateway/internal/repo/repo.go index 1efcdec..8a0a7e9 100644 --- a/gateway/internal/repo/repo.go +++ b/gateway/internal/repo/repo.go @@ -266,11 +266,14 @@ func GetSessionByID(ctx context.Context, id uuid.UUID) (*models.Session, error) err := db.DB.QueryRowContext(ctx, `SELECT session_id, session_alias, from_agent, subject, status, owner_user_id, created_at, updated_at, rename_dismissed, COALESCE(alias_source, 'platform'), - COALESCE(max_rounds, 0), COALESCE(used_rounds, 0) + COALESCE(max_rounds, 0), COALESCE(used_rounds, 0), + COALESCE(NULLIF(permission_mode, ''), 'workspace'), + COALESCE(NULLIF(permission_enforcement, ''), 'advisory') FROM sessions WHERE session_id = $1`, id, ).Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status, &s.OwnerUserID, &s.CreatedAt, &s.UpdatedAt, &dismissed, &s.AliasSource, - &s.MaxRounds, &s.UsedRounds) + &s.MaxRounds, &s.UsedRounds, + &s.PermissionMode, &s.PermissionEnforcement) if err != nil { return nil, err } @@ -299,40 +302,6 @@ func UpdateSessionAlias(ctx context.Context, id uuid.UUID, alias string) error { return err } -func ListSessions(ctx context.Context, statusFilter string, limit int) ([]models.Session, error) { - q := `SELECT s.session_id, s.session_alias, s.from_agent, s.subject, s.status, - s.owner_user_id, s.created_at, s.updated_at, - (SELECT COUNT(*) FROM mails m WHERE m.session_id = s.session_id) - FROM sessions s` - args := []any{} - if statusFilter != "" { - q += ` WHERE s.status = $1` - args = append(args, statusFilter) - } - q += ` ORDER BY s.updated_at DESC` - if limit > 0 { - q += fmt.Sprintf(` LIMIT %d`, limit) - } - - rows, err := db.DB.QueryContext(ctx, q, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - sessions := []models.Session{} - for rows.Next() { - var s models.Session - if err := rows.Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status, - &s.OwnerUserID, &s.CreatedAt, &s.UpdatedAt, - &s.MaxRounds, &s.UsedRounds, &s.MailCount); err != nil { - return nil, err - } - sessions = append(sessions, s) - } - return sessions, rows.Err() -} - // ---------- Mail ---------- func CreateMail(ctx context.Context, sessionID uuid.UUID, parentMailID *uuid.UUID, @@ -393,6 +362,21 @@ func CreateDecisionMail(ctx context.Context, sessionID uuid.UUID, parentMailID u return id, err } +// DeleteMailByID 删一封邮件。 +// +// **只用于回滚一次刚失败的发信**,不是给人用的「删邮件」功能 —— +// 邮件是不可篡改的历史记录,没有任何人面入口能删它。 +// +// 场景:附件挂载在建邮件之后才发现冲突(竞态窗口),此时这封邮件不应存在: +// 发件方收到的是 4xx,它会重试,而一封无附件的残余邮件会让收件方收到两封。 +// +// attachments 表的外键是 ON DELETE CASCADE,所以已经挂上去的那几条会跟着消失; +// relayed_mails 的 mail_id 无 CASCADE,由调用方用 ReleaseRelay 归还幂等键。 +func DeleteMailByID(ctx context.Context, id uuid.UUID) error { + _, err := db.DB.ExecContext(ctx, `DELETE FROM mails WHERE mail_id = $1`, id) + return err +} + func GetMailByID(ctx context.Context, id uuid.UUID) (*models.Mail, error) { var m models.Mail var alias *string @@ -402,14 +386,17 @@ func GetMailByID(ctx context.Context, id uuid.UUID) (*models.Mail, error) { `SELECT m.mail_id, m.session_id, m.parent_mail_id, m.from_name, m.from_workspace, m.to_name, m.to_workspace, m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result, - m.status, m.created_at, s.session_alias, s.workspace, m.rename_alias, m.rename_reason + m.status, m.created_at, s.session_alias, s.workspace, m.rename_alias, m.rename_reason, + EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human, + EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human FROM mails m JOIN sessions s ON m.session_id = s.session_id WHERE m.mail_id = $1`, id, ).Scan(&m.ID, &m.SessionID, &m.ParentMailID, &m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace, &ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult, - &m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &renameAlias, &renameReason) + &m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &renameAlias, &renameReason, + &m.FromHuman, &m.ToHuman) if err != nil { return nil, err } @@ -443,7 +430,9 @@ func ListInbox(ctx context.Context, agentName, status string, limit int) ([]mode m.from_name, m.from_workspace, m.to_name, m.to_workspace, m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result, m.status, m.created_at, s.session_alias, s.workspace, - EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human + EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human, + COALESCE(NULLIF(s.permission_mode, ''), 'workspace') AS permission_mode, + COALESCE(NULLIF(s.permission_enforcement, ''), 'advisory') AS permission_enforcement FROM mails m JOIN sessions s ON m.session_id = s.session_id WHERE (m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `) @@ -472,7 +461,8 @@ func ListInbox(ctx context.Context, agentName, status string, limit int) ([]mode if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID, &m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace, &ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult, - &m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman); err != nil { + &m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman, + &m.PermissionMode, &m.PermissionEnforcement); err != nil { return nil, err } if len(ccJSON) > 0 { @@ -513,7 +503,9 @@ func GetSessionMails(ctx context.Context, sessionID uuid.UUID) ([]models.Mail, e `SELECT m.mail_id, m.session_id, m.parent_mail_id, m.from_name, m.from_workspace, m.to_name, m.to_workspace, m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result, - m.status, m.created_at, s.session_alias, s.workspace + m.status, m.created_at, s.session_alias, s.workspace, + EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human, + EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human FROM mails m JOIN sessions s ON m.session_id = s.session_id WHERE m.session_id = $1 @@ -531,7 +523,7 @@ func GetSessionMails(ctx context.Context, sessionID uuid.UUID) ([]models.Mail, e if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID, &m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace, &ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult, - &m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace); err != nil { + &m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman, &m.ToHuman); err != nil { return nil, err } if len(ccJSON) > 0 { @@ -632,13 +624,15 @@ func GetSessionMailByID(ctx context.Context, sessionID, mailID uuid.UUID) (*mode `SELECT m.mail_id, m.session_id, m.parent_mail_id, m.from_name, m.from_workspace, m.to_name, m.to_workspace, m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result, - m.status, m.created_at, s.session_alias, s.workspace + m.status, m.created_at, s.session_alias, s.workspace, + EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human, + EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human FROM mails m JOIN sessions s ON m.session_id = s.session_id WHERE m.session_id = $1 AND m.mail_id = $2`, sessionID, mailID, ).Scan(&m.ID, &m.SessionID, &m.ParentMailID, &m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace, &ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult, - &m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace) + &m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman, &m.ToHuman) if err != nil { return nil, err } @@ -704,6 +698,18 @@ func FindNamedSessionFor(ctx context.Context, name, path, alias string) (uuid.UU // mails.to_workspace 反推。只看 to_workspace:Agent 回信时 from_workspace 存的是 // Agent 名而不是路径,拿它比路径永远匹配不上(旧实现就挂在这里)。 func FindOrCreateDefaultSession(ctx context.Context, name, path, fromAgent, subject string) (uuid.UUID, error) { + id, _, err := FindOrCreateDefaultSessionCreated(ctx, name, path, fromAgent, subject) + return id, err +} + +// FindOrCreateDefaultSessionCreated 与 FindOrCreateDefaultSession 相同,但额外返回 +// **这次调用是否真的新建了会话**。 +// +// 为什么需要这个返回值:调用方此前用 `parentMailID == nil` 判断「是不是新建会话」, +// 而复用已有默认会话时 parentMailID 也是 nil —— 于是「仅在新建时生效」的字段 +// (往返预算、权限档位)在每一封省略 session 位的信上都被重写了。 +// 实测:第一封 max_rounds=7 → 第二封省略该字段 → 预算被静默改成默认的 20。 +func FindOrCreateDefaultSessionCreated(ctx context.Context, name, path, fromAgent, subject string) (uuid.UUID, bool, error) { var id uuid.UUID err := db.DB.QueryRowContext(ctx, ` SELECT s.session_id @@ -729,12 +735,13 @@ func FindOrCreateDefaultSession(ctx context.Context, name, path, fromAgent, subj `, name, path).Scan(&id) if err == nil { TouchSession(ctx, id) - return id, nil + return id, false, nil } if !errors.Is(err, sql.ErrNoRows) { - return uuid.Nil, err + return uuid.Nil, false, err } - return CreateSession(ctx, nil, fromAgent, subject, path) + newID, cErr := CreateSession(ctx, nil, fromAgent, subject, path) + return newID, cErr == nil, cErr } // SessionAliasOf 返回会话别名,未命名或查询失败时返回空串。 @@ -889,6 +896,12 @@ type Contact struct { MaxRounds int `json:"max_rounds"` UsedRounds int `json:"used_rounds"` + // PermissionMode 与 PermissionEnforcement 必须成对出现在列表上: + // 前者是「这条任务要求什么」,后者是「对方平台实际做到了什么」。 + // 只显示前者会让人以为 plan 档把 homeagent 管住了(它没有拦截点)。 + PermissionMode string `json:"permission_mode"` + PermissionEnforcement string `json:"permission_enforcement"` + // LastFrom/LastPreview 是最后一封邮件的发件人与正文摘要, // 卡片视图用它显示「最新进展」——列表视图只显示地址时, // 人必须逐条点开才知道哪条有新动静。 @@ -961,6 +974,8 @@ func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Cont s.subject, COALESCE(s.max_rounds, 0), COALESCE(s.used_rounds, 0), + COALESCE(NULLIF(s.permission_mode, ''), 'workspace'), + COALESCE(NULLIF(s.permission_enforcement, ''), 'advisory'), COALESCE((SELECT from_name FROM mails WHERE mail_id = `+lastMail+`), ''), COALESCE((SELECT body FROM mails WHERE mail_id = `+lastMail+`), '') FROM sessions s`+firstMail+` @@ -978,6 +993,7 @@ func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Cont if err := rows.Scan(&c.SessionID, &c.AgentName, &c.Path, &c.SessionAlias, &c.Status, &c.MailCount, &c.UnreadCount, &c.LastActivity, &c.Subject, &c.MaxRounds, &c.UsedRounds, + &c.PermissionMode, &c.PermissionEnforcement, &c.LastFrom, &c.LastPreview); err != nil { return nil, err } @@ -1141,7 +1157,9 @@ func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail, SELECT m.mail_id, m.session_id, m.parent_mail_id, m.from_name, m.from_workspace, m.to_name, m.to_workspace, m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result, - m.status, m.created_at, s.session_alias, s.workspace + m.status, m.created_at, s.session_alias, s.workspace, + EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human, + EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human FROM mails m JOIN sessions s ON m.session_id = s.session_id WHERE m.from_name = $1 AND s.status <> 'archived' @@ -1161,7 +1179,7 @@ func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail, if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID, &m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace, &ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult, - &m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace); err != nil { + &m.Status, &m.CreatedAt, &alias, &m.SessionWorkspace, &m.FromHuman, &m.ToHuman); err != nil { return nil, err } if len(ccJSON) > 0 { @@ -1223,6 +1241,8 @@ func ListSessionsFor(ctx context.Context, forUser string, limit int) ([]models.S q := `SELECT s.session_id, s.session_alias, s.from_agent, s.subject, s.status, s.owner_user_id, s.created_at, s.updated_at, COALESCE(s.max_rounds, 0), COALESCE(s.used_rounds, 0), + COALESCE(NULLIF(s.permission_mode, ''), 'workspace'), + COALESCE(NULLIF(s.permission_enforcement, ''), 'advisory'), (SELECT COUNT(*) FROM mails m WHERE m.session_id = s.session_id) FROM sessions s WHERE s.status <> 'archived'` @@ -1255,7 +1275,8 @@ func ListSessionsFor(ctx context.Context, forUser string, limit int) ([]models.S // 结果 /me/sessions 整个 500,联系人栅拉不到任何数据。 if err := rows.Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status, &s.OwnerUserID, &s.CreatedAt, &s.UpdatedAt, - &s.MaxRounds, &s.UsedRounds, &s.MailCount); err != nil { + &s.MaxRounds, &s.UsedRounds, + &s.PermissionMode, &s.PermissionEnforcement, &s.MailCount); err != nil { return nil, err } sessions = append(sessions, s) diff --git a/gateway/internal/scheduler/calendar.go b/gateway/internal/scheduler/calendar.go index 298922f..0355bea 100644 --- a/gateway/internal/scheduler/calendar.go +++ b/gateway/internal/scheduler/calendar.go @@ -260,6 +260,22 @@ func SendCalendarMail(ctx context.Context, eventID, toAddr, subject, body, creat return err } + // 人建的日程 → 把他设为会话 owner。 + // + // 为什么必须设:权限询问的决策人解析是「会话 owner → 线索里最近的人类 → 409」。 + // 日历提醒的发件人是 `calendar`(不是人也不是 Agent),所以一旦 Agent 在 + // 这条会话里要跑需要授权的命令,线索上根本找不到人类 —— 而那个日程 + // 就是人自己在界面上设的,他当然是合理的决策人。 + // + // 不设的后果(删掉管理员兜底之后暴露):人建的提醒触发后,Agent 的权限询问 + // 直接得 409「这条链上没有人类」。 + // + // created_by 是 Agent(Agent 自己建的日程)时 owner 保持为空 —— + // 那条链上确实没有人类,409 是对的。 + if u, uErr := repo.GetUserByName(ctx, createdBy); uErr == nil && u != nil { + _ = repo.SetSessionOwner(ctx, sessionID, u.ID) + } + // 发件人固定为 calendar:它不是任何一个 Agent,也不是人。 // 用创建者的名字会让 Agent 以为人在实时找它,而人此刻可能在睡觉 —— // 模型据此判断「要不要马上追问」,来源写错会让它问一个不在线的人。 diff --git a/plugins/dsh-mail-bridge/lib/permission-mode.d.ts b/plugins/dsh-mail-bridge/lib/permission-mode.d.ts new file mode 100644 index 0000000..23252fe --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/permission-mode.d.ts @@ -0,0 +1,33 @@ +export const MODE_PLAN: 'plan'; +export const MODE_WORKSPACE: 'workspace'; +export const MODE_FULL: 'full'; +export const MODES: string[]; +export const DEFAULT_MODE: string; + +export const ENFORCE_NATIVE: 'native'; +export const ENFORCE_ADVISORY: 'advisory'; + +export function normalizeMode(mode: unknown): string; +export function normalizeEnforcement(e: unknown): string; +export function modeAtMost(a: string, b: string): string; +export function modeNeedsHuman(mode: string): boolean; + +export interface OpencodePermissionRule { + permission: string; + action: string; + pattern: string; +} +export function opencodePermissions(mode: string): OpencodePermissionRule[]; + +export function dshSandboxMode(mode: string): 'read-only' | 'workspace-write' | 'danger-full-access'; +export function dshApprovalPolicy(mode: string): 'ask' | 'never'; + +export function piGuardedTools(mode: string): string[]; +export function piBlocksOutright(mode: string): boolean; + +export interface ModeBriefingCtx { + mode: string; + enforcement: string; + workspace?: string; +} +export function modeBriefing(ctx: ModeBriefingCtx): string; diff --git a/plugins/dsh-mail-bridge/lib/permission-mode.js b/plugins/dsh-mail-bridge/lib/permission-mode.js new file mode 100644 index 0000000..4a0dcd4 --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/permission-mode.js @@ -0,0 +1,274 @@ +/** + * 权限档位 → 平台原生配置的翻译 —— 四个平台共用的判据。 + * + * ## 分工 + * + * **AgentMail 声明,平台执行,插件只翻译。** 这个模块是「翻译」那一步的 + * 唯一实现:把 `plan` / `workspace` / `full` 翻成各平台原生的沙箱/审批配置。 + * + * 为什么不让插件自己按工具名猜着拦:那会同时违反 I-1(平台原生信号是唯一 + * 真相来源)与 I-4(插件只搬运不决策),而且四个插件对「workspace 到底管 + * 什么」必然各猜一套 —— 同一封 workspace 档的邮件在 A 平台被拦、在 B 平台放行。 + * + * ## 为什么必须「向更严取整」 + * + * 平台表达不出精确档位时,一律往更严的方向走,并如实上报自己做到了什么 + * (native / advisory)。pi 就是例子:write/edit 能查 `input.path` 判断越界, + * 而 bash 命令要碰哪些文件是解析不出来的 —— 于是 workspace 档下 pi 只能 + * 「每条 bash 都问人」,比声明的更严。 + * + * 不定这条规则的后果:不同插件会朝不同方向取整,而往宽松取整是静默失效 + * (人以为收紧了,实际没有)。 + */ + +/** 只读:查资料、读代码、出方案,一个字都不许写。 */ +export const MODE_PLAN = 'plan'; +/** 本目录内可动手,越界要问人。默认档。 */ +export const MODE_WORKSPACE = 'workspace'; +/** 自动放行,不问人。 */ +export const MODE_FULL = 'full'; + +/** 全部合法档位,按宽松程度递增。顺序是 modeAtMost 的依据。 */ +export const MODES = [MODE_PLAN, MODE_WORKSPACE, MODE_FULL]; + +/** 没有显式指定时的档位。与 Gateway 的 DefaultPermissionMode 必须一致。 */ +export const DEFAULT_MODE = MODE_WORKSPACE; + +/** 平台有原生拦截点,档位被真正执行。 */ +export const ENFORCE_NATIVE = 'native'; +/** 平台没有拦截点,档位只写进提示词。 */ +export const ENFORCE_ADVISORY = 'advisory'; + +/** + * 把外部输入收敛成合法档位。 + * + * 非法值 → 默认档(**不是** full)。拼错一个档位名不该换来更大的权限。 + * 与 Gateway 的 NormalizePermissionMode 同语义。 + * + * @param {unknown} mode + * @returns {string} + */ +export function normalizeMode(mode) { + return MODES.includes(mode) ? mode : DEFAULT_MODE; +} + +/** + * 收敛强制力取值。空串或非法值 → advisory。 + * + * 保守方向是 advisory 而不是 native:不能替一个没自报过的平台宣称 + * 「档位在这里是被强制的」。 + * + * @param {unknown} e + * @returns {string} + */ +export function normalizeEnforcement(e) { + return e === ENFORCE_NATIVE || e === ENFORCE_ADVISORY ? e : ENFORCE_ADVISORY; +} + +/** + * 取两个档位里更严的那一个。 + * + * 先归一化再比较 —— 两个脏值都变成默认档,于是结果与参数顺序无关(可交换)。 + * Gateway 侧的 ModeAtMost 曾因为「modeRank 把未知值当最严、Normalize 把它 + * 归到默认档」而不可交换,单元测试当场抓到。两边保持同一套语义。 + * + * @param {string} a + * @param {string} b + * @returns {string} + */ +export function modeAtMost(a, b) { + const na = normalizeMode(a); + const nb = normalizeMode(b); + return MODES.indexOf(na) <= MODES.indexOf(nb) ? na : nb; +} + +/** + * 这一档会不会产生权限邮件(即需不需要人来点头)。 + * + * 只有 workspace 档需要人:plan 档当场拒绝、full 档自动放行,两者都不问人。 + * 插件据此决定要不要把平台的权限钩子接到 `/permission/request`。 + * + * @param {string} mode + * @returns {boolean} + */ +export function modeNeedsHuman(mode) { + return normalizeMode(mode) === MODE_WORKSPACE; +} + +/** + * opencode 的 permission 规则数组。 + * + * ## 六条实测结论(不实测就会做出「看起来对但管不住」的东西) + * + * 1. **规则是 findLast 胜出**(二进制里 + * `findLast((z)=>g.match(j,z.permission)&&g.match(J,z.pattern))`) + * → deny 必须放前面、allow 放后面。反了的话连允许的路径也被拒。 + * 2. **pattern 匹配 worktree 相对路径**(`patterns:[relative(y.worktree,file)]`) + * → 写 `/tmp/**` 这种绝对 pattern 永远匹配不上(`/tmp/x` 相对 + * `/home/program/agentmail` 是 `../../../tmp/x`)。所以 workspace 档用 `**`。 + * 3. **write / edit / patch 共用 `edit` 一个权限名** + * (`if(A==="write"||A==="edit"||A==="patch"){G.edit=I}`)。 + * 4. **全 deny 让工具从模型清单里消失**(模型自述「I don't have a bash tool + * available in this session」),部分 deny 则工具保留、越界调用才报错。 + * plan 档用前者更好:模型不会浪费轮次去试。 + * 5. **task(子代理)能绕过父会话权限** —— 实测中模型发现自己没 write, + * 主动 task 委派给一个带 write 的子代理去写成了。plan/workspace 必须 + * `task deny *`,否则档位形同虚设。 + * 6. **bash 能绕过 edit 的路径限制** —— 模型用 shell 重定向写成了本该被 + * deny 的文件。所以 workspace 档必须同时管 bash,只管 edit 没用。 + * + * 另注:opencode 原生有 `plan_enter` / `plan_exit` 权限项,与我们的 plan 档 + * **撞名但语义不同**(那是它自己的计划模式开关),这里不碰它们。 + * + * @param {string} mode + * @returns {{permission: string, action: string, pattern: string}[]} + */ +export function opencodePermissions(mode) { + const m = normalizeMode(mode); + + if (m === MODE_FULL) { + // 全权:不下发任何规则,用平台自己的默认配置。 + // 显式全 allow 会覆盖掉用户在 opencode.jsonc 里的个人设置。 + return []; + } + + if (m === MODE_PLAN) { + // 只读。四项都要 deny: + // - edit 覆盖 write/edit/patch + // - bash 否则 shell 重定向就能写文件(实测过) + // - task 否则子代理能绕过(实测过) + // - webfetch/websearch 不禁:查资料是 plan 档的本职 + return [ + { permission: 'edit', action: 'deny', pattern: '*' }, + { permission: 'bash', action: 'deny', pattern: '*' }, + { permission: 'task', action: 'deny', pattern: '*' }, + ]; + } + + // workspace:目录内可写,越界问人。 + // + // deny 在前、allow 在后(findLast 胜出)。pattern `**` 是 worktree + // 相对路径,等价于「这个工作目录内的任何文件」。 + // + // bash 一律 ask 而不是 allow:命令要碰哪些文件解析不出来, + // 这就是「向更严取整」——比声明的严,不比它松。 + // + // task 仍然 deny:子代理带着自己的权限跑,父会话的边界对它无效。 + return [ + { permission: 'edit', action: 'deny', pattern: '*' }, + { permission: 'edit', action: 'allow', pattern: '**' }, + { permission: 'bash', action: 'ask', pattern: '*' }, + { permission: 'task', action: 'deny', pattern: '*' }, + ]; +} + +/** + * DSH 的沙箱模式。 + * + * 三档与 DSH 原生的三档**一一对应** —— 这不是巧合,是同一个问题的同一个答案 + * (见 `@deepseek-ai/dsh-sandbox-policy` 的 SANDBOX_MODES)。 + * + * @param {string} mode + * @returns {'read-only'|'workspace-write'|'danger-full-access'} + */ +export function dshSandboxMode(mode) { + switch (normalizeMode(mode)) { + case MODE_PLAN: return 'read-only'; + case MODE_FULL: return 'danger-full-access'; + default: return 'workspace-write'; + } +} + +/** + * DSH 的审批策略。 + * + * 关键实测:`danger-full-access` 对应 `approval: "never"`,而 + * `ApprovalService.decide()` 里 `if (effectivePolicy === "never") return "rejected"` + * **在 waterfall 之前短路** —— 于是 `approval/request` 钩子根本不触发。 + * + * 这解释了一个此前查不清的现象:本机 dsh 配了 `defaultPreset: danger-full-access`, + * 所以整个权限转邮件链路从来没在 dsh 上跑起来过。 + * + * @param {string} mode + * @returns {'ask'|'never'} + */ +export function dshApprovalPolicy(mode) { + return modeNeedsHuman(mode) ? 'ask' : 'never'; +} + +/** + * pi 侧应当守卫的工具名。 + * + * pi 只有 `tool_call` 钩子能 `{block:true}`,没有沙箱 —— 所以档位靠 + * 「拦哪些工具」表达: + * + * - plan 拦 bash/write/edit(读类工具 read/grep/find/ls 不拦) + * - workspace 拦同样三个,但 write/edit 可以查 `input.path` 判越界, + * bash 无法判断 → 一律问人(向更严取整) + * - full 不拦 + * + * @param {string} mode + * @returns {string[]} + */ +export function piGuardedTools(mode) { + return normalizeMode(mode) === MODE_FULL ? [] : ['bash', 'write', 'edit']; +} + +/** + * pi 在某档位下,某次工具调用该不该直接拒绝(不问人)。 + * + * plan 档下所有被守卫的工具都直接拒绝 —— 该档语义就是「这轮不动手」, + * 没什么可问人的,模型该把方案写在回信里。 + * + * workspace 档返回 false(走问人流程)。full 档不会进到这里。 + * + * @param {string} mode + * @returns {boolean} + */ +export function piBlocksOutright(mode) { + return normalizeMode(mode) === MODE_PLAN; +} + +/** + * 给模型看的档位说明,放进提示词。 + * + * 为什么 advisory 时措辞完全不同:那种平台(homeagent)没有任何机制阻止 + * 模型动手,所以只能把约束说成「请你遵守」而不是「你做不到」。 + * 假装它是强制的更危险 —— 模型会以为越界会被拦,于是不必自己小心。 + * + * @param {{mode: string, enforcement: string, workspace?: string}} ctx + * @returns {string} + */ +export function modeBriefing({ mode, enforcement, workspace }) { + const m = normalizeMode(mode); + const enforced = normalizeEnforcement(enforcement) === ENFORCE_NATIVE; + const dir = workspace ? `\`${workspace}\`` : '本任务的工作目录'; + + if (m === MODE_FULL) { + return '本任务权限档位:full(全权)。工具调用不需要额外授权。'; + } + + if (m === MODE_PLAN) { + return enforced + ? [ + '本任务权限档位:plan(只读)。', + '写文件、改文件、执行命令都会被平台拦下 —— 这一档只用来查与想。', + '请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。', + ].join('\n') + : [ + '本任务权限档位:plan(只读)。', + '**这个平台无法强制这一档**,所以约束靠你自己遵守:请不要写文件、改文件或执行命令。', + '请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。', + ].join('\n'); + } + + return enforced + ? [ + `本任务权限档位:workspace。可以在 ${dir} 内读写,越出该目录的写入与命令执行会先向人类请求授权。`, + '授权可能需要等待,也可能被拒绝 —— 被拒绝时请换一条不需要越界的做法,或在回信里说明需要人工执行哪一步。', + ].join('\n') + : [ + `本任务权限档位:workspace。请把改动限制在 ${dir} 内。`, + '**这个平台无法强制这一档**,所以边界靠你自己遵守:需要改该目录之外的东西时,不要直接动手,先在回信里说明。', + ].join('\n'); +} diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 8a96366..27fea8f 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -27,6 +27,8 @@ import { replyInstruction, inboundHeadline, } from '../lib/relay-policy.js'; +import { clampRelayKey, isPermanentFailure } from '../lib/relay-key.js'; +import { BoundedMap, BoundedSet, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from '../lib/bounded.js'; import { userMessage, replySubject, @@ -146,34 +148,115 @@ class GatewayClient { } // ─── 会话映射(与 opencode-mail-bridge 相同结构)─── +// +// 这几张表都是**常驻进程里只增不减**的形态:键来自邮件事件流,会话数随时间 +// 单调增长。两条出口 —— `forgetSession()`(会话归档,确定性)与 Bounded* 的 +// 上限淘汰(兜底)。没有它们,插件跟着 dsh 跑几周之后表里会躺着几十万条再也 +// 不会被查到的条目,而 GC 收不掉(还被强引用着)。 -const sessionMap = new Map(); -const reverseMap = new Map(); -const mailDrivenSessions = new Set(); +const sessionMap = new BoundedMap(MAX_TRACKED_SESSIONS); +const reverseMap = new BoundedMap(MAX_TRACKED_SESSIONS); +const mailDrivenSessions = new BoundedSet(MAX_TRACKED_SESSIONS); // 回信上下文。fromHuman / inReplyTo 是服务端给的两个信号: // 前者决定要不要自动转发(Agent 间不转,见 lib/relay-policy.js), // 后者决定提示词说「新任务」还是「你上封信的回复到了」。 -const mailContexts = new Map(); -const relayedSummaries = new Map(); +}>(MAX_TRACKED_SESSIONS); +const relayedSummaries = new BoundedMap(MAX_TRACKED_SESSIONS); // 管理员在配置页划定的可用模型范围(按优先级)。随心跳响应更新。 // 空数组 = 不限定,回退到环境变量或平台默认。 let allowedModels: { provider: string; model: string }[] = []; -const syncedTitles = new Map(); +const syncedTitles = new BoundedMap(MAX_TRACKED_SESSIONS); + +/** + * 会话归档 → 忘掉它的全部映射。 + * + * 归档是个**确定性的终点**:归档后那条会话不可寻址(别名 404),也不会再有新邮件 + * 投进来,`agent/status` 也不该再把总结转回去(会话已经收不了信)。留着这些条目 + * 只是占内存,而上限淘汰是「猜」—— 能确切知道该删的时候就不该靠猜。 + * + * DSH 侧那个 agent **不 dispose**:人可能还在界面上看它,而且它正在跑的那一轮 + * 不该被归档打断。这里只解除邮件绑定。 + */ +function forgetSession(mailSessionID: string): void { + if (!mailSessionID) return; + // peek 而不是 get:这是清理路径,不该把即将删掉的条目刷成「最近活跃」。 + const bound = sessionMap.peek(mailSessionID); + mailContexts.delete(mailSessionID); + sessionMap.delete(mailSessionID); + const dshSessionId = bound?.dshSessionId; + if (!dshSessionId) return; + reverseMap.delete(dshSessionId); + mailDrivenSessions.delete(dshSessionId); + relayedSummaries.delete(dshSessionId); + syncedTitles.delete(dshSessionId); +} // 权限询问:DSH 的 approval/request 是 waterfall 钩子,插件把它转成邮件问人, // 人类决策通过 SSE 回来后再 resolve 这个 promise,让 DSH 自己恢复执行。 // relay_key 用 `${sessionId}:${toolName}:${callId}` —— DSH 不给询问发 id, // 而同一个 callId 的同一个工具只会问一次。 +// +// **这张表不设上界**(与上面几张不同):它装的是「还在等结果的东西」。静默淘汰 +// 一条会让 DSH 侧那个 `await` 永远不返回 —— 那次工具调用直接挂死。它有确定的 +// 清理路径(决策到达 / 询问被 abort / 拆插件时 fail closed),不需要靠猜。 interface PendingApproval { resolve: (outcome: string) => void; sessionId: string; } const pendingApprovals = new Map(); +// 权限被插件主动拒绝时的真正原因 —— 键是 `${agentId}:${callId}`。 +// +// 为什么需要这张表:DSH 把 `approval/request` 的返回值翻译成模型可见文本时 +// 用的是 **dsh-tools 里写死的句子**(`node_modules/@deepseek-ai/dsh-tools/lib/index.js`): +// +// case "rejected": reason = `the user rejected tool "${exec.name}"` +// case "unavailable": reason = `... no approval channel is available` +// +// 于是插件回 'rejected' 时模型看到的是「the user rejected tool bash」—— +// 而实际上**没有任何用户拒绝它**,是「这条链上没有人类可问」或「转发遇到 +// 4xx 永久失败」。服务端给的 suggestion(换不需要权限的方式 / 在回信里请上游 +// 转达)根本没有出口,只进了 journalctl。模型得到的信息既是错的, +// 也不含任何可行动的提示 —— 它只会以为人在拒绝它,而不会改道。 +// +// pi(`{block:true, reason}`)与 opencode(`output.reason`)的 reason 直达模型, +// 只有 DSH 把它吞了。出路是 `tools/post-execute`:门禁拒绝的调用**也会**走 +// post-execute(源码里 `{kind:"post-result"}` → finalizeScheduledExecution +// → postExecute),而 `{kind:'block', feedback}` 能换掉模型看到的内容。 +interface DeniedReason { + text: string; + at: number; +} +const deniedReasons = new Map(); + +/** 10 分钟前的条目不可能还有对应的 post-execute,清掉以免无限增长。 */ +const DENIED_REASON_TTL_MS = 10 * 60 * 1000; + +function denialKey(agentId: string, callId: unknown): string { + return `${agentId}:${String(callId ?? 'nocall')}`; +} + +function noteDenial(agentId: string, callId: unknown, text: string): void { + const now = Date.now(); + for (const [k, v] of deniedReasons) { + if (now - v.at > DENIED_REASON_TTL_MS) deniedReasons.delete(k); + } + deniedReasons.set(denialKey(agentId, callId), { text, at: now }); +} + +function takeDenial(agentId: string, callId: unknown): string | undefined { + const key = denialKey(agentId, callId); + const hit = deniedReasons.get(key); + if (!hit) return undefined; + deniedReasons.delete(key); // 一次性:同一次调用只能被换一次 + if (Date.now() - hit.at > DENIED_REASON_TTL_MS) return undefined; + return hit.text; +} + // ─── 运行时导入 DSH 内部函数 ─── let _defineTool: any; @@ -301,7 +384,10 @@ export function apply(ctx: any, config: PluginConfig): void { // 已经投过的 mail_id。心跳与 SSE 建连之间有个窗口:那期间到的邮件 // 既在 pending_mails 里、也会被 SSE 推一次 —— 不去重就会投两遍。 - const deliveredMails = new Set(); + // + // 有界:插件跟着 dsh 长期活着,这里会攒下每一封处理过的邮件 id 而永远没有 + // 出口。淘汰是安全的 —— 它防的两种重复都发生在秒到分钟级。 + const deliveredMails = new BoundedSet(MAX_TRACKED_MAILS); let caughtUp = false; /** @@ -888,7 +974,7 @@ export function apply(ctx: any, config: PluginConfig): void { body: renderFailureReport(failures, data.subject), reply_to: data.mail_id || '', relay: 'summary', - relay_key: `model-failure:${data.mail_id || sessionId}`, + relay_key: clampRelayKey(`model-failure:${data.mail_id || sessionId}`), }); ctx.logger.info(`[dsh-mail-bridge] 已回报模型调用失败给 ${data.from_name}`); } catch (e: any) { @@ -981,6 +1067,7 @@ export function apply(ctx: any, config: PluginConfig): void { cc: args.cc || '', reply_to: args.reply_to || '', session_alias: args.session_alias || '', attachment_ids: args.attachment_ids || [], + from_session_id: toolCtx?.sessionID || '', }); noteExplicitSend(toolCtx?.sessionID, args.to, args.reply_to); const budget = typeof result.budget_remaining === 'number' @@ -1402,7 +1489,7 @@ export function apply(ctx: any, config: PluginConfig): void { reply_to: mctx.mailID || '', // relay + relay_key:走免配额通道(harness 的搬运不该收费) relay: 'summary', - relay_key: `${agent.id}:${events.length}`, + relay_key: clampRelayKey(`${agent.id}:${events.length}`), }); relayedSummaries.set(String(agent.id), lastText); explicitSends.delete(String(agent.id)); @@ -1447,7 +1534,9 @@ export function apply(ctx: any, config: PluginConfig): void { if (!mailSessionID) return next(); // DSH 不给询问发 id,用 (会话, 工具, callId) 做幂等键。 - const relayKey = `${agentId}:${req.toolName}:${req.callId ?? 'nocall'}`; + // clampRelayKey 收尾:callId 的长度由上游模型决定,pi 侧实测过带思考签名的 + // 13601 字节 id,超服务端 160 字节列宽直接 400。 + const relayKey = clampRelayKey(`${agentId}:${req.toolName}:${req.callId ?? 'nocall'}`); const mctx = mailContexts.get(mailSessionID); try { @@ -1483,12 +1572,40 @@ export function apply(ctx: any, config: PluginConfig): void { const hint = [e?.body?.error, e?.body?.detail, e?.body?.suggestion] .filter(Boolean).join(' '); console.error(`[dsh-mail-bridge] 权限询问无人可投,当场拒绝 ${relayKey}:${hint}`); + // 把真正的原因存起来,post-execute 会用它换掉 dsh-tools 写死的 + // 「the user rejected tool X」—— 没有任何用户拒绝过它。 + noteDenial(agentId, req.callId, [ + e?.body?.error || `权限询问无法送达:这条任务链上没有人类用户`, + e?.body?.detail || '', + e?.body?.suggestion || '', + ].filter(Boolean).join('\n')); // 用 'rejected' 而不是 'denied':DSH 的 ApprovalOutcome 只认 - // allowed-once / rejected / cancelled,写错了它不报错而是当成未知值处理。 + // allowed-once / rejected / cancelled / unavailable,写错了它不报错 + // 而是归一化成 'unavailable'。 return 'rejected'; } - // 其余失败(网络抖动、Gateway 重启)是暂时的,交给下一个 answerer(本地 UI)。 - console.error(`[dsh-mail-bridge] 权限询问转发失败: ${e?.message || e}`); + + // 其余 4xx(400 / 401 / 403 / 404 / 422…)同样永远不会因重试成功, + // 不能 `return next()` —— 下一个 answerer 是本地 UI,而邮件驱动的会话 + // 没有 UI,waterfall 跑到尾仍旧无人应答。 + // pi 侧实测:relay_key 过长报 400 被当暂时失败让位, + // 那条 bash 在无人批准的情况下执行了 —— fail closed 才安全。 + if (isPermanentFailure(e)) { + const hint = [e?.body?.error, e?.body?.detail, e?.body?.suggestion] + .filter(Boolean).join(' '); + console.error( + `[dsh-mail-bridge] 权限询问遇到永久失败(HTTP ${e?.status}),当场拒绝 ${relayKey}:${hint || e?.message || ''}`, + ); + noteDenial(agentId, req.callId, [ + `无法把 ${req.toolName} 的授权请求送达给人类(HTTP ${e?.status}):${e?.body?.error || e?.message || '请求被服务端拒绝'}`, + e?.body?.detail || '', + e?.body?.suggestion || '这是一个不会因重试而改变的失败。请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。', + ].filter(Boolean).join('\n')); + return 'rejected'; + } + + // 暂时失败(5xx / 408 / 429 / 网络抖动)交给下一个 answerer(本地 UI)。 + console.error(`[dsh-mail-bridge] 权限询问转发暂时失败: ${e?.message || e}`); return next(); } @@ -1503,6 +1620,40 @@ export function apply(ctx: any, config: PluginConfig): void { }); }); + // 把插件主动拒绝的真正原因递给模型。 + // + // DSH 将 approval/request 的 'rejected' 翻译成写死的 + // `the user rejected tool "X"`(dsh-tools/lib/index.js)—— 而当插件因为 + // 「没人可问」或「转发遇 4xx」主动拒绝时,**没有任何用户拒绝过它**。 + // 模型看到一句不存在的拒绝,只会以为人不同意,不会去换一条路; + // 服务端给的 suggestion(换不需要权限的方式 / 在回信里请上游转达)则只进了日志。 + // + // 门禁拒绝的调用也会进 post-execute(pre-execute 的 deny 走 + // `{kind:"post-result"}` → finalizeScheduledExecution → postExecute), + // 而 `{kind:'block', feedback}` 能换掉模型看到的内容 —— 这是 DSH 上 + // 唯一能把真实原因送到模型眼前的口子。 + // + // pi(`{block:true, reason}`)与 opencode(`output.reason`)的 reason 直达模型, + // 不需要这道绕行。 + ctx.on('tools/post-execute', async (exec: any, result: any, next: () => Promise) => { + const agentId = String(exec?.agent?.id ?? ''); + if (!agentId || !mailDrivenSessions.has(agentId)) return next(); + // 只管失败的结果:成功的调用不可能是被我们拒绝的那一次。 + if (!result?.isError) return next(); + + const reason = takeDenial(agentId, exec?.callId); + if (!reason) return next(); + + console.error(`[dsh-mail-bridge] 已把拒绝原因递给模型(${exec?.name})`); + return { + kind: 'block', + feedback: [{ + type: 'text', + text: `无法执行 ${exec?.name}:${reason}`, + }], + }; + }); + /** 人类决策回来:先看是不是在等的那条 approval,否则当普通通知投给会话。 */ function handlePermissionDecision(data: any): void { const relayKey = String(data?.relay_key ?? ''); @@ -1549,6 +1700,10 @@ export function apply(ctx: any, config: PluginConfig): void { case 'permission_decision': handlePermissionDecision(data); break; + case 'session_archived': + // 会话归档 = 那条会话再也不会收信,映射可以确定性地清掉(不必等上限淘汰)。 + forgetSession(String(data?.session_id || '')); + break; } }); return () => { diff --git a/plugins/dsh-mail-bridge/test/permission-mode.test.mjs b/plugins/dsh-mail-bridge/test/permission-mode.test.mjs new file mode 100644 index 0000000..dada93d --- /dev/null +++ b/plugins/dsh-mail-bridge/test/permission-mode.test.mjs @@ -0,0 +1,246 @@ +/** + * lib/permission-mode.js 的测试 —— 四个平台逐字节共用。 + * + * 这些判据编码了六条 opencode 实测结论。不实测就写代码会做出「看起来对但 + * 管不住」的东西,所以每条结论都在这里钉死,改坏了会当场失败。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + MODE_PLAN, MODE_WORKSPACE, MODE_FULL, MODES, DEFAULT_MODE, + ENFORCE_NATIVE, ENFORCE_ADVISORY, + normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman, + opencodePermissions, dshSandboxMode, dshApprovalPolicy, + piGuardedTools, piBlocksOutright, modeBriefing, +} from '../lib/permission-mode.js'; + +// ─── 归一化 ─── + +test('合法档位原样返回', () => { + for (const m of MODES) assert.equal(normalizeMode(m), m); +}); + +test('非法档位 fail-closed 到默认档,不是 full', () => { + for (const bad of ['', 'FULL', 'full-access', 'workspace-write', null, undefined, 42, {}]) { + assert.equal(normalizeMode(bad), DEFAULT_MODE, `${String(bad)} 应当归到默认档`); + } + assert.notEqual(DEFAULT_MODE, MODE_FULL, '默认档不能是 full'); +}); + +test('强制力保守方向是 advisory', () => { + assert.equal(normalizeEnforcement(ENFORCE_NATIVE), ENFORCE_NATIVE); + assert.equal(normalizeEnforcement(ENFORCE_ADVISORY), ENFORCE_ADVISORY); + for (const bad of ['', 'NATIVE', 'enforced', null, undefined]) { + assert.equal(normalizeEnforcement(bad), ENFORCE_ADVISORY); + } +}); + +test('档位顺序必须是 plan < workspace < full(modeAtMost 的依据)', () => { + assert.deepEqual(MODES, [MODE_PLAN, MODE_WORKSPACE, MODE_FULL]); +}); + +// ─── modeAtMost ─── + +test('modeAtMost 取更严的一档', () => { + assert.equal(modeAtMost(MODE_PLAN, MODE_FULL), MODE_PLAN); + assert.equal(modeAtMost(MODE_FULL, MODE_PLAN), MODE_PLAN); + assert.equal(modeAtMost(MODE_WORKSPACE, MODE_FULL), MODE_WORKSPACE); + assert.equal(modeAtMost(MODE_FULL, MODE_FULL), MODE_FULL); +}); + +// Gateway 侧曾因为「未知值当最严 vs 归到默认档」两套语义而不可交换, +// 单元测试当场抓到。两边保持同一套语义。 +test('modeAtMost 可交换(脏值也不例外)', () => { + const all = [...MODES, 'garbage', '', null]; + for (const a of all) { + for (const b of all) { + assert.equal(modeAtMost(a, b), modeAtMost(b, a), + `不可交换:(${a},${b})`); + } + } +}); + +test('脏值不得把 plan 抬成更宽松的档', () => { + assert.equal(modeAtMost('garbage', MODE_PLAN), MODE_PLAN); +}); + +// ─── modeNeedsHuman ─── + +test('只有 workspace 档需要人点头', () => { + assert.equal(modeNeedsHuman(MODE_PLAN), false, 'plan 档当场拒绝,不问人'); + assert.equal(modeNeedsHuman(MODE_WORKSPACE), true); + assert.equal(modeNeedsHuman(MODE_FULL), false, 'full 档自动放行,不问人'); +}); + +test('脏档位按默认档处理,即需要人(宁可多问一次)', () => { + assert.equal(modeNeedsHuman('garbage'), true); + assert.equal(modeNeedsHuman(''), true); +}); + +// ─── opencode ─── + +test('full 档不下发规则,不覆盖用户自己的 opencode.jsonc', () => { + assert.deepEqual(opencodePermissions(MODE_FULL), []); +}); + +// 实测结论 3、5、6:edit 覆盖 write/edit/patch;task 会绕过;bash 能重定向写文件 +test('plan 档同时 deny edit / bash / task', () => { + const rules = opencodePermissions(MODE_PLAN); + const denied = new Set(rules.filter(r => r.action === 'deny').map(r => r.permission)); + assert.ok(denied.has('edit'), 'edit 覆盖 write/edit/patch,必须 deny'); + assert.ok(denied.has('bash'), 'bash 能用 shell 重定向写文件(实测过),必须 deny'); + assert.ok(denied.has('task'), 'task 子代理会绕过父会话权限(实测过),必须 deny'); +}); + +test('plan 档不禁 webfetch/websearch —— 查资料是这一档的本职', () => { + const rules = opencodePermissions(MODE_PLAN); + for (const p of ['webfetch', 'websearch', 'read', 'grep', 'glob']) { + assert.equal(rules.some(r => r.permission === p), false, `${p} 不该被禁`); + } +}); + +// 实测结论 1:findLast 胜出 → deny 必须在 allow 之前 +test('workspace 档的 edit 规则 deny 在前 allow 在后(findLast 胜出)', () => { + const rules = opencodePermissions(MODE_WORKSPACE); + const denyIdx = rules.findIndex(r => r.permission === 'edit' && r.action === 'deny'); + const allowIdx = rules.findIndex(r => r.permission === 'edit' && r.action === 'allow'); + assert.ok(denyIdx >= 0 && allowIdx >= 0, '两条 edit 规则都要在'); + assert.ok(denyIdx < allowIdx, + 'deny 必须在 allow 之前 —— 反了的话最后匹配到 deny,连允许的路径也被拒'); +}); + +// 实测结论 2:pattern 匹配 worktree 相对路径,绝对路径永远匹配不上 +test('workspace 档的 allow pattern 是相对路径而非绝对路径', () => { + const rules = opencodePermissions(MODE_WORKSPACE); + const allow = rules.find(r => r.permission === 'edit' && r.action === 'allow'); + assert.ok(allow, '要有 allow 规则'); + assert.equal(allow.pattern.startsWith('/'), false, + 'pattern 匹配的是 worktree 相对路径,绝对路径永远匹配不上(实测)'); +}); + +// 向更严取整:命令要碰哪些文件解析不出来 +test('workspace 档的 bash 是 ask 而不是 allow(向更严取整)', () => { + const rules = opencodePermissions(MODE_WORKSPACE); + const bash = rules.find(r => r.permission === 'bash'); + assert.equal(bash.action, 'ask', + 'bash 命令的影响范围无法解析,只能问人 —— 比声明的严,不比它松'); +}); + +test('workspace 档仍然 deny task(子代理带自己的权限跑)', () => { + const rules = opencodePermissions(MODE_WORKSPACE); + const task = rules.find(r => r.permission === 'task'); + assert.equal(task.action, 'deny'); +}); + +test('opencode 规则不碰 plan_enter / plan_exit(撞名但语义不同)', () => { + for (const m of MODES) { + for (const r of opencodePermissions(m)) { + assert.notEqual(r.permission, 'plan_enter'); + assert.notEqual(r.permission, 'plan_exit'); + } + } +}); + +test('脏档位按默认档下发(与 workspace 相同)', () => { + assert.deepEqual(opencodePermissions('garbage'), opencodePermissions(MODE_WORKSPACE)); +}); + +// ─── DSH ─── + +test('DSH 三档与原生沙箱一一对应', () => { + assert.equal(dshSandboxMode(MODE_PLAN), 'read-only'); + assert.equal(dshSandboxMode(MODE_WORKSPACE), 'workspace-write'); + assert.equal(dshSandboxMode(MODE_FULL), 'danger-full-access'); +}); + +// 关键实测:danger-full-access → approval:"never" → decide() 在 waterfall +// 之前短路 return "rejected",approval/request 钩子根本不触发。 +test('DSH 审批策略只在 workspace 档是 ask', () => { + assert.equal(dshApprovalPolicy(MODE_WORKSPACE), 'ask'); + assert.equal(dshApprovalPolicy(MODE_PLAN), 'never'); + assert.equal(dshApprovalPolicy(MODE_FULL), 'never'); +}); + +test('DSH 脏档位按默认档(workspace-write + ask)', () => { + assert.equal(dshSandboxMode('garbage'), 'workspace-write'); + assert.equal(dshApprovalPolicy('garbage'), 'ask'); +}); + +// ─── pi ─── + +test('pi 在 full 档不守卫任何工具', () => { + assert.deepEqual(piGuardedTools(MODE_FULL), []); +}); + +test('pi 在 plan / workspace 档守卫 bash / write / edit', () => { + for (const m of [MODE_PLAN, MODE_WORKSPACE]) { + const g = piGuardedTools(m); + assert.ok(g.includes('bash')); + assert.ok(g.includes('write')); + assert.ok(g.includes('edit')); + } +}); + +test('pi 不守卫读类工具', () => { + const g = piGuardedTools(MODE_WORKSPACE); + for (const t of ['read', 'grep', 'find', 'ls']) { + assert.equal(g.includes(t), false, `${t} 是读类工具,不该守卫`); + } +}); + +test('pi 在 plan 档直接拒绝,不走问人流程', () => { + assert.equal(piBlocksOutright(MODE_PLAN), true); + assert.equal(piBlocksOutright(MODE_WORKSPACE), false); + assert.equal(piBlocksOutright(MODE_FULL), false); +}); + +// ─── modeBriefing ─── + +test('full 档的说明不提授权', () => { + const s = modeBriefing({ mode: MODE_FULL, enforcement: ENFORCE_NATIVE }); + assert.match(s, /full/); + assert.equal(/授权/.test(s.replace('不需要额外授权', '')), false); +}); + +// advisory 与 native 措辞必须不同:假装 advisory 是强制的会让模型以为 +// 越界会被拦,于是不必自己小心 —— 那比做不到本身更危险。 +test('advisory 必须明说平台无法强制这一档', () => { + const adv = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_ADVISORY }); + const nat = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_NATIVE }); + assert.match(adv, /无法强制/); + assert.equal(/无法强制/.test(nat), false, 'native 不该说无法强制'); + assert.notEqual(adv, nat, '两种强制力的措辞必须不同'); +}); + +test('workspace 档的 advisory 版同样明说', () => { + const adv = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_ADVISORY, workspace: '/tmp/x' }); + assert.match(adv, /无法强制/); + assert.match(adv, /\/tmp\/x/, '要带上具体目录'); +}); + +test('native 的 workspace 说明要交代「授权可能被拒」', () => { + const s = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_NATIVE, workspace: '/srv/app' }); + assert.match(s, /\/srv\/app/); + assert.match(s, /拒绝/, '被拒时该怎么办必须说清楚,否则模型会反复重试'); +}); + +test('plan 档的说明必须告诉模型「把方案写在回信里」', () => { + for (const e of [ENFORCE_NATIVE, ENFORCE_ADVISORY]) { + const s = modeBriefing({ mode: MODE_PLAN, enforcement: e }); + assert.match(s, /回信/, '不给出路的话模型只会反复撞墙'); + } +}); + +test('缺 workspace 时用兜底措辞,不出现 undefined', () => { + const s = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_NATIVE }); + assert.equal(/undefined/.test(s), false); + assert.equal(/`` /.test(s), false); +}); + +test('脏输入不炸且按默认档', () => { + const s = modeBriefing({ mode: 'garbage', enforcement: 'garbage' }); + assert.match(s, /workspace/); + assert.match(s, /无法强制/, '脏强制力按 advisory 处理'); +}); diff --git a/plugins/homeagent-mail-bridge/bounded.go b/plugins/homeagent-mail-bridge/bounded.go new file mode 100644 index 0000000..e55a74c --- /dev/null +++ b/plugins/homeagent-mail-bridge/bounded.go @@ -0,0 +1,125 @@ +package main + +// 有界去重表 —— 三个 Node 插件里 `lib/bounded.js` 的 Go 对应物。 +// +// **不能共用那个文件**(homeagent 是 Go 子进程插件),但要解决的问题完全一样: +// `deliveredMails` 是「这封邮件我处理过吗」的记忆,键来自 SSE 事件流, +// 而插件跟着 homed 长期活着 —— 邮件数单调增长,键却从来没有出口。 +// +// # 为什么 Go 侧不做 LRU +// +// Node 的 `Map` 保证插入顺序,所以那边「删掉再插入」就等于「移到队尾」, +// LRU 几乎免费。Go 的 map **不保证遍历顺序**,做 LRU 要额外维护一个链表。 +// +// 这里不值得:`deliveredMails` 防的两种重复(SSE 重放、心跳与建连之间的窗口) +// 都发生在秒到分钟级,先进先出(丢最早插入的)与丢最久未访问的在这个场景下 +// 没有可观察的差别。而跨进程、跨天的去重本来就由 `ledger`(落盘,14 天保留期) +// 负责,这张表只是同进程内的快速路径。 +// +// # 为什么不是「攒满就整表清空」 +// +// 整表清空会在那一刻把**全部**记忆丢掉,于是紧接着到达的 SSE 重放会被当成 +// 新邮件全部重投一遍 —— 一次性放大成一批重复投递。FIFO 每次只丢最老的一条, +// 而最老的那条恰好是最不可能再出现的。 + +// maxTrackedMails 是同进程内已投递邮件 id 的记忆上限。 +// +// 与 Node 侧的 MAX_TRACKED_MAILS 取同一个数:SSE 重放最多回放服务端环形缓冲的 +// 500 条事件,一次补拉最多 5 封(catchupLimit)。2000 是三个数量级的余量, +// 内存代价约 200KB。 +const maxTrackedMails = 2000 + +// boundedIDSet 是一个带 FIFO 上限的字符串集合。 +// +// 非并发安全:调用方(plugin.go)已经用 sseMu 保护着它, +// 自带一把锁只会让「到底该拿哪把锁」变得含糊。 +type boundedIDSet struct { + limit int + seen map[string]struct{} + // order 记录插入顺序,用来知道该丢谁。 + // + // 用 slice 而不是 container/list:上限只有 2000,切片头部推进的代价 + // (一次 append + 一个下标)远小于链表节点的分配开销。 + order []string + // head 是 order 里第一个仍然有效的下标。丢弃时只推进它,不做 order[1:] —— + // 后者每次都要搬移整个底层数组。 + head int + // evicted 累计淘汰条数,观测用。 + evicted int +} + +func newBoundedIDSet(limit int) *boundedIDSet { + // 上限非法时回落到 1 而不是 panic:这张表是优化项,配错了应当退化成 + // 「只记得最后一条」(多几次重复投递),而不是让插件起不来。 + if limit < 1 { + limit = 1 + } + return &boundedIDSet{ + limit: limit, + seen: make(map[string]struct{}, limit), + order: make([]string, 0, limit), + } +} + +// has 报告这个 id 是否已经记住过。 +func (s *boundedIDSet) has(id string) bool { + if s == nil || id == "" { + return false + } + _, ok := s.seen[id] + return ok +} + +// add 记住一个 id,并在超限时丢掉最早插入的那些。 +// +// 返回值是「这次调用**新加入**了吗」—— 调用方常常想在一次操作里同时完成 +// 「查重」与「登记」,分两步做需要两次加锁或一段不必要的临界区。 +func (s *boundedIDSet) add(id string) bool { + if s == nil || id == "" { + return false + } + if _, ok := s.seen[id]; ok { + // 已存在时**不**移到队尾:FIFO 语义下位置由首次插入决定。 + return false + } + s.seen[id] = struct{}{} + s.order = append(s.order, id) + for len(s.order)-s.head > s.limit { + oldest := s.order[s.head] + s.order[s.head] = "" // 断引用,让字符串可回收 + s.head++ + delete(s.seen, oldest) + s.evicted++ + } + // 前缀攒到一半以上时压实一次,否则 order 的底层数组会随插入次数无限增长 + // —— 那正是这张表本来要修的病,只是换了个地方。 + if s.head > 0 && s.head >= len(s.order)/2 { + s.compact() + } + return true +} + +// compact 把 order 重排到从 0 开始,丢掉已淘汰的前缀。 +// +// 容量固定为 `limit*2` 而不是 `cap(live)+limit`:后者里的 `cap(live)` 是 +// 原切片剩下的容量,而 append 会不断扩容 —— 于是每次压实都把上一轮扩大后的 +// 容量继承下去,底层数组仍然单调增长(实测灌 1 万条后 cap 到 1130)。 +// 那正是这张表本来要修的病,只是从 map 换到了切片上。 +// +// limit*2 刚好是下一次触发压实的长度(head 走到 limit 时 len == 2*limit), +// 于是 append 在两次压实之间不会扩容。 +func (s *boundedIDSet) compact() { + live := s.order[s.head:] + fresh := make([]string, len(live), s.limit*2) + copy(fresh, live) + s.order = fresh + s.head = 0 +} + +// size 是当前记住的条数,观测与测试用。 +func (s *boundedIDSet) size() int { + if s == nil { + return 0 + } + return len(s.seen) +} diff --git a/plugins/homeagent-mail-bridge/bounded_test.go b/plugins/homeagent-mail-bridge/bounded_test.go new file mode 100644 index 0000000..65d18cb --- /dev/null +++ b/plugins/homeagent-mail-bridge/bounded_test.go @@ -0,0 +1,162 @@ +package main + +import ( + "fmt" + "testing" +) + +// 这些用例钉住的是 bounded.go 的三条性质:封顶、FIFO、以及 +// 「add 的返回值就是查重结果」—— 后者让调用方能在一把锁里查重 + 登记。 + +func TestBoundedSetCapsSize(t *testing.T) { + s := newBoundedIDSet(3) + for _, id := range []string{"a", "b", "c", "d", "e"} { + s.add(id) + } + if s.size() != 3 { + t.Fatalf("上限之后 size 必须封顶,得到 %d —— 这正是泄露的反面", s.size()) + } + if s.has("a") || s.has("b") { + t.Error("最早插入的两条应当被淘汰") + } + for _, id := range []string{"c", "d", "e"} { + if !s.has(id) { + t.Errorf("%s 应当还在", id) + } + } + if s.evicted != 2 { + t.Errorf("evicted 应为 2,得到 %d", s.evicted) + } +} + +func TestBoundedSetAddReportsFreshness(t *testing.T) { + // 调用方(plugin.go 的两处去重)依赖这个返回值在同一把锁里完成 + // 「查重 + 登记」。分两步做需要两次加锁,中间那个窗口正是原来的竞态。 + s := newBoundedIDSet(10) + if !s.add("m1") { + t.Error("首次 add 应当返回 true(新加入)") + } + if s.add("m1") { + t.Error("重复 add 应当返回 false(已存在)") + } + if s.size() != 1 { + t.Errorf("重复 add 不该占额外位置,size=%d", s.size()) + } +} + +func TestBoundedSetFIFONotLRU(t *testing.T) { + // Go 的 map 不保证遍历顺序,所以这里是显式的 FIFO 而不是 LRU。 + // 这条用例把那个决定钉住:反复 has 不会让条目留得更久。 + s := newBoundedIDSet(2) + s.add("a") + s.add("b") + for i := 0; i < 5; i++ { + s.has("a") // 在 LRU 语义下这会保住 a + } + s.add("c") + if s.has("a") { + t.Error("FIFO 语义下最早插入的 a 应当被淘汰,has() 不刷新活跃度") + } + if !s.has("b") || !s.has("c") { + t.Error("b 与 c 应当都在") + } +} + +func TestBoundedSetReAddDoesNotRefreshPosition(t *testing.T) { + // 重复 add 也不改变位置(FIFO 由首次插入决定)。写成「已存在时移到队尾」 + // 会让一条被反复推送的邮件把别的条目挤出去。 + s := newBoundedIDSet(2) + s.add("a") + s.add("b") + s.add("a") // 已存在,位置不变 + s.add("c") + if s.has("a") { + t.Error("a 是最早插入的,重复 add 不该救回它") + } +} + +func TestBoundedSetIgnoresEmptyID(t *testing.T) { + // 空 mail_id 是「事件残缺」而不是「一封 id 为空的邮件」。 + // 记住它会让第二封残缺事件被误判成重复。 + s := newBoundedIDSet(5) + if s.add("") { + t.Error("空 id 不该被记住") + } + if s.has("") { + t.Error("空 id 永远不算已见过") + } + if s.size() != 0 { + t.Errorf("空 id 不该占位置,size=%d", s.size()) + } +} + +func TestBoundedSetNilSafe(t *testing.T) { + // 构造函数失败或字段没初始化时不该 panic —— 去重是优化项, + // 退化成「什么都不记得」(多几次重复投递)远好过插件崩溃。 + var s *boundedIDSet + if s.has("x") { + t.Error("nil 上 has 应为 false") + } + if s.add("x") { + t.Error("nil 上 add 应为 false") + } + if s.size() != 0 { + t.Error("nil 上 size 应为 0") + } +} + +func TestBoundedSetIllegalLimitFallsBackToOne(t *testing.T) { + // 上限非法时回落到 1 而不是 panic 或 0。 + // 0 的后果最隐蔽:每次 add 之后立刻把自己淘汰掉 → 去重全失效且不报错。 + for _, limit := range []int{0, -1, -100} { + s := newBoundedIDSet(limit) + s.add("a") + if !s.has("a") { + t.Errorf("limit=%d:刚加入的那条必须还在(回落到 1,而不是 0)", limit) + } + s.add("b") + if s.size() != 1 { + t.Errorf("limit=%d:size 应为 1,得到 %d", limit, s.size()) + } + } +} + +func TestBoundedSetCompactsOrderSlice(t *testing.T) { + // order 切片的底层数组不能随插入次数无限增长 —— 那正是这张表要修的病, + // 只是换了个地方(map 有界了,切片没有)。 + s := newBoundedIDSet(10) + for i := 0; i < 10_000; i++ { + s.add(fmt.Sprintf("mail-%d", i)) + } + if s.size() != 10 { + t.Fatalf("size 应当封顶在 10,得到 %d", s.size()) + } + // 压实之后 order 的有效长度不该远大于上限 + if live := len(s.order) - s.head; live > 10 { + t.Errorf("order 有效长度 %d 超过上限 10", live) + } + if len(s.order) > 10*4 { + t.Errorf("order 底层长度 %d 相对上限 10 增长失控(压实没生效)", len(s.order)) + } + if cap(s.order) > 10*8 { + t.Errorf("order 容量 %d 相对上限 10 增长失控", cap(s.order)) + } + // 最新的必须还在,最老的必须没了 + if !s.has("mail-9999") { + t.Error("最新的那条必须还在") + } + if s.has("mail-0") { + t.Error("最老的那条必须已被淘汰") + } + if s.evicted != 9990 { + t.Errorf("evicted 应为 9990,得到 %d", s.evicted) + } +} + +func TestBoundedSetLimitMatchesNodeSide(t *testing.T) { + // 与 Node 侧 lib/bounded.js 的 MAX_TRACKED_MAILS 取同一个数。 + // 四个平台在同一套语义下运行,一侧偷偷调小会让「重复投递」只在那个平台出现。 + if maxTrackedMails != 2000 { + t.Errorf("maxTrackedMails 应为 2000(与 Node 侧一致),得到 %d", maxTrackedMails) + } +} diff --git a/plugins/homeagent-mail-bridge/plugin.go b/plugins/homeagent-mail-bridge/plugin.go index c7e10ee..9998287 100644 --- a/plugins/homeagent-mail-bridge/plugin.go +++ b/plugins/homeagent-mail-bridge/plugin.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "encoding/json" + "errors" "fmt" "io" "log" @@ -87,7 +88,10 @@ type Plugin struct { // // 这只挡得住**本进程内**的重复。跨进程(homed 重启、插件子进程被换) // 靠 ledger —— 它落盘,且区分「投过」与「跑完」。 - deliveredMails map[string]bool + // + // 有界(见 bounded.go):插件跟着 homed 长期活着,普通 map 会攒下每一封 + // 处理过的邮件 id 而永远没有出口。 + deliveredMails *boundedIDSet // 跨进程投递账本(见 ledger.go)。 // @@ -95,6 +99,14 @@ type Plugin struct { // 前者回答的是「上一个进程有没有已经把这封跑完」。 ledger *deliveryLedger + // currentSessionID 是当前正在处理的邮件所属的 agentmail 会话 ID。 + // + // homeagent 是单事件循环(所有邮件共享一个 turn),同一时刻只处理一封信。 + // 模型调 send_mail 时,Gateway 需要知道「这封信是从哪条会话里发出的」 + // 才能用 InheritedMode 继承档位。SDK 的工具 handler 不传 session 上下文, + // 所以靠这个字段做桥接。 + currentSessionID string + // 单调递增的 last-seen-ID:被重放的旧事件不会让它回退。 // 原来直接赋值(p.lastEventID = eid),Gateway 重放时发旧 ID, // 于是 lastEventID 从 123 退回 116 → 下次重连又报 116 → 又重放。 @@ -180,7 +192,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e client: &http.Client{Timeout: 60 * time.Second}, sseClient: &http.Client{}, // 无超时:SSE 是长连接 stopCh: make(chan struct{}), - deliveredMails: make(map[string]bool), + deliveredMails: newBoundedIDSet(maxTrackedMails), explicitSends: make(map[string]time.Time), }, nil } @@ -246,6 +258,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { "body": map[string]interface{}{"type": "string", "description": "邮件正文(Markdown)"}, "cc": map[string]interface{}{"type": "string", "description": "抄送"}, "reply_to": map[string]interface{}{"type": "string", "description": "回复某封邮件时传其 mail_id"}, + // 字段名必须是 attachment_ids、元素必须是裸 id 字符串 —— 逐字对齐服务端 + // SendMailRequest.AttachmentIDs。服务端解请求体时没开 DisallowUnknownFields, + // 所以字段名错了是**静默丢附件**而不是报错:实测传 + // attachments:[{"attachment_id":…}] 返回 200,那封邮件的附件数是 0。 + "attachment_ids": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + "description": "附件 ID 列表(先用 upload_attachment 上传取得)", + }, }, "required": []string{"to", "subject", "body"}, }, @@ -270,7 +291,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { registerTool("upload_attachment", sdk.ToolDef{ Name: "upload_attachment", - Description: "上传本地文件作为邮件附件。返回 attachment_id,填入 send_mail 的 attachments 字段。", + Description: "上传本地文件作为邮件附件。返回 attachment_id,填入 send_mail 的 attachment_ids 字段。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ @@ -536,6 +557,9 @@ func (p *Plugin) catchUp(pending int) { // parent_mail_id 非空 = 这封是回信。收件箱返回的字段名是它, // 而 SSE 事件里叫 in_reply_to —— 两个名字指同一件事。 ParentMailID string `json:"parent_mail_id"` + // from_session_id 用于档位继承:模型调 send_mail 时,Gateway 据此 + // 从来源会话继承权限档位(InheritedMode)。 + SessionID string `json:"session_id"` } `json:"mails"` } url := fmt.Sprintf("%s/api/v1/mail/inbox?status=unread&limit=%d", p.gwURL, limit) @@ -559,12 +583,10 @@ func (p *Plugin) catchUp(pending int) { // 必须在循环里逗封查而不是拉完一批再筛:InjectInputSync 一封要跑 // 几十秒,那期间 SSE 完全可能已经投过后面那几封。 p.sseMu.Lock() - dup := p.deliveredMails[m.MailID] - if !dup { - p.deliveredMails[m.MailID] = true - } + // add 返回「本次是否新加入」,于是查重与登记在同一把锁里一步完成。 + fresh := p.deliveredMails.add(m.MailID) p.sseMu.Unlock() - if dup { + if !fresh { continue } @@ -606,7 +628,9 @@ func (p *Plugin) catchUp(pending int) { replyInstruction(m.FromHuman, ""), ) + p.currentSessionID = m.SessionID reply := p.sdk.InjectInputSync(p.name, p.name, prompt) + p.currentSessionID = "" if reply == "" { // B-6:模型没回,发一封告知。发出去就算处理完(理由同 handleNewMail)。 p.sendFailureReply(m.FromName, m.Subject, m.MailID, "模型未产生回复") @@ -614,7 +638,7 @@ func (p *Plugin) catchUp(pending int) { continue } // B-5.3:检查模型是否已经自己发过信 - rk := "homeagent:" + m.MailID + rk := ClampRelayKey("homeagent:" + m.MailID) p.explicitSendsMu.Lock() _, sent := p.explicitSends[rk] p.explicitSendsMu.Unlock() @@ -637,8 +661,16 @@ func (p *Plugin) catchUp(pending int) { // B-5.2:自动回信带 relay:"summary" —— 搬运不算模型自主发信,不扣配额 if err := p.sendMailRelay(m.FromName, "Re: "+m.Subject, reply, m.MailID, rk); err != nil { + // 永久失败(4xx)重试一万次也是同一个结果 —— 标完成,否则 + // 每次重启都重跑一遍模型再碰同一堆墙(烧 token 且永不收敛)。 + // 暂时失败(5xx / 网络)不标,下次重启重试。 + if st := statusOf(err); IsPermanentFailure(st) { + log.Printf("[homeagent-mail-bridge] 补投回信遇永久失败(HTTP %d,标完成不再重试): %v", st, err) + p.ledger.complete(m.MailID) + continue + } // 回信没发出去 —— 不标完成,下次重启重试。 - log.Printf("[homeagent-mail-bridge] 补投回信失败(不标完成): %v", err) + log.Printf("[homeagent-mail-bridge] 补投回信暂时失败(不标完成): %v", err) continue } p.ledger.complete(m.MailID) @@ -794,12 +826,11 @@ func (p *Plugin) parseSSELine(line string) { // B-7.3:去重。SSE 重放时同一封邮件会再出现,没有这层 // 每封邮件会被注入 agent 两遍(实测 21 次超时 → 21 次重放)。 p.sseMu.Lock() - if p.deliveredMails[evt.MailID] { - p.sseMu.Unlock() + fresh := p.deliveredMails.add(evt.MailID) + p.sseMu.Unlock() + if !fresh { return } - p.deliveredMails[evt.MailID] = true - p.sseMu.Unlock() // 跨进程去重:上一个插件子进程可能已经把这封跑完了。 // deliveredMails 只在本进程内有效,homed 重启会把它清空 —— @@ -908,7 +939,7 @@ func (p *Plugin) sendFailureReply(to, subject, replyTo, reason string) { "请稍后重试,或通过其他方式联系。", subject, reason, ) - rk := "homeagent:failure:" + replyTo + rk := ClampRelayKey("homeagent:failure:" + replyTo) if err := p.sendMailRelay(to, "Re: "+subject, body, replyTo, rk); err != nil { log.Printf("[homeagent-mail-bridge] 失败通知发送失败: %v", err) } @@ -946,7 +977,10 @@ func (p *Plugin) handleNewMail(evt mailEvent, resumed bool) { ) // InjectInputSync 阻塞等待 agent 处理完毕,返回最终回复文本。 + // 工具 handler 没有独立的 session 上下文,因此在本轮处理期间暂存来源会话。 + p.currentSessionID = evt.SessionID reply := p.sdk.InjectInputSync(p.name, p.name, prompt) + p.currentSessionID = "" // B-6:模型没回(空 = turn/end 信号 kind=error,或模型没说话) if reply == "" { @@ -961,7 +995,7 @@ func (p *Plugin) handleNewMail(evt mailEvent, resumed bool) { } // B-5.3:检查模型是否已经自己发过信(通过 send_mail 或 output_send) - rk := "homeagent:" + evt.MailID + rk := ClampRelayKey("homeagent:" + evt.MailID) p.explicitSendsMu.Lock() _, sent := p.explicitSends[rk] if sent { @@ -990,9 +1024,16 @@ func (p *Plugin) handleNewMail(evt mailEvent, resumed bool) { // B-5.2:自动回信带 relay:"summary" + relay_key if err := p.sendMailRelay(evt.FromName, "Re: "+evt.Subject, reply, evt.MailID, rk); err != nil { - // 回信没发出去 —— **不标完成**,让下次重启能重试。 + // 永久失败(4xx)标完成:重试不会变好,不标的话每次重启 + // 都重跑一遍模型再碰同一堆墙。 + if st := statusOf(err); IsPermanentFailure(st) { + log.Printf("[homeagent-mail-bridge] 自动回信遇永久失败(HTTP %d,标完成不再重试): %v", st, err) + p.ledger.complete(evt.MailID) + return + } + // 暂时失败 → **不标完成**,让下次重启能重试。 // 发件人至今一个字都没收到,这时标「已完成」就是静默丢件。 - log.Printf("[homeagent-mail-bridge] 自动回信失败(不标完成,下次会重试): %v", err) + log.Printf("[homeagent-mail-bridge] 自动回信暂时失败(不标完成,下次会重试): %v", err) } else { log.Printf("[homeagent-mail-bridge] 已自动回信给 %s(%d 字)", evt.FromName, len(reply)) p.ledger.complete(evt.MailID) @@ -1079,7 +1120,7 @@ func (p *Plugin) handleReadInbox(args map[string]interface{}) (interface{}, erro fn, _ := att["filename"].(string) sz, _ := att["size_bytes"].(float64) aid, _ := att["attachment_id"].(string) - fmt.Fprintf(&sb, " - %s (%.1fKB, id=%s)\n", fn, sz/1024, aid) + fmt.Fprintf(&sb, " - %s (%s, id=%s)\n", fn, formatSize(int64(sz)), aid) } } } @@ -1134,12 +1175,20 @@ func (p *Plugin) handleSendMail(args map[string]interface{}) (interface{}, error "subject": subj, "body": body, } + if p.currentSessionID != "" { + payload["from_session_id"] = p.currentSessionID + } if cc != "" { payload["cc"] = cc } if replyTo != "" { payload["reply_to"] = replyTo } + // 附件必须由 send_mail 带上:上传只是把文件登记成「待挂载」, + // 24 小时内没有任何邮件引用它就会被 GC 清掉。 + if ids := stringList(args["attachment_ids"]); len(ids) > 0 { + payload["attachment_ids"] = ids + } var result map[string]interface{} if err := p.post("/mail/send", payload, &result); err != nil { @@ -1161,7 +1210,7 @@ func (p *Plugin) handleSendMail(args map[string]interface{}) (interface{}, error // C-14 附件上传 —— 真 multipart,不是桩。 // // 读取本地文件 → 构造 multipart/form-data → POST /api/v1/attachments。 -// 返回 attachment_id,填入 send_mail 的 attachments 字段。 +// 返回 attachment_id,填入 send_mail 的 attachment_ids 字段。 func (p *Plugin) handleUploadAttachment(args map[string]interface{}) (interface{}, error) { filePath, _ := args["file_path"].(string) if filePath == "" { @@ -1203,17 +1252,33 @@ func (p *Plugin) handleUploadAttachment(args map[string]interface{}) (interface{ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) } + // 服务端返回的是 {"attachment":{…}},字段**不在**顶层。 + // + // 这里原先按平铺解,于是三个字段全是零值。那是最坏的一种失败:上传其实 + // 成功了(HTTP 200、文件已落盘、库里已登记),没有任何一层报错,但模型 + // 看到的是 `id= filename= size=0KB` —— 拿着空 id 它没法发出这个附件, + // 而 24 小时后 GC 会把那个没人引用的文件清掉。 var result struct { - AttachmentID string `json:"attachment_id"` - Filename string `json:"filename"` - SizeBytes int `json:"size_bytes"` + Attachment struct { + AttachmentID string `json:"attachment_id"` + Filename string `json:"filename"` + SizeBytes int64 `json:"size_bytes"` + } `json:"attachment"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err } + a := result.Attachment + // 解出空 id 说明响应结构又变了,必须当场报错。回一句「已上传」配一个空 id + // 只会让模型接着去发信,然后收到一封没有附件的邮件 —— 那正是上面那个 bug + // 之所以能存活的原因。 + if a.AttachmentID == "" { + return nil, fmt.Errorf("上传响应里没有 attachment_id(服务端响应结构可能已变更),附件无法发出") + } - text := fmt.Sprintf("附件已上传:id=%s filename=%s size=%dKB\n在 send_mail 的 attachments 字段传 [{\"attachment_id\":\"%s\"}]", - result.AttachmentID, result.Filename, result.SizeBytes/1024, result.AttachmentID) + text := fmt.Sprintf("附件已上传:%s(%s)。attachment_id: %s\n"+ + "在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出:attachment_ids=[\"%s\"]", + a.Filename, formatSize(a.SizeBytes), a.AttachmentID, a.AttachmentID) return map[string]interface{}{ "content": []map[string]interface{}{{"type": "text", "text": text}}, }, nil @@ -1260,7 +1325,7 @@ func (p *Plugin) handleDownloadAttachment(args map[string]interface{}) (interfac return nil, fmt.Errorf("写入文件失败: %v", err) } - text := fmt.Sprintf("附件已下载:%s(%dKB)", savePath, written/1024) + text := fmt.Sprintf("附件已下载:%s(%s)", savePath, formatSize(written)) return map[string]interface{}{ "content": []map[string]interface{}{{"type": "text", "text": text}}, }, nil @@ -1288,6 +1353,30 @@ func (p *Plugin) get(url string, out interface{}) error { return json.NewDecoder(resp.Body).Decode(out) } +// httpError 带状态码的 HTTP 错误。 +// +// 为什么要结构化:调用方需要区分「永久失败」与「暂时失败」 +// (见 IsPermanentFailure)。把状态码埋在 error 文本里,调用方只能 +// strings.Contains("HTTP 400") —— 那会在报文变化时静默失效。 +type httpError struct { + Status int + Path string + Body string +} + +func (e *httpError) Error() string { + return fmt.Sprintf("POST %s HTTP %d: %s", e.Path, e.Status, e.Body) +} + +// statusOf 从 error 里取 HTTP 状态码;不是 httpError 时返回 0(按网络层错误处理)。 +func statusOf(err error) int { + var he *httpError + if errors.As(err, &he) { + return he.Status + } + return 0 +} + func (p *Plugin) post(path string, payload interface{}, out interface{}) error { data, err := json.Marshal(payload) if err != nil { @@ -1313,7 +1402,7 @@ func (p *Plugin) post(path string, payload interface{}, out interface{}) error { if resp.StatusCode >= 400 { body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("POST %s HTTP %d: %s", path, resp.StatusCode, string(body)) + return &httpError{Status: resp.StatusCode, Path: path, Body: string(body)} } if out != nil { return json.NewDecoder(resp.Body).Decode(out) diff --git a/plugins/homeagent-mail-bridge/tools.go b/plugins/homeagent-mail-bridge/tools.go index e554ab9..c6259e0 100644 --- a/plugins/homeagent-mail-bridge/tools.go +++ b/plugins/homeagent-mail-bridge/tools.go @@ -69,8 +69,9 @@ func (p *Plugin) handleReadMail(args map[string]interface{}) (interface{}, error if len(data.Mail.Attachments) > 0 { fmt.Fprintf(&sb, "附件:\n") for _, a := range data.Mail.Attachments { - fmt.Fprintf(&sb, " - %s (%.1fKB, id=%s)\n", a.Filename, float64(a.SizeBytes)/1024, a.AttachmentID) + fmt.Fprintf(&sb, " - %s (%s, id=%s)\n", a.Filename, formatSize(int64(a.SizeBytes)), a.AttachmentID) } + sb.WriteString(" 用 download_attachment 取回(传 attachment_id 与 save_path)\n") } fmt.Fprintf(&sb, "\n%s\n", data.Mail.Body) diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index 2c2135d..c4fc70a 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -35,6 +35,8 @@ import { } from "./lib/relay-dedup.js"; import { adoptedSessionID, adoptMissingMessage } from "./lib/adopt.js"; import { autoRelayDecision, replyInstruction, inboundHeadline } from "./lib/relay-policy.js"; +import { clampRelayKey, isPermanentFailure } from "./lib/relay-key.js"; +import { BoundedMap, BoundedSet, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from "./lib/bounded.js"; import { appendRenameProposal, renameProposalNote } from "./lib/rename-proposal.js"; // opencode 原生支持三态权限,免批由它自己记(response:"always"), // 所以这里只借用决策文本的判定,不需要 createGrantStore。 @@ -199,6 +201,7 @@ const sendMailTool = { reply_to: args.reply_to || "", session_alias: args.session_alias || "", attachment_ids: args.attachment_ids || [], + from_session_id: context?.sessionID || "", }); // 发成功后才记:失败的调用不该压掉自动转发 —— @@ -585,9 +588,13 @@ function startSSE(onEvent) { // AgentMail 会话 ↔ opencode 会话的绑定。 // 网关已经根据三维地址的 session 位完成了「复用默认 / 新建 / 具名必须存在」的判定, // 推送过来的 session_id 就是那个判定结果;插件只负责忠实映射,不自己决定开不开新会话。 -const sessionMap = new Map(); // agentmail session_id -> opencode session id -const reverseMap = new Map(); // opencode session id -> agentmail session_id(供 event 钩子回写命名) -const syncedTitles = new Map(); // opencode session id -> 已回写过的标题(去重,避免 session.updated 刷屏) +// 这几张表都是**常驻进程里只增不减**的形态:键来自邮件事件流,会话数随时间 +// 单调增长。两条出口 —— `forgetSession()`(会话归档,确定性)与 Bounded* 的 +// 上限淘汰(兜底)。没有它们,插件跑几周之后表里躺着几十万条再也不会被查到的 +// 条目,而 GC 收不掉(还被强引用着)。 +const sessionMap = new BoundedMap(MAX_TRACKED_SESSIONS); // agentmail session_id -> opencode session id +const reverseMap = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> agentmail session_id(供 event 钩子回写命名) +const syncedTitles = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> 已回写过的标题(去重,避免 session.updated 刷屏) // 权限询问的双向定位。 // @@ -595,11 +602,15 @@ const syncedTitles = new Map(); // opencode session id -> 已回写过的标题 // 转出去时要记住 permission 属于哪个会话(回信地址从那里来), // 人类决策回来时要用 permission.id 去回复 opencode。 // 服务端会把 relay_key 随决策事件回传,所以插件重启丢了内存映射也能续上。 +// +// **这张表不设上界**(与上面几张不同):它装的是「还在等结果的东西」。 +// 静默淘汰一条会让 opencode 侧那次工具调用永远等不到回答 —— 而它有确定的 +// 清理路径(决策到达 / 询问被取消),不需要靠猜。 const pendingPermissions = new Map(); // permission.id -> { sessionID, callID } // 每个会话「上一次转出去的最后一条 assistant 消息」,避免 session.idle 重复触发时重发。 // 服务端另有 relay_key 幂等兜底,这里只是少打一次网关。 -const relayedSummaries = new Map(); // opencode session id -> assistant message id +const relayedSummaries = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> assistant message id // 哪些会话参与邮件往来,idle 时要把总结转回去。 // @@ -608,7 +619,7 @@ const relayedSummaries = new Map(); // opencode session id -> assistant message // 却永远没有回音,发件人只看到信发出去后再无音讯。 // // 没有邮件投进来的 TUI 会话不在这里,它们不该被搬进邮件系统。 -const mailDrivenSessions = new Set(); // opencode session id +const mailDrivenSessions = new BoundedSet(MAX_TRACKED_SESSIONS); // opencode session id // 管理员在配置页划定的可用模型范围(按优先级)。随心跳响应更新。 // 空数组 = 不限定,回退到环境变量或平台默认。 @@ -771,7 +782,7 @@ async function relaySummary(client, directory, sessionID) { reply_to: ctx.mailID || "", // relay + relay_key:走免配额通道,并以 assistant message id 保证只转一次 relay: "summary", - relay_key: last.id, + relay_key: clampRelayKey(last.id), }); relayedSummaries.set(sessionID, last.id); explicitSends.delete(sessionID); // 一轮结束,窗口关闭 @@ -785,7 +796,31 @@ function stripRe(subject) { // 每个 AgentMail 会话最近一封来信的上下文,用于决定总结回给谁。 // 一个会话里可能来过多封信,回最近那封(reply_to 指向它,回信才落回同一线索)。 -const mailContexts = new Map(); // agentmail session_id -> { replyTo, subject, mailID } +const mailContexts = new BoundedMap(MAX_TRACKED_SESSIONS); // agentmail session_id -> { replyTo, subject, mailID } + +/** + * 会话归档 → 忘掉它的全部映射。 + * + * 归档是个**确定性的终点**:归档后那条会话不可寻址(别名 404),也不会再有新 + * 邮件投进来,`session.idle` 也不该再把总结转回去(会话已经收不了信)。 + * 留着这些条目只是占内存,而上限淘汰是「猜」—— 能确切知道该删的时候就不该靠猜。 + * + * opencode 侧那条会话**不删**:人可能还在 TUI 里看它。这里只解除邮件绑定。 + * + * @param {string} mailSessionID + */ +function forgetSession(mailSessionID) { + if (!mailSessionID) return; + // peek 而不是 get:这是清理路径,不该把即将删掉的条目刷成「最近活跃」。 + const opencodeID = sessionMap.peek(mailSessionID); + mailContexts.delete(mailSessionID); + sessionMap.delete(mailSessionID); + if (!opencodeID) return; + reverseMap.delete(opencodeID); + mailDrivenSessions.delete(opencodeID); + relayedSummaries.delete(opencodeID); + syncedTitles.delete(opencodeID); +} // relaySummary 需要 client/directory,而 event 钩子拿不到它们 // (只在插件初始化时给一次)。插件启动时把它们闭包进来。 @@ -941,7 +976,7 @@ async function deliverMail(client, directory, data, kind) { body: renderFailureReport(failures, data.subject), reply_to: data.mail_id || "", relay: "summary", - relay_key: `model-failure:${data.mail_id || sessionID}`, + relay_key: clampRelayKey(`model-failure:${data.mail_id || sessionID}`), }); console.error(`[mail-bridge] 已回报模型调用失败给 ${data.from_name}`); } catch (e) { @@ -1073,7 +1108,10 @@ export default async function mailBridge(input) { // 已经投过的 mail_id。心跳与 SSE 建连之间有个窗口:那期间到的邮件 // 既在 pending_mails 里、也会被 SSE 推一次 —— 不去重就会投两遍。 - const deliveredMails = new Set(); + // + // 有界:插件跟着 opencode serve 一起长期活着,这里会攒下每一封处理过的 + // 邮件 id 而永远没有出口。淘汰是安全的 —— 它防的两种重复都发生在秒到分钟级。 + const deliveredMails = new BoundedSet(MAX_TRACKED_MAILS); /** * 补投离线期间积压的未读邮件。 @@ -1145,6 +1183,12 @@ export default async function mailBridge(input) { return; } + if (type === "session_archived") { + // 会话归档 = 那条会话再也不会收信,映射可以确定性地清掉(不必等上限淘汰)。 + forgetSession(data?.session_id || ""); + return; + } + if (type !== "new_mail") return; if (data?.mail_id) deliveredMails.add(data.mail_id); deliverMail(client, directory, data, "mail") @@ -1194,35 +1238,42 @@ export default async function mailBridge(input) { ? "\n```json\n" + JSON.stringify(input.metadata, null, 2) + "\n```" : "", ].filter(Boolean).join("\n"), - relayKey: input.id, + relayKey: clampRelayKey(input.id), }); console.error(`[mail-bridge] 权限询问已转邮件 ${input.id}(${input.type})`); } catch (e) { pendingPermissions.delete(input.id); - // 409 = 这条任务链上没有人类,永远不会有人来点头。 + // 4xx = 请求本身被服务端拒绝,重试一万次也是同一个结果。 // // 必须当场 deny:保持 "ask" 等于把会话交给本地 TUI 弹窗, // 而邮件驱动的会话根本没有 TUI —— 模型会永久挂在那里。 - // 这正是生产事故的形状:pi 把任务派给自己的另一条会话, - // 那条会话要跑 bash,权限邮件无人可投,整条线索卡死。 // - // deny 的同时把服务端的建议原文带给模型,它才知道下一步该换什么做法。 - if (e?.status === 409 && e?.body?.suggestion) { - console.error(`[mail-bridge] 权限询问无人可投,当场拒绝 ${input.id}:${e.body.error || ""}`); + // 两个真实事故都是这个形状: + // 409:pi 把任务派给自己另一条会话,那条要跑 bash, + // 权限邮件无人可投,整条线索卡死 + // 400:relay_key 超长(extended thinking 把思考签名拼进了 toolCallId), + // 被当暂时失败让位 → 那条命令无人批准就执行了 + // + // deny 的同时把原因带给模型(opencode 把 reason 作为工具报错回去), + // 它才知道下一步该换什么做法。 + if (isPermanentFailure(e)) { + const b = e?.body || {}; + console.error( + `[mail-bridge] 权限询问遇到永久失败(HTTP ${e?.status}),当场拒绝 ${input.id}:${b.error || e?.message || ""}`, + ); output.status = "deny"; - // opencode 把 reason 作为工具报错回给模型 output.reason = [ - e.body.error || "权限询问无法送达:该任务链上没有人类用户", - e.body.detail || "", - e.body.suggestion || "", + b.error || `无法把授权请求送达给人类(HTTP ${e?.status})`, + b.detail || "", + b.suggestion || "这是一个不会因重试而改变的失败。请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。", ].filter(Boolean).join("\n"); return; } - // 其余失败(网络抖动、Gateway 重启)保持 ask:那些是暂时的, - // 人仍可能在本地看到弹窗,不该把一次抖动当成永久拒绝。 - console.error("[mail-bridge] 权限询问转发失败:", e?.message || e); + // 暂时失败(5xx / 408 / 429 / 网络拖动)保持 ask:那些真的可能下一次就好, + // 人也仍可能在本地看到弹窗,不该把一次抖动当成永久拒绝。 + console.error("[mail-bridge] 权限询问转发暂时失败(保持 ask):", e?.message || e); return; } output.status = "ask"; diff --git a/plugins/opencode-mail-bridge/lib/permission-mode.js b/plugins/opencode-mail-bridge/lib/permission-mode.js new file mode 100644 index 0000000..4a0dcd4 --- /dev/null +++ b/plugins/opencode-mail-bridge/lib/permission-mode.js @@ -0,0 +1,274 @@ +/** + * 权限档位 → 平台原生配置的翻译 —— 四个平台共用的判据。 + * + * ## 分工 + * + * **AgentMail 声明,平台执行,插件只翻译。** 这个模块是「翻译」那一步的 + * 唯一实现:把 `plan` / `workspace` / `full` 翻成各平台原生的沙箱/审批配置。 + * + * 为什么不让插件自己按工具名猜着拦:那会同时违反 I-1(平台原生信号是唯一 + * 真相来源)与 I-4(插件只搬运不决策),而且四个插件对「workspace 到底管 + * 什么」必然各猜一套 —— 同一封 workspace 档的邮件在 A 平台被拦、在 B 平台放行。 + * + * ## 为什么必须「向更严取整」 + * + * 平台表达不出精确档位时,一律往更严的方向走,并如实上报自己做到了什么 + * (native / advisory)。pi 就是例子:write/edit 能查 `input.path` 判断越界, + * 而 bash 命令要碰哪些文件是解析不出来的 —— 于是 workspace 档下 pi 只能 + * 「每条 bash 都问人」,比声明的更严。 + * + * 不定这条规则的后果:不同插件会朝不同方向取整,而往宽松取整是静默失效 + * (人以为收紧了,实际没有)。 + */ + +/** 只读:查资料、读代码、出方案,一个字都不许写。 */ +export const MODE_PLAN = 'plan'; +/** 本目录内可动手,越界要问人。默认档。 */ +export const MODE_WORKSPACE = 'workspace'; +/** 自动放行,不问人。 */ +export const MODE_FULL = 'full'; + +/** 全部合法档位,按宽松程度递增。顺序是 modeAtMost 的依据。 */ +export const MODES = [MODE_PLAN, MODE_WORKSPACE, MODE_FULL]; + +/** 没有显式指定时的档位。与 Gateway 的 DefaultPermissionMode 必须一致。 */ +export const DEFAULT_MODE = MODE_WORKSPACE; + +/** 平台有原生拦截点,档位被真正执行。 */ +export const ENFORCE_NATIVE = 'native'; +/** 平台没有拦截点,档位只写进提示词。 */ +export const ENFORCE_ADVISORY = 'advisory'; + +/** + * 把外部输入收敛成合法档位。 + * + * 非法值 → 默认档(**不是** full)。拼错一个档位名不该换来更大的权限。 + * 与 Gateway 的 NormalizePermissionMode 同语义。 + * + * @param {unknown} mode + * @returns {string} + */ +export function normalizeMode(mode) { + return MODES.includes(mode) ? mode : DEFAULT_MODE; +} + +/** + * 收敛强制力取值。空串或非法值 → advisory。 + * + * 保守方向是 advisory 而不是 native:不能替一个没自报过的平台宣称 + * 「档位在这里是被强制的」。 + * + * @param {unknown} e + * @returns {string} + */ +export function normalizeEnforcement(e) { + return e === ENFORCE_NATIVE || e === ENFORCE_ADVISORY ? e : ENFORCE_ADVISORY; +} + +/** + * 取两个档位里更严的那一个。 + * + * 先归一化再比较 —— 两个脏值都变成默认档,于是结果与参数顺序无关(可交换)。 + * Gateway 侧的 ModeAtMost 曾因为「modeRank 把未知值当最严、Normalize 把它 + * 归到默认档」而不可交换,单元测试当场抓到。两边保持同一套语义。 + * + * @param {string} a + * @param {string} b + * @returns {string} + */ +export function modeAtMost(a, b) { + const na = normalizeMode(a); + const nb = normalizeMode(b); + return MODES.indexOf(na) <= MODES.indexOf(nb) ? na : nb; +} + +/** + * 这一档会不会产生权限邮件(即需不需要人来点头)。 + * + * 只有 workspace 档需要人:plan 档当场拒绝、full 档自动放行,两者都不问人。 + * 插件据此决定要不要把平台的权限钩子接到 `/permission/request`。 + * + * @param {string} mode + * @returns {boolean} + */ +export function modeNeedsHuman(mode) { + return normalizeMode(mode) === MODE_WORKSPACE; +} + +/** + * opencode 的 permission 规则数组。 + * + * ## 六条实测结论(不实测就会做出「看起来对但管不住」的东西) + * + * 1. **规则是 findLast 胜出**(二进制里 + * `findLast((z)=>g.match(j,z.permission)&&g.match(J,z.pattern))`) + * → deny 必须放前面、allow 放后面。反了的话连允许的路径也被拒。 + * 2. **pattern 匹配 worktree 相对路径**(`patterns:[relative(y.worktree,file)]`) + * → 写 `/tmp/**` 这种绝对 pattern 永远匹配不上(`/tmp/x` 相对 + * `/home/program/agentmail` 是 `../../../tmp/x`)。所以 workspace 档用 `**`。 + * 3. **write / edit / patch 共用 `edit` 一个权限名** + * (`if(A==="write"||A==="edit"||A==="patch"){G.edit=I}`)。 + * 4. **全 deny 让工具从模型清单里消失**(模型自述「I don't have a bash tool + * available in this session」),部分 deny 则工具保留、越界调用才报错。 + * plan 档用前者更好:模型不会浪费轮次去试。 + * 5. **task(子代理)能绕过父会话权限** —— 实测中模型发现自己没 write, + * 主动 task 委派给一个带 write 的子代理去写成了。plan/workspace 必须 + * `task deny *`,否则档位形同虚设。 + * 6. **bash 能绕过 edit 的路径限制** —— 模型用 shell 重定向写成了本该被 + * deny 的文件。所以 workspace 档必须同时管 bash,只管 edit 没用。 + * + * 另注:opencode 原生有 `plan_enter` / `plan_exit` 权限项,与我们的 plan 档 + * **撞名但语义不同**(那是它自己的计划模式开关),这里不碰它们。 + * + * @param {string} mode + * @returns {{permission: string, action: string, pattern: string}[]} + */ +export function opencodePermissions(mode) { + const m = normalizeMode(mode); + + if (m === MODE_FULL) { + // 全权:不下发任何规则,用平台自己的默认配置。 + // 显式全 allow 会覆盖掉用户在 opencode.jsonc 里的个人设置。 + return []; + } + + if (m === MODE_PLAN) { + // 只读。四项都要 deny: + // - edit 覆盖 write/edit/patch + // - bash 否则 shell 重定向就能写文件(实测过) + // - task 否则子代理能绕过(实测过) + // - webfetch/websearch 不禁:查资料是 plan 档的本职 + return [ + { permission: 'edit', action: 'deny', pattern: '*' }, + { permission: 'bash', action: 'deny', pattern: '*' }, + { permission: 'task', action: 'deny', pattern: '*' }, + ]; + } + + // workspace:目录内可写,越界问人。 + // + // deny 在前、allow 在后(findLast 胜出)。pattern `**` 是 worktree + // 相对路径,等价于「这个工作目录内的任何文件」。 + // + // bash 一律 ask 而不是 allow:命令要碰哪些文件解析不出来, + // 这就是「向更严取整」——比声明的严,不比它松。 + // + // task 仍然 deny:子代理带着自己的权限跑,父会话的边界对它无效。 + return [ + { permission: 'edit', action: 'deny', pattern: '*' }, + { permission: 'edit', action: 'allow', pattern: '**' }, + { permission: 'bash', action: 'ask', pattern: '*' }, + { permission: 'task', action: 'deny', pattern: '*' }, + ]; +} + +/** + * DSH 的沙箱模式。 + * + * 三档与 DSH 原生的三档**一一对应** —— 这不是巧合,是同一个问题的同一个答案 + * (见 `@deepseek-ai/dsh-sandbox-policy` 的 SANDBOX_MODES)。 + * + * @param {string} mode + * @returns {'read-only'|'workspace-write'|'danger-full-access'} + */ +export function dshSandboxMode(mode) { + switch (normalizeMode(mode)) { + case MODE_PLAN: return 'read-only'; + case MODE_FULL: return 'danger-full-access'; + default: return 'workspace-write'; + } +} + +/** + * DSH 的审批策略。 + * + * 关键实测:`danger-full-access` 对应 `approval: "never"`,而 + * `ApprovalService.decide()` 里 `if (effectivePolicy === "never") return "rejected"` + * **在 waterfall 之前短路** —— 于是 `approval/request` 钩子根本不触发。 + * + * 这解释了一个此前查不清的现象:本机 dsh 配了 `defaultPreset: danger-full-access`, + * 所以整个权限转邮件链路从来没在 dsh 上跑起来过。 + * + * @param {string} mode + * @returns {'ask'|'never'} + */ +export function dshApprovalPolicy(mode) { + return modeNeedsHuman(mode) ? 'ask' : 'never'; +} + +/** + * pi 侧应当守卫的工具名。 + * + * pi 只有 `tool_call` 钩子能 `{block:true}`,没有沙箱 —— 所以档位靠 + * 「拦哪些工具」表达: + * + * - plan 拦 bash/write/edit(读类工具 read/grep/find/ls 不拦) + * - workspace 拦同样三个,但 write/edit 可以查 `input.path` 判越界, + * bash 无法判断 → 一律问人(向更严取整) + * - full 不拦 + * + * @param {string} mode + * @returns {string[]} + */ +export function piGuardedTools(mode) { + return normalizeMode(mode) === MODE_FULL ? [] : ['bash', 'write', 'edit']; +} + +/** + * pi 在某档位下,某次工具调用该不该直接拒绝(不问人)。 + * + * plan 档下所有被守卫的工具都直接拒绝 —— 该档语义就是「这轮不动手」, + * 没什么可问人的,模型该把方案写在回信里。 + * + * workspace 档返回 false(走问人流程)。full 档不会进到这里。 + * + * @param {string} mode + * @returns {boolean} + */ +export function piBlocksOutright(mode) { + return normalizeMode(mode) === MODE_PLAN; +} + +/** + * 给模型看的档位说明,放进提示词。 + * + * 为什么 advisory 时措辞完全不同:那种平台(homeagent)没有任何机制阻止 + * 模型动手,所以只能把约束说成「请你遵守」而不是「你做不到」。 + * 假装它是强制的更危险 —— 模型会以为越界会被拦,于是不必自己小心。 + * + * @param {{mode: string, enforcement: string, workspace?: string}} ctx + * @returns {string} + */ +export function modeBriefing({ mode, enforcement, workspace }) { + const m = normalizeMode(mode); + const enforced = normalizeEnforcement(enforcement) === ENFORCE_NATIVE; + const dir = workspace ? `\`${workspace}\`` : '本任务的工作目录'; + + if (m === MODE_FULL) { + return '本任务权限档位:full(全权)。工具调用不需要额外授权。'; + } + + if (m === MODE_PLAN) { + return enforced + ? [ + '本任务权限档位:plan(只读)。', + '写文件、改文件、执行命令都会被平台拦下 —— 这一档只用来查与想。', + '请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。', + ].join('\n') + : [ + '本任务权限档位:plan(只读)。', + '**这个平台无法强制这一档**,所以约束靠你自己遵守:请不要写文件、改文件或执行命令。', + '请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。', + ].join('\n'); + } + + return enforced + ? [ + `本任务权限档位:workspace。可以在 ${dir} 内读写,越出该目录的写入与命令执行会先向人类请求授权。`, + '授权可能需要等待,也可能被拒绝 —— 被拒绝时请换一条不需要越界的做法,或在回信里说明需要人工执行哪一步。', + ].join('\n') + : [ + `本任务权限档位:workspace。请把改动限制在 ${dir} 内。`, + '**这个平台无法强制这一档**,所以边界靠你自己遵守:需要改该目录之外的东西时,不要直接动手,先在回信里说明。', + ].join('\n'); +} diff --git a/plugins/opencode-mail-bridge/test/permission-mode.test.mjs b/plugins/opencode-mail-bridge/test/permission-mode.test.mjs new file mode 100644 index 0000000..dada93d --- /dev/null +++ b/plugins/opencode-mail-bridge/test/permission-mode.test.mjs @@ -0,0 +1,246 @@ +/** + * lib/permission-mode.js 的测试 —— 四个平台逐字节共用。 + * + * 这些判据编码了六条 opencode 实测结论。不实测就写代码会做出「看起来对但 + * 管不住」的东西,所以每条结论都在这里钉死,改坏了会当场失败。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + MODE_PLAN, MODE_WORKSPACE, MODE_FULL, MODES, DEFAULT_MODE, + ENFORCE_NATIVE, ENFORCE_ADVISORY, + normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman, + opencodePermissions, dshSandboxMode, dshApprovalPolicy, + piGuardedTools, piBlocksOutright, modeBriefing, +} from '../lib/permission-mode.js'; + +// ─── 归一化 ─── + +test('合法档位原样返回', () => { + for (const m of MODES) assert.equal(normalizeMode(m), m); +}); + +test('非法档位 fail-closed 到默认档,不是 full', () => { + for (const bad of ['', 'FULL', 'full-access', 'workspace-write', null, undefined, 42, {}]) { + assert.equal(normalizeMode(bad), DEFAULT_MODE, `${String(bad)} 应当归到默认档`); + } + assert.notEqual(DEFAULT_MODE, MODE_FULL, '默认档不能是 full'); +}); + +test('强制力保守方向是 advisory', () => { + assert.equal(normalizeEnforcement(ENFORCE_NATIVE), ENFORCE_NATIVE); + assert.equal(normalizeEnforcement(ENFORCE_ADVISORY), ENFORCE_ADVISORY); + for (const bad of ['', 'NATIVE', 'enforced', null, undefined]) { + assert.equal(normalizeEnforcement(bad), ENFORCE_ADVISORY); + } +}); + +test('档位顺序必须是 plan < workspace < full(modeAtMost 的依据)', () => { + assert.deepEqual(MODES, [MODE_PLAN, MODE_WORKSPACE, MODE_FULL]); +}); + +// ─── modeAtMost ─── + +test('modeAtMost 取更严的一档', () => { + assert.equal(modeAtMost(MODE_PLAN, MODE_FULL), MODE_PLAN); + assert.equal(modeAtMost(MODE_FULL, MODE_PLAN), MODE_PLAN); + assert.equal(modeAtMost(MODE_WORKSPACE, MODE_FULL), MODE_WORKSPACE); + assert.equal(modeAtMost(MODE_FULL, MODE_FULL), MODE_FULL); +}); + +// Gateway 侧曾因为「未知值当最严 vs 归到默认档」两套语义而不可交换, +// 单元测试当场抓到。两边保持同一套语义。 +test('modeAtMost 可交换(脏值也不例外)', () => { + const all = [...MODES, 'garbage', '', null]; + for (const a of all) { + for (const b of all) { + assert.equal(modeAtMost(a, b), modeAtMost(b, a), + `不可交换:(${a},${b})`); + } + } +}); + +test('脏值不得把 plan 抬成更宽松的档', () => { + assert.equal(modeAtMost('garbage', MODE_PLAN), MODE_PLAN); +}); + +// ─── modeNeedsHuman ─── + +test('只有 workspace 档需要人点头', () => { + assert.equal(modeNeedsHuman(MODE_PLAN), false, 'plan 档当场拒绝,不问人'); + assert.equal(modeNeedsHuman(MODE_WORKSPACE), true); + assert.equal(modeNeedsHuman(MODE_FULL), false, 'full 档自动放行,不问人'); +}); + +test('脏档位按默认档处理,即需要人(宁可多问一次)', () => { + assert.equal(modeNeedsHuman('garbage'), true); + assert.equal(modeNeedsHuman(''), true); +}); + +// ─── opencode ─── + +test('full 档不下发规则,不覆盖用户自己的 opencode.jsonc', () => { + assert.deepEqual(opencodePermissions(MODE_FULL), []); +}); + +// 实测结论 3、5、6:edit 覆盖 write/edit/patch;task 会绕过;bash 能重定向写文件 +test('plan 档同时 deny edit / bash / task', () => { + const rules = opencodePermissions(MODE_PLAN); + const denied = new Set(rules.filter(r => r.action === 'deny').map(r => r.permission)); + assert.ok(denied.has('edit'), 'edit 覆盖 write/edit/patch,必须 deny'); + assert.ok(denied.has('bash'), 'bash 能用 shell 重定向写文件(实测过),必须 deny'); + assert.ok(denied.has('task'), 'task 子代理会绕过父会话权限(实测过),必须 deny'); +}); + +test('plan 档不禁 webfetch/websearch —— 查资料是这一档的本职', () => { + const rules = opencodePermissions(MODE_PLAN); + for (const p of ['webfetch', 'websearch', 'read', 'grep', 'glob']) { + assert.equal(rules.some(r => r.permission === p), false, `${p} 不该被禁`); + } +}); + +// 实测结论 1:findLast 胜出 → deny 必须在 allow 之前 +test('workspace 档的 edit 规则 deny 在前 allow 在后(findLast 胜出)', () => { + const rules = opencodePermissions(MODE_WORKSPACE); + const denyIdx = rules.findIndex(r => r.permission === 'edit' && r.action === 'deny'); + const allowIdx = rules.findIndex(r => r.permission === 'edit' && r.action === 'allow'); + assert.ok(denyIdx >= 0 && allowIdx >= 0, '两条 edit 规则都要在'); + assert.ok(denyIdx < allowIdx, + 'deny 必须在 allow 之前 —— 反了的话最后匹配到 deny,连允许的路径也被拒'); +}); + +// 实测结论 2:pattern 匹配 worktree 相对路径,绝对路径永远匹配不上 +test('workspace 档的 allow pattern 是相对路径而非绝对路径', () => { + const rules = opencodePermissions(MODE_WORKSPACE); + const allow = rules.find(r => r.permission === 'edit' && r.action === 'allow'); + assert.ok(allow, '要有 allow 规则'); + assert.equal(allow.pattern.startsWith('/'), false, + 'pattern 匹配的是 worktree 相对路径,绝对路径永远匹配不上(实测)'); +}); + +// 向更严取整:命令要碰哪些文件解析不出来 +test('workspace 档的 bash 是 ask 而不是 allow(向更严取整)', () => { + const rules = opencodePermissions(MODE_WORKSPACE); + const bash = rules.find(r => r.permission === 'bash'); + assert.equal(bash.action, 'ask', + 'bash 命令的影响范围无法解析,只能问人 —— 比声明的严,不比它松'); +}); + +test('workspace 档仍然 deny task(子代理带自己的权限跑)', () => { + const rules = opencodePermissions(MODE_WORKSPACE); + const task = rules.find(r => r.permission === 'task'); + assert.equal(task.action, 'deny'); +}); + +test('opencode 规则不碰 plan_enter / plan_exit(撞名但语义不同)', () => { + for (const m of MODES) { + for (const r of opencodePermissions(m)) { + assert.notEqual(r.permission, 'plan_enter'); + assert.notEqual(r.permission, 'plan_exit'); + } + } +}); + +test('脏档位按默认档下发(与 workspace 相同)', () => { + assert.deepEqual(opencodePermissions('garbage'), opencodePermissions(MODE_WORKSPACE)); +}); + +// ─── DSH ─── + +test('DSH 三档与原生沙箱一一对应', () => { + assert.equal(dshSandboxMode(MODE_PLAN), 'read-only'); + assert.equal(dshSandboxMode(MODE_WORKSPACE), 'workspace-write'); + assert.equal(dshSandboxMode(MODE_FULL), 'danger-full-access'); +}); + +// 关键实测:danger-full-access → approval:"never" → decide() 在 waterfall +// 之前短路 return "rejected",approval/request 钩子根本不触发。 +test('DSH 审批策略只在 workspace 档是 ask', () => { + assert.equal(dshApprovalPolicy(MODE_WORKSPACE), 'ask'); + assert.equal(dshApprovalPolicy(MODE_PLAN), 'never'); + assert.equal(dshApprovalPolicy(MODE_FULL), 'never'); +}); + +test('DSH 脏档位按默认档(workspace-write + ask)', () => { + assert.equal(dshSandboxMode('garbage'), 'workspace-write'); + assert.equal(dshApprovalPolicy('garbage'), 'ask'); +}); + +// ─── pi ─── + +test('pi 在 full 档不守卫任何工具', () => { + assert.deepEqual(piGuardedTools(MODE_FULL), []); +}); + +test('pi 在 plan / workspace 档守卫 bash / write / edit', () => { + for (const m of [MODE_PLAN, MODE_WORKSPACE]) { + const g = piGuardedTools(m); + assert.ok(g.includes('bash')); + assert.ok(g.includes('write')); + assert.ok(g.includes('edit')); + } +}); + +test('pi 不守卫读类工具', () => { + const g = piGuardedTools(MODE_WORKSPACE); + for (const t of ['read', 'grep', 'find', 'ls']) { + assert.equal(g.includes(t), false, `${t} 是读类工具,不该守卫`); + } +}); + +test('pi 在 plan 档直接拒绝,不走问人流程', () => { + assert.equal(piBlocksOutright(MODE_PLAN), true); + assert.equal(piBlocksOutright(MODE_WORKSPACE), false); + assert.equal(piBlocksOutright(MODE_FULL), false); +}); + +// ─── modeBriefing ─── + +test('full 档的说明不提授权', () => { + const s = modeBriefing({ mode: MODE_FULL, enforcement: ENFORCE_NATIVE }); + assert.match(s, /full/); + assert.equal(/授权/.test(s.replace('不需要额外授权', '')), false); +}); + +// advisory 与 native 措辞必须不同:假装 advisory 是强制的会让模型以为 +// 越界会被拦,于是不必自己小心 —— 那比做不到本身更危险。 +test('advisory 必须明说平台无法强制这一档', () => { + const adv = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_ADVISORY }); + const nat = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_NATIVE }); + assert.match(adv, /无法强制/); + assert.equal(/无法强制/.test(nat), false, 'native 不该说无法强制'); + assert.notEqual(adv, nat, '两种强制力的措辞必须不同'); +}); + +test('workspace 档的 advisory 版同样明说', () => { + const adv = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_ADVISORY, workspace: '/tmp/x' }); + assert.match(adv, /无法强制/); + assert.match(adv, /\/tmp\/x/, '要带上具体目录'); +}); + +test('native 的 workspace 说明要交代「授权可能被拒」', () => { + const s = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_NATIVE, workspace: '/srv/app' }); + assert.match(s, /\/srv\/app/); + assert.match(s, /拒绝/, '被拒时该怎么办必须说清楚,否则模型会反复重试'); +}); + +test('plan 档的说明必须告诉模型「把方案写在回信里」', () => { + for (const e of [ENFORCE_NATIVE, ENFORCE_ADVISORY]) { + const s = modeBriefing({ mode: MODE_PLAN, enforcement: e }); + assert.match(s, /回信/, '不给出路的话模型只会反复撞墙'); + } +}); + +test('缺 workspace 时用兜底措辞,不出现 undefined', () => { + const s = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_NATIVE }); + assert.equal(/undefined/.test(s), false); + assert.equal(/`` /.test(s), false); +}); + +test('脏输入不炸且按默认档', () => { + const s = modeBriefing({ mode: 'garbage', enforcement: 'garbage' }); + assert.match(s, /workspace/); + assert.match(s, /无法强制/, '脏强制力按 advisory 处理'); +}); diff --git a/plugins/pi-mail-bridge/lib/permission-mode.js b/plugins/pi-mail-bridge/lib/permission-mode.js new file mode 100644 index 0000000..4a0dcd4 --- /dev/null +++ b/plugins/pi-mail-bridge/lib/permission-mode.js @@ -0,0 +1,274 @@ +/** + * 权限档位 → 平台原生配置的翻译 —— 四个平台共用的判据。 + * + * ## 分工 + * + * **AgentMail 声明,平台执行,插件只翻译。** 这个模块是「翻译」那一步的 + * 唯一实现:把 `plan` / `workspace` / `full` 翻成各平台原生的沙箱/审批配置。 + * + * 为什么不让插件自己按工具名猜着拦:那会同时违反 I-1(平台原生信号是唯一 + * 真相来源)与 I-4(插件只搬运不决策),而且四个插件对「workspace 到底管 + * 什么」必然各猜一套 —— 同一封 workspace 档的邮件在 A 平台被拦、在 B 平台放行。 + * + * ## 为什么必须「向更严取整」 + * + * 平台表达不出精确档位时,一律往更严的方向走,并如实上报自己做到了什么 + * (native / advisory)。pi 就是例子:write/edit 能查 `input.path` 判断越界, + * 而 bash 命令要碰哪些文件是解析不出来的 —— 于是 workspace 档下 pi 只能 + * 「每条 bash 都问人」,比声明的更严。 + * + * 不定这条规则的后果:不同插件会朝不同方向取整,而往宽松取整是静默失效 + * (人以为收紧了,实际没有)。 + */ + +/** 只读:查资料、读代码、出方案,一个字都不许写。 */ +export const MODE_PLAN = 'plan'; +/** 本目录内可动手,越界要问人。默认档。 */ +export const MODE_WORKSPACE = 'workspace'; +/** 自动放行,不问人。 */ +export const MODE_FULL = 'full'; + +/** 全部合法档位,按宽松程度递增。顺序是 modeAtMost 的依据。 */ +export const MODES = [MODE_PLAN, MODE_WORKSPACE, MODE_FULL]; + +/** 没有显式指定时的档位。与 Gateway 的 DefaultPermissionMode 必须一致。 */ +export const DEFAULT_MODE = MODE_WORKSPACE; + +/** 平台有原生拦截点,档位被真正执行。 */ +export const ENFORCE_NATIVE = 'native'; +/** 平台没有拦截点,档位只写进提示词。 */ +export const ENFORCE_ADVISORY = 'advisory'; + +/** + * 把外部输入收敛成合法档位。 + * + * 非法值 → 默认档(**不是** full)。拼错一个档位名不该换来更大的权限。 + * 与 Gateway 的 NormalizePermissionMode 同语义。 + * + * @param {unknown} mode + * @returns {string} + */ +export function normalizeMode(mode) { + return MODES.includes(mode) ? mode : DEFAULT_MODE; +} + +/** + * 收敛强制力取值。空串或非法值 → advisory。 + * + * 保守方向是 advisory 而不是 native:不能替一个没自报过的平台宣称 + * 「档位在这里是被强制的」。 + * + * @param {unknown} e + * @returns {string} + */ +export function normalizeEnforcement(e) { + return e === ENFORCE_NATIVE || e === ENFORCE_ADVISORY ? e : ENFORCE_ADVISORY; +} + +/** + * 取两个档位里更严的那一个。 + * + * 先归一化再比较 —— 两个脏值都变成默认档,于是结果与参数顺序无关(可交换)。 + * Gateway 侧的 ModeAtMost 曾因为「modeRank 把未知值当最严、Normalize 把它 + * 归到默认档」而不可交换,单元测试当场抓到。两边保持同一套语义。 + * + * @param {string} a + * @param {string} b + * @returns {string} + */ +export function modeAtMost(a, b) { + const na = normalizeMode(a); + const nb = normalizeMode(b); + return MODES.indexOf(na) <= MODES.indexOf(nb) ? na : nb; +} + +/** + * 这一档会不会产生权限邮件(即需不需要人来点头)。 + * + * 只有 workspace 档需要人:plan 档当场拒绝、full 档自动放行,两者都不问人。 + * 插件据此决定要不要把平台的权限钩子接到 `/permission/request`。 + * + * @param {string} mode + * @returns {boolean} + */ +export function modeNeedsHuman(mode) { + return normalizeMode(mode) === MODE_WORKSPACE; +} + +/** + * opencode 的 permission 规则数组。 + * + * ## 六条实测结论(不实测就会做出「看起来对但管不住」的东西) + * + * 1. **规则是 findLast 胜出**(二进制里 + * `findLast((z)=>g.match(j,z.permission)&&g.match(J,z.pattern))`) + * → deny 必须放前面、allow 放后面。反了的话连允许的路径也被拒。 + * 2. **pattern 匹配 worktree 相对路径**(`patterns:[relative(y.worktree,file)]`) + * → 写 `/tmp/**` 这种绝对 pattern 永远匹配不上(`/tmp/x` 相对 + * `/home/program/agentmail` 是 `../../../tmp/x`)。所以 workspace 档用 `**`。 + * 3. **write / edit / patch 共用 `edit` 一个权限名** + * (`if(A==="write"||A==="edit"||A==="patch"){G.edit=I}`)。 + * 4. **全 deny 让工具从模型清单里消失**(模型自述「I don't have a bash tool + * available in this session」),部分 deny 则工具保留、越界调用才报错。 + * plan 档用前者更好:模型不会浪费轮次去试。 + * 5. **task(子代理)能绕过父会话权限** —— 实测中模型发现自己没 write, + * 主动 task 委派给一个带 write 的子代理去写成了。plan/workspace 必须 + * `task deny *`,否则档位形同虚设。 + * 6. **bash 能绕过 edit 的路径限制** —— 模型用 shell 重定向写成了本该被 + * deny 的文件。所以 workspace 档必须同时管 bash,只管 edit 没用。 + * + * 另注:opencode 原生有 `plan_enter` / `plan_exit` 权限项,与我们的 plan 档 + * **撞名但语义不同**(那是它自己的计划模式开关),这里不碰它们。 + * + * @param {string} mode + * @returns {{permission: string, action: string, pattern: string}[]} + */ +export function opencodePermissions(mode) { + const m = normalizeMode(mode); + + if (m === MODE_FULL) { + // 全权:不下发任何规则,用平台自己的默认配置。 + // 显式全 allow 会覆盖掉用户在 opencode.jsonc 里的个人设置。 + return []; + } + + if (m === MODE_PLAN) { + // 只读。四项都要 deny: + // - edit 覆盖 write/edit/patch + // - bash 否则 shell 重定向就能写文件(实测过) + // - task 否则子代理能绕过(实测过) + // - webfetch/websearch 不禁:查资料是 plan 档的本职 + return [ + { permission: 'edit', action: 'deny', pattern: '*' }, + { permission: 'bash', action: 'deny', pattern: '*' }, + { permission: 'task', action: 'deny', pattern: '*' }, + ]; + } + + // workspace:目录内可写,越界问人。 + // + // deny 在前、allow 在后(findLast 胜出)。pattern `**` 是 worktree + // 相对路径,等价于「这个工作目录内的任何文件」。 + // + // bash 一律 ask 而不是 allow:命令要碰哪些文件解析不出来, + // 这就是「向更严取整」——比声明的严,不比它松。 + // + // task 仍然 deny:子代理带着自己的权限跑,父会话的边界对它无效。 + return [ + { permission: 'edit', action: 'deny', pattern: '*' }, + { permission: 'edit', action: 'allow', pattern: '**' }, + { permission: 'bash', action: 'ask', pattern: '*' }, + { permission: 'task', action: 'deny', pattern: '*' }, + ]; +} + +/** + * DSH 的沙箱模式。 + * + * 三档与 DSH 原生的三档**一一对应** —— 这不是巧合,是同一个问题的同一个答案 + * (见 `@deepseek-ai/dsh-sandbox-policy` 的 SANDBOX_MODES)。 + * + * @param {string} mode + * @returns {'read-only'|'workspace-write'|'danger-full-access'} + */ +export function dshSandboxMode(mode) { + switch (normalizeMode(mode)) { + case MODE_PLAN: return 'read-only'; + case MODE_FULL: return 'danger-full-access'; + default: return 'workspace-write'; + } +} + +/** + * DSH 的审批策略。 + * + * 关键实测:`danger-full-access` 对应 `approval: "never"`,而 + * `ApprovalService.decide()` 里 `if (effectivePolicy === "never") return "rejected"` + * **在 waterfall 之前短路** —— 于是 `approval/request` 钩子根本不触发。 + * + * 这解释了一个此前查不清的现象:本机 dsh 配了 `defaultPreset: danger-full-access`, + * 所以整个权限转邮件链路从来没在 dsh 上跑起来过。 + * + * @param {string} mode + * @returns {'ask'|'never'} + */ +export function dshApprovalPolicy(mode) { + return modeNeedsHuman(mode) ? 'ask' : 'never'; +} + +/** + * pi 侧应当守卫的工具名。 + * + * pi 只有 `tool_call` 钩子能 `{block:true}`,没有沙箱 —— 所以档位靠 + * 「拦哪些工具」表达: + * + * - plan 拦 bash/write/edit(读类工具 read/grep/find/ls 不拦) + * - workspace 拦同样三个,但 write/edit 可以查 `input.path` 判越界, + * bash 无法判断 → 一律问人(向更严取整) + * - full 不拦 + * + * @param {string} mode + * @returns {string[]} + */ +export function piGuardedTools(mode) { + return normalizeMode(mode) === MODE_FULL ? [] : ['bash', 'write', 'edit']; +} + +/** + * pi 在某档位下,某次工具调用该不该直接拒绝(不问人)。 + * + * plan 档下所有被守卫的工具都直接拒绝 —— 该档语义就是「这轮不动手」, + * 没什么可问人的,模型该把方案写在回信里。 + * + * workspace 档返回 false(走问人流程)。full 档不会进到这里。 + * + * @param {string} mode + * @returns {boolean} + */ +export function piBlocksOutright(mode) { + return normalizeMode(mode) === MODE_PLAN; +} + +/** + * 给模型看的档位说明,放进提示词。 + * + * 为什么 advisory 时措辞完全不同:那种平台(homeagent)没有任何机制阻止 + * 模型动手,所以只能把约束说成「请你遵守」而不是「你做不到」。 + * 假装它是强制的更危险 —— 模型会以为越界会被拦,于是不必自己小心。 + * + * @param {{mode: string, enforcement: string, workspace?: string}} ctx + * @returns {string} + */ +export function modeBriefing({ mode, enforcement, workspace }) { + const m = normalizeMode(mode); + const enforced = normalizeEnforcement(enforcement) === ENFORCE_NATIVE; + const dir = workspace ? `\`${workspace}\`` : '本任务的工作目录'; + + if (m === MODE_FULL) { + return '本任务权限档位:full(全权)。工具调用不需要额外授权。'; + } + + if (m === MODE_PLAN) { + return enforced + ? [ + '本任务权限档位:plan(只读)。', + '写文件、改文件、执行命令都会被平台拦下 —— 这一档只用来查与想。', + '请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。', + ].join('\n') + : [ + '本任务权限档位:plan(只读)。', + '**这个平台无法强制这一档**,所以约束靠你自己遵守:请不要写文件、改文件或执行命令。', + '请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。', + ].join('\n'); + } + + return enforced + ? [ + `本任务权限档位:workspace。可以在 ${dir} 内读写,越出该目录的写入与命令执行会先向人类请求授权。`, + '授权可能需要等待,也可能被拒绝 —— 被拒绝时请换一条不需要越界的做法,或在回信里说明需要人工执行哪一步。', + ].join('\n') + : [ + `本任务权限档位:workspace。请把改动限制在 ${dir} 内。`, + '**这个平台无法强制这一档**,所以边界靠你自己遵守:需要改该目录之外的东西时,不要直接动手,先在回信里说明。', + ].join('\n'); +} diff --git a/plugins/pi-mail-bridge/src/index.mjs b/plugins/pi-mail-bridge/src/index.mjs index 9a962fa..eb083ae 100644 --- a/plugins/pi-mail-bridge/src/index.mjs +++ b/plugins/pi-mail-bridge/src/index.mjs @@ -36,11 +36,13 @@ import { mkdirSync, openSync, closeSync, unlinkSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import { ModelRuntime } from '@earendil-works/pi-coding-agent'; +import { ModelRuntime, getAgentDir } from '@earendil-works/pi-coding-agent'; import { GatewayClient, readLocalKey, generateLocalKey, saveConfig } from './gateway.mjs'; import { createWorkerPool } from './pool.mjs'; +import { createSessionScanner } from './session-scan.mjs'; import { describeError } from './turn.mjs'; +import { BoundedSet, MAX_TRACKED_MAILS } from '../lib/bounded.js'; import { snapshotPiModels } from '../lib/model-scope.js'; import { snapshotPiSessions } from '../lib/session-snapshot.js'; import { selectCatchup } from '../lib/catchup.js'; @@ -91,12 +93,18 @@ const log = (...args) => console.error('[pi-mail-bridge]', ...args); // 会话映射、权限授权、命名指纹都下沉到 pool 里按邮件会话存 —— 主进程不再持有 // AgentSession 对象(那东西跨不了进程边界)。 -const deliveredMails = new Set(); // 已投过的 mail_id(SSE 与补拉共用,B-7.3) +// 已投过的 mail_id(SSE 与补拉共用,B-7.3)。 +// +// 有界:桥是守护进程,跑几十天下来这里会攒下每一封处理过的邮件 id 而永远 +// 没有出口。淘汰是安全的 —— 它防的两种重复(心跳与 SSE 建连之间的窗口、 +// SSE 断线重放)都发生在秒到分钟级,几千封之前的 id 不可能再来。 +const deliveredMails = new BoundedSet(MAX_TRACKED_MAILS); let allowedModels = []; let modelRuntime = null; let client = null; let pool = null; +let sessionScanner = null; let heartbeatTimer = null; let shuttingDown = false; @@ -172,14 +180,15 @@ function handlePermissionDecision(data) { async function reportSessions() { try { - const { SessionManager } = await import('@earendil-works/pi-coding-agent'); - // 不传参数:`listAll(dir)` 把字符串当**自定义会话目录**,传 getAgentDir() - // 会去 ~/.pi/agent 下直接找 .jsonl(那里没有),得到空列表。 - // 不传时它用默认的 ~/.pi/agent/sessions,逐个 cwd 子目录扫。 + // **不用 `SessionManager.listAll()`**:它为了拿 id/cwd/name/modified 四个 + // 字段,把 ~/.pi/agent/sessions 下每个 .jsonl 的每一行都读进来并 JSON.parse, + // 还把所有消息正文拼成一个 allMessagesText 大字符串。本机实测(115 个文件 / + // 145MB)单次 1431ms、堆里瞬时 240MB —— 而这 282MB 每 30 秒分配一次随即 + // 变成垃圾,且那 1.4 秒是同步解析,跑在事件循环上(SSE 读循环那期间停着)。 // - // 用 listAll 而不是 list(cwd):桥的进程 cwd 与会话 cwd 无关, - // 按前者过滤会漏掉所有真正在干活的会话。 - const all = await SessionManager.listAll(); + // sessionScanner 只读 header 的首行 + 增量扫尾部找 session_info: + // 稳态下未变化的文件一个字节都不读(实测 3ms / 0 字节)。 + const all = await sessionScanner.scan(); const driven = pool.mailDrivenIDs(); return snapshotPiSessions(all, (id) => driven.has(id)); } catch (e) { @@ -255,6 +264,16 @@ async function main() { const runtimeErr = modelRuntime.getError?.(); if (runtimeErr) log(`模型运行时告警: ${runtimeErr}`); + // 会话目录扫描器。**必须建一次并复用** —— 它的省内存全靠跨拍存活的 + // size 缓存(稳态下未变化的文件一个字节都不读)。每拍新建一个等于 + // 每拍都冷启动,退回 listAll 那种全量读的开销。 + // + // 路径自己拼而不是 import getSessionsDir:SDK 只导出 getAgentDir, + // getSessionsDir 是内部函数(dist/config.js 里 `join(getAgentDir(), "sessions")`)。 + sessionScanner = createSessionScanner({ + sessionsDir: join(getAgentDir(), 'sessions'), + }); + // 工作进程池。config() 每次派活时取一次 —— allowedModels 随心跳变, // 取快照会让 worker 用上一轮的模型范围。 pool = createWorkerPool({ @@ -339,6 +358,14 @@ function handleSSEEvent(type, data) { handlePermissionDecision(data); return; } + if (type === 'session_archived') { + // 会话归档 = 那条会话再也不会收信(别名 404),pool 里的 sessionState + // 可以确定性地清掉,不必等上限淘汰去猜。 + if (pool?.forget(data?.session_id || '')) { + log(`会话 ${data.session_id} 已归档,清除本地状态`); + } + return; + } if (type !== 'new_mail') return; if (data?.role && data.role !== 'to' && data.role !== 'cc') return; const id = data?.mail_id; diff --git a/plugins/pi-mail-bridge/src/pool.mjs b/plugins/pi-mail-bridge/src/pool.mjs index 378bf1b..cefd50f 100644 --- a/plugins/pi-mail-bridge/src/pool.mjs +++ b/plugins/pi-mail-bridge/src/pool.mjs @@ -47,11 +47,20 @@ * `{type:'name_synced', signature}` 命名指纹,防下一个 worker 重复 sync * `{type:'reconfigure', url, agentKey}` connect_to_server 换了坐标 * `{type:'done', ok, error}` 这封处理完了 + * + * # 内存边界 + * + * `sessionState` 与 `retired` 是**跨 worker 长期存活**的两张表,键来自邮件会话流 + * —— 会话数随时间单调增长。两条出口:`forget()`(会话归档,确定性)与 + * `BoundedMap`/`BoundedSet` 的上限淘汰(兜底)。缺了它们这里就是常驻进程里 + * 一处只增不减的结构。 */ import { fork } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { BoundedMap, BoundedSet, MAX_TRACKED_SESSIONS } from '../lib/bounded.js'; + const WORKER_PATH = fileURLToPath(new URL('./worker.mjs', import.meta.url)); /** @@ -81,14 +90,14 @@ export function createWorkerPool({ * 这是 worker 一封一进程之后仍需在主进程留存的全部东西 —— 下一封邮件靠 * sessionFile 接着谈,靠 grants 不重复问已经「一直同意」过的工具。 */ - const sessionState = new Map(); + const sessionState = new BoundedMap(MAX_TRACKED_SESSIONS); /** * 被模型降级换掉的旧 pi 会话 id。 * * 仍要计入 mail_driven:它们已经参与过邮件往来,而磁盘上的会话文件 * 不会因为换模型而消失 —— 心跳快照仍会上报它们。 */ - const retired = new Set(); + const retired = new BoundedSet(MAX_TRACKED_SESSIONS); let stopped = false; /** @@ -235,12 +244,38 @@ export function createWorkerPool({ /** 这条邮件会话有 worker 在跑吗(B-4.2 判断降级路径用)。 */ const hasSession = (mailSessionID) => sessionState.has(mailSessionID); + /** + * 忘掉一条已归档会话的全部状态。 + * + * 归档是个**确定性的终点**:归档后那条会话不可寻址(别名 404),也不会再有 + * 新邮件投进来。把它的 sessionState 留着只是占内存,而上限淘汰是「猜」—— + * 能确切知道该删的时候就不该依赖猜。 + * + * 正在跑的 worker **不杀**:归档不是中止指令,模型可能正在写文件;它自己跑完 + * 就退,只是那一轮的回信会因为会话已归档而被服务端拦下。 + * + * @param {string} mailSessionID + * @returns {boolean} 是否真的删掉了东西 + */ + function forget(mailSessionID) { + if (!mailSessionID) return false; + // peek 而不是 get:这是清理路径,不该把即将删掉的条目刷成「最近活跃」。 + const state = sessionState.peek(mailSessionID); + // 已归档会话的 pi 会话 id 也不必再报 mail_driven:那个标记的用途是让人在 + // 补全里看到「这条在跑邮件」,而已归档的会话不在补全候选里。 + if (state?.piSessionId) retired.delete(state.piSessionId); + return sessionState.delete(mailSessionID); + } + /** * 邮件驱动过的 pi 会话 id,喂给心跳快照的 `mail_driven` 标记。 * * 不随 worker 退出而清:worker 退了不代表那条会话不再参与邮件往来 —— * 下一封邮件还会接着谈,而人在补全里需要看到它带着这个标记。 - * 重启丢是已知取舍(契约第六节)。 + * 重启丢是已知取舍(契约第六节);确定性的清理时机是归档(见 forget)。 + * + * 返回普通 Set 而不是 BoundedSet:调用方只拿它做一轮 has 查询就丢, + * 没有长期持有,不需要上界。 */ const mailDrivenIDs = () => { const out = new Set(retired); @@ -268,10 +303,13 @@ export function createWorkerPool({ running: running.size, queued: queue.length, sessions: sessionState.size, + // 淘汰计数持续增长说明上限设得太小 —— 那意味着会话上下文在被白白丢掉, + // 而症状是「这条会话怎么突然不记得前面说过什么了」。 + evictedSessions: sessionState.evicted, workers: [...running.values()].map((e) => ({ pid: e.child.pid, mailID: e.mailID, ageMs: Date.now() - e.startedAt, })), }); - return { submit, routePermission, hasSession, mailDrivenIDs, stop, stats }; + return { submit, routePermission, hasSession, forget, mailDrivenIDs, stop, stats }; } diff --git a/plugins/pi-mail-bridge/src/turn.mjs b/plugins/pi-mail-bridge/src/turn.mjs index 5787fc9..f23cc86 100644 --- a/plugins/pi-mail-bridge/src/turn.mjs +++ b/plugins/pi-mail-bridge/src/turn.mjs @@ -9,6 +9,7 @@ */ import { replyInstruction, inboundHeadline } from '../lib/relay-policy.js'; +import { clampRelayKey } from '../lib/relay-key.js'; /** 去掉已有的 Re: 前缀,避免 Re: Re: Re: 叠加。 */ export function stripRe(subject) { @@ -180,7 +181,10 @@ export function buildMailPrompt({ agentName, data, kind, reused }) { * @returns {string} */ export function relayKeyFor(piSessionId, leafId) { - return `${piSessionId || 'unknown'}:${leafId || 'noleaf'}`; + // clampRelayKey 收尾:会话 id 与 leafId 平常都短,但不能假定—— + // 同一个假定在权限询问那边已经坏过一次(toolCallId 被拼了思考签名, + // 437 ~ 13601 字节)。超限时才改写,所以合规的键不受影响。 + return clampRelayKey(`${piSessionId || 'unknown'}:${leafId || 'noleaf'}`); } /** diff --git a/plugins/pi-mail-bridge/test/permission-mode.test.mjs b/plugins/pi-mail-bridge/test/permission-mode.test.mjs new file mode 100644 index 0000000..dada93d --- /dev/null +++ b/plugins/pi-mail-bridge/test/permission-mode.test.mjs @@ -0,0 +1,246 @@ +/** + * lib/permission-mode.js 的测试 —— 四个平台逐字节共用。 + * + * 这些判据编码了六条 opencode 实测结论。不实测就写代码会做出「看起来对但 + * 管不住」的东西,所以每条结论都在这里钉死,改坏了会当场失败。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + MODE_PLAN, MODE_WORKSPACE, MODE_FULL, MODES, DEFAULT_MODE, + ENFORCE_NATIVE, ENFORCE_ADVISORY, + normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman, + opencodePermissions, dshSandboxMode, dshApprovalPolicy, + piGuardedTools, piBlocksOutright, modeBriefing, +} from '../lib/permission-mode.js'; + +// ─── 归一化 ─── + +test('合法档位原样返回', () => { + for (const m of MODES) assert.equal(normalizeMode(m), m); +}); + +test('非法档位 fail-closed 到默认档,不是 full', () => { + for (const bad of ['', 'FULL', 'full-access', 'workspace-write', null, undefined, 42, {}]) { + assert.equal(normalizeMode(bad), DEFAULT_MODE, `${String(bad)} 应当归到默认档`); + } + assert.notEqual(DEFAULT_MODE, MODE_FULL, '默认档不能是 full'); +}); + +test('强制力保守方向是 advisory', () => { + assert.equal(normalizeEnforcement(ENFORCE_NATIVE), ENFORCE_NATIVE); + assert.equal(normalizeEnforcement(ENFORCE_ADVISORY), ENFORCE_ADVISORY); + for (const bad of ['', 'NATIVE', 'enforced', null, undefined]) { + assert.equal(normalizeEnforcement(bad), ENFORCE_ADVISORY); + } +}); + +test('档位顺序必须是 plan < workspace < full(modeAtMost 的依据)', () => { + assert.deepEqual(MODES, [MODE_PLAN, MODE_WORKSPACE, MODE_FULL]); +}); + +// ─── modeAtMost ─── + +test('modeAtMost 取更严的一档', () => { + assert.equal(modeAtMost(MODE_PLAN, MODE_FULL), MODE_PLAN); + assert.equal(modeAtMost(MODE_FULL, MODE_PLAN), MODE_PLAN); + assert.equal(modeAtMost(MODE_WORKSPACE, MODE_FULL), MODE_WORKSPACE); + assert.equal(modeAtMost(MODE_FULL, MODE_FULL), MODE_FULL); +}); + +// Gateway 侧曾因为「未知值当最严 vs 归到默认档」两套语义而不可交换, +// 单元测试当场抓到。两边保持同一套语义。 +test('modeAtMost 可交换(脏值也不例外)', () => { + const all = [...MODES, 'garbage', '', null]; + for (const a of all) { + for (const b of all) { + assert.equal(modeAtMost(a, b), modeAtMost(b, a), + `不可交换:(${a},${b})`); + } + } +}); + +test('脏值不得把 plan 抬成更宽松的档', () => { + assert.equal(modeAtMost('garbage', MODE_PLAN), MODE_PLAN); +}); + +// ─── modeNeedsHuman ─── + +test('只有 workspace 档需要人点头', () => { + assert.equal(modeNeedsHuman(MODE_PLAN), false, 'plan 档当场拒绝,不问人'); + assert.equal(modeNeedsHuman(MODE_WORKSPACE), true); + assert.equal(modeNeedsHuman(MODE_FULL), false, 'full 档自动放行,不问人'); +}); + +test('脏档位按默认档处理,即需要人(宁可多问一次)', () => { + assert.equal(modeNeedsHuman('garbage'), true); + assert.equal(modeNeedsHuman(''), true); +}); + +// ─── opencode ─── + +test('full 档不下发规则,不覆盖用户自己的 opencode.jsonc', () => { + assert.deepEqual(opencodePermissions(MODE_FULL), []); +}); + +// 实测结论 3、5、6:edit 覆盖 write/edit/patch;task 会绕过;bash 能重定向写文件 +test('plan 档同时 deny edit / bash / task', () => { + const rules = opencodePermissions(MODE_PLAN); + const denied = new Set(rules.filter(r => r.action === 'deny').map(r => r.permission)); + assert.ok(denied.has('edit'), 'edit 覆盖 write/edit/patch,必须 deny'); + assert.ok(denied.has('bash'), 'bash 能用 shell 重定向写文件(实测过),必须 deny'); + assert.ok(denied.has('task'), 'task 子代理会绕过父会话权限(实测过),必须 deny'); +}); + +test('plan 档不禁 webfetch/websearch —— 查资料是这一档的本职', () => { + const rules = opencodePermissions(MODE_PLAN); + for (const p of ['webfetch', 'websearch', 'read', 'grep', 'glob']) { + assert.equal(rules.some(r => r.permission === p), false, `${p} 不该被禁`); + } +}); + +// 实测结论 1:findLast 胜出 → deny 必须在 allow 之前 +test('workspace 档的 edit 规则 deny 在前 allow 在后(findLast 胜出)', () => { + const rules = opencodePermissions(MODE_WORKSPACE); + const denyIdx = rules.findIndex(r => r.permission === 'edit' && r.action === 'deny'); + const allowIdx = rules.findIndex(r => r.permission === 'edit' && r.action === 'allow'); + assert.ok(denyIdx >= 0 && allowIdx >= 0, '两条 edit 规则都要在'); + assert.ok(denyIdx < allowIdx, + 'deny 必须在 allow 之前 —— 反了的话最后匹配到 deny,连允许的路径也被拒'); +}); + +// 实测结论 2:pattern 匹配 worktree 相对路径,绝对路径永远匹配不上 +test('workspace 档的 allow pattern 是相对路径而非绝对路径', () => { + const rules = opencodePermissions(MODE_WORKSPACE); + const allow = rules.find(r => r.permission === 'edit' && r.action === 'allow'); + assert.ok(allow, '要有 allow 规则'); + assert.equal(allow.pattern.startsWith('/'), false, + 'pattern 匹配的是 worktree 相对路径,绝对路径永远匹配不上(实测)'); +}); + +// 向更严取整:命令要碰哪些文件解析不出来 +test('workspace 档的 bash 是 ask 而不是 allow(向更严取整)', () => { + const rules = opencodePermissions(MODE_WORKSPACE); + const bash = rules.find(r => r.permission === 'bash'); + assert.equal(bash.action, 'ask', + 'bash 命令的影响范围无法解析,只能问人 —— 比声明的严,不比它松'); +}); + +test('workspace 档仍然 deny task(子代理带自己的权限跑)', () => { + const rules = opencodePermissions(MODE_WORKSPACE); + const task = rules.find(r => r.permission === 'task'); + assert.equal(task.action, 'deny'); +}); + +test('opencode 规则不碰 plan_enter / plan_exit(撞名但语义不同)', () => { + for (const m of MODES) { + for (const r of opencodePermissions(m)) { + assert.notEqual(r.permission, 'plan_enter'); + assert.notEqual(r.permission, 'plan_exit'); + } + } +}); + +test('脏档位按默认档下发(与 workspace 相同)', () => { + assert.deepEqual(opencodePermissions('garbage'), opencodePermissions(MODE_WORKSPACE)); +}); + +// ─── DSH ─── + +test('DSH 三档与原生沙箱一一对应', () => { + assert.equal(dshSandboxMode(MODE_PLAN), 'read-only'); + assert.equal(dshSandboxMode(MODE_WORKSPACE), 'workspace-write'); + assert.equal(dshSandboxMode(MODE_FULL), 'danger-full-access'); +}); + +// 关键实测:danger-full-access → approval:"never" → decide() 在 waterfall +// 之前短路 return "rejected",approval/request 钩子根本不触发。 +test('DSH 审批策略只在 workspace 档是 ask', () => { + assert.equal(dshApprovalPolicy(MODE_WORKSPACE), 'ask'); + assert.equal(dshApprovalPolicy(MODE_PLAN), 'never'); + assert.equal(dshApprovalPolicy(MODE_FULL), 'never'); +}); + +test('DSH 脏档位按默认档(workspace-write + ask)', () => { + assert.equal(dshSandboxMode('garbage'), 'workspace-write'); + assert.equal(dshApprovalPolicy('garbage'), 'ask'); +}); + +// ─── pi ─── + +test('pi 在 full 档不守卫任何工具', () => { + assert.deepEqual(piGuardedTools(MODE_FULL), []); +}); + +test('pi 在 plan / workspace 档守卫 bash / write / edit', () => { + for (const m of [MODE_PLAN, MODE_WORKSPACE]) { + const g = piGuardedTools(m); + assert.ok(g.includes('bash')); + assert.ok(g.includes('write')); + assert.ok(g.includes('edit')); + } +}); + +test('pi 不守卫读类工具', () => { + const g = piGuardedTools(MODE_WORKSPACE); + for (const t of ['read', 'grep', 'find', 'ls']) { + assert.equal(g.includes(t), false, `${t} 是读类工具,不该守卫`); + } +}); + +test('pi 在 plan 档直接拒绝,不走问人流程', () => { + assert.equal(piBlocksOutright(MODE_PLAN), true); + assert.equal(piBlocksOutright(MODE_WORKSPACE), false); + assert.equal(piBlocksOutright(MODE_FULL), false); +}); + +// ─── modeBriefing ─── + +test('full 档的说明不提授权', () => { + const s = modeBriefing({ mode: MODE_FULL, enforcement: ENFORCE_NATIVE }); + assert.match(s, /full/); + assert.equal(/授权/.test(s.replace('不需要额外授权', '')), false); +}); + +// advisory 与 native 措辞必须不同:假装 advisory 是强制的会让模型以为 +// 越界会被拦,于是不必自己小心 —— 那比做不到本身更危险。 +test('advisory 必须明说平台无法强制这一档', () => { + const adv = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_ADVISORY }); + const nat = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_NATIVE }); + assert.match(adv, /无法强制/); + assert.equal(/无法强制/.test(nat), false, 'native 不该说无法强制'); + assert.notEqual(adv, nat, '两种强制力的措辞必须不同'); +}); + +test('workspace 档的 advisory 版同样明说', () => { + const adv = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_ADVISORY, workspace: '/tmp/x' }); + assert.match(adv, /无法强制/); + assert.match(adv, /\/tmp\/x/, '要带上具体目录'); +}); + +test('native 的 workspace 说明要交代「授权可能被拒」', () => { + const s = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_NATIVE, workspace: '/srv/app' }); + assert.match(s, /\/srv\/app/); + assert.match(s, /拒绝/, '被拒时该怎么办必须说清楚,否则模型会反复重试'); +}); + +test('plan 档的说明必须告诉模型「把方案写在回信里」', () => { + for (const e of [ENFORCE_NATIVE, ENFORCE_ADVISORY]) { + const s = modeBriefing({ mode: MODE_PLAN, enforcement: e }); + assert.match(s, /回信/, '不给出路的话模型只会反复撞墙'); + } +}); + +test('缺 workspace 时用兜底措辞,不出现 undefined', () => { + const s = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_NATIVE }); + assert.equal(/undefined/.test(s), false); + assert.equal(/`` /.test(s), false); +}); + +test('脏输入不炸且按默认档', () => { + const s = modeBriefing({ mode: 'garbage', enforcement: 'garbage' }); + assert.match(s, /workspace/); + assert.match(s, /无法强制/, '脏强制力按 advisory 处理'); +});