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
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ test-suite-live: cli

## Local gateway integration (unit tests run separately via 'make test')
test-local: cli
./test/test-flow.sh local
./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 @@ -81,7 +81,7 @@ test-kind: cli
test-remote: cli dev-sandbox
@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 ocp
HARNESS_OS_IMAGE=$(IMAGE) ./test/test-flow.sh openshift

## All: unit + local + kind + remote
test-all: test test-local test-kind test-remote
Expand Down
66 changes: 64 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,41 @@ harness apply -f harness.yaml --task "review this codebase for security issues"
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.

```yaml
name: reviewer
repo: https://github.com/stackrox/collector
entrypoint: claude
task: "identify the highest-priority C++ remediation"
```

```bash
harness apply -f reviewer.yaml --task "focus on RAII and move semantics"
```

### Getting results out

The agent runs in an isolated sandbox. To extract results:

```bash
# Agent outputs to stdout (--task mode)
harness apply -f harness.yaml --task "summarize the codebase" > results.md

# Pull a specific file from the sandbox
openshell sandbox exec <name> -- cat /sandbox/collector/report.md > report.md

# Extract a diff
openshell sandbox exec <name> -- git -C /sandbox/collector diff > changes.patch

# Download files
openshell sandbox exec <name> -- tar czf - /sandbox/collector/output/ > output.tar.gz
```

If the `github` provider is attached, the agent can push directly -- the proxy provides a scoped `GITHUB_TOKEN` without exposing raw credentials.

### Coding agent

Launch an interactive coding session with Claude Code or OpenCode.
Expand All @@ -30,7 +65,7 @@ Launch an interactive coding session with Claude Code or OpenCode.
harness apply -f harness.yaml --attach

# On OpenShift
harness apply -f harness.yaml --attach --gateway ocp
harness apply -f harness.yaml --attach --gateway openshift

# OpenCode instead of Claude
harness apply -f harness.yaml --attach --entrypoint opencode
Expand All @@ -52,6 +87,7 @@ A single file defines what runs, what credentials it gets, and what files are up
name: agent
entrypoint: claude
tty: true
repo: https://github.com/stackrox/collector # cloned outside sandbox, uploaded in

providers:
- profile: github
Expand Down Expand Up @@ -148,7 +184,7 @@ Or build from source: `make cli`
| `harness apply --attach` | Interactive TTY mode |
| `harness apply --dry-run` | Validate without deploying |
| `harness apply -o yaml` | Output resolved config |
| `harness deploy [local\|ocp\|kind]` | Deploy gateway only |
| `harness deploy <gateway>` | Deploy gateway only |
| `harness get agents\|providers\|gateways` | List resources |
| `harness describe <name>` | Sandbox details |
| `harness delete <name> [--all]` | Tear down |
Expand All @@ -173,6 +209,32 @@ Each provider discovers credentials from the host. Missing providers are skipped
| `profiles/gateways/*.yaml` | Gateway profiles per target |
| `profiles/images/sandbox-default/` | Sandbox image defaults (overridable via payloads) |

## Testing

Tested on macOS (arm64) with Podman. Linux support is expected but not yet validated.

```bash
make test # vet + unit tests (5 packages)
make lint # golangci-lint
make test-suite # config parsing (23 tests, no gateway needed)
make test-local # full e2e on local Podman (22 tests)
make test-kind # self-contained kind cluster lifecycle
make test-remote # full e2e on OCP (needs KUBECONFIG)
```

`test-local` is the primary validation target. It deploys the gateway, registers all 4 providers, creates sandboxes, verifies exec/env/GWS token resolution/MCP config/Claude inference, tests missing-provider recovery, and tears down.

`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.

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
```

## Documentation

| Document | What it is |
Expand Down
13 changes: 7 additions & 6 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ Behavior specification for the OpenShell Harness CLI.
## Overview

