diff --git a/Dockerfile b/Dockerfile index ea8682b..f82c499 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,16 +23,23 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/server # ─── Stage 3: runtime ─── FROM registry.jerryyan.top/library/alpine:3.20 +# nodejs/npm 供安装 ACP agent CLI(claude-code-acp / codex / gemini-cli 等, +# 均为 npm 包)。npm 全局 prefix 指到 /data/npm-global(持久卷),容器内 +# `npm i -g xxx` 一次安装,重建镜像后无需重装;binary_path 填 +# /data/npm-global/bin/ 即可。 RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ - && apk add --no-cache ca-certificates tzdata git openssh-client && \ + && apk add --no-cache ca-certificates tzdata git openssh-client nodejs npm && \ addgroup -S app && adduser -S app -G app WORKDIR /app COPY --from=go /out/server /app/server COPY migrations /app/migrations -RUN mkdir -p /data/uploads && chown -R app:app /data +RUN mkdir -p /data/uploads /data/npm-global && chown -R app:app /data ENV APP_DATA_DIR=/data \ APP_WORKSPACE_DATA_DIR=/data \ - APP_STORAGE_LOCALFS_BASE_PATH=/data/uploads + APP_STORAGE_LOCALFS_BASE_PATH=/data/uploads \ + NPM_CONFIG_PREFIX=/data/npm-global \ + NPM_CONFIG_REGISTRY=https://registry.npmmirror.com \ + PATH=/data/npm-global/bin:$PATH USER app EXPOSE 8080 ENTRYPOINT ["/app/server"] diff --git a/go.mod b/go.mod index 5e397d1..e87bd9c 100644 --- a/go.mod +++ b/go.mod @@ -15,10 +15,12 @@ require ( github.com/modelcontextprotocol/go-sdk v1.6.0 github.com/oklog/ulid/v2 v2.1.1 github.com/openai/openai-go v1.12.0 + github.com/pelletier/go-toml/v2 v2.2.4 github.com/pkoukk/tiktoken-go v0.1.8 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 + golang.org/x/sys v0.42.0 google.golang.org/genai v1.55.0 ) @@ -75,7 +77,6 @@ require ( github.com/moby/term v0.5.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect @@ -108,7 +109,6 @@ require ( golang.org/x/net v0.49.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect google.golang.org/grpc v1.74.2 // indirect diff --git a/internal/acp/agent_home.go b/internal/acp/agent_home.go new file mode 100644 index 0000000..2b3f752 --- /dev/null +++ b/internal/acp/agent_home.go @@ -0,0 +1,92 @@ +// agent_home.go 实现 agent CLI 受管 home 目录的物化与重定向 env 注入。 +// +// 每个 AgentKind 一个受管 home://。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 "" +} diff --git a/internal/acp/agent_home_internal_test.go b/internal/acp/agent_home_internal_test.go new file mode 100644 index 0000000..3a0bd0c --- /dev/null +++ b/internal/acp/agent_home_internal_test.go @@ -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 +} diff --git a/internal/acp/agentkind_configfiles.go b/internal/acp/agentkind_configfiles.go new file mode 100644 index 0000000..45145c6 --- /dev/null +++ b/internal/acp/agentkind_configfiles.go @@ -0,0 +1,133 @@ +// agentkind_configfiles.go 实现 AgentKind 配置文件管理(admin only)。 +// +// 配置文件是 agent CLI 自身的配置(如 .claude/settings.json、.codex/config.toml), +// rel_path 相对受管 home 目录,spawn 前由 supervisor 物化到 +// // 并通过 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 +} diff --git a/internal/acp/agentkind_configfiles_test.go b/internal/acp/agentkind_configfiles_test.go new file mode 100644 index 0000000..02b6795 --- /dev/null +++ b/internal/acp/agentkind_configfiles_test.go @@ -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) +} diff --git a/internal/acp/agentkind_mcpservers_test.go b/internal/acp/agentkind_mcpservers_test.go new file mode 100644 index 0000000..c20def1 --- /dev/null +++ b/internal/acp/agentkind_mcpservers_test.go @@ -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) +} diff --git a/internal/acp/agentkind_service.go b/internal/acp/agentkind_service.go index eb3a248..ffdda6b 100644 --- a/internal/acp/agentkind_service.go +++ b/internal/acp/agentkind_service.go @@ -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"] = "" } + 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"] = "" + } 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 { diff --git a/internal/acp/agentkind_service_test.go b/internal/acp/agentkind_service_test.go index 34cbbe6..4c1a5fa 100644 --- a/internal/acp/agentkind_service_test.go +++ b/internal/acp/agentkind_service_test.go @@ -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) { diff --git a/internal/acp/domain.go b/internal/acp/domain.go index 3cd5f4b..cb3a197 100644 --- a/internal/acp/domain.go +++ b/internal/acp/domain.go @@ -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 枚举。 diff --git a/internal/acp/dto.go b/internal/acp/dto.go index 3410afa..005473d 100644 --- a/internal/acp/dto.go +++ b/internal/acp/dto.go @@ -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 是权限请求的对外视图。 diff --git a/internal/acp/handler.go b/internal/acp/handler.go index 8e564f7..dd28c15 100644 --- a/internal/acp/handler.go +++ b/internal/acp/handler.go @@ -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"), diff --git a/internal/acp/handlers/client_initialize.go b/internal/acp/handlers/client_initialize.go index c02b542..0da36fb 100644 --- a/internal/acp/handlers/client_initialize.go +++ b/internal/acp/handlers/client_initialize.go @@ -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{ diff --git a/internal/acp/handlers/client_session_new.go b/internal/acp/handlers/client_session_new.go index 99f00dc..8ea58e0 100644 --- a/internal/acp/handlers/client_session_new.go +++ b/internal/acp/handlers/client_session_new.go @@ -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} } diff --git a/internal/acp/mcp_inject.go b/internal/acp/mcp_inject.go new file mode 100644 index 0000000..91c1f0f --- /dev/null +++ b/internal/acp/mcp_inject.go @@ -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 +} diff --git a/internal/acp/mcp_inject_internal_test.go b/internal/acp/mcp_inject_internal_test.go new file mode 100644 index 0000000..dd90e06 --- /dev/null +++ b/internal/acp/mcp_inject_internal_test.go @@ -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"`) +} diff --git a/internal/acp/queries/agent_kind_config_files.sql b/internal/acp/queries/agent_kind_config_files.sql new file mode 100644 index 0000000..a1096c0 --- /dev/null +++ b/internal/acp/queries/agent_kind_config_files.sql @@ -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; diff --git a/internal/acp/queries/agent_kinds.sql b/internal/acp/queries/agent_kinds.sql index f1747d1..fb183ce 100644 --- a/internal/acp/queries/agent_kinds.sql +++ b/internal/acp/queries/agent_kinds.sql @@ -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; diff --git a/internal/acp/relay_test.go b/internal/acp/relay_test.go index c00b67b..5147f93 100644 --- a/internal/acp/relay_test.go +++ b/internal/acp/relay_test.go @@ -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") diff --git a/internal/acp/repository.go b/internal/acp/repository.go index 462696e..a1992df 100644 --- a/internal/acp/repository.go +++ b/internal/acp/repository.go @@ -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, } } diff --git a/internal/acp/session_service.go b/internal/acp/session_service.go index 50f4bbd..600a410 100644 --- a/internal/acp/session_service.go +++ b/internal/acp/session_service.go @@ -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) diff --git a/internal/acp/session_service_test.go b/internal/acp/session_service_test.go index e27372e..4f505d9 100644 --- a/internal/acp/session_service_test.go +++ b/internal/acp/session_service_test.go @@ -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() diff --git a/internal/acp/sqlc/agent_kind_config_files.sql.go b/internal/acp/sqlc/agent_kind_config_files.sql.go new file mode 100644 index 0000000..5ef777c --- /dev/null +++ b/internal/acp/sqlc/agent_kind_config_files.sql.go @@ -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 +} diff --git a/internal/acp/sqlc/agent_kinds.sql.go b/internal/acp/sqlc/agent_kinds.sql.go index 91d7891..16af105 100644 --- a/internal/acp/sqlc/agent_kinds.sql.go +++ b/internal/acp/sqlc/agent_kinds.sql.go @@ -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 } diff --git a/internal/acp/sqlc/models.go b/internal/acp/sqlc/models.go index 1c7c7e7..66e4d1f 100644 --- a/internal/acp/sqlc/models.go +++ b/internal/acp/sqlc/models.go @@ -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 { diff --git a/internal/acp/supervisor.go b/internal/acp/supervisor.go index b176125..6c864ee 100644 --- a/internal/acp/supervisor.go +++ b/internal/acp/supervisor.go @@ -41,6 +41,9 @@ type SupervisorConfig struct { EventMaxPayload int EventTruncateField int ShutdownGrace time.Duration + // AgentHomesDir 是 agent CLI 受管 home 的根目录(/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) } diff --git a/internal/app/app.go b/internal/app/app.go index 07fe761..7cf240a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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, ) diff --git a/internal/audit/sqlc/models.go b/internal/audit/sqlc/models.go index ecc8a25..ec31e15 100644 --- a/internal/audit/sqlc/models.go +++ b/internal/audit/sqlc/models.go @@ -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 { diff --git a/internal/chat/sqlc/models.go b/internal/chat/sqlc/models.go index 34557db..d4ee0e3 100644 --- a/internal/chat/sqlc/models.go +++ b/internal/chat/sqlc/models.go @@ -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 { diff --git a/internal/infra/notify/sqlc/models.go b/internal/infra/notify/sqlc/models.go index 8dab7b4..3b19478 100644 --- a/internal/infra/notify/sqlc/models.go +++ b/internal/infra/notify/sqlc/models.go @@ -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 { diff --git a/internal/jobs/sqlc/models.go b/internal/jobs/sqlc/models.go index c609782..f521470 100644 --- a/internal/jobs/sqlc/models.go +++ b/internal/jobs/sqlc/models.go @@ -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 { diff --git a/internal/mcp/sqlc/models.go b/internal/mcp/sqlc/models.go index a329b20..8e168f0 100644 --- a/internal/mcp/sqlc/models.go +++ b/internal/mcp/sqlc/models.go @@ -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 { diff --git a/internal/project/sqlc/models.go b/internal/project/sqlc/models.go index 91a19ce..373418f 100644 --- a/internal/project/sqlc/models.go +++ b/internal/project/sqlc/models.go @@ -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 { diff --git a/internal/run/sqlc/models.go b/internal/run/sqlc/models.go index e860ba5..4f61c55 100644 --- a/internal/run/sqlc/models.go +++ b/internal/run/sqlc/models.go @@ -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 { diff --git a/internal/user/sqlc/models.go b/internal/user/sqlc/models.go index cbdf0d1..ea1c55a 100644 --- a/internal/user/sqlc/models.go +++ b/internal/user/sqlc/models.go @@ -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 { diff --git a/internal/workspace/sqlc/models.go b/internal/workspace/sqlc/models.go index 4871025..0b39e58 100644 --- a/internal/workspace/sqlc/models.go +++ b/internal/workspace/sqlc/models.go @@ -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 { diff --git a/migrations/0013_acp_agent_configs.down.sql b/migrations/0013_acp_agent_configs.down.sql new file mode 100644 index 0000000..9da270f --- /dev/null +++ b/migrations/0013_acp_agent_configs.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS acp_agent_kind_config_files; +ALTER TABLE acp_agent_kinds DROP CONSTRAINT IF EXISTS acp_agent_kinds_client_type_check; +ALTER TABLE acp_agent_kinds DROP COLUMN IF EXISTS client_type; diff --git a/migrations/0013_acp_agent_configs.up.sql b/migrations/0013_acp_agent_configs.up.sql new file mode 100644 index 0000000..1d153e6 --- /dev/null +++ b/migrations/0013_acp_agent_configs.up.sql @@ -0,0 +1,23 @@ +-- ============ AgentKind client_type ============ +-- client_type 决定 spawn 时注入哪个配置目录重定向变量(CLAUDE_CONFIG_DIR / +-- CODEX_HOME / GEMINI_CLI_HOME)以及前端渲染哪套配置表单。generic 仅注入 HOME。 +ALTER TABLE acp_agent_kinds ADD COLUMN client_type TEXT NOT NULL DEFAULT 'generic'; +ALTER TABLE acp_agent_kinds + ADD CONSTRAINT acp_agent_kinds_client_type_check + CHECK (client_type IN ('claude_code', 'codex', 'gemini', 'generic')); + +-- ============ AgentKind 配置文件 ============ +-- rel_path 相对受管 home 目录(如 .claude/settings.json、.codex/config.toml)。 +-- encrypted_content 是 AES-GCM 密文(内容可能含 auth token / API key)。 +-- spawn 前全量物化到 /agent-homes//。 +CREATE TABLE acp_agent_kind_config_files ( + id UUID PRIMARY KEY, + agent_kind_id UUID NOT NULL REFERENCES acp_agent_kinds(id) ON DELETE CASCADE, + rel_path TEXT NOT NULL, + encrypted_content BYTEA NOT NULL, + updated_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT acp_agent_kind_config_files_unique UNIQUE (agent_kind_id, rel_path) +); +CREATE INDEX idx_acp_agent_kind_config_files_kind ON acp_agent_kind_config_files(agent_kind_id); diff --git a/migrations/0014_acp_agent_mcp_servers.down.sql b/migrations/0014_acp_agent_mcp_servers.down.sql new file mode 100644 index 0000000..a8a8d77 --- /dev/null +++ b/migrations/0014_acp_agent_mcp_servers.down.sql @@ -0,0 +1 @@ +ALTER TABLE acp_agent_kinds DROP COLUMN IF EXISTS encrypted_mcp_servers; diff --git a/migrations/0014_acp_agent_mcp_servers.up.sql b/migrations/0014_acp_agent_mcp_servers.up.sql new file mode 100644 index 0000000..0b63c94 --- /dev/null +++ b/migrations/0014_acp_agent_mcp_servers.up.sql @@ -0,0 +1,6 @@ +-- ============ AgentKind 第三方 MCP servers ============ +-- encrypted_mcp_servers 是 AES-GCM 密文,明文为 McpServerSpec JSON 数组 +-- (条目可能含 auth header / stdio env 密钥)。session/new 握手时与 ACW 自家 +-- MCP(per-session token)一并下发给 agent;http/sse 条目按 agent 能力过滤。 +-- 与 encrypted_env 的差异:内容明文返回给 admin 供编辑(同配置文件先例)。 +ALTER TABLE acp_agent_kinds ADD COLUMN encrypted_mcp_servers BYTEA; diff --git a/web/package.json b/web/package.json index e4bf936..e4a1b43 100644 --- a/web/package.json +++ b/web/package.json @@ -23,6 +23,7 @@ "markdown-it": "^14.1.1", "naive-ui": "^2.44.1", "pinia": "^3.0.4", + "smol-toml": "^1.6.1", "vue": "^3.5.33", "vue-router": "^5.0.6" }, diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 1b946b5..fc83163 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: pinia: specifier: ^3.0.4 version: 3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)) + smol-toml: + specifier: ^1.6.1 + version: 1.6.1 vue: specifier: ^3.5.33 version: 3.5.33(typescript@6.0.3) @@ -1364,6 +1367,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2905,6 +2912,8 @@ snapshots: signal-exit@4.1.0: {} + smol-toml@1.6.1: {} + source-map-js@1.2.1: {} speakingurl@14.0.1: {} diff --git a/web/src/api/acp.ts b/web/src/api/acp.ts index 999eb8f..3fc90a4 100644 --- a/web/src/api/acp.ts +++ b/web/src/api/acp.ts @@ -1,5 +1,23 @@ import { request } from './client' +// AgentClientType mirrors backend acp.ClientType: decides which config-dir +// redirect env var is injected at spawn and which config form is rendered. +export type AgentClientType = 'claude_code' | 'codex' | 'gemini' | 'generic' + +// McpServerSpec mirrors backend acp.McpServerSpec. Delivered to the agent via +// ACP session/new alongside the built-in ACW MCP server (per-session token). +// stdio is the ACP baseline; http/sse entries are skipped for agents that do +// not declare the matching mcpCapabilities. Name "acw" is reserved. +export interface McpServerSpec { + name: string + type: 'stdio' | 'http' | 'sse' + command?: string + args?: string[] + env?: Record + url?: string + headers?: Record +} + // Admin DTO from `internal/acp/dto.go: AgentKindAdminDTO`. export interface AgentKindAdmin { id: string @@ -11,6 +29,8 @@ export interface AgentKindAdmin { env_keys: string[] enabled: boolean tool_allowlist: string[] + client_type: AgentClientType + mcp_servers: McpServerSpec[] created_by: string created_at: string updated_at: string @@ -38,6 +58,8 @@ export interface CreateAgentKindReq { env?: Record enabled?: boolean tool_allowlist?: string[] + client_type?: AgentClientType + mcp_servers?: McpServerSpec[] } export interface UpdateAgentKindReq { @@ -48,6 +70,15 @@ export interface UpdateAgentKindReq { env?: Record enabled?: boolean tool_allowlist?: string[] + client_type?: AgentClientType + mcp_servers?: McpServerSpec[] +} + +// AgentConfigFile mirrors backend ConfigFileDTO (admin only, plaintext content). +export interface AgentConfigFile { + rel_path: string + content: string + updated_at: string } // PermissionRequest mirrors backend PermissionRequestDTO. @@ -126,6 +157,21 @@ export const acpApi = { remove: (id: string) => request(`/api/v1/acp/agent-kinds/${enc(id)}`, { method: 'DELETE' }), }, + // Agent CLI 配置文件(admin only)。rel_path 含斜杠,走 body / query 而非 URL path。 + configFiles: { + list: (kindId: string) => + request(`/api/v1/acp/agent-kinds/${enc(kindId)}/config-files`), + upsert: (kindId: string, relPath: string, content: string) => + request(`/api/v1/acp/agent-kinds/${enc(kindId)}/config-files`, { + method: 'PUT', + body: { rel_path: relPath, content }, + }), + remove: (kindId: string, relPath: string) => + request( + `/api/v1/acp/agent-kinds/${enc(kindId)}/config-files?rel_path=${enc(relPath)}`, + { method: 'DELETE' }, + ), + }, sessions: { list: (opts: { all?: boolean; requirement_id?: string } = {}) => { const q = new URLSearchParams() diff --git a/web/src/components/acp/AgentConfigFilesPanel.vue b/web/src/components/acp/AgentConfigFilesPanel.vue new file mode 100644 index 0000000..a1e2aa9 --- /dev/null +++ b/web/src/components/acp/AgentConfigFilesPanel.vue @@ -0,0 +1,509 @@ + + + diff --git a/web/src/components/acp/AgentKindForm.vue b/web/src/components/acp/AgentKindForm.vue index ba0795a..80e64ca 100644 --- a/web/src/components/acp/AgentKindForm.vue +++ b/web/src/components/acp/AgentKindForm.vue @@ -1,6 +1,7 @@ diff --git a/web/src/views/workspaces/components/CommitHistoryTab.vue b/web/src/views/workspaces/components/CommitHistoryTab.vue index ebcbefe..8709bf9 100644 --- a/web/src/views/workspaces/components/CommitHistoryTab.vue +++ b/web/src/views/workspaces/components/CommitHistoryTab.vue @@ -112,7 +112,7 @@ async function refresh() { } else { commits.value = await workspacesApi.logOnWorktree(target.value) } - } catch (e: unknown) { + } catch { commits.value = [] } finally { loading.value = false