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:
@@ -16,41 +16,40 @@
|
||||
|
||||
HM 用 `Authorization: Bearer <service_token>` 调 AM。统一响应信封建议 `{ "success": true, "data": {...} }`(HM 会从 `data` 取值;失败给 `{ "success": false, "message": "..." }`)。
|
||||
|
||||
### 1.1 启动 agent(核心)
|
||||
`POST {AM}/api/agent/agents/start`
|
||||
### 1.1 启动 agent(核心,已对齐你们的 `POST /agents`)
|
||||
`POST {AM}/agents`
|
||||
|
||||
**请求体(HM 发):**
|
||||
**请求体(HM 实际发送的,已按你们 CODING_A2A §2 对齐):**
|
||||
```json
|
||||
{
|
||||
"manager_deployment_id": "dep_b5fab27e9255", // HM 侧记录 id,回调/排查用
|
||||
"template_key": "architect", // 模板标识
|
||||
"agent_definition": "---\nname: architect\nmodel: opus\n...\n---\n<Agent_Prompt>...</Agent_Prompt>",
|
||||
"model": "opus", // 从模板 frontmatter 解析(可空)
|
||||
"env": { // 资源配置,注入 agent 的 .env(见 §2)
|
||||
"GIT_REPO_URL": "https://github.com/owner/repo",
|
||||
"GIT_TOKEN": "ghp_…",
|
||||
"VM_HOST": "1.2.3.4", "VM_USER": "deploy", "VM_PASSWORD": "…"
|
||||
"name": "dep-b5fab27e9255", // 由 HM 部署 id 派生(DNS 安全)
|
||||
"template": "coding_a2a_agent",
|
||||
"framework": "A2A",
|
||||
"config": {
|
||||
"user_id": "22",
|
||||
"manager_deployment_id": "dep_b5fab27e9255",
|
||||
"callback_url": "https://code.xinghanlab.com/api/agent/callbacks/runtime-events"
|
||||
},
|
||||
"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 返回):**
|
||||
```json
|
||||
{ "runtime_id": "rt_xxx", "subdomain": "https://abc123.agents.example.com", "access_token": "…", "status": "running" }
|
||||
```
|
||||
- `runtime_id`:AM 侧实例 id(HM 存,用于后续 status/stop/delete)。HM 也接受 `agent_id`/`id`/`deployment_id` 作为别名。
|
||||
- `subdomain`:agent 对外**唯一可直连地址**(完整 URL 最佳)。HM 也接受 `address`/`url` 别名。
|
||||
- `access_token`:客户端连 agent 时出示的令牌(见 §3)。HM 也接受 `token` 别名。
|
||||
- `status`:`running`/`starting`/… HM 也接受 `runtime_status` 别名。
|
||||
**响应(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)。
|
||||
- `status` ← `status`/`runtime_status`(取不到默认 `running`)。
|
||||
|
||||
**AM 启动时必须做的:**
|
||||
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 计费)——不要直连上游模型。
|
||||
**AM 启动时做的(你们已实现):**用 `env.AGENT_INSTRUCTION_TEXT`+`AGENT_ROLE_NAME` 设角色;注入 env;agent 用模型走 `OPENAI_BASE_URL`(=HM `/v1`)。
|
||||
|
||||
> ⚠️ **provisioning 是同步的**:HM 启动调用超时默认 60s(`AGENT_RUNTIME_START_TIMEOUT_SECONDS`),请在该时间内完成起容器+分配地址并返回。
|
||||
> ⚠️ provisioning 同步:HM 超时默认 60s(`AGENT_RUNTIME_START_TIMEOUT_SECONDS`)。
|
||||
|
||||
### 1.2 取 agent 状态
|
||||
`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 名。
|
||||
|
||||
> **2026-06-04 已对齐 AM 的 `coding_a2a_agent`**:env 名按你们文档实现。当前**只支持 AM 已支持的 4 类**(git / mysql / postgres / azure blob);vm / redis / mongo / 对象桶等 HM 暂不下发,待 AM 支持再加。
|
||||
|
||||
| 资源类型 | provider | 注入的 env(非密 + 密钥) |
|
||||
|---|---|---|
|
||||
| git | github / gitea / gitlab | `GIT_PROVIDER` `GIT_REPO_URL` `GIT_BRANCH` `GIT_API_BASE` · `GIT_TOKEN`(密) |
|
||||
| vm | (ssh) | `VM_HOST` `VM_PORT` `VM_USER` · `VM_PASSWORD` / `VM_SSH_KEY`(密) |
|
||||
| git | github / gitea / gitlab | `GIT_PROVIDER` `GIT_REPO_URL` `GIT_DEFAULT_BRANCH` · `GIT_TOKEN`(密) |
|
||||
| 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 | redis | `REDIS_HOST` `REDIS_PORT` `REDIS_DB` · `REDIS_PASSWORD`(密) |
|
||||
| database | mongo | `MONGO_HOST` `MONGO_PORT` `MONGO_DATABASE` `MONGO_USER` · `MONGO_PASSWORD`(密) |
|
||||
| storage(blob) | azure blob | `BLOB_ACCOUNT` `BLOB_CONTAINER` · `BLOB_KEY`(密) |
|
||||
| storage(bucket) | s3/oss/minio | `BUCKET_ENDPOINT` `BUCKET_REGION` `BUCKET_NAME` · `BUCKET_ACCESS_KEY_ID` `BUCKET_SECRET_ACCESS_KEY`(密) |
|
||||
| database | postgres | `POSTGRES_HOST` `POSTGRES_PORT` `POSTGRES_DATABASE` `POSTGRES_USER` · `POSTGRES_PASSWORD`(密) |
|
||||
| storage | azure blob | `AZURE_BLOB_ACCOUNT_NAME` `AZURE_BLOB_CONTAINER` · `AZURE_BLOB_ACCOUNT_KEY`(密) |
|
||||
|
||||
另外随启动注入(模型网关):`OPENAI_BASE_URL`(= HM `/v1`)、`MODEL_NAME`。**`OPENAI_API_KEY` 不在启动时注入** —— 由客户端在 A2A 请求里带 `api_key`(你们文档 §7);后续若走 V2 解密路径再调整。
|
||||
|
||||
- 缺失的可选字段不会出现在 env 里(agent 自行容错)。
|
||||
- 同类型资源 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_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_STOP_PATH` | `/api/agent/agents/{agent_id}/stop` | 停止 |
|
||||
| `AGENT_RUNTIME_START_TIMEOUT_SECONDS` | `60` | 启动超时 |
|
||||
|
||||
@@ -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`。
|
||||
- 客户端**直连该子域名,SSE 双向通信**,请求携带 `access_token`(具体头部/握手以 AM 的 agent 端契约为准)。
|
||||
- **agent 自己校验 token**;token 错/缺则拒绝(防公网裸奔)。
|
||||
- agent 用 `.env` 里注入的资源(git/vm/db/blob 配置)自行干活;**用模型仍打 HM `/v1/*`**。
|
||||
- agent 不会把 `.env`/密钥回显到对话或日志(由 AM 保证)。
|
||||
- **同步**:`POST {subdomain}/message/send`
|
||||
- **流式**:`POST {subdomain}/message/stream`(返回 `text/event-stream`)
|
||||
- **发现/健康**:`GET {subdomain}/.well-known/agent.json`、`GET {subdomain}/health`
|
||||
|
||||
> 该直连契约(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)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -31,101 +31,73 @@ const (
|
||||
func normalizeProvider(p string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(p)) {
|
||||
case "postgres", "postgresql", "pg":
|
||||
return "pg"
|
||||
return "postgres"
|
||||
case "mysql", "mariadb":
|
||||
return "mysql"
|
||||
case "redis":
|
||||
return "redis"
|
||||
case "mongo", "mongodb", "mg":
|
||||
return "mongo"
|
||||
case "github":
|
||||
return "github"
|
||||
case "gitea":
|
||||
return "gitea"
|
||||
case "gitlab":
|
||||
return "gitlab"
|
||||
case "blob", "azureblob", "azure-blob":
|
||||
case "blob", "azureblob", "azure-blob", "azure":
|
||||
return "blob"
|
||||
case "bucket", "s3", "oss", "minio", "cos":
|
||||
return "bucket"
|
||||
default:
|
||||
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 {
|
||||
return []envField{
|
||||
{prefix + "_HOST", envMeta, "host"},
|
||||
{prefix + "_PORT", envMeta, "port"},
|
||||
{prefix + "_DATABASE", envMeta, "database"},
|
||||
{prefix + "_USER", envMeta, "user"},
|
||||
{prefix + "_PASSWORD", envSecret, "password"},
|
||||
{prefix + "_DATABASE", envMeta, "db_name"},
|
||||
{prefix + "_USER", envMeta, "username"},
|
||||
{prefix + "_PASSWORD", envSecret, "database_password"},
|
||||
}
|
||||
}
|
||||
|
||||
// builtinEnvSpec returns the fixed env mapping for a binding, keyed by resource
|
||||
// type and provider. Built into HM; not user-configurable.
|
||||
// builtinEnvSpec returns the fixed env mapping for a binding. The env NAMES match
|
||||
// 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),
|
||||
// storage (azure blob / object-storage bucket).
|
||||
// Supported by AM today: git (github/gitea/gitlab) · database (mysql/postgres) ·
|
||||
// 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 {
|
||||
rt := strings.ToLower(strings.TrimSpace(resourceType))
|
||||
p := normalizeProvider(provider)
|
||||
|
||||
switch rt {
|
||||
case "git":
|
||||
// Same env names across gitea/github/gitlab; provider passed through.
|
||||
return []envField{
|
||||
{"GIT_PROVIDER", envMeta, "provider"},
|
||||
{"GIT_REPO_URL", envMeta, "repo_url"},
|
||||
{"GIT_BRANCH", envMeta, "default_branch"},
|
||||
{"GIT_API_BASE", envMeta, "api_base"},
|
||||
{"GIT_DEFAULT_BRANCH", envMeta, "default_branch"},
|
||||
{"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":
|
||||
switch p {
|
||||
case "mysql":
|
||||
return dbEnvSpec("MYSQL")
|
||||
case "pg":
|
||||
return dbEnvSpec("PG")
|
||||
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"},
|
||||
}
|
||||
case "postgres":
|
||||
return dbEnvSpec("POSTGRES")
|
||||
default:
|
||||
return dbEnvSpec("DB")
|
||||
return nil // AM supports only mysql / postgres
|
||||
}
|
||||
case "blob", "storage":
|
||||
if p == "bucket" {
|
||||
if p == "blob" || p == "azure" || p == "" {
|
||||
return []envField{
|
||||
{"BUCKET_ENDPOINT", envMeta, "endpoint"},
|
||||
{"BUCKET_REGION", envMeta, "region"},
|
||||
{"BUCKET_NAME", envMeta, "bucket"},
|
||||
{"BUCKET_ACCESS_KEY_ID", envSecret, "access_key_id"},
|
||||
{"BUCKET_SECRET_ACCESS_KEY", envSecret, "secret_access_key"},
|
||||
{"AZURE_BLOB_ACCOUNT_NAME", envMeta, "account"},
|
||||
{"AZURE_BLOB_CONTAINER", envMeta, "container"},
|
||||
{"AZURE_BLOB_ACCOUNT_KEY", envSecret, "access_key"},
|
||||
}
|
||||
}
|
||||
// Default: Azure Blob.
|
||||
return []envField{
|
||||
{"BLOB_ACCOUNT", envMeta, "account"},
|
||||
{"BLOB_CONTAINER", envMeta, "container"},
|
||||
{"BLOB_KEY", envSecret, "key"},
|
||||
}
|
||||
return nil // object-storage buckets not supported by AM yet
|
||||
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) != "" {
|
||||
_ = 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.
|
||||
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.
|
||||
result, err := amStartTemplateAgent(c.Request.Context(), amStartArgs{
|
||||
ManagerDeploymentID: deploymentID,
|
||||
UserID: strconv.Itoa(userID),
|
||||
TemplateKey: tpl.TemplateKey,
|
||||
AgentDefinition: tpl.Definition,
|
||||
Model: tpl.Model,
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
// (.md) is maintained by HM and sent to AM here.
|
||||
type amStartArgs struct {
|
||||
ManagerDeploymentID string
|
||||
UserID string
|
||||
TemplateKey string
|
||||
AgentDefinition string // the agent .md (system prompt + frontmatter)
|
||||
Model string
|
||||
@@ -38,6 +39,13 @@ type amStartArgs struct {
|
||||
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.
|
||||
type amStartResult struct {
|
||||
RuntimeID string
|
||||
@@ -47,7 +55,8 @@ type amStartResult struct {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -121,39 +130,68 @@ func amTemplateDo(ctx context.Context, method, path string, body any, timeout ti
|
||||
return extractAgentRuntimeData(envelope), nil
|
||||
}
|
||||
|
||||
// amStartTemplateAgent asks AM to start an agent from the HM-maintained template
|
||||
// definition (.md) with the resolved resources injected as env.
|
||||
// Proposed request: {manager_deployment_id, template_key, agent_definition,
|
||||
// model, env, callback_url}.
|
||||
// Proposed response data: {runtime_id, subdomain, access_token, status}.
|
||||
// amStartTemplateAgent asks AM to start a coding_a2a_agent. It maps HM's model
|
||||
// (template .md + resource env) onto AM's create contract (CODING_A2A doc):
|
||||
// POST /agents { name, template:"coding_a2a_agent", framework:"A2A",
|
||||
// config:{user_id}, env:{...} } where the role/instruction/model are folded into
|
||||
// env. AM's create response carries access_info.domain / namespace etc.
|
||||
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")
|
||||
}
|
||||
// Build AM's env: resource env + role/instruction + model gateway.
|
||||
env := map[string]string{}
|
||||
for k, v := range args.Env {
|
||||
env[k] = v
|
||||
}
|
||||
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{
|
||||
"manager_deployment_id": args.ManagerDeploymentID,
|
||||
"template_key": args.TemplateKey,
|
||||
"agent_definition": args.AgentDefinition,
|
||||
"model": args.Model,
|
||||
"env": args.Env,
|
||||
"callback_url": args.CallbackURL,
|
||||
"name": name,
|
||||
"template": "coding_a2a_agent",
|
||||
"framework": "A2A",
|
||||
"config": map[string]any{
|
||||
"user_id": args.UserID,
|
||||
"manager_deployment_id": args.ManagerDeploymentID,
|
||||
"callback_url": args.CallbackURL,
|
||||
},
|
||||
"env": env,
|
||||
}
|
||||
data, err := amTemplateDo(ctx, http.MethodPost, agentTemplateStartPath(), payload, agentTemplateStartTimeout())
|
||||
if err != nil {
|
||||
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{
|
||||
RuntimeID: stringFromMap(data, "runtime_id", "agent_id", "id", "deployment_id"),
|
||||
Subdomain: stringFromMap(data, "subdomain", "address", "url"),
|
||||
RuntimeID: firstNonEmpty(stringFromMap(data, "runtime_id", "agent_id", "id", "name", "namespace"), name),
|
||||
Subdomain: subdomain,
|
||||
AccessToken: stringFromMap(data, "access_token", "token"),
|
||||
Status: stringFromMap(data, "status", "runtime_status"),
|
||||
Status: firstNonEmpty(stringFromMap(data, "status", "runtime_status"), "running"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -16,21 +16,21 @@ import (
|
||||
func TestBuildAgentEnvFromBindings_BuiltinBlobMetadataOnly(t *testing.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{
|
||||
UserId: 7,
|
||||
Name: "blob1",
|
||||
ResourceType: "blob",
|
||||
Provider: "blob",
|
||||
Provider: "azure",
|
||||
Metadata: `{"account":"myacct","container":"uploads"}`,
|
||||
}
|
||||
require.NoError(t, model.DB.Create(&b).Error)
|
||||
|
||||
env, err := buildAgentEnvFromBindings(7, []int{b.Id})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "myacct", env["BLOB_ACCOUNT"])
|
||||
require.Equal(t, "uploads", env["BLOB_CONTAINER"])
|
||||
_, hasKey := env["BLOB_KEY"]
|
||||
require.Equal(t, "myacct", env["AZURE_BLOB_ACCOUNT_NAME"])
|
||||
require.Equal(t, "uploads", env["AZURE_BLOB_CONTAINER"])
|
||||
_, hasKey := env["AZURE_BLOB_ACCOUNT_KEY"]
|
||||
require.False(t, hasKey) // no secret bound
|
||||
}
|
||||
|
||||
@@ -38,13 +38,12 @@ func TestBuildAgentEnvFromBindings_DatabaseProviderPrefix(t *testing.T) {
|
||||
setupResourceControllerTestDB(t)
|
||||
cases := []struct {
|
||||
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"},
|
||||
{"pg", "PG_HOST"},
|
||||
{"postgresql", "PG_HOST"}, // alias normalises to pg
|
||||
{"redis", "REDIS_HOST"},
|
||||
{"mg", "MONGO_HOST"}, // alias normalises to mongo
|
||||
{"postgres", "POSTGRES_HOST"},
|
||||
{"postgresql", "POSTGRES_HOST"}, // alias normalises to postgres
|
||||
{"pg", "POSTGRES_HOST"}, // alias normalises to postgres
|
||||
}
|
||||
for _, tc := range cases {
|
||||
b := model.ResourceBinding{
|
||||
@@ -52,7 +51,8 @@ func TestBuildAgentEnvFromBindings_DatabaseProviderPrefix(t *testing.T) {
|
||||
Name: "db-" + tc.provider,
|
||||
ResourceType: "database",
|
||||
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)
|
||||
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) {
|
||||
setupResourceControllerTestDB(t)
|
||||
for _, provider := range []string{"github", "gitea", "gitlab"} {
|
||||
@@ -69,14 +77,14 @@ func TestBuildAgentEnvFromBindings_GitNamesProviderAgnostic(t *testing.T) {
|
||||
Name: "git-" + provider,
|
||||
ResourceType: "git",
|
||||
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)
|
||||
env, err := buildAgentEnvFromBindings(7, []int{b.Id})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://x/owner/repo", env["GIT_REPO_URL"])
|
||||
require.Equal(t, "main", env["GIT_BRANCH"])
|
||||
require.Equal(t, provider, env["GIT_PROVIDER"])
|
||||
require.Equal(t, "main", env["GIT_DEFAULT_BRANCH"])
|
||||
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) {
|
||||
require.Contains(t, agentTemplateStartPath(), "/start")
|
||||
require.Contains(t, agentTemplateStartPath(), "/agents")
|
||||
require.Contains(t, agentTemplateAgentStopPath("rt-9"), "/agents/rt-9/stop")
|
||||
require.Contains(t, agentTemplateAgentPath("rt-9"), "/agents/rt-9")
|
||||
}
|
||||
|
||||
func TestAMStartTemplateAgent_RoundTrip(t *testing.T) {
|
||||
var gotBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodPost, r.Method)
|
||||
require.Equal(t, "/api/agent/agents/start", r.URL.Path)
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
require.Contains(t, string(body), "manager_deployment_id")
|
||||
require.Contains(t, string(body), "agent_definition")
|
||||
require.Equal(t, "/agents", r.URL.Path) // AM's create endpoint
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(b)
|
||||
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()
|
||||
t.Setenv("AGENT_RUNTIME_BASE_URL", srv.URL)
|
||||
|
||||
res, err := amStartTemplateAgent(context.Background(), amStartArgs{
|
||||
ManagerDeploymentID: "dep_1",
|
||||
UserID: "22",
|
||||
TemplateKey: "architect",
|
||||
AgentDefinition: "---\nname: architect\n---\nbody",
|
||||
Model: "opus",
|
||||
Env: map[string]string{"K": "V"},
|
||||
Model: "gpt-5.4",
|
||||
Env: map[string]string{"GIT_REPO_URL": "https://x/r"},
|
||||
CallbackURL: "https://cb",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "rt-9", res.RuntimeID)
|
||||
require.Equal(t, "https://x.example", res.Subdomain)
|
||||
require.Equal(t, "tok", res.AccessToken)
|
||||
require.Equal(t, "running", res.Status)
|
||||
// payload mapped onto AM's contract
|
||||
require.Contains(t, gotBody, `"template":"coding_a2a_agent"`)
|
||||
require.Contains(t, gotBody, "AGENT_INSTRUCTION_TEXT") // .md folded into env
|
||||
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) {
|
||||
|
||||
Vendored
+1
-1
@@ -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.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>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -138,7 +138,12 @@ export function DeploySubAgentPage() {
|
||||
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 canDeploy = Boolean(selectedTemplate) && !deploy.isPending
|
||||
|
||||
|
||||
+17
-20
@@ -5,7 +5,6 @@ import {
|
||||
Database,
|
||||
GitBranch,
|
||||
Plus,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
@@ -85,30 +84,28 @@ const KINDS: Kind[] = [
|
||||
secret: [{ k: 'token', label: '访问令牌 (PAT)', ph: '…' }],
|
||||
},
|
||||
{
|
||||
key: 'vm',
|
||||
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',
|
||||
key: 'mysql',
|
||||
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',
|
||||
label: '数据库',
|
||||
label: 'PostgreSQL',
|
||||
icon: Database,
|
||||
externalFrom: 'host',
|
||||
fields: [
|
||||
{ k: 'engine', label: '引擎', ph: 'postgres / mysql' },
|
||||
{ k: 'host', label: '主机', ph: 'db.example.com' },
|
||||
{ k: 'port', label: '端口', ph: '5432' },
|
||||
{ k: 'db_name', label: '数据库名', ph: 'app' },
|
||||
|
||||
Reference in New Issue
Block a user