Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 71 additions & 61 deletions README.md

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ Each sandbox is an isolated container with a Claude Code agent, credential provi

The harness exposes a single entry point (`harness`) with subcommands.

### `harness new [--local|--remote] [--profile NAME] [--name SANDBOX_NAME] [--no-tty]`
### `harness up [--local|--remote] [--profile NAME] [--name SANDBOX_NAME] [--no-tty]`

Create a new sandbox. This is the primary command. It performs these steps in order:
Full flow: deploy gateway, register providers, and create a sandbox. This is the primary command. It performs these steps in order:

1. **Ensure gateway** — if `--local`, verify the local podman gateway is running. If `--remote`, deploy to OpenShift (Helm chart, CRDs, SCCs, route, mTLS certs). If neither, check for an active gateway.
2. **Ensure providers** — if no providers are registered on the gateway, run provider registration.
Expand All @@ -31,6 +31,12 @@ If `--no-tty` is passed, the sandbox runs `startup.sh` and exits (for testing).

If `--name` is not provided, the sandbox name comes from the profile's `name` field.

### `harness create [NAME] [--profile NAME] [--no-tty]`

Create a sandbox without deploying the gateway or registering providers. Errors if the gateway is not already running. Use this when the gateway and providers are already set up (e.g., after a previous `harness up` or `harness deploy` + `harness providers`).

Accepts a positional `NAME` argument or `--name` flag. Otherwise behaves like the sandbox creation step of `harness up`.

### `harness connect [SANDBOX_NAME]`

