Files
MailUI4Agents/docs/PLAN.md

101 KiB
Raw Blame History

AgentMail 实施计划

邮件驱动·多智能体协作平台 — Go 后端 + React 前端 + Pi 插件 版本v0.1 | 日期2026-09-01


总览

Phase 1 (Week 1-2)  后端核心        — Go Gateway + SQLite默认/PostgreSQL可选+ SSE
Phase 2 (Week 2-3)  Pi Agent 插件    — 两个工具 + 三个钩子拦截器 + SSE 监听
Phase 3 (Week 3-4)  前端             — React 三栏 UI + 三段式补全 + 权限卡片
Phase 4 (Week 4)    多用户与鉴权     — 人类多账号 + 会话隔离 + 登录页
Phase 5 (Week 5)    联调 + 部署      — 端到端闭环 + Docker + 公网反代
Phase 6 (Week 6+)   进阶功能         — 对话树 / 转发 / 配额 / DSH 插件

Phase 1后端核心Go Gateway

1.1 项目初始化

  • 创建项目目录 gateway/
  • go mod init github.com/agentmail/gateway
  • 目录结构规划:
gateway/
├── cmd/
│   └── server/
│       └── main.go              # 入口,启动 HTTP 服务
├── internal/
│   ├── config/
│   │   └── config.go            # 环境变量配置
│   ├── db/
│   │   ├── db.go                # 连接池
│   │   ├── migrate.go           # 迁移执行
│   │   └── migrations/
│   │       └── init.sql         # 建表 SQL
│   ├── models/
│   │   └── models.go            # 数据结构
│   ├── repo/
│   │   └── repo.go              # 数据库操作层
│   ├── sse/
│   │   └── manager.go           # SSE 连接管理
│   └── middleware/
│       └── auth.go              # Agent 认证中间件
├── go.mod
├── go.sum
├── Dockerfile
└── Makefile

1.2 配置管理

  • internal/config/config.go
    • 数据库连接串、端口、CORS 配置等
    • 从环境变量读取,提供默认值
    • 结构体单例 + Load() 函数

1.3 数据库层

  • internal/db/db.go — database/sql 连接 + 方言适配SQLite 默认 / PostgreSQL 可选)
  • internal/db/migrate.go — embed SQL + 按方言执行迁移
  • internal/db/migrations/init.sql + init_sqlite.sql — 6 张表 users/user_sessions/agents/sessions/mails/permission_requests
  • internal/repo/repo.go — 全部数据库操作函数(方言差异走 db.CCHas / db.JSONCast

repo 函数清单:

函数 用途
CreateOrUpdateAgent 注册/更新 Agent
VerifyAgent 验证 Agent 名+密钥
HeartbeatAgent 更新心跳 + 返回未读数
ListAgents 列出 Agent@补全用)
CreateSession 创建新会话
GetSessionByID 查会话详情
ListSessions 列出会话
TouchSession 更新会话时间戳
UpdateSessionAlias 更新会话别名(校验唯一 + 保留字 new + 禁含 . / @ 空白)
FindSessionByAlias 按别名查会话(唯一命名空间)
FindNamedSessionFor 按 name@path.<别名> 严格寻址;不存在 → 无法送达
FindOrCreateDefaultSession session 位省略时的默认会话(复用最近活跃,无则建)
SyncSessionAlias 回写平台侧 slug 为本侧别名,撞名自动追加 -2/-3
SyncSessionTitle 回写平台侧模型生成的摘要标题
AgentCanAccessSession Agent 只能同步自己参与过的会话
CreateMail 创建普通邮件
CreatePermissionMail 创建权限请求邮件
CreateDecisionMail 创建人类决策邮件
GetMailByID 查邮件详情
ListInbox 查收件箱
MarkMailRead 标记已读
CountUnread 统计未读数
GetSessionMails 查会话全部邮件
CreatePermissionRequest 创建权限请求记录
DecidePermission 执行权限决策
ListPendingPermissions 列出待决权限
GetPermissionByMailID 按邮件查权限请求

1.4 SSE 连接管理

  • internal/sse/manager.go

功能:

  • 管理所有 SSE 客户端连接Agent 侧 + 前端)
  • agentName 路由推送Agent 只收自己的邮件通知,前端收所有)
  • 心跳保活(每 30s 发 : heartbeat
  • 客户端断开自动清理

数据结构:

type Client struct {
    ID        string
    AgentName string  // 空 = 前端客户端
    Res       http.ResponseWriter
    Flusher   http.Flusher
}

type Manager struct {
    mu      sync.RWMutex
    clients map[string]*Client
}

方法:

  • AddClient(id, agentName, res, flusher)
  • RemoveClient(id)
  • SendToAgent(agentName, eventType, data)
  • SendToFrontend(eventType, data)
  • Broadcast(eventType, data)
  • ClientCount() int

1.5 中间件

  • internal/middleware/auth.go

逻辑:

  1. 从 header 读 X-Agent-Name + X-Agent-Secret
  2. repo.VerifyAgent 验证
  3. 验证通过 → 更新 last_seen,把 agentName 注入 context
  4. 失败 → 返回 401

注意: 这个中间件只用于 Agent 侧的 API。前端 API 用另一个认证MVP 阶段可先不实现前端认证)。

1.6 HTTP 路由

  • cmd/server/main.go

路由表:

GET  /health                          → 健康检查
POST /api/v1/agent/register           → Agent 注册
POST /api/v1/agent/heartbeat          → Agent 心跳(需认证)
GET  /api/v1/agents                   → 在线 Agent 列表

POST /api/v1/mail/send                → 发送邮件(需认证)
GET  /api/v1/mail/inbox               → 收件箱(需认证)
GET  /api/v1/mail/:id                 → 邮件详情(需认证)
POST /api/v1/mail/:id/read            → 标记已读(需认证)

POST /api/v1/permission/request       → Agent 请求权限(需认证)
POST /api/v1/permission/decide        → 人类决策(无需认证)
GET  /api/v1/permission/pending       → 待决权限列表(无需认证)

GET  /api/v1/sessions                 → 会话列表
GET  /api/v1/sessions/:id             → 会话详情 + 邮件
GET  /api/v1/sessions/:id/mails       → 会话内邮件列表
PUT  /api/v1/sessions/:id/alias       → 更新会话别名(人类显式命名)
POST /api/v1/sessions/:id/sync        → Agent 回写平台侧生成的标题/slug需 Agent 认证)

POST   /api/v1/admin/agent-keys        → 签发/登记 Agent 接入密钥(管理员)
GET    /api/v1/admin/agent-keys        → Agent 密钥列表(只给 token_hint
DELETE /api/v1/admin/agent-keys/{id}   → 吊销
POST   /api/v1/admin/agent-keys/{id}/bind → 绑定到 Agent

POST   /api/v1/me/keys                 → 签发客户端连接密钥(用户自助)
GET    /api/v1/me/keys                 → 我的密钥列表
DELETE /api/v1/me/keys/{id}            → 吊销

GET  /api/v1/events/stream            → SSE 端点
GET  /api/v1/events/status            → SSE 连接状态

实现顺序:

  1. 健康检查 + 静态路由
  2. Agent 注册/心跳
  3. 邮件发送/收件箱
  4. 权限请求/决策
  5. 会话管理
  6. SSE 端点

1.7 Go 编译验证

  • go build ./cmd/server 编译通过
  • 启动服务(默认内置 SQLite无需外部依赖
  • curl 手动测试每个 API 端点

1.8 数据库后端SQLite 默认 + 外部库可选

决策:默认 SQLiteDATABASE_URL 非空时切外部 PostgreSQL。 前端已 go:embed 进二进制,数据库再挂 Docker 就自相矛盾;部署产物应当是「一个二进制 + 一个 .db」。

  • internal/db 收拢方言差异repo 层只写一份 SQL
    • 占位符SQLite 也支持 $1/$2,无需改写
    • NOW() / gen_random_uuid()SQLite 侧注册同名函数补齐
    • JSON 包含判断:db.CCHas(col, argN) —— PG 用 jsonb_build_arraySQLite 用 json_each
    • 唯一冲突:db.IsUniqueViolation 同时识别 PG 23505 与 SQLite 2067/1555
    • JOIN LATERALSQLite 无此语法,联系人聚合改用关联子查询
  • 两份 schemamigrations/init.sqlPGmigrations/init_sqlite.sql
  • DATABASE_URL 识别 postgres://sqlite://file:、裸 .db 路径;空值 = 内置 SQLite
  • SQLite 连接开 WAL + busy_timeout=5000 + foreign_keys=ON,连接池限 1单写者
  • 删除 docker-compose.yml 与重复的 gateway/migrations/

1.9 部署systemd 单元

  • deploy/agentmail-gateway.service — 含 ProtectSystem=strictReadWritePaths=data/
  • deploy/opencode-serve.service — 托管 opencode headlessmail-bridge 宿主)
  • deploy/install.sh — 构建前端 → 嵌入 → 装服务;首装生成随机管理员密码与 Agent secret
  • OPENCODE_SERVER_PASSWORD 由安装脚本随机生成(此前裸奔,同机任何进程都能开会话)

Phase 2Agent 邮件桥接插件

计划书原本按 Pi Agent 设计,实际第一个接入的平台是 opencodeplugins/opencode-mail-bridge/)。 结构也从「按文件拆 tools/transport/hooks」收敛为单文件 index.js —— opencode 的插件契约是一个默认导出函数返回 hooks 对象,拆成多文件只会增加跳转成本而无收益。

2.1 插件结构(已落地)

plugins/opencode-mail-bridge/
├── package.json
└── index.js        # 凭证层 + HTTP + SSE + 四个工具 + event 钩子
  • 单文件实现,无需 transport/tools 分层
  • 凭证层:密钥优先级 AGENTMAIL_AGENT_KEY > ~/.agentmail/agent.key > 旧 secret > 本地生成

2.2 HTTP 传输层(已落地)

  • apiGet / apiPost 封装 fetch,认证头由 authHeaders() 统一给出 (有密钥走 Authorization: Bearer,否则退回 X-Agent-Name + X-Agent-Secret
  • 错误处理:非 2xx 抛出服务端 error 文案,调用方能看到「密钥无效」这类可操作信息

2.3 SSE 客户端(已落地)

  • fetch + ReadableStream 手工解析 SSE 帧Node 原生 EventSource 不支持自定义请求头, 而认证头是必需的)
  • 连接 /api/v1/events/stream;断流 3s、出错 5s 后重连;AbortController 支持优雅停止
  • 事件分发:new_mail / permission_decisiondeliverMail()

2.4 工具实现(已落地,四个)

  • send_mail — 参数 to / subject / body / cc? / reply_to? / session_alias? 地址解析在网关侧完成(三维寻址是网关的职责,插件不该各自实现一份解析)
  • read_inbox — 参数 filter?unread/all/ limit?;返回带预览的列表
  • request_permission — 参数 question / options? / context?
  • connect_to_server — 参数 gateway_url? / key_token?;登记密钥并完成注册, 失败时直接把待登记的密钥全文打出来,省一轮来回

