From 5e16fbb7e68b79be436215229ce81f1aaeafd733 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 23:46:02 -0700 Subject: [PATCH 01/22] feat: single-binary harness replaces launcher, agent.yaml is primary config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `harness launch` hidden command for in-cluster sandbox creation (ports configureGateway mTLS setup from launcher/main.go) - Replace --profile flag with --agent (default: "default"), reads agents/*.yaml - Delete sandbox/launcher/ module entirely (separate go.mod, binary, image) - Delete profiles/ directory (replaced by agents/) - Rename ProviderRef.Type to ProviderRef.Profile in agent.yaml (references OpenShell provider profiles by name) - Remove injectAtlassianEnv() (JIRA vars now in agent.yaml provider config, resolved via os.ExpandEnv at render time) - Add build/runner/Dockerfile for runner image (harness + openshell CLI) - Rename gateway config Images.Launcher to Images.Runner, env RUNNER_IMAGE - Update Makefile: cli-runner/runner/dev-runner replace launcher targets - Update CI: images.yml builds runner, ci.yml drops launcher module test - Update test-flow.sh: --profile → --agent flag --- .github/workflows/ci.yml | 2 - .github/workflows/images.yml | 36 ++-- Makefile | 50 +++-- agents/default.yaml | 8 +- agents/demo.yaml | 24 +++ build/runner/Dockerfile | 10 + cmd/create.go | 133 +++++++------ cmd/create_test.go | 71 ------- cmd/helpers_test.go | 21 ++- sandbox/launcher/main.go => cmd/launch.go | 215 ++++++++-------------- cmd/sandbox.go | 1 + cmd/up.go | 210 +++++++++------------ cmd/up_test.go | 85 ++++----- gateways/ocp/gateway.toml | 2 +- internal/agent/agent.go | 12 +- internal/agent/agent_test.go | 47 +++-- internal/gateway/config.go | 10 +- internal/gateway/config_test.go | 16 +- main.go | 3 +- profiles/ci.toml | 10 - profiles/default.toml | 18 -- sandbox/launcher/Dockerfile | 25 --- sandbox/launcher/SPEC.md | 123 ------------- sandbox/launcher/entrypoint.sh | 138 -------------- sandbox/launcher/go.mod | 5 - sandbox/launcher/go.sum | 2 - sandbox/launcher/main_test.go | 140 -------------- test/test-flow.sh | 12 +- 28 files changed, 412 insertions(+), 1017 deletions(-) create mode 100644 agents/demo.yaml create mode 100644 build/runner/Dockerfile rename sandbox/launcher/main.go => cmd/launch.go (51%) delete mode 100644 profiles/ci.toml delete mode 100644 profiles/default.toml delete mode 100644 sandbox/launcher/Dockerfile delete mode 100644 sandbox/launcher/SPEC.md delete mode 100644 sandbox/launcher/entrypoint.sh delete mode 100644 sandbox/launcher/go.mod delete mode 100644 sandbox/launcher/go.sum delete mode 100644 sandbox/launcher/main_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index caba9e9..6acd464 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,6 @@ jobs: go-version-file: go.mod - run: go vet ./... - run: CGO_ENABLED=0 go test ./... - - name: Test launcher module - run: cd sandbox/launcher && go vet ./... && go test ./... lint: runs-on: ubuntu-latest diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index ea7a601..45dbf5e 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -5,14 +5,24 @@ on: branches: [main] paths: - 'sandbox/**' - - 'profiles/**' + - 'build/**' + - 'agents/**' - 'providers.toml' + - '*.go' + - 'go.*' + - 'cmd/**' + - 'internal/**' tags: ["v*"] pull_request: paths: - 'sandbox/**' - - 'profiles/**' + - 'build/**' + - 'agents/**' - 'providers.toml' + - '*.go' + - 'go.*' + - 'cmd/**' + - 'internal/**' permissions: contents: read @@ -54,19 +64,19 @@ jobs: type=registry,ref=${{ env.IMAGE_BASE }}:sandbox cache-to: type=registry,ref=${{ env.IMAGE_BASE }}:sandbox-cache,mode=max - launcher: + runner: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: go.mod - - name: Build launcher binary - run: cd sandbox/launcher && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o launcher . + - name: Build harness binary + run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/runner/harness . - name: Install openshell CLI run: | curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh - cp "$(which openshell)" sandbox/launcher/openshell + cp "$(which openshell)" build/runner/openshell - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: @@ -78,17 +88,17 @@ jobs: with: images: ${{ env.IMAGE_BASE }} tags: | - type=raw,value=launcher,enable={{is_default_branch}} - type=semver,pattern=launcher-v{{version}} - type=ref,event=pr,prefix=launcher-pr- + type=raw,value=runner,enable={{is_default_branch}} + type=semver,pattern=runner-v{{version}} + type=ref,event=pr,prefix=runner-pr- - uses: docker/build-push-action@v6 with: - context: sandbox/launcher + context: build/runner platforms: linux/amd64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: | - type=registry,ref=${{ env.IMAGE_BASE }}:launcher-cache - type=registry,ref=${{ env.IMAGE_BASE }}:launcher - cache-to: type=registry,ref=${{ env.IMAGE_BASE }}:launcher-cache,mode=max + type=registry,ref=${{ env.IMAGE_BASE }}:runner-cache + type=registry,ref=${{ env.IMAGE_BASE }}:runner + cache-to: type=registry,ref=${{ env.IMAGE_BASE }}:runner-cache,mode=max diff --git a/Makefile b/Makefile index a5d4a55..26c015c 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ ## ## Images: ## make sandbox # build + push sandbox (multi-arch) -## make launcher # build + push launcher +## make runner # build + push runner REGISTRY ?= ghcr.io/robbycochran/harness-openshell DEV_REGISTRY ?= quay.io/rcochran/openshell @@ -21,14 +21,14 @@ DEV_TAG := dev-$(shell git rev-parse --short HEAD) PLATFORM := linux/amd64 SANDBOX_IMAGE := $(REGISTRY):sandbox -LAUNCHER_IMAGE := $(REGISTRY):launcher +RUNNER_IMAGE := $(REGISTRY):runner DEV_SANDBOX_IMAGE := $(DEV_REGISTRY):$(DEV_TAG)-sandbox -DEV_LAUNCHER_IMAGE := $(DEV_REGISTRY):$(DEV_TAG)-launcher +DEV_RUNNER_IMAGE := $(DEV_REGISTRY):$(DEV_TAG)-runner -.PHONY: cli sandbox push-sandbox cli-launcher launcher push-launcher \ +.PHONY: cli sandbox push-sandbox cli-runner runner push-runner \ vet lint ci ci-local ci-kind \ dev-test-local dev-test-kind dev-test-remote dev-test-all \ - dev-sandbox dev-launcher clean help + dev-sandbox dev-runner clean help ## ── CLI ────────────────────────────────────────────────────────────── @@ -48,25 +48,24 @@ sandbox: sandbox/Dockerfile sandbox/startup.sh \ push-sandbox: sandbox @echo "Already pushed by buildx" -## Cross-compile Go launcher binary for linux/amd64 -cli-launcher: - cd sandbox/launcher && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o launcher . - @echo "Built: sandbox/launcher/launcher" +## Cross-compile harness binary for the runner image +cli-runner: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/runner/harness . + @echo "Built: build/runner/harness" -## Launcher image (Go binary + openshell CLI, scratch-based) -launcher: cli-launcher sandbox/launcher/Dockerfile sandbox/launcher/openshell - docker build --platform $(PLATFORM) -t $(LAUNCHER_IMAGE) sandbox/launcher/ - @echo "Built: $(LAUNCHER_IMAGE)" +## Runner image (harness binary + openshell CLI) +runner: cli-runner build/runner/Dockerfile + docker build --platform $(PLATFORM) -t $(RUNNER_IMAGE) build/runner/ + @echo "Built: $(RUNNER_IMAGE)" -push-launcher: launcher - docker push $(LAUNCHER_IMAGE) +push-runner: runner + docker push $(RUNNER_IMAGE) ## ── Lint targets ───────────────────────────────────────────────────── ## Run go vet vet: go vet ./... - cd sandbox/launcher && go vet ./... ## Run golangci-lint (install: https://golangci-lint.run/usage/install/) lint: @@ -82,7 +81,6 @@ lint: ## Unit tests + bats + lint (fast, ~2min, no gateway needed) ci: vet CGO_ENABLED=0 go test ./... - cd sandbox/launcher && go test ./... bats test/preflight.bats ## CI + local gateway integration (ci mode, no credentials) @@ -116,14 +114,14 @@ dev-test-kind: cli ci dev-sandbox ## Remote (OCP): unit + bats + OCP full + OCP CI ## Requires: KUBECONFIG set, provider credentials. ## Builds dev images to quay.io (OCP can't pull private ghcr.io). -dev-test-remote: cli ci dev-sandbox dev-launcher +dev-test-remote: cli ci dev-sandbox dev-runner @test -n "$${KUBECONFIG}" || { echo "ERROR: Set KUBECONFIG for OCP (e.g. export KUBECONFIG=infracluster/kubeconfig)"; exit 1; } @echo "" @echo "=== Integration: OCP (full) ===" - SANDBOX_IMAGE=$(DEV_SANDBOX_IMAGE) LAUNCHER_IMAGE=$(DEV_LAUNCHER_IMAGE) ./test/test-flow.sh ocp --full + SANDBOX_IMAGE=$(DEV_SANDBOX_IMAGE) RUNNER_IMAGE=$(DEV_RUNNER_IMAGE) ./test/test-flow.sh ocp --full @echo "" @echo "=== Integration: OCP (ci) ===" - LAUNCHER_IMAGE=$(DEV_LAUNCHER_IMAGE) ./test/test-flow.sh ocp --ci + RUNNER_IMAGE=$(DEV_RUNNER_IMAGE) ./test/test-flow.sh ocp --ci ## All: local + kind + remote dev-test-all: dev-test-local dev-test-kind dev-test-remote @@ -135,17 +133,17 @@ dev-sandbox: docker buildx build --platform linux/amd64,linux/arm64 -t $(DEV_SANDBOX_IMAGE) sandbox/ --push @echo "Built and pushed: $(DEV_SANDBOX_IMAGE)" -## Build dev launcher image to quay.io -dev-launcher: cli-launcher - docker build --platform $(PLATFORM) -t $(DEV_LAUNCHER_IMAGE) sandbox/launcher/ - docker push $(DEV_LAUNCHER_IMAGE) - @echo "Built and pushed: $(DEV_LAUNCHER_IMAGE)" +## Build dev runner image to quay.io +dev-runner: cli-runner + docker build --platform $(PLATFORM) -t $(DEV_RUNNER_IMAGE) build/runner/ + docker push $(DEV_RUNNER_IMAGE) + @echo "Built and pushed: $(DEV_RUNNER_IMAGE)" ## ── Convenience targets ─────────────────────────────────────────────── ## Clean built binaries clean: - rm -f harness sandbox/launcher/launcher + rm -f harness build/runner/harness @echo "Cleaned binaries" ## Show available targets diff --git a/agents/default.yaml b/agents/default.yaml index 0a21ab5..627d9d5 100644 --- a/agents/default.yaml +++ b/agents/default.yaml @@ -10,13 +10,13 @@ entrypoint: claude --bare tty: true providers: - - type: github - - type: vertex-local - - type: atlassian + - profile: github + - profile: vertex-local + - profile: atlassian config: JIRA_URL: ${JIRA_URL} JIRA_USERNAME: ${JIRA_USERNAME} - - type: gws + - profile: gws # Non-secret env vars injected into sandbox. # Proxy config: gateway routes inference through its local proxy. diff --git a/agents/demo.yaml b/agents/demo.yaml new file mode 100644 index 0000000..6614c7f --- /dev/null +++ b/agents/demo.yaml @@ -0,0 +1,24 @@ +# Hackathon demo agent -- runs the cross-tool status snapshot task. +# +# Usage: +# harness up --local --agent demo + +name: demo +image: ghcr.io/robbycochran/harness-openshell:sandbox +entrypoint: claude --bare -p +task: demo/DEMO-TASK.md +tty: false + +providers: + - profile: github + - profile: vertex-local + - profile: atlassian + config: + JIRA_URL: ${JIRA_URL} + JIRA_USERNAME: ${JIRA_USERNAME} + - profile: gws + +env: + ANTHROPIC_BASE_URL: https://inference.local + ANTHROPIC_API_KEY: sk-ant-openshell-proxy-managed + CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1" diff --git a/build/runner/Dockerfile b/build/runner/Dockerfile new file mode 100644 index 0000000..f266c1a --- /dev/null +++ b/build/runner/Dockerfile @@ -0,0 +1,10 @@ +FROM registry.access.redhat.com/ubi9/ubi-minimal:latest + +RUN microdnf install -y openssh-clients --nodocs && microdnf clean all + +COPY harness /usr/local/bin/harness +COPY openshell /usr/local/bin/openshell + +USER 1000 + +ENTRYPOINT ["harness", "launch"] diff --git a/cmd/create.go b/cmd/create.go index 8f309bd..e1dd167 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/robbycochran/harness-openshell/internal/agent" "github.com/robbycochran/harness-openshell/internal/gateway" "github.com/robbycochran/harness-openshell/internal/preflight" "github.com/robbycochran/harness-openshell/internal/profile" @@ -16,7 +17,7 @@ import ( func NewCreateCmd(harnessDir, cli string) *cobra.Command { var ( - profileName string + agentName string sandboxName string ) @@ -41,34 +42,36 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { status.Section("Gateway") status.OKf("%s (%s)", activeGW.Name, activeGW.Endpoint) - // 2. Parse the profile - cfg, err := profile.Parse(harnessDir, profileName) + // 2. Parse agent config + agentPath := filepath.Join(harnessDir, "agents", agentName+".yaml") + agentCfg, err := agent.ParseFile(agentPath) if err != nil { return err } + name := agentCfg.Name if sandboxName != "" { - cfg.Name = sandboxName + name = sandboxName } - injectAtlassianEnv(cfg) - status.Section("Profile") - fmt.Printf(" Name: %s\n", cfg.Name) - fmt.Printf(" From: %s\n", cfg.From) + status.Section("Agent") + fmt.Printf(" Name: %s\n", name) + fmt.Printf(" Image: %s\n", agentCfg.Image) // 3. Validate providers are registered status.Section("Providers") - registered, missing := profile.ValidateProviders(cfg.Providers, gw) - for _, name := range registered { - status.OKf("%s: attached", name) + providerNames := agentCfg.ProviderNames() + registered, missing := profile.ValidateProviders(providerNames, gw) + for _, n := range registered { + status.OKf("%s: attached", n) } - for _, name := range missing { - status.Failf("%s: not registered", name) + for _, n := range missing { + status.Failf("%s: not registered", n) } if len(missing) > 0 && len(registered) == 0 { return fmt.Errorf("no providers available — run: harness providers") } - // 4. Run preflight checks for profile providers + // 4. Run preflight checks status.Section("Preflight") providersPath := filepath.Join(harnessDir, "providers.toml") allProviders, err := preflight.LoadProviders(providersPath) @@ -77,7 +80,7 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { } else { preflightOK := true for _, p := range allProviders { - if !providerInList(p.Name, cfg.Providers) { + if !providerInList(p.Name, providerNames) { continue } ok, details := preflight.CheckProvider(p) @@ -98,34 +101,64 @@ 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 + // 5. Determine whether the in-cluster runner is needed. + needsRunner := false if !isLocal { - needsLauncher = profileHasCustomProviders(cfg.Providers, allProviders) + needsRunner = profileHasCustomProviders(providerNames, allProviders) } // 6. Deploy the sandbox status.Section("Creating sandbox") - if needsLauncher { - status.Info("Custom providers detected — using in-cluster launcher") + if needsRunner { + status.Info("Custom providers detected — using in-cluster runner") gwCfg := loadGatewayConfigForActive(harnessDir, activeGW) - return createViaLauncher(harnessDir, gwCfg, gw, profileName, cfg) + return createViaRunner(harnessDir, gwCfg, gw, agentName, name) } - return createDirect(harnessDir, gw, profileName, cfg, registered) + + // Render payload and create directly + payloadDir, err := os.MkdirTemp("", "harness-payload-") + if err != nil { + return fmt.Errorf("creating payload dir: %w", err) + } + defer os.RemoveAll(payloadDir) + + if err := agent.RenderPayload(agentCfg, harnessDir, payloadDir); err != nil { + return fmt.Errorf("rendering payload: %w", err) + } + + sandboxImage := agentCfg.Image + if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { + sandboxImage = envImage + } + + cfg := &profile.Config{ + Name: name, + From: sandboxImage, + } + + return createSandbox(sandboxOpts{ + harnessDir: harnessDir, + gw: gw, + cfg: cfg, + providers: registered, + noTTY: true, + retrySleep: 5 * time.Second, + sandboxCmd: []string{"bash", "/sandbox/.config/openshell/run.sh"}, + payloadDir: payloadDir, + onSuccess: func(n string) { + fmt.Println() + status.OKf("Sandbox created: %s — connect with: harness connect %s", n, n) + }, + }) }, } - cmd.Flags().StringVar(&profileName, "profile", "default", "Profile name (from profiles/)") - cmd.Flags().StringVar(&sandboxName, "name", "", "Sandbox name (overrides profile)") + cmd.Flags().StringVar(&agentName, "agent", "default", "Agent config name (from agents/)") + cmd.Flags().StringVar(&sandboxName, "name", "", "Sandbox name (overrides agent config)") return cmd } -// activeGatewayInfo returns the currently selected gateway. func activeGatewayInfo(gw gateway.Gateway) (*gateway.GatewayInfo, error) { gateways, err := gw.GatewayList() if err != nil { @@ -139,17 +172,14 @@ func activeGatewayInfo(gw gateway.Gateway) (*gateway.GatewayInfo, error) { 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 { +func profileHasCustomProviders(providerNames []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 { + for _, name := range providerNames { if custom[name] { return true } @@ -157,17 +187,13 @@ func profileHasCustomProviders(profileProviders []string, allProviders []preflig 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 { @@ -185,38 +211,11 @@ func loadGatewayConfigForActive(harnessDir string, active *gateway.GatewayInfo) return nil } -// 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 { +func createViaRunner(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gateway, agentName, sandboxName string) error { if gwCfg == nil { return fmt.Errorf("no gateway config found for remote gateway — expected gateways//gateway.toml") } - return upRemote(harnessDir, gwCfg, gw, profileName, cfg.Name) -} - -// 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 { - var sandboxCmd []string - if cfg.Startup != "" { - sandboxCmd = []string{"bash", "-c", fmt.Sprintf(". %s", cfg.Startup)} - } else { - sandboxCmd = []string{"true"} - } - - return createSandbox(sandboxOpts{ - harnessDir: harnessDir, - gw: gw, - cfg: cfg, - providers: registered, - noTTY: true, - retrySleep: 5 * time.Second, - sandboxCmd: sandboxCmd, - onSuccess: func(name string) { - fmt.Println() - status.OKf("Sandbox created: %s — connect with: harness connect %s", name, name) - }, - }) + return upRemote(harnessDir, gwCfg, gw, agentName, sandboxName) } func providerInList(name string, providers []string) bool { diff --git a/cmd/create_test.go b/cmd/create_test.go index c8fb9b3..3915f95 100644 --- a/cmd/create_test.go +++ b/cmd/create_test.go @@ -6,7 +6,6 @@ import ( "github.com/robbycochran/harness-openshell/internal/gateway" "github.com/robbycochran/harness-openshell/internal/preflight" - "github.com/robbycochran/harness-openshell/internal/profile" ) func TestActiveGatewayInfo_ListError(t *testing.T) { @@ -37,76 +36,6 @@ func TestActiveGatewayInfo_RemoteGateway(t *testing.T) { } } -func TestCreateDirect_NoProviders(t *testing.T) { - dir := setupTestProfile(t) - gw := &mockGW{ - providers: map[string]bool{}, - } - - cfg, err := profile.Parse(dir, "default") - if err != nil { - t.Fatalf("parse profile: %v", err) - } - - err = createDirect(dir, gw, "default", cfg, nil) - if err != nil { - t.Fatalf("createDirect: %v", err) - } - if gw.createCalls != 1 { - t.Errorf("createCalls = %d, want 1", gw.createCalls) - } - opts := gw.createOpts[0] - if len(opts.Providers) != 0 { - t.Errorf("Providers = %v, want empty", opts.Providers) - } -} - -func TestCreateDirect_WithProviders(t *testing.T) { - dir := setupTestProfile(t) - gw := &mockGW{ - providers: map[string]bool{"github": true}, - } - - cfg, err := profile.Parse(dir, "default") - if err != nil { - t.Fatalf("parse profile: %v", err) - } - - err = createDirect(dir, gw, "default", cfg, []string{"github"}) - if err != nil { - t.Fatalf("createDirect: %v", err) - } - opts := gw.createOpts[0] - if len(opts.Providers) != 1 || opts.Providers[0] != "github" { - t.Errorf("Providers = %v, want [github]", opts.Providers) - } - if opts.TTY { - t.Error("TTY should be false for create (non-interactive)") - } -} - -func TestCreateDirect_SandboxName(t *testing.T) { - dir := setupTestProfile(t) - gw := &mockGW{ - providers: map[string]bool{}, - } - - cfg, err := profile.Parse(dir, "default") - if err != nil { - t.Fatalf("parse profile: %v", err) - } - cfg.Name = "custom-sandbox" - - err = createDirect(dir, gw, "default", cfg, nil) - if err != nil { - t.Fatalf("createDirect: %v", err) - } - opts := gw.createOpts[0] - if opts.Name != "custom-sandbox" { - t.Errorf("Name = %q, want custom-sandbox", opts.Name) - } -} - func TestProfileHasCustomProviders_NoCustom(t *testing.T) { allProviders := []preflight.Provider{ {Name: "github", Type: "openshell"}, diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index 2d76ebc..3ee2f24 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -69,18 +69,19 @@ func (m *mockGW) GatewaySelect(string) error func (m *mockGW) ProviderRefreshConfigure(string, gateway.ProviderRefreshOpts) error { return nil } func (m *mockGW) ProviderRefreshRotate(string, string) error { return nil } -func setupTestProfile(t *testing.T) string { +func setupTestAgent(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" -from = "quay.io/test:latest" -command = "claude --bare" -providers = ["github", "vertex-local", "atlassian"] - -[env] -FOO = "bar" + os.MkdirAll(filepath.Join(dir, "agents"), 0o755) + os.WriteFile(filepath.Join(dir, "agents", "default.yaml"), []byte(`name: test-agent +image: quay.io/test:latest +entrypoint: claude --bare +providers: + - profile: github + - profile: vertex-local + - profile: atlassian +env: + FOO: bar `), 0o644) return dir } diff --git a/sandbox/launcher/main.go b/cmd/launch.go similarity index 51% rename from sandbox/launcher/main.go rename to cmd/launch.go index 561814f..2411342 100644 --- a/sandbox/launcher/main.go +++ b/cmd/launch.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "encoding/json" @@ -10,37 +10,74 @@ import ( "strings" "time" - "github.com/BurntSushi/toml" + "github.com/robbycochran/harness-openshell/internal/agent" + "github.com/spf13/cobra" ) -type Config struct { - Name string `toml:"name"` - From string `toml:"from"` - Command string `toml:"command"` - Keep *bool `toml:"keep"` - Providers []string `toml:"providers"` - Env map[string]string `toml:"env"` +func NewLaunchCmd(harnessDir, cli string) *cobra.Command { + return &cobra.Command{ + Use: "launch", + Short: "Run in-cluster: render agent config into a sandbox", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runLaunch(cli) + }, + } } -func (c *Config) KeepSandbox() bool { - if c.Keep == nil { - return true +func runLaunch(cli string) error { + endpoint := os.Getenv("GATEWAY_ENDPOINT") + if endpoint == "" { + endpoint = "https://openshell.openshell.svc.cluster.local:8080" } - return *c.Keep -} + if cli == "" { + cli = "openshell" + } + + if err := configureGateway(endpoint, "/secrets/mtls", cli); err != nil { + return fmt.Errorf("gateway config: %w", err) + } + + agentCfg, err := agent.ParseFile("/etc/openshell/sandbox/agent.yaml") + if err != nil { + return err + } + + if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { + agentCfg.Image = envImage + } + + fmt.Println("=== Sandbox Runner ===") + fmt.Printf(" Name: %s\n", agentCfg.Name) + fmt.Printf(" Image: %s\n", agentCfg.Image) + fmt.Printf(" Entrypoint: %s\n", agentCfg.EffectiveEntrypoint()) + fmt.Printf(" Gateway: %s\n", endpoint) + fmt.Println() + + providerNames := agentCfg.ProviderNames() + registered := checkProviders(providerNames, cli) -func parseConfig(path string) (*Config, error) { - var cfg Config - if _, err := toml.DecodeFile(path, &cfg); err != nil { - return nil, fmt.Errorf("parsing %s: %w", path, err) + payloadDir := "/tmp/openshell/payload" + if err := agent.RenderPayload(agentCfg, "/etc/openshell/sandbox", payloadDir); err != nil { + return fmt.Errorf("rendering payload: %w", err) + } + fmt.Printf(" Payload: %s\n", payloadDir) + + if err := launchCreateSandbox(agentCfg, registered, cli); err != nil { + return err } - if cfg.Name == "" { - cfg.Name = "agent" + + if err := launchUploadPayload(agentCfg.Name, payloadDir, cli); err != nil { + return fmt.Errorf("upload failed: %w", err) } - if cfg.Command == "" { - cfg.Command = "claude --bare" + + if err := launchExecEntrypoint(agentCfg.Name, cli); err != nil { + return fmt.Errorf("entrypoint failed: %w", err) } - return &cfg, nil + + fmt.Printf("\nSandbox '%s' is ready.\n", agentCfg.Name) + fmt.Printf("Connect with: openshell sandbox connect %s\n", agentCfg.Name) + return nil } func configureGateway(endpoint, mtlsDir, cli string) error { @@ -53,17 +90,15 @@ func configureGateway(endpoint, mtlsDir, cli string) error { } if len(missing) > 0 { fmt.Fprintf(os.Stderr, "WARNING: mTLS certs missing from %s: %v\n", mtlsDir, missing) - fmt.Fprintf(os.Stderr, "WARNING: falling back to INSECURE mode — gateway connection is NOT encrypted\n") + fmt.Fprintf(os.Stderr, "WARNING: falling back to INSECURE mode\n") os.Setenv("OPENSHELL_GATEWAY_ENDPOINT", endpoint) os.Setenv("OPENSHELL_GATEWAY_INSECURE", "true") return nil } - fmt.Println(" ✓ mTLS certs found — using encrypted connection") + fmt.Println(" mTLS certs found") httpEndpoint := strings.Replace(endpoint, "https:", "http:", 1) - // 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 @@ -78,8 +113,6 @@ 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") var meta map[string]any if data, err := os.ReadFile(metaPath); err == nil { @@ -106,8 +139,11 @@ func configureGateway(endpoint, mtlsDir, cli string) error { } } - run(cli, "gateway", "select", "openshell") - fmt.Println(" ✓ mTLS gateway configured") + selectCmd := exec.Command(cli, "gateway", "select", "openshell") + selectCmd.Stdout = os.Stdout + selectCmd.Stderr = io.Discard + selectCmd.Run() + fmt.Println(" mTLS gateway configured") return nil } @@ -127,38 +163,16 @@ func checkProviders(providers []string, cli string) []string { return registered } -var stageFilesFrom = "/etc/openshell/env/sandbox.env" - -func stageFiles(harnessDir string) error { - if err := os.MkdirAll(harnessDir, 0o755); err != nil { - return err - } - - if envPath := stageFilesFrom; fileExists(envPath) { - if err := copyFile(envPath, filepath.Join(harnessDir, "sandbox.env")); err != nil { - return fmt.Errorf("copying sandbox.env: %w", err) - } - data, _ := os.ReadFile(envPath) - lines := strings.Count(string(data), "\n") - fmt.Printf(" Env: %d vars\n", lines) - } - - return nil -} - -func createSandbox(cfg *Config, providers []string, cli string) error { +func launchCreateSandbox(cfg *agent.AgentConfig, providers []string, cli string) error { fmt.Println("\n=== Creating sandbox ===") for attempt := 1; attempt <= 5; attempt++ { args := []string{"sandbox", "create", "--name", cfg.Name, "--no-tty"} - if cfg.From != "" { - args = append(args, "--from", cfg.From) + if cfg.Image != "" { + args = append(args, "--from", cfg.Image) } for _, p := range providers { args = append(args, "--provider", p) } - if !cfg.KeepSandbox() { - args = append(args, "--no-keep") - } args = append(args, "--", "true") cmd := exec.Command(cli, args...) @@ -168,7 +182,7 @@ func createSandbox(cfg *Config, providers []string, cli string) error { return nil } - fmt.Printf(" Attempt %d failed (supervisor race), retrying in 10s...\n", attempt) + fmt.Printf(" Attempt %d failed, retrying in 10s...\n", attempt) del := exec.Command(cli, "sandbox", "delete", cfg.Name) del.Stdout = io.Discard del.Stderr = io.Discard @@ -182,29 +196,22 @@ func createSandbox(cfg *Config, providers []string, cli string) error { return nil } -func uploadFiles(name, harnessDir, cli string) error { - fmt.Println(" Uploading to /sandbox/.config/openshell/...") - cmd := exec.Command(cli, "sandbox", "upload", name, harnessDir, "/sandbox/.config", "--no-git-ignore") +func launchUploadPayload(name, payloadDir, cli string) error { + fmt.Println(" Uploading payload...") + cmd := exec.Command(cli, "sandbox", "upload", name, payloadDir, "/sandbox/.config/openshell", "--no-git-ignore") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } -func runStartup(name, cli string) error { - fmt.Println(" Running startup...") - cmd := exec.Command(cli, "sandbox", "exec", "--name", name, "--", "bash", "/sandbox/startup.sh") +func launchExecEntrypoint(name, cli string) error { + fmt.Println(" Starting entrypoint...") + cmd := exec.Command(cli, "sandbox", "exec", "--name", name, "--", "bash", "/sandbox/.config/openshell/run.sh") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } -func run(name string, args ...string) { - cmd := exec.Command(name, args...) - cmd.Stdout = os.Stdout - cmd.Stderr = io.Discard - cmd.Run() -} - func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { @@ -221,71 +228,3 @@ func copyFile(src, dst string) error { } return out.Close() } - -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -func main() { - endpoint := os.Getenv("GATEWAY_ENDPOINT") - if endpoint == "" { - endpoint = "https://openshell.openshell.svc.cluster.local:8080" - } - cli := os.Getenv("OPENSHELL_CLI") - if cli == "" { - cli = "openshell" - } - configPath := "/etc/openshell/sandbox/config.toml" - - if err := configureGateway(endpoint, "/secrets/mtls", cli); err != nil { - fmt.Fprintf(os.Stderr, "ERROR: gateway config: %v\n", err) - os.Exit(1) - } - - cfg, err := parseConfig(configPath) - if err != nil { - fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) - os.Exit(1) - } - - if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { - cfg.From = envImage - } - - fmt.Println("=== Sandbox Launcher ===") - fmt.Printf(" Name: %s\n", cfg.Name) - fmt.Printf(" From: %s\n", cfg.From) - fmt.Printf(" Providers: %s\n", strings.Join(cfg.Providers, " ")) - fmt.Printf(" Command: %s\n", cfg.Command) - fmt.Printf(" Gateway: %s\n", endpoint) - fmt.Println() - - providers := checkProviders(cfg.Providers, cli) - - harnessDir := "/tmp/openshell" - if err := stageFiles(harnessDir); err != nil { - fmt.Fprintf(os.Stderr, "ERROR: staging files: %v\n", err) - os.Exit(1) - } - - if err := createSandbox(cfg, providers, cli); err != nil { - fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) - os.Exit(1) - } - - if err := uploadFiles(cfg.Name, harnessDir, cli); err != nil { - fmt.Fprintf(os.Stderr, "ERROR: upload failed: %v\n", err) - os.Exit(1) - } - - if err := runStartup(cfg.Name, cli); err != nil { - fmt.Fprintf(os.Stderr, "ERROR: startup failed: %v\n", err) - os.Exit(1) - } - - fmt.Printf("\nSandbox '%s' is ready.\n", cfg.Name) - fmt.Printf("Connect with: openshell sandbox connect %s\n", cfg.Name) - fmt.Printf("Or from inside the sandbox: %s\n", cfg.Command) - -} diff --git a/cmd/sandbox.go b/cmd/sandbox.go index 5b98da8..bbfb4f0 100644 --- a/cmd/sandbox.go +++ b/cmd/sandbox.go @@ -21,6 +21,7 @@ type sandboxOpts struct { noTTY bool // true → TTY=false for the sandbox retrySleep time.Duration // pause between retry attempts sandboxCmd []string // command to run inside the sandbox + payloadDir string // pre-rendered payload dir; skips StageHarnessDir when set onSuccess func(name string) // called after successful creation (optional) } diff --git a/cmd/up.go b/cmd/up.go index 6cadc0c..3406f85 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/robbycochran/harness-openshell/internal/agent" "github.com/robbycochran/harness-openshell/internal/gateway" "github.com/robbycochran/harness-openshell/internal/k8s" "github.com/robbycochran/harness-openshell/internal/profile" @@ -20,7 +21,7 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { var ( local bool remote bool - profileName string + agentName string sandboxName string noTTY bool ) @@ -28,7 +29,7 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { cmd := &cobra.Command{ 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.", + Long: "Deploy gateway and register providers if needed, then render an agent config into a running sandbox.", RunE: func(cmd *cobra.Command, args []string) error { if local && remote { return fmt.Errorf("--local and --remote are mutually exclusive") @@ -39,23 +40,22 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { gw := gateway.New(cli) - // Load gateway config for the selected target gwName := "local" if remote { gwName = "ocp" } gwDir := filepath.Join(harnessDir, "gateways", gwName) - gwCfg, _ := gateway.LoadConfig(gwDir) // nil is fine — backward compat + gwCfg, _ := gateway.LoadConfig(gwDir) if remote { - return upRemote(harnessDir, gwCfg, gw, profileName, sandboxName) + return upRemote(harnessDir, gwCfg, gw, agentName, sandboxName) } return upLocal(upLocalOpts{ harnessDir: harnessDir, gw: gw, gwCfg: gwCfg, ensureLocal: local, - profileName: profileName, + agentName: agentName, sandboxName: sandboxName, noTTY: noTTY, retrySleep: 5 * time.Second, @@ -65,8 +65,8 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { 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().StringVar(&agentName, "agent", "default", "Agent config name (from agents/)") + cmd.Flags().StringVar(&sandboxName, "name", "", "Sandbox name (overrides agent config)") cmd.Flags().BoolVar(&noTTY, "no-tty", false, "Non-interactive mode (for testing)") return cmd @@ -77,13 +77,13 @@ type upLocalOpts struct { gw gateway.Gateway gwCfg *gateway.GatewayConfig ensureLocal bool - profileName string + agentName string sandboxName string noTTY bool retrySleep time.Duration } -func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gateway, profileName, sandboxName string) error { +func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gateway, agentName, sandboxName string) error { ctx := context.Background() namespace := k8s.DefaultNamespace() kc := k8s.New("", namespace) @@ -112,38 +112,26 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa } } - // Parse profile - cfg, err := profile.Parse(harnessDir, profileName) + // 3. Parse agent config + agentPath := filepath.Join(harnessDir, "agents", agentName+".yaml") + agentCfg, err := agent.ParseFile(agentPath) if err != nil { return err } + name := agentCfg.Name if sandboxName != "" { - cfg.Name = sandboxName + name = sandboxName } - injectAtlassianEnv(cfg) - // Resolve sandbox image for remote deploys. - // SANDBOX_IMAGE env var overrides everything (dev/CI builds). - // Otherwise the profile's 'from' field is authoritative — each profile - // specifies its own image (default.toml uses the custom sandbox, - // ci.toml uses the upstream community base). + // Resolve sandbox image + sandboxImage := agentCfg.Image if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { - cfg.From = envImage - } else if cfg.From != "" { - fromPath := cfg.From - if !filepath.IsAbs(fromPath) { - fromPath = filepath.Join(harnessDir, fromPath) - } - if info, err := os.Stat(fromPath); err == nil && info.IsDir() { - cfg.From = envOr("SANDBOX_IMAGE", "ghcr.io/robbycochran/harness-openshell:sandbox") - } + sandboxImage = envImage } - profilePath := filepath.Join(harnessDir, "profiles", profileName+".toml") - - // 1. ConfigMap from profile - out, err := kc.RunKubectl(ctx, "create", "configmap", "sandbox-"+cfg.Name, - "--from-file=config.toml="+profilePath, + // 4. ConfigMap from agent.yaml + out, err := kc.RunKubectl(ctx, "create", "configmap", "sandbox-"+name, + "--from-file=agent.yaml="+agentPath, "--dry-run=client", "-o", "yaml") if err != nil { return fmt.Errorf("creating config configmap: %w", err) @@ -155,36 +143,20 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa return fmt.Errorf("applying config configmap: %w", err) } - // 2. ConfigMap from env (conditional) - envContent := cfg.BuildSandboxEnv() - if envContent != "" { - out, err := kc.RunKubectl(ctx, "create", "configmap", "sandbox-"+cfg.Name+"-env", - "--from-literal=sandbox.env="+envContent, - "--dry-run=client", "-o", "yaml") - if err == nil { - if _, err := kc.RunKubectlOpts(ctx, k8s.KubectlOpts{ - Args: []string{"apply", "-f", "-"}, - Stdin: strings.NewReader(out), - }); err != nil { - return fmt.Errorf("applying env configmap: %w", err) - } - } - } - - // 3. Clean up old job (best-effort — may not exist) - jobName := "sandbox-" + cfg.Name + // 5. Clean up old job + jobName := "sandbox-" + name kc.RunKubectl(ctx, "delete", "job", jobName, "--grace-period=30") kc.RunKubectl(ctx, "delete", "pod", "-l", "job-name="+jobName, "--grace-period=30") - // 4. Apply launcher Job - launcherImage := envOr("LAUNCHER_IMAGE", "ghcr.io/robbycochran/harness-openshell:launcher") - launcherSA := "openshell-launcher" - launcherEndpoint := "https://openshell.openshell.svc.cluster.local:8080" + // 6. Apply runner Job + runnerImage := envOr("RUNNER_IMAGE", "ghcr.io/robbycochran/harness-openshell:runner") + runnerSA := "openshell-launcher" + gatewayEndpoint := "https://openshell.openshell.svc.cluster.local:8080" mtlsSecret := "openshell-client-tls" if gwCfg != nil { - launcherImage = gwCfg.Images.Launcher - launcherSA = gwCfg.Launcher.ServiceAccount - launcherEndpoint = gwCfg.Launcher.GatewayEndpoint + runnerImage = gwCfg.Images.Runner + runnerSA = gwCfg.Launcher.ServiceAccount + gatewayEndpoint = gwCfg.Launcher.GatewayEndpoint mtlsSecret = gwCfg.Secrets.MTLS } @@ -196,53 +168,52 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa "backoffLimit": 0, "template": map[string]any{ "spec": map[string]any{ - "serviceAccountName": launcherSA, + "serviceAccountName": runnerSA, "restartPolicy": "Never", "containers": []map[string]any{{ - "name": "launcher", - "image": launcherImage, + "name": "runner", + "image": runnerImage, "imagePullPolicy": "Always", - "env": launcherEnv(launcherEndpoint, cfg.From), + "command": []string{"harness", "launch"}, + "env": runnerEnv(gatewayEndpoint, sandboxImage), "volumeMounts": []map[string]any{ {"name": "config", "mountPath": "/etc/openshell/sandbox", "readOnly": true}, {"name": "gateway-mtls", "mountPath": "/secrets/mtls", "readOnly": true}, - {"name": "sandbox-env", "mountPath": "/etc/openshell/env", "readOnly": true}, }, }}, "volumes": []map[string]any{ - {"name": "config", "configMap": map[string]any{"name": "sandbox-" + cfg.Name}}, + {"name": "config", "configMap": map[string]any{"name": "sandbox-" + name}}, {"name": "gateway-mtls", "secret": map[string]any{"secretName": mtlsSecret}}, - {"name": "sandbox-env", "configMap": map[string]any{"name": "sandbox-" + cfg.Name + "-env", "optional": true}}, }, }, }, }, } if err := kc.ApplyYAML(ctx, job); err != nil { - return fmt.Errorf("applying launcher job: %w", err) + return fmt.Errorf("applying runner job: %w", err) } - // 5. Wait for launcher pod + // 7. Wait for runner pod fmt.Println() - status.Info("Waiting for launcher...") + status.Info("Waiting for runner...") kc.RunKubectl(ctx, "wait", "--for=condition=ready", "pod", "-l", "job-name="+jobName, "--timeout=120s") - // 6. Tail logs in background + // 8. Tail logs in background logCmd := exec.CommandContext(ctx, "kubectl", "-n", namespace, "logs", "-f", "-l", "job-name="+jobName) logCmd.Stdout = os.Stdout logCmd.Stderr = os.Stderr logCmd.Start() - // 7. Poll job status (10 min timeout) + // 9. Poll job status (10 min timeout) var jobStatus string deadline := time.Now().Add(10 * time.Minute) for time.Now().Before(deadline) { jobStatus, err = kc.RunKubectl(ctx, "get", "job", jobName, "-o", "jsonpath={.status.conditions[0].type}") if err != nil { - return fmt.Errorf("checking launcher job status: %w", err) + return fmt.Errorf("checking runner job status: %w", err) } if jobStatus == "Complete" || jobStatus == "Failed" || jobStatus == "SuccessCriteriaMet" { break @@ -257,13 +228,13 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa fmt.Println() if jobStatus == "Complete" || jobStatus == "SuccessCriteriaMet" { - status.OKf("Sandbox ready. Connect with: harness connect %s", cfg.Name) + status.OKf("Sandbox ready. Connect with: harness connect %s", name) return nil } if jobStatus == "" { - return fmt.Errorf("launcher job timed out — check: kubectl logs -n %s -l job-name=%s", namespace, jobName) + return fmt.Errorf("runner job timed out — check: kubectl logs -n %s -l job-name=%s", namespace, jobName) } - return fmt.Errorf("launcher job failed (status: %s) — check: kubectl logs -n %s -l job-name=%s", jobStatus, namespace, jobName) + return fmt.Errorf("runner job failed (status: %s) — check: kubectl logs -n %s -l job-name=%s", jobStatus, namespace, jobName) } func upLocal(opts upLocalOpts) error { @@ -293,35 +264,53 @@ func upLocal(opts upLocalOpts) error { } } - // 3. Parse profile - cfg, err := profile.Parse(opts.harnessDir, opts.profileName) + // 3. Parse agent config + agentPath := filepath.Join(opts.harnessDir, "agents", opts.agentName+".yaml") + agentCfg, err := agent.ParseFile(agentPath) if err != nil { return err } + + // 4. Render payload + payloadDir, err := os.MkdirTemp("", "harness-payload-") + if err != nil { + return fmt.Errorf("creating payload dir: %w", err) + } + defer os.RemoveAll(payloadDir) + + if err := agent.RenderPayload(agentCfg, opts.harnessDir, payloadDir); err != nil { + return fmt.Errorf("rendering payload: %w", err) + } + + // 5. Build sandbox config + sandboxName := agentCfg.Name if opts.sandboxName != "" { - cfg.Name = opts.sandboxName + sandboxName = opts.sandboxName } - injectAtlassianEnv(cfg) + noTTY := opts.noTTY || agentCfg.NoTTY() - // Resolve image before printing so the log shows the effective path. - // createSandbox resolves idempotently, so this won't double-resolve. + sandboxImage := agentCfg.Image if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { - cfg.From = envImage - } else if cfg.From != "" && !filepath.IsAbs(cfg.From) { - candidate := filepath.Join(opts.harnessDir, cfg.From) - if info, err := os.Stat(candidate); err == nil && info.IsDir() { - cfg.From = candidate - } + sandboxImage = envImage + } + + cfg := &profile.Config{ + Name: sandboxName, + From: sandboxImage, } fmt.Println() fmt.Println("=== Sandbox ===") - fmt.Printf(" Profile: %s\n", opts.profileName) - fmt.Printf(" From: %s\n", cfg.From) + fmt.Printf(" Agent: %s\n", opts.agentName) + fmt.Printf(" Image: %s\n", cfg.From) + if agentCfg.Task != "" { + fmt.Printf(" Task: %s\n", agentCfg.Task) + } - // 4. Validate providers against profile + // 6. Validate providers status.Section("Providers") - registered, missing := profile.ValidateProviders(cfg.Providers, gw) + providerNames := agentCfg.ProviderNames() + registered, missing := profile.ValidateProviders(providerNames, gw) for _, name := range registered { status.OKf("%s: attached", name) } @@ -333,53 +322,24 @@ func upLocal(opts upLocalOpts) error { status.Warn("no providers available — run: harness providers") } - // 5. Build command - var sandboxCmd []string - if cfg.Startup != "" { - if opts.noTTY { - sandboxCmd = []string{"bash", "-c", fmt.Sprintf(". %s", cfg.Startup)} - } else { - sandboxCmd = []string{"bash", "-c", fmt.Sprintf(". %s && exec %s", cfg.Startup, cfg.Command)} - } - } else { - if opts.noTTY { - sandboxCmd = []string{"true"} - } else { - sandboxCmd = []string{"bash", "-c", fmt.Sprintf("exec %s", cfg.Command)} - } - } - - // 6. Create sandbox + // 7. Create sandbox fmt.Println() fmt.Println("=== Creating sandbox ===") + sandboxCmd := []string{"bash", "/sandbox/.config/openshell/run.sh"} + return createSandbox(sandboxOpts{ harnessDir: opts.harnessDir, gw: gw, cfg: cfg, providers: registered, - noTTY: opts.noTTY, + noTTY: noTTY, retrySleep: opts.retrySleep, sandboxCmd: sandboxCmd, + payloadDir: payloadDir, }) } -// injectAtlassianEnv adds JIRA_URL and JIRA_USERNAME from the local environment -// into cfg.Env so they land in sandbox.env. Interim until Phase 2 payload renderer -// ships; remove when harness create renders payload/env.sh instead. -func injectAtlassianEnv(cfg *profile.Config) { - if cfg.Env == nil { - cfg.Env = make(map[string]string) - } - for _, key := range []string{"JIRA_URL", "JIRA_USERNAME"} { - if _, exists := cfg.Env[key]; !exists { - if v := os.Getenv(key); v != "" { - cfg.Env[key] = v - } - } - } -} - -func launcherEnv(gatewayEndpoint, sandboxImage string) []map[string]any { +func runnerEnv(gatewayEndpoint, sandboxImage string) []map[string]any { env := []map[string]any{ {"name": "GATEWAY_ENDPOINT", "value": gatewayEndpoint}, {"name": "HOME", "value": "/tmp"}, diff --git a/cmd/up_test.go b/cmd/up_test.go index 3c01b6e..931ea29 100644 --- a/cmd/up_test.go +++ b/cmd/up_test.go @@ -2,8 +2,6 @@ package cmd import ( "fmt" - "os" - "path/filepath" "strings" "testing" @@ -12,14 +10,14 @@ import ( ) func TestUpLocal_NoGateway(t *testing.T) { - dir := setupTestProfile(t) + dir := setupTestAgent(t) gw := &mockGW{inferenceErr: fmt.Errorf("connection refused")} err := upLocal(upLocalOpts{ - harnessDir: dir, - gw: gw, - profileName: "default", - noTTY: true, + harnessDir: dir, + gw: gw, + agentName: "default", + noTTY: true, }) if err == nil { t.Fatal("expected error") @@ -30,18 +28,17 @@ func TestUpLocal_NoGateway(t *testing.T) { } func TestUpLocal_NoProviders_RegistersProviders(t *testing.T) { - dir := setupTestProfile(t) - os.MkdirAll(filepath.Join(dir, "sandbox", "profiles"), 0o755) + dir := setupTestAgent(t) gw := &mockGW{ providerList: nil, providers: map[string]bool{}, } err := upLocal(upLocalOpts{ - harnessDir: dir, - gw: gw, - profileName: "default", - noTTY: true, + harnessDir: dir, + gw: gw, + agentName: "default", + noTTY: true, }) if err != nil { t.Fatalf("upLocal: %v", err) @@ -49,18 +46,17 @@ func TestUpLocal_NoProviders_RegistersProviders(t *testing.T) { } func TestUpLocal_MissingProviders(t *testing.T) { - dir := setupTestProfile(t) + dir := setupTestAgent(t) gw := &mockGW{ providerList: []string{"github"}, providers: map[string]bool{"github": true}, } err := upLocal(upLocalOpts{ - harnessDir: dir, - gw: gw, - profileName: "default", - noTTY: true, - + harnessDir: dir, + gw: gw, + agentName: "default", + noTTY: true, }) if err != nil { t.Fatalf("upLocal: %v", err) @@ -75,18 +71,17 @@ func TestUpLocal_MissingProviders(t *testing.T) { } func TestUpLocal_AllProvidersMissing(t *testing.T) { - dir := setupTestProfile(t) + dir := setupTestAgent(t) gw := &mockGW{ providerList: []string{"github"}, providers: map[string]bool{}, } err := upLocal(upLocalOpts{ - harnessDir: dir, - gw: gw, - profileName: "default", - noTTY: true, - + harnessDir: dir, + gw: gw, + agentName: "default", + noTTY: true, }) if err != nil { t.Fatalf("upLocal: %v", err) @@ -97,24 +92,23 @@ func TestUpLocal_AllProvidersMissing(t *testing.T) { } } -func TestUpLocal_ProfileNotFound(t *testing.T) { - dir := setupTestProfile(t) +func TestUpLocal_AgentNotFound(t *testing.T) { + dir := setupTestAgent(t) gw := &mockGW{providerList: []string{"github"}} err := upLocal(upLocalOpts{ - harnessDir: dir, - gw: gw, - profileName: "nonexistent", - noTTY: true, - + harnessDir: dir, + gw: gw, + agentName: "nonexistent", + noTTY: true, }) if err == nil { - t.Fatal("expected error for missing profile") + t.Fatal("expected error for missing agent config") } } func TestUpLocal_SandboxCreateRetry(t *testing.T) { - dir := setupTestProfile(t) + dir := setupTestAgent(t) gw := &mockGW{ providerList: []string{"github"}, providers: map[string]bool{"github": true}, @@ -122,12 +116,11 @@ func TestUpLocal_SandboxCreateRetry(t *testing.T) { } err := upLocal(upLocalOpts{ - harnessDir: dir, - gw: gw, - profileName: "default", - noTTY: true, - - retrySleep: 0, + harnessDir: dir, + gw: gw, + agentName: "default", + noTTY: true, + retrySleep: 0, }) if err != nil { t.Fatalf("upLocal: %v", err) @@ -190,8 +183,8 @@ func TestProfileHasCustomProviders(t *testing.T) { } func TestUpLocal_SandboxCreateOpts(t *testing.T) { - t.Setenv("SANDBOX_IMAGE", "") // prevent env leak from Makefile into profile.From check - dir := setupTestProfile(t) + t.Setenv("SANDBOX_IMAGE", "") + dir := setupTestAgent(t) gw := &mockGW{ providerList: []string{"github", "vertex-local"}, providers: map[string]bool{"github": true, "vertex-local": true}, @@ -200,10 +193,9 @@ func TestUpLocal_SandboxCreateOpts(t *testing.T) { err := upLocal(upLocalOpts{ harnessDir: dir, gw: gw, - profileName: "default", + agentName: "default", sandboxName: "custom-name", noTTY: true, - }) if err != nil { t.Fatalf("upLocal: %v", err) @@ -213,12 +205,9 @@ func TestUpLocal_SandboxCreateOpts(t *testing.T) { t.Errorf("Name = %q, want custom-name", opts.Name) } if opts.From != "quay.io/test:latest" { - t.Errorf("From = %q", opts.From) + t.Errorf("From = %q, want quay.io/test:latest", opts.From) } if opts.TTY { t.Error("TTY = true, want false (noTTY)") } - if !opts.Keep { - t.Error("Keep = false, want true (default)") - } } diff --git a/gateways/ocp/gateway.toml b/gateways/ocp/gateway.toml index fcb9985..0106b23 100644 --- a/gateways/ocp/gateway.toml +++ b/gateways/ocp/gateway.toml @@ -32,7 +32,7 @@ values = "values.yaml" manifests = ["addons/rbac.yaml", "addons/route.yaml"] [images] -launcher = "ghcr.io/robbycochran/harness-openshell:launcher" +runner = "ghcr.io/robbycochran/harness-openshell:runner" [ocp] scc-privileged = ["openshell", "openshell-sandbox"] diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 6baf5bf..2fa9fe6 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -11,8 +11,8 @@ import ( ) type ProviderRef struct { - Type string `yaml:"type"` - Config map[string]string `yaml:"config,omitempty"` + Profile string `yaml:"profile"` + Config map[string]string `yaml:"config,omitempty"` } type AgentConfig struct { @@ -44,7 +44,7 @@ func (c *AgentConfig) EffectiveEntrypoint() string { func (c *AgentConfig) ProviderNames() []string { names := make([]string, len(c.Providers)) for i, p := range c.Providers { - names[i] = p.Type + names[i] = p.Profile } return names } @@ -66,8 +66,8 @@ func Parse(data []byte) (*AgentConfig, error) { return nil, fmt.Errorf("agent config: name is required") } for i, p := range cfg.Providers { - if p.Type == "" { - return nil, fmt.Errorf("agent config: providers[%d].type is required", i) + if p.Profile == "" { + return nil, fmt.Errorf("agent config: providers[%d].profile is required", i) } } return &cfg, nil @@ -93,7 +93,7 @@ func (c *AgentConfig) BuildEnvSh() string { sort.Strings(keys) var b strings.Builder for _, k := range keys { - fmt.Fprintf(&b, "export %s=%q\n", k, env[k]) + fmt.Fprintf(&b, "export %s=%q\n", k, os.ExpandEnv(env[k])) } return b.String() } diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 01666a9..5003bb2 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -11,11 +11,11 @@ func TestParse_Valid(t *testing.T) { data := []byte(` name: daily-standup providers: - - type: atlassian + - profile: atlassian config: JIRA_USERNAME: alice@example.com JIRA_URL: https://issues.redhat.com - - type: github + - profile: github task: tasks/daily-standup.md entrypoint: claude --bare `) @@ -29,8 +29,8 @@ entrypoint: claude --bare if len(cfg.Providers) != 2 { t.Fatalf("Providers = %d, want 2", len(cfg.Providers)) } - if cfg.Providers[0].Type != "atlassian" { - t.Errorf("Providers[0].Type = %q, want atlassian", cfg.Providers[0].Type) + if cfg.Providers[0].Profile != "atlassian" { + t.Errorf("Providers[0].Profile = %q, want atlassian", cfg.Providers[0].Profile) } if cfg.Providers[0].Config["JIRA_USERNAME"] != "alice@example.com" { t.Errorf("JIRA_USERNAME = %q", cfg.Providers[0].Config["JIRA_USERNAME"]) @@ -51,7 +51,7 @@ func TestParse_MissingName(t *testing.T) { } } -func TestParse_MissingProviderType(t *testing.T) { +func TestParse_MissingProviderProfile(t *testing.T) { data := []byte(` name: test providers: @@ -60,10 +60,10 @@ providers: `) _, err := Parse(data) if err == nil { - t.Fatal("expected error for missing provider type") + t.Fatal("expected error for missing provider profile") } - if !strings.Contains(err.Error(), "type is required") { - t.Errorf("error = %q, want 'type is required'", err) + if !strings.Contains(err.Error(), "profile is required") { + t.Errorf("error = %q, want 'profile is required'", err) } } @@ -73,9 +73,6 @@ func TestParse_InvalidYAML(t *testing.T) { if err == nil { t.Fatal("expected error for invalid YAML") } - if !strings.Contains(err.Error(), "parsing agent config") { - t.Errorf("error = %q, want 'parsing agent config'", err) - } } func TestParseFile_NotFound(t *testing.T) { @@ -102,9 +99,9 @@ providers: [] func TestProviderNames(t *testing.T) { cfg := &AgentConfig{ Providers: []ProviderRef{ - {Type: "github"}, - {Type: "atlassian"}, - {Type: "gws"}, + {Profile: "github"}, + {Profile: "atlassian"}, + {Profile: "gws"}, }, } names := cfg.ProviderNames() @@ -147,7 +144,7 @@ func TestNoTTY(t *testing.T) { func TestBuildEnvSh(t *testing.T) { cfg := &AgentConfig{ Providers: []ProviderRef{ - {Type: "atlassian", Config: map[string]string{ + {Profile: "atlassian", Config: map[string]string{ "JIRA_URL": "https://issues.redhat.com", "JIRA_USERNAME": "alice", }}, @@ -163,7 +160,7 @@ func TestBuildEnvSh(t *testing.T) { } func TestBuildEnvSh_Empty(t *testing.T) { - cfg := &AgentConfig{Providers: []ProviderRef{{Type: "github"}}} + cfg := &AgentConfig{Providers: []ProviderRef{{Profile: "github"}}} if env := cfg.BuildEnvSh(); env != "" { t.Errorf("expected empty env.sh, got:\n%s", env) } @@ -176,7 +173,7 @@ func TestBuildEnvSh_TopLevelEnv(t *testing.T) { "ANTHROPIC_API_KEY": "sk-proxy", }, Providers: []ProviderRef{ - {Type: "atlassian", Config: map[string]string{"JIRA_URL": "https://jira.example.com"}}, + {Profile: "atlassian", Config: map[string]string{"JIRA_URL": "https://jira.example.com"}}, }, } env := cfg.BuildEnvSh() @@ -191,7 +188,7 @@ func TestBuildEnvSh_TopLevelEnv(t *testing.T) { func TestBuildEnvSh_ProviderOverridesTopLevel(t *testing.T) { cfg := &AgentConfig{ Env: map[string]string{"FOO": "from-top"}, - Providers: []ProviderRef{{Type: "test", Config: map[string]string{"FOO": "from-provider"}}}, + Providers: []ProviderRef{{Profile: "test", Config: map[string]string{"FOO": "from-provider"}}}, } env := cfg.BuildEnvSh() if !strings.Contains(env, `"from-provider"`) { @@ -206,8 +203,8 @@ image: ghcr.io/test:latest entrypoint: claude --bare tty: true providers: - - type: github - - type: atlassian + - profile: github + - profile: atlassian config: JIRA_URL: https://jira.example.com env: @@ -236,7 +233,7 @@ env: func TestBuildEnvSh_Sorted(t *testing.T) { cfg := &AgentConfig{ Providers: []ProviderRef{ - {Type: "test", Config: map[string]string{"Z_VAR": "z", "A_VAR": "a"}}, + {Profile: "test", Config: map[string]string{"Z_VAR": "z", "A_VAR": "a"}}, }, } env := cfg.BuildEnvSh() @@ -288,7 +285,7 @@ func TestRenderPayload(t *testing.T) { cfg := &AgentConfig{ Name: "test-agent", Providers: []ProviderRef{ - {Type: "atlassian", Config: map[string]string{"JIRA_URL": "https://jira.example.com"}}, + {Profile: "atlassian", Config: map[string]string{"JIRA_URL": "https://jira.example.com"}}, }, Task: "my-task.md", Entrypoint: "claude --bare", @@ -331,7 +328,7 @@ func TestRenderPayload(t *testing.T) { func TestRenderPayload_NoEnv(t *testing.T) { cfg := &AgentConfig{ Name: "minimal", - Providers: []ProviderRef{{Type: "github"}}, + Providers: []ProviderRef{{Profile: "github"}}, } destDir := filepath.Join(t.TempDir(), "payload") @@ -353,7 +350,7 @@ func TestRenderPayload_Include(t *testing.T) { cfg := &AgentConfig{ Name: "with-include", - Providers: []ProviderRef{{Type: "github"}}, + Providers: []ProviderRef{{Profile: "github"}}, Include: []string{"helper.sh"}, } @@ -375,7 +372,7 @@ func TestRenderPayload_IncludePathTraversal(t *testing.T) { baseDir := t.TempDir() cfg := &AgentConfig{ Name: "evil", - Providers: []ProviderRef{{Type: "github"}}, + Providers: []ProviderRef{{Profile: "github"}}, Include: []string{"../../etc/passwd"}, } diff --git a/internal/gateway/config.go b/internal/gateway/config.go index c4a07bf..f67ad6d 100644 --- a/internal/gateway/config.go +++ b/internal/gateway/config.go @@ -56,7 +56,7 @@ type AddonsSection struct { } type ImagesSection struct { - Launcher string `toml:"launcher"` + Runner string `toml:"runner"` } type OCPSection struct { @@ -93,8 +93,8 @@ func (c *GatewayConfig) applyDefaults() { if c.Chart.CRD.URL == "" { c.Chart.CRD.URL = "https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/manifest.yaml" } - if c.Images.Launcher == "" { - c.Images.Launcher = "ghcr.io/robbycochran/harness-openshell:launcher" + if c.Images.Runner == "" { + c.Images.Runner = "ghcr.io/robbycochran/harness-openshell:runner" } if c.Secrets.MTLS == "" { c.Secrets.MTLS = "openshell-client-tls" @@ -115,8 +115,8 @@ func (c *GatewayConfig) applyDefaults() { } func (c *GatewayConfig) applyEnvOverrides() { - if v := os.Getenv("LAUNCHER_IMAGE"); v != "" { - c.Images.Launcher = v + if v := os.Getenv("RUNNER_IMAGE"); v != "" { + c.Images.Runner = v } if v := os.Getenv("GATEWAY_NAME"); v != "" { c.Gateway.Name = v diff --git a/internal/gateway/config_test.go b/internal/gateway/config_test.go index 7620077..ca3e77b 100644 --- a/internal/gateway/config_test.go +++ b/internal/gateway/config_test.go @@ -43,7 +43,7 @@ values = "values.yaml" manifests = ["addons/rbac.yaml", "addons/route.yaml"] [images] -launcher = "example.com/launcher:v1" +runner = "example.com/runner:v1" [ocp] scc-privileged = ["sa1", "sa2"] @@ -93,8 +93,8 @@ gateway-endpoint = "https://gw.cluster.local:9090" if cfg.Chart.CRD.URL != "https://example.com/crd.yaml" { t.Errorf("chart.crd.url = %q", cfg.Chart.CRD.URL) } - if cfg.Images.Launcher != "example.com/launcher:v1" { - t.Errorf("images.launcher = %q", cfg.Images.Launcher) + if cfg.Images.Runner != "example.com/runner:v1" { + t.Errorf("images.runner = %q", cfg.Images.Runner) } if len(cfg.OCP.SCCPrivileged) != 2 { t.Errorf("ocp.scc-privileged = %v, want 2 entries", cfg.OCP.SCCPrivileged) @@ -197,10 +197,10 @@ type = "remote" name = "original-name" [images] -launcher = "original-launcher" +runner = "original-runner" `) - t.Setenv("LAUNCHER_IMAGE", "env-launcher:v2") + t.Setenv("RUNNER_IMAGE", "env-runner:v2") t.Setenv("GATEWAY_NAME", "env-gw-name") cfg, err := LoadConfig(dir) @@ -208,8 +208,8 @@ launcher = "original-launcher" t.Fatal(err) } - if cfg.Images.Launcher != "env-launcher:v2" { - t.Errorf("LAUNCHER_IMAGE override: got %q", cfg.Images.Launcher) + if cfg.Images.Runner != "env-runner:v2" { + t.Errorf("RUNNER_IMAGE override: got %q", cfg.Images.Runner) } if cfg.Gateway.Name != "env-gw-name" { t.Errorf("GATEWAY_NAME override: got %q", cfg.Gateway.Name) @@ -226,7 +226,7 @@ name = "original-name" `) // Ensure env vars are not set - t.Setenv("LAUNCHER_IMAGE", "") + t.Setenv("RUNNER_IMAGE", "") t.Setenv("GATEWAY_NAME", "") cfg, err := LoadConfig(dir) diff --git a/main.go b/main.go index 4b4ad0a..7c44bca 100644 --- a/main.go +++ b/main.go @@ -40,6 +40,7 @@ func main() { cmd.NewTeardownCmd(harnessDir, cli), cmd.NewPreflightCmd(harnessDir, cli), cmd.NewProvidersCmd(harnessDir, cli), + cmd.NewLaunchCmd(harnessDir, cli), ) if err := root.Execute(); err != nil { @@ -62,7 +63,7 @@ func detectHarnessDir() string { for _, root := range roots { dir := root for range 5 { - if _, err := os.Stat(filepath.Join(dir, "profiles", "default.toml")); err == nil { + if _, err := os.Stat(filepath.Join(dir, "agents", "default.yaml")); err == nil { return dir } dir = filepath.Dir(dir) diff --git a/profiles/ci.toml b/profiles/ci.toml deleted file mode 100644 index fcd328d..0000000 --- a/profiles/ci.toml +++ /dev/null @@ -1,10 +0,0 @@ -# CI integration test profile. -# -# Minimal sandbox with no providers and no credentials. -# Uses the public community base image (no registry auth needed). - -name = "ci-test" -from = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" -command = "bash" -keep = true -providers = [] diff --git a/profiles/default.toml b/profiles/default.toml deleted file mode 100644 index 50b9387..0000000 --- a/profiles/default.toml +++ /dev/null @@ -1,18 +0,0 @@ -# Default agent configuration. -# -# Usage: -# harness create # uses profiles/default.toml -# harness create --profile research # uses profiles/research.toml - -name = "agent" -from = "ghcr.io/robbycochran/harness-openshell:sandbox" -command = "claude --bare" -startup = "/sandbox/startup.sh" -keep = true - -providers = ["github", "vertex-local", "atlassian", "gws"] - -[env] -ANTHROPIC_BASE_URL = "https://inference.local" -ANTHROPIC_API_KEY = "sk-ant-openshell-proxy-managed" -CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = "1" diff --git a/sandbox/launcher/Dockerfile b/sandbox/launcher/Dockerfile deleted file mode 100644 index a491de6..0000000 --- a/sandbox/launcher/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -# syntax=docker/dockerfile:1.4 - -# OpenShell in-cluster sandbox launcher. -# -# 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: -# 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 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 - -USER 1000 - -ENTRYPOINT ["/usr/local/bin/launcher"] diff --git a/sandbox/launcher/SPEC.md b/sandbox/launcher/SPEC.md deleted file mode 100644 index 310fddb..0000000 --- a/sandbox/launcher/SPEC.md +++ /dev/null @@ -1,123 +0,0 @@ -# Sandbox Launcher - -> **Status:** Currently implemented as bash + Python. This spec describes -> the target Go rewrite — a single static binary replacing the entrypoint.sh -> and its PyYAML dependency. - -In-cluster binary that creates OpenShell sandboxes from a YAML config. -Runs as a Kubernetes Job — `./sandbox-ocp.sh` generates and applies it. - -## What it does - -1. Read `config.toml` from a mounted ConfigMap -2. Register the in-cluster gateway using mounted mTLS certs -3. Verify providers exist, skip any that aren't registered -4. Call `openshell sandbox create` with providers and skills config -5. Print connection instructions and exit - -## Config format - -Mounted at `/etc/openshell/sandbox/config.yaml`: - -```yaml -name: agent -command: claude --bare -keep: true - -providers: - - github - - vertex-local - - atlassian - -skills: - - repo: https://github.com/stackrox/skills - - repo: https://github.com/robbycochran/skills - ref: v1.0 - path: claude/skills -``` - -## Mounted volumes - -| Mount path | Source | Purpose | -|------------|--------|---------| -| `/etc/openshell/sandbox/` | ConfigMap | Sandbox config (config.yaml) | -| `/secrets/mtls/` | Secret `openshell-client-tls` | mTLS certs for gateway connection | -| `/secrets/gws/` | Secret `openshell-gws` (optional) | GWS OAuth credentials | - -## Environment variables (from Job spec) - -| Var | Source | Purpose | -|-----|--------|---------| -| `GATEWAY_ENDPOINT` | Job env | Gateway in-cluster address | -| `HOME` | Job env | Writable home for gateway config | -| `JIRA_URL` | Secret `openshell-atlassian` (optional) | Atlassian site URL | -| `JIRA_USERNAME` | Secret `openshell-atlassian` (optional) | Atlassian username | - -## Behavior - -### Gateway registration - -If `/secrets/mtls/tls.crt` exists, configures the `openshell` CLI with -mTLS certs and registers the in-cluster gateway. Otherwise falls back -to insecure mode (for local dev). - -### Provider detection - -For each provider in the config, checks if it's registered with the -gateway (`openshell provider get `). Skips unregistered providers -with a warning — doesn't fail. - -### Sandbox creation - -Calls `openshell sandbox create` with: -- `--name` from config -- `--no-tty` (no interactive terminal in a Job) -- `--provider` flags for each registered provider -- `--no-keep` if `keep: false` -- `-- bash -c "export SANDBOX_SKILLS_JSON=; . /sandbox/startup.sh"` - -Skills are serialized as base64-encoded JSON and passed as an env var -to the sandbox's startup script. The startup script decodes it, clones -repos, and auto-detects marketplace plugins vs raw skill directories. - -### Retry - -Retries up to 3 times on supervisor race conditions (common on -Kubernetes where the sandbox pod isn't fully ready when the SSH -connection is attempted). Deletes and recreates the sandbox between -retries. 5-second backoff. - -### Exit - -Prints connection instructions and exits 0. The sandbox pod lives -independently — the Job completing doesn't affect it. - -## Build - -``` -# Cross-compile for linux/amd64 -GOOS=linux GOARCH=amd64 go build -o launcher . - -# Or via Makefile -make cli-launcher -``` - -## Image - -Minimal image with two static binaries: - -```dockerfile -FROM scratch -COPY launcher /usr/local/bin/launcher -COPY openshell /usr/local/bin/openshell -ENTRYPOINT ["/usr/local/bin/launcher"] -``` - -No bash, no Python, no pip, no package manager. ~50MB total. - -## Future - -- Read skills config directly instead of passing via env var -- Support `openshell sandbox connect` for interactive sessions (needs TTY) -- Watch sandbox status and stream logs -- Support for non-GitHub skill repos (GitLab, Bitbucket) diff --git a/sandbox/launcher/entrypoint.sh b/sandbox/launcher/entrypoint.sh deleted file mode 100644 index e52ce06..0000000 --- a/sandbox/launcher/entrypoint.sh +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env bash -# In-cluster sandbox launcher. -# -# Runs as a Kubernetes Job. Reads sandbox configuration from a mounted -# ConfigMap (config.yaml) and credentials from volume-mounted Secrets. -# Calls openshell sandbox create in-cluster. -# -# Config mounted at: /etc/openshell/sandbox/config.yaml -# Secrets mounted at: /secrets/gws/, /secrets/mtls/ -set -uo pipefail - -GATEWAY_ENDPOINT="${GATEWAY_ENDPOINT:-https://openshell.openshell.svc.cluster.local:8080}" -CLI="${OPENSHELL_CLI:-openshell}" -CONFIG="/etc/openshell/sandbox/config.toml" - -# ── Configure gateway connection ────────────────────────────────────── -MTLS_DIR="/secrets/mtls" -if [[ -f "$MTLS_DIR/tls.crt" ]]; then - # Register as http:// (skips cert generation), then fix endpoint + add real certs - HTTP_ENDPOINT="${GATEWAY_ENDPOINT/https:/http:}" - "$CLI" gateway add "$HTTP_ENDPOINT" --name openshell 2>/dev/null || true - - # Patch to https and set auth_mode to mtls - GW_DIR="$HOME/.config/openshell/gateways/openshell" - mkdir -p "$GW_DIR/mtls" - python3 -c " -import json -with open('$GW_DIR/metadata.json') as f: d = json.load(f) -d['gateway_endpoint'] = '$GATEWAY_ENDPOINT' -d['auth_mode'] = 'mtls' -with open('$GW_DIR/metadata.json', 'w') as f: json.dump(d, f) -" - cp "$MTLS_DIR/ca.crt" "$GW_DIR/mtls/ca.crt" - cp "$MTLS_DIR/tls.crt" "$GW_DIR/mtls/tls.crt" - cp "$MTLS_DIR/tls.key" "$GW_DIR/mtls/tls.key" - echo " ✓ mTLS gateway configured" -else - echo " No mTLS certs, using insecure mode" - export OPENSHELL_GATEWAY_ENDPOINT="$GATEWAY_ENDPOINT" - export OPENSHELL_GATEWAY_INSECURE=true -fi - -# ── Parse config.toml ───────────────────────────────────────────────── -if [[ ! -f "$CONFIG" ]]; then - echo "ERROR: Config not found at $CONFIG" - exit 1 -fi - -eval "$(python3 -c " -try: - import tomllib -except ImportError: - import tomli as tomllib -import sys, shlex -with open(sys.argv[1], 'rb') as f: - c = tomllib.load(f) -print(f'SANDBOX_NAME={shlex.quote(c.get(\"name\", \"agent\"))}') -print(f'SANDBOX_IMAGE={shlex.quote(c.get(\"image\", \"\"))}') -print(f'SANDBOX_COMMAND={shlex.quote(c.get(\"command\", \"claude --bare\"))}') -print(f'SANDBOX_KEEP={shlex.quote(str(c.get(\"keep\", True)).lower())}') -providers = c.get('providers', []) -print(f'SANDBOX_PROVIDERS={shlex.quote(\" \".join(providers))}') -" "$CONFIG")" - -FROM_FLAGS=() -if [[ -n "$SANDBOX_IMAGE" ]]; then - FROM_FLAGS=(--from "$SANDBOX_IMAGE") -fi - -echo "=== Sandbox Launcher ===" -echo " Name: $SANDBOX_NAME" -echo " Image: $SANDBOX_IMAGE" -echo " Providers: $SANDBOX_PROVIDERS" -echo " Command: $SANDBOX_COMMAND" -echo " Gateway: $GATEWAY_ENDPOINT" -echo "" - -# ── Build provider flags ─────────────────────────────────────────────── -PROVIDER_FLAGS=() -for name in $SANDBOX_PROVIDERS; do - if "$CLI" provider get "$name" &>/dev/null; then - PROVIDER_FLAGS+=(--provider "$name") - echo " Provider $name: attached" - else - echo " Provider $name: not registered (skipping)" - fi -done - -# ── Stage files for upload to /sandbox/.config/openshell/ ───────────── -HARNESS_DIR="/tmp/openshell" -mkdir -p "$HARNESS_DIR" - -# Sandbox env vars (from agent config via ConfigMap) -if [[ -f /etc/openshell/env/sandbox.env ]]; then - cp /etc/openshell/env/sandbox.env "$HARNESS_DIR/sandbox.env" - echo " Env: $(wc -l < "$HARNESS_DIR/sandbox.env") vars" -fi - -# GWS credentials -if [[ -f /secrets/gws/credentials.json ]]; then - cp /secrets/gws/* "$HARNESS_DIR/" - echo " GWS: staged" -else - echo " GWS: not mounted (skipping)" -fi - -# ── Keep flag ────────────────────────────────────────────────────────── -KEEP_ARGS=() -[[ "$SANDBOX_KEEP" != "true" ]] && KEEP_ARGS=(--no-keep) - -echo "" -echo "=== Creating sandbox ===" -for attempt in 1 2 3 4 5; do - "$CLI" sandbox create \ - --name "$SANDBOX_NAME" \ - --no-tty \ - ${FROM_FLAGS[@]+"${FROM_FLAGS[@]}"} \ - ${PROVIDER_FLAGS[@]+"${PROVIDER_FLAGS[@]}"} \ - ${KEEP_ARGS[@]+"${KEEP_ARGS[@]}"} \ - -- true \ - && break - echo " Attempt $attempt failed (supervisor race), retrying in 10s..." - "$CLI" sandbox delete "$SANDBOX_NAME" 2>/dev/null || true - sleep 10 - [[ $attempt -eq 5 ]] && { echo "ERROR: Failed after 5 attempts."; exit 1; } -done - -# Upload all staged files in one shot -echo " Uploading to /sandbox/.config/openshell/..." -"$CLI" sandbox upload "$SANDBOX_NAME" "$HARNESS_DIR" /sandbox/.config --no-git-ignore - -echo " Running startup..." -"$CLI" sandbox exec --name "$SANDBOX_NAME" -- bash /sandbox/startup.sh - -echo "" -echo "Sandbox '$SANDBOX_NAME' is ready." -echo "Connect with: openshell sandbox connect $SANDBOX_NAME" -echo "Or from inside the sandbox: $SANDBOX_COMMAND" diff --git a/sandbox/launcher/go.mod b/sandbox/launcher/go.mod deleted file mode 100644 index 4d9bc5e..0000000 --- a/sandbox/launcher/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module github.com/robbycochran/harness-openshell/sandbox/launcher - -go 1.22.4 - -require github.com/BurntSushi/toml v1.6.0 // indirect diff --git a/sandbox/launcher/go.sum b/sandbox/launcher/go.sum deleted file mode 100644 index f74b269..0000000 --- a/sandbox/launcher/go.sum +++ /dev/null @@ -1,2 +0,0 @@ -github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= -github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= diff --git a/sandbox/launcher/main_test.go b/sandbox/launcher/main_test.go deleted file mode 100644 index d2b8ec0..0000000 --- a/sandbox/launcher/main_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "testing" -) - -func TestParseConfig_Full(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.toml") - os.WriteFile(path, []byte(` -name = "research" -from = "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 := parseConfig(path) - if err != nil { - t.Fatalf("parseConfig: %v", err) - } - if cfg.Name != "research" { - t.Errorf("Name = %q, want %q", cfg.Name, "research") - } - if cfg.From != "quay.io/test/sandbox:latest" { - t.Errorf("From = %q, want %q", cfg.From, "quay.io/test/sandbox:latest") - } - if cfg.Command != "claude --bare --model opus" { - t.Errorf("Command = %q, want %q", cfg.Command, "claude --bare --model opus") - } - if cfg.KeepSandbox() { - t.Error("KeepSandbox() = true, want false") - } - if len(cfg.Providers) != 2 { - t.Fatalf("Providers len = %d, want 2", len(cfg.Providers)) - } - if cfg.Providers[0] != "github" || cfg.Providers[1] != "vertex-local" { - t.Errorf("Providers = %v", cfg.Providers) - } - if cfg.Env["ANTHROPIC_BASE_URL"] != "https://inference.local" { - t.Errorf("Env[ANTHROPIC_BASE_URL] = %q", cfg.Env["ANTHROPIC_BASE_URL"]) - } - if cfg.Env["JIRA_URL"] != "https://example.atlassian.net" { - t.Errorf("Env[JIRA_URL] = %q", cfg.Env["JIRA_URL"]) - } -} - -func TestParseConfig_Defaults(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.toml") - os.WriteFile(path, []byte(` -from = "quay.io/test/sandbox:latest" -`), 0o644) - - cfg, err := parseConfig(path) - if err != nil { - t.Fatalf("parseConfig: %v", err) - } - if cfg.Name != "agent" { - t.Errorf("Name = %q, want %q (default)", cfg.Name, "agent") - } - if cfg.Command != "claude --bare" { - t.Errorf("Command = %q, want %q (default)", cfg.Command, "claude --bare") - } - if !cfg.KeepSandbox() { - t.Error("KeepSandbox() = false, want true (default)") - } - if len(cfg.Providers) != 0 { - t.Errorf("Providers = %v, want empty", cfg.Providers) - } -} - -func TestParseConfig_Missing(t *testing.T) { - _, err := parseConfig("/nonexistent/config.toml") - if err == nil { - t.Error("expected error for missing file") - } -} - -func TestParseConfig_Invalid(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.toml") - os.WriteFile(path, []byte(`not valid toml {{{{`), 0o644) - - _, err := parseConfig(path) - if err == nil { - t.Error("expected error for invalid TOML") - } -} - -func TestStageFiles_EnvFromFile(t *testing.T) { - dir := t.TempDir() - harnessDir := filepath.Join(dir, "harness") - envDir := filepath.Join(dir, "env") - - os.MkdirAll(envDir, 0o755) - - envContent := "export FOO=bar\nexport BAZ=qux\n" - envFile := filepath.Join(envDir, "sandbox.env") - os.WriteFile(envFile, []byte(envContent), 0o644) - - origStageFiles := stageFilesFrom - stageFilesFrom = envFile - defer func() { stageFilesFrom = origStageFiles }() - - if err := stageFiles(harnessDir); err != nil { - t.Fatalf("stageFiles: %v", err) - } - - data, err := os.ReadFile(filepath.Join(harnessDir, "sandbox.env")) - if err != nil { - t.Fatalf("reading sandbox.env: %v", err) - } - if string(data) != envContent { - t.Errorf("sandbox.env = %q, want %q", string(data), envContent) - } -} - -func TestStageFiles_NoEnv(t *testing.T) { - dir := t.TempDir() - harnessDir := filepath.Join(dir, "harness") - - origStageFiles := stageFilesFrom - stageFilesFrom = "/nonexistent/sandbox.env" - defer func() { stageFilesFrom = origStageFiles }() - - if err := stageFiles(harnessDir); err != nil { - t.Fatalf("stageFiles: %v", err) - } - - if _, err := os.Stat(filepath.Join(harnessDir, "sandbox.env")); err == nil { - t.Error("sandbox.env should not exist when env file not present") - } -} diff --git a/test/test-flow.sh b/test/test-flow.sh index 52b28b2..db79750 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -41,7 +41,7 @@ for arg in "$@"; do --full) FULL=true ;; --reuse-gateway) REUSE_GATEWAY=true ;; --no-providers) NO_PROVIDERS=true ;; - --profile=*) PROFILE="${arg#--profile=}" ;; + --agent=*) PROFILE="${arg#--agent=}" ;; -*) ;; *) [[ -z "$TARGET" ]] && TARGET="$arg" ;; esac @@ -190,7 +190,7 @@ test_errors() { echo "=== test: error scenarios ===" # Bad profile - step_fail "nonexistent profile" "$HARNESS" up --local --profile nonexistent --no-tty + step_fail "nonexistent profile" "$HARNESS" up --local --agent nonexistent --no-tty # Teardown idempotency (skip k8s teardown when reusing gateway) if $REUSE_GATEWAY; then @@ -225,13 +225,13 @@ test_local() { if $FULL; then local sandbox_name="test-agent" - step_output "sandbox create (up)" "$HARNESS" up --local --name "$sandbox_name" --profile "$PROFILE" --no-tty + step_output "sandbox create (up)" "$HARNESS" up --local --name "$sandbox_name" --agent "$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_output "sandbox create (create)" "$HARNESS" create --name "$create_name" --agent "$PROFILE" step "sandbox verify (create)" "$CLI" sandbox exec --name "$create_name" -- echo "hello" step "sandbox delete (create)" "$CLI" sandbox delete "$create_name" @@ -298,7 +298,7 @@ test_kind() { if $FULL; then local sandbox_name="test-kind" - step_output "sandbox create" "$HARNESS" up --name "$sandbox_name" --profile "$PROFILE" --no-tty + step_output "sandbox create" "$HARNESS" up --name "$sandbox_name" --agent "$PROFILE" --no-tty sandbox_verify "$sandbox_name" if ! $NO_PROVIDERS; then @@ -348,7 +348,7 @@ test_ocp() { if $NO_PROVIDERS; then # ci mode: use harness create (skips provider registration) with public ci profile sandbox_name="test-ocp" - step_output "sandbox create" "$HARNESS" create --profile=ci --name "$sandbox_name" + step_output "sandbox create" "$HARNESS" create --agent=ci --name "$sandbox_name" else # default mode: full up (deploy already done above, providers registered) sandbox_name="agent" From 7c13ccaeb0a2972d219d9736e6883421c1128df0 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 23:52:04 -0700 Subject: [PATCH 02/22] feat: add -f flag, move provider profiles, auto-register missing providers - Add -f/--file flag to `harness up` and `harness create` for direct agent YAML file paths (e.g., harness up -f /path/to/agent.yaml) - Move sandbox/profiles/ to agents/providers/profiles/ (co-located with agent config since agent.yaml references them by profile name) - Auto-register missing providers: when agent.yaml references providers not yet on the gateway, upLocal now calls registerProviders automatically instead of skipping - Update providers.go profile import path --- .../providers}/profiles/atlassian-user.yaml | 0 .../providers}/profiles/atlassian.yaml | 0 .../providers}/profiles/gws.yaml | 0 cmd/create.go | 11 ++--- cmd/providers.go | 4 +- cmd/up.go | 42 ++++++++++++------- cmd/up_test.go | 15 +++---- 7 files changed, 43 insertions(+), 29 deletions(-) rename {sandbox => agents/providers}/profiles/atlassian-user.yaml (100%) rename {sandbox => agents/providers}/profiles/atlassian.yaml (100%) rename {sandbox => agents/providers}/profiles/gws.yaml (100%) diff --git a/sandbox/profiles/atlassian-user.yaml b/agents/providers/profiles/atlassian-user.yaml similarity index 100% rename from sandbox/profiles/atlassian-user.yaml rename to agents/providers/profiles/atlassian-user.yaml diff --git a/sandbox/profiles/atlassian.yaml b/agents/providers/profiles/atlassian.yaml similarity index 100% rename from sandbox/profiles/atlassian.yaml rename to agents/providers/profiles/atlassian.yaml diff --git a/sandbox/profiles/gws.yaml b/agents/providers/profiles/gws.yaml similarity index 100% rename from sandbox/profiles/gws.yaml rename to agents/providers/profiles/gws.yaml diff --git a/cmd/create.go b/cmd/create.go index e1dd167..fb048c0 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -18,6 +18,7 @@ import ( func NewCreateCmd(harnessDir, cli string) *cobra.Command { var ( agentName string + agentFile string sandboxName string ) @@ -30,6 +31,8 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { sandboxName = args[0] } + agentPath := resolveAgentPath(harnessDir, agentName, agentFile) + gw := gateway.New(cli) // 1. Check which gateway is active and whether it's local or remote. @@ -41,9 +44,6 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { status.Section("Gateway") status.OKf("%s (%s)", activeGW.Name, activeGW.Endpoint) - - // 2. Parse agent config - agentPath := filepath.Join(harnessDir, "agents", agentName+".yaml") agentCfg, err := agent.ParseFile(agentPath) if err != nil { return err @@ -154,6 +154,7 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { } cmd.Flags().StringVar(&agentName, "agent", "default", "Agent config name (from agents/)") + cmd.Flags().StringVarP(&agentFile, "file", "f", "", "Path to agent YAML file (overrides --agent)") cmd.Flags().StringVar(&sandboxName, "name", "", "Sandbox name (overrides agent config)") return cmd @@ -211,11 +212,11 @@ func loadGatewayConfigForActive(harnessDir string, active *gateway.GatewayInfo) return nil } -func createViaRunner(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gateway, agentName, sandboxName string) error { +func createViaRunner(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gateway, agentPath, sandboxName string) error { if gwCfg == nil { return fmt.Errorf("no gateway config found for remote gateway — expected gateways//gateway.toml") } - return upRemote(harnessDir, gwCfg, gw, agentName, sandboxName) + return upRemote(harnessDir, gwCfg, gw, agentPath, sandboxName) } func providerInList(name string, providers []string) bool { diff --git a/cmd/providers.go b/cmd/providers.go index 6c221cf..ecb73d9 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -79,7 +79,7 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, gwCfg // Import custom profiles status.Section("Importing custom profiles") - profilesDir := filepath.Join(harnessDir, "sandbox", "profiles") + profilesDir := filepath.Join(harnessDir, "agents", "providers", "profiles") if err := gw.ProviderProfileImport(profilesDir); err != nil { status.Info("already imported") } @@ -310,7 +310,7 @@ func gwsProfileScopes(harnessDir string) string { } func deleteCustomProfiles(harnessDir string, gw gateway.Gateway) { - profilesDir := filepath.Join(harnessDir, "sandbox", "profiles") + profilesDir := filepath.Join(harnessDir, "agents", "providers", "profiles") entries, err := os.ReadDir(profilesDir) if err != nil { return diff --git a/cmd/up.go b/cmd/up.go index 3406f85..f142ef1 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -22,6 +22,7 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { local bool remote bool agentName string + agentFile string sandboxName string noTTY bool ) @@ -38,6 +39,8 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { sandboxName = args[0] } + agentPath := resolveAgentPath(harnessDir, agentName, agentFile) + gw := gateway.New(cli) gwName := "local" @@ -48,14 +51,14 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { gwCfg, _ := gateway.LoadConfig(gwDir) if remote { - return upRemote(harnessDir, gwCfg, gw, agentName, sandboxName) + return upRemote(harnessDir, gwCfg, gw, agentPath, sandboxName) } return upLocal(upLocalOpts{ harnessDir: harnessDir, gw: gw, gwCfg: gwCfg, ensureLocal: local, - agentName: agentName, + agentPath: agentPath, sandboxName: sandboxName, noTTY: noTTY, retrySleep: 5 * time.Second, @@ -66,24 +69,32 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { cmd.Flags().BoolVar(&local, "local", false, "Ensure local podman gateway") cmd.Flags().BoolVar(&remote, "remote", false, "Ensure OCP gateway") cmd.Flags().StringVar(&agentName, "agent", "default", "Agent config name (from agents/)") + cmd.Flags().StringVarP(&agentFile, "file", "f", "", "Path to agent YAML file (overrides --agent)") cmd.Flags().StringVar(&sandboxName, "name", "", "Sandbox name (overrides agent config)") cmd.Flags().BoolVar(&noTTY, "no-tty", false, "Non-interactive mode (for testing)") return cmd } +func resolveAgentPath(harnessDir, agentName, agentFile string) string { + if agentFile != "" { + return agentFile + } + return filepath.Join(harnessDir, "agents", agentName+".yaml") +} + type upLocalOpts struct { harnessDir string gw gateway.Gateway gwCfg *gateway.GatewayConfig ensureLocal bool - agentName string + agentPath string sandboxName string noTTY bool retrySleep time.Duration } -func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gateway, agentName, sandboxName string) error { +func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gateway, agentPath, sandboxName string) error { ctx := context.Background() namespace := k8s.DefaultNamespace() kc := k8s.New("", namespace) @@ -113,7 +124,6 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa } // 3. Parse agent config - agentPath := filepath.Join(harnessDir, "agents", agentName+".yaml") agentCfg, err := agent.ParseFile(agentPath) if err != nil { return err @@ -265,8 +275,7 @@ func upLocal(opts upLocalOpts) error { } // 3. Parse agent config - agentPath := filepath.Join(opts.harnessDir, "agents", opts.agentName+".yaml") - agentCfg, err := agent.ParseFile(agentPath) + agentCfg, err := agent.ParseFile(opts.agentPath) if err != nil { return err } @@ -301,25 +310,28 @@ func upLocal(opts upLocalOpts) error { fmt.Println() fmt.Println("=== Sandbox ===") - fmt.Printf(" Agent: %s\n", opts.agentName) + fmt.Printf(" Agent: %s\n", opts.agentPath) fmt.Printf(" Image: %s\n", cfg.From) if agentCfg.Task != "" { fmt.Printf(" Task: %s\n", agentCfg.Task) } - // 6. Validate providers + // 6. Validate providers — auto-register missing ones status.Section("Providers") providerNames := agentCfg.ProviderNames() registered, missing := profile.ValidateProviders(providerNames, gw) for _, name := range registered { status.OKf("%s: attached", name) } - for _, name := range missing { - status.Failf("%s: not registered (skipping)", name) - } - if len(missing) > 0 && len(registered) == 0 { - fmt.Println() - status.Warn("no providers available — run: harness providers") + if len(missing) > 0 { + status.Info("Registering missing providers...") + if err := registerProviders(opts.harnessDir, gw, false, opts.gwCfg); err != nil { + status.Warn(fmt.Sprintf("provider registration: %v", err)) + } + registered, missing = profile.ValidateProviders(providerNames, gw) + for _, name := range missing { + status.Failf("%s: not registered (skipping)", name) + } } // 7. Create sandbox diff --git a/cmd/up_test.go b/cmd/up_test.go index 931ea29..89da377 100644 --- a/cmd/up_test.go +++ b/cmd/up_test.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "path/filepath" "strings" "testing" @@ -16,7 +17,7 @@ func TestUpLocal_NoGateway(t *testing.T) { err := upLocal(upLocalOpts{ harnessDir: dir, gw: gw, - agentName: "default", + agentPath: filepath.Join(dir, "agents", "default.yaml"), noTTY: true, }) if err == nil { @@ -37,7 +38,7 @@ func TestUpLocal_NoProviders_RegistersProviders(t *testing.T) { err := upLocal(upLocalOpts{ harnessDir: dir, gw: gw, - agentName: "default", + agentPath: filepath.Join(dir, "agents", "default.yaml"), noTTY: true, }) if err != nil { @@ -55,7 +56,7 @@ func TestUpLocal_MissingProviders(t *testing.T) { err := upLocal(upLocalOpts{ harnessDir: dir, gw: gw, - agentName: "default", + agentPath: filepath.Join(dir, "agents", "default.yaml"), noTTY: true, }) if err != nil { @@ -80,7 +81,7 @@ func TestUpLocal_AllProvidersMissing(t *testing.T) { err := upLocal(upLocalOpts{ harnessDir: dir, gw: gw, - agentName: "default", + agentPath: filepath.Join(dir, "agents", "default.yaml"), noTTY: true, }) if err != nil { @@ -99,7 +100,7 @@ func TestUpLocal_AgentNotFound(t *testing.T) { err := upLocal(upLocalOpts{ harnessDir: dir, gw: gw, - agentName: "nonexistent", + agentPath: filepath.Join(dir, "agents", "nonexistent.yaml"), noTTY: true, }) if err == nil { @@ -118,7 +119,7 @@ func TestUpLocal_SandboxCreateRetry(t *testing.T) { err := upLocal(upLocalOpts{ harnessDir: dir, gw: gw, - agentName: "default", + agentPath: filepath.Join(dir, "agents", "default.yaml"), noTTY: true, retrySleep: 0, }) @@ -193,7 +194,7 @@ func TestUpLocal_SandboxCreateOpts(t *testing.T) { err := upLocal(upLocalOpts{ harnessDir: dir, gw: gw, - agentName: "default", + agentPath: filepath.Join(dir, "agents", "default.yaml"), sandboxName: "custom-name", noTTY: true, }) From 86adf240c6226be4ea326c87a86735c482ab18e5 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 00:27:15 -0700 Subject: [PATCH 03/22] fix: CI provider registration, README updates, help cleanup - Fix CI integration test: only register providers when the agent config actually declares them (ci.yaml has providers: [], so skip) - Parse agent config before provider registration in both upLocal and upRemote to avoid registering unneeded providers - Move provider profiles path in providers.go to agents/providers/profiles/ - Hide cobra completion command from help output - Update README: add -f flag, runner Dockerfile, provider profiles path - Update TODO.md: mark launcher consolidation done, clean up stale items --- README.md | 310 ++++++++++++++++++------------------------------------ TODO.md | 54 ++-------- cmd/up.go | 80 +++++++------- main.go | 2 + 4 files changed, 148 insertions(+), 298 deletions(-) diff --git a/README.md b/README.md index 056c513..cfb838a 100644 --- a/README.md +++ b/README.md @@ -1,253 +1,143 @@ # OpenShell Harness -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. - -## What is OpenShell? - -[OpenShell](https://github.com/NVIDIA/OpenShell) is NVIDIA's open-source runtime for autonomous AI agents. It runs each agent in an isolated container governed by declarative YAML policies, with a gateway control plane that manages sandbox lifecycle, credential injection, and network enforcement across compute drivers (Podman, Docker, Kubernetes). - -The core idea is **deny by default**. Every sandbox starts with no outbound network access, no filesystem access beyond its working directory, and no credentials. You explicitly grant what the agent needs -- which hosts it can reach, which HTTP methods and URL paths are allowed, which binaries can make those requests, and which credentials are injected -- and everything else is blocked at the proxy layer before it ever leaves the sandbox. - -### Why deny by default matters for AI agents - -Traditional application sandboxing focuses on untrusted code. AI agent sandboxing has a different threat model: the agent has legitimate access to powerful tools (GitHub API, Jira, cloud infrastructure) but its judgment about when and how to use them is uncertain. An agent might decide to push code, delete a branch, or post a comment in ways you didn't intend. - -Deny-by-default addresses this by making every capability an explicit grant: - -- **Network policies operate at Layer 7**, not just IP/port. You can allow `GET` requests to `api.github.com` while blocking `POST`, `PUT`, and `DELETE` -- the agent can read repositories but cannot push code, create issues, or modify settings. Git clone works; git push is denied at the proxy before it reaches GitHub. - -- **Policies are scoped to binaries.** A GitHub PAT bound to the `git` binary cannot be exfiltrated by `curl` to an arbitrary endpoint. Credentials are tied to `(credential, endpoint, binary)` triples. - -- **Credentials never touch the sandbox filesystem.** The gateway injects credentials via proxy-side resolution. The sandbox sees placeholder tokens; real secrets are substituted at the network boundary. If the agent reads its own environment, it finds a proxy-managed placeholder, not your PAT. - -- **Network policies are hot-reloadable.** When you need to grant push access to a specific repository, you update the policy YAML and apply it to a running sandbox -- no restart, no rebuild. The policy engine confirms the new revision before returning. - -- **Deny rules are immutable.** Provider profiles can declare deny rules that users cannot override. Even if you grant broad GitHub API access, `deleteRepository`, `deleteRef`, and `updateBranchProtectionRule` mutations stay blocked. - -This means you can hand an AI agent your real credentials -- your GitHub PAT, your Jira API token, your GCP service account -- and enforce at the infrastructure layer exactly what it can do with them. The agent operates within a capability boundary you define, not one you hope the model respects. - -### Policy layers - -OpenShell enforces four layers of defense in depth: - -| Layer | What it controls | Mutability | -|-------|-----------------|------------| -| **Filesystem** | Read/write access to paths (`/sandbox` writable, `/usr` read-only, everything else denied) | Locked at creation | -| **Network** | Per-host, per-path, per-method, per-binary egress rules | Hot-reloadable | -| **Process** | User/group identity, privilege escalation, syscall filtering (Landlock) | Locked at creation | -| **Inference** | Model API routing through `inference.local` (strips caller creds, injects backend creds) | Hot-reloadable | - -Policies compose at runtime: base sandbox policy + auto-generated provider policies + user-authored policy. Deny always wins over allow. - -## Relationship to OpenShell - -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. +Orchestration CLI for [OpenShell](https://github.com/NVIDIA/OpenShell). Automates gateway deployment, provider registration, and sandbox creation across local Podman and remote Kubernetes/OpenShift targets. Wraps the `openshell` CLI -- does not replace it. + +## Example + +An agent config declares the sandbox image, entrypoint, and which providers to attach: + +```yaml +# agents/default.yaml +name: agent +image: ghcr.io/robbycochran/harness-openshell:sandbox +entrypoint: claude --bare +tty: true + +providers: + - profile: github + - profile: vertex-local + - profile: atlassian + config: + JIRA_URL: ${JIRA_URL} + JIRA_USERNAME: ${JIRA_USERNAME} + - profile: gws + +env: + ANTHROPIC_BASE_URL: https://inference.local + ANTHROPIC_API_KEY: sk-ant-openshell-proxy-managed +``` -The harness automates four things that OpenShell leaves to the user: +```bash +harness up --local +``` -- **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/`). +This deploys a local gateway, registers four providers, and creates a sandbox running Claude Code. The sandbox has: `gh` CLI (GitHub), `mcp-atlassian` (Jira/Confluence MCP server), `gws` CLI (Gmail, Calendar, Docs), and Vertex AI inference routed through the gateway proxy at `inference.local`. -- **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. +Credentials are proxy-managed. The sandbox holds placeholder tokens; real secrets are substituted by the gateway at the network boundary. -- **Credential validation** -- Preflight checks verify credentials are present and valid on the host before registration. +For non-interactive task agents, set `task:` and `tty: false`: -- **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. +```yaml +# agents/demo.yaml +name: demo +entrypoint: claude --bare -p +task: demo/DEMO-TASK.md +tty: false +# ... same providers and env +``` -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)). +## Local Setup -## How It Compares +### Prerequisites -| Concern | OpenShell Harness | [Kaiden](https://github.com/openkaiden/kaiden) | [Plandex](https://github.com/plandex-ai/plandex) | -|---------|-------------------|--------|---------| -| **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 | -| **Configuration** | TOML profiles + provider catalog | JSON projects (GUI-driven) | YAML plans | +- [OpenShell CLI](https://github.com/NVIDIA/OpenShell) (`brew install openshell && brew services start openshell` on macOS) +- Podman +- Go 1.23+ -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. +### Credentials -## Three Domains +Each provider requires credentials on the host. The harness validates these before registration. -| Domain | Question | Config | Commands | -|--------|----------|--------|----------| -| **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` | `up`, `create`, `connect` | +| Provider | Required | +|----------|----------| +| `github` | `GITHUB_TOKEN` env var | +| `vertex-local` | `gcloud auth application-default login --project ` + `ANTHROPIC_VERTEX_PROJECT_ID` + `CLOUD_ML_REGION` env vars | +| `atlassian` | `JIRA_API_TOKEN` + `JIRA_URL` + `JIRA_USERNAME` env vars | +| `gws` | OAuth client secret at `~/.config/gws/client_secret.json` | -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. +See `providers.toml` for the full input schema and health checks per provider. -## Quick Start +### Run ```bash -# Install OpenShell CLI -curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh - -# Authenticate with Google Cloud (Vertex AI inference) -gcloud auth application-default login --project your-gcp-project-id +make cli +./harness up --local +``` -# Set credentials -export GITHUB_TOKEN="ghp_..." -export JIRA_API_TOKEN="..." -export JIRA_URL="https://mysite.atlassian.net" -export JIRA_USERNAME="you@example.com" +For remote OpenShift: `./harness up --remote` (requires `kubectl`, `helm`, cluster access). -# Build the harness -make cli +## How It Works -# Local -- deploy gateway, register providers, create sandbox -harness up --local +The harness orchestrates three OpenShell components via the `openshell` CLI: -# Remote -- same flow on OpenShift -harness up --remote +- **Gateway** -- OpenShell's credential proxy and L7 network policy engine. Runs as a Podman container (local) or Kubernetes StatefulSet (remote). Manages provider credentials, inference routing, and sandbox lifecycle. +- **Providers** -- Credential registrations on the gateway. Each provider's required inputs (env vars, files, connectivity checks) are declared in `providers.toml`. The harness runs preflight validation before registering. +- **Sandbox** -- Container running the agent entrypoint, configured by `agents/*.yaml`. The gateway injects credentials at the network boundary -- the sandbox process sees proxy-managed placeholder tokens. Network egress is deny-by-default at L7. -# Reconnect to a running sandbox -harness connect +``` +harness CLI ──→ openshell CLI ──→ Gateway (Podman or K8s) + ├── Provider credentials + ├── L7 network policy + ├── inference.local proxy + └── Sandbox container + ├── claude --bare + ├── gh, mcp-atlassian, gws + └── placeholder tokens ``` -Podman is required for local sandboxes. kubectl + helm are required for remote. +See the [OpenShell docs](https://github.com/NVIDIA/OpenShell) for the full security model (L7 policy, Landlock, proxy credential resolution). + +## Config Files + +| File | Purpose | +|------|---------| +| `agents/*.yaml` | Agent config: image, entrypoint, providers, env, optional task file | +| `agents/providers/profiles/` | OpenShell provider profiles (imported to gateway on registration) | +| `providers.toml` | Provider catalog: required inputs and health checks per provider | +| `gateways/*/gateway.toml` | Deployment target config: `local/` (Podman), `kind/`, `ocp/` (OpenShift) | +| `sandbox/Dockerfile` | Sandbox image: OpenShell base + MCP servers + CLI tools | +| `build/runner/Dockerfile` | Runner image: harness binary for in-cluster sandbox creation | +| `sandbox/policy.yaml` | Network egress rules applied to sandboxes | ## Commands ``` -harness up [--local|--remote] [--profile NAME] [--name SANDBOX_NAME] - Full flow: deploy gateway + register providers + create sandbox + connect. +harness up [--local|--remote] [--agent NAME] [-f FILE] [--name SANDBOX] + Deploy gateway + register providers + create sandbox. + --agent defaults to "default" (reads agents/default.yaml). + -f renders any agent YAML file directly. -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 create [--agent NAME] [-f FILE] [--name SANDBOX] + Create a sandbox without deploying the gateway. Assumes gateway is running. harness connect [NAME] Reconnect to a running sandbox. -harness deploy [GATEWAY_NAME] - Deploy or verify a gateway by name (local, ocp, kind). +harness deploy [local|ocp|kind] + Deploy or verify the gateway for a target. harness providers [--force] - Register providers with the gateway. + Register providers with the gateway. --force re-registers all. harness preflight [--strict] - Validate local environment prerequisites. + Validate local credentials and prerequisites. harness teardown [--sandboxes] [--providers] [--k8s] Tear down resources. At least one flag required. ``` -## Profiles - -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 -name = "agent" -from = "ghcr.io/robbycochran/harness-openshell:sandbox" -command = "claude --bare" -providers = ["github", "vertex-local", "atlassian"] - -[env] -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 up --profile research` - -## Provider Catalog - -`providers.toml` defines available providers and how to validate them: - -```toml -[[providers]] -name = "atlassian" -type = "openshell" -description = "Jira and Confluence (Basic auth resolved by proxy)" -inputs = [ - { key = "JIRA_API_TOKEN", kind = "env", secret = true }, - { key = "JIRA_URL", kind = "env", sandbox = true }, - { key = "JIRA_USERNAME", kind = "env", sandbox = true }, -] -``` - -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 | - +------------------------------+ -``` - -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), deny-by-default network policy enforcement at L7, 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 | -|------|---------| -| `main.go`, `cmd/` | CLI commands (Go) | -| `internal/gateway/` | OpenShell CLI wrapper (Gateway interface) | -| `internal/k8s/` | kubectl/helm/oc runner with retry and transient error handling | -| `internal/profile/` | Profile TOML parsing and staging | -| `internal/preflight/` | Provider prerequisite validation | -| `profiles/` | Sandbox profiles (TOML) | -| `providers.toml` | Provider catalog (inputs, prerequisites, upstream tracking) | -| `gateways/` | Per-target gateway configs (`local/`, `ocp/`, `kind/`) | -| `sandbox/` | Sandbox container image (Dockerfile, startup, policy, agent instructions) | -| `sandbox/launcher/` | In-cluster launcher for OCP sandboxes | -| `sandbox/profiles/` | OpenShell provider type profiles (YAML, imported into gateway) | -| `test/` | Tests (bats preflight, test-flow.sh integration) | - -## Testing - -```bash -make validate-dev # build dev images + run integration tests -go test ./... # Go unit tests -bats test/preflight.bats # 29 preflight unit tests -``` - -## Contributing +## References -See [AGENTS.md](AGENTS.md) for coding guidelines, project principles, and workaround tracking. +- [AGENTS.md](AGENTS.md) -- coding guidelines, project principles, workaround tracking +- [SPEC.md](SPEC.md) -- full CLI specification +- [PROVIDERS-SPEC.md](PROVIDERS-SPEC.md) -- provider configuration format diff --git a/TODO.md b/TODO.md index 87ca0d7..2ea2edd 100644 --- a/TODO.md +++ b/TODO.md @@ -43,58 +43,24 @@ - Remove when: bash path is no longer needed for dual-testing - Blocked by: decision to stop maintaining bash path -### Launcher consolidation -- `sandbox/launcher/` is a separate Go module (runs in-cluster) -- Has its own `parseConfig` duplicating `internal/profile/` -- Can't share code (different execution context, separate binary) -- Consider: extract shared types to a third module, or accept duplication +### Launcher consolidation — DONE (#50) +- ~~`sandbox/launcher/` is a separate Go module~~ → deleted, replaced by `harness launch` +- ~~Has its own `parseConfig` duplicating `internal/profile/`~~ → uses `internal/agent/` +- Single binary, single image (`:runner`), single config format (`agents/*.yaml`) -## Profile Schema — Cross-Project Alignment +## Agent Schema -Analysis of field naming and structure against OpenShell provider profiles (#896) -and Kaiden projects (#1272). See `profile.md` for full comparison. +The agent config format is `agents/*.yaml` (YAML). TOML profiles are removed. -### Current schema (Config struct) +### Future fields -``` -name, image, command, keep, providers, [env] -``` - -### Changes - -- [ ] Add `description` field — one line of human-readable context per profile. - 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 - (`ANTHROPIC_*`), agent config (`CLAUDE_CODE_*`), provider metadata - (`JIRA_URL`, `JIRA_USERNAME`), and custom provider workaround paths - (`GOOGLE_WORKSPACE_*`). At minimum, add grouping comments in `default.toml`. - Longer term, consider a `[provider-config]` section for non-secret provider - metadata that belongs with the provider, not the sandbox. The `ANTHROPIC_*` - vars should eventually drop entirely when OpenShell inference automation ships. - -### No changes needed - -- **`name`** — correct term, maps to `openshell sandbox create --name` -- **`image`** — harness-openshell differentiator (Kaiden is folder-first, we're image-first) -- **`command`** — maps to `openshell sandbox create --command` -- **`providers`** — correct term, matches OpenShell's provider naming throughout -- **`keep`** — unique to harness-openshell lifecycle, neither upstream has it, that's fine -- **TOML format** — trivially convertible to YAML (OpenShell) or JSON (Kaiden), no reason to change - -### Future fields (not now) - -- `repo` — git URL to clone into the sandbox at start (Kaiden supports this via `folder`) -- `secrets` — non-provider secrets to inject, cleaner than stuffing credentials into `[env]` +- [ ] `description` — one line of human-readable context per agent config +- [ ] `repo` — git URL to clone into the sandbox at start +- [ ] `secrets` — non-provider secrets to inject, cleaner than stuffing credentials into `env:` ## Low-Priority Cleanup (from audit) -- [ ] `copyFile` in launcher: check Close error (`defer out.Close()` discards it) -- [ ] Launcher `configureGateway`: route stderr to `os.Stderr` not `os.Stdout` (line 61) -- [ ] Launcher `run()` helper: log errors instead of silently discarding - [ ] Unexport internal-only functions in `internal/preflight/` and `internal/profile/` -- [ ] Stale comment in `providers.sh` line 18: says "default: us-east5", actual default is "global" - [ ] `SandboxExec`/`SandboxUpload` on Gateway interface — no callers yet (premature abstraction, but harmless) ## Testing diff --git a/cmd/up.go b/cmd/up.go index f142ef1..5ecebdd 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -111,19 +111,7 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa } } - // 2. Ensure providers - providers, err := gw.ProviderList() - if err != nil { - return fmt.Errorf("listing providers: %w", err) - } - if len(providers) == 0 { - status.Section("Registering providers") - if err := registerProviders(harnessDir, gw, false, gwCfg); err != nil { - return fmt.Errorf("provider registration failed: %w", err) - } - } - - // 3. Parse agent config + // 2. Parse agent config agentCfg, err := agent.ParseFile(agentPath) if err != nil { return err @@ -133,6 +121,18 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa name = sandboxName } + // 3. Ensure providers needed by the agent + providerNames := agentCfg.ProviderNames() + if len(providerNames) > 0 { + _, missing := profile.ValidateProviders(providerNames, gw) + if len(missing) > 0 { + status.Section("Registering providers") + if err := registerProviders(harnessDir, gw, false, gwCfg); err != nil { + return fmt.Errorf("provider registration failed: %w", err) + } + } + } + // Resolve sandbox image sandboxImage := agentCfg.Image if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { @@ -262,24 +262,34 @@ func upLocal(opts upLocalOpts) error { } } - // 2. Ensure providers - providers, err := gw.ProviderList() - if err != nil { - return fmt.Errorf("listing providers: %w", err) - } - if len(providers) == 0 { - status.Section("Registering providers") - if err := registerProviders(opts.harnessDir, gw, false, opts.gwCfg); err != nil { - return fmt.Errorf("provider registration failed: %w", err) - } - } - - // 3. Parse agent config + // 2. Parse agent config agentCfg, err := agent.ParseFile(opts.agentPath) if err != nil { return err } + // 3. Ensure providers needed by the agent are registered + providerNames := agentCfg.ProviderNames() + var registered []string + if len(providerNames) > 0 { + var missing []string + registered, missing = profile.ValidateProviders(providerNames, gw) + if len(missing) > 0 { + status.Section("Registering providers") + if err := registerProviders(opts.harnessDir, gw, false, opts.gwCfg); err != nil { + status.Warn(fmt.Sprintf("provider registration: %v", err)) + } + registered, missing = profile.ValidateProviders(providerNames, gw) + } + status.Section("Providers") + for _, name := range registered { + status.OKf("%s: attached", name) + } + for _, name := range missing { + status.Failf("%s: not registered (skipping)", name) + } + } + // 4. Render payload payloadDir, err := os.MkdirTemp("", "harness-payload-") if err != nil { @@ -316,24 +326,6 @@ func upLocal(opts upLocalOpts) error { fmt.Printf(" Task: %s\n", agentCfg.Task) } - // 6. Validate providers — auto-register missing ones - status.Section("Providers") - providerNames := agentCfg.ProviderNames() - registered, missing := profile.ValidateProviders(providerNames, gw) - for _, name := range registered { - status.OKf("%s: attached", name) - } - if len(missing) > 0 { - status.Info("Registering missing providers...") - if err := registerProviders(opts.harnessDir, gw, false, opts.gwCfg); err != nil { - status.Warn(fmt.Sprintf("provider registration: %v", err)) - } - registered, missing = profile.ValidateProviders(providerNames, gw) - for _, name := range missing { - status.Failf("%s: not registered (skipping)", name) - } - } - // 7. Create sandbox fmt.Println() fmt.Println("=== Creating sandbox ===") diff --git a/main.go b/main.go index 7c44bca..e1a90db 100644 --- a/main.go +++ b/main.go @@ -32,6 +32,8 @@ func main() { cli = "openshell" } + root.CompletionOptions.HiddenDefaultCmd = true + root.AddCommand( cmd.NewUpCmd(harnessDir, cli), cmd.NewCreateCmd(harnessDir, cli), From 9a480295982d35f6eea99f3a5d92cba9813ae3bf Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 00:34:41 -0700 Subject: [PATCH 04/22] fix: kind image pull -- pre-load into kind, drop registry push dependency Pre-load dev sandbox image into kind via kind load docker-image instead of pushing to quay.io and relying on pull secrets. dev-test-kind builds sandbox image locally (single-arch), no push. Falls back to pull secret only when image is not in local docker. --- Makefile | 7 ++++--- test/kind-lifecycle.sh | 27 ++++++++++++++++++--------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index 26c015c..ad9938d 100644 --- a/Makefile +++ b/Makefile @@ -105,11 +105,12 @@ dev-test-local: cli ci ## Kind: unit + bats + kind full (self-contained lifecycle) ## Creates/destroys its own kind cluster. Never touches your OCP kubectl context. -## Builds dev sandbox image to quay.io (kind can't pull private ghcr.io). +## Builds sandbox image locally and pre-loads into kind (no registry push needed). ## Use KEEP=1 to keep the cluster after tests (for debugging). -dev-test-kind: cli ci dev-sandbox +dev-test-kind: cli ci + docker build -t $(DEV_SANDBOX_IMAGE) sandbox/ @echo "" - SANDBOX_IMAGE=$(DEV_SANDBOX_IMAGE) SANDBOX_PULL_SECRET=quay-pull ./test/kind-lifecycle.sh $(if $(KEEP),--keep) + SANDBOX_IMAGE=$(DEV_SANDBOX_IMAGE) ./test/kind-lifecycle.sh $(if $(KEEP),--keep) ## Remote (OCP): unit + bats + OCP full + OCP CI ## Requires: KUBECONFIG set, provider credentials. diff --git a/test/kind-lifecycle.sh b/test/kind-lifecycle.sh index 791d6c7..6184db4 100755 --- a/test/kind-lifecycle.sh +++ b/test/kind-lifecycle.sh @@ -67,9 +67,17 @@ echo "" kubectl create namespace openshell --dry-run=client -o yaml | kubectl apply -f - 2>/dev/null || true -# Set up pull secret for dev images if docker credentials exist -if [[ -f "$HOME/.docker/config.json" ]]; then - QUAY_PASS=$(python3 -c " +# Pre-load dev sandbox image into kind (avoids pull secrets and ImagePullBackOff). +# SANDBOX_IMAGE is set by the Makefile for dev builds; CI uses the public community image. +if [[ -n "${SANDBOX_IMAGE:-}" ]]; then + echo " Pre-loading image: $SANDBOX_IMAGE" + if docker image inspect "$SANDBOX_IMAGE" &>/dev/null; then + kind load docker-image "$SANDBOX_IMAGE" --name "$CLUSTER_NAME" 2>/dev/null || true + else + echo " Image not in local docker — kind will pull at sandbox creation time" + # Fall back to pull secret for private registries + if [[ -f "$HOME/.docker/config.json" ]]; then + QUAY_PASS=$(python3 -c " import json, base64, pathlib, sys try: d = json.loads(pathlib.Path('$HOME/.docker/config.json').read_text()) @@ -77,12 +85,13 @@ try: except Exception: sys.exit(1) " 2>/dev/null) || true - - if [[ -n "${QUAY_PASS:-}" ]]; then - kubectl create secret docker-registry quay-pull \ - --docker-server=quay.io --docker-username=rcochran \ - --docker-password="$QUAY_PASS" \ - -n openshell --dry-run=client -o yaml | kubectl apply -f - 2>/dev/null || true + if [[ -n "${QUAY_PASS:-}" ]]; then + kubectl create secret docker-registry quay-pull \ + --docker-server=quay.io --docker-username=rcochran \ + --docker-password="$QUAY_PASS" \ + -n openshell --dry-run=client -o yaml | kubectl apply -f - 2>/dev/null || true + fi + fi fi fi From d6b98ed5273ee7cf597abfcd57daad2aa91a2d21 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 00:40:55 -0700 Subject: [PATCH 05/22] fix: kind test namespace isolation -- use openshell-kind-test namespace Kind tests now use OPENSHELL_NAMESPACE=openshell-kind-test to avoid colliding with the OCP openshell namespace. Cleanup deregisters the kind gateway from the openshell CLI so it does not leak into OCP sessions. --- test/kind-lifecycle.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/kind-lifecycle.sh b/test/kind-lifecycle.sh index 6184db4..f1ab961 100755 --- a/test/kind-lifecycle.sh +++ b/test/kind-lifecycle.sh @@ -29,10 +29,12 @@ done cleanup() { local rc=$? + # Deregister kind gateway from openshell CLI so it doesn't leak into OCP sessions + openshell gateway remove openshell-kind 2>/dev/null || true if $KEEP_CLUSTER; then echo "" echo "Cluster kept: $CLUSTER_NAME" - echo " KUBECONFIG=$KIND_KUBECONFIG kubectl get nodes" + echo " KUBECONFIG=$KIND_KUBECONFIG OPENSHELL_NAMESPACE=openshell-kind-test kubectl get nodes" echo " kind delete cluster --name $CLUSTER_NAME" else echo "" @@ -48,6 +50,7 @@ trap cleanup EXIT KIND_KUBECONFIG=$(mktemp /tmp/kind-${CLUSTER_NAME}-XXXXXX.kubeconfig) export KUBECONFIG="$KIND_KUBECONFIG" +export OPENSHELL_NAMESPACE="openshell-kind-test" echo "=== kind cluster lifecycle ===" echo " Cluster: $CLUSTER_NAME" @@ -65,7 +68,7 @@ echo "" # ── Pre-test setup ────────────────────────────────────────────────── -kubectl create namespace openshell --dry-run=client -o yaml | kubectl apply -f - 2>/dev/null || true +kubectl create namespace "$OPENSHELL_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - 2>/dev/null || true # Pre-load dev sandbox image into kind (avoids pull secrets and ImagePullBackOff). # SANDBOX_IMAGE is set by the Makefile for dev builds; CI uses the public community image. From add032c52cbdd76dde1d93f48810047f63f1fc23 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 00:42:08 -0700 Subject: [PATCH 06/22] fix: revert kind namespace -- same namespace, different clusters Kind and OCP use different KUBECONFIGs (different clusters), so the openshell namespace name should be the same. Keep the gateway cleanup in the exit trap to prevent CLI gateway registration leaks. --- README.md | 10 ++++++++++ test/kind-lifecycle.sh | 5 ++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cfb838a..da80be4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,16 @@ Orchestration CLI for [OpenShell](https://github.com/NVIDIA/OpenShell). Automates gateway deployment, provider registration, and sandbox creation across local Podman and remote Kubernetes/OpenShift targets. Wraps the `openshell` CLI -- does not replace it. +## Where This Fits + +[OpenShell](https://github.com/NVIDIA/OpenShell) provides the runtime: gateway, sandboxes, L7 network policy, and credential proxy. It handles sandbox lifecycle and credential injection once providers are registered, but leaves gateway deployment orchestration, credential validation, and multi-target configuration to the user. + +[NemoClaw](https://github.com/NVIDIA/NemoClaw) is NVIDIA's reference stack for running agents inside OpenShell with managed Nemotron inference. It bundles a specific model backend and onboarding flow. + +This harness fills a different gap: multi-provider credential management (preflight validation, registration, health checks) across deployment targets (local Podman, kind, OpenShift) with declarative agent configs. It is model-agnostic -- the agent config chooses the entrypoint and inference backend. The harness orchestrates the infrastructure around it. + +The harness wraps `openshell` CLI commands. As OpenShell adds native support for provider validation, multi-target deployment, and agent config rendering, the corresponding harness code shrinks. Every workaround tracks the upstream issue that would eliminate it (see [AGENTS.md](AGENTS.md)). + ## Example An agent config declares the sandbox image, entrypoint, and which providers to attach: diff --git a/test/kind-lifecycle.sh b/test/kind-lifecycle.sh index f1ab961..0b5c26d 100755 --- a/test/kind-lifecycle.sh +++ b/test/kind-lifecycle.sh @@ -34,7 +34,7 @@ cleanup() { if $KEEP_CLUSTER; then echo "" echo "Cluster kept: $CLUSTER_NAME" - echo " KUBECONFIG=$KIND_KUBECONFIG OPENSHELL_NAMESPACE=openshell-kind-test kubectl get nodes" + echo " KUBECONFIG=$KIND_KUBECONFIG kubectl get nodes" echo " kind delete cluster --name $CLUSTER_NAME" else echo "" @@ -50,7 +50,6 @@ trap cleanup EXIT KIND_KUBECONFIG=$(mktemp /tmp/kind-${CLUSTER_NAME}-XXXXXX.kubeconfig) export KUBECONFIG="$KIND_KUBECONFIG" -export OPENSHELL_NAMESPACE="openshell-kind-test" echo "=== kind cluster lifecycle ===" echo " Cluster: $CLUSTER_NAME" @@ -68,7 +67,7 @@ echo "" # ── Pre-test setup ────────────────────────────────────────────────── -kubectl create namespace "$OPENSHELL_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - 2>/dev/null || true +kubectl create namespace openshell --dry-run=client -o yaml | kubectl apply -f - 2>/dev/null || true # Pre-load dev sandbox image into kind (avoids pull secrets and ImagePullBackOff). # SANDBOX_IMAGE is set by the Makefile for dev builds; CI uses the public community image. From a4f13fbd3e0970f099ec1d72e509fb79470a76e9 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 00:50:48 -0700 Subject: [PATCH 07/22] fix: wire payloadDir into createSandbox -- payload was never uploaded createSandbox always used profile.StageHarnessDir (legacy path) and ignored the payloadDir field. The agent-rendered payload (env.sh, run.sh, task.md) was never uploaded to the sandbox, causing 'bash /sandbox/.config/openshell/run.sh' to fail with file not found. When payloadDir is set, use it as UploadSrc directly. Fall back to profile staging only when payloadDir is empty. --- cmd/sandbox.go | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/cmd/sandbox.go b/cmd/sandbox.go index bbfb4f0..241d953 100644 --- a/cmd/sandbox.go +++ b/cmd/sandbox.go @@ -43,15 +43,24 @@ func createSandbox(opts sandboxOpts) error { } } - // Stage files for upload into the sandbox. - tmpParent, err := os.MkdirTemp("", "harness-") - if err != nil { - return fmt.Errorf("creating staging dir: %w", err) + // Determine upload source: pre-rendered payload or legacy profile staging. + uploadSrc := opts.payloadDir + uploadDst := "/sandbox/.config/openshell" + var tmpParent string + if uploadSrc == "" { + var err error + tmpParent, err = os.MkdirTemp("", "harness-") + if err != nil { + return fmt.Errorf("creating staging dir: %w", err) + } + uploadSrc = filepath.Join(tmpParent, "openshell") + uploadDst = "/sandbox/.config" + if err := profile.StageHarnessDir(cfg, uploadSrc); err != nil { + return fmt.Errorf("staging files: %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) + if tmpParent != "" { + defer os.RemoveAll(tmpParent) } // Create sandbox with retry loop (up to 5 attempts). @@ -62,8 +71,8 @@ func createSandbox(opts sandboxOpts) error { Providers: opts.providers, TTY: !opts.noTTY, Keep: cfg.KeepSandbox(), - UploadSrc: harnessUploadDir, - UploadDst: "/sandbox/.config", + UploadSrc: uploadSrc, + UploadDst: uploadDst, Command: opts.sandboxCmd, }) if err == nil { From 8b91d988c984f5ef6a4d1248a45fe4109f9ae382 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 01:08:14 -0700 Subject: [PATCH 08/22] fix: payload upload path and noTTY sandbox command Two fixes for sandbox creation with agent configs: 1. Upload path: openshell --upload copies the source directory BY NAME into the destination. Rename the payload dir to 'openshell' so files land at /sandbox/.config/openshell/{env.sh,run.sh} as expected. 2. noTTY command: when --no-tty is set, use 'true' as the sandbox command (just start it) instead of 'bash run.sh' which execs the entrypoint. The entrypoint (claude --bare) needs TTY or piped input. --- cmd/create.go | 2 +- cmd/sandbox.go | 34 +++++++++++++++++----------------- cmd/up.go | 7 ++++++- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index fb048c0..ec187b0 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -143,7 +143,7 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { providers: registered, noTTY: true, retrySleep: 5 * time.Second, - sandboxCmd: []string{"bash", "/sandbox/.config/openshell/run.sh"}, + sandboxCmd: []string{"true"}, payloadDir: payloadDir, onSuccess: func(n string) { fmt.Println() diff --git a/cmd/sandbox.go b/cmd/sandbox.go index 241d953..aa7a7e8 100644 --- a/cmd/sandbox.go +++ b/cmd/sandbox.go @@ -43,25 +43,25 @@ func createSandbox(opts sandboxOpts) error { } } - // Determine upload source: pre-rendered payload or legacy profile staging. - uploadSrc := opts.payloadDir - uploadDst := "/sandbox/.config/openshell" - var tmpParent string - if uploadSrc == "" { - var err error - tmpParent, err = os.MkdirTemp("", "harness-") - if err != nil { - return fmt.Errorf("creating staging dir: %w", err) + // Stage upload directory. openshell --upload copies the source directory + // BY NAME into the destination, so we always create a subdirectory called + // "openshell" and upload to /sandbox/.config → /sandbox/.config/openshell/*. + tmpParent, err := os.MkdirTemp("", "harness-") + if err != nil { + return fmt.Errorf("creating staging dir: %w", err) + } + defer os.RemoveAll(tmpParent) + uploadDir := filepath.Join(tmpParent, "openshell") + + if opts.payloadDir != "" { + if err := os.Rename(opts.payloadDir, uploadDir); err != nil { + return fmt.Errorf("staging payload: %w", err) } - uploadSrc = filepath.Join(tmpParent, "openshell") - uploadDst = "/sandbox/.config" - if err := profile.StageHarnessDir(cfg, uploadSrc); err != nil { + } else { + if err := profile.StageHarnessDir(cfg, uploadDir); err != nil { return fmt.Errorf("staging files: %w", err) } } - if tmpParent != "" { - defer os.RemoveAll(tmpParent) - } // Create sandbox with retry loop (up to 5 attempts). for attempt := 1; attempt <= 5; attempt++ { @@ -71,8 +71,8 @@ func createSandbox(opts sandboxOpts) error { Providers: opts.providers, TTY: !opts.noTTY, Keep: cfg.KeepSandbox(), - UploadSrc: uploadSrc, - UploadDst: uploadDst, + UploadSrc: uploadDir, + UploadDst: "/sandbox/.config", Command: opts.sandboxCmd, }) if err == nil { diff --git a/cmd/up.go b/cmd/up.go index 5ecebdd..b9df622 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -329,7 +329,12 @@ func upLocal(opts upLocalOpts) error { // 7. Create sandbox fmt.Println() fmt.Println("=== Creating sandbox ===") - sandboxCmd := []string{"bash", "/sandbox/.config/openshell/run.sh"} + var sandboxCmd []string + if noTTY { + sandboxCmd = []string{"true"} + } else { + sandboxCmd = []string{"bash", "/sandbox/.config/openshell/run.sh"} + } return createSandbox(sandboxOpts{ harnessDir: opts.harnessDir, From 06ce0141cfdf15203c2165967847ade4655e1889 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 01:10:42 -0700 Subject: [PATCH 09/22] fix: write sandbox.env for startup.sh compat RenderPayload now writes both env.sh (sourced by run.sh) and sandbox.env (sourced by the sandbox image startup.sh on boot). Without sandbox.env, env vars like ANTHROPIC_BASE_URL are not available in sandbox exec sessions. --- internal/agent/agent.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 2fa9fe6..47c8f86 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -139,6 +139,11 @@ func RenderPayload(cfg *AgentConfig, baseDir, destDir string) error { if err := os.WriteFile(filepath.Join(destDir, "env.sh"), []byte(envContent), 0o644); err != nil { return fmt.Errorf("writing env.sh: %w", err) } + // sandbox.env is sourced by the sandbox image's startup.sh on boot, + // making env vars available to sandbox exec sessions. + if err := os.WriteFile(filepath.Join(destDir, "sandbox.env"), []byte(envContent), 0o644); err != nil { + return fmt.Errorf("writing sandbox.env: %w", err) + } } runSh := cfg.BuildRunSh() From 7b672fab1b15fe612d0de4284f9ae7a3fa334403 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 01:13:44 -0700 Subject: [PATCH 10/22] fix: source sandbox.env on boot so env vars are available in exec sessions The sandbox command now sources sandbox.env and appends it to .bashrc before running. This replaces what startup.sh did in the old launcher flow. Works for both noTTY (test) and interactive modes. --- cmd/create.go | 4 +++- cmd/up.go | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index ec187b0..cf13652 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -143,7 +143,9 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { providers: registered, noTTY: true, retrySleep: 5 * time.Second, - sandboxCmd: []string{"true"}, + sandboxCmd: []string{"bash", "-c", + ". /sandbox/.config/openshell/sandbox.env 2>/dev/null && " + + "cat /sandbox/.config/openshell/sandbox.env >> /sandbox/.bashrc 2>/dev/null; true"}, payloadDir: payloadDir, onSuccess: func(n string) { fmt.Println() diff --git a/cmd/up.go b/cmd/up.go index b9df622..f99ecb9 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -329,11 +329,13 @@ func upLocal(opts upLocalOpts) error { // 7. Create sandbox fmt.Println() fmt.Println("=== Creating sandbox ===") + envInit := ". /sandbox/.config/openshell/sandbox.env 2>/dev/null && " + + "cat /sandbox/.config/openshell/sandbox.env >> /sandbox/.bashrc 2>/dev/null; " var sandboxCmd []string if noTTY { - sandboxCmd = []string{"true"} + sandboxCmd = []string{"bash", "-c", envInit + "true"} } else { - sandboxCmd = []string{"bash", "/sandbox/.config/openshell/run.sh"} + sandboxCmd = []string{"bash", "-c", envInit + "exec bash /sandbox/.config/openshell/run.sh"} } return createSandbox(sandboxOpts{ From a5f5f6d3ee74b66bdfa78cef6418889daa8865aa Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 01:19:11 -0700 Subject: [PATCH 11/22] fix: copy openshell binary into runner build context The Makefile cli-runner target now copies the openshell binary alongside the harness binary so docker build can find it. --- .gitignore | 2 ++ Makefile | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index bdfe82d..88bacfe 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ sandbox/launcher/launcher infracluster/ dashboard.html .claude/ +build/runner/harness +build/runner/openshell diff --git a/Makefile b/Makefile index ad9938d..076ff84 100644 --- a/Makefile +++ b/Makefile @@ -51,7 +51,8 @@ push-sandbox: sandbox ## Cross-compile harness binary for the runner image cli-runner: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/runner/harness . - @echo "Built: build/runner/harness" + cp "$$(which openshell)" build/runner/openshell + @echo "Built: build/runner/harness + openshell" ## Runner image (harness binary + openshell CLI) runner: cli-runner build/runner/Dockerfile @@ -144,7 +145,7 @@ dev-runner: cli-runner ## Clean built binaries clean: - rm -f harness build/runner/harness + rm -f harness build/runner/harness build/runner/openshell @echo "Cleaned binaries" ## Show available targets From abf2081f0186a812fabae92c41661a4e3e68730b Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 01:43:30 -0700 Subject: [PATCH 12/22] fix: detectHarnessDir non-fatal for in-cluster launch The launch command runs inside the runner container where there is no agents/ directory. detectHarnessDir now returns empty string instead of os.Exit(1), letting launch work without a harness dir. --- main.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/main.go b/main.go index e1a90db..0f183e0 100644 --- a/main.go +++ b/main.go @@ -71,7 +71,5 @@ func detectHarnessDir() string { dir = filepath.Dir(dir) } } - fmt.Fprintf(os.Stderr, "ERROR: could not detect harness directory — set HARNESS_DIR or run from the project root\n") - os.Exit(1) return "" } From a0532fc2f39dbd698a095140f0deeb2745a58c1a Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 01:48:46 -0700 Subject: [PATCH 13/22] fix: force-overwrite openshell binary in runner build context --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 076ff84..303e804 100644 --- a/Makefile +++ b/Makefile @@ -51,7 +51,7 @@ push-sandbox: sandbox ## Cross-compile harness binary for the runner image cli-runner: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/runner/harness . - cp "$$(which openshell)" build/runner/openshell + rm -f build/runner/openshell && cp "$$(which openshell)" build/runner/openshell @echo "Built: build/runner/harness + openshell" ## Runner image (harness binary + openshell CLI) From 03fc7d3034b0ca03e06f66de9229d4ff8edaf1c8 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 01:53:35 -0700 Subject: [PATCH 14/22] fix: retry GWS API call in sandbox_verify (token refresh timing) The gateway token refresh may not complete before the first API call. Add 3 retries with 3s sleep to handle the timing window. --- test/test-flow.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test-flow.sh b/test/test-flow.sh index db79750..82eeee4 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -168,7 +168,7 @@ sandbox_verify() { # Provider-dependent checks (require credentials + inference) step "sandbox: env vars" "$CLI" sandbox exec --name "$name" -- bash -c 'test -n "$ANTHROPIC_BASE_URL"' step "sandbox: gws token placeholder" "$CLI" sandbox exec --name "$name" -- bash -c 'echo "$GOOGLE_WORKSPACE_CLI_TOKEN" | grep -q "openshell:resolve:env"' - step "sandbox: gws api call" "$CLI" sandbox exec --name "$name" -- bash -c 'curl -sf https://gmail.googleapis.com/gmail/v1/users/me/profile -H "Authorization: Bearer $GOOGLE_WORKSPACE_CLI_TOKEN" -o /dev/null' + step "sandbox: gws api call" "$CLI" sandbox exec --name "$name" -- bash -c 'for i in 1 2 3; do curl -sf https://gmail.googleapis.com/gmail/v1/users/me/profile -H "Authorization: Bearer $GOOGLE_WORKSPACE_CLI_TOKEN" -o /dev/null && exit 0; sleep 3; done; exit 1' step "sandbox: mcp config" "$CLI" sandbox exec --name "$name" -- test -f /sandbox/.mcp.json step_output "sandbox: claude responds" "$CLI" sandbox exec --name "$name" -- bash -c 'echo "respond with ok" | claude --bare --print 2>&1 | head -1' } From ab1bb378551ba88a72b851325c4e163ab9619f40 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 02:07:33 -0700 Subject: [PATCH 15/22] fix: install openshell inside runner Dockerfile for correct platform The Makefile was copying the local (macOS arm64) openshell binary into the runner image, causing 'exec format error' on linux/amd64 clusters. Now the Dockerfile installs openshell from the upstream install script during docker build, getting the correct platform binary. --- .github/workflows/images.yml | 4 ---- Makefile | 3 +-- build/runner/Dockerfile | 5 +++-- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index 45dbf5e..499ff0a 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -73,10 +73,6 @@ jobs: go-version-file: go.mod - name: Build harness binary run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/runner/harness . - - name: Install openshell CLI - run: | - curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh - cp "$(which openshell)" build/runner/openshell - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: diff --git a/Makefile b/Makefile index 303e804..024fc62 100644 --- a/Makefile +++ b/Makefile @@ -51,8 +51,7 @@ push-sandbox: sandbox ## Cross-compile harness binary for the runner image cli-runner: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/runner/harness . - rm -f build/runner/openshell && cp "$$(which openshell)" build/runner/openshell - @echo "Built: build/runner/harness + openshell" + @echo "Built: build/runner/harness" ## Runner image (harness binary + openshell CLI) runner: cli-runner build/runner/Dockerfile diff --git a/build/runner/Dockerfile b/build/runner/Dockerfile index f266c1a..f39410f 100644 --- a/build/runner/Dockerfile +++ b/build/runner/Dockerfile @@ -1,9 +1,10 @@ FROM registry.access.redhat.com/ubi9/ubi-minimal:latest -RUN microdnf install -y openssh-clients --nodocs && microdnf clean all +RUN microdnf install -y openssh-clients tar gzip --nodocs && microdnf clean all + +RUN curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh COPY harness /usr/local/bin/harness -COPY openshell /usr/local/bin/openshell USER 1000 From 2a90eae5028926b8af6ab05ac0a69653d9ed3339 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 02:15:11 -0700 Subject: [PATCH 16/22] fix: download linux openshell binary in runner Dockerfile Use the musl static binary tarball from GitHub releases instead of the install script (which needs rpm/dpkg in the container) or copying the host binary (wrong platform on macOS). --- build/runner/Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build/runner/Dockerfile b/build/runner/Dockerfile index f39410f..581dcf4 100644 --- a/build/runner/Dockerfile +++ b/build/runner/Dockerfile @@ -2,7 +2,9 @@ FROM registry.access.redhat.com/ubi9/ubi-minimal:latest RUN microdnf install -y openssh-clients tar gzip --nodocs && microdnf clean all -RUN curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh +ARG OPENSHELL_VERSION=0.0.57 +RUN curl -LsSf "https://github.com/NVIDIA/OpenShell/releases/download/v${OPENSHELL_VERSION}/openshell-x86_64-unknown-linux-musl.tar.gz" \ + | tar xz -C /usr/local/bin openshell COPY harness /usr/local/bin/harness From 3925a61925c7eba7cd193a13d61f23081b5739e7 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 02:33:01 -0700 Subject: [PATCH 17/22] fix: source sandbox.env in launch command, increase runner timeout to 15min Two OCP fixes: 1. harness launch now sources sandbox.env when creating the sandbox, same as the local path. Without this, env vars like ANTHROPIC_BASE_URL are not available in exec sessions. 2. Increase upRemote job poll timeout from 10min to 15min. The runner Job includes image pull + payload render + sandbox creation which can exceed 10 minutes on cold pulls. --- cmd/launch.go | 4 +++- cmd/up.go | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/launch.go b/cmd/launch.go index 2411342..a49c202 100644 --- a/cmd/launch.go +++ b/cmd/launch.go @@ -173,7 +173,9 @@ func launchCreateSandbox(cfg *agent.AgentConfig, providers []string, cli string) for _, p := range providers { args = append(args, "--provider", p) } - args = append(args, "--", "true") + args = append(args, "--", "bash", "-c", + ". /sandbox/.config/openshell/sandbox.env 2>/dev/null && "+ + "cat /sandbox/.config/openshell/sandbox.env >> /sandbox/.bashrc 2>/dev/null; true") cmd := exec.Command(cli, args...) cmd.Stdout = os.Stdout diff --git a/cmd/up.go b/cmd/up.go index f99ecb9..7c9d83d 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -218,7 +218,7 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa // 9. Poll job status (10 min timeout) var jobStatus string - deadline := time.Now().Add(10 * time.Minute) + deadline := time.Now().Add(15 * time.Minute) for time.Now().Before(deadline) { jobStatus, err = kc.RunKubectl(ctx, "get", "job", jobName, "-o", "jsonpath={.status.conditions[0].type}") From 98a16895d586530fb484761938e0f337e4e97c2f Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 02:57:05 -0700 Subject: [PATCH 18/22] fix: source sandbox.env AFTER upload in launch exec, not at create time The sandbox command runs before payload upload, so sandbox.env does not exist yet. Move the env sourcing into launchExecEntrypoint which runs after upload. Sources sandbox.env, appends to .bashrc (for exec sessions), then execs run.sh. --- cmd/launch.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cmd/launch.go b/cmd/launch.go index a49c202..9ecc79f 100644 --- a/cmd/launch.go +++ b/cmd/launch.go @@ -173,9 +173,7 @@ func launchCreateSandbox(cfg *agent.AgentConfig, providers []string, cli string) for _, p := range providers { args = append(args, "--provider", p) } - args = append(args, "--", "bash", "-c", - ". /sandbox/.config/openshell/sandbox.env 2>/dev/null && "+ - "cat /sandbox/.config/openshell/sandbox.env >> /sandbox/.bashrc 2>/dev/null; true") + args = append(args, "--", "true") cmd := exec.Command(cli, args...) cmd.Stdout = os.Stdout @@ -207,8 +205,11 @@ func launchUploadPayload(name, payloadDir, cli string) error { } func launchExecEntrypoint(name, cli string) error { - fmt.Println(" Starting entrypoint...") - cmd := exec.Command(cli, "sandbox", "exec", "--name", name, "--", "bash", "/sandbox/.config/openshell/run.sh") + fmt.Println(" Sourcing env + starting entrypoint...") + initCmd := ". /sandbox/.config/openshell/sandbox.env 2>/dev/null && " + + "cat /sandbox/.config/openshell/sandbox.env >> /sandbox/.bashrc 2>/dev/null; " + + "exec bash /sandbox/.config/openshell/run.sh" + cmd := exec.Command(cli, "sandbox", "exec", "--name", name, "--", "bash", "-c", initCmd) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() From 0e0e892de4068117e10ad4518f56e338e03d3e14 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 03:23:24 -0700 Subject: [PATCH 19/22] fix: runner sets up env and exits, does not start entrypoint The runner Job should set up the sandbox environment (source sandbox.env, append to .bashrc, gh auth) then EXIT. The user starts the entrypoint when they connect. Running 'exec claude --bare' made the runner Job hang indefinitely (907s timeout) since claude never exits. --- cmd/launch.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd/launch.go b/cmd/launch.go index 9ecc79f..ce54f3f 100644 --- a/cmd/launch.go +++ b/cmd/launch.go @@ -71,8 +71,8 @@ func runLaunch(cli string) error { return fmt.Errorf("upload failed: %w", err) } - if err := launchExecEntrypoint(agentCfg.Name, cli); err != nil { - return fmt.Errorf("entrypoint failed: %w", err) + if err := launchSetupEnv(agentCfg.Name, cli); err != nil { + return fmt.Errorf("env setup failed: %w", err) } fmt.Printf("\nSandbox '%s' is ready.\n", agentCfg.Name) @@ -204,12 +204,12 @@ func launchUploadPayload(name, payloadDir, cli string) error { return cmd.Run() } -func launchExecEntrypoint(name, cli string) error { - fmt.Println(" Sourcing env + starting entrypoint...") - initCmd := ". /sandbox/.config/openshell/sandbox.env 2>/dev/null && " + +func launchSetupEnv(name, cli string) error { + fmt.Println(" Setting up sandbox environment...") + setupCmd := ". /sandbox/.config/openshell/sandbox.env 2>/dev/null && " + "cat /sandbox/.config/openshell/sandbox.env >> /sandbox/.bashrc 2>/dev/null; " + - "exec bash /sandbox/.config/openshell/run.sh" - cmd := exec.Command(cli, "sandbox", "exec", "--name", name, "--", "bash", "-c", initCmd) + "gh auth setup-git 2>/dev/null; true" + cmd := exec.Command(cli, "sandbox", "exec", "--name", name, "--", "bash", "-c", setupCmd) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() From c07b6de6edfdbb19de42bc74f92d34f3044163a6 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 03:32:59 -0700 Subject: [PATCH 20/22] fix: combine create+upload+env in single sandbox create call The runner now does create+upload+env-sourcing in one openshell sandbox create call with --upload. This ensures sandbox.env is sourced as the sandbox's initial command (same as local path), making env vars available in subsequent exec sessions on K8s. --- cmd/launch.go | 36 +++++++----------------------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/cmd/launch.go b/cmd/launch.go index ce54f3f..6edf504 100644 --- a/cmd/launch.go +++ b/cmd/launch.go @@ -63,18 +63,10 @@ func runLaunch(cli string) error { } fmt.Printf(" Payload: %s\n", payloadDir) - if err := launchCreateSandbox(agentCfg, registered, cli); err != nil { + if err := launchCreateSandbox(agentCfg, registered, payloadDir, cli); err != nil { return err } - if err := launchUploadPayload(agentCfg.Name, payloadDir, cli); err != nil { - return fmt.Errorf("upload failed: %w", err) - } - - if err := launchSetupEnv(agentCfg.Name, cli); err != nil { - return fmt.Errorf("env setup failed: %w", err) - } - fmt.Printf("\nSandbox '%s' is ready.\n", agentCfg.Name) fmt.Printf("Connect with: openshell sandbox connect %s\n", agentCfg.Name) return nil @@ -163,8 +155,11 @@ func checkProviders(providers []string, cli string) []string { return registered } -func launchCreateSandbox(cfg *agent.AgentConfig, providers []string, cli string) error { +func launchCreateSandbox(cfg *agent.AgentConfig, providers []string, payloadDir, cli string) error { fmt.Println("\n=== Creating sandbox ===") + envInit := ". /sandbox/.config/openshell/sandbox.env 2>/dev/null && " + + "cat /sandbox/.config/openshell/sandbox.env >> /sandbox/.bashrc 2>/dev/null; " + + "gh auth setup-git 2>/dev/null; true" for attempt := 1; attempt <= 5; attempt++ { args := []string{"sandbox", "create", "--name", cfg.Name, "--no-tty"} if cfg.Image != "" { @@ -173,7 +168,8 @@ func launchCreateSandbox(cfg *agent.AgentConfig, providers []string, cli string) for _, p := range providers { args = append(args, "--provider", p) } - args = append(args, "--", "true") + args = append(args, "--upload", payloadDir+":/sandbox/.config/openshell", "--no-git-ignore") + args = append(args, "--", "bash", "-c", envInit) cmd := exec.Command(cli, args...) cmd.Stdout = os.Stdout @@ -196,24 +192,6 @@ func launchCreateSandbox(cfg *agent.AgentConfig, providers []string, cli string) return nil } -func launchUploadPayload(name, payloadDir, cli string) error { - fmt.Println(" Uploading payload...") - cmd := exec.Command(cli, "sandbox", "upload", name, payloadDir, "/sandbox/.config/openshell", "--no-git-ignore") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -func launchSetupEnv(name, cli string) error { - fmt.Println(" Setting up sandbox environment...") - setupCmd := ". /sandbox/.config/openshell/sandbox.env 2>/dev/null && " + - "cat /sandbox/.config/openshell/sandbox.env >> /sandbox/.bashrc 2>/dev/null; " + - "gh auth setup-git 2>/dev/null; true" - cmd := exec.Command(cli, "sandbox", "exec", "--name", name, "--", "bash", "-c", setupCmd) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} func copyFile(src, dst string) error { in, err := os.Open(src) From f479d933085ab86b1620c1cb31cf51d3640ba6e7 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 04:02:28 -0700 Subject: [PATCH 21/22] fix: runner upload path -- render to 'openshell' dir, upload to /sandbox/.config openshell --upload copies the source dir BY NAME into the dest. Render payload to /tmp/.../openshell/ and upload to /sandbox/.config so files land at /sandbox/.config/openshell/{env.sh,run.sh,sandbox.env}. --- cmd/launch.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/launch.go b/cmd/launch.go index 6edf504..d402fd5 100644 --- a/cmd/launch.go +++ b/cmd/launch.go @@ -57,7 +57,7 @@ func runLaunch(cli string) error { providerNames := agentCfg.ProviderNames() registered := checkProviders(providerNames, cli) - payloadDir := "/tmp/openshell/payload" + payloadDir := "/tmp/openshell-staging/openshell" if err := agent.RenderPayload(agentCfg, "/etc/openshell/sandbox", payloadDir); err != nil { return fmt.Errorf("rendering payload: %w", err) } @@ -168,7 +168,7 @@ func launchCreateSandbox(cfg *agent.AgentConfig, providers []string, payloadDir, for _, p := range providers { args = append(args, "--provider", p) } - args = append(args, "--upload", payloadDir+":/sandbox/.config/openshell", "--no-git-ignore") + args = append(args, "--upload", payloadDir+":/sandbox/.config", "--no-git-ignore") args = append(args, "--", "bash", "-c", envInit) cmd := exec.Command(cli, args...) From 170d9e73c3c2e0570d7e6741b60d6d4e9b4d268b Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 04:12:22 -0700 Subject: [PATCH 22/22] fix: add 2s wait before sandbox exec test (SSH readiness after Ready) On kind, the sandbox SSH connection may not be immediately available after the sandbox reaches Ready state. Add a brief sleep. --- test/test-flow.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/test-flow.sh b/test/test-flow.sh index 82eeee4..ca47a4d 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -158,7 +158,8 @@ sandbox_verify() { printf " ✓ %-35s\n" "sandbox ready" ((PASS++)) - # Basic exec works + # Basic exec works (brief wait for SSH readiness after Ready state) + sleep 2 step "sandbox: exec" "$CLI" sandbox exec --name "$name" -- echo "hello" if $NO_PROVIDERS; then