From 1c4e0a3e18ce3fc0421f99a7f3d8c10d08af26c4 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Thu, 4 Jun 2026 13:10:32 -0700 Subject: [PATCH 1/6] X-Smart-Branch-Parent: main From bba77b9a1d4780dbe102e58f337352fb130f63c1 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Thu, 4 Jun 2026 13:31:58 -0700 Subject: [PATCH 2/6] feat: Go harness CLI with Cobra, native `new --local` with provider validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cobra CLI replacing the bash bin/harness dispatcher. All 7 subcommands registered: new, connect, deploy, teardown, preflight, providers, test. The `new --local` path is implemented natively in Go — parses profile TOML, validates that profile providers are registered on the gateway (✓/✗ for each), stages env + GWS creds, creates sandbox with retry. Remote path and other subcommands delegate to existing bash scripts. Package structure: - internal/profile/ — Config struct, TOML parsing, ValidateProviders, StageHarnessDir (6 unit tests) - internal/runner/ — RunScript, RunCLI, Exec helpers for os/exec - cmd/ — one file per subcommand Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + Makefile | 15 ++- cmd/connect.go | 21 ++++ cmd/deploy.go | 17 ++++ cmd/new.go | 163 +++++++++++++++++++++++++++++++ cmd/preflight.go | 17 ++++ cmd/providers.go | 17 ++++ cmd/teardown.go | 17 ++++ cmd/test.go | 26 +++++ go.mod | 13 +++ go.sum | 12 +++ internal/profile/parse.go | 29 ++++++ internal/profile/profile.go | 115 ++++++++++++++++++++++ internal/profile/profile_test.go | 138 ++++++++++++++++++++++++++ internal/runner/exec_unix.go | 9 ++ internal/runner/runner.go | 52 ++++++++++ main.go | 66 +++++++++++++ 17 files changed, 724 insertions(+), 4 deletions(-) create mode 100644 cmd/connect.go create mode 100644 cmd/deploy.go create mode 100644 cmd/new.go create mode 100644 cmd/preflight.go create mode 100644 cmd/providers.go create mode 100644 cmd/teardown.go create mode 100644 cmd/test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/profile/parse.go create mode 100644 internal/profile/profile.go create mode 100644 internal/profile/profile_test.go create mode 100644 internal/runner/exec_unix.go create mode 100644 internal/runner/runner.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore index 4680f41..bdfe82d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ __pycache__/ .certs/ # Build artifacts +harness sandbox/launcher/openshell sandbox/launcher/launcher infracluster/ diff --git a/Makefile b/Makefile index fe0b34a..9d9ed69 100644 --- a/Makefile +++ b/Makefile @@ -13,9 +13,16 @@ PLATFORM := linux/amd64 SANDBOX_IMAGE := $(REGISTRY):sandbox LAUNCHER_IMAGE := $(REGISTRY):launcher -.PHONY: sandbox push-sandbox cli-launcher launcher push-launcher \ +.PHONY: cli sandbox push-sandbox cli-launcher launcher push-launcher \ test test-podman test-ocp clean help +## ── CLI ────────────────────────────────────────────────────────────── + +## Build the harness CLI binary +cli: + CGO_ENABLED=0 go build -o harness . + @echo "Built: ./harness" + ## ── Images ──────────────────────────────────────────────────────────── ## Sandbox image (Claude Code + mcp-atlassian + gws, multi-arch) @@ -56,10 +63,10 @@ test-ocp: sandbox push-launcher ## ── Convenience targets ─────────────────────────────────────────────── -## Clean staged binaries +## Clean built binaries clean: - rm -f sandbox/launcher/openshell sandbox/launcher/launcher - @echo "Cleaned staged binaries" + rm -f harness sandbox/launcher/openshell sandbox/launcher/launcher + @echo "Cleaned binaries" ## Show available targets help: diff --git a/cmd/connect.go b/cmd/connect.go new file mode 100644 index 0000000..c6b6ff9 --- /dev/null +++ b/cmd/connect.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "github.com/robbycochran/harness-openshell/internal/runner" + "github.com/spf13/cobra" +) + +func NewConnectCmd(cli string) *cobra.Command { + return &cobra.Command{ + Use: "connect [SANDBOX_NAME]", + Short: "Reconnect to a running sandbox", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cliArgs := []string{"sandbox", "connect"} + if len(args) > 0 { + cliArgs = append(cliArgs, args[0]) + } + return runner.Exec(cli, cliArgs...) + }, + } +} diff --git a/cmd/deploy.go b/cmd/deploy.go new file mode 100644 index 0000000..82f1735 --- /dev/null +++ b/cmd/deploy.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "github.com/robbycochran/harness-openshell/internal/runner" + "github.com/spf13/cobra" +) + +func NewDeployCmd(harnessDir string) *cobra.Command { + return &cobra.Command{ + Use: "deploy [--local|--remote]", + Short: "Deploy or verify the gateway", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runner.RunScript(harnessDir, "deploy.sh", args...) + }, + } +} diff --git a/cmd/new.go b/cmd/new.go new file mode 100644 index 0000000..eaf8c7c --- /dev/null +++ b/cmd/new.go @@ -0,0 +1,163 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/robbycochran/harness-openshell/internal/profile" + "github.com/robbycochran/harness-openshell/internal/runner" + "github.com/spf13/cobra" +) + +func NewNewCmd(harnessDir, cli string) *cobra.Command { + var ( + local bool + remote bool + profileName string + sandboxName string + noTTY bool + ) + + cmd := &cobra.Command{ + Use: "new [flags]", + Short: "Create a new sandbox", + Long: "Deploy gateway and providers if needed, then create a sandbox from a profile.", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 && sandboxName == "" { + sandboxName = args[0] + } + + if remote { + return newRemote(harnessDir, profileName, sandboxName, noTTY) + } + return newLocal(harnessDir, cli, local, profileName, sandboxName, noTTY) + }, + } + + cmd.Flags().BoolVar(&local, "local", false, "Ensure local podman gateway") + cmd.Flags().BoolVar(&remote, "remote", false, "Ensure OCP gateway") + cmd.Flags().StringVar(&profileName, "profile", "default", "Profile name (from profiles/)") + cmd.Flags().StringVar(&sandboxName, "name", "", "Sandbox name (overrides profile)") + cmd.Flags().BoolVar(&noTTY, "no-tty", false, "Non-interactive mode (for testing)") + + return cmd +} + +func newRemote(harnessDir, profileName, sandboxName string, noTTY bool) error { + args := []string{"--remote", "--profile", profileName} + if sandboxName != "" { + args = append(args, "--name", sandboxName) + } + if noTTY { + args = append(args, "--no-tty") + } + return runner.RunScript(harnessDir, "new.sh", args...) +} + +func newLocal(harnessDir, cli string, ensureLocal bool, profileName, sandboxName string, noTTY bool) error { + // 1. Ensure gateway + if ensureLocal { + fmt.Println("=== Ensuring local gateway ===") + if err := runner.RunScript(harnessDir, "deploy.sh", "--local"); err != nil { + return fmt.Errorf("deploy failed: %w", err) + } + } else { + if err := runner.RunCLISilent(cli, "inference", "get"); err != nil { + return fmt.Errorf("no active gateway — use --local or --remote") + } + } + + // 2. Ensure providers + out, _ := runner.RunCLIOutput(cli, "provider", "list") + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) <= 1 { + fmt.Println("\n=== Registering providers ===") + if err := runner.RunScript(harnessDir, "providers.sh"); err != nil { + return fmt.Errorf("provider registration failed: %w", err) + } + } + + // 3. Parse profile + cfg, err := profile.Parse(harnessDir, profileName) + if err != nil { + return err + } + if sandboxName != "" { + cfg.Name = sandboxName + } + + fmt.Println() + fmt.Println("=== Sandbox ===") + fmt.Printf(" Profile: %s\n", profileName) + fmt.Printf(" Image: %s\n", cfg.Image) + + // 4. Validate providers against profile + fmt.Println() + fmt.Println("=== Providers ===") + registered, missing := profile.ValidateProviders(cfg.Providers, cli) + for _, name := range registered { + fmt.Printf(" ✓ %s: attached\n", name) + } + for _, name := range missing { + fmt.Printf(" ✗ %s: not registered (skipping)\n", name) + } + if len(missing) > 0 && len(registered) == 0 { + fmt.Println() + fmt.Println("WARNING: no providers available. Run: harness providers") + } + + // 5. Stage files + harnessUploadDir := "/tmp/openshell" + os.RemoveAll(harnessUploadDir) + if err := profile.StageHarnessDir(cfg, harnessUploadDir); err != nil { + return fmt.Errorf("staging files: %w", err) + } + + // 6. Create sandbox with retry + ttyFlag := "--tty" + if noTTY { + ttyFlag = "--no-tty" + } + + var sandboxCmd []string + if noTTY { + sandboxCmd = []string{"--", "bash", "/sandbox/startup.sh"} + } else { + sandboxCmd = []string{"--", "bash", "-c", fmt.Sprintf(". /sandbox/startup.sh && exec %s", cfg.Command)} + } + + fmt.Println() + fmt.Println("=== Creating sandbox ===") + for attempt := 1; attempt <= 5; attempt++ { + args := []string{"sandbox", "create", ttyFlag} + args = append(args, "--name", cfg.Name) + if cfg.Image != "" { + args = append(args, "--from", cfg.Image) + } + for _, p := range registered { + args = append(args, "--provider", p) + } + args = append(args, "--upload", harnessUploadDir+":/sandbox/.config", "--no-git-ignore") + args = append(args, sandboxCmd...) + + c := exec.Command(cli, args...) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if c.Run() == nil { + return nil + } + + fmt.Printf(" Attempt %d failed (supervisor race), retrying in 5s...\n", attempt) + runner.RunCLISilent(cli, "sandbox", "delete", cfg.Name) + + if attempt == 5 { + return fmt.Errorf("failed after 5 attempts") + } + time.Sleep(5 * time.Second) + } + return nil +} diff --git a/cmd/preflight.go b/cmd/preflight.go new file mode 100644 index 0000000..62782e1 --- /dev/null +++ b/cmd/preflight.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "github.com/robbycochran/harness-openshell/internal/runner" + "github.com/spf13/cobra" +) + +func NewPreflightCmd(harnessDir string) *cobra.Command { + return &cobra.Command{ + Use: "preflight [--strict]", + Short: "Check environment prerequisites", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runner.RunScript(harnessDir, "preflight.sh", args...) + }, + } +} diff --git a/cmd/providers.go b/cmd/providers.go new file mode 100644 index 0000000..843f164 --- /dev/null +++ b/cmd/providers.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "github.com/robbycochran/harness-openshell/internal/runner" + "github.com/spf13/cobra" +) + +func NewProvidersCmd(harnessDir string) *cobra.Command { + return &cobra.Command{ + Use: "providers [--force]", + Short: "Register providers with the gateway", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runner.RunScript(harnessDir, "providers.sh", args...) + }, + } +} diff --git a/cmd/teardown.go b/cmd/teardown.go new file mode 100644 index 0000000..6cb2e6f --- /dev/null +++ b/cmd/teardown.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "github.com/robbycochran/harness-openshell/internal/runner" + "github.com/spf13/cobra" +) + +func NewTeardownCmd(harnessDir string) *cobra.Command { + return &cobra.Command{ + Use: "teardown [--sandboxes] [--providers] [--k8s]", + Short: "Tear down sandboxes, providers, or k8s resources", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runner.RunScript(harnessDir, "teardown.sh", args...) + }, + } +} diff --git a/cmd/test.go b/cmd/test.go new file mode 100644 index 0000000..c679819 --- /dev/null +++ b/cmd/test.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "os" + "os/exec" + "path/filepath" + + "github.com/spf13/cobra" +) + +func NewTestCmd(harnessDir string) *cobra.Command { + return &cobra.Command{ + Use: "test [podman|ocp|all] [--full]", + Short: "End-to-end validation", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + path := filepath.Join(harnessDir, "test", "test-flow.sh") + c := exec.Command("bash", append([]string{path}, args...)...) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + c.Dir = harnessDir + return c.Run() + }, + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9ce1bc4 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/robbycochran/harness-openshell + +go 1.22.4 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c09a7f6 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/profile/parse.go b/internal/profile/parse.go new file mode 100644 index 0000000..e00ee66 --- /dev/null +++ b/internal/profile/parse.go @@ -0,0 +1,29 @@ +package profile + +import ( + "fmt" + "path/filepath" + + "github.com/BurntSushi/toml" +) + +// Parse reads a profile TOML file and returns a Config with defaults applied. +func Parse(harnessDir, name string) (*Config, error) { + path := filepath.Join(harnessDir, "profiles", name+".toml") + return ParseFile(path) +} + +// ParseFile reads a profile TOML file by path. +func ParseFile(path string) (*Config, error) { + var cfg Config + if _, err := toml.DecodeFile(path, &cfg); err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + if cfg.Name == "" { + cfg.Name = "agent" + } + if cfg.Command == "" { + cfg.Command = "claude --bare" + } + return &cfg, nil +} diff --git a/internal/profile/profile.go b/internal/profile/profile.go new file mode 100644 index 0000000..2a33dc3 --- /dev/null +++ b/internal/profile/profile.go @@ -0,0 +1,115 @@ +package profile + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +type Config struct { + Name string `toml:"name"` + Image string `toml:"image"` + Command string `toml:"command"` + Keep *bool `toml:"keep"` + Providers []string `toml:"providers"` + Env map[string]string `toml:"env"` +} + +func (c *Config) KeepSandbox() bool { + if c.Keep == nil { + return true + } + return *c.Keep +} + +func (c *Config) BuildSandboxEnv() string { + if len(c.Env) == 0 { + return "" + } + keys := make([]string, 0, len(c.Env)) + for k := range c.Env { + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for _, k := range keys { + fmt.Fprintf(&b, "export %s=%s\n", k, c.Env[k]) + } + return b.String() +} + +// ValidateProviders checks which profile providers are registered on the +// gateway. Returns the list of registered providers and a list of missing ones. +func ValidateProviders(providers []string, cli string) (registered, missing []string) { + for _, name := range providers { + cmd := exec.Command(cli, "provider", "get", name) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + if cmd.Run() == nil { + registered = append(registered, name) + } else { + missing = append(missing, name) + } + } + return +} + +// StageHarnessDir writes sandbox.env and copies GWS credentials to harnessDir. +func StageHarnessDir(cfg *Config, harnessDir string) error { + if err := os.MkdirAll(harnessDir, 0o755); err != nil { + return err + } + + envContent := cfg.BuildSandboxEnv() + if envContent != "" { + if err := os.WriteFile(filepath.Join(harnessDir, "sandbox.env"), []byte(envContent), 0o644); err != nil { + return fmt.Errorf("writing sandbox.env: %w", err) + } + lines := strings.Count(envContent, "\n") + fmt.Printf(" Env: %d vars staged\n", lines) + } + + if err := stageGWSCreds(harnessDir); err != nil { + fmt.Printf(" GWS: %v\n", err) + } + return nil +} + +func stageGWSCreds(harnessDir string) error { + gwsPath, err := exec.LookPath("gws") + if err != nil { + return fmt.Errorf("not installed (skipping)") + } + + check := exec.Command(gwsPath, "auth", "status") + check.Stdout = io.Discard + check.Stderr = io.Discard + if check.Run() != nil { + return fmt.Errorf("not authenticated (skipping)") + } + + out, err := exec.Command(gwsPath, "auth", "export", "--unmasked").Output() + if err != nil { + return fmt.Errorf("export failed (skipping)") + } + if err := os.WriteFile(filepath.Join(harnessDir, "credentials.json"), out, 0o600); err != nil { + return err + } + + gwsConfigDir := os.Getenv("GWS_CONFIG_DIR") + if gwsConfigDir == "" { + home, _ := os.UserHomeDir() + gwsConfigDir = filepath.Join(home, ".config", "gws") + } + clientSecret := filepath.Join(gwsConfigDir, "client_secret.json") + if data, err := os.ReadFile(clientSecret); err == nil { + os.WriteFile(filepath.Join(harnessDir, "client_secret.json"), data, 0o600) + } + + fmt.Println(" GWS: exported") + return nil +} diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go new file mode 100644 index 0000000..04f124a --- /dev/null +++ b/internal/profile/profile_test.go @@ -0,0 +1,138 @@ +package profile + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseFile_Full(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + os.WriteFile(path, []byte(` +name = "research" +image = "quay.io/test/sandbox:latest" +command = "claude --bare --model opus" +keep = false +providers = ["github", "vertex-local"] + +[env] +ANTHROPIC_BASE_URL = "https://inference.local" +JIRA_URL = "https://example.atlassian.net" +`), 0o644) + + cfg, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + if cfg.Name != "research" { + t.Errorf("Name = %q, want %q", cfg.Name, "research") + } + if cfg.Image != "quay.io/test/sandbox:latest" { + t.Errorf("Image = %q", cfg.Image) + } + if cfg.Command != "claude --bare --model opus" { + t.Errorf("Command = %q", cfg.Command) + } + if cfg.KeepSandbox() { + t.Error("KeepSandbox() = true, want false") + } + if len(cfg.Providers) != 2 || cfg.Providers[0] != "github" { + t.Errorf("Providers = %v", cfg.Providers) + } + if cfg.Env["JIRA_URL"] != "https://example.atlassian.net" { + t.Errorf("Env[JIRA_URL] = %q", cfg.Env["JIRA_URL"]) + } +} + +func TestParseFile_Defaults(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + os.WriteFile(path, []byte(`image = "quay.io/test:latest"`), 0o644) + + cfg, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + if cfg.Name != "agent" { + t.Errorf("Name = %q, want default 'agent'", cfg.Name) + } + if cfg.Command != "claude --bare" { + t.Errorf("Command = %q, want default", cfg.Command) + } + if !cfg.KeepSandbox() { + t.Error("KeepSandbox() = false, want true (default)") + } +} + +func TestParseFile_Missing(t *testing.T) { + _, err := ParseFile("/nonexistent.toml") + if err == nil { + t.Error("expected error for missing file") + } +} + +func TestParse_ByName(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "profiles"), 0o755) + os.WriteFile(filepath.Join(dir, "profiles", "test.toml"), []byte(` +name = "test-agent" +image = "test:latest" +`), 0o644) + + cfg, err := Parse(dir, "test") + if err != nil { + t.Fatalf("Parse: %v", err) + } + if cfg.Name != "test-agent" { + t.Errorf("Name = %q", cfg.Name) + } +} + +func TestBuildSandboxEnv(t *testing.T) { + cfg := &Config{ + Env: map[string]string{ + "ZEBRA": "z", + "APPLE": "a", + }, + } + env := cfg.BuildSandboxEnv() + lines := strings.Split(strings.TrimSpace(env), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d: %q", len(lines), env) + } + if lines[0] != "export APPLE=a" { + t.Errorf("first line = %q (should be sorted)", lines[0]) + } + if lines[1] != "export ZEBRA=z" { + t.Errorf("second line = %q", lines[1]) + } +} + +func TestBuildSandboxEnv_Empty(t *testing.T) { + cfg := &Config{} + if env := cfg.BuildSandboxEnv(); env != "" { + t.Errorf("expected empty, got %q", env) + } +} + +func TestStageHarnessDir(t *testing.T) { + dir := t.TempDir() + harnessDir := filepath.Join(dir, "harness") + + cfg := &Config{ + Env: map[string]string{"FOO": "bar"}, + } + if err := StageHarnessDir(cfg, harnessDir); err != nil { + t.Fatalf("StageHarnessDir: %v", err) + } + + data, err := os.ReadFile(filepath.Join(harnessDir, "sandbox.env")) + if err != nil { + t.Fatalf("reading sandbox.env: %v", err) + } + if !strings.Contains(string(data), "export FOO=bar") { + t.Errorf("sandbox.env = %q", string(data)) + } +} diff --git a/internal/runner/exec_unix.go b/internal/runner/exec_unix.go new file mode 100644 index 0000000..78563ae --- /dev/null +++ b/internal/runner/exec_unix.go @@ -0,0 +1,9 @@ +//go:build !windows + +package runner + +import "syscall" + +func execSyscall(path string, args []string, env []string) error { + return syscall.Exec(path, args, env) +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go new file mode 100644 index 0000000..f951b65 --- /dev/null +++ b/internal/runner/runner.go @@ -0,0 +1,52 @@ +package runner + +import ( + "io" + "os" + "os/exec" + "path/filepath" +) + +// RunScript executes a bash script from bin/scripts/ with stdout/stderr passthrough. +func RunScript(harnessDir, scriptName string, args ...string) error { + path := filepath.Join(harnessDir, "bin", "scripts", scriptName) + cmd := exec.Command("bash", append([]string{path}, args...)...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Dir = harnessDir + return cmd.Run() +} + +// RunCLI executes the openshell CLI with stdout/stderr passthrough. +func RunCLI(cli string, args ...string) error { + cmd := exec.Command(cli, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// RunCLISilent executes the openshell CLI with discarded output. +func RunCLISilent(cli string, args ...string) error { + cmd := exec.Command(cli, args...) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + return cmd.Run() +} + +// RunCLIOutput executes the openshell CLI and returns stdout. +func RunCLIOutput(cli string, args ...string) ([]byte, error) { + cmd := exec.Command(cli, args...) + cmd.Stderr = io.Discard + return cmd.Output() +} + +// Exec replaces the current process with the given command (unix exec). +func Exec(name string, args ...string) error { + path, err := exec.LookPath(name) + if err != nil { + return err + } + return execSyscall(path, append([]string{name}, args...), os.Environ()) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..30c22e1 --- /dev/null +++ b/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/robbycochran/harness-openshell/cmd" + "github.com/spf13/cobra" +) + +func main() { + harnessDir := detectHarnessDir() + + root := &cobra.Command{ + Use: "harness", + Short: "OpenShell Harness — deploy and manage AI agent sandboxes", + } + + cli := os.Getenv("OPENSHELL_CLI") + if cli == "" { + cli = "openshell" + } + + root.AddCommand( + cmd.NewNewCmd(harnessDir, cli), + cmd.NewConnectCmd(cli), + cmd.NewDeployCmd(harnessDir), + cmd.NewTeardownCmd(harnessDir), + cmd.NewPreflightCmd(harnessDir), + cmd.NewProvidersCmd(harnessDir), + cmd.NewTestCmd(harnessDir), + ) + + if err := root.Execute(); err != nil { + os.Exit(1) + } +} + +func detectHarnessDir() string { + if d := os.Getenv("HARNESS_DIR"); d != "" { + return d + } + ex, err := os.Executable() + if err == nil { + dir := filepath.Dir(ex) + for range 5 { + if _, err := os.Stat(filepath.Join(dir, "profiles", "default.toml")); err == nil { + return dir + } + dir = filepath.Dir(dir) + } + } + cwd, err := os.Getwd() + if err == nil { + dir := cwd + for range 5 { + if _, err := os.Stat(filepath.Join(dir, "profiles", "default.toml")); err == nil { + return dir + } + dir = filepath.Dir(dir) + } + } + fmt.Fprintf(os.Stderr, "WARNING: could not detect harness directory (set HARNESS_DIR)\n") + return "." +} From 3eff939925dbe5a6dff7b87a3de0aaf8ef8e1bc0 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Thu, 4 Jun 2026 13:36:40 -0700 Subject: [PATCH 3/6] refactor: extract Gateway interface for future gRPC swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All OpenShell CLI calls now go through the gateway.Gateway interface: InferenceGet, ProviderGet, ProviderList, SandboxCreate, SandboxDelete, SandboxConnect, SandboxUpload, SandboxExec. gateway.CLI implements the interface by shelling out to the openshell binary. A future gateway.GRPC implementation can swap in by changing one line (NewCLI → NewGRPC). ValidateProviders now takes a gateway.Gateway instead of a CLI path string. Tests use a mockGateway (3 new test cases). Runner package simplified to just RunScript (bash wrapper helper). Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/connect.go | 9 +-- cmd/new.go | 59 +++++++-------- internal/gateway/cli.go | 120 +++++++++++++++++++++++++++++++ internal/gateway/gateway.go | 44 ++++++++++++ internal/profile/profile.go | 9 ++- internal/profile/profile_test.go | 56 +++++++++++++++ internal/runner/exec_unix.go | 9 --- internal/runner/runner.go | 34 --------- 8 files changed, 254 insertions(+), 86 deletions(-) create mode 100644 internal/gateway/cli.go create mode 100644 internal/gateway/gateway.go delete mode 100644 internal/runner/exec_unix.go diff --git a/cmd/connect.go b/cmd/connect.go index c6b6ff9..b5595bb 100644 --- a/cmd/connect.go +++ b/cmd/connect.go @@ -1,7 +1,7 @@ package cmd import ( - "github.com/robbycochran/harness-openshell/internal/runner" + "github.com/robbycochran/harness-openshell/internal/gateway" "github.com/spf13/cobra" ) @@ -11,11 +11,12 @@ func NewConnectCmd(cli string) *cobra.Command { Short: "Reconnect to a running sandbox", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - cliArgs := []string{"sandbox", "connect"} + gw := gateway.NewCLI(cli) + name := "" if len(args) > 0 { - cliArgs = append(cliArgs, args[0]) + name = args[0] } - return runner.Exec(cli, cliArgs...) + return gw.SandboxConnect(name) }, } } diff --git a/cmd/new.go b/cmd/new.go index eaf8c7c..472719d 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -3,10 +3,9 @@ package cmd import ( "fmt" "os" - "os/exec" - "strings" "time" + "github.com/robbycochran/harness-openshell/internal/gateway" "github.com/robbycochran/harness-openshell/internal/profile" "github.com/robbycochran/harness-openshell/internal/runner" "github.com/spf13/cobra" @@ -33,7 +32,9 @@ func NewNewCmd(harnessDir, cli string) *cobra.Command { if remote { return newRemote(harnessDir, profileName, sandboxName, noTTY) } - return newLocal(harnessDir, cli, local, profileName, sandboxName, noTTY) + + gw := gateway.NewCLI(cli) + return newLocal(harnessDir, gw, local, profileName, sandboxName, noTTY) }, } @@ -57,7 +58,7 @@ func newRemote(harnessDir, profileName, sandboxName string, noTTY bool) error { return runner.RunScript(harnessDir, "new.sh", args...) } -func newLocal(harnessDir, cli string, ensureLocal bool, profileName, sandboxName string, noTTY bool) error { +func newLocal(harnessDir string, gw gateway.Gateway, ensureLocal bool, profileName, sandboxName string, noTTY bool) error { // 1. Ensure gateway if ensureLocal { fmt.Println("=== Ensuring local gateway ===") @@ -65,15 +66,14 @@ func newLocal(harnessDir, cli string, ensureLocal bool, profileName, sandboxName return fmt.Errorf("deploy failed: %w", err) } } else { - if err := runner.RunCLISilent(cli, "inference", "get"); err != nil { + if err := gw.InferenceGet(); err != nil { return fmt.Errorf("no active gateway — use --local or --remote") } } // 2. Ensure providers - out, _ := runner.RunCLIOutput(cli, "provider", "list") - lines := strings.Split(strings.TrimSpace(string(out)), "\n") - if len(lines) <= 1 { + providers, _ := gw.ProviderList() + if len(providers) == 0 { fmt.Println("\n=== Registering providers ===") if err := runner.RunScript(harnessDir, "providers.sh"); err != nil { return fmt.Errorf("provider registration failed: %w", err) @@ -97,7 +97,7 @@ func newLocal(harnessDir, cli string, ensureLocal bool, profileName, sandboxName // 4. Validate providers against profile fmt.Println() fmt.Println("=== Providers ===") - registered, missing := profile.ValidateProviders(cfg.Providers, cli) + registered, missing := profile.ValidateProviders(cfg.Providers, gw) for _, name := range registered { fmt.Printf(" ✓ %s: attached\n", name) } @@ -116,43 +116,34 @@ func newLocal(harnessDir, cli string, ensureLocal bool, profileName, sandboxName return fmt.Errorf("staging files: %w", err) } - // 6. Create sandbox with retry - ttyFlag := "--tty" - if noTTY { - ttyFlag = "--no-tty" - } - + // 6. Build command var sandboxCmd []string if noTTY { - sandboxCmd = []string{"--", "bash", "/sandbox/startup.sh"} + sandboxCmd = []string{"bash", "/sandbox/startup.sh"} } else { - sandboxCmd = []string{"--", "bash", "-c", fmt.Sprintf(". /sandbox/startup.sh && exec %s", cfg.Command)} + sandboxCmd = []string{"bash", "-c", fmt.Sprintf(". /sandbox/startup.sh && exec %s", cfg.Command)} } + // 7. Create sandbox with retry fmt.Println() fmt.Println("=== Creating sandbox ===") for attempt := 1; attempt <= 5; attempt++ { - args := []string{"sandbox", "create", ttyFlag} - args = append(args, "--name", cfg.Name) - if cfg.Image != "" { - args = append(args, "--from", cfg.Image) - } - for _, p := range registered { - args = append(args, "--provider", p) - } - args = append(args, "--upload", harnessUploadDir+":/sandbox/.config", "--no-git-ignore") - args = append(args, sandboxCmd...) - - c := exec.Command(cli, args...) - c.Stdin = os.Stdin - c.Stdout = os.Stdout - c.Stderr = os.Stderr - if c.Run() == nil { + err := gw.SandboxCreate(gateway.SandboxCreateOpts{ + Name: cfg.Name, + Image: cfg.Image, + Providers: registered, + TTY: !noTTY, + Keep: cfg.KeepSandbox(), + UploadSrc: harnessUploadDir, + UploadDst: "/sandbox/.config", + Command: sandboxCmd, + }) + if err == nil { return nil } fmt.Printf(" Attempt %d failed (supervisor race), retrying in 5s...\n", attempt) - runner.RunCLISilent(cli, "sandbox", "delete", cfg.Name) + gw.SandboxDelete(cfg.Name) if attempt == 5 { return fmt.Errorf("failed after 5 attempts") diff --git a/internal/gateway/cli.go b/internal/gateway/cli.go new file mode 100644 index 0000000..3dcb3d1 --- /dev/null +++ b/internal/gateway/cli.go @@ -0,0 +1,120 @@ +package gateway + +import ( + "io" + "os" + "os/exec" + "strings" + "syscall" +) + +// CLI implements Gateway by shelling out to the openshell binary. +type CLI struct { + bin string // path or name of the openshell binary +} + +func NewCLI(bin string) *CLI { + return &CLI{bin: bin} +} + +func (c *CLI) InferenceGet() error { + return c.silent("inference", "get") +} + +func (c *CLI) ProviderGet(name string) error { + return c.silent("provider", "get", name) +} + +func (c *CLI) ProviderList() ([]string, error) { + out, err := c.output("provider", "list") + if err != nil { + return nil, err + } + var names []string + for i, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if i == 0 || strings.TrimSpace(line) == "" { + continue // skip header + } + fields := strings.Fields(line) + if len(fields) > 0 { + names = append(names, fields[0]) + } + } + return names, nil +} + +func (c *CLI) SandboxCreate(opts SandboxCreateOpts) error { + args := []string{"sandbox", "create", "--name", opts.Name} + if opts.TTY { + args = append(args, "--tty") + } else { + args = append(args, "--no-tty") + } + if opts.Image != "" { + args = append(args, "--from", opts.Image) + } + for _, p := range opts.Providers { + args = append(args, "--provider", p) + } + if !opts.Keep { + args = append(args, "--no-keep") + } + if opts.UploadSrc != "" { + args = append(args, "--upload", opts.UploadSrc+":"+opts.UploadDst, "--no-git-ignore") + } + if len(opts.Command) > 0 { + args = append(args, "--") + args = append(args, opts.Command...) + } + return c.passthrough(args...) +} + +func (c *CLI) SandboxDelete(name string) error { + return c.silent("sandbox", "delete", name) +} + +func (c *CLI) SandboxConnect(name string) error { + path, err := exec.LookPath(c.bin) + if err != nil { + return err + } + args := []string{c.bin, "sandbox", "connect"} + if name != "" { + args = append(args, name) + } + return syscall.Exec(path, args, os.Environ()) +} + +func (c *CLI) SandboxUpload(name, localDir, remotePath string) error { + return c.passthrough("sandbox", "upload", name, localDir, remotePath, "--no-git-ignore") +} + +func (c *CLI) SandboxExec(name string, command ...string) error { + args := []string{"sandbox", "exec", "--name", name, "--"} + args = append(args, command...) + return c.passthrough(args...) +} + +// passthrough runs the CLI with stdin/stdout/stderr connected. +func (c *CLI) passthrough(args ...string) error { + cmd := exec.Command(c.bin, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// silent runs the CLI with all output discarded. +func (c *CLI) silent(args ...string) error { + cmd := exec.Command(c.bin, args...) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + return cmd.Run() +} + +// output runs the CLI and returns stdout. +func (c *CLI) output(args ...string) ([]byte, error) { + cmd := exec.Command(c.bin, args...) + cmd.Stderr = io.Discard + return cmd.Output() +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go new file mode 100644 index 0000000..6d4fb4a --- /dev/null +++ b/internal/gateway/gateway.go @@ -0,0 +1,44 @@ +package gateway + +// Gateway abstracts all OpenShell gateway operations. The CLI implementation +// shells out to the openshell binary; a future gRPC implementation will call +// the gateway API directly. +type Gateway interface { + // InferenceGet checks if the gateway is active and reachable. + InferenceGet() error + + // ProviderGet checks if a provider is registered. Returns nil if it exists. + ProviderGet(name string) error + + // ProviderList returns the names of all registered providers. + ProviderList() ([]string, error) + + // SandboxCreate creates a new sandbox. Stdin/stdout/stderr are connected + // for TTY mode. + SandboxCreate(opts SandboxCreateOpts) error + + // SandboxDelete deletes a sandbox by name. Ignores "not found" errors. + SandboxDelete(name string) error + + // SandboxConnect opens an interactive session to a running sandbox. + // In CLI mode this replaces the process (exec syscall). In gRPC mode + // this would use bidirectional streaming. + SandboxConnect(name string) error + + // SandboxUpload copies local files into a running sandbox. + SandboxUpload(name, localDir, remotePath string) error + + // SandboxExec runs a command inside a sandbox and waits for it to finish. + SandboxExec(name string, command ...string) error +} + +type SandboxCreateOpts struct { + Name string + Image string + Providers []string + TTY bool + Keep bool + UploadSrc string // local path (empty = no upload) + UploadDst string // remote path + Command []string +} diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 2a33dc3..66234c4 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -8,6 +8,8 @@ import ( "path/filepath" "sort" "strings" + + "github.com/robbycochran/harness-openshell/internal/gateway" ) type Config struct { @@ -44,12 +46,9 @@ func (c *Config) BuildSandboxEnv() string { // ValidateProviders checks which profile providers are registered on the // gateway. Returns the list of registered providers and a list of missing ones. -func ValidateProviders(providers []string, cli string) (registered, missing []string) { +func ValidateProviders(providers []string, gw gateway.Gateway) (registered, missing []string) { for _, name := range providers { - cmd := exec.Command(cli, "provider", "get", name) - cmd.Stdout = io.Discard - cmd.Stderr = io.Discard - if cmd.Run() == nil { + if gw.ProviderGet(name) == nil { registered = append(registered, name) } else { missing = append(missing, name) diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go index 04f124a..808f548 100644 --- a/internal/profile/profile_test.go +++ b/internal/profile/profile_test.go @@ -1,12 +1,35 @@ package profile import ( + "fmt" "os" "path/filepath" "strings" "testing" + + "github.com/robbycochran/harness-openshell/internal/gateway" ) +// mockGateway implements gateway.Gateway for testing provider validation. +type mockGateway struct { + providers map[string]bool +} + +func (m *mockGateway) ProviderGet(name string) error { + if m.providers[name] { + return nil + } + return fmt.Errorf("not found") +} + +func (m *mockGateway) InferenceGet() error { return nil } +func (m *mockGateway) ProviderList() ([]string, error) { return nil, nil } +func (m *mockGateway) SandboxCreate(gateway.SandboxCreateOpts) error { return nil } +func (m *mockGateway) SandboxDelete(string) error { return nil } +func (m *mockGateway) SandboxConnect(string) error { return nil } +func (m *mockGateway) SandboxUpload(string, string, string) error { return nil } +func (m *mockGateway) SandboxExec(string, ...string) error { return nil } + func TestParseFile_Full(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") @@ -136,3 +159,36 @@ func TestStageHarnessDir(t *testing.T) { t.Errorf("sandbox.env = %q", string(data)) } } + +func TestValidateProviders_AllRegistered(t *testing.T) { + gw := &mockGateway{providers: map[string]bool{"github": true, "vertex-local": true}} + reg, missing := ValidateProviders([]string{"github", "vertex-local"}, gw) + if len(reg) != 2 { + t.Errorf("registered = %v, want 2", reg) + } + if len(missing) != 0 { + t.Errorf("missing = %v, want empty", missing) + } +} + +func TestValidateProviders_SomeMissing(t *testing.T) { + gw := &mockGateway{providers: map[string]bool{"github": true}} + reg, missing := ValidateProviders([]string{"github", "vertex-local", "atlassian"}, gw) + if len(reg) != 1 || reg[0] != "github" { + t.Errorf("registered = %v", reg) + } + if len(missing) != 2 { + t.Errorf("missing = %v, want 2 items", missing) + } +} + +func TestValidateProviders_NoneRegistered(t *testing.T) { + gw := &mockGateway{providers: map[string]bool{}} + reg, missing := ValidateProviders([]string{"github", "vertex-local"}, gw) + if len(reg) != 0 { + t.Errorf("registered = %v, want empty", reg) + } + if len(missing) != 2 { + t.Errorf("missing = %v, want 2 items", missing) + } +} diff --git a/internal/runner/exec_unix.go b/internal/runner/exec_unix.go deleted file mode 100644 index 78563ae..0000000 --- a/internal/runner/exec_unix.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !windows - -package runner - -import "syscall" - -func execSyscall(path string, args []string, env []string) error { - return syscall.Exec(path, args, env) -} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index f951b65..8c78654 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -1,7 +1,6 @@ package runner import ( - "io" "os" "os/exec" "path/filepath" @@ -17,36 +16,3 @@ func RunScript(harnessDir, scriptName string, args ...string) error { cmd.Dir = harnessDir return cmd.Run() } - -// RunCLI executes the openshell CLI with stdout/stderr passthrough. -func RunCLI(cli string, args ...string) error { - cmd := exec.Command(cli, args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -// RunCLISilent executes the openshell CLI with discarded output. -func RunCLISilent(cli string, args ...string) error { - cmd := exec.Command(cli, args...) - cmd.Stdout = io.Discard - cmd.Stderr = io.Discard - return cmd.Run() -} - -// RunCLIOutput executes the openshell CLI and returns stdout. -func RunCLIOutput(cli string, args ...string) ([]byte, error) { - cmd := exec.Command(cli, args...) - cmd.Stderr = io.Discard - return cmd.Output() -} - -// Exec replaces the current process with the given command (unix exec). -func Exec(name string, args ...string) error { - path, err := exec.LookPath(name) - if err != nil { - return err - } - return execSyscall(path, append([]string{name}, args...), os.Environ()) -} From 986cf89a0155de69f6066a83f3519b0cb26e5406 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Thu, 4 Jun 2026 15:16:43 -0700 Subject: [PATCH 4/6] test: comprehensive tests with dual-path integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit tests (no gateway needed): - 11 gateway CLI tests — stub-based: output parsing, arg building, provider get/list, inference check, sandbox create args - 7 command tests — mock gateway: no gateway, missing providers, all providers missing, bad profile, retry logic, create opts - 10 profile tests (existing) Integration tests (test-flow.sh): - --go flag runs same flow through Go binary instead of bash scripts - step_fail helper for expected-failure assertions - Error scenarios: bad profile, no gateway, teardown idempotency, missing providers (all run in both bash and Go paths) - Full matrix: {bash,go} x {podman,ocp} via make targets Makefile: - test-unit: fast unit tests (Go + bats, no gateway) - test-go-podman / test-go-ocp: Go binary integration - test-all: all 4 combinations with image rebuilds Total: 28 Go harness tests + 7 launcher tests + 29 bats = 64 unit tests Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 28 ++++- cmd/new.go | 48 ++++++-- cmd/new_test.go | 233 +++++++++++++++++++++++++++++++++++ internal/gateway/cli_test.go | 185 +++++++++++++++++++++++++++ test/test-flow.sh | 140 ++++++++++++++------- 5 files changed, 574 insertions(+), 60 deletions(-) create mode 100644 cmd/new_test.go create mode 100644 internal/gateway/cli_test.go diff --git a/Makefile b/Makefile index 9d9ed69..cef0857 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,8 @@ SANDBOX_IMAGE := $(REGISTRY):sandbox LAUNCHER_IMAGE := $(REGISTRY):launcher .PHONY: cli sandbox push-sandbox cli-launcher launcher push-launcher \ - test test-podman test-ocp clean help + test-unit test test-podman test-ocp \ + test-go-podman test-go-ocp test-all clean help ## ── CLI ────────────────────────────────────────────────────────────── @@ -49,18 +50,37 @@ push-launcher: launcher ## ── Test targets ───────────────────────────────────────────────────── -## Build + push sandbox and launcher, then run full tests on both platforms +## Unit tests only (no live gateway, fast) +test-unit: + CGO_ENABLED=0 go test ./... + cd sandbox/launcher && go test ./... + bats test/preflight.bats + +## Bash + both platforms (full lifecycle, rebuilds images) test: sandbox push-launcher ./test/test-flow.sh all --full -## Build + push sandbox and launcher, then run full podman test +## Bash + podman (full lifecycle) test-podman: sandbox push-launcher ./test/test-flow.sh podman --full -## Build + push sandbox and launcher, then run full OCP test +## Bash + OCP (full lifecycle) test-ocp: sandbox push-launcher ./test/test-flow.sh ocp --full +## Go + podman (full lifecycle, rebuilds CLI + images) +test-go-podman: cli sandbox push-launcher + ./test/test-flow.sh podman --full --go + +## Go + OCP (full lifecycle) +test-go-ocp: cli sandbox push-launcher + ./test/test-flow.sh ocp --full --go + +## All 4 combinations: {bash,go} x {podman,ocp} +test-all: cli sandbox push-launcher + ./test/test-flow.sh all --full + ./test/test-flow.sh all --full --go + ## ── Convenience targets ─────────────────────────────────────────────── ## Clean built binaries diff --git a/cmd/new.go b/cmd/new.go index 472719d..887d9cb 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -34,7 +34,18 @@ func NewNewCmd(harnessDir, cli string) *cobra.Command { } gw := gateway.NewCLI(cli) - return newLocal(harnessDir, gw, local, profileName, sandboxName, noTTY) + return newLocal(newLocalOpts{ + harnessDir: harnessDir, + gw: gw, + ensureLocal: local, + profileName: profileName, + sandboxName: sandboxName, + noTTY: noTTY, + runScript: func(name string, args ...string) error { + return runner.RunScript(harnessDir, name, args...) + }, + retrySleep: 5 * time.Second, + }) }, } @@ -47,6 +58,17 @@ func NewNewCmd(harnessDir, cli string) *cobra.Command { return cmd } +type newLocalOpts struct { + harnessDir string + gw gateway.Gateway + ensureLocal bool + profileName string + sandboxName string + noTTY bool + runScript func(name string, args ...string) error + retrySleep time.Duration +} + func newRemote(harnessDir, profileName, sandboxName string, noTTY bool) error { args := []string{"--remote", "--profile", profileName} if sandboxName != "" { @@ -58,11 +80,13 @@ func newRemote(harnessDir, profileName, sandboxName string, noTTY bool) error { return runner.RunScript(harnessDir, "new.sh", args...) } -func newLocal(harnessDir string, gw gateway.Gateway, ensureLocal bool, profileName, sandboxName string, noTTY bool) error { +func newLocal(opts newLocalOpts) error { + gw := opts.gw + // 1. Ensure gateway - if ensureLocal { + if opts.ensureLocal { fmt.Println("=== Ensuring local gateway ===") - if err := runner.RunScript(harnessDir, "deploy.sh", "--local"); err != nil { + if err := opts.runScript("deploy.sh", "--local"); err != nil { return fmt.Errorf("deploy failed: %w", err) } } else { @@ -75,23 +99,23 @@ func newLocal(harnessDir string, gw gateway.Gateway, ensureLocal bool, profileNa providers, _ := gw.ProviderList() if len(providers) == 0 { fmt.Println("\n=== Registering providers ===") - if err := runner.RunScript(harnessDir, "providers.sh"); err != nil { + if err := opts.runScript("providers.sh"); err != nil { return fmt.Errorf("provider registration failed: %w", err) } } // 3. Parse profile - cfg, err := profile.Parse(harnessDir, profileName) + cfg, err := profile.Parse(opts.harnessDir, opts.profileName) if err != nil { return err } - if sandboxName != "" { - cfg.Name = sandboxName + if opts.sandboxName != "" { + cfg.Name = opts.sandboxName } fmt.Println() fmt.Println("=== Sandbox ===") - fmt.Printf(" Profile: %s\n", profileName) + fmt.Printf(" Profile: %s\n", opts.profileName) fmt.Printf(" Image: %s\n", cfg.Image) // 4. Validate providers against profile @@ -118,7 +142,7 @@ func newLocal(harnessDir string, gw gateway.Gateway, ensureLocal bool, profileNa // 6. Build command var sandboxCmd []string - if noTTY { + if opts.noTTY { sandboxCmd = []string{"bash", "/sandbox/startup.sh"} } else { sandboxCmd = []string{"bash", "-c", fmt.Sprintf(". /sandbox/startup.sh && exec %s", cfg.Command)} @@ -132,7 +156,7 @@ func newLocal(harnessDir string, gw gateway.Gateway, ensureLocal bool, profileNa Name: cfg.Name, Image: cfg.Image, Providers: registered, - TTY: !noTTY, + TTY: !opts.noTTY, Keep: cfg.KeepSandbox(), UploadSrc: harnessUploadDir, UploadDst: "/sandbox/.config", @@ -148,7 +172,7 @@ func newLocal(harnessDir string, gw gateway.Gateway, ensureLocal bool, profileNa if attempt == 5 { return fmt.Errorf("failed after 5 attempts") } - time.Sleep(5 * time.Second) + time.Sleep(opts.retrySleep) } return nil } diff --git a/cmd/new_test.go b/cmd/new_test.go new file mode 100644 index 0000000..a4d9362 --- /dev/null +++ b/cmd/new_test.go @@ -0,0 +1,233 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/robbycochran/harness-openshell/internal/gateway" +) + +type mockGW struct { + inferenceErr error + providers map[string]bool + providerList []string + providerErr error + createErr error + createCalls int + createOpts []gateway.SandboxCreateOpts + deletedNames []string +} + +func (m *mockGW) InferenceGet() error { return m.inferenceErr } +func (m *mockGW) ProviderGet(name string) error { + if m.providers[name] { + return nil + } + return fmt.Errorf("not found") +} +func (m *mockGW) ProviderList() ([]string, error) { return m.providerList, m.providerErr } +func (m *mockGW) SandboxCreate(opts gateway.SandboxCreateOpts) error { + m.createCalls++ + m.createOpts = append(m.createOpts, opts) + if m.createErr != nil && m.createCalls == 1 { + return m.createErr + } + return nil +} +func (m *mockGW) SandboxDelete(name string) error { + m.deletedNames = append(m.deletedNames, name) + return nil +} +func (m *mockGW) SandboxConnect(string) error { return nil } +func (m *mockGW) SandboxUpload(string, string, string) error { return nil } +func (m *mockGW) SandboxExec(string, ...string) error { return nil } + +func setupTestProfile(t *testing.T) string { + t.Helper() + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "profiles"), 0o755) + os.WriteFile(filepath.Join(dir, "profiles", "default.toml"), []byte(` +name = "test-agent" +image = "quay.io/test:latest" +command = "claude --bare" +providers = ["github", "vertex-local", "atlassian"] + +[env] +FOO = "bar" +`), 0o644) + return dir +} + +func noopScript(name string, args ...string) error { return nil } + +func TestNewLocal_NoGateway(t *testing.T) { + dir := setupTestProfile(t) + gw := &mockGW{inferenceErr: fmt.Errorf("connection refused")} + + err := newLocal(newLocalOpts{ + harnessDir: dir, + gw: gw, + profileName: "default", + noTTY: true, + runScript: noopScript, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "no active gateway") { + t.Errorf("error = %q, want 'no active gateway'", err) + } +} + +func TestNewLocal_NoProviders_CallsScript(t *testing.T) { + dir := setupTestProfile(t) + scriptCalled := false + gw := &mockGW{ + providerList: nil, + providers: map[string]bool{}, + } + + newLocal(newLocalOpts{ + harnessDir: dir, + gw: gw, + profileName: "default", + noTTY: true, + runScript: func(name string, args ...string) error { + if name == "providers.sh" { + scriptCalled = true + } + return nil + }, + }) + if !scriptCalled { + t.Error("expected providers.sh to be called when no providers registered") + } +} + +func TestNewLocal_MissingProviders(t *testing.T) { + dir := setupTestProfile(t) + gw := &mockGW{ + providerList: []string{"github"}, + providers: map[string]bool{"github": true}, + } + + err := newLocal(newLocalOpts{ + harnessDir: dir, + gw: gw, + profileName: "default", + noTTY: true, + runScript: noopScript, + }) + if err != nil { + t.Fatalf("newLocal: %v", err) + } + if gw.createCalls != 1 { + t.Fatalf("createCalls = %d, want 1", gw.createCalls) + } + opts := gw.createOpts[0] + if len(opts.Providers) != 1 || opts.Providers[0] != "github" { + t.Errorf("Providers = %v, want [github] only", opts.Providers) + } +} + +func TestNewLocal_AllProvidersMissing(t *testing.T) { + dir := setupTestProfile(t) + gw := &mockGW{ + providerList: []string{"github"}, + providers: map[string]bool{}, + } + + err := newLocal(newLocalOpts{ + harnessDir: dir, + gw: gw, + profileName: "default", + noTTY: true, + runScript: noopScript, + }) + if err != nil { + t.Fatalf("newLocal: %v", err) + } + opts := gw.createOpts[0] + if len(opts.Providers) != 0 { + t.Errorf("Providers = %v, want empty", opts.Providers) + } +} + +func TestNewLocal_ProfileNotFound(t *testing.T) { + dir := setupTestProfile(t) + gw := &mockGW{providerList: []string{"github"}} + + err := newLocal(newLocalOpts{ + harnessDir: dir, + gw: gw, + profileName: "nonexistent", + noTTY: true, + runScript: noopScript, + }) + if err == nil { + t.Fatal("expected error for missing profile") + } +} + +func TestNewLocal_SandboxCreateRetry(t *testing.T) { + dir := setupTestProfile(t) + gw := &mockGW{ + providerList: []string{"github"}, + providers: map[string]bool{"github": true}, + createErr: fmt.Errorf("supervisor race"), + } + + err := newLocal(newLocalOpts{ + harnessDir: dir, + gw: gw, + profileName: "default", + noTTY: true, + runScript: noopScript, + retrySleep: 0, + }) + if err != nil { + t.Fatalf("newLocal: %v", err) + } + if gw.createCalls != 2 { + t.Errorf("createCalls = %d, want 2 (first fails, second succeeds)", gw.createCalls) + } + if len(gw.deletedNames) != 1 { + t.Errorf("deletedNames = %v, want 1 cleanup delete", gw.deletedNames) + } +} + +func TestNewLocal_SandboxCreateOpts(t *testing.T) { + dir := setupTestProfile(t) + gw := &mockGW{ + providerList: []string{"github", "vertex-local"}, + providers: map[string]bool{"github": true, "vertex-local": true}, + } + + err := newLocal(newLocalOpts{ + harnessDir: dir, + gw: gw, + profileName: "default", + sandboxName: "custom-name", + noTTY: true, + runScript: noopScript, + }) + if err != nil { + t.Fatalf("newLocal: %v", err) + } + opts := gw.createOpts[0] + if opts.Name != "custom-name" { + t.Errorf("Name = %q, want custom-name", opts.Name) + } + if opts.Image != "quay.io/test:latest" { + t.Errorf("Image = %q", opts.Image) + } + if opts.TTY { + t.Error("TTY = true, want false (noTTY)") + } + if !opts.Keep { + t.Error("Keep = false, want true (default)") + } +} diff --git a/internal/gateway/cli_test.go b/internal/gateway/cli_test.go new file mode 100644 index 0000000..42a3fed --- /dev/null +++ b/internal/gateway/cli_test.go @@ -0,0 +1,185 @@ +package gateway + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeStub(t *testing.T, script string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "stub") + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return path +} + +func TestProviderList_ParsesTable(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +printf "NAME\tTYPE\tSTATUS\n" +printf "github\tgithub\tactive\n" +printf "vertex-local\tgoogle-vertex-ai\tactive\n" +printf "atlassian\tatlassian\tactive\n" +`) + gw := NewCLI(bin) + names, err := gw.ProviderList() + if err != nil { + t.Fatalf("ProviderList: %v", err) + } + if len(names) != 3 { + t.Fatalf("got %d providers, want 3: %v", len(names), names) + } + if names[0] != "github" || names[1] != "vertex-local" || names[2] != "atlassian" { + t.Errorf("names = %v", names) + } +} + +func TestProviderList_Empty(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +printf "NAME\tTYPE\tSTATUS\n" +`) + gw := NewCLI(bin) + names, err := gw.ProviderList() + if err != nil { + t.Fatalf("ProviderList: %v", err) + } + if len(names) != 0 { + t.Errorf("got %d providers, want 0: %v", len(names), names) + } +} + +func TestProviderList_CLINotFound(t *testing.T) { + gw := NewCLI("/nonexistent/openshell") + _, err := gw.ProviderList() + if err == nil { + t.Error("expected error for missing CLI") + } +} + +func TestProviderGet_Exists(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +[[ "$3" == "github" ]] && exit 0 +exit 1 +`) + gw := NewCLI(bin) + if err := gw.ProviderGet("github"); err != nil { + t.Errorf("ProviderGet(github) = %v, want nil", err) + } +} + +func TestProviderGet_Missing(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +exit 1 +`) + gw := NewCLI(bin) + if err := gw.ProviderGet("nonexistent"); err == nil { + t.Error("ProviderGet(nonexistent) = nil, want error") + } +} + +func TestInferenceGet_Active(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +exit 0 +`) + gw := NewCLI(bin) + if err := gw.InferenceGet(); err != nil { + t.Errorf("InferenceGet = %v, want nil", err) + } +} + +func TestInferenceGet_NoGateway(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +exit 1 +`) + gw := NewCLI(bin) + if err := gw.InferenceGet(); err == nil { + t.Error("InferenceGet = nil, want error") + } +} + +func TestSandboxCreate_ArgsMinimal(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +echo "$@" > /tmp/test-create-args +`) + gw := NewCLI(bin) + gw.SandboxCreate(SandboxCreateOpts{ + Name: "test", + TTY: false, + Keep: true, + }) + data, _ := os.ReadFile("/tmp/test-create-args") + args := strings.TrimSpace(string(data)) + if !strings.Contains(args, "--name test") { + t.Errorf("missing --name: %s", args) + } + if !strings.Contains(args, "--no-tty") { + t.Errorf("missing --no-tty: %s", args) + } + if strings.Contains(args, "--no-keep") { + t.Errorf("should not have --no-keep: %s", args) + } + if strings.Contains(args, "--from") { + t.Errorf("should not have --from: %s", args) + } + os.Remove("/tmp/test-create-args") +} + +func TestSandboxCreate_ArgsFull(t *testing.T) { + dir := t.TempDir() + argsFile := filepath.Join(dir, "args") + bin := writeStub(t, `#!/bin/bash +echo "$@" > `+argsFile+` +`) + gw := NewCLI(bin) + gw.SandboxCreate(SandboxCreateOpts{ + Name: "my-agent", + Image: "quay.io/test:latest", + Providers: []string{"github", "vertex-local"}, + TTY: true, + Keep: false, + UploadSrc: "/tmp/openshell", + UploadDst: "/sandbox/.config", + Command: []string{"bash", "-c", "exec claude"}, + }) + data, _ := os.ReadFile(argsFile) + args := strings.TrimSpace(string(data)) + + for _, want := range []string{ + "--name my-agent", + "--tty", + "--from quay.io/test:latest", + "--provider github", + "--provider vertex-local", + "--no-keep", + "--upload /tmp/openshell:/sandbox/.config", + "--no-git-ignore", + "-- bash -c exec claude", + } { + if !strings.Contains(args, want) { + t.Errorf("missing %q in: %s", want, args) + } + } +} + +func TestSandboxDelete_Silent(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +exit 0 +`) + gw := NewCLI(bin) + if err := gw.SandboxDelete("test"); err != nil { + t.Errorf("SandboxDelete = %v", err) + } +} + +func TestSandboxDelete_NotFound(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +exit 1 +`) + gw := NewCLI(bin) + if err := gw.SandboxDelete("missing"); err == nil { + t.Error("SandboxDelete = nil, want error") + } +} diff --git a/test/test-flow.sh b/test/test-flow.sh index 506ab63..290c7b8 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -1,28 +1,55 @@ #!/usr/bin/env bash # End-to-end validation for podman and OCP flows. # +# Supports both the bash (bin/harness) and Go (./harness) entry points. +# Use --go to test the Go binary. Use --full for sandbox lifecycle tests. +# # Usage: -# ./test-flow.sh podman # quick: deploy + providers + teardown -# ./test-flow.sh podman --full # full: + sandbox + verify integrations -# ./test-flow.sh ocp # quick: deploy + creds + providers + teardown -# ./test-flow.sh ocp --full # full: + sandbox + verify integrations -# ./test-flow.sh all # quick for both -# ./test-flow.sh all --full # full for both +# ./test-flow.sh podman # quick bash: deploy + providers + teardown +# ./test-flow.sh podman --full # full bash: + sandbox + verify integrations +# ./test-flow.sh podman --go # quick Go: same flow via Go binary +# ./test-flow.sh podman --full --go # full Go +# ./test-flow.sh ocp [--full] [--go] # OCP variants +# ./test-flow.sh all [--full] [--go] # both platforms set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" source "$SCRIPT_DIR/bin/scripts/lib/profile.sh" CLI="${OPENSHELL_CLI:-openshell}" -TARGET="${1:-}" +# ── Parse args ────────────────────────────────────────────────────── +TARGET="" FULL=false -[[ "${2:-}" == "--full" || "${3:-}" == "--full" ]] && FULL=true +USE_GO=false + +for arg in "$@"; do + case "$arg" in + --full) FULL=true ;; + --go) USE_GO=true ;; + -*) ;; + *) [[ -z "$TARGET" ]] && TARGET="$arg" ;; + esac +done if [[ -z "$TARGET" ]]; then - echo "Usage: $0 [--full]" + echo "Usage: $0 [--full] [--go]" exit 1 fi +# ── Entry point: bash or Go ───────────────────────────────────────── +if $USE_GO; then + HARNESS="$SCRIPT_DIR/harness" + if [[ ! -x "$HARNESS" ]]; then + echo "ERROR: Go binary not found at $HARNESS" + echo " Build it first: make cli" + exit 1 + fi + IMPL="go" +else + HARNESS="$SCRIPT_DIR/bin/harness" + IMPL="bash" +fi + # ── Helpers ────────────────────────────────────────────────────────── PASS=0 @@ -43,6 +70,20 @@ step() { fi } +step_fail() { + local label="$1"; shift + local start=$(date +%s) + if ! "$@" &>/dev/null; then + local elapsed=$(( $(date +%s) - start )) + printf " ✓ %-35s (expected failure, %ds)\n" "$label" "$elapsed" + ((PASS++)) + else + local elapsed=$(( $(date +%s) - start )) + printf " ✗ %-35s (should have failed, %ds)\n" "$label" "$elapsed" + ((FAIL++)) + fi +} + step_output() { local label="$1"; shift local start=$(date +%s) @@ -74,7 +115,6 @@ check_providers() { sandbox_verify() { local name="$1" - # Check sandbox is ready local phase phase=$("$CLI" sandbox list 2>/dev/null | strip_ansi | awk -v n="$name" '$1==n {print $NF}') if [[ "$phase" != "Ready" ]]; then @@ -85,16 +125,9 @@ sandbox_verify() { printf " ✓ %-35s\n" "sandbox ready" ((PASS++)) - # Check env vars step "sandbox: env vars" "$CLI" sandbox exec --name "$name" -- bash -c 'test -n "$ANTHROPIC_BASE_URL"' - - # Check GWS credentials step "sandbox: gws creds" "$CLI" sandbox exec --name "$name" -- test -f /sandbox/.config/openshell/credentials.json - - # Check MCP config step "sandbox: mcp config" "$CLI" sandbox exec --name "$name" -- test -f /sandbox/.mcp.json - - # Check Claude responds step_output "sandbox: claude responds" "$CLI" sandbox exec --name "$name" -- bash -c 'echo "respond with ok" | claude --bare --print 2>&1 | head -1' } @@ -103,33 +136,59 @@ summary() { local elapsed=$(( $(date +%s) - TOTAL_START )) echo "" if [[ $FAIL -eq 0 ]]; then - echo "${PASS}/${total} passed (${elapsed}s)" + echo "${PASS}/${total} passed (${elapsed}s) [${IMPL}]" else - echo "${PASS}/${total} passed, ${FAIL} failed (${elapsed}s)" + echo "${PASS}/${total} passed, ${FAIL} failed (${elapsed}s) [${IMPL}]" fi } +# ── Error scenarios (run on both paths) ────────────────────────────── + +test_errors() { + echo "=== test: error scenarios ($IMPL) ===" + + # Bad profile + step_fail "nonexistent profile" "$HARNESS" new --local --profile nonexistent --no-tty + + # No gateway (teardown first, then try new without --local) + "$HARNESS" teardown --sandboxes --providers &>/dev/null || true + step_fail "new without gateway" "$HARNESS" new --name test-nogw --no-tty + + # Teardown idempotency + step "teardown (first)" "$HARNESS" teardown + step "teardown (second)" "$HARNESS" teardown + + echo "" +} + # ── Podman flow ────────────────────────────────────────────────────── test_podman() { local mode="quick" $FULL && mode="full" - echo "=== test-flow: podman ($mode) ===" + echo "=== test-flow: podman ($mode) [$IMPL] ===" - step "teardown" "$SCRIPT_DIR/bin/scripts/teardown.sh" --sandboxes --providers - step "deploy" "$SCRIPT_DIR/bin/scripts/deploy.sh" --local - step "setup providers" "$SCRIPT_DIR/bin/scripts/providers.sh" + step "teardown" "$HARNESS" teardown --sandboxes --providers + step "deploy" "$HARNESS" deploy --local + step "setup providers" "$HARNESS" providers step "gateway reachable" "$CLI" inference get check_providers if $FULL; then local sandbox_name="test-agent" - step_output "sandbox create" "$SCRIPT_DIR/bin/scripts/new.sh" --local --name "$sandbox_name" --no-tty + step_output "sandbox create" "$HARNESS" new --local --name "$sandbox_name" --no-tty sandbox_verify "$sandbox_name" step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" + + # Missing providers scenario + echo "" + echo "=== test: missing providers ($IMPL) ===" + step "teardown providers" "$HARNESS" teardown --providers + step_output "new with no providers" "$HARNESS" new --local --name test-noprov --no-tty + step "cleanup" "$HARNESS" teardown --sandboxes fi - step "teardown (clean)" "$SCRIPT_DIR/bin/scripts/teardown.sh" --sandboxes --providers + step "teardown (clean)" "$HARNESS" teardown --sandboxes --providers } # ── OCP flow ───────────────────────────────────────────────────────── @@ -137,20 +196,19 @@ test_podman() { test_ocp() { local mode="quick" $FULL && mode="full" - echo "=== test-flow: ocp ($mode) ===" + echo "=== test-flow: ocp ($mode) [$IMPL] ===" - step "teardown" "$SCRIPT_DIR/bin/scripts/teardown.sh" - step "deploy" "$SCRIPT_DIR/bin/scripts/deploy.sh" --remote + step "teardown" "$HARNESS" teardown + step "deploy" "$HARNESS" deploy --remote step "setup creds" "$SCRIPT_DIR/bin/scripts/creds.sh" - step "setup providers" "$SCRIPT_DIR/bin/scripts/providers.sh" + step "setup providers" "$HARNESS" providers step "gateway reachable" "$CLI" inference get check_providers if $FULL; then - step_output "sandbox create" "$SCRIPT_DIR/bin/scripts/new.sh" --remote + step_output "sandbox create" "$HARNESS" new --remote local sandbox_name="agent" - # Wait for ready for i in $(seq 1 30); do local phase=$("$CLI" sandbox list 2>/dev/null | strip_ansi | awk -v n="$sandbox_name" '$1==n {print $NF}') [[ "$phase" == "Ready" ]] && break @@ -160,26 +218,20 @@ test_ocp() { step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" fi - step "teardown (clean)" "$SCRIPT_DIR/bin/scripts/teardown.sh" + step "teardown (clean)" "$HARNESS" teardown } # ── Main ───────────────────────────────────────────────────────────── +test_errors + case "$TARGET" in - podman) - test_podman - ;; - ocp) - test_ocp - ;; - all) - test_podman - echo "" - test_ocp - ;; + podman) test_podman ;; + ocp) test_ocp ;; + all) test_podman; echo ""; test_ocp ;; *) echo "Unknown target: $TARGET" - echo "Usage: $0 [--full]" + echo "Usage: $0 [--full] [--go]" exit 1 ;; esac From fc37732ea88530d82b735746402c5a24720a3ae3 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Thu, 4 Jun 2026 16:40:52 -0700 Subject: [PATCH 5/6] fix: launcher works on OCP with UBI9 base Three fixes discovered by OCP integration tests: 1. Dockerfile: use UBI9-minimal + openssh-clients (not scratch/distroless). The openshell CLI is dynamically linked against glibc and shells out to ssh for sandbox connections. 2. metadata.json: read-then-patch instead of overwrite. The openshell CLI writes fields like gateway_type that it needs later. Overwriting with only gateway_endpoint + auth_mode caused "Unknown gateway" errors. 3. K8s volume mounts: skip dotfiles (..data, ..2024_*) when copying from Secret-mounted directories. These are K8s internal symlinks. 4. Job YAML: add emptyDir volume for /tmp so the launcher has a writable tmpdir regardless of OpenShift security policies. Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/scripts/new.sh | 4 ++++ sandbox/launcher/Dockerfile | 7 +++--- sandbox/launcher/main.go | 44 +++++++++++++++++++++++-------------- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/bin/scripts/new.sh b/bin/scripts/new.sh index 3a6f439..acccfe3 100755 --- a/bin/scripts/new.sh +++ b/bin/scripts/new.sh @@ -155,6 +155,8 @@ spec: - name: HOME value: "/tmp" volumeMounts: + - name: tmp + mountPath: /tmp - name: config mountPath: /etc/openshell/sandbox readOnly: true @@ -168,6 +170,8 @@ spec: mountPath: /etc/openshell/env readOnly: true volumes: + - name: tmp + emptyDir: {} - name: config configMap: name: sandbox-${SANDBOX_NAME} diff --git a/sandbox/launcher/Dockerfile b/sandbox/launcher/Dockerfile index 5cfece4..a491de6 100644 --- a/sandbox/launcher/Dockerfile +++ b/sandbox/launcher/Dockerfile @@ -2,19 +2,20 @@ # OpenShell in-cluster sandbox launcher. # -# A minimal image containing the Go launcher and openshell CLI. +# A minimal image containing the Go launcher, openshell CLI, and openssh. # Runs as a Kubernetes Job triggered by `kubectl apply -f sandbox.yaml`. # Reads sandbox configuration from a ConfigMap and credentials from # mounted Secrets — no credentials transit the kubectl client. # # Build: -# # Cross-compile the Go launcher first: # GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o launcher . # docker build --platform linux/amd64 -t quay.io/rcochran/openshell:launcher . # # Push: docker push quay.io/rcochran/openshell:launcher -FROM scratch +FROM registry.access.redhat.com/ubi9/ubi-minimal:latest + +RUN microdnf install -y openssh-clients --nodocs && microdnf clean all COPY launcher /usr/local/bin/launcher COPY openshell /usr/local/bin/openshell diff --git a/sandbox/launcher/main.go b/sandbox/launcher/main.go index 4fa425e..23cb8c7 100644 --- a/sandbox/launcher/main.go +++ b/sandbox/launcher/main.go @@ -43,12 +43,6 @@ func parseConfig(path string) (*Config, error) { return &cfg, nil } -type gatewayMetadata struct { - GatewayEndpoint string `json:"gateway_endpoint"` - AuthMode string `json:"auth_mode"` - Name string `json:"name,omitempty"` -} - func configureGateway(endpoint, mtlsDir, cli string) error { certFile := filepath.Join(mtlsDir, "tls.crt") if _, err := os.Stat(certFile); err != nil { @@ -59,7 +53,13 @@ func configureGateway(endpoint, mtlsDir, cli string) error { } httpEndpoint := strings.Replace(endpoint, "https:", "http:", 1) - run(cli, "gateway", "add", httpEndpoint, "--name", "openshell") + + // Register via http:// (skips cert validation probe), then patch to https + mTLS. + // Let the CLI create the full metadata.json with all required fields. + cmd := exec.Command(cli, "gateway", "add", httpEndpoint, "--name", "openshell") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stdout + cmd.Run() home := os.Getenv("HOME") gwDir := filepath.Join(home, ".config", "openshell", "gateways", "openshell") @@ -68,17 +68,18 @@ func configureGateway(endpoint, mtlsDir, cli string) error { return fmt.Errorf("creating mtls dir: %w", err) } + // Patch metadata.json — read what gateway add created, update only the + // two fields we need. Preserve all other fields the CLI wrote. metaPath := filepath.Join(gwDir, "metadata.json") - data, err := os.ReadFile(metaPath) - if err != nil { - return fmt.Errorf("reading metadata.json: %w", err) + var meta map[string]any + if data, err := os.ReadFile(metaPath); err == nil { + json.Unmarshal(data, &meta) } - var meta gatewayMetadata - if err := json.Unmarshal(data, &meta); err != nil { - return fmt.Errorf("parsing metadata.json: %w", err) + if meta == nil { + meta = make(map[string]any) } - meta.GatewayEndpoint = endpoint - meta.AuthMode = "mtls" + meta["gateway_endpoint"] = endpoint + meta["auth_mode"] = "mtls" out, err := json.Marshal(meta) if err != nil { return fmt.Errorf("marshaling metadata.json: %w", err) @@ -92,6 +93,8 @@ func configureGateway(endpoint, mtlsDir, cli string) error { return fmt.Errorf("copying %s: %w", name, err) } } + + run(cli, "gateway", "select", "openshell") fmt.Println(" ✓ mTLS gateway configured") return nil } @@ -134,7 +137,7 @@ func stageFiles(cfg *Config, gwsDir, harnessDir string) error { return fmt.Errorf("reading gws dir: %w", err) } for _, e := range entries { - if !e.IsDir() { + if !e.IsDir() && !strings.HasPrefix(e.Name(), "..") { src := filepath.Join(gwsDir, e.Name()) dst := filepath.Join(harnessDir, e.Name()) if err := copyFile(src, dst); err != nil { @@ -239,6 +242,13 @@ func main() { } configPath := "/etc/openshell/sandbox/config.toml" + // Scratch images have no filesystem — ensure HOME exists. + home := os.Getenv("HOME") + if home == "" { + home = "/tmp" + } + os.MkdirAll(home, 0o755) + if err := configureGateway(endpoint, "/secrets/mtls", cli); err != nil { fmt.Fprintf(os.Stderr, "ERROR: gateway config: %v\n", err) os.Exit(1) @@ -260,7 +270,7 @@ func main() { providers := checkProviders(cfg.Providers, cli) - harnessDir := "/tmp/openshell" + harnessDir := filepath.Join(home, "openshell") if err := stageFiles(cfg, "/secrets/gws", harnessDir); err != nil { fmt.Fprintf(os.Stderr, "ERROR: staging files: %v\n", err) os.Exit(1) From 2cc709206ca47ac3d960bc94ffdde2f91e11e0b4 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Thu, 4 Jun 2026 16:42:13 -0700 Subject: [PATCH 6/6] fix: remove unnecessary tmp volume and HOME mkdir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UBI9 has a writable /tmp by default — the emptyDir volume mount and Go-side os.MkdirAll(home) were only needed for scratch/distroless bases. Revert staging dir to /tmp/openshell. Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/scripts/new.sh | 4 ---- sandbox/launcher/main.go | 9 +-------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/bin/scripts/new.sh b/bin/scripts/new.sh index acccfe3..3a6f439 100755 --- a/bin/scripts/new.sh +++ b/bin/scripts/new.sh @@ -155,8 +155,6 @@ spec: - name: HOME value: "/tmp" volumeMounts: - - name: tmp - mountPath: /tmp - name: config mountPath: /etc/openshell/sandbox readOnly: true @@ -170,8 +168,6 @@ spec: mountPath: /etc/openshell/env readOnly: true volumes: - - name: tmp - emptyDir: {} - name: config configMap: name: sandbox-${SANDBOX_NAME} diff --git a/sandbox/launcher/main.go b/sandbox/launcher/main.go index 23cb8c7..96bb997 100644 --- a/sandbox/launcher/main.go +++ b/sandbox/launcher/main.go @@ -242,13 +242,6 @@ func main() { } configPath := "/etc/openshell/sandbox/config.toml" - // Scratch images have no filesystem — ensure HOME exists. - home := os.Getenv("HOME") - if home == "" { - home = "/tmp" - } - os.MkdirAll(home, 0o755) - if err := configureGateway(endpoint, "/secrets/mtls", cli); err != nil { fmt.Fprintf(os.Stderr, "ERROR: gateway config: %v\n", err) os.Exit(1) @@ -270,7 +263,7 @@ func main() { providers := checkProviders(cfg.Providers, cli) - harnessDir := filepath.Join(home, "openshell") + harnessDir := "/tmp/openshell" if err := stageFiles(cfg, "/secrets/gws", harnessDir); err != nil { fmt.Fprintf(os.Stderr, "ERROR: staging files: %v\n", err) os.Exit(1)