feat(agent): align HM to AM's coding_a2a_agent contract

Adapt HM's template-agent integration to AM's actual CODING_A2A API (per their
doc), keeping it isolated in agent_template_runtime.go:

- start payload -> AM's POST /agents { name, template:"coding_a2a_agent",
  framework:"A2A", config:{user_id,...}, env } with the template .md folded into
  env.AGENT_INSTRUCTION_TEXT, template_key -> AGENT_ROLE_NAME, model gateway via
  OPENAI_BASE_URL + MODEL_NAME (OPENAI_API_KEY left to the client per A2A request).
- response parse -> access_info.domain/external_ip -> subdomain, namespace/name
  -> runtime_id; AM issues no access_token (client uses A2A api_key).
- env names aligned to AM: GIT_DEFAULT_BRANCH, POSTGRES_* (was PG_*),
  AZURE_BLOB_ACCOUNT_NAME/CONTAINER/ACCOUNT_KEY (was BLOB_*); source keys aligned
  to the resource-binding form (db_name/username/database_password/access_key).
  Only AM-supported types (git/mysql/postgres/azure-blob); vm/redis/mongo/bucket
  now rejected as unsupported until AM adds them.
- frontend: resources page splits DB into MySQL/PostgreSQL (correct provider),
  drops vm; deploy page hides unsupported resource types.
- docs: AM contract + client doc updated to the real env names, payload, and the
  A2A direct-connect (message/send · message/stream) + api_key auth.
