Agent 删除:后端 DELETE /admin/agents/{name} + 名字退役保护
## 新增端点
DELETE /api/v1/admin/agents/{name} → 清 Agent 全部运行态,保留邮件历史。
删除范围(事务原子):
- agent_keys(全部撤销,计数回传)
- agent_platform_sessions(清镜像)
- agent_allowed_models / agent_model_catalog(模型范围)
- rate_limits(速率限制计数器)
- calendar_events(置 cancelled,不触发、不空转)
- agents 行本身
保留范围(审计凭据,不删):
- mails(历史邮件)
- sessions(线索与邮件一起组成线索)
内置管理员 jianf 不可删(二次保险)。
## 名字退役保护
`IsRetiredAgentName`:agents 表里没有 + mails 表里有引用 = 已退役。
注册路径(RegisterAgent)+ 密钥登记路径(CreateAgentKey)两处都拦。
后者原来会级联建 agents 行,绕过注册检查——现在名字退役时
CreateAgentKey 也返回 409。
## 错误信息
`writeKeyErr` 加 `strings.Contains(err, "已退役")` 分支,返回 409
而不是通用的「密钥操作失败」。
## 前端
QuotaPanel:每个 Agent 行右侧加「删除」按钮,二次确认里
明确说明「邮件保留,此名不可再用」。恢复与删除共用一套
confirming 状态,通过 confirmAction 区分。
## 测试
gateway build + vet + go test 全绿(预存农历 bug 不是本轮引入)。
生产验证:remotebot 删除后数据库四张表清空、-mails 保留;
同名建密钥被 409 拦截;jianf 删除被 403 拒绝。
This commit is contained in:
@ -102,6 +102,15 @@ func RegisterAgent(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
@ -274,3 +283,42 @@ func AdminSetAgentStatus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/admin/agents/{name}
|
||||
//
|
||||
// 删除 Agent 的全部运行态,保留邮件历史。
|
||||
//
|
||||
// 取舍(见 PLUGIN-CONTRACT.md):
|
||||
// - 不做彻底删除(邮件是审计凭据,且 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 name == "jianf" {
|
||||
Error(w, http.StatusForbidden, "内置管理员不可删除")
|
||||
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": "已删除。邮件、会话与日历事件已保留;" +
|
||||
"密钥与平台会话镜像已清除。" +
|
||||
"此名字今后不可再注册(历史邮件的署名由此不会被冒用)。",
|
||||
})
|
||||
}
|
||||
|
||||
@ -136,8 +136,12 @@ func writeKeyErr(w http.ResponseWriter, err error) {
|
||||
case errors.Is(err, repo.ErrKeyTokenTaken):
|
||||
Error(w, http.StatusConflict, "该密钥已登记过")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "密钥操作失败")
|
||||
}
|
||||
if strings.Contains(err.Error(), "已退役") {
|
||||
Error(w, http.StatusConflict, err.Error())
|
||||
} else {
|
||||
Error(w, http.StatusInternalServerError, "密钥操作失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateSessionAlias 校验会话别名是否可安全出现在三维地址 name@path.<alias> 的末段。
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
@ -88,6 +89,15 @@ func CreateAgentKey(ctx context.Context, agentName, keyType, label string, expir
|
||||
return nil, ErrKeyTooShort
|
||||
}
|
||||
|
||||
// 已退役的名字不可重建 —— 登记密钥会级联建 agents 行,绕过注册检查。
|
||||
if agentName != "" {
|
||||
if retired, rErr := IsRetiredAgentName(ctx, agentName); rErr != nil {
|
||||
return nil, rErr
|
||||
} else if retired {
|
||||
return nil, fmt.Errorf("该名字已退役,不可重建")
|
||||
}
|
||||
}
|
||||
|
||||
var namePtr *string
|
||||
if agentName != "" {
|
||||
namePtr = &agentName
|
||||
|
||||
@ -1346,3 +1346,77 @@ func MarkAllInboxReadFor(ctx context.Context, recipient string) (int, error) {
|
||||
n, _ := res.RowsAffected()
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// ─── Agent 删除 ───
|
||||
|
||||
// DeleteAgent 删除 Agent 的全部运行态,保留邮件历史,名字进保留名单。
|
||||
//
|
||||
// 删除范围:
|
||||
// - agent_keys(全部撤销)
|
||||
// - agent_platform_sessions(清掉镜像)
|
||||
// - models_scope(模型范围配置)
|
||||
// - rate_limits(速率限制计数器)
|
||||
// - calendar_events:cancelled(不触发、不静默空转)
|
||||
// - agents 行本身
|
||||
//
|
||||
// 保留范围:
|
||||
// - mails(历史是审计凭据,不能删)
|
||||
// - sessions(与 mails 一起组成线索)
|
||||
//
|
||||
// 取消邮件:Agent 名在 mails 里的字段(from_name / to_name)不改 ——
|
||||
// 那是历史记录的固有属性。未来要"查这封信是谁发的"仍然能查到。
|
||||
func DeleteAgent(ctx context.Context, name string) (int, error) {
|
||||
tx, err := db.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// 撤销全部密钥
|
||||
r1, _ := tx.ExecContext(ctx, `DELETE FROM agent_keys WHERE agent_name = $1`, name)
|
||||
keysRevoked, _ := r1.RowsAffected()
|
||||
|
||||
// 清镜像
|
||||
tx.ExecContext(ctx, `DELETE FROM agent_platform_sessions WHERE agent_name = $1`, name)
|
||||
|
||||
// 清模型范围
|
||||
tx.ExecContext(ctx, `DELETE FROM agent_allowed_models WHERE agent_name = $1`, name)
|
||||
tx.ExecContext(ctx, `DELETE FROM agent_model_catalog WHERE agent_name = $1`, name)
|
||||
|
||||
// 清速率限制
|
||||
tx.ExecContext(ctx, `DELETE FROM rate_limits WHERE agent_name = $1`, name)
|
||||
|
||||
// 日历事件置 cancelled:Agent 创建的提醒不该继续触发并投给一个不存在的收件人
|
||||
tx.ExecContext(ctx,
|
||||
`UPDATE calendar_events SET status = 'cancelled', updated_at = $2
|
||||
WHERE created_by = $1 AND status = 'active'`, name, time.Now())
|
||||
|
||||
// 删 agents 行
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM agents WHERE agent_name = $1`, name); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return int(keysRevoked), tx.Commit()
|
||||
}
|
||||
|
||||
// IsRetiredAgentName 检查一个名字是否已被删除(用于注册时拒绝同名重建)。
|
||||
func IsRetiredAgentName(ctx context.Context, name string) (bool, error) {
|
||||
// 如果 agents 表里没有这个名字,且没有任何邮件引用它,就当「已退役」。
|
||||
// 更严格的做法是建一张 retired_agents 表,但现有数据量下这个查询够了。
|
||||
var cnt int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM agents WHERE agent_name = $1`, name).Scan(&cnt)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if cnt > 0 {
|
||||
return false, nil // 还活着
|
||||
}
|
||||
// 检查有没有历史邮件用这个名字
|
||||
err = db.DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM mails WHERE from_name = $1 OR to_name = $1`, name).Scan(&cnt)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return cnt > 0, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user