From 072653961469697d0dbb0dd8867332906488d7d6 Mon Sep 17 00:00:00 2001 From: Greg Allen Date: Wed, 10 Jun 2026 18:14:49 -0400 Subject: [PATCH] feat(harness): wire ResolveForge into load pipeline (ADR-0045 PR 3/7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add LoadWithOpts and LoadRaw to the harness package, and --forge flag to both `fullsend run` and `fullsend lock` commands. LoadWithOpts runs the Unmarshal → validateForge → ResolveForge → Validate pipeline so forge-specific overrides are applied before validation. LoadRaw provides unmarshal-only loading for base composition and multi-forge lock discovery. The --forge flag accepts an explicit platform name; when omitted, the run command auto-detects from CI environment variables (GITHUB_ACTIONS, GITLAB_CI), while the lock command iterates all forge variants and locks the union of dependencies across platforms. Signed-off-by: Claude Opus 4.6 Signed-off-by: Greg Allen --- docs/guides/dev/cli-internals.md | 5 +- docs/guides/user/running-agents-locally.md | 5 + internal/cli/lock.go | 234 ++++++++++++++------- internal/cli/lock_test.go | 182 +++++++++++++++- internal/cli/run.go | 41 +++- internal/cli/run_test.go | 76 +++++++ internal/harness/forge.go | 31 ++- internal/harness/forge_test.go | 2 +- internal/harness/harness.go | 49 +++++ internal/harness/harness_test.go | 160 ++++++++++++++ 10 files changed, 693 insertions(+), 92 deletions(-) diff --git a/docs/guides/dev/cli-internals.md b/docs/guides/dev/cli-internals.md index c964086fc8..6cc4ee524c 100644 --- a/docs/guides/dev/cli-internals.md +++ b/docs/guides/dev/cli-internals.md @@ -33,6 +33,7 @@ fullsend │ └── sync-scaffold # Update workflow templates ├── lock # Pin remote deps to lock.yaml │ ├── --fullsend-dir # Base directory with .fullsend layout +│ ├── --forge # Lock only this forge variant; omit for all │ ├── --update # Force re-resolve even if current │ ├── --offline # Reject network fetches │ ├── --max-depth # Max transitive dependency depth @@ -42,6 +43,7 @@ fullsend │ ├── --target-repo # Path to the target repository │ ├── --output-dir # Base directory for run output │ ├── --env-file # Load env vars from dotenv file (repeatable) +│ ├── --forge # Forge platform (github, gitlab); auto-detected from CI env │ ├── --no-post-script # Skip post-script execution │ ├── --debug [filter] # Enable Claude Code debug logging │ ├── --offline # Reject network fetches @@ -261,7 +263,8 @@ Vendoring commit messages use title + body (upload and stale delete). `admin ana ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────┐ │ -│ │ Load harness │ Parse YAML config for agent │ +│ │ Load harness │ LoadWithOpts: unmarshal → validateForge → │ +│ │ │ ResolveForge(--forge / env) → Validate │ │ └──────┬──────┘ │ │ ▼ │ │ ┌──────────────────┐ │ diff --git a/docs/guides/user/running-agents-locally.md b/docs/guides/user/running-agents-locally.md index 52fcfcf306..12851c00fc 100644 --- a/docs/guides/user/running-agents-locally.md +++ b/docs/guides/user/running-agents-locally.md @@ -193,6 +193,7 @@ resolution limits: | Flag | Default | Description | |------|---------|-------------| +| `--forge` | (auto-detect) | Forge platform to use (`github`, `gitlab`). Auto-detected from CI env vars (`GITHUB_ACTIONS`, `GITLAB_CI`) when omitted | | `--max-depth` | 10 | Maximum dependency depth for transitive resolution (0 disables) | | `--max-resources` | 50 | Maximum total remote resources fetched per harness | | `--offline` | false | Reject network fetches; only use cached remote resources | @@ -207,6 +208,10 @@ generated. Generate or update a lock file with: fullsend lock code --fullsend-dir /path/to/.fullsend ``` +When `--forge` is specified, only that platform variant is locked. When omitted, +all forge variants defined in the harness are resolved and the union of their +dependencies is locked. + When the lock entry is current (harness SHA256 matches), dependencies are resolved from the local cache without network access. If the harness has changed or a cached artifact is missing, `fullsend run` falls back to normal network diff --git a/internal/cli/lock.go b/internal/cli/lock.go index fe706bc536..b6e1711632 100644 --- a/internal/cli/lock.go +++ b/internal/cli/lock.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "time" @@ -23,6 +24,7 @@ import ( func newLockCmd() *cobra.Command { var fullsendDir string var update bool + var forgeFlag string var rFlags resolveFlags cmd := &cobra.Command{ @@ -32,6 +34,10 @@ func newLockCmd() *cobra.Command { and SHA256 hashes in .fullsend/lock.yaml. Subsequent fullsend run invocations use the lock file to skip re-resolution when dependencies have not changed. +When --forge is specified, the named platform's forge overrides are applied +before locking. When --forge is omitted and the harness has a forge: section, +all forge variants are resolved and the union of dependencies is locked. + The lock file should be committed to version control so all environments use the same pinned dependencies.`, Args: cobra.ExactArgs(1), @@ -44,12 +50,13 @@ use the same pinned dependencies.`, } agentName := args[0] printer := ui.New(os.Stdout) - return runLock(cmd.Context(), agentName, fullsendDir, update, rFlags, printer) + return runLock(cmd.Context(), agentName, fullsendDir, forgeFlag, update, rFlags, printer) }, } cmd.Flags().StringVar(&fullsendDir, "fullsend-dir", "", "base directory containing the .fullsend layout") cmd.Flags().BoolVar(&update, "update", false, "force re-resolve even if lock entry is current") + cmd.Flags().StringVar(&forgeFlag, "forge", "", `forge platform to lock (e.g. "github"); omit to lock all forge variants`) cmd.Flags().BoolVar(&rFlags.offline, "offline", false, "reject network fetches; only use cached remote resources") cmd.Flags().IntVar(&rFlags.maxDepth, "max-depth", resolve.DefaultMaxDepth, "maximum dependency depth for transitive resolution (0 disables)") cmd.Flags().IntVar(&rFlags.maxResources, "max-resources", resolve.DefaultMaxResources, "maximum total remote resources per harness") @@ -58,7 +65,7 @@ use the same pinned dependencies.`, return cmd } -func runLock(ctx context.Context, agentName, fullsendDir string, update bool, rFlags resolveFlags, printer *ui.Printer) error { +func runLock(ctx context.Context, agentName, fullsendDir, forgeFlag string, update bool, rFlags resolveFlags, printer *ui.Printer) error { printer.Banner(Version()) printer.Header("Locking dependencies: " + agentName) printer.Blank() @@ -68,59 +75,32 @@ func runLock(ctx context.Context, agentName, fullsendDir string, update bool, rF return fmt.Errorf("resolving fullsend dir: %w", err) } - harnessPath := filepath.Join(absFullsendDir, "harness", agentName+".yaml") - h, err := harness.Load(harnessPath) - if err != nil { - printer.StepFail("Failed to load harness") - return fmt.Errorf("loading harness: %w", err) - } - - if err := h.ResolveRelativeTo(absFullsendDir); err != nil { - printer.StepFail("Path validation failed") - return fmt.Errorf("resolving paths: %w", err) + // Validate the --forge flag value if explicitly provided, but do NOT + // auto-detect from CI env vars. Auto-detection is appropriate for `run` + // (pick one platform), but `lock` without --forge means "lock all + // forge variants" — auto-detecting would silently lock only one. + if forgeFlag != "" && !harness.ValidForgePlatform(forgeFlag) { + return fmt.Errorf("--forge: %q is not a valid forge platform (valid: %s)", forgeFlag, harness.ForgeKeyList()) } - if !h.HasURLReferences() { - printer.StepDone("Harness has no remote dependencies — nothing to lock") - return nil - } - - // Load and validate org config for allowed_remote_resources. - orgConfigPath := filepath.Join(absFullsendDir, "config.yaml") - orgConfigData, err := os.ReadFile(orgConfigPath) - if err != nil { - printer.StepFail("Failed to load org config") - if os.IsNotExist(err) { - return fmt.Errorf("URL-referenced resources require an org-level config.yaml with allowed_remote_resources (expected at %s)", orgConfigPath) - } - return fmt.Errorf("reading org config: %w", err) - } - orgCfg, err := config.ParseOrgConfig(orgConfigData) - if err != nil { - printer.StepFail("Failed to parse org config") - return fmt.Errorf("parsing org config: %w", err) - } - if err := h.ValidateAllowedRemoteResources(orgCfg.AllowedRemoteResources); err != nil { - printer.StepFail("Remote resource allowlist validation failed") - return fmt.Errorf("validating allowed remote resources: %w", err) - } + harnessPath := filepath.Join(absFullsendDir, "harness", agentName+".yaml") + lockPath := filepath.Join(absFullsendDir, "lock.yaml") - // Compute harness source hash. + // Compute harness source hash and check staleness before doing any + // network resolution. This avoids resolving all forge variants when the + // lock entry is already current. harnessData, err := os.ReadFile(harnessPath) if err != nil { - return fmt.Errorf("reading harness file for hashing: %w", err) + return fmt.Errorf("reading harness file: %w", err) } harnessHash := fetch.ComputeSHA256(harnessData) - // Load existing lock file. - lockPath := filepath.Join(absFullsendDir, "lock.yaml") lf, err := lock.Load(lockPath) if err != nil { printer.StepWarn("Could not load existing lock file: " + err.Error()) lf = nil } - // Check if lock entry is already current. if !update && lf != nil { if entry := lf.Lookup(agentName); entry != nil && !entry.IsStale(harnessHash) { printer.StepDone(fmt.Sprintf("Lock entry for %s is up to date (%d dependencies)", agentName, len(entry.Dependencies))) @@ -128,45 +108,107 @@ func runLock(ctx context.Context, agentName, fullsendDir string, update bool, rF } } - // Resolve all dependencies. - printer.StepStart("Resolving dependencies") + // Determine which forge variants to lock. When --forge is specified, lock + // only that variant. When omitted, load the raw harness to discover all + // forge keys and lock each variant's URL set (union of dependencies). + forgePlatforms, err := lockForgePlatforms(harnessPath, forgeFlag) + if err != nil { + return err + } + + // Resolve each forge variant and collect the union of dependencies. + var allDeps []resolve.Dependency + seen := make(map[string]bool) + var orgCfg *config.OrgConfig - policy := fetch.DefaultPolicy - policy.Offline = rFlags.offline + for _, platform := range forgePlatforms { + h, loadErr := harness.LoadWithOpts(harnessPath, harness.LoadOpts{ + ForgePlatform: platform, + }) + if loadErr != nil { + printer.StepFail(fmt.Sprintf("Failed to load harness (forge: %s)", platform)) + return fmt.Errorf("loading harness for forge %q: %w", platform, loadErr) + } - var forgeClient forge.Client - if h.HasURLSkills() { - if rFlags.forgeClient != nil { - forgeClient = rFlags.forgeClient + if err := h.ResolveRelativeTo(absFullsendDir); err != nil { + printer.StepFail("Path validation failed") + return fmt.Errorf("resolving paths: %w", err) + } + + if !h.HasURLReferences() { + if platform != "" { + printer.StepInfo(fmt.Sprintf("Forge variant %q has no remote dependencies", platform)) + } + continue + } + + if orgCfg == nil { + var orgErr error + orgCfg, orgErr = loadOrgConfig(absFullsendDir, printer) + if orgErr != nil { + return orgErr + } + } + if err := h.ValidateAllowedRemoteResources(orgCfg.AllowedRemoteResources); err != nil { + printer.StepFail("Remote resource allowlist validation failed") + return fmt.Errorf("validating allowed remote resources: %w", err) + } + + if platform != "" { + printer.StepStart(fmt.Sprintf("Resolving dependencies (forge: %s)", platform)) } else { - token, err := resolveToken() - if err != nil { - printer.StepFail("Skill URLs require a GitHub token (set GH_TOKEN, GITHUB_TOKEN, or run 'gh auth login')") - return fmt.Errorf("skill URLs require a GitHub token: %w", err) + printer.StepStart("Resolving dependencies") + } + + policy := fetch.DefaultPolicy + policy.Offline = rFlags.offline + + var forgeClient forge.Client + if h.HasURLSkills() { + if rFlags.forgeClient != nil { + forgeClient = rFlags.forgeClient + } else { + token, tokenErr := resolveToken() + if tokenErr != nil { + printer.StepFail("Skill URLs require a GitHub token (set GH_TOKEN, GITHUB_TOKEN, or run 'gh auth login')") + return fmt.Errorf("skill URLs require a GitHub token: %w", tokenErr) + } + forgeClient = gh.New(token) } - forgeClient = gh.New(token) } - } - deps, err := resolve.ResolveHarness(ctx, h, resolve.ResolveOpts{ - WorkspaceRoot: absFullsendDir, - FetchPolicy: policy, - AuditLogPath: filepath.Join(absFullsendDir, ".fullsend-cache", "fetch-audit.jsonl"), - MaxDepth: rFlags.maxDepth, - MaxResources: rFlags.maxResources, - ForgeClient: forgeClient, - }) - if err != nil { - printer.StepFail("Resolution failed") - return fmt.Errorf("resolving remote resources: %w", err) + deps, resolveErr := resolve.ResolveHarness(ctx, h, resolve.ResolveOpts{ + WorkspaceRoot: absFullsendDir, + FetchPolicy: policy, + AuditLogPath: filepath.Join(absFullsendDir, ".fullsend-cache", "fetch-audit.jsonl"), + MaxDepth: rFlags.maxDepth, + MaxResources: rFlags.maxResources, + ForgeClient: forgeClient, + }) + if resolveErr != nil { + printer.StepFail("Resolution failed") + return fmt.Errorf("resolving remote resources: %w", resolveErr) + } + + for _, dep := range deps { + if !seen[dep.URL] { + seen[dep.URL] = true + allDeps = append(allDeps, dep) + } + } + + printer.StepDone(fmt.Sprintf("Resolved %d dependencies", len(deps))) } - printer.StepDone(fmt.Sprintf("Resolved %d dependencies", len(deps))) + if len(allDeps) == 0 { + printer.StepDone("Harness has no remote dependencies — nothing to lock") + return nil + } // Build lock entry from resolved deps. now := time.Now().UTC() - lockDeps := make([]lock.DependencyEntry, 0, len(deps)) - for _, dep := range deps { + lockDeps := make([]lock.DependencyEntry, 0, len(allDeps)) + for _, dep := range allDeps { entry := lock.DependencyEntry{ Field: dep.Field, URL: dep.URL, @@ -210,9 +252,9 @@ func runLock(ctx context.Context, agentName, fullsendDir string, update bool, rF printer.StepFail("Failed to write lock file") return fmt.Errorf("saving lock file: %w", err) } - printer.StepDone(fmt.Sprintf("Locked %d dependencies for %s -> %s", len(deps), agentName, lockPath)) + printer.StepDone(fmt.Sprintf("Locked %d dependencies for %s -> %s", len(allDeps), agentName, lockPath)) - for _, dep := range deps { + for _, dep := range allDeps { if dep.CacheHit { printer.StepInfo(fmt.Sprintf(" %s: %s (cached)", dep.Field, dep.URL)) } else { @@ -223,6 +265,56 @@ func runLock(ctx context.Context, agentName, fullsendDir string, update bool, rF return nil } +// lockForgePlatforms determines which forge platform(s) to lock. When a +// specific platform is requested, returns just that one. When empty, +// loads the raw harness to discover forge keys and returns all of them. +// If the harness has no forge section, returns a single empty string +// (lock the harness as-is). +func lockForgePlatforms(harnessPath, forgePlatform string) ([]string, error) { + if forgePlatform != "" { + return []string{forgePlatform}, nil + } + + h, err := harness.LoadRaw(harnessPath) + if err != nil { + return nil, fmt.Errorf("loading harness for forge discovery: %w", err) + } + + if len(h.Forge) == 0 { + return []string{""}, nil + } + + platforms := make([]string, 0, len(h.Forge)) + for key := range h.Forge { + if !harness.ValidForgePlatform(key) { + return nil, fmt.Errorf("forge: unrecognized key %q in harness (valid: %s)", key, harness.ForgeKeyList()) + } + platforms = append(platforms, key) + } + sort.Strings(platforms) + return platforms, nil +} + +// loadOrgConfig reads and parses the org config.yaml for remote resource +// validation. +func loadOrgConfig(absFullsendDir string, printer *ui.Printer) (*config.OrgConfig, error) { + orgConfigPath := filepath.Join(absFullsendDir, "config.yaml") + orgConfigData, err := os.ReadFile(orgConfigPath) + if err != nil { + printer.StepFail("Failed to load org config") + if os.IsNotExist(err) { + return nil, fmt.Errorf("URL-referenced resources require an org-level config.yaml with allowed_remote_resources (expected at %s)", orgConfigPath) + } + return nil, fmt.Errorf("reading org config: %w", err) + } + orgCfg, err := config.ParseOrgConfig(orgConfigData) + if err != nil { + printer.StepFail("Failed to parse org config") + return nil, fmt.Errorf("parsing org config: %w", err) + } + return orgCfg, nil +} + // resolveFromLock resolves harness dependencies using a lock file entry instead // of fetching from the network. For each pinned dependency, it verifies the // content exists in the local cache and replaces the harness URL field with the diff --git a/internal/cli/lock_test.go b/internal/cli/lock_test.go index 94eaefdbc0..525ba891e8 100644 --- a/internal/cli/lock_test.go +++ b/internal/cli/lock_test.go @@ -89,7 +89,7 @@ func TestRunLock_GeneratesLockFile(t *testing.T) { defer func() { fetch.DefaultPolicy = fetch.FetchPolicy{} }() printer := ui.New(os.Stdout) - err := runLock(context.Background(), "code", dir, false, resolveFlags{}, printer) + err := runLock(context.Background(), "code", dir, "", false, resolveFlags{}, printer) require.NoError(t, err) lockPath := filepath.Join(dir, "lock.yaml") @@ -168,7 +168,7 @@ allowed_remote_resources: defer func() { fetch.DefaultPolicy = fetch.FetchPolicy{} }() printer := ui.New(os.Stdout) - err := runLock(context.Background(), "code", dir, false, resolveFlags{forgeClient: fakeClient}, printer) + err := runLock(context.Background(), "code", dir, "", false, resolveFlags{forgeClient: fakeClient}, printer) require.NoError(t, err) lockPath := filepath.Join(dir, "lock.yaml") @@ -257,7 +257,7 @@ allowed_remote_resources: printer := ui.New(os.Stdout) // Step 1: Generate the lock file. - err := runLock(context.Background(), "code", dir, false, resolveFlags{forgeClient: fakeClient}, printer) + err := runLock(context.Background(), "code", dir, "", false, resolveFlags{forgeClient: fakeClient}, printer) require.NoError(t, err) lockPath := filepath.Join(dir, "lock.yaml") @@ -313,7 +313,7 @@ skills: )) printer := ui.New(os.Stdout) - err := runLock(context.Background(), "local", dir, false, resolveFlags{}, printer) + err := runLock(context.Background(), "local", dir, "", false, resolveFlags{}, printer) require.NoError(t, err) _, err = os.Stat(filepath.Join(dir, "lock.yaml")) @@ -339,10 +339,10 @@ func TestRunLock_AlreadyUpToDate(t *testing.T) { printer := ui.New(os.Stdout) // First lock. - require.NoError(t, runLock(context.Background(), "code", dir, false, resolveFlags{}, printer)) + require.NoError(t, runLock(context.Background(), "code", dir, "", false, resolveFlags{}, printer)) // Second lock without --update should detect it's current. - require.NoError(t, runLock(context.Background(), "code", dir, false, resolveFlags{}, printer)) + require.NoError(t, runLock(context.Background(), "code", dir, "", false, resolveFlags{}, printer)) // Verify lock file still exists and is valid. lf, err := lock.Load(filepath.Join(dir, "lock.yaml")) @@ -369,14 +369,14 @@ func TestRunLock_UpdateForceReResolve(t *testing.T) { printer := ui.New(os.Stdout) // First lock. - require.NoError(t, runLock(context.Background(), "code", dir, false, resolveFlags{}, printer)) + require.NoError(t, runLock(context.Background(), "code", dir, "", false, resolveFlags{}, printer)) lf1, _ := lock.Load(filepath.Join(dir, "lock.yaml")) entry1 := lf1.Lookup("code") resolvedAt1 := entry1.ResolvedAt // Second lock with --update should re-resolve. - require.NoError(t, runLock(context.Background(), "code", dir, true, resolveFlags{}, printer)) + require.NoError(t, runLock(context.Background(), "code", dir, "", true, resolveFlags{}, printer)) lf2, _ := lock.Load(filepath.Join(dir, "lock.yaml")) entry2 := lf2.Lookup("code") @@ -384,6 +384,172 @@ func TestRunLock_UpdateForceReResolve(t *testing.T) { assert.True(t, entry2.ResolvedAt.After(resolvedAt1) || entry2.ResolvedAt.Equal(resolvedAt1)) } +func TestRunLock_MultiForgeLockAllVariants(t *testing.T) { + agentContent := []byte("You are a coding agent.") + agentHash := fetch.ComputeSHA256(agentContent) + policyContent := []byte("sandbox: strict") + policyHash := fetch.ComputeSHA256(policyContent) + + srv, policy := newLockTestServer(t, map[string][]byte{ + "/agents/code.md": agentContent, + "/policies/sandbox.yaml": policyContent, + }) + + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755)) + + // Forge overrides use local skills (no URL validation needed) and the + // agent/policy URLs are shared. Each variant adds a different pre_script. + harnessContent := fmt.Sprintf(`agent: "%s/agents/code.md#sha256=%s" +policy: "%s/policies/sandbox.yaml#sha256=%s" +allowed_remote_resources: + - "%s/" +forge: + github: + pre_script: scripts/gh-pre.sh + gitlab: + pre_script: scripts/gl-pre.sh +`, srv.URL, agentHash, srv.URL, policyHash, srv.URL) + + require.NoError(t, os.WriteFile( + filepath.Join(dir, "harness", "multi.yaml"), + []byte(harnessContent), + 0o644, + )) + + orgConfig := fmt.Sprintf("allowed_remote_resources:\n - \"%s/\"\n", srv.URL) + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(orgConfig), 0o644)) + + fetch.DefaultPolicy = policy + defer func() { fetch.DefaultPolicy = fetch.FetchPolicy{} }() + + printer := ui.New(os.Stdout) + err := runLock(context.Background(), "multi", dir, "", false, resolveFlags{}, printer) + require.NoError(t, err) + + lf, err := lock.Load(filepath.Join(dir, "lock.yaml")) + require.NoError(t, err) + + entry := lf.Lookup("multi") + require.NotNil(t, entry) + + // Both variants share the same agent+policy URLs → 2 deps (deduped). + assert.Equal(t, 2, len(entry.Dependencies)) + + urls := make(map[string]bool) + for _, dep := range entry.Dependencies { + urls[dep.URL] = true + } + assert.True(t, urls[fmt.Sprintf("%s/agents/code.md", srv.URL)]) + assert.True(t, urls[fmt.Sprintf("%s/policies/sandbox.yaml", srv.URL)]) +} + +func TestRunLock_ForgeSelectsSingleVariant(t *testing.T) { + agentContent := []byte("You are a coding agent.") + agentHash := fetch.ComputeSHA256(agentContent) + policyContent := []byte("sandbox: strict") + policyHash := fetch.ComputeSHA256(policyContent) + + srv, policy := newLockTestServer(t, map[string][]byte{ + "/agents/code.md": agentContent, + "/policies/sandbox.yaml": policyContent, + }) + + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755)) + + harnessContent := fmt.Sprintf(`agent: "%s/agents/code.md#sha256=%s" +policy: "%s/policies/sandbox.yaml#sha256=%s" +allowed_remote_resources: + - "%s/" +forge: + github: + pre_script: scripts/gh-pre.sh + gitlab: + pre_script: scripts/gl-pre.sh +`, srv.URL, agentHash, srv.URL, policyHash, srv.URL) + + require.NoError(t, os.WriteFile( + filepath.Join(dir, "harness", "single.yaml"), + []byte(harnessContent), + 0o644, + )) + + orgConfig := fmt.Sprintf("allowed_remote_resources:\n - \"%s/\"\n", srv.URL) + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(orgConfig), 0o644)) + + fetch.DefaultPolicy = policy + defer func() { fetch.DefaultPolicy = fetch.FetchPolicy{} }() + + printer := ui.New(os.Stdout) + // Lock only the github variant — should still lock all URL deps. + err := runLock(context.Background(), "single", dir, "github", false, resolveFlags{}, printer) + require.NoError(t, err) + + lf, err := lock.Load(filepath.Join(dir, "lock.yaml")) + require.NoError(t, err) + + entry := lf.Lookup("single") + require.NotNil(t, entry) + + // Single variant still resolves agent+policy URLs. + assert.Equal(t, 2, len(entry.Dependencies)) +} + +func TestRunLock_ForgeDeduplicatesAcrossVariants(t *testing.T) { + agentContent := []byte("You are a coding agent.") + agentHash := fetch.ComputeSHA256(agentContent) + policyContent := []byte("sandbox: strict") + policyHash := fetch.ComputeSHA256(policyContent) + + srv, policy := newLockTestServer(t, map[string][]byte{ + "/agents/code.md": agentContent, + "/policies/sandbox.yaml": policyContent, + }) + + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755)) + + // Both forge variants share the same base agent+policy URLs. Each variant + // adds a different local pre_script. The lock should deduplicate the + // shared URLs across variants. + harnessContent := fmt.Sprintf(`agent: "%s/agents/code.md#sha256=%s" +policy: "%s/policies/sandbox.yaml#sha256=%s" +allowed_remote_resources: + - "%s/" +forge: + github: + pre_script: scripts/gh-pre.sh + gitlab: + pre_script: scripts/gl-pre.sh +`, srv.URL, agentHash, srv.URL, policyHash, srv.URL) + + require.NoError(t, os.WriteFile( + filepath.Join(dir, "harness", "dedup.yaml"), + []byte(harnessContent), + 0o644, + )) + + orgConfig := fmt.Sprintf("allowed_remote_resources:\n - \"%s/\"\n", srv.URL) + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(orgConfig), 0o644)) + + fetch.DefaultPolicy = policy + defer func() { fetch.DefaultPolicy = fetch.FetchPolicy{} }() + + printer := ui.New(os.Stdout) + err := runLock(context.Background(), "dedup", dir, "", false, resolveFlags{}, printer) + require.NoError(t, err) + + lf, err := lock.Load(filepath.Join(dir, "lock.yaml")) + require.NoError(t, err) + + entry := lf.Lookup("dedup") + require.NotNil(t, entry) + + // Agent + policy = 2 deps (deduped across both forge variants). + assert.Equal(t, 2, len(entry.Dependencies)) +} + func TestResolveFromLock_Success(t *testing.T) { agentContent := []byte("You are a coding agent.") agentHash := fetch.ComputeSHA256(agentContent) diff --git a/internal/cli/run.go b/internal/cli/run.go index 6cba7a97f2..589da480b1 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -74,6 +74,7 @@ func newRunCmd() *cobra.Command { var noPostScript bool var debugFilter string var keepSandbox bool + var forgeFlag string var rFlags resolveFlags var sOpts statusOpts @@ -85,7 +86,7 @@ func newRunCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { agentName := args[0] printer := ui.New(os.Stdout) - return runAgent(cmd.Context(), agentName, fullsendDir, outputBase, targetRepo, fullsendBinary, envFiles, noPostScript, debugFilter, rFlags, sOpts, printer, keepSandbox) + return runAgent(cmd.Context(), agentName, fullsendDir, outputBase, targetRepo, fullsendBinary, envFiles, noPostScript, debugFilter, forgeFlag, rFlags, sOpts, printer, keepSandbox) }, } @@ -98,6 +99,7 @@ func newRunCmd() *cobra.Command { cmd.Flags().BoolVar(&keepSandbox, "keep-sandbox", false, "skip sandbox deletion after the run (useful for post-failure inspection)") cmd.Flags().StringVar(&debugFilter, "debug", "", `enable Claude Code debug logging with optional category filter (e.g. "api,hooks")`) cmd.Flags().Lookup("debug").NoOptDefVal = "*" + cmd.Flags().StringVar(&forgeFlag, "forge", "", `forge platform to use (e.g. "github", "gitlab"); auto-detected from CI env vars when omitted`) cmd.Flags().BoolVar(&rFlags.offline, "offline", false, "reject network fetches; only use cached remote resources") cmd.Flags().IntVar(&rFlags.maxDepth, "max-depth", resolve.DefaultMaxDepth, "maximum dependency depth for transitive resolution (0 disables)") cmd.Flags().IntVar(&rFlags.maxResources, "max-resources", resolve.DefaultMaxResources, "maximum total remote resources per harness") @@ -111,7 +113,7 @@ func newRunCmd() *cobra.Command { return cmd } -func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRepo, fullsendBinary string, envFiles []string, noPostScript bool, debug string, rFlags resolveFlags, sOpts statusOpts, printer *ui.Printer, keepSandbox bool) (runErr error) { +func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRepo, fullsendBinary string, envFiles []string, noPostScript bool, debug string, forgeFlag string, rFlags resolveFlags, sOpts statusOpts, printer *ui.Printer, keepSandbox bool) (runErr error) { printer.Banner(Version()) printer.Blank() printer.Header("Running agent: " + agentName) @@ -136,7 +138,15 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep harnessStart := time.Now() printer.StepStart("Loading harness: " + harnessPath) - h, err := harness.Load(harnessPath) + forgePlatform, err := detectForgePlatform(forgeFlag) + if err != nil { + printer.StepFail("Invalid --forge flag") + return err + } + + h, err := harness.LoadWithOpts(harnessPath, harness.LoadOpts{ + ForgePlatform: forgePlatform, + }) if err != nil { printer.StepFail("Failed to load harness") return fmt.Errorf("loading harness: %w", err) @@ -294,6 +304,12 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep // Print plan. printer.KeyValue("Agent", h.Agent) + if h.Role != "" { + printer.KeyValue("Role", h.Role) + } + if h.Slug != "" { + printer.KeyValue("Slug", h.Slug) + } if h.Policy != "" { printer.KeyValue("Policy", h.Policy) } @@ -1659,6 +1675,25 @@ func sandboxArch() string { return runtime.GOARCH } +// detectForgePlatform determines the forge platform from the CLI flag or CI +// environment variables. Precedence: explicit flag > GITHUB_ACTIONS > GITLAB_CI. +// Returns an error if the flag value is not a recognized forge key. +func detectForgePlatform(flag string) (string, error) { + if flag != "" { + if !harness.ValidForgePlatform(flag) { + return "", fmt.Errorf("--forge: %q is not a valid forge platform (valid: %s)", flag, harness.ForgeKeyList()) + } + return flag, nil + } + if os.Getenv("GITHUB_ACTIONS") == "true" { + return "github", nil + } + if os.Getenv("GITLAB_CI") == "true" { + return "gitlab", nil + } + return "", nil +} + func titleCase(s string) string { words := strings.Fields(s) for i, w := range words { diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 8a91ad00f2..1bba7ab829 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -885,3 +885,79 @@ func TestPRHeadSHAFromEventPath_NoInputs(t *testing.T) { got := prHeadSHAFromEventPath(f) assert.Empty(t, got) } + +// --- detectForgePlatform tests --- + +func TestDetectForgePlatform_ExplicitFlag(t *testing.T) { + p, err := detectForgePlatform("github") + require.NoError(t, err) + assert.Equal(t, "github", p) + + p, err = detectForgePlatform("gitlab") + require.NoError(t, err) + assert.Equal(t, "gitlab", p) +} + +func TestDetectForgePlatform_InvalidFlag(t *testing.T) { + _, err := detectForgePlatform("bitbucket") + require.Error(t, err) + assert.Contains(t, err.Error(), "not a valid forge platform") +} + +func TestDetectForgePlatform_GitHubActions(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("GITLAB_CI", "") + + p, err := detectForgePlatform("") + require.NoError(t, err) + assert.Equal(t, "github", p) +} + +func TestDetectForgePlatform_GitLabCI(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "") + t.Setenv("GITLAB_CI", "true") + + p, err := detectForgePlatform("") + require.NoError(t, err) + assert.Equal(t, "gitlab", p) +} + +func TestDetectForgePlatform_NoEnv(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "") + t.Setenv("GITLAB_CI", "") + + p, err := detectForgePlatform("") + require.NoError(t, err) + assert.Equal(t, "", p) +} + +func TestDetectForgePlatform_FlagOverridesEnv(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "true") + + p, err := detectForgePlatform("gitlab") + require.NoError(t, err) + assert.Equal(t, "gitlab", p) +} + +func TestDetectForgePlatform_GitHubPrecedesGitLab(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("GITLAB_CI", "true") + + p, err := detectForgePlatform("") + require.NoError(t, err) + assert.Equal(t, "github", p) +} + +func TestRunCommand_HasForgeFlag(t *testing.T) { + cmd := newRunCmd() + flag := cmd.Flags().Lookup("forge") + require.NotNil(t, flag) + assert.Equal(t, "", flag.DefValue) +} + +func TestLockCommand_HasForgeFlag(t *testing.T) { + cmd := newLockCmd() + flag := cmd.Flags().Lookup("forge") + require.NotNil(t, flag) + assert.Equal(t, "", flag.DefValue) +} diff --git a/internal/harness/forge.go b/internal/harness/forge.go index af1cae832b..0cce45e65e 100644 --- a/internal/harness/forge.go +++ b/internal/harness/forge.go @@ -2,6 +2,7 @@ package harness import ( "fmt" + "sort" "strings" ) @@ -23,12 +24,27 @@ var validForgeKeys = map[string]bool{ "gitlab": true, } +// ValidForgePlatform reports whether platform is a recognized forge key. +func ValidForgePlatform(platform string) bool { + return validForgeKeys[platform] +} + +// ForgeKeyList returns a comma-separated list of valid forge platform keys. +func ForgeKeyList() string { + keys := make([]string, 0, len(validForgeKeys)) + for k := range validForgeKeys { + keys = append(keys, k) + } + sort.Strings(keys) + return strings.Join(keys, ", ") +} + // validateForge checks that the forge section contains only recognized keys // and that each ForgeConfig uses valid field values. func (h *Harness) validateForge() error { for key, fc := range h.Forge { if !validForgeKeys[key] { - return fmt.Errorf("forge: unrecognized key %q (valid keys are: github, gitlab)", key) + return fmt.Errorf("forge: unrecognized key %q (valid: %s)", key, ForgeKeyList()) } if fc == nil { continue @@ -63,18 +79,17 @@ func (h *Harness) validateForge() error { // h.Forge is nil, this is a no-op. If platform is not present in h.Forge, // an error is returned. // -// Pipeline ordering: ResolveForge runs between Unmarshal and Validate. It -// consumes h.Forge (sets it to nil), so validateForge — which validates the -// pre-merge forge map structure — must run before ResolveForge if both are -// called. In the planned pipeline (PR 3), LoadWithOpts calls ResolveForge -// then Validate; validateForge sees nil and is a no-op, which is correct -// because the forge map was already validated before merging. +// Pipeline ordering: LoadWithOpts calls validateForge → ResolveForge → +// Validate. validateForge must run first because ResolveForge consumes +// h.Forge (sets it to nil). After ResolveForge, Validate's validateForge +// call sees nil and is a no-op, which is correct because the forge map +// was already validated before merging. func (h *Harness) ResolveForge(platform string) error { if platform == "" || h.Forge == nil { return nil } if !validForgeKeys[platform] { - return fmt.Errorf("forge platform %q is not valid (valid keys are: github, gitlab)", platform) + return fmt.Errorf("forge platform %q is not valid (valid: %s)", platform, ForgeKeyList()) } fc, ok := h.Forge[platform] if !ok { diff --git a/internal/harness/forge_test.go b/internal/harness/forge_test.go index 5b92b5189f..b597272975 100644 --- a/internal/harness/forge_test.go +++ b/internal/harness/forge_test.go @@ -254,7 +254,7 @@ func TestValidate_ForgeUnrecognizedKey(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "unrecognized key") assert.Contains(t, err.Error(), "gihub") - assert.Contains(t, err.Error(), "valid keys are: github, gitlab") + assert.Contains(t, err.Error(), "valid: github, gitlab") } func TestValidate_ForgeScriptURL(t *testing.T) { diff --git a/internal/harness/harness.go b/internal/harness/harness.go index bf5686a171..2b94cc4002 100644 --- a/internal/harness/harness.go +++ b/internal/harness/harness.go @@ -239,6 +239,55 @@ func Load(path string) (*Harness, error) { return &h, nil } +// LoadOpts configures forge-aware harness loading. +type LoadOpts struct { + ForgePlatform string +} + +// LoadWithOpts reads a harness YAML file and applies forge resolution before +// validation. The pipeline is: Unmarshal → validateForge → ResolveForge → +// Validate. validateForge runs first to reject malformed forge maps before +// ResolveForge consumes (nils out) the map. When ForgePlatform is empty, +// ResolveForge is a no-op but validateForge still runs. +func LoadWithOpts(path string, opts LoadOpts) (*Harness, error) { + h, err := LoadRaw(path) + if err != nil { + return nil, err + } + + if err := h.validateForge(); err != nil { + return nil, fmt.Errorf("invalid harness: %w", err) + } + + if err := h.ResolveForge(opts.ForgePlatform); err != nil { + return nil, fmt.Errorf("resolving forge config: %w", err) + } + + if err := h.Validate(); err != nil { + return nil, fmt.Errorf("invalid harness: %w", err) + } + + return h, nil +} + +// LoadRaw reads and unmarshals a harness YAML file without calling Validate +// or ResolveForge. Used by base composition to load base harnesses without +// consuming their forge maps before merging, and by the lock command to +// discover forge keys without resolving them. +func LoadRaw(path string) (*Harness, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading harness file: %w", err) + } + + var h Harness + if err := yaml.Unmarshal(data, &h); err != nil { + return nil, fmt.Errorf("parsing harness YAML: %w", err) + } + + return &h, nil +} + // Validate checks that required fields are present. func (h *Harness) Validate() error { if h.Agent == "" { diff --git a/internal/harness/harness_test.go b/internal/harness/harness_test.go index 9c9d3d34ad..d65ea21372 100644 --- a/internal/harness/harness_test.go +++ b/internal/harness/harness_test.go @@ -1144,3 +1144,163 @@ func TestValidate_SlugInvalid(t *testing.T) { assert.Contains(t, err.Error(), "slug") } } + +// --- LoadRaw tests --- + +func TestLoadRaw_ReturnsUnvalidatedHarness(t *testing.T) { + // LoadRaw should not call Validate(), so a harness missing the required + // 'agent' field should load without error. + content := ` +skills: + - skills/a +` + dir := t.TempDir() + path := filepath.Join(dir, "test.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + h, err := LoadRaw(path) + require.NoError(t, err) + assert.Empty(t, h.Agent) + assert.Equal(t, []string{"skills/a"}, h.Skills) +} + +func TestLoadRaw_PreservesForgeMap(t *testing.T) { + content := ` +agent: agents/test.md +forge: + github: + pre_script: scripts/pre-gh.sh +` + dir := t.TempDir() + path := filepath.Join(dir, "test.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + h, err := LoadRaw(path) + require.NoError(t, err) + require.NotNil(t, h.Forge) + require.Contains(t, h.Forge, "github") + assert.Equal(t, "scripts/pre-gh.sh", h.Forge["github"].PreScript) +} + +func TestLoadRaw_FileNotFound(t *testing.T) { + _, err := LoadRaw("/nonexistent/harness.yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "reading harness file") +} + +// --- LoadWithOpts tests --- + +func TestLoadWithOpts_AppliesForgeResolution(t *testing.T) { + content := ` +agent: agents/test.md +pre_script: scripts/pre-common.sh +skills: + - skills/common +forge: + github: + pre_script: scripts/pre-gh.sh + skills: + - skills/gh-specific +` + dir := t.TempDir() + path := filepath.Join(dir, "test.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + h, err := LoadWithOpts(path, LoadOpts{ForgePlatform: "github"}) + require.NoError(t, err) + assert.Equal(t, "scripts/pre-gh.sh", h.PreScript) + assert.Equal(t, []string{"skills/common", "skills/gh-specific"}, h.Skills) + assert.Nil(t, h.Forge, "forge map should be consumed after ResolveForge") +} + +func TestLoadWithOpts_NoForge_SameAsLoad(t *testing.T) { + content := ` +agent: agents/test.md +pre_script: scripts/pre.sh +` + dir := t.TempDir() + path := filepath.Join(dir, "test.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + h, err := LoadWithOpts(path, LoadOpts{}) + require.NoError(t, err) + assert.Equal(t, "scripts/pre.sh", h.PreScript) +} + +func TestLoadWithOpts_EmptyPlatform_PreservesForge(t *testing.T) { + content := ` +agent: agents/test.md +forge: + github: + pre_script: scripts/pre-gh.sh +` + dir := t.TempDir() + path := filepath.Join(dir, "test.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + h, err := LoadWithOpts(path, LoadOpts{ForgePlatform: ""}) + require.NoError(t, err) + assert.NotNil(t, h.Forge, "forge map should be preserved when platform is empty") +} + +func TestLoadWithOpts_InvalidPlatform(t *testing.T) { + content := ` +agent: agents/test.md +forge: + github: + pre_script: scripts/pre-gh.sh +` + dir := t.TempDir() + path := filepath.Join(dir, "test.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + _, err := LoadWithOpts(path, LoadOpts{ForgePlatform: "bitbucket"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not valid") +} + +func TestLoadWithOpts_ValidationAfterForge(t *testing.T) { + // A harness with a forge override that produces valid state should pass. + // The validation_loop in the forge block replaces the top-level one. + content := ` +agent: agents/test.md +forge: + github: + validation_loop: + script: scripts/validate-gh.sh + max_iterations: 2 +` + dir := t.TempDir() + path := filepath.Join(dir, "test.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + h, err := LoadWithOpts(path, LoadOpts{ForgePlatform: "github"}) + require.NoError(t, err) + require.NotNil(t, h.ValidationLoop) + assert.Equal(t, "scripts/validate-gh.sh", h.ValidationLoop.Script) +} + +func TestLoadWithOpts_PlatformNotConfigured(t *testing.T) { + content := ` +agent: agents/test.md +forge: + github: + pre_script: scripts/pre-gh.sh +` + dir := t.TempDir() + path := filepath.Join(dir, "test.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + _, err := LoadWithOpts(path, LoadOpts{ForgePlatform: "gitlab"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not configured") +} + +// --- ValidForgePlatform tests --- + +func TestValidForgePlatform(t *testing.T) { + assert.True(t, ValidForgePlatform("github")) + assert.True(t, ValidForgePlatform("gitlab")) + assert.False(t, ValidForgePlatform("bitbucket")) + assert.False(t, ValidForgePlatform("")) +}