与原计划的偏差:原设想「权限请求 / 提问 / 最终总结」三种行为由钩子自动拦截, Agent 不需要手动调工具。opencode 的插件契约没有提供拦截这三类行为的钩子 chat.message 只能观察不能冻结会话),因此改为: request_permission 作为显式工具暴露给 Agent「提问」与「最终总结」由 Agent 自行用 send_mail 表达。这不影响协作语义 —— 邮件本身就是提问与总结的载体。

2.5 插件入口(已落地)

  • 启动时读凭证 → POST /agent/register → 启动 SSE → 30s 心跳定时器
  • new_mail 事件:不注入正文,只给发件人/主题/mail_id 与「先调 read_inbox」的指示session_idsessionMap 决定续谈已有 opencode 会话还是新开一个 (网关已按 session 位判好复用/新建,插件只忠实映射)
  • permission_decision 事件:把决策结论作为新一轮 prompt 投给对应会话
  • session.updated 钩子:把 opencode 侧模型生成的会话标题与 slug 回写为 AgentMail 的会话命名

2.6 测试(已实测)

  • 工具注册:四个工具出现在模型的工具列表中(用 llmsproxy/AUTO 实测)
  • 端到端收发:人 → Agent → 人 回信闭环,reply_to 使回信落回同一会话
  • 会话记忆:第一封「记住 42」→ 第二封(省略 session 位)问「刚才的数字」→ 回「42」 证明续谈映射正确
  • 密钥流程首装本地生成0600→ 管理员登记 → 重启注册成功 → 收发邮件与 SSE 全通
  • 权限闭环:request_permission/permission/pending 可查 → 人类决策 → 插件收到并开会话

排查记录(两个真实缺陷)

  1. 「工具没注册」是假象 —— opencode 免费模型限流后连工具清单都不吐;换 llmsproxy/AUTO 后正常。 顺带修了 provider 配置里 baseURL// 的拼写错误。
  2. 「发邮件没回复」是真 bug —— SSE 回调调的 client.session.message.send(...) 在 opencode SDK 里 不存在session.message 是读消息的 getterPromise reject 又被空 .catch(() => {}) 吞掉, 于是邮件到了、SSE 收到了、却没建任何会话也没报错。 正确 API 是 session.create + session.promptAsync教训:跨 SDK 调用不要写空 catch。

Phase 3前端React

实际实现比原计划收敛组件拆分粒度更粗MailItem/MailBody/ReplyEditor/PermissionCard 都并入了 MailView因为它们只在这一处使用独立文件只增加跳转成本 且「新建邮件」按用户要求做成右侧整页而非弹窗。

3.1 项目初始化

  • web/ 目录Vite + React + TypeScript + TailwindCSS

3.2 目录结构(已落地)

web/src/
├── api/          client.tsHTTP/ sse.tsEventSource + 指数退避重连)
├── stores/       mail / session / contact / ui / authZustand
├── components/   Sidebar / MailList / MailView / ComposePage / ContactPanel
│                 AddressInput / LoginPage / SetupPage / AccountPage
│                 AdminUsersPage / KeyPanel / icons
├── types/index.ts
└── App.tsx

3.3 类型定义

  • types/index.ts — Mail / Session / Contact / User / Agent / AdminScopes 等

3.4 API 客户端

  • api/client.ts — 统一 request()credentials: 'include'401 统一跳登录; 覆盖邮件/会话/联系人/权限/用户管理/密钥全部端点

3.5 SSE

  • api/sse.ts — 单例 EventSource + 多订阅者;断线指数退避 1s→15s 上限 (没有单独做 useSSE hook订阅者是 store 而非组件hook 反而多一层)

3.6 状态管理

  • stores/mailStore.ts / sessionStore.ts / contactStore.ts / uiStore.ts / authStore.ts

3.7 核心组件(已落地)

  • App.tsx — 三栏容器60px 图标栏 / 320px 列表 / 右侧自适应(替代原计划的 Layout.tsx
  • Sidebar.tsx — 图标栏(收件/发件/联系人 + 新建 + 账号/管理员入口 + 退出),纯 SVG 无 emoji
  • MailList.tsx — 邮件列表未读蓝色粗体、权限橙色标签、CC 标记
  • MailView.tsx — 邮件正文react-markdown+ 权限卡片 + 回复区,三者合一
  • ComposePage.tsx — 右侧整页写信(非弹窗,用户明确要求),含编辑/预览切换、 会话别名输入(仅地址以 .new 结尾时出现)
  • AddressInput.tsx — 三段式补全,接 /contacts/suggest,支持方向键
  • ContactPanel.tsx — 联系人(= 一条 name@path.session 三维地址)+ 归档
  • LoginPage / SetupPage / AccountPage / AdminUsersPage / KeyPanel
  • icons.tsx — 20+ 纯 SVG 图标,全站零 emoji

3.8 样式

  • TailwindCSS 配置
  • 浅色主题(深色留待后续)
  • 列表 hover/selected 状态
  • 权限请求卡片橙色高亮
  • 未读蓝色粗体
  • 加载/空状态

3.9 开发环境

  • Vite proxy/apihttp://localhost:8180(与 Gateway 默认 PORT 一致)
  • npm run typechecktsc --noEmitnpm testMarkdown XSS 回归)

Phase 4多用户与鉴权人类账号体系

背景:人类不是单一的 human,而是多用户,各自账号密码登录,拥有独立收件箱与会话。 寻址上人类用户同样是三维地址的 name 位:jianf@.newalice@.deploy-review

4.1 数据模型

  • users
CREATE TABLE IF NOT EXISTS users (
    user_id       UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    username      VARCHAR(64) NOT NULL UNIQUE,   -- 即寻址的 name 位
    display_name  VARCHAR(128) NOT NULL DEFAULT '',
    password_hash VARCHAR(255) NOT NULL,          -- bcrypt
    role          VARCHAR(16) NOT NULL DEFAULT 'user',  -- admin / user
    status        VARCHAR(16) NOT NULL DEFAULT 'active', -- active / disabled
    created_at    TIMESTAMPTZ DEFAULT NOW(),
    last_login    TIMESTAMPTZ
);

CREATE TABLE IF NOT EXISTS user_sessions (
    token       VARCHAR(64) PRIMARY KEY,          -- 随机 token存 Cookie
    user_id     UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
    created_at  TIMESTAMPTZ DEFAULT NOW(),
    expires_at  TIMESTAMPTZ NOT NULL,
    user_agent  VARCHAR(256) DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_exp  ON user_sessions(expires_at);

关键决策usernameagents.agent_name 共一个命名空间(注册时互斥校验), 因为二者都出现在三维地址的 name 位,同名会导致路由歧义。

4.2 历史数据迁移

  • 现有邮件里的字面量 'human' 迁移到默认管理员账号
    • 创建 admin 用户(首次启动时从环境变量 ADMIN_USER / ADMIN_PASSWORD 读取)
    • UPDATE mails SET from_name = 'admin' WHERE from_name = 'human'to_name 同理)
    • 保留 human 作为兼容别名Agent 仍可 send_mail to="human@",网关解析为“当前会话的发起人类用户”

4.3 认证 API

POST /api/v1/auth/login      { username, password }  → Set-Cookie: am_session
POST /api/v1/auth/logout     → 销毁 token
GET  /api/v1/auth/me         → { user_id, username, display_name, role }
POST /api/v1/auth/password   { old_password, new_password }

管理员专用:

GET    /api/v1/admin/users            → 用户列表
POST   /api/v1/admin/users            { username, password, display_name, role }
PUT    /api/v1/admin/users/{id}       { display_name, role, status }
DELETE /api/v1/admin/users/{id}       → 禁用(不物理删除,保留邮件历史)
POST   /api/v1/admin/users/{id}/reset { new_password }
  • 密码用 golang.org/x/crypto/bcryptcost 12
  • token 用 crypto/rand 32 字节 hex有效期 7 天,每次请求滞后续期
  • CookieHttpOnly + SameSite=Lax + 生产环境 Secure
  • 登录失败限速:同一 username 连续 5 次失败锁 5 分钟内存计数MVP 阶段够用)

