From 1a5d3d5b529da96ef353ce71f082f37207e0fee2 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 13:31:44 -0700 Subject: [PATCH 1/6] refactor: rename gateway profiles, add auto-discovery and default profile dir Rename gateway profiles to describe deployment method and service type: local.yaml -> local-container.yaml kind.yaml -> helm-nodeport.yaml ocp.yaml -> helm-openshift-route.yaml Add listGatewayProfiles() that scans profiles/gateways/*.yaml and merges with embedded profiles. Init prompt and doctor checks now use discovered profiles instead of hardcoded name lists. Adding a new gateway target is just dropping a YAML file. Add HARNESS_PROFILE_DIR env var and ~/.config/harness-openshell default so the binary has a home on first run without requiring a checkout. Doctor target checks now use gateway config type (local vs remote) instead of matching on profile names, so new gateway profiles work without code changes. --- README.md | 4 +- SPEC.md | 12 ++--- cmd/apply.go | 8 ++-- cmd/delete.go | 2 +- cmd/deploy.go | 4 +- cmd/deploy_test.go | 4 +- cmd/doctor.go | 38 ++++----------- cmd/doctor_test.go | 23 +-------- cmd/helpers_test.go | 8 ++++ cmd/init_cmd.go | 29 ++++++----- cmd/init_cmd_test.go | 48 +++++++++---------- cmd/resolve.go | 39 +++++++++++++++ cmd/teardown.go | 3 +- main.go | 29 +++++++---- profiles/README.md | 2 +- profiles/agent-ocp.yaml | 2 +- profiles/gateways/README.md | 12 ++--- .../{kind.yaml => helm-nodeport.yaml} | 0 .../{ocp.yaml => helm-openshift-route.yaml} | 0 .../{local.yaml => local-container.yaml} | 0 20 files changed, 143 insertions(+), 124 deletions(-) rename profiles/gateways/{kind.yaml => helm-nodeport.yaml} (100%) rename profiles/gateways/{ocp.yaml => helm-openshift-route.yaml} (100%) rename profiles/gateways/{local.yaml => local-container.yaml} (100%) diff --git a/README.md b/README.md index 446c612..28a6fcb 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,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 helm-openshift-route # OpenCode instead of Claude harness apply -f harness.yaml --attach --entrypoint opencode @@ -148,7 +148,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 ` | Deploy gateway only | | `harness get agents\|providers\|gateways` | List resources | | `harness describe ` | Sandbox details | | `harness delete [--all]` | Tear down | diff --git a/SPEC.md b/SPEC.md index 91f2653..8476560 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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-nodeport** -- Kubernetes pods via a kind cluster (NodePort access) +- **helm-openshift-route** -- 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). @@ -69,7 +69,7 @@ type: github credentials: [GITHUB_TOKEN] --- kind: gateway -name: local +name: local-container type: local ``` @@ -84,7 +84,7 @@ Primary command. Resolves an agent config, deploys the gateway and providers, cr 1. **Parse agent config** -- resolve `agent-.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: @@ -118,7 +118,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 ` Deploy or verify the gateway for a target. Reads `profiles/gateways/.yaml`. diff --git a/cmd/apply.go b/cmd/apply.go index ca32337..467a4fd 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -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) @@ -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)") @@ -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 { diff --git a/cmd/delete.go b/cmd/delete.go index 5ec8b3e..0ada7e0 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -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("", "")) } diff --git a/cmd/deploy.go b/cmd/deploy.go index 81babcc..a5b2125 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -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/.yaml.", + Long: "Deploy a gateway by name (e.g., local-container, helm-nodeport, helm-openshift-route). Reads configuration from profiles/gateways/.yaml.", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { gatewayName, err := resolveGatewayName(args) @@ -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 ") + return "", fmt.Errorf("specify a gateway: harness deploy ") } // lookPath is exec.LookPath, overridable in tests to avoid a host diff --git a/cmd/deploy_test.go b/cmd/deploy_test.go index 385d05e..f21b41b 100644 --- a/cmd/deploy_test.go +++ b/cmd/deploy_test.go @@ -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", "helm-openshift-route") os.MkdirAll(filepath.Join(gwDir, "helm"), 0o755) os.MkdirAll(filepath.Join(gwDir, "addons"), 0o755) os.WriteFile(filepath.Join(gwDir, "gateway.yaml"), []byte(` @@ -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-nodeport") os.MkdirAll(gwDir, 0o755) os.WriteFile(filepath.Join(gwDir, "gateway.yaml"), []byte(` gateway: diff --git a/cmd/doctor.go b/cmd/doctor.go index f5abfbe..c70948b 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -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 { @@ -150,25 +149,6 @@ func checkLocalDeps() []CheckResult { 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 -} - func checkRemoteDeps() []CheckResult { var results []CheckResult diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go index 4b3a77c..3a2e670 100644 --- a/cmd/doctor_test.go +++ b/cmd/doctor_test.go @@ -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") @@ -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 = "helm-openshift-route" results := checkTargetDeps(cfg, "", "") if len(results) < 1 { t.Fatal("expected at least 1 result for remote") diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index 2608ed7..8ab0d54 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -9,6 +9,14 @@ import ( "github.com/robbycochran/harness-openshell/internal/gateway" ) +func init() { + EmbeddedGatewayProfiles = map[string][]byte{ + "local-container": []byte("gateway:\n type: local\n"), + "helm-nodeport": []byte("gateway:\n type: remote\n platform: k8s\n service: nodeport\n"), + "helm-openshift-route": []byte("gateway:\n type: remote\n platform: ocp\n service: route\n"), + } +} + type mockGW struct { inferenceErr error providers map[string]bool diff --git a/cmd/init_cmd.go b/cmd/init_cmd.go index 814b31e..5450312 100644 --- a/cmd/init_cmd.go +++ b/cmd/init_cmd.go @@ -27,7 +27,7 @@ var defaultProviders = []availableProvider{ {ID: "google-workspace", DisplayName: "Google Workspace", Category: "knowledge"}, } -func NewInitCmd() *cobra.Command { +func NewInitCmd(harnessDir string) *cobra.Command { var ( outputPath string force bool @@ -42,7 +42,7 @@ gateway target. The generated config is yours to version, share, and customize. Use --non-interactive to write the embedded default config without prompts.`, RunE: func(cmd *cobra.Command, args []string) error { - return initRun(os.Stdin, os.Stdout, outputPath, force, nonInteractive, DefaultAgentConfig) + return initRun(os.Stdin, os.Stdout, outputPath, force, nonInteractive, DefaultAgentConfig, harnessDir) }, } @@ -53,7 +53,7 @@ Use --non-interactive to write the embedded default config without prompts.`, return cmd } -func initRun(in io.Reader, out io.Writer, outputPath string, force, nonInteractive bool, defaultCfg []byte) error { +func initRun(in io.Reader, out io.Writer, outputPath string, force, nonInteractive bool, defaultCfg []byte, harnessDir string) error { if _, err := os.Stat(outputPath); err == nil && !force { return fmt.Errorf("%s already exists (use --force to overwrite)", outputPath) } @@ -78,7 +78,7 @@ func initRun(in io.Reader, out io.Writer, outputPath string, force, nonInteracti } cfg.Providers = providers - target, err := promptGateway(scanner, out) + target, err := promptGateway(scanner, out, harnessDir) if err != nil { return err } @@ -141,21 +141,24 @@ func promptProviders(scanner *bufio.Scanner, out io.Writer) ([]agent.ProviderRef return buildProviderRefs(available, indices), nil } -func promptGateway(scanner *bufio.Scanner, out io.Writer) (string, error) { - fmt.Fprint(out, "Gateway target [local/kind/ocp] (default: local): ") +func promptGateway(scanner *bufio.Scanner, out io.Writer, harnessDir string) (string, error) { + profiles := listGatewayProfiles(harnessDir) + defaultGW := "local-container" + choices := strings.Join(profiles, "/") + fmt.Fprintf(out, "Gateway target [%s] (default: %s): ", choices, defaultGW) if !scanner.Scan() { - return "local", nil + return defaultGW, nil } input := strings.TrimSpace(strings.ToLower(scanner.Text())) if input == "" { - return "local", nil + return defaultGW, nil } - switch input { - case "local", "kind", "ocp": - return input, nil - default: - return "", fmt.Errorf("unknown gateway target: %q (use local, kind, or ocp)", input) + for _, p := range profiles { + if input == p { + return input, nil + } } + return "", fmt.Errorf("unknown gateway target: %q (available: %s)", input, choices) } func discoverProviders() []availableProvider { diff --git a/cmd/init_cmd_test.go b/cmd/init_cmd_test.go index d8e5f19..8bbd109 100644 --- a/cmd/init_cmd_test.go +++ b/cmd/init_cmd_test.go @@ -25,7 +25,7 @@ func TestInitRun_NonInteractive(t *testing.T) { outPath := filepath.Join(dir, "harness.yaml") var buf bytes.Buffer - err := initRun(strings.NewReader(""), &buf, outPath, false, true, testDefaultConfig) + err := initRun(strings.NewReader(""), &buf, outPath, false, true, testDefaultConfig, "") if err != nil { t.Fatalf("initRun: %v", err) } @@ -53,7 +53,7 @@ func TestInitRun_OverwriteGuard(t *testing.T) { os.WriteFile(outPath, []byte("existing"), 0o644) var buf bytes.Buffer - err := initRun(strings.NewReader(""), &buf, outPath, false, true, testDefaultConfig) + err := initRun(strings.NewReader(""), &buf, outPath, false, true, testDefaultConfig, "") if err == nil { t.Fatal("expected error for existing file without --force") } @@ -68,7 +68,7 @@ func TestInitRun_OverwriteWithForce(t *testing.T) { os.WriteFile(outPath, []byte("existing"), 0o644) var buf bytes.Buffer - err := initRun(strings.NewReader(""), &buf, outPath, true, true, testDefaultConfig) + err := initRun(strings.NewReader(""), &buf, outPath, true, true, testDefaultConfig, "") if err != nil { t.Fatalf("initRun with --force: %v", err) } @@ -89,7 +89,7 @@ func TestInitRun_InteractiveDefaults(t *testing.T) { // Empty input = accept defaults for each prompt input := "\n\n\n" - err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig) + err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig, "") if err != nil { t.Fatalf("initRun: %v", err) } @@ -113,8 +113,8 @@ func TestInitRun_InteractiveOpenCode(t *testing.T) { outPath := filepath.Join(dir, "harness.yaml") var buf bytes.Buffer - input := "opencode\n1\nlocal\n" - err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig) + input := "opencode\n1\n\n" + err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig, "") if err != nil { t.Fatalf("initRun: %v", err) } @@ -132,8 +132,8 @@ func TestInitRun_InteractiveProvidersSingle(t *testing.T) { outPath := filepath.Join(dir, "harness.yaml") var buf bytes.Buffer - input := "claude\n1\nlocal\n" - err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig) + input := "claude\n1\n\n" + err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig, "") if err != nil { t.Fatalf("initRun: %v", err) } @@ -151,8 +151,8 @@ func TestInitRun_InteractiveProvidersMultiple(t *testing.T) { outPath := filepath.Join(dir, "harness.yaml") var buf bytes.Buffer - input := "claude\n1,3\nlocal\n" - err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig) + input := "claude\n1,3\n\n" + err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig, "") if err != nil { t.Fatalf("initRun: %v", err) } @@ -170,8 +170,8 @@ func TestInitRun_InteractiveProvidersNone(t *testing.T) { outPath := filepath.Join(dir, "harness.yaml") var buf bytes.Buffer - input := "claude\nnone\nlocal\n" - err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig) + input := "claude\nnone\n\n" + err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig, "") if err != nil { t.Fatalf("initRun: %v", err) } @@ -189,8 +189,8 @@ func TestInitRun_InteractiveGatewayKind(t *testing.T) { outPath := filepath.Join(dir, "harness.yaml") var buf bytes.Buffer - input := "claude\n1\nkind\n" - err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig) + input := "claude\n1\nhelm-nodeport\n" + err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig, "") if err != nil { t.Fatalf("initRun: %v", err) } @@ -198,8 +198,8 @@ func TestInitRun_InteractiveGatewayKind(t *testing.T) { data, _ := os.ReadFile(outPath) var cfg agent.AgentConfig yaml.Unmarshal(data, &cfg) - if cfg.Gateway != "kind" { - t.Errorf("Gateway = %q, want kind", cfg.Gateway) + if cfg.Gateway != "helm-nodeport" { + t.Errorf("Gateway = %q, want helm-nodeport", cfg.Gateway) } } @@ -208,8 +208,8 @@ func TestInitRun_InteractiveGatewayOCP(t *testing.T) { outPath := filepath.Join(dir, "harness.yaml") var buf bytes.Buffer - input := "claude\n1\nocp\n" - err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig) + input := "claude\n1\nhelm-openshift-route\n" + err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig, "") if err != nil { t.Fatalf("initRun: %v", err) } @@ -217,8 +217,8 @@ func TestInitRun_InteractiveGatewayOCP(t *testing.T) { data, _ := os.ReadFile(outPath) var cfg agent.AgentConfig yaml.Unmarshal(data, &cfg) - if cfg.Gateway != "ocp" { - t.Errorf("Gateway = %q, want ocp", cfg.Gateway) + if cfg.Gateway != "helm-openshift-route" { + t.Errorf("Gateway = %q, want helm-openshift-route", cfg.Gateway) } } @@ -228,7 +228,7 @@ func TestInitRun_InvalidGateway(t *testing.T) { var buf bytes.Buffer input := "claude\n1\nbadtarget\n" - err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig) + err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig, "") if err == nil { t.Fatal("expected error for invalid gateway target") } @@ -240,7 +240,7 @@ func TestInitRun_RemoteIsInvalidGateway(t *testing.T) { var buf bytes.Buffer input := "claude\n1\nremote\n" - err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig) + err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig, "") if err == nil { t.Fatal("expected error: 'remote' is not a valid gateway, use 'ocp'") } @@ -251,7 +251,7 @@ func TestInitRun_OutputContainsNextSteps(t *testing.T) { outPath := filepath.Join(dir, "harness.yaml") var buf bytes.Buffer - err := initRun(strings.NewReader(""), &buf, outPath, false, true, testDefaultConfig) + err := initRun(strings.NewReader(""), &buf, outPath, false, true, testDefaultConfig, "") if err != nil { t.Fatalf("initRun: %v", err) } @@ -325,7 +325,7 @@ func TestInitNoCredentialLeak(t *testing.T) { t.Setenv("ANTHROPIC_API_KEY", "sk-secret-key-12345") - err := initRun(strings.NewReader(""), &buf, outPath, false, true, testDefaultConfig) + err := initRun(strings.NewReader(""), &buf, outPath, false, true, testDefaultConfig, "") if err != nil { t.Fatalf("initRun: %v", err) } diff --git a/cmd/resolve.go b/cmd/resolve.go index 2b17c9b..dc09c42 100644 --- a/cmd/resolve.go +++ b/cmd/resolve.go @@ -6,6 +6,8 @@ import ( "io/fs" "os" "path/filepath" + "sort" + "strings" "github.com/robbycochran/harness-openshell/internal/agent" "github.com/robbycochran/harness-openshell/internal/gateway" @@ -143,3 +145,40 @@ func loadGatewayProfile(harnessDir, name string) []byte { } return nil } + +func resolveFirstRemoteGateway(harnessDir string) *gateway.GatewayConfig { + for _, name := range listGatewayProfiles(harnessDir) { + cfg, err := resolveGatewayConfig(harnessDir, name) + if err == nil && !cfg.IsLocal() { + return cfg + } + } + return nil +} + +func listGatewayProfiles(harnessDir string) []string { + seen := make(map[string]bool) + for name := range EmbeddedGatewayProfiles { + seen[name] = true + } + dir := filepath.Join(harnessDir, "profiles", "gateways") + entries, err := os.ReadDir(dir) + if err == nil { + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { + continue + } + if strings.ToLower(e.Name()) == "readme.md" { + continue + } + name := e.Name()[:len(e.Name())-5] + seen[name] = true + } + } + names := make([]string, 0, len(seen)) + for name := range seen { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/cmd/teardown.go b/cmd/teardown.go index c49188a..8b11f22 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -47,8 +47,7 @@ func NewTeardownCmd(harnessDir, cli string) *cobra.Command { } if k8sFlag { ns := k8s.DefaultNamespace() - // Load OCP gateway config for SCC/secret names; fall back to defaults - gwCfg, _ := resolveGatewayConfig(harnessDir, "ocp") + gwCfg := resolveFirstRemoteGateway(harnessDir) teardownK8s(gw, gwCfg, k8s.New("", ns), k8s.New("", "")) } diff --git a/main.go b/main.go index e96e2a1..66c974e 100644 --- a/main.go +++ b/main.go @@ -16,14 +16,14 @@ var version = "dev" //go:embed profiles/agent-basic.yaml var defaultAgentConfig []byte -//go:embed profiles/gateways/local.yaml -var localGatewayProfile []byte +//go:embed profiles/gateways/local-container.yaml +var localContainerGatewayProfile []byte -//go:embed profiles/gateways/kind.yaml -var kindGatewayProfile []byte +//go:embed profiles/gateways/helm-nodeport.yaml +var helmNodeportGatewayProfile []byte -//go:embed profiles/gateways/ocp.yaml -var ocpGatewayProfile []byte +//go:embed profiles/gateways/helm-openshift-route.yaml +var helmOpenshiftRouteGatewayProfile []byte func main() { harnessDir := detectHarnessDir() @@ -54,9 +54,9 @@ func main() { cmd.Version = version cmd.DefaultAgentConfig = defaultAgentConfig cmd.EmbeddedGatewayProfiles = map[string][]byte{ - "local": localGatewayProfile, - "kind": kindGatewayProfile, - "ocp": ocpGatewayProfile, + "local-container": localContainerGatewayProfile, + "helm-nodeport": helmNodeportGatewayProfile, + "helm-openshift-route": helmOpenshiftRouteGatewayProfile, } root.CompletionOptions.HiddenDefaultCmd = true @@ -67,7 +67,7 @@ func main() { cmd.NewDeleteCmd(harnessDir, cli), cmd.NewDeployCmd(harnessDir, cli), cmd.NewDoctorCmd(harnessDir, cli), - cmd.NewInitCmd(), + cmd.NewInitCmd(harnessDir), ) // Deprecated aliases @@ -86,6 +86,9 @@ func main() { } func detectHarnessDir() string { + if d := os.Getenv("HARNESS_PROFILE_DIR"); d != "" { + return d + } if d := os.Getenv("HARNESS_OS_DIR"); d != "" { return d } @@ -108,5 +111,11 @@ func detectHarnessDir() string { dir = filepath.Dir(dir) } } + if home, err := os.UserHomeDir(); err == nil { + d := filepath.Join(home, ".config", "harness-openshell") + os.MkdirAll(filepath.Join(d, "profiles", "gateways"), 0o755) + os.MkdirAll(filepath.Join(d, "profiles", "providers"), 0o755) + return d + } return "" } diff --git a/profiles/README.md b/profiles/README.md index 28a9e22..26d179f 100644 --- a/profiles/README.md +++ b/profiles/README.md @@ -10,7 +10,7 @@ Define what runs in the sandbox. One agent config = one sandbox. name: agent # sandbox name entrypoint: claude # claude, opencode, bash, or any binary on PATH tty: true # enable TTY (default: true) -gateway: ocp # target gateway (default: local) +gateway: helm-openshift-route # target gateway (default: local-container) task: @tasks/review.md # task file passed to entrypoint via -p image: ghcr.io/... # override sandbox image policy: path/to/policy.yaml # network policy file diff --git a/profiles/agent-ocp.yaml b/profiles/agent-ocp.yaml index 0687c4b..79fcaa3 100644 --- a/profiles/agent-ocp.yaml +++ b/profiles/agent-ocp.yaml @@ -7,7 +7,7 @@ # Requires: KUBECONFIG, kubectl, helm, cluster access. name: agent -gateway: ocp +gateway: helm-openshift-route entrypoint: claude tty: true diff --git a/profiles/gateways/README.md b/profiles/gateways/README.md index 4270b28..b81d6d9 100644 --- a/profiles/gateways/README.md +++ b/profiles/gateways/README.md @@ -46,15 +46,15 @@ secrets: ## Targets -### `local.yaml` -- Podman on your machine +### `local-container.yaml` -- Podman on your machine The default. Requires openshell installed and running via `brew services start openshell` or equivalent. No Helm, no K8s. -### `kind.yaml` -- local kind cluster +### `helm-nodeport.yaml` -- local kind cluster Deploys to a kind cluster. Uses NodePort access (no Ingress needed). TLS disabled for local dev simplicity. Requires `kind create cluster`. -### `ocp.yaml` -- OpenShift cluster +### `helm-openshift-route.yaml` -- OpenShift cluster Deploys to an OpenShift cluster with Route-based access and mTLS. Requires `oc login` and cluster-admin for SCC grants. @@ -62,15 +62,15 @@ Deploys to an OpenShift cluster with Route-based access and mTLS. Requires `oc l ```bash harness apply -f harness.yaml # uses local (default) -harness apply -f harness.yaml --gateway ocp # uses gateways/ocp.yaml -harness apply -f harness.yaml --gateway kind # uses gateways/kind.yaml +harness apply -f harness.yaml --gateway helm-openshift-route # uses gateways/helm-openshift-route.yaml +harness apply -f harness.yaml --gateway helm-nodeport # uses gateways/helm-nodeport.yaml ``` Agent configs can also set a default gateway: ```yaml name: agent -gateway: ocp +gateway: helm-openshift-route ``` The `OPENSHELL_GATEWAY` env var works as a fallback. diff --git a/profiles/gateways/kind.yaml b/profiles/gateways/helm-nodeport.yaml similarity index 100% rename from profiles/gateways/kind.yaml rename to profiles/gateways/helm-nodeport.yaml diff --git a/profiles/gateways/ocp.yaml b/profiles/gateways/helm-openshift-route.yaml similarity index 100% rename from profiles/gateways/ocp.yaml rename to profiles/gateways/helm-openshift-route.yaml diff --git a/profiles/gateways/local.yaml b/profiles/gateways/local-container.yaml similarity index 100% rename from profiles/gateways/local.yaml rename to profiles/gateways/local-container.yaml From 272e80cff7dc0be946d768e7a33488cf69eef9c4 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 13:37:37 -0700 Subject: [PATCH 2/6] fix: update integration test scripts and Makefile for renamed gateways Update test-flow.sh, kind-lifecycle.sh, and Makefile to use new gateway names (local-container, helm-nodeport, helm-openshift-route). The test case dispatcher accepts both old and new names for backwards compat during the transition. --- Makefile | 4 ++-- test/kind-lifecycle.sh | 2 +- test/test-flow.sh | 44 +++++++++++++++++++++--------------------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/Makefile b/Makefile index 1df56d6..b0021ca 100644 --- a/Makefile +++ b/Makefile @@ -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). @@ -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 helm-openshift-route ## All: unit + local + kind + remote test-all: test test-local test-kind test-remote diff --git a/test/kind-lifecycle.sh b/test/kind-lifecycle.sh index 41186f4..f3b5696 100755 --- a/test/kind-lifecycle.sh +++ b/test/kind-lifecycle.sh @@ -18,7 +18,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" CLUSTER_NAME="openshell-test" KIND_KUBECONFIG="" KEEP_CLUSTER=false -TEST_ARGS=("kind") +TEST_ARGS=("helm-nodeport") for arg in "$@"; do case "$arg" in diff --git a/test/test-flow.sh b/test/test-flow.sh index 27a3c53..5887b03 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -5,11 +5,11 @@ # Override with --ci or --no-providers. # # Usage: -# ./test-flow.sh local # full test with credentials -# ./test-flow.sh kind # full test on kind cluster -# ./test-flow.sh ocp # full test on OCP -# ./test-flow.sh ocp --reuse-gateway # skip deploy/teardown -# ./test-flow.sh all # all gateways +# ./test-flow.sh local-container # full test with credentials +# ./test-flow.sh helm-nodeport # full test on kind cluster +# ./test-flow.sh helm-openshift-route # full test on OCP +# ./test-flow.sh helm-openshift-route --reuse-gateway # skip deploy/teardown +# ./test-flow.sh all # all gateways set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" @@ -50,7 +50,7 @@ for arg in "$@"; do done if [[ -z "$TARGET" ]]; then - echo "Usage: $0 [--ci] [--reuse-gateway] [--debug]" + echo "Usage: $0 [--ci] [--reuse-gateway] [--debug]" exit 1 fi @@ -179,7 +179,7 @@ summary() { test_errors() { echo "=== test: error scenarios ===" - step_fail "nonexistent profile" harness apply --gateway local --agent nonexistent + step_fail "nonexistent profile" harness apply --gateway local-container --agent nonexistent if $REUSE_GATEWAY; then step "teardown (first)" harness delete --sandboxes --providers @@ -197,15 +197,15 @@ test_errors() { test_local() { local mode="full" $NO_PROVIDERS && mode="$mode, no-providers" - echo "=== test-flow: local ($mode) ===" + echo "=== test-flow: local-container ($mode) ===" step "teardown" harness delete --sandboxes --providers - step "deploy" harness deploy local + step "deploy" harness deploy local-container step "gateway reachable" "$CLI" inference get # up auto-registers providers when missing local sandbox_name="test-agent" - step "sandbox create (up)" harness apply --gateway local --name "$sandbox_name" $AGENT_FLAG "$PROFILE" + step "sandbox create (up)" harness apply --gateway local-container --name "$sandbox_name" $AGENT_FLAG "$PROFILE" sandbox_verify "$sandbox_name" step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" @@ -218,7 +218,7 @@ test_local() { echo "" echo "=== test: missing providers ===" step "teardown providers" harness delete --providers - step "up with no providers" harness apply --gateway local --name test-noprov + step "up with no providers" harness apply --gateway local-container --name test-noprov step "cleanup" harness delete --sandboxes fi @@ -251,7 +251,7 @@ test_gws() { test_kind() { local mode="full" $NO_PROVIDERS && mode="$mode, no-providers" - echo "=== test-flow: kind ($mode) ===" + echo "=== test-flow: helm-nodeport ($mode) ===" if ! kubectl get nodes &>/dev/null; then echo " ERROR: no kind cluster — run: kind create cluster --name openshell" @@ -260,11 +260,11 @@ test_kind() { fi step "teardown" harness delete --sandboxes --providers --k8s - step "deploy" harness deploy kind + step "deploy" harness deploy helm-nodeport step "gateway reachable" "$CLI" inference get local sandbox_name="test-kind" - step "sandbox create" harness apply --gateway kind --name "$sandbox_name" $AGENT_FLAG "$PROFILE" + step "sandbox create" harness apply --gateway helm-nodeport --name "$sandbox_name" $AGENT_FLAG "$PROFILE" sandbox_verify "$sandbox_name" if ! $NO_PROVIDERS; then @@ -282,7 +282,7 @@ test_kind() { test_ocp() { local mode="full" $REUSE_GATEWAY && mode="$mode, reuse-gateway" - echo "=== test-flow: ocp ($mode) ===" + echo "=== test-flow: helm-openshift-route ($mode) ===" if $REUSE_GATEWAY; then OCP_GW=$("$CLI" gateway list 2>/dev/null | strip_ansi | awk '/-remote-/ {gsub(/^\*/, ""); print $1; exit}') @@ -290,13 +290,13 @@ test_ocp() { step "teardown sandboxes+providers" harness delete --sandboxes --providers if ! "$CLI" inference get &>/dev/null; then - step "deploy" harness deploy ocp + step "deploy" harness deploy helm-openshift-route else step "gateway reachable" "$CLI" inference get fi else step "teardown" harness delete --sandboxes --providers --k8s - step "deploy" harness deploy ocp + step "deploy" harness deploy helm-openshift-route fi local sandbox_name @@ -305,7 +305,7 @@ test_ocp() { step "sandbox create" harness apply -f test/ci-agent.yaml --name "$sandbox_name" else sandbox_name="agent" - step "sandbox create (up)" harness apply --gateway ocp --name "$sandbox_name" + step "sandbox create (up)" harness apply --gateway helm-openshift-route --name "$sandbox_name" fi sandbox_verify "$sandbox_name" @@ -323,13 +323,13 @@ test_ocp() { test_errors case "$TARGET" in - local|podman) test_local ;; - kind) test_kind ;; - ocp) test_ocp ;; + local-container|local|podman) test_local ;; + helm-nodeport|kind) test_kind ;; + helm-openshift-route|ocp) test_ocp ;; all) test_local; echo ""; test_kind; echo ""; test_ocp ;; *) echo "Unknown target: $TARGET" - echo "Usage: $0 [--ci] [--reuse-gateway]" + echo "Usage: $0 [--ci] [--reuse-gateway]" exit 1 ;; esac From 2f6a51eb955de0c707e04f35bc7782185d2ace71 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 13:40:35 -0700 Subject: [PATCH 3/6] fix: use local-container in doctor check result names --- cmd/doctor.go | 6 +++--- cmd/doctor_test.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/doctor.go b/cmd/doctor.go index c70948b..aad563c 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -138,15 +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"}} + return []CheckResult{{Group: "target", Name: "local-container", Status: "fail", Message: "no container runtime (podman or docker) responding"}} } func checkRemoteDeps() []CheckResult { diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go index 3a2e670..44168c0 100644 --- a/cmd/doctor_test.go +++ b/cmd/doctor_test.go @@ -76,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) } } @@ -175,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) From aacc57cbe336bb4d9f133ae99e0f48ce669f0726 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 14:36:09 -0700 Subject: [PATCH 4/6] refactor: simplify gateway names to openshift and helm Rename helm-openshift-route -> openshift and helm-nodeport -> helm. Shorter, clearer names that match how teams refer to targets. --- Makefile | 2 +- README.md | 2 +- SPEC.md | 4 +-- cmd/deploy.go | 2 +- cmd/deploy_test.go | 4 +-- cmd/doctor_test.go | 2 +- cmd/helpers_test.go | 4 +-- cmd/init_cmd_test.go | 12 ++++---- main.go | 8 +++--- profiles/README.md | 2 +- profiles/agent-ocp.yaml | 2 +- profiles/gateways/README.md | 10 +++---- .../{helm-nodeport.yaml => helm.yaml} | 0 ...lm-openshift-route.yaml => openshift.yaml} | 0 test/kind-lifecycle.sh | 2 +- test/test-flow.sh | 28 +++++++++---------- 16 files changed, 42 insertions(+), 42 deletions(-) rename profiles/gateways/{helm-nodeport.yaml => helm.yaml} (100%) rename profiles/gateways/{helm-openshift-route.yaml => openshift.yaml} (100%) diff --git a/Makefile b/Makefile index b0021ca..388b217 100644 --- a/Makefile +++ b/Makefile @@ -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 helm-openshift-route + HARNESS_OS_IMAGE=$(IMAGE) ./test/test-flow.sh openshift ## All: unit + local + kind + remote test-all: test test-local test-kind test-remote diff --git a/README.md b/README.md index 28a6fcb..52b7d74 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,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 helm-openshift-route +harness apply -f harness.yaml --attach --gateway openshift # OpenCode instead of Claude harness apply -f harness.yaml --attach --entrypoint opencode diff --git a/SPEC.md b/SPEC.md index 8476560..55b7d00 100644 --- a/SPEC.md +++ b/SPEC.md @@ -6,8 +6,8 @@ Behavior specification for the OpenShell Harness CLI. The harness deploys and manages AI agent sandboxes on three targets: - **local-container** -- Podman containers via a local OpenShell gateway -- **helm-nodeport** -- Kubernetes pods via a kind cluster (NodePort access) -- **helm-openshift-route** -- Kubernetes pods via an OpenShift-hosted OpenShell gateway (Route access) +- **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). diff --git a/cmd/deploy.go b/cmd/deploy.go index a5b2125..7ef884d 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -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-container, helm-nodeport, helm-openshift-route). Reads configuration from profiles/gateways/.yaml.", + Long: "Deploy a gateway by name (e.g., local-container, helm, openshift). Reads configuration from profiles/gateways/.yaml.", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { gatewayName, err := resolveGatewayName(args) diff --git a/cmd/deploy_test.go b/cmd/deploy_test.go index f21b41b..1b57f3d 100644 --- a/cmd/deploy_test.go +++ b/cmd/deploy_test.go @@ -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", "helm-openshift-route") + 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(` @@ -53,7 +53,7 @@ secrets: func setupK8sGatewayConfig(t *testing.T, dir string) string { t.Helper() - gwDir := filepath.Join(dir, "gateways", "helm-nodeport") + gwDir := filepath.Join(dir, "gateways", "helm") os.MkdirAll(gwDir, 0o755) os.WriteFile(filepath.Join(gwDir, "gateway.yaml"), []byte(` gateway: diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go index 44168c0..ad4c7d0 100644 --- a/cmd/doctor_test.go +++ b/cmd/doctor_test.go @@ -53,7 +53,7 @@ func TestCheckTargetDeps_Local(t *testing.T) { func TestCheckTargetDeps_Remote(t *testing.T) { cfg := testAgentConfig(t) - cfg.Gateway = "helm-openshift-route" + cfg.Gateway = "openshift" results := checkTargetDeps(cfg, "", "") if len(results) < 1 { t.Fatal("expected at least 1 result for remote") diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index 8ab0d54..d47ab58 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -12,8 +12,8 @@ import ( func init() { EmbeddedGatewayProfiles = map[string][]byte{ "local-container": []byte("gateway:\n type: local\n"), - "helm-nodeport": []byte("gateway:\n type: remote\n platform: k8s\n service: nodeport\n"), - "helm-openshift-route": []byte("gateway:\n type: remote\n platform: ocp\n service: route\n"), + "helm": []byte("gateway:\n type: remote\n platform: k8s\n service: nodeport\n"), + "openshift": []byte("gateway:\n type: remote\n platform: ocp\n service: route\n"), } } diff --git a/cmd/init_cmd_test.go b/cmd/init_cmd_test.go index 8bbd109..8a27064 100644 --- a/cmd/init_cmd_test.go +++ b/cmd/init_cmd_test.go @@ -189,7 +189,7 @@ func TestInitRun_InteractiveGatewayKind(t *testing.T) { outPath := filepath.Join(dir, "harness.yaml") var buf bytes.Buffer - input := "claude\n1\nhelm-nodeport\n" + input := "claude\n1\nhelm\n" err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig, "") if err != nil { t.Fatalf("initRun: %v", err) @@ -198,8 +198,8 @@ func TestInitRun_InteractiveGatewayKind(t *testing.T) { data, _ := os.ReadFile(outPath) var cfg agent.AgentConfig yaml.Unmarshal(data, &cfg) - if cfg.Gateway != "helm-nodeport" { - t.Errorf("Gateway = %q, want helm-nodeport", cfg.Gateway) + if cfg.Gateway != "helm" { + t.Errorf("Gateway = %q, want helm", cfg.Gateway) } } @@ -208,7 +208,7 @@ func TestInitRun_InteractiveGatewayOCP(t *testing.T) { outPath := filepath.Join(dir, "harness.yaml") var buf bytes.Buffer - input := "claude\n1\nhelm-openshift-route\n" + input := "claude\n1\nopenshift\n" err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig, "") if err != nil { t.Fatalf("initRun: %v", err) @@ -217,8 +217,8 @@ func TestInitRun_InteractiveGatewayOCP(t *testing.T) { data, _ := os.ReadFile(outPath) var cfg agent.AgentConfig yaml.Unmarshal(data, &cfg) - if cfg.Gateway != "helm-openshift-route" { - t.Errorf("Gateway = %q, want helm-openshift-route", cfg.Gateway) + if cfg.Gateway != "openshift" { + t.Errorf("Gateway = %q, want openshift", cfg.Gateway) } } diff --git a/main.go b/main.go index 66c974e..7b0c897 100644 --- a/main.go +++ b/main.go @@ -19,10 +19,10 @@ var defaultAgentConfig []byte //go:embed profiles/gateways/local-container.yaml var localContainerGatewayProfile []byte -//go:embed profiles/gateways/helm-nodeport.yaml +//go:embed profiles/gateways/helm.yaml var helmNodeportGatewayProfile []byte -//go:embed profiles/gateways/helm-openshift-route.yaml +//go:embed profiles/gateways/openshift.yaml var helmOpenshiftRouteGatewayProfile []byte func main() { @@ -55,8 +55,8 @@ func main() { cmd.DefaultAgentConfig = defaultAgentConfig cmd.EmbeddedGatewayProfiles = map[string][]byte{ "local-container": localContainerGatewayProfile, - "helm-nodeport": helmNodeportGatewayProfile, - "helm-openshift-route": helmOpenshiftRouteGatewayProfile, + "helm": helmNodeportGatewayProfile, + "openshift": helmOpenshiftRouteGatewayProfile, } root.CompletionOptions.HiddenDefaultCmd = true diff --git a/profiles/README.md b/profiles/README.md index 26d179f..7cd4880 100644 --- a/profiles/README.md +++ b/profiles/README.md @@ -10,7 +10,7 @@ Define what runs in the sandbox. One agent config = one sandbox. name: agent # sandbox name entrypoint: claude # claude, opencode, bash, or any binary on PATH tty: true # enable TTY (default: true) -gateway: helm-openshift-route # target gateway (default: local-container) +gateway: openshift # target gateway (default: local-container) task: @tasks/review.md # task file passed to entrypoint via -p image: ghcr.io/... # override sandbox image policy: path/to/policy.yaml # network policy file diff --git a/profiles/agent-ocp.yaml b/profiles/agent-ocp.yaml index 79fcaa3..57ba467 100644 --- a/profiles/agent-ocp.yaml +++ b/profiles/agent-ocp.yaml @@ -7,7 +7,7 @@ # Requires: KUBECONFIG, kubectl, helm, cluster access. name: agent -gateway: helm-openshift-route +gateway: openshift entrypoint: claude tty: true diff --git a/profiles/gateways/README.md b/profiles/gateways/README.md index b81d6d9..3f57e6c 100644 --- a/profiles/gateways/README.md +++ b/profiles/gateways/README.md @@ -50,11 +50,11 @@ secrets: The default. Requires openshell installed and running via `brew services start openshell` or equivalent. No Helm, no K8s. -### `helm-nodeport.yaml` -- local kind cluster +### `helm.yaml` -- local kind cluster Deploys to a kind cluster. Uses NodePort access (no Ingress needed). TLS disabled for local dev simplicity. Requires `kind create cluster`. -### `helm-openshift-route.yaml` -- OpenShift cluster +### `openshift.yaml` -- OpenShift cluster Deploys to an OpenShift cluster with Route-based access and mTLS. Requires `oc login` and cluster-admin for SCC grants. @@ -62,15 +62,15 @@ Deploys to an OpenShift cluster with Route-based access and mTLS. Requires `oc l ```bash harness apply -f harness.yaml # uses local (default) -harness apply -f harness.yaml --gateway helm-openshift-route # uses gateways/helm-openshift-route.yaml -harness apply -f harness.yaml --gateway helm-nodeport # uses gateways/helm-nodeport.yaml +harness apply -f harness.yaml --gateway openshift # uses gateways/openshift.yaml +harness apply -f harness.yaml --gateway helm # uses gateways/helm.yaml ``` Agent configs can also set a default gateway: ```yaml name: agent -gateway: helm-openshift-route +gateway: openshift ``` The `OPENSHELL_GATEWAY` env var works as a fallback. diff --git a/profiles/gateways/helm-nodeport.yaml b/profiles/gateways/helm.yaml similarity index 100% rename from profiles/gateways/helm-nodeport.yaml rename to profiles/gateways/helm.yaml diff --git a/profiles/gateways/helm-openshift-route.yaml b/profiles/gateways/openshift.yaml similarity index 100% rename from profiles/gateways/helm-openshift-route.yaml rename to profiles/gateways/openshift.yaml diff --git a/test/kind-lifecycle.sh b/test/kind-lifecycle.sh index f3b5696..a295a9d 100755 --- a/test/kind-lifecycle.sh +++ b/test/kind-lifecycle.sh @@ -18,7 +18,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" CLUSTER_NAME="openshell-test" KIND_KUBECONFIG="" KEEP_CLUSTER=false -TEST_ARGS=("helm-nodeport") +TEST_ARGS=("helm") for arg in "$@"; do case "$arg" in diff --git a/test/test-flow.sh b/test/test-flow.sh index 5887b03..69bedd8 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -6,9 +6,9 @@ # # Usage: # ./test-flow.sh local-container # full test with credentials -# ./test-flow.sh helm-nodeport # full test on kind cluster -# ./test-flow.sh helm-openshift-route # full test on OCP -# ./test-flow.sh helm-openshift-route --reuse-gateway # skip deploy/teardown +# ./test-flow.sh helm # full test on kind cluster +# ./test-flow.sh openshift # full test on OCP +# ./test-flow.sh openshift --reuse-gateway # skip deploy/teardown # ./test-flow.sh all # all gateways set -uo pipefail @@ -50,7 +50,7 @@ for arg in "$@"; do done if [[ -z "$TARGET" ]]; then - echo "Usage: $0 [--ci] [--reuse-gateway] [--debug]" + echo "Usage: $0 [--ci] [--reuse-gateway] [--debug]" exit 1 fi @@ -251,7 +251,7 @@ test_gws() { test_kind() { local mode="full" $NO_PROVIDERS && mode="$mode, no-providers" - echo "=== test-flow: helm-nodeport ($mode) ===" + echo "=== test-flow: helm ($mode) ===" if ! kubectl get nodes &>/dev/null; then echo " ERROR: no kind cluster — run: kind create cluster --name openshell" @@ -260,11 +260,11 @@ test_kind() { fi step "teardown" harness delete --sandboxes --providers --k8s - step "deploy" harness deploy helm-nodeport + step "deploy" harness deploy helm step "gateway reachable" "$CLI" inference get local sandbox_name="test-kind" - step "sandbox create" harness apply --gateway helm-nodeport --name "$sandbox_name" $AGENT_FLAG "$PROFILE" + step "sandbox create" harness apply --gateway helm --name "$sandbox_name" $AGENT_FLAG "$PROFILE" sandbox_verify "$sandbox_name" if ! $NO_PROVIDERS; then @@ -282,7 +282,7 @@ test_kind() { test_ocp() { local mode="full" $REUSE_GATEWAY && mode="$mode, reuse-gateway" - echo "=== test-flow: helm-openshift-route ($mode) ===" + echo "=== test-flow: openshift ($mode) ===" if $REUSE_GATEWAY; then OCP_GW=$("$CLI" gateway list 2>/dev/null | strip_ansi | awk '/-remote-/ {gsub(/^\*/, ""); print $1; exit}') @@ -290,13 +290,13 @@ test_ocp() { step "teardown sandboxes+providers" harness delete --sandboxes --providers if ! "$CLI" inference get &>/dev/null; then - step "deploy" harness deploy helm-openshift-route + step "deploy" harness deploy openshift else step "gateway reachable" "$CLI" inference get fi else step "teardown" harness delete --sandboxes --providers --k8s - step "deploy" harness deploy helm-openshift-route + step "deploy" harness deploy openshift fi local sandbox_name @@ -305,7 +305,7 @@ test_ocp() { step "sandbox create" harness apply -f test/ci-agent.yaml --name "$sandbox_name" else sandbox_name="agent" - step "sandbox create (up)" harness apply --gateway helm-openshift-route --name "$sandbox_name" + step "sandbox create (up)" harness apply --gateway openshift --name "$sandbox_name" fi sandbox_verify "$sandbox_name" @@ -324,12 +324,12 @@ test_errors case "$TARGET" in local-container|local|podman) test_local ;; - helm-nodeport|kind) test_kind ;; - helm-openshift-route|ocp) test_ocp ;; + helm|kind) test_kind ;; + openshift|ocp) test_ocp ;; all) test_local; echo ""; test_kind; echo ""; test_ocp ;; *) echo "Unknown target: $TARGET" - echo "Usage: $0 [--ci] [--reuse-gateway]" + echo "Usage: $0 [--ci] [--reuse-gateway]" exit 1 ;; esac From 181c73a5d7df6c8cc57f3f16f69bfd4c6a6c9d6d Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 14:48:26 -0700 Subject: [PATCH 5/6] feat: add repo field for cloning repos into sandbox Clone a git repository outside the sandbox (shallow, --depth 1) and upload it via --upload. Git credentials never enter the sandbox. The agent gets a working copy at /sandbox/ to read and modify. Results can be extracted via sandbox exec, stdout in --task mode, or the agent can push via the github provider's scoped token. --- README.md | 36 +++++++++++++++++++++++++++++++++ SPEC.md | 1 + TODO.md | 2 +- cmd/executor.go | 45 +++++++++++++++++++++++++++++++++++++++++ internal/agent/agent.go | 1 + profiles/README.md | 3 ++- 6 files changed, 86 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 52b7d74..b70f287 100644 --- a/README.md +++ b/README.md @@ -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 -- cat /sandbox/collector/report.md > report.md + +# Extract a diff +openshell sandbox exec -- git -C /sandbox/collector diff > changes.patch + +# Download files +openshell sandbox exec -- 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. @@ -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 diff --git a/SPEC.md b/SPEC.md index 55b7d00..7f46025 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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/`. 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 diff --git a/TODO.md b/TODO.md index c4f83b9..6f395d5 100644 --- a/TODO.md +++ b/TODO.md @@ -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] diff --git a/cmd/executor.go b/cmd/executor.go index bf672a7..bd3b3bc 100644 --- a/cmd/executor.go +++ b/cmd/executor.go @@ -3,7 +3,10 @@ package cmd import ( "fmt" "os" + "os/exec" + "path" "path/filepath" + "strings" "time" "github.com/robbycochran/harness-openshell/internal/agent" @@ -72,6 +75,17 @@ func upLocal(opts upLocalOpts) error { registered := ensureProviders(opts.harnessDir, gw, agentCfg, opts.providerRefresh, opts.harness) + // Clone repo outside the sandbox so git credentials never enter it. + var repoUpload *gateway.Upload + if agentCfg.Repo != "" { + upload, cleanup, err := cloneRepo(agentCfg.Repo) + if err != nil { + return fmt.Errorf("cloning repo: %w", err) + } + defer cleanup() + repoUpload = &upload + } + payloadDir, err := os.MkdirTemp("", "harness-payload-") if err != nil { return fmt.Errorf("creating payload dir: %w", err) @@ -94,6 +108,10 @@ func upLocal(opts upLocalOpts) error { } } + if repoUpload != nil { + extraUploads = append(extraUploads, *repoUpload) + } + status.Header("Sandbox") var sandboxCmd []string if noTTY && agentCfg.Task == "" { @@ -143,3 +161,30 @@ func upLocal(opts upLocalOpts) error { return nil } + +// cloneRepo clones a git repository to a temp directory and returns an Upload +// 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) { + repoName := strings.TrimSuffix(path.Base(repo), ".git") + tmpDir, err := os.MkdirTemp("", "harness-repo-") + if err != nil { + return gateway.Upload{}, nil, fmt.Errorf("creating temp dir: %w", err) + } + cleanup := func() { os.RemoveAll(tmpDir) } + + cloneDir := filepath.Join(tmpDir, repoName) + status.Infof("Repo: %s", repo) + + cmd := exec.Command("git", "clone", "--depth", "1", repo, cloneDir) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + cleanup() + return gateway.Upload{}, nil, fmt.Errorf("git clone %s: %w", repo, err) + } + + status.OKf("Cloned %s", repoName) + return gateway.Upload{Src: cloneDir, Dst: "/sandbox"}, cleanup, nil +} diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 911fe7e..94115c6 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -25,6 +25,7 @@ type PayloadEntry struct { type AgentConfig struct { Name string `yaml:"name"` Gateway string `yaml:"gateway,omitempty"` + Repo string `yaml:"repo,omitempty"` Providers []ProviderRef `yaml:"providers"` Env map[string]string `yaml:"env,omitempty"` Task string `yaml:"task,omitempty"` diff --git a/profiles/README.md b/profiles/README.md index 7cd4880..9a56053 100644 --- a/profiles/README.md +++ b/profiles/README.md @@ -10,7 +10,8 @@ Define what runs in the sandbox. One agent config = one sandbox. name: agent # sandbox name entrypoint: claude # claude, opencode, bash, or any binary on PATH tty: true # enable TTY (default: true) -gateway: openshift # target gateway (default: local-container) +repo: https://github.com/org/repo # cloned outside sandbox, uploaded to /sandbox/ +gateway: openshift # target gateway (default: local-container) task: @tasks/review.md # task file passed to entrypoint via -p image: ghcr.io/... # override sandbox image policy: path/to/policy.yaml # network policy file From 26c8a200ea16dc39e5874dae6044672ac11185a4 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 14:59:43 -0700 Subject: [PATCH 6/6] docs: add testing section to README --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index b70f287..432ac59 100644 --- a/README.md +++ b/README.md @@ -209,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 |