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:
2026-09-05 00:28:05 +08:00
parent 784192d8c4
commit 9ebb8dfb41
7 changed files with 209 additions and 26 deletions

View File

@ -240,6 +240,7 @@ func main() {
// 没有「彻底删除」—— Agent 名与人类用户名共用命名空间, // 没有「彻底删除」—— Agent 名与人类用户名共用命名空间,
// 删掉后同名注册者会让历史邮件看起来像是他发的。 // 删掉后同名注册者会让历史邮件看起来像是他发的。
r.Put("/admin/agents/{name}/status", handler.AdminSetAgentStatus) r.Put("/admin/agents/{name}/status", handler.AdminSetAgentStatus)
r.Delete("/admin/agents/{name}", handler.AdminDeleteAgent)
}) })
}) })

View File

@ -102,6 +102,15 @@ func RegisterAgent(w http.ResponseWriter, r *http.Request) {
return 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 非空约束仍在; // 密钥认证时不需要 secret但 agents.secret 非空约束仍在;
// 存密钥本身作占位,旧的 name/secret 路径不受影响。 // 存密钥本身作占位,旧的 name/secret 路径不受影响。
secret := req.Secret secret := req.Secret
@ -274,3 +283,42 @@ func AdminSetAgentStatus(w http.ResponseWriter, r *http.Request) {
} }
JSON(w, http.StatusOK, resp) 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": "已删除。邮件、会话与日历事件已保留;" +
"密钥与平台会话镜像已清除。" +
"此名字今后不可再注册(历史邮件的署名由此不会被冒用)。",
})
}

View File

