Skip to content

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

Merged
harshitsinghbhandari merged 11 commits into
mainfrom
feat/issue-107
Jun 8, 2026
Merged

feat(config): persist per-project agent config and resolve it at spawn#154
harshitsinghbhandari merged 11 commits into
mainfrom
feat/issue-107

Conversation

@neversettle17-101

Copy link
Copy Markdown
Collaborator

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 project CLI → daemon HTTP → SQLite path (no YAML loader exists in the Go rewrite — see note below).

Changes

  1. Storage — nullable projects.agent_config JSON column (migration 0008). The store marshals/unmarshals so domain.ProjectRecord.AgentConfig is a plain map[string]any (matches ports.AgentConfig, no translation layer). Unset round-trips to nil, not {}.
  2. Resolutionsession_manager loads the project row and populates LaunchConfig.Config before GetLaunchCommand. A missing project yields a nil config (adapter defaults) rather than a spawn failure.
  3. Validationclaude-code now declares a real ConfigSpec (model, permissions) and validates cfg.Config against it at spawn: unknown key, wrong primitive type, or out-of-set enum → clear error. It applies the model override (--model) and a config-driven permission mode (an explicit LaunchConfig.Permissions still wins).
  4. SurfacePUT /api/v1/projects/{id}/agent-config + ao project set-config <id> (--set key=value, --config-json, --clear); config shown in ao 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 bumped 00040008).

Acceptance criteria

  • ✅ Config survives daemon restart (persisted in SQLite).
  • ✅ A spawn receives the resolved AgentConfig in LaunchConfig.Config.
  • ✅ Invalid keys/values rejected by the owning adapter with a clear error.
  • ✅ OS-agnostic; no new env-only config for per-project state.

Tests

  • adapter: applies model/permissions, explicit Permissions overrides config, rejects bad config (unknown key / wrong type / bad enum)
  • store: agent_config round-trips (mixed values, unset→nil, clear→nil)
  • service: SetAgentConfig persists + not-found path
  • session manager: spawn resolves project config into the launch command; nil for projects without config

🤖 Generated with Claude Code

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-apps

greptile-apps Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR wires per-project agent configuration end-to-end: a new nullable config JSON column (migration 0008), typed domain.ProjectConfig / AgentConfig, a PUT /api/v1/projects/{id}/config endpoint, and resolution at spawn time (harness overrides, model, permissions, env, base branch, symlinks, post-create commands).

  • Storage: ProjectConfig is marshalled as a nullable JSON blob; NULL round-trips to zero config cleanly.
  • Spawn resolution: session_manager loads the project row and applies harness overrides, typed agent config, per-project env, base branch, and workspace provisioning (symlinks + post-create) before starting the agent.
  • Restore gap: workspace.Restore creates a fresh git worktree just like workspace.Create, but provisionWorkspace is not called in the restore path — configured symlinks and post-create commands are skipped for restored sessions.

Confidence Score: 3/5

The 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 provisionWorkspace call (symlinks + post-create) is wired into Spawn but not into Restore. Because workspace.Restore creates an entirely new git worktree, any project-configured .env symlinks or setup scripts (e.g. pnpm install) are silently omitted when a session is restored, leaving the agent in a directory that may be missing expected files or installed dependencies. Everything else in the PR — typed config, validation, storage, agent config resolution, env overrides — is implemented correctly on both code paths.

backend/internal/session_manager/manager.go — the Restore function needs a provisionWorkspace call after loadProject.

Important Files Changed

