diff --git a/.goreleaser.yml b/.goreleaser.yml index 24545ace10..b690734ce8 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -4,7 +4,7 @@ builds: - main: ./cmd/fullsend/ binary: fullsend ldflags: - - -s -w -X github.com/fullsend-ai/fullsend/internal/cli.version={{.Version}} + - -s -w -X github.com/fullsend-ai/fullsend/internal/cli.version={{.Version}} -X github.com/fullsend-ai/fullsend/internal/cli.commitSHA={{.FullCommit}} env: - CGO_ENABLED=0 goos: diff --git a/docs/ADRs/0006-ordered-layer-model.md b/docs/ADRs/0006-ordered-layer-model.md index f41f3e6470..9902d97233 100644 --- a/docs/ADRs/0006-ordered-layer-model.md +++ b/docs/ADRs/0006-ordered-layer-model.md @@ -25,7 +25,7 @@ Installing fullsend into an org involves multiple concerns with ordering depende Each installation concern is a `Layer` implementing `Install`, `Uninstall`, and `Analyze`. Layers are composed into an ordered `Stack`. Install runs layers forward; uninstall runs them in reverse; analyze runs them forward and collects reports. -The current stack order is: config-repo → workflows → secrets → inference → dispatch-token → enrollment. +The current stack order is: config-repo → workflows → harness-wrappers → vendor-binary → secrets → inference → dispatch-token → enrollment. Each layer is idempotent — re-running install skips already-completed work. Uninstall collects all errors rather than stopping on the first, so partial teardown still makes progress. Each layer declares the OAuth scopes it needs via `RequiredScopes`, enabling a preflight check that fails early when the token lacks required permissions. diff --git a/docs/ADRs/0045-forge-portable-harness-schema.md b/docs/ADRs/0045-forge-portable-harness-schema.md index 090a221c12..1b1597e6b2 100644 --- a/docs/ADRs/0045-forge-portable-harness-schema.md +++ b/docs/ADRs/0045-forge-portable-harness-schema.md @@ -582,6 +582,21 @@ forge-specific artifact. The harness and agent definition are portable. removing an agent is deleting a file, adding one is creating a thin wrapper with `base:`. +- **Default URL allowlist for `base` composition.** `fullsend install` + sets `allowed_remote_resources` in `config.yaml` to include the + fullsend scaffold URL prefix + (`https://raw.githubusercontent.com/fullsend-ai/fullsend/`), ensuring + generated `base:` URLs pass the allowlist without manual configuration. + Integrity is enforced by the mandatory `#sha256=...` hash in each URL. + +- **Phase 2 dual-write.** During Phase 2, agent identity (`role`, `slug`) + is written to both `config.yaml`'s `agents:` block and harness wrapper + files. The `agents:` block remains the source of truth for existing + consumers (`loadKnownSlugs`, `runUninstall`, `SecretsLayer`). Phase 3 + migrates consumers to harness-file discovery; Phase 4 removes the + `agents:` block. Reconciliation between the two is not needed because + both are written atomically during `fullsend install`. + - **Merge semantics add complexity.** The inheritance rules (scalars override, skills concatenate, runner_env merges, validation_loop replaces) must be well-documented and tested. Edge cases — such as a forge block diff --git a/docs/architecture.md b/docs/architecture.md index cdac80515b..7a0bfa0f2d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,7 +38,7 @@ Infrastructure platform choice and configuration are specified in the adopting o **Decided:** - Forge abstraction: all forge operations go through the `forge.Client` interface, keeping the rest of the codebase forge-agnostic ([ADR 0005](ADRs/0005-forge-abstraction-layer.md)). -- Installation model: ordered layer stack (install forward, uninstall reverse, analyze for status reporting) with idempotent operations. Current stack: config-repo → workflows → secrets → inference → dispatch → enrollment ([ADR 0006](ADRs/0006-ordered-layer-model.md)). +- Installation model: ordered layer stack (install forward, uninstall reverse, analyze for status reporting) with idempotent operations. Current stack: config-repo → workflows → harness-wrappers → vendor-binary → secrets → inference → dispatch → enrollment ([ADR 0006](ADRs/0006-ordered-layer-model.md)). - Cross-repo dispatch: enrolled repos call `.fullsend` via `workflow_call`; a dispatch workflow mints OIDC tokens exchanged at a central token mint (GCP Cloud Function) for scoped GitHub App installation tokens per agent role. App PEM secrets are stored in Secret Manager, not the config repo ([ADR 0008](ADRs/0008-workflow-dispatch-for-cross-repo-dispatch.md)). - Shim workflow security: `pull_request_target` prevents PR authors from modifying the shim workflow. No long-lived secrets flow through the shim — OIDC tokens are issued by the GitHub runtime and scoped to the workflow run ([ADR 0009](ADRs/0009-pull-request-target-in-shim-workflows.md)). - Repo maintenance: a workflow in `.fullsend` (`.github/workflows/repo-maintenance.yml`) reconciles enrollment shims in target repos when `config.yaml` changes or on manual dispatch. The CLI's `EnrollmentLayer.Install()` dispatches this workflow via `workflow_dispatch` and monitors it for completion, then reports any enrollment PRs created in target repos. diff --git a/docs/guides/dev/cli-internals.md b/docs/guides/dev/cli-internals.md index 3be167db79..cfc6f413c3 100644 --- a/docs/guides/dev/cli-internals.md +++ b/docs/guides/dev/cli-internals.md @@ -251,9 +251,9 @@ type Layer interface { ``` ``` -Stack order: ConfigRepo → Workflows → VendorBinary → Secrets → Inference → Dispatch → Enrollment -Install: process 1→7 (forward) -Uninstall: process 7→1 (reverse) +Stack order: ConfigRepo → Workflows → HarnessWrappers → VendorBinary → Secrets → Inference → Dispatch → Enrollment +Install: process 1→8 (forward) +Uninstall: process 8→1 (reverse) ``` Per-repo mode does not use the layer stack — it runs the same phases inline in `runPerRepoInstall()` and `runGitHubSetupPerRepo()` since there's no need for composable uninstall ordering with a single repo. Binary vendoring (when `--vendor-fullsend-binary` is set) and stale binary cleanup are handled inline or via shared helpers; per-org mode uses `VendorBinaryLayer`. diff --git a/docs/plans/vertex-inference-provisioning.md b/docs/plans/vertex-inference-provisioning.md index 5d01ee60b3..8f7c0fdf2a 100644 --- a/docs/plans/vertex-inference-provisioning.md +++ b/docs/plans/vertex-inference-provisioning.md @@ -147,10 +147,12 @@ type InferenceLayer struct { 1. ConfigRepoLayer 2. WorkflowsLayer -3. SecretsLayer (agent app keys) -4. **InferenceLayer** (inference provider credentials) ← NEW -5. DispatchTokenLayer -6. EnrollmentLayer +3. HarnessWrappersLayer +4. VendorBinaryLayer +5. SecretsLayer (agent app keys) +6. **InferenceLayer** (inference provider credentials) ← NEW +7. DispatchTokenLayer +8. EnrollmentLayer Rationale: InferenceLayer needs `.fullsend` repo to exist (created by ConfigRepoLayer) and stores repo-level secrets (like SecretsLayer). It must run before EnrollmentLayer since enrolled repos will need these secrets available. @@ -228,9 +230,9 @@ Add the InferenceLayer to the stack between SecretsLayer and DispatchTokenLayer. #### 5a. `docs/ADRs/0006-ordered-layer-model.md` — Update layer stack ordering -This is the canonical ADR defining the layer model. The Consequences section lists the current stack as `config-repo → workflows → secrets → dispatch-token → enrollment`. Update to include InferenceLayer at position 4: +This is the canonical ADR defining the layer model. Update to include InferenceLayer at position 4: -`config-repo → workflows → secrets → inference → dispatch-token → enrollment` +`config-repo → workflows → harness-wrappers → vendor-binary → secrets → inference → dispatch-token → enrollment` #### 5b. `docs/architecture.md` — Update architecture overview diff --git a/internal/cli/admin.go b/internal/cli/admin.go index ec2027ccf2..fcc9af3fc5 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -1211,7 +1211,7 @@ func runDryRun(ctx context.Context, client forge.Client, printer *ui.Printer, or } else { dispatcher = gcf.NewProvisioner(gcf.Config{}, nil) } - stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, vendorBinary, makeVendorFunc(fullsendBinary), dispatcher) + stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, vendorBinary, makeVendorFunc(fullsendBinary), dispatcher, commitSHA) if err := runPreflight(ctx, stack, layers.OpInstall, client, printer); err != nil { return err @@ -1572,7 +1572,7 @@ func runInstall(ctx context.Context, client forge.Client, printer *ui.Printer, o }, gcf.NewLiveGCFClient(mintProject)) } - stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, vendorBinary, makeVendorFunc(fullsendBinary), disp) + stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, vendorBinary, makeVendorFunc(fullsendBinary), disp, commitSHA) if err := runPreflight(ctx, stack, layers.OpInstall, client, printer); err != nil { return err @@ -1818,7 +1818,7 @@ func runAnalyze(ctx context.Context, client forge.Client, printer *ui.Printer, o } dispatcher := gcf.NewProvisioner(gcf.Config{}, nil) - stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, nil, agentCreds, nil, inferenceProvider, false, nil, dispatcher) + stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, nil, agentCreds, nil, inferenceProvider, false, nil, dispatcher, commitSHA) if err := runPreflight(ctx, stack, layers.OpAnalyze, client, printer); err != nil { return err @@ -1843,6 +1843,7 @@ func buildLayerStack( vendorBinary bool, vendorFn layers.VendorFunc, dispatcher dispatch.Dispatcher, + commitSHA string, ) *layers.Stack { dispatchLayer := layers.NewOIDCDispatchLayer(org, client, enrolledRepoIDs, dispatcher, printer) @@ -1859,6 +1860,7 @@ func buildLayerStack( return layers.NewStack( layers.NewConfigRepoLayer(org, client, cfg, printer, privateRepo), layers.NewWorkflowsLayer(org, client, printer, user, version), + layers.NewHarnessWrappersLayer(org, client, printer, agentCreds, commitSHA), layers.NewVendorBinaryLayer(org, forge.ConfigRepoName, client, printer, vendorBinary, vendorFn), layers.NewSecretsLayer(org, client, agentCreds, printer).WithOIDCMode(), layers.NewInferenceLayer(org, client, inferenceProvider, printer), diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 797dfb4851..3363b574f8 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -1100,6 +1100,7 @@ func TestBuildLayerStack_NilEnabledRepos_SkipsDisabledRepos(t *testing.T) { false, // vendorBinary nil, // vendorFn nil, // dispatcher + "dev", // commitSHA ) // The enrollment layer (last in the stack) should have no repos to @@ -1134,6 +1135,7 @@ func TestBuildLayerStack_EmptyEnabledRepos_IncludesDisabledRepos(t *testing.T) { false, []string{}, // explicitly empty (not nil) nil, nil, nil, false, nil, nil, + "dev", // commitSHA ) // The enrollment layer should have disabled repos to reconcile. @@ -1211,6 +1213,7 @@ func TestCheckInstallScopes_SyncWithLayers(t *testing.T) { stack := layers.NewStack( layers.NewConfigRepoLayer("test-org", nil, emptyCfg, ui.New(&discardWriter{}), false), layers.NewWorkflowsLayer("test-org", nil, ui.New(&discardWriter{}), "", "test-version"), + layers.NewHarnessWrappersLayer("test-org", nil, ui.New(&discardWriter{}), nil, "dev"), layers.NewSecretsLayer("test-org", nil, nil, ui.New(&discardWriter{})), layers.NewInferenceLayer("test-org", nil, nil, ui.New(&discardWriter{})), layers.NewOIDCDispatchLayer("test-org", nil, nil, nil, ui.New(&discardWriter{})), diff --git a/internal/cli/github.go b/internal/cli/github.go index d2568dfe17..2dd31b06a2 100644 --- a/internal/cli/github.go +++ b/internal/cli/github.go @@ -450,7 +450,7 @@ func runGitHubSetupPerOrg(ctx context.Context, client forge.Client, printer *ui. vendorFn = makeVendorFunc(cfg.fullsendBinary) } - stack := buildLayerStack(org, client, orgCfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, cfg.vendorBinary, vendorFn, dispatcher) + stack := buildLayerStack(org, client, orgCfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, cfg.vendorBinary, vendorFn, dispatcher, commitSHA) if cfg.dryRun { printer.Header("Dry run — analyzing what setup would do") @@ -486,7 +486,7 @@ func runGitHubSetupPerOrg(ctx context.Context, client forge.Client, printer *ui. orgCfg = config.NewOrgConfig(repoNames, enabledRepos, roles, agents, inferenceProviderName) orgCfg.Dispatch.Mode = "oidc-mint" - stack = buildLayerStack(org, client, orgCfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, cfg.vendorBinary, vendorFn, dispatcher) + stack = buildLayerStack(org, client, orgCfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, cfg.vendorBinary, vendorFn, dispatcher, commitSHA) } if err := runPreflight(ctx, stack, layers.OpInstall, client, printer); err != nil { diff --git a/internal/cli/root.go b/internal/cli/root.go index fef2c8229f..0c8d89afb0 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -7,12 +7,18 @@ import ( ) var version = "dev" +var commitSHA = "dev" // Version returns the CLI version string set at build time. func Version() string { return version } +// CommitSHA returns the git commit SHA set at build time. +func CommitSHA() string { + return commitSHA +} + func newRootCmd() *cobra.Command { cmd := &cobra.Command{ Use: "fullsend", diff --git a/internal/config/config.go b/internal/config/config.go index 674cd1258c..01340cb5dd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -115,6 +115,10 @@ func NewOrgConfig(allRepos, enabledRepos, roles []string, agents []AgentEntry, i }, Agents: agents, Repos: repos, + // Default allowlist for base: composition in harness wrappers (ADR-0045 Phase 2). + AllowedRemoteResources: []string{ + "https://raw.githubusercontent.com/fullsend-ai/fullsend/", + }, } if inferenceProvider != "" { cfg.Inference = InferenceConfig{Provider: inferenceProvider} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1731f67efb..c1145ac40d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -57,6 +57,8 @@ func TestNewOrgConfig(t *testing.T) { assert.Equal(t, "fullsend", cfg.Agents[0].Role) assert.Equal(t, "test", cfg.Agents[0].Name) assert.Equal(t, "test-slug", cfg.Agents[0].Slug) + + assert.Equal(t, []string{"https://raw.githubusercontent.com/fullsend-ai/fullsend/"}, cfg.AllowedRemoteResources) } func TestOrgConfigMarshal(t *testing.T) { diff --git a/internal/layers/harnesswrappers.go b/internal/layers/harnesswrappers.go new file mode 100644 index 0000000000..98ad631ee9 --- /dev/null +++ b/internal/layers/harnesswrappers.go @@ -0,0 +1,229 @@ +package layers + +import ( + "context" + "fmt" + "strings" + + "github.com/fullsend-ai/fullsend/internal/forge" + "github.com/fullsend-ai/fullsend/internal/scaffold" + "github.com/fullsend-ai/fullsend/internal/ui" +) + +const wrapperHeader = "# This file is managed by fullsend. Do not edit it directly.\n# To customize, use customized/harness/ instead (see ADR-0035).\n" + +// HarnessWrappersLayer generates thin harness wrapper files in the .fullsend +// config repo. Each wrapper references an upstream scaffold harness via a +// base: URL and sets role/slug locally, enabling orgs to customize by adding +// override fields. +type HarnessWrappersLayer struct { + org string + client forge.Client + ui *ui.Printer + agents []AgentCredentials + commitSHA string +} + +var _ Layer = (*HarnessWrappersLayer)(nil) + +// NewHarnessWrappersLayer creates a new HarnessWrappersLayer. +func NewHarnessWrappersLayer(org string, client forge.Client, printer *ui.Printer, agents []AgentCredentials, commitSHA string) *HarnessWrappersLayer { + return &HarnessWrappersLayer{ + org: org, + client: client, + ui: printer, + agents: agents, + commitSHA: commitSHA, + } +} + +func (l *HarnessWrappersLayer) Name() string { + return "harness-wrappers" +} + +func (l *HarnessWrappersLayer) RequiredScopes(op Operation) []string { + switch op { + case OpInstall, OpAnalyze: + return []string{"repo"} + default: + return nil + } +} + +// harnessesForRole returns the harness filename(s) for a given agent role. +// The coder role maps to both code and fix harnesses (fix reuses the coder app). +// The fullsend role is the org-level app and has no harness. +func harnessesForRole(role string) []string { + switch role { + case "fullsend": + return nil + case "coder": + return []string{"code", "fix"} + default: + return []string{role} + } +} + +func (l *HarnessWrappersLayer) Install(ctx context.Context) error { + if l.commitSHA == "" || l.commitSHA == "dev" { + l.ui.StepDone("Skipped harness wrappers (dev build, no stable commit SHA)") + return nil + } + + slugForRole := make(map[string]string, len(l.agents)) + for _, ac := range l.agents { + slugForRole[ac.Role] = ac.Slug + } + + existing, err := l.loadExistingHarnesses(ctx) + if err != nil { + return fmt.Errorf("checking existing harnesses: %w", err) + } + + var files []forge.TreeFile + var generated []string + seen := make(map[string]bool) + + for _, ac := range l.agents { + for _, name := range harnessesForRole(ac.Role) { + path := "harness/" + name + ".yaml" + if seen[path] { + continue + } + seen[path] = true + + if content, exists := existing[path]; exists { + if !strings.HasPrefix(string(content), "# This file is managed by fullsend.") { + l.ui.StepDone(fmt.Sprintf("Skipping %s (customized)", path)) + continue + } + } + + baseURL, err := scaffold.HarnessBaseURLWithHash(name, l.commitSHA) + if err != nil { + return fmt.Errorf("generating base URL for %s: %w", name, err) + } + + role := ac.Role + slug := slugForRole[ac.Role] + if name == "fix" { + role = "coder" + slug = slugForRole["coder"] + } + + wrapper := wrapperHeader + + "base: " + baseURL + "\n" + + "role: " + role + "\n" + + "slug: " + slug + "\n" + + files = append(files, forge.TreeFile{ + Path: path, + Content: []byte(wrapper), + Mode: "100644", + }) + generated = append(generated, name) + } + } + + if len(files) == 0 { + l.ui.StepDone("No harness wrappers to generate") + return nil + } + + l.ui.StepStart(fmt.Sprintf("Writing %d harness wrapper(s)", len(files))) + committed, err := l.client.CommitFiles(ctx, l.org, forge.ConfigRepoName, + "chore: generate harness wrappers with base composition", files) + if err != nil { + l.ui.StepFail("Failed to commit harness wrappers") + return fmt.Errorf("committing harness wrappers: %w", err) + } + if committed { + l.ui.StepDone(fmt.Sprintf("Generated wrappers: %s", strings.Join(generated, ", "))) + } else { + l.ui.StepDone("Harness wrappers up to date") + } + + return nil +} + +func (l *HarnessWrappersLayer) Uninstall(_ context.Context) error { + return nil +} + +func (l *HarnessWrappersLayer) Analyze(ctx context.Context) (*LayerReport, error) { + report := &LayerReport{Name: l.Name()} + + if l.commitSHA == "" || l.commitSHA == "dev" { + report.Status = StatusNotInstalled + report.Details = append(report.Details, "dev build: harness wrappers not generated") + return report, nil + } + + var present, missing []string + seen := make(map[string]bool) + for _, ac := range l.agents { + for _, name := range harnessesForRole(ac.Role) { + path := "harness/" + name + ".yaml" + if seen[path] { + continue + } + seen[path] = true + _, err := l.client.GetFileContent(ctx, l.org, forge.ConfigRepoName, path) + if err != nil { + if forge.IsNotFound(err) { + missing = append(missing, path) + continue + } + return nil, fmt.Errorf("checking %s: %w", path, err) + } + present = append(present, path) + } + } + + switch { + case len(missing) == 0 && len(present) == 0: + report.Status = StatusNotInstalled + case len(missing) == 0: + report.Status = StatusInstalled + for _, p := range present { + report.Details = append(report.Details, p+" exists") + } + case len(present) == 0: + report.Status = StatusNotInstalled + for _, m := range missing { + report.WouldInstall = append(report.WouldInstall, "write "+m) + } + default: + report.Status = StatusDegraded + for _, p := range present { + report.Details = append(report.Details, p+" exists") + } + for _, m := range missing { + report.WouldFix = append(report.WouldFix, "write "+m) + } + } + + return report, nil +} + +// loadExistingHarnesses reads all harness files that would be generated, +// returning a map of path → content. Files that do not exist (404) are +// omitted silently. Non-404 errors (network, permissions) are returned +// so Install can fail fast rather than risk overwriting customized files. +func (l *HarnessWrappersLayer) loadExistingHarnesses(ctx context.Context) (map[string][]byte, error) { + result := make(map[string][]byte) + for _, ac := range l.agents { + for _, name := range harnessesForRole(ac.Role) { + path := "harness/" + name + ".yaml" + content, err := l.client.GetFileContent(ctx, l.org, forge.ConfigRepoName, path) + if err != nil { + if forge.IsNotFound(err) { + continue + } + return nil, fmt.Errorf("reading existing %s: %w", path, err) + } + result[path] = content + } + } + return result, nil +} diff --git a/internal/layers/harnesswrappers_test.go b/internal/layers/harnesswrappers_test.go new file mode 100644 index 0000000000..86955dcb51 --- /dev/null +++ b/internal/layers/harnesswrappers_test.go @@ -0,0 +1,413 @@ +package layers + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/fullsend-ai/fullsend/internal/config" + "github.com/fullsend-ai/fullsend/internal/forge" + "github.com/fullsend-ai/fullsend/internal/harness" + "github.com/fullsend-ai/fullsend/internal/scaffold" + "github.com/fullsend-ai/fullsend/internal/ui" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testCommitSHA = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + +func testPrinter() *ui.Printer { + var buf bytes.Buffer + return ui.New(&buf) +} + +func testAgents() []AgentCredentials { + return []AgentCredentials{ + {AgentEntry: config.AgentEntry{Role: "fullsend", Name: "test-fullsend", Slug: "test-fullsend"}}, + {AgentEntry: config.AgentEntry{Role: "triage", Name: "test-triage", Slug: "test-triage"}}, + {AgentEntry: config.AgentEntry{Role: "coder", Name: "test-coder", Slug: "test-coder"}}, + {AgentEntry: config.AgentEntry{Role: "review", Name: "test-review", Slug: "test-review"}}, + {AgentEntry: config.AgentEntry{Role: "retro", Name: "test-retro", Slug: "test-retro"}}, + {AgentEntry: config.AgentEntry{Role: "prioritize", Name: "test-prioritize", Slug: "test-prioritize"}}, + } +} + +func TestHarnessWrappersLayer_Name(t *testing.T) { + layer := NewHarnessWrappersLayer("org", nil, testPrinter(), nil, "dev") + assert.Equal(t, "harness-wrappers", layer.Name()) +} + +func TestHarnessWrappersLayer_RequiredScopes(t *testing.T) { + layer := NewHarnessWrappersLayer("org", nil, testPrinter(), nil, "dev") + assert.Equal(t, []string{"repo"}, layer.RequiredScopes(OpInstall)) + assert.Equal(t, []string{"repo"}, layer.RequiredScopes(OpAnalyze)) + assert.Nil(t, layer.RequiredScopes(OpUninstall)) +} + +func TestHarnessWrappersLayer_Install_DevBuild(t *testing.T) { + client := forge.NewFakeClient() + layer := NewHarnessWrappersLayer("org", client, testPrinter(), testAgents(), "dev") + + err := layer.Install(context.Background()) + require.NoError(t, err) + assert.Empty(t, client.CommittedFiles, "dev build should not commit any files") +} + +func TestHarnessWrappersLayer_Install_EmptyCommitSHA(t *testing.T) { + client := forge.NewFakeClient() + layer := NewHarnessWrappersLayer("org", client, testPrinter(), testAgents(), "") + + err := layer.Install(context.Background()) + require.NoError(t, err) + assert.Empty(t, client.CommittedFiles) +} + +func TestHarnessWrappersLayer_Install_GeneratesWrappers(t *testing.T) { + client := forge.NewFakeClient() + client.Repos = []forge.Repository{{FullName: "org/.fullsend", DefaultBranch: "main"}} + layer := NewHarnessWrappersLayer("org", client, testPrinter(), testAgents(), testCommitSHA) + + err := layer.Install(context.Background()) + require.NoError(t, err) + + require.Len(t, client.CommittedFiles, 1) + batch := client.CommittedFiles[0] + assert.Equal(t, "org", batch.Owner) + assert.Equal(t, ".fullsend", batch.Repo) + + paths := make(map[string]string) + for _, f := range batch.Files { + paths[f.Path] = string(f.Content) + } + + // fullsend role should be skipped (no harness) + assert.NotContains(t, paths, "harness/fullsend.yaml") + + // triage should have a wrapper + assert.Contains(t, paths, "harness/triage.yaml") + assert.Contains(t, paths["harness/triage.yaml"], "role: triage") + assert.Contains(t, paths["harness/triage.yaml"], "slug: test-triage") + assert.Contains(t, paths["harness/triage.yaml"], "base: https://raw.githubusercontent.com/fullsend-ai/fullsend/") + + // review should have a wrapper + assert.Contains(t, paths, "harness/review.yaml") + assert.Contains(t, paths["harness/review.yaml"], "role: review") + assert.Contains(t, paths["harness/review.yaml"], "slug: test-review") + + // coder role should generate both code and fix wrappers + assert.Contains(t, paths, "harness/code.yaml") + assert.Contains(t, paths["harness/code.yaml"], "role: coder") + assert.Contains(t, paths["harness/code.yaml"], "slug: test-coder") + + assert.Contains(t, paths, "harness/fix.yaml") + assert.Contains(t, paths["harness/fix.yaml"], "role: coder") + assert.Contains(t, paths["harness/fix.yaml"], "slug: test-coder") + + // retro and prioritize should have wrappers + assert.Contains(t, paths, "harness/retro.yaml") + assert.Contains(t, paths["harness/retro.yaml"], "role: retro") + assert.Contains(t, paths["harness/retro.yaml"], "slug: test-retro") + + assert.Contains(t, paths, "harness/prioritize.yaml") + assert.Contains(t, paths["harness/prioritize.yaml"], "role: prioritize") + assert.Contains(t, paths["harness/prioritize.yaml"], "slug: test-prioritize") +} + +func TestHarnessWrappersLayer_Install_WrapperContainsManagedHeader(t *testing.T) { + client := forge.NewFakeClient() + agents := []AgentCredentials{ + {AgentEntry: config.AgentEntry{Role: "triage", Name: "t", Slug: "test-triage"}}, + } + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + + err := layer.Install(context.Background()) + require.NoError(t, err) + + require.Len(t, client.CommittedFiles, 1) + content := string(client.CommittedFiles[0].Files[0].Content) + assert.True(t, len(content) > 0) + assert.Contains(t, content, "# This file is managed by fullsend.") + assert.Contains(t, content, "# To customize, use customized/harness/ instead (see ADR-0035).") +} + +func TestHarnessWrappersLayer_Install_WrapperContainsIntegrityHash(t *testing.T) { + client := forge.NewFakeClient() + agents := []AgentCredentials{ + {AgentEntry: config.AgentEntry{Role: "triage", Name: "t", Slug: "test-triage"}}, + } + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + + err := layer.Install(context.Background()) + require.NoError(t, err) + + content := string(client.CommittedFiles[0].Files[0].Content) + assert.Contains(t, content, "#sha256=") +} + +func TestHarnessWrappersLayer_Install_SkipsCustomizedFile(t *testing.T) { + client := forge.NewFakeClient() + // Pre-populate with a customized (non-managed) harness file + client.FileContents["org/.fullsend/harness/triage.yaml"] = []byte("agent: agents/custom-triage.md\nmodel: sonnet\n") + + agents := []AgentCredentials{ + {AgentEntry: config.AgentEntry{Role: "triage", Name: "t", Slug: "test-triage"}}, + } + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + + err := layer.Install(context.Background()) + require.NoError(t, err) + + // No files should be committed since the only file was customized + assert.Empty(t, client.CommittedFiles) +} + +func TestHarnessWrappersLayer_Install_OverwritesManagedFile(t *testing.T) { + client := forge.NewFakeClient() + // Pre-populate with a managed harness file + client.FileContents["org/.fullsend/harness/triage.yaml"] = []byte("# This file is managed by fullsend. Do not edit it directly.\nbase: https://old-url\nrole: triage\nslug: old-slug\n") + + agents := []AgentCredentials{ + {AgentEntry: config.AgentEntry{Role: "triage", Name: "t", Slug: "test-triage"}}, + } + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + + err := layer.Install(context.Background()) + require.NoError(t, err) + + require.Len(t, client.CommittedFiles, 1) + content := string(client.CommittedFiles[0].Files[0].Content) + assert.Contains(t, content, "slug: test-triage") + assert.NotContains(t, content, "old-slug") +} + +func TestHarnessWrappersLayer_Install_CommitFilesError(t *testing.T) { + client := forge.NewFakeClient() + client.Errors["CommitFiles"] = errors.New("network error") + + agents := []AgentCredentials{ + {AgentEntry: config.AgentEntry{Role: "triage", Name: "t", Slug: "test-triage"}}, + } + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + + err := layer.Install(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "committing harness wrappers") +} + +func TestHarnessWrappersLayer_Install_NoAgentsNoCommit(t *testing.T) { + client := forge.NewFakeClient() + layer := NewHarnessWrappersLayer("org", client, testPrinter(), nil, testCommitSHA) + + err := layer.Install(context.Background()) + require.NoError(t, err) + assert.Empty(t, client.CommittedFiles) +} + +func TestHarnessWrappersLayer_Install_OnlyFullsendRoleNoCommit(t *testing.T) { + client := forge.NewFakeClient() + agents := []AgentCredentials{ + {AgentEntry: config.AgentEntry{Role: "fullsend", Name: "fs", Slug: "test-fullsend"}}, + } + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + + err := layer.Install(context.Background()) + require.NoError(t, err) + assert.Empty(t, client.CommittedFiles) +} + +func TestHarnessWrappersLayer_Install_WrapperParsesAsValidHarness(t *testing.T) { + client := forge.NewFakeClient() + agents := []AgentCredentials{ + {AgentEntry: config.AgentEntry{Role: "triage", Name: "t", Slug: "test-triage"}}, + } + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + + err := layer.Install(context.Background()) + require.NoError(t, err) + + require.Len(t, client.CommittedFiles, 1) + content := client.CommittedFiles[0].Files[0].Content + + // Write to a temp file and verify it parses via LoadRaw + dir := t.TempDir() + path := filepath.Join(dir, "triage.yaml") + require.NoError(t, os.WriteFile(path, content, 0o644)) + + h, loadErr := harness.LoadRaw(path) + require.NoError(t, loadErr) + assert.Equal(t, "triage", h.Role) + assert.Equal(t, "test-triage", h.Slug) + assert.NotEmpty(t, h.Base) +} + +func TestHarnessWrappersLayer_Install_BaseURLMatchesScaffold(t *testing.T) { + client := forge.NewFakeClient() + agents := []AgentCredentials{ + {AgentEntry: config.AgentEntry{Role: "triage", Name: "t", Slug: "test-triage"}}, + } + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + + err := layer.Install(context.Background()) + require.NoError(t, err) + + expectedURL, urlErr := scaffold.HarnessBaseURLWithHash("triage", testCommitSHA) + require.NoError(t, urlErr) + + content := string(client.CommittedFiles[0].Files[0].Content) + assert.Contains(t, content, "base: "+expectedURL) +} + +func TestHarnessWrappersLayer_Uninstall_NoOp(t *testing.T) { + layer := NewHarnessWrappersLayer("org", nil, testPrinter(), nil, "dev") + err := layer.Uninstall(context.Background()) + require.NoError(t, err) +} + +func TestHarnessWrappersLayer_Analyze_DevBuild(t *testing.T) { + layer := NewHarnessWrappersLayer("org", nil, testPrinter(), nil, "dev") + report, err := layer.Analyze(context.Background()) + require.NoError(t, err) + assert.Equal(t, StatusNotInstalled, report.Status) + assert.Contains(t, report.Details[0], "dev build") +} + +func TestHarnessWrappersLayer_Analyze_AllPresent(t *testing.T) { + client := forge.NewFakeClient() + agents := []AgentCredentials{ + {AgentEntry: config.AgentEntry{Role: "triage", Name: "t", Slug: "test-triage"}}, + } + client.FileContents["org/.fullsend/harness/triage.yaml"] = []byte("role: triage\n") + + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + report, err := layer.Analyze(context.Background()) + require.NoError(t, err) + assert.Equal(t, StatusInstalled, report.Status) +} + +func TestHarnessWrappersLayer_Analyze_AllMissing(t *testing.T) { + client := forge.NewFakeClient() + agents := []AgentCredentials{ + {AgentEntry: config.AgentEntry{Role: "triage", Name: "t", Slug: "test-triage"}}, + } + + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + report, err := layer.Analyze(context.Background()) + require.NoError(t, err) + assert.Equal(t, StatusNotInstalled, report.Status) + assert.Len(t, report.WouldInstall, 1) + assert.Contains(t, report.WouldInstall[0], "harness/triage.yaml") +} + +func TestHarnessWrappersLayer_Analyze_Degraded(t *testing.T) { + client := forge.NewFakeClient() + agents := []AgentCredentials{ + {AgentEntry: config.AgentEntry{Role: "triage", Name: "t", Slug: "test-triage"}}, + {AgentEntry: config.AgentEntry{Role: "review", Name: "r", Slug: "test-review"}}, + } + // Only triage exists + client.FileContents["org/.fullsend/harness/triage.yaml"] = []byte("role: triage\n") + + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + report, err := layer.Analyze(context.Background()) + require.NoError(t, err) + assert.Equal(t, StatusDegraded, report.Status) + assert.Len(t, report.Details, 1) + assert.Len(t, report.WouldFix, 1) +} + +func TestHarnessWrappersLayer_Analyze_NoAgents(t *testing.T) { + client := forge.NewFakeClient() + layer := NewHarnessWrappersLayer("org", client, testPrinter(), nil, testCommitSHA) + report, err := layer.Analyze(context.Background()) + require.NoError(t, err) + assert.Equal(t, StatusNotInstalled, report.Status) +} + +func TestHarnessesForRole(t *testing.T) { + tests := []struct { + role string + expected []string + }{ + {"fullsend", nil}, + {"coder", []string{"code", "fix"}}, + {"triage", []string{"triage"}}, + {"review", []string{"review"}}, + {"retro", []string{"retro"}}, + {"prioritize", []string{"prioritize"}}, + {"custom", []string{"custom"}}, + } + + for _, tt := range tests { + t.Run(tt.role, func(t *testing.T) { + assert.Equal(t, tt.expected, harnessesForRole(tt.role)) + }) + } +} + +func TestHarnessWrappersLayer_Install_FileMode(t *testing.T) { + client := forge.NewFakeClient() + agents := []AgentCredentials{ + {AgentEntry: config.AgentEntry{Role: "triage", Name: "t", Slug: "test-triage"}}, + } + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + + err := layer.Install(context.Background()) + require.NoError(t, err) + + require.Len(t, client.CommittedFiles, 1) + for _, f := range client.CommittedFiles[0].Files { + assert.Equal(t, "100644", f.Mode, "wrapper files should be regular files") + } +} + +func TestHarnessWrappersLayer_Install_CoderFixDedup(t *testing.T) { + client := forge.NewFakeClient() + agents := []AgentCredentials{ + {AgentEntry: config.AgentEntry{Role: "coder", Name: "coder-a", Slug: "slug-a"}}, + {AgentEntry: config.AgentEntry{Role: "coder", Name: "coder-b", Slug: "slug-b"}}, + } + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + + err := layer.Install(context.Background()) + require.NoError(t, err) + + require.Len(t, client.CommittedFiles, 1) + paths := make(map[string]bool) + for _, f := range client.CommittedFiles[0].Files { + assert.False(t, paths[f.Path], "duplicate file in commit: %s", f.Path) + paths[f.Path] = true + } + assert.True(t, paths["harness/code.yaml"]) + assert.True(t, paths["harness/fix.yaml"]) + assert.Len(t, client.CommittedFiles[0].Files, 2) +} + +func TestHarnessWrappersLayer_Install_LoadExistingHarnessesError(t *testing.T) { + client := forge.NewFakeClient() + client.Errors["GetFileContent"] = errors.New("permission denied") + agents := []AgentCredentials{ + {AgentEntry: config.AgentEntry{Role: "triage", Name: "t", Slug: "test-triage"}}, + } + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + + err := layer.Install(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "checking existing harnesses") +} + +func TestHarnessWrappersLayer_Install_IdempotentNoChange(t *testing.T) { + client := forge.NewFakeClient() + changed := false + client.CommitFilesChanged = &changed + agents := []AgentCredentials{ + {AgentEntry: config.AgentEntry{Role: "triage", Name: "t", Slug: "test-triage"}}, + } + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + + err := layer.Install(context.Background()) + require.NoError(t, err) + // Should succeed without error even when tree is unchanged +}