You've already forked agentic-coding-workflow
ACP / MCP
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user