diff --git a/Makefile b/Makefile index 388b217..41155ae 100644 --- a/Makefile +++ b/Makefile @@ -66,8 +66,10 @@ test-suite-live: cli ./test/suite/run.sh --live ## Local gateway integration (unit tests run separately via 'make test') +## Builds sandbox image locally — no registry push needed for Podman. test-local: cli - ./test/test-flow.sh local-container + $(CONTAINER_CLI) build -t $(IMAGE) profiles/images/sandbox-default/ + HARNESS_OS_IMAGE=$(IMAGE) ./test/test-flow.sh local-container ## Kind: self-contained cluster lifecycle ## Builds sandbox image locally and pre-loads into kind (no registry push needed). @@ -77,8 +79,8 @@ test-kind: cli @echo "" HARNESS_OS_IMAGE=$(IMAGE) CONTAINER_CLI=$(CONTAINER_CLI) ./test/kind-lifecycle.sh $(if $(KEEP),--keep) -## Remote (OCP): requires KUBECONFIG set -test-remote: cli dev-sandbox +## Remote (OCP): requires KUBECONFIG set. Pushes image since the cluster pulls from registry. +test-remote: cli dev-push @test -n "$${KUBECONFIG}" || { echo "ERROR: Set KUBECONFIG for OCP (e.g. export KUBECONFIG=infracluster/kubeconfig)"; exit 1; } @echo "" HARNESS_OS_IMAGE=$(IMAGE) ./test/test-flow.sh openshift diff --git a/README.md b/README.md index 432ac59..cd0b5f1 100644 --- a/README.md +++ b/README.md @@ -23,17 +23,21 @@ harness apply -f harness.yaml --task @skills/cpp-pro/SKILL.md ### Clone a repo into the sandbox -The `repo` field clones a repository outside the sandbox and uploads it. Git credentials never enter the sandbox. +The `repo` field clones a repository outside the sandbox and uploads it. Git credentials never enter the sandbox unless needed. + +Use `base_agent` to inherit providers, env, and payloads from an existing config — you only specify what's different: ```yaml name: reviewer +base_agent: default repo: https://github.com/stackrox/collector -entrypoint: claude task: "identify the highest-priority C++ remediation" ``` +This inherits everything from `agent-default.yaml` (providers, inference routing, payloads) and adds the repo and task. Without `base_agent`, you'd need to specify the inference provider and env vars yourself. + ```bash -harness apply -f reviewer.yaml --task "focus on RAII and move semantics" +harness apply -f reviewer.yaml ``` ### Getting results out @@ -226,14 +230,9 @@ make test-remote # full e2e on OCP (needs KUBECONFIG) `test-kind` creates its own kind cluster, builds and loads the sandbox image, runs the full flow, and deletes the cluster on exit. Use `KEEP=1` to keep the cluster for debugging. -`test-remote` requires `KUBECONFIG` pointing at an OCP cluster. Use `--reuse-gateway` to skip deploy/teardown when iterating. +`test-remote` requires `KUBECONFIG` pointing at an OCP cluster and pushes the image automatically. Use `--reuse-gateway` to skip deploy/teardown when iterating. -Dev images must be pushed before integration tests will pass: - -```bash -make dev-push # build + push multi-arch sandbox image -make test-local # now sandbox create can pull the image -``` +Each integration target builds (and pushes, for remote) the sandbox image automatically. ## Documentation diff --git a/SPEC.md b/SPEC.md index 7f46025..ae8dce9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -37,10 +37,12 @@ env: Fields: - `name` (required) -- sandbox name, used for `openshell sandbox connect` +- `base_agent` -- name of a base agent config to inherit from (e.g., `default` resolves `agent-default.yaml`). Providers, env, and payloads are merged additively; scalar fields (entrypoint, gateway, repo, task, image, policy) from the overlay win when non-empty. - `image` -- container image for the sandbox (default: version-matched from ghcr.io, override with `HARNESS_OS_IMAGE` env) - `entrypoint` -- command to run (default: `claude`). Supports `claude`, `opencode`, `bash`, or any binary on PATH. - `tty` -- enable TTY (default: true) -- `repo` -- git URL to clone outside the sandbox and upload to `/sandbox/`. Shallow clone (`--depth 1`). Git credentials never enter the sandbox. +- `repo` -- git URL to clone outside the sandbox and upload to `/sandbox/`. Shallow clone (`--depth 1`) with submodules. Git credentials never enter the sandbox unless needed. +- `repo_ref` -- branch, tag, or ref to clone (default: HEAD). Passed as `--branch` to git clone. - `task` -- path to a task.md file, passed to entrypoint via `-p "$(cat task.md)"` - `providers` -- list of provider profile references - `providers[].profile` -- OpenShell provider profile name diff --git a/cmd/executor.go b/cmd/executor.go index bd3b3bc..6f099b8 100644 --- a/cmd/executor.go +++ b/cmd/executor.go @@ -75,10 +75,14 @@ func upLocal(opts upLocalOpts) error { registered := ensureProviders(opts.harnessDir, gw, agentCfg, opts.providerRefresh, opts.harness) + if needsInference(agentCfg.EffectiveEntrypoint()) && !hasInferenceProvider(agentCfg.Providers) { + status.Warn("No inference provider configured — the agent will not be able to authenticate. Add google-vertex-ai to providers.") + } + // Clone repo outside the sandbox so git credentials never enter it. var repoUpload *gateway.Upload if agentCfg.Repo != "" { - upload, cleanup, err := cloneRepo(agentCfg.Repo) + upload, cleanup, err := cloneRepo(agentCfg.Repo, agentCfg.RepoRef) if err != nil { return fmt.Errorf("cloning repo: %w", err) } @@ -166,7 +170,7 @@ func upLocal(opts upLocalOpts) error { // that places it at /sandbox/. The clone happens outside the sandbox // so git credentials never enter it. Returns a cleanup function that removes // the temp directory. -func cloneRepo(repo string) (gateway.Upload, func(), error) { +func cloneRepo(repo, ref string) (gateway.Upload, func(), error) { repoName := strings.TrimSuffix(path.Base(repo), ".git") tmpDir, err := os.MkdirTemp("", "harness-repo-") if err != nil { @@ -175,9 +179,19 @@ func cloneRepo(repo string) (gateway.Upload, func(), error) { cleanup := func() { os.RemoveAll(tmpDir) } cloneDir := filepath.Join(tmpDir, repoName) - status.Infof("Repo: %s", repo) + if ref != "" { + status.Infof("Repo: %s (ref: %s)", repo, ref) + } else { + status.Infof("Repo: %s", repo) + } - cmd := exec.Command("git", "clone", "--depth", "1", repo, cloneDir) + args := []string{"clone", "--depth", "1", "--recurse-submodules", "--shallow-submodules"} + if ref != "" { + args = append(args, "--branch", ref) + } + args = append(args, repo, cloneDir) + + cmd := exec.Command("git", args...) cmd.Stdout = os.Stderr cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { @@ -188,3 +202,24 @@ func cloneRepo(repo string) (gateway.Upload, func(), error) { status.OKf("Cloned %s", repoName) return gateway.Upload{Src: cloneDir, Dst: "/sandbox"}, cleanup, nil } + +var inferenceProviders = map[string]bool{ + "google-vertex-ai": true, +} + +func needsInference(entrypoint string) bool { + switch entrypoint { + case "claude", "opencode": + return true + } + return false +} + +func hasInferenceProvider(providers []agent.ProviderRef) bool { + for _, p := range providers { + if inferenceProviders[p.Profile] { + return true + } + } + return false +} diff --git a/cmd/resolve.go b/cmd/resolve.go index dc09c42..eb3122f 100644 --- a/cmd/resolve.go +++ b/cmd/resolve.go @@ -48,6 +48,12 @@ func resolveHarness(harnessDir, agentName, agentFile string) (*agent.Harness, er path := resolveAgentPath(harnessDir, agentName, agentFile) h, err := agent.ParseHarnessFile(path) if err == nil { + if h.Agent.BaseAgent != "" { + h, err = resolveBaseAgent(harnessDir, h) + if err != nil { + return nil, err + } + } return h, nil } if agentFile != "" || agentName != "default" || len(DefaultAgentConfig) == 0 { @@ -59,6 +65,36 @@ func resolveHarness(harnessDir, agentName, agentFile string) (*agent.Harness, er return agent.ParseHarness(DefaultAgentConfig) } +func resolveBaseAgent(harnessDir string, overlay *agent.Harness) (*agent.Harness, error) { + baseName := overlay.Agent.BaseAgent + basePath := resolveAgentPath(harnessDir, baseName, "") + baseH, err := agent.ParseHarnessFile(basePath) + if err != nil { + if len(DefaultAgentConfig) > 0 && baseName == "default" { + baseH, err = agent.ParseHarness(DefaultAgentConfig) + } + if err != nil { + return nil, fmt.Errorf("resolving base_agent %q: %w", baseName, err) + } + } + overlay.Agent = baseH.Agent.MergeOver(overlay.Agent) + // Merge base harness-level payloads, providers, gateways, policy + for name, data := range baseH.Providers { + if _, exists := overlay.Providers[name]; !exists { + overlay.Providers[name] = data + } + } + for name, data := range baseH.Gateways { + if _, exists := overlay.Gateways[name]; !exists { + overlay.Gateways[name] = data + } + } + if overlay.Policy == nil { + overlay.Policy = baseH.Policy + } + return overlay, nil +} + func resolveGatewayConfigWithHarness(harnessDir, name string, h *agent.Harness) (*gateway.GatewayConfig, error) { if h != nil { if data, ok := h.Gateways[name]; ok { diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 94115c6..579985b 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -24,8 +24,10 @@ type PayloadEntry struct { type AgentConfig struct { Name string `yaml:"name"` + BaseAgent string `yaml:"base_agent,omitempty"` Gateway string `yaml:"gateway,omitempty"` Repo string `yaml:"repo,omitempty"` + RepoRef string `yaml:"repo_ref,omitempty"` Providers []ProviderRef `yaml:"providers"` Env map[string]string `yaml:"env,omitempty"` Task string `yaml:"task,omitempty"` @@ -37,6 +39,77 @@ type AgentConfig struct { Payloads []PayloadEntry `yaml:"payloads,omitempty"` } +// MergeOver applies overlay fields on top of a base config. Non-empty +// overlay fields win. Providers and env are additive (overlay appends +// to base). The overlay's name always wins. +func (base *AgentConfig) MergeOver(overlay *AgentConfig) *AgentConfig { + merged := *base + merged.Name = overlay.Name + if overlay.Gateway != "" { + merged.Gateway = overlay.Gateway + } + if overlay.Repo != "" { + merged.Repo = overlay.Repo + } + if overlay.RepoRef != "" { + merged.RepoRef = overlay.RepoRef + } + if overlay.Entrypoint != "" { + merged.Entrypoint = overlay.Entrypoint + } + if overlay.Task != "" { + merged.Task = overlay.Task + } + if overlay.TTY != nil { + merged.TTY = overlay.TTY + } + if overlay.Policy != "" { + merged.Policy = overlay.Policy + } + if overlay.Image != "" { + merged.Image = overlay.Image + } + + // Providers: base + overlay (deduplicated by profile name) + if len(overlay.Providers) > 0 { + seen := make(map[string]bool) + var providers []ProviderRef + for _, p := range overlay.Providers { + seen[p.Profile] = true + providers = append(providers, p) + } + for _, p := range base.Providers { + if !seen[p.Profile] { + providers = append(providers, p) + } + } + merged.Providers = providers + } + + // Env: base + overlay (overlay wins on conflicts) + if len(overlay.Env) > 0 { + env := make(map[string]string) + for k, v := range base.Env { + env[k] = v + } + for k, v := range overlay.Env { + env[k] = v + } + merged.Env = env + } + + // Payloads and includes: append + if len(overlay.Payloads) > 0 { + merged.Payloads = append(merged.Payloads, overlay.Payloads...) + } + if len(overlay.Include) > 0 { + merged.Include = append(merged.Include, overlay.Include...) + } + + merged.BaseAgent = "" + return &merged +} + func (c *AgentConfig) NoTTY() bool { if c.TTY == nil { return false diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index ec2aa10..7ca58d5 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -752,3 +752,84 @@ func TestRenderPayload_IncludePathTraversal(t *testing.T) { t.Errorf("error = %q, want 'escapes base directory'", err) } } + +func TestMergeOver(t *testing.T) { + base := &AgentConfig{ + Name: "base", + Entrypoint: "claude", + Gateway: "local-container", + Providers: []ProviderRef{ + {Profile: "github"}, + {Profile: "google-vertex-ai"}, + }, + Env: map[string]string{ + "ANTHROPIC_BASE_URL": "https://inference.local", + "ANTHROPIC_API_KEY": "sk-ant-openshell-proxy-managed", + }, + Payloads: []PayloadEntry{ + {SandboxPath: "/sandbox/.claude/CLAUDE.md", Content: "base instructions"}, + }, + } + + overlay := &AgentConfig{ + Name: "reviewer", + Repo: "https://github.com/stackrox/collector", + Task: "identify the highest-priority C++ remediation", + Providers: []ProviderRef{ + {Profile: "atlassian", Env: map[string]string{"JIRA_URL": "https://issues.redhat.com"}}, + }, + Env: map[string]string{ + "CUSTOM_VAR": "value", + }, + } + + merged := base.MergeOver(overlay) + + if merged.Name != "reviewer" { + t.Errorf("Name = %q, want reviewer", merged.Name) + } + if merged.Entrypoint != "claude" { + t.Errorf("Entrypoint = %q, want claude (from base)", merged.Entrypoint) + } + if merged.Gateway != "local-container" { + t.Errorf("Gateway = %q, want local-container (from base)", merged.Gateway) + } + if merged.Repo != "https://github.com/stackrox/collector" { + t.Errorf("Repo = %q, want stackrox/collector", merged.Repo) + } + if merged.Task != "identify the highest-priority C++ remediation" { + t.Errorf("Task = %q, want overlay task", merged.Task) + } + + // Providers: overlay (atlassian) + base (github, vertex) — deduplicated + if len(merged.Providers) != 3 { + t.Fatalf("Providers count = %d, want 3", len(merged.Providers)) + } + providerNames := make(map[string]bool) + for _, p := range merged.Providers { + providerNames[p.Profile] = true + } + for _, want := range []string{"atlassian", "github", "google-vertex-ai"} { + if !providerNames[want] { + t.Errorf("missing provider %q", want) + } + } + + // Env: base + overlay merged + if merged.Env["ANTHROPIC_BASE_URL"] != "https://inference.local" { + t.Error("missing base env ANTHROPIC_BASE_URL") + } + if merged.Env["CUSTOM_VAR"] != "value" { + t.Error("missing overlay env CUSTOM_VAR") + } + + // Payloads: base preserved + if len(merged.Payloads) != 1 { + t.Errorf("Payloads count = %d, want 1 (from base)", len(merged.Payloads)) + } + + // BaseAgent field cleared + if merged.BaseAgent != "" { + t.Errorf("BaseAgent = %q, want empty (cleared after merge)", merged.BaseAgent) + } +} diff --git a/profiles/README.md b/profiles/README.md index 9a56053..f768877 100644 --- a/profiles/README.md +++ b/profiles/README.md @@ -8,9 +8,11 @@ Define what runs in the sandbox. One agent config = one sandbox. ```yaml name: agent # sandbox name +base_agent: default # inherit from agent-default.yaml (providers, env, payloads) entrypoint: claude # claude, opencode, bash, or any binary on PATH tty: true # enable TTY (default: true) repo: https://github.com/org/repo # cloned outside sandbox, uploaded to /sandbox/ +repo_ref: main # branch, tag, or ref to clone (default: HEAD) gateway: openshift # target gateway (default: local-container) task: @tasks/review.md # task file passed to entrypoint via -p image: ghcr.io/... # override sandbox image