Filename Overview
backend/internal/session_manager/manager.go Adds project config resolution at spawn and restore: harness overrides, agent config, env, base branch, symlinks, and post-create commands. The Restore path applies agent config and env correctly but omits provisionWorkspace, leaving symlinks and post-create commands unapplied after a restore.
backend/internal/domain/projectconfig.go New typed ProjectConfig with validateRepoRelative path-traversal guard for symlinks, validation of role-override harnesses, and WithDefaults/IsZero helpers. Solid.
backend/internal/domain/agentconfig.go Typed AgentConfig and PermissionMode moved to domain; Validate enforces the permission enum at write time. Clean.
backend/internal/adapters/agent/claudecode/claudecode.go Implements GetConfigSpec (model + permissions fields), applies --model and --permission-mode from typed AgentConfig, and validates config in GetLaunchCommand as defense-in-depth. Explicit Permissions overrides config correctly.
backend/internal/storage/sqlite/store/project_store.go Marshals/unmarshals ProjectConfig as nullable JSON; NULL round-trips to zero config correctly. Migration 0008 adds the nullable config TEXT column.
backend/internal/service/project/service.go Adds SetConfig service method with validation and not-found guard; projectFromRow now reads DefaultBranch from stored config; sessionPrefix respects per-project override. All paths look correct.
backend/internal/httpd/controllers/projects.go Adds PUT /api/v1/projects/{id}/config handler using strict JSON decoding (DisallowUnknownFields) and upgrades POST /projects to the same strict decoder. Clean.
backend/internal/cli/project.go Adds set-config subcommand with flag-based and --config-json modes; mirrors the typed domain config in CLI DTOs; buildProjectConfig correctly validates that at least one flag is set.

Sequence Diagram

sequenceDiagram
    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
Loading

Comments Outside Diff (1)

  1. backend/internal/session_manager/manager.go, line 371-396 (link)

    P1 provisionWorkspace skipped on Restore, leaving symlinks and post-create unrun

    workspace.Restore (line 371) creates a brand-new git worktree — the branch already existed but there is no live filesystem at that path. The Spawn path calls provisionWorkspace immediately after workspace.Create for exactly this reason, but the Restore path does not. Any project with Symlinks entries (e.g. .env) will have those files missing in the restored workspace, and PostCreate commands (e.g. pnpm install) will not run, so the agent starts in a partially-provisioned directory that may be missing expected runtime dependencies.

    The fix is to call m.provisionWorkspace(ctx, project, ws.Path) in Restore after loadProject — the project config is already available at that point.

Reviews (8): Last reviewed commit: "fix(config): reject symlink path travers..." | Re-trigger Greptile

Comment thread backend/internal/adapters/agent/claudecode/claudecode.go Outdated
Comment thread backend/internal/adapters/agent/claudecode/claudecode.go Outdated
neversettle17-101 and others added 5 commits June 7, 2026 11:14
…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>
Comment thread backend/internal/domain/projectconfig.go Outdated
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@neversettle17-101

Copy link
Copy Markdown
Collaborator Author

Review: default-config / fail-safe paths

Reviewing specifically from the lens of: if a user defines no config (or partial config), spawning should never fail.

The overall direction is correct — loadProject returns a zero record on miss, effectiveAgentConfig merges over zero safely, and WithDefaults() ensures BaseBranch is always "main" even with no config. But there are four places where a missing or bad config can still surface as a hard failure.


1. AgentRulesFile turns a typo into a spawn failure (high impact)

projectRules in session_manager/manager.go treats a missing rules file as an error that aborts spawn:

data, err := os.ReadFile(path)
if err != nil {
    return "", fmt.Errorf("agent rules file: %w", err)  // ← spawn fails
}

If a user sets agentRulesFile: .rules.md and that file is later deleted, renamed, or was never there, every subsequent spawn for that project fails — silently from their perspective, because the error is buried in spawn logs. The file is optional context, not a hard dependency.

Suggested fix: treat a missing file as a warning, not an error. Return the inline rules without the file contents, and log a warning (or surface it on the session record).

data, err := os.ReadFile(path)
if err != nil {
    if !errors.Is(err, os.ErrNotExist) {
        return "", fmt.Errorf("agent rules file: %w", err)
    }
    // Missing file: skip silently or log, don't abort spawn
} else {
    rules = appendPromptSection(rules, strings.TrimRight(string(data), "\n"))
}

2. Corrupted config JSON in DB makes the project inaccessible (medium impact)

unmarshalProjectConfig returns an error on bad JSON, which propagates through GetProject, ListProjects, and FindProjectByPath. If a row's config column ever contains invalid JSON (direct DB edit, migration bug, encoding edge case), every operation on that project — and every list call — fails.

