Files
MailUI4Agents/gateway/internal/handler/agents.go
JianFeeeee a101c2fada 停用/恢复文案说清密钥不会自动回来 + 原子部署脚本
## 起因

本会话踩到一次:浏览器测试点了 opencode 的「停用」按钮验证确认流程,
停用连带撤销全部密钥。插件从此拿着已撤销的密钥重试了 18 小时
(gateway 日志 2690 次 401),而 UI 只说了「已停用」。

恢复时同样没提示「密钥不会自动回来」,点完恢复以为就完事了。

## 文案

后端 `AdminSetAgentStatus`:
- 停用 detail 补一句「停用期间别人发信给它会收到 409」
- 恢复 detail 改成「停用时撤销的密钥不会自动回来 —— 必须在密钥面板
  重新签发一把并写进该插件的配置,否则它会一直拿旧密钥重试并被拒(401)」
- 恢复响应加 `needs_new_key: true` 字段,前端可据此做更强的提示

前端 QuotaPanel:恢复成功的 notice 不再是「已恢复」四个字,
把重新签发这一步说全;面板说明与按钮 title 同步。

## AdminDeleteAgent 两处修正

- 硬编码 `name == "jianf"` 改成按 `repo.IsHumanUser` 判定 —— 换管理员时
  硬编码会失效,而人类账号不该走 Agent 删除端点
- detail 原来说「日历事件已保留」,实际 `DeleteAgent` 把 active 事件置为
  cancelled(留着会由调度器一直触发,而发信人已不存在)。文案改成实际行为

## deploy/redeploy-gateway.sh(新)

日常改后端不必重跑 install.sh(它重装 npm 依赖、重写 systemd 单元、
重新生成 env)。这个脚本做手工 `stop → cp → start` 不做的四件事:

- `sqlite3 .backup` 备份数据库 + 立即 `PRAGMA integrity_check` 复核。
  不用 cp:WAL 模式下 cp 会拿到主库与 -wal 不同步的快照
- `install -m 0755` 原子替换二进制。install 本质是 rename,要么完整
  换掉要么原样不动;cp 是就地写入,中途失败会留下半截文件且旧的已被覆盖
- 旧二进制留档并打印可直接粘贴的回滚命令
- 后置验证清单:服务 active / /health 可达 / 近 2 分钟无 panic /
  SSE 重连计数。任一项不过**自动回滚**,不「先上着再修」

`--dry-run` 只打印动作,`--skip-tests` 急救用,`--skip-web` 跳过前端同步。
纪律来自 git-release-discipline skill 第五章。

## 验证

- 脚本 dry-run + 真实跑通一次:备份 integrity_check=ok、原子替换、
  9 个 SSE 客户端重连、验证四项全绿
- 停用/恢复文案线上实测;`DELETE /admin/agents/jianf` → 403「是人类用户」
- 端到端:jianf → opencode「部署脚本验收」→ 回信「部署验收 OK」
- 全量测试:gateway 全包 / web 176 / opencode 217 / pi 288 / dsh 241 /
  homeagent go ok;三方共用模块同源检查通过
2026-09-05 10:10:13 +08:00

