diff --git a/gateway/cmd/server/main.go b/gateway/cmd/server/main.go index bf6b3b3..64bb408 100644 --- a/gateway/cmd/server/main.go +++ b/gateway/cmd/server/main.go @@ -240,6 +240,7 @@ func main() { // 没有「彻底删除」—— Agent 名与人类用户名共用命名空间, // 删掉后同名注册者会让历史邮件看起来像是他发的。 r.Put("/admin/agents/{name}/status", handler.AdminSetAgentStatus) + r.Delete("/admin/agents/{name}", handler.AdminDeleteAgent) }) }) diff --git a/gateway/internal/handler/agents.go b/gateway/internal/handler/agents.go index 77ea356..ec4b2b5 100644 --- a/gateway/internal/handler/agents.go +++ b/gateway/internal/handler/agents.go @@ -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": "已删除。邮件、会话与日历事件已保留;" + + "密钥与平台会话镜像已清除。" + + "此名字今后不可再注册(历史邮件的署名由此不会被冒用)。", + }) +} diff --git a/gateway/internal/handler/helpers.go b/gateway/internal/handler/helpers.go index 707d17f..a49cb86 100644 --- a/gateway/internal/handler/helpers.go +++ b/gateway/internal/handler/helpers.go @@ -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. 的末段。 diff --git a/gateway/internal/repo/keys.go b/gateway/internal/repo/keys.go index 963fdfb..07e788d 100644 --- a/gateway/internal/repo/keys.go +++ b/gateway/internal/repo/keys.go @@ -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 diff --git a/gateway/internal/repo/repo.go b/gateway/internal/repo/repo.go index ba0c435..824418f 100644 --- a/gateway/internal/repo/repo.go +++ b/gateway/internal/repo/repo.go @@ -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 +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 8bb8a36..2162dc2 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -419,6 +419,21 @@ export async function adminSetAgentStatus(agentName: string, disabled: boolean) ); } +/** + * 彻底删除一个 Agent。保留邮件历史与会话,清除密钥、镜像、日历。 + * 名字今后不可再注册(防止同名新注册冒用历史署名)。 + */ +export async function adminDeleteAgent(agentName: string) { + return request<{ + agent_name: string; + keys_revoked: number; + detail: string; + }>( + 'DELETE', + `/admin/agents/${encodeURIComponent(agentName)}` + ); +} + /** 改该 Agent 的新任务默认预算(0 = 不限)。 */ export async function adminSetDefaultRounds(agentName: string, defaultRounds: number) { return request<{ quota: AgentStats }>( diff --git a/web/src/components/QuotaPanel.tsx b/web/src/components/QuotaPanel.tsx index 172c6ed..fae8c6c 100644 --- a/web/src/components/QuotaPanel.tsx +++ b/web/src/components/QuotaPanel.tsx @@ -14,6 +14,7 @@ export default function QuotaPanel() { const [busy, setBusy] = useState(null); const [drafts, setDrafts] = useState>({}); const [confirming, setConfirming] = useState(null); + const [confirmAction, setConfirmAction] = useState<'toggle' | 'delete' | null>(null); const [notice, setNotice] = useState(null); const load = useCallback(async () => { @@ -48,6 +49,26 @@ export default function QuotaPanel() { } }; + const deleteAgent = async (name: string) => { + setBusy(name); + setError(null); + setNotice(null); + try { + const result = await api.adminDeleteAgent(name); + await load(); + setConfirming(null); + setConfirmAction(null); + const revoked = typeof result.keys_revoked === 'number' && result.keys_revoked > 0 + ? `(已撤销 ${result.keys_revoked} 把密钥)` + : ''; + setNotice(`${name} 已删除${revoked}`); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(null); + } + }; + const toggleStatus = async (name: string, currentDisabled: boolean) => { setBusy(name); setError(null); @@ -97,10 +118,8 @@ export default function QuotaPanel() { 从地址补全与联系人里隐藏,并拒绝它重新注册。 邮件、会话、模型范围全部保留,随时可恢复。
- - 没有「彻底删除」:Agent 名与人类用户名共用命名空间,删掉之后若有人注册同名, - 历史邮件会看起来像是他发的。 - + 删除:在停用的基础上清掉运行态(日历事件置 cancelled)。 + 邮件与会话保留(历史是审计凭据),此名字今后不可再注册。

{error &&
{error}
} @@ -150,40 +169,52 @@ export default function QuotaPanel() { - {/* 停用/恢复按钮 */} + {/* 停用/恢复/删除 按钮 */} {isConfirming ? (
- 确认? + 确认{confirmAction === 'delete' ? '删除' : ''}?
) : ( - +
+ + +
)} );