You've already forked agentic-coding-workflow
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ea4adfd8e | |||
| 2a9661a6ef |
+10
-3
@@ -23,16 +23,23 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/server
|
||||
|
||||
# ─── Stage 3: runtime ───
|
||||
FROM registry.jerryyan.top/library/alpine:3.20
|
||||
# nodejs/npm 供安装 ACP agent CLI(claude-code-acp / codex / gemini-cli 等,
|
||||
# 均为 npm 包)。npm 全局 prefix 指到 /data/npm-global(持久卷),容器内
|
||||
# `npm i -g xxx` 一次安装,重建镜像后无需重装;binary_path 填
|
||||
# /data/npm-global/bin/<cli> 即可。
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||
&& apk add --no-cache ca-certificates tzdata git openssh-client && \
|
||||
&& apk add --no-cache ca-certificates tzdata git openssh-client nodejs npm && \
|
||||
addgroup -S app && adduser -S app -G app
|
||||
WORKDIR /app
|
||||
COPY --from=go /out/server /app/server
|
||||
COPY migrations /app/migrations
|
||||
RUN mkdir -p /data/uploads && chown -R app:app /data
|
||||
RUN mkdir -p /data/uploads /data/npm-global && chown -R app:app /data
|
||||
ENV APP_DATA_DIR=/data \
|
||||
APP_WORKSPACE_DATA_DIR=/data \
|
||||
APP_STORAGE_LOCALFS_BASE_PATH=/data/uploads
|
||||
APP_STORAGE_LOCALFS_BASE_PATH=/data/uploads \
|
||||
NPM_CONFIG_PREFIX=/data/npm-global \
|
||||
NPM_CONFIG_REGISTRY=https://registry.npmmirror.com \
|
||||
PATH=/data/npm-global/bin:$PATH
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/app/server"]
|
||||
|
||||
@@ -15,10 +15,12 @@ require (
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.0
|
||||
github.com/oklog/ulid/v2 v2.1.1
|
||||
github.com/openai/openai-go v1.12.0
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/pkoukk/tiktoken-go v0.1.8
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
|
||||
golang.org/x/sys v0.42.0
|
||||
google.golang.org/genai v1.55.0
|
||||
)
|
||||
|
||||
@@ -75,7 +77,6 @@ require (
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
@@ -108,7 +109,6 @@ require (
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
|
||||
google.golang.org/grpc v1.74.2 // indirect
|
||||
|
||||
@@ -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 == "" {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "name / display_name / binary_path required")
|
||||
}
|
||||
clientType := in.ClientType
|
||||
if clientType == "" {
|
||||
clientType = ClientGeneric
|
||||
}
|
||||
if !clientType.Valid() {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "invalid client_type")
|
||||
}
|
||||
encrypted, err := encryptEnv(s.enc, in.Env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encryptedMCP, err := encryptMCPServers(s.enc, in.MCPServers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := in.Args
|
||||
if args == nil {
|
||||
args = []string{}
|
||||
@@ -44,8 +55,10 @@ func (s *agentKindService) Create(ctx context.Context, c Caller, in CreateAgentK
|
||||
k := &AgentKind{
|
||||
ID: uuid.New(), Name: in.Name, DisplayName: in.DisplayName, Description: in.Description,
|
||||
BinaryPath: in.BinaryPath, Args: args, EncryptedEnv: encrypted, Enabled: in.Enabled,
|
||||
ToolAllowlist: allowlist,
|
||||
CreatedBy: c.UserID,
|
||||
ToolAllowlist: allowlist,
|
||||
ClientType: clientType,
|
||||
EncryptedMCPServers: encryptedMCP,
|
||||
CreatedBy: c.UserID,
|
||||
}
|
||||
out, err := s.repo.CreateAgentKind(ctx, k)
|
||||
if err != nil {
|
||||
@@ -114,6 +127,21 @@ func (s *agentKindService) Update(ctx context.Context, c Caller, id uuid.UUID, i
|
||||
cur.ToolAllowlist = in.ToolAllowlist
|
||||
changed["tool_allowlist"] = "<changed>"
|
||||
}
|
||||
if in.ClientType != nil && *in.ClientType != cur.ClientType {
|
||||
if !in.ClientType.Valid() {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "invalid client_type")
|
||||
}
|
||||
cur.ClientType = *in.ClientType
|
||||
changed["client_type"] = string(*in.ClientType)
|
||||
}
|
||||
if in.MCPServers != nil {
|
||||
encryptedMCP, eerr := encryptMCPServers(s.enc, in.MCPServers)
|
||||
if eerr != nil {
|
||||
return nil, eerr
|
||||
}
|
||||
cur.EncryptedMCPServers = encryptedMCP
|
||||
changed["mcp_servers"] = "<changed>"
|
||||
}
|
||||
if in.Enabled != nil && *in.Enabled != cur.Enabled {
|
||||
cur.Enabled = *in.Enabled
|
||||
changed["enabled"] = *in.Enabled
|
||||
@@ -171,6 +199,70 @@ func encryptEnv(enc *crypto.Encryptor, env map[string]string) ([]byte, error) {
|
||||
return enc.Encrypt(b)
|
||||
}
|
||||
|
||||
// encryptMCPServers 校验并加密 MCP server 列表。nil → nil(不存储)。
|
||||
func encryptMCPServers(enc *crypto.Encryptor, servers []McpServerSpec) ([]byte, error) {
|
||||
if servers == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if err := validateMCPServers(servers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := json.Marshal(servers)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "marshal mcp servers")
|
||||
}
|
||||
return enc.Encrypt(b)
|
||||
}
|
||||
|
||||
// decryptMCPServers 解密 MCP server 列表。空密文 → 空列表。
|
||||
func decryptMCPServers(enc *crypto.Encryptor, encrypted []byte) ([]McpServerSpec, error) {
|
||||
if len(encrypted) == 0 {
|
||||
return []McpServerSpec{}, nil
|
||||
}
|
||||
plain, err := enc.Decrypt(encrypted)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "decrypt mcp servers")
|
||||
}
|
||||
var out []McpServerSpec
|
||||
if err := json.Unmarshal(plain, &out); err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "unmarshal mcp servers")
|
||||
}
|
||||
if out == nil {
|
||||
out = []McpServerSpec{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// validateMCPServers 校验条目:name 必填且不重复;stdio 需 command,http/sse 需 url。
|
||||
func validateMCPServers(servers []McpServerSpec) error {
|
||||
seen := map[string]bool{}
|
||||
for _, sv := range servers {
|
||||
if sv.Name == "" {
|
||||
return errs.New(errs.CodeInvalidInput, "mcp server name required")
|
||||
}
|
||||
if sv.Name == AcwMcpServerName {
|
||||
return errs.New(errs.CodeInvalidInput, "mcp server name '"+AcwMcpServerName+"' is reserved")
|
||||
}
|
||||
if seen[sv.Name] {
|
||||
return errs.New(errs.CodeInvalidInput, "duplicate mcp server name: "+sv.Name)
|
||||
}
|
||||
seen[sv.Name] = true
|
||||
switch sv.Type {
|
||||
case McpStdio:
|
||||
if sv.Command == "" {
|
||||
return errs.New(errs.CodeInvalidInput, "mcp server "+sv.Name+": command required for stdio")
|
||||
}
|
||||
case McpHTTP, McpSSE:
|
||||
if sv.URL == "" {
|
||||
return errs.New(errs.CodeInvalidInput, "mcp server "+sv.Name+": url required for "+string(sv.Type))
|
||||
}
|
||||
default:
|
||||
return errs.New(errs.CodeInvalidInput, "mcp server "+sv.Name+": invalid type")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decryptEnv 解密并反序列化 env map。Part 2 的 supervisor.Spawn 用。
|
||||
func decryptEnv(enc *crypto.Encryptor, encrypted []byte) (map[string]string, error) {
|
||||
if len(encrypted) == 0 {
|
||||
|
||||
@@ -21,10 +21,14 @@ import (
|
||||
// (隔离单测,不引 testcontainers)。
|
||||
type fakeAgentKindRepo struct {
|
||||
store map[uuid.UUID]*acp.AgentKind
|
||||
files map[string]*acp.ConfigFile // key: kindID/relPath
|
||||
}
|
||||
|
||||
func newFakeAgentKindRepo() *fakeAgentKindRepo {
|
||||
return &fakeAgentKindRepo{store: map[uuid.UUID]*acp.AgentKind{}}
|
||||
return &fakeAgentKindRepo{
|
||||
store: map[uuid.UUID]*acp.AgentKind{},
|
||||
files: map[string]*acp.ConfigFile{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeAgentKindRepo) CreateAgentKind(_ context.Context, k *acp.AgentKind) (*acp.AgentKind, error) {
|
||||
@@ -81,6 +85,29 @@ func (f *fakeAgentKindRepo) DeleteAgentKind(_ context.Context, id uuid.UUID) err
|
||||
func (f *fakeAgentKindRepo) CountAgentKindUsage(_ context.Context, _ uuid.UUID) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (f *fakeAgentKindRepo) ListConfigFiles(_ context.Context, kindID uuid.UUID) ([]*acp.ConfigFile, error) {
|
||||
out := make([]*acp.ConfigFile, 0)
|
||||
for _, cf := range f.files {
|
||||
if cf.AgentKindID == kindID {
|
||||
cp := *cf
|
||||
out = append(out, &cp)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeAgentKindRepo) UpsertConfigFile(_ context.Context, cf *acp.ConfigFile) (*acp.ConfigFile, error) {
|
||||
cp := *cf
|
||||
f.files[cf.AgentKindID.String()+"/"+cf.RelPath] = &cp
|
||||
return &cp, nil
|
||||
}
|
||||
func (f *fakeAgentKindRepo) DeleteConfigFile(_ context.Context, kindID uuid.UUID, relPath string) (bool, error) {
|
||||
key := kindID.String() + "/" + relPath
|
||||
if _, ok := f.files[key]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
delete(f.files, key)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// 其他方法 panic(不该被 AgentKindService 调用)
|
||||
func (f *fakeAgentKindRepo) InsertSession(context.Context, *acp.Session) (*acp.Session, error) {
|
||||
|
||||
+100
-13
@@ -47,21 +47,90 @@ const (
|
||||
RPCError RPCKind = "error"
|
||||
)
|
||||
|
||||
// ClientType 标识 agent CLI 的客户端类型,决定 spawn 时注入哪个配置目录
|
||||
// 重定向变量(CLAUDE_CONFIG_DIR / CODEX_HOME / GEMINI_CLI_HOME)以及前端
|
||||
// 渲染哪套配置表单。generic 仅注入 HOME。
|
||||
type ClientType string
|
||||
|
||||
const (
|
||||
ClientClaudeCode ClientType = "claude_code"
|
||||
ClientCodex ClientType = "codex"
|
||||
ClientGemini ClientType = "gemini"
|
||||
ClientGeneric ClientType = "generic"
|
||||
)
|
||||
|
||||
// Valid 判定 client_type 是否为已知枚举值。
|
||||
func (t ClientType) Valid() bool {
|
||||
switch t {
|
||||
case ClientClaudeCode, ClientCodex, ClientGemini, ClientGeneric:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ACW 自家 MCP 注入约定:session 创建时签发的 system token 与 MCP 公网地址
|
||||
// 经 extraEnv 注入子进程,同时在 session/new 握手时作为 http MCP server 下发。
|
||||
const (
|
||||
EnvMCPToken = "ACW_MCP_TOKEN"
|
||||
EnvMCPURL = "ACW_MCP_URL"
|
||||
// AcwMcpServerName 是自家 MCP 在 mcpServers 里的保留名,第三方条目不可占用。
|
||||
AcwMcpServerName = "acw"
|
||||
)
|
||||
|
||||
// McpServerType 是第三方 MCP server 的传输类型。stdio 是 ACP 基线(所有 agent
|
||||
// 必须支持);http / sse 需 agent 在 initialize 中声明 mcpCapabilities 才下发。
|
||||
type McpServerType string
|
||||
|
||||
const (
|
||||
McpStdio McpServerType = "stdio"
|
||||
McpHTTP McpServerType = "http"
|
||||
McpSSE McpServerType = "sse"
|
||||
)
|
||||
|
||||
// McpServerSpec 是 admin 在 AgentKind 上配置的一条 MCP server。session/new
|
||||
// 握手时转换为 ACP wire 格式随 mcpServers 下发。Env / Headers 可能含密钥,
|
||||
// 整个列表加密落库(encrypted_mcp_servers);明文仅返回给 admin 编辑。
|
||||
type McpServerSpec struct {
|
||||
Name string `json:"name"`
|
||||
Type McpServerType `json:"type"`
|
||||
Command string `json:"command,omitempty"` // stdio
|
||||
Args []string `json:"args,omitempty"` // stdio
|
||||
Env map[string]string `json:"env,omitempty"` // stdio
|
||||
URL string `json:"url,omitempty"` // http / sse
|
||||
Headers map[string]string `json:"headers,omitempty"` // http / sse
|
||||
}
|
||||
|
||||
// AgentKind 是 admin 注册的 agent 类型记录。EncryptedEnv 是 AES-GCM 密文,
|
||||
// 仅 supervisor spawn 时解密为 map 注入子进程。其他场景不解密、不出 API。
|
||||
// EncryptedMCPServers 同为密文,但明文经 API 返回给 admin 供编辑。
|
||||
type AgentKind struct {
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
DisplayName string
|
||||
Description string
|
||||
BinaryPath string
|
||||
Args []string
|
||||
EncryptedEnv []byte
|
||||
Enabled bool
|
||||
ToolAllowlist []string // 命中的工具调用自动放行;含 "*" 表示放行全部可识别的工具(无法识别名称的调用仍走人工审批)
|
||||
CreatedBy uuid.UUID
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
DisplayName string
|
||||
Description string
|
||||
BinaryPath string
|
||||
Args []string
|
||||
EncryptedEnv []byte
|
||||
Enabled bool
|
||||
ToolAllowlist []string // 命中的工具调用自动放行;含 "*" 表示放行全部可识别的工具(无法识别名称的调用仍走人工审批)
|
||||
ClientType ClientType
|
||||
EncryptedMCPServers []byte
|
||||
CreatedBy uuid.UUID
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// ConfigFile 是 AgentKind 维度的 agent CLI 配置文件(如 .claude/settings.json、
|
||||
// .codex/config.toml)。RelPath 相对受管 home 目录;EncryptedContent 是 AES-GCM
|
||||
// 密文(内容可能含 auth token),spawn 前解密物化到受管目录。
|
||||
type ConfigFile struct {
|
||||
ID uuid.UUID
|
||||
AgentKindID uuid.UUID
|
||||
RelPath string
|
||||
EncryptedContent []byte
|
||||
UpdatedBy uuid.UUID
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Session 是一次 ACP 会话。AgentSessionID 是 agent 侧的 session 标识(来自
|
||||
@@ -114,6 +183,19 @@ type AgentKindService interface {
|
||||
Get(ctx context.Context, c Caller, id uuid.UUID) (*AgentKind, error)
|
||||
Update(ctx context.Context, c Caller, id uuid.UUID, in UpdateAgentKindInput) (*AgentKind, error)
|
||||
Delete(ctx context.Context, c Caller, id uuid.UUID) error
|
||||
|
||||
// 配置文件管理(admin only)。与 env 不同:内容明文返回给 admin 供编辑,
|
||||
// 存储层加密。content 是明文。
|
||||
ListConfigFiles(ctx context.Context, c Caller, kindID uuid.UUID) ([]*ConfigFileContent, error)
|
||||
UpsertConfigFile(ctx context.Context, c Caller, kindID uuid.UUID, relPath, content string) (*ConfigFileContent, error)
|
||||
DeleteConfigFile(ctx context.Context, c Caller, kindID uuid.UUID, relPath string) error
|
||||
}
|
||||
|
||||
// ConfigFileContent 是解密后的配置文件视图(service → handler)。
|
||||
type ConfigFileContent struct {
|
||||
RelPath string
|
||||
Content string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// SessionService 暴露 Session 聚合根的应用服务方法。
|
||||
@@ -143,6 +225,7 @@ type CreateSessionInput struct {
|
||||
}
|
||||
|
||||
// CreateAgentKindInput 携带创建必需字段。Args / Env 可为 nil。
|
||||
// ClientType 为空时默认 generic。
|
||||
type CreateAgentKindInput struct {
|
||||
Name string
|
||||
DisplayName string
|
||||
@@ -152,6 +235,8 @@ type CreateAgentKindInput struct {
|
||||
Env map[string]string
|
||||
Enabled bool
|
||||
ToolAllowlist []string
|
||||
ClientType ClientType
|
||||
MCPServers []McpServerSpec
|
||||
}
|
||||
|
||||
// UpdateAgentKindInput 是 patch 输入。Name 不可改(创建后稳定,引用约束)。
|
||||
@@ -163,7 +248,9 @@ type UpdateAgentKindInput struct {
|
||||
Args []string // nil = 不改;非 nil = 替换
|
||||
Env map[string]string // nil = 不改;非 nil(含空)= 替换
|
||||
Enabled *bool
|
||||
ToolAllowlist []string // nil = 不改;非 nil(含空)= 替换
|
||||
ToolAllowlist []string // nil = 不改;非 nil(含空)= 替换
|
||||
ClientType *ClientType // nil = 不改
|
||||
MCPServers []McpServerSpec // nil = 不改;非 nil(含空)= 替换
|
||||
}
|
||||
|
||||
// PermissionStatus 是 acp_permission_requests.status 枚举。
|
||||
|
||||
+31
-3
@@ -18,9 +18,12 @@ type AgentKindAdminDTO struct {
|
||||
EnvKeys []string `json:"env_keys"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
CreatedBy uuid.UUID `json:"created_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ClientType string `json:"client_type"`
|
||||
// MCPServers 明文返回(admin only,编辑所需);条目可能含 header/env 密钥。
|
||||
MCPServers []McpServerSpec `json:"mcp_servers"`
|
||||
CreatedBy uuid.UUID `json:"created_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AgentKindPublicDTO 是普通用户视图,不暴露 binary_path / args / env_keys。
|
||||
@@ -41,6 +44,8 @@ type CreateAgentKindReq struct {
|
||||
Env map[string]string `json:"env"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ClientType string `json:"client_type"`
|
||||
MCPServers []McpServerSpec `json:"mcp_servers"`
|
||||
}
|
||||
|
||||
type UpdateAgentKindReq struct {
|
||||
@@ -51,6 +56,29 @@ type UpdateAgentKindReq struct {
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
ToolAllowlist []string `json:"tool_allowlist,omitempty"`
|
||||
ClientType *string `json:"client_type,omitempty"`
|
||||
MCPServers []McpServerSpec `json:"mcp_servers,omitempty"` // nil = 不改;非 nil(含空)= 替换
|
||||
}
|
||||
|
||||
// ConfigFileDTO 是 AgentKind 配置文件的对外视图(admin only,content 明文)。
|
||||
type ConfigFileDTO struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
Content string `json:"content"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// UpsertConfigFileReq 是 PUT config-files 请求体。
|
||||
type UpsertConfigFileReq struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func configFileToDTO(f *ConfigFileContent) ConfigFileDTO {
|
||||
return ConfigFileDTO{
|
||||
RelPath: f.RelPath,
|
||||
Content: f.Content,
|
||||
UpdatedAt: f.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// PermissionRequestDTO 是权限请求的对外视图。
|
||||
|
||||
@@ -68,6 +68,11 @@ func (h *Handler) Mount(r chi.Router) {
|
||||
r.Get("/{id}", h.getAgentKind)
|
||||
r.Patch("/{id}", h.updateAgentKind)
|
||||
r.Delete("/{id}", h.deleteAgentKind)
|
||||
// 配置文件管理(admin only,service 层鉴权)。rel_path 含斜杠,
|
||||
// 不放 URL path,统一走 body / query。
|
||||
r.Get("/{id}/config-files", h.listConfigFiles)
|
||||
r.Put("/{id}/config-files", h.upsertConfigFile)
|
||||
r.Delete("/{id}/config-files", h.deleteConfigFile)
|
||||
})
|
||||
r.Route("/api/v1/acp/sessions", func(r chi.Router) {
|
||||
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
|
||||
@@ -209,6 +214,8 @@ func (h *Handler) createAgentKind(w http.ResponseWriter, r *http.Request) {
|
||||
Env: req.Env,
|
||||
Enabled: enabled,
|
||||
ToolAllowlist: req.ToolAllowlist,
|
||||
ClientType: ClientType(req.ClientType),
|
||||
MCPServers: req.MCPServers,
|
||||
}
|
||||
out, err := h.akSvc.Create(r.Context(), c, in)
|
||||
if err != nil {
|
||||
@@ -265,6 +272,11 @@ func (h *Handler) updateAgentKind(w http.ResponseWriter, r *http.Request) {
|
||||
Env: req.Env,
|
||||
Enabled: req.Enabled,
|
||||
ToolAllowlist: req.ToolAllowlist,
|
||||
MCPServers: req.MCPServers,
|
||||
}
|
||||
if req.ClientType != nil {
|
||||
ct := ClientType(*req.ClientType)
|
||||
in.ClientType = &ct
|
||||
}
|
||||
out, err := h.akSvc.Update(r.Context(), c, id, in)
|
||||
if err != nil {
|
||||
@@ -292,6 +304,74 @@ func (h *Handler) deleteAgentKind(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ===== AgentKind 配置文件 endpoints =====
|
||||
|
||||
func (h *Handler) listConfigFiles(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := h.caller(r)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
id, perr := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if perr != nil {
|
||||
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
|
||||
return
|
||||
}
|
||||
files, err := h.akSvc.ListConfigFiles(r.Context(), c, id)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
out := make([]ConfigFileDTO, 0, len(files))
|
||||
for _, f := range files {
|
||||
out = append(out, configFileToDTO(f))
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *Handler) upsertConfigFile(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := h.caller(r)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
id, perr := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if perr != nil {
|
||||
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
|
||||
return
|
||||
}
|
||||
var req UpsertConfigFileReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json"))
|
||||
return
|
||||
}
|
||||
out, err := h.akSvc.UpsertConfigFile(r.Context(), c, id, req.RelPath, req.Content)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, configFileToDTO(out))
|
||||
}
|
||||
|
||||
func (h *Handler) deleteConfigFile(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := h.caller(r)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
id, perr := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if perr != nil {
|
||||
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
|
||||
return
|
||||
}
|
||||
relPath := r.URL.Query().Get("rel_path")
|
||||
if err := h.akSvc.DeleteConfigFile(r.Context(), c, id, relPath); err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ===== Session endpoints =====
|
||||
|
||||
func (h *Handler) listSessions(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -617,6 +697,10 @@ func (h *Handler) agentKindToAdminDTO(k *AgentKind) AgentKindAdminDTO {
|
||||
sort.Strings(envKeys)
|
||||
}
|
||||
}
|
||||
mcpServers := []McpServerSpec{}
|
||||
if servers, err := decryptMCPServers(h.enc, k.EncryptedMCPServers); err == nil {
|
||||
mcpServers = servers
|
||||
}
|
||||
return AgentKindAdminDTO{
|
||||
ID: k.ID,
|
||||
Name: k.Name,
|
||||
@@ -627,6 +711,8 @@ func (h *Handler) agentKindToAdminDTO(k *AgentKind) AgentKindAdminDTO {
|
||||
EnvKeys: envKeys,
|
||||
Enabled: k.Enabled,
|
||||
ToolAllowlist: append([]string(nil), k.ToolAllowlist...),
|
||||
ClientType: string(k.ClientType),
|
||||
MCPServers: mcpServers,
|
||||
CreatedBy: k.CreatedBy,
|
||||
CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
|
||||
@@ -31,6 +31,18 @@ type InitializeResult struct {
|
||||
AuthMethods []any `json:"authMethods,omitempty"`
|
||||
}
|
||||
|
||||
// McpCapabilities 从 agentCapabilities.mcpCapabilities 解析 http/sse 支持声明
|
||||
// (ACP v1:均默认 false;stdio 是基线无需声明)。字段缺失或形状异常按 false 处理。
|
||||
func (r InitializeResult) McpCapabilities() (httpOK, sseOK bool) {
|
||||
caps, ok := r.AgentCapabilities["mcpCapabilities"].(map[string]any)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
httpOK, _ = caps["http"].(bool)
|
||||
sseOK, _ = caps["sse"].(bool)
|
||||
return httpOK, sseOK
|
||||
}
|
||||
|
||||
// BuildInitializeParams returns standard initialize params.
|
||||
func BuildInitializeParams(version string) InitializeParams {
|
||||
return InitializeParams{
|
||||
|
||||
@@ -1,17 +1,56 @@
|
||||
package handlers
|
||||
|
||||
// session/new method types (spec §5.4).
|
||||
// session/new method types (spec §5.4 + ACP v1 schema/v1/schema.json)。
|
||||
//
|
||||
// mcpServers 元素是 untagged union:
|
||||
// - stdio:无 type 判别字段(v1 规定;带 type 会被部分 agent 忽略),
|
||||
// name/command/args/env 全必填(空数组可)
|
||||
// - http / sse:type 为 "http"/"sse" 判别符,name/url/headers 必填
|
||||
//
|
||||
// env / headers 均为 [{name,value}] 数组(非对象)。
|
||||
|
||||
type SessionNewParams struct {
|
||||
Cwd string `json:"cwd"`
|
||||
McpServers []string `json:"mcpServers"` // MVP: no MCP, send empty slice
|
||||
Cwd string `json:"cwd"`
|
||||
McpServers []any `json:"mcpServers"` // McpServerStdio | McpServerHTTP
|
||||
}
|
||||
|
||||
type SessionNewResult struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
}
|
||||
|
||||
// BuildSessionNewParams returns standard session/new params.
|
||||
func BuildSessionNewParams(cwd string) SessionNewParams {
|
||||
return SessionNewParams{Cwd: cwd, McpServers: []string{}}
|
||||
// EnvVariable / HttpHeader 是 ACP wire 的 name-value 对。
|
||||
type EnvVariable struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type HttpHeader struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// McpServerStdio 是 stdio 传输的 MCP server(基线,所有 agent 必须支持)。
|
||||
type McpServerStdio struct {
|
||||
Name string `json:"name"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env []EnvVariable `json:"env"`
|
||||
}
|
||||
|
||||
// McpServerHTTP 是 http / sse 传输的 MCP server。Type 必须为 "http" 或 "sse",
|
||||
// 仅当 agent 在 initialize 中声明对应 mcpCapabilities 时才可下发。
|
||||
type McpServerHTTP struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Headers []HttpHeader `json:"headers"`
|
||||
}
|
||||
|
||||
// BuildSessionNewParams returns standard session/new params.
|
||||
// servers 为 nil 时发送空数组(mcpServers 是协议必填字段)。
|
||||
func BuildSessionNewParams(cwd string, servers []any) SessionNewParams {
|
||||
if servers == nil {
|
||||
servers = []any{}
|
||||
}
|
||||
return SessionNewParams{Cwd: cwd, McpServers: servers}
|
||||
}
|
||||
|
||||
@@ -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,48 +1,50 @@
|
||||
-- name: CreateAgentKind :one
|
||||
INSERT INTO acp_agent_kinds (
|
||||
id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist, client_type, encrypted_mcp_servers
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist;
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers;
|
||||
|
||||
-- name: GetAgentKindByID :one
|
||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
|
||||
FROM acp_agent_kinds
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetAgentKindByName :one
|
||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
|
||||
FROM acp_agent_kinds
|
||||
WHERE name = $1;
|
||||
|
||||
-- name: ListAgentKinds :many
|
||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
|
||||
FROM acp_agent_kinds
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: ListEnabledAgentKinds :many
|
||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
|
||||
FROM acp_agent_kinds
|
||||
WHERE enabled = TRUE
|
||||
ORDER BY name ASC;
|
||||
|
||||
-- name: UpdateAgentKind :one
|
||||
UPDATE acp_agent_kinds
|
||||
SET display_name = $2,
|
||||
description = $3,
|
||||
binary_path = $4,
|
||||
args = $5,
|
||||
encrypted_env = $6,
|
||||
enabled = $7,
|
||||
tool_allowlist = $8,
|
||||
updated_at = now()
|
||||
SET display_name = $2,
|
||||
description = $3,
|
||||
binary_path = $4,
|
||||
args = $5,
|
||||
encrypted_env = $6,
|
||||
enabled = $7,
|
||||
tool_allowlist = $8,
|
||||
client_type = $9,
|
||||
encrypted_mcp_servers = $10,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist;
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers;
|
||||
|
||||
-- name: DeleteAgentKind :exec
|
||||
DELETE FROM acp_agent_kinds WHERE id = $1;
|
||||
|
||||
@@ -115,6 +115,15 @@ func (f *fakeEventRepo) ListEventsSince(context.Context, uuid.UUID, int64, int32
|
||||
func (f *fakeEventRepo) PurgeEventsBefore(context.Context, time.Time) (int64, error) {
|
||||
panic("n/a")
|
||||
}
|
||||
func (f *fakeEventRepo) ListConfigFiles(context.Context, uuid.UUID) ([]*acp.ConfigFile, error) {
|
||||
panic("n/a")
|
||||
}
|
||||
func (f *fakeEventRepo) UpsertConfigFile(context.Context, *acp.ConfigFile) (*acp.ConfigFile, error) {
|
||||
panic("n/a")
|
||||
}
|
||||
func (f *fakeEventRepo) DeleteConfigFile(context.Context, uuid.UUID, string) (bool, error) {
|
||||
panic("n/a")
|
||||
}
|
||||
|
||||
func (f *fakeEventRepo) InTx(context.Context, func(context.Context, pgx.Tx) error) error {
|
||||
panic("n/a")
|
||||
|
||||
+92
-30
@@ -28,6 +28,11 @@ type Repository interface {
|
||||
DeleteAgentKind(ctx context.Context, id uuid.UUID) error
|
||||
CountAgentKindUsage(ctx context.Context, id uuid.UUID) (int64, error)
|
||||
|
||||
// AgentKind 配置文件(内容为密文,加解密在 service / supervisor 层)
|
||||
ListConfigFiles(ctx context.Context, kindID uuid.UUID) ([]*ConfigFile, error)
|
||||
UpsertConfigFile(ctx context.Context, f *ConfigFile) (*ConfigFile, error)
|
||||
DeleteConfigFile(ctx context.Context, kindID uuid.UUID, relPath string) (bool, error)
|
||||
|
||||
// Session
|
||||
InsertSession(ctx context.Context, s *Session) (*Session, error)
|
||||
GetSessionByID(ctx context.Context, id uuid.UUID) (*Session, error)
|
||||
@@ -142,16 +147,18 @@ func pgxNoRowsToErrNotFound(err error, code errs.Code, msg string) error {
|
||||
|
||||
func (r *pgRepo) CreateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind, error) {
|
||||
row, err := r.q.CreateAgentKind(ctx, acpsqlc.CreateAgentKindParams{
|
||||
ID: toPgUUID(k.ID),
|
||||
Name: k.Name,
|
||||
DisplayName: k.DisplayName,
|
||||
Description: k.Description,
|
||||
BinaryPath: k.BinaryPath,
|
||||
Args: k.Args,
|
||||
EncryptedEnv: k.EncryptedEnv,
|
||||
Enabled: k.Enabled,
|
||||
CreatedBy: toPgUUID(k.CreatedBy),
|
||||
ToolAllowlist: normalizeStrSlice(k.ToolAllowlist),
|
||||
ID: toPgUUID(k.ID),
|
||||
Name: k.Name,
|
||||
DisplayName: k.DisplayName,
|
||||
Description: k.Description,
|
||||
BinaryPath: k.BinaryPath,
|
||||
Args: k.Args,
|
||||
EncryptedEnv: k.EncryptedEnv,
|
||||
Enabled: k.Enabled,
|
||||
CreatedBy: toPgUUID(k.CreatedBy),
|
||||
ToolAllowlist: normalizeStrSlice(k.ToolAllowlist),
|
||||
ClientType: string(k.ClientType),
|
||||
EncryptedMcpServers: k.EncryptedMCPServers,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "create agent kind")
|
||||
@@ -201,14 +208,16 @@ func (r *pgRepo) ListEnabledAgentKinds(ctx context.Context) ([]*AgentKind, error
|
||||
|
||||
func (r *pgRepo) UpdateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind, error) {
|
||||
row, err := r.q.UpdateAgentKind(ctx, acpsqlc.UpdateAgentKindParams{
|
||||
ID: toPgUUID(k.ID),
|
||||
DisplayName: k.DisplayName,
|
||||
Description: k.Description,
|
||||
BinaryPath: k.BinaryPath,
|
||||
Args: k.Args,
|
||||
EncryptedEnv: k.EncryptedEnv,
|
||||
Enabled: k.Enabled,
|
||||
ToolAllowlist: normalizeStrSlice(k.ToolAllowlist),
|
||||
ID: toPgUUID(k.ID),
|
||||
DisplayName: k.DisplayName,
|
||||
Description: k.Description,
|
||||
BinaryPath: k.BinaryPath,
|
||||
Args: k.Args,
|
||||
EncryptedEnv: k.EncryptedEnv,
|
||||
Enabled: k.Enabled,
|
||||
ToolAllowlist: normalizeStrSlice(k.ToolAllowlist),
|
||||
ClientType: string(k.ClientType),
|
||||
EncryptedMcpServers: k.EncryptedMCPServers,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, pgxNoRowsToErrNotFound(err, errs.CodeAcpAgentKindNotFound, "agent kind not found")
|
||||
@@ -234,22 +243,75 @@ func (r *pgRepo) CountAgentKindUsage(ctx context.Context, id uuid.UUID) (int64,
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ===== AgentKind 配置文件 =====
|
||||
|
||||
func (r *pgRepo) ListConfigFiles(ctx context.Context, kindID uuid.UUID) ([]*ConfigFile, error) {
|
||||
rows, err := r.q.ListAgentKindConfigFiles(ctx, toPgUUID(kindID))
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "list agent kind config files")
|
||||
}
|
||||
out := make([]*ConfigFile, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, rowToConfigFile(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) UpsertConfigFile(ctx context.Context, f *ConfigFile) (*ConfigFile, error) {
|
||||
row, err := r.q.UpsertAgentKindConfigFile(ctx, acpsqlc.UpsertAgentKindConfigFileParams{
|
||||
ID: toPgUUID(f.ID),
|
||||
AgentKindID: toPgUUID(f.AgentKindID),
|
||||
RelPath: f.RelPath,
|
||||
EncryptedContent: f.EncryptedContent,
|
||||
UpdatedBy: toPgUUID(f.UpdatedBy),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "upsert agent kind config file")
|
||||
}
|
||||
return rowToConfigFile(row), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) DeleteConfigFile(ctx context.Context, kindID uuid.UUID, relPath string) (bool, error) {
|
||||
n, err := r.q.DeleteAgentKindConfigFile(ctx, acpsqlc.DeleteAgentKindConfigFileParams{
|
||||
AgentKindID: toPgUUID(kindID),
|
||||
RelPath: relPath,
|
||||
})
|
||||
if err != nil {
|
||||
return false, errs.Wrap(err, errs.CodeInternal, "delete agent kind config file")
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// ===== row → domain converters =====
|
||||
|
||||
func rowToAgentKind(row acpsqlc.AcpAgentKind) *AgentKind {
|
||||
return &AgentKind{
|
||||
ID: fromPgUUID(row.ID),
|
||||
Name: row.Name,
|
||||
DisplayName: row.DisplayName,
|
||||
Description: row.Description,
|
||||
BinaryPath: row.BinaryPath,
|
||||
Args: row.Args,
|
||||
EncryptedEnv: row.EncryptedEnv,
|
||||
Enabled: row.Enabled,
|
||||
ToolAllowlist: row.ToolAllowlist,
|
||||
CreatedBy: fromPgUUID(row.CreatedBy),
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
ID: fromPgUUID(row.ID),
|
||||
Name: row.Name,
|
||||
DisplayName: row.DisplayName,
|
||||
Description: row.Description,
|
||||
BinaryPath: row.BinaryPath,
|
||||
Args: row.Args,
|
||||
EncryptedEnv: row.EncryptedEnv,
|
||||
Enabled: row.Enabled,
|
||||
ToolAllowlist: row.ToolAllowlist,
|
||||
ClientType: ClientType(row.ClientType),
|
||||
EncryptedMCPServers: row.EncryptedMcpServers,
|
||||
CreatedBy: fromPgUUID(row.CreatedBy),
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
func rowToConfigFile(row acpsqlc.AcpAgentKindConfigFile) *ConfigFile {
|
||||
return &ConfigFile{
|
||||
ID: fromPgUUID(row.ID),
|
||||
AgentKindID: fromPgUUID(row.AgentKindID),
|
||||
RelPath: row.RelPath,
|
||||
EncryptedContent: row.EncryptedContent,
|
||||
UpdatedBy: fromPgUUID(row.UpdatedBy),
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -261,8 +261,8 @@ func (s *sessionService) Create(ctx context.Context, c Caller, in CreateSessionI
|
||||
|
||||
// 9. async spawn(失败时回滚 worktree + revoke MCP token)
|
||||
extraEnv := map[string]string{
|
||||
"ACW_MCP_TOKEN": tokenRes.Plaintext,
|
||||
"ACW_MCP_URL": s.cfg.MCPPublicURL,
|
||||
EnvMCPToken: tokenRes.Plaintext,
|
||||
EnvMCPURL: s.cfg.MCPPublicURL,
|
||||
}
|
||||
go func() {
|
||||
spawnCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
|
||||
@@ -144,6 +144,16 @@ func (r *fakeAcpRepo) InTx(_ context.Context, fn func(context.Context, pgx.Tx) e
|
||||
}
|
||||
func (r *fakeAcpRepo) WithTx(_ pgx.Tx) acp.Repository { return r }
|
||||
|
||||
func (r *fakeAcpRepo) ListConfigFiles(context.Context, uuid.UUID) ([]*acp.ConfigFile, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *fakeAcpRepo) UpsertConfigFile(context.Context, *acp.ConfigFile) (*acp.ConfigFile, error) {
|
||||
panic("n/a")
|
||||
}
|
||||
func (r *fakeAcpRepo) DeleteConfigFile(context.Context, uuid.UUID, string) (bool, error) {
|
||||
panic("n/a")
|
||||
}
|
||||
|
||||
func (r *fakeAcpRepo) InsertSession(_ context.Context, s *acp.Session) (*acp.Session, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: agent_kind_config_files.sql
|
||||
|
||||
package acpsqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const deleteAgentKindConfigFile = `-- name: DeleteAgentKindConfigFile :execrows
|
||||
DELETE FROM acp_agent_kind_config_files
|
||||
WHERE agent_kind_id = $1 AND rel_path = $2
|
||||
`
|
||||
|
||||
type DeleteAgentKindConfigFileParams struct {
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
RelPath string `json:"rel_path"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteAgentKindConfigFile(ctx context.Context, arg DeleteAgentKindConfigFileParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, deleteAgentKindConfigFile, arg.AgentKindID, arg.RelPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const listAgentKindConfigFiles = `-- name: ListAgentKindConfigFiles :many
|
||||
SELECT id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at
|
||||
FROM acp_agent_kind_config_files
|
||||
WHERE agent_kind_id = $1
|
||||
ORDER BY rel_path ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListAgentKindConfigFiles(ctx context.Context, agentKindID pgtype.UUID) ([]AcpAgentKindConfigFile, error) {
|
||||
rows, err := q.db.Query(ctx, listAgentKindConfigFiles, agentKindID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []AcpAgentKindConfigFile
|
||||
for rows.Next() {
|
||||
var i AcpAgentKindConfigFile
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.AgentKindID,
|
||||
&i.RelPath,
|
||||
&i.EncryptedContent,
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertAgentKindConfigFile = `-- name: UpsertAgentKindConfigFile :one
|
||||
INSERT INTO acp_agent_kind_config_files (
|
||||
id, agent_kind_id, rel_path, encrypted_content, updated_by
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (agent_kind_id, rel_path) DO UPDATE
|
||||
SET encrypted_content = EXCLUDED.encrypted_content,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
updated_at = now()
|
||||
RETURNING id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertAgentKindConfigFileParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
RelPath string `json:"rel_path"`
|
||||
EncryptedContent []byte `json:"encrypted_content"`
|
||||
UpdatedBy pgtype.UUID `json:"updated_by"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertAgentKindConfigFile(ctx context.Context, arg UpsertAgentKindConfigFileParams) (AcpAgentKindConfigFile, error) {
|
||||
row := q.db.QueryRow(ctx, upsertAgentKindConfigFile,
|
||||
arg.ID,
|
||||
arg.AgentKindID,
|
||||
arg.RelPath,
|
||||
arg.EncryptedContent,
|
||||
arg.UpdatedBy,
|
||||
)
|
||||
var i AcpAgentKindConfigFile
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.AgentKindID,
|
||||
&i.RelPath,
|
||||
&i.EncryptedContent,
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -24,23 +24,25 @@ func (q *Queries) CountAgentKindUsage(ctx context.Context, agentKindID pgtype.UU
|
||||
|
||||
const createAgentKind = `-- name: CreateAgentKind :one
|
||||
INSERT INTO acp_agent_kinds (
|
||||
id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist, client_type, encrypted_mcp_servers
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
|
||||
`
|
||||
|
||||
type CreateAgentKindParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ClientType string `json:"client_type"`
|
||||
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams) (AcpAgentKind, error) {
|
||||
@@ -55,6 +57,8 @@ func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams
|
||||
arg.Enabled,
|
||||
arg.CreatedBy,
|
||||
arg.ToolAllowlist,
|
||||
arg.ClientType,
|
||||
arg.EncryptedMcpServers,
|
||||
)
|
||||
var i AcpAgentKind
|
||||
err := row.Scan(
|
||||
@@ -70,6 +74,8 @@ func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ToolAllowlist,
|
||||
&i.ClientType,
|
||||
&i.EncryptedMcpServers,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -85,7 +91,7 @@ func (q *Queries) DeleteAgentKind(ctx context.Context, id pgtype.UUID) error {
|
||||
|
||||
const getAgentKindByID = `-- name: GetAgentKindByID :one
|
||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
|
||||
FROM acp_agent_kinds
|
||||
WHERE id = $1
|
||||
`
|
||||
@@ -106,13 +112,15 @@ func (q *Queries) GetAgentKindByID(ctx context.Context, id pgtype.UUID) (AcpAgen
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ToolAllowlist,
|
||||
&i.ClientType,
|
||||
&i.EncryptedMcpServers,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAgentKindByName = `-- name: GetAgentKindByName :one
|
||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
|
||||
FROM acp_agent_kinds
|
||||
WHERE name = $1
|
||||
`
|
||||
@@ -133,13 +141,15 @@ func (q *Queries) GetAgentKindByName(ctx context.Context, name string) (AcpAgent
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ToolAllowlist,
|
||||
&i.ClientType,
|
||||
&i.EncryptedMcpServers,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listAgentKinds = `-- name: ListAgentKinds :many
|
||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
|
||||
FROM acp_agent_kinds
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
@@ -166,6 +176,8 @@ func (q *Queries) ListAgentKinds(ctx context.Context) ([]AcpAgentKind, error) {
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ToolAllowlist,
|
||||
&i.ClientType,
|
||||
&i.EncryptedMcpServers,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -179,7 +191,7 @@ func (q *Queries) ListAgentKinds(ctx context.Context) ([]AcpAgentKind, error) {
|
||||
|
||||
const listEnabledAgentKinds = `-- name: ListEnabledAgentKinds :many
|
||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
|
||||
FROM acp_agent_kinds
|
||||
WHERE enabled = TRUE
|
||||
ORDER BY name ASC
|
||||
@@ -207,6 +219,8 @@ func (q *Queries) ListEnabledAgentKinds(ctx context.Context) ([]AcpAgentKind, er
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ToolAllowlist,
|
||||
&i.ClientType,
|
||||
&i.EncryptedMcpServers,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -220,28 +234,32 @@ func (q *Queries) ListEnabledAgentKinds(ctx context.Context) ([]AcpAgentKind, er
|
||||
|
||||
const updateAgentKind = `-- name: UpdateAgentKind :one
|
||||
UPDATE acp_agent_kinds
|
||||
SET display_name = $2,
|
||||
description = $3,
|
||||
binary_path = $4,
|
||||
args = $5,
|
||||
encrypted_env = $6,
|
||||
enabled = $7,
|
||||
tool_allowlist = $8,
|
||||
updated_at = now()
|
||||
SET display_name = $2,
|
||||
description = $3,
|
||||
binary_path = $4,
|
||||
args = $5,
|
||||
encrypted_env = $6,
|
||||
enabled = $7,
|
||||
tool_allowlist = $8,
|
||||
client_type = $9,
|
||||
encrypted_mcp_servers = $10,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist
|
||||
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers
|
||||
`
|
||||
|
||||
type UpdateAgentKindParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ClientType string `json:"client_type"`
|
||||
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams) (AcpAgentKind, error) {
|
||||
@@ -254,6 +272,8 @@ func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams
|
||||
arg.EncryptedEnv,
|
||||
arg.Enabled,
|
||||
arg.ToolAllowlist,
|
||||
arg.ClientType,
|
||||
arg.EncryptedMcpServers,
|
||||
)
|
||||
var i AcpAgentKind
|
||||
err := row.Scan(
|
||||
@@ -269,6 +289,8 @@ func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ToolAllowlist,
|
||||
&i.ClientType,
|
||||
&i.EncryptedMcpServers,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
+24
-12
@@ -11,18 +11,30 @@ import (
|
||||
)
|
||||
|
||||
type AcpAgentKind struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ClientType string `json:"client_type"`
|
||||
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
|
||||
}
|
||||
|
||||
type AcpAgentKindConfigFile struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
RelPath string `json:"rel_path"`
|
||||
EncryptedContent []byte `json:"encrypted_content"`
|
||||
UpdatedBy pgtype.UUID `json:"updated_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AcpEvent struct {
|
||||
|
||||
@@ -41,6 +41,9 @@ type SupervisorConfig struct {
|
||||
EventMaxPayload int
|
||||
EventTruncateField int
|
||||
ShutdownGrace time.Duration
|
||||
// AgentHomesDir 是 agent CLI 受管 home 的根目录(<DataDir>/agent-homes)。
|
||||
// 空串时跳过配置物化与重定向 env 注入(部分测试)。
|
||||
AgentHomesDir string
|
||||
}
|
||||
|
||||
// Supervisor 是 ACP 子进程总管。
|
||||
@@ -154,10 +157,24 @@ func (s *Supervisor) GetRelay(sid uuid.UUID) *Relay {
|
||||
// handshake 失败时调用 Kill 回滚并返回错误。调用方(SessionService)负责前置的
|
||||
// worktree 占用与 acp_sessions 行 INSERT;本方法仅负责 OS 层资源与协议层握手。
|
||||
func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind, extraEnv map[string]string) (*Process, error) {
|
||||
envMap, err := decryptEnv(s.crypto, kind.EncryptedEnv)
|
||||
// 受管 home:物化 DB 中的配置文件并注入重定向 env(优先级最低,可被
|
||||
// AgentKind env / extraEnv 覆盖)。AgentHomesDir 未配置时跳过(部分测试)。
|
||||
envMap := map[string]string{}
|
||||
if s.cfg.AgentHomesDir != "" {
|
||||
home, err := s.materializeAgentHome(ctx, kind)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("materialize agent home: %w", err)
|
||||
}
|
||||
envMap = agentHomeEnv(home, kind.ClientType)
|
||||
}
|
||||
|
||||
kindEnv, err := decryptEnv(s.crypto, kind.EncryptedEnv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range kindEnv {
|
||||
envMap[k] = v
|
||||
}
|
||||
|
||||
// System override: extraEnv overrides agent_kinds same-name keys (spec §6.1 #11)
|
||||
for k, v := range extraEnv {
|
||||
@@ -251,7 +268,7 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
|
||||
ToolAllowlist: kind.ToolAllowlist,
|
||||
})
|
||||
|
||||
if err := s.handshake(ctx, sess, relay, proc); err != nil {
|
||||
if err := s.handshake(ctx, sess, kind, relay, proc, extraEnv); err != nil {
|
||||
s.log.Error("acp.handshake_failed", "session_id", sess.ID, "err", err.Error())
|
||||
s.Kill(ctx, sess.ID, s.cfg.KillGrace)
|
||||
return nil, err
|
||||
@@ -261,14 +278,32 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
|
||||
|
||||
// handshake 发送 initialize → session/new,并把返回的 agent session id + pid
|
||||
// 持久化到 acp_sessions 行(status → running)。SpawnTimeout 是整个握手的硬上限。
|
||||
func (s *Supervisor) handshake(ctx context.Context, sess *Session, relay *Relay, proc *Process) error {
|
||||
// session/new 同时下发 mcpServers:ACW 自家 MCP(extraEnv 中的 per-session
|
||||
// token)+ AgentKind 第三方 MCP,按 initialize 声明的 mcpCapabilities 过滤。
|
||||
func (s *Supervisor) handshake(ctx context.Context, sess *Session, kind *AgentKind,
|
||||
relay *Relay, proc *Process, extraEnv map[string]string) error {
|
||||
hsCtx, cancel := context.WithTimeout(ctx, s.cfg.SpawnTimeout)
|
||||
defer cancel()
|
||||
|
||||
if _, err := relay.Call(hsCtx, "initialize", handlers.BuildInitializeParams("dev")); err != nil {
|
||||
initResp, err := relay.Call(hsCtx, "initialize", handlers.BuildInitializeParams("dev"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize: %w", err)
|
||||
}
|
||||
resp, err := relay.Call(hsCtx, "session/new", handlers.BuildSessionNewParams(sess.CwdPath))
|
||||
var ir handlers.InitializeResult
|
||||
if err := json.Unmarshal(initResp.Result, &ir); err != nil {
|
||||
// 能力解析失败不阻断握手:按无 http/sse 能力处理(仅基线 stdio 可下发)。
|
||||
s.log.Warn("acp.handshake.parse_initialize_result", "session_id", sess.ID, "err", err.Error())
|
||||
}
|
||||
httpOK, sseOK := ir.McpCapabilities()
|
||||
|
||||
specs, err := decryptMCPServers(s.crypto, kind.EncryptedMCPServers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mcpServers := buildMcpServers(specs, extraEnv[EnvMCPURL], extraEnv[EnvMCPToken],
|
||||
httpOK, sseOK, s.log, sess.ID)
|
||||
|
||||
resp, err := relay.Call(hsCtx, "session/new", handlers.BuildSessionNewParams(sess.CwdPath, mcpServers))
|
||||
if err != nil {
|
||||
return fmt.Errorf("session/new: %w", err)
|
||||
}
|
||||
|
||||
@@ -355,6 +355,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
||||
EventMaxPayload: cfg.Acp.EventMaxPayload,
|
||||
EventTruncateField: cfg.Acp.EventTruncateField,
|
||||
ShutdownGrace: cfg.Acp.ShutdownGrace,
|
||||
AgentHomesDir: filepath.Join(cfg.DataDir, "agent-homes"),
|
||||
},
|
||||
log,
|
||||
)
|
||||
|
||||
@@ -11,18 +11,30 @@ import (
|
||||
)
|
||||
|
||||
type AcpAgentKind struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ClientType string `json:"client_type"`
|
||||
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
|
||||
}
|
||||
|
||||
type AcpAgentKindConfigFile struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
RelPath string `json:"rel_path"`
|
||||
EncryptedContent []byte `json:"encrypted_content"`
|
||||
UpdatedBy pgtype.UUID `json:"updated_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AcpEvent struct {
|
||||
|
||||
@@ -11,18 +11,30 @@ import (
|
||||
)
|
||||
|
||||
type AcpAgentKind struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ClientType string `json:"client_type"`
|
||||
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
|
||||
}
|
||||
|
||||
type AcpAgentKindConfigFile struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
RelPath string `json:"rel_path"`
|
||||
EncryptedContent []byte `json:"encrypted_content"`
|
||||
UpdatedBy pgtype.UUID `json:"updated_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AcpEvent struct {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Commit 表示一条 git log 记录。
|
||||
type Commit struct {
|
||||
Hash string
|
||||
Author string
|
||||
Email string
|
||||
Date time.Time
|
||||
Subject string
|
||||
}
|
||||
|
||||
// Log 返回 dir 的最近 n 条提交历史。n <= 0 时默认 50 条。
|
||||
func (r *DefaultRunner) Log(ctx context.Context, dir string, n int) ([]Commit, error) {
|
||||
ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout)
|
||||
defer cancel()
|
||||
if n <= 0 {
|
||||
n = 50
|
||||
}
|
||||
out, _, err := r.exec(ctx, dir, nil, "log", "--format=%H%x00%an%x00%ae%x00%aI%x00%s%x00", "-n", strconv.Itoa(n))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseLog(out), nil
|
||||
}
|
||||
|
||||
func parseLog(buf []byte) []Commit {
|
||||
if len(buf) == 0 {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(string(buf), "\x00")
|
||||
var commits []Commit
|
||||
// parts 以空字符串结尾(因为 format 最后有 %x00),所以每 5 个字段为一组。
|
||||
for i := 0; i+4 < len(parts); i += 5 {
|
||||
hash := parts[i]
|
||||
if hash == "" {
|
||||
continue
|
||||
}
|
||||
author := parts[i+1]
|
||||
email := parts[i+2]
|
||||
dateStr := parts[i+3]
|
||||
subject := parts[i+4]
|
||||
|
||||
date, _ := time.Parse(time.RFC3339, dateStr)
|
||||
|
||||
commits = append(commits, Commit{
|
||||
Hash: hash,
|
||||
Author: author,
|
||||
Email: email,
|
||||
Date: date,
|
||||
Subject: subject,
|
||||
})
|
||||
}
|
||||
return commits
|
||||
}
|
||||
@@ -33,6 +33,7 @@ type Runner interface {
|
||||
WorktreeList(ctx context.Context, dir string) ([]Worktree, error)
|
||||
ListRemoteBranches(ctx context.Context, dir string, cred *Credential) ([]string, error)
|
||||
Checkout(ctx context.Context, dir, branch string) error
|
||||
Log(ctx context.Context, dir string, n int) ([]Commit, error)
|
||||
}
|
||||
|
||||
// FileStatus 表示 `git status --porcelain=v1` 的一行。
|
||||
|
||||
@@ -11,18 +11,30 @@ import (
|
||||
)
|
||||
|
||||
type AcpAgentKind struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ClientType string `json:"client_type"`
|
||||
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
|
||||
}
|
||||
|
||||
type AcpAgentKindConfigFile struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
RelPath string `json:"rel_path"`
|
||||
EncryptedContent []byte `json:"encrypted_content"`
|
||||
UpdatedBy pgtype.UUID `json:"updated_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AcpEvent struct {
|
||||
|
||||
@@ -11,18 +11,30 @@ import (
|
||||
)
|
||||
|
||||
type AcpAgentKind struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ClientType string `json:"client_type"`
|
||||
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
|
||||
}
|
||||
|
||||
type AcpAgentKindConfigFile struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
RelPath string `json:"rel_path"`
|
||||
EncryptedContent []byte `json:"encrypted_content"`
|
||||
UpdatedBy pgtype.UUID `json:"updated_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AcpEvent struct {
|
||||
|
||||
+24
-12
@@ -11,18 +11,30 @@ import (
|
||||
)
|
||||
|
||||
type AcpAgentKind struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ClientType string `json:"client_type"`
|
||||
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
|
||||
}
|
||||
|
||||
type AcpAgentKindConfigFile struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
RelPath string `json:"rel_path"`
|
||||
EncryptedContent []byte `json:"encrypted_content"`
|
||||
UpdatedBy pgtype.UUID `json:"updated_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AcpEvent struct {
|
||||
|
||||
@@ -105,6 +105,14 @@ func (f *fakeGitOpsSvc) PushOnWorktree(_ context.Context, _ workspace.Caller, wt
|
||||
f.lastTarget = "worktree:" + wtID.String()
|
||||
return nil
|
||||
}
|
||||
func (f *fakeGitOpsSvc) LogOnMain(_ context.Context, _ workspace.Caller, wsID uuid.UUID, _ int) ([]git.Commit, error) {
|
||||
f.lastTarget = "main:" + wsID.String()
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeGitOpsSvc) LogOnWorktree(_ context.Context, _ workspace.Caller, wtID uuid.UUID, _ int) ([]git.Commit, error) {
|
||||
f.lastTarget = "worktree:" + wtID.String()
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// wsTestDeps 在 testDeps 基础上挂一个已存在的 workspace + worktree 服务。
|
||||
func wsTestDeps() (ServerDeps, *fakeWorkspaceSvc, *fakeWorktreeSvc, *fakeGitOpsSvc) {
|
||||
|
||||
@@ -11,18 +11,30 @@ import (
|
||||
)
|
||||
|
||||
type AcpAgentKind struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ClientType string `json:"client_type"`
|
||||
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
|
||||
}
|
||||
|
||||
type AcpAgentKindConfigFile struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
RelPath string `json:"rel_path"`
|
||||
EncryptedContent []byte `json:"encrypted_content"`
|
||||
UpdatedBy pgtype.UUID `json:"updated_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AcpEvent struct {
|
||||
|
||||
+24
-12
@@ -11,18 +11,30 @@ import (
|
||||
)
|
||||
|
||||
type AcpAgentKind struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ClientType string `json:"client_type"`
|
||||
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
|
||||
}
|
||||
|
||||
type AcpAgentKindConfigFile struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
RelPath string `json:"rel_path"`
|
||||
EncryptedContent []byte `json:"encrypted_content"`
|
||||
UpdatedBy pgtype.UUID `json:"updated_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AcpEvent struct {
|
||||
|
||||
@@ -11,18 +11,30 @@ import (
|
||||
)
|
||||
|
||||
type AcpAgentKind struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ClientType string `json:"client_type"`
|
||||
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
|
||||
}
|
||||
|
||||
type AcpAgentKindConfigFile struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
RelPath string `json:"rel_path"`
|
||||
EncryptedContent []byte `json:"encrypted_content"`
|
||||
UpdatedBy pgtype.UUID `json:"updated_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AcpEvent struct {
|
||||
|
||||
@@ -77,12 +77,12 @@ type Workspace struct {
|
||||
|
||||
// Worktree 表示一个支线 worktree(不含 main_path)。
|
||||
type Worktree struct {
|
||||
ID uuid.UUID
|
||||
WorkspaceID uuid.UUID
|
||||
Branch string
|
||||
Path string
|
||||
Status WorktreeStatus
|
||||
ActiveHolder string // "user:<uid>" / "session:<sid>" / 空
|
||||
ID uuid.UUID
|
||||
WorkspaceID uuid.UUID
|
||||
Branch string
|
||||
Path string
|
||||
Status WorktreeStatus
|
||||
ActiveHolder string // "user:<uid>" / "session:<sid>" / 空
|
||||
AcquiredAt *time.Time
|
||||
LastUsedAt time.Time
|
||||
PruneWarningAt *time.Time
|
||||
@@ -167,10 +167,12 @@ type GitOpsService interface {
|
||||
StatusOnMain(ctx context.Context, c Caller, wsID uuid.UUID) ([]git.FileStatus, error)
|
||||
CommitOnMain(ctx context.Context, c Caller, wsID uuid.UUID, in CommitInput) (string, error)
|
||||
PushOnMain(ctx context.Context, c Caller, wsID uuid.UUID) error
|
||||
LogOnMain(ctx context.Context, c Caller, wsID uuid.UUID, n int) ([]git.Commit, error)
|
||||
|
||||
StatusOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID) ([]git.FileStatus, error)
|
||||
CommitOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID, in CommitInput) (string, error)
|
||||
PushOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID) error
|
||||
LogOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID, n int) ([]git.Commit, error)
|
||||
}
|
||||
|
||||
// ===== Input DTO =====
|
||||
|
||||
@@ -119,3 +119,15 @@ type commitResp struct {
|
||||
type branchListResp struct {
|
||||
Branches []string `json:"branches"`
|
||||
}
|
||||
|
||||
type commitLogResp struct {
|
||||
Hash string `json:"hash"`
|
||||
Author string `json:"author"`
|
||||
Email string `json:"email"`
|
||||
Date time.Time `json:"date"`
|
||||
Subject string `json:"subject"`
|
||||
}
|
||||
|
||||
func toCommitLogResp(c git.Commit) commitLogResp {
|
||||
return commitLogResp{Hash: c.Hash, Author: c.Author, Email: c.Email, Date: c.Date, Subject: c.Subject}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,18 @@ func (s *gitOpsService) PushOnMain(ctx context.Context, c Caller, wsID uuid.UUID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *gitOpsService) LogOnMain(ctx context.Context, c Caller, wsID uuid.UUID, n int) ([]git.Commit, error) {
|
||||
ws, err := s.guardWorkspaceRead(ctx, c, wsID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commits, err := s.gitr.Log(ctx, ws.MainPath, n)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeGitCmdFailed, "git log")
|
||||
}
|
||||
return commits, nil
|
||||
}
|
||||
|
||||
// ===== Worktree =====
|
||||
|
||||
func (s *gitOpsService) StatusOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID) ([]git.FileStatus, error) {
|
||||
@@ -133,6 +145,18 @@ func (s *gitOpsService) PushOnWorktree(ctx context.Context, c Caller, wtID uuid.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *gitOpsService) LogOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID, n int) ([]git.Commit, error) {
|
||||
wt, _, err := s.guardWorktreeRead(ctx, c, wtID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commits, err := s.gitr.Log(ctx, wt.Path, n)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeGitCmdFailed, "git log")
|
||||
}
|
||||
return commits, nil
|
||||
}
|
||||
|
||||
// ===== guards =====
|
||||
|
||||
func (s *gitOpsService) guardWorkspaceRead(ctx context.Context, c Caller, wsID uuid.UUID) (*Workspace, error) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
@@ -59,6 +60,7 @@ func (h *Handler) Mount(r chi.Router) {
|
||||
r.Get("/main/status", h.statusOnMain)
|
||||
r.Post("/main/commit", h.commitOnMain)
|
||||
r.Post("/main/push", h.pushOnMain)
|
||||
r.Get("/main/log", h.logOnMain)
|
||||
|
||||
r.Get("/worktrees", h.listWorktrees)
|
||||
r.Post("/worktrees", h.createWorktree)
|
||||
@@ -71,6 +73,7 @@ func (h *Handler) Mount(r chi.Router) {
|
||||
r.Get("/status", h.statusOnWorktree)
|
||||
r.Post("/commit", h.commitOnWorktree)
|
||||
r.Post("/push", h.pushOnWorktree)
|
||||
r.Get("/log", h.logOnWorktree)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -536,3 +539,59 @@ func (h *Handler) pushGeneric(w http.ResponseWriter, r *http.Request, onMain boo
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) logOnMain(w http.ResponseWriter, r *http.Request) {
|
||||
h.logGeneric(w, r, true)
|
||||
}
|
||||
|
||||
func (h *Handler) logOnWorktree(w http.ResponseWriter, r *http.Request) {
|
||||
h.logGeneric(w, r, false)
|
||||
}
|
||||
|
||||
func (h *Handler) logGeneric(w http.ResponseWriter, r *http.Request, onMain bool) {
|
||||
c, err := h.caller(r)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
param := "wsID"
|
||||
if !onMain {
|
||||
param = "wtID"
|
||||
}
|
||||
id, err := parseUUID(chi.URLParam(r, param))
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
n := parseLogN(r)
|
||||
var commits []git.Commit
|
||||
if onMain {
|
||||
commits, err = h.gop.LogOnMain(r.Context(), c, id, n)
|
||||
} else {
|
||||
commits, err = h.gop.LogOnWorktree(r.Context(), c, id, n)
|
||||
}
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
resp := make([]commitLogResp, 0, len(commits))
|
||||
for _, cc := range commits {
|
||||
resp = append(resp, toCommitLogResp(cc))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func parseLogN(r *http.Request) int {
|
||||
nStr := r.URL.Query().Get("n")
|
||||
if nStr == "" {
|
||||
return 50
|
||||
}
|
||||
n, err := strconv.Atoi(nStr)
|
||||
if err != nil || n <= 0 {
|
||||
return 50
|
||||
}
|
||||
if n > 200 {
|
||||
return 200
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -11,18 +11,30 @@ import (
|
||||
)
|
||||
|
||||
type AcpAgentKind struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
Args []string `json:"args"`
|
||||
EncryptedEnv []byte `json:"encrypted_env"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ClientType string `json:"client_type"`
|
||||
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
|
||||
}
|
||||
|
||||
type AcpAgentKindConfigFile struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
RelPath string `json:"rel_path"`
|
||||
EncryptedContent []byte `json:"encrypted_content"`
|
||||
UpdatedBy pgtype.UUID `json:"updated_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AcpEvent struct {
|
||||
|
||||
@@ -226,11 +226,14 @@ func (f *fakeGit) WorktreeRemove(_ context.Context, _, _ string) error
|
||||
func (f *fakeGit) WorktreeList(_ context.Context, _ string) ([]git.Worktree, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeGit) FetchRemoteBranch(_ context.Context, _, _ string, _ *git.Credential) error { return nil }
|
||||
func (f *fakeGit) Checkout(_ context.Context, _, _ string) error { return nil }
|
||||
func (f *fakeGit) FetchRemoteBranch(_ context.Context, _, _ string, _ *git.Credential) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeGit) Checkout(_ context.Context, _, _ string) error { return nil }
|
||||
func (f *fakeGit) ListRemoteBranches(_ context.Context, _ string, _ *git.Credential) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeGit) Log(_ context.Context, _ string, _ int) ([]git.Commit, error) { return nil, nil }
|
||||
|
||||
// trackGit 包装 git.Runner,记录 FetchRemoteBranch / Checkout 调用以供断言。
|
||||
// 嵌入 Runner 接口本身可避免为每个方法重复转发。
|
||||
|
||||
@@ -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",
|
||||
"naive-ui": "^2.44.1",
|
||||
"pinia": "^3.0.4",
|
||||
"smol-toml": "^1.6.1",
|
||||
"vue": "^3.5.33",
|
||||
"vue-router": "^5.0.6"
|
||||
},
|
||||
|
||||
Generated
+9
@@ -20,6 +20,9 @@ importers:
|
||||
pinia:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3))
|
||||
smol-toml:
|
||||
specifier: ^1.6.1
|
||||
version: 1.6.1
|
||||
vue:
|
||||
specifier: ^3.5.33
|
||||
version: 3.5.33(typescript@6.0.3)
|
||||
@@ -1364,6 +1367,10 @@ packages:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
smol-toml@1.6.1:
|
||||
resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2905,6 +2912,8 @@ snapshots:
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
smol-toml@1.6.1: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import { request } from './client'
|
||||
|
||||
// AgentClientType mirrors backend acp.ClientType: decides which config-dir
|
||||
// redirect env var is injected at spawn and which config form is rendered.
|
||||
export type AgentClientType = 'claude_code' | 'codex' | 'gemini' | 'generic'
|
||||
|
||||
// McpServerSpec mirrors backend acp.McpServerSpec. Delivered to the agent via
|
||||
// ACP session/new alongside the built-in ACW MCP server (per-session token).
|
||||
// stdio is the ACP baseline; http/sse entries are skipped for agents that do
|
||||
// not declare the matching mcpCapabilities. Name "acw" is reserved.
|
||||
export interface McpServerSpec {
|
||||
name: string
|
||||
type: 'stdio' | 'http' | 'sse'
|
||||
command?: string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
url?: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
// Admin DTO from `internal/acp/dto.go: AgentKindAdminDTO`.
|
||||
export interface AgentKindAdmin {
|
||||
id: string
|
||||
@@ -11,6 +29,8 @@ export interface AgentKindAdmin {
|
||||
env_keys: string[]
|
||||
enabled: boolean
|
||||
tool_allowlist: string[]
|
||||
client_type: AgentClientType
|
||||
mcp_servers: McpServerSpec[]
|
||||
created_by: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -38,6 +58,8 @@ export interface CreateAgentKindReq {
|
||||
env?: Record<string, string>
|
||||
enabled?: boolean
|
||||
tool_allowlist?: string[]
|
||||
client_type?: AgentClientType
|
||||
mcp_servers?: McpServerSpec[]
|
||||
}
|
||||
|
||||
export interface UpdateAgentKindReq {
|
||||
@@ -48,6 +70,15 @@ export interface UpdateAgentKindReq {
|
||||
env?: Record<string, string>
|
||||
enabled?: boolean
|
||||
tool_allowlist?: string[]
|
||||
client_type?: AgentClientType
|
||||
mcp_servers?: McpServerSpec[]
|
||||
}
|
||||
|
||||
// AgentConfigFile mirrors backend ConfigFileDTO (admin only, plaintext content).
|
||||
export interface AgentConfigFile {
|
||||
rel_path: string
|
||||
content: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// PermissionRequest mirrors backend PermissionRequestDTO.
|
||||
@@ -126,6 +157,21 @@ export const acpApi = {
|
||||
remove: (id: string) =>
|
||||
request<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: {
|
||||
list: (opts: { all?: boolean; requirement_id?: string } = {}) => {
|
||||
const q = new URLSearchParams()
|
||||
|
||||
@@ -94,3 +94,11 @@ export interface CommitBody {
|
||||
export interface CommitResp {
|
||||
sha: string
|
||||
}
|
||||
|
||||
export interface CommitLog {
|
||||
hash: string
|
||||
author: string
|
||||
email: string
|
||||
date: string
|
||||
subject: string
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
CreateWorktreeBody,
|
||||
CommitBody,
|
||||
CommitResp,
|
||||
CommitLog,
|
||||
} from './types'
|
||||
|
||||
const enc = (v: string) => encodeURIComponent(v)
|
||||
@@ -63,10 +64,14 @@ export const workspacesApi = {
|
||||
request<CommitResp>(`/api/v1/workspaces/${enc(wsID)}/main/commit`, { method: 'POST', body }),
|
||||
pushOnMain: (wsID: string) =>
|
||||
request<void>(`/api/v1/workspaces/${enc(wsID)}/main/push`, { method: 'POST' }),
|
||||
logOnMain: (wsID: string, n = 50) =>
|
||||
request<CommitLog[]>(`/api/v1/workspaces/${enc(wsID)}/main/log?n=${n}`),
|
||||
statusOnWorktree: (wtID: string) =>
|
||||
request<FileStatus[]>(`/api/v1/worktrees/${enc(wtID)}/status`),
|
||||
commitOnWorktree: (wtID: string, body: CommitBody) =>
|
||||
request<CommitResp>(`/api/v1/worktrees/${enc(wtID)}/commit`, { method: 'POST', body }),
|
||||
pushOnWorktree: (wtID: string) =>
|
||||
request<void>(`/api/v1/worktrees/${enc(wtID)}/push`, { method: 'POST' }),
|
||||
logOnWorktree: (wtID: string, n = 50) =>
|
||||
request<CommitLog[]>(`/api/v1/worktrees/${enc(wtID)}/log?n=${n}`),
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
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<{
|
||||
modelValue: {
|
||||
@@ -12,11 +13,20 @@ const props = defineProps<{
|
||||
env: Record<string, string>
|
||||
enabled: boolean
|
||||
tool_allowlist: string[]
|
||||
client_type: AgentClientType
|
||||
mcp_servers: McpServerSpec[]
|
||||
}
|
||||
isEdit: boolean
|
||||
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<{
|
||||
(e: 'update:modelValue', value: typeof props.modelValue): void
|
||||
(e: 'submit'): void
|
||||
@@ -72,6 +82,109 @@ function addEnv() {
|
||||
function removeEnv(idx: number) {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -108,6 +221,16 @@ function removeEnv(idx: number) {
|
||||
:autosize="{ minRows: 2, maxRows: 6 }"
|
||||
/>
|
||||
</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>
|
||||
<label class="block text-sm font-medium mb-1">Binary Path</label>
|
||||
<NInput
|
||||
@@ -176,6 +299,78 @@ function removeEnv(idx: number) {
|
||||
placeholder="fs/read_text_file safe.tool"
|
||||
/>
|
||||
</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">
|
||||
<NCheckbox v-model:checked="local.enabled">
|
||||
Enabled
|
||||
|
||||
@@ -3,8 +3,9 @@ import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import AgentKindForm from '@/components/acp/AgentKindForm.vue'
|
||||
import AgentConfigFilesPanel from '@/components/acp/AgentConfigFilesPanel.vue'
|
||||
import { useAcpStore } from '@/stores/acp'
|
||||
import type { UpdateAgentKindReq } from '@/api/acp'
|
||||
import type { AgentClientType, McpServerSpec, UpdateAgentKindReq } from '@/api/acp'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -22,6 +23,8 @@ const form = ref({
|
||||
env: {} as Record<string, string>,
|
||||
enabled: true,
|
||||
tool_allowlist: [] as string[],
|
||||
client_type: 'generic' as AgentClientType,
|
||||
mcp_servers: [] as McpServerSpec[],
|
||||
})
|
||||
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.
|
||||
enabled: k.enabled,
|
||||
tool_allowlist: [...(k.tool_allowlist ?? [])],
|
||||
client_type: k.client_type ?? 'generic',
|
||||
mcp_servers: [...(k.mcp_servers ?? [])],
|
||||
}
|
||||
existingEnvKeys.value = [...k.env_keys]
|
||||
}
|
||||
@@ -52,6 +57,9 @@ async function submit() {
|
||||
args: form.value.args,
|
||||
enabled: form.value.enabled,
|
||||
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
|
||||
// wipe existing env on the backend (per service patch semantics).
|
||||
@@ -67,6 +75,8 @@ async function submit() {
|
||||
env: form.value.env,
|
||||
enabled: form.value.enabled,
|
||||
tool_allowlist: form.value.tool_allowlist,
|
||||
client_type: form.value.client_type,
|
||||
mcp_servers: form.value.mcp_servers,
|
||||
})
|
||||
}
|
||||
router.push('/admin/acp/agent-kinds')
|
||||
@@ -89,6 +99,21 @@ async function submit() {
|
||||
@submit="submit"
|
||||
@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>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
@@ -34,6 +34,15 @@
|
||||
:ws="store.current"
|
||||
/>
|
||||
</NTabPane>
|
||||
<NTabPane
|
||||
name="commits"
|
||||
tab="Commits"
|
||||
>
|
||||
<CommitHistoryTab
|
||||
v-if="store.current"
|
||||
:ws="store.current"
|
||||
/>
|
||||
</NTabPane>
|
||||
<NTabPane
|
||||
name="runs"
|
||||
tab="Run"
|
||||
@@ -66,13 +75,14 @@ import { useWorkspacesStore } from '@/stores/workspaces'
|
||||
import SyncBar from './components/SyncBar.vue'
|
||||
import WorktreesTab from './components/WorktreesTab.vue'
|
||||
import MainTab from './components/MainTab.vue'
|
||||
import CommitHistoryTab from './components/CommitHistoryTab.vue'
|
||||
import SettingsTab from './components/SettingsTab.vue'
|
||||
import RunsTab from './components/RunsTab.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useWorkspacesStore()
|
||||
const msg = useMessage()
|
||||
const tab = ref<'worktrees' | 'main' | 'settings' | 'runs'>('worktrees')
|
||||
const tab = ref<'worktrees' | 'main' | 'commits' | 'settings' | 'runs'>('worktrees')
|
||||
|
||||
const projectSlug = computed(() => route.params.slug as string)
|
||||
const wsSlug = computed(() => route.params.wsSlug as string)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- 目标选择:main 或某个 worktree -->
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-500">查看目标</span>
|
||||
<NSelect
|
||||
v-model:value="target"
|
||||
:options="targetOptions"
|
||||
size="small"
|
||||
class="w-64"
|
||||
/>
|
||||
<NButton
|
||||
size="small"
|
||||
:loading="loading"
|
||||
@click="refresh"
|
||||
>
|
||||
刷新
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="commits"
|
||||
:loading="loading"
|
||||
size="small"
|
||||
:scroll-x="800"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref, watch } from 'vue'
|
||||
import { NButton, NDataTable, NSelect, NTag, NTooltip } from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
|
||||
import { workspacesApi } from '@/api/workspaces'
|
||||
import { useWorkspacesStore } from '@/stores/workspaces'
|
||||
import type { CommitLog, Workspace } from '@/api/types'
|
||||
|
||||
const props = defineProps<{ ws: Workspace }>()
|
||||
const store = useWorkspacesStore()
|
||||
|
||||
const target = ref<string>('main')
|
||||
const commits = ref<CommitLog[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const targetOptions = computed(() => {
|
||||
const opts = [{ label: `Main (${props.ws.default_branch})`, value: 'main' }]
|
||||
for (const wt of store.worktrees) {
|
||||
opts.push({ label: wt.branch, value: wt.id })
|
||||
}
|
||||
return opts
|
||||
})
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function shortHash(hash: string) {
|
||||
return hash.slice(0, 8)
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<CommitLog>>(() => [
|
||||
{
|
||||
title: 'Commit',
|
||||
key: 'hash',
|
||||
width: 100,
|
||||
fixed: 'left',
|
||||
render(row: CommitLog) {
|
||||
return h(
|
||||
NTag,
|
||||
{ size: 'tiny', type: 'default' },
|
||||
{ default: () => shortHash(row.hash) },
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '提交信息',
|
||||
key: 'subject',
|
||||
ellipsis: { tooltip: true },
|
||||
},
|
||||
{
|
||||
title: '作者',
|
||||
key: 'author',
|
||||
width: 140,
|
||||
render(row: CommitLog) {
|
||||
return h(
|
||||
NTooltip,
|
||||
{},
|
||||
{
|
||||
trigger: () => h('span', row.author),
|
||||
default: () => row.email,
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
key: 'date',
|
||||
width: 160,
|
||||
render(row: CommitLog) {
|
||||
return h('span', { class: 'text-xs text-gray-500' }, formatDate(row.date))
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (target.value === 'main') {
|
||||
commits.value = await workspacesApi.logOnMain(props.ws.id)
|
||||
} else {
|
||||
commits.value = await workspacesApi.logOnWorktree(target.value)
|
||||
}
|
||||
} catch {
|
||||
commits.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(target, refresh)
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user