func unmarshalProjectConfig(s sql.NullString) (domain.ProjectConfig, error) {
    ...
    if err := json.Unmarshal([]byte(s.String), &cfg); err != nil {
        return domain.ProjectConfig{}, fmt.Errorf("unmarshal project config: %w", err)
        // ↑ this causes ListProjects to fail entirely
    }
}

Suggested fix: on unmarshal error, log the error and return a zero config rather than propagating. The project row is still valid; only the config is damaged. This matches how the legacy TS handled resolveError — degrade gracefully rather than blocking all access.


3. Restore path does not apply AgentConfig to the restore command (low impact, but inconsistent)

The Spawn path correctly passes effectiveAgentConfig(...) into LaunchConfig.Config. The Restore path (around line 383 in the diff) loads the project for Env but the argv for restore is built from GetRestoreCommand(ctx, RestoreConfig{...}) — which receives an empty Config (the RestoreConfig struct has a Config AgentConfig field, but it isn't populated from project.Config in this PR).

So a project configured with model: claude-opus-4-5 will get that model on fresh spawns, but not on restores. Probably fine for now but worth noting so a follow-up wires it.


4. runtime.GOOS inline check in runPostCreate violates the cross-platform rule

if runtime.GOOS == "windows" {
    cmd = exec.CommandContext(ctx, "cmd", "/c", command)
} else {
    cmd = exec.CommandContext(ctx, "sh", "-c", command)
}

The go-rewrite skill's Golden Rule says: never inline runtime.GOOS at call sites — put it in the platform package. This should use getShell() (or whatever the Go rewrite's platform helper is) so Windows pty-host nuances (Git Bash, AO_SHELL override, etc.) are respected consistently. This is a spawn failure risk on Windows if cmd /c is unavailable or the wrong shell is used.


What is already handled correctly

  • loadProject with !ok → returns zero ProjectRecord{} → all field accessors on zero config return safe zero values. ✓
  • effectiveAgentConfig on zero config → returns zero AgentConfig{} → adapter emits no flags. ✓
  • WithDefaults() always fills DefaultBranchBaseBranch is never empty. ✓
  • AgentConfig.Validate() accepts "" for both fields → zero config always passes validation at launch time. ✓
  • applySymlinks / runPostCreate on nil slices → no-ops. ✓
  • spawnEnv with nil projectEnv → range over nil map is a no-op. ✓

The one structural note: IsZero uses reflect.DeepEqual. This is correct today but fragile — if ProjectConfig ever gains a field whose zero value is non-nil (a pointer, interface, or function), reflect.DeepEqual will return false for semantically-zero configs. A hand-written IsZero comparing each field explicitly is more robust long-term.

🤖 Generated with Claude Code

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>
@neversettle17-101

Copy link
Copy Markdown
Collaborator Author

Thanks — great fail-safe review. Addressed in 1f86e8c:

1. AgentRulesFile typo → spawn failure (high) — Fixed. projectRules now treats a missing file (os.ErrNotExist) as absent optional context and returns the inline rules without aborting; only a real read error (e.g. permissions) surfaces. Test flipped to assert the skip.

