Files
2026-06-12 11:44:19 +08:00

134 lines
4.6 KiB
Go

// 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
}