Commit Graph

6 Commits

Author SHA1 Message Date
344f970353 修死信黑洞:发信前校验收件人可达性
## 事故

发给已彻底删除的 Agent 返回 200:邮件入库、分配 20 个来回预算、
建好会话,而那一端永远不会有人读。发件人看到 200 和一个 session_id,
以为送出去了。

实测(修复前):
  POST /me/mail/send to=remotebot@/tmp  → 200
  mail_id 53e4c9ea… session dc8a41c3… budget_max 20

remotebot 的 agents 行在本会话早前已被 DELETE /admin/agents 删掉。

根因:三个发信入口的检查链只有「地址语法 / 调用权限 / 会话别名」,
从不问「这个名字存在吗」。`AgentDisabled` 那个函数只在注册路径被调用,
发信路径压根不查——它的注释甚至写着「Agent 不存在时返回 false」。

静默丢件比报错严重:报错能立刻改,静默丢件要等对方追问才发现。
这与之前修过的「relay_key 400 被当暂时失败导致静默挂死」同类。

## 修法

`repo.RecipientDeliverable(ctx, name)` 作为唯一判据:
- 人类用户 → 放行(人的收件箱一直在,不受 Agent 停用影响)
- Agent 在册且未停用 → 放行
- Agent 不存在 → ErrRecipientUnknown → 404
- Agent 已停用 → ErrRecipientDisabled → 409

`handler.checkDeliverable` 把它接到三个入口,**收件人与抄送位一起查**:
不查 cc 的话抄送位就成了绕过口,而且因为不是主收件人更不容易被发现。

- me.go   MeSendMail   (人类发信)
- mail.go SendMail     (Agent 发信)
- forward.go ForwardMail(人与 Agent 两条转发路径共用)

停用选择「当场拒收」而非「入库等恢复后补投」:停用的语义就是这个
Agent 现在不干活,让发件人以为信已送达更坏——它会照常等回信。

## 测试

`internal/repo/deliverable_test.go` 8 例:人类 / 在线 Agent / 不存在 /
删除后不可达 / 停用 409 / 恢复后重新可达 / 空名放行 /
同名人类优先于已停用 Agent。

负向对照:让 RecipientDeliverable 无条件 return nil(还原事故前行为),
UnknownName、AfterDelete、Disabled 三例如期失败。

## 线上验证

  发给已删除 remotebot        → 404「收件人不存在」
  发给在线 pi                → 200,pi 回信「可达性 OK」
  cc 位放已删除 remotebot     → 404(绕过口已封)
  停用 pi 后发信             → 409「已被管理员停用」
  恢复 pi 后发信             → 200

## 顺带

- 部署改用 sqlite3 .backup + install -m 0755(原子 rename,不写坏
  运行中进程镜像),来自 git-release-discipline skill 的运维纪律
- 清理本会话测试残留:误登记的 opencode 密钥、remotebot 两把残留密钥、
  死信测试邮件与会话
2026-09-05 09:58:36 +08:00
784192d8c4 Agent→Agent 不自动转发 + 提示词区分新活/回复/补投
## 设计规则:Agent 之间不自动转发

自动转发存在的理由是「人不该等模型记得调 send_mail」—— 收件方是人时这是
纯收益。**收件方是另一个 Agent 时这个理由不成立,而且有害**:双方的插件都
会自动回一封,于是两个模型都以为「我只要把话说完就行」,实际在持续互相唤醒。
生产实测 pi 与 dsh 客套 6 轮直到撞上连续 relay 跳数上限。

规则现在写死在共用模块 `lib/relay-policy.js`(三平台逐字节相同):
- `autoRelayDecision` — 插件该不该替模型开口
- `replyInstruction` — 提示词怎么跟模型说(人类 vs Agent 各一套措辞)
- `inboundHeadline` — 进来的是新活、回复、还是补投

`from_human` 缺失时保守按 Agent 处理:宁可让模型多调一次 send_mail,
也不能承诺一个不会发生的自动回信让发件方白等。

## Gateway 侧:`in_reply_to` + `from_human`

- `notify.Mail` 新增 `ParentMailID`(非空 = 这是对收件方某封信的回复)
- `notify.Mail` 新增 `FromHuman`(走 `repo.IsHumanUser`)
- SSE payload 里叫 `in_reply_to` / `from_human`
- 四个调用点全部传入:handler/mail(转发后产出的邮件,parentMailID 从
  resolveTarget 取)、handler/me(同理)、handler/forward(传空串,
  因为对收件方而言那封原邮件不在它的线索里)、scheduler/calendar(传空串)