2. Corrupt config JSON blocks the project (medium) — Fixed. unmarshalProjectConfig now degrades a damaged config column to a zero config instead of erroring, so the project row and ListProjects stay accessible. projectRowFromGen / GetProject / ListProjects / FindProjectByPath no longer propagate an unmarshal error. (The store has no logger, so it degrades silently rather than logging — happy to surface it as a resolveError on the read-model in a follow-up if you'd like.) Added an internal test for NULL / valid / corrupt.

3. Restore doesn't apply AgentConfig (low) — Wired it now rather than deferring: Restore loads the project and passes effectiveAgentConfig(rec.Kind, project.Config) into both GetRestoreCommand's RestoreConfig.Config and the fallback LaunchConfig. Test added.

4. runtime.GOOS inline in runPostCreate — Kept inline, intentionally: there is no platform package in this repo, and runtime.GOOS is the established pattern — adapters/runtime/zellij/zellij.go inlines it 3× and all ~25 agent adapters do the same. Introducing a platform package for this one call site would be a single-impl abstraction against the codebase grain. If the team wants a shared shell helper, that's worth its own PR sweeping zellij + the adapters too, and I'm glad to file it.

IsZero / reflect.DeepEqual note — Keeping reflect.DeepEqual(c, ProjectConfig{}): comparing a value against its type's zero value is robust for every field kind — a nil pointer/interface/func/slice/map all DeepEqual their zero counterpart, so a semantically-zero config always reports zero. It can't regress the way a stale hand-written comparison can when a field is added. Open to switching if you still prefer explicit.

@neversettle17-101

Copy link
Copy Markdown
Collaborator Author

Local test run report — PR #154 per-project agent config

Binary: built from 1f86e8c (latest on branch, includes fix commit addressing all three review findings)
Daemon: restarted fresh with the PR binary before testing
Date: 2026-06-08


Results summary

TC Description Result
TC-01 No config → defaults (branch=main, no config key) ✅ PASS
TC-02 Model override stored and returned ✅ PASS
TC-03 Permission mode stored; invalid value rejected at set-time ✅ PASS
TC-04 Worker harness override stored; invalid harness rejected ✅ PASS
TC-05 Default branch override reflected in top-level + config ✅ PASS
TC-06 Env vars stored correctly ✅ PASS
TC-07 Config survives daemon restart ✅ PASS
TC-08 Three invalid values rejected; no partial write ✅ PASS
TC-09 --clear removes config; defaultBranch reverts to main ✅ PASS
TC-10 Role override stores without clobbering base config ✅ PASS
TC-11 --config-json full-object roundtrip ✅ PASS
TC-12 Config accepted at project add time (via API) ✅ PASS
TC-13 Missing agentRulesFile → spawn succeeds (fix confirmed) ✅ PASS
TC-14 Corrupt config JSON in DB → ls and get degrade gracefully ✅ PASS

All 14 test cases pass. 0 failures.


TC detail

TC-01 — no config

defaultBranch: main  ✓
"config" key absent in response  ✓

TC-02 — model override

"agentConfig": { "model": "claude-opus-4-5" }   

TC-03 — permissions + invalid rejection

permissions: accept-edits  ✓
ao project set-config --permission yolo →
  invalid permissions "yolo": want one of default, accept-edits, auto, bypass-permissions (INVALID_PROJECT_CONFIG)  ✓

TC-04 — harness override + invalid rejection

worker.agent: codex  ✓
ao project set-config --worker-agent nonexistent →
  worker.agent: unknown harness "nonexistent" (INVALID_PROJECT_CONFIG)  ✓

TC-05 — default branch

project.defaultBranch: develop  ✓
config.defaultBranch: develop   ✓

TC-06 — env vars

MY_TOKEN: secret123  ✓
AO_SESSION_ID (attempt to override internal): stored in config but overridden at runtime by spawnEnv  ✓

TC-07 — daemon restart persistence

Before restart: model=claude-opus-4-5, permissions=auto
After restart:  model=claude-opus-4-5 ✓, permissions=auto ✓

TC-08 — all three invalid-value paths

--permission yolo          → INVALID_PROJECT_CONFIG  ✓
--worker-agent fake-agent  → INVALID_PROJECT_CONFIG  ✓
--opencode-strategy sometimes → INVALID_PROJECT_CONFIG  ✓
Config after all bad attempts: absent (no partial write)  ✓

TC-09 — clear

Before: config present
After --clear: config absent ✓, defaultBranch: main ✓

TC-10 — role override merge

base.model: base-model  ✓
base.permissions: auto  ✓
worker.harness: codex   ✓
worker.agentConfig.model: "" (inherits base, not overridden)  ✓

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)

POST /api/v1/projects with config.defaultBranch=staging, model=claude-sonnet-4-6
→ project.defaultBranch: staging ✓, config.agentConfig.model: claude-sonnet-4-6 ✓