The harness deploys and manages AI agent sandboxes on three targets:
- **Local** -- Podman containers via a local OpenShell gateway
- **Kind** -- Kubernetes pods via a kind cluster
- **Remote** -- Kubernetes pods via an OpenShift-hosted OpenShell gateway
- **local-container** -- Podman containers via a local OpenShell gateway
- **helm** -- Kubernetes pods via a kind cluster (NodePort access)
- **openshift** -- Kubernetes pods via an OpenShift-hosted OpenShell gateway (Route access)

Each sandbox is an isolated container running an agent entrypoint (Claude Code or OpenCode), with credential providers, network policies, and a rendered payload (run.sh, task.md).

Expand Down Expand Up @@ -40,6 +40,7 @@ Fields:
- `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.
- `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 Expand Up @@ -69,7 +70,7 @@ type: github
credentials: [GITHUB_TOKEN]
---
kind: gateway
name: local
name: local-container
type: local
```

Expand All @@ -84,7 +85,7 @@ Primary command. Resolves an agent config, deploys the gateway and providers, cr
1. **Parse agent config** -- resolve `agent-<name>.yaml` from harness directory (default: `default`). `-f` overrides with a direct file path. Falls back to embedded `agent-basic.yaml` when `agent-default.yaml` is not found on disk.
2. **Check output mode** -- if `-o yaml` or `-o json`, render the fully resolved config and exit. No gateway interaction needed.
3. **Check version** -- warn if openshell CLI is below v0.0.59.
4. **Resolve gateway** -- `--gateway` selects a profile by name; `--gateway-profile` loads from a file path. Default: `local`. `OPENSHELL_GATEWAY` env var is used as fallback.
4. **Resolve gateway** -- `--gateway` selects a profile by name; `--gateway-profile` loads from a file path. Default: `local-container`. `OPENSHELL_GATEWAY` env var is used as fallback.
5. **Dry-run check** -- if `--dry-run`, validate each step (gateway reachable, providers resolvable, env vars resolved, image available) and exit with pass/fail report.
6. **Ensure gateway** -- deploy if needed (local: Podman, remote: Helm to K8s/OCP).
7. **Ensure providers** -- auto-register missing providers. Three registration flows:
Expand Down Expand Up @@ -118,7 +119,7 @@ Show detailed status for a specific sandbox: phase, active gateway, and register

Delete sandboxes by name, or use flags for bulk operations. `--all` deletes sandboxes, providers, and k8s resources. Reuses the same teardown functions as the old `teardown` command.

### `harness deploy [local|ocp|kind]`
### `harness deploy <gateway>`

Deploy or verify the gateway for a target. Reads `profiles/gateways/<target>.yaml`.

Expand Down
2 changes: 1 addition & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@

### Future fields
- [ ] `description` -- one line of human-readable context per agent config
- [ ] `repo` -- git URL to clone into the sandbox at start
- [x] `repo` -- git URL cloned outside sandbox and uploaded (git creds never enter sandbox)

## Testing [DONE]

Expand Down
8 changes: 4 additions & 4 deletions cmd/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,12 @@ then deploy a sandbox. Use --dry-run to validate without deploying, or
if agentCfg.Gateway != "" {
gwTarget = agentCfg.Gateway
} else {
gwTarget = "local"
gwTarget = "local-container"
}
}
gwCfg, _ = resolveGatewayConfigWithHarness(harnessDir, gwTarget, harness)
}
isRemote := gwTarget != "local"
isRemote := gwCfg != nil && !gwCfg.IsLocal()

if dryRun {
return dryRunApply(gw, agentCfg, gwTarget, isRemote)
Expand All @@ -138,7 +138,7 @@ then deploy a sandbox. Use --dry-run to validate without deploying, or

cmd.Flags().StringVarP(&file, "file", "f", "", "Path to harness/agent YAML file")
cmd.Flags().StringVar(&agentName, "agent", "default", "Agent config name (from profiles/)")
cmd.Flags().StringVar(&gatewayName, "gateway", envOr("OPENSHELL_GATEWAY", ""), "Gateway profile name (local, kind, ocp)")
cmd.Flags().StringVar(&gatewayName, "gateway", envOr("OPENSHELL_GATEWAY", ""), "Gateway profile name")
cmd.Flags().StringVar(&gatewayProfile, "gateway-profile", "", "Path to gateway profile YAML")
cmd.Flags().StringVar(&sandboxName, "name", "", "Sandbox name (overrides agent config)")
cmd.Flags().StringVar(&task, "task", "", "Task to pass to the agent (inline text or @filepath)")
Expand All @@ -156,7 +156,7 @@ func renderOutput(harnessDir string, h *agent.Harness, format string) error {

gwName := h.Agent.Gateway
if gwName == "" {
gwName = "local"
gwName = "local-container"
}
if len(h.Gateways) == 0 {
if gwData := loadGatewayProfile(harnessDir, gwName); gwData != nil {
Expand Down
2 changes: 1 addition & 1 deletion cmd/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Examples:
}
if all || k8sFlag {
ns := k8s.DefaultNamespace()
gwCfg, _ := resolveGatewayConfig(harnessDir, "ocp")
gwCfg := resolveFirstRemoteGateway(harnessDir)
teardownK8s(gw, gwCfg, k8s.New("", ns), k8s.New("", ""))
}

Expand Down
4 changes: 2 additions & 2 deletions cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func NewDeployCmd(harnessDir, cli string) *cobra.Command {
cmd := &cobra.Command{
Use: "deploy [gateway]",
Short: "Deploy or verify the gateway",
Long: "Deploy a gateway by name (e.g., local, ocp, kind). Reads configuration from profiles/gateways/<name>.yaml.",
Long: "Deploy a gateway by name (e.g., local-container, helm, openshift). Reads configuration from profiles/gateways/<name>.yaml.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
gatewayName, err := resolveGatewayName(args)
Expand Down Expand Up @@ -55,7 +55,7 @@ func resolveGatewayName(args []string) (string, error) {
if len(args) > 0 {
return args[0], nil
}
return "", fmt.Errorf("specify a gateway: harness deploy <local|ocp|kind>")
return "", fmt.Errorf("specify a gateway: harness deploy <gateway-name>")
}

// lookPath is exec.LookPath, overridable in tests to avoid a host
Expand Down
4 changes: 2 additions & 2 deletions cmd/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func setupDeployHarnessDir(t *testing.T) string {

func setupOCPGatewayConfig(t *testing.T, dir string) string {
t.Helper()
gwDir := filepath.Join(dir, "gateways", "ocp")
gwDir := filepath.Join(dir, "gateways", "openshift")
os.MkdirAll(filepath.Join(gwDir, "helm"), 0o755)
os.MkdirAll(filepath.Join(gwDir, "addons"), 0o755)
os.WriteFile(filepath.Join(gwDir, "gateway.yaml"), []byte(`
Expand Down Expand Up @@ -53,7 +53,7 @@ secrets:

