feat(config): persist per-project agent config and resolve it at spawn (#154)

* feat(config): persist per-project agent config and resolve it at spawn

Each project can now carry its own agent config (model, permissions,
adapter-specific keys) that survives daemon restart and is resolved into
the launch command when a session spawns.

- storage: add nullable projects.agent_config JSON column (migration 0008);
  marshal/unmarshal in the store so the domain carries map[string]any
- resolution: session manager loads the project row and populates
  LaunchConfig.Config before GetLaunchCommand
- validation: claude-code declares a ConfigSpec (model, permissions) and
  rejects unknown keys / bad types / bad enums at spawn; it applies the
  model override and config-driven permission mode (explicit Permissions
  still wins)
- surface: PUT /projects/{id}/agent-config + `ao project set-config`
  (--set/--config-json/--clear), config shown in `ao project get`

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(claudecode): validate string-list/required config keys and unhandled types

Address review on per-project agent config validation:
- handle ConfigFieldStringList (list of strings) explicitly
- reject unhandled ConfigFieldType via a default case rather than
  silently passing
- enforce Required fields are present

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(config): make per-project agent config a typed struct

Replace the free-form map[string]any agent config with a typed
domain.AgentConfig{Model, Permissions} so values are validated when set
(CLI/API) instead of silently dropped at spawn, and the OpenAPI/TS schema
and UI get real typed fields.

- domain: AgentConfig struct + Validate(); PermissionMode moves to domain
  and ports re-exports it as a type alias (zero adapter churn)
- storage: marshal/unmarshal the typed struct (IsZero → SQL NULL)
- service: validate on Add and SetAgentConfig; read-model exposes a typed
  *AgentConfig
- claudecode: read typed cfg.Config.Model/.Permissions; drop the
  map/spec-based validateConfig in favor of the typed Validate()
- cli: typed `ao project set-config --model/--permission/--clear`
- docs: add docs/design/per-project-config.md blueprint sequencing the
  remaining # Projects fields toward fully typed per-project config

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(config): full typed per-project ProjectConfig (store, resolve, surface)

Expand per-project config from agentConfig-only to the full legacy
`projects.<id>` surface, modeled as one typed domain.ProjectConfig
persisted in a single projects.config JSON column.

Wired end-to-end at spawn:
- defaultBranch  → base branch for the session worktree (ports.WorkspaceConfig.BaseBranch)
- env            → merged into the runtime env (AO-internal vars still win)
- symlinks       → repo files linked into the workspace
- postCreate     → commands run in the workspace (OS-agnostic shell)
- agentRules / agentRulesFile / orchestratorRules → merged into the prompt
- worker/orchestrator role overrides → harness + agent-config resolution

Stored + validated + surfaced now, consumption deferred (no consumer yet):
tracker, scm(+webhook), opencodeIssueSessionStrategy; sessionPrefix feeds
the display prefix only (session-id generation unchanged).

Validation lives on domain.ProjectConfig.Validate() and runs when config is
set (CLI/API). PermissionMode/AgentConfig stay typed; harness names validated
via domain.AgentHarness.IsKnown().

Surface: PUT /projects/{id}/config (replaces /agent-config) + typed
`ao project set-config` flags (--default-branch/--env/--symlink/--post-create/
--agent-rules/--worker-agent/… or --config-json). OpenAPI + TS regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(lint): tighten symlink dir perms to 0o750 (gosec G301)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(config): centralize default project config + tests

Add domain.DefaultProjectConfig / ProjectConfig.WithDefaults with a single
DefaultBranchName ("main") source of truth, replacing the literal "main"
scattered in the read-model and the gitworktree adapter. Unconfigured
projects now resolve the default branch through one path; every other field
defaults to its zero value.

Tests: defaults present for all fields (DefaultProjectConfig/WithDefaults),
and an unconfigured project reports the default branch + derived session
prefix while omitting the empty config object.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(config): encode documented defaults (branch=main, tracker=github)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(config): fail-safe paths for missing/corrupt per-project config

Address review on default-config / fail-safe spawning:
- projectRules: a missing AgentRulesFile is optional context, skipped
  rather than aborting every spawn (only a real read error surfaces)
- store: a corrupt config JSON column degrades to a zero config instead
  of failing GetProject/ListProjects/FindProjectByPath for that row
- restore: re-apply the project's resolved AgentConfig so a configured
  model/permissions carry across a restore (matches fresh spawn)

Tests: missing rules file skips, corrupt config degrades to zero, restore
applies the project agent config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(config): trim per-project config to consumer-backed fields

Drop config that has no live consumer yet, so this PR lands only the
fields actually read at spawn/display:

- Remove prompt rules (agentRules, agentRulesFile, orchestratorRules)
  from ProjectConfig. Project/agent instructions belong on the system
  prompt path or repo-local AGENTS.md, not another rules family.
- Remove future-only integration config with no consumer: tracker, scm,
  scm.webhook, and opencodeIssueSessionStrategy (plus their types,
  constants, the github tracker default, CLI flags, and spec schemas).
  These return in focused PRs alongside the code that reads them.

Kept: defaultBranch, sessionPrefix, env, symlinks, postCreate,
agentConfig (model/permissions), and worker/orchestrator role
overrides. Cross-agent model/permissions support stays follow-up (#157).

Regenerated openapi.yaml + frontend schema.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(config): reject unknown config JSON keys; confine symlink paths

Two review hardenings on the now-trimmed per-project config surface:

- Project add/set-config endpoints decode with DisallowUnknownFields, so
  a misspelled or removed config field surfaces as a clear 400 instead
  of being silently dropped. Locks the removals from e213b68 (and any
  future trims) at the API gate. Covered by new controllers test.
- applySymlinks now refuses absolute paths and any ".." segment via a
  safeRelPath guard, so a project config cannot escape the project or
  workspace tree via a malicious symlinks entry. Covered by new
  session_manager test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(config): reject symlink path traversal at config write time

greptile flagged ProjectConfig.Symlinks as a write-time path-traversal
gap on PR #154 — the runtime guard in applySymlinks catches a malicious
entry on every spawn, but the config itself accepted it. Move the check
into ProjectConfig.Validate so a bad symlinks entry surfaces as
INVALID_PROJECT_CONFIG when set (CLI/API) instead of silently sitting in
the row until the next spawn. The runtime guard stays as
defense-in-depth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: harshitsinghbhandari <24b4506@iitb.ac.in>
This commit is contained in:
neversettle 2026-06-08 21:35:29 +05:30 committed by GitHub
parent 33a1ee6df9
commit 7698c24931
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1666 additions and 170 deletions

View File

@ -74,13 +74,37 @@ func (p *Plugin) Manifest() adapters.Manifest {
}
}
// GetConfigSpec reports the agent-specific config keys. Claude Code exposes
// none yet.
// permissionConfigEnum lists the permission modes the "permissions" config key
// accepts. It mirrors the ports.PermissionMode constants so a project's stored
// config validates against the same vocabulary the launch command maps.
var permissionConfigEnum = []string{
string(ports.PermissionModeDefault),
string(ports.PermissionModeAcceptEdits),
string(ports.PermissionModeAuto),
string(ports.PermissionModeBypassPermissions),
}
// GetConfigSpec reports the per-project agent config keys Claude Code
// understands: a model override and a starting permission mode.
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
if err := ctx.Err(); err != nil {
return ports.ConfigSpec{}, err
}
return ports.ConfigSpec{}, nil
return ports.ConfigSpec{
Fields: []ports.ConfigField{
{
Key: "model",
Type: ports.ConfigFieldString,
Description: "Model override passed to `claude --model` (e.g. claude-opus-4-5).",
},
{
Key: "permissions",
Type: ports.ConfigFieldEnum,
Description: "Starting permission mode.",
Enum: permissionConfigEnum,
},
},
}, nil
}
// GetLaunchCommand builds the argv to start an interactive Claude Code
@ -103,6 +127,12 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
// The prompt is passed after `--` so a prompt beginning with "-" is not
// mistaken for a flag.
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
// Defense-in-depth: the project service validates on write, but re-check
// here so a config written by any other path can't launch a bad command.
if err := cfg.Config.Validate(); err != nil {
return nil, fmt.Errorf("claude-code: %w", err)
}
binary, err := p.claudeBinary(ctx)
if err != nil {
return nil, err
@ -112,7 +142,18 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
if cfg.SessionID != "" {
cmd = append(cmd, "--session-id", claudeSessionUUID(cfg.SessionID))
}
appendPermissionFlags(&cmd, cfg.Permissions)
// A project's configured permissions drive the starting mode; the explicit
// LaunchConfig.Permissions wins when set so a per-spawn override still takes
// precedence over the stored project default.
permissions := cfg.Permissions
if permissions == "" {
permissions = cfg.Config.Permissions
}
appendPermissionFlags(&cmd, permissions)
if model := strings.TrimSpace(cfg.Config.Model); model != "" {
cmd = append(cmd, "--model", model)
}
systemPrompt, err := resolveSystemPrompt(cfg)
if err != nil {

View File

@ -422,6 +422,48 @@ func TestGetRestoreCommandFalseWithoutSessionID(t *testing.T) {
}
}
func TestGetLaunchCommandAppliesAgentConfig(t *testing.T) {
p := &Plugin{resolvedBinary: "claude"}
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Config: ports.AgentConfig{
Model: "claude-opus-4-5",
Permissions: ports.PermissionModeAcceptEdits,
},
})
if err != nil {
t.Fatal(err)
}
if !containsSubsequence(cmd, []string{"--model", "claude-opus-4-5"}) {
t.Fatalf("command %#v missing --model flag", cmd)
}
if !containsSubsequence(cmd, []string{"--permission-mode", "acceptEdits"}) {
t.Fatalf("command %#v missing config-driven permission mode", cmd)
}
}
func TestGetLaunchCommandExplicitPermissionsOverrideConfig(t *testing.T) {
p := &Plugin{resolvedBinary: "claude"}
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Permissions: ports.PermissionModeBypassPermissions,
Config: ports.AgentConfig{Permissions: ports.PermissionModeAcceptEdits},
})
if err != nil {
t.Fatal(err)
}
if !containsSubsequence(cmd, []string{"--permission-mode", "bypassPermissions"}) {
t.Fatalf("explicit Permissions should win; got %#v", cmd)
}
}
func TestGetLaunchCommandRejectsInvalidConfig(t *testing.T) {
p := &Plugin{resolvedBinary: "claude"}
if _, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Config: ports.AgentConfig{Permissions: "yolo"},
}); err == nil {
t.Fatal("expected error for invalid permission mode")
}
}
func TestManifestID(t *testing.T) {
if got := New().Manifest().ID; got != "claude-code" {
t.Fatalf("manifest id = %q, want claude-code", got)

View File

@ -15,7 +15,9 @@ import (
const (
defaultGitBinary = "git"
defaultBranch = "main"
// defaultBranch is the base branch used when neither the per-project config
// nor the adapter options name one. It shares domain's single source of truth.
defaultBranch = domain.DefaultBranchName
)
// ErrUnsafePath is returned when a resolved worktree path escapes the managed
@ -122,7 +124,7 @@ func (w *Workspace) Create(ctx context.Context, cfg ports.WorkspaceConfig) (port
if err != nil {
return ports.WorkspaceInfo{}, err
}
if err := w.addWorktree(ctx, repo, path, cfg.Branch); err != nil {
if err := w.addWorktree(ctx, repo, path, cfg.Branch, cfg.BaseBranch); err != nil {
return ports.WorkspaceInfo{}, err
}
return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil
@ -198,13 +200,13 @@ func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (por
if err := w.validateBranch(ctx, repo, cfg.Branch); err != nil {
return ports.WorkspaceInfo{}, err
}
if err := w.addWorktree(ctx, repo, path, cfg.Branch); err != nil {
if err := w.addWorktree(ctx, repo, path, cfg.Branch, cfg.BaseBranch); err != nil {
return ports.WorkspaceInfo{}, err
}
return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil
}
func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch string) error {
func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch, baseBranch string) error {
// Refuse early if the branch is already checked out in another worktree:
// `git worktree add` will fail, but its stderr leaks through as an opaque
// 500. A typed sentinel lets the HTTP layer surface a 409.
@ -233,7 +235,7 @@ func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch string)
// neither origin/<branch>, the default branch, nor any tag is reachable,
// the branch genuinely has no base — surface ErrBranchNotFetched so callers
// can suggest `git fetch`.
baseRef, err := w.resolveBaseRef(ctx, repo, branch)
baseRef, err := w.resolveBaseRef(ctx, repo, branch, baseBranch)
if err != nil {
if errors.Is(err, errNoBaseRef) {
return fmt.Errorf("%w: %q has no local head, no remote, and no tag — run `git fetch` then retry", ErrBranchNotFetched, branch)
@ -257,8 +259,15 @@ func (w *Workspace) validateBranch(ctx context.Context, repo, branch string) err
// addWorktree translates it into ErrBranchNotFetched.
var errNoBaseRef = errors.New("gitworktree: no base ref found")
func (w *Workspace) resolveBaseRef(ctx context.Context, repo, branch string) (string, error) {
candidates := baseRefCandidates(branch, w.defaultBranch)
func (w *Workspace) resolveBaseRef(ctx context.Context, repo, branch, baseBranch string) (string, error) {
// A per-project base branch (cfg.BaseBranch) overrides the adapter default,
// so a project that branches off e.g. "develop" materialises worktrees from
// there. Empty falls back to the adapter's configured default.
defaultBranch := w.defaultBranch
if strings.TrimSpace(baseBranch) != "" {
defaultBranch = baseBranch
}
candidates := baseRefCandidates(branch, defaultBranch)
for _, ref := range candidates {
exists, err := w.refExists(ctx, repo, ref)
if err != nil {

View File

@ -62,6 +62,12 @@ func (c *commandContext) patchJSON(ctx context.Context, path string, body, out a
return c.doJSON(ctx, http.MethodPatch, path, body, out)
}
// putJSON sends body as JSON to PUT /api/v1/<path> on the running daemon and
// decodes a 2xx response into out.
func (c *commandContext) putJSON(ctx context.Context, path string, body, out any) error {
return c.doJSON(ctx, http.MethodPut, path, body, out)
}
// deleteJSON sends DELETE /api/v1/<path> to the running daemon and decodes a
// 2xx response into out.
func (c *commandContext) deleteJSON(ctx context.Context, path string, out any) error {

View File

@ -128,6 +128,11 @@ func (f *fakeProjectManager) Add(_ context.Context, in projectsvc.AddInput) (pro
return projectsvc.Project{ID: id, Path: in.Path}, nil
}
func (f *fakeProjectManager) SetConfig(_ context.Context, id domain.ProjectID, in projectsvc.SetConfigInput) (projectsvc.Project, error) {
cfg := in.Config
return projectsvc.Project{ID: id, Config: &cfg}, nil
}
func (f *fakeProjectManager) Remove(context.Context, domain.ProjectID) (projectsvc.RemoveResult, error) {
return projectsvc.RemoveResult{}, nil
}

View File

@ -2,9 +2,11 @@ package cli
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"net/url"
"reflect"
"sort"
"strings"
"text/tabwriter"
@ -53,11 +55,57 @@ type projectDetails struct {
Repo string `json:"repo"`
DefaultBranch string `json:"defaultBranch"`
DefaultHarness string `json:"agent,omitempty"`
Tracker map[string]any `json:"tracker,omitempty"`
SCM map[string]any `json:"scm,omitempty"`
Config *projectConfig `json:"config,omitempty"`
ResolveError string `json:"resolveError,omitempty"`
}
// agentConfig mirrors the daemon's typed domain.AgentConfig for the CLI client.
type agentConfig struct {
Model string `json:"model,omitempty"`
Permissions string `json:"permissions,omitempty"`
}
// roleOverride mirrors domain.RoleOverride.
type roleOverride struct {
Agent string `json:"agent,omitempty"`
AgentConfig agentConfig `json:"agentConfig,omitempty"`
}
// projectConfig mirrors the daemon's typed domain.ProjectConfig for the CLI
// client. The CLI sets common fields via flags and the whole object via
// --config-json.
type projectConfig struct {
DefaultBranch string `json:"defaultBranch,omitempty"`
SessionPrefix string `json:"sessionPrefix,omitempty"`
Env map[string]string `json:"env,omitempty"`
Symlinks []string `json:"symlinks,omitempty"`
PostCreate []string `json:"postCreate,omitempty"`
AgentConfig agentConfig `json:"agentConfig,omitempty"`
Worker roleOverride `json:"worker,omitempty"`
Orchestrator roleOverride `json:"orchestrator,omitempty"`
}
// setConfigRequest mirrors the daemon's SetConfigInput body for
// PUT /api/v1/projects/{id}/config.
type setConfigRequest struct {
Config projectConfig `json:"config"`
}
type projectSetConfigOptions struct {
defaultBranch string
sessionPrefix string
model string
permission string
workerAgent string
orchestratorAgent string
env []string
symlink []string
postCreate []string
configJSON string
clear bool
json bool
}
type projectListResult struct {
Projects []projectSummary `json:"projects"`
}
@ -86,6 +134,7 @@ func newProjectCommand(ctx *commandContext) *cobra.Command {
cmd.AddCommand(newProjectListCommand(ctx))
cmd.AddCommand(newProjectGetCommand(ctx))
cmd.AddCommand(newProjectAddCommand(ctx))
cmd.AddCommand(newProjectSetConfigCommand(ctx))
cmd.AddCommand(newProjectRemoveCommand(ctx))
return cmd
}
@ -179,6 +228,112 @@ func newProjectAddCommand(ctx *commandContext) *cobra.Command {
return cmd
}
func newProjectSetConfigCommand(ctx *commandContext) *cobra.Command {
var opts projectSetConfigOptions
cmd := &cobra.Command{
Use: "set-config <id>",
Short: "Set the per-project config",
Long: "Replace a project's per-project config (branch, session prefix, env, " +
"symlinks, post-create, agent model/permissions, role overrides). The config " +
"is resolved when a session spawns.\n\n" +
"Set fields via flags, pass the whole object with --config-json, or --clear " +
"to remove all config.",
Args: func(cmd *cobra.Command, args []string) error {
if err := cobra.ExactArgs(1)(cmd, args); err != nil {
return usageError{err}
}
if strings.TrimSpace(args[0]) == "" {
return usageError{errors.New("usage: project id is required")}
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
id := strings.TrimSpace(args[0])
config, err := buildProjectConfig(opts)
if err != nil {
return err
}
req := setConfigRequest{Config: config}
var res projectResult
if err := ctx.putJSON(cmd.Context(), "projects/"+url.PathEscape(id)+"/config", req, &res); err != nil {
return err
}
if opts.json {
return writeJSON(cmd.OutOrStdout(), res)
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "updated config for project %s\n", res.Project.ID)
return err
},
}
f := cmd.Flags()
f.StringVar(&opts.defaultBranch, "default-branch", "", "Base branch new session worktrees are created from")
f.StringVar(&opts.sessionPrefix, "session-prefix", "", "Displayed session-id prefix")
f.StringVar(&opts.model, "model", "", "Agent model override (e.g. claude-opus-4-5)")
f.StringVar(&opts.permission, "permission", "", "Permission mode: default, accept-edits, auto, bypass-permissions")
f.StringVar(&opts.workerAgent, "worker-agent", "", "Harness override for worker sessions")
f.StringVar(&opts.orchestratorAgent, "orchestrator-agent", "", "Harness override for orchestrator sessions")
f.StringArrayVar(&opts.env, "env", nil, "Env var KEY=VALUE forwarded into sessions (repeatable)")
f.StringArrayVar(&opts.symlink, "symlink", nil, "Repo-relative path to symlink into workspaces (repeatable)")
f.StringArrayVar(&opts.postCreate, "post-create", nil, "Command to run after workspace creation (repeatable)")
f.StringVar(&opts.configJSON, "config-json", "", "Full config as a JSON object (overrides field flags)")
f.BoolVar(&opts.clear, "clear", false, "Clear all config")
f.BoolVar(&opts.json, "json", false, "Output the updated project as JSON")
return cmd
}
// buildProjectConfig turns the set-config flags into the typed config sent to
// the daemon. --clear empties the config; --config-json supplies the whole
// object; otherwise the field flags form the config. The daemon validates the
// values.
func buildProjectConfig(opts projectSetConfigOptions) (projectConfig, error) {
if opts.clear {
return projectConfig{}, nil
}
if opts.configJSON != "" {
var cfg projectConfig
if err := json.Unmarshal([]byte(opts.configJSON), &cfg); err != nil {
return projectConfig{}, usageError{fmt.Errorf("--config-json is not a valid JSON object: %w", err)}
}
return cfg, nil
}
env, err := parseEnvPairs(opts.env)
if err != nil {
return projectConfig{}, err
}
cfg := projectConfig{
DefaultBranch: opts.defaultBranch,
SessionPrefix: opts.sessionPrefix,
Env: env,
Symlinks: opts.symlink,
PostCreate: opts.postCreate,
AgentConfig: agentConfig{Model: opts.model, Permissions: opts.permission},
Worker: roleOverride{Agent: opts.workerAgent},
Orchestrator: roleOverride{Agent: opts.orchestratorAgent},
}
if reflect.DeepEqual(cfg, projectConfig{}) {
return projectConfig{}, usageError{errors.New("usage: provide at least one config flag, --config-json, or --clear")}
}
return cfg, nil
}
// parseEnvPairs turns repeated KEY=VALUE flags into a map.
func parseEnvPairs(pairs []string) (map[string]string, error) {
if len(pairs) == 0 {
return nil, nil
}
env := make(map[string]string, len(pairs))
for _, pair := range pairs {
key, value, ok := strings.Cut(pair, "=")
key = strings.TrimSpace(key)
if !ok || key == "" {
return nil, usageError{fmt.Errorf("invalid --env %q: expected KEY=VALUE", pair)}
}
env[key] = value
}
return env, nil
}
func newProjectRemoveCommand(ctx *commandContext) *cobra.Command {
var opts projectRemoveOptions
cmd := &cobra.Command{
@ -270,6 +425,7 @@ func writeProjectDetails(cmd *cobra.Command, res projectGetResult) error {
{label: "repo", value: p.Repo},
{label: "default branch", value: p.DefaultBranch},
{label: "default harness", value: p.DefaultHarness},
{label: "config", value: formatProjectConfig(p.Config)},
{label: "resolve error", value: p.ResolveError},
}
for _, f := range fields {
@ -283,6 +439,19 @@ func writeProjectDetails(cmd *cobra.Command, res projectGetResult) error {
return nil
}
// formatProjectConfig renders the per-project config as compact JSON for the
// `project get` text view. A nil config returns "" so the row is skipped.
func formatProjectConfig(config *projectConfig) string {
if config == nil {
return ""
}
data, err := json.Marshal(config)
if err != nil {
return ""
}
return string(data)
}
func confirmProjectRemoval(cmd *cobra.Command, id string) (bool, error) {
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Remove project %q? Type the project id to confirm: ", id); err != nil {
return false, err

View File

@ -0,0 +1,48 @@
package domain
import "fmt"
// PermissionMode controls how much review an agent requires before acting. It
// lives in domain (not ports) so the typed AgentConfig can carry it; ports
// re-exports it as a type alias so agent adapters keep referring to
// ports.PermissionMode unchanged.
type PermissionMode string
// The permission modes adapters map onto their agent's native approval flags.
const (
// PermissionModeDefault is special: adapters emit no flag for it so the
// agent resolves its starting mode from the user's own config (e.g.
// Claude's TUI reading ~/.claude/settings.json defaultMode).
PermissionModeDefault PermissionMode = "default"
PermissionModeAcceptEdits PermissionMode = "accept-edits"
PermissionModeAuto PermissionMode = "auto"
PermissionModeBypassPermissions PermissionMode = "bypass-permissions"
)
// AgentConfig is the typed per-project agent configuration. It replaces the
// former free-form map so the fields are validated and the API/UI render a
// real form rather than arbitrary JSON. An empty value (IsZero) means unset.
type AgentConfig struct {
// Model overrides the agent's default model (e.g. claude-opus-4-5).
Model string `json:"model,omitempty"`
// Permissions sets the agent's starting permission mode. Empty defers to
// the agent's own configuration.
Permissions PermissionMode `json:"permissions,omitempty"`
}
// IsZero reports whether the config carries no settings, so storage can persist
// SQL NULL and resolution can skip an empty config.
func (c AgentConfig) IsZero() bool {
return c == AgentConfig{}
}
// Validate rejects values outside the typed vocabulary so a bad config is
// refused when it is set (CLI/API) rather than silently dropped at spawn.
func (c AgentConfig) Validate() error {
switch c.Permissions {
case "", PermissionModeDefault, PermissionModeAcceptEdits, PermissionModeAuto, PermissionModeBypassPermissions:
return nil
default:
return fmt.Errorf("invalid permissions %q: want one of default, accept-edits, auto, bypass-permissions", c.Permissions)
}
}

View File

@ -29,3 +29,23 @@ const (
HarnessPi AgentHarness = "pi"
HarnessAutohand AgentHarness = "autohand"
)
// AllHarnesses lists every supported harness. It is the canonical set used to
// validate user-supplied harness names (e.g. per-project role overrides).
var AllHarnesses = []AgentHarness{
HarnessClaudeCode, HarnessCodex, HarnessAider, HarnessOpenCode, HarnessGrok,
HarnessDroid, HarnessAmp, HarnessAgy, HarnessCrush, HarnessCursor, HarnessQwen,
HarnessCopilot, HarnessGoose, HarnessAuggie, HarnessContinue, HarnessDevin,
HarnessCline, HarnessKimi, HarnessKiro, HarnessKilocode, HarnessVibe, HarnessPi,
HarnessAutohand,
}
// IsKnown reports whether h is one of the supported harnesses.
func (h AgentHarness) IsKnown() bool {
for _, k := range AllHarnesses {
if h == k {
return true
}
}
return false
}

View File

@ -10,4 +10,7 @@ type ProjectRecord struct {
DisplayName string
RegisteredAt time.Time
ArchivedAt time.Time
// Config holds the typed per-project configuration AO resolves at spawn. An
// IsZero value means unset.
Config ProjectConfig
}

View File

@ -0,0 +1,119 @@
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.<id>` 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"`
}
// 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
}
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)
}
}
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
}

View File

@ -0,0 +1,78 @@
package domain
import "testing"
func TestProjectConfigValidate(t *testing.T) {
tests := []struct {
name string
cfg ProjectConfig
wantErr bool
}{
{"empty ok", ProjectConfig{}, false},
{"good agent config", ProjectConfig{AgentConfig: AgentConfig{Model: "m", Permissions: PermissionModeAuto}}, false},
{"bad permission", ProjectConfig{AgentConfig: AgentConfig{Permissions: "yolo"}}, true},
{"good role override", ProjectConfig{Worker: RoleOverride{Harness: HarnessCodex}}, false},
{"unknown role harness", ProjectConfig{Orchestrator: RoleOverride{Harness: "nope"}}, true},
{"bad role agent config", ProjectConfig{Worker: RoleOverride{AgentConfig: AgentConfig{Permissions: "nope"}}}, true},
{"good symlinks", ProjectConfig{Symlinks: []string{".env", "configs/dev.toml"}}, false},
{"symlink absolute path", ProjectConfig{Symlinks: []string{"/etc/passwd"}}, true},
{"symlink parent escape", ProjectConfig{Symlinks: []string{"../escape"}}, true},
{"symlink embedded parent", ProjectConfig{Symlinks: []string{"a/../../b"}}, true},
{"symlink bare ..", ProjectConfig{Symlinks: []string{".."}}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.cfg.Validate(); (err != nil) != tt.wantErr {
t.Fatalf("Validate() err = %v, wantErr = %v", err, tt.wantErr)
}
})
}
}
func TestDefaultProjectConfig(t *testing.T) {
def := DefaultProjectConfig()
// The one documented non-empty default.
if def.DefaultBranch != "main" {
t.Fatalf("default DefaultBranch = %q, want main", def.DefaultBranch)
}
// Every other field defaults to its zero value: clearing the documented
// default must leave the config completely empty.
def.DefaultBranch = ""
if !def.IsZero() {
t.Fatalf("default config has unexpected non-zero fields: %#v", def)
}
}
func TestProjectConfigWithDefaults(t *testing.T) {
// An unset config gets the documented defaults.
got := (ProjectConfig{}).WithDefaults()
if got.DefaultBranch != DefaultBranchName {
t.Fatalf("WithDefaults = %#v, want branch=main", got)
}
// Set fields are preserved, not overwritten.
got = (ProjectConfig{
DefaultBranch: "develop",
AgentConfig: AgentConfig{Model: "m"},
}).WithDefaults()
if got.DefaultBranch != "develop" {
t.Fatalf("WithDefaults overwrote set fields: %#v", got)
}
if got.AgentConfig.Model != "m" {
t.Fatalf("WithDefaults dropped a set field: %#v", got.AgentConfig)
}
}
func TestProjectConfigIsZero(t *testing.T) {
if !(ProjectConfig{}).IsZero() {
t.Fatal("empty config should be zero")
}
if (ProjectConfig{DefaultBranch: "main"}).IsZero() {
t.Fatal("populated config should not be zero")
}
if (ProjectConfig{Env: map[string]string{"A": "b"}}).IsZero() {
t.Fatal("config with env should not be zero")
}
}

View File

@ -283,6 +283,51 @@ paths:
summary: Fetch one project; discriminates ok vs degraded
tags:
- projects
/api/v1/projects/{id}/config:
put:
operationId: setProjectConfig
parameters:
- description: Project identifier (registry key).
in: path
name: id
required: true
schema:
description: Project identifier (registry key).
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SetProjectConfigInput'
required: true
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/ProjectResponse'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Bad Request
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Internal Server Error
summary: Replace a project's per-project config
tags:
- projects
/api/v1/prs/{id}/merge:
post:
operationId: mergePR
@ -916,6 +961,8 @@ components:
type: object
AddProjectInput:
properties:
config:
$ref: '#/components/schemas/ProjectConfig'
name:
type:
- "null"
@ -929,6 +976,13 @@ components:
required:
- path
type: object
AgentConfig:
properties:
model:
type: string
permissions:
type: string
type: object
ClaimPRRequest:
properties:
allowTakeover:
@ -1074,6 +1128,8 @@ components:
properties:
agent:
type: string
config:
$ref: '#/components/schemas/ProjectConfig'
defaultBranch:
type: string
id:
@ -1084,10 +1140,6 @@ components:
type: string
repo:
type: string
scm:
$ref: '#/components/schemas/SCMConfig'
tracker:
$ref: '#/components/schemas/TrackerConfig'
required:
- id
- name
@ -1095,6 +1147,31 @@ components:
- repo
- defaultBranch
type: object
ProjectConfig:
properties:
agentConfig:
$ref: '#/components/schemas/AgentConfig'
defaultBranch:
type: string
env:
additionalProperties:
type: string
type: object
orchestrator:
$ref: '#/components/schemas/RoleOverride'
postCreate:
items:
type: string
type: array
sessionPrefix:
type: string
symlinks:
items:
type: string
type: array
worker:
$ref: '#/components/schemas/RoleOverride'
type: object
ProjectGetResponse:
properties:
project:
@ -1189,6 +1266,13 @@ components:
- sessionId
- session
type: object
RoleOverride:
properties:
agent:
type: string
agentConfig:
$ref: '#/components/schemas/AgentConfig'
type: object
RollbackSessionResponse:
properties:
deleted:
@ -1203,36 +1287,6 @@ components:
- ok
- sessionId
type: object
SCMConfig:
properties:
package:
type: string
path:
type: string
plugin:
type: string
webhook:
$ref: '#/components/schemas/SCMWebhookConfig'
type: object
SCMWebhookConfig:
properties:
deliveryHeader:
type: string
enabled:
type:
- "null"
- boolean
eventHeader:
type: string
maxBodyBytes:
type: integer
path:
type: string
secretEnvVar:
type: string
signatureHeader:
type: string
type: object
SendSessionMessageRequest:
properties:
message:
@ -1358,6 +1412,13 @@ components:
- sessionId
- state
type: object
SetProjectConfigInput:
properties:
config:
$ref: '#/components/schemas/ProjectConfig'
required:
- config
type: object
SpawnOrchestratorRequest:
properties:
clean:
@ -1421,15 +1482,6 @@ components:
required:
- projectId
type: object
TrackerConfig:
properties:
package:
type: string
path:
type: string
plugin:
type: string
type: object
tags:
- description: Project registry, configuration, and lifecycle administration
name: projects

View File

@ -118,10 +118,13 @@ var schemaNames = map[string]string{
// httpd/envelope
"EnvelopeAPIError": "APIError",
// domain
"DomainProjectID": "ProjectID",
"DomainSessionID": "SessionID",
"DomainIssueID": "IssueID",
"DomainSession": "Session",
"DomainProjectID": "ProjectID",
"DomainSessionID": "SessionID",
"DomainIssueID": "IssueID",
"DomainSession": "Session",
"DomainProjectConfig": "ProjectConfig",
"DomainAgentConfig": "AgentConfig",
"DomainRoleOverride": "RoleOverride",
// httpd/controllers (wire envelopes)
"ControllersListProjectsResponse": "ListProjectsResponse",
"ControllersProjectResponse": "ProjectResponse",
@ -154,14 +157,12 @@ var schemaNames = map[string]string{
"ControllersResolveCommentsRequest": "ResolveCommentsRequest",
"ControllersResolveCommentsResponse": "ResolveCommentsResponse",
// service/project entities + DTOs
"ProjectProject": "Project",
"ProjectSummary": "ProjectSummary",
"ProjectDegraded": "DegradedProject",
"ProjectAddInput": "AddProjectInput",
"ProjectRemoveResult": "RemoveProjectResult",
"ProjectTrackerConfig": "TrackerConfig",
"ProjectSCMConfig": "SCMConfig",
"ProjectSCMWebhookConfig": "SCMWebhookConfig",
"ProjectProject": "Project",
"ProjectSummary": "ProjectSummary",
"ProjectDegraded": "DegradedProject",
"ProjectAddInput": "AddProjectInput",
"ProjectRemoveResult": "RemoveProjectResult",
"ProjectSetConfigInput": "SetProjectConfigInput",
}
// markRequestBodyRequired sets requestBody.required: true on the operation's
@ -297,6 +298,18 @@ func projectOperations() []operation {
{http.StatusInternalServerError, envelope.APIError{}},
},
},
{
method: http.MethodPut, path: "/api/v1/projects/{id}/config", id: "setProjectConfig", tag: "projects",
summary: "Replace a project's per-project config",
pathParams: []any{controllers.ProjectIDParam{}},
reqBody: projectsvc.SetConfigInput{},
resps: []respUnit{
{http.StatusOK, controllers.ProjectResponse{}},
{http.StatusBadRequest, envelope.APIError{}},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusInternalServerError, envelope.APIError{}},
},
},
{
method: http.MethodDelete, path: "/api/v1/projects/{id}", id: "removeProject", tag: "projects",
summary: "Remove a project; stops sessions, cleans workspaces, unregisters",

View File

@ -27,6 +27,7 @@ func (c *ProjectsController) Register(r chi.Router) {
r.Get("/projects", c.list)
r.Post("/projects", c.add)
r.Get("/projects/{id}", c.get)
r.Put("/projects/{id}/config", c.setConfig)
r.Delete("/projects/{id}", c.remove)
}
@ -52,7 +53,7 @@ func (c *ProjectsController) add(w http.ResponseWriter, r *http.Request) {
return
}
var in projectsvc.AddInput
if err := decodeJSON(r, &in); err != nil {
if err := decodeJSONStrict(r, &in); err != nil {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
return
}
@ -82,6 +83,24 @@ func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) {
envelope.WriteJSON(w, http.StatusOK, resp)
}
func (c *ProjectsController) setConfig(w http.ResponseWriter, r *http.Request) {
if c.Mgr == nil {
apispec.NotImplemented(w, r, "PUT", "/api/v1/projects/{id}/config")
return
}
var in projectsvc.SetConfigInput
if err := decodeJSONStrict(r, &in); err != nil {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
return
}
p, err := c.Mgr.SetConfig(r.Context(), projectID(r), in)
if err != nil {
envelope.WriteError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusOK, ProjectResponse{Project: p})
}
func (c *ProjectsController) remove(w http.ResponseWriter, r *http.Request) {
if c.Mgr == nil {
apispec.NotImplemented(w, r, "DELETE", "/api/v1/projects/{id}")
@ -102,3 +121,12 @@ func projectID(r *http.Request) domain.ProjectID {
func decodeJSON(r *http.Request, out any) error {
return json.NewDecoder(r.Body).Decode(out)
}
// decodeJSONStrict rejects request bodies that include keys outside the target
// type. Used on project add/set-config so a misspelled or removed config field
// surfaces as a 400 instead of being silently dropped.
func decodeJSONStrict(r *http.Request, out any) error {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
return dec.Decode(out)
}

View File

@ -307,6 +307,36 @@ func TestProjectsAPI_Delete(t *testing.T) {
}
// TestProjectsAPI_RejectsUnknownConfigKeys locks the strict-decoder gate on the
// project config endpoints: a misspelled or removed field surfaces as a clear
// 400 instead of being silently dropped, so the API cannot accumulate dead
// config the daemon never reads.
func TestProjectsAPI_RejectsUnknownConfigKeys(t *testing.T) {
srv := newTestServer(t)
repo := gitRepo(t, "rejects-unknown")
body, status, _ := doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(repo)+`,"projectId":"rej"}`)
if status != http.StatusCreated {
t.Fatalf("seed create = %d, want 201; body=%s", status, body)
}
// PUT a config body with an extraneous top-level key.
body, status, _ = doRequest(t, srv, "PUT", "/api/v1/projects/rej/config", `{"config":{"defaultBranch":"develop"},"surprise":"!"}`)
assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON")
// PUT a config body with a removed field inside the nested config — the
// canonical regression: agentRules / tracker are no longer modelled, so
// projects can't sneak them back in.
body, status, _ = doRequest(t, srv, "PUT", "/api/v1/projects/rej/config", `{"config":{"agentRules":"x"}}`)
assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON")
body, status, _ = doRequest(t, srv, "PUT", "/api/v1/projects/rej/config", `{"config":{"tracker":{"plugin":"github"}}}`)
assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON")
// POST /projects gets the same gate, so add-time config rides the same rail.
otherRepo := gitRepo(t, "rejects-unknown-add")
body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(otherRepo)+`,"projectId":"rej2","config":{"orchestratorRules":"x"}}`)
assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON")
}
func TestProjectsRoutes_LegacyUnregistered(t *testing.T) {
srv := newTestServer(t)

View File

@ -66,9 +66,10 @@ const (
MetadataKeySummary = "summary"
)
// AgentConfig holds values loaded from the selected agent's config section.
// Agent adapters own validation for their custom keys.
type AgentConfig map[string]any
// AgentConfig is the typed per-project agent config handed to adapters at
// launch. It aliases domain.AgentConfig so storage, services, and adapters
// share one definition without a translation layer.
type AgentConfig = domain.AgentConfig
// ConfigSpec describes the agent-specific config keys AO can expose to users.
type ConfigSpec struct {
@ -139,18 +140,19 @@ type SessionInfo struct {
Summary string
}
// PermissionMode controls how much review an agent requires before acting.
type PermissionMode string
// PermissionMode controls how much review an agent requires before acting. It
// is a type alias for domain.PermissionMode so adapters keep using
// ports.PermissionMode while the typed AgentConfig (in domain) reuses the same
// type.
type PermissionMode = domain.PermissionMode
// The permission modes adapters map onto their agent's native approval flags.
// These re-export the domain constants so existing adapter code is unchanged.
const (
// PermissionModeDefault is special: adapters emit no flag for it so the
// agent resolves its starting mode from the user's own config (e.g.
// Claude's TUI reading ~/.claude/settings.json defaultMode).
PermissionModeDefault PermissionMode = "default"
PermissionModeAcceptEdits PermissionMode = "accept-edits"
PermissionModeAuto PermissionMode = "auto"
PermissionModeBypassPermissions PermissionMode = "bypass-permissions"
PermissionModeDefault = domain.PermissionModeDefault
PermissionModeAcceptEdits = domain.PermissionModeAcceptEdits
PermissionModeAuto = domain.PermissionModeAuto
PermissionModeBypassPermissions = domain.PermissionModeBypassPermissions
)
// PromptDeliveryStrategy describes how AO should deliver the initial prompt.

View File

@ -125,6 +125,9 @@ type WorkspaceConfig struct {
ProjectID domain.ProjectID
SessionID domain.SessionID
Branch string
// BaseBranch is the per-project default branch new session branches are
// created from. Empty falls back to the workspace adapter's own default.
BaseBranch string
}
// WorkspaceInfo describes a created workspace — where it lives and its branch.

View File

@ -11,9 +11,16 @@ type GetResult struct {
// AddInput is the body shape for POST /api/v1/projects.
type AddInput struct {
Path string `json:"path"`
ProjectID *string `json:"projectId,omitempty"`
Name *string `json:"name,omitempty"`
Path string `json:"path"`
ProjectID *string `json:"projectId,omitempty"`
Name *string `json:"name,omitempty"`
Config *domain.ProjectConfig `json:"config,omitempty"`
}
// SetConfigInput is the body shape for PUT /api/v1/projects/{id}/config. Config
// replaces the project's stored config wholesale; a zero-value config clears it.
type SetConfigInput struct {
Config domain.ProjectConfig `json:"config"`
}
// RemoveResult reports what DELETE /api/v1/projects/{id} actually did.

View File

@ -26,6 +26,10 @@ type Manager interface {
// Add registers a new project from a git repository path.
Add(ctx context.Context, in AddInput) (Project, error)
// SetConfig replaces a project's per-project config, returning the updated
// read-model.
SetConfig(ctx context.Context, id domain.ProjectID, in SetConfigInput) (Project, error)
// Remove unregisters a project, stopping its sessions and reclaiming
// managed workspaces.
Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error)
@ -54,7 +58,7 @@ func (m *Service) List(ctx context.Context) ([]Summary, error) {
out = append(out, Summary{
ID: domain.ProjectID(row.ID),
Name: displayName(row),
SessionPrefix: sessionPrefix(row.ID),
SessionPrefix: resolveSessionPrefix(row),
})
}
return out, nil
@ -119,12 +123,21 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) {
})
}
var config domain.ProjectConfig
if in.Config != nil {
if err := in.Config.Validate(); err != nil {
return Project{}, apierr.Invalid("INVALID_PROJECT_CONFIG", err.Error(), nil)
}
config = *in.Config
}
row := domain.ProjectRecord{
ID: string(id),
Path: path,
RepoOriginURL: resolveGitOriginURL(path),
DisplayName: name,
RegisteredAt: time.Now(),
Config: config,
}
if err := m.store.UpsertProject(ctx, row); err != nil {
return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register project")
@ -132,6 +145,29 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) {
return projectFromRow(row), nil
}
// SetConfig replaces the project's stored config. The typed config is validated
// here so a bad value is rejected when set rather than surfacing at spawn.
func (m *Service) SetConfig(ctx context.Context, id domain.ProjectID, in SetConfigInput) (Project, error) {
if err := validateProjectID(id); err != nil {
return Project{}, err
}
if err := in.Config.Validate(); err != nil {
return Project{}, apierr.Invalid("INVALID_PROJECT_CONFIG", err.Error(), nil)
}
row, ok, err := m.store.GetProject(ctx, string(id))
if err != nil {
return Project{}, apierr.Internal("PROJECT_LOAD_FAILED", "Failed to load project")
}
if !ok || !row.ArchivedAt.IsZero() {
return Project{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project")
}
row.Config = in.Config
if err := m.store.UpsertProject(ctx, row); err != nil {
return Project{}, apierr.Internal("PROJECT_CONFIG_UPDATE_FAILED", "Failed to update project config")
}
return projectFromRow(row), nil
}
// resolveGitOriginURL returns the project's `origin` remote URL via
// `git -C path remote get-url origin`. A missing remote, missing repo, or any
// other git error returns an empty string — `project add` must not fail just
@ -169,13 +205,18 @@ func (m *Service) suggestID(ctx context.Context, base domain.ProjectID) domain.P
}
func projectFromRow(row domain.ProjectRecord) Project {
return Project{
p := Project{
ID: domain.ProjectID(row.ID),
Name: displayName(row),
Path: row.Path,
Repo: row.RepoOriginURL,
DefaultBranch: "main",
DefaultBranch: row.Config.WithDefaults().DefaultBranch,
}
if !row.Config.IsZero() {
cfg := row.Config
p.Config = &cfg
}
return p
}
func displayName(row domain.ProjectRecord) string {
@ -251,6 +292,16 @@ func validateProjectID(id domain.ProjectID) error {
return nil
}
// resolveSessionPrefix prefers an explicit per-project SessionPrefix and falls
// back to the id-derived prefix. (Display only; session-id generation is
// unchanged.)
func resolveSessionPrefix(row domain.ProjectRecord) string {
if p := strings.TrimSpace(row.Config.SessionPrefix); p != "" {
return p
}
return sessionPrefix(row.ID)
}
func sessionPrefix(id string) string {
if id == "" {
return "ao"

View File

@ -95,6 +95,87 @@ func TestManager_AddListGetRemove(t *testing.T) {
wantCode(t, err, "PROJECT_NOT_FOUND")
}
func TestManager_DefaultsWhenUnconfigured(t *testing.T) {
ctx := context.Background()
m := newManager(t)
repo := gitRepo(t)
if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao")}); err != nil {
t.Fatalf("Add: %v", err)
}
// Get on a project that set no config still reports the default branch and a
// derived session prefix, and omits the (empty) config object.
got, err := m.Get(ctx, "ao")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Project == nil {
t.Fatalf("Get returned no project: %#v", got)
}
if got.Project.DefaultBranch != domain.DefaultBranchName {
t.Fatalf("default branch = %q, want %q", got.Project.DefaultBranch, domain.DefaultBranchName)
}
if got.Project.Config != nil {
t.Fatalf("unconfigured project should omit config, got %#v", got.Project.Config)
}
list, err := m.List(ctx)
if err != nil || len(list) != 1 {
t.Fatalf("List = %v, %v", list, err)
}
if list[0].SessionPrefix != "ao" {
t.Fatalf("default session prefix = %q, want derived 'ao'", list[0].SessionPrefix)
}
}
func TestManager_SetConfig(t *testing.T) {
ctx := context.Background()
m := newManager(t)
repo := gitRepo(t)
if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao")}); err != nil {
t.Fatalf("Add: %v", err)
}
cfg := domain.ProjectConfig{
DefaultBranch: "develop",
Env: map[string]string{"FOO": "bar"},
AgentConfig: domain.AgentConfig{Model: "claude-opus-4-5"},
}
proj, err := m.SetConfig(ctx, "ao", project.SetConfigInput{Config: cfg})
if err != nil {
t.Fatalf("SetConfig: %v", err)
}
if proj.Config == nil || proj.Config.AgentConfig.Model != "claude-opus-4-5" {
t.Fatalf("returned config = %#v", proj.Config)
}
if proj.DefaultBranch != "develop" {
t.Fatalf("DefaultBranch = %q, want develop", proj.DefaultBranch)
}
// The config persists and shows up on a fresh Get.
got, err := m.Get(ctx, "ao")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Project == nil || got.Project.Config == nil || got.Project.Config.Env["FOO"] != "bar" {
t.Fatalf("Get config = %#v", got.Project)
}
// An invalid permission value is rejected when set.
_, err = m.SetConfig(ctx, "ao", project.SetConfigInput{Config: domain.ProjectConfig{AgentConfig: domain.AgentConfig{Permissions: "yolo"}}})
wantCode(t, err, "INVALID_PROJECT_CONFIG")
// An unknown role-override harness is rejected too.
_, err = m.SetConfig(ctx, "ao", project.SetConfigInput{Config: domain.ProjectConfig{Worker: domain.RoleOverride{Harness: "nope"}}})
wantCode(t, err, "INVALID_PROJECT_CONFIG")
// Setting on an unknown project is a clean not-found.
_, err = m.SetConfig(ctx, "ghost", project.SetConfigInput{Config: cfg})
wantCode(t, err, "PROJECT_NOT_FOUND")
}
func TestManager_ReaddAfterRemove(t *testing.T) {
ctx := context.Background()
m := newManager(t)

View File

@ -12,14 +12,13 @@ type Summary struct {
// Project is the full read-model returned by GET /api/v1/projects/{id}.
type Project struct {
ID domain.ProjectID `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Repo string `json:"repo"`
DefaultBranch string `json:"defaultBranch"`
Agent string `json:"agent,omitempty"`
Tracker *TrackerConfig `json:"tracker,omitempty"`
SCM *SCMConfig `json:"scm,omitempty"`
ID domain.ProjectID `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Repo string `json:"repo"`
DefaultBranch string `json:"defaultBranch"`
Agent string `json:"agent,omitempty"`
Config *domain.ProjectConfig `json:"config,omitempty"`
}
// Degraded is returned in place of Project when project config failed to load.
@ -29,29 +28,3 @@ type Degraded struct {
Path string `json:"path"`
ResolveError string `json:"resolveError"`
}
// TrackerConfig mirrors tracker behaviour config exposed by the projects API.
type TrackerConfig struct {
Plugin string `json:"plugin,omitempty"`
Package string `json:"package,omitempty"`
Path string `json:"path,omitempty"`
}
// SCMConfig mirrors SCM behaviour config exposed by the projects API.
type SCMConfig struct {
Plugin string `json:"plugin,omitempty"`
Package string `json:"package,omitempty"`
Path string `json:"path,omitempty"`
Webhook *SCMWebhookConfig `json:"webhook,omitempty"`
}
// SCMWebhookConfig describes SCM webhook settings.
type SCMWebhookConfig struct {
Enabled *bool `json:"enabled,omitempty"`
Path string `json:"path,omitempty"`
SecretEnvVar string `json:"secretEnvVar,omitempty"`
SignatureHeader string `json:"signatureHeader,omitempty"`
EventHeader string `json:"eventHeader,omitempty"`
DeliveryHeader string `json:"deliveryHeader,omitempty"`
MaxBodyBytes int `json:"maxBodyBytes,omitempty"`
}

View File

@ -6,7 +6,11 @@ import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
@ -46,6 +50,9 @@ type runtimeController interface {
// Store is the persistence surface needed by the internal session Manager.
type Store interface {
// GetProject loads a project row so spawn can resolve its per-project agent
// config into the launch command. ok=false means the project is unknown.
GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error)
CreateSession(ctx context.Context, rec domain.SessionRecord) (domain.SessionRecord, error)
GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error)
ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error)
@ -120,6 +127,14 @@ func New(d Deps) *Manager {
// workspace and runtime, then reports completion to the LCM. A failure after the
// row exists parks it as terminated and rolls back what was built.
func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) {
project, err := m.loadProject(ctx, cfg.ProjectID)
if err != nil {
return domain.SessionRecord{}, fmt.Errorf("spawn: %w", err)
}
// A per-project role override picks the harness when the spawn names none,
// so a project can default workers to one agent and orchestrators to another.
cfg.Harness = effectiveHarness(cfg.Harness, cfg.Kind, project.Config)
prompt, err := m.buildSpawnPrompt(ctx, cfg)
if err != nil {
return domain.SessionRecord{}, fmt.Errorf("spawn: prompt: %w", err)
@ -138,12 +153,25 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
// derived from the assigned session id.
branch = "ao/" + string(id)
}
ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{ProjectID: cfg.ProjectID, SessionID: id, Branch: branch})
ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{
ProjectID: cfg.ProjectID,
SessionID: id,
Branch: branch,
BaseBranch: project.Config.WithDefaults().DefaultBranch,
})
if err != nil {
m.markSpawnFailedTerminated(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: workspace: %w", id, err)
}
// Per-project workspace provisioning: symlink shared files, then run any
// post-create commands (e.g. `pnpm install`) before the agent launches.
if err := m.provisionWorkspace(ctx, project, ws.Path); err != nil {
_ = m.workspace.Destroy(ctx, ws)
m.markSpawnFailedTerminated(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: provision: %w", id, err)
}
agent, ok := m.agents.Agent(cfg.Harness)
if !ok {
_ = m.workspace.Destroy(ctx, ws)
@ -160,6 +188,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
WorkspacePath: ws.Path,
Prompt: prompt,
IssueID: string(cfg.IssueID),
Config: effectiveAgentConfig(cfg.Kind, project.Config),
})
if err != nil {
_ = m.workspace.Destroy(ctx, ws)
@ -179,7 +208,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
SessionID: id,
WorkspacePath: ws.Path,
Argv: argv,
Env: spawnEnv(id, cfg.ProjectID, cfg.IssueID, m.dataDir),
Env: spawnEnv(id, cfg.ProjectID, cfg.IssueID, m.dataDir, project.Config.Env),
})
if err != nil {
_ = m.workspace.Destroy(ctx, ws)
@ -197,6 +226,56 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
return m.getRecord(ctx, id)
}
// loadProject loads the project record so spawn can resolve its per-project
// config (harness/agent overrides, env, branch, rules, provisioning). A missing
// project yields a zero record rather than an error: the project may be
// unregistered yet still have live sessions, and an empty config simply means
// every field falls back to its default.
func (m *Manager) loadProject(ctx context.Context, projectID domain.ProjectID) (domain.ProjectRecord, error) {
row, ok, err := m.store.GetProject(ctx, string(projectID))
if err != nil {
return domain.ProjectRecord{}, fmt.Errorf("load project: %w", err)
}
if !ok {
return domain.ProjectRecord{}, nil
}
return row, nil
}
// effectiveHarness resolves the harness for a spawn: an explicit harness wins;
// otherwise the project's role override for the session kind applies; otherwise
// it stays empty so the daemon's global default (AO_AGENT) is used downstream.
func effectiveHarness(explicit domain.AgentHarness, kind domain.SessionKind, cfg domain.ProjectConfig) domain.AgentHarness {
if explicit != "" {
return explicit
}
if role := roleOverride(kind, cfg).Harness; role != "" {
return role
}
return ""
}
// effectiveAgentConfig merges the role override's agent config over the
// project's base agent config; set override fields win.
func effectiveAgentConfig(kind domain.SessionKind, cfg domain.ProjectConfig) ports.AgentConfig {
merged := cfg.AgentConfig
override := roleOverride(kind, cfg).AgentConfig
if override.Model != "" {
merged.Model = override.Model
}
if override.Permissions != "" {
merged.Permissions = override.Permissions
}
return merged
}
func roleOverride(kind domain.SessionKind, cfg domain.ProjectConfig) domain.RoleOverride {
if kind == domain.KindOrchestrator {
return cfg.Orchestrator
}
return cfg.Worker
}
// markSpawnFailedTerminated best-effort parks an orphaned spawn as terminated.
// A phantom half-spawned row is worse than a terminal one; we only delete the
// row when nothing observable has landed yet (seed state) via rollbackSpawn.
@ -300,7 +379,13 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess
if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil {
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
}
argv, err := restoreArgv(ctx, agent, id, ws.Path, meta)
project, err := m.loadProject(ctx, rec.ProjectID)
if err != nil {
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
}
// Restore re-applies the project's resolved agent config so a configured
// model/permissions carry across a restore, matching fresh spawn.
argv, err := restoreArgv(ctx, agent, id, ws.Path, meta, effectiveAgentConfig(rec.Kind, project.Config))
if err != nil {
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
}
@ -308,7 +393,7 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess
SessionID: id,
WorkspacePath: ws.Path,
Argv: argv,
Env: spawnEnv(id, rec.ProjectID, rec.IssueID, m.dataDir),
Env: spawnEnv(id, rec.ProjectID, rec.IssueID, m.dataDir, project.Config.Env),
})
if err != nil {
return domain.SessionRecord{}, fmt.Errorf("restore %s: runtime: %w", id, err)
@ -403,6 +488,7 @@ func buildPrompt(cfg ports.SpawnConfig) string {
func (m *Manager) buildSpawnPrompt(ctx context.Context, cfg ports.SpawnConfig) (string, error) {
prompt := buildPrompt(cfg)
switch cfg.Kind {
case domain.KindOrchestrator:
return appendPromptSection(orchestratorPrompt(cfg.ProjectID), prompt), nil
@ -465,13 +551,105 @@ func appendPromptSection(prompt, section string) string {
}
}
func spawnEnv(id domain.SessionID, project domain.ProjectID, issue domain.IssueID, dataDir string) map[string]string {
return map[string]string{
EnvSessionID: string(id),
EnvProjectID: string(project),
EnvIssueID: string(issue),
EnvDataDir: dataDir,
// spawnEnv builds the runtime environment: the per-project env vars first, then
// the AO-internal vars last so they always win (a project cannot override
// AO_SESSION_ID and friends).
func spawnEnv(id domain.SessionID, project domain.ProjectID, issue domain.IssueID, dataDir string, projectEnv map[string]string) map[string]string {
env := make(map[string]string, len(projectEnv)+4)
for k, v := range projectEnv {
env[k] = v
}
env[EnvSessionID] = string(id)
env[EnvProjectID] = string(project)
env[EnvIssueID] = string(issue)
env[EnvDataDir] = dataDir
return env
}
// provisionWorkspace applies the project's per-workspace setup after the
// worktree exists: symlink shared files from the project repo, then run any
// post-create commands. Either failing aborts the spawn so a half-provisioned
// workspace never launches an agent.
func (m *Manager) provisionWorkspace(ctx context.Context, project domain.ProjectRecord, workspacePath string) error {
if err := applySymlinks(project.Path, workspacePath, project.Config.Symlinks); err != nil {
return err
}
return runPostCreate(ctx, workspacePath, project.Config.PostCreate)
}
// applySymlinks links each repo-relative path into the workspace. A source that
// does not exist is skipped (symlinks are a convenience for optional files like
// .env); a real link failure aborts. Paths must be repo-relative with no
// parent traversal (no leading "/", no ".." segment) — a bad path is refused
// up front so a project config cannot escape the project or workspace tree.
func applySymlinks(projectPath, workspacePath string, symlinks []string) error {
for _, rel := range symlinks {
rel = strings.TrimSpace(rel)
if rel == "" {
continue
}
clean, err := safeRelPath(rel)
if err != nil {
return fmt.Errorf("symlink %q: %w", rel, err)
}
source := filepath.Join(projectPath, clean)
if _, err := os.Stat(source); err != nil {
continue
}
target := filepath.Join(workspacePath, clean)
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
return fmt.Errorf("symlink %q: %w", rel, err)
}
if _, err := os.Lstat(target); err == nil {
continue
}
if err := os.Symlink(source, target); err != nil {
return fmt.Errorf("symlink %q: %w", rel, err)
}
}
return nil
}
// safeRelPath confines rel to a repo-relative path: no absolute paths and no
// ".." segments (before or after Clean). The cleaned form is returned so
// callers join it against project/workspace roots safely.
func safeRelPath(rel string) (string, error) {
if filepath.IsAbs(rel) || strings.HasPrefix(rel, "/") || strings.HasPrefix(rel, `\`) {
return "", fmt.Errorf("path must be repo-relative")
}
clean := filepath.Clean(rel)
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || clean == "." || clean == "" {
return "", fmt.Errorf("path must be repo-relative")
}
for _, seg := range strings.Split(filepath.ToSlash(clean), "/") {
if seg == ".." {
return "", fmt.Errorf("path must be repo-relative")
}
}
return clean, nil
}
// runPostCreate runs each post-create command in the workspace via the platform
// shell, so OS-agnostic commands like "pnpm install" work. A non-zero exit
// aborts the spawn with the command output.
func runPostCreate(ctx context.Context, workspacePath string, commands []string) error {
for _, command := range commands {
command = strings.TrimSpace(command)
if command == "" {
continue
}
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.CommandContext(ctx, "cmd", "/c", command)
} else {
cmd = exec.CommandContext(ctx, "sh", "-c", command)
}
cmd.Dir = workspacePath
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("postCreate %q: %w: %s", command, err, strings.TrimSpace(string(out)))
}
}
return nil
}
// preLauncher is an optional Agent capability: a step the manager runs before
@ -505,13 +683,13 @@ func (m *Manager) prepareWorkspace(ctx context.Context, agent ports.Agent, id do
// restoreArgv builds the argv to relaunch a torn-down session: the agent's
// native resume command when it can continue the session, else a fresh launch.
// The agent signals via ok=false (e.g. no native session id captured yet).
func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath string, meta domain.SessionMetadata) ([]string, error) {
func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath string, meta domain.SessionMetadata, agentConfig ports.AgentConfig) ([]string, error) {
ref := ports.SessionRef{
ID: string(id),
WorkspacePath: workspacePath,
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: meta.AgentSessionID},
}
cmd, ok, err := agent.GetRestoreCommand(ctx, ports.RestoreConfig{Session: ref})
cmd, ok, err := agent.GetRestoreCommand(ctx, ports.RestoreConfig{Session: ref, Config: agentConfig})
if err != nil {
return nil, fmt.Errorf("restore command: %w", err)
}
@ -522,6 +700,7 @@ func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, wo
SessionID: string(id),
WorkspacePath: workspacePath,
Prompt: meta.Prompt,
Config: agentConfig,
})
if err != nil {
return nil, fmt.Errorf("launch command: %w", err)

View File

@ -17,11 +17,16 @@ var ctx = context.Background()
type fakeStore struct {
sessions map[domain.SessionID]domain.SessionRecord
pr map[domain.SessionID]domain.PRFacts
projects map[string]domain.ProjectRecord
num int
}
func newFakeStore() *fakeStore {
return &fakeStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}}
return &fakeStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}, projects: map[string]domain.ProjectRecord{}}
}
func (f *fakeStore) GetProject(_ context.Context, id string) (domain.ProjectRecord, bool, error) {
r, ok := f.projects[id]
return r, ok, nil
}
func (f *fakeStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) {
f.num++
@ -97,12 +102,14 @@ func (l *fakeLCM) MarkTerminated(_ context.Context, id domain.SessionID) error {
type fakeRuntime struct {
createErr error
created, destroyed int
lastCfg ports.RuntimeConfig
}
func (r *fakeRuntime) Create(context.Context, ports.RuntimeConfig) (ports.RuntimeHandle, error) {
func (r *fakeRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) {
if r.createErr != nil {
return ports.RuntimeHandle{}, r.createErr
}
r.lastCfg = cfg
r.created++
return ports.RuntimeHandle{ID: "h1"}, nil
}
@ -135,13 +142,43 @@ type fakeAgents struct{}
func (fakeAgents) Agent(domain.AgentHarness) (ports.Agent, bool) { return fakeAgent{}, true }
// recordingAgent captures the LaunchConfig it is handed so a test can assert the
// session manager resolved and forwarded a project's agent config.
type recordingAgent struct {
fakeAgent
lastConfig ports.AgentConfig
}
func (a *recordingAgent) GetLaunchCommand(_ context.Context, cfg ports.LaunchConfig) ([]string, error) {
a.lastConfig = cfg.Config
return []string{"launch"}, nil
}
func (a *recordingAgent) GetRestoreCommand(_ context.Context, cfg ports.RestoreConfig) ([]string, bool, error) {
a.lastConfig = cfg.Config
return []string{"resume"}, true, nil
}
type singleAgent struct{ agent ports.Agent }
func (s singleAgent) Agent(domain.AgentHarness) (ports.Agent, bool) { return s.agent, true }
type fakeWorkspace struct {
destroyErr error
destroyed int
lastCfg ports.WorkspaceConfig
// path, when set, is returned as the workspace path so provisioning tests
// can point at a real temp directory.
path string
}
func (w *fakeWorkspace) Create(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) {
return ports.WorkspaceInfo{Path: "/ws/" + string(cfg.SessionID), Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil
w.lastCfg = cfg
path := w.path
if path == "" {
path = "/ws/" + string(cfg.SessionID)
}
return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil
}
func (w *fakeWorkspace) Destroy(context.Context, ports.WorkspaceInfo) error {
w.destroyed++
@ -175,6 +212,52 @@ func mkLive(id domain.SessionID) domain.SessionRecord {
return domain.SessionRecord{ID: id, ProjectID: "mer", Metadata: domain.SessionMetadata{WorkspacePath: "/ws/" + string(id), RuntimeHandleID: "h1"}, Activity: domain.Activity{State: domain.ActivityActive}}
}
func TestSpawn_ResolvesProjectConfig(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{
DefaultBranch: "develop",
Env: map[string]string{"FOO": "bar"},
AgentConfig: domain.AgentConfig{Model: "base-model"},
// A worker role override wins over the base agent config for workers.
Worker: domain.RoleOverride{Harness: domain.HarnessCodex, AgentConfig: domain.AgentConfig{Model: "worker-model"}},
}}
agent := &recordingAgent{}
rt := &fakeRuntime{}
ws := &fakeWorkspace{}
lookPath := func(string) (string, error) { return "/bin/true", nil }
m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
rec, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker})
if err != nil {
t.Fatal(err)
}
if agent.lastConfig.Model != "worker-model" {
t.Fatalf("launch model = %q, want role override worker-model", agent.lastConfig.Model)
}
if rec.Harness != domain.HarnessCodex {
t.Fatalf("harness = %q, want codex from role override", rec.Harness)
}
if ws.lastCfg.BaseBranch != "develop" {
t.Fatalf("workspace base branch = %q, want develop", ws.lastCfg.BaseBranch)
}
if rt.lastCfg.Env["FOO"] != "bar" {
t.Fatalf("runtime env FOO = %q, want bar", rt.lastCfg.Env["FOO"])
}
if rt.lastCfg.Env[EnvSessionID] == "" {
t.Fatal("runtime env missing AO_SESSION_ID")
}
// A project with no stored config yields a zero AgentConfig (adapter defaults).
st.projects["bare"] = domain.ProjectRecord{ID: "bare"}
agent.lastConfig = ports.AgentConfig{Model: "stale"}
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "bare", Kind: domain.KindWorker}); err != nil {
t.Fatal(err)
}
if !agent.lastConfig.IsZero() {
t.Fatalf("launch config = %#v, want zero for project without config", agent.lastConfig)
}
}
func TestSpawn_AssignsIDAndGoesIdle(t *testing.T) {
m, st, rt, _ := newManager()
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"})
@ -239,6 +322,22 @@ func TestRestore_ReopensTerminal(t *testing.T) {
t.Fatal("restore should relaunch")
}
}
func TestRestore_AppliesProjectAgentConfig(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{AgentConfig: domain.AgentConfig{Model: "restore-model"}}}
seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"})
agent := &recordingAgent{}
lookPath := func(string) (string, error) { return "/bin/true", nil }
m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
if _, err := m.Restore(ctx, "mer-1"); err != nil {
t.Fatal(err)
}
if agent.lastConfig.Model != "restore-model" {
t.Fatalf("restore config model = %q, want restore-model (config must carry across restore)", agent.lastConfig.Model)
}
}
func TestRestore_RefusesLiveSession(t *testing.T) {
m, st, _, _ := newManager()
st.sessions["mer-1"] = mkLive("mer-1")

View File

@ -0,0 +1,104 @@
package sessionmanager
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
func TestSpawnEnvProjectVarsCannotOverrideInternal(t *testing.T) {
env := spawnEnv("mer-1", "mer", "issue-9", "/data", map[string]string{
"FOO": "bar",
EnvSessionID: "hacked", // a project must not override AO-internal vars
EnvProjectID: "hacked",
})
if env["FOO"] != "bar" {
t.Fatalf("FOO = %q, want bar", env["FOO"])
}
if env[EnvSessionID] != "mer-1" {
t.Fatalf("AO_SESSION_ID = %q, want mer-1 (internal wins)", env[EnvSessionID])
}
if env[EnvProjectID] != "mer" {
t.Fatalf("AO_PROJECT_ID = %q, want mer (internal wins)", env[EnvProjectID])
}
}
func TestEffectiveHarnessAndAgentConfig(t *testing.T) {
cfg := domain.ProjectConfig{
AgentConfig: domain.AgentConfig{Model: "base", Permissions: domain.PermissionModeAuto},
Worker: domain.RoleOverride{Harness: domain.HarnessCodex, AgentConfig: domain.AgentConfig{Model: "worker"}},
Orchestrator: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
}
// Explicit harness always wins.
if h := effectiveHarness(domain.HarnessAider, domain.KindWorker, cfg); h != domain.HarnessAider {
t.Fatalf("explicit harness = %q, want aider", h)
}
// Empty harness falls back to the role override per kind.
if h := effectiveHarness("", domain.KindWorker, cfg); h != domain.HarnessCodex {
t.Fatalf("worker harness = %q, want codex", h)
}
if h := effectiveHarness("", domain.KindOrchestrator, cfg); h != domain.HarnessClaudeCode {
t.Fatalf("orchestrator harness = %q, want claude-code", h)
}
// Role override merges over the base agent config (set fields win; unset keep base).
got := effectiveAgentConfig(domain.KindWorker, cfg)
if got.Model != "worker" || got.Permissions != domain.PermissionModeAuto {
t.Fatalf("merged worker config = %#v, want model=worker permissions=auto", got)
}
// Orchestrator has no agent-config override, so the base config is used as-is.
if got := effectiveAgentConfig(domain.KindOrchestrator, cfg); got.Model != "base" {
t.Fatalf("orchestrator config = %#v, want base", got)
}
}
func TestApplySymlinks(t *testing.T) {
project := t.TempDir()
workspace := t.TempDir()
if err := os.WriteFile(filepath.Join(project, ".env"), []byte("X=1"), 0o644); err != nil {
t.Fatal(err)
}
// A present source is linked; a missing source is skipped, not an error.
if err := applySymlinks(project, workspace, []string{".env", "missing.txt"}); err != nil {
t.Fatalf("applySymlinks: %v", err)
}
target := filepath.Join(workspace, ".env")
if data, err := os.ReadFile(target); err != nil || string(data) != "X=1" {
t.Fatalf("symlinked .env = %q err=%v", data, err)
}
if _, err := os.Lstat(filepath.Join(workspace, "missing.txt")); !os.IsNotExist(err) {
t.Fatal("missing source should not have been linked")
}
}
func TestApplySymlinksRejectsParentTraversal(t *testing.T) {
project := t.TempDir()
workspace := t.TempDir()
// A "..", "/" or "../" segment escapes the project tree and must be refused
// before any stat/link runs, so a project config cannot link in arbitrary
// host files.
for _, bad := range []string{"../escape", "/etc/passwd", "a/../../b", ".."} {
if err := applySymlinks(project, workspace, []string{bad}); err == nil {
t.Fatalf("applySymlinks(%q) accepted an unsafe path", bad)
}
}
}
func TestRunPostCreate(t *testing.T) {
workspace := t.TempDir()
if err := runPostCreate(context.Background(), workspace, []string{"echo hi > out.txt"}); err != nil {
t.Fatalf("runPostCreate: %v", err)
}
if _, err := os.Stat(filepath.Join(workspace, "out.txt")); err != nil {
t.Fatalf("post-create command did not run in workspace: %v", err)
}
// A failing command surfaces an error.
if err := runPostCreate(context.Background(), workspace, []string{"exit 3"}); err == nil {
t.Fatal("expected error from failing post-create command")
}
}

View File

@ -107,6 +107,7 @@ type Project struct {
DisplayName string
RegisteredAt time.Time
ArchivedAt sql.NullTime
Config sql.NullString
}
type Session struct {

View File

@ -31,7 +31,7 @@ func (q *Queries) ArchiveProject(ctx context.Context, arg ArchiveProjectParams)
}
const findProjectByPath = `-- name: FindProjectByPath :one
SELECT id, path, repo_origin_url, display_name, registered_at, archived_at
SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config
FROM projects WHERE path = ? AND archived_at IS NULL
`
@ -45,12 +45,13 @@ func (q *Queries) FindProjectByPath(ctx context.Context, path string) (Project,
&i.DisplayName,
&i.RegisteredAt,
&i.ArchivedAt,
&i.Config,
)
return i, err
}
const getProject = `-- name: GetProject :one
SELECT id, path, repo_origin_url, display_name, registered_at, archived_at
SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config
FROM projects WHERE id = ?
`
@ -64,12 +65,13 @@ func (q *Queries) GetProject(ctx context.Context, id domain.ProjectID) (Project,
&i.DisplayName,
&i.RegisteredAt,
&i.ArchivedAt,
&i.Config,
)
return i, err
}
const listProjects = `-- name: ListProjects :many
SELECT id, path, repo_origin_url, display_name, registered_at, archived_at
SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config
FROM projects WHERE archived_at IS NULL ORDER BY id
`
@ -89,6 +91,7 @@ func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) {
&i.DisplayName,
&i.RegisteredAt,
&i.ArchivedAt,
&i.Config,
); err != nil {
return nil, err
}
@ -104,13 +107,14 @@ func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) {
}
const upsertProject = `-- name: UpsertProject :exec
INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at, config)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
path = excluded.path,
repo_origin_url = excluded.repo_origin_url,
display_name = excluded.display_name,
archived_at = excluded.archived_at
archived_at = excluded.archived_at,
config = excluded.config
`
type UpsertProjectParams struct {
@ -120,6 +124,7 @@ type UpsertProjectParams struct {
DisplayName string
RegisteredAt time.Time
ArchivedAt sql.NullTime
Config sql.NullString
}
func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) error {
@ -130,6 +135,7 @@ func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) er
arg.DisplayName,
arg.RegisteredAt,
arg.ArchivedAt,
arg.Config,
)
return err
}

View File

@ -0,0 +1,15 @@
-- Per-project configuration. A single nullable JSON column on projects holds the
-- typed ProjectConfig (agent settings, env, symlinks, post-create, rules, role
-- overrides, tracker/scm, …) AO resolves at spawn. NULL means unset; a non-NULL
-- value is a JSON object. One blob per project keeps the registry's "SQLite twin
-- of the YAML config" shape rather than splitting config into many columns.
-- +goose Up
-- +goose StatementBegin
ALTER TABLE projects ADD COLUMN config TEXT;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE projects DROP COLUMN config;
-- +goose StatementEnd

View File

@ -1,22 +1,23 @@
-- name: UpsertProject :exec
INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at, config)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
path = excluded.path,
repo_origin_url = excluded.repo_origin_url,
display_name = excluded.display_name,
archived_at = excluded.archived_at;
archived_at = excluded.archived_at,
config = excluded.config;
-- name: GetProject :one
SELECT id, path, repo_origin_url, display_name, registered_at, archived_at
SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config
FROM projects WHERE id = ?;
-- name: ListProjects :many
SELECT id, path, repo_origin_url, display_name, registered_at, archived_at
SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config
FROM projects WHERE archived_at IS NULL ORDER BY id;
-- name: FindProjectByPath :one
SELECT id, path, repo_origin_url, display_name, registered_at, archived_at
SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config
FROM projects WHERE path = ? AND archived_at IS NULL;
-- name: ArchiveProject :execrows

View File

@ -3,6 +3,7 @@ package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
@ -13,6 +14,10 @@ import (
// UpsertProject inserts or replaces a registered project row.
func (s *Store) UpsertProject(ctx context.Context, r domain.ProjectRecord) error {
config, err := marshalProjectConfig(r.Config)
if err != nil {
return err
}
s.writeMu.Lock()
defer s.writeMu.Unlock()
return s.qw.UpsertProject(ctx, gen.UpsertProjectParams{
@ -22,6 +27,7 @@ func (s *Store) UpsertProject(ctx context.Context, r domain.ProjectRecord) error
DisplayName: r.DisplayName,
RegisteredAt: r.RegisteredAt,
ArchivedAt: nullTime(r.ArchivedAt),
Config: config,
})
}
@ -83,6 +89,7 @@ func projectRowFromGen(p gen.Project) domain.ProjectRecord {
RepoOriginURL: p.RepoOriginURL,
DisplayName: p.DisplayName,
RegisteredAt: p.RegisteredAt,
Config: unmarshalProjectConfig(p.Config),
}
if p.ArchivedAt.Valid {
r.ArchivedAt = p.ArchivedAt.Time
@ -90,6 +97,36 @@ func projectRowFromGen(p gen.Project) domain.ProjectRecord {
return r
}
// marshalProjectConfig encodes the typed per-project config into the nullable
// JSON column. An IsZero config stores SQL NULL so an unset config round-trips
// back to a zero value rather than an empty object.
func marshalProjectConfig(cfg domain.ProjectConfig) (sql.NullString, error) {
if cfg.IsZero() {
return sql.NullString{}, nil
}
data, err := json.Marshal(cfg)
if err != nil {
return sql.NullString{}, fmt.Errorf("marshal project config: %w", err)
}
return sql.NullString{String: string(data), Valid: true}, nil
}
// unmarshalProjectConfig decodes the nullable JSON column back into the typed
// struct. SQL NULL (an unset config) decodes to a zero value. A damaged config
// (invalid JSON from a direct DB edit or migration bug) also degrades to a zero
// config rather than erroring — a corrupt config must never block access to the
// project row, nor fail an entire ListProjects.
func unmarshalProjectConfig(s sql.NullString) domain.ProjectConfig {
if !s.Valid || s.String == "" {
return domain.ProjectConfig{}
}
var cfg domain.ProjectConfig
if err := json.Unmarshal([]byte(s.String), &cfg); err != nil {
return domain.ProjectConfig{}
}
return cfg
}
func nullTime(t time.Time) sql.NullTime {
if t.IsZero() {
return sql.NullTime{}

View File

@ -0,0 +1,24 @@
package store
import (
"database/sql"
"testing"
)
func TestUnmarshalProjectConfigDegradesGracefully(t *testing.T) {
// SQL NULL / empty → zero config.
if got := unmarshalProjectConfig(sql.NullString{}); !got.IsZero() {
t.Fatalf("NULL config = %#v, want zero", got)
}
// Valid JSON decodes.
if got := unmarshalProjectConfig(sql.NullString{String: `{"defaultBranch":"develop"}`, Valid: true}); got.DefaultBranch != "develop" {
t.Fatalf("valid config DefaultBranch = %q, want develop", got.DefaultBranch)
}
// Corrupt JSON must NOT error — it degrades to a zero config so the project
// row (and ListProjects) stay accessible.
if got := unmarshalProjectConfig(sql.NullString{String: `{not json`, Valid: true}); !got.IsZero() {
t.Fatalf("corrupt config = %#v, want zero (degraded)", got)
}
}

View File

@ -3,6 +3,7 @@ package store_test
import (
"context"
"encoding/json"
"reflect"
"sync"
"testing"
"time"
@ -71,6 +72,52 @@ func TestProjectCRUDAndArchive(t *testing.T) {
}
}
func TestProjectConfigRoundTrips(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
// A config with mixed field kinds (scalar, map, list, nested) survives the
// JSON round trip.
cfg := domain.ProjectConfig{
DefaultBranch: "develop",
Env: map[string]string{"FOO": "bar"},
Symlinks: []string{".env"},
PostCreate: []string{"echo hi"},
AgentConfig: domain.AgentConfig{Model: "claude-opus-4-5", Permissions: domain.PermissionModeAcceptEdits},
Worker: domain.RoleOverride{Harness: domain.HarnessCodex},
}
if err := s.UpsertProject(ctx, domain.ProjectRecord{
ID: "cfg", Path: "/tmp/cfg", RegisteredAt: now, Config: cfg,
}); err != nil {
t.Fatalf("upsert with config: %v", err)
}
got, ok, err := s.GetProject(ctx, "cfg")
if err != nil || !ok {
t.Fatalf("get: ok=%v err=%v", ok, err)
}
if !reflect.DeepEqual(got.Config, cfg) {
t.Fatalf("config = %#v, want %#v", got.Config, cfg)
}
// An unset config round-trips back to a zero value rather than an empty object.
seedProject(t, s, "nocfg")
got, _, _ = s.GetProject(ctx, "nocfg")
if !got.Config.IsZero() {
t.Fatalf("unset config = %#v, want zero", got.Config)
}
// Clearing replaces a previously-set config with a zero value.
if err := s.UpsertProject(ctx, domain.ProjectRecord{
ID: "cfg", Path: "/tmp/cfg", RegisteredAt: now, Config: domain.ProjectConfig{},
}); err != nil {
t.Fatalf("clear config: %v", err)
}
if got, _, _ := s.GetProject(ctx, "cfg"); !got.Config.IsZero() {
t.Fatalf("cleared config = %#v, want zero", got.Config)
}
}
func TestSessionCreateAssignsPerProjectID(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()

View File

@ -0,0 +1,129 @@
# Design: typed per-project configuration
Status: **partially implemented**`ProjectConfig` is typed, validated,
persisted (one `projects.config` JSON column), and surfaced via
`ao project set-config` + `PUT /projects/{id}/config`. The struct deliberately
carries only fields with a live consumer: `defaultBranch`, `env`, `symlinks`,
`postCreate`, `agentConfig`, and the `worker`/`orchestrator` role overrides are
wired at spawn; `sessionPrefix` feeds the display prefix. Settings whose
consumers do not yet exist — per-project `tracker`/`scm` config and prompt
`rules` — are intentionally **not** modeled yet and land in focused follow-up
PRs alongside the code that reads them (see "Sequencing" below). Cross-agent
`agentConfig.model`/`permissions` support is tracked in #157.
## Goal
Every per-project setting the legacy `agent-orchestrator.yaml` carried under
`projects:` should live as **typed, validated state** in SQLite, reachable
through exactly two entry points:
1. **CLI**`ao project ...` (thin client → daemon HTTP)
2. **UI** — the dashboard project settings form
There is no YAML loader in the Go rewrite, so this is not about parsing a file —
it is about giving each former YAML field a typed home, a validation owner, and a
CLI/API/UI surface. No setting should be a free-form `map[string]any`.
## Principle: typed over map
The legacy `agentConfig` was an open `map` (`.passthrough()`), which is why early
storage modeled it as `map[string]any`. That defers validation to spawn time and
forces the UI to render raw JSON. We instead model each setting as a **typed Go
struct** with a `Validate()` method, so:
- bad values are rejected when **set** (CLI/API), not silently dropped at spawn;
- the OpenAPI spec and frontend TS types are generated with real fields;
- the UI renders a typed form instead of a JSON textarea.
Adapter-specific keys, if ever needed, become typed fields owned by `domain`
rather than an escape-hatch map.
## Field catalog (legacy `projects.<id>`) and target home
| YAML field | Type | Storage today | Target |
|---|---|---|---|
| `name` | string | `projects.display_name` | done |
| `repo` | string | `projects.repo_origin_url` | done |
| `path` | string | `projects.path` | done |
| `defaultBranch` | string | hardcoded `"main"` | `projects.default_branch` |
| `sessionPrefix` | string | derived | `projects.session_prefix` |
| `agentConfig` | `{model, permissions}` | **`projects.agent_config` (typed)** | **done (this PR)** |
| `orchestrator`/`worker` overrides | `{agent, agentConfig}` | — | typed role-override columns/blob |
| `env` | `map[string]string` | — | `project_env` table (key/value rows) |
| `symlinks` | `[]string` | — | `projects.symlinks` (JSON) |
| `postCreate` | `[]string` | — | `projects.post_create` (JSON) |
| `agentRules` / `agentRulesFile` | string | partial (`SpawnConfig.AgentRules`) | `projects.agent_rules*` |
| `orchestratorRules` | string | — | `projects.orchestrator_rules` |
| `tracker` | `{plugin, …}` | DTO stub only | `projects.tracker` (typed blob) + adapter validation |
| `scm` | `{plugin, webhook{…}}` | DTO stub only | `projects.scm` (typed blob) + adapter validation |
| `opencodeIssueSessionStrategy` | enum | — | `projects.opencode_session_strategy` |
| `reactions` | per-project overrides | — | `project_reactions` (own slice) |
## Typed model
```go
// domain
type AgentConfig struct { // implemented
Model string `json:"model,omitempty"`
Permissions PermissionMode `json:"permissions,omitempty"`
}
func (c AgentConfig) Validate() error { ... }
// implemented today — only fields with a live consumer are modeled
type ProjectConfig struct {
DefaultBranch string
SessionPrefix string
AgentConfig AgentConfig
Worker RoleOverride // {Harness, AgentConfig}
Orchestrator RoleOverride
Env map[string]string
Symlinks []string
PostCreate []string
// future slices add fields here as their consumers land:
// AgentRules / AgentRulesFile / OrchestratorRules (prompt rules)
// Tracker TrackerConfig // adapter-validated
// SCM SCMConfig // adapter-validated
}
```
Each leaf type owns a `Validate()`. Plugin-shaped settings (`tracker`, `scm`)
delegate to the selected adapter, mirroring how `agentConfig` is consumed by the
agent adapter.
## Storage strategy
- **Scalar fields** (`default_branch`, `session_prefix`, `agent_rules`, enums) →
their own typed columns on `projects`.
- **Small structured blobs** (`agent_config`, `tracker`, `scm`, `symlinks`,
`post_create`) → nullable JSON columns, marshaled/unmarshaled in the store
(the pattern this PR established for `agent_config`).
- **Unbounded key/value sets** (`env`) → a child table keyed by `project_id`.
- **Its own domain** (`reactions`) → a separate slice; reactions already have a
reaction engine to integrate with.
## Surface (per field)
- **API** — extend the projects controller. Field groups get focused routes
(e.g. `PUT /projects/{id}/agent-config`, `PUT /projects/{id}/env`) rather than
one mega-PUT, so partial updates are clean and the OpenAPI stays legible.
- **CLI** — typed flags on `ao project` subcommands (e.g.
`ao project set-config --model --permission`, `ao project env set KEY=VAL`).
- **UI** — a generated typed form per group, driven by the OpenAPI schema.
## Sequencing (one slice per PR)
1. **agentConfig (typed)***this PR*. Establishes the typed+validated+surfaced
pattern end to end.
2. **Project identity scalars**`default_branch`, `session_prefix` (stop
hardcoding/deriving them).
3. **Workspace provisioning**`env`, `symlinks`, `postCreate` (these change
spawn/workspace wiring, so grouped).
4. **Rules**`agentRules`, `agentRulesFile`, `orchestratorRules` (consolidate
the partial `SpawnConfig.AgentRules` path).
5. **Role overrides**`worker` / `orchestrator` `{agent, agentConfig}`.
6. **Tracker / SCM per-project** — typed blobs with adapter-owned validation.
7. **Per-project reactions** — integrate with the reaction engine.
Each slice is independently shippable and follows the same shape: domain type +
`Validate()` → storage (column or blob or table) → service set/get → API route →
CLI flags → UI form → tests.

View File

@ -92,6 +92,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/projects/{id}/config": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/** Replace a project's per-project config */
put: operations["setProjectConfig"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/prs/{id}/merge": {
parameters: {
query?: never;
@ -312,10 +329,15 @@ export interface components {
requestId?: string;
};
AddProjectInput: {
config?: components["schemas"]["ProjectConfig"];
name?: null | string;
path: string;
projectId?: null | string;
};
AgentConfig: {
model?: string;
permissions?: string;
};
ClaimPRRequest: {
allowTakeover?: null | boolean;
pr: string;
@ -369,13 +391,24 @@ export interface components {
};
Project: {
agent?: string;
config?: components["schemas"]["ProjectConfig"];
defaultBranch: string;
id: string;
name: string;
path: string;
repo: string;
scm?: components["schemas"]["SCMConfig"];
tracker?: components["schemas"]["TrackerConfig"];
};
ProjectConfig: {
agentConfig?: components["schemas"]["AgentConfig"];
defaultBranch?: string;
env?: {
[key: string]: string;
};
orchestrator?: components["schemas"]["RoleOverride"];
postCreate?: string[];
sessionPrefix?: string;
symlinks?: string[];
worker?: components["schemas"]["RoleOverride"];
};
ProjectGetResponse: {
project: components["schemas"]["ProjectOrDegraded"];
@ -413,27 +446,16 @@ export interface components {
session: components["schemas"]["Session"];
sessionId: string;
};
RoleOverride: {
agent?: string;
agentConfig?: components["schemas"]["AgentConfig"];
};
RollbackSessionResponse: {
deleted?: boolean;
killed?: boolean;
ok: boolean;
sessionId: string;
};
SCMConfig: {
package?: string;
path?: string;
plugin?: string;
webhook?: components["schemas"]["SCMWebhookConfig"];
};
SCMWebhookConfig: {
deliveryHeader?: string;
enabled?: null | boolean;
eventHeader?: string;
maxBodyBytes?: number;
path?: string;
secretEnvVar?: string;
signatureHeader?: string;
};
SendSessionMessageRequest: {
message: string;
};
@ -484,6 +506,9 @@ export interface components {
sessionId: string;
state: string;
};
SetProjectConfigInput: {
config: components["schemas"]["ProjectConfig"];
};
SpawnOrchestratorRequest: {
clean?: boolean;
projectId: string;
@ -502,11 +527,6 @@ export interface components {
projectId: string;
prompt?: string;
};
TrackerConfig: {
package?: string;
path?: string;
plugin?: string;
};
};
responses: never;
parameters: never;
@ -885,6 +905,60 @@ export interface operations {
};
};
};
setProjectConfig: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project identifier (registry key). */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SetProjectConfigInput"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectResponse"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
};
};
mergePR: {
parameters: {
query?: never;