4.4 中间件与会话隔离

  • middleware.UserAuth —— 从 Cookie 取 token → 查 user_sessions → 注入 username 到 context
  • middleware.AdminOnly —— 叠在 UserAuth 之后,校验 role = 'admin'
  • 所有 /human/* 路由改为 /me/* 并强制鉴权,当前写死的 "human" 全部换成登录用户名:
    • HumanGetInboxMeGetInboxListInbox(ctx, username, ...)
    • HumanGetSentMeGetSentListSentBy(ctx, username, ...)
    • HumanSendMailMeSendMailfrom_name = username
    • ListContactsSuggestAddressArchiveContact 同样按当前用户过滤
  • 权限决策 POST /permission/decide 鉴权:只有该会话的参与人类或 admin 可决策
  • SSE /events/stream 鉴权:前端连接按登录用户分流,不再广播给全部前端
    • sse.Manager 新增 UserName 字段,SendToUser(username, ...) 取代 SendToFrontend

4.5 会话归属

  • sessions 表新增 owner_user_id UUID REFERENCES users(user_id)
  • 人类发起的会话归属于该用户Agent 发起的会话归属于它寄信的人类
  • ListContacts 只列当前用户参与的会话owner 或 在 to/cc/from 中出现)
  • 归档鉴权:非 owner 且非 admin 不得归档别人的会话

4.6 Agent 侧寻址适配

  • Agent 给人类发信时可写具体用户名:send_mail to="jianf@.new"
  • 保留 human@ 兼容写法 → 解析为当前会话的 owner 用户
  • SuggestAddress 的 name 层候选同时包含 在线 Agent + 已启用人类用户

4.7 前端

  • LoginPage.tsx —— 账号密码表单,失败提示,回车提交
  • authStore.ts —— me / login / logout;应用启动先拉 /auth/me 判断登录态
  • App.tsx —— 未登录渲染 LoginPage,已登录渲染主界面
  • api/client.ts —— 所有请求带 credentials: 'include'401 统一跳登录
  • 侧边栏底部显示当前用户 + 退出按钮(纯 SVG 图标,不用 emoji
  • 管理员页:用户列表 / 新建 / 禁用 / 重置密码

4.8 验证

  • 单测bcrypt 校验、token 过期、跨用户访问被拒403
  • 两个人类账号互不可见对方收件箱与联系人
  • Agent 寄信给 jianf@.new 仅 jianf 收到SSE 不泄露给其他登录会话

Phase 5密钥认证体系Agent-插件-Gateway 鉴权)

背景:当前 Agent 注册只需 name+secret 明文,无密钥机制,无法安全接入公网。 本 Phase 引入密钥token体系分为两类Agent 密钥(管理员为 Agent 生成,用于注册+通信) 和 用户密钥(用户自己生成,仅用于客户端连接 Gateway不可注册 Agent。 插件侧首次安装时本地生成密钥对,通过 connect_to_server 工具完成握手。

5.1 数据模型(已落地)

两方言各一份:internal/db/migrations/init.sqlinit_sqlite.sql。 下方 DDL 为 PostgreSQL 方言SQLite 侧 UUID→TEXTTIMESTAMPTZ→DATETIME

agent_keys 表Agent 注册密钥)

CREATE TABLE IF NOT EXISTS agent_keys (
    key_id       UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    key_token    VARCHAR(128) NOT NULL UNIQUE,   -- 密钥令牌(随机 hex
    agent_name   VARCHAR(64),                     -- 绑定到的 Agent 名(可为空=未绑定)
    key_type     VARCHAR(16) NOT NULL,            -- permanent / one_time / timed
    expires_at   TIMESTAMPTZ,                     -- timed 类型的过期时间permanent/one_time 为 NULL
    used_at      TIMESTAMPTZ,                     -- one_time 类型:首次使用时间(已用=失效)
    created_by   UUID REFERENCES users(user_id),  -- 创建者(管理员)
    created_at   TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_agent_keys_token ON agent_keys(key_token);
CREATE INDEX IF NOT EXISTS idx_agent_keys_agent ON agent_keys(agent_name);

user_keys 表(用户连接密钥)

CREATE TABLE IF NOT EXISTS user_keys (
    key_id       UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    key_token    VARCHAR(128) NOT NULL UNIQUE,
    user_id      UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
    label        VARCHAR(128) NOT NULL DEFAULT '',  -- 用户自定义标签(如 "我的笔记本"
    key_type     VARCHAR(16) NOT NULL DEFAULT 'permanent', -- permanent / one_time / timed
    expires_at   TIMESTAMPTZ,
    used_at      TIMESTAMPTZ,
    created_at   TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_keys_user ON user_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_user_keys_token ON user_keys(key_token);

关键约束

  • agent_keys.key_tokenuser_keys.key_token 共享一个全局唯一命名空间(验证时先查 agent_keys再查 user_keys
  • user_keys 的密钥 不能 用于 POST /agent/register(注册时校验来源表)
  • agent_keys 的密钥 不能 用于 /me/* 人类邮箱接口agent_auth 中间件与 user_auth 中间件分离)

5.2 Gateway API

管理员Agent 密钥管理

POST   /api/v1/admin/agent-keys          { agent_name?, key_type, label?, expires_hours?, key_token? }
GET    /api/v1/admin/agent-keys          ?agent_name=xxx
DELETE /api/v1/admin/agent-keys/{id}
POST   /api/v1/admin/agent-keys/{id}/bind  { agent_name }  -- 绑定到 Agent

key_token 用于登记客户端已在本地生成的密钥(插件首装场景):这样密钥全文只从客户端 流向服务器一次不必反方向传递。留空则由服务器生成32 字节随机 hex

密钥类型:

  • permanent:永不过期,可重复使用,适合正式部署的 Agent
  • one_time:一次性使用,首次验证后标记 used_at,再用即 403
  • timed:创建时指定 expires_hours(如 24h过期后失效

用户:连接密钥管理

POST   /api/v1/me/keys              { label, key_type, expires_hours? }
GET    /api/v1/me/keys              → 我的密钥列表(只给 token_hint
DELETE /api/v1/me/keys/{id}         → 删除密钥(带 user_id 条件,删不到即 404

用户密钥的 key_type 同样支持 permanent / one_time / timed但只能用于 /me/* 接口。

Agent 注册(改用密钥认证)

POST /api/v1/agent/register
Header: Authorization: Bearer <agent_key_token>
Body: { name, workspaces, platform }

→ Gateway 验证密钥:
  1. 查 agent_keys WHERE key_token = token
  2. 不存在 → 401
  3. one_time 类型且 used_at 非空 → 401已用过
  4. timed 类型且 expires_at < now() → 401已过期
  5. 验证通过 → 更新 used_atone_time→ 继续注册逻辑

Agent 心跳(改用密钥认证)

POST /api/v1/agent/heartbeat
Header: Authorization: Bearer <agent_key_token>

SSE 连接(改用密钥认证)

GET /api/v1/events/stream
Header: Authorization: Bearer <agent_key_token>
→ Agent 侧 SSE密钥验证后绑定到对应 Agent
→ 前端 SSE仍用 Cookie 认证(不变)

5.3 插件侧改动

首次安装:本地生成密钥

~/.agentmail/
├── config.json     # { "gateway_url": "...", "key_token": "...", "agent_name": "..." }
└── agent.key       # 本地密钥文件JSON含 key_token + created_at

插件启动时:

  1. 检查 ~/.agentmail/agent.key 是否存在
  2. 不存在 → 生成随机 32 字节 hex 作为 key_token写入文件0600并打印到 stderr
  3. 存在 → 读取 key_token
  4. 自动调 /agent/register;密钥未登记时提示管理员把打印出的密钥填进后台

connect_to_server 工具

args: {
  gateway_url: z.string().describe("Gateway 地址,如 http://mail.example.com:8080"),
  key_token: z.string().optional().describe("密钥令牌(可选,也可从本地配置读取)")
}

execute: {
  1. 读取本地 key_token参数优先否则从 ~/.agentmail/agent.key 读取)
  2.  POST /api/v1/agent/register Authorization: Bearer key_token
  3.  gateway_url  key_token 保存到 ~/.agentmail/config.json
  4. 启动 SSE 监听
  5. 返回连接结果
}

心跳认证改动

心跳与所有 Agent 接口统一携带 Authorization: Bearer <key_token>。 旧的 X-Agent-Name / X-Agent-Secret 保留兼容(见 5.7 最后一条)。

密钥来源优先级(已落地)

  1. AGENTMAIL_AGENT_KEY 环境变量 —— systemd 部署走这条
  2. ~/.agentmail/agent.keyAGENTMAIL_CONFIG_DIR 可改目录)
  3. 都没有时退回 AGENTMAIL_AGENT_SECRET 的旧路径;连 secret 也没有才本地生成新密钥

文件权限:agent.keyconfig.json 均 0600目录 0700。

5.4 密钥验证中间件Gateway已落地

middleware.AgentAuth 同时承担密钥与旧凭证两条路径:

有 Authorization: Bearer <token>
  → repo.VerifyAgentKey查 agent_keys
    → 不存在        → 401 "密钥无效"
    → one_time 已用  → 401 "密钥已使用(一次性密钥只能用一次)"
    → timed 已过期   → 401 "密钥已过期"
    → 通过one_time 写 used_atWHERE used_at IS NULL并发下只有一个请求能标记成功
  → agent_name 为空(待绑定)→ 403提示先调 /agent/register
  → 注入 agent_name 到 context刷新 last_seen

无 Bearer走 X-Agent-Name + X-Agent-Secret 旧路径

middleware.UserAuth 同理支持 Cookie 与 Bearer <user_key_token>。两类密钥各查自己的表, 因此 Agent 密钥读不了人类邮箱,用户密钥也注册不了 Agent。

/agent/register 不经中间件(注册时 Agent 还没身份),自行取 Bearer 校验: 密钥已绑定且与请求 name 不符 → 403防止拿别人的密钥冒充新身份未绑定则注册成功后落定。

5.5 管理员前端

  • 管理员页面拆成「用户管理 / Agent 密钥」两个 tabAdminUsersPage.tsx
  • 创建密钥:选类型(长期/一次性/限时)+ 可选绑定 Agent + 限时可填小时数
  • 登记客户端已生成的密钥:插件首装在本地生成密钥并打印,管理员把它填进「登记」框即可, 密钥全文只从客户端流向服务器一次,不需要反方向传递
  • 密钥列表:只显示 token_hint(前 8 位),全文仅在创建响应里出现一次, 前端用一次性横幅提示「关闭后无法再次查看」
  • 绑定 Agent / 吊销密钥

5.6 用户前端

  • 个人中心新增「客户端连接密钥」区(AccountPage.tsx,与管理员面板共用 KeyPanel.tsx
  • 创建密钥:填备注 + 选类型;限时可填小时数
  • 密钥列表 + 吊销;状态标注「可用 / 已使用 / 已过期」

5.7 验证(已实测)

  • permanent 密钥 → 注册成功,重复使用仍成功
  • one_time 密钥 → 首次成功;再用 401「密钥已使用一次性密钥只能用一次
  • timed 密钥24h→ 注册成功;手动把 expires_at 改到过去 → 401「密钥已过期」
  • timed 缺 expires_hours → 400非法 key_type → 400
  • 用户密钥 → /me/mail/send 正常;→ /agent/register 401「密钥无效」
  • Agent 密钥 → /me/mail/inbox 401两类密钥各查自己的表不会串门
  • 待绑定密钥首次注册落定为该 Agent同一密钥改注册别的 name → 403
  • 登记重复密钥 → 409登记过短密钥<32 位)→ 400
  • 密钥列表接口不回传 key_token 全文(实测 回传全文: False
  • SSE 鉴权加固:原先只凭 X-Agent-Name 就分流,等于任何人报个名字就能读走别人的 新邮件通知。现在必须通过 Bearer 密钥或 name+secret 验证;仅报名字 / 错 secret / 匿名一律 401
  • 插件密钥流程端到端首装本地生成密钥0600目录 0700并打印 → 管理员登记 → 插件重启注册成功 → 用该密钥收发邮件与订阅 SSE 全通
  • 旧的 X-Agent-Name/Secret 方式保留兼容(与原计划「向后不兼容」不同: 已部署的 Agent 不该因为引入密钥就集体失联,两条路径并存成本很低)

Phase 6联调 + 部署

6.1 端到端闭环测试

  • 启动 Gatewaygo run ./cmd/serverSQLite 自动建库,无需外部数据库)
  • 启动前端(npm run dev
  • 启动 opencode serve + mail-bridge 插件

测试场景(均已在 systemd 部署态实测):

# 步骤 结果
1 人类登录后新建邮件,抄送第二个收件人 session + mail 落库cc_list 入库
2 Agent 收到 SSE 通知 插件只注入通知(发件人/主题/mail_id不注入正文
3 Agent 调 read_inbox 返回邮件详情,含被抄送的那封
4 Agent 调 send_mail 回复人类 发起任务的用户收到,reply_to 使回信落回同一会话
5 request_permission 权限请求邮件入库,/permission/pending 可查
6 人类决策 /permission/decide 生成决策邮件Agent 侧收到
7 归档 name@path.session 会话与邮件均标记 archived归档后该别名不可寻址404

session 位三态实测: 省略 → 复用默认会话;.new → 每次新建;具名别名 → 命中已有, 不存在则 404「无法送达」跨收件人借用别名同样 404。

平台命名同步实测: 发信不指定别名 → opencode 侧 slug 回写为 calm-meadow 模型生成的摘要标题「生产环境联调邮件回复」回写为 subject 再用 opencode@/root.calm-meadow 续谈Agent 正确引用上一封内容。

6.2 部署编排(改为 systemd不用容器

原计划的 docker-compose 已作废:数据库换成内置 SQLite 后,部署产物就是「一个二进制 + 一个 .db」 再套一层容器编排只是徒增运维层级。见 1.9。

  • deploy/install.sh 一键安装(构建 → 嵌入 → systemd
  • deploy/*.service 两个单元gateway 与 opencode-serve
  • 删除 docker-compose.yml

6.3 一键启动脚本

  • deploy/install.sh 覆盖构建 + 安装 + 启动
  • 开发态无需脚本:go run ./cmd/server 即起SQLite 自动建库),前端 npm run dev 代理到 8180

6.4 错误处理与边界

  • Agent 离线时邮件投递邮件先入库Agent 心跳响应回传未读数,上线后 read_inbox 补齐
  • Gateway 重启后 SSE 客户端重连:前端 api/sse.ts 指数退避1s→15s 上限), 插件侧 startSSE 断流 3s / 出错 5s 后重连
  • 无效 Agent 名/密钥处理:错密钥与不存在的 Agent 统一回 Invalid credentials(不区分,避免探测账号存在性); 缺头回 Missing X-Agent-Name or X-Agent-Secret header
  • 畸形三维地址:缺 name 位、空 to、别名不存在均有明确错误文案
  • 邮件内容 XSS 防护react-markdown 默认不解析 raw HTML 且清空非 http(s)/mailto 协议 URL 新增回归测试 web/test/markdown-xss.test.mjsnpm test)守住这两个前提, 防止日后为「支持 HTML 邮件」加上 rehype-raw 而无声开口子
  • 并发安全:sse.Managersync.RWMutexgo build -race 通过; 实测 10 客户端并发连接(断开后计数归零)+ 20 封并发发信全部成功SQLite WAL + busy_timeout

6.5 公网反代(已完成)

  • mail.jianfgit.xyz → portal-nginx(.106:3080) → .60:8180
  • 列入 portal 认证豁免名单(应用自带账号体系,不叠 portal 登录)

Phase 7进阶功能MVP 后迭代)

7.1 对话树(已完成)

不建 tree_nodes(偏离原计划):mails.parent_mail_id 已经完整编码了树结构 —— 回复指向来信,转发指向被转发的原件。再维护一张 tree_nodes 就是第二份真相, 两处不一致时无法判断谁对。直接用递归 CTE 在 mails 上查(idx_mails_parent 已有)。

  • repo/thread.goAncestorsRaw(向上)/ DescendantsRaw(向下)两个方向的递归 CTE
  • GET /mail/{id}/thread?dir=around|up|down&offset=N&limit=N
  • 树可跨会话:转发把线索引到新会话,但 parent 仍指向原件。 这正是树视图比会话内平铺更有价值的地方 —— 能看出线索分叉去了哪里
  • 按会话逐个鉴权A 转发给 B 之后B 与 C 在新会话里的往来不能回流给 A。 被过滤的节点计入 hidden,鉴权结果按会话缓存(一条线索里同一会话通常有多封)
  • detached / parent_hidden 两个标记:前者表示父不在当前已加载集合里, 后者区分「无权查看」(永久)与「尚未加载」(随上滑补齐)。 不能因为找不到父节点就把子节点悄悄丢掉
  • 前端 ThreadView.tsx:缩进 + 连接线,不引图形库 (邮件树又浅又窄,一条主链加几个转发分支,缩进足够表达层级, 省掉渲染 SVG 的依赖与它带来的布局/缩放问题)

分块加载(用户要求:展示完整节点,首屏只加载当前屏幕,上滑逐步补齐)

原先实现是深度上限 100 直接截断,超长线索看不到全貌。改为按方向分页:

  • 首屏 dir=around 取锚点附近一块limit 在两个方向各分一半); dir=up|down + offset 增量加载,两端各自带 has_more_*next_*
  • 游标用「相对锚点的层号/节点偏移」而不是 mail_id 偏移量每次从锚点重走一遍,无状态、不可伪造; 用 mail_id 做游标就必须允许传入不可见的邮件(不可见的中间段要穿过去), 那还得单独证明它确实是锚点的祖先,反而更绕。 祖先方向的层号天然稳定 —— 新邮件只会追加成叶子,不会插进已有链条中间
  • depth 改为相对锚点0 = 锚点,负 = 祖先,正 = 子孙): 分块加载时根可能还没取到,绝对深度无从得知
  • repo 层不做可见性过滤:不可见的中间段必须能穿过(转发把线索引进别人的会话, 再往上却可能仍是自己参与的往来)。过滤放在 handler 层,那里才知道调用者是谁
  • 前端 IntersectionObserver 双哨兵rootMargin 200px 提前触发)+ 向上加载的滚动位置补偿(顶部插入内容后按 scrollHeight 增量修正 scrollTop 否则视线会被内容顶走)+ busy ref 防两个哨兵同时触发并发请求 setState 异步,读 state 会双双看到 false
  • 实测 251 封长链7 页取完depth 连续无缺口,取到真正的根; 311 封(含 60 个转发分支9 页取完,depth=1 恰为 61 个
  • limit 夹到 [1,200],非法值回落默认 40分页参数不该因笔误让整个请求失败 limit=1 时两方向各保底 1否则算出 downLimit=0 连锚点自己都不返回

工作列表卡片视图(已完成)

中间栏原先只有一种呈现 —— 紧凑列表行,三行显示 agent / path / .alias 与「N 封 · 时间」。 它答得了「跟谁在聊」,答不了「在聊什么、进展如何」:一条线索是一件正在进行的工作, 而工作的状态在列表上完全看不见,必须逐条点开。

  • WorkCard.tsx:主题(平台模型生成的摘要)+ 最新一封的发件人与摘要 + 往返预算徽标
  • 两种视图共用同一份数据与同一套动作(打开/写信/归档),只有单项渲染不同; 容器(滚动/空态/归档确认)留在 ContactPanel
  • 归档确认框抽成 ArchiveConfirm 两视图共用:归档是破坏性操作, 换个视图就换套确认 UI 只会让人对「自己点了什么」更没底
  • 视图偏好存 localStorage:纯展示偏好不值得建表加 API 而每次刷新退回默认视图会让人反复点同一个按钮;读写都容错(隐私模式会抛异常)
  • 卡片视图把中间栏从 320px 放宽到 400px两行摘要 + 预算条挤不下); 窄屏仍是 w-full
  • 预算徽标在「不限」max=0不显示一个对每张卡片都成立的「0/0」是纯噪声。 剩 1 个来回转橙、用尽转红 —— 那是需要人介入的时刻
  • 数据一次取回(ListContactsFor 增补 subject/max_rounds/used_rounds/ last_from/last_preview),不让卡片为每条会话再打一次库; 摘要按 rune 截断(中文一字三字节,裸切会留 U+FFFD

顺带修掉的时间戳精度问题:卡片的「最新进展」要取会话里最后一封邮件, 而 SQLite 的 CURRENT_TIMESTAMP 只有精度 —— 同一秒内插入的多封邮件 按 created_at 排序结果不确定(实测同秒插 5 封,顺序是乱的,由随机 UUID 决定)。 「最早那封」(决定联系人身份)同样会取错。

  • db.NOW() 升到微秒(毫秒不够:一次插入只要几十到几百微秒, 循环里连插几封会落在同一毫秒)。实测确认驱动能原样扫回 time.Time
  • mails 的三条 INSERT 显式传 NOW():改 schema 默认值只对新库生效 —— CREATE TABLE IF NOT EXISTS 不改已存在的表,而 SQLite 没有 ALTER COLUMN
  • 所有 ORDER BY created_atmail_id 兜底:老数据仍是秒精度, 没有第二排序键时同秒行的顺序由存储引擎决定,翻页会重复或漏行
  • 测试用显式发号的时钟而非挂钟:测试在循环里连插几封很可能落在同一微秒, 而生产里两封邮件之间至少隔着一次模型推理

7.2 抄送(已完成)/ 转发

  • cc_list JSONB 字段加入 mails 表
  • 发送时支持抄送多个三维地址
  • 前端邮件详情显示抄送列表
  • 收件箱检索覆盖被抄送邮件(to_name = $1 OR cc_list @> ...
  • 转发功能(引用原文 + 新收件人 + 附件随行)
    • POST /mail/{id}/forwardAgentPOST /me/mail/{id}/forward(人类)
    • 与「回复」的区别:回复落回原会话,转发按目标地址另行寻址 —— 它是一条新线索
    • 只能转发自己参与过的邮件(发件/收件/被抄送之一),否则 403
    • 引用块逐行加 > 前缀:原文含代码块或列表时,只有逐行前缀才保持引用语义
    • Fwd: 前缀不叠加;parent_mail_id 指向原邮件以便回溯
    • 附件一同带过去(内容寻址下只新增元数据,不拷磁盘文件)

7.3 配额机制(已完成;语义经两轮修正)

最终形态:额度只有一层 —— 本任务(会话)的往返预算。

第一版做的是 agents.max_rounds 终身额度,用户两次纠正后才对齐到正确模型:

  1. 「配额应当是在新建邮件、以及邮件对话页面是可编辑的」→ 预算下沉到会话
  2. 「为什么会有全局配额?不是每次单独配置配额,然后有一个默认配额吗?」→ 终身额度整个是错的工具

为什么终身额度是错的:它跑满后要管理员手工重置才能再干活, 而 Agent 是长期在线的 —— 那是把一次性资源的模型套在长期服务上。 而且一个全局计数器让并行任务互相抢额度:给紧急任务留的份被另一条线索吃掉。

  • sessions.max_rounds / used_rounds:真正的额度,每条会话独立计数
  • agents.default_rounds(默认 20派给该 Agent 的新任务默认几个来回。 按 Agent 配而不是全站一个数 —— 跑测试的小工具与重构整个模块的 Agent 合理来回数差一个量级
  • 写信不给 max_rounds 时取收件 Agent 的默认值;写信页把它显示为 输入框 placeholder人该看得到「不填会是多少」否则得先去管理员页查
  • agents.used_rounds 降级为纯统计:只累加、不拦请求。 保留是因为「这个 Agent 一共发了多少信」有观测价值; BumpSentCount 连 error 都不返回 —— 统计写失败不该让邮件发不出去
  • 管理员页 tab 从「发信配额」改名「默认预算」,列出 默认来回数 + 进行中任务数 + 累计发信数;不再有「重置」按钮 (累计数是历史,归零它只会销毁信息)
  • PUT /admin/quotas/{name} 兼容旧字段名 max_rounds 已部署的前端与脚本不该因为改名就难以察觉地失效
  • 心跳不再回传额度(额度不属于 Agent剩余往返随发信响应的 budget_remaining 回传,在那里才有意义

新建会话速率限制(替代终身额度的防滥用手段):

预算按会话计Agent 就可以用 .new 开一串新会话,每条都是全新预算。

  • repo/sessionrate.go:滑动窗口,同一 Agent 1 小时最多新建 20 条,超出 429
  • 判断与记账在同一把锁里:分开的话并发请求会双双通过检查把上限刷穿 —— 与配额那条 UPDATE 同样的道理。单测用 80 并发验证恰好放行 20 次
  • 建会话失败时 ReleaseNewSession 归还名额(那次新建实际上没发生)
  • 用 429 而不是 403前者表示「稍后再来」后者表示「你没这个权限」 客户端据此决定重试还是放弃
  • 被限速的 Agent 仍可在已有会话里回信 —— 不是全面封杀; 也不禁止 Agent 主动开新会话,那会堵死 Agent 之间的主动协作
  • 人类不受此限:手工点「新建邮件」的频率天然受限, 加限制只会在批量派活时误伤(实测人类连开 25 条会话全通)
  • 省略 session 位的「默认会话」不计入:一个 name@path 只有一条, 不构成暴开手段
  • 已知取舍:进程内内存计数,与登录限速同一取舍。多实例部署时各自计数, 等效上限变成 N 倍

实测 11 组:新注册默认 20 / 按 Agent 分别设tiny=5/ 派活自动取默认值 / 显式值优先 / 22 封连发确认终身额度不再拦(第 21 封被会话预算拦下)/ 对话页调高后可继续 / 暴开 24 条会话恰好放行 20 / 人类连开 25 条全通 / 被限速仍能回信 / 默认会话不计入 / 老库补 default_rounds 且旧统计不丢。

7.4 会话别名动态更新(已完成)

会话别名的基础能力已在 Phase 1 落地(见「三维寻址 session 位三态」):

  • 发信时用 session_alias.new 新建的会话命名,响应回传 session_alias
  • PUT /sessions/:id/alias 事后改名,唯一性冲突返回 409
  • 别名全局唯一(idx_sessions_alias_uniq 部分唯一索引NULL 不受约束)
  • 保留字与字符校验:不可为 new,不可含 . / @ 与空白(否则地址切分歧义)
  • 前端 ComposePage 在地址以 .new 结尾时才显示「会话别名」输入框

别名/标题的默认来源 = Agent 平台自己的命名机制(不在本侧另造一套):

  • POST /sessions/:id/syncAgent 认证)接收平台侧 alias + title
  • opencode 插件建会话时不传 title,让平台按首轮对话由模型生成摘要标题; 创建即拿到的 slugwitty-planet)立即回写为寻址别名,标题随 session.updated 事件回写
  • 平台 slug 不保证全局唯一,本侧别名必须唯一 → SyncSessionAlias 撞名自动追加 -2/-3,同步永不失败
  • normalizeAlias 把平台命名改写为合法寻址别名(非法字符换 -、压缩连续 -、避开保留字 new、按 UTF-8 边界截断 128 字节)
  • 手工命名(发信 session_alias / PUT alias)仍可覆盖平台命名,属显式优先

由 Agent 在邮件正文里主动提议改名(已完成,与上面的自动同步互补):

两者分工:自动同步 = 平台起的名字,后台静默生效不打扰人; 正文提议 = Agent 干完活觉得该换个更贴切的名字,需要人点头。

为什么要人点头而不是让 Agent 直接调 PUT alias 别名是的寻址入口(name@path.别名。Agent 干到一半自己改掉, 人上一秒记住的地址下一秒就失效。提议 + 人确认,既让 Agent 表达意图, 又保证寻址稳定性由人掌握。

  • 载体是 HTML 注释 <!-- agentmail:rename-session alias="x" reason="y" -->
    • react-markdown 默认不解析 raw HTML注释在页面上不可见
    • 纯文本客户端里是一行不碍事的注释,不像自造标记那样显眼
    • 不与 Markdown 语法冲突,不会被格式化工具改写
    • 实测它会被转义成可见文本节点而不是被丢弃,所以必须从入库正文里主动剥掉
  • handler/rename_proposal.goextractRenameProposal 解析 + 剥标记
    • 只认最后一条Agent 在长回复里可能反复修正措辞,最后写下的才是结论
    • 别名过 normalizeAlias + validateSessionAlias,非法的视为无提议但标记仍剥掉 (与其在正文里留一行乱码,不如当它没提)
    • 理由截断到 200 字节(按 UTF-8 边界),否则提示条被撑破
    • 人类发信同样剥标记但不产生提议 —— 人有改名按钮,用不着向自己提议
  • 存在邮件上(mails.rename_alias/rename_reason)而非会话上: 邮件是不可篡改的历史记录,「谁在哪一封里提了什么」应当留痕
  • sessions.rename_dismissed 记下被驳回的建议,提示条不再反复弹同一个
  • GET /sessions/:id/rename-proposal 取最新未处理提议 (既不是当前别名 = 未接受,也不在驳回记录里)
  • POST /sessions/:id/rename-proposal/dismiss 驳回;无待处理时返回 no_pending(幂等)
  • 接受走已有的 PUT /sessions/:id/alias,不另开端点: 那条路径已有唯一性校验与 409复制一遍只会多一个出错的地方。 实测提议的名字被别的会话占用时接受返回 409而不是默默造出重名
  • 插件 send_mailpropose_alias / propose_reason 参数,自动拼注释标记; 发信响应回传规范化后的别名Agent 提的名字可能被改写过)
  • 前端 RenameProposalBar(会话顶部):显示 旧别名 → 新别名 + 理由 + 「改名后旧别名立即失效」的后果提示;接受/忽略两个按钮。 new_mail 事件触发重新拉取(新来信可能带新建议)
  • SQLite 老库补列CREATE TABLE IF NOT EXISTS 不会给已存在的表加列, 而 SQLite 没有 ADD COLUMN IF NOT EXISTSdb.addMissingColumnspragma_table_info 后按需 ALTER实测老库补列成功、旧数据不丢、二次启动不重复补

sessions.alias_source —— 生产实测发现的隐患

用户接受改名后opencode 下一次 session.updated 事件会带着平台 slug 再同步一次, 把人刚定的名字冲掉 —— 人上一秒记住的寻址地址下一秒失效。实测确认了这个行为 fix-cache-penetrationstellar-engine 覆盖)。

  • sessions.alias_sourceplatform(平台自动同步,可被后续同步覆盖)/ manual(人显式指定,平台同步不得覆盖)
  • UpdateSessionAlias 与「发信时显式给 session_alias」都标 manual
  • SyncSessionAlias 遇到 manual 直接返回当前别名,不写入; 条件放进 WHERE alias_source <> 'manual' 并检查 RowsAffected —— 并发下用户可能刚好在检查与写入之间接受了提议,分两步会把它冲掉
  • 标题不受此保护subject 只用于展示,被平台的摘要标题刷新是好事; 受保护的只有承担寻址职责的别名
  • 老库补列默认 platform:无从得知历史别名是人定的还是平台定的, 而 platform 只影响「平台同步能否覆盖」,人随时可手工改名转成 manual
  • 四个场景实测platform 可反复覆盖 / 用户改名后平台同步无效 / 发信显式命名即受保护 / 全流程(平台命名 → Agent 提议 → 用户接受 → 平台再同步不覆盖)

7.5 附件(已完成)

内容存磁盘、元数据入库:附件是「写一次读多次」的冷数据,塞进 SQLite 的 BLOB 只会让 .db 膨胀、WAL 变大、备份变慢,换不来任何好处。

  • attachments 表(两方言各一份);mail_id 允许为 NULL 表示「已上传未挂载」
  • internal/blob:内容寻址存储,路径由 sha256 派生(ab/cd/abcd…
    • 相同内容天然去重,重复上传不占额外空间
    • 路径与用户给的 filename 完全无关,杜绝 ../ 穿越
    • 先写临时文件再按内容哈希 rename中途崩溃不会留下「哈希对不上内容」的文件
    • 超限即中止并清理临时文件;恰好等于上限放行(边界不误杀)
  • 上传与发信两步POST /attachments 拿 id → 发信时放进 attachment_ids。 Agent 侧工具走 JSON 无法带 multipart人类侧也需要「写正文前先传文件」
  • 挂载校验:WHERE mail_id IS NULL AND uploader = ? 一条 UPDATE 完成判断与写入, 避免并发下把同一附件挂到两封邮件上;不属于自己 403已挂载 409
  • 下载鉴权:已挂载的看邮件所属会话的参与关系,未挂载的只有上传者本人能看
  • 下载一律 octet-stream + attachment + nosniff:绝不按声明的 MIME 内联渲染, 否则上传一个 .html/.svg 就能在本站域下执行脚本
  • Content-Disposition 双写:filename* 承载 UTF-8filename= 兜底且转义引号与控制字符
  • 删除:只允许删自己上传且未挂载的;内容被其他记录共享时不删磁盘文件
  • GC启动时 + 每小时扫一遍,清理超过 24h 未挂载的记录与无引用文件
  • 单个上限 25MBAGENTMAIL_MAX_ATTACHMENT_BYTES 双层限制:MaxBytesReader 卡整个请求体,blob.Put 卡单文件内容
  • 插件工具 upload_attachment / download_attachmentread_inbox 列出附件清单含 id
  • 前端 Attachments.tsx:写信/回复的选择器XHR 上传进度)+ 邮件详情的下载清单 + 列表页附件徽标

7.6 WebAPI 化(已完成)

WebUI 调用的就是公开 API没有仅前端可用的私有通道 —— 第三方客户端拿一把用户密钥 即可获得与网页完全相同的能力。

  • 实测前端用到的每个端点都能用 Authorization: Bearer <user_key> 调通 读、写、上传、下载、转发、管理员接口、SSE
  • 两处例外补齐 ?access_token=SSEEventSource 不能带自定义头)与 附件下载(<a download> 由浏览器直接发起)。 只有这两个端点接受 query 令牌,其余一律 401 —— URL 里的令牌会进访问日志与 Referer 附件下载因此单独挂在 UserAuthAllowQueryToken 中间件下
  • CORS 暴露 Content-DispositionContent-Length(前者是取文件名所必需)
  • web/src/api/config.ts 集中基地址与令牌: VITE_API_BASE 构建期注入、window.__AGENTMAIL_API_BASE__ 运行时覆盖、setToken() 切凭证。 业务代码不感知 Cookie 与密钥的差异,src/api/ 可整体抽成 SDK
  • docs/API.md:完整接口清单、三类调用者的认证边界、错误码约定

7.7 DeepSeek Harness 插件(已完成)

plugins/dsh-mail-bridge/Cordis 插件框架 + TypeScript。 适配方法与踩坑记录已固化为 docs/PLUGIN-CONTRACT.md 后续接入新平台按那份清单走。

  • Cordis 插件骨架:export const inject + export const name + apply(ctx, config) - 没有 injectctx.tools / ctx.agents 根本不存在 (报 cannot get property "tools" without inject - 但可选服务不能写进 inject —— 那是硬依赖,服务没挂载时整个插件不启动。 sessionQueryctx.get() 取:会话上报只是补全体验, 不该能把邮件投递整体拘死
  • 四个工具经 defineTool 注册(send_mail / read_inbox / upload_attachment / download_attachment - 直接给 ctx.tools.register 原始对象会报 parameters must be lossless JSON before schema projection - 还必须声明 output: { schema, render }
  • ctx.agents.create() 建会话 + agent.followup() 投递消息 - followup() 要完整的 UserMessagecontent + source 不是 opencode 那种 parts 数组。传错不当场报错,而是在 agent-loop 的 preStep 里抛 Cannot read properties of undefined (reading 'kind') —— 错误落在框架内部不指向调用点turn 一 start 就 end、模型请求根本不发出去。 这个坑花了一下午,已用 lib/message.js + 测试钉住 - setup 留空base bundle 已注册 agent-loop / llm / tools agentOptions: { provider, model } 就够了;挂 preset 反而多余
  • agent/statusidle 时自动转发最后一条 assistant 消息 (对应 opencode 的 session.idle),复用 lib/relay-dedup.js 让位于 模型的主动回信,带 relay: 'summary' 走免配额通道
  • approval/request 钩子把权限询问转成邮件问人 - 与 opencode 的关键差异:那边的 permission.ask同步钩子, 卡住会挂死整个请求,只能「转出去 + 立即返回 ask」 DSH 这边是异步 waterfall,返回 Promise<ApprovalOutcome>,可以真的等人 - 拆插件时未决询问一律 fail closedunavailable 否则 DSH 侧那些 await 永不返回 - DSH 不给询问发 id会话:工具:callId 作幂等键
  • 会话别名由模型生成的标题派生(与「别名复用平台命名」的既定决策一致) - slugFromTitle 保留中文(转拼音后既不好读也不好打, 而三维地址按最后一个 . 切分,中文不影响解析) - 但必须去掉 . @ / 等寻址分隔符 —— 留在别名里会让它自己被解析器切开 - fallback 占位标题不派生别名DSH 在模型生成真标题前会先落一个 内容是「用户第一句话截断」的标题,而那句话是插件自己拼的提示词
  • 补上心跳 —— 之前完全没有Gateway 靠 last_seen 判在线, 一直靠注册那一次撑着
  • 与 opencode 插件共用 lib/ 下的纯函数模块(逐字节相同)

7.7.1 工作目录归属(修复)

症状dsh 指定工作目录完全失效,所有会话落进「未分组」。

根因两层

  1. 插件建会话时的 cwd 是自己拼的 ~/.dsh/mail-sessions/mail-<uuid> —— 每封邮件一个全新的空目录。平台按 cwd 给会话分组,于是所有邮件会话 既不属于任何项目、彼此也不同组
  2. Gateway 从来没把地址的 path 位发给插件:notifyRecipients 的 payload 只有 mail_id/session_id/from_name/subjectto_workspace 虽然入库了 却不在 SSE 事件里 —— 插件即使想用也拿不到
  • SSE new_mail 事件加 to_workspace每个收件方拿到自己那个地址的 path 不是主收件人的 —— 抄送给 opencode@/a 与主发给 dsh@/b 是两个工作区
  • 两个插件的 cwd 都改为取寻址的 path 位(共用 lib/workspace.js
  • 不存在的目录不创建而是回退到兜底目录:一个笔误 /home/porgram/x不该在磁盘上落下真目录Agent 会在里面一无所获地干活
  • 拒绝相对路径cwd 的相对基准是 harness 进程的启动目录systemd 下通常是 /

7.7.2 平台会话快照上报(新增)

症状:会话别名列不出工作区下的历史会话,无法选择。

人直接在平台界面上开的会话Gateway 一无所知;而邮件驱动的那些也因为 workspace 没存在会话上(只在 mails.to_workspace,且 Agent 回信的 from_workspace 填的是 Agent 名而不是路径)而匹配不上。

  • sessions.workspace 新列,CreateSession 从地址的 path 位带入
  • agent_platform_sessions 镜像表 + 心跳携带 platform_sessions
  • 插件上报而非 Gateway 反向拉取当前架构是单向的Agent 持密钥主动连 GatewayGateway 从不外呼),反向拉取需要它保存各平台的地址与凭证, 那是另一套信任模型
  • sessions分开存镜像里是别人家的会话id 属于平台的 id 空间, 没有本侧的 owner/预算/邮件。混进 sessions 会让每一处「按会话鉴权」 都要先判断这条到底是不是真的本侧会话
  • 整表替换而非增量合并:平台侧删掉的会话必须从候选里消失 —— session 位是三态语义,指向不存在的会话直接 404
  • platform_sessions 省略与传空数组语义不同:拉不到列表时省略该字段 (保留镜像),传空数组的语义是「平台侧确实一条会话都没有」
  • subagent 子会话不上报:实测 DSH 一次列出 49 条子会话,标题就是派活的 提示词前缀(九条都叫 You are auditing ONE fileslug 全撞名; 它们是父 agent 内部的工作单元,人往里发邮件毫无意义
  • slug 撞名只留最近那条:服务端只能取其中一条,上报同名项只会让补全里 出现几个点哪个都不确定的候选
  • SuggestSessionCandidates 取代 SuggestSessionsFor:以会话自己的 workspace 为权威,历史会话(该列为空)回退到 mails 反推 —— 升级后老会话不该从候选列表里消失
  • 补全候选带标题与来源:suggestions 保留纯字符串数组(不打破已部署的前端 与第三方客户端),新增同序的 candidates;过滤时标题也参与匹配 —— 人记得的是「缓存选型」而不是 brisk-harbor 这种随机短名

7.8 跨主机 Agent协议层面已支持注册中心不做

原计划的 Gateway + Registry 拆分与 etcd/Consul 注册取消。 它要解决「Gateway 怎么找到 Agent」而这个问题在本架构里不存在 连接方向是单向的 —— Agent 主动连 GatewayGateway 从不外呼。 远端 Agent 只需要一个公网 URL 加一把密钥,被叫方自己会打进来。

  • Agent 从另一台主机经公网完成注册 / 心跳 / 收件箱 / SSE 长连 2026-09-02 用 deploy/remote-agent-demo.py 验证,纯标准库 60 行)
  • 心跳带模型目录与平台会话快照同样跨主机可用
  • (运维便利,不阻塞)一条命令为远端主机建密钥并打印环境变量
  • (运维便利,不阻塞)密钥轮换
  • (运维便利,不阻塞)agents.host_url 由心跳自报填充

细节见 docs/PHASE7-REMAINING.md 的「7.8 跨主机 Agent」一节。

7.9 插件自动转发 + 会话往返预算(已完成)

用户提出的三条原则,逐条落地:

原则一:插件应当自动转发 Agent 平台原生的问询与权限请求,而不是让模型自己调工具。

原实现有个 request_permission 工具让模型主动调 —— 这是把 harness 的职责推给模型: 它可能忘了调,也可能在不需要时乱调,而真正被 opencode 拦下的那次询问反而没人看见

  • 删掉 request_permission 工具,改用 permission.ask 钩子接管
  • 钩子里只记下待决策项并转出邮件,output.status 保持 "ask" —— 不在钩子里阻塞等人回复:那是同步钩子,卡住会把整个 opencode 请求挂死
  • opencode 的三态权限映射成人话:同意 = once、一直同意 = always、拒绝 = reject
  • 人类决策后走 SSE 回来,用 client.postSessionIdPermissionsPermissionId 回复原生 permission 让 opencode 自己恢复原来的工具调用 —— 不再往会话里塞「你的请求已批准」的文字干扰它
  • 转发失败时不接管(保持 ask),让本地 TUI 弹窗兜底,而不是让 Agent 干等
  • permission.replied 事件清理待决策记录,避免邮件决策回来又去回复一条已结案的 permission

原则二:插件应当自动转发 Agent 最后一条总结性消息,且不消耗配额。

  • 触发点选 session.idle(一轮跑完)而不是 message.updated 后者在流式生成中反复触发,转出去是半截话
  • 只取 time.completed 非空的 assistant 消息:未完成/被中断的不转
  • mailContexts 记住该会话最近一封来信的发件人与 mail_id 总结回给它并带 reply_to,回信才落回同一线索
  • 提示语相应改写:告诉模型「回信不用你自己发,把话说完就行」, 只在需要主动联系他人或带附件时才调 send_mail

原则三(基本原则):插件自动转发的邮件不消耗配额。

配额存在的意义是防止 Agent 无限自我循环。harness 代劳的搬运不属于此列 —— 对它收费会导致配额用尽时 Agent 连交代都做不了,而那正是最需要它说话的时刻。

  • mails.relay + relay_key 参数;relayKinds 白名单只有 permission / summary (不是任意字符串,否则 relay:"anything" 就是绕过配额的后门)
  • 防滥用不靠计数,靠幂等键relayed_mails(agent_name, relay_key) 主键唯一。 relay_key 是上游那条消息的稳定 idpermission id / assistant message id 由平台生成、模型伪造不出来。于是插件重试与 SSE 重放不产生第二封, 想多转就得拿出不同的上游消息 id
  • 判断与占用在同一条 INSERT 里(靠唯一约束):分成「先查再插」两步的话, 插件的两次重试会双双通过检查各插一条
  • 建邮件失败时 ReleaseRelay 归还名额,否则那条上游消息永远转不出来了
  • 重复转发返回 200 {"status":"duplicate_relay"} 而非报错 —— 重复是插件重试的正常结果,不是故障
  • 响应带 quota_charged: false,免得插件看到额度没变以为数据错了
  • permission_decision 事件回传 relay_key + relay_kind 两边 id 空间不同,插件要拿上游 id 才能回复 opencode 这个映射必须服务端持久化,插件重启后内存映射就没了

配额下沉到会话(用户:配额应当在新建邮件、以及邮件对话页面是可编辑的)

配额的真实语义是「这件事值得多少个来回」—— 那是任务的属性,不是 Agent 的属性。 只有 agents.max_rounds 一个全局计数器时有两个问题:并行任务互相抢额度; used_rounds 单调递增,跑满就得管理员手工重置才能再干活。

  • sessions.max_rounds / used_rounds0 = 本会话不限)
  • 写信时给:POST /me/mail/sendmax_rounds,仅新建会话时生效 —— 续谈也接受的话,每封新信都会悄悄改掉对方正在遵守的预算
  • 对话页里改:GET/PUT /sessions/{id}/budgetmax_roundsreset 可同时给 (「加到 20 并从头算」是一次很自然的操作,拆两个请求只多一次往返)
  • 额度只有这一层(后续修正):原先叠了一层 Agent 终身额度, 但那种额度跑满要人工重置才能再干活,已降级为纯统计。见 7.3
  • 绕过手段Agent 用 .new 开一串会话)由新建会话速率限制堵住,见 7.3
  • 判断与自增在同一条 UPDATEWHERE used_rounds < max_rounds 40 并发 vs 上限 10 的单测覆盖,-race 通过
  • 允许把上限调到低于已用次数:那表示「就到这里为止」,是人的合法意图
  • 前端ComposePage 的「往返预算」输入框 + MailView 会话头部的 BudgetEditor (点徽标就地编辑,可改上限/重置/取消);预算变更广播 session_update
  • 老库补列默认 0不限引入预算不该把已在进行的会话卡死

Agent 侧标记已读(这一轮顺带修的真实缺陷)

原实现 Agent 只能读收件箱,没有任何办法把邮件标掉 —— 生产库里 opencode 名下 积了 31 封未读,每次 read_inbox 都把同一批旧邮件重新捞出来, 处理过的信和新来的信混在一起,模型分不清哪封该回;心跳里的未读数也只增不减。

  • POST /mail/readAgent 认证):给 mail_ids 标指定几封,不给则全部标掉
  • 鉴权写进 UPDATEWHEREto_name = $1 OR cc 含 $1)而不是先查后改: 不是发给自己的邮件根本改不动,既省一次查询,也没有「查完到改之间邮件被转走」的窗口
  • 别人的 id 混在批次里不报错,只是不被标掉 —— 报错会让整批失败, 而 Agent 通常把上一轮列出的 id 原样传回,其中可能混着已读的(幂等)
  • 「全部标掉」排除已归档会话:那些邮件在收件箱里看不到, 标了只会让「标记了 N 封」与用户看到的对不上
  • 一批上限 200畸形 id 一律 400不静默跳过那会让调用方以为标成功了
  • 插件 read_inbox 读完自动标掉本次列出的那些(不是全部未读 —— limit 之外的还没看过,一并标掉等于让它们凭空消失); 标记失败不让 read_inbox 失败,代价只是下次重复看到
  • markread_test.go 5 个用例 + 端到端 10 组(含抄送、归档、跨 Agent 越权)

验证:单测 relay_test.go10 个用例,含「免配额类型恰好两种」的防扩散断言)+ budget_test.go6 个,含 40 并发不刷穿)。端到端 8 组: 自主发信扣额 → 用尽后 relay 照样发出且不扣 → 同 key 幂等 → 换 key 可再转 → 参数校验 4 项 → 权限询问幂等 + 决策回传 relay_key → 对话页调预算 → 两层独立且全局拦下时会话退回 → 老库补列不丢数据。


里程碑时间线

时间 里程碑 验收标准
Week 1 末 后端核心完成 Gateway 启动 + 所有 API curl 可通
Week 2 末 插件完成 opencode 可收发邮件 + 钩子自动转邮件
Week 3 末 前端完成 三栏 UI + 三段式补全 + SSE 实时更新
Week 4 末 多用户完成 两个人类账号互不可见对方邮件,登录鉴权生效
Week 5 末 密钥体系完成 Agent 用密钥注册、用户密钥与 Agent 密钥隔离
Week 6 末 MVP 闭环 人→Agent→人 完整工作流可演示
Week 7+ 进阶功能 对话树 / 转发 / 配额 / DSH 插件

实际状态Phase 1-6 全部完成,已 systemd 部署并端到端验证(含密钥认证、 session 三态语义、平台命名同步、权限闭环、并发与鉴权边界)。剩余仅 Phase 7。


当前进度

已完成

后端Go

  • 项目结构与 MVP 技术规格书
  • schema 两方言各一份users / user_sessions / agents / sessions / mails / permission_requests
  • 数据模型 + 连接池 + 内嵌迁移器
  • Repository 层(含联系人聚合、归档、三段式补全查询)
  • 三维寻址 name@path.session 解析器 + 单测(internal/models/address.go
  • session 位三态语义:省略 → 默认会话(复用最近活跃,无则建);new → 强制新建; 具体别名 → 必须已存在且该收件人参与过,否则 404「无法送达」不静默新建
  • 会话别名.new 发信时可传 session_alias 命名,PUT /sessions/:id/alias 事后改名; 全局唯一(部分唯一索引),保留字 new. / @ 空白一律拒绝
  • 别名默认来自 Agent 平台自己的命名机制POST /sessions/:id/sync 接收平台 slug + 模型生成的摘要标题; 插件不传占位 title让平台正常生成撞名自动追加 -2/-3同步永不失败
  • 抄送 cc_list JSONB + GIN 索引 + 收件箱抄送可见
  • SSE 管理器(按 agent 分流推送 + 心跳保活)
  • Agent 认证中间件 + 注册/心跳
  • 邮件收发 / 权限请求与决策 / 会话 / 联系人 / 归档 API
  • 静态资源 go:embed单二进制内含前端
  • 密钥认证体系agent_keys / user_keys+ 三种生命周期 + SSE 鉴权加固
  • SQLite 默认后端 + DATABASE_URL 切外部 PostgreSQL方言差异收在 internal/db
  • systemd 部署deploy/install.shopencode serve 加访问密码

前端React

  • 三栏布局60px 图标栏 / 320px 列表 / 右侧主区
  • 新建邮件为右侧整页(非弹窗)
  • 三段式地址补全(接 /contacts/suggest,支持方向键)
  • 联系人面板 + 归档(二次确认)
  • 邮件详情 / 会话线程 / 权限卡片 / 回复栏
  • 全站纯 SVG 图标,不使用 emoji
  • SSE 自动重连(指数退避)+ session_archived 即时移除

部署

  • 公网 mail.jianfgit.xyz 可访问portal-nginx → .60:8180

下一步

MVP 计划Phase 1-6已全部落地并在 systemd 部署态实测通过。剩余工作都在 Phase 7 进阶功能

  • 对话树(不建 tree_nodes 表,用 parent_mail_id 递归 CTE + 按方向分块加载)
  • 转发(引用原文 + 附件随行;抄送早已完成)
  • 配额机制(只限发信不限收信,判断与自增在同一条 UPDATE
  • 附件(内容寻址磁盘存储,上传与发信两步)
  • WebAPI 化WebUI 与第三方客户端同一套 API
  • Agent 在邮件正文里主动提议改会话别名(平台命名自动同步已完成)
  • 插件自动转发平台原生权限询问与最终总结(不消耗配额)
  • 配额下沉到会话:写信时给、对话页里随时改
  • 工作列表卡片视图(中间栏,与列表视图切换)
  • DeepSeek Harness 插件(dsh-mail-bridge
  • 平台会话快照同步:工作区下的历史会话可在写信时选中
  • 插件适配方法固化为 docs/PLUGIN-CONTRACT.md(能力矩阵 / 行为约定 / 降级语义 / 线协议 / 不变量 / 验收清单)
  • 跨主机 Agent 发现Gateway + Registry 拆分)

7.10 窄屏适配(已完成)

原实现只有三栏并排60导航+ 320列表+ 详情,在 375px 屏上详情栏被挤到 不足 0 —— 用户反馈「窄屏基本不可用」。

第一版我做成了「窄屏一次只显示一栏」(分栏切换),用户纠正应当是 新页面覆盖老页面并带动画,于是重做为覆盖式。

  • useIsNarrow()matchMedia('(max-width: 767px)')。 用 matchMedia 而不是监听 resize —— 后者每变化一像素都触发还得自己节流, 前者只在跨过阈值时回调一次
  • NarrowStack:底层(列表)始终挂载,覆盖层(详情)绝对定位盖在上面。 两个实际好处:列表滚动位置与选中态天然保留;退出动画有东西可播 —— 直接卸载再渲染另一个组件的话,没有任何一帧能让旧页面往右滑出去
  • 因此必须区分「逻辑上是否打开」与「是否还在 DOM 里」: 关闭时先播 200ms 滑出,动画结束才卸载
  • 入场用双层 requestAnimationFrame:必须让浏览器至少绘制一帧 「在右侧之外」的状态,否则挂载与 translate-x-0 在同一帧内完成, transition 根本不触发(单层 rAF 在 Safari 上偶尔仍被合帧)
  • motion-reduce:transition-none 尊重 prefers-reduced-motion
  • 打开覆盖层时底层 aria-hidden,否则屏幕阅读器会读到两层内容
  • 导航:竖条在窄屏让位给底部 NarrowNav60px 在手机上白占一成宽度, 而底部横排拇指够得到)。曾经还有一个抽屉式侧栏,后来删掉了 —— 见 7.10.1
  • env(safe-area-inset-bottom)iPhone 手势条会盖住最后一排
  • 窄屏专属控件用条件渲染而非 md:hidden:后者只是视觉隐藏, 元素仍在 DOM 与 tab 序列里,宽屏用户按 Tab 会聚焦到看不见的返回按钮上。 为此抽了 NarrowOnly / BackButton 两个组件 (原先还有 NavToggle,随抽屉一起删除)
  • 列表栏 w-full md:w-[320px];各页横向内边距 px-4 md:px-6 px-6 在 375px 屏上白吃 48px
  • 管理页的 3/4 列 grid 改响应式;列表行 flex-wrap (宁可占两行,不要把每列挤成看不清的窄条)
  • 详情页与写信页都有返回出口 —— 否则窄屏进去就出不来。 写信页用 cancelCompose 而不是 showList:写信态要一起结束, 只滑走覆盖层的话下次进列表又会弹回来
  • narrowPane 在宽屏下也维护:否则从窄屏拖宽再拖回来, 用户会发现自己回到了列表,刚打开的邮件不见了
  • web/test/narrow-layout.test.mjs:结构性断言(现 28 条), 钉住「覆盖而非分栏」「延迟卸载」「双层 rAF」「条件渲染而非 md:hidden」 「无裸 px-6」等不变量。不做像素级视觉快照 —— 字体差异下极脆

7.10.1 真机尺寸实测与修复2026-09-02

上面那些都是「照着规则写对」,实际用 playwright 连本机共享 Chromium 在 390pxiPhone 14 Pro与 320pxiPhone SE量了一遍发现一个功能性 bug 加五处可用性问题。详细记录见 docs/PHASE7-REMAINING.md

  • 删掉抽屉式侧栏。它是 fixed ... z-50 且铺满视口高度,把底部导航 最左那一项盖住点不到(elementFromPoint 命中抽屉里的 SVG。 修法不是给导航加 z-index 而是删掉抽屉:它装的六项与底部导航完全重复, 唯一独有的是退出登录 —— 为一个按钮维护一套 fixed 层级 + 遮罩不划算, 而它还附带了「Esc 关不掉」「底层未锁滚」两个毛病
  • 退出登录移到「我的」页:与密码、密钥同属「账号自身」, 而那页此前根本没有退出入口
  • .tap 工具类44x44 触摸命中区,用居中的透明伪元素实现, 视觉尺寸一像素不动。实测详情页工具按钮只有 15-16px 高 「抄送」20x15直接加 padding 会把头部撑散、320px 下换行。 只在 max-width: 767px 生效 —— 桌面精度足够,且扩大后的命中区 在密排工具栏里会互相重叠
  • .reveal 工具类opacity-0 group-hover:opacity-100 在没有 hover 的设备上永远透明却仍然接收点击 —— 一个看不见却按得动的「归档」 比没有按钮更糟。改为默认可见,只在 (hover: hover) and (pointer: fine) 时隐藏(单看 hover 会把带触摸板的 平板算进去)
  • 对话树缩进随屏宽自适应:固定「每级 20px、上限 8 级」在 320px 下 把卡片压到 110px 可用宽度,发件人一行直接被 truncate 吃掉。 窄屏改为每级 10px、上限 5 级
  • 对话树补返回出口:原先只有「关闭」,而两者语义不同 —— 返回退出整个详情栏,关闭只收起树留在这封邮件上
  • AccountPage 根本没有滚动容器(用户反馈「我的页进去后无法滑动」)。 窄屏外壳是 h-full flex flex-col overflow-hidden、页面是 flex-1 flex flex-col,中间没有一层 overflow-y-auto —— 内容超出的部分直接被裁,滚不到也点不到。 实测 390px 下内容需 860px、容器 795px「退出登录」连同下面 65px 一起消失; 1280x800 的桌面上同样看不到。其余六个页面级组件都有这一层,只有它漏了
  • 登录页与初始化页在矮屏(横屏手机、软键盘弹出后)滚不到底: 卡片高约 371pxh-full flex items-center 在内容超高时让它上下同时 溢出,溢出到顶部那段滚不到(scrollTop 最小是 0。实测 568x280 下 「登录」按钮完全在视口外。改用卡片自己的 my-auto —— auto margin 在 空间不足时自动退化为 0于是矮屏变成正常的顶对齐可滚布局 而高屏仍然垂直居中390x844 与 1280x800 实测 centered=true

验证:web/test/manual/npm run test:narrow / test:wide 窄屏 18 项 + 宽屏 5 项全通过;结构性断言从 20 条扩到 37 条, 把每一条修复都钉住。

滚动那一项两边都加了检查:结构断言查「七个页面级组件都含 overflow-y-auto」 实测脚本的 scrollHealth() 查「每页都有滚动容器且没有内容被 overflow-hidden 的父级裁掉」。判据刻意是「滚动容器」而不是「当前正在滚动」—— 内容暂时不够高时后者为假,但页面是健康的。

那套脚本刻意留在仓库里而不是用完就删:结构性断言守不住「按钮实际多大、 点下去命中谁」,而这次最严重的 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.pathbash 不能
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-accessapproval: "never",而 ApprovalService.decide()if (effectivePolicy === "never") return "rejected" 在 waterfall 之前短路 —— 所以整个「权限转邮件」链路在 dsh 上从未真正跑起来过。 按档位下发 sandbox/modeworkspace 档的会话才会拿到 approval: ask

已完成

  • models/permission_mode.go:三档常量、NormalizePermissionMode(非法值 fail-closed 到默认档而非 fullModeAtMost(继承与取整共用一个判据)、 ModeNeedsHuman、native/advisory 强制力。12 例测试 + 2 组负向对照
  • schema 三处同步:sessions.permission_mode(旧库默认 workspace不追授全权sessions.permission_enforcement(旧库默认 advisory不替没自报的插件宣称 「档位在这里是被强制的」)、agents.mode_enforcement
  • repo/permission_mode.go:读写 + InheritedMode 继承 + AgentModeEnforcement
  • 读路径三处加列:GetSessionByID / ListSessionsFor / ListContactsFor
  • me.go 人发信可指定档位(人是权限的源头);非法值报 400 而不是静默用默认档 —— 他以为给了 plan 实际拿到 workspace比报错更坏
  • permission.go 按档位决定这次询问该不该存在删管理员兜底409 恢复可达
  • 日历会话给人类创建者设 owner否则删掉兜底后人建的提醒触发时 Agent 的 权限询问会因发件人是 calendar 而在线索上找不到人类 → 误伤成 409
  • SSE 与补拉路径下发 permission_mode / permission_enforcement (补拉路径必须有:否则 plan 档的任务在插件重启后悄悄变成 workspace 档)
  • 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 已返回源邮件(含 SessionIDInheritedMode(&src.SessionID, 默认档)。不修则 plan 档转发出去就升到 workspace
  • calendar_events 加列 permission_modeschema 三处同步)+ 日历投递接线: - 人建日程可指定;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插件接线

  • dshpresets.mount 注册工具 + applyPermissionModesandbox/mode + approval/policy + 心跳报 nativeaf61a37
  • opencodesession.create({permission}) 按六条实测结论下发 + 心跳报 native3bb419f
  • pitool_call hook 按档位判定plan 直接 block / workspace 问人 / full 放行) + 心跳报 native0c98fab
  • homeagentpermission_mode.go + advisory 提示词 + 心跳报 advisoryed37032

L5 实测结果2026-09-06

12 格矩阵已完成 8 格实测。关键发现:

平台 plan workspace full enforcement
pi bash block无权限询问 bash 一律问人(无法静态判路径) bash 直接执行 native
opencode briefing 生效,模型自愿遵守 briefing 生效 briefing 生效 advisory
dsh 写文件真被拦read-only 沙箱echo 命令仍可跑) 目录内可写;/tmp 属允许临时目录不询问 danger-full-access native
homeagent ⚠️ 只在提示词告知,模型不遵守 ⚠️ 只在提示词告知 ⚠️ 只在提示词告知 advisory

*opencode session.create 1.18.29 不支持 permission 参数,改为提示词 advisory 路径模型自愿遵守plan 档回信说明被拒。dsh Landlock ABI 版本旧导致 partial enforcement。

结论:pi 是唯一 100% 平台级强制。opencode advisory 路径模型自愿遵守。dsh 有真沙箱但内核 ABI 限制了完整性。

P5前端与文档

  • types / API / 卡片档位徽标 / 对话页档位选择器 / 新建邮件档位选择 (两个字段成对显示:要求档位 + 实际强制力be61724
  • docs/PLUGIN-CONTRACT.md 新章节 + 平台差异表补一行(已有)

已知取舍,尚未处理:

  • 前端有 Markdown XSS 与窄屏结构性回归,但没有渲染组件跑断言的测试
  • 深色主题未做
  • 窄屏实测脚本已入库(web/test/manual/),但没进 CI —— 要一个 headless 环境加一个测试用 Gateway 实例
  • 登录限速与新建会话限速已改为 DB 事务rate_limits 表),多实例部署不再各自计数
  • SQLite 抄送查询走 json_each 全表展开,无索引;单机量级下够用, 百万级邮件时需要加物化列或换回 PostgreSQL

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-5blob.Store 可枚举 + GC 反向扫盘,一次跑掉 7 个孤儿
  • Battachments 这类拼错字段名返回 400 且列出正确字段;日历创建带 status 仍 200
  • CGET /mail/{id} 返回 from_human / to_humanaddr-verify 两项转绿
  • D契约文档有 B-5.6 条款demo 按 from_human 决定是否回信

文件清单(完整)

agentmail/
├── docs/
│   ├── MVP-SPEC.md            # MVP 技术规格书
│   └── PLAN.md                # 本文件
├── gateway/
│   ├── cmd/server/main.go     # 入口 + 路由编排
│   ├── internal/
│   │   ├── config/config.go
│   │   ├── db/
│   │   │   ├── db.go              # 连接 + 方言适配SQLite / PostgreSQL
│   │   │   ├── migrate.go
│   │   │   └── migrations/
│   │   │       ├── init.sql        # PostgreSQL schema
│   │   │       └── init_sqlite.sql # SQLite schema默认
│   │   ├── models/
│   │   │   ├── models.go
│   │   │   ├── address.go         # name@path.session 解析
│   │   │   └── address_test.go
│   │   ├── repo/
│   │   │   ├── repo.go
│   │   │   ├── users.go
│   │   │   └── keys.go            # agent_keys / user_keys
│   │   ├── handler/
│   │   │   ├── helpers.go         # 错误映射 + 别名规范化
│   │   │   ├── alias_test.go
│   │   │   ├── agents.go
│   │   │   ├── mail.go
│   │   │   ├── me.go              # 人类自己的邮箱(原 human.go
│   │   │   ├── contacts.go
│   │   │   ├── permission.go
│   │   │   ├── sessions.go        # 含 POST /sessions/:id/sync
│   │   │   ├── events.go          # SSE鉴权后分流
│   │   │   ├── keys.go            # 密钥管理
│   │   │   ├── ratelimit.go       # 登录失败限速
│   │   │   └── auth.go
│   │   ├── sse/manager.go
│   │   ├── static/static.go       # go:embed 前端产物
│   │   └── middleware/
│   │       ├── auth.go            # Agent 鉴权
│   │       └── user.go            # 人类用户鉴权
│   └── go.mod / go.sum
├── plugins/
│   └── opencode-mail-bridge/      # 第一个接入平台
│       ├── package.json
│       └── index.js               # 凭证 + HTTP + SSE + 四个工具 + event 钩子
├── web/
│   ├── src/
│   │   ├── api/
│   │   │   ├── client.ts
│   │   │   └── sse.ts
│   │   ├── stores/
│   │   │   ├── mailStore.ts
│   │   │   ├── sessionStore.ts
│   │   │   ├── contactStore.ts
│   │   │   ├── uiStore.ts
│   │   │   └── authStore.ts
│   │   ├── components/
│   │   │   ├── icons.tsx          # 纯 SVG无 emoji
│   │   │   ├── Sidebar.tsx
│   │   │   ├── MailList.tsx
│   │   │   ├── ContactPanel.tsx
│   │   │   ├── MailView.tsx
│   │   │   ├── ComposePage.tsx    # 右侧整页写信
│   │   │   ├── AddressInput.tsx   # 三段式补全
│   │   │   ├── LoginPage.tsx
│   │   │   ├── SetupPage.tsx      # 首启初始化向导
│   │   │   ├── AccountPage.tsx    # 个人中心 + 连接密钥
│   │   │   ├── AdminUsersPage.tsx # 用户管理 / Agent 密钥
│   │   │   └── KeyPanel.tsx       # 两处共用的密钥面板
│   │   ├── types/index.ts
│   │   ├── App.tsx
│   │   └── main.tsx
│   ├── test/markdown-xss.test.mjs # XSS 回归npm test
│   ├── index.html
│   ├── package.json
│   └── vite.config.ts
├── deploy/
│   ├── agentmail-gateway.service
│   ├── opencode-serve.service
│   └── install.sh                  # 构建 + 嵌入 + systemd 一键装
└── README.md