fix(agent): review fixes — success-check, template name, seed Once, update guard

Full review of the template-agent code surfaced and fixed:
- [frontend, critical] deploy/stop/delete treated HTTP-200-with-success:false as
  success (backend agentError returns 200 per new-api convention) -> a failed
  call wrongly toasted success. Added okOrThrow() that inspects the envelope and
  throws the server (or a friendly Chinese) message so onError fires.
- [frontend] "我的 Agent" showed the raw template key (architect) instead of the
  Chinese name; now resolves name via the templates list.
- [backend] ensureAgentTemplatesSeeded consumed sync.Once even when model.DB was
  nil (would permanently skip seeding) -> DB check moved outside the Once.
- [backend] AdminUpdateAgentTemplate could wipe name_zh/definition with empty
  values -> guard those critical fields.
- [security] warn when starting an agent with secret-bearing env over a non-HTTPS
  AM URL (secrets must not transit the network in clear).

Go + frontend build/tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 00:59:03 +08:00
co-authored by Claude Opus 4.8
parent c4e84258b7
commit 16cca5df1c
4 changed files with 70 additions and 15 deletions
+14 -6
View File
@@ -73,10 +73,12 @@ var seedAgentTemplatesOnce sync.Once
// ensureAgentTemplatesSeeded idempotently inserts any missing preset templates.
func ensureAgentTemplatesSeeded() {
// Guard the DB check OUTSIDE the Once so we don't consume it before the DB
// is ready (which would permanently skip seeding).
if model.DB == nil {
return
}
seedAgentTemplatesOnce.Do(func() {
if model.DB == nil {
return
}
entries, err := presetAgentFS.ReadDir("agent_template_presets")
if err != nil {
common.SysLog("agent template preset read: " + err.Error())
@@ -221,14 +223,20 @@ func AdminUpdateAgentTemplate(c *gin.Context) {
agentError(c, "POLICY_REJECTED", "invalid request body")
return
}
// Update editable fields only; key/source stay.
// Update editable fields only; key/source stay. Guard the critical fields
// (name_zh, definition) so an omitted/empty value can't wipe a working
// template; description/model/sort_order may be cleared intentionally.
updates := map[string]any{
"name_zh": req.NameZh,
"description_zh": req.DescriptionZh,
"model": req.Model,
"definition": req.Definition,
"sort_order": req.SortOrder,
}
if strings.TrimSpace(req.NameZh) != "" {
updates["name_zh"] = req.NameZh
}
if strings.TrimSpace(req.Definition) != "" {
updates["definition"] = req.Definition
}
if strings.TrimSpace(req.Status) != "" {
updates["status"] = req.Status
}
@@ -127,6 +127,16 @@ func amTemplateDo(ctx context.Context, method, path string, body any, timeout ti
// model, env, callback_url}.
// Proposed response data: {runtime_id, subdomain, access_token, status}.
func amStartTemplateAgent(ctx context.Context, args amStartArgs) (amStartResult, error) {
// The env may carry plaintext secrets (vm password, db password, blob key…).
// Warn if the AM endpoint is plain http on a non-loopback host so they don't
// silently transit the network in the clear — AM should be HTTPS / private.
if len(args.Env) > 0 {
base := agentRuntimeClientConfigForMode(agentRuntimeModeAgent).BaseURL
if strings.HasPrefix(base, "http://") &&
!strings.Contains(base, "localhost") && !strings.Contains(base, "127.0.0.1") {
common.SysLog("WARNING: starting template agent with secret env over a non-HTTPS AM URL; use HTTPS or a private network")
}
}
payload := map[string]any{
"manager_deployment_id": args.ManagerDeploymentID,
"template_key": args.TemplateKey,
+1 -1
View File
@@ -20,7 +20,7 @@
<meta property="og:type" content="website" />
<meta name="theme-color" content="#7B6BE3" />
<link rel="icon" href="/favicon.ico"><script defer src="/static/js/vendor-radix.829c7e3fad.js"></script><script defer src="/static/js/vendor-tanstack.632dbe8908.js"></script><script defer src="/static/js/lib-react.5c8909c28c.js"></script><script defer src="/static/js/6374.bc21d8b214.js"></script><script defer src="/static/js/index.99cba94710.js"></script><link href="/static/css/index.cc291c3921.css" rel="stylesheet"></head>
<link rel="icon" href="/favicon.ico"><script defer src="/static/js/vendor-radix.829c7e3fad.js"></script><script defer src="/static/js/vendor-tanstack.632dbe8908.js"></script><script defer src="/static/js/lib-react.5c8909c28c.js"></script><script defer src="/static/js/6374.bc21d8b214.js"></script><script defer src="/static/js/index.deee68f4fa.js"></script><link href="/static/css/index.592272f49e.css" rel="stylesheet"></head>
<body>
<div id="root"></div>
@@ -87,6 +87,24 @@ async function listAgents(): Promise<AgentItem[]> {
return res.data?.data?.items ?? []
}
// The backend returns HTTP 200 with {success:false, error:{code,message}} for
// business failures (new-api convention), so axios resolves — we must check the
// envelope ourselves and throw, otherwise a failed call looks like a success.
type Envelope = {
data?: { success?: boolean; message?: string; error?: { code?: string; message?: string } }
}
const FRIENDLY: Record<string, string> = {
RUNTIME_UNAVAILABLE: '云端服务暂时不可用,请稍后再试',
RESOURCE_BINDING_INVALID: '所选资源无效,请重新选择',
}
function okOrThrow(res: Envelope) {
const d = res?.data
if (d && d.success === false) {
const code = d.error?.code ?? ''
throw new Error(FRIENDLY[code] || d.error?.message || d.message || '操作失败')
}
}
export function DeploySubAgentPage() {
const qc = useQueryClient()
const [selectedBindings, setSelectedBindings] = useState<number[]>([])
@@ -110,11 +128,13 @@ export function DeploySubAgentPage() {
})
const deploy = useMutation({
mutationFn: () =>
api.post('/api/heicode/agents', {
mutationFn: async () => {
const res = await api.post('/api/heicode/agents', {
template_id: selectedTemplate,
binding_ids: selectedBindings,
}),
})
okOrThrow(res)
},
onSuccess: () => {
toast.success('Agent 已开始部署')
setSelectedBindings([])
@@ -282,7 +302,14 @@ export function DeploySubAgentPage() {
) : (
<div className='mt-4 space-y-3'>
{(agentsQ.data ?? []).map((a) => (
<AgentRow key={a.agent_id} agent={a} />
<AgentRow
key={a.agent_id}
agent={a}
templateName={
templates.find((t) => t.template_id === a.template_id)?.name ??
a.template_id
}
/>
))}
</div>
)}
@@ -291,12 +318,20 @@ export function DeploySubAgentPage() {
)
}
function AgentRow({ agent }: { agent: AgentItem }) {
function AgentRow({
agent,
templateName,
}: {
agent: AgentItem
templateName: string
}) {
const qc = useQueryClient()
const st = statusLabel(agent.status)
const stop = useMutation({
mutationFn: () => api.post(`/api/heicode/agents/${agent.agent_id}/stop`),
mutationFn: async () => {
okOrThrow(await api.post(`/api/heicode/agents/${agent.agent_id}/stop`))
},
onSuccess: () => {
toast.success('已停止')
void qc.invalidateQueries({ queryKey: ['heicode-agents'] })
@@ -304,7 +339,9 @@ function AgentRow({ agent }: { agent: AgentItem }) {
onError: (e) => toast.error(e instanceof Error ? e.message : '操作失败'),
})
const del = useMutation({
mutationFn: () => api.delete(`/api/heicode/agents/${agent.agent_id}`),
mutationFn: async () => {
okOrThrow(await api.delete(`/api/heicode/agents/${agent.agent_id}`))
},
onSuccess: () => {
toast.success('已删除')
void qc.invalidateQueries({ queryKey: ['heicode-agents'] })
@@ -322,7 +359,7 @@ function AgentRow({ agent }: { agent: AgentItem }) {
<article className='flex flex-col gap-2 rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='min-w-0'>
<div className='flex items-center gap-2'>
<span className='truncate text-sm font-medium'>{agent.template_id}</span>
<span className='truncate text-sm font-medium'>{templateName}</span>
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1 ring-inset',