TC-13 — missing agentRulesFile (review finding #1 — fixed in 1f86e8c)

Set agentRulesFile: .nonexistent-rules.md
Spawn → spawned session tc13-rules-4 (idle)  ✓
Spawn did NOT fail. Fix confirmed: missing file is skipped, not a spawn abort.

TC-14 — corrupt config JSON (review finding #2 — fixed in 1f86e8c)

Injected NOT_VALID_JSON into projects.config column directly via sqlite3
ao project ls  → PASS: returned 14 projects without error  ✓
ao project get tc14-corrupt →
  { "defaultBranch": "main" }  ✓  (degraded to zero config, no crash)

One observation: empty role agentConfig objects in response

When only --model is set (no role-level flags), the response includes:

"worker":       { "agentConfig": {} },
"orchestrator": { "agentConfig": {} }

These empty objects appear because buildProjectConfig in the CLI always sets Worker and Orchestrator from the flag values (both zero when not provided), and the server echoes them back. Functionally harmless — effectiveAgentConfig correctly treats an empty role override as "no override". But it's noise in the API response. Worth a follow-up cleanup to omit zero-value role overrides from the JSON.


Review findings status

Finding Status
agentRulesFile missing → spawn fails ✅ Fixed in 1f86e8c
Corrupt DB config → project inaccessible ✅ Fixed in 1f86e8c
Restore doesn't apply AgentConfig ✅ Fixed in 1f86e8c (per commit message)
runtime.GOOS inline check in runPostCreate ⚠️ Not addressed — still inline, should use platform helper

🤖 Generated with Claude Code

@harshitsinghbhandari

Copy link
Copy Markdown
Collaborator

A few scope/shape notes after reviewing the added config fields:

  1. Drop the prompt/rules config fields from this PR. We should not carry forward agentRules, agentRulesFile, or orchestratorRules as project config. If project/agent instructions are needed, they should come through the system prompt path or repo-local AGENTS.md, not another rules field family.

  2. Do not persist config that is not consumed yet. Please remove/defer the future-only integration fields for now: tracker, scm, scm.webhook, and opencodeIssueSessionStrategy. We can add those later in focused PRs when the actual consumers exist.

  3. Keep agentConfig.model / agentConfig.permissions, but treat cross-agent support as follow-up. I filed Support typed agentConfig model and permissions across all agents #157 for making model/permissions work across all adapters with a qualified model format like claude.claude-opus-4-8 or codex.gpt-5-5, while keeping shared permission values and adapter-specific implementations.

  4. Please keep the currently-consumed fields focused: storage/API config, defaultBranch, sessionPrefix if needed for display, workspace setup (env, symlinks, postCreate), and worker/orchestrator agent overrides.

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>
Comment thread backend/internal/domain/projectconfig.go
harshitsinghbhandari and others added 2 commits June 8, 2026 19:17
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 harshitsinghbhandari left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • loadProject returns a zero record on miss; effectiveAgentConfig merges over zero safely; WithDefaults() guarantees BaseBranch == "main".
  • unmarshalProjectConfig degrades a corrupt JSON column to a zero config so ListProjects and GetProject stay accessible.
  • Restore parity: both GetRestoreCommand and the fallback GetLaunchCommand receive the resolved AgentConfig, so a configured model carries across restore.
  • spawnEnv writes the project env first, AO internals last — AO_SESSION_ID & friends can't be overridden.

Security

  • Symlink path traversal is gated twice: ProjectConfig.Validate rejects ../absolute paths at write time (→ INVALID_PROJECT_CONFIG), and applySymlinks re-checks at spawn as defense-in-depth. Covers .., /etc/passwd, a/../../b, bare ...
  • POST /projects and PUT /projects/{id}/config use DisallowUnknownFields, so a misspelled or removed field surfaces as a clean 400 instead of silently dropping into the row. Test covers agentRules / tracker / orchestratorRules regressions.
  • 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).

Migration0008_add_project_config.sql adds a nullable config TEXT column. Backward-compatible (existing rows decode to zero config).

Minor follow-ups (non-blocking)

  1. Duplicated path validatordomain.validateRepoRelative and sessionmanager.safeRelPath are 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.
  2. Empty role-override objects in responses — when only --model is 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.
  3. runtime.GOOS inline in runPostCreate — already discussed; the inline pattern matches zellij + the agent adapters. Fine to keep; if a platform helper lands later it should sweep all call sites together.

LGTM, approving. 🚀

@harshitsinghbhandari harshitsinghbhandari merged commit 7698c24 into main Jun 8, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants