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..499ff0a 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,15 @@ 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: Install openshell CLI - run: | - curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh - cp "$(which openshell)" sandbox/launcher/openshell + - name: Build harness binary + run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/runner/harness . - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: @@ -78,17 +84,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/.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 a5d4a55..024fc62 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) @@ -107,23 +105,24 @@ 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. ## 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 +134,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 build/runner/openshell @echo "Cleaned binaries" ## Show available targets diff --git a/README.md b/README.md index 056c513..da80be4 100644 --- a/README.md +++ b/README.md @@ -1,253 +1,153 @@ # 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. +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. -## What is OpenShell? +## Where This Fits -[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). +[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. -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. +[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. -### Why deny by default matters for AI agents +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. -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. +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)). -Deny-by-default addresses this by making every capability an explicit grant: +## Example -- **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. +An agent config declares the sandbox image, entrypoint, and which providers to attach: -- **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. +```yaml +# agents/default.yaml +name: agent +image: ghcr.io/robbycochran/harness-openshell:sandbox +entrypoint: claude --bare +tty: true -- **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. +providers: + - profile: github + - profile: vertex-local + - profile: atlassian + config: + JIRA_URL: ${JIRA_URL} + JIRA_USERNAME: ${JIRA_USERNAME} + - profile: gws -- **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. +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 +``` + +See the [OpenShell docs](https://github.com/NVIDIA/OpenShell) for the full security model (L7 policy, Landlock, proxy credential resolution). + +## Config Files -Podman is required for local sandboxes. kubectl + helm are required for remote. +| 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/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/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/build/runner/Dockerfile b/build/runner/Dockerfile new file mode 100644 index 0000000..581dcf4 --- /dev/null +++ b/build/runner/Dockerfile @@ -0,0 +1,13 @@ +FROM registry.access.redhat.com/ubi9/ubi-minimal:latest + +RUN microdnf install -y openssh-clients tar gzip --nodocs && microdnf clean all + +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 + +USER 1000 + +ENTRYPOINT ["harness", "launch"] diff --git a/cmd/create.go b/cmd/create.go index 8f309bd..cf13652 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,8 @@ import ( func NewCreateCmd(harnessDir, cli string) *cobra.Command { var ( - profileName string + agentName string + agentFile string sandboxName string ) @@ -29,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. @@ -40,35 +44,34 @@ 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) + 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,67 @@ 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) + } + + // 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 } - return createDirect(harnessDir, gw, profileName, cfg, registered) + + 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", "-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() + 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().StringVarP(&agentFile, "file", "f", "", "Path to agent YAML file (overrides --agent)") + 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 +175,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 +190,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 +214,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, 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, 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, agentPath, 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/cmd/launch.go b/cmd/launch.go new file mode 100644 index 0000000..d402fd5 --- /dev/null +++ b/cmd/launch.go @@ -0,0 +1,211 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/robbycochran/harness-openshell/internal/agent" + "github.com/spf13/cobra" +) + +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 runLaunch(cli string) error { + endpoint := os.Getenv("GATEWAY_ENDPOINT") + if endpoint == "" { + endpoint = "https://openshell.openshell.svc.cluster.local:8080" + } + 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) + + payloadDir := "/tmp/openshell-staging/openshell" + 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, payloadDir, cli); err != nil { + return err + } + + 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 { + requiredCerts := []string{"ca.crt", "tls.crt", "tls.key"} + var missing []string + for _, name := range requiredCerts { + if _, err := os.Stat(filepath.Join(mtlsDir, name)); err != nil { + missing = append(missing, name) + } + } + 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\n") + os.Setenv("OPENSHELL_GATEWAY_ENDPOINT", endpoint) + os.Setenv("OPENSHELL_GATEWAY_INSECURE", "true") + return nil + } + fmt.Println(" mTLS certs found") + + httpEndpoint := strings.Replace(endpoint, "https:", "http:", 1) + + cmd := exec.Command(cli, "gateway", "add", httpEndpoint, "--name", "openshell") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stdout + if err := cmd.Run(); err != nil { + return fmt.Errorf("gateway add: %w", err) + } + + home := os.Getenv("HOME") + gwDir := filepath.Join(home, ".config", "openshell", "gateways", "openshell") + mtlsDest := filepath.Join(gwDir, "mtls") + if err := os.MkdirAll(mtlsDest, 0o700); err != nil { + return fmt.Errorf("creating mtls dir: %w", err) + } + + metaPath := filepath.Join(gwDir, "metadata.json") + var meta map[string]any + if data, err := os.ReadFile(metaPath); err == nil { + if err := json.Unmarshal(data, &meta); err != nil { + return fmt.Errorf("parsing metadata.json: %w", err) + } + } + if meta == nil { + meta = make(map[string]any) + } + meta["gateway_endpoint"] = endpoint + meta["auth_mode"] = "mtls" + out, err := json.Marshal(meta) + if err != nil { + return fmt.Errorf("marshaling metadata.json: %w", err) + } + if err := os.WriteFile(metaPath, out, 0o644); err != nil { + return fmt.Errorf("writing metadata.json: %w", err) + } + + for _, name := range []string{"ca.crt", "tls.crt", "tls.key"} { + if err := copyFile(filepath.Join(mtlsDir, name), filepath.Join(mtlsDest, name)); err != nil { + return fmt.Errorf("copying %s: %w", name, err) + } + } + + selectCmd := exec.Command(cli, "gateway", "select", "openshell") + selectCmd.Stdout = os.Stdout + selectCmd.Stderr = io.Discard + selectCmd.Run() + fmt.Println(" mTLS gateway configured") + return nil +} + +func checkProviders(providers []string, cli string) []string { + var registered []string + for _, name := range providers { + cmd := exec.Command(cli, "provider", "get", name) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + if cmd.Run() == nil { + registered = append(registered, name) + fmt.Printf(" Provider %s: attached\n", name) + } else { + fmt.Printf(" Provider %s: not registered (skipping)\n", name) + } + } + return registered +} + +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 != "" { + args = append(args, "--from", cfg.Image) + } + for _, p := range providers { + args = append(args, "--provider", p) + } + args = append(args, "--upload", payloadDir+":/sandbox/.config", "--no-git-ignore") + args = append(args, "--", "bash", "-c", envInit) + + cmd := exec.Command(cli, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if cmd.Run() == nil { + return nil + } + + 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 + del.Run() + + if attempt == 5 { + return fmt.Errorf("failed after 5 attempts") + } + time.Sleep(10 * time.Second) + } + return nil +} + + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + if _, err = io.Copy(out, in); err != nil { + out.Close() + return err + } + return out.Close() +} 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/sandbox.go b/cmd/sandbox.go index 5b98da8..aa7a7e8 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) } @@ -42,15 +43,24 @@ func createSandbox(opts sandboxOpts) error { } } - // Stage files for upload into the sandbox. + // 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) - harnessUploadDir := filepath.Join(tmpParent, "openshell") - if err := profile.StageHarnessDir(cfg, harnessUploadDir); err != nil { - return fmt.Errorf("staging files: %w", err) + uploadDir := filepath.Join(tmpParent, "openshell") + + if opts.payloadDir != "" { + if err := os.Rename(opts.payloadDir, uploadDir); err != nil { + return fmt.Errorf("staging payload: %w", err) + } + } else { + if err := profile.StageHarnessDir(cfg, uploadDir); err != nil { + return fmt.Errorf("staging files: %w", err) + } } // Create sandbox with retry loop (up to 5 attempts). @@ -61,7 +71,7 @@ func createSandbox(opts sandboxOpts) error { Providers: opts.providers, TTY: !opts.noTTY, Keep: cfg.KeepSandbox(), - UploadSrc: harnessUploadDir, + UploadSrc: uploadDir, UploadDst: "/sandbox/.config", Command: opts.sandboxCmd, }) diff --git a/cmd/up.go b/cmd/up.go index 6cadc0c..7c9d83d 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,8 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { var ( local bool remote bool - profileName string + agentName string + agentFile string sandboxName string noTTY bool ) @@ -28,7 +30,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") @@ -37,25 +39,26 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { sandboxName = args[0] } + agentPath := resolveAgentPath(harnessDir, agentName, agentFile) + 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, agentPath, sandboxName) } return upLocal(upLocalOpts{ harnessDir: harnessDir, gw: gw, gwCfg: gwCfg, ensureLocal: local, - profileName: profileName, + agentPath: agentPath, sandboxName: sandboxName, noTTY: noTTY, retrySleep: 5 * time.Second, @@ -65,25 +68,33 @@ 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().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 - profileName string + agentPath 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, agentPath, sandboxName string) error { ctx := context.Background() namespace := k8s.DefaultNamespace() kc := k8s.New("", namespace) @@ -100,50 +111,37 @@ 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) - } - } - - // Parse profile - cfg, err := profile.Parse(harnessDir, profileName) + // 2. Parse agent config + 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). - 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") + // 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) + } } } - profilePath := filepath.Join(harnessDir, "profiles", profileName+".toml") + // Resolve sandbox image + sandboxImage := agentCfg.Image + if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { + sandboxImage = envImage + } - // 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 +153,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 +178,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) + 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}") 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 +238,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 { @@ -281,105 +262,95 @@ func upLocal(opts upLocalOpts) error { } } - // 2. Ensure providers - providers, err := gw.ProviderList() + // 2. Parse agent config + agentCfg, err := agent.ParseFile(opts.agentPath) if err != nil { - return fmt.Errorf("listing providers: %w", err) + return 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. 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) } } - // 3. Parse profile - cfg, err := profile.Parse(opts.harnessDir, opts.profileName) + // 4. Render payload + payloadDir, err := os.MkdirTemp("", "harness-payload-") if err != nil { - return err + 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) - - // 4. Validate providers against profile - status.Section("Providers") - registered, missing := profile.ValidateProviders(cfg.Providers, gw) - for _, name := range registered { - status.OKf("%s: attached", name) - } - for _, name := range missing { - status.Failf("%s: not registered (skipping)", name) - } - if len(missing) > 0 && len(registered) == 0 { - fmt.Println() - status.Warn("no providers available — run: harness providers") + fmt.Printf(" Agent: %s\n", opts.agentPath) + fmt.Printf(" Image: %s\n", cfg.From) + if agentCfg.Task != "" { + fmt.Printf(" Task: %s\n", agentCfg.Task) } - // 5. Build command + // 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 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)} - } + if noTTY { + sandboxCmd = []string{"bash", "-c", envInit + "true"} } else { - if opts.noTTY { - sandboxCmd = []string{"true"} - } else { - sandboxCmd = []string{"bash", "-c", fmt.Sprintf("exec %s", cfg.Command)} - } + sandboxCmd = []string{"bash", "-c", envInit + "exec bash /sandbox/.config/openshell/run.sh"} } - // 6. Create sandbox - fmt.Println() - fmt.Println("=== Creating sandbox ===") 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..89da377 100644 --- a/cmd/up_test.go +++ b/cmd/up_test.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "os" "path/filepath" "strings" "testing" @@ -12,14 +11,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, + agentPath: filepath.Join(dir, "agents", "default.yaml"), + noTTY: true, }) if err == nil { t.Fatal("expected error") @@ -30,18 +29,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, + agentPath: filepath.Join(dir, "agents", "default.yaml"), + noTTY: true, }) if err != nil { t.Fatalf("upLocal: %v", err) @@ -49,18 +47,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, + agentPath: filepath.Join(dir, "agents", "default.yaml"), + noTTY: true, }) if err != nil { t.Fatalf("upLocal: %v", err) @@ -75,18 +72,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, + agentPath: filepath.Join(dir, "agents", "default.yaml"), + noTTY: true, }) if err != nil { t.Fatalf("upLocal: %v", err) @@ -97,24 +93,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, + agentPath: filepath.Join(dir, "agents", "nonexistent.yaml"), + 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 +117,11 @@ func TestUpLocal_SandboxCreateRetry(t *testing.T) { } err := upLocal(upLocalOpts{ - harnessDir: dir, - gw: gw, - profileName: "default", - noTTY: true, - - retrySleep: 0, + harnessDir: dir, + gw: gw, + agentPath: filepath.Join(dir, "agents", "default.yaml"), + noTTY: true, + retrySleep: 0, }) if err != nil { t.Fatalf("upLocal: %v", err) @@ -190,8 +184,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 +194,9 @@ func TestUpLocal_SandboxCreateOpts(t *testing.T) { err := upLocal(upLocalOpts{ harnessDir: dir, gw: gw, - profileName: "default", + agentPath: filepath.Join(dir, "agents", "default.yaml"), sandboxName: "custom-name", noTTY: true, - }) if err != nil { t.Fatalf("upLocal: %v", err) @@ -213,12 +206,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..47c8f86 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() } @@ -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() 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..0f183e0 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), @@ -40,6 +42,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,13 +65,11 @@ 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) } } - fmt.Fprintf(os.Stderr, "ERROR: could not detect harness directory — set HARNESS_DIR or run from the project root\n") - os.Exit(1) return "" } 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.go b/sandbox/launcher/main.go deleted file mode 100644 index 561814f..0000000 --- a/sandbox/launcher/main.go +++ /dev/null @@ -1,291 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "github.com/BurntSushi/toml" -) - -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 (c *Config) KeepSandbox() bool { - if c.Keep == nil { - return true - } - return *c.Keep -} - -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) - } - if cfg.Name == "" { - cfg.Name = "agent" - } - if cfg.Command == "" { - cfg.Command = "claude --bare" - } - return &cfg, nil -} - -func configureGateway(endpoint, mtlsDir, cli string) error { - requiredCerts := []string{"ca.crt", "tls.crt", "tls.key"} - var missing []string - for _, name := range requiredCerts { - if _, err := os.Stat(filepath.Join(mtlsDir, name)); err != nil { - missing = append(missing, name) - } - } - 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") - os.Setenv("OPENSHELL_GATEWAY_ENDPOINT", endpoint) - os.Setenv("OPENSHELL_GATEWAY_INSECURE", "true") - return nil - } - fmt.Println(" ✓ mTLS certs found — using encrypted connection") - - 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 - if err := cmd.Run(); err != nil { - return fmt.Errorf("gateway add: %w", err) - } - - home := os.Getenv("HOME") - gwDir := filepath.Join(home, ".config", "openshell", "gateways", "openshell") - mtlsDest := filepath.Join(gwDir, "mtls") - if err := os.MkdirAll(mtlsDest, 0o700); err != nil { - 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 { - if err := json.Unmarshal(data, &meta); err != nil { - return fmt.Errorf("parsing metadata.json: %w", err) - } - } - if meta == nil { - meta = make(map[string]any) - } - meta["gateway_endpoint"] = endpoint - meta["auth_mode"] = "mtls" - out, err := json.Marshal(meta) - if err != nil { - return fmt.Errorf("marshaling metadata.json: %w", err) - } - if err := os.WriteFile(metaPath, out, 0o644); err != nil { - return fmt.Errorf("writing metadata.json: %w", err) - } - - for _, name := range []string{"ca.crt", "tls.crt", "tls.key"} { - if err := copyFile(filepath.Join(mtlsDir, name), filepath.Join(mtlsDest, name)); err != nil { - return fmt.Errorf("copying %s: %w", name, err) - } - } - - run(cli, "gateway", "select", "openshell") - fmt.Println(" ✓ mTLS gateway configured") - return nil -} - -func checkProviders(providers []string, cli string) []string { - var registered []string - for _, name := range providers { - cmd := exec.Command(cli, "provider", "get", name) - cmd.Stdout = io.Discard - cmd.Stderr = io.Discard - if cmd.Run() == nil { - registered = append(registered, name) - fmt.Printf(" Provider %s: attached\n", name) - } else { - fmt.Printf(" Provider %s: not registered (skipping)\n", name) - } - } - 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 { - 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) - } - 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...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if cmd.Run() == nil { - return nil - } - - fmt.Printf(" Attempt %d failed (supervisor race), retrying in 10s...\n", attempt) - del := exec.Command(cli, "sandbox", "delete", cfg.Name) - del.Stdout = io.Discard - del.Stderr = io.Discard - del.Run() - - if attempt == 5 { - return fmt.Errorf("failed after 5 attempts") - } - time.Sleep(10 * time.Second) - } - 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") - 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") - 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 { - return err - } - defer in.Close() - out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) - if err != nil { - return err - } - if _, err = io.Copy(out, in); err != nil { - out.Close() - return err - } - 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/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/kind-lifecycle.sh b/test/kind-lifecycle.sh index 791d6c7..0b5c26d 100755 --- a/test/kind-lifecycle.sh +++ b/test/kind-lifecycle.sh @@ -29,6 +29,8 @@ 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" @@ -67,9 +69,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 +87,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 diff --git a/test/test-flow.sh b/test/test-flow.sh index 52b28b2..ca47a4d 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 @@ -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 @@ -168,7 +169,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' } @@ -190,7 +191,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 +226,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 +299,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 +349,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"