// agentkind_configfiles.go 实现 AgentKind 配置文件管理(admin only)。 // // 配置文件是 agent CLI 自身的配置(如 .claude/settings.json、.codex/config.toml), // rel_path 相对受管 home 目录,spawn 前由 supervisor 物化到 // // 并通过 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 }