- `ListInbox` 的 SELECT 加 `EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name)`
  → `models.Mail.FromHuman`,让补拉路径也有这个信号

## 提示词分流

三种处境各一套标题:
- 新活(人类):「你收到一封新邮件」+ 「回信不用你自己发:…」
- 新活(Agent):「你收到一封新邮件(对方是一个 Agent)」+ 「插件不会替你
  回信。需要回复时你必须自己调 send_mail…请先判断是否真的需要回复」
- 回复到了:「你上一封信的回复到了。**这不是新任务**。」
- 补投:在标题里说明「离线期间积压」

## homeagent 特殊处理

Go 插件不能直接 `import('../lib/relay-policy.js')`,因此新增 `relay_policy.go`
(Go 对应物)+ `relay_policy_test.go`(11 例,逐条对齐 Node 侧判据)。
`sseLoop` / `catchUp` 两条路径都接上。

## `mailEvent` 命名类型

homeagent 的 SSE 事件解析 / handleNewMail / handlePermissionDecision 三处
原来各写一遍匿名 struct(字段列表几乎相同),加 `from_human` / `in_reply_to`
时漏改一处 → 编译报错但错误信息是两串几乎相同的字段列表,极难定位。
提成 `mailEvent` 命名类型:一处改、三处跟着走。

## 测试

- `lib/relay-policy.test.mjs`(Node)16 例:含「replyInstruction 与
  autoRelayDecision 不得互相矛盾」「Agent 来信的标题要点名且回复要明确反对」
- `relay_policy_test.go`(Go)11 例:逐条对齐 Node 侧
- `turn.test.mjs` +3 例:from_human 缺失时按 Agent 处理 / Agent 来信时改口 /
  回复到了说「不是新任务」;删掉两条旧的「必定自动转发」断言
- 共用脚本 `check-shared-libs.sh` +1 个文件(relay-policy)
- pi 288 / dsh 241 / opencode 217 / homeagent 14 / gateway 8 包全绿
2026-09-04 23:52:52 +08:00
e6fd2fafdc feat: agent 邮件寻址能力全面补齐 + .new 别名替换
## 别名替换(让 .new 邮件可寻址)

repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调

notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new

FormatAddress(name,path,session) 空 path 也必须留 @ 与 .

## Agent 侧寻址发现(五个只读端点)

handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做

repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)

repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空

## 共用模块(三插件逐字节相同)

lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
                  renderParticipants/renderContacts/renderThread

lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
  - selfName 参数(兼容旧调用不传的情况)

check-shared-libs.sh 纳入 addressing + discovery

## 插件侧

opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)

dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)

## 测试

repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
  → dsh 用 session_participants 取到地址 → send_mail 给 opencode
  → 地址取自工具返回值(.crisp-planet),未手工拼写
2026-09-03 12:09:12 +08:00
9e5c557cdf feat: 跨主机 Agent 验证 + 离线邮件补投 + 400 指向具体字段
7.8「跨主机 Agent 发现」原计划(Gateway + Registry 拆分、etcd/Consul 注册)
取消,改为验证现有协议已经够用。验证过程暴露两个真实缺陷,一并修掉。

## 为什么不做注册中心

它要解决「Gateway 怎么找到 Agent」,而这个问题在本架构里不存在:
连接方向是单向的 —— Agent 主动连 Gateway,Gateway 从不外呼。
远端 Agent 只需要一个公网 URL 加一把密钥,被叫方自己会打进来。
注册中心要解决的「被叫方在哪」根本没出现过。

同一个理由此前已经决定了平台会话同步走插件上报而不是 Gateway 拉取。

## 验证方式:一个纯标准库脚本

`deploy/remote-agent-demo.py` 在另一台主机(192.168.2.106)上跑,
不装 AgentMail 的任何代码。注册 / 心跳(带模型目录)/ SSE 长连 /
收件箱 / 标记已读 / 发信全通,Gateway 侧 status=online 且 last_seen 随心跳推进。
完整一轮往返跑通:admin 发给 remotebot@/tmp/remotebot-ws,脚本回信入库。

「协议层面已支持」的含义就是这个:跨主机不需要新组件,只需要三个环境变量。

## 缺陷一:SSE 只推连上之后的事件,没人补拉积压

写那个脚本时第一版只挂了 SSE,启动前发的邮件永远不会被处理。
查了才发现**两个正式插件也有这个洞** —— 原以为它们做了补拉,实际没有。
后果比明确的失败更难排查:邮件躺在收件箱里,而发件人以为 Agent 收到了。

新增共用模块 `lib/catchup.js`,两插件在首个成功心跳后补投一次。五条约束
都对应一种具体的坏行为:

- 只在**首个**心跳后补 —— 每轮都补会把「模型正在处理中、尚未标已读」的
  邮件重复投递
- 串行、一次最多 5 封 —— 每封都要起一轮模型,并发放出去等于对上游打 N 个
  并发请求,且最后几封要等前面全部跑完
- 与 SSE 共用 deliveredMails 去重 —— 心跳与 SSE 建连之间有个窗口,
  那期间到的邮件两条路都会到
- 按时间**正序**投(收件箱倒序返回)—— 倒着塞进去同一会话的上下文是乱的
- permission 类不补投 —— 原来的工具调用早随进程没了,没有可恢复的上下文

端到端两平台各验一次:停插件 → 发信 → 启插件 → 日志「补投 1 封离线期间的
邮件」→ 回信入库;随后在线再发一封确认只回一次。

## 缺陷二:400 只说 "Invalid JSON",不说是哪个字段

脚本把 `workspaces` 传成字符串数组(它要 `[{name, path}]`),
得到的只是一句固定文案,只能靠翻服务端结构体才能发现。
两个官方插件都传 `workspaces: []`,所以这个洞一直没暴露;
第三方客户端没有「翻服务端源码」这个条件。

新增 `handler.DecodeBody`,22 处 `Decode` + 固定文案的调用点全部换过去:

    {"error": "字段 \"workspaces\" 类型不对:期望 object,收到 string"}
    {"error": "JSON 语法错误(第 8 字节处)"}
    {"error": "请求体为空"}

刻意不回显 encoding/json 的原文 —— 它带 Go 类型名(models.Workspace),
那是本侧的实现细节,不该出现在公开 API 的响应里。期望类型用 JSON 的说法。
截断的 JSON 走 io.ErrUnexpectedEOF 而不是 json.SyntaxError,单独一条分支,
否则会落到笼统的兜底文案里(写测试时才发现)。

## 验证

- Go:13 个新测试(decode_test.go 含「不得泄漏 Go 类型名」断言)
- 插件:两侧各 10 个补投测试,共 200 个
- 共用模块同源校验通过(catchup 已纳入 check-shared-libs.sh)
- 生产已部署
2026-09-02 22:47:31 +08:00
07e6b789b2 feat: 配额下沉到会话 + 窄屏覆盖式布局 + 工作列表卡片视图
## 配额重构:废除 Agent 终身额度

原实现在 agents 上放一个 max_rounds/used_rounds 计数器,used_rounds 单调递增、
永不重置 —— 跑满就要管理员手工重置才能再干活。那是把一次性资源模型套在长期
在线的服务上,且并行任务互相抢额度。

改为:
- 唯一被强制的预算是【会话】的往返预算(sessions.max_rounds/used_rounds),
  写信时给、对话页里随时改 —— 配额的语义是「这件事值得多少个来回」,
  那是任务的属性而不是 Agent 的属性
- agents.default_rounds 只作为「派给这个 Agent 的新任务」的默认值(默认 20)
- agents.used_rounds 降级为纯统计
- 新建会话速率限制(1h/20 条)堵住用 .new 开一串新会话绕过预算;
  人类不受限(agentLimiterKey 返回空串即不计量)

## 窄屏适配(用户反馈「窄屏基本不可用」)

原先只有三栏并排:60(导航)+320(列表)+详情,375px 屏上详情被挤到 0。

第一版做成「一次只显示一栏」,用户纠正应当是新页面覆盖老页面并带动画,
于是重做为覆盖式:
- NarrowStack:底层列表始终挂载,详情绝对定位盖在上面。两个好处 ——
  列表滚动位置与选中态天然保留;退出动画有东西可播(直接卸载再渲染另一个
  组件的话,没有任何一帧能让旧页面往右滑出去)
- 因此必须区分「逻辑上是否打开」与「是否还在 DOM 里」:关闭时先播 200ms
  滑出,动画结束才卸载
- 入场用双层 requestAnimationFrame:必须让浏览器至少绘制一帧「在右侧之外」
  的状态,否则挂载与 translate-x-0 在同一帧内完成,transition 不触发
- 窄屏专属控件用 useIsNarrow() 条件渲染而非 md:hidden —— 后者只是视觉隐藏,
  宽屏用户按 Tab 会聚焦到看不见的返回按钮
- 底部导航 + 抽屉侧栏 + env(safe-area-inset-bottom)

## 工作列表卡片视图(Phase 7.1 最后一项)

中间栏可切列表/卡片。列表答「跟谁在聊」,卡片答「在聊什么、进展如何」:
主题 + 最新一封的发件人与摘要 + 往返预算徽标。

