From 215078108c60315c967bc4a60f896138c225e9a6 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 11:01:12 -0700 Subject: [PATCH 1/6] refactor: remove atlassian K8s secret and sandbox env injection Eliminates the parallel env injection path for atlassian non-secret config (JIRA_URL, JIRA_USERNAME). These vars remain in sandbox.env via BuildSandboxEnv() as a transition bridge until Phase 2 payload renderer ships. Removes: - ProviderEnvVars() and its call sites in up.go and create.go - sandbox = true flag on Input struct (now unused) - ensureCreds() and the openshell-atlassian K8s secret it created - cmd/creds.go and cmd/creds_test.go providers.toml: JIRA_URL/JIRA_USERNAME inputs are no longer marked sandbox = true since the Sandbox field is gone. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- cmd/create.go | 13 ------ cmd/creds.go | 45 ------------------- cmd/creds_test.go | 77 --------------------------------- cmd/up.go | 22 +--------- internal/preflight/preflight.go | 32 ++------------ providers.toml | 4 +- 6 files changed, 6 insertions(+), 187 deletions(-) delete mode 100644 cmd/creds.go delete mode 100644 cmd/creds_test.go diff --git a/cmd/create.go b/cmd/create.go index e9cf3d2..9715b4c 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -203,19 +203,6 @@ func createDirect(harnessDir string, gw gateway.Gateway, profileName string, cfg } } - providersPath := filepath.Join(harnessDir, "providers.toml") - if allProviders, err := preflight.LoadProviders(providersPath); err == nil { - providerEnv := preflight.ProviderEnvVars(allProviders, cfg.Providers) - if cfg.Env == nil { - cfg.Env = make(map[string]string) - } - for k, v := range providerEnv { - if _, exists := cfg.Env[k]; !exists { - cfg.Env[k] = v - } - } - } - tmpParent, err := os.MkdirTemp("", "harness-") if err != nil { return fmt.Errorf("creating staging dir: %w", err) diff --git a/cmd/creds.go b/cmd/creds.go deleted file mode 100644 index d0de4f9..0000000 --- a/cmd/creds.go +++ /dev/null @@ -1,45 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "os" - - "github.com/robbycochran/harness-openshell/internal/k8s" - "github.com/robbycochran/harness-openshell/internal/status" -) - -func ensureCreds(kc k8s.Runner, namespace string, force bool) error { - ctx := context.Background() - - if !kc.NamespaceExists(ctx, namespace) { - return fmt.Errorf("namespace '%s' not found — run: harness deploy --remote", namespace) - } - - status.Section("Setting up cluster credentials") - status.Detailf("Namespace: %s", namespace) - - // Atlassian credentials - status.Section("Atlassian") - if force && kc.SecretExists(ctx, "openshell-atlassian") { - kc.RunKubectl(ctx, "delete", "secret", "openshell-atlassian") - status.Info("Deleted existing secret") - } - - if kc.SecretExists(ctx, "openshell-atlassian") { - status.Info("openshell-atlassian: exists (use --force to recreate)") - } else if jiraURL := os.Getenv("JIRA_URL"); jiraURL != "" { - jiraUser := os.Getenv("JIRA_USERNAME") - _, err := kc.RunKubectl(ctx, "create", "secret", "generic", "openshell-atlassian", - "--from-literal=JIRA_URL="+jiraURL, - "--from-literal=JIRA_USERNAME="+jiraUser) - if err != nil { - return fmt.Errorf("creating atlassian secret: %w", err) - } - status.OKf("openshell-atlassian: created (%s)", jiraURL) - } else { - status.Info("Atlassian: JIRA_URL not set (skipping)") - } - - return nil -} diff --git a/cmd/creds_test.go b/cmd/creds_test.go deleted file mode 100644 index 5b86554..0000000 --- a/cmd/creds_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package cmd - -import ( - "fmt" - "testing" - - "github.com/robbycochran/harness-openshell/internal/k8s" -) - -func TestEnsureCreds_NamespaceNotFound(t *testing.T) { - nsRunner := k8s.NewMockRunner() - nsRunner.Errors["namespace-exists"] = fmt.Errorf("not found") - - err := ensureCreds(nsRunner, "openshell", false) - if err == nil { - t.Fatal("expected error for missing namespace") - } -} - -func TestEnsureCreds_SecretsExist(t *testing.T) { - nsRunner := k8s.NewMockRunner() - // namespace exists (no error), secrets exist (no error) - - err := ensureCreds(nsRunner, "openshell", false) - if err != nil { - t.Fatalf("ensureCreds: %v", err) - } - // Should NOT attempt to create secrets - if nsRunner.HasCall("create secret") { - t.Error("should not create secrets when they already exist") - } -} - -func TestEnsureCreds_ForceDeletesExisting(t *testing.T) { - nsRunner := k8s.NewMockRunner() - - err := ensureCreds(nsRunner, "openshell", true) - if err != nil { - t.Fatalf("ensureCreds: %v", err) - } - // Should attempt to delete atlassian secret - if nsRunner.CallCount("delete secret") != 1 { - t.Errorf("expected 1 secret delete, got %d: %v", - nsRunner.CallCount("delete secret"), nsRunner.Calls) - } -} - -func TestEnsureCreds_AtlassianCreated(t *testing.T) { - nsRunner := k8s.NewMockRunner() - nsRunner.Errors["secret-exists openshell-atlassian"] = fmt.Errorf("not found") - - t.Setenv("JIRA_URL", "https://example.atlassian.net") - t.Setenv("JIRA_USERNAME", "user@example.com") - - err := ensureCreds(nsRunner, "openshell", false) - if err != nil { - t.Fatalf("ensureCreds: %v", err) - } - if !nsRunner.HasCall("create secret generic openshell-atlassian") { - t.Errorf("expected atlassian secret creation, calls: %v", nsRunner.Calls) - } -} - -func TestEnsureCreds_AtlassianSkipped(t *testing.T) { - nsRunner := k8s.NewMockRunner() - nsRunner.Errors["secret-exists openshell-atlassian"] = fmt.Errorf("not found") - t.Setenv("JIRA_URL", "") - t.Setenv("JIRA_USERNAME", "") - - err := ensureCreds(nsRunner, "openshell", false) - if err != nil { - t.Fatalf("ensureCreds: %v", err) - } - if nsRunner.HasCall("create secret generic openshell-atlassian") { - t.Error("should not create atlassian secret without JIRA_URL") - } -} diff --git a/cmd/up.go b/cmd/up.go index 88460c3..82473b3 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -11,7 +11,6 @@ import ( "github.com/robbycochran/harness-openshell/internal/gateway" "github.com/robbycochran/harness-openshell/internal/k8s" - "github.com/robbycochran/harness-openshell/internal/preflight" "github.com/robbycochran/harness-openshell/internal/profile" "github.com/robbycochran/harness-openshell/internal/status" "github.com/spf13/cobra" @@ -113,11 +112,6 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa } } - // 3. Ensure credentials - if err := ensureCreds(kc, namespace, false); err != nil { - return fmt.Errorf("credentials setup failed: %w", err) - } - // Parse profile cfg, err := profile.Parse(harnessDir, profileName) if err != nil { @@ -334,21 +328,7 @@ func upLocal(opts upLocalOpts) error { status.Warn("no providers available — run: harness providers") } - // 5. Inject non-secret provider env vars into sandbox env - providersPath := filepath.Join(opts.harnessDir, "providers.toml") - if allProviders, err := preflight.LoadProviders(providersPath); err == nil { - providerEnv := preflight.ProviderEnvVars(allProviders, cfg.Providers) - if cfg.Env == nil { - cfg.Env = make(map[string]string) - } - for k, v := range providerEnv { - if _, exists := cfg.Env[k]; !exists { - cfg.Env[k] = v - } - } - } - - // 6. Stage files + // 5. Stage files // The upload preserves the source dir name as a subdirectory at the destination. // startup.sh expects files at /sandbox/.config/openshell/sandbox.env, so the // staging dir must be named "openshell". diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go index 996e325..14fa014 100644 --- a/internal/preflight/preflight.go +++ b/internal/preflight/preflight.go @@ -26,10 +26,9 @@ type Provider struct { } type Input struct { - Key string `toml:"key"` - Kind string `toml:"kind"` - Secret bool `toml:"secret"` - Sandbox bool `toml:"sandbox"` + Key string `toml:"key"` + Kind string `toml:"kind"` + Secret bool `toml:"secret"` } type ProvidersFile struct { @@ -85,31 +84,6 @@ func EnabledProviders(all []Provider, config *ConfigFile) []Provider { return result } -// ProviderEnvVars returns environment variables marked with sandbox = true -// for the named providers, resolved from the local environment. Only -// includes env vars that are set. -func ProviderEnvVars(providers []Provider, names []string) map[string]string { - wanted := make(map[string]bool, len(names)) - for _, n := range names { - wanted[n] = true - } - env := make(map[string]string) - for _, p := range providers { - if !wanted[p.Name] { - continue - } - for _, inp := range p.Inputs { - if inp.Kind != "env" || !inp.Sandbox { - continue - } - if val := os.Getenv(inp.Key); val != "" { - env[inp.Key] = val - } - } - } - return env -} - func CheckInput(inp Input) (bool, string) { switch inp.Kind { case "env": diff --git a/providers.toml b/providers.toml index 2188ed3..41b71de 100644 --- a/providers.toml +++ b/providers.toml @@ -29,8 +29,8 @@ type = "openshell" description = "Jira and Confluence (read-only, 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 }, + { key = "JIRA_URL", kind = "env" }, + { key = "JIRA_USERNAME", kind = "env" }, { key = "curl -sf ${JIRA_URL}/rest/api/2/serverInfo -o /dev/null", kind = "check" }, { key = "curl -sf -u ${JIRA_USERNAME}:${JIRA_API_TOKEN} ${JIRA_URL}/rest/api/2/myself -o /dev/null", kind = "check" }, ] From 7e8ba69a101fa057eb226d65a568fad830857b01 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 11:34:00 -0700 Subject: [PATCH 2/6] refactor: add OCP ci validation and fix gateway.toml secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add NO_PROVIDERS support to test_ocp so --ci mode works the same as local and kind (skip provider registration, use ci profile). OCP ci uses harness create instead of harness up to avoid auto-registration. Add validate-ocp and validate-ocp-ci Makefile targets. Remove openshell-atlassian from gateway.toml [secrets] — the K8s secret is no longer created (removed in the atlassian simplification). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Makefile | 19 +++++++++++++++++++ gateways/ocp/gateway.toml | 1 - test/test-flow.sh | 20 +++++++++++++++----- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 6e5d1ea..47ab9bd 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,7 @@ DEV_LAUNCHER_IMAGE := $(DEV_REGISTRY):$(DEV_TAG)-launcher .PHONY: cli sandbox push-sandbox cli-launcher launcher push-launcher \ vet lint test-unit test test-local test-kind test-ocp test-all validate \ validate-local validate-local-ci validate-kind validate-kind-ci \ + validate-ocp validate-ocp-ci \ dev-sandbox dev-launcher validate-dev clean help ## ── CLI ────────────────────────────────────────────────────────────── @@ -196,6 +197,24 @@ validate-kind-ci: cli @echo "=== Integration: kind gateway, ci mode ===" ./test/test-flow.sh kind --ci +## OCP validation with credentials (default mode). Requires: KUBECONFIG, all provider creds. +validate-ocp: cli push-launcher + @echo "=== Unit tests ===" + CGO_ENABLED=0 go test ./... + cd sandbox/launcher && go test ./... + @echo "" + @echo "=== Integration: OCP gateway, default mode ===" + ./test/test-flow.sh ocp --full + +## OCP validation without credentials (ci mode). Requires: KUBECONFIG, launcher image pushed. +validate-ocp-ci: cli push-launcher + @echo "=== Unit tests ===" + CGO_ENABLED=0 go test ./... + cd sandbox/launcher && go test ./... + @echo "" + @echo "=== Integration: OCP gateway, ci mode ===" + ./test/test-flow.sh ocp --ci --full + ## ── Convenience targets ─────────────────────────────────────────────── ## Clean built binaries diff --git a/gateways/ocp/gateway.toml b/gateways/ocp/gateway.toml index a815042..fcb9985 100644 --- a/gateways/ocp/gateway.toml +++ b/gateways/ocp/gateway.toml @@ -39,7 +39,6 @@ scc-privileged = ["openshell", "openshell-sandbox"] scc-anyuid = ["openshell"] [secrets] -names = ["openshell-atlassian"] mtls = "openshell-client-tls" [launcher] diff --git a/test/test-flow.sh b/test/test-flow.sh index 9a2f48a..ffff551 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -313,13 +313,23 @@ test_ocp() { step "deploy" "$HARNESS" deploy --remote fi - step "setup providers" "$HARNESS" providers - step "gateway reachable" "$CLI" inference get - check_providers + if ! $NO_PROVIDERS; then + step "setup providers" "$HARNESS" providers + step "gateway reachable" "$CLI" inference get + check_providers + fi if $FULL; then - step_output "sandbox create (up)" "$HARNESS" up --remote - local sandbox_name="agent" + local sandbox_name + 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" + else + # default mode: full up (deploy already done above, providers registered) + sandbox_name="agent" + step_output "sandbox create (up)" "$HARNESS" up --remote --name "$sandbox_name" --no-tty + fi for i in $(seq 1 30); do local phase=$("$CLI" sandbox list 2>/dev/null | strip_ansi | awk -v n="$sandbox_name" '$1==n {print $NF}') From 4d7376e916e94e7b7f70a4bb2822270d33f204a9 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 12:22:22 -0700 Subject: [PATCH 3/6] fix: inject atlassian config vars into sandbox.env after ProviderEnvVars removal Phase 1 removed ProviderEnvVars() which was adding JIRA_URL and JIRA_USERNAME to cfg.Env at runtime. Without this, BuildSandboxEnv() / StageHarnessDir() produced sandbox.env without those vars, breaking startup.sh in the atlassian MCP server setup and causing sandboxes to fail to reach Ready. Add injectAtlassianEnv() as the explicit interim path: reads JIRA_URL and JIRA_USERNAME from the local environment and adds them to cfg.Env before any staging or configmap creation. Remove when Phase 2 payload renderer ships. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- cmd/create.go | 1 + cmd/up.go | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/cmd/create.go b/cmd/create.go index 9715b4c..0a67068 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -49,6 +49,7 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { if sandboxName != "" { cfg.Name = sandboxName } + injectAtlassianEnv(cfg) status.Section("Profile") fmt.Printf(" Name: %s\n", cfg.Name) diff --git a/cmd/up.go b/cmd/up.go index 82473b3..f216e61 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -120,6 +120,7 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa if sandboxName != "" { cfg.Name = sandboxName } + injectAtlassianEnv(cfg) // Resolve sandbox image for remote deploys. // SANDBOX_IMAGE env var overrides everything (dev/CI builds). @@ -300,6 +301,7 @@ func upLocal(opts upLocalOpts) error { if opts.sandboxName != "" { cfg.Name = opts.sandboxName } + injectAtlassianEnv(cfg) // Resolve Dockerfile path relative to harnessDir if cfg.From != "" && !filepath.IsAbs(cfg.From) { @@ -387,6 +389,22 @@ func upLocal(opts upLocalOpts) error { return nil // unreachable but required by compiler } +// 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 { env := []map[string]any{ {"name": "GATEWAY_ENDPOINT", "value": gatewayEndpoint}, From 2cc5f57638ff71df8fd4a385b56b23d03449cf1c Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 13:21:27 -0700 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20k8s=20sandbox=20image=20=E2=80=94=20?= =?UTF-8?q?pull=20secrets,=20SANDBOX=5FIMAGE=20in=20upLocal,=20idempotent?= =?UTF-8?q?=20deploy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for validate-kind/validate-ocp with dev images: 1. SANDBOX_IMAGE override in upLocal(): direct-mode sandbox creation was not reading SANDBOX_IMAGE env var, so the profile private image was always used. Add same override that upRemote already had. 2. Idempotent gateway registration: re-deploying to existing kind gateway failed with 'already exists'. Now removes by name before re-adding so deploy is idempotent. 3. validate-kind Makefile: pass SANDBOX_IMAGE and SANDBOX_PULL_SECRET so kind can pull from quay.io/rcochran. Pre-creates quay-pull secret in openshell namespace using local docker credentials. Also adds sandbox_wait() helper with fast-fail on ImagePullBackOff. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Makefile | 19 ++++++++++++------- cmd/deploy.go | 3 ++- cmd/up.go | 7 +++++-- test/test-flow.sh | 33 +++++++++++++++++++++++++++------ 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/Makefile b/Makefile index 47ab9bd..b9692c8 100644 --- a/Makefile +++ b/Makefile @@ -178,14 +178,19 @@ validate-local-ci: cli ./test/test-flow.sh local --ci ## Default validation on kind: unit tests + full integration with user credentials. -## Requires: kind create cluster --name openshell -validate-kind: cli +## Requires: kind create cluster --name openshell. Builds dev sandbox image to quay.io/rcochran. +validate-kind: cli dev-sandbox @echo "=== Unit tests ===" CGO_ENABLED=0 go test ./... cd sandbox/launcher && go test ./... @echo "" @echo "=== Integration: kind gateway, default mode ===" - ./test/test-flow.sh kind --full + @kubectl create namespace openshell --dry-run=client -o yaml 2>/dev/null | kubectl apply -f - 2>/dev/null || true + @kubectl create secret docker-registry quay-pull \ + --docker-server=quay.io --docker-username=rcochran \ + --docker-password="$$(python3 -c "import json,base64,pathlib; d=json.loads(pathlib.Path('$$HOME/.docker/config.json').read_text()); print(base64.b64decode(d['auths']['quay.io']['auth']).decode().split(':',1)[1])")" \ + -n openshell --dry-run=client -o yaml 2>/dev/null | kubectl apply -f - 2>/dev/null || true + SANDBOX_IMAGE=$(DEV_SANDBOX_IMAGE) SANDBOX_PULL_SECRET=quay-pull ./test/test-flow.sh kind --full ## CI validation on kind: unit tests + integration without credentials. ## Requires: kind create cluster --name openshell @@ -198,22 +203,22 @@ validate-kind-ci: cli ./test/test-flow.sh kind --ci ## OCP validation with credentials (default mode). Requires: KUBECONFIG, all provider creds. -validate-ocp: cli push-launcher +validate-ocp: cli dev-launcher dev-sandbox @echo "=== Unit tests ===" CGO_ENABLED=0 go test ./... cd sandbox/launcher && go test ./... @echo "" @echo "=== Integration: OCP gateway, default mode ===" - ./test/test-flow.sh ocp --full + SANDBOX_IMAGE=$(DEV_SANDBOX_IMAGE) LAUNCHER_IMAGE=$(DEV_LAUNCHER_IMAGE) ./test/test-flow.sh ocp --full ## OCP validation without credentials (ci mode). Requires: KUBECONFIG, launcher image pushed. -validate-ocp-ci: cli push-launcher +validate-ocp-ci: cli dev-launcher @echo "=== Unit tests ===" CGO_ENABLED=0 go test ./... cd sandbox/launcher && go test ./... @echo "" @echo "=== Integration: OCP gateway, ci mode ===" - ./test/test-flow.sh ocp --ci --full + LAUNCHER_IMAGE=$(DEV_LAUNCHER_IMAGE) ./test/test-flow.sh ocp --ci --full ## ── Convenience targets ─────────────────────────────────────────────── diff --git a/cmd/deploy.go b/cmd/deploy.go index b3f36d7..79adaf8 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -264,7 +264,8 @@ func deployFromConfig(harnessDir string, gwCfg *gateway.GatewayConfig, gw gatewa return fmt.Errorf("listing existing gateways: %w", err) } for _, g := range existing { - if routeHost != "" && strings.Contains(g.Endpoint, routeHost) { + // Remove stale registration for same name or same route host (idempotent re-deploy). + if g.Name == gatewayName || (routeHost != "" && strings.Contains(g.Endpoint, routeHost)) { gw.GatewayRemove(g.Name) } } diff --git a/cmd/up.go b/cmd/up.go index f216e61..e16f7a5 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -303,8 +303,11 @@ func upLocal(opts upLocalOpts) error { } injectAtlassianEnv(cfg) - // Resolve Dockerfile path relative to harnessDir - if cfg.From != "" && !filepath.IsAbs(cfg.From) { + // SANDBOX_IMAGE env var overrides the profile's image (dev/CI builds). + if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { + cfg.From = envImage + } else if cfg.From != "" && !filepath.IsAbs(cfg.From) { + // Resolve Dockerfile path relative to harnessDir candidate := filepath.Join(opts.harnessDir, cfg.From) if info, err := os.Stat(candidate); err == nil && info.IsDir() { cfg.From = candidate diff --git a/test/test-flow.sh b/test/test-flow.sh index ffff551..43d76fc 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -119,10 +119,36 @@ check_providers() { fi } +sandbox_wait() { + # Poll for sandbox Ready, failing fast on k8s bad states (ImagePullBackOff, etc.) + local name="$1" + for i in $(seq 1 30); do + local phase + phase=$("$CLI" sandbox list 2>/dev/null | strip_ansi | awk -v n="$name" '$1==n {print $NF}') + [[ "$phase" == "Ready" ]] && return 0 + + # On k8s targets, check for pod bad states so we fail fast instead of timing out + if kubectl get pods -n openshell 2>/dev/null | grep -q "ImagePullBackOff\|ErrImagePull\|CrashLoopBackOff"; then + local bad + bad=$(kubectl get pods -n openshell 2>/dev/null | grep "ImagePullBackOff\|ErrImagePull\|CrashLoopBackOff" | awk '{print $1, $3}' | head -3) + echo " ERROR: k8s pod in bad state: $bad" >&2 + return 1 + fi + sleep 2 + done + return 1 +} + sandbox_verify() { local name="$1" local phase - phase=$("$CLI" sandbox list 2>/dev/null | strip_ansi | awk -v n="$name" '$1==n {print $NF}') + if ! sandbox_wait "$name"; then + phase=$("$CLI" sandbox list 2>/dev/null | strip_ansi | awk -v n="$name" '$1==n {print $NF}') + printf " ✗ %-35s %s\n" "sandbox ready" "(phase: ${phase:-not found})" + ((FAIL++)) + return + fi + phase="Ready" if [[ "$phase" != "Ready" ]]; then printf " ✗ %-35s\n" "sandbox ready" ((FAIL++)) @@ -331,11 +357,6 @@ test_ocp() { step_output "sandbox create (up)" "$HARNESS" up --remote --name "$sandbox_name" --no-tty fi - for i in $(seq 1 30); do - local phase=$("$CLI" sandbox list 2>/dev/null | strip_ansi | awk -v n="$sandbox_name" '$1==n {print $NF}') - [[ "$phase" == "Ready" ]] && break - sleep 2 - done sandbox_verify "$sandbox_name" step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" fi From d51536adbbf6ef861fbe5dfed7fbf6e70b57343a Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 13:23:06 -0700 Subject: [PATCH 5/6] test: clear SANDBOX_IMAGE in TestUpLocal_SandboxCreateOpts to prevent env leak Co-Authored-By: Claude Sonnet 4.6 (1M context) --- cmd/up_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/up_test.go b/cmd/up_test.go index c3154eb..3c01b6e 100644 --- a/cmd/up_test.go +++ b/cmd/up_test.go @@ -190,6 +190,7 @@ 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) gw := &mockGW{ providerList: []string{"github", "vertex-local"}, From 58d38421a2b31a1e8a95214f30198df14456fe2e Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 14:09:00 -0700 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20sandbox=20exec=20rejects=20newlines?= =?UTF-8?q?=20=E2=80=94=20collapse=20multi-line=20curl=20to=20single=20lin?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openshell sandbox exec rejects command arguments containing newline or carriage return characters (gRPC InvalidArgument). The test_gws Gmail API curl was split across lines with backslash continuation inside single quotes, which passes a literal newline to the sandbox exec API and fails. Collapse the curl commands to single lines. Also remove leftover sleep 2 and diagnostic code added during investigation. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- test/test-flow.sh | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/test/test-flow.sh b/test/test-flow.sh index 43d76fc..192120a 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -259,18 +259,15 @@ test_gws() { 'echo "$GOOGLE_WORKSPACE_CLI_TOKEN" | grep -q "openshell:resolve:env"' # Real API call works through proxy (token resolved on the wire) + # Note: sandbox exec rejects newlines in args — keep curl on one line. step "gws: Gmail API via proxy" \ - "$CLI" sandbox exec --name "$sandbox_name" -- bash -c \ - 'curl -sf https://gmail.googleapis.com/gmail/v1/users/me/profile \ - -H "Authorization: Bearer $GOOGLE_WORKSPACE_CLI_TOKEN" -o /dev/null' + "$CLI" sandbox exec --name "$sandbox_name" -- bash -c 'curl -sf https://gmail.googleapis.com/gmail/v1/users/me/profile -H "Authorization: Bearer $GOOGLE_WORKSPACE_CLI_TOKEN" -o /dev/null' # Force gateway token rotation, verify sandbox still works openshell provider refresh rotate gws \ --credential-key GOOGLE_WORKSPACE_CLI_TOKEN &>/dev/null step "gws: works after rotation" \ - "$CLI" sandbox exec --name "$sandbox_name" -- bash -c \ - 'curl -sf https://gmail.googleapis.com/gmail/v1/users/me/profile \ - -H "Authorization: Bearer $GOOGLE_WORKSPACE_CLI_TOKEN" -o /dev/null' + "$CLI" sandbox exec --name "$sandbox_name" -- bash -c 'curl -sf https://gmail.googleapis.com/gmail/v1/users/me/profile -H "Authorization: Bearer $GOOGLE_WORKSPACE_CLI_TOKEN" -o /dev/null' echo "" }