func setupK8sGatewayConfig(t *testing.T, dir string) string {
t.Helper()
gwDir := filepath.Join(dir, "gateways", "kind")
gwDir := filepath.Join(dir, "gateways", "helm")
os.MkdirAll(gwDir, 0o755)
os.WriteFile(filepath.Join(gwDir, "gateway.yaml"), []byte(`
gateway:
Expand Down
44 changes: 12 additions & 32 deletions cmd/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,22 +114,21 @@ func checkOpenShell(cfg *agent.AgentConfig, cli, _ string) []CheckResult {
}}
}

func checkTargetDeps(cfg *agent.AgentConfig, _, _ string) []CheckResult {
func checkTargetDeps(cfg *agent.AgentConfig, harnessDir, _ string) []CheckResult {
target := cfg.Gateway
if target == "" {
target = "local"
target = "local-container"
}

switch target {
case "local":
return checkLocalDeps()
case "kind":
return checkKindDeps()
case "ocp":
gwCfg, _ := resolveGatewayConfig(harnessDir, target)
if gwCfg != nil {
if gwCfg.IsLocal() {
return checkLocalDeps()
}
return checkRemoteDeps()
default:
return checkLocalDeps()
}

return checkLocalDeps()
}

func checkLocalDeps() []CheckResult {
Expand All @@ -139,34 +138,15 @@ func checkLocalDeps() []CheckResult {
if out, e := exec.Command("podman", "version", "--format", "{{.Client.Version}}").Output(); e == nil {
ver = " " + strings.TrimSpace(string(out))
}
return []CheckResult{{Group: "target", Name: "local", Status: "pass", Message: "podman" + ver + " running"}}
return []CheckResult{{Group: "target", Name: "local-container", Status: "pass", Message: "podman" + ver + " running"}}
}
}
if _, err := exec.LookPath("docker"); err == nil {
if err := exec.Command("docker", "info").Run(); err == nil {
return []CheckResult{{Group: "target", Name: "local", Status: "pass", Message: "docker running"}}
return []CheckResult{{Group: "target", Name: "local-container", Status: "pass", Message: "docker running"}}
}
}
return []CheckResult{{Group: "target", Name: "local", Status: "fail", Message: "no container runtime (podman or docker) responding"}}
}

func checkKindDeps() []CheckResult {
var results []CheckResult
results = append(results, checkLocalDeps()...)

if _, err := exec.LookPath("kubectl"); err != nil {
results = append(results, CheckResult{Group: "target", Name: "kubectl", Status: "fail", Message: "kubectl not found on PATH"})
} else {
results = append(results, CheckResult{Group: "target", Name: "kubectl", Status: "pass", Message: "found"})
}

if _, err := exec.LookPath("kind"); err != nil {
results = append(results, CheckResult{Group: "target", Name: "kind", Status: "fail", Message: "kind not found on PATH"})
} else {
results = append(results, CheckResult{Group: "target", Name: "kind", Status: "pass", Message: "found"})
}

return results
return []CheckResult{{Group: "target", Name: "local-container", Status: "fail", Message: "no container runtime (podman or docker) responding"}}
}

func checkRemoteDeps() []CheckResult {
Expand Down
29 changes: 5 additions & 24 deletions cmd/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func TestCheckOpenShell_NotFound(t *testing.T) {

func TestCheckTargetDeps_Local(t *testing.T) {
cfg := testAgentConfig(t)
cfg.Gateway = "local"
cfg.Gateway = "local-container"
results := checkTargetDeps(cfg, "", "")
if len(results) == 0 {
t.Fatal("expected at least 1 result")
Expand All @@ -51,28 +51,9 @@ func TestCheckTargetDeps_Local(t *testing.T) {
}
}

func TestCheckTargetDeps_Kind(t *testing.T) {
cfg := testAgentConfig(t)
cfg.Gateway = "kind"
results := checkTargetDeps(cfg, "", "")
if len(results) < 2 {
t.Fatalf("expected at least 2 results for kind, got %d", len(results))
}
names := make(map[string]bool)
for _, r := range results {
names[r.Name] = true
}
if !names["kubectl"] {
t.Error("missing kubectl check for kind target")
}
if !names["kind"] {
t.Error("missing kind binary check for kind target")
}
}

func TestCheckTargetDeps_Remote(t *testing.T) {
cfg := testAgentConfig(t)
cfg.Gateway = "ocp"
cfg.Gateway = "openshift"
results := checkTargetDeps(cfg, "", "")
if len(results) < 1 {
t.Fatal("expected at least 1 result for remote")
Expand All @@ -95,8 +76,8 @@ func TestCheckTargetDeps_EmptyGateway_DefaultsToLocal(t *testing.T) {
if len(results) == 0 {
t.Fatal("expected at least 1 result")
}
if results[0].Name != "local" {
t.Errorf("Name = %q, want local (default)", results[0].Name)
if results[0].Name != "local-container" {
t.Errorf("Name = %q, want local-container (default)", results[0].Name)
}
}

Expand Down Expand Up @@ -194,7 +175,7 @@ credentials:
func TestDoctorOutputJSON(t *testing.T) {
results := []CheckResult{
{Group: "openshell", Name: "binary", Status: "pass", Message: "v0.0.63"},
{Group: "target", Name: "local", Status: "pass", Message: "podman running"},
{Group: "target", Name: "local-container", Status: "pass", Message: "podman running"},
}

err := printStructured(formatJSON, results)
Expand Down
Loading
Loading