feat(agent): per-agent client↔agent access token for per-user authorization

HM now mints a random per-agent access token at deploy, injects it into the
agent env (AGENT_ACCESS_TOKEN + HEICODE_AGENT_ID) and returns it to the
deploying client (agent list access_token). Only the owning user receives it,
so only they can drive the agent — closing the gap where any valid sk- could
drive any agent and exfiltrate its mounted resources.

AM authorizes the caller either locally (compare to its env token) or via the
new public POST /api/heicode/agent-access/verify {agent_id, access_token} ->
{valid, user_id} (constant-time compare, no info leak on miss). AM may opt out.

Docs: AM contract §3.1 + client API §6 updated; access_token no longer empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 21:58:10 +08:00
co-authored by Claude Opus 4.8
parent 3a2ad36e5d
commit e326964362
4 changed files with 105 additions and 12 deletions
+63 -1
View File
@@ -1,6 +1,7 @@
package controller
import (
"crypto/subtle"
"strconv"
"strings"
"time"
@@ -162,6 +163,18 @@ func HeicodeDeployAgent(c *gin.Context) {
}
env["OPENAI_API_KEY"] = modelKey
// Per-agent client↔agent access token. HM mints a random secret, injects it
// into the agent env (AGENT_ACCESS_TOKEN) AND returns it to the deploying
// client (the agent list's access_token). Only the user who deployed this
// agent receives the token, so only they can drive it: AM can verify the
// caller's token == its own env AGENT_ACCESS_TOKEN locally (no HM round-trip),
// or call POST /api/heicode/agent-access/verify to let HM be the authority.
// AM may opt out of this entirely. HEICODE_AGENT_ID lets the agent name itself
// when calling the verify endpoint.
agentAccessToken := common.GetUUID()
env["AGENT_ACCESS_TOKEN"] = agentAccessToken
env["HEICODE_AGENT_ID"] = deploymentID
// Ask AM to start the agent with the template definition (.md) + env injected.
result, err := amStartTemplateAgent(c.Request.Context(), amStartArgs{
ManagerDeploymentID: deploymentID,
@@ -185,7 +198,7 @@ func HeicodeDeployAgent(c *gin.Context) {
UserID: strconv.Itoa(userID),
TemplateID: req.TemplateID,
Subdomain: result.Subdomain,
AccessToken: sealAgentToken(result.AccessToken),
AccessToken: sealAgentToken(agentAccessToken),
BindingIDsJSON: string(bindingIDsJSON),
RuntimeDeploymentID: result.RuntimeID,
Status: firstNonEmpty(result.Status, "running"),
@@ -347,3 +360,52 @@ func HeicodeDeleteAgent(c *gin.Context) {
"runtime_cleanup": runtimeCleanup, // "failed" => AM may still hold an orphan
})
}
// HeicodeVerifyAgentAccess: POST /api/heicode/agent-access/verify (public).
//
// The client↔agent access-control primitive HM provides for AM (optional). When
// the desktop client connects to an agent's subdomain it presents the per-agent
// access_token it got from the agent list. AM's agent can authorize the caller
// in one of two ways:
//
// ① Local (no HM call): compare the caller's token to its own env
// AGENT_ACCESS_TOKEN — they are the same secret HM injected at deploy.
// ② Authoritative: POST here with {agent_id, access_token}. HM constant-time
// compares against the stored token and returns {valid, user_id} so AM also
// learns which HM user owns the agent.
//
// Only the user who deployed the agent ever receives its token, so a valid match
// means "this user is allowed to call this agent". No user identity is leaked on
// a miss. AM may ignore this entirely and roll its own scheme.
func HeicodeVerifyAgentAccess(c *gin.Context) {
var req struct {
AgentID string `json:"agent_id"`
AccessToken string `json:"access_token"`
}
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
agentError(c, "INVALID_REQUEST", "invalid request body")
return
}
req.AgentID = strings.TrimSpace(req.AgentID)
req.AccessToken = strings.TrimSpace(req.AccessToken)
if req.AgentID == "" || req.AccessToken == "" || model.DB == nil {
common.ApiSuccess(c, gin.H{"valid": false})
return
}
var row model.AgentDeployment
if err := model.DB.Where("deployment_id = ? AND template_id <> ''", req.AgentID).First(&row).Error; err != nil {
common.ApiSuccess(c, gin.H{"valid": false})
return
}
stored := unsealAgentToken(row.AccessToken)
if stored == "" || subtle.ConstantTimeCompare([]byte(stored), []byte(req.AccessToken)) != 1 {
common.ApiSuccess(c, gin.H{"valid": false})
return
}
common.ApiSuccess(c, gin.H{
"valid": true,
"agent_id": row.DeploymentID,
"user_id": row.UserID,
"template_id": row.TemplateID,
})
}
+7
View File
@@ -539,6 +539,13 @@ func SetApiRouter(router *gin.Engine) {
heicodeAgentRoute.DELETE("/agents/:deployment_id", controller.HeicodeDeleteAgent)
}
// Client↔agent access control (HM-provided, AM-optional). Called by the
// agent server-side to confirm the caller's per-agent access_token belongs
// to this agent's owner, so only that user can drive it. Public on purpose
// (the agent has no HM user session); it leaks nothing on a token miss and
// the token itself is the bearer secret. Constant-time compared.
apiRouter.POST("/heicode/agent-access/verify", controller.HeicodeVerifyAgentAccess)
// Agent template library (admin-maintained agent .md definitions; the
// client list is served by the heicode group above, Chinese display).
agentTemplateAdminRoute := apiRouter.Group("/agent-templates")