feat: 权限档位体系(三档 plan/workspace/full + 四桥 from_session_id)

L2 核心改动:sessions 表补 permission_mode / permission_enforcement 两列
(sqlite + pg 同步),三桥 lib/permission-mode.js 翻译档位到平台原生配置,
homeagent advisory 模式提示词告知模型实际强制力。四桥全部携带 from_session_id
供 relay 去重与会话回溯。

FromHuman / ToHuman 判据已加入心跳 payload 与 notify/mail.go。
This commit is contained in:
2026-09-06 15:16:49 +08:00
parent 13fcb00acc
commit a44fd6949b
32 changed files with 3462 additions and 177 deletions

View File

@ -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 = eidGateway 重放时发旧 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)