You've already forked agentic-coding-workflow
ACP / MCP
This commit is contained in:
+10
-3
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 枚举。
|
||||||
|
|||||||
@@ -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 是权限请求的对外视图。
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+9
@@ -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: {}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 safe.tool"
|
placeholder="fs/read_text_file safe.tool"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">
|
||||||
|
第三方 MCP Servers(session 启动时随平台 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 @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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user