fix: relay 死循环防护 + DSH 工作区注册修复 + homeagent 工具集补齐 + 三插件 connect_to_server
## relay 死循环防护(两道防线) ### 主防线:免配额只给发往人类的 relay(handler/mail.go) 原设计:relay 走免配额通道(harness 搬运不该算模型自主发信)。 问题:收件方是另一个同样会自动转发的 Agent 时,整个回路里没有任何 一处在计数——生产上跑出过 41 封(会话 f3d824ce),间隔从 15 分钟 缩到 5 秒,且用了 37 封才烧掉 4/20 预算。 改为:repo.IsHumanUser(to.Name) 判定。Agent→Agent 的 relay 照样扣预算。 顺带修次序问题:原来是「先占幂等键再扣预算」,预算耗尽时幂等键 已被占用,加了额度也无法重发。现在预算失败会 ReleaseRelay 还回去。 ### 兜底:hop_limit 列接通(repo/relayhops.go) schema 里早有 hop_limit INT DEFAULT 5,从未有代码读它。 CountTrailingRelayHops 从最新邮件往前扫,遇到第一封非 relay 邮件即停(中间有一封自主发信或人类插话就归零)。 5 测试:空会话 / 只数 relay / 自主发信打断归零 / 达到上限 / 按会话独立 ## DSH 工作区注册修复 问题:上一轮加的 workspaceRegistry.create(cwd) 用了兜底值 cwd(来自 resolveWorkspaceCwd,可能是 ~/.dsh/mail-sessions/mail-<uuid>), 而不是会话 header 里的真实 cwd。两者不一致时 attachSession 拒绝, 且 create 已先执行,每封邮件都往注册表里塞一条空的垃圾 workspace。 修复:读 handle.agent.session.header.cwd —— create 路径下是 meta.cwd, resume 路径下是持久化 header 里那个。 ## homeagent 插件:11 工具齐平 opencode tools.go 新增:read_mail / forward_mail / suggest_address / list_contacts / session_participants / read_thread / connect_to_server + handleConnectToServer(注册到 Gateway 前先用候选坐标试注册, 成功才写回 p.gwURL/p.key,失败不破坏原配置) 关键修:Plugin.name(插件名,homed 注册用)与 Plugin.agentName (AgentMail 身份,Gateway 密钥绑定用)是两个命名空间。 它们混淆会导致 403:「该密钥已绑定到 Agent 'homeagent',不能用于 注册 'homeagent-mail-bridge'」。现已分开,并在 systemd drop-in 里显式设 AGENTMAIL_AGENT_NAME=homeagent。 ## 三插件补齐 connect_to_server 之前只有 opencode 有。后果:Gateway 换地址或密钥需要重新登记时, opencode 里的模型能自己修好,其他平台只能干等环境变量被人改。 DSH 版:从 GatewayClient 内部调 register(),成功后写回 client.baseURL 与 client.agentKey 当场生效。 pi 版:新导出 KEY_FILE / saveLocalKey(从 gateway.mjs),connect 工具直接用。 # 测试 relayhops_test.go 5 例 opencode 172 / dsh 188 / pi 214 全绿 check-shared-libs.sh 三方同源(rename-proposal 已纳入校验)
This commit is contained in:
@ -108,7 +108,7 @@ export default function AdminUsersPage() {
|
||||
</TabButton>
|
||||
<TabButton active={tab === 'quotas'} onClick={() => setTab('quotas')}>
|
||||
<BotIcon className="w-4 h-4" />
|
||||
默认预算
|
||||
Agent 管理
|
||||
</TabButton>
|
||||
<TabButton active={tab === 'models'} onClick={() => setTab('models')}>
|
||||
<CpuIcon className="w-4 h-4" />
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import { BotIcon, CheckIcon, CloseIcon } from './icons';
|
||||
import { BotIcon, CheckIcon, ArchiveIcon } from './icons';
|
||||
|
||||
/**
|
||||
* Agent 管理面板(管理员):默认预算 + 停用/恢复。
|
||||
@ -14,6 +14,7 @@ export default function QuotaPanel() {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@ -50,14 +51,17 @@ export default function QuotaPanel() {
|
||||
const toggleStatus = async (name: string, currentDisabled: boolean) => {
|
||||
setBusy(name);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await api.adminSetAgentStatus(name, !currentDisabled);
|
||||
await load();
|
||||
setConfirming(null);
|
||||
// 停用时显示撤销了几把密钥,这是操作结果里最有信息量的部分
|
||||
if (result.detail) setError(null); // 清掉旧错误,让 detail 独占提示区
|
||||
setBusy(null);
|
||||
return;
|
||||
// 撤销了几把密钥是操作结果里最有信息量的部分 —— 恢复后要重新签发几把,
|
||||
// 只说「已停用」的话用户不知道还有这一步。
|
||||
const revoked = typeof result.keys_revoked === 'number' && result.keys_revoked > 0
|
||||
? `(已撤销 ${result.keys_revoked} 把密钥)`
|
||||
: '';
|
||||
setNotice(`${name} ${result.disabled ? '已停用' : '已恢复'}${revoked}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@ -85,13 +89,22 @@ export default function QuotaPanel() {
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-gray-500">
|
||||
派给某个 Agent 的新任务默认多少个来回(填 0 = 不限)。这只是默认值 ——
|
||||
写信时可以单独指定,之后在对话页里还能随时调整。
|
||||
<br />
|
||||
<strong className="font-medium text-gray-600">默认预算</strong>:派给某个 Agent 的新任务默认多少个来回(填 0 = 不限)。
|
||||
这只是默认值 —— 写信时可以单独指定,之后在对话页里还能随时调整。
|
||||
插件自动转发的最终总结与权限询问不占用预算。
|
||||
<br />
|
||||
<strong className="font-medium text-gray-600">停用</strong>:撤销该 Agent 的全部密钥、
|
||||
从地址补全与联系人里隐藏,并拒绝它重新注册。
|
||||
<span className="text-gray-400">邮件、会话、模型范围全部保留,随时可恢复。</span>
|
||||
<br />
|
||||
<span className="text-gray-400">
|
||||
没有「彻底删除」:Agent 名与人类用户名共用命名空间,删掉之后若有人注册同名,
|
||||
历史邮件会看起来像是他发的。
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{error && <div className="text-[11px] text-gray-600 bg-gray-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>}
|
||||
{notice && <div className="text-[11px] text-green-700 bg-green-50 rounded px-2 py-1.5">{notice}</div>}
|
||||
|
||||
{stats.length === 0 ? (
|
||||
<div className="text-xs text-gray-400 py-3">暂无已注册的 Agent</div>
|
||||
@ -159,14 +172,17 @@ export default function QuotaPanel() {
|
||||
<button
|
||||
onClick={() => setConfirming(s.agent_name)}
|
||||
disabled={busy === s.agent_name}
|
||||
title={isDisabled ? '恢复此 Agent(密钥需重新签发)' : '停用此 Agent(撤销全部密钥,可恢复)'}
|
||||
className={`tap shrink-0 ${
|
||||
title={isDisabled
|
||||
? '恢复此 Agent(恢复为离线;密钥需重新签发)'
|
||||
: '停用此 Agent(撤销全部密钥并从补全里隐藏;邮件与会话保留,可恢复)'}
|
||||
className={`tap shrink-0 inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded border ${
|
||||
isDisabled
|
||||
? 'text-gray-400 hover:text-green-600'
|
||||
: 'text-gray-400 hover:text-red-500'
|
||||
? 'border-green-200 text-green-700 hover:bg-green-50'
|
||||
: 'border-gray-200 text-gray-500 hover:text-red-600 hover:border-red-200'
|
||||
} disabled:opacity-30`}
|
||||
>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
<ArchiveIcon className="w-3 h-3" />
|
||||
{isDisabled ? '恢复' : '停用'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user