@ -136,8 +136,12 @@ func writeKeyErr(w http.ResponseWriter, err error) {
case errors.Is(err, repo.ErrKeyTokenTaken): case errors.Is(err, repo.ErrKeyTokenTaken):
Error(w, http.StatusConflict, "该密钥已登记过") Error(w, http.StatusConflict, "该密钥已登记过")
default: 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> 的末段。 // validateSessionAlias 校验会话别名是否可安全出现在三维地址 name@path.<alias> 的末段。

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"time" "time"
"github.com/agentmail/gateway/internal/db" "github.com/agentmail/gateway/internal/db"
@ -88,6 +89,15 @@ func CreateAgentKey(ctx context.Context, agentName, keyType, label string, expir
return nil, ErrKeyTooShort 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 var namePtr *string
if agentName != "" { if agentName != "" {
namePtr = &agentName namePtr = &agentName

View File

@ -1346,3 +1346,77 @@ func MarkAllInboxReadFor(ctx context.Context, recipient string) (int, error) {
n, _ := res.RowsAffected() n, _ := res.RowsAffected()
return int(n), nil return int(n), nil
} }
// ─── Agent 删除 ───
// DeleteAgent 删除 Agent 的全部运行态,保留邮件历史,名字进保留名单。
//
// 删除范围:
// - agent_keys全部撤销
// - agent_platform_sessions清掉镜像
// - models_scope模型范围配置
// - rate_limits速率限制计数器
// - calendar_eventscancelled不触发、不静默空转
// - 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)
// 日历事件置 cancelledAgent 创建的提醒不该继续触发并投给一个不存在的收件人
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
}

View File

@ -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 = 不限)。 */ /** 改该 Agent 的新任务默认预算0 = 不限)。 */
export async function adminSetDefaultRounds(agentName: string, defaultRounds: number) { export async function adminSetDefaultRounds(agentName: string, defaultRounds: number) {
return request<{ quota: AgentStats }>( return request<{ quota: AgentStats }>(

View File

@ -14,6 +14,7 @@ export default function QuotaPanel() {
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({}); const [drafts, setDrafts] = useState<Record<string, string>>({});
const [confirming, setConfirming] = useState<string | null>(null); const [confirming, setConfirming] = useState<string | null>(null);
const [confirmAction, setConfirmAction] = useState<'toggle' | 'delete' | null>(null);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const load = useCallback(async () => { 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) => { const toggleStatus = async (name: string, currentDisabled: boolean) => {
setBusy(name); setBusy(name);
setError(null); setError(null);
@ -97,10 +118,8 @@ export default function QuotaPanel() {
<span className="text-gray-400"></span> <span className="text-gray-400"></span>
<br /> <br />
<span className="text-gray-400"> <strong className="font-medium text-gray-600"></strong> cancelled
Agent <span className="text-gray-400"></span>
</span>
</p> </p>
{error && <div className="text-[11px] text-red-600 bg-red-50 rounded px-2 py-1.5">{error}</div>} {error && <div className="text-[11px] text-red-600 bg-red-50 rounded px-2 py-1.5">{error}</div>}
@ -150,40 +169,52 @@ export default function QuotaPanel() {
<CheckIcon className="w-4 h-4" /> <CheckIcon className="w-4 h-4" />
</button> </button>
{/* 停用/恢复按钮 */} {/* 停用/恢复/删除 按钮 */}
{isConfirming ? ( {isConfirming ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-[10px] text-gray-500"></span> <span className="text-[10px] text-gray-500">{confirmAction === 'delete' ? '删除' : ''}</span>
<button <button
onClick={() => toggleStatus(s.agent_name, isDisabled)} onClick={() => confirmAction === 'delete'
? deleteAgent(s.agent_name)
: toggleStatus(s.agent_name, isDisabled)}
disabled={busy === s.agent_name} disabled={busy === s.agent_name}
className="tap text-[10px] px-1.5 py-0.5 rounded bg-red-50 text-red-600 hover:bg-red-100" className="tap text-[10px] px-1.5 py-0.5 rounded bg-red-50 text-red-600 hover:bg-red-100"
> >
{isDisabled ? '恢复' : '停用'} {confirmAction === 'delete' ? '删除' : (isDisabled ? '恢复' : '停用')}
</button> </button>
<button <button
onClick={() => setConfirming(null)} onClick={() => { setConfirming(null); setConfirmAction(null); }}
className="tap text-[10px] text-gray-400 hover:text-gray-600" className="tap text-[10px] text-gray-400 hover:text-gray-600"
> >
</button> </button>
</div> </div>
) : ( ) : (
<button <div className="flex items-center gap-1">
onClick={() => setConfirming(s.agent_name)} <button
disabled={busy === s.agent_name} onClick={() => { setConfirming(s.agent_name); setConfirmAction('toggle'); }}
title={isDisabled disabled={busy === s.agent_name}
? '恢复此 Agent恢复为离线密钥需重新签发' title={isDisabled
: '停用此 Agent撤销全部密钥并从补全里隐藏;邮件与会话保留,可恢复)'} ? '恢复此 Agent恢复为离线;密钥需重新签发)'
className={`tap shrink-0 inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded border ${ : '停用此 Agent撤销全部密钥并从补全里隐藏邮件与会话保留可恢复'}
isDisabled className={`tap shrink-0 inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded border ${
? 'border-green-200 text-green-700 hover:bg-green-50' isDisabled
: 'border-gray-200 text-gray-500 hover:text-red-600 hover:border-red-200' ? 'border-green-200 text-green-700 hover:bg-green-50'
} disabled:opacity-30`} : 'border-gray-200 text-gray-500 hover:text-red-600 hover:border-red-200'
> } disabled:opacity-30`}
<ArchiveIcon className="w-3 h-3" /> >
{isDisabled ? '恢复' : '停用'} <ArchiveIcon className="w-3 h-3" />
</button> {isDisabled ? '恢复' : '停用'}
</button>
<button
onClick={() => { setConfirming(s.agent_name); setConfirmAction('delete'); }}
disabled={busy === s.agent_name}
title="彻底删除此 Agent清除密钥与运行态邮件保留但此名今后不可再用"
className="tap shrink-0 text-[10px] px-1.5 py-0.5 rounded border border-red-200 text-red-400 hover:text-red-600 hover:border-red-300 disabled:opacity-30"
>
</button>
</div>
)} )}
</div> </div>
); );