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
+21 -4
View File
@@ -51,6 +51,8 @@ HM 用 `Authorization: Bearer <service_token>` 调 AM。统一响应信封建议
"OPENAI_BASE_URL": "https://code.xinghanlab.com/v1",
"OPENAI_API_KEY": "sk-xxxx",
"MODEL_NAME": "gpt-5.4",
"AGENT_ACCESS_TOKEN": "550e8400-e29b-...", // 客户端↔agent 访问令牌(见 §3.1)
"HEICODE_AGENT_ID": "dep_b5fab27e9255", // 回 HM verify 时带这个
"GIT_PROVIDER": "github", "GIT_REPO_URL": "...", "GIT_TOKEN": "…",
"MYSQL_HOST": "...", "MYSQL_PASSWORD": "…"
}
@@ -60,7 +62,7 @@ HM 用 `Authorization: Bearer <service_token>` 调 AM。统一响应信封建议
**响应(HM 解析你们返回的字段):**
- `runtime_id` ← `runtime_id`/`agent_id`/`id`/`name`/`namespace`(取到的实例标识,用于 status/stop/delete)。
- `subdomain` ← `subdomain` 或 `access_info.domain` / `access_info.external_ip`(agent 直连地址)。
- `access_token` ← `access_token`/`token`(你们当前不返回 → HM 留空,客户端用 A2A `api_key`,见 §3)。
- `access_token`:**HM 自己现签并下发**(不取你们返回值),即注入 env 的 `AGENT_ACCESS_TOKEN`,客户端用它做访问鉴权(见 §3.1)。
- `status` ← `status`/`runtime_status`(取不到默认 `running`)。
**AM 启动时做的(你们已实现):**用 `env.AGENT_INSTRUCTION_TEXT`+`AGENT_ROLE_NAME` 设角色;注入 env;agent 用模型走 `OPENAI_BASE_URL`(=HM `/v1`)。
@@ -110,8 +112,23 @@ HM 把用户绑定的资源按**资源类型 + provider**解成固定 env 名(
客户端从 HM 拿到 `subdomain` 后**直连 agent**,走你们的 **A2A 协议**(`POST {subdomain}/message/send` 同步、`POST {subdomain}/message/stream` 流式、`GET {subdomain}/.well-known/agent.json`、`GET {subdomain}/health`)。HM 不在回路。HM 已据此写好客户端文档 `heicode-desktop-client-api.md` §6。
- **鉴权 = A2A 请求里的 `api_key`**(模型 sk-)。你们当前**不返回专属 access_token**,所以 HM 列表里 `access_token` 为空,客户端用 `api_key` 连。
- ⚠️ **安全缺口(请你们确认/补)**:agent 端 env 里挂着用户的 git token / db 密码 / blob key,工具能 `run_database_query`/`read_blob_text`。若子域名公网可达、且只要任一有效 sk 就能驱动它,**别的用户可经此读走该用户的资源**。需要**按用户隔离**(per-agent 专属令牌,或网络隔离 + 校验 api_key 属于该 agent 的所有者)。HM 侧规划后续提供 V2 解密接口(客户端加密、agent 解密拿 key)配合。
### 3.1 客户端↔agent 访问鉴权(HM 已提供,AM 可选接)
> 解决「agent 公网可达、谁有有效 sk 都能驱动别人的 agent、读走其挂载的 git token / db 密码 / blob key」的按用户隔离问题。**HM 侧已落地,AM 可选择接或自己另搞一套。**
- 部署 agent 时,**HM 给每个 agent 现签一把随机访问令牌**(per-agent access token,UUID),并:
1. **注入进 agent 的 env**:`AGENT_ACCESS_TOKEN`(令牌本身)、`HEICODE_AGENT_ID`(该 agent 的 `agent_id`,即 `dep-xxx`)。
2. **返回给客户端**:HM 的 agent 列表/详情里 `access_token` 字段就是这把令牌(只有部署它的那个用户拿得到)。
- 客户端直连 agent 时,把这把令牌一起带上(建议放请求头 `X-Agent-Access-Token`,或 A2A `params` 里约定字段)。
- **agent 鉴权该令牌,二选一:**
- ① **本地校验(零额外依赖,推荐)**:比较 `调用方令牌 == 自己 env 里的 AGENT_ACCESS_TOKEN`。两者是 HM 启动时注入的同一把密钥,相等即放行。**不用回 HM。**
- ② **HM 权威校验**:`POST {HM}/api/heicode/agent-access/verify`,body `{"agent_id":"<HEICODE_AGENT_ID>","access_token":"<调用方令牌>"}` → 返回 `{"success":true,"data":{"valid":true,"user_id":22,"template_id":"..."}}`。令牌不符只回 `{"valid":false}`(不泄露任何用户信息)。这样 AM 还能拿到「该 agent 属于哪个 HM 用户」。常量时间比较、公开端点(agent 无 HM 会话,令牌本身即凭据)。
- **只有部署该 agent 的用户拿到令牌 ⇒ 只有他能调** = 「这个 agent 这个用户能调」。
- **通道加密 = TLS**:agent 子域名请走 **HTTPS**,令牌与对话内容由 TLS 加密;HM 不在回路,无需 HM 中转解密。
模型鉴权(打 HM `/v1/*`)仍是 A2A 请求里的 `api_key`(模型 sk-),与上面的访问令牌**职责分离**:`api_key` 管「用谁的额度跑模型」,`AGENT_ACCESS_TOKEN` 管「谁有权调这个 agent」。
> 若 AM 完全不接:退化为旧状态——任一有效 sk 都能驱动任一 agent,存在跨用户读资源风险。HM 已把机制备好,接入成本极低(本地校验仅一行字符串比较)。
---
@@ -150,7 +167,7 @@ HM 这些路径都可用环境变量覆盖(默认值见 §1),AM 若用别的路
- [ ] `POST /agents/start` 收到 `agent_definition`+`env`+`callback_url`,起 agent、注入 env、返回 `{runtime_id, subdomain, access_token, status}`。
- [ ] agent 用模型打到 HM `/v1/*`(带用户身份),计费正常。
- [ ] agent 子域名校验 access_token;非法连接被拒。
- [ ] agent 子域名按 §3.1 校验 `AGENT_ACCESS_TOKEN`(本地比较或回 HM `/api/heicode/agent-access/verify`);非该 agent 所有者的调用被拒。
- [ ] `GET /agents/{id}` 返回真实 status;`stop`/`delete` 生效。
- [ ] agent 不泄露 env/密钥;启动接口 HTTPS/私网。
- [ ] 把 §3 的 SSE 直连契约回给 HM 补进客户端文档。
+14 -7
View File
@@ -24,7 +24,7 @@
**总原则**
- 客户端调 HM 用 **V2 设备签名**(与模型调用同一套,见 §1)。
- 列表里的 `access_token` 是连 agent 用的,**由 agent 自己校验**;HM 只负责发给你。
- 列表里的 `access_token` 是连 agent 用的**专属访问令牌**(HM 给每个 agent 现签,只有部署它的你拿得到)。直连时带上它(`X-Agent-Access-Token` 头);**agent 校验「是不是该 agent 的所有者」**。它与模型 `api_key` 职责分离:`access_token` 管「谁能调这个 agent」,`api_key` 管「用谁的额度跑模型」。
- 模型永远走 HM `/v1/*`,不要直连上游。
---
@@ -150,7 +150,7 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
> **2026-06-04 生产实测**:部署 / 列表 / 详情 / 状态 / 删除均真实可用。
> - `subdomain` 由 AM 分配(如 `dep-xxxx.taijiagnet.com`);新建后 `status` 为 `Pending`,需等 agent 起来变 `running`(直连前先确认,见 §6)。
> - **`access_token` 当前恒为空字符串**——AM 的 coding_a2a_agent **不发专属令牌**,直连用 A2A 的 `api_key`(见 §6)。
> - **`access_token` 是 HM 现签的 per-agent 专属访问令牌**(非空,UUID)——直连 agent 时带上(`X-Agent-Access-Token` 头),agent 据此判定「是不是本 agent 的所有者」(见 §6)。
> - `stop`/`delete` 调 AM:目前 AM 的 `stop` 端点缺失、`delete` 有已知 bug,所以 `delete` 会**先清掉 HM 本地记录**并返回 `runtime_cleanup:"failed"`(AM 侧可能残留);`stop` 暂时会失败。
| 方法 | 路径 | 说明 | 状态 |
@@ -172,7 +172,7 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
"agent_id": "dep_4bb07dc1e376", // HM 侧 agent 记录 id(部署/停删都用它)
"template_id": "architect", // 用了哪个模板
"subdomain": "dep-4bb07dc1e376.taijiagnet.com", // ★ 直连地址(AM 分配,主机名)
"access_token": "", // 当前恒空(AM 不发;直连用 A2A api_key)
"access_token": "550e8400-e29b-41d4-a716-446655440000", // ★ per-agent 访问令牌(直连带上)
"binding_ids": [], // 挂了哪些资源绑定(网页台绑的)
"status": "Pending", // Pending | running | failed | stopped …
"runtime_id": "dep-4bb07dc1e376", // AM 侧运行时 id
@@ -208,6 +208,8 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
- **流式**:`POST {subdomain}/message/stream`(返回 `text/event-stream`)
- **发现/健康**:`GET {subdomain}/.well-known/agent.json`、`GET {subdomain}/health`
请求头:`X-Agent-Access-Token: <§5 列表里的 access_token>` —— **访问鉴权**(证明你是该 agent 的所有者)。
请求体(JSON-RPC,A2A):
```json
{ "jsonrpc":"2.0", "id":"task-1", "method":"message/send",
@@ -220,11 +222,13 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
}
```
- **鉴权 = `params.api_key`**(模型 sk-,客户端自带的网关 key);agent 凭它调 HM `/v1/*` 计费。HM 当前列表里的 `access_token` 字段对该 agent**为空**(AM 不发专属 token);连 agent 用上面的 `api_key`。
**两层鉴权,职责分离:**
- **访问鉴权 = `access_token`**(`X-Agent-Access-Token` 头,§5 列表里那把 per-agent 令牌):证明「你是这个 agent 的所有者,有权调它」。agent 本地比对自己 env 里的 `AGENT_ACCESS_TOKEN`,或回 HM `POST /api/heicode/agent-access/verify` 校验。
- **模型鉴权 = `params.api_key`**(模型 sk-,客户端自带的网关 key):agent 凭它调 HM `/v1/*`,计费到你账上。
- agent 用 `.env` 里注入的资源(git/mysql/postgres/azure-blob)自行干活;工具:`read_file/write_file/edit_file/run_command/git_*/run_database_query/list_blob_objects` 等。缺配置的资源工具调用会返回 `resource not configured`,不阻塞。
- 可在 `params.configuration.resources` 里按需覆盖资源(请求级覆盖启动级)。
> ⚠️ 安全:当前 agent 端鉴权仅为"持有效 api_key",非 per-agent 专属令牌。HM 侧规划**用 V2 解密接口**做按用户隔离(客户端加密请求→agent 解密拿 key),或由 AM 自行加门。上线公网前需确认该端点不被他人用任意 key 驱动(见 AM 契约 §3/§4)。
> ℹ️ 安全:HM 已为「客户端↔agent」提供按用户隔离机制 —— per-agent 访问令牌(`access_token`,部署时现签、注入 agent env、随列表下发),只有部署该 agent 的你拿得到 ⇒ 只有你能调。**AM 是否启用由 AM 决定**(可本地比对或回 HM verify,也可自行另搞一套);若 AM 不启用,则退化为「任一有效 api_key 可驱动任一 agent」。通道加密走 agent 子域名的 HTTPS/TLS。详见 AM 契约 §3.1。
---
@@ -267,7 +271,9 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
- 想看有哪些角色: GET /api/heicode/agent-templates (中文名/简介)
- 部署(也可在网页台做): POST /api/heicode/agents {template_id, binding_ids}
4. 选一个 status=running 的 agent → 取 subdomain + access_token
5. 直连 subdomain (SSE, 带 token) → 与 agent 对话/干活 ← 不经 HM
5. 直连 subdomain (SSE) → 与 agent 对话/干活 ← 不经 HM
- 头带 X-Agent-Access-Token: <access_token> (访问鉴权,证明是该 agent 所有者)
- body params.api_key: <模型 sk> (模型鉴权,计费到你账上)
6. 期间用模型 → HM /v1/* (客户端与 agent 两端都是)
7. 管理: GET /agents/{id}/status | POST /agents/{id}/stop | DELETE /agents/{id}
```
@@ -285,6 +291,7 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
| `POST /api/heicode/agents` | 会话/设备 | 🟢(调 AM,AM 接口 🔴) |
| `POST /api/heicode/agents/{id}/stop`、`DELETE /{id}` | 会话/设备 | 🟢(调 AM 🔴) |
| 模型 `/v1/*` | 同模型调用 | 🟢 |
| 直连 agent SSE(subdomain+token) | agent 自校验 | 🔴(待 AM agent 端) |
| `POST /api/heicode/agent-access/verify` | 公开(令牌即凭据) | 🟢(供 agent 服务端校验,见 §6) |
| 直连 agent SSE(subdomain + X-Agent-Access-Token) | agent 校验(本地比对/回 HM verify) | 🔴(待 AM agent 端) |
> 🔴 项不阻塞客户端集成:HM 侧字段/接口已定型并生产验证,等 AM 实现 agent 启动/状态/直连即全线打通。AM 契约见 `heicode-hm-template-agent-model.md` §7 与代码 `controller/agent_template_runtime.go`(隔离层)。
+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")