package domain import ( "fmt" "path/filepath" "reflect" "strings" ) // ProjectConfig is the typed per-project configuration — the SQLite twin of the // legacy agent-orchestrator.yaml `projects.` block. It is persisted as one // JSON blob per project and resolved at spawn. Each field is typed and // validated; there is no free-form map. // // Only fields with a live consumer are modeled: DefaultBranch, Env, Symlinks, // PostCreate, AgentConfig, and the role overrides are consumed at spawn; // SessionPrefix feeds the display prefix. Settings whose consumers do not yet // exist (tracker/SCM per-project config, prompt rules) are intentionally absent // and land in focused follow-up PRs alongside the code that reads them. type ProjectConfig struct { // DefaultBranch is the base branch new session worktrees are created from. DefaultBranch string `json:"defaultBranch,omitempty"` // SessionPrefix overrides the displayed session-id prefix. SessionPrefix string `json:"sessionPrefix,omitempty"` // Env are extra environment variables forwarded into worker session // runtimes. AO-internal vars (AO_SESSION, AO_PROJECT_ID, …) always win. Env map[string]string `json:"env,omitempty"` // Symlinks are repo-relative paths symlinked into each session workspace. Symlinks []string `json:"symlinks,omitempty"` // PostCreate are shell commands run in the workspace after it is created. PostCreate []string `json:"postCreate,omitempty"` // AgentConfig is the default agent config for the project. AgentConfig AgentConfig `json:"agentConfig,omitempty"` // Worker and Orchestrator are role-specific harness/agent-config overrides. Worker RoleOverride `json:"worker,omitempty"` Orchestrator RoleOverride `json:"orchestrator,omitempty"` // Reviewers names the agent(s) that review a worker's PR when a review is // triggered. It is configured independently of the Worker override; an empty // list falls back to the worker's own harness (see ResolveReviewerHarness). Reviewers []ReviewerConfig `json:"reviewers,omitempty"` } // ReviewerConfig names one reviewer agent by harness. The harness is drawn from // the reviewer vocabulary (ReviewerHarness), which is distinct from the worker // AgentHarness set. type ReviewerConfig struct { Harness ReviewerHarness `json:"harness"` } // FallbackReviewerHarness is the reviewer used when a project configures none // and the worker's harness is not itself a supported reviewer. const FallbackReviewerHarness = ReviewerClaudeCode // ResolveReviewerHarness picks the reviewer harness for a worker. A configured // reviewer wins; otherwise it reuses the worker's own harness when that harness // is also a supported reviewer, falling back to claude-code. func (c ProjectConfig) ResolveReviewerHarness(workerHarness AgentHarness) ReviewerHarness { if len(c.Reviewers) > 0 { return c.Reviewers[0].Harness } if h := ReviewerHarness(workerHarness); h.IsKnown() { return h } return FallbackReviewerHarness } // RoleOverride overrides the harness and/or agent config for a session role. type RoleOverride struct { Harness AgentHarness `json:"agent,omitempty"` AgentConfig AgentConfig `json:"agentConfig,omitempty"` } // DefaultBranchName is the base branch used when a project configures none. const DefaultBranchName = "main" // DefaultProjectConfig returns the config a project has when it sets nothing: // branch "main". Every other field defaults to its zero value (no // env/symlinks/post-create, agent + role defaults). func DefaultProjectConfig() ProjectConfig { return ProjectConfig{ DefaultBranch: DefaultBranchName, } } // WithDefaults overlays DefaultProjectConfig onto c, filling only fields the // project left unset. A set field is always preserved. func (c ProjectConfig) WithDefaults() ProjectConfig { def := DefaultProjectConfig() if c.DefaultBranch == "" { c.DefaultBranch = def.DefaultBranch } return c } // IsZero reports whether the config carries no settings, so storage can persist // SQL NULL and resolution can skip an empty config. func (c ProjectConfig) IsZero() bool { return reflect.DeepEqual(c, ProjectConfig{}) } // Validate rejects values outside the typed vocabulary so a bad config is // refused when it is set (CLI/API) rather than surfacing at spawn. func (c ProjectConfig) Validate() error { if err := c.AgentConfig.Validate(); err != nil { return err } if err := validateNameComponent("sessionPrefix", c.SessionPrefix); err != nil { return err } for role, ro := range map[string]RoleOverride{"worker": c.Worker, "orchestrator": c.Orchestrator} { if ro.Harness != "" && !ro.Harness.IsKnown() { return fmt.Errorf("%s.agent: unknown harness %q", role, ro.Harness) } if err := ro.AgentConfig.Validate(); err != nil { return fmt.Errorf("%s.%w", role, err) } } for _, s := range c.Symlinks { if err := validateRepoRelative(s); err != nil { return fmt.Errorf("symlink %q: %w", s, err) } } for i, rv := range c.Reviewers { if !rv.Harness.IsKnown() { return fmt.Errorf("reviewers[%d].harness: unknown harness %q", i, rv.Harness) } } return nil } func validateNameComponent(name, value string) error { trimmed := strings.TrimSpace(value) if trimmed == "" { return nil } if strings.ContainsAny(trimmed, `/\`) || trimmed == "." || trimmed == ".." { return fmt.Errorf("%s: must not contain path separators or traversal components", name) } return nil } // validateRepoRelative refuses paths that would let a project config escape // its repo root: absolute paths and any ".." segment (before or after Clean). // The same guard runs at spawn time as defense-in-depth, but enforcing it here // rejects bad config when it is set rather than at every later spawn. func validateRepoRelative(p string) error { trimmed := strings.TrimSpace(p) if trimmed == "" { return nil } if filepath.IsAbs(trimmed) || strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, `\`) { return fmt.Errorf("path must be repo-relative and must not escape the project root") } clean := filepath.Clean(trimmed) if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { return fmt.Errorf("path must be repo-relative and must not escape the project root") } for _, seg := range strings.Split(filepath.ToSlash(clean), "/") { if seg == ".." { return fmt.Errorf("path must be repo-relative and must not escape the project root") } } return nil }