333 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"database/sql"
"errors"
"net/http"
"strings"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/go-chi/chi/v5"
)
// ---------- Agent ----------
type registerRequest struct {
Name string `json:"name"`
Secret string `json:"secret"`
Workspaces []models.Workspace `json:"workspaces"`
Platform string `json:"platform"`
}
// heartbeatRequest 是心跳可选带的上报体。
//
// 字段全可省:旧插件发空心跳,不能因为新增了上报就把它们报错。
type heartbeatRequest struct {
// PlatformSessions 是平台侧当前的会话快照(按最近活跃排序)。
//
// 为什么让插件上报而不是 Gateway 反向拉取:当前架构是单向的
// Agent 持密钥主动连 GatewayGateway 从不外呼)。反向拉取需要 Gateway
// 保存各平台的地址与凭证,那是另一套信任模型。
//
// nil 与空数组语义不同nil = 本次不上报(保留现有镜像),
// 空数组 = 平台侧确实一条会话都没有(清空镜像)。
// 拿不到会话列表的插件应当省略该字段,而不是传空数组把镜像抹掉。
PlatformSessions []repo.PlatformSession `json:"platform_sessions"`
// Models 是平台当前看得见的模型目录,供配置页勾选。
//
// 随心跳上报而不是只在注册时上报:模型清单会在运行中变
// (换 provider 配置、上游上下线、换了 API key。只在注册时报一次的话
// 目录会静静变陈,而管理员在配置页上看到的是上次重启时的快照 ——
// 选中一个平台已经调不到的模型,失败要到真发邮件时才暴露。
//
// 与 PlatformSessions 同一约定nil = 本次不上报(保留现有目录),
// 空数组 = 平台确实一个模型都拿不到。拿不到目录时必须省略:
// 清空目录会让配置页变成空白,管理员以为该平台没有任何可用模型。
Models []repo.CatalogModel `json:"models"`
}
// POST /api/v1/agent/register
//
// 两种认证方式:
// 1. Authorization: Bearer <agent_key_token> —— 密钥认证(推荐)。
// 密钥未绑定时用本请求的 name 落定;已绑定时 name 必须与之一致,
// 否则等于拿别人的密钥冒充新身份。
// 2. body 里带 secret —— 旧方式,兼容保留。
func RegisterAgent(w http.ResponseWriter, r *http.Request) {
var req registerRequest
if !DecodeBody(w, r, &req) {
return
}
if req.Name == "" {
Error(w, http.StatusBadRequest, "Missing name")
return
}
keyToken := middleware.BearerToken(r)
if keyToken == "" && req.Secret == "" {
Error(w, http.StatusBadRequest, "需要 Authorization: Bearer <密钥> 或 body 里的 secret")
return
}
if keyToken != "" {
bound, err := repo.VerifyAgentKey(r.Context(), keyToken)
if err != nil {
writeKeyErr(w, err)
return
}
if bound != "" && bound != req.Name {
Error(w, http.StatusForbidden,
"该密钥已绑定到 Agent \""+bound+"\",不能用于注册 \""+req.Name+"\"")
return
}
}
if req.Platform == "" {
req.Platform = "pi"
}
// 三维地址的 name 位与人类用户名共用命名空间,不得重名
if ok, err := repo.AgentNameAvailable(r.Context(), req.Name); err != nil {
Error(w, http.StatusInternalServerError, "Failed to validate agent name")
return
} else if !ok {
Error(w, http.StatusConflict, "该名称已被人类用户占用")
return
}
if req.Name == "human" {
Error(w, http.StatusBadRequest, "human 是保留别名,不能作为 Agent 名")
return
}
// 已退役的名字不可重建 —— 历史邮件的署名由此不会被冒用
if retired, err := repo.IsRetiredAgentName(r.Context(), req.Name); err != nil {
Error(w, http.StatusInternalServerError, "Failed to check agent name")
return
} else if retired {
Error(w, http.StatusConflict, "该名字已退役,不可重建(历史邮件署名保护)")
return
}
// 密钥认证时不需要 secret但 agents.secret 非空约束仍在;
// 存密钥本身作占位,旧的 name/secret 路径不受影响。
secret := req.Secret
if secret == "" {
secret = keyToken
}
if err := repo.CreateOrUpdateAgent(r.Context(), req.Name, secret, req.Platform, req.Workspaces); err != nil {
// 已停用的 Agent 不得靠重新注册复活。回 403 而不是 500
// 这是一个明确的策略拒绝,插件应当停止重试并把原因打出来。
if errors.Is(err, repo.ErrAgentDisabled) {
Error(w, http.StatusForbidden,
"Agent \""+req.Name+"\" 已被管理员停用,无法注册。"+
"如需重新启用,请在管理页「默认预算」里恢复它。")
return
}
Error(w, http.StatusInternalServerError, "Failed to register agent")
return
}
// 待绑定密钥在首次注册成功后落定到该 Agent
if keyToken != "" {
if err := repo.ClaimAgentKey(r.Context(), keyToken, req.Name); err != nil {
Error(w, http.StatusInternalServerError, "Failed to bind key")
return
}
}
JSON(w, http.StatusOK, map[string]string{
"status": "registered",
"agent_name": req.Name,
})
}
// POST /api/v1/agent/heartbeat
func HeartbeatAgent(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
pending, err := repo.HeartbeatAgent(r.Context(), agentName)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to heartbeat")
return
}
// 可选的平台会话快照。解不开就当作没带:心跳的主职责是「我还活着」,
// 不该因为上报体格式不对就把 Agent 判成离线。
var req heartbeatRequest
if r.ContentLength > 0 {
_ = Decode(r, &req)
}
syncedSessions := -1 // -1 = 本次未上报
if req.PlatformSessions != nil {
if err := repo.ReplacePlatformSessions(r.Context(), agentName, req.PlatformSessions); err != nil {
// 镜像写失败只影响候选补全,不影响投递,因此不报错
syncedSessions = -1
} else {
syncedSessions = len(req.PlatformSessions)
}
}
// 模型目录同理:写失败只让配置页看到的目录陈一轮,下一次心跳会补上。
syncedModels := -1
if req.Models != nil {
if err := repo.ReplaceModelCatalog(r.Context(), agentName, req.Models); err == nil {
syncedModels = len(req.Models)
}
}
// 心跳回传该 Agent 的累计统计与新任务默认预算。
//
// 不再回传「剩余额度」:额度属于具体任务(会话)而不属于 Agent
// 剩余往返随每次发信响应budget_remaining回传在那里才有意义。
stats, sErr := repo.GetAgentStats(r.Context(), agentName)
if sErr != nil {
// 统计读不到不影响心跳本身
stats = repo.AgentStats{AgentName: agentName}
}
resp := map[string]interface{}{
"status": "ok",
"pending_mails": pending,
"stats": stats,
}
if syncedSessions >= 0 {
resp["platform_sessions_synced"] = syncedSessions
}
if syncedModels >= 0 {
resp["models_synced"] = syncedModels
}
// 回传当前生效的模型范围,插件无需另起一个请求去读。
//
// 随心跳回传而不是让插件自己轮询:管理员在配置页改了范围后,
// 插件最多一个心跳周期30 秒)就能看到新值,不需要重启。
if allowed, aErr := repo.ListAllowedModels(r.Context(), agentName); aErr == nil {
resp["allowed_models"] = allowed
resp["models_unrestricted"] = len(allowed) == 0
}
JSON(w, http.StatusOK, resp)
}
// GET /api/v1/agents
func ListAgents(w http.ResponseWriter, r *http.Request) {
statusFilter := r.URL.Query().Get("status")
agents, err := repo.ListAgents(r.Context(), statusFilter)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list agents")
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"agents": emptySlice(agents),
})
}
// ---------- 停用 / 恢复 ----------
type setAgentStatusRequest struct {
// Disabled true = 停用false = 恢复
Disabled bool `json:"disabled"`
}
// PUT /api/v1/admin/agents/{name}/status —— 停用或恢复一个 Agent
//
// 停用是可逆的「归档」,比 DELETE 轻一档:
// - 邮件、会话、权限记录、转发幂等键全部保留(往来里有一半是人自己写的)
// - 从地址补全、GET /agents、可授权范围里消失
// - 全部密钥被撤销,插件拿不到新任务也发不出信
// - 重新注册会被拒(否则插件下次启动就把它复活了)
// - 别人发信给它得到 409见 repo.RecipientDeliverable
//
// 与 DELETE 的分工:停用留着运行态随时可恢复,删除清掉运行态且名字退役。
func AdminSetAgentStatus(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(chi.URLParam(r, "name"))
if name == "" {
Error(w, http.StatusBadRequest, "Missing agent name")
return
}
var req setAgentStatusRequest
if !DecodeBody(w, r, &req) {
return
}
revoked, err := repo.SetAgentDisabled(r.Context(), name, req.Disabled)
if errors.Is(err, sql.ErrNoRows) {
Error(w, http.StatusNotFound, "Agent 不存在: "+name)
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to update agent status")
return
}
resp := map[string]any{
"agent_name": name,
"disabled": req.Disabled,
}
if req.Disabled {
resp["keys_revoked"] = revoked
resp["detail"] = "已停用。邮件与会话保留;该 Agent 的密钥已全部撤销," +
"恢复后需要重新签发。停用期间别人发信给它会收到 409。"
} else {
resp["needs_new_key"] = true
resp["detail"] = "已恢复为离线状态。停用时撤销的密钥不会自动回来 —— " +
"必须在「密钥」面板重新签发一把并写进该插件的配置," +
"否则它会一直拿旧密钥重试并被拒401。"
}
JSON(w, http.StatusOK, resp)
}
// DELETE /api/v1/admin/agents/{name}
//
// 删除 Agent 的全部运行态,保留邮件历史。
//
// 取舍(见 PLUGIN-CONTRACT.md
// - 邮件与会话不删(是审计凭据,且往来里有一半是人自己写的)
// - 它建的日历事件置 cancelled留着会由调度器一直触发发信人却已不存在
// - 名字立即不可重建Agent 名与人类用户名共用命名空间,
// 否则下一个同名注册者会看起来像是历史邮件的发信人)
func AdminDeleteAgent(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(chi.URLParam(r, "name"))
if name == "" {
Error(w, http.StatusBadRequest, "Missing agent name")
return
}
// 人类账号不走这个端点。硬编码某个用户名会在换管理员时失效,
// 所以按「是不是人类用户」判定。
if isHuman, hErr := repo.IsHumanUser(r.Context(), name); hErr != nil {
Error(w, http.StatusInternalServerError, "Failed to check recipient")
return
} else if isHuman {
Error(w, http.StatusForbidden,
"\""+name+"\" 是人类用户,不能用这个端点删除(请去用户管理)")
return
}
keysRevoked, err := repo.DeleteAgent(r.Context(), name)
if errors.Is(err, sql.ErrNoRows) {
Error(w, http.StatusNotFound, "Agent 不存在: "+name)
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to delete agent")
return
}
JSON(w, http.StatusOK, map[string]any{
"agent_name": name,
"keys_revoked": keysRevoked,
"detail": "已删除。邮件与会话保留(审计凭据);" +
"密钥、平台会话镜像、模型范围已清除;它建的日历事件已置为取消。" +
"此名字今后不可再注册(历史邮件的署名由此不会被冒用)。",
})
}