feat(config): persist per-project agent config and resolve it at spawn#154
Conversation
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>
Greptile SummaryThis PR wires per-project agent configuration end-to-end: a new nullable
Confidence Score: 3/5The spawn path is complete and correct, but the restore path creates a fresh workspace without running symlinks or post-create commands — sessions that rely on provisioned files will start in an incomplete workspace after restore. The new backend/internal/session_manager/manager.go — the Important Files Changed
Sequence DiagramsequenceDiagram
participant CLI as ao CLI
participant HTTP as HTTP Controller
participant Svc as Project Service
participant Store as SQLite Store
participant SM as Session Manager
participant WS as Workspace Adapter
participant Agent as Agent Adapter
CLI->>HTTP: "PUT /api/v1/projects/{id}/config"
HTTP->>Svc: SetConfig(id, ProjectConfig)
Svc->>Svc: ProjectConfig.Validate()
Svc->>Store: UpsertProject(row with Config JSON)
Store-->>Svc: ok
Svc-->>HTTP: Project read-model
HTTP-->>CLI: 200 ProjectResponse
Note over SM,Agent: Spawn path (config fully resolved)
SM->>Store: GetProject(projectID)
Store-->>SM: "ProjectRecord{Config}"
SM->>SM: effectiveHarness(kind, Config)
SM->>WS: "Create(WorkspaceConfig{BaseBranch})"
WS-->>SM: "WorkspaceInfo{Path}"
SM->>SM: provisionWorkspace(symlinks + postCreate)
SM->>Agent: "GetLaunchCommand(LaunchConfig{Config: effectiveAgentConfig})"
Agent-->>SM: argv
SM->>SM: spawnEnv(... projectEnv)
Note over SM,Agent: Restore path (provisionWorkspace missing)
SM->>WS: Restore(WorkspaceConfig) - fresh worktree
WS-->>SM: "WorkspaceInfo{Path}"
SM->>Store: GetProject(projectID)
Store-->>SM: "ProjectRecord{Config}"
SM->>Agent: GetRestoreCommand / GetLaunchCommand with agentConfig
SM->>SM: spawnEnv(... projectEnv)
Note right of SM: provisionWorkspace NOT called here
|
…led 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>
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>
…urface)
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>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review: default-config / fail-safe pathsReviewing specifically from the lens of: if a user defines no config (or partial config), spawning should never fail. The overall direction is correct — 1.
|
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>
|
Thanks — great fail-safe review. Addressed in 1f86e8c: 1. AgentRulesFile typo → spawn failure (high) — Fixed. 2. Corrupt config JSON blocks the project (medium) — Fixed. 3. Restore doesn't apply AgentConfig (low) — Wired it now rather than deferring: Restore loads the project and passes 4. IsZero / reflect.DeepEqual note — Keeping |
Local test run report — PR #154 per-project agent configBinary: built from Results summary
All 14 test cases pass. 0 failures. TC detailTC-01 — no config TC-02 — model override "agentConfig": { "model": "claude-opus-4-5" } ✓TC-03 — permissions + invalid rejection TC-04 — harness override + invalid rejection TC-05 — default branch TC-06 — env vars TC-07 — daemon restart persistence TC-08 — all three invalid-value paths TC-09 — clear TC-10 — role override merge TC-11 — --config-json { "defaultBranch": "release", "agentConfig": { "model": "claude-haiku-4-5", "permissions": "bypass-permissions" }, "worker": { "agent": "aider" } }
→ all fields round-tripped correctly ✓TC-12 — config at add-time (API) TC-13 — missing agentRulesFile (review finding #1 — fixed in TC-14 — corrupt config JSON (review finding #2 — fixed in One observation: empty role agentConfig objects in responseWhen only "worker": { "agentConfig": {} },
"orchestrator": { "agentConfig": {} }These empty objects appear because Review findings status
🤖 Generated with Claude Code |
|
A few scope/shape notes after reviewing the added config fields:
Net: this PR should land the config surface only for fields that are actually used now, and avoid recreating the old YAML surface ahead of consumers. |
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>
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>
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>
harshitsinghbhandari
left a comment
There was a problem hiding this comment.
Reviewed end-to-end at head 700e874. CI is green on all 8 checks, lint is clean, and go test -race ./... passes (1127 tests). The scope is now exactly what we agreed: only fields with a live consumer (defaultBranch, sessionPrefix, env, symlinks, postCreate, agentConfig, role overrides). Rules/tracker/scm/opencode are deferred behind their consumers, cross-agent model/permissions tracked in #157.
What I checked
Correctness / fail-safe paths
loadProjectreturns a zero record on miss;effectiveAgentConfigmerges over zero safely;WithDefaults()guaranteesBaseBranch == "main".unmarshalProjectConfigdegrades a corrupt JSON column to a zero config soListProjectsandGetProjectstay accessible.- Restore parity: both
GetRestoreCommandand the fallbackGetLaunchCommandreceive the resolvedAgentConfig, so a configured model carries across restore. spawnEnvwrites the project env first, AO internals last —AO_SESSION_ID& friends can't be overridden.
Security
- Symlink path traversal is gated twice:
ProjectConfig.Validaterejects../absolute paths at write time (→INVALID_PROJECT_CONFIG), andapplySymlinksre-checks at spawn as defense-in-depth. Covers..,/etc/passwd,a/../../b, bare... POST /projectsandPUT /projects/{id}/configuseDisallowUnknownFields, so a misspelled or removed field surfaces as a clean 400 instead of silently dropping into the row. Test coversagentRules/tracker/orchestratorRulesregressions.- Symlink dir perms tightened to
0o750(gosec G301).
Tests — coverage matches the surface: domain Validate (happy/error/traversal), claudecode adapter (apply/override/reject-bad-config), service layer (defaults/persist/invalid-rejection), session_manager (spawn resolution, restore parity, env precedence, symlinks), store (round-trip mixed kinds, unset→zero, clear→zero, corrupt→zero).
Migration — 0008_add_project_config.sql adds a nullable config TEXT column. Backward-compatible (existing rows decode to zero config).
Minor follow-ups (non-blocking)
- Duplicated path validator —
domain.validateRepoRelativeandsessionmanager.safeRelPathare essentially the same check. The split is defensible (runtime one returns the cleaned path for joining), but worth consolidating into a shared helper later so they can't drift. - Empty role-override objects in responses — when only
--modelis set, the response includes"worker": {"agentConfig": {}}and"orchestrator": {"agentConfig": {}}. Functionally harmless (effectiveAgentConfig treats{}as no-override) but noisy on the wire. Worth omitting zero-value role overrides as a follow-up. runtime.GOOSinline inrunPostCreate— already discussed; the inline pattern matcheszellij+ the agent adapters. Fine to keep; if aplatformhelper lands later it should sweep all call sites together.
LGTM, approving. 🚀
Closes #107
Summary
Wires the previously-dormant per-project agent config half of the project registry. A project can now store agent config (model, permissions, adapter-specific keys) that survives daemon restart and is resolved into the launch command at spawn.
The DB row is authoritative, written through the existing
ao projectCLI → daemon HTTP → SQLite path (no YAML loader exists in the Go rewrite — see note below).Changes
projects.agent_configJSON column (migration0008). The store marshals/unmarshals sodomain.ProjectRecord.AgentConfigis a plainmap[string]any(matchesports.AgentConfig, no translation layer). Unset round-trips tonil, not{}.session_managerloads the project row and populatesLaunchConfig.ConfigbeforeGetLaunchCommand. A missing project yields a nil config (adapter defaults) rather than a spawn failure.claude-codenow declares a realConfigSpec(model,permissions) and validatescfg.Configagainst it at spawn: unknown key, wrong primitive type, or out-of-set enum → clear error. It applies themodeloverride (--model) and a config-driven permission mode (an explicitLaunchConfig.Permissionsstill wins).PUT /api/v1/projects/{id}/agent-config+ao project set-config <id>(--set key=value,--config-json,--clear); config shown inao project get. OpenAPI + frontend TS schema regenerated.Note on the issue comments
The "solution design" comment framed this as YAML-authoritative with a write-through cache and a config sync loop. That's the legacy TS model — the Go rewrite has no YAML project loader (global config is env vars; projects come via
ao project add). This PR follows the issue body + the first comment: the DB row is the source of truth, written via the CLI/API. The file checklist from that comment is otherwise followed (migration number bumped0004→0008).Acceptance criteria
AgentConfiginLaunchConfig.Config.Tests
SetAgentConfigpersists + not-found path🤖 Generated with Claude Code