Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
Expand Down
19 changes: 9 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<repo-name>`. Shallow clone (`--depth 1`). Git credentials never enter the sandbox.
- `repo` -- git URL to clone outside the sandbox and upload to `/sandbox/<repo-name>`. 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
Expand Down
43 changes: 39 additions & 4 deletions cmd/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -166,7 +170,7 @@ func upLocal(opts upLocalOpts) error {
// that places it at /sandbox/<repo-name>. 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 {
Expand All @@ -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 {
Expand All @@ -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
}
36 changes: 36 additions & 0 deletions cmd/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
73 changes: 73 additions & 0 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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
Expand Down
81 changes: 81 additions & 0 deletions internal/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
2 changes: 2 additions & 0 deletions profiles/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>
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
Expand Down
Loading