Reconnect to a running sandbox via `openshell sandbox connect`.
Expand Down Expand Up @@ -274,7 +280,7 @@ The launcher connects to the gateway at `https://openshell.openshell.svc.cluster

- `internal/gateway/cli_test.go` — stub-based tests for CLI output parsing and argument building
- `internal/profile/profile_test.go` — TOML parsing, env generation, provider validation with mock gateway
- `cmd/new_test.go` — orchestration tests: no gateway, missing providers, retry logic, create opts
- `cmd/new_test.go` — orchestration tests for `up` and `create`: no gateway, missing providers, retry logic, create opts
- `sandbox/launcher/main_test.go` — launcher config parsing and file staging

### Integration Tests (`test/test-flow.sh`)
Expand Down
9 changes: 5 additions & 4 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

| Command | Go Status | Notes |
|---------|-----------|-------|
| `new --local` | Native | Profile parsing, provider validation, sandbox create with retry |
| `new --remote` | Native | K8s Job YAML via internal/k8s, prerequisite chain (deploy+providers+creds) |
| `up --local` | Native | Full flow: deploy + providers + sandbox create with retry |
| `up --remote` | Native | K8s Job YAML via internal/k8s, prerequisite chain (deploy+providers+creds) |
| `create` | Native | Sandbox creation only (no deploy/providers) |
| `connect` | Native | exec into `openshell sandbox connect` |
| `deploy --local` | Native | Podman check, gateway find/select/verify |
| `deploy --remote` | Native | Helm install, Route, mTLS, RBAC, SCCs via internal/k8s |
Expand All @@ -17,7 +18,7 @@
| `test` | Bash | test-flow.sh orchestration (intentionally stays bash) |
| **Launcher** | Native | In-cluster Go binary, UBI9 + openssh |

**Score: 11/12 paths native Go.** Only `test` stays bash (test orchestration, not a user command).
**Score: 12/13 paths native Go.** Only `test` stays bash (test orchestration, not a user command).

## Architecture Improvements

Expand Down Expand Up @@ -62,7 +63,7 @@ name, image, command, keep, providers, [env]
### Changes

- [ ] Add `description` field — one line of human-readable context per profile.
Makes multi-profile use cases (`harness new --profile research`) self-documenting.
Makes multi-profile use cases (`harness up --profile research`) self-documenting.
Both OpenShell and Kaiden include this.

- [ ] Split `[env]` by purpose — the current `[env]` map mixes inference config
Expand Down
271 changes: 271 additions & 0 deletions cmd/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
package cmd

import (
"fmt"
"os"
"path/filepath"
"strings"
"time"

"github.com/robbycochran/harness-openshell/internal/gateway"
"github.com/robbycochran/harness-openshell/internal/preflight"
"github.com/robbycochran/harness-openshell/internal/profile"
"github.com/robbycochran/harness-openshell/internal/status"
"github.com/spf13/cobra"
)

func NewCreateCmd(harnessDir, cli string) *cobra.Command {
var (
profileName string
sandboxName string
)

cmd := &cobra.Command{
Use: "create [flags]",
Short: "Create a sandbox without attaching",
Long: "Validate gateway readiness, run preflight checks, and deploy a sandbox. Does not attach interactively — use 'harness connect' afterward.",
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 && sandboxName == "" {
sandboxName = args[0]
}

gw := gateway.New(cli)

// 1. Check which gateway is active and whether it's local or remote.
activeGW, err := activeGatewayInfo(gw)
if err != nil {
return err
}
isLocal := strings.Contains(activeGW.Endpoint, "127.0.0.1")

status.Section("Gateway")
status.OKf("%s (%s)", activeGW.Name, activeGW.Endpoint)

// 2. Parse the profile
cfg, err := profile.Parse(harnessDir, profileName)
if err != nil {
return err
}
if sandboxName != "" {
cfg.Name = sandboxName
}

status.Section("Profile")
fmt.Printf(" Name: %s\n", cfg.Name)
fmt.Printf(" From: %s\n", cfg.From)

// 3. Validate providers are registered
status.Section("Providers")
registered, missing := profile.ValidateProviders(cfg.Providers, gw)
for _, name := range registered {
status.OKf("%s: attached", name)
}
for _, name := range missing {
status.Failf("%s: not registered", name)
}
if len(missing) > 0 && len(registered) == 0 {
return fmt.Errorf("no providers available — run: harness providers")
}

// 4. Run preflight checks for profile providers
status.Section("Preflight")
providersPath := filepath.Join(harnessDir, "providers.toml")
allProviders, err := preflight.LoadProviders(providersPath)
if err != nil {
status.Warn("could not load providers.toml — skipping preflight")
} else {
preflightOK := true
for _, p := range allProviders {
if !providerInList(p.Name, cfg.Providers) {
continue
}
ok, details := preflight.CheckProvider(p)
if ok {
status.OKf("%s: ready", p.Name)
} else {
status.Failf("%s: prerequisites missing", p.Name)
if p.Required {
preflightOK = false
}
}
for _, d := range details {
status.Detail(d)
}
}
if !preflightOK {
return fmt.Errorf("preflight checks failed — fix issues above")
}
}

// 5. Determine whether the launcher is needed.
// The launcher bridges cluster-side secrets (mTLS, GWS creds)
// into the sandbox. It's only needed when:
// - the gateway is remote (not local), AND
// - the profile references custom providers (type="custom" in providers.toml)
needsLauncher := false
if !isLocal {
needsLauncher = profileHasCustomProviders(cfg.Providers, allProviders)
}

// 6. Deploy the sandbox
status.Section("Creating sandbox")
if needsLauncher {
status.Info("Custom providers detected — using in-cluster launcher")
gwCfg := loadGatewayConfigForActive(harnessDir, activeGW)
return createViaLauncher(harnessDir, gwCfg, gw, profileName, cfg)
}
return createDirect(harnessDir, gw, profileName, cfg, registered)
},
}

cmd.Flags().StringVar(&profileName, "profile", "default", "Profile name (from profiles/)")
cmd.Flags().StringVar(&sandboxName, "name", "", "Sandbox name (overrides profile)")

return cmd
}

// activeGatewayInfo returns the currently selected gateway.
func activeGatewayInfo(gw gateway.Gateway) (*gateway.GatewayInfo, error) {
gateways, err := gw.GatewayList()
if err != nil {
return nil, fmt.Errorf("could not list gateways: %w — deploy one first: harness deploy", err)
}
for _, g := range gateways {
if g.Active {
return &g, nil
}
}
return nil, fmt.Errorf("no active gateway — deploy one first: harness deploy")
}

// profileHasCustomProviders checks whether any of the profile's requested
// providers are type="custom" in providers.toml. Custom providers require
// the in-cluster launcher to bridge secrets the workstation doesn't have.
func profileHasCustomProviders(profileProviders []string, allProviders []preflight.Provider) bool {
custom := make(map[string]bool)
for _, p := range allProviders {
if p.Type == "custom" {
custom[p.Name] = true
}
}
for _, name := range profileProviders {
if custom[name] {
return true
}
}
return false
}

// loadGatewayConfigForActive tries to find the gateway.toml that matches the
// active gateway. Falls back to scanning all gateway dirs if no name match.
func loadGatewayConfigForActive(harnessDir string, active *gateway.GatewayInfo) *gateway.GatewayConfig {
// Try exact name match first (e.g., active gateway named "ocp" → gateways/ocp/)
if active != nil && active.Name != "" {
dir := filepath.Join(harnessDir, "gateways", active.Name)
if cfg, err := gateway.LoadConfig(dir); err == nil {
return cfg
}
}
// Fall back to scanning all gateway dirs for a remote config
gwDir := filepath.Join(harnessDir, "gateways")
entries, err := os.ReadDir(gwDir)
if err != nil {
return nil
}
for _, e := range entries {
if !e.IsDir() {
continue
}
cfg, err := gateway.LoadConfig(filepath.Join(gwDir, e.Name()))
if err == nil && !cfg.IsLocal() {
return cfg
}
}
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 {
if gwCfg == nil {
return fmt.Errorf("no gateway config found for remote gateway — expected gateways/<name>/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 {
if cfg.From != "" && !filepath.IsAbs(cfg.From) {
candidate := filepath.Join(harnessDir, cfg.From)
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
cfg.From = candidate
}
}

providersPath := filepath.Join(harnessDir, "providers.toml")
if allProviders, err := preflight.LoadProviders(providersPath); err == nil {
providerEnv := preflight.ProviderEnvVars(allProviders, cfg.Providers)
if cfg.Env == nil {
cfg.Env = make(map[string]string)
}
for k, v := range providerEnv {
if _, exists := cfg.Env[k]; !exists {
cfg.Env[k] = v
}
}
}

tmpParent, err := os.MkdirTemp("", "harness-")
if err != nil {
return fmt.Errorf("creating staging dir: %w", err)
}
defer os.RemoveAll(tmpParent)
harnessUploadDir := filepath.Join(tmpParent, "openshell")
if err := profile.StageHarnessDir(cfg, harnessUploadDir); err != nil {
return fmt.Errorf("staging files: %w", err)
}

var sandboxCmd []string
if cfg.Startup != "" {
sandboxCmd = []string{"bash", "-c", fmt.Sprintf(". %s", cfg.Startup)}
} else {
sandboxCmd = []string{"true"}
}

for attempt := 1; attempt <= 5; attempt++ {
err := gw.SandboxCreate(gateway.SandboxCreateOpts{
Name: cfg.Name,
From: cfg.From,
Providers: registered,
TTY: false,
Keep: cfg.KeepSandbox(),
UploadSrc: harnessUploadDir,
UploadDst: "/sandbox/.config",
Command: sandboxCmd,
})
if err == nil {
fmt.Println()
status.OKf("Sandbox created: %s — connect with: harness connect %s", cfg.Name, cfg.Name)
return nil
}

fmt.Printf(" Attempt %d failed: %v, retrying in 5s...\n", attempt, err)
gw.SandboxDelete(cfg.Name)

if attempt == 5 {
return fmt.Errorf("sandbox create failed after 5 attempts: %w", err)
}
time.Sleep(5 * time.Second)
}
return nil
}

func providerInList(name string, providers []string) bool {
for _, p := range providers {
if p == name {
return true
}
}
return false
}
2 changes: 1 addition & 1 deletion cmd/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, gwCfg
}

fmt.Println()
status.Done("Done. Launch a sandbox with: harness new --local")
status.Done("Done. Launch a sandbox with: harness up --local")
return nil
}

Expand Down
Loading
Loading