ACP / MCP

This commit is contained in:
2026-06-12 11:44:19 +08:00
parent 2a9661a6ef
commit 4ea4adfd8e
47 changed files with 2558 additions and 240 deletions
+92
View File
@@ -0,0 +1,92 @@
// agent_home.go 实现 agent CLI 受管 home 目录的物化与重定向 env 注入。
//
// 每个 AgentKind 一个受管 home:<agent_homes_dir>/<agent_kind_id>/。spawn 前把
// DB 中的配置文件解密落盘(全量覆写受管文件,不清空目录 —— agent 运行期写入的
// 缓存 / 凭据得以保留),再按 client_type 注入配置目录重定向变量。受管 home 位于
// DataDir 下,容器场景随持久卷存活。
package acp
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// materializeAgentHome 创建受管 home 并物化该 AgentKind 的全部配置文件。
// 返回 home 绝对路径。配置文件为空时仍创建目录(agent 需要可写 home)。
func (s *Supervisor) materializeAgentHome(ctx context.Context, kind *AgentKind) (string, error) {
home, err := filepath.Abs(filepath.Join(s.cfg.AgentHomesDir, kind.ID.String()))
if err != nil {
return "", fmt.Errorf("resolve agent home: %w", err)
}
if err := os.MkdirAll(home, 0o700); err != nil {
return "", fmt.Errorf("create agent home: %w", err)
}
// client 专属配置目录预创建:CODEX_HOME 指向的目录必须已存在(codex 硬性要求),
// 其余预创建无害且省去 agent 首次启动的目录初始化分支。
if sub := clientConfigSubdir(kind.ClientType); sub != "" {
if err := os.MkdirAll(filepath.Join(home, sub), 0o700); err != nil {
return "", fmt.Errorf("create client config dir: %w", err)
}
}
files, err := s.repo.ListConfigFiles(ctx, kind.ID)
if err != nil {
return "", err
}
sep := string(filepath.Separator)
for _, f := range files {
plain, derr := s.crypto.Decrypt(f.EncryptedContent)
if derr != nil {
return "", fmt.Errorf("decrypt config file %s: %w", f.RelPath, derr)
}
dst := filepath.Clean(filepath.Join(home, filepath.FromSlash(f.RelPath)))
// rel_path 写入时已校验;此处兜底防 DB 被直接篡改后逃逸受管目录。
if !strings.HasPrefix(dst, home+sep) {
return "", fmt.Errorf("config file %s escapes agent home", f.RelPath)
}
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
return "", fmt.Errorf("create config file dir %s: %w", f.RelPath, err)
}
if err := os.WriteFile(dst, plain, 0o600); err != nil {
return "", fmt.Errorf("write config file %s: %w", f.RelPath, err)
}
}
return home, nil
}
// agentHomeEnv 返回受管 home 对应的重定向 env 默认值。优先级最低:AgentKind env
// 与系统 extraEnv 可覆盖同名 key。
func agentHomeEnv(home string, ct ClientType) map[string]string {
env := map[string]string{"HOME": home}
if runtime.GOOS == "windows" {
// Windows 上 Node/Rust 解析 home 走 USERPROFILE(开发环境)。
env["USERPROFILE"] = home
}
switch ct {
case ClientClaudeCode:
env["CLAUDE_CONFIG_DIR"] = filepath.Join(home, ".claude")
case ClientCodex:
env["CODEX_HOME"] = filepath.Join(home, ".codex")
case ClientGemini:
// GEMINI_CLI_HOME 替换的是 home 本身,.gemini 段由 CLI 拼接。
env["GEMINI_CLI_HOME"] = home
}
return env
}
// clientConfigSubdir 返回 client 专属配置子目录(相对受管 home),无则空串。
func clientConfigSubdir(ct ClientType) string {
switch ct {
case ClientClaudeCode:
return ".claude"
case ClientCodex:
return ".codex"
case ClientGemini:
return ".gemini"
}
return ""
}
+107
View File
@@ -0,0 +1,107 @@
package acp
import (
"context"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
)
// cfgRepoStub 只实现 ListConfigFiles;其余方法走嵌入的 nil 接口(调用即 panic)。
type cfgRepoStub struct {
Repository
files []*ConfigFile
}
func (s cfgRepoStub) ListConfigFiles(context.Context, uuid.UUID) ([]*ConfigFile, error) {
return s.files, nil
}
func TestMaterializeAgentHome(t *testing.T) {
t.Parallel()
enc, err := crypto.NewEncryptor([]byte("0123456789abcdef0123456789abcdef"))
require.NoError(t, err)
kind := &AgentKind{ID: uuid.New(), ClientType: ClientCodex}
content := []byte(`model = "gpt-5.5"`)
encrypted, err := enc.Encrypt(content)
require.NoError(t, err)
root := t.TempDir()
s := &Supervisor{
repo: cfgRepoStub{files: []*ConfigFile{{AgentKindID: kind.ID, RelPath: ".codex/config.toml", EncryptedContent: encrypted}}},
crypto: enc,
cfg: SupervisorConfig{AgentHomesDir: root},
}
home, err := s.materializeAgentHome(context.Background(), kind)
require.NoError(t, err)
assert.Equal(t, filepath.Join(root, kind.ID.String()), home)
got, err := os.ReadFile(filepath.Join(home, ".codex", "config.toml"))
require.NoError(t, err)
assert.Equal(t, content, got)
// 二次物化幂等(覆写)
_, err = s.materializeAgentHome(context.Background(), kind)
require.NoError(t, err)
}
func TestMaterializeAgentHome_RejectsEscape(t *testing.T) {
t.Parallel()
enc, err := crypto.NewEncryptor([]byte("0123456789abcdef0123456789abcdef"))
require.NoError(t, err)
encrypted, err := enc.Encrypt([]byte("x"))
require.NoError(t, err)
kind := &AgentKind{ID: uuid.New(), ClientType: ClientGeneric}
s := &Supervisor{
repo: cfgRepoStub{files: []*ConfigFile{{AgentKindID: kind.ID, RelPath: "../escape.txt", EncryptedContent: encrypted}}},
crypto: enc,
cfg: SupervisorConfig{AgentHomesDir: t.TempDir()},
}
_, err = s.materializeAgentHome(context.Background(), kind)
require.Error(t, err)
assert.Contains(t, err.Error(), "escapes agent home")
}
func TestAgentHomeEnv_ByClientType(t *testing.T) {
t.Parallel()
home := filepath.Join(string(filepath.Separator), "data", "agent-homes", "x")
env := agentHomeEnv(home, ClientClaudeCode)
assert.Equal(t, home, env["HOME"])
assert.Equal(t, filepath.Join(home, ".claude"), env["CLAUDE_CONFIG_DIR"])
env = agentHomeEnv(home, ClientCodex)
assert.Equal(t, filepath.Join(home, ".codex"), env["CODEX_HOME"])
env = agentHomeEnv(home, ClientGemini)
assert.Equal(t, home, env["GEMINI_CLI_HOME"])
env = agentHomeEnv(home, ClientGeneric)
assert.Equal(t, map[string]string{"HOME": home}, stripWinEnv(env))
if runtime.GOOS == "windows" {
assert.Equal(t, home, agentHomeEnv(home, ClientGeneric)["USERPROFILE"])
}
}
// stripWinEnv 去掉平台相关的 USERPROFILE,便于跨平台断言。
func stripWinEnv(env map[string]string) map[string]string {
out := map[string]string{}
for k, v := range env {
if k == "USERPROFILE" {
continue
}
out[k] = v
}
return out
}
+133
View File
@@ -0,0 +1,133 @@
// agentkind_configfiles.go 实现 AgentKind 配置文件管理(admin only)。
//
// 配置文件是 agent CLI 自身的配置(如 .claude/settings.json、.codex/config.toml),
// rel_path 相对受管 home 目录,spawn 前由 supervisor 物化到
// <agent_homes_dir>/<agent_kind_id>/ 并通过 CLAUDE_CONFIG_DIR / CODEX_HOME /
// GEMINI_CLI_HOME / HOME 指过去。
//
// 与 env 的安全模型差异(有意为之):env 加密后永不出 API;配置文件内容同样
// 加密存储(可能含 auth token),但明文返回给 admin —— 否则无法在 Web 端编辑。
package acp
import (
"context"
"encoding/json"
"path"
"strings"
"github.com/google/uuid"
toml "github.com/pelletier/go-toml/v2"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
// maxConfigFileSize 是单个配置文件明文上限(字节)。
const maxConfigFileSize = 256 << 10
func (s *agentKindService) ListConfigFiles(ctx context.Context, c Caller, kindID uuid.UUID) ([]*ConfigFileContent, error) {
if !c.IsAdmin {
return nil, errs.New(errs.CodeForbidden, "admin only")
}
if _, err := s.repo.GetAgentKindByID(ctx, kindID); err != nil {
return nil, err
}
files, err := s.repo.ListConfigFiles(ctx, kindID)
if err != nil {
return nil, err
}
out := make([]*ConfigFileContent, 0, len(files))
for _, f := range files {
plain, derr := s.enc.Decrypt(f.EncryptedContent)
if derr != nil {
return nil, errs.Wrap(derr, errs.CodeInternal, "decrypt config file")
}
out = append(out, &ConfigFileContent{RelPath: f.RelPath, Content: string(plain), UpdatedAt: f.UpdatedAt})
}
return out, nil
}
func (s *agentKindService) UpsertConfigFile(ctx context.Context, c Caller, kindID uuid.UUID, relPath, content string) (*ConfigFileContent, error) {
if !c.IsAdmin {
return nil, errs.New(errs.CodeForbidden, "admin only")
}
if _, err := s.repo.GetAgentKindByID(ctx, kindID); err != nil {
return nil, err
}
clean, err := normalizeConfigRelPath(relPath)
if err != nil {
return nil, err
}
if len(content) > maxConfigFileSize {
return nil, errs.New(errs.CodeInvalidInput, "config file too large (max 256KB)")
}
if err := validateConfigSyntax(clean, content); err != nil {
return nil, err
}
encrypted, err := s.enc.Encrypt([]byte(content))
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "encrypt config file")
}
out, err := s.repo.UpsertConfigFile(ctx, &ConfigFile{
ID: uuid.New(), AgentKindID: kindID, RelPath: clean,
EncryptedContent: encrypted, UpdatedBy: c.UserID,
})
if err != nil {
return nil, err
}
s.recordAudit(ctx, c, "acp.agent_kind.config_file.upsert", kindID.String(),
map[string]any{"rel_path": clean, "size": len(content)})
return &ConfigFileContent{RelPath: out.RelPath, Content: content, UpdatedAt: out.UpdatedAt}, nil
}
func (s *agentKindService) DeleteConfigFile(ctx context.Context, c Caller, kindID uuid.UUID, relPath string) error {
if !c.IsAdmin {
return errs.New(errs.CodeForbidden, "admin only")
}
clean, err := normalizeConfigRelPath(relPath)
if err != nil {
return err
}
found, err := s.repo.DeleteConfigFile(ctx, kindID, clean)
if err != nil {
return err
}
if !found {
return errs.New(errs.CodeNotFound, "config file not found")
}
s.recordAudit(ctx, c, "acp.agent_kind.config_file.delete", kindID.String(),
map[string]any{"rel_path": clean})
return nil
}
// normalizeConfigRelPath 归一并校验 rel_path:统一正斜杠、path.Clean 后必须仍是
// 受管目录内的相对路径(拒绝绝对路径、盘符与 ".." 逃逸)。返回归一形式作为存储键。
func normalizeConfigRelPath(p string) (string, error) {
p = strings.TrimSpace(strings.ReplaceAll(p, "\\", "/"))
if p == "" || len(p) > 512 {
return "", errs.New(errs.CodeInvalidInput, "rel_path required (max 512 chars)")
}
if strings.HasPrefix(p, "/") || strings.Contains(p, ":") {
return "", errs.New(errs.CodeInvalidInput, "rel_path must be relative")
}
clean := path.Clean(p)
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
return "", errs.New(errs.CodeInvalidInput, "rel_path escapes managed directory")
}
return clean, nil
}
// validateConfigSyntax 按扩展名做语法校验。未识别的扩展名不校验。
func validateConfigSyntax(relPath, content string) error {
switch strings.ToLower(path.Ext(relPath)) {
case ".json":
if !json.Valid([]byte(content)) {
return errs.New(errs.CodeInvalidInput, "invalid JSON content")
}
case ".toml":
var v any
if err := toml.Unmarshal([]byte(content), &v); err != nil {
return errs.New(errs.CodeInvalidInput, "invalid TOML content: "+err.Error())
}
}
return nil
}
+156
View File
@@ -0,0 +1,156 @@
package acp_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/acp"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
func newConfigFileTestSvc(t *testing.T) (acp.AgentKindService, *acp.AgentKind, acp.Caller) {
t.Helper()
repo := newFakeAgentKindRepo()
svc := acp.NewAgentKindService(repo, testEncryptor(t), &agentKindRecordingRecorder{})
admin := acp.Caller{UserID: uuid.New(), IsAdmin: true}
k, err := svc.Create(context.Background(), admin, acp.CreateAgentKindInput{
Name: "claude", DisplayName: "Claude", BinaryPath: "/x",
ClientType: acp.ClientClaudeCode, Enabled: true,
})
require.NoError(t, err)
return svc, k, admin
}
func TestConfigFiles_UpsertListDelete_Roundtrip(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc, k, admin := newConfigFileTestSvc(t)
content := `{"model":"claude-sonnet-4-6"}`
out, err := svc.UpsertConfigFile(ctx, admin, k.ID, ".claude/settings.json", content)
require.NoError(t, err)
assert.Equal(t, ".claude/settings.json", out.RelPath)
assert.Equal(t, content, out.Content)
list, err := svc.ListConfigFiles(ctx, admin, k.ID)
require.NoError(t, err)
require.Len(t, list, 1)
assert.Equal(t, content, list[0].Content) // 解密往返
require.NoError(t, svc.DeleteConfigFile(ctx, admin, k.ID, ".claude/settings.json"))
list, err = svc.ListConfigFiles(ctx, admin, k.ID)
require.NoError(t, err)
assert.Empty(t, list)
}
func TestConfigFiles_AdminOnly(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc, k, _ := newConfigFileTestSvc(t)
user := acp.Caller{UserID: uuid.New(), IsAdmin: false}
_, err := svc.ListConfigFiles(ctx, user, k.ID)
ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeForbidden, ae.Code)
_, err = svc.UpsertConfigFile(ctx, user, k.ID, "a.json", "{}")
ae, ok = errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeForbidden, ae.Code)
}
func TestConfigFiles_RelPathValidation(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc, k, admin := newConfigFileTestSvc(t)
bad := []string{
"", "/etc/passwd", "../escape.json", "a/../../b.json",
"C:/windows/x.json", "..", ".",
}
for _, p := range bad {
_, err := svc.UpsertConfigFile(ctx, admin, k.ID, p, "{}")
ae, ok := errs.As(err)
require.True(t, ok, "rel_path %q should be rejected", p)
assert.Equal(t, errs.CodeInvalidInput, ae.Code, "rel_path %q", p)
}
// 反斜杠归一为正斜杠;./ 前缀被 Clean 掉
out, err := svc.UpsertConfigFile(ctx, admin, k.ID, `.codex\config.toml`, `model = "gpt-5.5"`)
require.NoError(t, err)
assert.Equal(t, ".codex/config.toml", out.RelPath)
out, err = svc.UpsertConfigFile(ctx, admin, k.ID, "./a/b.json", "{}")
require.NoError(t, err)
assert.Equal(t, "a/b.json", out.RelPath)
}
func TestConfigFiles_SyntaxValidation(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc, k, admin := newConfigFileTestSvc(t)
_, err := svc.UpsertConfigFile(ctx, admin, k.ID, ".claude/settings.json", "{not json")
ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeInvalidInput, ae.Code)
_, err = svc.UpsertConfigFile(ctx, admin, k.ID, ".codex/config.toml", "= broken toml")
ae, ok = errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeInvalidInput, ae.Code)
// 未识别扩展名不校验
_, err = svc.UpsertConfigFile(ctx, admin, k.ID, ".claude/CLAUDE.md", "# 任意文本")
require.NoError(t, err)
}
func TestConfigFiles_DeleteNotFound(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc, k, admin := newConfigFileTestSvc(t)
err := svc.DeleteConfigFile(ctx, admin, k.ID, "missing.json")
ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeNotFound, ae.Code)
}
func TestAgentKindService_ClientType(t *testing.T) {
t.Parallel()
ctx := context.Background()
repo := newFakeAgentKindRepo()
svc := acp.NewAgentKindService(repo, testEncryptor(t), nil)
admin := acp.Caller{UserID: uuid.New(), IsAdmin: true}
// 缺省 → generic
k, err := svc.Create(ctx, admin, acp.CreateAgentKindInput{
Name: "k1", DisplayName: "K1", BinaryPath: "/x", Enabled: true,
})
require.NoError(t, err)
assert.Equal(t, acp.ClientGeneric, k.ClientType)
// 非法值拒绝
_, err = svc.Create(ctx, admin, acp.CreateAgentKindInput{
Name: "k2", DisplayName: "K2", BinaryPath: "/x", ClientType: "bogus", Enabled: true,
})
ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeInvalidInput, ae.Code)
// 更新 client_type
ct := acp.ClientCodex
updated, err := svc.Update(ctx, admin, k.ID, acp.UpdateAgentKindInput{ClientType: &ct})
require.NoError(t, err)
assert.Equal(t, acp.ClientCodex, updated.ClientType)
bad := acp.ClientType("nope")
_, err = svc.Update(ctx, admin, k.ID, acp.UpdateAgentKindInput{ClientType: &bad})
ae, ok = errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeInvalidInput, ae.Code)
}
+71
View File
@@ -0,0 +1,71 @@
package acp_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/acp"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
func TestAgentKindService_MCPServers_Validation(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc := acp.NewAgentKindService(newFakeAgentKindRepo(), testEncryptor(t), nil)
admin := acp.Caller{UserID: uuid.New(), IsAdmin: true}
base := acp.CreateAgentKindInput{Name: "k", DisplayName: "K", BinaryPath: "/x", Enabled: true}
cases := []struct {
name string
servers []acp.McpServerSpec
}{
{"missing name", []acp.McpServerSpec{{Type: acp.McpStdio, Command: "/x"}}},
{"reserved acw name", []acp.McpServerSpec{{Name: "acw", Type: acp.McpHTTP, URL: "http://x"}}},
{"duplicate name", []acp.McpServerSpec{
{Name: "d", Type: acp.McpStdio, Command: "/x"},
{Name: "d", Type: acp.McpHTTP, URL: "http://x"},
}},
{"stdio without command", []acp.McpServerSpec{{Name: "a", Type: acp.McpStdio}}},
{"http without url", []acp.McpServerSpec{{Name: "a", Type: acp.McpHTTP}}},
{"invalid type", []acp.McpServerSpec{{Name: "a", Type: "magic"}}},
}
for _, tc := range cases {
in := base
in.MCPServers = tc.servers
_, err := svc.Create(ctx, admin, in)
ae, ok := errs.As(err)
require.True(t, ok, "case %s should fail", tc.name)
assert.Equal(t, errs.CodeInvalidInput, ae.Code, "case %s", tc.name)
}
}
func TestAgentKindService_MCPServers_CreateAndUpdate(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc := acp.NewAgentKindService(newFakeAgentKindRepo(), testEncryptor(t), nil)
admin := acp.Caller{UserID: uuid.New(), IsAdmin: true}
k, err := svc.Create(ctx, admin, acp.CreateAgentKindInput{
Name: "k", DisplayName: "K", BinaryPath: "/x", Enabled: true,
MCPServers: []acp.McpServerSpec{
{Name: "ctx7", Type: acp.McpHTTP, URL: "https://mcp.ctx7.dev", Headers: map[string]string{"X-Key": "v"}},
},
})
require.NoError(t, err)
assert.NotEmpty(t, k.EncryptedMCPServers) // 已加密落库
// nil = 不改
updated, err := svc.Update(ctx, admin, k.ID, acp.UpdateAgentKindInput{})
require.NoError(t, err)
assert.Equal(t, k.EncryptedMCPServers, updated.EncryptedMCPServers)
// 空列表 = 清空
updated, err = svc.Update(ctx, admin, k.ID, acp.UpdateAgentKindInput{MCPServers: []acp.McpServerSpec{}})
require.NoError(t, err)
assert.NotEqual(t, k.EncryptedMCPServers, updated.EncryptedMCPServers)
}
+94 -2
View File
@@ -29,10 +29,21 @@ func (s *agentKindService) Create(ctx context.Context, c Caller, in CreateAgentK
if in.Name == "" || in.DisplayName == "" || in.BinaryPath == "" {
return nil, errs.New(errs.CodeInvalidInput, "name / display_name / binary_path required")
}
clientType := in.ClientType
if clientType == "" {
clientType = ClientGeneric
}
if !clientType.Valid() {
return nil, errs.New(errs.CodeInvalidInput, "invalid client_type")
}
encrypted, err := encryptEnv(s.enc, in.Env)
if err != nil {
return nil, err
}
encryptedMCP, err := encryptMCPServers(s.enc, in.MCPServers)
if err != nil {
return nil, err
}
args := in.Args
if args == nil {
args = []string{}
@@ -44,8 +55,10 @@ func (s *agentKindService) Create(ctx context.Context, c Caller, in CreateAgentK
k := &AgentKind{
ID: uuid.New(), Name: in.Name, DisplayName: in.DisplayName, Description: in.Description,
BinaryPath: in.BinaryPath, Args: args, EncryptedEnv: encrypted, Enabled: in.Enabled,
ToolAllowlist: allowlist,
CreatedBy: c.UserID,
ToolAllowlist: allowlist,
ClientType: clientType,
EncryptedMCPServers: encryptedMCP,
CreatedBy: c.UserID,
}
out, err := s.repo.CreateAgentKind(ctx, k)
if err != nil {
@@ -114,6 +127,21 @@ func (s *agentKindService) Update(ctx context.Context, c Caller, id uuid.UUID, i
cur.ToolAllowlist = in.ToolAllowlist
changed["tool_allowlist"] = "<changed>"
}
if in.ClientType != nil && *in.ClientType != cur.ClientType {
if !in.ClientType.Valid() {
return nil, errs.New(errs.CodeInvalidInput, "invalid client_type")
}
cur.ClientType = *in.ClientType
changed["client_type"] = string(*in.ClientType)
}
if in.MCPServers != nil {
encryptedMCP, eerr := encryptMCPServers(s.enc, in.MCPServers)
if eerr != nil {
return nil, eerr
}
cur.EncryptedMCPServers = encryptedMCP
changed["mcp_servers"] = "<changed>"
}
if in.Enabled != nil && *in.Enabled != cur.Enabled {
cur.Enabled = *in.Enabled
changed["enabled"] = *in.Enabled
@@ -171,6 +199,70 @@ func encryptEnv(enc *crypto.Encryptor, env map[string]string) ([]byte, error) {
return enc.Encrypt(b)
}
// encryptMCPServers 校验并加密 MCP server 列表。nil → nil(不存储)。
func encryptMCPServers(enc *crypto.Encryptor, servers []McpServerSpec) ([]byte, error) {
if servers == nil {
return nil, nil
}
if err := validateMCPServers(servers); err != nil {
return nil, err
}
b, err := json.Marshal(servers)
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "marshal mcp servers")
}
return enc.Encrypt(b)
}
// decryptMCPServers 解密 MCP server 列表。空密文 → 空列表。
func decryptMCPServers(enc *crypto.Encryptor, encrypted []byte) ([]McpServerSpec, error) {
if len(encrypted) == 0 {
return []McpServerSpec{}, nil
}
plain, err := enc.Decrypt(encrypted)
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "decrypt mcp servers")
}
var out []McpServerSpec
if err := json.Unmarshal(plain, &out); err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "unmarshal mcp servers")
}
if out == nil {
out = []McpServerSpec{}
}
return out, nil
}
// validateMCPServers 校验条目:name 必填且不重复;stdio 需 command,http/sse 需 url。
func validateMCPServers(servers []McpServerSpec) error {
seen := map[string]bool{}
for _, sv := range servers {
if sv.Name == "" {
return errs.New(errs.CodeInvalidInput, "mcp server name required")
}
if sv.Name == AcwMcpServerName {
return errs.New(errs.CodeInvalidInput, "mcp server name '"+AcwMcpServerName+"' is reserved")
}
if seen[sv.Name] {
return errs.New(errs.CodeInvalidInput, "duplicate mcp server name: "+sv.Name)
}
seen[sv.Name] = true
switch sv.Type {
case McpStdio:
if sv.Command == "" {
return errs.New(errs.CodeInvalidInput, "mcp server "+sv.Name+": command required for stdio")
}
case McpHTTP, McpSSE:
if sv.URL == "" {
return errs.New(errs.CodeInvalidInput, "mcp server "+sv.Name+": url required for "+string(sv.Type))
}
default:
return errs.New(errs.CodeInvalidInput, "mcp server "+sv.Name+": invalid type")
}
}
return nil
}
// decryptEnv 解密并反序列化 env map。Part 2 的 supervisor.Spawn 用。
func decryptEnv(enc *crypto.Encryptor, encrypted []byte) (map[string]string, error) {
if len(encrypted) == 0 {
+28 -1
View File
@@ -21,10 +21,14 @@ import (
// (隔离单测,不引 testcontainers)。
type fakeAgentKindRepo struct {
store map[uuid.UUID]*acp.AgentKind
files map[string]*acp.ConfigFile // key: kindID/relPath
}
func newFakeAgentKindRepo() *fakeAgentKindRepo {
return &fakeAgentKindRepo{store: map[uuid.UUID]*acp.AgentKind{}}
return &fakeAgentKindRepo{
store: map[uuid.UUID]*acp.AgentKind{},
files: map[string]*acp.ConfigFile{},
}
}
func (f *fakeAgentKindRepo) CreateAgentKind(_ context.Context, k *acp.AgentKind) (*acp.AgentKind, error) {
@@ -81,6 +85,29 @@ func (f *fakeAgentKindRepo) DeleteAgentKind(_ context.Context, id uuid.UUID) err
func (f *fakeAgentKindRepo) CountAgentKindUsage(_ context.Context, _ uuid.UUID) (int64, error) {
return 0, nil
}
func (f *fakeAgentKindRepo) ListConfigFiles(_ context.Context, kindID uuid.UUID) ([]*acp.ConfigFile, error) {
out := make([]*acp.ConfigFile, 0)
for _, cf := range f.files {
if cf.AgentKindID == kindID {
cp := *cf
out = append(out, &cp)
}
}
return out, nil
}
func (f *fakeAgentKindRepo) UpsertConfigFile(_ context.Context, cf *acp.ConfigFile) (*acp.ConfigFile, error) {
cp := *cf
f.files[cf.AgentKindID.String()+"/"+cf.RelPath] = &cp
return &cp, nil
}
func (f *fakeAgentKindRepo) DeleteConfigFile(_ context.Context, kindID uuid.UUID, relPath string) (bool, error) {
key := kindID.String() + "/" + relPath
if _, ok := f.files[key]; !ok {
return false, nil
}
delete(f.files, key)
return true, nil
}
// 其他方法 panic(不该被 AgentKindService 调用)
func (f *fakeAgentKindRepo) InsertSession(context.Context, *acp.Session) (*acp.Session, error) {
+100 -13
View File
@@ -47,21 +47,90 @@ const (
RPCError RPCKind = "error"
)
// ClientType 标识 agent CLI 的客户端类型,决定 spawn 时注入哪个配置目录
// 重定向变量(CLAUDE_CONFIG_DIR / CODEX_HOME / GEMINI_CLI_HOME)以及前端
// 渲染哪套配置表单。generic 仅注入 HOME。
type ClientType string
const (
ClientClaudeCode ClientType = "claude_code"
ClientCodex ClientType = "codex"
ClientGemini ClientType = "gemini"
ClientGeneric ClientType = "generic"
)
// Valid 判定 client_type 是否为已知枚举值。
func (t ClientType) Valid() bool {
switch t {
case ClientClaudeCode, ClientCodex, ClientGemini, ClientGeneric:
return true
}
return false
}
// ACW 自家 MCP 注入约定:session 创建时签发的 system token 与 MCP 公网地址
// 经 extraEnv 注入子进程,同时在 session/new 握手时作为 http MCP server 下发。
const (
EnvMCPToken = "ACW_MCP_TOKEN"
EnvMCPURL = "ACW_MCP_URL"
// AcwMcpServerName 是自家 MCP 在 mcpServers 里的保留名,第三方条目不可占用。
AcwMcpServerName = "acw"
)
// McpServerType 是第三方 MCP server 的传输类型。stdio 是 ACP 基线(所有 agent
// 必须支持);http / sse 需 agent 在 initialize 中声明 mcpCapabilities 才下发。
type McpServerType string
const (
McpStdio McpServerType = "stdio"
McpHTTP McpServerType = "http"
McpSSE McpServerType = "sse"
)
// McpServerSpec 是 admin 在 AgentKind 上配置的一条 MCP server。session/new
// 握手时转换为 ACP wire 格式随 mcpServers 下发。Env / Headers 可能含密钥,
// 整个列表加密落库(encrypted_mcp_servers);明文仅返回给 admin 编辑。
type McpServerSpec struct {
Name string `json:"name"`
Type McpServerType `json:"type"`
Command string `json:"command,omitempty"` // stdio
Args []string `json:"args,omitempty"` // stdio
Env map[string]string `json:"env,omitempty"` // stdio
URL string `json:"url,omitempty"` // http / sse
Headers map[string]string `json:"headers,omitempty"` // http / sse
}
// AgentKind 是 admin 注册的 agent 类型记录。EncryptedEnv 是 AES-GCM 密文,
// 仅 supervisor spawn 时解密为 map 注入子进程。其他场景不解密、不出 API。
// EncryptedMCPServers 同为密文,但明文经 API 返回给 admin 供编辑。
type AgentKind struct {
ID uuid.UUID
Name string
DisplayName string
Description string
BinaryPath string
Args []string
EncryptedEnv []byte
Enabled bool
ToolAllowlist []string // 命中的工具调用自动放行;含 "*" 表示放行全部可识别的工具(无法识别名称的调用仍走人工审批)
CreatedBy uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
ID uuid.UUID
Name string
DisplayName string
Description string
BinaryPath string
Args []string
EncryptedEnv []byte
Enabled bool
ToolAllowlist []string // 命中的工具调用自动放行;含 "*" 表示放行全部可识别的工具(无法识别名称的调用仍走人工审批)
ClientType ClientType
EncryptedMCPServers []byte
CreatedBy uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
}
// ConfigFile 是 AgentKind 维度的 agent CLI 配置文件(如 .claude/settings.json、
// .codex/config.toml)。RelPath 相对受管 home 目录;EncryptedContent 是 AES-GCM
// 密文(内容可能含 auth token),spawn 前解密物化到受管目录。
type ConfigFile struct {
ID uuid.UUID
AgentKindID uuid.UUID
RelPath string
EncryptedContent []byte
UpdatedBy uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
}
// Session 是一次 ACP 会话。AgentSessionID 是 agent 侧的 session 标识(来自
@@ -114,6 +183,19 @@ type AgentKindService interface {
Get(ctx context.Context, c Caller, id uuid.UUID) (*AgentKind, error)
Update(ctx context.Context, c Caller, id uuid.UUID, in UpdateAgentKindInput) (*AgentKind, error)
Delete(ctx context.Context, c Caller, id uuid.UUID) error
// 配置文件管理(admin only)。与 env 不同:内容明文返回给 admin 供编辑,
// 存储层加密。content 是明文。
ListConfigFiles(ctx context.Context, c Caller, kindID uuid.UUID) ([]*ConfigFileContent, error)
UpsertConfigFile(ctx context.Context, c Caller, kindID uuid.UUID, relPath, content string) (*ConfigFileContent, error)
DeleteConfigFile(ctx context.Context, c Caller, kindID uuid.UUID, relPath string) error
}
// ConfigFileContent 是解密后的配置文件视图(service → handler)。
type ConfigFileContent struct {
RelPath string
Content string
UpdatedAt time.Time
}
// SessionService 暴露 Session 聚合根的应用服务方法。
@@ -143,6 +225,7 @@ type CreateSessionInput struct {
}
// CreateAgentKindInput 携带创建必需字段。Args / Env 可为 nil。
// ClientType 为空时默认 generic。
type CreateAgentKindInput struct {
Name string
DisplayName string
@@ -152,6 +235,8 @@ type CreateAgentKindInput struct {
Env map[string]string
Enabled bool
ToolAllowlist []string
ClientType ClientType
MCPServers []McpServerSpec
}
// UpdateAgentKindInput 是 patch 输入。Name 不可改(创建后稳定,引用约束)。
@@ -163,7 +248,9 @@ type UpdateAgentKindInput struct {
Args []string // nil = 不改;非 nil = 替换
Env map[string]string // nil = 不改;非 nil(含空)= 替换
Enabled *bool
ToolAllowlist []string // nil = 不改;非 nil(含空)= 替换
ToolAllowlist []string // nil = 不改;非 nil(含空)= 替换
ClientType *ClientType // nil = 不改
MCPServers []McpServerSpec // nil = 不改;非 nil(含空)= 替换
}
// PermissionStatus 是 acp_permission_requests.status 枚举。
+31 -3
View File
@@ -18,9 +18,12 @@ type AgentKindAdminDTO struct {
EnvKeys []string `json:"env_keys"`
Enabled bool `json:"enabled"`
ToolAllowlist []string `json:"tool_allowlist"`
CreatedBy uuid.UUID `json:"created_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ClientType string `json:"client_type"`
// MCPServers 明文返回(admin only,编辑所需);条目可能含 header/env 密钥。
MCPServers []McpServerSpec `json:"mcp_servers"`
CreatedBy uuid.UUID `json:"created_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// AgentKindPublicDTO 是普通用户视图,不暴露 binary_path / args / env_keys。
@@ -41,6 +44,8 @@ type CreateAgentKindReq struct {
Env map[string]string `json:"env"`
Enabled *bool `json:"enabled"`
ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"`
MCPServers []McpServerSpec `json:"mcp_servers"`
}
type UpdateAgentKindReq struct {
@@ -51,6 +56,29 @@ type UpdateAgentKindReq struct {
Env map[string]string `json:"env,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
ToolAllowlist []string `json:"tool_allowlist,omitempty"`
ClientType *string `json:"client_type,omitempty"`
MCPServers []McpServerSpec `json:"mcp_servers,omitempty"` // nil = 不改;非 nil(含空)= 替换
}
// ConfigFileDTO 是 AgentKind 配置文件的对外视图(admin only,content 明文)。
type ConfigFileDTO struct {
RelPath string `json:"rel_path"`
Content string `json:"content"`
UpdatedAt string `json:"updated_at"`
}
// UpsertConfigFileReq 是 PUT config-files 请求体。
type UpsertConfigFileReq struct {
RelPath string `json:"rel_path"`
Content string `json:"content"`
}
func configFileToDTO(f *ConfigFileContent) ConfigFileDTO {
return ConfigFileDTO{
RelPath: f.RelPath,
Content: f.Content,
UpdatedAt: f.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
}
// PermissionRequestDTO 是权限请求的对外视图。
+86
View File
@@ -68,6 +68,11 @@ func (h *Handler) Mount(r chi.Router) {
r.Get("/{id}", h.getAgentKind)
r.Patch("/{id}", h.updateAgentKind)
r.Delete("/{id}", h.deleteAgentKind)
// 配置文件管理(admin only,service 层鉴权)。rel_path 含斜杠,
// 不放 URL path,统一走 body / query。
r.Get("/{id}/config-files", h.listConfigFiles)
r.Put("/{id}/config-files", h.upsertConfigFile)
r.Delete("/{id}/config-files", h.deleteConfigFile)
})
r.Route("/api/v1/acp/sessions", func(r chi.Router) {
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
@@ -209,6 +214,8 @@ func (h *Handler) createAgentKind(w http.ResponseWriter, r *http.Request) {
Env: req.Env,
Enabled: enabled,
ToolAllowlist: req.ToolAllowlist,
ClientType: ClientType(req.ClientType),
MCPServers: req.MCPServers,
}
out, err := h.akSvc.Create(r.Context(), c, in)
if err != nil {
@@ -265,6 +272,11 @@ func (h *Handler) updateAgentKind(w http.ResponseWriter, r *http.Request) {
Env: req.Env,
Enabled: req.Enabled,
ToolAllowlist: req.ToolAllowlist,
MCPServers: req.MCPServers,
}
if req.ClientType != nil {
ct := ClientType(*req.ClientType)
in.ClientType = &ct
}
out, err := h.akSvc.Update(r.Context(), c, id, in)
if err != nil {
@@ -292,6 +304,74 @@ func (h *Handler) deleteAgentKind(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// ===== AgentKind 配置文件 endpoints =====
func (h *Handler) listConfigFiles(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, perr := uuid.Parse(chi.URLParam(r, "id"))
if perr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
return
}
files, err := h.akSvc.ListConfigFiles(r.Context(), c, id)
if err != nil {
writeErr(w, r, err)
return
}
out := make([]ConfigFileDTO, 0, len(files))
for _, f := range files {
out = append(out, configFileToDTO(f))
}
httpx.WriteJSON(w, http.StatusOK, out)
}
func (h *Handler) upsertConfigFile(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, perr := uuid.Parse(chi.URLParam(r, "id"))
if perr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
return
}
var req UpsertConfigFileReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json"))
return
}
out, err := h.akSvc.UpsertConfigFile(r.Context(), c, id, req.RelPath, req.Content)
if err != nil {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusOK, configFileToDTO(out))
}
func (h *Handler) deleteConfigFile(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, perr := uuid.Parse(chi.URLParam(r, "id"))
if perr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
return
}
relPath := r.URL.Query().Get("rel_path")
if err := h.akSvc.DeleteConfigFile(r.Context(), c, id, relPath); err != nil {
writeErr(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ===== Session endpoints =====
func (h *Handler) listSessions(w http.ResponseWriter, r *http.Request) {
@@ -617,6 +697,10 @@ func (h *Handler) agentKindToAdminDTO(k *AgentKind) AgentKindAdminDTO {
sort.Strings(envKeys)
}
}
mcpServers := []McpServerSpec{}
if servers, err := decryptMCPServers(h.enc, k.EncryptedMCPServers); err == nil {
mcpServers = servers
}
return AgentKindAdminDTO{
ID: k.ID,
Name: k.Name,
@@ -627,6 +711,8 @@ func (h *Handler) agentKindToAdminDTO(k *AgentKind) AgentKindAdminDTO {
EnvKeys: envKeys,
Enabled: k.Enabled,
ToolAllowlist: append([]string(nil), k.ToolAllowlist...),
ClientType: string(k.ClientType),
MCPServers: mcpServers,
CreatedBy: k.CreatedBy,
CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
UpdatedAt: k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
@@ -31,6 +31,18 @@ type InitializeResult struct {
AuthMethods []any `json:"authMethods,omitempty"`
}
// McpCapabilities 从 agentCapabilities.mcpCapabilities 解析 http/sse 支持声明
// (ACP v1:均默认 false;stdio 是基线无需声明)。字段缺失或形状异常按 false 处理。
func (r InitializeResult) McpCapabilities() (httpOK, sseOK bool) {
caps, ok := r.AgentCapabilities["mcpCapabilities"].(map[string]any)
if !ok {
return false, false
}
httpOK, _ = caps["http"].(bool)
sseOK, _ = caps["sse"].(bool)
return httpOK, sseOK
}
// BuildInitializeParams returns standard initialize params.
func BuildInitializeParams(version string) InitializeParams {
return InitializeParams{
+45 -6
View File
@@ -1,17 +1,56 @@
package handlers
// session/new method types (spec §5.4).
// session/new method types (spec §5.4 + ACP v1 schema/v1/schema.json)。
//
// mcpServers 元素是 untagged union:
// - stdio:无 type 判别字段(v1 规定;带 type 会被部分 agent 忽略),
// name/command/args/env 全必填(空数组可)
// - http / sse:type 为 "http"/"sse" 判别符,name/url/headers 必填
//
// env / headers 均为 [{name,value}] 数组(非对象)。
type SessionNewParams struct {
Cwd string `json:"cwd"`
McpServers []string `json:"mcpServers"` // MVP: no MCP, send empty slice
Cwd string `json:"cwd"`
McpServers []any `json:"mcpServers"` // McpServerStdio | McpServerHTTP
}
type SessionNewResult struct {
SessionID string `json:"sessionId"`
}
// BuildSessionNewParams returns standard session/new params.
func BuildSessionNewParams(cwd string) SessionNewParams {
return SessionNewParams{Cwd: cwd, McpServers: []string{}}
// EnvVariable / HttpHeader 是 ACP wire 的 name-value 对。
type EnvVariable struct {
Name string `json:"name"`
Value string `json:"value"`
}
type HttpHeader struct {
Name string `json:"name"`
Value string `json:"value"`
}
// McpServerStdio 是 stdio 传输的 MCP server(基线,所有 agent 必须支持)。
type McpServerStdio struct {
Name string `json:"name"`
Command string `json:"command"`
Args []string `json:"args"`
Env []EnvVariable `json:"env"`
}
// McpServerHTTP 是 http / sse 传输的 MCP server。Type 必须为 "http" 或 "sse",
// 仅当 agent 在 initialize 中声明对应 mcpCapabilities 时才可下发。
type McpServerHTTP struct {
Type string `json:"type"`
Name string `json:"name"`
URL string `json:"url"`
Headers []HttpHeader `json:"headers"`
}
// BuildSessionNewParams returns standard session/new params.
// servers 为 nil 时发送空数组(mcpServers 是协议必填字段)。
func BuildSessionNewParams(cwd string, servers []any) SessionNewParams {
if servers == nil {
servers = []any{}
}
return SessionNewParams{Cwd: cwd, McpServers: servers}
}
+96
View File
@@ -0,0 +1,96 @@
// mcp_inject.go 构造 session/new 下发的 mcpServers 列表。
//
// 组成:ACW 自家 MCP(http 类型,per-session system token 做 Bearer 认证)+
// AgentKind 上配置的第三方 MCP。能力门控(ACP v1):stdio 是基线直接下发;
// http / sse 需 agent 在 initialize 响应中声明 mcpCapabilities.http / .sse,
// 未声明的条目逐条跳过并记日志——session 照常启动,自家 MCP 仍可由管理员
// 通过配置文件 + ACW_MCP_TOKEN 环境变量引用做静态接入兜底。
package acp
import (
"log/slog"
"sort"
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
)
// buildMcpServers 按 agent 能力构造 mcpServers。acwURL / acwToken 任一为空时
// 不注入自家 MCP(如 MCP 模块未配置 public_url)。
func buildMcpServers(specs []McpServerSpec, acwURL, acwToken string, httpOK, sseOK bool,
log *slog.Logger, sessionID uuid.UUID) []any {
out := []any{}
if acwURL != "" && acwToken != "" {
if httpOK {
out = append(out, handlers.McpServerHTTP{
Type: "http", Name: AcwMcpServerName, URL: acwURL,
Headers: []handlers.HttpHeader{{Name: "Authorization", Value: "Bearer " + acwToken}},
})
} else {
log.Warn("acp.mcp_inject.acw_skipped_no_http_capability",
"session_id", sessionID,
"hint", "agent 未声明 mcpCapabilities.http;可在 AgentKind 配置文件中静态引用 ACW_MCP_TOKEN 接入")
}
}
for _, sp := range specs {
switch sp.Type {
case McpStdio:
args := sp.Args
if args == nil {
args = []string{}
}
out = append(out, handlers.McpServerStdio{
Name: sp.Name, Command: sp.Command, Args: args, Env: toEnvVariables(sp.Env),
})
case McpHTTP:
if !httpOK {
log.Warn("acp.mcp_inject.server_skipped_no_capability",
"session_id", sessionID, "name", sp.Name, "type", "http")
continue
}
out = append(out, handlers.McpServerHTTP{
Type: "http", Name: sp.Name, URL: sp.URL, Headers: toHTTPHeaders(sp.Headers),
})
case McpSSE:
if !sseOK {
log.Warn("acp.mcp_inject.server_skipped_no_capability",
"session_id", sessionID, "name", sp.Name, "type", "sse")
continue
}
out = append(out, handlers.McpServerHTTP{
Type: "sse", Name: sp.Name, URL: sp.URL, Headers: toHTTPHeaders(sp.Headers),
})
}
}
return out
}
// toEnvVariables / toHTTPHeaders 把 map 转为 ACP wire 的 name-value 数组,
// 按 key 排序保证序列化确定性(map 迭代随机)。
func toEnvVariables(m map[string]string) []handlers.EnvVariable {
out := make([]handlers.EnvVariable, 0, len(m))
for _, k := range sortedKeys(m) {
out = append(out, handlers.EnvVariable{Name: k, Value: m[k]})
}
return out
}
func toHTTPHeaders(m map[string]string) []handlers.HttpHeader {
out := make([]handlers.HttpHeader, 0, len(m))
for _, k := range sortedKeys(m) {
out = append(out, handlers.HttpHeader{Name: k, Value: m[k]})
}
return out
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+82
View File
@@ -0,0 +1,82 @@
package acp
import (
"encoding/json"
"log/slog"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func marshalServers(t *testing.T, servers []any) string {
t.Helper()
b, err := json.Marshal(servers)
require.NoError(t, err)
return string(b)
}
func TestBuildMcpServers_AcwInjectedWithHTTPCapability(t *testing.T) {
t.Parallel()
out := buildMcpServers(nil, "http://x/mcp", "tok123", true, false, slog.Default(), uuid.New())
require.Len(t, out, 1)
j := marshalServers(t, out)
assert.Contains(t, j, `"type":"http"`)
assert.Contains(t, j, `"name":"acw"`)
assert.Contains(t, j, `"url":"http://x/mcp"`)
assert.Contains(t, j, `{"name":"Authorization","value":"Bearer tok123"}`)
}
func TestBuildMcpServers_AcwSkippedWithoutCapabilityOrConfig(t *testing.T) {
t.Parallel()
// 无 http 能力 → 跳过自家 MCP
out := buildMcpServers(nil, "http://x/mcp", "tok", false, false, slog.Default(), uuid.New())
assert.Empty(t, out)
// 未配置 public_url / token → 不注入
out = buildMcpServers(nil, "", "tok", true, true, slog.Default(), uuid.New())
assert.Empty(t, out)
out = buildMcpServers(nil, "http://x/mcp", "", true, true, slog.Default(), uuid.New())
assert.Empty(t, out)
}
func TestBuildMcpServers_StdioBaselineNoTypeField(t *testing.T) {
t.Parallel()
specs := []McpServerSpec{{
Name: "fs", Type: McpStdio, Command: "/bin/mcp-fs",
Env: map[string]string{"B": "2", "A": "1"},
}}
// stdio 不受能力门控(http/sse 均 false 仍下发)
out := buildMcpServers(specs, "", "", false, false, slog.Default(), uuid.New())
require.Len(t, out, 1)
j := marshalServers(t, out)
// ACP v1:stdio 无 type 判别字段;args/env 必填(空数组);env 按 key 排序
assert.NotContains(t, j, `"type"`)
assert.Contains(t, j, `"command":"/bin/mcp-fs"`)
assert.Contains(t, j, `"args":[]`)
assert.Contains(t, j, `"env":[{"name":"A","value":"1"},{"name":"B","value":"2"}]`)
}
func TestBuildMcpServers_CapabilityFiltering(t *testing.T) {
t.Parallel()
specs := []McpServerSpec{
{Name: "h", Type: McpHTTP, URL: "http://h"},
{Name: "s", Type: McpSSE, URL: "http://s"},
{Name: "io", Type: McpStdio, Command: "/x"},
}
// 仅 http 能力:sse 条目被跳过
out := buildMcpServers(specs, "", "", true, false, slog.Default(), uuid.New())
j := marshalServers(t, out)
require.Len(t, out, 2)
assert.Contains(t, j, `"name":"h"`)
assert.NotContains(t, j, `"name":"s"`)
assert.Contains(t, j, `"name":"io"`)
// 全能力:全部下发,sse 条目 type 正确
out = buildMcpServers(specs, "", "", true, true, slog.Default(), uuid.New())
require.Len(t, out, 3)
assert.Contains(t, marshalServers(t, out), `"type":"sse"`)
}
@@ -0,0 +1,19 @@
-- name: ListAgentKindConfigFiles :many
SELECT id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at
FROM acp_agent_kind_config_files
WHERE agent_kind_id = $1
ORDER BY rel_path ASC;
-- name: UpsertAgentKindConfigFile :one
INSERT INTO acp_agent_kind_config_files (
id, agent_kind_id, rel_path, encrypted_content, updated_by
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (agent_kind_id, rel_path) DO UPDATE
SET encrypted_content = EXCLUDED.encrypted_content,
updated_by = EXCLUDED.updated_by,
updated_at = now()
RETURNING id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at;
-- name: DeleteAgentKindConfigFile :execrows
DELETE FROM acp_agent_kind_config_files
WHERE agent_kind_id = $1 AND rel_path = $2;
+18 -16
View File
@@ -1,48 +1,50 @@
-- name: CreateAgentKind :one
INSERT INTO acp_agent_kinds (
id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist, client_type, encrypted_mcp_servers
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist;
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers;
-- name: GetAgentKindByID :one
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
FROM acp_agent_kinds
WHERE id = $1;
-- name: GetAgentKindByName :one
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
FROM acp_agent_kinds
WHERE name = $1;
-- name: ListAgentKinds :many
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
FROM acp_agent_kinds
ORDER BY created_at DESC;
-- name: ListEnabledAgentKinds :many
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
FROM acp_agent_kinds
WHERE enabled = TRUE
ORDER BY name ASC;
-- name: UpdateAgentKind :one
UPDATE acp_agent_kinds
SET display_name = $2,
description = $3,
binary_path = $4,
args = $5,
encrypted_env = $6,
enabled = $7,
tool_allowlist = $8,
updated_at = now()
SET display_name = $2,
description = $3,
binary_path = $4,
args = $5,
encrypted_env = $6,
enabled = $7,
tool_allowlist = $8,
client_type = $9,
encrypted_mcp_servers = $10,
updated_at = now()
WHERE id = $1
RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist;
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers;
-- name: DeleteAgentKind :exec
DELETE FROM acp_agent_kinds WHERE id = $1;
+9
View File
@@ -115,6 +115,15 @@ func (f *fakeEventRepo) ListEventsSince(context.Context, uuid.UUID, int64, int32
func (f *fakeEventRepo) PurgeEventsBefore(context.Context, time.Time) (int64, error) {
panic("n/a")
}
func (f *fakeEventRepo) ListConfigFiles(context.Context, uuid.UUID) ([]*acp.ConfigFile, error) {
panic("n/a")
}
func (f *fakeEventRepo) UpsertConfigFile(context.Context, *acp.ConfigFile) (*acp.ConfigFile, error) {
panic("n/a")
}
func (f *fakeEventRepo) DeleteConfigFile(context.Context, uuid.UUID, string) (bool, error) {
panic("n/a")
}
func (f *fakeEventRepo) InTx(context.Context, func(context.Context, pgx.Tx) error) error {
panic("n/a")
+92 -30
View File
@@ -28,6 +28,11 @@ type Repository interface {
DeleteAgentKind(ctx context.Context, id uuid.UUID) error
CountAgentKindUsage(ctx context.Context, id uuid.UUID) (int64, error)
// AgentKind 配置文件(内容为密文,加解密在 service / supervisor 层)
ListConfigFiles(ctx context.Context, kindID uuid.UUID) ([]*ConfigFile, error)
UpsertConfigFile(ctx context.Context, f *ConfigFile) (*ConfigFile, error)
DeleteConfigFile(ctx context.Context, kindID uuid.UUID, relPath string) (bool, error)
// Session
InsertSession(ctx context.Context, s *Session) (*Session, error)
GetSessionByID(ctx context.Context, id uuid.UUID) (*Session, error)
@@ -142,16 +147,18 @@ func pgxNoRowsToErrNotFound(err error, code errs.Code, msg string) error {
func (r *pgRepo) CreateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind, error) {
row, err := r.q.CreateAgentKind(ctx, acpsqlc.CreateAgentKindParams{
ID: toPgUUID(k.ID),
Name: k.Name,
DisplayName: k.DisplayName,
Description: k.Description,
BinaryPath: k.BinaryPath,
Args: k.Args,
EncryptedEnv: k.EncryptedEnv,
Enabled: k.Enabled,
CreatedBy: toPgUUID(k.CreatedBy),
ToolAllowlist: normalizeStrSlice(k.ToolAllowlist),
ID: toPgUUID(k.ID),
Name: k.Name,
DisplayName: k.DisplayName,
Description: k.Description,
BinaryPath: k.BinaryPath,
Args: k.Args,
EncryptedEnv: k.EncryptedEnv,
Enabled: k.Enabled,
CreatedBy: toPgUUID(k.CreatedBy),
ToolAllowlist: normalizeStrSlice(k.ToolAllowlist),
ClientType: string(k.ClientType),
EncryptedMcpServers: k.EncryptedMCPServers,
})
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "create agent kind")
@@ -201,14 +208,16 @@ func (r *pgRepo) ListEnabledAgentKinds(ctx context.Context) ([]*AgentKind, error
func (r *pgRepo) UpdateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind, error) {
row, err := r.q.UpdateAgentKind(ctx, acpsqlc.UpdateAgentKindParams{
ID: toPgUUID(k.ID),
DisplayName: k.DisplayName,
Description: k.Description,
BinaryPath: k.BinaryPath,
Args: k.Args,
EncryptedEnv: k.EncryptedEnv,
Enabled: k.Enabled,
ToolAllowlist: normalizeStrSlice(k.ToolAllowlist),
ID: toPgUUID(k.ID),
DisplayName: k.DisplayName,
Description: k.Description,
BinaryPath: k.BinaryPath,
Args: k.Args,
EncryptedEnv: k.EncryptedEnv,
Enabled: k.Enabled,
ToolAllowlist: normalizeStrSlice(k.ToolAllowlist),
ClientType: string(k.ClientType),
EncryptedMcpServers: k.EncryptedMCPServers,
})
if err != nil {
return nil, pgxNoRowsToErrNotFound(err, errs.CodeAcpAgentKindNotFound, "agent kind not found")
@@ -234,22 +243,75 @@ func (r *pgRepo) CountAgentKindUsage(ctx context.Context, id uuid.UUID) (int64,
return n, nil
}
// ===== AgentKind 配置文件 =====
func (r *pgRepo) ListConfigFiles(ctx context.Context, kindID uuid.UUID) ([]*ConfigFile, error) {
rows, err := r.q.ListAgentKindConfigFiles(ctx, toPgUUID(kindID))
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list agent kind config files")
}
out := make([]*ConfigFile, 0, len(rows))
for _, row := range rows {
out = append(out, rowToConfigFile(row))
}
return out, nil
}
func (r *pgRepo) UpsertConfigFile(ctx context.Context, f *ConfigFile) (*ConfigFile, error) {
row, err := r.q.UpsertAgentKindConfigFile(ctx, acpsqlc.UpsertAgentKindConfigFileParams{
ID: toPgUUID(f.ID),
AgentKindID: toPgUUID(f.AgentKindID),
RelPath: f.RelPath,
EncryptedContent: f.EncryptedContent,
UpdatedBy: toPgUUID(f.UpdatedBy),
})
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "upsert agent kind config file")
}
return rowToConfigFile(row), nil
}
func (r *pgRepo) DeleteConfigFile(ctx context.Context, kindID uuid.UUID, relPath string) (bool, error) {
n, err := r.q.DeleteAgentKindConfigFile(ctx, acpsqlc.DeleteAgentKindConfigFileParams{
AgentKindID: toPgUUID(kindID),
RelPath: relPath,
})
if err != nil {
return false, errs.Wrap(err, errs.CodeInternal, "delete agent kind config file")
}
return n > 0, nil
}
// ===== row → domain converters =====
func rowToAgentKind(row acpsqlc.AcpAgentKind) *AgentKind {
return &AgentKind{
ID: fromPgUUID(row.ID),
Name: row.Name,
DisplayName: row.DisplayName,
Description: row.Description,
BinaryPath: row.BinaryPath,
Args: row.Args,
EncryptedEnv: row.EncryptedEnv,
Enabled: row.Enabled,
ToolAllowlist: row.ToolAllowlist,
CreatedBy: fromPgUUID(row.CreatedBy),
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
ID: fromPgUUID(row.ID),
Name: row.Name,
DisplayName: row.DisplayName,
Description: row.Description,
BinaryPath: row.BinaryPath,
Args: row.Args,
EncryptedEnv: row.EncryptedEnv,
Enabled: row.Enabled,
ToolAllowlist: row.ToolAllowlist,
ClientType: ClientType(row.ClientType),
EncryptedMCPServers: row.EncryptedMcpServers,
CreatedBy: fromPgUUID(row.CreatedBy),
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}
}
func rowToConfigFile(row acpsqlc.AcpAgentKindConfigFile) *ConfigFile {
return &ConfigFile{
ID: fromPgUUID(row.ID),
AgentKindID: fromPgUUID(row.AgentKindID),
RelPath: row.RelPath,
EncryptedContent: row.EncryptedContent,
UpdatedBy: fromPgUUID(row.UpdatedBy),
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}
}
+2 -2
View File
@@ -261,8 +261,8 @@ func (s *sessionService) Create(ctx context.Context, c Caller, in CreateSessionI
// 9. async spawn(失败时回滚 worktree + revoke MCP token)
extraEnv := map[string]string{
"ACW_MCP_TOKEN": tokenRes.Plaintext,
"ACW_MCP_URL": s.cfg.MCPPublicURL,
EnvMCPToken: tokenRes.Plaintext,
EnvMCPURL: s.cfg.MCPPublicURL,
}
go func() {
spawnCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+10
View File
@@ -144,6 +144,16 @@ func (r *fakeAcpRepo) InTx(_ context.Context, fn func(context.Context, pgx.Tx) e
}
func (r *fakeAcpRepo) WithTx(_ pgx.Tx) acp.Repository { return r }
func (r *fakeAcpRepo) ListConfigFiles(context.Context, uuid.UUID) ([]*acp.ConfigFile, error) {
return nil, nil
}
func (r *fakeAcpRepo) UpsertConfigFile(context.Context, *acp.ConfigFile) (*acp.ConfigFile, error) {
panic("n/a")
}
func (r *fakeAcpRepo) DeleteConfigFile(context.Context, uuid.UUID, string) (bool, error) {
panic("n/a")
}
func (r *fakeAcpRepo) InsertSession(_ context.Context, s *acp.Session) (*acp.Session, error) {
r.mu.Lock()
defer r.mu.Unlock()
@@ -0,0 +1,105 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: agent_kind_config_files.sql
package acpsqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const deleteAgentKindConfigFile = `-- name: DeleteAgentKindConfigFile :execrows
DELETE FROM acp_agent_kind_config_files
WHERE agent_kind_id = $1 AND rel_path = $2
`
type DeleteAgentKindConfigFileParams struct {
AgentKindID pgtype.UUID `json:"agent_kind_id"`
RelPath string `json:"rel_path"`
}
func (q *Queries) DeleteAgentKindConfigFile(ctx context.Context, arg DeleteAgentKindConfigFileParams) (int64, error) {
result, err := q.db.Exec(ctx, deleteAgentKindConfigFile, arg.AgentKindID, arg.RelPath)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const listAgentKindConfigFiles = `-- name: ListAgentKindConfigFiles :many
SELECT id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at
FROM acp_agent_kind_config_files
WHERE agent_kind_id = $1
ORDER BY rel_path ASC
`
func (q *Queries) ListAgentKindConfigFiles(ctx context.Context, agentKindID pgtype.UUID) ([]AcpAgentKindConfigFile, error) {
rows, err := q.db.Query(ctx, listAgentKindConfigFiles, agentKindID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AcpAgentKindConfigFile
for rows.Next() {
var i AcpAgentKindConfigFile
if err := rows.Scan(
&i.ID,
&i.AgentKindID,
&i.RelPath,
&i.EncryptedContent,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertAgentKindConfigFile = `-- name: UpsertAgentKindConfigFile :one
INSERT INTO acp_agent_kind_config_files (
id, agent_kind_id, rel_path, encrypted_content, updated_by
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (agent_kind_id, rel_path) DO UPDATE
SET encrypted_content = EXCLUDED.encrypted_content,
updated_by = EXCLUDED.updated_by,
updated_at = now()
RETURNING id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at
`
type UpsertAgentKindConfigFileParams struct {
ID pgtype.UUID `json:"id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
RelPath string `json:"rel_path"`
EncryptedContent []byte `json:"encrypted_content"`
UpdatedBy pgtype.UUID `json:"updated_by"`
}
func (q *Queries) UpsertAgentKindConfigFile(ctx context.Context, arg UpsertAgentKindConfigFileParams) (AcpAgentKindConfigFile, error) {
row := q.db.QueryRow(ctx, upsertAgentKindConfigFile,
arg.ID,
arg.AgentKindID,
arg.RelPath,
arg.EncryptedContent,
arg.UpdatedBy,
)
var i AcpAgentKindConfigFile
err := row.Scan(
&i.ID,
&i.AgentKindID,
&i.RelPath,
&i.EncryptedContent,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+56 -34
View File
@@ -24,23 +24,25 @@ func (q *Queries) CountAgentKindUsage(ctx context.Context, agentKindID pgtype.UU
const createAgentKind = `-- name: CreateAgentKind :one
INSERT INTO acp_agent_kinds (
id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist, client_type, encrypted_mcp_servers
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
`
type CreateAgentKindParams struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
ToolAllowlist []string `json:"tool_allowlist"`
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
}
func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams) (AcpAgentKind, error) {
@@ -55,6 +57,8 @@ func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams
arg.Enabled,
arg.CreatedBy,
arg.ToolAllowlist,
arg.ClientType,
arg.EncryptedMcpServers,
)
var i AcpAgentKind
err := row.Scan(
@@ -70,6 +74,8 @@ func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams
&i.CreatedAt,
&i.UpdatedAt,
&i.ToolAllowlist,
&i.ClientType,
&i.EncryptedMcpServers,
)
return i, err
}
@@ -85,7 +91,7 @@ func (q *Queries) DeleteAgentKind(ctx context.Context, id pgtype.UUID) error {
const getAgentKindByID = `-- name: GetAgentKindByID :one
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
FROM acp_agent_kinds
WHERE id = $1
`
@@ -106,13 +112,15 @@ func (q *Queries) GetAgentKindByID(ctx context.Context, id pgtype.UUID) (AcpAgen
&i.CreatedAt,
&i.UpdatedAt,
&i.ToolAllowlist,
&i.ClientType,
&i.EncryptedMcpServers,
)
return i, err
}
const getAgentKindByName = `-- name: GetAgentKindByName :one
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
FROM acp_agent_kinds
WHERE name = $1
`
@@ -133,13 +141,15 @@ func (q *Queries) GetAgentKindByName(ctx context.Context, name string) (AcpAgent
&i.CreatedAt,
&i.UpdatedAt,
&i.ToolAllowlist,
&i.ClientType,
&i.EncryptedMcpServers,
)
return i, err
}
const listAgentKinds = `-- name: ListAgentKinds :many
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
FROM acp_agent_kinds
ORDER BY created_at DESC
`
@@ -166,6 +176,8 @@ func (q *Queries) ListAgentKinds(ctx context.Context) ([]AcpAgentKind, error) {
&i.CreatedAt,
&i.UpdatedAt,
&i.ToolAllowlist,
&i.ClientType,
&i.EncryptedMcpServers,
); err != nil {
return nil, err
}
@@ -179,7 +191,7 @@ func (q *Queries) ListAgentKinds(ctx context.Context) ([]AcpAgentKind, error) {
const listEnabledAgentKinds = `-- name: ListEnabledAgentKinds :many
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
FROM acp_agent_kinds
WHERE enabled = TRUE
ORDER BY name ASC
@@ -207,6 +219,8 @@ func (q *Queries) ListEnabledAgentKinds(ctx context.Context) ([]AcpAgentKind, er
&i.CreatedAt,
&i.UpdatedAt,
&i.ToolAllowlist,
&i.ClientType,
&i.EncryptedMcpServers,
); err != nil {
return nil, err
}
@@ -220,28 +234,32 @@ func (q *Queries) ListEnabledAgentKinds(ctx context.Context) ([]AcpAgentKind, er
const updateAgentKind = `-- name: UpdateAgentKind :one
UPDATE acp_agent_kinds
SET display_name = $2,
description = $3,
binary_path = $4,
args = $5,
encrypted_env = $6,
enabled = $7,
tool_allowlist = $8,
updated_at = now()
SET display_name = $2,
description = $3,
binary_path = $4,
args = $5,
encrypted_env = $6,
enabled = $7,
tool_allowlist = $8,
client_type = $9,
encrypted_mcp_servers = $10,
updated_at = now()
WHERE id = $1
RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
`
type UpdateAgentKindParams struct {
ID pgtype.UUID `json:"id"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
ToolAllowlist []string `json:"tool_allowlist"`
ID pgtype.UUID `json:"id"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
}
func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams) (AcpAgentKind, error) {
@@ -254,6 +272,8 @@ func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams
arg.EncryptedEnv,
arg.Enabled,
arg.ToolAllowlist,
arg.ClientType,
arg.EncryptedMcpServers,
)
var i AcpAgentKind
err := row.Scan(
@@ -269,6 +289,8 @@ func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams
&i.CreatedAt,
&i.UpdatedAt,
&i.ToolAllowlist,
&i.ClientType,
&i.EncryptedMcpServers,
)
return i, err
}
+24 -12
View File
@@ -11,18 +11,30 @@ import (
)
type AcpAgentKind struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
}
type AcpAgentKindConfigFile struct {
ID pgtype.UUID `json:"id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
RelPath string `json:"rel_path"`
EncryptedContent []byte `json:"encrypted_content"`
UpdatedBy pgtype.UUID `json:"updated_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type AcpEvent struct {
+40 -5
View File
@@ -41,6 +41,9 @@ type SupervisorConfig struct {
EventMaxPayload int
EventTruncateField int
ShutdownGrace time.Duration
// AgentHomesDir 是 agent CLI 受管 home 的根目录(<DataDir>/agent-homes)。
// 空串时跳过配置物化与重定向 env 注入(部分测试)。
AgentHomesDir string
}
// Supervisor 是 ACP 子进程总管。
@@ -154,10 +157,24 @@ func (s *Supervisor) GetRelay(sid uuid.UUID) *Relay {
// handshake 失败时调用 Kill 回滚并返回错误。调用方(SessionService)负责前置的
// worktree 占用与 acp_sessions 行 INSERT;本方法仅负责 OS 层资源与协议层握手。
func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind, extraEnv map[string]string) (*Process, error) {
envMap, err := decryptEnv(s.crypto, kind.EncryptedEnv)
// 受管 home:物化 DB 中的配置文件并注入重定向 env(优先级最低,可被
// AgentKind env / extraEnv 覆盖)。AgentHomesDir 未配置时跳过(部分测试)。
envMap := map[string]string{}
if s.cfg.AgentHomesDir != "" {
home, err := s.materializeAgentHome(ctx, kind)
if err != nil {
return nil, fmt.Errorf("materialize agent home: %w", err)
}
envMap = agentHomeEnv(home, kind.ClientType)
}
kindEnv, err := decryptEnv(s.crypto, kind.EncryptedEnv)
if err != nil {
return nil, err
}
for k, v := range kindEnv {
envMap[k] = v
}
// System override: extraEnv overrides agent_kinds same-name keys (spec §6.1 #11)
for k, v := range extraEnv {
@@ -251,7 +268,7 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
ToolAllowlist: kind.ToolAllowlist,
})
if err := s.handshake(ctx, sess, relay, proc); err != nil {
if err := s.handshake(ctx, sess, kind, relay, proc, extraEnv); err != nil {
s.log.Error("acp.handshake_failed", "session_id", sess.ID, "err", err.Error())
s.Kill(ctx, sess.ID, s.cfg.KillGrace)
return nil, err
@@ -261,14 +278,32 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
// handshake 发送 initialize → session/new,并把返回的 agent session id + pid
// 持久化到 acp_sessions 行(status → running)。SpawnTimeout 是整个握手的硬上限。
func (s *Supervisor) handshake(ctx context.Context, sess *Session, relay *Relay, proc *Process) error {
// session/new 同时下发 mcpServers:ACW 自家 MCP(extraEnv 中的 per-session
// token)+ AgentKind 第三方 MCP,按 initialize 声明的 mcpCapabilities 过滤。
func (s *Supervisor) handshake(ctx context.Context, sess *Session, kind *AgentKind,
relay *Relay, proc *Process, extraEnv map[string]string) error {
hsCtx, cancel := context.WithTimeout(ctx, s.cfg.SpawnTimeout)
defer cancel()
if _, err := relay.Call(hsCtx, "initialize", handlers.BuildInitializeParams("dev")); err != nil {
initResp, err := relay.Call(hsCtx, "initialize", handlers.BuildInitializeParams("dev"))
if err != nil {
return fmt.Errorf("initialize: %w", err)
}
resp, err := relay.Call(hsCtx, "session/new", handlers.BuildSessionNewParams(sess.CwdPath))
var ir handlers.InitializeResult
if err := json.Unmarshal(initResp.Result, &ir); err != nil {
// 能力解析失败不阻断握手:按无 http/sse 能力处理(仅基线 stdio 可下发)。
s.log.Warn("acp.handshake.parse_initialize_result", "session_id", sess.ID, "err", err.Error())
}
httpOK, sseOK := ir.McpCapabilities()
specs, err := decryptMCPServers(s.crypto, kind.EncryptedMCPServers)
if err != nil {
return err
}
mcpServers := buildMcpServers(specs, extraEnv[EnvMCPURL], extraEnv[EnvMCPToken],
httpOK, sseOK, s.log, sess.ID)
resp, err := relay.Call(hsCtx, "session/new", handlers.BuildSessionNewParams(sess.CwdPath, mcpServers))
if err != nil {
return fmt.Errorf("session/new: %w", err)
}
+1
View File
@@ -355,6 +355,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
EventMaxPayload: cfg.Acp.EventMaxPayload,
EventTruncateField: cfg.Acp.EventTruncateField,
ShutdownGrace: cfg.Acp.ShutdownGrace,
AgentHomesDir: filepath.Join(cfg.DataDir, "agent-homes"),
},
log,
)
+24 -12
View File
@@ -11,18 +11,30 @@ import (
)
type AcpAgentKind struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
}
type AcpAgentKindConfigFile struct {
ID pgtype.UUID `json:"id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
RelPath string `json:"rel_path"`
EncryptedContent []byte `json:"encrypted_content"`
UpdatedBy pgtype.UUID `json:"updated_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type AcpEvent struct {
+24 -12
View File
@@ -11,18 +11,30 @@ import (
)
type AcpAgentKind struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
}
type AcpAgentKindConfigFile struct {
ID pgtype.UUID `json:"id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
RelPath string `json:"rel_path"`
EncryptedContent []byte `json:"encrypted_content"`
UpdatedBy pgtype.UUID `json:"updated_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type AcpEvent struct {
+24 -12
View File
@@ -11,18 +11,30 @@ import (
)
type AcpAgentKind struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
}
type AcpAgentKindConfigFile struct {
ID pgtype.UUID `json:"id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
RelPath string `json:"rel_path"`
EncryptedContent []byte `json:"encrypted_content"`
UpdatedBy pgtype.UUID `json:"updated_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type AcpEvent struct {
+24 -12
View File
@@ -11,18 +11,30 @@ import (
)
type AcpAgentKind struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
}
type AcpAgentKindConfigFile struct {
ID pgtype.UUID `json:"id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
RelPath string `json:"rel_path"`
EncryptedContent []byte `json:"encrypted_content"`
UpdatedBy pgtype.UUID `json:"updated_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type AcpEvent struct {
+24 -12
View File
@@ -11,18 +11,30 @@ import (
)
type AcpAgentKind struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
}
type AcpAgentKindConfigFile struct {
ID pgtype.UUID `json:"id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
RelPath string `json:"rel_path"`
EncryptedContent []byte `json:"encrypted_content"`
UpdatedBy pgtype.UUID `json:"updated_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type AcpEvent struct {
+24 -12
View File
@@ -11,18 +11,30 @@ import (
)
type AcpAgentKind struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
}
type AcpAgentKindConfigFile struct {
ID pgtype.UUID `json:"id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
RelPath string `json:"rel_path"`
EncryptedContent []byte `json:"encrypted_content"`
UpdatedBy pgtype.UUID `json:"updated_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type AcpEvent struct {
+24 -12
View File
@@ -11,18 +11,30 @@ import (
)
type AcpAgentKind struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
}
type AcpAgentKindConfigFile struct {
ID pgtype.UUID `json:"id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
RelPath string `json:"rel_path"`
EncryptedContent []byte `json:"encrypted_content"`
UpdatedBy pgtype.UUID `json:"updated_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type AcpEvent struct {
+24 -12
View File
@@ -11,18 +11,30 @@ import (
)
type AcpAgentKind struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
}
type AcpAgentKindConfigFile struct {
ID pgtype.UUID `json:"id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
RelPath string `json:"rel_path"`
EncryptedContent []byte `json:"encrypted_content"`
UpdatedBy pgtype.UUID `json:"updated_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type AcpEvent struct {
+24 -12
View File
@@ -11,18 +11,30 @@ import (
)
type AcpAgentKind struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
}
type AcpAgentKindConfigFile struct {
ID pgtype.UUID `json:"id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
RelPath string `json:"rel_path"`
EncryptedContent []byte `json:"encrypted_content"`
UpdatedBy pgtype.UUID `json:"updated_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type AcpEvent struct {