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' ? '删除' : ''}?
) : (
-
+
+
+
+
)}
);