From 5c3e85cbfa99a5a515b78cb4a640050f06f38e87 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Sun, 7 Jun 2026 11:06:55 +0530 Subject: [PATCH 01/11] 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 --- .../adapters/agent/claudecode/claudecode.go | 103 ++++++++++++++- .../agent/claudecode/claudecode_test.go | 52 ++++++++ backend/internal/cli/client.go | 6 + backend/internal/cli/dto_drift_e2e_test.go | 4 + backend/internal/cli/project.go | 120 ++++++++++++++++++ backend/internal/domain/project.go | 4 + backend/internal/httpd/apispec/openapi.yaml | 61 +++++++++ .../internal/httpd/apispec/specgen/build.go | 12 ++ .../internal/httpd/controllers/projects.go | 19 +++ backend/internal/service/project/dto.go | 14 +- backend/internal/service/project/service.go | 27 ++++ .../internal/service/project/service_test.go | 32 +++++ backend/internal/service/project/types.go | 1 + backend/internal/session_manager/manager.go | 26 ++++ .../internal/session_manager/manager_test.go | 48 ++++++- backend/internal/storage/sqlite/gen/models.go | 1 + .../storage/sqlite/gen/projects.sql.go | 18 ++- .../0008_add_project_agent_config.sql | 15 +++ .../storage/sqlite/queries/projects.sql | 13 +- .../storage/sqlite/store/project_store.go | 60 ++++++++- .../storage/sqlite/store/store_test.go | 38 ++++++ frontend/src/api/schema.ts | 82 ++++++++++++ 22 files changed, 731 insertions(+), 25 deletions(-) create mode 100644 backend/internal/storage/sqlite/migrations/0008_add_project_agent_config.sql diff --git a/backend/internal/adapters/agent/claudecode/claudecode.go b/backend/internal/adapters/agent/claudecode/claudecode.go index e16b073b..66385c6b 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode.go +++ b/backend/internal/adapters/agent/claudecode/claudecode.go @@ -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,14 @@ 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) { + spec, err := p.GetConfigSpec(ctx) + if err != nil { + return nil, err + } + if err := validateConfig(spec, cfg.Config); err != nil { + return nil, err + } + binary, err := p.claudeBinary(ctx) if err != nil { return nil, err @@ -112,7 +144,20 @@ 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 "permissions" config key drives 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 == "" { + if mode, ok := cfg.Config["permissions"].(string); ok { + permissions = ports.PermissionMode(mode) + } + } + appendPermissionFlags(&cmd, permissions) + + if model, ok := cfg.Config["model"].(string); ok && strings.TrimSpace(model) != "" { + cmd = append(cmd, "--model", strings.TrimSpace(model)) + } systemPrompt, err := resolveSystemPrompt(cfg) if err != nil { @@ -441,6 +486,56 @@ func ensureWorkspaceTrusted(configPath, workspacePath string) error { return nil } +// validateConfig rejects a per-project agent config whose keys or values the +// adapter does not understand, so a bad project config surfaces a clear error at +// spawn rather than an opaque CLI failure. An unknown key, a value of the wrong +// primitive type, or an enum value outside the field's allowed set is an error. +func validateConfig(spec ports.ConfigSpec, cfg ports.AgentConfig) error { + allowed := make(map[string]ports.ConfigField, len(spec.Fields)) + for _, f := range spec.Fields { + allowed[f.Key] = f + } + for key, raw := range cfg { + field, ok := allowed[key] + if !ok { + return fmt.Errorf("claude-code: unknown config key %q", key) + } + if err := validateConfigValue(field, raw); err != nil { + return err + } + } + return nil +} + +func validateConfigValue(field ports.ConfigField, raw any) error { + switch field.Type { + case ports.ConfigFieldString: + if _, ok := raw.(string); !ok { + return fmt.Errorf("claude-code: config key %q must be a string", field.Key) + } + case ports.ConfigFieldBool: + if _, ok := raw.(bool); !ok { + return fmt.Errorf("claude-code: config key %q must be a bool", field.Key) + } + case ports.ConfigFieldNumber: + if _, ok := raw.(float64); !ok { + return fmt.Errorf("claude-code: config key %q must be a number", field.Key) + } + case ports.ConfigFieldEnum: + s, ok := raw.(string) + if !ok { + return fmt.Errorf("claude-code: config key %q must be a string", field.Key) + } + for _, allowed := range field.Enum { + if s == allowed { + return nil + } + } + return fmt.Errorf("claude-code: config key %q must be one of %s", field.Key, strings.Join(field.Enum, ", ")) + } + return nil +} + func fileExists(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() diff --git a/backend/internal/adapters/agent/claudecode/claudecode_test.go b/backend/internal/adapters/agent/claudecode/claudecode_test.go index bdaf3465..fc1c7c42 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode_test.go +++ b/backend/internal/adapters/agent/claudecode/claudecode_test.go @@ -422,6 +422,58 @@ 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": string(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": string(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) { + tests := []struct { + name string + config ports.AgentConfig + }{ + {"unknown key", ports.AgentConfig{"nope": "x"}}, + {"wrong type for model", ports.AgentConfig{"model": 42}}, + {"bad permission enum", ports.AgentConfig{"permissions": "yolo"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Plugin{resolvedBinary: "claude"} + if _, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{Config: tt.config}); err == nil { + t.Fatalf("expected error for config %#v", tt.config) + } + }) + } +} + func TestManifestID(t *testing.T) { if got := New().Manifest().ID; got != "claude-code" { t.Fatalf("manifest id = %q, want claude-code", got) diff --git a/backend/internal/cli/client.go b/backend/internal/cli/client.go index 92eafd44..4c8ddff2 100644 --- a/backend/internal/cli/client.go +++ b/backend/internal/cli/client.go @@ -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/ 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/ to the running daemon and decodes a // 2xx response into out. func (c *commandContext) deleteJSON(ctx context.Context, path string, out any) error { diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go index 4681717d..98d979b8 100644 --- a/backend/internal/cli/dto_drift_e2e_test.go +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -128,6 +128,10 @@ func (f *fakeProjectManager) Add(_ context.Context, in projectsvc.AddInput) (pro return projectsvc.Project{ID: id, Path: in.Path}, nil } +func (f *fakeProjectManager) SetAgentConfig(_ context.Context, id domain.ProjectID, in projectsvc.SetAgentConfigInput) (projectsvc.Project, error) { + return projectsvc.Project{ID: id, AgentConfig: in.Config}, nil +} + func (f *fakeProjectManager) Remove(context.Context, domain.ProjectID) (projectsvc.RemoveResult, error) { return projectsvc.RemoveResult{}, nil } diff --git a/backend/internal/cli/project.go b/backend/internal/cli/project.go index 6a5c1fb2..0b8a23d4 100644 --- a/backend/internal/cli/project.go +++ b/backend/internal/cli/project.go @@ -2,6 +2,7 @@ package cli import ( "bufio" + "encoding/json" "errors" "fmt" "net/url" @@ -53,11 +54,25 @@ type projectDetails struct { Repo string `json:"repo"` DefaultBranch string `json:"defaultBranch"` DefaultHarness string `json:"agent,omitempty"` + AgentConfig map[string]any `json:"agentConfig,omitempty"` Tracker map[string]any `json:"tracker,omitempty"` SCM map[string]any `json:"scm,omitempty"` ResolveError string `json:"resolveError,omitempty"` } +// setAgentConfigRequest mirrors the daemon's SetAgentConfigInput body for +// PUT /api/v1/projects/{id}/agent-config. +type setAgentConfigRequest struct { + Config map[string]any `json:"config"` +} + +type projectSetConfigOptions struct { + set []string + configJSON string + clear bool + json bool +} + type projectListResult struct { Projects []projectSummary `json:"projects"` } @@ -86,6 +101,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 +195,96 @@ func newProjectAddCommand(ctx *commandContext) *cobra.Command { return cmd } +func newProjectSetConfigCommand(ctx *commandContext) *cobra.Command { + var opts projectSetConfigOptions + cmd := &cobra.Command{ + Use: "set-config ", + Short: "Set the per-project agent config", + Long: "Replace a project's per-project agent config (model, permissions, " + + "adapter-specific keys). The config is resolved into the launch command " + + "when a session spawns; the owning agent adapter validates the keys.\n\n" + + "Use --set key=value (repeatable) for string values, --config-json for a " + + "full JSON object, 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 := buildAgentConfig(opts) + if err != nil { + return err + } + req := setAgentConfigRequest{Config: config} + var res projectResult + if err := ctx.putJSON(cmd.Context(), "projects/"+url.PathEscape(id)+"/agent-config", req, &res); err != nil { + return err + } + if opts.json { + return writeJSON(cmd.OutOrStdout(), res) + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "updated agent config for project %s\n", res.Project.ID) + return err + }, + } + f := cmd.Flags() + f.StringArrayVar(&opts.set, "set", nil, "Config key=value (repeatable; values are strings)") + f.StringVar(&opts.configJSON, "config-json", "", "Full config as a JSON object") + f.BoolVar(&opts.clear, "clear", false, "Clear all agent config") + f.BoolVar(&opts.json, "json", false, "Output the updated project as JSON") + return cmd +} + +// buildAgentConfig turns the set-config flags into the config map sent to the +// daemon. The three input modes are mutually exclusive: --clear empties the +// config, --config-json supplies the whole object, and --set builds it from +// key=value pairs. +func buildAgentConfig(opts projectSetConfigOptions) (map[string]any, error) { + modes := 0 + if opts.clear { + modes++ + } + if opts.configJSON != "" { + modes++ + } + if len(opts.set) > 0 { + modes++ + } + switch { + case modes == 0: + return nil, usageError{errors.New("usage: provide --set, --config-json, or --clear")} + case modes > 1: + return nil, usageError{errors.New("usage: --set, --config-json, and --clear are mutually exclusive")} + } + + if opts.clear { + return map[string]any{}, nil + } + if opts.configJSON != "" { + var config map[string]any + if err := json.Unmarshal([]byte(opts.configJSON), &config); err != nil { + return nil, usageError{fmt.Errorf("--config-json is not a valid JSON object: %w", err)} + } + return config, nil + } + + config := make(map[string]any, len(opts.set)) + for _, pair := range opts.set { + key, value, ok := strings.Cut(pair, "=") + key = strings.TrimSpace(key) + if !ok || key == "" { + return nil, usageError{fmt.Errorf("invalid --set %q: expected key=value", pair)} + } + config[key] = value + } + return config, nil +} + func newProjectRemoveCommand(ctx *commandContext) *cobra.Command { var opts projectRemoveOptions cmd := &cobra.Command{ @@ -270,6 +376,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: "agent config", value: formatAgentConfig(p.AgentConfig)}, {label: "resolve error", value: p.ResolveError}, } for _, f := range fields { @@ -283,6 +390,19 @@ func writeProjectDetails(cmd *cobra.Command, res projectGetResult) error { return nil } +// formatAgentConfig renders the per-project agent config as compact JSON for the +// `project get` text view. An empty config returns "" so the row is skipped. +func formatAgentConfig(config map[string]any) string { + if len(config) == 0 { + 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 diff --git a/backend/internal/domain/project.go b/backend/internal/domain/project.go index b00e65c7..51c981d7 100644 --- a/backend/internal/domain/project.go +++ b/backend/internal/domain/project.go @@ -10,4 +10,8 @@ type ProjectRecord struct { DisplayName string RegisteredAt time.Time ArchivedAt time.Time + // AgentConfig holds the per-project agent settings (model, permissions, + // adapter-specific keys) AO resolves into a launch command at spawn. nil + // means unset; the owning agent adapter validates the keys. + AgentConfig map[string]any } diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 12d0cde9..d9e0d9df 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -283,6 +283,51 @@ paths: summary: Fetch one project; discriminates ok vs degraded tags: - projects + /api/v1/projects/{id}/agent-config: + put: + operationId: setProjectAgentConfig + 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/ProjectSetAgentConfigInput' + 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 agent config + tags: + - projects /api/v1/prs/{id}/merge: post: operationId: mergePR @@ -916,6 +961,9 @@ components: type: object AddProjectInput: properties: + agentConfig: + additionalProperties: {} + type: object name: type: - "null" @@ -1074,6 +1122,9 @@ components: properties: agent: type: string + agentConfig: + additionalProperties: {} + type: object defaultBranch: type: string id: @@ -1120,6 +1171,16 @@ components: required: - project type: object + ProjectSetAgentConfigInput: + properties: + config: + additionalProperties: {} + type: + - object + - "null" + required: + - config + type: object ProjectSummary: properties: id: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index cb83ecd2..4e89a76c 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -297,6 +297,18 @@ func projectOperations() []operation { {http.StatusInternalServerError, envelope.APIError{}}, }, }, + { + method: http.MethodPut, path: "/api/v1/projects/{id}/agent-config", id: "setProjectAgentConfig", tag: "projects", + summary: "Replace a project's per-project agent config", + pathParams: []any{controllers.ProjectIDParam{}}, + reqBody: projectsvc.SetAgentConfigInput{}, + 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", diff --git a/backend/internal/httpd/controllers/projects.go b/backend/internal/httpd/controllers/projects.go index ba877f9c..83feda83 100644 --- a/backend/internal/httpd/controllers/projects.go +++ b/backend/internal/httpd/controllers/projects.go @@ -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}/agent-config", c.setAgentConfig) r.Delete("/projects/{id}", c.remove) } @@ -82,6 +83,24 @@ func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) { envelope.WriteJSON(w, http.StatusOK, resp) } +func (c *ProjectsController) setAgentConfig(w http.ResponseWriter, r *http.Request) { + if c.Mgr == nil { + apispec.NotImplemented(w, r, "PUT", "/api/v1/projects/{id}/agent-config") + return + } + var in projectsvc.SetAgentConfigInput + if err := decodeJSON(r, &in); err != nil { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) + return + } + p, err := c.Mgr.SetAgentConfig(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}") diff --git a/backend/internal/service/project/dto.go b/backend/internal/service/project/dto.go index 3d532932..c01ba85d 100644 --- a/backend/internal/service/project/dto.go +++ b/backend/internal/service/project/dto.go @@ -11,9 +11,17 @@ 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"` + AgentConfig map[string]any `json:"agentConfig,omitempty"` +} + +// SetAgentConfigInput is the body shape for PUT +// /api/v1/projects/{id}/agent-config. Config replaces the project's stored +// agent config wholesale; an empty/nil map clears it. +type SetAgentConfigInput struct { + Config map[string]any `json:"config"` } // RemoveResult reports what DELETE /api/v1/projects/{id} actually did. diff --git a/backend/internal/service/project/service.go b/backend/internal/service/project/service.go index 966f9bb4..f7f220d9 100644 --- a/backend/internal/service/project/service.go +++ b/backend/internal/service/project/service.go @@ -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) + // SetAgentConfig replaces a project's per-project agent config, returning + // the updated read-model. + SetAgentConfig(ctx context.Context, id domain.ProjectID, in SetAgentConfigInput) (Project, error) + // Remove unregisters a project, stopping its sessions and reclaiming // managed workspaces. Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) @@ -125,6 +129,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { RepoOriginURL: resolveGitOriginURL(path), DisplayName: name, RegisteredAt: time.Now(), + AgentConfig: in.AgentConfig, } if err := m.store.UpsertProject(ctx, row); err != nil { return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register project") @@ -132,6 +137,27 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { return projectFromRow(row), nil } +// SetAgentConfig replaces the project's stored agent config. The config is +// persisted as-is; the owning agent adapter validates the keys at spawn when the +// session's harness — and therefore the adapter that owns the keys — is known. +func (m *Service) SetAgentConfig(ctx context.Context, id domain.ProjectID, in SetAgentConfigInput) (Project, error) { + if err := validateProjectID(id); err != nil { + return Project{}, err + } + 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.AgentConfig = in.Config + if err := m.store.UpsertProject(ctx, row); err != nil { + return Project{}, apierr.Internal("PROJECT_CONFIG_UPDATE_FAILED", "Failed to update project agent 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 @@ -175,6 +201,7 @@ func projectFromRow(row domain.ProjectRecord) Project { Path: row.Path, Repo: row.RepoOriginURL, DefaultBranch: "main", + AgentConfig: row.AgentConfig, } } diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index e67f526f..2a584754 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -95,6 +95,38 @@ func TestManager_AddListGetRemove(t *testing.T) { wantCode(t, err, "PROJECT_NOT_FOUND") } +func TestManager_SetAgentConfig(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 := map[string]any{"model": "claude-opus-4-5"} + proj, err := m.SetAgentConfig(ctx, "ao", project.SetAgentConfigInput{Config: cfg}) + if err != nil { + t.Fatalf("SetAgentConfig: %v", err) + } + if proj.AgentConfig["model"] != "claude-opus-4-5" { + t.Fatalf("returned config = %#v", proj.AgentConfig) + } + + // 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.AgentConfig["model"] != "claude-opus-4-5" { + t.Fatalf("Get config = %#v", got.Project) + } + + // Setting on an unknown project is a clean not-found. + _, err = m.SetAgentConfig(ctx, "ghost", project.SetAgentConfigInput{Config: cfg}) + wantCode(t, err, "PROJECT_NOT_FOUND") +} + func TestManager_ReaddAfterRemove(t *testing.T) { ctx := context.Background() m := newManager(t) diff --git a/backend/internal/service/project/types.go b/backend/internal/service/project/types.go index 618500b4..20fe8cf3 100644 --- a/backend/internal/service/project/types.go +++ b/backend/internal/service/project/types.go @@ -18,6 +18,7 @@ type Project struct { Repo string `json:"repo"` DefaultBranch string `json:"defaultBranch"` Agent string `json:"agent,omitempty"` + AgentConfig map[string]any `json:"agentConfig,omitempty"` Tracker *TrackerConfig `json:"tracker,omitempty"` SCM *SCMConfig `json:"scm,omitempty"` } diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 66a5272d..64df17fa 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -46,6 +46,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) @@ -155,11 +158,18 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess m.markSpawnFailedTerminated(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err) } + agentConfig, err := m.resolveAgentConfig(ctx, cfg.ProjectID) + if err != nil { + _ = m.workspace.Destroy(ctx, ws) + m.markSpawnFailedTerminated(ctx, id) + return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err) + } argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{ SessionID: string(id), WorkspacePath: ws.Path, Prompt: prompt, IssueID: string(cfg.IssueID), + Config: agentConfig, }) if err != nil { _ = m.workspace.Destroy(ctx, ws) @@ -197,6 +207,22 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess return m.getRecord(ctx, id) } +// resolveAgentConfig loads the project's per-project agent config so it can be +// handed to the adapter's GetLaunchCommand. A missing project row yields a nil +// config rather than an error: the project may be unregistered yet still have +// live sessions, and an empty config simply means the adapter falls back to its +// own defaults. +func (m *Manager) resolveAgentConfig(ctx context.Context, projectID domain.ProjectID) (ports.AgentConfig, error) { + row, ok, err := m.store.GetProject(ctx, string(projectID)) + if err != nil { + return nil, fmt.Errorf("resolve agent config: %w", err) + } + if !ok || len(row.AgentConfig) == 0 { + return nil, nil + } + return ports.AgentConfig(row.AgentConfig), nil +} + // 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. diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index be125274..51695192 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -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++ @@ -135,6 +140,22 @@ 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 +} + +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 @@ -175,6 +196,31 @@ 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_ResolvesProjectAgentConfig(t *testing.T) { + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", AgentConfig: map[string]any{"model": "claude-opus-4-5"}} + 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.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { + t.Fatal(err) + } + if agent.lastConfig["model"] != "claude-opus-4-5" { + t.Fatalf("launch config = %#v, want model resolved from project", agent.lastConfig) + } + + // A project with no stored config yields a nil AgentConfig (adapter defaults). + st.projects["bare"] = domain.ProjectRecord{ID: "bare"} + agent.lastConfig = ports.AgentConfig{"stale": true} + if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "bare", Kind: domain.KindWorker}); err != nil { + t.Fatal(err) + } + if agent.lastConfig != nil { + t.Fatalf("launch config = %#v, want nil 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"}) diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index 824825a1..d6da1d26 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -107,6 +107,7 @@ type Project struct { DisplayName string RegisteredAt time.Time ArchivedAt sql.NullTime + AgentConfig sql.NullString } type Session struct { diff --git a/backend/internal/storage/sqlite/gen/projects.sql.go b/backend/internal/storage/sqlite/gen/projects.sql.go index dea720c6..8615f10c 100644 --- a/backend/internal/storage/sqlite/gen/projects.sql.go +++ b/backend/internal/storage/sqlite/gen/projects.sql.go @@ -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, agent_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.AgentConfig, ) 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, agent_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.AgentConfig, ) 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, agent_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.AgentConfig, ); 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, agent_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, + agent_config = excluded.agent_config ` type UpsertProjectParams struct { @@ -120,6 +124,7 @@ type UpsertProjectParams struct { DisplayName string RegisteredAt time.Time ArchivedAt sql.NullTime + AgentConfig 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.AgentConfig, ) return err } diff --git a/backend/internal/storage/sqlite/migrations/0008_add_project_agent_config.sql b/backend/internal/storage/sqlite/migrations/0008_add_project_agent_config.sql new file mode 100644 index 00000000..649debf5 --- /dev/null +++ b/backend/internal/storage/sqlite/migrations/0008_add_project_agent_config.sql @@ -0,0 +1,15 @@ +-- Per-project agent config. A single nullable JSON column on projects holds the +-- agent settings (model, permissions, adapter-specific keys) AO resolves into +-- LaunchConfig.Config 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 agent config into its own table. + +-- +goose Up +-- +goose StatementBegin +ALTER TABLE projects ADD COLUMN agent_config TEXT; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE projects DROP COLUMN agent_config; +-- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/queries/projects.sql b/backend/internal/storage/sqlite/queries/projects.sql index 3d41d0d5..08988ae4 100644 --- a/backend/internal/storage/sqlite/queries/projects.sql +++ b/backend/internal/storage/sqlite/queries/projects.sql @@ -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, agent_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, + agent_config = excluded.agent_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, agent_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, agent_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, agent_config FROM projects WHERE path = ? AND archived_at IS NULL; -- name: ArchiveProject :execrows diff --git a/backend/internal/storage/sqlite/store/project_store.go b/backend/internal/storage/sqlite/store/project_store.go index 932205a8..04d1563a 100644 --- a/backend/internal/storage/sqlite/store/project_store.go +++ b/backend/internal/storage/sqlite/store/project_store.go @@ -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 { + agentConfig, err := marshalAgentConfig(r.AgentConfig) + 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), + AgentConfig: agentConfig, }) } @@ -34,7 +40,11 @@ func (s *Store) GetProject(ctx context.Context, id string) (domain.ProjectRecord if err != nil { return domain.ProjectRecord{}, false, fmt.Errorf("get project %s: %w", id, err) } - return projectRowFromGen(p), true, nil + r, err := projectRowFromGen(p) + if err != nil { + return domain.ProjectRecord{}, false, fmt.Errorf("get project %s: %w", id, err) + } + return r, true, nil } // FindProjectByPath returns a project registered at path, active or archived. @@ -46,7 +56,11 @@ func (s *Store) FindProjectByPath(ctx context.Context, path string) (domain.Proj if err != nil { return domain.ProjectRecord{}, false, fmt.Errorf("find project by path %s: %w", path, err) } - return projectRowFromGen(p), true, nil + r, err := projectRowFromGen(p) + if err != nil { + return domain.ProjectRecord{}, false, fmt.Errorf("find project by path %s: %w", path, err) + } + return r, true, nil } // ListProjects returns active projects ordered by id. @@ -57,7 +71,11 @@ func (s *Store) ListProjects(ctx context.Context) ([]domain.ProjectRecord, error } out := make([]domain.ProjectRecord, 0, len(rows)) for _, p := range rows { - out = append(out, projectRowFromGen(p)) + r, err := projectRowFromGen(p) + if err != nil { + return nil, fmt.Errorf("list projects: %w", err) + } + out = append(out, r) } return out, nil } @@ -76,18 +94,50 @@ func (s *Store) ArchiveProject(ctx context.Context, id string, at time.Time) (bo return n > 0, nil } -func projectRowFromGen(p gen.Project) domain.ProjectRecord { +func projectRowFromGen(p gen.Project) (domain.ProjectRecord, error) { + agentConfig, err := unmarshalAgentConfig(p.AgentConfig) + if err != nil { + return domain.ProjectRecord{}, err + } r := domain.ProjectRecord{ ID: string(p.ID), Path: p.Path, RepoOriginURL: p.RepoOriginURL, DisplayName: p.DisplayName, RegisteredAt: p.RegisteredAt, + AgentConfig: agentConfig, } if p.ArchivedAt.Valid { r.ArchivedAt = p.ArchivedAt.Time } - return r + return r, nil +} + +// marshalAgentConfig encodes a per-project agent config into the nullable JSON +// column. A nil or empty map stores SQL NULL so an unset config round-trips back +// to nil rather than an empty object. +func marshalAgentConfig(cfg map[string]any) (sql.NullString, error) { + if len(cfg) == 0 { + return sql.NullString{}, nil + } + data, err := json.Marshal(cfg) + if err != nil { + return sql.NullString{}, fmt.Errorf("marshal agent config: %w", err) + } + return sql.NullString{String: string(data), Valid: true}, nil +} + +// unmarshalAgentConfig decodes the nullable JSON column back into a map. SQL +// NULL (an unset config) decodes to nil. +func unmarshalAgentConfig(s sql.NullString) (map[string]any, error) { + if !s.Valid || s.String == "" { + return nil, nil + } + var cfg map[string]any + if err := json.Unmarshal([]byte(s.String), &cfg); err != nil { + return nil, fmt.Errorf("unmarshal agent config: %w", err) + } + return cfg, nil } func nullTime(t time.Time) sql.NullTime { diff --git a/backend/internal/storage/sqlite/store/store_test.go b/backend/internal/storage/sqlite/store/store_test.go index a03c1e9b..c6d8f17c 100644 --- a/backend/internal/storage/sqlite/store/store_test.go +++ b/backend/internal/storage/sqlite/store/store_test.go @@ -71,6 +71,44 @@ func TestProjectCRUDAndArchive(t *testing.T) { } } +func TestProjectAgentConfigRoundTrips(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + + // A config with mixed value kinds survives the JSON round trip. + cfg := map[string]any{"model": "claude-opus-4-5", "permissions": "accept-edits"} + if err := s.UpsertProject(ctx, domain.ProjectRecord{ + ID: "cfg", Path: "/tmp/cfg", RegisteredAt: now, AgentConfig: 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 got.AgentConfig["model"] != "claude-opus-4-5" || got.AgentConfig["permissions"] != "accept-edits" { + t.Fatalf("agent config = %#v", got.AgentConfig) + } + + // An unset config round-trips back to nil rather than an empty object. + seedProject(t, s, "nocfg") + got, _, _ = s.GetProject(ctx, "nocfg") + if got.AgentConfig != nil { + t.Fatalf("unset config = %#v, want nil", got.AgentConfig) + } + + // Clearing replaces a previously-set config with nil. + if err := s.UpsertProject(ctx, domain.ProjectRecord{ + ID: "cfg", Path: "/tmp/cfg", RegisteredAt: now, AgentConfig: nil, + }); err != nil { + t.Fatalf("clear config: %v", err) + } + if got, _, _ := s.GetProject(ctx, "cfg"); got.AgentConfig != nil { + t.Fatalf("cleared config = %#v, want nil", got.AgentConfig) + } +} + func TestSessionCreateAssignsPerProjectID(t *testing.T) { s := newTestStore(t) ctx := context.Background() diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index b23fb275..b7f4330e 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -92,6 +92,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/projects/{id}/agent-config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Replace a project's per-project agent config */ + put: operations["setProjectAgentConfig"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/prs/{id}/merge": { parameters: { query?: never; @@ -312,6 +329,9 @@ export interface components { requestId?: string; }; AddProjectInput: { + agentConfig?: { + [key: string]: unknown; + }; name?: null | string; path: string; projectId?: null | string; @@ -369,6 +389,9 @@ export interface components { }; Project: { agent?: string; + agentConfig?: { + [key: string]: unknown; + }; defaultBranch: string; id: string; name: string; @@ -386,6 +409,11 @@ export interface components { ProjectResponse: { project: components["schemas"]["Project"]; }; + ProjectSetAgentConfigInput: { + config: { + [key: string]: unknown; + } | null; + }; ProjectSummary: { id: string; name: string; @@ -885,6 +913,60 @@ export interface operations { }; }; }; + setProjectAgentConfig: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project identifier (registry key). */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProjectSetAgentConfigInput"]; + }; + }; + 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; From 8eaa2d8c5541bea63ed316f11165160d57033607 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Sun, 7 Jun 2026 11:14:23 +0530 Subject: [PATCH 02/11] 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 --- .../adapters/agent/claudecode/claudecode.go | 19 +++++++++++++ .../agent/claudecode/claudecode_test.go | 28 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/backend/internal/adapters/agent/claudecode/claudecode.go b/backend/internal/adapters/agent/claudecode/claudecode.go index 66385c6b..b487b059 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode.go +++ b/backend/internal/adapters/agent/claudecode/claudecode.go @@ -504,6 +504,13 @@ func validateConfig(spec ports.ConfigSpec, cfg ports.AgentConfig) error { return err } } + for _, f := range spec.Fields { + if f.Required { + if _, ok := cfg[f.Key]; !ok { + return fmt.Errorf("claude-code: config key %q is required", f.Key) + } + } + } return nil } @@ -532,6 +539,18 @@ func validateConfigValue(field ports.ConfigField, raw any) error { } } return fmt.Errorf("claude-code: config key %q must be one of %s", field.Key, strings.Join(field.Enum, ", ")) + case ports.ConfigFieldStringList: + list, ok := raw.([]any) + if !ok { + return fmt.Errorf("claude-code: config key %q must be a list of strings", field.Key) + } + for _, item := range list { + if _, ok := item.(string); !ok { + return fmt.Errorf("claude-code: config key %q must be a list of strings", field.Key) + } + } + default: + return fmt.Errorf("claude-code: config key %q has unhandled type %q", field.Key, field.Type) } return nil } diff --git a/backend/internal/adapters/agent/claudecode/claudecode_test.go b/backend/internal/adapters/agent/claudecode/claudecode_test.go index fc1c7c42..842b188f 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode_test.go +++ b/backend/internal/adapters/agent/claudecode/claudecode_test.go @@ -474,6 +474,34 @@ func TestGetLaunchCommandRejectsInvalidConfig(t *testing.T) { } } +func TestValidateConfigEnforcesSpecKinds(t *testing.T) { + spec := ports.ConfigSpec{Fields: []ports.ConfigField{ + {Key: "name", Type: ports.ConfigFieldString, Required: true}, + {Key: "tags", Type: ports.ConfigFieldStringList}, + {Key: "mystery", Type: ports.ConfigFieldType("weird")}, + }} + tests := []struct { + name string + cfg ports.AgentConfig + wantErr bool + }{ + {"required field present", ports.AgentConfig{"name": "x"}, false}, + {"required field missing", ports.AgentConfig{"tags": []any{"a"}}, true}, + {"string list ok", ports.AgentConfig{"name": "x", "tags": []any{"a", "b"}}, false}, + {"string list with non-string", ports.AgentConfig{"name": "x", "tags": []any{"a", 1.0}}, true}, + {"string list wrong kind", ports.AgentConfig{"name": "x", "tags": "a"}, true}, + {"unhandled field type", ports.AgentConfig{"name": "x", "mystery": "v"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateConfig(spec, tt.cfg) + if (err != nil) != tt.wantErr { + t.Fatalf("validateConfig(%#v) err = %v, wantErr = %v", tt.cfg, err, tt.wantErr) + } + }) + } +} + func TestManifestID(t *testing.T) { if got := New().Manifest().ID; got != "claude-code" { t.Fatalf("manifest id = %q, want claude-code", got) From be4e22f67b5d0e090af311d05d240ff50d19a7df Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Sun, 7 Jun 2026 17:39:30 +0530 Subject: [PATCH 03/11] refactor(config): make per-project agent config a typed struct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../adapters/agent/claudecode/claudecode.go | 93 ++------------ .../agent/claudecode/claudecode_test.go | 54 ++------ backend/internal/cli/dto_drift_e2e_test.go | 3 +- backend/internal/cli/project.go | 82 +++++------- backend/internal/domain/agentconfig.go | 48 +++++++ backend/internal/domain/project.go | 8 +- backend/internal/httpd/apispec/openapi.yaml | 18 +-- backend/internal/ports/agent.go | 26 ++-- backend/internal/service/project/dto.go | 12 +- backend/internal/service/project/service.go | 27 +++- .../internal/service/project/service_test.go | 10 +- backend/internal/service/project/types.go | 18 +-- backend/internal/session_manager/manager.go | 8 +- .../internal/session_manager/manager_test.go | 12 +- .../storage/sqlite/store/project_store.go | 22 ++-- .../storage/sqlite/store/store_test.go | 22 ++-- docs/design/per-project-config.md | 120 ++++++++++++++++++ frontend/src/api/schema.ts | 16 +-- 18 files changed, 327 insertions(+), 272 deletions(-) create mode 100644 backend/internal/domain/agentconfig.go create mode 100644 docs/design/per-project-config.md diff --git a/backend/internal/adapters/agent/claudecode/claudecode.go b/backend/internal/adapters/agent/claudecode/claudecode.go index b487b059..c107c620 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode.go +++ b/backend/internal/adapters/agent/claudecode/claudecode.go @@ -127,12 +127,10 @@ 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) { - spec, err := p.GetConfigSpec(ctx) - if err != nil { - return nil, err - } - if err := validateConfig(spec, cfg.Config); err != nil { - return nil, err + // 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) @@ -144,19 +142,17 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( if cfg.SessionID != "" { cmd = append(cmd, "--session-id", claudeSessionUUID(cfg.SessionID)) } - // A project's "permissions" config key drives the starting mode; the - // explicit LaunchConfig.Permissions wins when set so a per-spawn override - // still takes precedence over the stored project default. + // 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 == "" { - if mode, ok := cfg.Config["permissions"].(string); ok { - permissions = ports.PermissionMode(mode) - } + permissions = cfg.Config.Permissions } appendPermissionFlags(&cmd, permissions) - if model, ok := cfg.Config["model"].(string); ok && strings.TrimSpace(model) != "" { - cmd = append(cmd, "--model", strings.TrimSpace(model)) + if model := strings.TrimSpace(cfg.Config.Model); model != "" { + cmd = append(cmd, "--model", model) } systemPrompt, err := resolveSystemPrompt(cfg) @@ -486,75 +482,6 @@ func ensureWorkspaceTrusted(configPath, workspacePath string) error { return nil } -// validateConfig rejects a per-project agent config whose keys or values the -// adapter does not understand, so a bad project config surfaces a clear error at -// spawn rather than an opaque CLI failure. An unknown key, a value of the wrong -// primitive type, or an enum value outside the field's allowed set is an error. -func validateConfig(spec ports.ConfigSpec, cfg ports.AgentConfig) error { - allowed := make(map[string]ports.ConfigField, len(spec.Fields)) - for _, f := range spec.Fields { - allowed[f.Key] = f - } - for key, raw := range cfg { - field, ok := allowed[key] - if !ok { - return fmt.Errorf("claude-code: unknown config key %q", key) - } - if err := validateConfigValue(field, raw); err != nil { - return err - } - } - for _, f := range spec.Fields { - if f.Required { - if _, ok := cfg[f.Key]; !ok { - return fmt.Errorf("claude-code: config key %q is required", f.Key) - } - } - } - return nil -} - -func validateConfigValue(field ports.ConfigField, raw any) error { - switch field.Type { - case ports.ConfigFieldString: - if _, ok := raw.(string); !ok { - return fmt.Errorf("claude-code: config key %q must be a string", field.Key) - } - case ports.ConfigFieldBool: - if _, ok := raw.(bool); !ok { - return fmt.Errorf("claude-code: config key %q must be a bool", field.Key) - } - case ports.ConfigFieldNumber: - if _, ok := raw.(float64); !ok { - return fmt.Errorf("claude-code: config key %q must be a number", field.Key) - } - case ports.ConfigFieldEnum: - s, ok := raw.(string) - if !ok { - return fmt.Errorf("claude-code: config key %q must be a string", field.Key) - } - for _, allowed := range field.Enum { - if s == allowed { - return nil - } - } - return fmt.Errorf("claude-code: config key %q must be one of %s", field.Key, strings.Join(field.Enum, ", ")) - case ports.ConfigFieldStringList: - list, ok := raw.([]any) - if !ok { - return fmt.Errorf("claude-code: config key %q must be a list of strings", field.Key) - } - for _, item := range list { - if _, ok := item.(string); !ok { - return fmt.Errorf("claude-code: config key %q must be a list of strings", field.Key) - } - } - default: - return fmt.Errorf("claude-code: config key %q has unhandled type %q", field.Key, field.Type) - } - return nil -} - func fileExists(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() diff --git a/backend/internal/adapters/agent/claudecode/claudecode_test.go b/backend/internal/adapters/agent/claudecode/claudecode_test.go index 842b188f..6cddab27 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode_test.go +++ b/backend/internal/adapters/agent/claudecode/claudecode_test.go @@ -426,8 +426,8 @@ 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": string(ports.PermissionModeAcceptEdits), + Model: "claude-opus-4-5", + Permissions: ports.PermissionModeAcceptEdits, }, }) if err != nil { @@ -445,7 +445,7 @@ func TestGetLaunchCommandExplicitPermissionsOverrideConfig(t *testing.T) { p := &Plugin{resolvedBinary: "claude"} cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ Permissions: ports.PermissionModeBypassPermissions, - Config: ports.AgentConfig{"permissions": string(ports.PermissionModeAcceptEdits)}, + Config: ports.AgentConfig{Permissions: ports.PermissionModeAcceptEdits}, }) if err != nil { t.Fatal(err) @@ -456,49 +456,11 @@ func TestGetLaunchCommandExplicitPermissionsOverrideConfig(t *testing.T) { } func TestGetLaunchCommandRejectsInvalidConfig(t *testing.T) { - tests := []struct { - name string - config ports.AgentConfig - }{ - {"unknown key", ports.AgentConfig{"nope": "x"}}, - {"wrong type for model", ports.AgentConfig{"model": 42}}, - {"bad permission enum", ports.AgentConfig{"permissions": "yolo"}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p := &Plugin{resolvedBinary: "claude"} - if _, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{Config: tt.config}); err == nil { - t.Fatalf("expected error for config %#v", tt.config) - } - }) - } -} - -func TestValidateConfigEnforcesSpecKinds(t *testing.T) { - spec := ports.ConfigSpec{Fields: []ports.ConfigField{ - {Key: "name", Type: ports.ConfigFieldString, Required: true}, - {Key: "tags", Type: ports.ConfigFieldStringList}, - {Key: "mystery", Type: ports.ConfigFieldType("weird")}, - }} - tests := []struct { - name string - cfg ports.AgentConfig - wantErr bool - }{ - {"required field present", ports.AgentConfig{"name": "x"}, false}, - {"required field missing", ports.AgentConfig{"tags": []any{"a"}}, true}, - {"string list ok", ports.AgentConfig{"name": "x", "tags": []any{"a", "b"}}, false}, - {"string list with non-string", ports.AgentConfig{"name": "x", "tags": []any{"a", 1.0}}, true}, - {"string list wrong kind", ports.AgentConfig{"name": "x", "tags": "a"}, true}, - {"unhandled field type", ports.AgentConfig{"name": "x", "mystery": "v"}, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateConfig(spec, tt.cfg) - if (err != nil) != tt.wantErr { - t.Fatalf("validateConfig(%#v) err = %v, wantErr = %v", tt.cfg, err, tt.wantErr) - } - }) + 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") } } diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go index 98d979b8..eadcfe8d 100644 --- a/backend/internal/cli/dto_drift_e2e_test.go +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -129,7 +129,8 @@ func (f *fakeProjectManager) Add(_ context.Context, in projectsvc.AddInput) (pro } func (f *fakeProjectManager) SetAgentConfig(_ context.Context, id domain.ProjectID, in projectsvc.SetAgentConfigInput) (projectsvc.Project, error) { - return projectsvc.Project{ID: id, AgentConfig: in.Config}, nil + cfg := in.Config + return projectsvc.Project{ID: id, AgentConfig: &cfg}, nil } func (f *fakeProjectManager) Remove(context.Context, domain.ProjectID) (projectsvc.RemoveResult, error) { diff --git a/backend/internal/cli/project.go b/backend/internal/cli/project.go index 0b8a23d4..bb22e584 100644 --- a/backend/internal/cli/project.go +++ b/backend/internal/cli/project.go @@ -54,21 +54,27 @@ type projectDetails struct { Repo string `json:"repo"` DefaultBranch string `json:"defaultBranch"` DefaultHarness string `json:"agent,omitempty"` - AgentConfig map[string]any `json:"agentConfig,omitempty"` + AgentConfig *agentConfig `json:"agentConfig,omitempty"` Tracker map[string]any `json:"tracker,omitempty"` SCM map[string]any `json:"scm,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"` +} + // setAgentConfigRequest mirrors the daemon's SetAgentConfigInput body for // PUT /api/v1/projects/{id}/agent-config. type setAgentConfigRequest struct { - Config map[string]any `json:"config"` + Config agentConfig `json:"config"` } type projectSetConfigOptions struct { - set []string - configJSON string + model string + permission string clear bool json bool } @@ -200,11 +206,9 @@ func newProjectSetConfigCommand(ctx *commandContext) *cobra.Command { cmd := &cobra.Command{ Use: "set-config ", Short: "Set the per-project agent config", - Long: "Replace a project's per-project agent config (model, permissions, " + - "adapter-specific keys). The config is resolved into the launch command " + - "when a session spawns; the owning agent adapter validates the keys.\n\n" + - "Use --set key=value (repeatable) for string values, --config-json for a " + - "full JSON object, or --clear to remove all config.", + Long: "Replace a project's per-project agent config. The config is resolved " + + "into the launch command when a session spawns.\n\n" + + "Set --model and/or --permission, or pass --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} @@ -233,56 +237,28 @@ func newProjectSetConfigCommand(ctx *commandContext) *cobra.Command { }, } f := cmd.Flags() - f.StringArrayVar(&opts.set, "set", nil, "Config key=value (repeatable; values are strings)") - f.StringVar(&opts.configJSON, "config-json", "", "Full config as a JSON object") + f.StringVar(&opts.model, "model", "", "Model override (e.g. claude-opus-4-5)") + f.StringVar(&opts.permission, "permission", "", "Permission mode: default, accept-edits, auto, bypass-permissions") f.BoolVar(&opts.clear, "clear", false, "Clear all agent config") f.BoolVar(&opts.json, "json", false, "Output the updated project as JSON") return cmd } -// buildAgentConfig turns the set-config flags into the config map sent to the -// daemon. The three input modes are mutually exclusive: --clear empties the -// config, --config-json supplies the whole object, and --set builds it from -// key=value pairs. -func buildAgentConfig(opts projectSetConfigOptions) (map[string]any, error) { - modes := 0 +// buildAgentConfig turns the set-config flags into the typed config sent to the +// daemon. --clear is mutually exclusive with the field flags and empties the +// config; otherwise the supplied fields form the new config. The daemon +// validates the values. +func buildAgentConfig(opts projectSetConfigOptions) (agentConfig, error) { if opts.clear { - modes++ - } - if opts.configJSON != "" { - modes++ - } - if len(opts.set) > 0 { - modes++ - } - switch { - case modes == 0: - return nil, usageError{errors.New("usage: provide --set, --config-json, or --clear")} - case modes > 1: - return nil, usageError{errors.New("usage: --set, --config-json, and --clear are mutually exclusive")} - } - - if opts.clear { - return map[string]any{}, nil - } - if opts.configJSON != "" { - var config map[string]any - if err := json.Unmarshal([]byte(opts.configJSON), &config); err != nil { - return nil, usageError{fmt.Errorf("--config-json is not a valid JSON object: %w", err)} + if opts.model != "" || opts.permission != "" { + return agentConfig{}, usageError{errors.New("usage: --clear cannot be combined with --model or --permission")} } - return config, nil + return agentConfig{}, nil } - - config := make(map[string]any, len(opts.set)) - for _, pair := range opts.set { - key, value, ok := strings.Cut(pair, "=") - key = strings.TrimSpace(key) - if !ok || key == "" { - return nil, usageError{fmt.Errorf("invalid --set %q: expected key=value", pair)} - } - config[key] = value + if opts.model == "" && opts.permission == "" { + return agentConfig{}, usageError{errors.New("usage: provide --model and/or --permission, or --clear")} } - return config, nil + return agentConfig{Model: opts.model, Permissions: opts.permission}, nil } func newProjectRemoveCommand(ctx *commandContext) *cobra.Command { @@ -391,9 +367,9 @@ func writeProjectDetails(cmd *cobra.Command, res projectGetResult) error { } // formatAgentConfig renders the per-project agent config as compact JSON for the -// `project get` text view. An empty config returns "" so the row is skipped. -func formatAgentConfig(config map[string]any) string { - if len(config) == 0 { +// `project get` text view. A nil config returns "" so the row is skipped. +func formatAgentConfig(config *agentConfig) string { + if config == nil { return "" } data, err := json.Marshal(config) diff --git a/backend/internal/domain/agentconfig.go b/backend/internal/domain/agentconfig.go new file mode 100644 index 00000000..19fe10a0 --- /dev/null +++ b/backend/internal/domain/agentconfig.go @@ -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) + } +} diff --git a/backend/internal/domain/project.go b/backend/internal/domain/project.go index 51c981d7..015d0a0a 100644 --- a/backend/internal/domain/project.go +++ b/backend/internal/domain/project.go @@ -10,8 +10,8 @@ type ProjectRecord struct { DisplayName string RegisteredAt time.Time ArchivedAt time.Time - // AgentConfig holds the per-project agent settings (model, permissions, - // adapter-specific keys) AO resolves into a launch command at spawn. nil - // means unset; the owning agent adapter validates the keys. - AgentConfig map[string]any + // AgentConfig holds the typed per-project agent settings (model, + // permissions) AO resolves into a launch command at spawn. An IsZero value + // means unset. + AgentConfig AgentConfig } diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index d9e0d9df..de8c6e52 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -962,8 +962,7 @@ components: AddProjectInput: properties: agentConfig: - additionalProperties: {} - type: object + $ref: '#/components/schemas/DomainAgentConfig' name: type: - "null" @@ -1051,6 +1050,13 @@ components: - state - lastActivityAt type: object + DomainAgentConfig: + properties: + model: + type: string + permissions: + type: string + type: object KillSessionResponse: properties: freed: @@ -1123,8 +1129,7 @@ components: agent: type: string agentConfig: - additionalProperties: {} - type: object + $ref: '#/components/schemas/DomainAgentConfig' defaultBranch: type: string id: @@ -1174,10 +1179,7 @@ components: ProjectSetAgentConfigInput: properties: config: - additionalProperties: {} - type: - - object - - "null" + $ref: '#/components/schemas/DomainAgentConfig' required: - config type: object diff --git a/backend/internal/ports/agent.go b/backend/internal/ports/agent.go index a00e6342..b2f56e9a 100644 --- a/backend/internal/ports/agent.go +++ b/backend/internal/ports/agent.go @@ -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. diff --git a/backend/internal/service/project/dto.go b/backend/internal/service/project/dto.go index c01ba85d..2727a48b 100644 --- a/backend/internal/service/project/dto.go +++ b/backend/internal/service/project/dto.go @@ -11,17 +11,17 @@ 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"` - AgentConfig map[string]any `json:"agentConfig,omitempty"` + Path string `json:"path"` + ProjectID *string `json:"projectId,omitempty"` + Name *string `json:"name,omitempty"` + AgentConfig *domain.AgentConfig `json:"agentConfig,omitempty"` } // SetAgentConfigInput is the body shape for PUT // /api/v1/projects/{id}/agent-config. Config replaces the project's stored -// agent config wholesale; an empty/nil map clears it. +// agent config wholesale; a zero-value config clears it. type SetAgentConfigInput struct { - Config map[string]any `json:"config"` + Config domain.AgentConfig `json:"config"` } // RemoveResult reports what DELETE /api/v1/projects/{id} actually did. diff --git a/backend/internal/service/project/service.go b/backend/internal/service/project/service.go index f7f220d9..95898949 100644 --- a/backend/internal/service/project/service.go +++ b/backend/internal/service/project/service.go @@ -123,13 +123,21 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { }) } + var agentConfig domain.AgentConfig + if in.AgentConfig != nil { + if err := in.AgentConfig.Validate(); err != nil { + return Project{}, apierr.Invalid("INVALID_AGENT_CONFIG", err.Error(), nil) + } + agentConfig = *in.AgentConfig + } + row := domain.ProjectRecord{ ID: string(id), Path: path, RepoOriginURL: resolveGitOriginURL(path), DisplayName: name, RegisteredAt: time.Now(), - AgentConfig: in.AgentConfig, + AgentConfig: agentConfig, } if err := m.store.UpsertProject(ctx, row); err != nil { return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register project") @@ -137,13 +145,16 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { return projectFromRow(row), nil } -// SetAgentConfig replaces the project's stored agent config. The config is -// persisted as-is; the owning agent adapter validates the keys at spawn when the -// session's harness — and therefore the adapter that owns the keys — is known. +// SetAgentConfig replaces the project's stored agent config. The typed config is +// validated here so a bad value is rejected when set rather than silently +// dropped at spawn. func (m *Service) SetAgentConfig(ctx context.Context, id domain.ProjectID, in SetAgentConfigInput) (Project, error) { if err := validateProjectID(id); err != nil { return Project{}, err } + if err := in.Config.Validate(); err != nil { + return Project{}, apierr.Invalid("INVALID_AGENT_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") @@ -195,14 +206,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", - AgentConfig: row.AgentConfig, } + if !row.AgentConfig.IsZero() { + cfg := row.AgentConfig + p.AgentConfig = &cfg + } + return p } func displayName(row domain.ProjectRecord) string { diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index 2a584754..32555ffc 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -104,12 +104,12 @@ func TestManager_SetAgentConfig(t *testing.T) { t.Fatalf("Add: %v", err) } - cfg := map[string]any{"model": "claude-opus-4-5"} + cfg := domain.AgentConfig{Model: "claude-opus-4-5"} proj, err := m.SetAgentConfig(ctx, "ao", project.SetAgentConfigInput{Config: cfg}) if err != nil { t.Fatalf("SetAgentConfig: %v", err) } - if proj.AgentConfig["model"] != "claude-opus-4-5" { + if proj.AgentConfig == nil || proj.AgentConfig.Model != "claude-opus-4-5" { t.Fatalf("returned config = %#v", proj.AgentConfig) } @@ -118,10 +118,14 @@ func TestManager_SetAgentConfig(t *testing.T) { if err != nil { t.Fatalf("Get: %v", err) } - if got.Project == nil || got.Project.AgentConfig["model"] != "claude-opus-4-5" { + if got.Project == nil || got.Project.AgentConfig == nil || got.Project.AgentConfig.Model != "claude-opus-4-5" { t.Fatalf("Get config = %#v", got.Project) } + // An invalid permission value is rejected when set. + _, err = m.SetAgentConfig(ctx, "ao", project.SetAgentConfigInput{Config: domain.AgentConfig{Permissions: "yolo"}}) + wantCode(t, err, "INVALID_AGENT_CONFIG") + // Setting on an unknown project is a clean not-found. _, err = m.SetAgentConfig(ctx, "ghost", project.SetAgentConfigInput{Config: cfg}) wantCode(t, err, "PROJECT_NOT_FOUND") diff --git a/backend/internal/service/project/types.go b/backend/internal/service/project/types.go index 20fe8cf3..50848f58 100644 --- a/backend/internal/service/project/types.go +++ b/backend/internal/service/project/types.go @@ -12,15 +12,15 @@ 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"` - AgentConfig map[string]any `json:"agentConfig,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"` + AgentConfig *domain.AgentConfig `json:"agentConfig,omitempty"` + Tracker *TrackerConfig `json:"tracker,omitempty"` + SCM *SCMConfig `json:"scm,omitempty"` } // Degraded is returned in place of Project when project config failed to load. diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 64df17fa..5dd943d5 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -215,12 +215,12 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess func (m *Manager) resolveAgentConfig(ctx context.Context, projectID domain.ProjectID) (ports.AgentConfig, error) { row, ok, err := m.store.GetProject(ctx, string(projectID)) if err != nil { - return nil, fmt.Errorf("resolve agent config: %w", err) + return ports.AgentConfig{}, fmt.Errorf("resolve agent config: %w", err) } - if !ok || len(row.AgentConfig) == 0 { - return nil, nil + if !ok { + return ports.AgentConfig{}, nil } - return ports.AgentConfig(row.AgentConfig), nil + return row.AgentConfig, nil } // markSpawnFailedTerminated best-effort parks an orphaned spawn as terminated. diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 51695192..83f8a92b 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -198,7 +198,7 @@ func mkLive(id domain.SessionID) domain.SessionRecord { func TestSpawn_ResolvesProjectAgentConfig(t *testing.T) { st := newFakeStore() - st.projects["mer"] = domain.ProjectRecord{ID: "mer", AgentConfig: map[string]any{"model": "claude-opus-4-5"}} + st.projects["mer"] = domain.ProjectRecord{ID: "mer", AgentConfig: domain.AgentConfig{Model: "claude-opus-4-5"}} 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}) @@ -206,18 +206,18 @@ func TestSpawn_ResolvesProjectAgentConfig(t *testing.T) { if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { t.Fatal(err) } - if agent.lastConfig["model"] != "claude-opus-4-5" { + if agent.lastConfig.Model != "claude-opus-4-5" { t.Fatalf("launch config = %#v, want model resolved from project", agent.lastConfig) } - // A project with no stored config yields a nil AgentConfig (adapter defaults). + // A project with no stored config yields a zero AgentConfig (adapter defaults). st.projects["bare"] = domain.ProjectRecord{ID: "bare"} - agent.lastConfig = ports.AgentConfig{"stale": true} + 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 != nil { - t.Fatalf("launch config = %#v, want nil for project without config", agent.lastConfig) + if !agent.lastConfig.IsZero() { + t.Fatalf("launch config = %#v, want zero for project without config", agent.lastConfig) } } diff --git a/backend/internal/storage/sqlite/store/project_store.go b/backend/internal/storage/sqlite/store/project_store.go index 04d1563a..53c86387 100644 --- a/backend/internal/storage/sqlite/store/project_store.go +++ b/backend/internal/storage/sqlite/store/project_store.go @@ -113,11 +113,11 @@ func projectRowFromGen(p gen.Project) (domain.ProjectRecord, error) { return r, nil } -// marshalAgentConfig encodes a per-project agent config into the nullable JSON -// column. A nil or empty map stores SQL NULL so an unset config round-trips back -// to nil rather than an empty object. -func marshalAgentConfig(cfg map[string]any) (sql.NullString, error) { - if len(cfg) == 0 { +// marshalAgentConfig encodes the typed per-project agent 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 marshalAgentConfig(cfg domain.AgentConfig) (sql.NullString, error) { + if cfg.IsZero() { return sql.NullString{}, nil } data, err := json.Marshal(cfg) @@ -127,15 +127,15 @@ func marshalAgentConfig(cfg map[string]any) (sql.NullString, error) { return sql.NullString{String: string(data), Valid: true}, nil } -// unmarshalAgentConfig decodes the nullable JSON column back into a map. SQL -// NULL (an unset config) decodes to nil. -func unmarshalAgentConfig(s sql.NullString) (map[string]any, error) { +// unmarshalAgentConfig decodes the nullable JSON column back into the typed +// struct. SQL NULL (an unset config) decodes to a zero value. +func unmarshalAgentConfig(s sql.NullString) (domain.AgentConfig, error) { if !s.Valid || s.String == "" { - return nil, nil + return domain.AgentConfig{}, nil } - var cfg map[string]any + var cfg domain.AgentConfig if err := json.Unmarshal([]byte(s.String), &cfg); err != nil { - return nil, fmt.Errorf("unmarshal agent config: %w", err) + return domain.AgentConfig{}, fmt.Errorf("unmarshal agent config: %w", err) } return cfg, nil } diff --git a/backend/internal/storage/sqlite/store/store_test.go b/backend/internal/storage/sqlite/store/store_test.go index c6d8f17c..1ebb4ae7 100644 --- a/backend/internal/storage/sqlite/store/store_test.go +++ b/backend/internal/storage/sqlite/store/store_test.go @@ -76,8 +76,8 @@ func TestProjectAgentConfigRoundTrips(t *testing.T) { ctx := context.Background() now := time.Now().UTC().Truncate(time.Second) - // A config with mixed value kinds survives the JSON round trip. - cfg := map[string]any{"model": "claude-opus-4-5", "permissions": "accept-edits"} + // A populated config survives the JSON round trip. + cfg := domain.AgentConfig{Model: "claude-opus-4-5", Permissions: domain.PermissionModeAcceptEdits} if err := s.UpsertProject(ctx, domain.ProjectRecord{ ID: "cfg", Path: "/tmp/cfg", RegisteredAt: now, AgentConfig: cfg, }); err != nil { @@ -87,25 +87,25 @@ func TestProjectAgentConfigRoundTrips(t *testing.T) { if err != nil || !ok { t.Fatalf("get: ok=%v err=%v", ok, err) } - if got.AgentConfig["model"] != "claude-opus-4-5" || got.AgentConfig["permissions"] != "accept-edits" { - t.Fatalf("agent config = %#v", got.AgentConfig) + if got.AgentConfig != cfg { + t.Fatalf("agent config = %#v, want %#v", got.AgentConfig, cfg) } - // An unset config round-trips back to nil rather than an empty object. + // 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.AgentConfig != nil { - t.Fatalf("unset config = %#v, want nil", got.AgentConfig) + if !got.AgentConfig.IsZero() { + t.Fatalf("unset config = %#v, want zero", got.AgentConfig) } - // Clearing replaces a previously-set config with nil. + // Clearing replaces a previously-set config with a zero value. if err := s.UpsertProject(ctx, domain.ProjectRecord{ - ID: "cfg", Path: "/tmp/cfg", RegisteredAt: now, AgentConfig: nil, + ID: "cfg", Path: "/tmp/cfg", RegisteredAt: now, AgentConfig: domain.AgentConfig{}, }); err != nil { t.Fatalf("clear config: %v", err) } - if got, _, _ := s.GetProject(ctx, "cfg"); got.AgentConfig != nil { - t.Fatalf("cleared config = %#v, want nil", got.AgentConfig) + if got, _, _ := s.GetProject(ctx, "cfg"); !got.AgentConfig.IsZero() { + t.Fatalf("cleared config = %#v, want zero", got.AgentConfig) } } diff --git a/docs/design/per-project-config.md b/docs/design/per-project-config.md new file mode 100644 index 00000000..cdd73c6d --- /dev/null +++ b/docs/design/per-project-config.md @@ -0,0 +1,120 @@ +# Design: typed per-project configuration + +Status: **blueprint** (agent config slice implemented; the rest is sequenced below) + +## 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.`) 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 { ... } + +// target — assembled incrementally, one slice per PR +type ProjectConfig struct { + DefaultBranch string + SessionPrefix string + AgentConfig AgentConfig + Worker RoleOverride // {Harness, AgentConfig} + Orchestrator RoleOverride + Env map[string]string + Symlinks []string + PostCreate []string + AgentRules string + 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. diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index b7f4330e..524befd2 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -329,9 +329,7 @@ export interface components { requestId?: string; }; AddProjectInput: { - agentConfig?: { - [key: string]: unknown; - }; + agentConfig?: components["schemas"]["DomainAgentConfig"]; name?: null | string; path: string; projectId?: null | string; @@ -362,6 +360,10 @@ export interface components { lastActivityAt: string; state: string; }; + DomainAgentConfig: { + model?: string; + permissions?: string; + }; KillSessionResponse: { freed?: boolean; ok: boolean; @@ -389,9 +391,7 @@ export interface components { }; Project: { agent?: string; - agentConfig?: { - [key: string]: unknown; - }; + agentConfig?: components["schemas"]["DomainAgentConfig"]; defaultBranch: string; id: string; name: string; @@ -410,9 +410,7 @@ export interface components { project: components["schemas"]["Project"]; }; ProjectSetAgentConfigInput: { - config: { - [key: string]: unknown; - } | null; + config: components["schemas"]["DomainAgentConfig"]; }; ProjectSummary: { id: string; From 03581ac9f59c8bd8cf8a0beba02e80ef598e253b Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Sun, 7 Jun 2026 18:19:43 +0530 Subject: [PATCH 04/11] feat(config): full typed per-project ProjectConfig (store, resolve, surface) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand per-project config from agentConfig-only to the full legacy `projects.` 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 --- .../workspace/gitworktree/workspace.go | 19 +- backend/internal/cli/dto_drift_e2e_test.go | 4 +- backend/internal/cli/project.go | 159 +++++++++++--- backend/internal/domain/harness.go | 20 ++ backend/internal/domain/project.go | 7 +- backend/internal/domain/projectconfig.go | 116 ++++++++++ backend/internal/domain/projectconfig_test.go | 39 ++++ backend/internal/httpd/apispec/openapi.yaml | 106 +++++---- .../internal/httpd/apispec/specgen/build.go | 34 +-- .../internal/httpd/controllers/projects.go | 10 +- backend/internal/ports/outbound.go | 3 + backend/internal/service/project/dto.go | 17 +- backend/internal/service/project/service.go | 55 +++-- .../internal/service/project/service_test.go | 31 ++- backend/internal/service/project/types.go | 42 +--- backend/internal/session_manager/manager.go | 207 +++++++++++++++--- .../internal/session_manager/manager_test.go | 48 +++- .../session_manager/provision_test.go | 115 ++++++++++ backend/internal/storage/sqlite/gen/models.go | 2 +- .../storage/sqlite/gen/projects.sql.go | 20 +- .../0008_add_project_agent_config.sql | 15 -- .../migrations/0008_add_project_config.sql | 15 ++ .../storage/sqlite/queries/projects.sql | 10 +- .../storage/sqlite/store/project_store.go | 28 +-- .../storage/sqlite/store/store_test.go | 31 ++- docs/design/per-project-config.md | 8 +- frontend/src/api/schema.ts | 54 +++-- 27 files changed, 924 insertions(+), 291 deletions(-) create mode 100644 backend/internal/domain/projectconfig.go create mode 100644 backend/internal/domain/projectconfig_test.go create mode 100644 backend/internal/session_manager/provision_test.go delete mode 100644 backend/internal/storage/sqlite/migrations/0008_add_project_agent_config.sql create mode 100644 backend/internal/storage/sqlite/migrations/0008_add_project_config.sql diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index 1e8e6c64..68486cab 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -122,7 +122,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 +198,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 +233,7 @@ func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch string) // neither origin/, 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 +257,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 { diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go index eadcfe8d..216e7c52 100644 --- a/backend/internal/cli/dto_drift_e2e_test.go +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -128,9 +128,9 @@ func (f *fakeProjectManager) Add(_ context.Context, in projectsvc.AddInput) (pro return projectsvc.Project{ID: id, Path: in.Path}, nil } -func (f *fakeProjectManager) SetAgentConfig(_ context.Context, id domain.ProjectID, in projectsvc.SetAgentConfigInput) (projectsvc.Project, error) { +func (f *fakeProjectManager) SetConfig(_ context.Context, id domain.ProjectID, in projectsvc.SetConfigInput) (projectsvc.Project, error) { cfg := in.Config - return projectsvc.Project{ID: id, AgentConfig: &cfg}, nil + return projectsvc.Project{ID: id, Config: &cfg}, nil } func (f *fakeProjectManager) Remove(context.Context, domain.ProjectID) (projectsvc.RemoveResult, error) { diff --git a/backend/internal/cli/project.go b/backend/internal/cli/project.go index bb22e584..68fa94ab 100644 --- a/backend/internal/cli/project.go +++ b/backend/internal/cli/project.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/url" + "reflect" "sort" "strings" "text/tabwriter" @@ -54,9 +55,7 @@ type projectDetails struct { Repo string `json:"repo"` DefaultBranch string `json:"defaultBranch"` DefaultHarness string `json:"agent,omitempty"` - AgentConfig *agentConfig `json:"agentConfig,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"` } @@ -66,17 +65,53 @@ type agentConfig struct { Permissions string `json:"permissions,omitempty"` } -// setAgentConfigRequest mirrors the daemon's SetAgentConfigInput body for -// PUT /api/v1/projects/{id}/agent-config. -type setAgentConfigRequest struct { - Config agentConfig `json:"config"` +// 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"` + AgentRules string `json:"agentRules,omitempty"` + AgentRulesFile string `json:"agentRulesFile,omitempty"` + OrchestratorRules string `json:"orchestratorRules,omitempty"` + AgentConfig agentConfig `json:"agentConfig,omitempty"` + Worker roleOverride `json:"worker,omitempty"` + Orchestrator roleOverride `json:"orchestrator,omitempty"` + OpencodeIssueSessionStrategy string `json:"opencodeIssueSessionStrategy,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 { - model string - permission string - clear bool - json bool + defaultBranch string + sessionPrefix string + model string + permission string + agentRules string + agentRulesFile string + orchestratorRules string + workerAgent string + orchestratorAgent string + opencodeStrategy string + env []string + symlink []string + postCreate []string + configJSON string + clear bool + json bool } type projectListResult struct { @@ -205,10 +240,12 @@ func newProjectSetConfigCommand(ctx *commandContext) *cobra.Command { var opts projectSetConfigOptions cmd := &cobra.Command{ Use: "set-config ", - Short: "Set the per-project agent config", - Long: "Replace a project's per-project agent config. The config is resolved " + - "into the launch command when a session spawns.\n\n" + - "Set --model and/or --permission, or pass --clear to remove all config.", + Short: "Set the per-project config", + Long: "Replace a project's per-project config (branch, env, symlinks, " + + "post-create, rules, 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} @@ -220,45 +257,97 @@ func newProjectSetConfigCommand(ctx *commandContext) *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { id := strings.TrimSpace(args[0]) - config, err := buildAgentConfig(opts) + config, err := buildProjectConfig(opts) if err != nil { return err } - req := setAgentConfigRequest{Config: config} + req := setConfigRequest{Config: config} var res projectResult - if err := ctx.putJSON(cmd.Context(), "projects/"+url.PathEscape(id)+"/agent-config", req, &res); err != nil { + 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 agent config for project %s\n", res.Project.ID) + _, err = fmt.Fprintf(cmd.OutOrStdout(), "updated config for project %s\n", res.Project.ID) return err }, } f := cmd.Flags() - f.StringVar(&opts.model, "model", "", "Model override (e.g. claude-opus-4-5)") + 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.BoolVar(&opts.clear, "clear", false, "Clear all agent config") + f.StringVar(&opts.agentRules, "agent-rules", "", "Inline rules appended to every agent prompt") + f.StringVar(&opts.agentRulesFile, "agent-rules-file", "", "Path (relative to the project) to a rules file") + f.StringVar(&opts.orchestratorRules, "orchestrator-rules", "", "Inline rules appended to orchestrator prompts") + f.StringVar(&opts.workerAgent, "worker-agent", "", "Harness override for worker sessions") + f.StringVar(&opts.orchestratorAgent, "orchestrator-agent", "", "Harness override for orchestrator sessions") + f.StringVar(&opts.opencodeStrategy, "opencode-strategy", "", "OpenCode issue-session strategy: reuse, delete, ignore") + 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 } -// buildAgentConfig turns the set-config flags into the typed config sent to the -// daemon. --clear is mutually exclusive with the field flags and empties the -// config; otherwise the supplied fields form the new config. The daemon -// validates the values. -func buildAgentConfig(opts projectSetConfigOptions) (agentConfig, error) { +// 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 { - if opts.model != "" || opts.permission != "" { - return agentConfig{}, usageError{errors.New("usage: --clear cannot be combined with --model or --permission")} + 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 agentConfig{}, nil + 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, + AgentRules: opts.agentRules, + AgentRulesFile: opts.agentRulesFile, + OrchestratorRules: opts.orchestratorRules, + AgentConfig: agentConfig{Model: opts.model, Permissions: opts.permission}, + Worker: roleOverride{Agent: opts.workerAgent}, + Orchestrator: roleOverride{Agent: opts.orchestratorAgent}, + OpencodeIssueSessionStrategy: opts.opencodeStrategy, } - if opts.model == "" && opts.permission == "" { - return agentConfig{}, usageError{errors.New("usage: provide --model and/or --permission, or --clear")} + 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 agentConfig{Model: opts.model, Permissions: opts.permission}, nil + return env, nil } func newProjectRemoveCommand(ctx *commandContext) *cobra.Command { @@ -352,7 +441,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: "agent config", value: formatAgentConfig(p.AgentConfig)}, + {label: "config", value: formatProjectConfig(p.Config)}, {label: "resolve error", value: p.ResolveError}, } for _, f := range fields { @@ -366,9 +455,9 @@ func writeProjectDetails(cmd *cobra.Command, res projectGetResult) error { return nil } -// formatAgentConfig renders the per-project agent config as compact JSON for the +// 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 formatAgentConfig(config *agentConfig) string { +func formatProjectConfig(config *projectConfig) string { if config == nil { return "" } diff --git a/backend/internal/domain/harness.go b/backend/internal/domain/harness.go index 41a84742..97babe69 100644 --- a/backend/internal/domain/harness.go +++ b/backend/internal/domain/harness.go @@ -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 +} diff --git a/backend/internal/domain/project.go b/backend/internal/domain/project.go index 015d0a0a..28906cdf 100644 --- a/backend/internal/domain/project.go +++ b/backend/internal/domain/project.go @@ -10,8 +10,7 @@ type ProjectRecord struct { DisplayName string RegisteredAt time.Time ArchivedAt time.Time - // AgentConfig holds the typed per-project agent settings (model, - // permissions) AO resolves into a launch command at spawn. An IsZero value - // means unset. - AgentConfig AgentConfig + // Config holds the typed per-project configuration AO resolves at spawn. An + // IsZero value means unset. + Config ProjectConfig } diff --git a/backend/internal/domain/projectconfig.go b/backend/internal/domain/projectconfig.go new file mode 100644 index 00000000..912034a1 --- /dev/null +++ b/backend/internal/domain/projectconfig.go @@ -0,0 +1,116 @@ +package domain + +import ( + "fmt" + "reflect" +) + +// ProjectConfig is the typed per-project configuration — the SQLite twin of the +// legacy agent-orchestrator.yaml `projects.` block. It is persisted as one +// JSON blob per project and resolved at spawn. Each field is typed and +// validated; there is no free-form map. +// +// Some fields are consumed at spawn today (DefaultBranch, Env, Symlinks, +// PostCreate, the rules, AgentConfig, and the role overrides). Others are +// persisted and validated but not yet consumed — Tracker, SCM, and +// OpencodeIssueSessionStrategy await the infrastructure that will read them, and +// SessionPrefix currently feeds only the display prefix (session-id generation +// is unchanged). +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"` + + // AgentRules are inline rules appended to every agent prompt for the project. + AgentRules string `json:"agentRules,omitempty"` + // AgentRulesFile is a path (relative to the project) whose contents are + // appended to every agent prompt. + AgentRulesFile string `json:"agentRulesFile,omitempty"` + // OrchestratorRules are inline rules appended to orchestrator prompts. + OrchestratorRules string `json:"orchestratorRules,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"` + + // Tracker selects and configures the project's issue tracker (not yet consumed). + Tracker TrackerConfig `json:"tracker,omitempty"` + // SCM selects and configures the project's source-control integration (not yet consumed). + SCM SCMConfig `json:"scm,omitempty"` + // OpencodeIssueSessionStrategy controls OpenCode issue-session reuse (not yet consumed). + OpencodeIssueSessionStrategy string `json:"opencodeIssueSessionStrategy,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"` +} + +// TrackerConfig selects and configures a project's issue tracker. +type TrackerConfig struct { + Plugin string `json:"plugin,omitempty"` + TeamID string `json:"teamId,omitempty"` +} + +// SCMConfig selects and configures a project's source-control integration. +type SCMConfig struct { + Plugin string `json:"plugin,omitempty"` + Webhook *SCMWebhookConfig `json:"webhook,omitempty"` +} + +// SCMWebhookConfig describes SCM webhook acceleration settings. +type SCMWebhookConfig struct { + 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"` +} + +// The OpenCode issue-session strategies. +const ( + OpencodeSessionReuse = "reuse" + OpencodeSessionDelete = "delete" + OpencodeSessionIgnore = "ignore" +) + +// 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) + } + } + switch c.OpencodeIssueSessionStrategy { + case "", OpencodeSessionReuse, OpencodeSessionDelete, OpencodeSessionIgnore: + default: + return fmt.Errorf("opencodeIssueSessionStrategy: want one of reuse, delete, ignore") + } + return nil +} diff --git a/backend/internal/domain/projectconfig_test.go b/backend/internal/domain/projectconfig_test.go new file mode 100644 index 00000000..c244ca96 --- /dev/null +++ b/backend/internal/domain/projectconfig_test.go @@ -0,0 +1,39 @@ +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 opencode strategy", ProjectConfig{OpencodeIssueSessionStrategy: OpencodeSessionReuse}, false}, + {"bad opencode strategy", ProjectConfig{OpencodeIssueSessionStrategy: "sometimes"}, 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 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") + } +} diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index de8c6e52..bddd7033 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -283,9 +283,9 @@ paths: summary: Fetch one project; discriminates ok vs degraded tags: - projects - /api/v1/projects/{id}/agent-config: + /api/v1/projects/{id}/config: put: - operationId: setProjectAgentConfig + operationId: setProjectConfig parameters: - description: Project identifier (registry key). in: path @@ -298,7 +298,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ProjectSetAgentConfigInput' + $ref: '#/components/schemas/SetProjectConfigInput' required: true responses: "200": @@ -325,7 +325,7 @@ paths: schema: $ref: '#/components/schemas/APIError' description: Internal Server Error - summary: Replace a project's per-project agent config + summary: Replace a project's per-project config tags: - projects /api/v1/prs/{id}/merge: @@ -961,8 +961,8 @@ components: type: object AddProjectInput: properties: - agentConfig: - $ref: '#/components/schemas/DomainAgentConfig' + config: + $ref: '#/components/schemas/ProjectConfig' name: type: - "null" @@ -976,6 +976,13 @@ components: required: - path type: object + AgentConfig: + properties: + model: + type: string + permissions: + type: string + type: object ClaimPRRequest: properties: allowTakeover: @@ -1050,13 +1057,6 @@ components: - state - lastActivityAt type: object - DomainAgentConfig: - properties: - model: - type: string - permissions: - type: string - type: object KillSessionResponse: properties: freed: @@ -1128,8 +1128,8 @@ components: properties: agent: type: string - agentConfig: - $ref: '#/components/schemas/DomainAgentConfig' + config: + $ref: '#/components/schemas/ProjectConfig' defaultBranch: type: string id: @@ -1140,10 +1140,6 @@ components: type: string repo: type: string - scm: - $ref: '#/components/schemas/SCMConfig' - tracker: - $ref: '#/components/schemas/TrackerConfig' required: - id - name @@ -1151,6 +1147,43 @@ components: - repo - defaultBranch type: object + ProjectConfig: + properties: + agentConfig: + $ref: '#/components/schemas/AgentConfig' + agentRules: + type: string + agentRulesFile: + type: string + defaultBranch: + type: string + env: + additionalProperties: + type: string + type: object + opencodeIssueSessionStrategy: + type: string + orchestrator: + $ref: '#/components/schemas/RoleOverride' + orchestratorRules: + type: string + postCreate: + items: + type: string + type: array + scm: + $ref: '#/components/schemas/SCMConfig' + sessionPrefix: + type: string + symlinks: + items: + type: string + type: array + tracker: + $ref: '#/components/schemas/TrackerConfig' + worker: + $ref: '#/components/schemas/RoleOverride' + type: object ProjectGetResponse: properties: project: @@ -1176,13 +1209,6 @@ components: required: - project type: object - ProjectSetAgentConfigInput: - properties: - config: - $ref: '#/components/schemas/DomainAgentConfig' - required: - - config - type: object ProjectSummary: properties: id: @@ -1252,6 +1278,13 @@ components: - sessionId - session type: object + RoleOverride: + properties: + agent: + type: string + agentConfig: + $ref: '#/components/schemas/AgentConfig' + type: object RollbackSessionResponse: properties: deleted: @@ -1268,10 +1301,6 @@ components: type: object SCMConfig: properties: - package: - type: string - path: - type: string plugin: type: string webhook: @@ -1281,10 +1310,6 @@ components: properties: deliveryHeader: type: string - enabled: - type: - - "null" - - boolean eventHeader: type: string maxBodyBytes: @@ -1421,6 +1446,13 @@ components: - sessionId - state type: object + SetProjectConfigInput: + properties: + config: + $ref: '#/components/schemas/ProjectConfig' + required: + - config + type: object SpawnOrchestratorRequest: properties: clean: @@ -1486,12 +1518,10 @@ components: type: object TrackerConfig: properties: - package: - type: string - path: - type: string plugin: type: string + teamId: + type: string type: object tags: - description: Project registry, configuration, and lifecycle administration diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 4e89a76c..40e5402e 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -118,10 +118,16 @@ 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", + "DomainTrackerConfig": "TrackerConfig", + "DomainSCMConfig": "SCMConfig", + "DomainSCMWebhookConfig": "SCMWebhookConfig", // httpd/controllers (wire envelopes) "ControllersListProjectsResponse": "ListProjectsResponse", "ControllersProjectResponse": "ProjectResponse", @@ -154,14 +160,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 @@ -298,10 +302,10 @@ func projectOperations() []operation { }, }, { - method: http.MethodPut, path: "/api/v1/projects/{id}/agent-config", id: "setProjectAgentConfig", tag: "projects", - summary: "Replace a project's per-project agent config", + 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.SetAgentConfigInput{}, + reqBody: projectsvc.SetConfigInput{}, resps: []respUnit{ {http.StatusOK, controllers.ProjectResponse{}}, {http.StatusBadRequest, envelope.APIError{}}, diff --git a/backend/internal/httpd/controllers/projects.go b/backend/internal/httpd/controllers/projects.go index 83feda83..3db688b5 100644 --- a/backend/internal/httpd/controllers/projects.go +++ b/backend/internal/httpd/controllers/projects.go @@ -27,7 +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}/agent-config", c.setAgentConfig) + r.Put("/projects/{id}/config", c.setConfig) r.Delete("/projects/{id}", c.remove) } @@ -83,17 +83,17 @@ func (c *ProjectsController) get(w http.ResponseWriter, r *http.Request) { envelope.WriteJSON(w, http.StatusOK, resp) } -func (c *ProjectsController) setAgentConfig(w http.ResponseWriter, r *http.Request) { +func (c *ProjectsController) setConfig(w http.ResponseWriter, r *http.Request) { if c.Mgr == nil { - apispec.NotImplemented(w, r, "PUT", "/api/v1/projects/{id}/agent-config") + apispec.NotImplemented(w, r, "PUT", "/api/v1/projects/{id}/config") return } - var in projectsvc.SetAgentConfigInput + var in projectsvc.SetConfigInput if err := decodeJSON(r, &in); err != nil { envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil) return } - p, err := c.Mgr.SetAgentConfig(r.Context(), projectID(r), in) + p, err := c.Mgr.SetConfig(r.Context(), projectID(r), in) if err != nil { envelope.WriteError(w, r, err) return diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index 720cb168..d10fdff4 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -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. diff --git a/backend/internal/service/project/dto.go b/backend/internal/service/project/dto.go index 2727a48b..fd750fe9 100644 --- a/backend/internal/service/project/dto.go +++ b/backend/internal/service/project/dto.go @@ -11,17 +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"` - AgentConfig *domain.AgentConfig `json:"agentConfig,omitempty"` + Path string `json:"path"` + ProjectID *string `json:"projectId,omitempty"` + Name *string `json:"name,omitempty"` + Config *domain.ProjectConfig `json:"config,omitempty"` } -// SetAgentConfigInput is the body shape for PUT -// /api/v1/projects/{id}/agent-config. Config replaces the project's stored -// agent config wholesale; a zero-value config clears it. -type SetAgentConfigInput struct { - Config domain.AgentConfig `json:"config"` +// 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. diff --git a/backend/internal/service/project/service.go b/backend/internal/service/project/service.go index 95898949..47fc0834 100644 --- a/backend/internal/service/project/service.go +++ b/backend/internal/service/project/service.go @@ -26,9 +26,9 @@ type Manager interface { // Add registers a new project from a git repository path. Add(ctx context.Context, in AddInput) (Project, error) - // SetAgentConfig replaces a project's per-project agent config, returning - // the updated read-model. - SetAgentConfig(ctx context.Context, id domain.ProjectID, in SetAgentConfigInput) (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. @@ -58,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 @@ -123,12 +123,12 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { }) } - var agentConfig domain.AgentConfig - if in.AgentConfig != nil { - if err := in.AgentConfig.Validate(); err != nil { - return Project{}, apierr.Invalid("INVALID_AGENT_CONFIG", err.Error(), nil) + 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) } - agentConfig = *in.AgentConfig + config = *in.Config } row := domain.ProjectRecord{ @@ -137,7 +137,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { RepoOriginURL: resolveGitOriginURL(path), DisplayName: name, RegisteredAt: time.Now(), - AgentConfig: agentConfig, + Config: config, } if err := m.store.UpsertProject(ctx, row); err != nil { return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register project") @@ -145,15 +145,14 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { return projectFromRow(row), nil } -// SetAgentConfig replaces the project's stored agent config. The typed config is -// validated here so a bad value is rejected when set rather than silently -// dropped at spawn. -func (m *Service) SetAgentConfig(ctx context.Context, id domain.ProjectID, in SetAgentConfigInput) (Project, error) { +// 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_AGENT_CONFIG", err.Error(), nil) + return Project{}, apierr.Invalid("INVALID_PROJECT_CONFIG", err.Error(), nil) } row, ok, err := m.store.GetProject(ctx, string(id)) if err != nil { @@ -162,9 +161,9 @@ func (m *Service) SetAgentConfig(ctx context.Context, id domain.ProjectID, in Se if !ok || !row.ArchivedAt.IsZero() { return Project{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project") } - row.AgentConfig = in.Config + 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 agent config") + return Project{}, apierr.Internal("PROJECT_CONFIG_UPDATE_FAILED", "Failed to update project config") } return projectFromRow(row), nil } @@ -206,16 +205,20 @@ func (m *Service) suggestID(ctx context.Context, base domain.ProjectID) domain.P } func projectFromRow(row domain.ProjectRecord) Project { + defaultBranch := "main" + if row.Config.DefaultBranch != "" { + defaultBranch = row.Config.DefaultBranch + } p := Project{ ID: domain.ProjectID(row.ID), Name: displayName(row), Path: row.Path, Repo: row.RepoOriginURL, - DefaultBranch: "main", + DefaultBranch: defaultBranch, } - if !row.AgentConfig.IsZero() { - cfg := row.AgentConfig - p.AgentConfig = &cfg + if !row.Config.IsZero() { + cfg := row.Config + p.Config = &cfg } return p } @@ -293,6 +296,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" diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index 32555ffc..d1d3a88e 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -95,7 +95,7 @@ func TestManager_AddListGetRemove(t *testing.T) { wantCode(t, err, "PROJECT_NOT_FOUND") } -func TestManager_SetAgentConfig(t *testing.T) { +func TestManager_SetConfig(t *testing.T) { ctx := context.Background() m := newManager(t) repo := gitRepo(t) @@ -104,13 +104,20 @@ func TestManager_SetAgentConfig(t *testing.T) { t.Fatalf("Add: %v", err) } - cfg := domain.AgentConfig{Model: "claude-opus-4-5"} - proj, err := m.SetAgentConfig(ctx, "ao", project.SetAgentConfigInput{Config: cfg}) + 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("SetAgentConfig: %v", err) + 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.AgentConfig == nil || proj.AgentConfig.Model != "claude-opus-4-5" { - t.Fatalf("returned config = %#v", proj.AgentConfig) + if proj.DefaultBranch != "develop" { + t.Fatalf("DefaultBranch = %q, want develop", proj.DefaultBranch) } // The config persists and shows up on a fresh Get. @@ -118,16 +125,20 @@ func TestManager_SetAgentConfig(t *testing.T) { if err != nil { t.Fatalf("Get: %v", err) } - if got.Project == nil || got.Project.AgentConfig == nil || got.Project.AgentConfig.Model != "claude-opus-4-5" { + 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.SetAgentConfig(ctx, "ao", project.SetAgentConfigInput{Config: domain.AgentConfig{Permissions: "yolo"}}) - wantCode(t, err, "INVALID_AGENT_CONFIG") + _, 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.SetAgentConfig(ctx, "ghost", project.SetAgentConfigInput{Config: cfg}) + _, err = m.SetConfig(ctx, "ghost", project.SetConfigInput{Config: cfg}) wantCode(t, err, "PROJECT_NOT_FOUND") } diff --git a/backend/internal/service/project/types.go b/backend/internal/service/project/types.go index 50848f58..af3739ff 100644 --- a/backend/internal/service/project/types.go +++ b/backend/internal/service/project/types.go @@ -12,15 +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"` - AgentConfig *domain.AgentConfig `json:"agentConfig,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. @@ -30,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"` -} diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 5dd943d5..df25b4a1 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -6,7 +6,11 @@ import ( "context" "errors" "fmt" + "os" "os/exec" + "path/filepath" + "runtime" + "strings" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -123,7 +127,15 @@ 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) { - prompt, err := m.buildSpawnPrompt(ctx, cfg) + 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, project) if err != nil { return domain.SessionRecord{}, fmt.Errorf("spawn: prompt: %w", err) } @@ -141,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.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) @@ -158,18 +183,12 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess m.markSpawnFailedTerminated(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err) } - agentConfig, err := m.resolveAgentConfig(ctx, cfg.ProjectID) - if err != nil { - _ = m.workspace.Destroy(ctx, ws) - m.markSpawnFailedTerminated(ctx, id) - return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err) - } argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{ SessionID: string(id), WorkspacePath: ws.Path, Prompt: prompt, IssueID: string(cfg.IssueID), - Config: agentConfig, + Config: effectiveAgentConfig(cfg.Kind, project.Config), }) if err != nil { _ = m.workspace.Destroy(ctx, ws) @@ -189,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) @@ -207,20 +226,54 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess return m.getRecord(ctx, id) } -// resolveAgentConfig loads the project's per-project agent config so it can be -// handed to the adapter's GetLaunchCommand. A missing project row yields a nil -// config rather than an error: the project may be unregistered yet still have -// live sessions, and an empty config simply means the adapter falls back to its -// own defaults. -func (m *Manager) resolveAgentConfig(ctx context.Context, projectID domain.ProjectID) (ports.AgentConfig, error) { +// 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 ports.AgentConfig{}, fmt.Errorf("resolve agent config: %w", err) + return domain.ProjectRecord{}, fmt.Errorf("load project: %w", err) } if !ok { - return ports.AgentConfig{}, nil + 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 row.AgentConfig, nil + 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. @@ -330,11 +383,15 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess if err != nil { return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err) } + project, err := m.loadProject(ctx, rec.ProjectID) + if err != nil { + return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err) + } handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{ 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) @@ -427,10 +484,20 @@ func buildPrompt(cfg ports.SpawnConfig) string { } } -func (m *Manager) buildSpawnPrompt(ctx context.Context, cfg ports.SpawnConfig) (string, error) { +func (m *Manager) buildSpawnPrompt(ctx context.Context, cfg ports.SpawnConfig, project domain.ProjectRecord) (string, error) { prompt := buildPrompt(cfg) + + // Project-level rules apply to every agent prompt; OrchestratorRules layer + // on top for orchestrator sessions only. + rules, err := projectRules(project) + if err != nil { + return "", err + } + prompt = appendPromptSection(prompt, rules) + switch cfg.Kind { case domain.KindOrchestrator: + prompt = appendPromptSection(prompt, project.Config.OrchestratorRules) return appendPromptSection(orchestratorPrompt(cfg.ProjectID), prompt), nil case domain.KindWorker: orchestratorID, ok, err := m.activeOrchestratorSessionID(ctx, cfg.ProjectID) @@ -444,6 +511,25 @@ func (m *Manager) buildSpawnPrompt(ctx context.Context, cfg ports.SpawnConfig) ( return prompt, nil } +// projectRules assembles the project's inline AgentRules and the contents of its +// AgentRulesFile (read relative to the project path). A missing AgentRulesFile +// is an error so a typo'd path surfaces rather than silently dropping rules. +func projectRules(project domain.ProjectRecord) (string, error) { + rules := project.Config.AgentRules + if file := strings.TrimSpace(project.Config.AgentRulesFile); file != "" { + path := file + if !filepath.IsAbs(path) { + path = filepath.Join(project.Path, file) + } + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("agent rules file: %w", err) + } + rules = appendPromptSection(rules, strings.TrimRight(string(data), "\n")) + } + return rules, nil +} + func (m *Manager) activeOrchestratorSessionID(ctx context.Context, project domain.ProjectID) (domain.SessionID, bool, error) { recs, err := m.store.ListSessions(ctx, project) if err != nil { @@ -491,13 +577,80 @@ 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. +func applySymlinks(projectPath, workspacePath string, symlinks []string) error { + for _, rel := range symlinks { + rel = strings.TrimSpace(rel) + if rel == "" { + continue + } + source := filepath.Join(projectPath, rel) + if _, err := os.Stat(source); err != nil { + continue + } + target := filepath.Join(workspacePath, rel) + if err := os.MkdirAll(filepath.Dir(target), 0o755); 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 +} + +// 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 diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 83f8a92b..1347ac8c 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -102,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 } @@ -159,10 +161,19 @@ func (s singleAgent) Agent(domain.AgentHarness) (ports.Agent, bool) { return s.a 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++ @@ -196,18 +207,39 @@ 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_ResolvesProjectAgentConfig(t *testing.T) { +func TestSpawn_ResolvesProjectConfig(t *testing.T) { st := newFakeStore() - st.projects["mer"] = domain.ProjectRecord{ID: "mer", AgentConfig: domain.AgentConfig{Model: "claude-opus-4-5"}} + 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: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) + m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) - if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { + rec, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + if err != nil { t.Fatal(err) } - if agent.lastConfig.Model != "claude-opus-4-5" { - t.Fatalf("launch config = %#v, want model resolved from project", agent.lastConfig) + 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). diff --git a/backend/internal/session_manager/provision_test.go b/backend/internal/session_manager/provision_test.go new file mode 100644 index 00000000..976e9074 --- /dev/null +++ b/backend/internal/session_manager/provision_test.go @@ -0,0 +1,115 @@ +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 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") + } +} + +func TestProjectRulesReadsFile(t *testing.T) { + project := t.TempDir() + if err := os.WriteFile(filepath.Join(project, ".rules.md"), []byte("run tests\n"), 0o644); err != nil { + t.Fatal(err) + } + rec := domain.ProjectRecord{Path: project, Config: domain.ProjectConfig{ + AgentRules: "use conventional commits", + AgentRulesFile: ".rules.md", + }} + rules, err := projectRules(rec) + if err != nil { + t.Fatalf("projectRules: %v", err) + } + if want := "use conventional commits\n\nrun tests"; rules != want { + t.Fatalf("rules = %q, want %q", rules, want) + } + + // A missing rules file is a clear error. + rec.Config.AgentRulesFile = "nope.md" + if _, err := projectRules(rec); err == nil { + t.Fatal("expected error for missing rules file") + } +} diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index d6da1d26..d718e8be 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -107,7 +107,7 @@ type Project struct { DisplayName string RegisteredAt time.Time ArchivedAt sql.NullTime - AgentConfig sql.NullString + Config sql.NullString } type Session struct { diff --git a/backend/internal/storage/sqlite/gen/projects.sql.go b/backend/internal/storage/sqlite/gen/projects.sql.go index 8615f10c..4ab0398a 100644 --- a/backend/internal/storage/sqlite/gen/projects.sql.go +++ b/backend/internal/storage/sqlite/gen/projects.sql.go @@ -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, agent_config +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config FROM projects WHERE path = ? AND archived_at IS NULL ` @@ -45,13 +45,13 @@ func (q *Queries) FindProjectByPath(ctx context.Context, path string) (Project, &i.DisplayName, &i.RegisteredAt, &i.ArchivedAt, - &i.AgentConfig, + &i.Config, ) return i, err } const getProject = `-- name: GetProject :one -SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, agent_config +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config FROM projects WHERE id = ? ` @@ -65,13 +65,13 @@ func (q *Queries) GetProject(ctx context.Context, id domain.ProjectID) (Project, &i.DisplayName, &i.RegisteredAt, &i.ArchivedAt, - &i.AgentConfig, + &i.Config, ) return i, err } const listProjects = `-- name: ListProjects :many -SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, agent_config +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, config FROM projects WHERE archived_at IS NULL ORDER BY id ` @@ -91,7 +91,7 @@ func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) { &i.DisplayName, &i.RegisteredAt, &i.ArchivedAt, - &i.AgentConfig, + &i.Config, ); err != nil { return nil, err } @@ -107,14 +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, agent_config) +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, - agent_config = excluded.agent_config + config = excluded.config ` type UpsertProjectParams struct { @@ -124,7 +124,7 @@ type UpsertProjectParams struct { DisplayName string RegisteredAt time.Time ArchivedAt sql.NullTime - AgentConfig sql.NullString + Config sql.NullString } func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) error { @@ -135,7 +135,7 @@ func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) er arg.DisplayName, arg.RegisteredAt, arg.ArchivedAt, - arg.AgentConfig, + arg.Config, ) return err } diff --git a/backend/internal/storage/sqlite/migrations/0008_add_project_agent_config.sql b/backend/internal/storage/sqlite/migrations/0008_add_project_agent_config.sql deleted file mode 100644 index 649debf5..00000000 --- a/backend/internal/storage/sqlite/migrations/0008_add_project_agent_config.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Per-project agent config. A single nullable JSON column on projects holds the --- agent settings (model, permissions, adapter-specific keys) AO resolves into --- LaunchConfig.Config 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 agent config into its own table. - --- +goose Up --- +goose StatementBegin -ALTER TABLE projects ADD COLUMN agent_config TEXT; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -ALTER TABLE projects DROP COLUMN agent_config; --- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/migrations/0008_add_project_config.sql b/backend/internal/storage/sqlite/migrations/0008_add_project_config.sql new file mode 100644 index 00000000..e8f987c9 --- /dev/null +++ b/backend/internal/storage/sqlite/migrations/0008_add_project_config.sql @@ -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 diff --git a/backend/internal/storage/sqlite/queries/projects.sql b/backend/internal/storage/sqlite/queries/projects.sql index 08988ae4..61c04b89 100644 --- a/backend/internal/storage/sqlite/queries/projects.sql +++ b/backend/internal/storage/sqlite/queries/projects.sql @@ -1,23 +1,23 @@ -- name: UpsertProject :exec -INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at, agent_config) +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, - agent_config = excluded.agent_config; + config = excluded.config; -- name: GetProject :one -SELECT id, path, repo_origin_url, display_name, registered_at, archived_at, agent_config +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, agent_config +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, agent_config +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 diff --git a/backend/internal/storage/sqlite/store/project_store.go b/backend/internal/storage/sqlite/store/project_store.go index 53c86387..30384503 100644 --- a/backend/internal/storage/sqlite/store/project_store.go +++ b/backend/internal/storage/sqlite/store/project_store.go @@ -14,7 +14,7 @@ import ( // UpsertProject inserts or replaces a registered project row. func (s *Store) UpsertProject(ctx context.Context, r domain.ProjectRecord) error { - agentConfig, err := marshalAgentConfig(r.AgentConfig) + config, err := marshalProjectConfig(r.Config) if err != nil { return err } @@ -27,7 +27,7 @@ func (s *Store) UpsertProject(ctx context.Context, r domain.ProjectRecord) error DisplayName: r.DisplayName, RegisteredAt: r.RegisteredAt, ArchivedAt: nullTime(r.ArchivedAt), - AgentConfig: agentConfig, + Config: config, }) } @@ -95,7 +95,7 @@ func (s *Store) ArchiveProject(ctx context.Context, id string, at time.Time) (bo } func projectRowFromGen(p gen.Project) (domain.ProjectRecord, error) { - agentConfig, err := unmarshalAgentConfig(p.AgentConfig) + config, err := unmarshalProjectConfig(p.Config) if err != nil { return domain.ProjectRecord{}, err } @@ -105,7 +105,7 @@ func projectRowFromGen(p gen.Project) (domain.ProjectRecord, error) { RepoOriginURL: p.RepoOriginURL, DisplayName: p.DisplayName, RegisteredAt: p.RegisteredAt, - AgentConfig: agentConfig, + Config: config, } if p.ArchivedAt.Valid { r.ArchivedAt = p.ArchivedAt.Time @@ -113,29 +113,29 @@ func projectRowFromGen(p gen.Project) (domain.ProjectRecord, error) { return r, nil } -// marshalAgentConfig encodes the typed per-project agent 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 marshalAgentConfig(cfg domain.AgentConfig) (sql.NullString, error) { +// 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 agent config: %w", err) + return sql.NullString{}, fmt.Errorf("marshal project config: %w", err) } return sql.NullString{String: string(data), Valid: true}, nil } -// unmarshalAgentConfig decodes the nullable JSON column back into the typed +// unmarshalProjectConfig decodes the nullable JSON column back into the typed // struct. SQL NULL (an unset config) decodes to a zero value. -func unmarshalAgentConfig(s sql.NullString) (domain.AgentConfig, error) { +func unmarshalProjectConfig(s sql.NullString) (domain.ProjectConfig, error) { if !s.Valid || s.String == "" { - return domain.AgentConfig{}, nil + return domain.ProjectConfig{}, nil } - var cfg domain.AgentConfig + var cfg domain.ProjectConfig if err := json.Unmarshal([]byte(s.String), &cfg); err != nil { - return domain.AgentConfig{}, fmt.Errorf("unmarshal agent config: %w", err) + return domain.ProjectConfig{}, fmt.Errorf("unmarshal project config: %w", err) } return cfg, nil } diff --git a/backend/internal/storage/sqlite/store/store_test.go b/backend/internal/storage/sqlite/store/store_test.go index 1ebb4ae7..31a2af31 100644 --- a/backend/internal/storage/sqlite/store/store_test.go +++ b/backend/internal/storage/sqlite/store/store_test.go @@ -3,6 +3,7 @@ package store_test import ( "context" "encoding/json" + "reflect" "sync" "testing" "time" @@ -71,15 +72,23 @@ func TestProjectCRUDAndArchive(t *testing.T) { } } -func TestProjectAgentConfigRoundTrips(t *testing.T) { +func TestProjectConfigRoundTrips(t *testing.T) { s := newTestStore(t) ctx := context.Background() now := time.Now().UTC().Truncate(time.Second) - // A populated config survives the JSON round trip. - cfg := domain.AgentConfig{Model: "claude-opus-4-5", Permissions: domain.PermissionModeAcceptEdits} + // 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, AgentConfig: cfg, + ID: "cfg", Path: "/tmp/cfg", RegisteredAt: now, Config: cfg, }); err != nil { t.Fatalf("upsert with config: %v", err) } @@ -87,25 +96,25 @@ func TestProjectAgentConfigRoundTrips(t *testing.T) { if err != nil || !ok { t.Fatalf("get: ok=%v err=%v", ok, err) } - if got.AgentConfig != cfg { - t.Fatalf("agent config = %#v, want %#v", got.AgentConfig, cfg) + 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.AgentConfig.IsZero() { - t.Fatalf("unset config = %#v, want zero", got.AgentConfig) + 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, AgentConfig: domain.AgentConfig{}, + 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.AgentConfig.IsZero() { - t.Fatalf("cleared config = %#v, want zero", got.AgentConfig) + if got, _, _ := s.GetProject(ctx, "cfg"); !got.Config.IsZero() { + t.Fatalf("cleared config = %#v, want zero", got.Config) } } diff --git a/docs/design/per-project-config.md b/docs/design/per-project-config.md index cdd73c6d..d774dac9 100644 --- a/docs/design/per-project-config.md +++ b/docs/design/per-project-config.md @@ -1,6 +1,12 @@ # Design: typed per-project configuration -Status: **blueprint** (agent config slice implemented; the rest is sequenced below) +Status: **implemented** — the full per-project `ProjectConfig` is typed, +validated, persisted (one `projects.config` JSON column), and surfaced via +`ao project set-config` + `PUT /projects/{id}/config`. Consumption is wired at +spawn for `defaultBranch`, `env`, `symlinks`, `postCreate`, the rules, +`agentConfig`, and the `worker`/`orchestrator` role overrides. `tracker`, `scm`, +`opencodeIssueSessionStrategy`, and `sessionPrefix`→id are stored + validated but +not yet consumed (their consumers do not exist — see "deferred" below). ## Goal diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 524befd2..2aca9df2 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -92,7 +92,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/projects/{id}/agent-config": { + "/api/v1/projects/{id}/config": { parameters: { query?: never; header?: never; @@ -100,8 +100,8 @@ export interface paths { cookie?: never; }; get?: never; - /** Replace a project's per-project agent config */ - put: operations["setProjectAgentConfig"]; + /** Replace a project's per-project config */ + put: operations["setProjectConfig"]; post?: never; delete?: never; options?: never; @@ -329,11 +329,15 @@ export interface components { requestId?: string; }; AddProjectInput: { - agentConfig?: components["schemas"]["DomainAgentConfig"]; + config?: components["schemas"]["ProjectConfig"]; name?: null | string; path: string; projectId?: null | string; }; + AgentConfig: { + model?: string; + permissions?: string; + }; ClaimPRRequest: { allowTakeover?: null | boolean; pr: string; @@ -360,10 +364,6 @@ export interface components { lastActivityAt: string; state: string; }; - DomainAgentConfig: { - model?: string; - permissions?: string; - }; KillSessionResponse: { freed?: boolean; ok: boolean; @@ -391,14 +391,30 @@ export interface components { }; Project: { agent?: string; - agentConfig?: components["schemas"]["DomainAgentConfig"]; + config?: components["schemas"]["ProjectConfig"]; defaultBranch: string; id: string; name: string; path: string; repo: string; + }; + ProjectConfig: { + agentConfig?: components["schemas"]["AgentConfig"]; + agentRules?: string; + agentRulesFile?: string; + defaultBranch?: string; + env?: { + [key: string]: string; + }; + opencodeIssueSessionStrategy?: string; + orchestrator?: components["schemas"]["RoleOverride"]; + orchestratorRules?: string; + postCreate?: string[]; scm?: components["schemas"]["SCMConfig"]; + sessionPrefix?: string; + symlinks?: string[]; tracker?: components["schemas"]["TrackerConfig"]; + worker?: components["schemas"]["RoleOverride"]; }; ProjectGetResponse: { project: components["schemas"]["ProjectOrDegraded"]; @@ -409,9 +425,6 @@ export interface components { ProjectResponse: { project: components["schemas"]["Project"]; }; - ProjectSetAgentConfigInput: { - config: components["schemas"]["DomainAgentConfig"]; - }; ProjectSummary: { id: string; name: string; @@ -439,6 +452,10 @@ export interface components { session: components["schemas"]["Session"]; sessionId: string; }; + RoleOverride: { + agent?: string; + agentConfig?: components["schemas"]["AgentConfig"]; + }; RollbackSessionResponse: { deleted?: boolean; killed?: boolean; @@ -446,14 +463,11 @@ export interface components { 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; @@ -510,6 +524,9 @@ export interface components { sessionId: string; state: string; }; + SetProjectConfigInput: { + config: components["schemas"]["ProjectConfig"]; + }; SpawnOrchestratorRequest: { clean?: boolean; projectId: string; @@ -529,9 +546,8 @@ export interface components { prompt?: string; }; TrackerConfig: { - package?: string; - path?: string; plugin?: string; + teamId?: string; }; }; responses: never; @@ -911,7 +927,7 @@ export interface operations { }; }; }; - setProjectAgentConfig: { + setProjectConfig: { parameters: { query?: never; header?: never; @@ -923,7 +939,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ProjectSetAgentConfigInput"]; + "application/json": components["schemas"]["SetProjectConfigInput"]; }; }; responses: { From 1d54ecf4964c3399f12b7c8037206fffb133a4f3 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Sun, 7 Jun 2026 18:23:17 +0530 Subject: [PATCH 05/11] fix(lint): tighten symlink dir perms to 0o750 (gosec G301) Co-Authored-By: Claude Opus 4.8 --- backend/internal/session_manager/manager.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index df25b4a1..b5744dea 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -617,7 +617,7 @@ func applySymlinks(projectPath, workspacePath string, symlinks []string) error { continue } target := filepath.Join(workspacePath, rel) - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + 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 { From 87c1b26e9c49ed90464f623d8e7f95e3d3901550 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Sun, 7 Jun 2026 19:29:58 +0530 Subject: [PATCH 06/11] 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 --- .../workspace/gitworktree/workspace.go | 4 ++- backend/internal/domain/projectconfig.go | 21 +++++++++++ backend/internal/domain/projectconfig_test.go | 35 +++++++++++++++++++ backend/internal/service/project/service.go | 6 +--- .../internal/service/project/service_test.go | 34 ++++++++++++++++++ backend/internal/session_manager/manager.go | 2 +- 6 files changed, 95 insertions(+), 7 deletions(-) diff --git a/backend/internal/adapters/workspace/gitworktree/workspace.go b/backend/internal/adapters/workspace/gitworktree/workspace.go index 68486cab..2ec01308 100644 --- a/backend/internal/adapters/workspace/gitworktree/workspace.go +++ b/backend/internal/adapters/workspace/gitworktree/workspace.go @@ -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 diff --git a/backend/internal/domain/projectconfig.go b/backend/internal/domain/projectconfig.go index 912034a1..400388e6 100644 --- a/backend/internal/domain/projectconfig.go +++ b/backend/internal/domain/projectconfig.go @@ -87,6 +87,27 @@ const ( OpencodeSessionIgnore = "ignore" ) +// DefaultBranchName is the base branch used when a project does not configure +// one. It is the single source of truth for the "main" default shared by the +// read-model and the workspace adapter. +const DefaultBranchName = "main" + +// DefaultProjectConfig returns the config a project has when it sets nothing. +// Only DefaultBranch carries a non-empty default; every other field's default +// is its zero value (no env, no symlinks, agent defaults, …). +func DefaultProjectConfig() ProjectConfig { + return ProjectConfig{DefaultBranch: DefaultBranchName} +} + +// WithDefaults overlays the defaults onto c, filling only fields the project +// left unset. A set field is always preserved. +func (c ProjectConfig) WithDefaults() ProjectConfig { + if c.DefaultBranch == "" { + c.DefaultBranch = DefaultBranchName + } + 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 { diff --git a/backend/internal/domain/projectconfig_test.go b/backend/internal/domain/projectconfig_test.go index c244ca96..0c56f4b0 100644 --- a/backend/internal/domain/projectconfig_test.go +++ b/backend/internal/domain/projectconfig_test.go @@ -26,6 +26,41 @@ func TestProjectConfigValidate(t *testing.T) { } } +func TestDefaultProjectConfig(t *testing.T) { + def := DefaultProjectConfig() + + // DefaultBranch is the one field with a non-empty default. + if def.DefaultBranch != DefaultBranchName { + t.Fatalf("default DefaultBranch = %q, want %q", def.DefaultBranch, DefaultBranchName) + } + if DefaultBranchName != "main" { + t.Fatalf("DefaultBranchName = %q, want main", DefaultBranchName) + } + + // Every other field defaults to its zero value: clearing DefaultBranch 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 default branch. + if got := (ProjectConfig{}).WithDefaults(); got.DefaultBranch != DefaultBranchName { + t.Fatalf("WithDefaults branch = %q, want %q", got.DefaultBranch, DefaultBranchName) + } + + // A set field is preserved, not overwritten. + got := (ProjectConfig{DefaultBranch: "develop", AgentConfig: AgentConfig{Model: "m"}}).WithDefaults() + if got.DefaultBranch != "develop" { + t.Fatalf("WithDefaults overwrote a set branch: %q", got.DefaultBranch) + } + 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") diff --git a/backend/internal/service/project/service.go b/backend/internal/service/project/service.go index 47fc0834..46c61425 100644 --- a/backend/internal/service/project/service.go +++ b/backend/internal/service/project/service.go @@ -205,16 +205,12 @@ func (m *Service) suggestID(ctx context.Context, base domain.ProjectID) domain.P } func projectFromRow(row domain.ProjectRecord) Project { - defaultBranch := "main" - if row.Config.DefaultBranch != "" { - defaultBranch = row.Config.DefaultBranch - } p := Project{ ID: domain.ProjectID(row.ID), Name: displayName(row), Path: row.Path, Repo: row.RepoOriginURL, - DefaultBranch: defaultBranch, + DefaultBranch: row.Config.WithDefaults().DefaultBranch, } if !row.Config.IsZero() { cfg := row.Config diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index d1d3a88e..4457e7e2 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -95,6 +95,40 @@ 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) diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index b5744dea..1de0c3f6 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -157,7 +157,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess ProjectID: cfg.ProjectID, SessionID: id, Branch: branch, - BaseBranch: project.Config.DefaultBranch, + BaseBranch: project.Config.WithDefaults().DefaultBranch, }) if err != nil { m.markSpawnFailedTerminated(ctx, id) From da32c9f457f762fed042037320d12bbdd49c874d Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Sun, 7 Jun 2026 19:34:36 +0530 Subject: [PATCH 07/11] feat(config): encode documented defaults (branch=main, tracker=github) Co-Authored-By: Claude Opus 4.8 --- backend/internal/domain/projectconfig.go | 33 +++++++++++------- backend/internal/domain/projectconfig_test.go | 34 +++++++++++-------- 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/backend/internal/domain/projectconfig.go b/backend/internal/domain/projectconfig.go index 400388e6..ecea54ee 100644 --- a/backend/internal/domain/projectconfig.go +++ b/backend/internal/domain/projectconfig.go @@ -87,23 +87,32 @@ const ( OpencodeSessionIgnore = "ignore" ) -// DefaultBranchName is the base branch used when a project does not configure -// one. It is the single source of truth for the "main" default shared by the -// read-model and the workspace adapter. -const DefaultBranchName = "main" - -// DefaultProjectConfig returns the config a project has when it sets nothing. -// Only DefaultBranch carries a non-empty default; every other field's default -// is its zero value (no env, no symlinks, agent defaults, …). +// Documented per-project defaults (mirrors the legacy agent-orchestrator.yaml). +const ( + DefaultBranchName = "main" // base branch when none is configured + DefaultTrackerName = "github" // issue tracker defaults to GitHub issues +) + +// DefaultProjectConfig returns the config a project has when it sets nothing: +// branch "main" and the GitHub issue tracker. Every other field defaults to its +// zero value (no env/symlinks/post-create/rules, agent + role defaults, no SCM +// webhook, no OpenCode strategy override). func DefaultProjectConfig() ProjectConfig { - return ProjectConfig{DefaultBranch: DefaultBranchName} + return ProjectConfig{ + DefaultBranch: DefaultBranchName, + Tracker: TrackerConfig{Plugin: DefaultTrackerName}, + } } -// WithDefaults overlays the defaults onto c, filling only fields the project -// left unset. A set field is always preserved. +// 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 = DefaultBranchName + c.DefaultBranch = def.DefaultBranch + } + if c.Tracker.Plugin == "" { + c.Tracker.Plugin = def.Tracker.Plugin } return c } diff --git a/backend/internal/domain/projectconfig_test.go b/backend/internal/domain/projectconfig_test.go index 0c56f4b0..df7249b8 100644 --- a/backend/internal/domain/projectconfig_test.go +++ b/backend/internal/domain/projectconfig_test.go @@ -29,32 +29,38 @@ func TestProjectConfigValidate(t *testing.T) { func TestDefaultProjectConfig(t *testing.T) { def := DefaultProjectConfig() - // DefaultBranch is the one field with a non-empty default. - if def.DefaultBranch != DefaultBranchName { - t.Fatalf("default DefaultBranch = %q, want %q", def.DefaultBranch, DefaultBranchName) + // The two documented non-empty defaults. + if def.DefaultBranch != "main" { + t.Fatalf("default DefaultBranch = %q, want main", def.DefaultBranch) } - if DefaultBranchName != "main" { - t.Fatalf("DefaultBranchName = %q, want main", DefaultBranchName) + if def.Tracker.Plugin != "github" { + t.Fatalf("default tracker = %q, want github", def.Tracker.Plugin) } - // Every other field defaults to its zero value: clearing DefaultBranch must - // leave the config completely empty. + // Every other field defaults to its zero value: clearing the two documented + // defaults must leave the config completely empty. def.DefaultBranch = "" + def.Tracker = TrackerConfig{} if !def.IsZero() { t.Fatalf("default config has unexpected non-zero fields: %#v", def) } } func TestProjectConfigWithDefaults(t *testing.T) { - // An unset config gets the default branch. - if got := (ProjectConfig{}).WithDefaults(); got.DefaultBranch != DefaultBranchName { - t.Fatalf("WithDefaults branch = %q, want %q", got.DefaultBranch, DefaultBranchName) + // An unset config gets the documented defaults. + got := (ProjectConfig{}).WithDefaults() + if got.DefaultBranch != DefaultBranchName || got.Tracker.Plugin != DefaultTrackerName { + t.Fatalf("WithDefaults = %#v, want branch=main tracker=github", got) } - // A set field is preserved, not overwritten. - got := (ProjectConfig{DefaultBranch: "develop", AgentConfig: AgentConfig{Model: "m"}}).WithDefaults() - if got.DefaultBranch != "develop" { - t.Fatalf("WithDefaults overwrote a set branch: %q", got.DefaultBranch) + // Set fields are preserved, not overwritten. + got = (ProjectConfig{ + DefaultBranch: "develop", + Tracker: TrackerConfig{Plugin: "linear"}, + AgentConfig: AgentConfig{Model: "m"}, + }).WithDefaults() + if got.DefaultBranch != "develop" || got.Tracker.Plugin != "linear" { + t.Fatalf("WithDefaults overwrote set fields: %#v", got) } if got.AgentConfig.Model != "m" { t.Fatalf("WithDefaults dropped a set field: %#v", got.AgentConfig) From 1f86e8c9fda832c2d0b3381026f461bb916f16dd Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Sun, 7 Jun 2026 19:54:19 +0530 Subject: [PATCH 08/11] 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 --- backend/internal/session_manager/manager.go | 24 +++++++---- .../internal/session_manager/manager_test.go | 21 ++++++++++ .../session_manager/provision_test.go | 11 +++-- .../storage/sqlite/store/project_store.go | 41 +++++++------------ .../store/project_store_internal_test.go | 24 +++++++++++ 5 files changed, 82 insertions(+), 39 deletions(-) create mode 100644 backend/internal/storage/sqlite/store/project_store_internal_test.go diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 1de0c3f6..b1a46956 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -379,11 +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) } - project, err := m.loadProject(ctx, rec.ProjectID) + // 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) } @@ -512,8 +514,10 @@ func (m *Manager) buildSpawnPrompt(ctx context.Context, cfg ports.SpawnConfig, p } // projectRules assembles the project's inline AgentRules and the contents of its -// AgentRulesFile (read relative to the project path). A missing AgentRulesFile -// is an error so a typo'd path surfaces rather than silently dropping rules. +// AgentRulesFile (read relative to the project path). A missing rules file is +// treated as absent optional context — not a hard dependency — so a deleted, +// renamed, or never-created file does not fail every spawn for the project. Only +// a real read error (e.g. permissions) surfaces. func projectRules(project domain.ProjectRecord) (string, error) { rules := project.Config.AgentRules if file := strings.TrimSpace(project.Config.AgentRulesFile); file != "" { @@ -521,11 +525,12 @@ func projectRules(project domain.ProjectRecord) (string, error) { if !filepath.IsAbs(path) { path = filepath.Join(project.Path, file) } - data, err := os.ReadFile(path) - if err != nil { + switch data, err := os.ReadFile(path); { + case err == nil: + rules = appendPromptSection(rules, strings.TrimRight(string(data), "\n")) + case !errors.Is(err, os.ErrNotExist): return "", fmt.Errorf("agent rules file: %w", err) } - rules = appendPromptSection(rules, strings.TrimRight(string(data), "\n")) } return rules, nil } @@ -684,13 +689,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) } @@ -701,6 +706,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) diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 1347ac8c..13aca200 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -154,6 +154,11 @@ func (a *recordingAgent) GetLaunchCommand(_ context.Context, cfg ports.LaunchCon 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 } @@ -317,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") diff --git a/backend/internal/session_manager/provision_test.go b/backend/internal/session_manager/provision_test.go index 976e9074..c6e55c0f 100644 --- a/backend/internal/session_manager/provision_test.go +++ b/backend/internal/session_manager/provision_test.go @@ -107,9 +107,14 @@ func TestProjectRulesReadsFile(t *testing.T) { t.Fatalf("rules = %q, want %q", rules, want) } - // A missing rules file is a clear error. + // A missing rules file is optional context, not a hard failure: it returns + // the inline rules without aborting the spawn. rec.Config.AgentRulesFile = "nope.md" - if _, err := projectRules(rec); err == nil { - t.Fatal("expected error for missing rules file") + got, err := projectRules(rec) + if err != nil { + t.Fatalf("missing rules file should not error: %v", err) + } + if got != "use conventional commits" { + t.Fatalf("rules with missing file = %q, want inline rules only", got) } } diff --git a/backend/internal/storage/sqlite/store/project_store.go b/backend/internal/storage/sqlite/store/project_store.go index 30384503..06a3bd97 100644 --- a/backend/internal/storage/sqlite/store/project_store.go +++ b/backend/internal/storage/sqlite/store/project_store.go @@ -40,11 +40,7 @@ func (s *Store) GetProject(ctx context.Context, id string) (domain.ProjectRecord if err != nil { return domain.ProjectRecord{}, false, fmt.Errorf("get project %s: %w", id, err) } - r, err := projectRowFromGen(p) - if err != nil { - return domain.ProjectRecord{}, false, fmt.Errorf("get project %s: %w", id, err) - } - return r, true, nil + return projectRowFromGen(p), true, nil } // FindProjectByPath returns a project registered at path, active or archived. @@ -56,11 +52,7 @@ func (s *Store) FindProjectByPath(ctx context.Context, path string) (domain.Proj if err != nil { return domain.ProjectRecord{}, false, fmt.Errorf("find project by path %s: %w", path, err) } - r, err := projectRowFromGen(p) - if err != nil { - return domain.ProjectRecord{}, false, fmt.Errorf("find project by path %s: %w", path, err) - } - return r, true, nil + return projectRowFromGen(p), true, nil } // ListProjects returns active projects ordered by id. @@ -71,11 +63,7 @@ func (s *Store) ListProjects(ctx context.Context) ([]domain.ProjectRecord, error } out := make([]domain.ProjectRecord, 0, len(rows)) for _, p := range rows { - r, err := projectRowFromGen(p) - if err != nil { - return nil, fmt.Errorf("list projects: %w", err) - } - out = append(out, r) + out = append(out, projectRowFromGen(p)) } return out, nil } @@ -94,23 +82,19 @@ func (s *Store) ArchiveProject(ctx context.Context, id string, at time.Time) (bo return n > 0, nil } -func projectRowFromGen(p gen.Project) (domain.ProjectRecord, error) { - config, err := unmarshalProjectConfig(p.Config) - if err != nil { - return domain.ProjectRecord{}, err - } +func projectRowFromGen(p gen.Project) domain.ProjectRecord { r := domain.ProjectRecord{ ID: string(p.ID), Path: p.Path, RepoOriginURL: p.RepoOriginURL, DisplayName: p.DisplayName, RegisteredAt: p.RegisteredAt, - Config: config, + Config: unmarshalProjectConfig(p.Config), } if p.ArchivedAt.Valid { r.ArchivedAt = p.ArchivedAt.Time } - return r, nil + return r } // marshalProjectConfig encodes the typed per-project config into the nullable @@ -128,16 +112,19 @@ func marshalProjectConfig(cfg domain.ProjectConfig) (sql.NullString, error) { } // unmarshalProjectConfig decodes the nullable JSON column back into the typed -// struct. SQL NULL (an unset config) decodes to a zero value. -func unmarshalProjectConfig(s sql.NullString) (domain.ProjectConfig, error) { +// 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{}, nil + return domain.ProjectConfig{} } var cfg domain.ProjectConfig if err := json.Unmarshal([]byte(s.String), &cfg); err != nil { - return domain.ProjectConfig{}, fmt.Errorf("unmarshal project config: %w", err) + return domain.ProjectConfig{} } - return cfg, nil + return cfg } func nullTime(t time.Time) sql.NullTime { diff --git a/backend/internal/storage/sqlite/store/project_store_internal_test.go b/backend/internal/storage/sqlite/store/project_store_internal_test.go new file mode 100644 index 00000000..60b518b3 --- /dev/null +++ b/backend/internal/storage/sqlite/store/project_store_internal_test.go @@ -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) + } +} From e213b6804b61aab078c31d78dd51e24b41ffbb80 Mon Sep 17 00:00:00 2001 From: Aditi Chauhan Date: Mon, 8 Jun 2026 19:11:48 +0530 Subject: [PATCH 09/11] 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 --- backend/internal/cli/project.go | 52 +++++-------- backend/internal/domain/projectconfig.go | 76 +++---------------- backend/internal/domain/projectconfig_test.go | 19 ++--- backend/internal/httpd/apispec/openapi.yaml | 41 ---------- .../internal/httpd/apispec/specgen/build.go | 17 ++--- backend/internal/session_manager/manager.go | 35 +-------- .../session_manager/provision_test.go | 29 ------- docs/design/per-project-config.md | 27 ++++--- frontend/src/api/schema.ts | 22 ------ 9 files changed, 57 insertions(+), 261 deletions(-) diff --git a/backend/internal/cli/project.go b/backend/internal/cli/project.go index 68fa94ab..d9a4eb6f 100644 --- a/backend/internal/cli/project.go +++ b/backend/internal/cli/project.go @@ -75,18 +75,14 @@ type roleOverride struct { // 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"` - AgentRules string `json:"agentRules,omitempty"` - AgentRulesFile string `json:"agentRulesFile,omitempty"` - OrchestratorRules string `json:"orchestratorRules,omitempty"` - AgentConfig agentConfig `json:"agentConfig,omitempty"` - Worker roleOverride `json:"worker,omitempty"` - Orchestrator roleOverride `json:"orchestrator,omitempty"` - OpencodeIssueSessionStrategy string `json:"opencodeIssueSessionStrategy,omitempty"` + 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 @@ -100,12 +96,8 @@ type projectSetConfigOptions struct { sessionPrefix string model string permission string - agentRules string - agentRulesFile string - orchestratorRules string workerAgent string orchestratorAgent string - opencodeStrategy string env []string symlink []string postCreate []string @@ -241,8 +233,8 @@ func newProjectSetConfigCommand(ctx *commandContext) *cobra.Command { cmd := &cobra.Command{ Use: "set-config ", Short: "Set the per-project config", - Long: "Replace a project's per-project config (branch, env, symlinks, " + - "post-create, rules, agent model/permissions, role overrides). The 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.", @@ -278,12 +270,8 @@ func newProjectSetConfigCommand(ctx *commandContext) *cobra.Command { 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.agentRules, "agent-rules", "", "Inline rules appended to every agent prompt") - f.StringVar(&opts.agentRulesFile, "agent-rules-file", "", "Path (relative to the project) to a rules file") - f.StringVar(&opts.orchestratorRules, "orchestrator-rules", "", "Inline rules appended to orchestrator prompts") f.StringVar(&opts.workerAgent, "worker-agent", "", "Harness override for worker sessions") f.StringVar(&opts.orchestratorAgent, "orchestrator-agent", "", "Harness override for orchestrator sessions") - f.StringVar(&opts.opencodeStrategy, "opencode-strategy", "", "OpenCode issue-session strategy: reuse, delete, ignore") 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)") @@ -314,18 +302,14 @@ func buildProjectConfig(opts projectSetConfigOptions) (projectConfig, error) { return projectConfig{}, err } cfg := projectConfig{ - DefaultBranch: opts.defaultBranch, - SessionPrefix: opts.sessionPrefix, - Env: env, - Symlinks: opts.symlink, - PostCreate: opts.postCreate, - AgentRules: opts.agentRules, - AgentRulesFile: opts.agentRulesFile, - OrchestratorRules: opts.orchestratorRules, - AgentConfig: agentConfig{Model: opts.model, Permissions: opts.permission}, - Worker: roleOverride{Agent: opts.workerAgent}, - Orchestrator: roleOverride{Agent: opts.orchestratorAgent}, - OpencodeIssueSessionStrategy: opts.opencodeStrategy, + 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")} diff --git a/backend/internal/domain/projectconfig.go b/backend/internal/domain/projectconfig.go index ecea54ee..2964d762 100644 --- a/backend/internal/domain/projectconfig.go +++ b/backend/internal/domain/projectconfig.go @@ -10,12 +10,11 @@ import ( // JSON blob per project and resolved at spawn. Each field is typed and // validated; there is no free-form map. // -// Some fields are consumed at spawn today (DefaultBranch, Env, Symlinks, -// PostCreate, the rules, AgentConfig, and the role overrides). Others are -// persisted and validated but not yet consumed — Tracker, SCM, and -// OpencodeIssueSessionStrategy await the infrastructure that will read them, and -// SessionPrefix currently feeds only the display prefix (session-id generation -// is unchanged). +// 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"` @@ -30,26 +29,11 @@ type ProjectConfig struct { // PostCreate are shell commands run in the workspace after it is created. PostCreate []string `json:"postCreate,omitempty"` - // AgentRules are inline rules appended to every agent prompt for the project. - AgentRules string `json:"agentRules,omitempty"` - // AgentRulesFile is a path (relative to the project) whose contents are - // appended to every agent prompt. - AgentRulesFile string `json:"agentRulesFile,omitempty"` - // OrchestratorRules are inline rules appended to orchestrator prompts. - OrchestratorRules string `json:"orchestratorRules,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"` - - // Tracker selects and configures the project's issue tracker (not yet consumed). - Tracker TrackerConfig `json:"tracker,omitempty"` - // SCM selects and configures the project's source-control integration (not yet consumed). - SCM SCMConfig `json:"scm,omitempty"` - // OpencodeIssueSessionStrategy controls OpenCode issue-session reuse (not yet consumed). - OpencodeIssueSessionStrategy string `json:"opencodeIssueSessionStrategy,omitempty"` } // RoleOverride overrides the harness and/or agent config for a session role. @@ -58,49 +42,15 @@ type RoleOverride struct { AgentConfig AgentConfig `json:"agentConfig,omitempty"` } -// TrackerConfig selects and configures a project's issue tracker. -type TrackerConfig struct { - Plugin string `json:"plugin,omitempty"` - TeamID string `json:"teamId,omitempty"` -} - -// SCMConfig selects and configures a project's source-control integration. -type SCMConfig struct { - Plugin string `json:"plugin,omitempty"` - Webhook *SCMWebhookConfig `json:"webhook,omitempty"` -} - -// SCMWebhookConfig describes SCM webhook acceleration settings. -type SCMWebhookConfig struct { - 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"` -} - -// The OpenCode issue-session strategies. -const ( - OpencodeSessionReuse = "reuse" - OpencodeSessionDelete = "delete" - OpencodeSessionIgnore = "ignore" -) - -// Documented per-project defaults (mirrors the legacy agent-orchestrator.yaml). -const ( - DefaultBranchName = "main" // base branch when none is configured - DefaultTrackerName = "github" // issue tracker defaults to GitHub issues -) +// 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" and the GitHub issue tracker. Every other field defaults to its -// zero value (no env/symlinks/post-create/rules, agent + role defaults, no SCM -// webhook, no OpenCode strategy override). +// 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, - Tracker: TrackerConfig{Plugin: DefaultTrackerName}, } } @@ -111,9 +61,6 @@ func (c ProjectConfig) WithDefaults() ProjectConfig { if c.DefaultBranch == "" { c.DefaultBranch = def.DefaultBranch } - if c.Tracker.Plugin == "" { - c.Tracker.Plugin = def.Tracker.Plugin - } return c } @@ -137,10 +84,5 @@ func (c ProjectConfig) Validate() error { return fmt.Errorf("%s.%w", role, err) } } - switch c.OpencodeIssueSessionStrategy { - case "", OpencodeSessionReuse, OpencodeSessionDelete, OpencodeSessionIgnore: - default: - return fmt.Errorf("opencodeIssueSessionStrategy: want one of reuse, delete, ignore") - } return nil } diff --git a/backend/internal/domain/projectconfig_test.go b/backend/internal/domain/projectconfig_test.go index df7249b8..046309fa 100644 --- a/backend/internal/domain/projectconfig_test.go +++ b/backend/internal/domain/projectconfig_test.go @@ -14,8 +14,6 @@ func TestProjectConfigValidate(t *testing.T) { {"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 opencode strategy", ProjectConfig{OpencodeIssueSessionStrategy: OpencodeSessionReuse}, false}, - {"bad opencode strategy", ProjectConfig{OpencodeIssueSessionStrategy: "sometimes"}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -29,18 +27,14 @@ func TestProjectConfigValidate(t *testing.T) { func TestDefaultProjectConfig(t *testing.T) { def := DefaultProjectConfig() - // The two documented non-empty defaults. + // The one documented non-empty default. if def.DefaultBranch != "main" { t.Fatalf("default DefaultBranch = %q, want main", def.DefaultBranch) } - if def.Tracker.Plugin != "github" { - t.Fatalf("default tracker = %q, want github", def.Tracker.Plugin) - } - // Every other field defaults to its zero value: clearing the two documented - // defaults must leave the config completely empty. + // Every other field defaults to its zero value: clearing the documented + // default must leave the config completely empty. def.DefaultBranch = "" - def.Tracker = TrackerConfig{} if !def.IsZero() { t.Fatalf("default config has unexpected non-zero fields: %#v", def) } @@ -49,17 +43,16 @@ func TestDefaultProjectConfig(t *testing.T) { func TestProjectConfigWithDefaults(t *testing.T) { // An unset config gets the documented defaults. got := (ProjectConfig{}).WithDefaults() - if got.DefaultBranch != DefaultBranchName || got.Tracker.Plugin != DefaultTrackerName { - t.Fatalf("WithDefaults = %#v, want branch=main tracker=github", got) + if got.DefaultBranch != DefaultBranchName { + t.Fatalf("WithDefaults = %#v, want branch=main", got) } // Set fields are preserved, not overwritten. got = (ProjectConfig{ DefaultBranch: "develop", - Tracker: TrackerConfig{Plugin: "linear"}, AgentConfig: AgentConfig{Model: "m"}, }).WithDefaults() - if got.DefaultBranch != "develop" || got.Tracker.Plugin != "linear" { + if got.DefaultBranch != "develop" { t.Fatalf("WithDefaults overwrote set fields: %#v", got) } if got.AgentConfig.Model != "m" { diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index bddd7033..53aa9e71 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1151,36 +1151,24 @@ components: properties: agentConfig: $ref: '#/components/schemas/AgentConfig' - agentRules: - type: string - agentRulesFile: - type: string defaultBranch: type: string env: additionalProperties: type: string type: object - opencodeIssueSessionStrategy: - type: string orchestrator: $ref: '#/components/schemas/RoleOverride' - orchestratorRules: - type: string postCreate: items: type: string type: array - scm: - $ref: '#/components/schemas/SCMConfig' sessionPrefix: type: string symlinks: items: type: string type: array - tracker: - $ref: '#/components/schemas/TrackerConfig' worker: $ref: '#/components/schemas/RoleOverride' type: object @@ -1299,28 +1287,6 @@ components: - ok - sessionId type: object - SCMConfig: - properties: - plugin: - type: string - webhook: - $ref: '#/components/schemas/SCMWebhookConfig' - type: object - SCMWebhookConfig: - properties: - deliveryHeader: - type: string - eventHeader: - type: string - maxBodyBytes: - type: integer - path: - type: string - secretEnvVar: - type: string - signatureHeader: - type: string - type: object SendSessionMessageRequest: properties: message: @@ -1516,13 +1482,6 @@ components: required: - projectId type: object - TrackerConfig: - properties: - plugin: - type: string - teamId: - type: string - type: object tags: - description: Project registry, configuration, and lifecycle administration name: projects diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 40e5402e..8159b797 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -118,16 +118,13 @@ var schemaNames = map[string]string{ // httpd/envelope "EnvelopeAPIError": "APIError", // domain - "DomainProjectID": "ProjectID", - "DomainSessionID": "SessionID", - "DomainIssueID": "IssueID", - "DomainSession": "Session", - "DomainProjectConfig": "ProjectConfig", - "DomainAgentConfig": "AgentConfig", - "DomainRoleOverride": "RoleOverride", - "DomainTrackerConfig": "TrackerConfig", - "DomainSCMConfig": "SCMConfig", - "DomainSCMWebhookConfig": "SCMWebhookConfig", + "DomainProjectID": "ProjectID", + "DomainSessionID": "SessionID", + "DomainIssueID": "IssueID", + "DomainSession": "Session", + "DomainProjectConfig": "ProjectConfig", + "DomainAgentConfig": "AgentConfig", + "DomainRoleOverride": "RoleOverride", // httpd/controllers (wire envelopes) "ControllersListProjectsResponse": "ListProjectsResponse", "ControllersProjectResponse": "ProjectResponse", diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index b1a46956..1105a5c5 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -135,7 +135,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess // 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, project) + prompt, err := m.buildSpawnPrompt(ctx, cfg) if err != nil { return domain.SessionRecord{}, fmt.Errorf("spawn: prompt: %w", err) } @@ -486,20 +486,11 @@ func buildPrompt(cfg ports.SpawnConfig) string { } } -func (m *Manager) buildSpawnPrompt(ctx context.Context, cfg ports.SpawnConfig, project domain.ProjectRecord) (string, error) { +func (m *Manager) buildSpawnPrompt(ctx context.Context, cfg ports.SpawnConfig) (string, error) { prompt := buildPrompt(cfg) - // Project-level rules apply to every agent prompt; OrchestratorRules layer - // on top for orchestrator sessions only. - rules, err := projectRules(project) - if err != nil { - return "", err - } - prompt = appendPromptSection(prompt, rules) - switch cfg.Kind { case domain.KindOrchestrator: - prompt = appendPromptSection(prompt, project.Config.OrchestratorRules) return appendPromptSection(orchestratorPrompt(cfg.ProjectID), prompt), nil case domain.KindWorker: orchestratorID, ok, err := m.activeOrchestratorSessionID(ctx, cfg.ProjectID) @@ -513,28 +504,6 @@ func (m *Manager) buildSpawnPrompt(ctx context.Context, cfg ports.SpawnConfig, p return prompt, nil } -// projectRules assembles the project's inline AgentRules and the contents of its -// AgentRulesFile (read relative to the project path). A missing rules file is -// treated as absent optional context — not a hard dependency — so a deleted, -// renamed, or never-created file does not fail every spawn for the project. Only -// a real read error (e.g. permissions) surfaces. -func projectRules(project domain.ProjectRecord) (string, error) { - rules := project.Config.AgentRules - if file := strings.TrimSpace(project.Config.AgentRulesFile); file != "" { - path := file - if !filepath.IsAbs(path) { - path = filepath.Join(project.Path, file) - } - switch data, err := os.ReadFile(path); { - case err == nil: - rules = appendPromptSection(rules, strings.TrimRight(string(data), "\n")) - case !errors.Is(err, os.ErrNotExist): - return "", fmt.Errorf("agent rules file: %w", err) - } - } - return rules, nil -} - func (m *Manager) activeOrchestratorSessionID(ctx context.Context, project domain.ProjectID) (domain.SessionID, bool, error) { recs, err := m.store.ListSessions(ctx, project) if err != nil { diff --git a/backend/internal/session_manager/provision_test.go b/backend/internal/session_manager/provision_test.go index c6e55c0f..5b73f91c 100644 --- a/backend/internal/session_manager/provision_test.go +++ b/backend/internal/session_manager/provision_test.go @@ -89,32 +89,3 @@ func TestRunPostCreate(t *testing.T) { t.Fatal("expected error from failing post-create command") } } - -func TestProjectRulesReadsFile(t *testing.T) { - project := t.TempDir() - if err := os.WriteFile(filepath.Join(project, ".rules.md"), []byte("run tests\n"), 0o644); err != nil { - t.Fatal(err) - } - rec := domain.ProjectRecord{Path: project, Config: domain.ProjectConfig{ - AgentRules: "use conventional commits", - AgentRulesFile: ".rules.md", - }} - rules, err := projectRules(rec) - if err != nil { - t.Fatalf("projectRules: %v", err) - } - if want := "use conventional commits\n\nrun tests"; rules != want { - t.Fatalf("rules = %q, want %q", rules, want) - } - - // A missing rules file is optional context, not a hard failure: it returns - // the inline rules without aborting the spawn. - rec.Config.AgentRulesFile = "nope.md" - got, err := projectRules(rec) - if err != nil { - t.Fatalf("missing rules file should not error: %v", err) - } - if got != "use conventional commits" { - t.Fatalf("rules with missing file = %q, want inline rules only", got) - } -} diff --git a/docs/design/per-project-config.md b/docs/design/per-project-config.md index d774dac9..f8e29080 100644 --- a/docs/design/per-project-config.md +++ b/docs/design/per-project-config.md @@ -1,12 +1,15 @@ # Design: typed per-project configuration -Status: **implemented** — the full per-project `ProjectConfig` is typed, -validated, persisted (one `projects.config` JSON column), and surfaced via -`ao project set-config` + `PUT /projects/{id}/config`. Consumption is wired at -spawn for `defaultBranch`, `env`, `symlinks`, `postCreate`, the rules, -`agentConfig`, and the `worker`/`orchestrator` role overrides. `tracker`, `scm`, -`opencodeIssueSessionStrategy`, and `sessionPrefix`→id are stored + validated but -not yet consumed (their consumers do not exist — see "deferred" below). +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 @@ -66,7 +69,7 @@ type AgentConfig struct { // implemented } func (c AgentConfig) Validate() error { ... } -// target — assembled incrementally, one slice per PR +// implemented today — only fields with a live consumer are modeled type ProjectConfig struct { DefaultBranch string SessionPrefix string @@ -76,10 +79,10 @@ type ProjectConfig struct { Env map[string]string Symlinks []string PostCreate []string - AgentRules string - Tracker TrackerConfig // adapter-validated - SCM SCMConfig // adapter-validated - // ... + // future slices add fields here as their consumers land: + // AgentRules / AgentRulesFile / OrchestratorRules (prompt rules) + // Tracker TrackerConfig // adapter-validated + // SCM SCMConfig // adapter-validated } ``` diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 2aca9df2..55299afd 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -400,20 +400,14 @@ export interface components { }; ProjectConfig: { agentConfig?: components["schemas"]["AgentConfig"]; - agentRules?: string; - agentRulesFile?: string; defaultBranch?: string; env?: { [key: string]: string; }; - opencodeIssueSessionStrategy?: string; orchestrator?: components["schemas"]["RoleOverride"]; - orchestratorRules?: string; postCreate?: string[]; - scm?: components["schemas"]["SCMConfig"]; sessionPrefix?: string; symlinks?: string[]; - tracker?: components["schemas"]["TrackerConfig"]; worker?: components["schemas"]["RoleOverride"]; }; ProjectGetResponse: { @@ -462,18 +456,6 @@ export interface components { ok: boolean; sessionId: string; }; - SCMConfig: { - plugin?: string; - webhook?: components["schemas"]["SCMWebhookConfig"]; - }; - SCMWebhookConfig: { - deliveryHeader?: string; - eventHeader?: string; - maxBodyBytes?: number; - path?: string; - secretEnvVar?: string; - signatureHeader?: string; - }; SendSessionMessageRequest: { message: string; }; @@ -545,10 +527,6 @@ export interface components { projectId: string; prompt?: string; }; - TrackerConfig: { - plugin?: string; - teamId?: string; - }; }; responses: never; parameters: never; From e440f10adaee57d7be7ebcb62973c71ce935244c Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Mon, 8 Jun 2026 19:17:40 +0530 Subject: [PATCH 10/11] 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 --- .../internal/httpd/controllers/projects.go | 13 ++++++-- .../httpd/controllers/projects_test.go | 30 ++++++++++++++++++ backend/internal/session_manager/manager.go | 31 +++++++++++++++++-- .../session_manager/provision_test.go | 13 ++++++++ 4 files changed, 82 insertions(+), 5 deletions(-) diff --git a/backend/internal/httpd/controllers/projects.go b/backend/internal/httpd/controllers/projects.go index 3db688b5..d3957a48 100644 --- a/backend/internal/httpd/controllers/projects.go +++ b/backend/internal/httpd/controllers/projects.go @@ -53,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 } @@ -89,7 +89,7 @@ func (c *ProjectsController) setConfig(w http.ResponseWriter, r *http.Request) { return } var in projectsvc.SetConfigInput - 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 } @@ -121,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) +} diff --git a/backend/internal/httpd/controllers/projects_test.go b/backend/internal/httpd/controllers/projects_test.go index e5c3e738..eed18962 100644 --- a/backend/internal/httpd/controllers/projects_test.go +++ b/backend/internal/httpd/controllers/projects_test.go @@ -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) diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 1105a5c5..d4cc9cd7 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -579,18 +579,24 @@ func (m *Manager) provisionWorkspace(ctx context.Context, project domain.Project // 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. +// .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 } - source := filepath.Join(projectPath, rel) + 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, rel) + target := filepath.Join(workspacePath, clean) if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil { return fmt.Errorf("symlink %q: %w", rel, err) } @@ -604,6 +610,25 @@ func applySymlinks(projectPath, workspacePath string, symlinks []string) error { 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. diff --git a/backend/internal/session_manager/provision_test.go b/backend/internal/session_manager/provision_test.go index 5b73f91c..becdce95 100644 --- a/backend/internal/session_manager/provision_test.go +++ b/backend/internal/session_manager/provision_test.go @@ -76,6 +76,19 @@ func TestApplySymlinks(t *testing.T) { } } +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 { From 700e8744114b855cfffdf68e7fadf3e8af80b4fd Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Mon, 8 Jun 2026 19:19:21 +0530 Subject: [PATCH 11/11] fix(config): reject symlink path traversal at config write time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/internal/domain/projectconfig.go | 31 +++++++++++++++++++ backend/internal/domain/projectconfig_test.go | 5 +++ 2 files changed, 36 insertions(+) diff --git a/backend/internal/domain/projectconfig.go b/backend/internal/domain/projectconfig.go index 2964d762..1e6cf32f 100644 --- a/backend/internal/domain/projectconfig.go +++ b/backend/internal/domain/projectconfig.go @@ -2,7 +2,9 @@ package domain import ( "fmt" + "path/filepath" "reflect" + "strings" ) // ProjectConfig is the typed per-project configuration — the SQLite twin of the @@ -84,5 +86,34 @@ func (c ProjectConfig) Validate() error { 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 } diff --git a/backend/internal/domain/projectconfig_test.go b/backend/internal/domain/projectconfig_test.go index 046309fa..76155101 100644 --- a/backend/internal/domain/projectconfig_test.go +++ b/backend/internal/domain/projectconfig_test.go @@ -14,6 +14,11 @@ func TestProjectConfigValidate(t *testing.T) { {"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) {