From 9a39f2ec1888d71d091057109c1025f9f60640ad Mon Sep 17 00:00:00 2001 From: Greg Allen Date: Tue, 30 Jun 2026 08:58:33 -0400 Subject: [PATCH] feat(run): add runtime agent resolution from config (ADR 0058 Phase 3) Signed-off-by: Claude Signed-off-by: Greg Allen --- docs/agents/README.md | 18 +- .../getting-started/configuring-github.md | 2 +- docs/guides/getting-started/org-mode.md | 2 +- .../adr-0045-forge-portable-harness-phase2.md | 2 +- docs/runtimes.md | 2 +- internal/cli/admin.go | 2 +- internal/cli/admin_test.go | 2 +- internal/cli/orgconfig.go | 9 + internal/cli/run.go | 137 +++++++-- internal/cli/run_test.go | 279 ++++++++++++++++++ internal/config/agents.go | 79 +++++ internal/config/agents_test.go | 162 ++++++++++ internal/config/config.go | 8 +- internal/harness/compose.go | 12 + internal/layers/harnesswrappers.go | 43 ++- internal/layers/harnesswrappers_test.go | 91 ++++-- 16 files changed, 773 insertions(+), 77 deletions(-) create mode 100644 internal/config/agents.go create mode 100644 internal/config/agents_test.go diff --git a/docs/agents/README.md b/docs/agents/README.md index f8c074b56..5c47287c1 100644 --- a/docs/agents/README.md +++ b/docs/agents/README.md @@ -1,8 +1,10 @@ -# Default Agents +# Agents -Reference documentation for the default agents shipped by fullsend. -All agents below are enabled by default. The set of default agents is defined by -the YAML files in [`internal/scaffold/fullsend-repo/harness/`](../../internal/scaffold/fullsend-repo/harness/). +Reference documentation for the agents shipped by fullsend. +The default agents below are defined by the YAML files in +[`internal/scaffold/fullsend-repo/harness/`](../../internal/scaffold/fullsend-repo/harness/). +Custom agents can be registered via the `agents:` field in org or per-repo +config (see [ADR 0058](../ADRs/0058-agent-registration.md)). | Agent | Summary | |-------|---------| @@ -23,5 +25,9 @@ a specific agent performs a specific task. See ## Custom Agents -Support for adding your own custom agents to the fullsend pipeline is coming -soon. +Custom agents can be added to the fullsend pipeline via the `agents:` field in +your org-level or per-repo `config.yaml`. Each entry is either a local path +(relative to the fullsend directory) or a pinned HTTPS URL with an integrity +hash. Config-registered agents override scaffold defaults when names collide +(case-insensitive). See [ADR 0058](../ADRs/0058-agent-registration.md) for +details. diff --git a/docs/guides/getting-started/configuring-github.md b/docs/guides/getting-started/configuring-github.md index 064d02a2e..659e32b6f 100644 --- a/docs/guides/getting-started/configuring-github.md +++ b/docs/guides/getting-started/configuring-github.md @@ -78,6 +78,6 @@ Actions tab to see the Fullsend workflow in action. In some minutes the * Read [Organization installation mode](org-mode.md) to learn how to share GCP project with other repositories within your GitHub organization. -* Read the [Default Agents](../../agents/README.md) section to learn about the default agents Fullsend +* Read the [Agents](../../agents/README.md) section to learn about the default agents Fullsend ships with. * Explore other sections of this documentation for more information. diff --git a/docs/guides/getting-started/org-mode.md b/docs/guides/getting-started/org-mode.md index 23349ea96..a783150f2 100644 --- a/docs/guides/getting-started/org-mode.md +++ b/docs/guides/getting-started/org-mode.md @@ -195,6 +195,6 @@ Each command prompts for confirmation. Add `--yolo` to skip prompts. See the [st ## Next Steps -* Read the [Default Agents](../../agents/README.md) section to learn about the default agents Fullsend +* Read the [Agents](../../agents/README.md) section to learn about the default agents Fullsend ships with. * Explore other sections of this documentation for more information. diff --git a/docs/plans/adr-0045-forge-portable-harness-phase2.md b/docs/plans/adr-0045-forge-portable-harness-phase2.md index 4786ab198..ab5212181 100644 --- a/docs/plans/adr-0045-forge-portable-harness-phase2.md +++ b/docs/plans/adr-0045-forge-portable-harness-phase2.md @@ -225,7 +225,7 @@ The `WorkflowsLayer` currently uses `scaffold.WalkFullsendRepo()` which skips `l **`HarnessWrappersLayer` struct:** - Fields: `org string`, `client forge.Client`, `printer *ui.Printer`, `agents []AgentCredentials`, `commitSHA string`, `existingHarnesses map[string]bool` -- `NewHarnessWrappersLayer(org string, client forge.Client, printer *ui.Printer, agents []AgentCredentials, commitSHA string) *HarnessWrappersLayer` +- `NewHarnessWrappersLayer(org string, client forge.Client, printer *ui.Printer, agents []AgentCredentials, commitSHA string, configAgentNames []string) *HarnessWrappersLayer` **`Install() error`:** 1. For each agent in `agents`: diff --git a/docs/runtimes.md b/docs/runtimes.md index d5b35eac7..fb7f09f03 100644 --- a/docs/runtimes.md +++ b/docs/runtimes.md @@ -44,7 +44,7 @@ The sandbox has two key directories that map to Claude Code's config levels: /sandbox/ ├── claude-config/ ← CLAUDE_CONFIG_DIR (personal level) │ ├── agents/ -│ │ └── .md Agent definition (filename derived from AgentName()) +│ │ └── .md Agent definition (filename derived from the agent name) │ ├── skills/ │ │ ├── code-review/SKILL.md Built-in skills (personal level — wins on collision) │ │ ├── pr-review/SKILL.md diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 6ab50562a..022a1fbb3 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -1854,7 +1854,7 @@ func buildLayerStack( return layers.NewStack( layers.NewConfigRepoLayer(org, client, cfg, printer, privateRepo), workflowsLayer(ctx, org, client, printer, user, version, vendor, vendorCollect, direct), - layers.NewHarnessWrappersLayer(org, client, printer, agentCreds, commitSHA), + layers.NewHarnessWrappersLayer(org, client, printer, agentCreds, commitSHA, configAgentNames(cfg.Agents)), vendorLayer(org, client, printer, vendor, vendorFn, vendorCollect, analyzeFullsendSource), 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 6118c37ab..5bf5f478f 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -1288,7 +1288,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", false), - layers.NewHarnessWrappersLayer("test-org", nil, ui.New(&discardWriter{}), nil, "dev"), + layers.NewHarnessWrappersLayer("test-org", nil, ui.New(&discardWriter{}), nil, "dev", nil), 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/orgconfig.go b/internal/cli/orgconfig.go index 21ad0135e..de62c4ed5 100644 --- a/internal/cli/orgconfig.go +++ b/internal/cli/orgconfig.go @@ -8,6 +8,15 @@ import ( "github.com/fullsend-ai/fullsend/internal/ui" ) +// configAgentNames extracts derived names from a list of agent entries. +func configAgentNames(agents []config.AgentEntry) []string { + names := make([]string, len(agents)) + for i, a := range agents { + names[i] = a.DerivedName() + } + return names +} + // tryLoadOrgConfig attempts to load org config from the given path. // Returns nil without error when the file is absent (best-effort). // Logs warnings via printer for non-ENOENT read errors and parse errors. diff --git a/internal/cli/run.go b/internal/cli/run.go index 93b9d2125..74c49ea79 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -171,12 +171,7 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep } // 1. Resolve and load harness. - harnessPath, err := resolveHarnessPath(absFullsendDir, agentName, printer) - if err != nil { - return err - } harnessStart := time.Now() - printer.StepStart("Loading harness: " + harnessPath) forgePlatform, err := detectForgePlatform(forgeFlag) if err != nil { @@ -188,8 +183,9 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep policy.Offline = rFlags.offline // Best-effort org config loading — provides the allowlist for base - // harness fetching. If the file is missing or unparseable we proceed - // without it; HasURLReferences will enforce its presence later if needed. + // harness fetching and the agent registry for config-driven resolution. + // If the file is missing or unparseable we proceed without it; + // HasURLReferences will enforce its presence later if needed. orgConfigPath := filepath.Join(absFullsendDir, "config.yaml") orgCfg := tryLoadOrgConfig(orgConfigPath, printer) var orgAllowlist []string @@ -197,20 +193,6 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep orgAllowlist = orgCfg.AllowedRemoteResources } - // If the harness has a URL base and org config failed to load, - // load it strictly now so LoadWithBase gets a proper error path - // rather than an unhelpful "URL base requires allowed_remote_resources". - if orgCfg == nil { - if rawH, rawErr := harness.LoadRaw(harnessPath); rawErr == nil && rawH.Base != "" && harness.IsURL(rawH.Base) { - var err error - orgCfg, err = requireOrgConfig(orgConfigPath, printer) - if err != nil { - return err - } - orgAllowlist = orgCfg.AllowedRemoteResources - } - } - var composeForgeClient forge.Client if rFlags.forgeClient != nil { composeForgeClient = rFlags.forgeClient @@ -218,20 +200,45 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep composeForgeClient = gh.New(token) } - h, baseDeps, err := harness.LoadWithBase(ctx, harnessPath, harness.ComposeOpts{ + composeOpts := harness.ComposeOpts{ WorkspaceRoot: absFullsendDir, FetchPolicy: policy, AuditLogPath: filepath.Join(absFullsendDir, ".fullsend-cache", "fetch-audit.jsonl"), ForgePlatform: forgePlatform, OrgAllowlist: orgAllowlist, ForgeClient: composeForgeClient, - }) + } + + // Resolve agent source: config agents take precedence over disk harnesses. + harnessPath, fetchDeps, err := resolveAgentSource(ctx, absFullsendDir, agentName, orgCfg, composeOpts, printer) + if err != nil { + return err + } + + printer.StepStart("Loading harness: " + harnessPath) + + // If the harness has a URL base and org config failed to load, + // load it strictly now so LoadWithBase gets a proper error path + // rather than an unhelpful "URL base requires allowed_remote_resources". + if orgCfg == nil { + if rawH, rawErr := harness.LoadRaw(harnessPath); rawErr == nil && rawH.Base != "" && harness.IsURL(rawH.Base) { + var err error + orgCfg, err = requireOrgConfig(orgConfigPath, printer) + if err != nil { + return err + } + composeOpts.OrgAllowlist = orgCfg.AllowedRemoteResources + } + } + + h, baseDeps, err := harness.LoadWithBase(ctx, harnessPath, composeOpts) if err != nil { printer.StepFail("Failed to load harness") return fmt.Errorf("loading harness: %w", err) } - for _, dep := range baseDeps { + allDeps := append(fetchDeps, baseDeps...) + for _, dep := range allDeps { if dep.CacheHit { printer.StepInfo(fmt.Sprintf("Base: %s (cache hit)", dep.URL)) } else { @@ -2344,3 +2351,85 @@ func validateRepoNames(repos []string) error { } return nil } + +// resolveAgentSource resolves the harness path for an agent, checking +// config-registered agents before falling back to disk-based lookup. +// Returns the local filesystem path to the harness (cached for URL sources) +// and any fetch dependencies from URL-based agent resolution. +func resolveAgentSource(ctx context.Context, fullsendDir, agentName string, orgCfg *config.OrgConfig, composeOpts harness.ComposeOpts, printer *ui.Printer) (string, []harness.Dependency, error) { + if orgCfg == nil || len(orgCfg.Agents) == 0 { + path, err := resolveHarnessPath(fullsendDir, agentName, printer) + return path, nil, err + } + + if err := config.ValidateAgentEntries(orgCfg.Agents, orgCfg.AllowedRemoteResources); err != nil { + return "", nil, fmt.Errorf("invalid agent config: %w", err) + } + + sha := commitSHA + if sha == "dev" { + sha = "" + } + scaffoldNames, snErr := scaffold.HarnessNames() + if snErr != nil { + return "", nil, fmt.Errorf("listing scaffold harnesses: %w", snErr) + } + + merged, err := config.MergedAgents(scaffoldNames, sha, orgCfg.Agents, scaffold.HarnessBaseURLWithHash) + if err != nil { + return "", nil, fmt.Errorf("building merged agent set: %w", err) + } + + agent := config.LookupMergedAgent(merged, agentName) + if agent == nil || !agent.IsConfig { + path, err := resolveHarnessPath(fullsendDir, agentName, printer) + return path, nil, err + } + + if harness.IsURL(agent.Source) { + printer.StepStart(fmt.Sprintf("Fetching agent harness: %s", agent.Name)) + localPath, dep, err := harness.FetchAgentHarness(ctx, agent.Source, composeOpts) + if err != nil { + printer.StepFail("Failed to fetch agent harness") + return "", nil, fmt.Errorf("resolving config agent %s: %w", agent.Name, err) + } + printer.StepDone(fmt.Sprintf("Agent %s resolved from config (URL)", agent.Name)) + return localPath, []harness.Dependency{dep}, nil + } + + contained, err := containedLocalPath(fullsendDir, agent.Source) + if err != nil { + return "", nil, fmt.Errorf("config agent %s: %w", agent.Name, err) + } + if _, err := os.Stat(contained); err != nil { + return "", nil, fmt.Errorf("config agent %s: local path %s: %w", agent.Name, agent.Source, err) + } + printer.StepDone(fmt.Sprintf("Agent %s resolved from config (local path)", agent.Name)) + return contained, nil, nil +} + +// containedLocalPath resolves a relative source path against baseDir and +// verifies the result stays within baseDir. Returns an error for absolute +// paths or paths that escape via traversal. +func containedLocalPath(baseDir, source string) (string, error) { + if filepath.IsAbs(source) { + return "", fmt.Errorf("local path must be relative, not absolute") + } + resolved := filepath.Clean(filepath.Join(baseDir, source)) + if rel, err := filepath.Rel(baseDir, resolved); err != nil || strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("local path %q escapes fullsend directory", source) + } + // Resolve symlinks and re-check containment to prevent symlink escape. + real, err := filepath.EvalSymlinks(resolved) + if err != nil { + return "", err + } + realBase, err := filepath.EvalSymlinks(baseDir) + if err != nil { + return "", err + } + if rel, err := filepath.Rel(realBase, real); err != nil || strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("local path %q escapes fullsend directory via symlink", source) + } + return real, nil +} diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 275035867..e40828abf 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -21,6 +21,7 @@ import ( "github.com/stretchr/testify/require" "github.com/fullsend-ai/fullsend/internal/binary" + "github.com/fullsend-ai/fullsend/internal/config" "github.com/fullsend-ai/fullsend/internal/fetch" "github.com/fullsend-ai/fullsend/internal/fetchsvc" "github.com/fullsend-ai/fullsend/internal/forge" @@ -478,6 +479,284 @@ func TestRunCommand_HasEnvFileFlag(t *testing.T) { assert.Equal(t, []string{"/tmp/a.env", "/tmp/b.env"}, val) } +func TestRunAgent_ConfigAgentLocalPath(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "agents"), 0o755)) + + require.NoError(t, os.WriteFile( + filepath.Join(dir, "agents", "custom.md"), + []byte("You are a custom agent."), + 0o644, + )) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "harness", "custom.yaml"), + []byte("agent: agents/custom.md\nrole: test\n"), + 0o644, + )) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "config.yaml"), + []byte("agents:\n - harness/custom.yaml\nallowed_remote_resources:\n - \"https://example.com/\"\n"), + 0o644, + )) + + rFlags := resolveFlags{maxDepth: 10, maxResources: 50} + printer := ui.New(io.Discard) + repoDir := t.TempDir() + err := runAgent(context.Background(), "custom", dir, "", repoDir, "", nil, false, "", "", rFlags, statusOpts{}, printer, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "openshell") +} + +func TestRunAgent_ConfigAgentURL(t *testing.T) { + harnessContent := []byte("agent: agents/remote.md\nrole: test\n") + harnessHash := fetch.ComputeSHA256(harnessContent) + + srv, policy := newLockTestServer(t, map[string][]byte{ + "/harness/triage.yaml": harnessContent, + }) + + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "agents"), 0o755)) + + require.NoError(t, os.WriteFile( + filepath.Join(dir, "agents", "remote.md"), + []byte("You are a remote agent."), + 0o644, + )) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "config.yaml"), + []byte(fmt.Sprintf("agents:\n - \"%s/harness/triage.yaml#sha256=%s\"\nallowed_remote_resources:\n - \"%s/\"\n", srv.URL, harnessHash, srv.URL)), + 0o644, + )) + + fetch.DefaultPolicy = policy + defer func() { fetch.DefaultPolicy = fetch.FetchPolicy{} }() + + rFlags := resolveFlags{maxDepth: 10, maxResources: 50} + printer := ui.New(io.Discard) + repoDir := t.TempDir() + err := runAgent(context.Background(), "triage", dir, "", repoDir, "", nil, false, "", "", rFlags, statusOpts{}, printer, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "openshell") +} + +func TestRunAgent_ConfigAgentOverridesScaffold(t *testing.T) { + // When config has an agent with the same name as a scaffold agent, + // the config source is used instead of the scaffold wrapper. + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "agents"), 0o755)) + + require.NoError(t, os.WriteFile( + filepath.Join(dir, "agents", "code.md"), + []byte("You are a custom code agent."), + 0o644, + )) + // Config-driven local path agent named "code" — should take precedence + // over any scaffold "code" harness wrapper. + require.NoError(t, os.WriteFile( + filepath.Join(dir, "harness", "code.yaml"), + []byte("agent: agents/code.md\nrole: test\n"), + 0o644, + )) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "config.yaml"), + []byte("agents:\n - harness/code.yaml\nallowed_remote_resources:\n - \"https://example.com/\"\n"), + 0o644, + )) + + rFlags := resolveFlags{maxDepth: 10, maxResources: 50} + printer := ui.New(io.Discard) + repoDir := t.TempDir() + err := runAgent(context.Background(), "code", dir, "", repoDir, "", nil, false, "", "", rFlags, statusOpts{}, printer, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "openshell") +} + +func TestRunAgent_ScaffoldFallback(t *testing.T) { + // When config has agents but the requested agent is not in config, + // fall back to disk-based resolution. + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "agents"), 0o755)) + + require.NoError(t, os.WriteFile( + filepath.Join(dir, "agents", "code.md"), + []byte("You are a coding agent."), + 0o644, + )) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "harness", "code.yaml"), + []byte("agent: agents/code.md\nrole: test\n"), + 0o644, + )) + // Config has agents but "code" is not among them — should fall back to disk. + require.NoError(t, os.WriteFile( + filepath.Join(dir, "config.yaml"), + []byte("agents:\n - harness/other.yaml\nallowed_remote_resources:\n - \"https://example.com/\"\n"), + 0o644, + )) + + rFlags := resolveFlags{maxDepth: 10, maxResources: 50} + printer := ui.New(io.Discard) + repoDir := t.TempDir() + err := runAgent(context.Background(), "code", dir, "", repoDir, "", nil, false, "", "", rFlags, statusOpts{}, printer, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "openshell") +} + +func TestRunAgent_UnknownAgentName(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755)) + + // Config has agents but "nonexistent" is not among them, and no file on disk. + require.NoError(t, os.WriteFile( + filepath.Join(dir, "config.yaml"), + []byte("agents:\n - harness/other.yaml\nallowed_remote_resources:\n - \"https://example.com/\"\n"), + 0o644, + )) + + rFlags := resolveFlags{maxDepth: 10, maxResources: 50} + printer := ui.New(io.Discard) + repoDir := t.TempDir() + err := runAgent(context.Background(), "nonexistent", dir, "", repoDir, "", nil, false, "", "", rFlags, statusOpts{}, printer, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "harness file not found") +} + +func TestResolveAgentSource_NoConfig(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "harness", "code.yaml"), + []byte("agent: agents/code.md\nrole: test\n"), + 0o644, + )) + + printer := ui.New(io.Discard) + path, deps, err := resolveAgentSource(context.Background(), dir, "code", nil, harness.ComposeOpts{}, printer) + require.NoError(t, err) + assert.Contains(t, path, "code.yaml") + assert.Empty(t, deps) +} + +func TestResolveAgentSource_ConfigLocalPath(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "harness", "custom.yaml"), + []byte("agent: agents/custom.md\nrole: test\n"), + 0o644, + )) + + orgCfg := &config.OrgConfig{ + Agents: []config.AgentEntry{ + {Source: "harness/custom.yaml"}, + }, + AllowedRemoteResources: []string{"https://example.com/"}, + } + + printer := ui.New(io.Discard) + path, deps, err := resolveAgentSource(context.Background(), dir, "custom", orgCfg, harness.ComposeOpts{}, printer) + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "harness", "custom.yaml"), path) + assert.Empty(t, deps) +} + +func TestResolveAgentSource_ConfigLocalPathNotFound(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755)) + + orgCfg := &config.OrgConfig{ + Agents: []config.AgentEntry{ + {Source: "harness/missing.yaml"}, + }, + AllowedRemoteResources: []string{"https://example.com/"}, + } + + printer := ui.New(io.Discard) + _, _, err := resolveAgentSource(context.Background(), dir, "missing", orgCfg, harness.ComposeOpts{}, printer) + require.Error(t, err) + assert.Contains(t, err.Error(), "config agent missing") +} + +func TestResolveAgentSource_ConfigLocalPathAbsoluteRejected(t *testing.T) { + dir := t.TempDir() + + orgCfg := &config.OrgConfig{ + Agents: []config.AgentEntry{ + {Source: "/etc/evil.yaml"}, + }, + AllowedRemoteResources: []string{"https://example.com/"}, + } + + printer := ui.New(io.Discard) + _, _, err := resolveAgentSource(context.Background(), dir, "evil", orgCfg, harness.ComposeOpts{}, printer) + require.Error(t, err) + assert.Contains(t, err.Error(), "absolute paths") +} + +func TestResolveAgentSource_ConfigLocalPathTraversalRejected(t *testing.T) { + dir := t.TempDir() + + orgCfg := &config.OrgConfig{ + Agents: []config.AgentEntry{ + {Source: "harness/../../etc/passwd"}, + }, + AllowedRemoteResources: []string{"https://example.com/"}, + } + + printer := ui.New(io.Discard) + _, _, err := resolveAgentSource(context.Background(), dir, "passwd", orgCfg, harness.ComposeOpts{}, printer) + require.Error(t, err) + assert.Contains(t, err.Error(), "path traversal") +} + +func TestContainedLocalPath_Valid(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "harness", "agent.yaml"), []byte("test"), 0o644)) + got, err := containedLocalPath(dir, "harness/agent.yaml") + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "harness", "agent.yaml"), got) +} + +func TestContainedLocalPath_AbsoluteRejected(t *testing.T) { + dir := t.TempDir() + _, err := containedLocalPath(dir, "/etc/evil.yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "must be relative, not absolute") +} + +func TestContainedLocalPath_TraversalRejected(t *testing.T) { + dir := t.TempDir() + _, err := containedLocalPath(dir, "harness/../../etc/passwd") + require.Error(t, err) + assert.Contains(t, err.Error(), "escapes fullsend directory") +} + +func TestContainedLocalPath_DotSegmentsCleaned(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "harness", "agent.yaml"), []byte("test"), 0o644)) + got, err := containedLocalPath(dir, "harness/./agent.yaml") + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "harness", "agent.yaml"), got) +} + +func TestContainedLocalPath_SymlinkEscapeRejected(t *testing.T) { + dir := t.TempDir() + outside := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(outside, "evil.yaml"), []byte("pwned"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755)) + require.NoError(t, os.Symlink(filepath.Join(outside, "evil.yaml"), filepath.Join(dir, "harness", "evil.yaml"))) + _, err := containedLocalPath(dir, "harness/evil.yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "escapes fullsend directory via symlink") +} + func TestApplySandboxImageOverride_Applied(t *testing.T) { t.Setenv("FULLSEND_SANDBOX_IMAGE", "ghcr.io/fullsend-ai/fullsend-sandbox:dev") diff --git a/internal/config/agents.go b/internal/config/agents.go new file mode 100644 index 000000000..6e70aeb7f --- /dev/null +++ b/internal/config/agents.go @@ -0,0 +1,79 @@ +package config + +import ( + "sort" + "strings" +) + +// MergedAgent represents an agent in the merged set produced by combining +// scaffold-discovered agents with config-registered agents. Config entries +// override scaffold entries with the same name (case-insensitive). +type MergedAgent struct { + Name string // canonical agent name + Source string // URL (with #sha256=), local path, or scaffold URL + IsConfig bool // true = from config agents list; false = scaffold default +} + +// MergedAgents builds the merged agent set from scaffold defaults and config +// overlay. Scaffold entries are constructed from scaffoldNames using builder +// (typically scaffold.HarnessBaseURLWithHash); config entries overlay by +// DerivedName, replacing scaffold entries with matching names +// (case-insensitive). The result is sorted by Name. +// +// When builder is nil or commitSHA is empty, scaffold entries are omitted +// (config-only mode). This mirrors the DefaultAgentEntries nil-builder +// pattern. +func MergedAgents(scaffoldNames []string, commitSHA string, configAgents []AgentEntry, builder AgentEntryBuilder) ([]MergedAgent, error) { + byName := make(map[string]*MergedAgent) + var order []string + + if builder != nil && commitSHA != "" { + for _, name := range scaffoldNames { + url, err := builder(name, commitSHA) + if err != nil { + return nil, err + } + lower := strings.ToLower(name) + byName[lower] = &MergedAgent{ + Name: name, + Source: url, + } + order = append(order, lower) + } + } + + for _, entry := range configAgents { + name := entry.DerivedName() + lower := strings.ToLower(name) + if _, exists := byName[lower]; !exists { + order = append(order, lower) + } + byName[lower] = &MergedAgent{ + Name: name, + Source: entry.Source, + IsConfig: true, + } + } + + result := make([]MergedAgent, 0, len(byName)) + for _, key := range order { + result = append(result, *byName[key]) + } + sort.Slice(result, func(i, j int) bool { + return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name) + }) + + return result, nil +} + +// LookupMergedAgent finds an agent by name (case-insensitive) in the merged set. +// Returns nil if not found. +func LookupMergedAgent(agents []MergedAgent, name string) *MergedAgent { + lower := strings.ToLower(name) + for i := range agents { + if strings.ToLower(agents[i].Name) == lower { + return &agents[i] + } + } + return nil +} diff --git a/internal/config/agents_test.go b/internal/config/agents_test.go new file mode 100644 index 000000000..074b5b55e --- /dev/null +++ b/internal/config/agents_test.go @@ -0,0 +1,162 @@ +package config + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func fakeBuilder(name, sha string) (string, error) { + return fmt.Sprintf("https://example.com/%s/%s.yaml#sha256=aaaa", sha, name), nil +} + +func TestMergedAgents_ScaffoldOnly(t *testing.T) { + result, err := MergedAgents([]string{"code", "triage"}, "abc123def456abc123def456abc123def456abc1", nil, fakeBuilder) + require.NoError(t, err) + require.Len(t, result, 2) + assert.Equal(t, "code", result[0].Name) + assert.False(t, result[0].IsConfig) + assert.Equal(t, "triage", result[1].Name) + assert.False(t, result[1].IsConfig) +} + +func TestMergedAgents_ConfigOnly(t *testing.T) { + agents := []AgentEntry{ + {Source: "harness/custom.yaml"}, + {Source: "https://example.com/lint.yaml#sha256=bbbb"}, + } + result, err := MergedAgents(nil, "", agents, nil) + require.NoError(t, err) + require.Len(t, result, 2) + assert.Equal(t, "custom", result[0].Name) + assert.True(t, result[0].IsConfig) + assert.Equal(t, "lint", result[1].Name) + assert.True(t, result[1].IsConfig) +} + +func TestMergedAgents_ConfigOverridesScaffold(t *testing.T) { + agents := []AgentEntry{ + {Source: "https://external.com/triage.yaml#sha256=cccc"}, + } + result, err := MergedAgents([]string{"code", "triage"}, "abc123def456abc123def456abc123def456abc1", agents, fakeBuilder) + require.NoError(t, err) + require.Len(t, result, 2) + + assert.Equal(t, "code", result[0].Name) + assert.False(t, result[0].IsConfig) + + assert.Equal(t, "triage", result[1].Name) + assert.True(t, result[1].IsConfig) + assert.Equal(t, "https://external.com/triage.yaml#sha256=cccc", result[1].Source) +} + +func TestMergedAgents_ConfigAppendsNewAgent(t *testing.T) { + agents := []AgentEntry{ + {Source: "harness/lint.yaml"}, + } + result, err := MergedAgents([]string{"code"}, "abc123def456abc123def456abc123def456abc1", agents, fakeBuilder) + require.NoError(t, err) + require.Len(t, result, 2) + assert.Equal(t, "code", result[0].Name) + assert.Equal(t, "lint", result[1].Name) + assert.True(t, result[1].IsConfig) +} + +func TestMergedAgents_BothEmpty(t *testing.T) { + result, err := MergedAgents(nil, "", nil, nil) + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestMergedAgents_CaseInsensitiveOverride(t *testing.T) { + agents := []AgentEntry{ + {Name: "Code", Source: "https://example.com/code.yaml#sha256=dddd"}, + } + result, err := MergedAgents([]string{"code"}, "abc123def456abc123def456abc123def456abc1", agents, fakeBuilder) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, "Code", result[0].Name) + assert.True(t, result[0].IsConfig) +} + +func TestMergedAgents_SortedByName(t *testing.T) { + agents := []AgentEntry{ + {Source: "harness/zebra.yaml"}, + {Source: "harness/alpha.yaml"}, + } + result, err := MergedAgents([]string{"middle"}, "abc123def456abc123def456abc123def456abc1", agents, fakeBuilder) + require.NoError(t, err) + require.Len(t, result, 3) + assert.Equal(t, "alpha", result[0].Name) + assert.Equal(t, "middle", result[1].Name) + assert.Equal(t, "zebra", result[2].Name) +} + +func TestMergedAgents_NilBuilder(t *testing.T) { + agents := []AgentEntry{ + {Source: "harness/custom.yaml"}, + } + result, err := MergedAgents([]string{"code", "triage"}, "abc123def456abc123def456abc123def456abc1", agents, nil) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, "custom", result[0].Name) +} + +func TestMergedAgents_EmptyCommitSHA(t *testing.T) { + agents := []AgentEntry{ + {Source: "harness/custom.yaml"}, + } + result, err := MergedAgents([]string{"code"}, "", agents, fakeBuilder) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, "custom", result[0].Name) +} + +func TestMergedAgents_ExplicitName(t *testing.T) { + agents := []AgentEntry{ + {Name: "my-linter", Source: "harness/lint.yaml"}, + } + result, err := MergedAgents(nil, "", agents, nil) + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, "my-linter", result[0].Name) + assert.Equal(t, "harness/lint.yaml", result[0].Source) +} + +func TestMergedAgents_BuilderError(t *testing.T) { + failBuilder := func(name, sha string) (string, error) { + return "", fmt.Errorf("build failed for %s", name) + } + _, err := MergedAgents([]string{"code"}, "abc123def456abc123def456abc123def456abc1", nil, failBuilder) + require.Error(t, err) + assert.Contains(t, err.Error(), "build failed for code") +} + +func TestLookupMergedAgent_Found(t *testing.T) { + agents := []MergedAgent{ + {Name: "code", Source: "url1"}, + {Name: "triage", Source: "url2"}, + } + found := LookupMergedAgent(agents, "triage") + require.NotNil(t, found) + assert.Equal(t, "triage", found.Name) +} + +func TestLookupMergedAgent_CaseInsensitive(t *testing.T) { + agents := []MergedAgent{ + {Name: "Code", Source: "url1"}, + } + found := LookupMergedAgent(agents, "code") + require.NotNil(t, found) + assert.Equal(t, "Code", found.Name) +} + +func TestLookupMergedAgent_NotFound(t *testing.T) { + agents := []MergedAgent{ + {Name: "code", Source: "url1"}, + } + found := LookupMergedAgent(agents, "missing") + assert.Nil(t, found) +} diff --git a/internal/config/config.go b/internal/config/config.go index 6a4479995..62723da4a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -298,7 +298,7 @@ func (c *OrgConfig) Validate() error { if err := validateStatusNotifications(c.Defaults.StatusNotifications); err != nil { return err } - if err := validateAgentEntries(c.Agents, c.AllowedRemoteResources); err != nil { + if err := ValidateAgentEntries(c.Agents, c.AllowedRemoteResources); err != nil { return err } if err := validateCreateIssues(c.CreateIssues); err != nil { @@ -307,12 +307,12 @@ func (c *OrgConfig) Validate() error { return nil } -// validateAgentEntries checks agent entries for structural correctness. +// ValidateAgentEntries checks agent entries for structural correctness. // Uses urlutil.IsURL, urlutil.ParseIntegrityHash, and // urlutil.MatchingAllowedPrefixInList for consistency with runtime // resolution (case-insensitive scheme, percent-decoding, dot-segment // cleaning). -func validateAgentEntries(agents []AgentEntry, allowlist []string) error { +func ValidateAgentEntries(agents []AgentEntry, allowlist []string) error { seen := make(map[string]bool, len(agents)) for i, entry := range agents { if entry.Source == "" { @@ -474,7 +474,7 @@ func (c *PerRepoConfig) Validate() error { } seen[role] = true } - if err := validateAgentEntries(c.Agents, c.AllowedRemoteResources); err != nil { + if err := ValidateAgentEntries(c.Agents, c.AllowedRemoteResources); err != nil { return err } if err := validateCreateIssues(c.CreateIssues); err != nil { diff --git a/internal/harness/compose.go b/internal/harness/compose.go index 6c11a6b6b..ecd2aa0b7 100644 --- a/internal/harness/compose.go +++ b/internal/harness/compose.go @@ -1116,3 +1116,15 @@ func mergeForgeConfigInto(base, child *ForgeConfig) { child.ValidationLoop = base.ValidationLoop } } + +// FetchAgentHarness fetches a URL-sourced agent harness using the same +// infrastructure as base composition (ADR 0038). It downloads the content, +// verifies the integrity hash, caches it, and returns the local cache path +// so the caller can pass it to LoadWithBase. +func FetchAgentHarness(ctx context.Context, rawURL string, opts ComposeOpts) (localPath string, dep Dependency, err error) { + dep, _, err = fetchBaseURL(ctx, rawURL, opts.OrgAllowlist, opts) + if err != nil { + return "", Dependency{}, fmt.Errorf("fetching agent harness %s: %w", rawURL, err) + } + return dep.LocalPath, dep, nil +} diff --git a/internal/layers/harnesswrappers.go b/internal/layers/harnesswrappers.go index ce82053b6..5c92af421 100644 --- a/internal/layers/harnesswrappers.go +++ b/internal/layers/harnesswrappers.go @@ -15,25 +15,34 @@ const wrapperHeader = "# This file is managed by fullsend. Do not edit it direct // 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. +// override fields. Config-driven agents (registered via the agents list in +// config.yaml) are skipped — they are resolved at runtime by fullsend run. type HarnessWrappersLayer struct { - org string - client forge.Client - ui *ui.Printer - agents []AgentCredentials - commitSHA string + org string + client forge.Client + ui *ui.Printer + agents []AgentCredentials + commitSHA string + configAgentNames map[string]bool } var _ Layer = (*HarnessWrappersLayer)(nil) -// NewHarnessWrappersLayer creates a new HarnessWrappersLayer. -func NewHarnessWrappersLayer(org string, client forge.Client, printer *ui.Printer, agents []AgentCredentials, commitSHA string) *HarnessWrappersLayer { +// NewHarnessWrappersLayer creates a new HarnessWrappersLayer. configAgentNames +// lists agent names registered in config; wrappers are not generated for these +// because fullsend run resolves them at runtime from config sources. +func NewHarnessWrappersLayer(org string, client forge.Client, printer *ui.Printer, agents []AgentCredentials, commitSHA string, configAgentNames []string) *HarnessWrappersLayer { + nameSet := make(map[string]bool, len(configAgentNames)) + for _, n := range configAgentNames { + nameSet[strings.ToLower(n)] = true + } return &HarnessWrappersLayer{ - org: org, - client: client, - ui: printer, - agents: agents, - commitSHA: commitSHA, + org: org, + client: client, + ui: printer, + agents: agents, + commitSHA: commitSHA, + configAgentNames: nameSet, } } @@ -93,6 +102,11 @@ func (l *HarnessWrappersLayer) Install(ctx context.Context) error { } seen[path] = true + if l.configAgentNames[strings.ToLower(name)] { + l.ui.StepDone(fmt.Sprintf("Skipping %s (config-driven agent)", name)) + continue + } + 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)) @@ -169,6 +183,9 @@ func (l *HarnessWrappersLayer) Analyze(ctx context.Context) (*LayerReport, error continue } seen[path] = true + if l.configAgentNames[strings.ToLower(name)] { + continue + } _, err := l.client.GetFileContent(ctx, l.org, forge.ConfigRepoName, path) if err != nil { if forge.IsNotFound(err) { diff --git a/internal/layers/harnesswrappers_test.go b/internal/layers/harnesswrappers_test.go index fb9750df6..4a5746fe0 100644 --- a/internal/layers/harnesswrappers_test.go +++ b/internal/layers/harnesswrappers_test.go @@ -35,12 +35,12 @@ func testAgents() []AgentCredentials { } func TestHarnessWrappersLayer_Name(t *testing.T) { - layer := NewHarnessWrappersLayer("org", nil, testPrinter(), nil, "dev") + layer := NewHarnessWrappersLayer("org", nil, testPrinter(), nil, "dev", nil) assert.Equal(t, "harness-wrappers", layer.Name()) } func TestHarnessWrappersLayer_RequiredScopes(t *testing.T) { - layer := NewHarnessWrappersLayer("org", nil, testPrinter(), nil, "dev") + layer := NewHarnessWrappersLayer("org", nil, testPrinter(), nil, "dev", nil) assert.Equal(t, []string{"repo"}, layer.RequiredScopes(OpInstall)) assert.Equal(t, []string{"repo"}, layer.RequiredScopes(OpAnalyze)) assert.Nil(t, layer.RequiredScopes(OpUninstall)) @@ -48,7 +48,7 @@ func TestHarnessWrappersLayer_RequiredScopes(t *testing.T) { func TestHarnessWrappersLayer_Install_DevBuild(t *testing.T) { client := forge.NewFakeClient() - layer := NewHarnessWrappersLayer("org", client, testPrinter(), testAgents(), "dev") + layer := NewHarnessWrappersLayer("org", client, testPrinter(), testAgents(), "dev", nil) err := layer.Install(context.Background()) require.NoError(t, err) @@ -57,7 +57,7 @@ func TestHarnessWrappersLayer_Install_DevBuild(t *testing.T) { func TestHarnessWrappersLayer_Install_EmptyCommitSHA(t *testing.T) { client := forge.NewFakeClient() - layer := NewHarnessWrappersLayer("org", client, testPrinter(), testAgents(), "") + layer := NewHarnessWrappersLayer("org", client, testPrinter(), testAgents(), "", nil) err := layer.Install(context.Background()) require.NoError(t, err) @@ -67,7 +67,7 @@ func TestHarnessWrappersLayer_Install_EmptyCommitSHA(t *testing.T) { 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) + layer := NewHarnessWrappersLayer("org", client, testPrinter(), testAgents(), testCommitSHA, nil) err := layer.Install(context.Background()) require.NoError(t, err) @@ -120,7 +120,7 @@ func TestHarnessWrappersLayer_Install_WrapperContainsManagedHeader(t *testing.T) agents := []AgentCredentials{ {Role: "triage", Name: "t", Slug: "test-triage"}, } - layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA, nil) err := layer.Install(context.Background()) require.NoError(t, err) @@ -137,7 +137,7 @@ func TestHarnessWrappersLayer_Install_WrapperContainsIntegrityHash(t *testing.T) agents := []AgentCredentials{ {Role: "triage", Name: "t", Slug: "test-triage"}, } - layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA, nil) err := layer.Install(context.Background()) require.NoError(t, err) @@ -154,7 +154,7 @@ func TestHarnessWrappersLayer_Install_SkipsCustomizedFile(t *testing.T) { agents := []AgentCredentials{ {Role: "triage", Name: "t", Slug: "test-triage"}, } - layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA, nil) err := layer.Install(context.Background()) require.NoError(t, err) @@ -171,7 +171,7 @@ func TestHarnessWrappersLayer_Install_OverwritesManagedFile(t *testing.T) { agents := []AgentCredentials{ {Role: "triage", Name: "t", Slug: "test-triage"}, } - layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA, nil) err := layer.Install(context.Background()) require.NoError(t, err) @@ -189,7 +189,7 @@ func TestHarnessWrappersLayer_Install_CommitFilesError(t *testing.T) { agents := []AgentCredentials{ {Role: "triage", Name: "t", Slug: "test-triage"}, } - layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA, nil) err := layer.Install(context.Background()) require.Error(t, err) @@ -198,7 +198,7 @@ func TestHarnessWrappersLayer_Install_CommitFilesError(t *testing.T) { func TestHarnessWrappersLayer_Install_NoAgentsNoCommit(t *testing.T) { client := forge.NewFakeClient() - layer := NewHarnessWrappersLayer("org", client, testPrinter(), nil, testCommitSHA) + layer := NewHarnessWrappersLayer("org", client, testPrinter(), nil, testCommitSHA, nil) err := layer.Install(context.Background()) require.NoError(t, err) @@ -210,7 +210,7 @@ func TestHarnessWrappersLayer_Install_OnlyFullsendRoleNoCommit(t *testing.T) { agents := []AgentCredentials{ {Role: "fullsend", Name: "fs", Slug: "test-fullsend"}, } - layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA, nil) err := layer.Install(context.Background()) require.NoError(t, err) @@ -222,7 +222,7 @@ func TestHarnessWrappersLayer_Install_WrapperParsesAsValidHarness(t *testing.T) agents := []AgentCredentials{ {Role: "triage", Name: "t", Slug: "test-triage"}, } - layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA, nil) err := layer.Install(context.Background()) require.NoError(t, err) @@ -247,7 +247,7 @@ func TestHarnessWrappersLayer_Install_BaseURLMatchesScaffold(t *testing.T) { agents := []AgentCredentials{ {Role: "triage", Name: "t", Slug: "test-triage"}, } - layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA, nil) err := layer.Install(context.Background()) require.NoError(t, err) @@ -260,13 +260,13 @@ func TestHarnessWrappersLayer_Install_BaseURLMatchesScaffold(t *testing.T) { } func TestHarnessWrappersLayer_Uninstall_NoOp(t *testing.T) { - layer := NewHarnessWrappersLayer("org", nil, testPrinter(), nil, "dev") + layer := NewHarnessWrappersLayer("org", nil, testPrinter(), nil, "dev", nil) err := layer.Uninstall(context.Background()) require.NoError(t, err) } func TestHarnessWrappersLayer_Analyze_DevBuild(t *testing.T) { - layer := NewHarnessWrappersLayer("org", nil, testPrinter(), nil, "dev") + layer := NewHarnessWrappersLayer("org", nil, testPrinter(), nil, "dev", nil) report, err := layer.Analyze(context.Background()) require.NoError(t, err) assert.Equal(t, StatusNotInstalled, report.Status) @@ -280,7 +280,7 @@ func TestHarnessWrappersLayer_Analyze_AllPresent(t *testing.T) { } client.FileContents["org/.fullsend/harness/triage.yaml"] = []byte("role: triage\n") - layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA, nil) report, err := layer.Analyze(context.Background()) require.NoError(t, err) assert.Equal(t, StatusInstalled, report.Status) @@ -292,7 +292,7 @@ func TestHarnessWrappersLayer_Analyze_AllMissing(t *testing.T) { {Role: "triage", Name: "t", Slug: "test-triage"}, } - layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA, nil) report, err := layer.Analyze(context.Background()) require.NoError(t, err) assert.Equal(t, StatusNotInstalled, report.Status) @@ -309,7 +309,7 @@ func TestHarnessWrappersLayer_Analyze_Degraded(t *testing.T) { // Only triage exists client.FileContents["org/.fullsend/harness/triage.yaml"] = []byte("role: triage\n") - layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA, nil) report, err := layer.Analyze(context.Background()) require.NoError(t, err) assert.Equal(t, StatusDegraded, report.Status) @@ -319,7 +319,7 @@ func TestHarnessWrappersLayer_Analyze_Degraded(t *testing.T) { func TestHarnessWrappersLayer_Analyze_NoAgents(t *testing.T) { client := forge.NewFakeClient() - layer := NewHarnessWrappersLayer("org", client, testPrinter(), nil, testCommitSHA) + layer := NewHarnessWrappersLayer("org", client, testPrinter(), nil, testCommitSHA, nil) report, err := layer.Analyze(context.Background()) require.NoError(t, err) assert.Equal(t, StatusNotInstalled, report.Status) @@ -351,7 +351,7 @@ func TestHarnessWrappersLayer_Install_FileMode(t *testing.T) { agents := []AgentCredentials{ {Role: "triage", Name: "t", Slug: "test-triage"}, } - layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA, nil) err := layer.Install(context.Background()) require.NoError(t, err) @@ -368,7 +368,7 @@ func TestHarnessWrappersLayer_Install_CoderFixDedup(t *testing.T) { {Role: "coder", Name: "coder-a", Slug: "slug-a"}, {Role: "coder", Name: "coder-b", Slug: "slug-b"}, } - layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA, nil) err := layer.Install(context.Background()) require.NoError(t, err) @@ -390,7 +390,7 @@ func TestHarnessWrappersLayer_Install_LoadExistingHarnessesError(t *testing.T) { agents := []AgentCredentials{ {Role: "triage", Name: "t", Slug: "test-triage"}, } - layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA, nil) err := layer.Install(context.Background()) require.Error(t, err) @@ -404,9 +404,52 @@ func TestHarnessWrappersLayer_Install_IdempotentNoChange(t *testing.T) { agents := []AgentCredentials{ {Role: "triage", Name: "t", Slug: "test-triage"}, } - layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA) + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA, nil) err := layer.Install(context.Background()) require.NoError(t, err) // Should succeed without error even when tree is unchanged } + +func TestHarnessWrappersLayer_Install_SkipsConfigAgents(t *testing.T) { + client := forge.NewFakeClient() + client.Repos = []forge.Repository{{FullName: "org/.fullsend", DefaultBranch: "main"}} + agents := []AgentCredentials{ + {Role: "triage", Name: "test-triage", Slug: "test-triage"}, + {Role: "review", Name: "test-review", Slug: "test-review"}, + } + // "triage" is config-driven; wrapper should not be generated for it. + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA, []string{"triage"}) + + err := layer.Install(context.Background()) + require.NoError(t, err) + + require.Len(t, client.CommittedFiles, 1) + batch := client.CommittedFiles[0] + paths := make(map[string]bool) + for _, f := range batch.Files { + paths[f.Path] = true + } + assert.NotContains(t, paths, "harness/triage.yaml", "config-driven agent should be skipped") + assert.Contains(t, paths, "harness/review.yaml", "non-config agent should still get a wrapper") +} + +func TestHarnessWrappersLayer_Analyze_SkipsConfigAgents(t *testing.T) { + client := forge.NewFakeClient() + agents := []AgentCredentials{ + {Role: "triage", Name: "test-triage", Slug: "test-triage"}, + {Role: "review", Name: "test-review", Slug: "test-review"}, + } + // "triage" is config-driven; should not appear in analysis. + layer := NewHarnessWrappersLayer("org", client, testPrinter(), agents, testCommitSHA, []string{"triage"}) + + report, err := layer.Analyze(context.Background()) + require.NoError(t, err) + + for _, detail := range report.Details { + assert.NotContains(t, detail, "triage", "config-driven agent should be excluded from analysis") + } + for _, item := range report.WouldInstall { + assert.NotContains(t, item, "triage", "config-driven agent should be excluded from would-install") + } +}