ACP / MCP

This commit is contained in:
2026-06-12 11:44:19 +08:00
parent 2a9661a6ef
commit 4ea4adfd8e
47 changed files with 2558 additions and 240 deletions
+10 -3
View File
@@ -23,16 +23,23 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/server
# ─── Stage 3: runtime ─── # ─── Stage 3: runtime ───
FROM registry.jerryyan.top/library/alpine:3.20 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/<cli> 即可。
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ 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 addgroup -S app && adduser -S app -G app
WORKDIR /app WORKDIR /app
COPY --from=go /out/server /app/server COPY --from=go /out/server /app/server
COPY migrations /app/migrations 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 \ ENV APP_DATA_DIR=/data \
APP_WORKSPACE_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 USER app
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/app/server"] ENTRYPOINT ["/app/server"]
+2 -2
View File
@@ -15,10 +15,12 @@ require (
github.com/modelcontextprotocol/go-sdk v1.6.0 github.com/modelcontextprotocol/go-sdk v1.6.0
github.com/oklog/ulid/v2 v2.1.1 github.com/oklog/ulid/v2 v2.1.1
github.com/openai/openai-go v1.12.0 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/pkoukk/tiktoken-go v0.1.8
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
golang.org/x/sys v0.42.0
google.golang.org/genai v1.55.0 google.golang.org/genai v1.55.0
) )
@@ -75,7 +77,6 @@ require (
github.com/moby/term v0.5.2 // indirect github.com/moby/term v0.5.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/sagikazarmark/locafero v0.11.0 // 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/net v0.49.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.19.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 golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
google.golang.org/grpc v1.74.2 // indirect google.golang.org/grpc v1.74.2 // indirect
+92
View File
@@ -0,0 +1,92 @@
// agent_home.go 实现 agent CLI 受管 home 目录的物化与重定向 env 注入。
//
// 每个 AgentKind 一个受管 home:<agent_homes_dir>/<agent_kind_id>/。spawn 前把
// DB 中的配置文件解密落盘(全量覆写受管文件,不清空目录 —— agent 运行期写入的
// 缓存 / 凭据得以保留),再按 client_type 注入配置目录重定向变量。受管 home 位于
// DataDir 下,容器场景随持久卷存活。
package acp
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// materializeAgentHome 创建受管 home 并物化该 AgentKind 的全部配置文件。
// 返回 home 绝对路径。配置文件为空时仍创建目录(agent 需要可写 home)。
func (s *Supervisor) materializeAgentHome(ctx context.Context, kind *AgentKind) (string, error) {
home, err := filepath.Abs(filepath.Join(s.cfg.AgentHomesDir, kind.ID.String()))
if err != nil {
return "", fmt.Errorf("resolve agent home: %w", err)
}
if err := os.MkdirAll(home, 0o700); err != nil {
return "", fmt.Errorf("create agent home: %w", err)
}
// client 专属配置目录预创建:CODEX_HOME 指向的目录必须已存在(codex 硬性要求),
// 其余预创建无害且省去 agent 首次启动的目录初始化分支。
if sub := clientConfigSubdir(kind.ClientType); sub != "" {
if err := os.MkdirAll(filepath.Join(home, sub), 0o700); err != nil {
return "", fmt.Errorf("create client config dir: %w", err)
}
}
files, err := s.repo.ListConfigFiles(ctx, kind.ID)
if err != nil {
return "", err
}
sep := string(filepath.Separator)
for _, f := range files {
plain, derr := s.crypto.Decrypt(f.EncryptedContent)
if derr != nil {
return "", fmt.Errorf("decrypt config file %s: %w", f.RelPath, derr)
}
dst := filepath.Clean(filepath.Join(home, filepath.FromSlash(f.RelPath)))
// rel_path 写入时已校验;此处兜底防 DB 被直接篡改后逃逸受管目录。
if !strings.HasPrefix(dst, home+sep) {
return "", fmt.Errorf("config file %s escapes agent home", f.RelPath)
}
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
return "", fmt.Errorf("create config file dir %s: %w", f.RelPath, err)
}
if err := os.WriteFile(dst, plain, 0o600); err != nil {
return "", fmt.Errorf("write config file %s: %w", f.RelPath, err)
}
}
return home, nil
}
// agentHomeEnv 返回受管 home 对应的重定向 env 默认值。优先级最低:AgentKind env
// 与系统 extraEnv 可覆盖同名 key。
func agentHomeEnv(home string, ct ClientType) map[string]string {
env := map[string]string{"HOME": home}
if runtime.GOOS == "windows" {
// Windows 上 Node/Rust 解析 home 走 USERPROFILE(开发环境)。
env["USERPROFILE"] = home
}
switch ct {
case ClientClaudeCode:
env["CLAUDE_CONFIG_DIR"] = filepath.Join(home, ".claude")
case ClientCodex:
env["CODEX_HOME"] = filepath.Join(home, ".codex")
case ClientGemini:
// GEMINI_CLI_HOME 替换的是 home 本身,.gemini 段由 CLI 拼接。
env["GEMINI_CLI_HOME"] = home
}
return env
}
// clientConfigSubdir 返回 client 专属配置子目录(相对受管 home),无则空串。
func clientConfigSubdir(ct ClientType) string {
switch ct {
case ClientClaudeCode:
return ".claude"
case ClientCodex:
return ".codex"
case ClientGemini:
return ".gemini"
}
return ""
}
+107
View File
@@ -0,0 +1,107 @@
package acp
import (
"context"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
)
// cfgRepoStub 只实现 ListConfigFiles;其余方法走嵌入的 nil 接口(调用即 panic)。
type cfgRepoStub struct {
Repository
files []*ConfigFile
}
func (s cfgRepoStub) ListConfigFiles(context.Context, uuid.UUID) ([]*ConfigFile, error) {
return s.files, nil
}
func TestMaterializeAgentHome(t *testing.T) {
t.Parallel()
enc, err := crypto.NewEncryptor([]byte("0123456789abcdef0123456789abcdef"))
require.NoError(t, err)
kind := &AgentKind{ID: uuid.New(), ClientType: ClientCodex}
content := []byte(`model = "gpt-5.5"`)
encrypted, err := enc.Encrypt(content)
require.NoError(t, err)
root := t.TempDir()
s := &Supervisor{
repo: cfgRepoStub{files: []*ConfigFile{{AgentKindID: kind.ID, RelPath: ".codex/config.toml", EncryptedContent: encrypted}}},
crypto: enc,
cfg: SupervisorConfig{AgentHomesDir: root},
}
home, err := s.materializeAgentHome(context.Background(), kind)
require.NoError(t, err)
assert.Equal(t, filepath.Join(root, kind.ID.String()), home)
got, err := os.ReadFile(filepath.Join(home, ".codex", "config.toml"))
require.NoError(t, err)
assert.Equal(t, content, got)
// 二次物化幂等(覆写)
_, err = s.materializeAgentHome(context.Background(), kind)
require.NoError(t, err)
}
func TestMaterializeAgentHome_RejectsEscape(t *testing.T) {
t.Parallel()
enc, err := crypto.NewEncryptor([]byte("0123456789abcdef0123456789abcdef"))
require.NoError(t, err)
encrypted, err := enc.Encrypt([]byte("x"))
require.NoError(t, err)
kind := &AgentKind{ID: uuid.New(), ClientType: ClientGeneric}
s := &Supervisor{
repo: cfgRepoStub{files: []*ConfigFile{{AgentKindID: kind.ID, RelPath: "../escape.txt", EncryptedContent: encrypted}}},
crypto: enc,
cfg: SupervisorConfig{AgentHomesDir: t.TempDir()},
}
_, err = s.materializeAgentHome(context.Background(), kind)
require.Error(t, err)
assert.Contains(t, err.Error(), "escapes agent home")
}
func TestAgentHomeEnv_ByClientType(t *testing.T) {
t.Parallel()
home := filepath.Join(string(filepath.Separator), "data", "agent-homes", "x")
env := agentHomeEnv(home, ClientClaudeCode)
assert.Equal(t, home, env["HOME"])
assert.Equal(t, filepath.Join(home, ".claude"), env["CLAUDE_CONFIG_DIR"])
env = agentHomeEnv(home, ClientCodex)
assert.Equal(t, filepath.Join(home, ".codex"), env["CODEX_HOME"])
env = agentHomeEnv(home, ClientGemini)
assert.Equal(t, home, env["GEMINI_CLI_HOME"])
env = agentHomeEnv(home, ClientGeneric)
assert.Equal(t, map[string]string{"HOME": home}, stripWinEnv(env))
if runtime.GOOS == "windows" {
assert.Equal(t, home, agentHomeEnv(home, ClientGeneric)["USERPROFILE"])
}
}
// stripWinEnv 去掉平台相关的 USERPROFILE,便于跨平台断言。
func stripWinEnv(env map[string]string) map[string]string {
out := map[string]string{}
for k, v := range env {
if k == "USERPROFILE" {
continue
}
out[k] = v
}
return out
}
+133
View File
@@ -0,0 +1,133 @@
// agentkind_configfiles.go 实现 AgentKind 配置文件管理(admin only)。
//
// 配置文件是 agent CLI 自身的配置(如 .claude/settings.json、.codex/config.toml),
// rel_path 相对受管 home 目录,spawn 前由 supervisor 物化到
// <agent_homes_dir>/<agent_kind_id>/ 并通过 CLAUDE_CONFIG_DIR / CODEX_HOME /
// GEMINI_CLI_HOME / HOME 指过去。
//
// 与 env 的安全模型差异(有意为之):env 加密后永不出 API;配置文件内容同样
// 加密存储(可能含 auth token),但明文返回给 admin —— 否则无法在 Web 端编辑。
package acp
import (
"context"
"encoding/json"
"path"
"strings"
"github.com/google/uuid"
toml "github.com/pelletier/go-toml/v2"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
// maxConfigFileSize 是单个配置文件明文上限(字节)。
const maxConfigFileSize = 256 << 10
func (s *agentKindService) ListConfigFiles(ctx context.Context, c Caller, kindID uuid.UUID) ([]*ConfigFileContent, error) {
if !c.IsAdmin {
return nil, errs.New(errs.CodeForbidden, "admin only")
}
if _, err := s.repo.GetAgentKindByID(ctx, kindID); err != nil {
return nil, err
}
files, err := s.repo.ListConfigFiles(ctx, kindID)
if err != nil {
return nil, err
}
out := make([]*ConfigFileContent, 0, len(files))
for _, f := range files {
plain, derr := s.enc.Decrypt(f.EncryptedContent)
if derr != nil {
return nil, errs.Wrap(derr, errs.CodeInternal, "decrypt config file")
}
out = append(out, &ConfigFileContent{RelPath: f.RelPath, Content: string(plain), UpdatedAt: f.UpdatedAt})
}
return out, nil
}
func (s *agentKindService) UpsertConfigFile(ctx context.Context, c Caller, kindID uuid.UUID, relPath, content string) (*ConfigFileContent, error) {
if !c.IsAdmin {
return nil, errs.New(errs.CodeForbidden, "admin only")
}
if _, err := s.repo.GetAgentKindByID(ctx, kindID); err != nil {
return nil, err
}
clean, err := normalizeConfigRelPath(relPath)
if err != nil {
return nil, err
}
if len(content) > maxConfigFileSize {
return nil, errs.New(errs.CodeInvalidInput, "config file too large (max 256KB)")
}
if err := validateConfigSyntax(clean, content); err != nil {
return nil, err
}
encrypted, err := s.enc.Encrypt([]byte(content))
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "encrypt config file")
}
out, err := s.repo.UpsertConfigFile(ctx, &ConfigFile{
ID: uuid.New(), AgentKindID: kindID, RelPath: clean,
EncryptedContent: encrypted, UpdatedBy: c.UserID,
})
if err != nil {
return nil, err
}
s.recordAudit(ctx, c, "acp.agent_kind.config_file.upsert", kindID.String(),
map[string]any{"rel_path": clean, "size": len(content)})
return &ConfigFileContent{RelPath: out.RelPath, Content: content, UpdatedAt: out.UpdatedAt}, nil
}
func (s *agentKindService) DeleteConfigFile(ctx context.Context, c Caller, kindID uuid.UUID, relPath string) error {
if !c.IsAdmin {
return errs.New(errs.CodeForbidden, "admin only")
}
clean, err := normalizeConfigRelPath(relPath)
if err != nil {
return err
}
found, err := s.repo.DeleteConfigFile(ctx, kindID, clean)
if err != nil {
return err
}
if !found {
return errs.New(errs.CodeNotFound, "config file not found")
}
s.recordAudit(ctx, c, "acp.agent_kind.config_file.delete", kindID.String(),
map[string]any{"rel_path": clean})
return nil
}
// normalizeConfigRelPath 归一并校验 rel_path:统一正斜杠、path.Clean 后必须仍是
// 受管目录内的相对路径(拒绝绝对路径、盘符与 ".." 逃逸)。返回归一形式作为存储键。
func normalizeConfigRelPath(p string) (string, error) {
p = strings.TrimSpace(strings.ReplaceAll(p, "\\", "/"))
if p == "" || len(p) > 512 {
return "", errs.New(errs.CodeInvalidInput, "rel_path required (max 512 chars)")
}
if strings.HasPrefix(p, "/") || strings.Contains(p, ":") {
return "", errs.New(errs.CodeInvalidInput, "rel_path must be relative")
}
clean := path.Clean(p)
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
return "", errs.New(errs.CodeInvalidInput, "rel_path escapes managed directory")
}
return clean, nil
}
// validateConfigSyntax 按扩展名做语法校验。未识别的扩展名不校验。
func validateConfigSyntax(relPath, content string) error {
switch strings.ToLower(path.Ext(relPath)) {
case ".json":
if !json.Valid([]byte(content)) {
return errs.New(errs.CodeInvalidInput, "invalid JSON content")
}
case ".toml":
var v any
if err := toml.Unmarshal([]byte(content), &v); err != nil {
return errs.New(errs.CodeInvalidInput, "invalid TOML content: "+err.Error())
}
}
return nil
}
+156
View File
@@ -0,0 +1,156 @@
package acp_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/acp"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
func newConfigFileTestSvc(t *testing.T) (acp.AgentKindService, *acp.AgentKind, acp.Caller) {
t.Helper()
repo := newFakeAgentKindRepo()
svc := acp.NewAgentKindService(repo, testEncryptor(t), &agentKindRecordingRecorder{})
admin := acp.Caller{UserID: uuid.New(), IsAdmin: true}
k, err := svc.Create(context.Background(), admin, acp.CreateAgentKindInput{
Name: "claude", DisplayName: "Claude", BinaryPath: "/x",
ClientType: acp.ClientClaudeCode, Enabled: true,
})
require.NoError(t, err)
return svc, k, admin
}
func TestConfigFiles_UpsertListDelete_Roundtrip(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc, k, admin := newConfigFileTestSvc(t)
content := `{"model":"claude-sonnet-4-6"}`
out, err := svc.UpsertConfigFile(ctx, admin, k.ID, ".claude/settings.json", content)
require.NoError(t, err)
assert.Equal(t, ".claude/settings.json", out.RelPath)
assert.Equal(t, content, out.Content)
list, err := svc.ListConfigFiles(ctx, admin, k.ID)
require.NoError(t, err)
require.Len(t, list, 1)
assert.Equal(t, content, list[0].Content) // 解密往返
require.NoError(t, svc.DeleteConfigFile(ctx, admin, k.ID, ".claude/settings.json"))
list, err = svc.ListConfigFiles(ctx, admin, k.ID)
require.NoError(t, err)
assert.Empty(t, list)
}
func TestConfigFiles_AdminOnly(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc, k, _ := newConfigFileTestSvc(t)
user := acp.Caller{UserID: uuid.New(), IsAdmin: false}
_, err := svc.ListConfigFiles(ctx, user, k.ID)
ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeForbidden, ae.Code)
_, err = svc.UpsertConfigFile(ctx, user, k.ID, "a.json", "{}")
ae, ok = errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeForbidden, ae.Code)
}
func TestConfigFiles_RelPathValidation(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc, k, admin := newConfigFileTestSvc(t)
bad := []string{
"", "/etc/passwd", "../escape.json", "a/../../b.json",
"C:/windows/x.json", "..", ".",
}
for _, p := range bad {
_, err := svc.UpsertConfigFile(ctx, admin, k.ID, p, "{}")
ae, ok := errs.As(err)
require.True(t, ok, "rel_path %q should be rejected", p)
assert.Equal(t, errs.CodeInvalidInput, ae.Code, "rel_path %q", p)
}
// 反斜杠归一为正斜杠;./ 前缀被 Clean 掉
out, err := svc.UpsertConfigFile(ctx, admin, k.ID, `.codex\config.toml`, `model = "gpt-5.5"`)
require.NoError(t, err)
assert.Equal(t, ".codex/config.toml", out.RelPath)
out, err = svc.UpsertConfigFile(ctx, admin, k.ID, "./a/b.json", "{}")
require.NoError(t, err)
assert.Equal(t, "a/b.json", out.RelPath)
}
func TestConfigFiles_SyntaxValidation(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc, k, admin := newConfigFileTestSvc(t)
_, err := svc.UpsertConfigFile(ctx, admin, k.ID, ".claude/settings.json", "{not json")
ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeInvalidInput, ae.Code)
_, err = svc.UpsertConfigFile(ctx, admin, k.ID, ".codex/config.toml", "= broken toml")
ae, ok = errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeInvalidInput, ae.Code)
// 未识别扩展名不校验
_, err = svc.UpsertConfigFile(ctx, admin, k.ID, ".claude/CLAUDE.md", "# 任意文本")
require.NoError(t, err)
}
func TestConfigFiles_DeleteNotFound(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc, k, admin := newConfigFileTestSvc(t)
err := svc.DeleteConfigFile(ctx, admin, k.ID, "missing.json")
ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeNotFound, ae.Code)
}
func TestAgentKindService_ClientType(t *testing.T) {
t.Parallel()
ctx := context.Background()
repo := newFakeAgentKindRepo()
svc := acp.NewAgentKindService(repo, testEncryptor(t), nil)
admin := acp.Caller{UserID: uuid.New(), IsAdmin: true}
// 缺省 → generic
k, err := svc.Create(ctx, admin, acp.CreateAgentKindInput{
Name: "k1", DisplayName: "K1", BinaryPath: "/x", Enabled: true,
})
require.NoError(t, err)
assert.Equal(t, acp.ClientGeneric, k.ClientType)
// 非法值拒绝
_, err = svc.Create(ctx, admin, acp.CreateAgentKindInput{
Name: "k2", DisplayName: "K2", BinaryPath: "/x", ClientType: "bogus", Enabled: true,
})
ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeInvalidInput, ae.Code)
// 更新 client_type
ct := acp.ClientCodex
updated, err := svc.Update(ctx, admin, k.ID, acp.UpdateAgentKindInput{ClientType: &ct})
require.NoError(t, err)
assert.Equal(t, acp.ClientCodex, updated.ClientType)
bad := acp.ClientType("nope")
_, err = svc.Update(ctx, admin, k.ID, acp.UpdateAgentKindInput{ClientType: &bad})
ae, ok = errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeInvalidInput, ae.Code)
}
+71
View File
@@ -0,0 +1,71 @@
package acp_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/acp"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
func TestAgentKindService_MCPServers_Validation(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc := acp.NewAgentKindService(newFakeAgentKindRepo(), testEncryptor(t), nil)
admin := acp.Caller{UserID: uuid.New(), IsAdmin: true}
base := acp.CreateAgentKindInput{Name: "k", DisplayName: "K", BinaryPath: "/x", Enabled: true}
cases := []struct {
name string
servers []acp.McpServerSpec
}{
{"missing name", []acp.McpServerSpec{{Type: acp.McpStdio, Command: "/x"}}},
{"reserved acw name", []acp.McpServerSpec{{Name: "acw", Type: acp.McpHTTP, URL: "http://x"}}},
{"duplicate name", []acp.McpServerSpec{
{Name: "d", Type: acp.McpStdio, Command: "/x"},
{Name: "d", Type: acp.McpHTTP, URL: "http://x"},
}},
{"stdio without command", []acp.McpServerSpec{{Name: "a", Type: acp.McpStdio}}},
{"http without url", []acp.McpServerSpec{{Name: "a", Type: acp.McpHTTP}}},
{"invalid type", []acp.McpServerSpec{{Name: "a", Type: "magic"}}},
}
for _, tc := range cases {
in := base
in.MCPServers = tc.servers
_, err := svc.Create(ctx, admin, in)
ae, ok := errs.As(err)
require.True(t, ok, "case %s should fail", tc.name)
assert.Equal(t, errs.CodeInvalidInput, ae.Code, "case %s", tc.name)
}
}
func TestAgentKindService_MCPServers_CreateAndUpdate(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc := acp.NewAgentKindService(newFakeAgentKindRepo(), testEncryptor(t), nil)
admin := acp.Caller{UserID: uuid.New(), IsAdmin: true}
k, err := svc.Create(ctx, admin, acp.CreateAgentKindInput{
Name: "k", DisplayName: "K", BinaryPath: "/x", Enabled: true,
MCPServers: []acp.McpServerSpec{
{Name: "ctx7", Type: acp.McpHTTP, URL: "https://mcp.ctx7.dev", Headers: map[string]string{"X-Key": "v"}},
},
})
require.NoError(t, err)
assert.NotEmpty(t, k.EncryptedMCPServers) // 已加密落库
// nil = 不改
updated, err := svc.Update(ctx, admin, k.ID, acp.UpdateAgentKindInput{})
require.NoError(t, err)
assert.Equal(t, k.EncryptedMCPServers, updated.EncryptedMCPServers)
// 空列表 = 清空
updated, err = svc.Update(ctx, admin, k.ID, acp.UpdateAgentKindInput{MCPServers: []acp.McpServerSpec{}})
require.NoError(t, err)
assert.NotEqual(t, k.EncryptedMCPServers, updated.EncryptedMCPServers)
}
+92
View File
@@ -29,10 +29,21 @@ func (s *agentKindService) Create(ctx context.Context, c Caller, in CreateAgentK
if in.Name == "" || in.DisplayName == "" || in.BinaryPath == "" { if in.Name == "" || in.DisplayName == "" || in.BinaryPath == "" {
return nil, errs.New(errs.CodeInvalidInput, "name / display_name / binary_path required") 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) encrypted, err := encryptEnv(s.enc, in.Env)
if err != nil { if err != nil {
return nil, err return nil, err
} }
encryptedMCP, err := encryptMCPServers(s.enc, in.MCPServers)
if err != nil {
return nil, err
}
args := in.Args args := in.Args
if args == nil { if args == nil {
args = []string{} args = []string{}
@@ -45,6 +56,8 @@ func (s *agentKindService) Create(ctx context.Context, c Caller, in CreateAgentK
ID: uuid.New(), Name: in.Name, DisplayName: in.DisplayName, Description: in.Description, ID: uuid.New(), Name: in.Name, DisplayName: in.DisplayName, Description: in.Description,
BinaryPath: in.BinaryPath, Args: args, EncryptedEnv: encrypted, Enabled: in.Enabled, BinaryPath: in.BinaryPath, Args: args, EncryptedEnv: encrypted, Enabled: in.Enabled,
ToolAllowlist: allowlist, ToolAllowlist: allowlist,
ClientType: clientType,
EncryptedMCPServers: encryptedMCP,
CreatedBy: c.UserID, CreatedBy: c.UserID,
} }
out, err := s.repo.CreateAgentKind(ctx, k) out, err := s.repo.CreateAgentKind(ctx, k)
@@ -114,6 +127,21 @@ func (s *agentKindService) Update(ctx context.Context, c Caller, id uuid.UUID, i
cur.ToolAllowlist = in.ToolAllowlist cur.ToolAllowlist = in.ToolAllowlist
changed["tool_allowlist"] = "<changed>" changed["tool_allowlist"] = "<changed>"
} }
if in.ClientType != nil && *in.ClientType != cur.ClientType {
if !in.ClientType.Valid() {
return nil, errs.New(errs.CodeInvalidInput, "invalid client_type")
}
cur.ClientType = *in.ClientType
changed["client_type"] = string(*in.ClientType)
}
if in.MCPServers != nil {
encryptedMCP, eerr := encryptMCPServers(s.enc, in.MCPServers)
if eerr != nil {
return nil, eerr
}
cur.EncryptedMCPServers = encryptedMCP
changed["mcp_servers"] = "<changed>"
}
if in.Enabled != nil && *in.Enabled != cur.Enabled { if in.Enabled != nil && *in.Enabled != cur.Enabled {
cur.Enabled = *in.Enabled cur.Enabled = *in.Enabled
changed["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) 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 用。 // decryptEnv 解密并反序列化 env map。Part 2 的 supervisor.Spawn 用。
func decryptEnv(enc *crypto.Encryptor, encrypted []byte) (map[string]string, error) { func decryptEnv(enc *crypto.Encryptor, encrypted []byte) (map[string]string, error) {
if len(encrypted) == 0 { if len(encrypted) == 0 {
+28 -1
View File
@@ -21,10 +21,14 @@ import (
// (隔离单测,不引 testcontainers)。 // (隔离单测,不引 testcontainers)。
type fakeAgentKindRepo struct { type fakeAgentKindRepo struct {
store map[uuid.UUID]*acp.AgentKind store map[uuid.UUID]*acp.AgentKind
files map[string]*acp.ConfigFile // key: kindID/relPath
} }
func newFakeAgentKindRepo() *fakeAgentKindRepo { 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) { 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) { func (f *fakeAgentKindRepo) CountAgentKindUsage(_ context.Context, _ uuid.UUID) (int64, error) {
return 0, nil 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 调用) // 其他方法 panic(不该被 AgentKindService 调用)
func (f *fakeAgentKindRepo) InsertSession(context.Context, *acp.Session) (*acp.Session, error) { func (f *fakeAgentKindRepo) InsertSession(context.Context, *acp.Session) (*acp.Session, error) {
+87
View File
@@ -47,8 +47,62 @@ const (
RPCError RPCKind = "error" 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 密文, // AgentKind 是 admin 注册的 agent 类型记录。EncryptedEnv 是 AES-GCM 密文,
// 仅 supervisor spawn 时解密为 map 注入子进程。其他场景不解密、不出 API。 // 仅 supervisor spawn 时解密为 map 注入子进程。其他场景不解密、不出 API。
// EncryptedMCPServers 同为密文,但明文经 API 返回给 admin 供编辑。
type AgentKind struct { type AgentKind struct {
ID uuid.UUID ID uuid.UUID
Name string Name string
@@ -59,11 +113,26 @@ type AgentKind struct {
EncryptedEnv []byte EncryptedEnv []byte
Enabled bool Enabled bool
ToolAllowlist []string // 命中的工具调用自动放行;含 "*" 表示放行全部可识别的工具(无法识别名称的调用仍走人工审批) ToolAllowlist []string // 命中的工具调用自动放行;含 "*" 表示放行全部可识别的工具(无法识别名称的调用仍走人工审批)
ClientType ClientType
EncryptedMCPServers []byte
CreatedBy uuid.UUID CreatedBy uuid.UUID
CreatedAt time.Time CreatedAt time.Time
UpdatedAt 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 标识(来自 // Session 是一次 ACP 会话。AgentSessionID 是 agent 侧的 session 标识(来自
// session/new response),与本表 ID 不同,仅用于 ACP 协议 method 内填充。 // session/new response),与本表 ID 不同,仅用于 ACP 协议 method 内填充。
type Session struct { type Session struct {
@@ -114,6 +183,19 @@ type AgentKindService interface {
Get(ctx context.Context, c Caller, id uuid.UUID) (*AgentKind, error) Get(ctx context.Context, c Caller, id uuid.UUID) (*AgentKind, error)
Update(ctx context.Context, c Caller, id uuid.UUID, in UpdateAgentKindInput) (*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 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 聚合根的应用服务方法。 // SessionService 暴露 Session 聚合根的应用服务方法。
@@ -143,6 +225,7 @@ type CreateSessionInput struct {
} }
// CreateAgentKindInput 携带创建必需字段。Args / Env 可为 nil。 // CreateAgentKindInput 携带创建必需字段。Args / Env 可为 nil。
// ClientType 为空时默认 generic。
type CreateAgentKindInput struct { type CreateAgentKindInput struct {
Name string Name string
DisplayName string DisplayName string
@@ -152,6 +235,8 @@ type CreateAgentKindInput struct {
Env map[string]string Env map[string]string
Enabled bool Enabled bool
ToolAllowlist []string ToolAllowlist []string
ClientType ClientType
MCPServers []McpServerSpec
} }
// UpdateAgentKindInput 是 patch 输入。Name 不可改(创建后稳定,引用约束)。 // UpdateAgentKindInput 是 patch 输入。Name 不可改(创建后稳定,引用约束)。
@@ -164,6 +249,8 @@ type UpdateAgentKindInput struct {
Env map[string]string // nil = 不改;非 nil(含空)= 替换 Env map[string]string // nil = 不改;非 nil(含空)= 替换
Enabled *bool Enabled *bool
ToolAllowlist []string // nil = 不改;非 nil(含空)= 替换 ToolAllowlist []string // nil = 不改;非 nil(含空)= 替换
ClientType *ClientType // nil = 不改
MCPServers []McpServerSpec // nil = 不改;非 nil(含空)= 替换
} }
// PermissionStatus 是 acp_permission_requests.status 枚举。 // PermissionStatus 是 acp_permission_requests.status 枚举。
+28
View File
@@ -18,6 +18,9 @@ type AgentKindAdminDTO struct {
EnvKeys []string `json:"env_keys"` EnvKeys []string `json:"env_keys"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
ToolAllowlist []string `json:"tool_allowlist"` ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"`
// MCPServers 明文返回(admin only,编辑所需);条目可能含 header/env 密钥。
MCPServers []McpServerSpec `json:"mcp_servers"`
CreatedBy uuid.UUID `json:"created_by"` CreatedBy uuid.UUID `json:"created_by"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
@@ -41,6 +44,8 @@ type CreateAgentKindReq struct {
Env map[string]string `json:"env"` Env map[string]string `json:"env"`
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
ToolAllowlist []string `json:"tool_allowlist"` ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"`
MCPServers []McpServerSpec `json:"mcp_servers"`
} }
type UpdateAgentKindReq struct { type UpdateAgentKindReq struct {
@@ -51,6 +56,29 @@ type UpdateAgentKindReq struct {
Env map[string]string `json:"env,omitempty"` Env map[string]string `json:"env,omitempty"`
Enabled *bool `json:"enabled,omitempty"` Enabled *bool `json:"enabled,omitempty"`
ToolAllowlist []string `json:"tool_allowlist,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 是权限请求的对外视图。 // PermissionRequestDTO 是权限请求的对外视图。
+86
View File
@@ -68,6 +68,11 @@ func (h *Handler) Mount(r chi.Router) {
r.Get("/{id}", h.getAgentKind) r.Get("/{id}", h.getAgentKind)
r.Patch("/{id}", h.updateAgentKind) r.Patch("/{id}", h.updateAgentKind)
r.Delete("/{id}", h.deleteAgentKind) 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.Route("/api/v1/acp/sessions", func(r chi.Router) {
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{})) 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, Env: req.Env,
Enabled: enabled, Enabled: enabled,
ToolAllowlist: req.ToolAllowlist, ToolAllowlist: req.ToolAllowlist,
ClientType: ClientType(req.ClientType),
MCPServers: req.MCPServers,
} }
out, err := h.akSvc.Create(r.Context(), c, in) out, err := h.akSvc.Create(r.Context(), c, in)
if err != nil { if err != nil {
@@ -265,6 +272,11 @@ func (h *Handler) updateAgentKind(w http.ResponseWriter, r *http.Request) {
Env: req.Env, Env: req.Env,
Enabled: req.Enabled, Enabled: req.Enabled,
ToolAllowlist: req.ToolAllowlist, 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) out, err := h.akSvc.Update(r.Context(), c, id, in)
if err != nil { if err != nil {
@@ -292,6 +304,74 @@ func (h *Handler) deleteAgentKind(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) 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 ===== // ===== Session endpoints =====
func (h *Handler) listSessions(w http.ResponseWriter, r *http.Request) { func (h *Handler) listSessions(w http.ResponseWriter, r *http.Request) {
@@ -617,6 +697,10 @@ func (h *Handler) agentKindToAdminDTO(k *AgentKind) AgentKindAdminDTO {
sort.Strings(envKeys) sort.Strings(envKeys)
} }
} }
mcpServers := []McpServerSpec{}
if servers, err := decryptMCPServers(h.enc, k.EncryptedMCPServers); err == nil {
mcpServers = servers
}
return AgentKindAdminDTO{ return AgentKindAdminDTO{
ID: k.ID, ID: k.ID,
Name: k.Name, Name: k.Name,
@@ -627,6 +711,8 @@ func (h *Handler) agentKindToAdminDTO(k *AgentKind) AgentKindAdminDTO {
EnvKeys: envKeys, EnvKeys: envKeys,
Enabled: k.Enabled, Enabled: k.Enabled,
ToolAllowlist: append([]string(nil), k.ToolAllowlist...), ToolAllowlist: append([]string(nil), k.ToolAllowlist...),
ClientType: string(k.ClientType),
MCPServers: mcpServers,
CreatedBy: k.CreatedBy, CreatedBy: k.CreatedBy,
CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
UpdatedAt: k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), UpdatedAt: k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
@@ -31,6 +31,18 @@ type InitializeResult struct {
AuthMethods []any `json:"authMethods,omitempty"` 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. // BuildInitializeParams returns standard initialize params.
func BuildInitializeParams(version string) InitializeParams { func BuildInitializeParams(version string) InitializeParams {
return InitializeParams{ return InitializeParams{
+44 -5
View File
@@ -1,17 +1,56 @@
package handlers 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 { type SessionNewParams struct {
Cwd string `json:"cwd"` Cwd string `json:"cwd"`
McpServers []string `json:"mcpServers"` // MVP: no MCP, send empty slice McpServers []any `json:"mcpServers"` // McpServerStdio | McpServerHTTP
} }
type SessionNewResult struct { type SessionNewResult struct {
SessionID string `json:"sessionId"` SessionID string `json:"sessionId"`
} }
// BuildSessionNewParams returns standard session/new params. // EnvVariable / HttpHeader 是 ACP wire 的 name-value 对。
func BuildSessionNewParams(cwd string) SessionNewParams { type EnvVariable struct {
return SessionNewParams{Cwd: cwd, McpServers: []string{}} Name string `json:"name"`
Value string `json:"value"`
}
type HttpHeader struct {
Name string `json:"name"`
Value string `json:"value"`
}
// McpServerStdio 是 stdio 传输的 MCP server(基线,所有 agent 必须支持)。
type McpServerStdio struct {
Name string `json:"name"`
Command string `json:"command"`
Args []string `json:"args"`
Env []EnvVariable `json:"env"`
}
// McpServerHTTP 是 http / sse 传输的 MCP server。Type 必须为 "http" 或 "sse",
// 仅当 agent 在 initialize 中声明对应 mcpCapabilities 时才可下发。
type McpServerHTTP struct {
Type string `json:"type"`
Name string `json:"name"`
URL string `json:"url"`
Headers []HttpHeader `json:"headers"`
}
// BuildSessionNewParams returns standard session/new params.
// servers 为 nil 时发送空数组(mcpServers 是协议必填字段)。
func BuildSessionNewParams(cwd string, servers []any) SessionNewParams {
if servers == nil {
servers = []any{}
}
return SessionNewParams{Cwd: cwd, McpServers: servers}
} }
+96
View File
@@ -0,0 +1,96 @@
// mcp_inject.go 构造 session/new 下发的 mcpServers 列表。
//
// 组成:ACW 自家 MCP(http 类型,per-session system token 做 Bearer 认证)+
// AgentKind 上配置的第三方 MCP。能力门控(ACP v1):stdio 是基线直接下发;
// http / sse 需 agent 在 initialize 响应中声明 mcpCapabilities.http / .sse,
// 未声明的条目逐条跳过并记日志——session 照常启动,自家 MCP 仍可由管理员
// 通过配置文件 + ACW_MCP_TOKEN 环境变量引用做静态接入兜底。
package acp
import (
"log/slog"
"sort"
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
)
// buildMcpServers 按 agent 能力构造 mcpServers。acwURL / acwToken 任一为空时
// 不注入自家 MCP(如 MCP 模块未配置 public_url)。
func buildMcpServers(specs []McpServerSpec, acwURL, acwToken string, httpOK, sseOK bool,
log *slog.Logger, sessionID uuid.UUID) []any {
out := []any{}
if acwURL != "" && acwToken != "" {
if httpOK {
out = append(out, handlers.McpServerHTTP{
Type: "http", Name: AcwMcpServerName, URL: acwURL,
Headers: []handlers.HttpHeader{{Name: "Authorization", Value: "Bearer " + acwToken}},
})
} else {
log.Warn("acp.mcp_inject.acw_skipped_no_http_capability",
"session_id", sessionID,
"hint", "agent 未声明 mcpCapabilities.http;可在 AgentKind 配置文件中静态引用 ACW_MCP_TOKEN 接入")
}
}
for _, sp := range specs {
switch sp.Type {
case McpStdio:
args := sp.Args
if args == nil {
args = []string{}
}
out = append(out, handlers.McpServerStdio{
Name: sp.Name, Command: sp.Command, Args: args, Env: toEnvVariables(sp.Env),
})
case McpHTTP:
if !httpOK {
log.Warn("acp.mcp_inject.server_skipped_no_capability",
"session_id", sessionID, "name", sp.Name, "type", "http")
continue
}
out = append(out, handlers.McpServerHTTP{
Type: "http", Name: sp.Name, URL: sp.URL, Headers: toHTTPHeaders(sp.Headers),
})
case McpSSE:
if !sseOK {
log.Warn("acp.mcp_inject.server_skipped_no_capability",
"session_id", sessionID, "name", sp.Name, "type", "sse")
continue
}
out = append(out, handlers.McpServerHTTP{
Type: "sse", Name: sp.Name, URL: sp.URL, Headers: toHTTPHeaders(sp.Headers),
})
}
}
return out
}
// toEnvVariables / toHTTPHeaders 把 map 转为 ACP wire 的 name-value 数组,
// 按 key 排序保证序列化确定性(map 迭代随机)。
func toEnvVariables(m map[string]string) []handlers.EnvVariable {
out := make([]handlers.EnvVariable, 0, len(m))
for _, k := range sortedKeys(m) {
out = append(out, handlers.EnvVariable{Name: k, Value: m[k]})
}
return out
}
func toHTTPHeaders(m map[string]string) []handlers.HttpHeader {
out := make([]handlers.HttpHeader, 0, len(m))
for _, k := range sortedKeys(m) {
out = append(out, handlers.HttpHeader{Name: k, Value: m[k]})
}
return out
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+82
View File
@@ -0,0 +1,82 @@
package acp
import (
"encoding/json"
"log/slog"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func marshalServers(t *testing.T, servers []any) string {
t.Helper()
b, err := json.Marshal(servers)
require.NoError(t, err)
return string(b)
}
func TestBuildMcpServers_AcwInjectedWithHTTPCapability(t *testing.T) {
t.Parallel()
out := buildMcpServers(nil, "http://x/mcp", "tok123", true, false, slog.Default(), uuid.New())
require.Len(t, out, 1)
j := marshalServers(t, out)
assert.Contains(t, j, `"type":"http"`)
assert.Contains(t, j, `"name":"acw"`)
assert.Contains(t, j, `"url":"http://x/mcp"`)
assert.Contains(t, j, `{"name":"Authorization","value":"Bearer tok123"}`)
}
func TestBuildMcpServers_AcwSkippedWithoutCapabilityOrConfig(t *testing.T) {
t.Parallel()
// 无 http 能力 → 跳过自家 MCP
out := buildMcpServers(nil, "http://x/mcp", "tok", false, false, slog.Default(), uuid.New())
assert.Empty(t, out)
// 未配置 public_url / token → 不注入
out = buildMcpServers(nil, "", "tok", true, true, slog.Default(), uuid.New())
assert.Empty(t, out)
out = buildMcpServers(nil, "http://x/mcp", "", true, true, slog.Default(), uuid.New())
assert.Empty(t, out)
}
func TestBuildMcpServers_StdioBaselineNoTypeField(t *testing.T) {
t.Parallel()
specs := []McpServerSpec{{
Name: "fs", Type: McpStdio, Command: "/bin/mcp-fs",
Env: map[string]string{"B": "2", "A": "1"},
}}
// stdio 不受能力门控(http/sse 均 false 仍下发)
out := buildMcpServers(specs, "", "", false, false, slog.Default(), uuid.New())
require.Len(t, out, 1)
j := marshalServers(t, out)
// ACP v1:stdio 无 type 判别字段;args/env 必填(空数组);env 按 key 排序
assert.NotContains(t, j, `"type"`)
assert.Contains(t, j, `"command":"/bin/mcp-fs"`)
assert.Contains(t, j, `"args":[]`)
assert.Contains(t, j, `"env":[{"name":"A","value":"1"},{"name":"B","value":"2"}]`)
}
func TestBuildMcpServers_CapabilityFiltering(t *testing.T) {
t.Parallel()
specs := []McpServerSpec{
{Name: "h", Type: McpHTTP, URL: "http://h"},
{Name: "s", Type: McpSSE, URL: "http://s"},
{Name: "io", Type: McpStdio, Command: "/x"},
}
// 仅 http 能力:sse 条目被跳过
out := buildMcpServers(specs, "", "", true, false, slog.Default(), uuid.New())
j := marshalServers(t, out)
require.Len(t, out, 2)
assert.Contains(t, j, `"name":"h"`)
assert.NotContains(t, j, `"name":"s"`)
assert.Contains(t, j, `"name":"io"`)
// 全能力:全部下发,sse 条目 type 正确
out = buildMcpServers(specs, "", "", true, true, slog.Default(), uuid.New())
require.Len(t, out, 3)
assert.Contains(t, marshalServers(t, out), `"type":"sse"`)
}
@@ -0,0 +1,19 @@
-- name: ListAgentKindConfigFiles :many
SELECT id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at
FROM acp_agent_kind_config_files
WHERE agent_kind_id = $1
ORDER BY rel_path ASC;
-- name: UpsertAgentKindConfigFile :one
INSERT INTO acp_agent_kind_config_files (
id, agent_kind_id, rel_path, encrypted_content, updated_by
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (agent_kind_id, rel_path) DO UPDATE
SET encrypted_content = EXCLUDED.encrypted_content,
updated_by = EXCLUDED.updated_by,
updated_at = now()
RETURNING id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at;
-- name: DeleteAgentKindConfigFile :execrows
DELETE FROM acp_agent_kind_config_files
WHERE agent_kind_id = $1 AND rel_path = $2;
+10 -8
View File
@@ -1,31 +1,31 @@
-- name: CreateAgentKind :one -- name: CreateAgentKind :one
INSERT INTO acp_agent_kinds ( INSERT INTO acp_agent_kinds (
id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist 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) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id, name, display_name, description, binary_path, args, encrypted_env, 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 -- name: GetAgentKindByID :one
SELECT id, name, display_name, description, binary_path, args, encrypted_env, 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 FROM acp_agent_kinds
WHERE id = $1; WHERE id = $1;
-- name: GetAgentKindByName :one -- name: GetAgentKindByName :one
SELECT id, name, display_name, description, binary_path, args, encrypted_env, 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 FROM acp_agent_kinds
WHERE name = $1; WHERE name = $1;
-- name: ListAgentKinds :many -- name: ListAgentKinds :many
SELECT id, name, display_name, description, binary_path, args, encrypted_env, 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 FROM acp_agent_kinds
ORDER BY created_at DESC; ORDER BY created_at DESC;
-- name: ListEnabledAgentKinds :many -- name: ListEnabledAgentKinds :many
SELECT id, name, display_name, description, binary_path, args, encrypted_env, 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 FROM acp_agent_kinds
WHERE enabled = TRUE WHERE enabled = TRUE
ORDER BY name ASC; ORDER BY name ASC;
@@ -39,10 +39,12 @@ SET display_name = $2,
encrypted_env = $6, encrypted_env = $6,
enabled = $7, enabled = $7,
tool_allowlist = $8, tool_allowlist = $8,
client_type = $9,
encrypted_mcp_servers = $10,
updated_at = now() updated_at = now()
WHERE id = $1 WHERE id = $1
RETURNING id, name, display_name, description, binary_path, args, encrypted_env, 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 -- name: DeleteAgentKind :exec
DELETE FROM acp_agent_kinds WHERE id = $1; DELETE FROM acp_agent_kinds WHERE id = $1;
+9
View File
@@ -115,6 +115,15 @@ func (f *fakeEventRepo) ListEventsSince(context.Context, uuid.UUID, int64, int32
func (f *fakeEventRepo) PurgeEventsBefore(context.Context, time.Time) (int64, error) { func (f *fakeEventRepo) PurgeEventsBefore(context.Context, time.Time) (int64, error) {
panic("n/a") 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 { func (f *fakeEventRepo) InTx(context.Context, func(context.Context, pgx.Tx) error) error {
panic("n/a") panic("n/a")
+62
View File
@@ -28,6 +28,11 @@ type Repository interface {
DeleteAgentKind(ctx context.Context, id uuid.UUID) error DeleteAgentKind(ctx context.Context, id uuid.UUID) error
CountAgentKindUsage(ctx context.Context, id uuid.UUID) (int64, 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 // Session
InsertSession(ctx context.Context, s *Session) (*Session, error) InsertSession(ctx context.Context, s *Session) (*Session, error)
GetSessionByID(ctx context.Context, id uuid.UUID) (*Session, error) GetSessionByID(ctx context.Context, id uuid.UUID) (*Session, error)
@@ -152,6 +157,8 @@ func (r *pgRepo) CreateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind,
Enabled: k.Enabled, Enabled: k.Enabled,
CreatedBy: toPgUUID(k.CreatedBy), CreatedBy: toPgUUID(k.CreatedBy),
ToolAllowlist: normalizeStrSlice(k.ToolAllowlist), ToolAllowlist: normalizeStrSlice(k.ToolAllowlist),
ClientType: string(k.ClientType),
EncryptedMcpServers: k.EncryptedMCPServers,
}) })
if err != nil { if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "create agent kind") return nil, errs.Wrap(err, errs.CodeInternal, "create agent kind")
@@ -209,6 +216,8 @@ func (r *pgRepo) UpdateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind,
EncryptedEnv: k.EncryptedEnv, EncryptedEnv: k.EncryptedEnv,
Enabled: k.Enabled, Enabled: k.Enabled,
ToolAllowlist: normalizeStrSlice(k.ToolAllowlist), ToolAllowlist: normalizeStrSlice(k.ToolAllowlist),
ClientType: string(k.ClientType),
EncryptedMcpServers: k.EncryptedMCPServers,
}) })
if err != nil { if err != nil {
return nil, pgxNoRowsToErrNotFound(err, errs.CodeAcpAgentKindNotFound, "agent kind not found") return nil, pgxNoRowsToErrNotFound(err, errs.CodeAcpAgentKindNotFound, "agent kind not found")
@@ -234,6 +243,45 @@ func (r *pgRepo) CountAgentKindUsage(ctx context.Context, id uuid.UUID) (int64,
return n, nil 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 ===== // ===== row → domain converters =====
func rowToAgentKind(row acpsqlc.AcpAgentKind) *AgentKind { func rowToAgentKind(row acpsqlc.AcpAgentKind) *AgentKind {
@@ -247,12 +295,26 @@ func rowToAgentKind(row acpsqlc.AcpAgentKind) *AgentKind {
EncryptedEnv: row.EncryptedEnv, EncryptedEnv: row.EncryptedEnv,
Enabled: row.Enabled, Enabled: row.Enabled,
ToolAllowlist: row.ToolAllowlist, ToolAllowlist: row.ToolAllowlist,
ClientType: ClientType(row.ClientType),
EncryptedMCPServers: row.EncryptedMcpServers,
CreatedBy: fromPgUUID(row.CreatedBy), CreatedBy: fromPgUUID(row.CreatedBy),
CreatedAt: row.CreatedAt.Time, CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.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,
}
}
// normalizeStrSlice 把 nil 切片归一为非 nil 空切片,避免写入 TEXT[] NOT NULL 列时 // normalizeStrSlice 把 nil 切片归一为非 nil 空切片,避免写入 TEXT[] NOT NULL 列时
// 产生 NULL(pg 驱动对 nil []string 写 NULL,违反 NOT NULL 约束)。 // 产生 NULL(pg 驱动对 nil []string 写 NULL,违反 NOT NULL 约束)。
func normalizeStrSlice(s []string) []string { func normalizeStrSlice(s []string) []string {
+2 -2
View File
@@ -261,8 +261,8 @@ func (s *sessionService) Create(ctx context.Context, c Caller, in CreateSessionI
// 9. async spawn(失败时回滚 worktree + revoke MCP token) // 9. async spawn(失败时回滚 worktree + revoke MCP token)
extraEnv := map[string]string{ extraEnv := map[string]string{
"ACW_MCP_TOKEN": tokenRes.Plaintext, EnvMCPToken: tokenRes.Plaintext,
"ACW_MCP_URL": s.cfg.MCPPublicURL, EnvMCPURL: s.cfg.MCPPublicURL,
} }
go func() { go func() {
spawnCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) spawnCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+10
View File
@@ -144,6 +144,16 @@ func (r *fakeAcpRepo) InTx(_ context.Context, fn func(context.Context, pgx.Tx) e
} }
func (r *fakeAcpRepo) WithTx(_ pgx.Tx) acp.Repository { return r } func (r *fakeAcpRepo) 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) { func (r *fakeAcpRepo) InsertSession(_ context.Context, s *acp.Session) (*acp.Session, error) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
@@ -0,0 +1,105 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: agent_kind_config_files.sql
package acpsqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const deleteAgentKindConfigFile = `-- name: DeleteAgentKindConfigFile :execrows
DELETE FROM acp_agent_kind_config_files
WHERE agent_kind_id = $1 AND rel_path = $2
`
type DeleteAgentKindConfigFileParams struct {
AgentKindID pgtype.UUID `json:"agent_kind_id"`
RelPath string `json:"rel_path"`
}
func (q *Queries) DeleteAgentKindConfigFile(ctx context.Context, arg DeleteAgentKindConfigFileParams) (int64, error) {
result, err := q.db.Exec(ctx, deleteAgentKindConfigFile, arg.AgentKindID, arg.RelPath)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const listAgentKindConfigFiles = `-- name: ListAgentKindConfigFiles :many
SELECT id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at
FROM acp_agent_kind_config_files
WHERE agent_kind_id = $1
ORDER BY rel_path ASC
`
func (q *Queries) ListAgentKindConfigFiles(ctx context.Context, agentKindID pgtype.UUID) ([]AcpAgentKindConfigFile, error) {
rows, err := q.db.Query(ctx, listAgentKindConfigFiles, agentKindID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AcpAgentKindConfigFile
for rows.Next() {
var i AcpAgentKindConfigFile
if err := rows.Scan(
&i.ID,
&i.AgentKindID,
&i.RelPath,
&i.EncryptedContent,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertAgentKindConfigFile = `-- name: UpsertAgentKindConfigFile :one
INSERT INTO acp_agent_kind_config_files (
id, agent_kind_id, rel_path, encrypted_content, updated_by
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (agent_kind_id, rel_path) DO UPDATE
SET encrypted_content = EXCLUDED.encrypted_content,
updated_by = EXCLUDED.updated_by,
updated_at = now()
RETURNING id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at
`
type UpsertAgentKindConfigFileParams struct {
ID pgtype.UUID `json:"id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
RelPath string `json:"rel_path"`
EncryptedContent []byte `json:"encrypted_content"`
UpdatedBy pgtype.UUID `json:"updated_by"`
}
func (q *Queries) UpsertAgentKindConfigFile(ctx context.Context, arg UpsertAgentKindConfigFileParams) (AcpAgentKindConfigFile, error) {
row := q.db.QueryRow(ctx, upsertAgentKindConfigFile,
arg.ID,
arg.AgentKindID,
arg.RelPath,
arg.EncryptedContent,
arg.UpdatedBy,
)
var i AcpAgentKindConfigFile
err := row.Scan(
&i.ID,
&i.AgentKindID,
&i.RelPath,
&i.EncryptedContent,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+30 -8
View File
@@ -24,10 +24,10 @@ func (q *Queries) CountAgentKindUsage(ctx context.Context, agentKindID pgtype.UU
const createAgentKind = `-- name: CreateAgentKind :one const createAgentKind = `-- name: CreateAgentKind :one
INSERT INTO acp_agent_kinds ( INSERT INTO acp_agent_kinds (
id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist 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) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id, name, display_name, description, binary_path, args, encrypted_env, 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 { type CreateAgentKindParams struct {
@@ -41,6 +41,8 @@ type CreateAgentKindParams struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"` CreatedBy pgtype.UUID `json:"created_by"`
ToolAllowlist []string `json:"tool_allowlist"` 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) { 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.Enabled,
arg.CreatedBy, arg.CreatedBy,
arg.ToolAllowlist, arg.ToolAllowlist,
arg.ClientType,
arg.EncryptedMcpServers,
) )
var i AcpAgentKind var i AcpAgentKind
err := row.Scan( err := row.Scan(
@@ -70,6 +74,8 @@ func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ToolAllowlist, &i.ToolAllowlist,
&i.ClientType,
&i.EncryptedMcpServers,
) )
return i, err return i, err
} }
@@ -85,7 +91,7 @@ func (q *Queries) DeleteAgentKind(ctx context.Context, id pgtype.UUID) error {
const getAgentKindByID = `-- name: GetAgentKindByID :one const getAgentKindByID = `-- name: GetAgentKindByID :one
SELECT id, name, display_name, description, binary_path, args, encrypted_env, 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 FROM acp_agent_kinds
WHERE id = $1 WHERE id = $1
` `
@@ -106,13 +112,15 @@ func (q *Queries) GetAgentKindByID(ctx context.Context, id pgtype.UUID) (AcpAgen
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ToolAllowlist, &i.ToolAllowlist,
&i.ClientType,
&i.EncryptedMcpServers,
) )
return i, err return i, err
} }
const getAgentKindByName = `-- name: GetAgentKindByName :one const getAgentKindByName = `-- name: GetAgentKindByName :one
SELECT id, name, display_name, description, binary_path, args, encrypted_env, 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 FROM acp_agent_kinds
WHERE name = $1 WHERE name = $1
` `
@@ -133,13 +141,15 @@ func (q *Queries) GetAgentKindByName(ctx context.Context, name string) (AcpAgent
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ToolAllowlist, &i.ToolAllowlist,
&i.ClientType,
&i.EncryptedMcpServers,
) )
return i, err return i, err
} }
const listAgentKinds = `-- name: ListAgentKinds :many const listAgentKinds = `-- name: ListAgentKinds :many
SELECT id, name, display_name, description, binary_path, args, encrypted_env, 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 FROM acp_agent_kinds
ORDER BY created_at DESC ORDER BY created_at DESC
` `
@@ -166,6 +176,8 @@ func (q *Queries) ListAgentKinds(ctx context.Context) ([]AcpAgentKind, error) {
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ToolAllowlist, &i.ToolAllowlist,
&i.ClientType,
&i.EncryptedMcpServers,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -179,7 +191,7 @@ func (q *Queries) ListAgentKinds(ctx context.Context) ([]AcpAgentKind, error) {
const listEnabledAgentKinds = `-- name: ListEnabledAgentKinds :many const listEnabledAgentKinds = `-- name: ListEnabledAgentKinds :many
SELECT id, name, display_name, description, binary_path, args, encrypted_env, 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 FROM acp_agent_kinds
WHERE enabled = TRUE WHERE enabled = TRUE
ORDER BY name ASC ORDER BY name ASC
@@ -207,6 +219,8 @@ func (q *Queries) ListEnabledAgentKinds(ctx context.Context) ([]AcpAgentKind, er
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ToolAllowlist, &i.ToolAllowlist,
&i.ClientType,
&i.EncryptedMcpServers,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -227,10 +241,12 @@ SET display_name = $2,
encrypted_env = $6, encrypted_env = $6,
enabled = $7, enabled = $7,
tool_allowlist = $8, tool_allowlist = $8,
client_type = $9,
encrypted_mcp_servers = $10,
updated_at = now() updated_at = now()
WHERE id = $1 WHERE id = $1
RETURNING id, name, display_name, description, binary_path, args, encrypted_env, 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 { type UpdateAgentKindParams struct {
@@ -242,6 +258,8 @@ type UpdateAgentKindParams struct {
EncryptedEnv []byte `json:"encrypted_env"` EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
ToolAllowlist []string `json:"tool_allowlist"` 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) { 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.EncryptedEnv,
arg.Enabled, arg.Enabled,
arg.ToolAllowlist, arg.ToolAllowlist,
arg.ClientType,
arg.EncryptedMcpServers,
) )
var i AcpAgentKind var i AcpAgentKind
err := row.Scan( err := row.Scan(
@@ -269,6 +289,8 @@ func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.ToolAllowlist, &i.ToolAllowlist,
&i.ClientType,
&i.EncryptedMcpServers,
) )
return i, err return i, err
} }
+12
View File
@@ -23,6 +23,18 @@ type AcpAgentKind struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"` 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 { type AcpEvent struct {
+40 -5
View File
@@ -41,6 +41,9 @@ type SupervisorConfig struct {
EventMaxPayload int EventMaxPayload int
EventTruncateField int EventTruncateField int
ShutdownGrace time.Duration ShutdownGrace time.Duration
// AgentHomesDir 是 agent CLI 受管 home 的根目录(<DataDir>/agent-homes)。
// 空串时跳过配置物化与重定向 env 注入(部分测试)。
AgentHomesDir string
} }
// Supervisor 是 ACP 子进程总管。 // Supervisor 是 ACP 子进程总管。
@@ -154,10 +157,24 @@ func (s *Supervisor) GetRelay(sid uuid.UUID) *Relay {
// handshake 失败时调用 Kill 回滚并返回错误。调用方(SessionService)负责前置的 // handshake 失败时调用 Kill 回滚并返回错误。调用方(SessionService)负责前置的
// worktree 占用与 acp_sessions 行 INSERT;本方法仅负责 OS 层资源与协议层握手。 // worktree 占用与 acp_sessions 行 INSERT;本方法仅负责 OS 层资源与协议层握手。
func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind, extraEnv map[string]string) (*Process, error) { 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 { if err != nil {
return nil, err return nil, err
} }
for k, v := range kindEnv {
envMap[k] = v
}
// System override: extraEnv overrides agent_kinds same-name keys (spec §6.1 #11) // System override: extraEnv overrides agent_kinds same-name keys (spec §6.1 #11)
for k, v := range extraEnv { for k, v := range extraEnv {
@@ -251,7 +268,7 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
ToolAllowlist: kind.ToolAllowlist, 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.log.Error("acp.handshake_failed", "session_id", sess.ID, "err", err.Error())
s.Kill(ctx, sess.ID, s.cfg.KillGrace) s.Kill(ctx, sess.ID, s.cfg.KillGrace)
return nil, err 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 // handshake 发送 initialize → session/new,并把返回的 agent session id + pid
// 持久化到 acp_sessions 行(status → running)。SpawnTimeout 是整个握手的硬上限。 // 持久化到 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) hsCtx, cancel := context.WithTimeout(ctx, s.cfg.SpawnTimeout)
defer cancel() 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) 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 { if err != nil {
return fmt.Errorf("session/new: %w", err) return fmt.Errorf("session/new: %w", err)
} }
+1
View File
@@ -355,6 +355,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
EventMaxPayload: cfg.Acp.EventMaxPayload, EventMaxPayload: cfg.Acp.EventMaxPayload,
EventTruncateField: cfg.Acp.EventTruncateField, EventTruncateField: cfg.Acp.EventTruncateField,
ShutdownGrace: cfg.Acp.ShutdownGrace, ShutdownGrace: cfg.Acp.ShutdownGrace,
AgentHomesDir: filepath.Join(cfg.DataDir, "agent-homes"),
}, },
log, log,
) )
+12
View File
@@ -23,6 +23,18 @@ type AcpAgentKind struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"` 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 { type AcpEvent struct {
+12
View File
@@ -23,6 +23,18 @@ type AcpAgentKind struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"` 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 { type AcpEvent struct {
+12
View File
@@ -23,6 +23,18 @@ type AcpAgentKind struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"` 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 { type AcpEvent struct {
+12
View File
@@ -23,6 +23,18 @@ type AcpAgentKind struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"` 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 { type AcpEvent struct {
+12
View File
@@ -23,6 +23,18 @@ type AcpAgentKind struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"` 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 { type AcpEvent struct {
+12
View File
@@ -23,6 +23,18 @@ type AcpAgentKind struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"` 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 { type AcpEvent struct {
+12
View File
@@ -23,6 +23,18 @@ type AcpAgentKind struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"` 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 { type AcpEvent struct {
+12
View File
@@ -23,6 +23,18 @@ type AcpAgentKind struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"` 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 { type AcpEvent struct {
+12
View File
@@ -23,6 +23,18 @@ type AcpAgentKind struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"` 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 { type AcpEvent struct {
@@ -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;
+23
View File
@@ -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 前全量物化到 <data_dir>/agent-homes/<agent_kind_id>/。
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);
@@ -0,0 +1 @@
ALTER TABLE acp_agent_kinds DROP COLUMN IF EXISTS encrypted_mcp_servers;
@@ -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;
+1
View File
@@ -23,6 +23,7 @@
"markdown-it": "^14.1.1", "markdown-it": "^14.1.1",
"naive-ui": "^2.44.1", "naive-ui": "^2.44.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"smol-toml": "^1.6.1",
"vue": "^3.5.33", "vue": "^3.5.33",
"vue-router": "^5.0.6" "vue-router": "^5.0.6"
}, },
+9
View File
@@ -20,6 +20,9 @@ importers:
pinia: pinia:
specifier: ^3.0.4 specifier: ^3.0.4
version: 3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)) 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: vue:
specifier: ^3.5.33 specifier: ^3.5.33
version: 3.5.33(typescript@6.0.3) version: 3.5.33(typescript@6.0.3)
@@ -1364,6 +1367,10 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'} engines: {node: '>=14'}
smol-toml@1.6.1:
resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
engines: {node: '>= 18'}
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2905,6 +2912,8 @@ snapshots:
signal-exit@4.1.0: {} signal-exit@4.1.0: {}
smol-toml@1.6.1: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
speakingurl@14.0.1: {} speakingurl@14.0.1: {}
+46
View File
@@ -1,5 +1,23 @@
import { request } from './client' 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<string, string>
url?: string
headers?: Record<string, string>
}
// Admin DTO from `internal/acp/dto.go: AgentKindAdminDTO`. // Admin DTO from `internal/acp/dto.go: AgentKindAdminDTO`.
export interface AgentKindAdmin { export interface AgentKindAdmin {
id: string id: string
@@ -11,6 +29,8 @@ export interface AgentKindAdmin {
env_keys: string[] env_keys: string[]
enabled: boolean enabled: boolean
tool_allowlist: string[] tool_allowlist: string[]
client_type: AgentClientType
mcp_servers: McpServerSpec[]
created_by: string created_by: string
created_at: string created_at: string
updated_at: string updated_at: string
@@ -38,6 +58,8 @@ export interface CreateAgentKindReq {
env?: Record<string, string> env?: Record<string, string>
enabled?: boolean enabled?: boolean
tool_allowlist?: string[] tool_allowlist?: string[]
client_type?: AgentClientType
mcp_servers?: McpServerSpec[]
} }
export interface UpdateAgentKindReq { export interface UpdateAgentKindReq {
@@ -48,6 +70,15 @@ export interface UpdateAgentKindReq {
env?: Record<string, string> env?: Record<string, string>
enabled?: boolean enabled?: boolean
tool_allowlist?: string[] 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. // PermissionRequest mirrors backend PermissionRequestDTO.
@@ -126,6 +157,21 @@ export const acpApi = {
remove: (id: string) => remove: (id: string) =>
request<void>(`/api/v1/acp/agent-kinds/${enc(id)}`, { method: 'DELETE' }), request<void>(`/api/v1/acp/agent-kinds/${enc(id)}`, { method: 'DELETE' }),
}, },
// Agent CLI 配置文件(admin only)。rel_path 含斜杠,走 body / query 而非 URL path。
configFiles: {
list: (kindId: string) =>
request<AgentConfigFile[]>(`/api/v1/acp/agent-kinds/${enc(kindId)}/config-files`),
upsert: (kindId: string, relPath: string, content: string) =>
request<AgentConfigFile>(`/api/v1/acp/agent-kinds/${enc(kindId)}/config-files`, {
method: 'PUT',
body: { rel_path: relPath, content },
}),
remove: (kindId: string, relPath: string) =>
request<void>(
`/api/v1/acp/agent-kinds/${enc(kindId)}/config-files?rel_path=${enc(relPath)}`,
{ method: 'DELETE' },
),
},
sessions: { sessions: {
list: (opts: { all?: boolean; requirement_id?: string } = {}) => { list: (opts: { all?: boolean; requirement_id?: string } = {}) => {
const q = new URLSearchParams() const q = new URLSearchParams()
@@ -0,0 +1,509 @@
<script setup lang="ts">
// AgentKind admin only
//
//
// - claude_code / codex / gemini
// .claude/settings.json / .codex/config.toml / .gemini/settings.json
//
// - rel_path JSON/TOML
//
// spawn home CLAUDE_CONFIG_DIR / CODEX_HOME /
// GEMINI_CLI_HOME agent spawn DB
import { ref, computed, onMounted, watch } from 'vue'
import { NInput, NButton, NSelect, NSpin } from 'naive-ui'
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
import { acpApi, type AgentConfigFile, type AgentClientType } from '@/api/acp'
const props = defineProps<{
kindId: string
clientType: AgentClientType
}>()
const files = ref<AgentConfigFile[]>([])
const loading = ref(false)
const error = ref('')
// ===== =====
const canonicalPath = computed(() => {
switch (props.clientType) {
case 'claude_code':
return '.claude/settings.json'
case 'codex':
return '.codex/config.toml'
case 'gemini':
return '.gemini/settings.json'
default:
return null
}
})
interface FormState {
model: string
// claude_code
defaultMode: string
allow: string
deny: string
ask: string
// codex
approvalPolicy: string | null
sandboxMode: string | null
}
const form = ref<FormState>({
model: '',
defaultMode: '',
allow: '',
deny: '',
ask: '',
approvalPolicy: null,
sandboxMode: null,
})
const approvalPolicyOptions = [
{ label: 'untrusted', value: 'untrusted' },
{ label: 'on-request', value: 'on-request' },
{ label: 'never', value: 'never' },
]
const sandboxModeOptions = [
{ label: 'read-only', value: 'read-only' },
{ label: 'workspace-write', value: 'workspace-write' },
{ label: 'danger-full-access', value: 'danger-full-access' },
]
function findFile(relPath: string): AgentConfigFile | undefined {
return files.value.find((f) => f.rel_path === relPath)
}
function linesToArr(s: string): string[] {
return s
.split('\n')
.map((x) => x.trim())
.filter((x) => x !== '')
}
// loadForm
function loadForm() {
const f = canonicalPath.value ? findFile(canonicalPath.value) : undefined
form.value = {
model: '',
defaultMode: '',
allow: '',
deny: '',
ask: '',
approvalPolicy: null,
sandboxMode: null,
}
if (!f) return
try {
if (props.clientType === 'codex') {
const doc = parseToml(f.content) as Record<string, unknown>
form.value.model = typeof doc.model === 'string' ? doc.model : ''
form.value.approvalPolicy =
typeof doc.approval_policy === 'string' ? doc.approval_policy : null
form.value.sandboxMode = typeof doc.sandbox_mode === 'string' ? doc.sandbox_mode : null
} else if (props.clientType === 'claude_code') {
const doc = JSON.parse(f.content) as Record<string, unknown>
form.value.model = typeof doc.model === 'string' ? doc.model : ''
const perms = (doc.permissions ?? {}) as Record<string, unknown>
form.value.defaultMode = typeof perms.defaultMode === 'string' ? perms.defaultMode : ''
form.value.allow = Array.isArray(perms.allow) ? perms.allow.join('\n') : ''
form.value.deny = Array.isArray(perms.deny) ? perms.deny.join('\n') : ''
form.value.ask = Array.isArray(perms.ask) ? perms.ask.join('\n') : ''
} else if (props.clientType === 'gemini') {
const doc = JSON.parse(f.content) as Record<string, unknown>
const model = (doc.model ?? {}) as Record<string, unknown>
form.value.model = typeof model.name === 'string' ? model.name : ''
}
} catch {
//
}
}
// saveForm key
async function saveForm() {
if (!canonicalPath.value) return
const existing = findFile(canonicalPath.value)
try {
if (props.clientType === 'codex') {
let doc: Record<string, unknown> = {}
if (existing) doc = parseToml(existing.content) as Record<string, unknown>
if (form.value.model) doc.model = form.value.model
else delete doc.model
if (form.value.approvalPolicy) doc.approval_policy = form.value.approvalPolicy
else delete doc.approval_policy
if (form.value.sandboxMode) doc.sandbox_mode = form.value.sandboxMode
else delete doc.sandbox_mode
await upsert(canonicalPath.value, stringifyToml(doc) + '\n')
} else {
let doc: Record<string, unknown> = {}
if (existing) doc = JSON.parse(existing.content) as Record<string, unknown>
if (props.clientType === 'claude_code') {
if (form.value.model) doc.model = form.value.model
else delete doc.model
const perms = (doc.permissions ?? {}) as Record<string, unknown>
if (form.value.defaultMode) perms.defaultMode = form.value.defaultMode
else delete perms.defaultMode
for (const key of ['allow', 'deny', 'ask'] as const) {
const arr = linesToArr(form.value[key])
if (arr.length) perms[key] = arr
else delete perms[key]
}
if (Object.keys(perms).length) doc.permissions = perms
else delete doc.permissions
} else if (props.clientType === 'gemini') {
const model = (doc.model ?? {}) as Record<string, unknown>
if (form.value.model) model.name = form.value.model
else delete model.name
if (Object.keys(model).length) doc.model = model
else delete doc.model
}
await upsert(canonicalPath.value, JSON.stringify(doc, null, 2) + '\n')
}
} catch (e) {
error.value = `保存失败:${e instanceof Error ? e.message : '现有文件内容无法解析,请在高级模式修复'}`
}
}
// ===== =====
const editingPath = ref<string | null>(null)
const editingContent = ref('')
const editingIsNew = ref(false)
const newRelPath = ref('')
const syntaxError = ref('')
// client_type
const templates = computed<{ label: string; rel_path: string; content: string }[]>(() => {
switch (props.clientType) {
case 'claude_code':
return [
{
label: '.claude/settings.json',
rel_path: '.claude/settings.json',
content: '{\n "permissions": {\n "allow": [],\n "deny": []\n }\n}\n',
},
]
case 'codex':
return [
{
label: '.codex/config.toml',
rel_path: '.codex/config.toml',
content: 'approval_policy = "on-request"\nsandbox_mode = "workspace-write"\n',
},
{
label: '.codex/auth.json',
rel_path: '.codex/auth.json',
content: '{\n "OPENAI_API_KEY": ""\n}\n',
},
]
case 'gemini':
return [
{
label: '.gemini/settings.json',
rel_path: '.gemini/settings.json',
content: '{\n "mcpServers": {}\n}\n',
},
]
default:
return []
}
})
function validateSyntax(relPath: string, content: string): string {
const lower = relPath.toLowerCase()
try {
if (lower.endsWith('.json')) JSON.parse(content)
else if (lower.endsWith('.toml')) parseToml(content)
} catch (e) {
return e instanceof Error ? e.message : 'syntax error'
}
return ''
}
watch([editingContent, newRelPath], () => {
const p = editingIsNew.value ? newRelPath.value : (editingPath.value ?? '')
syntaxError.value = p ? validateSyntax(p, editingContent.value) : ''
})
function startEdit(f: AgentConfigFile) {
editingIsNew.value = false
editingPath.value = f.rel_path
editingContent.value = f.content
syntaxError.value = ''
}
function startNew(template?: { rel_path: string; content: string }) {
editingIsNew.value = true
editingPath.value = null
newRelPath.value = template?.rel_path ?? ''
editingContent.value = template?.content ?? ''
syntaxError.value = ''
}
function cancelEdit() {
editingPath.value = null
editingIsNew.value = false
newRelPath.value = ''
editingContent.value = ''
syntaxError.value = ''
}
async function saveEdit() {
const relPath = editingIsNew.value ? newRelPath.value.trim() : editingPath.value
if (!relPath) {
error.value = '请填写文件路径'
return
}
if (syntaxError.value) return
await upsert(relPath, editingContent.value)
cancelEdit()
}
async function upsert(relPath: string, content: string) {
error.value = ''
try {
await acpApi.configFiles.upsert(props.kindId, relPath, content)
await refresh()
} catch (e) {
error.value = `保存失败:${e instanceof Error ? e.message : 'unknown'}`
}
}
async function removeFile(relPath: string) {
if (!confirm(`删除配置文件 ${relPath}`)) return
error.value = ''
try {
await acpApi.configFiles.remove(props.kindId, relPath)
await refresh()
} catch (e) {
error.value = `删除失败:${e instanceof Error ? e.message : 'unknown'}`
}
}
async function refresh() {
loading.value = true
try {
files.value = await acpApi.configFiles.list(props.kindId)
loadForm()
} catch (e) {
error.value = `加载失败:${e instanceof Error ? e.message : 'unknown'}`
} finally {
loading.value = false
}
}
onMounted(refresh)
watch(() => props.clientType, loadForm)
</script>
<template>
<div class="space-y-4">
<div>
<h2 class="text-lg font-semibold">客户端配置文件</h2>
<p class="text-xs text-gray-500 mt-1">
配置存数据库session 启动前物化到受管目录并通过
<code v-if="clientType === 'claude_code'">CLAUDE_CONFIG_DIR</code>
<code v-else-if="clientType === 'codex'">CODEX_HOME</code>
<code v-else-if="clientType === 'gemini'">GEMINI_CLI_HOME</code>
<code v-else>HOME</code>
指向该目录agent 运行期对这些文件的修改会在下次启动时被覆盖
</p>
</div>
<p
v-if="error"
class="text-sm text-red-600"
>
{{ error }}
</p>
<NSpin :show="loading">
<!-- 常用配置表单已知客户端类型 -->
<div
v-if="canonicalPath"
class="border rounded p-4 space-y-3"
>
<div class="text-sm font-medium">
常用配置
<span class="text-xs text-gray-500 font-normal">写入 {{ canonicalPath }}其余字段保留</span>
</div>
<div>
<label class="block text-sm mb-1">Model</label>
<NInput
v-model:value="form.model"
:placeholder="
clientType === 'codex'
? 'gpt-5.5'
: clientType === 'gemini'
? 'gemini-2.5-pro'
: 'claude-sonnet-4-6'
"
/>
</div>
<template v-if="clientType === 'claude_code'">
<div>
<label class="block text-sm mb-1">permissions.defaultMode</label>
<NInput
v-model:value="form.defaultMode"
placeholder="acceptEdits"
/>
</div>
<div class="grid grid-cols-3 gap-2">
<div>
<label class="block text-sm mb-1">allow每行一条</label>
<NInput
v-model:value="form.allow"
type="textarea"
:autosize="{ minRows: 3, maxRows: 8 }"
placeholder="Bash(git status)"
/>
</div>
<div>
<label class="block text-sm mb-1">deny每行一条</label>
<NInput
v-model:value="form.deny"
type="textarea"
:autosize="{ minRows: 3, maxRows: 8 }"
placeholder="Read(~/.ssh/**)"
/>
</div>
<div>
<label class="block text-sm mb-1">ask每行一条</label>
<NInput
v-model:value="form.ask"
type="textarea"
:autosize="{ minRows: 3, maxRows: 8 }"
placeholder="Write(src/**)"
/>
</div>
</div>
</template>
<template v-if="clientType === 'codex'">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-sm mb-1">approval_policy</label>
<NSelect
v-model:value="form.approvalPolicy"
:options="approvalPolicyOptions"
clearable
/>
</div>
<div>
<label class="block text-sm mb-1">sandbox_mode</label>
<NSelect
v-model:value="form.sandboxMode"
:options="sandboxModeOptions"
clearable
/>
</div>
</div>
</template>
<NButton
size="small"
type="primary"
@click="saveForm"
>
保存常用配置
</NButton>
</div>
<!-- 高级模式文件列表 + 原始编辑 -->
<div class="border rounded p-4 space-y-3 mt-4">
<div class="text-sm font-medium">全部配置文件高级模式</div>
<div
v-if="files.length === 0"
class="text-sm text-gray-500"
>
暂无配置文件
</div>
<div
v-for="f in files"
:key="f.rel_path"
class="flex items-center justify-between text-sm border-b pb-1"
>
<code>{{ f.rel_path }}</code>
<div class="flex gap-2 items-center">
<span class="text-xs text-gray-400">{{ f.updated_at }}</span>
<NButton
size="tiny"
@click="startEdit(f)"
>
编辑
</NButton>
<NButton
size="tiny"
quaternary
type="error"
@click="removeFile(f.rel_path)"
>
删除
</NButton>
</div>
</div>
<div class="flex gap-2 flex-wrap">
<NButton
size="small"
dashed
@click="startNew()"
>
+ 新建文件
</NButton>
<NButton
v-for="t in templates.filter((x) => !files.some((f) => f.rel_path === x.rel_path))"
:key="t.rel_path"
size="small"
dashed
@click="startNew(t)"
>
+ {{ t.label }}
</NButton>
</div>
<div
v-if="editingIsNew || editingPath !== null"
class="space-y-2 pt-2"
>
<div v-if="editingIsNew">
<label class="block text-sm mb-1">文件路径相对受管 home .claude/settings.json</label>
<NInput
v-model:value="newRelPath"
placeholder=".claude/settings.json"
/>
</div>
<div v-else>
<code class="text-sm">{{ editingPath }}</code>
</div>
<NInput
v-model:value="editingContent"
type="textarea"
class="font-mono"
:autosize="{ minRows: 8, maxRows: 24 }"
/>
<p
v-if="syntaxError"
class="text-xs text-red-600"
>
语法错误{{ syntaxError }}
</p>
<div class="flex gap-2">
<NButton
size="small"
type="primary"
:disabled="!!syntaxError"
@click="saveEdit"
>
保存
</NButton>
<NButton
size="small"
@click="cancelEdit"
>
取消
</NButton>
</div>
</div>
</div>
</NSpin>
</div>
</template>
+196 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed } from 'vue' import { ref, watch, computed } from 'vue'
import { NInput, NButton, NCheckbox } from 'naive-ui' import { NInput, NButton, NCheckbox, NSelect } from 'naive-ui'
import type { AgentClientType, McpServerSpec } from '@/api/acp'
const props = defineProps<{ const props = defineProps<{
modelValue: { modelValue: {
@@ -12,11 +13,20 @@ const props = defineProps<{
env: Record<string, string> env: Record<string, string>
enabled: boolean enabled: boolean
tool_allowlist: string[] tool_allowlist: string[]
client_type: AgentClientType
mcp_servers: McpServerSpec[]
} }
isEdit: boolean isEdit: boolean
existingEnvKeys?: string[] existingEnvKeys?: string[]
}>() }>()
const clientTypeOptions: { label: string; value: AgentClientType }[] = [
{ label: 'Claude Code', value: 'claude_code' },
{ label: 'Codex', value: 'codex' },
{ label: 'Gemini CLI', value: 'gemini' },
{ label: '通用(仅注入 HOME)', value: 'generic' },
]
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', value: typeof props.modelValue): void (e: 'update:modelValue', value: typeof props.modelValue): void
(e: 'submit'): void (e: 'submit'): void
@@ -72,6 +82,109 @@ function addEnv() {
function removeEnv(idx: number) { function removeEnv(idx: number) {
envEntries.value.splice(idx, 1) envEntries.value.splice(idx, 1)
} }
// ===== MCP servers =====
// session/new ACW MCP stdio ACP 线
// http/sse agent
const mcpTypeOptions = [
{ label: 'stdio', value: 'stdio' },
{ label: 'http', value: 'http' },
{ label: 'sse', value: 'sse' },
]
interface McpRow {
name: string
type: 'stdio' | 'http' | 'sse'
command: string
argsText: string
envText: string
url: string
headersText: string
}
function kvToText(m?: Record<string, string>): string {
return Object.entries(m ?? {})
.map(([k, v]) => `${k}=${v}`)
.join('\n')
}
function parseKV(s: string): Record<string, string> | undefined {
const m: Record<string, string> = {}
for (const line of s.split('\n')) {
const t = line.trim()
if (!t) continue
const i = t.indexOf('=')
if (i <= 0) continue
m[t.slice(0, i).trim()] = t.slice(i + 1)
}
return Object.keys(m).length ? m : undefined
}
function specsToRows(specs: McpServerSpec[]): McpRow[] {
return specs.map((s) => ({
name: s.name,
type: s.type,
command: s.command ?? '',
argsText: (s.args ?? []).join('\n'),
envText: kvToText(s.env),
url: s.url ?? '',
headersText: kvToText(s.headers),
}))
}
function rowsToSpecs(rows: McpRow[]): McpServerSpec[] {
return rows.map((r) => {
const spec: McpServerSpec = { name: r.name.trim(), type: r.type }
if (r.type === 'stdio') {
spec.command = r.command.trim()
const args = r.argsText.split('\n').filter((x) => x.trim() !== '')
if (args.length) spec.args = args
const env = parseKV(r.envText)
if (env) spec.env = env
} else {
spec.url = r.url.trim()
const headers = parseKV(r.headersText)
if (headers) spec.headers = headers
}
return spec
})
}
const mcpRows = ref<McpRow[]>(specsToRows(local.value.mcp_servers ?? []))
watch(
mcpRows,
(rows) => {
local.value.mcp_servers = rowsToSpecs(rows)
},
{ deep: true },
)
// modelValue watch
watch(
() => props.modelValue.mcp_servers,
(v) => {
if (JSON.stringify(v ?? []) !== JSON.stringify(rowsToSpecs(mcpRows.value))) {
mcpRows.value = specsToRows(v ?? [])
}
},
)
function addMcpServer() {
mcpRows.value.push({
name: '',
type: 'http',
command: '',
argsText: '',
envText: '',
url: '',
headersText: '',
})
}
function removeMcpServer(idx: number) {
mcpRows.value.splice(idx, 1)
}
</script> </script>
<template> <template>
@@ -108,6 +221,16 @@ function removeEnv(idx: number) {
:autosize="{ minRows: 2, maxRows: 6 }" :autosize="{ minRows: 2, maxRows: 6 }"
/> />
</div> </div>
<div>
<label class="block text-sm font-medium mb-1">客户端类型</label>
<NSelect
v-model:value="local.client_type"
:options="clientTypeOptions"
/>
<p class="text-xs text-gray-500 mt-1">
决定 session 启动时注入的配置目录变量与下方配置文件表单
</p>
</div>
<div> <div>
<label class="block text-sm font-medium mb-1">Binary Path</label> <label class="block text-sm font-medium mb-1">Binary Path</label>
<NInput <NInput
@@ -176,6 +299,78 @@ function removeEnv(idx: number) {
placeholder="fs/read_text_file&#10;safe.tool" placeholder="fs/read_text_file&#10;safe.tool"
/> />
</div> </div>
<div>
<label class="block text-sm font-medium mb-1">
第三方 MCP Serverssession 启动时随平台 MCP 一并下发名称 acw 为平台保留
</label>
<div
v-for="(m, idx) in mcpRows"
:key="idx"
class="border rounded p-3 mb-2 space-y-2"
>
<div class="flex gap-2">
<NInput
v-model:value="m.name"
placeholder="名称(如 context7)"
class="flex-1"
/>
<NSelect
v-model:value="m.type"
:options="mcpTypeOptions"
class="w-28"
/>
<NButton
quaternary
@click="removeMcpServer(idx)"
>
×
</NButton>
</div>
<template v-if="m.type === 'stdio'">
<NInput
v-model:value="m.command"
placeholder="command(如 npx)"
/>
<NInput
v-model:value="m.argsText"
type="textarea"
:autosize="{ minRows: 1, maxRows: 5 }"
placeholder="args(每行一个,如 -y&#10;@upstash/context7-mcp)"
/>
<NInput
v-model:value="m.envText"
type="textarea"
:autosize="{ minRows: 1, maxRows: 5 }"
placeholder="env(每行 KEY=VALUE)"
/>
</template>
<template v-else>
<NInput
v-model:value="m.url"
placeholder="URL(如 https://mcp.example.com/mcp)"
/>
<NInput
v-model:value="m.headersText"
type="textarea"
:autosize="{ minRows: 1, maxRows: 5 }"
placeholder="headers(每行 KEY=VALUE,如 Authorization=Bearer xxx)"
/>
<p
v-if="m.type === 'sse'"
class="text-xs text-gray-500"
>
注意codex 不支持 sse 类型会在启动时被跳过
</p>
</template>
</div>
<NButton
size="small"
dashed
@click="addMcpServer"
>
+ 添加 MCP Server
</NButton>
</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<NCheckbox v-model:checked="local.enabled"> <NCheckbox v-model:checked="local.enabled">
Enabled Enabled
+26 -1
View File
@@ -3,8 +3,9 @@ import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue' import AppShell from '@/layouts/AppShell.vue'
import AgentKindForm from '@/components/acp/AgentKindForm.vue' import AgentKindForm from '@/components/acp/AgentKindForm.vue'
import AgentConfigFilesPanel from '@/components/acp/AgentConfigFilesPanel.vue'
import { useAcpStore } from '@/stores/acp' import { useAcpStore } from '@/stores/acp'
import type { UpdateAgentKindReq } from '@/api/acp' import type { AgentClientType, McpServerSpec, UpdateAgentKindReq } from '@/api/acp'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -22,6 +23,8 @@ const form = ref({
env: {} as Record<string, string>, env: {} as Record<string, string>,
enabled: true, enabled: true,
tool_allowlist: [] as string[], tool_allowlist: [] as string[],
client_type: 'generic' as AgentClientType,
mcp_servers: [] as McpServerSpec[],
}) })
const existingEnvKeys = ref<string[]>([]) const existingEnvKeys = ref<string[]>([])
@@ -37,6 +40,8 @@ onMounted(async () => {
env: {}, // Edit mode: do not prefill values; user must replace entirely if they want to change. env: {}, // Edit mode: do not prefill values; user must replace entirely if they want to change.
enabled: k.enabled, enabled: k.enabled,
tool_allowlist: [...(k.tool_allowlist ?? [])], tool_allowlist: [...(k.tool_allowlist ?? [])],
client_type: k.client_type ?? 'generic',
mcp_servers: [...(k.mcp_servers ?? [])],
} }
existingEnvKeys.value = [...k.env_keys] existingEnvKeys.value = [...k.env_keys]
} }
@@ -52,6 +57,9 @@ async function submit() {
args: form.value.args, args: form.value.args,
enabled: form.value.enabled, enabled: form.value.enabled,
tool_allowlist: form.value.tool_allowlist, tool_allowlist: form.value.tool_allowlist,
client_type: form.value.client_type,
// mcp_servers env
mcp_servers: form.value.mcp_servers,
} }
// Only send env if user actually entered some values; an empty map would // Only send env if user actually entered some values; an empty map would
// wipe existing env on the backend (per service patch semantics). // wipe existing env on the backend (per service patch semantics).
@@ -67,6 +75,8 @@ async function submit() {
env: form.value.env, env: form.value.env,
enabled: form.value.enabled, enabled: form.value.enabled,
tool_allowlist: form.value.tool_allowlist, tool_allowlist: form.value.tool_allowlist,
client_type: form.value.client_type,
mcp_servers: form.value.mcp_servers,
}) })
} }
router.push('/admin/acp/agent-kinds') router.push('/admin/acp/agent-kinds')
@@ -89,6 +99,21 @@ async function submit() {
@submit="submit" @submit="submit"
@cancel="router.push('/admin/acp/agent-kinds')" @cancel="router.push('/admin/acp/agent-kinds')"
/> />
<div
v-if="isEdit && id"
class="mt-8"
>
<AgentConfigFilesPanel
:kind-id="id"
:client-type="form.client_type"
/>
</div>
<p
v-else
class="text-xs text-gray-500 mt-6"
>
保存后可在编辑页配置该客户端的配置文件settings.json / config.toml
</p>
</div> </div>
</AppShell> </AppShell>
</template> </template>
@@ -112,7 +112,7 @@ async function refresh() {
} else { } else {
commits.value = await workspacesApi.logOnWorktree(target.value) commits.value = await workspacesApi.logOnWorktree(target.value)
} }
} catch (e: unknown) { } catch {
commits.value = [] commits.value = []
} finally { } finally {
loading.value = false loading.value = false