- 两种视图共用同一份数据与同一套动作;归档确认框也共用 —— 归档是破坏性操作,
  换个视图就换套确认 UI 只会让人对「自己点了什么」更没底
- 预算徽标在「不限」时不显示(对每张卡片都成立的「0/0」是纯噪声)
- 数据一次取回,不让卡片为每条会话再打一次库

## 修掉的缺陷

- GET /me/sessions 一直 500:ListSessionsFor 的 SELECT 加了预算两列却没加进
  Scan,列数不匹配。联系人栏一条数据都拉不到,而错误只是「Failed to list sessions」
- GET /sessions/{id} 忘了填充附件:前端会话视图走的是这个端点,于是 Agent
  回信里的附件在 UI 上完全不存在(另一个端点填了但没人调用)
- 插件曾完全没在加载:为了可测在 index.js 里 export 了辅助函数与一个 Map,
  而 opencode 把入口模块的每一个导出都当成插件工厂逐个检查,多导出一个 Map
  就 "Plugin export is not a function",插件静默失效、邮件全投不进去。
  逻辑挪到 lib/relay-dedup.js,并加断言钉住「入口只有 default 导出」
- 同一件事发两封邮件:模型带附件主动回信后,session.idle 又把它最后那段话
  自动转了一遍(生产实测 311 与 342 字节各一封)。explicitSends 记录本轮
  主动发信,自动转发据此让位;relay_key 幂等管不了这个 —— 那个键保证的是
  「同一条消息不转两次」
- SQLite 时间戳只有秒精度:同秒插入的多封邮件排序不确定(实测同秒插 5 封,
  顺序由随机 UUID 决定)。「会话里最早那封」(决定联系人身份)与「最后那封」
  (决定最新进展)都会取错。NOW() 升到微秒 + mails 的 INSERT 显式传它
  (改 schema 默认值只对新库生效,SQLite 没有 ALTER COLUMN)+ 所有
  ORDER BY created_at 补 mail_id 兜底
- fillAttachments 从逐封查询改成一次 IN(...):原来是 N+1,200 封的会话打开
  要打 200 次库
- repo 层 5 处 rows.Next() 循环补 rows.Err():没有它,读到一半连接断掉会
  静默返回部分结果,UI 上表现为「邮件凭空少了几封」
- go:embed 占位页改名 placeholder.html:叫 index.html 会被 Vite 产物覆盖并
  提交进去,而它引用的 assets/ 是被忽略的 —— 新克隆打开是白屏

## 回复/转发栏

- 两处都加抄送(可折叠);原邮件带抄送时多一个「回复全部」,回填用
  cc_list[].raw 而非重拼 name@path(后者会丢掉会话段)
- 会话视图每张卡片加转发入口:转发之前只存在于单封邮件视图,而人多数时间
  待在会话视图里,等于功能在 UI 上找不到
- ReplyBar 的错误从 console.error 改为显示出来:预算耗尽、地址不存在、
  速率限制都走这条路,之前点发送毫无反应

## 测试

- repo: 列顺序(三个 SQL 分支)、卡片字段、previewRunes 边界、时间戳亚秒精度、
  批量附件查询、速率限制(80 goroutine 断言恰好 20 条通过)
- web: 窄屏布局 16 条结构性断言(覆盖而非分栏、延迟卸载、双层 rAF、
  条件渲染而非 md:hidden)
- 插件: 自动转发去重 17 条(含「入口只有 default 导出」不变量)
- install.sh 把插件测试也纳入部署前门禁
2026-09-02 14:16:46 +08:00
0e754617a4 feat: AgentMail —— 以邮件为统一范式的多智能体协作平台
Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是
「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制,
数据库默认内置 SQLite,systemd 托管。

核心设计
- 三维寻址 name@path.session,按最后一个 . 切分;session 位三态:
  省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达)
- 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的
  标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖
- 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构,
  再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载
- 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重,
  且路径与用户 filename 无关,杜绝 ../ 穿越
- 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与
  最终总结走免配额通道,靠上游消息 id 做幂等键而非计数
- 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过

后端 gateway/
- models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL)
  共用一份 repo 层 SQL,差异集中在 internal/db
- 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界)
- 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文
  只从客户端流向服务器一次
- 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、
  附件挂载),并发下不会刷穿

前端 web/
- 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件
- 全站纯 SVG 图标,不使用 emoji
- api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts

插件 plugins/opencode-mail-bridge/
- 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、
  session.idle 时转发本轮总结)
2026-09-02 10:29:26 +08:00