- tests updated for the new env names + AM payload/response shape. All green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 15:30:33 +08:00
co-authored by Claude Opus 4.8
parent e21cc1e81e
commit 074a3cc7e7
9 changed files with 213 additions and 165 deletions
+34 -34
View File
@@ -16,41 +16,40 @@
HM 用 `Authorization: Bearer <service_token>` 调 AM。统一响应信封建议 `{ "success": true, "data": {...} }`(HM 会从 `data` 取值;失败给 `{ "success": false, "message": "..." }`)。 HM 用 `Authorization: Bearer <service_token>` 调 AM。统一响应信封建议 `{ "success": true, "data": {...} }`(HM 会从 `data` 取值;失败给 `{ "success": false, "message": "..." }`)。
### 1.1 启动 agent(核心) ### 1.1 启动 agent(核心,已对齐你们的 `POST /agents`)
`POST {AM}/api/agent/agents/start` `POST {AM}/agents`
**请求体(HM 发):** **请求体(HM 实际发送的,已按你们 CODING_A2A §2 对齐):**
```json ```json
{ {
"manager_deployment_id": "dep_b5fab27e9255", // HM 侧记录 id,回调/排查用 "name": "dep-b5fab27e9255", // 由 HM 部署 id 派生(DNS 安全)
"template_key": "architect", // 模板标识 "template": "coding_a2a_agent",
"agent_definition": "---\nname: architect\nmodel: opus\n...\n---\n<Agent_Prompt>...</Agent_Prompt>", "framework": "A2A",
"model": "opus", // 从模板 frontmatter 解析(可空) "config": {
"env": { // 资源配置,注入 agent 的 .env(见 §2) "user_id": "22",
"GIT_REPO_URL": "https://github.com/owner/repo", "manager_deployment_id": "dep_b5fab27e9255",
"GIT_TOKEN": "ghp_…", "callback_url": "https://code.xinghanlab.com/api/agent/callbacks/runtime-events"
"VM_HOST": "1.2.3.4", "VM_USER": "deploy", "VM_PASSWORD": "…"
}, },
"callback_url": "https://code.xinghanlab.com/api/agent/callbacks/runtime-events" "env": {
"AGENT_ROLE_NAME": "architect",
"AGENT_INSTRUCTION_TEXT": "---\nname: architect\n---\n<Agent_Prompt>...</Agent_Prompt>",
"OPENAI_BASE_URL": "https://code.xinghanlab.com/v1",
"MODEL_NAME": "gpt-5.4",
"GIT_PROVIDER": "github", "GIT_REPO_URL": "...", "GIT_TOKEN": "…",
"MYSQL_HOST": "...", "MYSQL_PASSWORD": "…"
}
} }
``` ```
**响应 data(AM 返回):** **响应(HM 解析你们返回的字段):**
```json - `runtime_id` ← `runtime_id`/`agent_id`/`id`/`name`/`namespace`(取到的实例标识,用于 status/stop/delete)。
{ "runtime_id": "rt_xxx", "subdomain": "https://abc123.agents.example.com", "access_token": "…", "status": "running" } - `subdomain` ← `subdomain` 或 `access_info.domain` / `access_info.external_ip`(agent 直连地址)。
``` - `access_token` ← `access_token`/`token`(你们当前不返回 → HM 留空,客户端用 A2A `api_key`,见 §3)。
- `runtime_id`:AM 侧实例 id(HM 存,用于后续 status/stop/delete)。HM 也接受 `agent_id`/`id`/`deployment_id` 作为别名。 - `status` ← `status`/`runtime_status`(取不到默认 `running`)。
- `subdomain`:agent 对外**唯一可直连地址**(完整 URL 最佳)。HM 也接受 `address`/`url` 别名。
- `access_token`:客户端连 agent 时出示的令牌(见 §3)。HM 也接受 `token` 别名。
- `status`:`running`/`starting`/… HM 也接受 `runtime_status` 别名。
**AM 启动时必须做的:** **AM 启动时做的(你们已实现):**用 `env.AGENT_INSTRUCTION_TEXT`+`AGENT_ROLE_NAME` 设角色;注入 env;agent 用模型走 `OPENAI_BASE_URL`(=HM `/v1`)。
1. 用 `agent_definition`(.md:frontmatter + 系统提示)作为该 agent 的**角色/行为定义**(等价 Claude Code subagent)。
2. 把 `env` 注入 agent 的 `.env`/进程环境,**agent 只读 env 即知道有哪些资源**。
3. 分配唯一子域名 + 生成 access_token,**agent 端自行校验该 token**(§3)。
4. 该 agent 用模型时**调 HM 的 `/v1/*`**(带用户身份,HM 计费)——不要直连上游模型。
> ⚠️ **provisioning 是同步的**:HM 启动调用超时默认 60s(`AGENT_RUNTIME_START_TIMEOUT_SECONDS`),请在该时间内完成起容器+分配地址并返回。 > ⚠️ provisioning 同步:HM 超时默认 60s(`AGENT_RUNTIME_START_TIMEOUT_SECONDS`)。
### 1.2 取 agent 状态 ### 1.2 取 agent 状态
`GET {AM}/api/agent/agents/{runtime_id}` `GET {AM}/api/agent/agents/{runtime_id}`
@@ -68,19 +67,20 @@ HM 用 `Authorization: Bearer <service_token>` 调 AM。统一响应信封建议
HM 把用户绑定的资源按**资源类型 + provider**解成固定 env 名(代码 `controller/agent_template_env.go`)。**AM 的 agent 模板按这些名字读 env**;用户在 HM 只填业务字段、看不到 env 名。 HM 把用户绑定的资源按**资源类型 + provider**解成固定 env 名(代码 `controller/agent_template_env.go`)。**AM 的 agent 模板按这些名字读 env**;用户在 HM 只填业务字段、看不到 env 名。
> **2026-06-04 已对齐 AM 的 `coding_a2a_agent`**:env 名按你们文档实现。当前**只支持 AM 已支持的 4 类**(git / mysql / postgres / azure blob);vm / redis / mongo / 对象桶等 HM 暂不下发,待 AM 支持再加。
| 资源类型 | provider | 注入的 env(非密 + 密钥) | | 资源类型 | provider | 注入的 env(非密 + 密钥) |
|---|---|---| |---|---|---|
| git | github / gitea / gitlab | `GIT_PROVIDER` `GIT_REPO_URL` `GIT_BRANCH` `GIT_API_BASE` · `GIT_TOKEN`(密) | | git | github / gitea / gitlab | `GIT_PROVIDER` `GIT_REPO_URL` `GIT_DEFAULT_BRANCH` · `GIT_TOKEN`(密) |
| vm | (ssh) | `VM_HOST` `VM_PORT` `VM_USER` · `VM_PASSWORD` / `VM_SSH_KEY`(密) |
| database | mysql | `MYSQL_HOST` `MYSQL_PORT` `MYSQL_DATABASE` `MYSQL_USER` · `MYSQL_PASSWORD`(密) | | database | mysql | `MYSQL_HOST` `MYSQL_PORT` `MYSQL_DATABASE` `MYSQL_USER` · `MYSQL_PASSWORD`(密) |
| database | pg(postgres) | `PG_HOST` `PG_PORT` `PG_DATABASE` `PG_USER` · `PG_PASSWORD`(密) | | database | postgres | `POSTGRES_HOST` `POSTGRES_PORT` `POSTGRES_DATABASE` `POSTGRES_USER` · `POSTGRES_PASSWORD`(密) |
| database | redis | `REDIS_HOST` `REDIS_PORT` `REDIS_DB` · `REDIS_PASSWORD`(密) | | storage | azure blob | `AZURE_BLOB_ACCOUNT_NAME` `AZURE_BLOB_CONTAINER` · `AZURE_BLOB_ACCOUNT_KEY`(密) |
| database | mongo | `MONGO_HOST` `MONGO_PORT` `MONGO_DATABASE` `MONGO_USER` · `MONGO_PASSWORD`(密) |
| storage(blob) | azure blob | `BLOB_ACCOUNT` `BLOB_CONTAINER` · `BLOB_KEY`(密) | 另外随启动注入(模型网关):`OPENAI_BASE_URL`(= HM `/v1`)、`MODEL_NAME`。**`OPENAI_API_KEY` 不在启动时注入** —— 由客户端在 A2A 请求里带 `api_key`(你们文档 §7);后续若走 V2 解密路径再调整。
| storage(bucket) | s3/oss/minio | `BUCKET_ENDPOINT` `BUCKET_REGION` `BUCKET_NAME` · `BUCKET_ACCESS_KEY_ID` `BUCKET_SECRET_ACCESS_KEY`(密) |
- 缺失的可选字段不会出现在 env 里(agent 自行容错)。 - 缺失的可选字段不会出现在 env 里(agent 自行容错)。
- 同类型资源 HM 限制只挂一个(避免 env 名冲突)。 - 同类型资源 HM 限制只挂一个(避免 env 名冲突)。
- 角色/指令注入:HM 把模板 .md 放进 `env.AGENT_INSTRUCTION_TEXT`、模板 key 放进 `env.AGENT_ROLE_NAME`(对齐你们 §4)。
--- ---
@@ -114,7 +114,7 @@ HM 这些路径都可用环境变量覆盖(默认值见 §1),AM 若用别的路
|---|---|---| |---|---|---|
| `AGENT_RUNTIME_BASE_URL` | — | AM 基址(必须配,HTTPS/私网) | | `AGENT_RUNTIME_BASE_URL` | — | AM 基址(必须配,HTTPS/私网) |
| `AGENT_RUNTIME_SERVICE_TOKEN` | — | HM 调 AM 的 Bearer | | `AGENT_RUNTIME_SERVICE_TOKEN` | — | HM 调 AM 的 Bearer |
| `AGENT_RUNTIME_AGENT_START_PATH` | `/api/agent/agents/start` | 启动 | | `AGENT_RUNTIME_AGENT_START_PATH` | `/agents` | 启动(你们的 create) |
| `AGENT_RUNTIME_AGENT_PATH` | `/api/agent/agents/{agent_id}` | 取状态(GET)/删除(DELETE) | | `AGENT_RUNTIME_AGENT_PATH` | `/api/agent/agents/{agent_id}` | 取状态(GET)/删除(DELETE) |
| `AGENT_RUNTIME_AGENT_STOP_PATH` | `/api/agent/agents/{agent_id}/stop` | 停止 | | `AGENT_RUNTIME_AGENT_STOP_PATH` | `/api/agent/agents/{agent_id}/stop` | 停止 |
| `AGENT_RUNTIME_START_TIMEOUT_SECONDS` | `60` | 启动超时 | | `AGENT_RUNTIME_START_TIMEOUT_SECONDS` | `60` | 启动超时 |
+22 -8
View File
@@ -170,17 +170,31 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
--- ---
## 6. 直连 Agent(客户端 ↔ agent,SSE)🔴 ## 6. 直连 Agent(客户端 ↔ agent,A2A 协议)
> 这一段是 **客户端直接连 agent**,不经过 HM。HM 只负责把 `subdomain + access_token` 给你。 > 客户端**直接连 agent 子域名**(§5 的 `subdomain`),不经过 HM。agent 是 AM 的 `coding_a2a_agent`,走 **A2A 协议**。
- 从 §5 列表拿到某个 `status=running` agent 的 `subdomain` 和 `access_token`。 - **同步**:`POST {subdomain}/message/send`
- 客户端**直连该子域名,SSE 双向通信**,请求携带 `access_token`(具体头部/握手以 AM 的 agent 端契约为准)。 - **流式**:`POST {subdomain}/message/stream`(返回 `text/event-stream`)
- **agent 自己校验 token**;token 错/缺则拒绝(防公网裸奔)。 - **发现/健康**:`GET {subdomain}/.well-known/agent.json`、`GET {subdomain}/health`
- agent 用 `.env` 里注入的资源(git/vm/db/blob 配置)自行干活;**用模型仍打 HM `/v1/*`**。
- agent 不会把 `.env`/密钥回显到对话或日志(由 AM 保证)。
> 该直连契约(SSE 握手、token 头名、消息格式)由 AM 的 agent 端定义,定稿后补入本节。 请求体(JSON-RPC,A2A):
```json
{ "jsonrpc":"2.0", "id":"task-1", "method":"message/send",
"params": {
"api_key": "sk-…", // ★ 模型 key,agent 用它调 HM /v1 计费(客户端自带)
"model": "gpt-5.4",
"message": { "role":"user", "parts":[ {"kind":"text","text":"…"} ] },
"configuration": { "workspace": { "root_dir":"/workspace", "allowed_paths":["a.py"] } }
}
}
```
- **鉴权 = `params.api_key`**(模型 sk-,客户端自带的网关 key);agent 凭它调 HM `/v1/*` 计费。HM 当前列表里的 `access_token` 字段对该 agent**为空**(AM 不发专属 token);连 agent 用上面的 `api_key`。
- 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)。
--- ---
+28 -51
View File
@@ -31,101 +31,73 @@ const (
func normalizeProvider(p string) string { func normalizeProvider(p string) string {
switch strings.ToLower(strings.TrimSpace(p)) { switch strings.ToLower(strings.TrimSpace(p)) {
case "postgres", "postgresql", "pg": case "postgres", "postgresql", "pg":
return "pg" return "postgres"
case "mysql", "mariadb": case "mysql", "mariadb":
return "mysql" return "mysql"
case "redis":
return "redis"
case "mongo", "mongodb", "mg":
return "mongo"
case "github": case "github":
return "github" return "github"
case "gitea": case "gitea":
return "gitea" return "gitea"
case "gitlab": case "gitlab":
return "gitlab" return "gitlab"
case "blob", "azureblob", "azure-blob": case "blob", "azureblob", "azure-blob", "azure":
return "blob" return "blob"
case "bucket", "s3", "oss", "minio", "cos":
return "bucket"
default: default:
return strings.ToLower(strings.TrimSpace(p)) return strings.ToLower(strings.TrimSpace(p))
} }
} }
// dbEnvSpec maps the resource-binding form fields (host/port/db_name/username +
// secret database_password) to the AM-expected {PREFIX}_* env names.
func dbEnvSpec(prefix string) []envField { func dbEnvSpec(prefix string) []envField {
return []envField{ return []envField{
{prefix + "_HOST", envMeta, "host"}, {prefix + "_HOST", envMeta, "host"},
{prefix + "_PORT", envMeta, "port"}, {prefix + "_PORT", envMeta, "port"},
{prefix + "_DATABASE", envMeta, "database"}, {prefix + "_DATABASE", envMeta, "db_name"},
{prefix + "_USER", envMeta, "user"}, {prefix + "_USER", envMeta, "username"},
{prefix + "_PASSWORD", envSecret, "password"}, {prefix + "_PASSWORD", envSecret, "database_password"},
} }
} }
// builtinEnvSpec returns the fixed env mapping for a binding, keyed by resource // builtinEnvSpec returns the fixed env mapping for a binding. The env NAMES match
// type and provider. Built into HM; not user-configurable. // what AM's coding_a2a_agent reads (see docs/integration/heicode-am-contract.md
// §2); the source KEYS match what the resource-binding form stores.
// //
// Supported: git (gitea/github/gitlab), vm, database (mysql/pg/redis/mongo), // Supported by AM today: git (github/gitea/gitlab) · database (mysql/postgres) ·
// storage (azure blob / object-storage bucket). // azure blob. Other types (vm/redis/mongo/object-bucket) return nil → the deploy
// is rejected with an "unsupported" error until AM adds them.
func builtinEnvSpec(resourceType, provider string) []envField { func builtinEnvSpec(resourceType, provider string) []envField {
rt := strings.ToLower(strings.TrimSpace(resourceType)) rt := strings.ToLower(strings.TrimSpace(resourceType))
p := normalizeProvider(provider) p := normalizeProvider(provider)
switch rt { switch rt {
case "git": case "git":
// Same env names across gitea/github/gitlab; provider passed through.
return []envField{ return []envField{
{"GIT_PROVIDER", envMeta, "provider"}, {"GIT_PROVIDER", envMeta, "provider"},
{"GIT_REPO_URL", envMeta, "repo_url"}, {"GIT_REPO_URL", envMeta, "repo_url"},
{"GIT_BRANCH", envMeta, "default_branch"}, {"GIT_DEFAULT_BRANCH", envMeta, "default_branch"},
{"GIT_API_BASE", envMeta, "api_base"},
{"GIT_TOKEN", envSecret, "token"}, {"GIT_TOKEN", envSecret, "token"},
} }
case "vm":
return []envField{
{"VM_HOST", envMeta, "host"},
{"VM_PORT", envMeta, "port"},
{"VM_USER", envMeta, "user"},
{"VM_PASSWORD", envSecret, "password"},
{"VM_SSH_KEY", envSecret, "private_key"},
}
case "database", "db": case "database", "db":
switch p { switch p {
case "mysql": case "mysql":
return dbEnvSpec("MYSQL") return dbEnvSpec("MYSQL")
case "pg": case "postgres":
return dbEnvSpec("PG") return dbEnvSpec("POSTGRES")
case "mongo":
return dbEnvSpec("MONGO")
case "redis":
return []envField{
{"REDIS_HOST", envMeta, "host"},
{"REDIS_PORT", envMeta, "port"},
{"REDIS_DB", envMeta, "db"},
{"REDIS_PASSWORD", envSecret, "password"},
}
default: default:
return dbEnvSpec("DB") return nil // AM supports only mysql / postgres
} }
case "blob", "storage": case "blob", "storage":
if p == "bucket" { if p == "blob" || p == "azure" || p == "" {
return []envField{ return []envField{
{"BUCKET_ENDPOINT", envMeta, "endpoint"}, {"AZURE_BLOB_ACCOUNT_NAME", envMeta, "account"},
{"BUCKET_REGION", envMeta, "region"}, {"AZURE_BLOB_CONTAINER", envMeta, "container"},
{"BUCKET_NAME", envMeta, "bucket"}, {"AZURE_BLOB_ACCOUNT_KEY", envSecret, "access_key"},
{"BUCKET_ACCESS_KEY_ID", envSecret, "access_key_id"},
{"BUCKET_SECRET_ACCESS_KEY", envSecret, "secret_access_key"},
} }
} }
// Default: Azure Blob. return nil // object-storage buckets not supported by AM yet
return []envField{
{"BLOB_ACCOUNT", envMeta, "account"},
{"BLOB_CONTAINER", envMeta, "container"},
{"BLOB_KEY", envSecret, "key"},
}
default: default:
return nil return nil // vm / other not supported by AM yet
} }
} }
@@ -169,6 +141,11 @@ func buildAgentEnvFromBindings(userID int, bindingIDs []int) (map[string]string,
if strings.TrimSpace(binding.Metadata) != "" { if strings.TrimSpace(binding.Metadata) != "" {
_ = common.UnmarshalJsonStr(binding.Metadata, &metadata) _ = common.UnmarshalJsonStr(binding.Metadata, &metadata)
} }
// provider lives on the binding row, not in metadata — surface it so
// GIT_PROVIDER (and similar) resolve.
if _, ok := metadata["provider"]; !ok && strings.TrimSpace(binding.Provider) != "" {
metadata["provider"] = binding.Provider
}
// Resolve the secret JSON once per binding, only if one is bound. // Resolve the secret JSON once per binding, only if one is bound.
var secret map[string]any var secret map[string]any
@@ -120,6 +120,7 @@ func HeicodeDeployAgent(c *gin.Context) {
// Ask AM to start the agent with the template definition (.md) + env injected. // Ask AM to start the agent with the template definition (.md) + env injected.
result, err := amStartTemplateAgent(c.Request.Context(), amStartArgs{ result, err := amStartTemplateAgent(c.Request.Context(), amStartArgs{
ManagerDeploymentID: deploymentID, ManagerDeploymentID: deploymentID,
UserID: strconv.Itoa(userID),
TemplateKey: tpl.TemplateKey, TemplateKey: tpl.TemplateKey,
AgentDefinition: tpl.Definition, AgentDefinition: tpl.Definition,
Model: tpl.Model, Model: tpl.Model,
+62 -24
View File
@@ -31,6 +31,7 @@ import (
// (.md) is maintained by HM and sent to AM here. // (.md) is maintained by HM and sent to AM here.
type amStartArgs struct { type amStartArgs struct {
ManagerDeploymentID string ManagerDeploymentID string
UserID string
TemplateKey string TemplateKey string
AgentDefinition string // the agent .md (system prompt + frontmatter) AgentDefinition string // the agent .md (system prompt + frontmatter)
Model string Model string
@@ -38,6 +39,13 @@ type amStartArgs struct {
CallbackURL string CallbackURL string
} }
// publicV1BaseURL is the model gateway base URL the started agent should call
// for models (OPENAI_BASE_URL).
func publicV1BaseURL() string {
base := strings.TrimRight(strings.TrimSpace(common.GetEnvOrDefaultString("HEICODE_PUBLIC_BASE_URL", "https://code.xinghanlab.com")), "/")
return base + "/v1"
}
// amStartResult is what AM returns after starting a template agent. // amStartResult is what AM returns after starting a template agent.
type amStartResult struct { type amStartResult struct {
RuntimeID string RuntimeID string
@@ -47,7 +55,8 @@ type amStartResult struct {
} }
func agentTemplateStartPath() string { func agentTemplateStartPath() string {
return common.GetEnvOrDefaultString("AGENT_RUNTIME_AGENT_START_PATH", "/api/agent/agents/start") // AM's agent-manager create endpoint (CODING_A2A doc §2: POST /agents).
return common.GetEnvOrDefaultString("AGENT_RUNTIME_AGENT_START_PATH", "/agents")
} }
func agentTemplateAgentPath(agentRuntimeID string) string { func agentTemplateAgentPath(agentRuntimeID string) string {
@@ -121,39 +130,68 @@ func amTemplateDo(ctx context.Context, method, path string, body any, timeout ti
return extractAgentRuntimeData(envelope), nil return extractAgentRuntimeData(envelope), nil
} }
// amStartTemplateAgent asks AM to start an agent from the HM-maintained template // amStartTemplateAgent asks AM to start a coding_a2a_agent. It maps HM's model
// definition (.md) with the resolved resources injected as env. // (template .md + resource env) onto AM's create contract (CODING_A2A doc):
// Proposed request: {manager_deployment_id, template_key, agent_definition, // POST /agents { name, template:"coding_a2a_agent", framework:"A2A",
// model, env, callback_url}. // config:{user_id}, env:{...} } where the role/instruction/model are folded into
// Proposed response data: {runtime_id, subdomain, access_token, status}. // env. AM's create response carries access_info.domain / namespace etc.
func amStartTemplateAgent(ctx context.Context, args amStartArgs) (amStartResult, error) { func amStartTemplateAgent(ctx context.Context, args amStartArgs) (amStartResult, error) {
// The env may carry plaintext secrets (vm password, db password, blob key…). // Build AM's env: resource env + role/instruction + model gateway.
// Warn if the AM endpoint is plain http on a non-loopback host so they don't env := map[string]string{}
// silently transit the network in the clear — AM should be HTTPS / private. for k, v := range args.Env {
if len(args.Env) > 0 { env[k] = v
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")
}
} }
if args.TemplateKey != "" {
env["AGENT_ROLE_NAME"] = args.TemplateKey
}
if strings.TrimSpace(args.AgentDefinition) != "" {
env["AGENT_INSTRUCTION_TEXT"] = args.AgentDefinition
}
if args.Model != "" {
env["MODEL_NAME"] = args.Model
}
env["OPENAI_BASE_URL"] = publicV1BaseURL()
// NOTE: OPENAI_API_KEY is intentionally NOT injected here — the desktop
// client passes its own api_key per A2A request (CODING_A2A §7). A future
// V2-decrypt path may hand the agent a scoped key instead.
// env may carry plaintext secrets (db password, blob key…) — warn on http.
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")
}
name := strings.ToLower(strings.ReplaceAll(args.ManagerDeploymentID, "_", "-"))
payload := map[string]any{ payload := map[string]any{
"manager_deployment_id": args.ManagerDeploymentID, "name": name,
"template_key": args.TemplateKey, "template": "coding_a2a_agent",
"agent_definition": args.AgentDefinition, "framework": "A2A",
"model": args.Model, "config": map[string]any{
"env": args.Env, "user_id": args.UserID,
"callback_url": args.CallbackURL, "manager_deployment_id": args.ManagerDeploymentID,
"callback_url": args.CallbackURL,
},
"env": env,
} }
data, err := amTemplateDo(ctx, http.MethodPost, agentTemplateStartPath(), payload, agentTemplateStartTimeout()) data, err := amTemplateDo(ctx, http.MethodPost, agentTemplateStartPath(), payload, agentTemplateStartTimeout())
if err != nil { if err != nil {
return amStartResult{}, err return amStartResult{}, err
} }
// AM exposes the agent via access_info.{domain,external_ip}; the instance id
// is name/namespace. access_token is not issued by AM (auth = per-request
// api_key); left empty until the V2-decrypt path lands.
subdomain := stringFromMap(data, "subdomain", "address", "url")
if subdomain == "" {
if ai, ok := data["access_info"].(map[string]any); ok {
subdomain = stringFromMap(ai, "domain", "external_ip", "url")
}
}
return amStartResult{ return amStartResult{
RuntimeID: stringFromMap(data, "runtime_id", "agent_id", "id", "deployment_id"), RuntimeID: firstNonEmpty(stringFromMap(data, "runtime_id", "agent_id", "id", "name", "namespace"), name),
Subdomain: stringFromMap(data, "subdomain", "address", "url"), Subdomain: subdomain,
AccessToken: stringFromMap(data, "access_token", "token"), AccessToken: stringFromMap(data, "access_token", "token"),
Status: stringFromMap(data, "status", "runtime_status"), Status: firstNonEmpty(stringFromMap(data, "status", "runtime_status"), "running"),
}, nil }, nil
} }
+42 -26
View File
@@ -16,21 +16,21 @@ import (
func TestBuildAgentEnvFromBindings_BuiltinBlobMetadataOnly(t *testing.T) { func TestBuildAgentEnvFromBindings_BuiltinBlobMetadataOnly(t *testing.T) {
setupResourceControllerTestDB(t) setupResourceControllerTestDB(t)
// No secret_ref -> secret fields (BLOB_KEY) are skipped, not an error. // No secret_ref -> AZURE_BLOB_ACCOUNT_KEY is skipped, not an error.
b := model.ResourceBinding{ b := model.ResourceBinding{
UserId: 7, UserId: 7,
Name: "blob1", Name: "blob1",
ResourceType: "blob", ResourceType: "blob",
Provider: "blob", Provider: "azure",
Metadata: `{"account":"myacct","container":"uploads"}`, Metadata: `{"account":"myacct","container":"uploads"}`,
} }
require.NoError(t, model.DB.Create(&b).Error) require.NoError(t, model.DB.Create(&b).Error)
env, err := buildAgentEnvFromBindings(7, []int{b.Id}) env, err := buildAgentEnvFromBindings(7, []int{b.Id})
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "myacct", env["BLOB_ACCOUNT"]) require.Equal(t, "myacct", env["AZURE_BLOB_ACCOUNT_NAME"])
require.Equal(t, "uploads", env["BLOB_CONTAINER"]) require.Equal(t, "uploads", env["AZURE_BLOB_CONTAINER"])
_, hasKey := env["BLOB_KEY"] _, hasKey := env["AZURE_BLOB_ACCOUNT_KEY"]
require.False(t, hasKey) // no secret bound require.False(t, hasKey) // no secret bound
} }
@@ -38,13 +38,12 @@ func TestBuildAgentEnvFromBindings_DatabaseProviderPrefix(t *testing.T) {
setupResourceControllerTestDB(t) setupResourceControllerTestDB(t)
cases := []struct { cases := []struct {
provider string provider string
wantHost string // env var that should carry the host wantHost string // env var that should carry the host (AM names)
}{ }{
{"mysql", "MYSQL_HOST"}, {"mysql", "MYSQL_HOST"},
{"pg", "PG_HOST"}, {"postgres", "POSTGRES_HOST"},
{"postgresql", "PG_HOST"}, // alias normalises to pg {"postgresql", "POSTGRES_HOST"}, // alias normalises to postgres
{"redis", "REDIS_HOST"}, {"pg", "POSTGRES_HOST"}, // alias normalises to postgres
{"mg", "MONGO_HOST"}, // alias normalises to mongo
} }
for _, tc := range cases { for _, tc := range cases {
b := model.ResourceBinding{ b := model.ResourceBinding{
@@ -52,7 +51,8 @@ func TestBuildAgentEnvFromBindings_DatabaseProviderPrefix(t *testing.T) {
Name: "db-" + tc.provider, Name: "db-" + tc.provider,
ResourceType: "database", ResourceType: "database",
Provider: tc.provider, Provider: tc.provider,
Metadata: `{"host":"db.example","port":"5432","database":"app","user":"u"}`, // form fields: host/port/db_name/username (+ secret database_password)
Metadata: `{"host":"db.example","port":"5432","db_name":"app","username":"u"}`,
} }
require.NoError(t, model.DB.Create(&b).Error) require.NoError(t, model.DB.Create(&b).Error)
env, err := buildAgentEnvFromBindings(7, []int{b.Id}) env, err := buildAgentEnvFromBindings(7, []int{b.Id})
@@ -61,6 +61,14 @@ func TestBuildAgentEnvFromBindings_DatabaseProviderPrefix(t *testing.T) {
} }
} }
func TestBuildAgentEnvFromBindings_UnsupportedDBProviderRejected(t *testing.T) {
setupResourceControllerTestDB(t)
b := model.ResourceBinding{UserId: 7, Name: "r", ResourceType: "database", Provider: "redis", Status: "active", Metadata: `{"host":"h"}`}
require.NoError(t, model.DB.Create(&b).Error)
_, err := buildAgentEnvFromBindings(7, []int{b.Id}) // AM supports only mysql/postgres
require.Error(t, err)
}
func TestBuildAgentEnvFromBindings_GitNamesProviderAgnostic(t *testing.T) { func TestBuildAgentEnvFromBindings_GitNamesProviderAgnostic(t *testing.T) {
setupResourceControllerTestDB(t) setupResourceControllerTestDB(t)
for _, provider := range []string{"github", "gitea", "gitlab"} { for _, provider := range []string{"github", "gitea", "gitlab"} {
@@ -69,14 +77,14 @@ func TestBuildAgentEnvFromBindings_GitNamesProviderAgnostic(t *testing.T) {
Name: "git-" + provider, Name: "git-" + provider,
ResourceType: "git", ResourceType: "git",
Provider: provider, Provider: provider,
Metadata: `{"provider":"` + provider + `","repo_url":"https://x/owner/repo","default_branch":"main"}`, Metadata: `{"repo_url":"https://x/owner/repo","default_branch":"main"}`,
} }
require.NoError(t, model.DB.Create(&b).Error) require.NoError(t, model.DB.Create(&b).Error)
env, err := buildAgentEnvFromBindings(7, []int{b.Id}) env, err := buildAgentEnvFromBindings(7, []int{b.Id})
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "https://x/owner/repo", env["GIT_REPO_URL"]) require.Equal(t, "https://x/owner/repo", env["GIT_REPO_URL"])
require.Equal(t, "main", env["GIT_BRANCH"]) require.Equal(t, "main", env["GIT_DEFAULT_BRANCH"])
require.Equal(t, provider, env["GIT_PROVIDER"]) require.Equal(t, provider, env["GIT_PROVIDER"]) // sourced from binding.Provider
} }
} }
@@ -126,37 +134,45 @@ func TestBuildAgentEnvFromBindings_Empty(t *testing.T) {
} }
func TestAgentTemplatePathSubstitution(t *testing.T) { func TestAgentTemplatePathSubstitution(t *testing.T) {
require.Contains(t, agentTemplateStartPath(), "/start") require.Contains(t, agentTemplateStartPath(), "/agents")
require.Contains(t, agentTemplateAgentStopPath("rt-9"), "/agents/rt-9/stop") require.Contains(t, agentTemplateAgentStopPath("rt-9"), "/agents/rt-9/stop")
require.Contains(t, agentTemplateAgentPath("rt-9"), "/agents/rt-9") require.Contains(t, agentTemplateAgentPath("rt-9"), "/agents/rt-9")
} }
func TestAMStartTemplateAgent_RoundTrip(t *testing.T) { func TestAMStartTemplateAgent_RoundTrip(t *testing.T) {
var gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method) require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/agent/agents/start", r.URL.Path) require.Equal(t, "/agents", r.URL.Path) // AM's create endpoint
body, _ := io.ReadAll(r.Body) b, _ := io.ReadAll(r.Body)
require.Contains(t, string(body), "manager_deployment_id") gotBody = string(b)
require.Contains(t, string(body), "agent_definition")
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"runtime_id":"rt-9","subdomain":"https://x.example","access_token":"tok","status":"running"}}`)) // AM-style response: access_info.domain, namespace; no access_token.
_, _ = w.Write([]byte(`{"success":true,"data":{"namespace":"ns-1","access_info":{"domain":"https://abc.agents.example"}}}`))
})) }))
defer srv.Close() defer srv.Close()
t.Setenv("AGENT_RUNTIME_BASE_URL", srv.URL) t.Setenv("AGENT_RUNTIME_BASE_URL", srv.URL)
res, err := amStartTemplateAgent(context.Background(), amStartArgs{ res, err := amStartTemplateAgent(context.Background(), amStartArgs{
ManagerDeploymentID: "dep_1", ManagerDeploymentID: "dep_1",
UserID: "22",
TemplateKey: "architect", TemplateKey: "architect",
AgentDefinition: "---\nname: architect\n---\nbody", AgentDefinition: "---\nname: architect\n---\nbody",
Model: "opus", Model: "gpt-5.4",
Env: map[string]string{"K": "V"}, Env: map[string]string{"GIT_REPO_URL": "https://x/r"},
CallbackURL: "https://cb", CallbackURL: "https://cb",
}) })
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "rt-9", res.RuntimeID) // payload mapped onto AM's contract
require.Equal(t, "https://x.example", res.Subdomain) require.Contains(t, gotBody, `"template":"coding_a2a_agent"`)
require.Equal(t, "tok", res.AccessToken) require.Contains(t, gotBody, "AGENT_INSTRUCTION_TEXT") // .md folded into env
require.Equal(t, "running", res.Status) require.Contains(t, gotBody, "AGENT_ROLE_NAME")
require.Contains(t, gotBody, "OPENAI_BASE_URL")
require.Contains(t, gotBody, "manager_deployment_id")
// response parsed
require.Equal(t, "ns-1", res.RuntimeID)
require.Equal(t, "https://abc.agents.example", res.Subdomain)
require.Equal(t, "running", res.Status) // defaulted
} }
func TestAMGetAgentStatus_RoundTrip(t *testing.T) { func TestAMGetAgentStatus_RoundTrip(t *testing.T) {
+1 -1
View File
@@ -20,7 +20,7 @@
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta name="theme-color" content="#7B6BE3" /> <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.1b6c679c84.js"></script><link href="/static/css/index.592272f49e.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.13c9f5d1a3.js"></script><link href="/static/css/index.592272f49e.css" rel="stylesheet"></head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -138,7 +138,12 @@ export function DeploySubAgentPage() {
s.includes(id) ? s.filter((x) => x !== id) : [...s, id] s.includes(id) ? s.filter((x) => x !== id) : [...s, id]
) )
const resources = resourcesQ.data ?? [] // Only resource types the agent runtime currently supports (git / mysql /
// postgres / azure-blob). vm & others are hidden until AM adds them.
const SUPPORTED_TYPES = new Set(['git', 'database', 'blob'])
const resources = (resourcesQ.data ?? []).filter((r) =>
SUPPORTED_TYPES.has(r.resource_type)
)
const templates = templatesQ.data ?? [] const templates = templatesQ.data ?? []
const canDeploy = Boolean(selectedTemplate) && !deploy.isPending const canDeploy = Boolean(selectedTemplate) && !deploy.isPending
+17 -20
View File
@@ -5,7 +5,6 @@ import {
Database, Database,
GitBranch, GitBranch,
Plus, Plus,
Server,
ShieldCheck, ShieldCheck,
Trash2, Trash2,
} from 'lucide-react' } from 'lucide-react'
@@ -85,30 +84,28 @@ const KINDS: Kind[] = [
secret: [{ k: 'token', label: '访问令牌 (PAT)', ph: '…' }], secret: [{ k: 'token', label: '访问令牌 (PAT)', ph: '…' }],
}, },
{ {
key: 'vm', key: 'mysql',
resource_type: 'vm',
provider: 'ssh',
label: '虚拟机 (SSH)',
icon: Server,
externalFrom: 'host',
fields: [
{ k: 'host', label: '主机 / IP', ph: '20.24.50.121' },
{ k: 'port', label: '端口', ph: '22' },
{ k: 'user', label: '用户名', ph: 'root' },
],
secret: [
{ k: 'ssh_key', label: 'SSH 私钥', ph: '-----BEGIN OPENSSH PRIVATE KEY-----', textarea: true },
],
},
{
key: 'database',
resource_type: 'database', resource_type: 'database',
provider: 'mysql',
label: 'MySQL',
icon: Database,
externalFrom: 'host',
fields: [
{ k: 'host', label: '主机', ph: 'db.example.com' },
{ k: 'port', label: '端口', ph: '3306' },
{ k: 'db_name', label: '数据库名', ph: 'app' },
{ k: 'username', label: '用户名', ph: 'app' },
],
secret: [{ k: 'database_password', label: '数据库密码', ph: '…' }],
},
{
key: 'postgres',
resource_type: 'database',
provider: 'postgres', provider: 'postgres',
label: '数据库', label: 'PostgreSQL',
icon: Database, icon: Database,
externalFrom: 'host', externalFrom: 'host',
fields: [ fields: [
{ k: 'engine', label: '引擎', ph: 'postgres / mysql' },
{ k: 'host', label: '主机', ph: 'db.example.com' }, { k: 'host', label: '主机', ph: 'db.example.com' },
{ k: 'port', label: '端口', ph: '5432' }, { k: 'port', label: '端口', ph: '5432' },
{ k: 'db_name', label: '数据库名', ph: 'app' }, { k: 'db_name', label: '数据库名', ph: 'app' },