diff --git a/.gitignore b/.gitignore index 620b4fd..8045a79 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,9 @@ __pycache__/ # Build artifacts harness +harness-openshell build/runner/harness +build/runner/openshell infracluster/ .claude/ diff --git a/AGENTS.md b/AGENTS.md index f63d77e..93fd9da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ Current workarounds and their upstream tracking: | Workaround | Why | Upstream | |------------|-----|----------| -| Custom gateway image | `google-vertex-ai` provider not in released builds yet | Will ship in upstream release | +| Custom sandbox image | Adds mcp-atlassian and GWS CLI to community base | Upstreaming MCP integrations | | `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1` | Vertex AI rejects `context_management` beta header | Anthropic/Google to align APIs | | Atlassian `JIRA_URL`/`JIRA_USERNAME` as `--config` material | Provider v2 config keys not injected as env vars yet | OpenShell roadmap | | In-cluster runner Job | OpenShell doesn't have a native K8s-triggered sandbox creation | Potential future CRD | @@ -79,7 +79,7 @@ Previously worked around, now resolved: | Was | Resolution | |-----|-----------| -| GWS credential export/upload to sandbox | GWS is now a native provider (`google-workspace`). Gateway manages OAuth refresh via `oauth2_refresh_token` strategy + `request_body_credential_rewrite` on `oauth2.googleapis.com`. Sandbox gets a proxy-resolved placeholder. | +| GWS credential export/upload to sandbox | GWS is now a native provider (`google-workspace`). Gateway manages OAuth refresh via `oauth2-refresh-token` strategy + `request_body_credential_rewrite` on `oauth2.googleapis.com`. Sandbox gets a proxy-resolved placeholder. | ## Validation @@ -96,7 +96,7 @@ registration, credential injection, and the GWS OAuth token lifecycle. Runs in GitHub Actions on every PR. ``` ---ci flag = --no-providers --agent=ci --full +--ci flag = --no-providers --agent=ci ``` ### Make targets diff --git a/README.md b/README.md index 1a3bb9e..e2c9328 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Automates gateway deployment, provider registration, and sandbox creation across ```bash # macOS -brew install openshell && brew services start openshell +brew tap nvidia/openshell && brew install openshell && brew services start openshell # Download the harness binary (macOS ARM64 shown -- see Releases for other platforms) curl -L https://github.com/robbycochran/harness-openshell/releases/latest/download/harness_darwin_arm64 -o harness @@ -43,7 +43,7 @@ An agent config declares the sandbox image, entrypoint, and which providers to a # agents/default.yaml name: agent image: ghcr.io/robbycochran/harness-openshell:sandbox -entrypoint: claude --bare +entrypoint: claude tty: true providers: @@ -71,7 +71,7 @@ For non-interactive task agents, set `task:` and `tty: false`: ```yaml # agents/demo.yaml name: demo -entrypoint: claude --bare -p +entrypoint: claude -p task: demo/DEMO-TASK.md tty: false # ... same providers and env @@ -121,7 +121,7 @@ harness CLI ──→ openshell CLI ──→ Gateway (Podman or K8s) ├── L7 network policy ├── inference.local proxy └── Sandbox container - ├── claude --bare + ├── claude ├── gh, mcp-atlassian, gws └── placeholder tokens ``` diff --git a/TODO.md b/TODO.md index cf58479..ee980eb 100644 --- a/TODO.md +++ b/TODO.md @@ -16,10 +16,10 @@ - Consider: gateway.toml uses a `registry` field and images are relative to it ### Consolidate internal/profile into internal/agent -- `internal/profile/` has dead code: `Parse()`, `ParseFile()`, `BuildSandboxEnv()` -- Only `Config` struct, `ValidateProviders`, and `StageHarnessDir` still used -- Move `ValidateProviders` to agent or gateway package, inline `Config` into sandbox.go -- Remove TOML dependency +- ✅ Removed dead code: `Parse()`, `ParseFile()`, `BuildSandboxEnv()` +- Only `Config` struct, `ValidateProviders`, and `StageHarnessDir` remain +- TODO: Move `ValidateProviders` to agent or gateway package, inline `Config` into sandbox.go +- TODO: Remove TOML dependency (github.com/BurntSushi/toml) if no longer used ## Agent Config diff --git a/TODOS.md b/TODOS.md index 32a32cd..5377c87 100644 --- a/TODOS.md +++ b/TODOS.md @@ -1,5 +1,13 @@ # TODOs +## remove providers.tooml, add a todo for provider profile validation in thefture +## convert toml to yaml for gateways +## specify the yaml formats + +## for flows that supports agent.yaml (create and up..) should also support --provider-profile and config + +## document that we need a way to specify non secret env vars in providers to capture like secrets, thats what provider config captures + ## registerProviders should filter by agent's provider list **What:** `registerProviders()` in `cmd/providers.go` uses the gateway config's provider diff --git a/agents/providers/profiles/atlassian.yaml b/agents/providers/profiles/atlassian.yaml index 17de766..8575624 100644 --- a/agents/providers/profiles/atlassian.yaml +++ b/agents/providers/profiles/atlassian.yaml @@ -2,7 +2,7 @@ # # Only JIRA_API_TOKEN is a provider credential (proxy resolves it in # Basic auth headers). JIRA_URL and JIRA_USERNAME are non-secret config -# uploaded to the sandbox by sandbox-ocp.sh. +# passed via agent.yaml config field. id: atlassian display_name: Atlassian (Jira + Confluence) diff --git a/cmd/create.go b/cmd/create.go index e8a51c3..bd6d306 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "time" @@ -52,12 +53,7 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { name = sandboxName } - sandboxImage := agentCfg.Image - if sandboxImage == "" { - sandboxImage = defaultSandboxImage() - } else if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { - sandboxImage = envImage - } + sandboxImage := resolveSandboxImage(agentCfg.Image) status.Header("Agent") status.Infof("Name: %s", name) @@ -86,7 +82,7 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { status.Header("Preflight") preflightOK := true for _, p := range allProviders { - if !providerInList(p.Name, missing) { + if !slices.Contains(missing, p.Name) { continue } ok, details := preflight.CheckProvider(p) @@ -222,11 +218,3 @@ func createViaRunner(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway return upRemote(harnessDir, gwCfg, gw, agentPath, sandboxName) } -func providerInList(name string, providers []string) bool { - for _, p := range providers { - if p == name { - return true - } - } - return false -} diff --git a/cmd/create_test.go b/cmd/create_test.go index 3915f95..e446664 100644 --- a/cmd/create_test.go +++ b/cmd/create_test.go @@ -56,14 +56,3 @@ func TestProfileHasCustomProviders_WithCustom(t *testing.T) { } } -func TestProviderInList(t *testing.T) { - if !providerInList("github", []string{"github", "vertex-local"}) { - t.Error("github should be in list") - } - if providerInList("atlassian", []string{"github", "vertex-local"}) { - t.Error("atlassian should not be in list") - } - if providerInList("github", nil) { - t.Error("nil list should return false") - } -} diff --git a/cmd/launch.go b/cmd/launch.go index ced117f..1624c4b 100644 --- a/cmd/launch.go +++ b/cmd/launch.go @@ -43,11 +43,7 @@ func runLaunch(cli string) error { return err } - if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { - agentCfg.Image = envImage - } else if agentCfg.Image == "" { - agentCfg.Image = defaultSandboxImage() - } + agentCfg.Image = resolveSandboxImage(agentCfg.Image) fmt.Println("=== Sandbox Runner ===") fmt.Printf(" Name: %s\n", agentCfg.Name) diff --git a/cmd/providers.go b/cmd/providers.go index fa5643f..b5b45b4 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -283,7 +283,7 @@ func registerGWS(harnessDir string, gw gateway.Gateway, enabled func(string) boo // gwsProfileScopes reads the refresh.scopes list from agents/providers/profiles/gws.yaml // and returns them as a space-separated string for use as OAuth scope material. func gwsProfileScopes(harnessDir string) string { - profilePath := filepath.Join(harnessDir, "sandbox", "profiles", "gws.yaml") + profilePath := filepath.Join(harnessDir, "agents", "providers", "profiles", "gws.yaml") data, err := os.ReadFile(profilePath) if err != nil { return "" diff --git a/cmd/sandbox.go b/cmd/sandbox.go index bfd53d0..ec5a30a 100644 --- a/cmd/sandbox.go +++ b/cmd/sandbox.go @@ -58,7 +58,7 @@ func createSandbox(opts sandboxOpts) error { return fmt.Errorf("staging payload: %w", err) } } else { - if err := profile.StageHarnessDir(cfg, uploadDir); err != nil { + if err := profile.StageHarnessDir(uploadDir); err != nil { return fmt.Errorf("staging files: %w", err) } } diff --git a/cmd/sandbox_image.go b/cmd/sandbox_image.go new file mode 100644 index 0000000..bb11085 --- /dev/null +++ b/cmd/sandbox_image.go @@ -0,0 +1,15 @@ +package cmd + +import "os" + +// resolveSandboxImage returns the final sandbox image path following +// the precedence: SANDBOX_IMAGE env var > agentImage > defaultSandboxImage(). +func resolveSandboxImage(agentImage string) string { + if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { + return envImage + } + if agentImage != "" { + return agentImage + } + return defaultSandboxImage() +} diff --git a/cmd/up.go b/cmd/up.go index 2e22353..a98913d 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -133,12 +133,7 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa name = sandboxName } - sandboxImage := agentCfg.Image - if sandboxImage == "" { - sandboxImage = defaultSandboxImage() - } else if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { - sandboxImage = envImage - } + sandboxImage := resolveSandboxImage(agentCfg.Image) // Top-level context status.Infof("Agent: %s (%s)", name, filepath.Base(agentPath)) @@ -296,12 +291,7 @@ func upLocal(opts upLocalOpts) error { } noTTY := opts.noTTY || agentCfg.NoTTY() - sandboxImage := agentCfg.Image - if sandboxImage == "" { - sandboxImage = defaultSandboxImage() - } else if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { - sandboxImage = envImage - } + sandboxImage := resolveSandboxImage(agentCfg.Image) // Top-level context status.Infof("Agent: %s (%s)", sandboxName, filepath.Base(opts.agentPath)) diff --git a/docs/archive/README.md b/docs/archive/README.md new file mode 100644 index 0000000..827003b --- /dev/null +++ b/docs/archive/README.md @@ -0,0 +1,14 @@ +# Archived Documentation + +This directory contains historical design documents that are no longer current but may have valuable context. + +## design-v1-2026-06-08.md + +Original design proposal from early development. **Outdated** — uses old naming (`harness.toml` vs `openshell.toml`, `profiles/*.toml` vs `agents/*.yaml`). Current implementation diverged from this design on 2026-06-08. + +**Still valuable sections:** +- Provider health two-level model (lines 208-244) +- OAuth-proxy auth roadmap for OCP (lines 435-476) +- OCP vs local k8s comparison (lines 397-421) + +For current architecture, see: README.md, SPEC.md, and AGENTS.md in the repo root. diff --git a/docs/design.md b/docs/archive/design-v1-2026-06-08.md similarity index 100% rename from docs/design.md rename to docs/archive/design-v1-2026-06-08.md diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 04f8a67..34655d9 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -1,13 +1,7 @@ package profile import ( - "fmt" "os" - "path/filepath" - "sort" - "strings" - - "github.com/BurntSushi/toml" ) // ProviderChecker checks if a provider is registered. @@ -32,42 +26,6 @@ func (c *Config) KeepSandbox() bool { return *c.Keep } -func (c *Config) BuildSandboxEnv() string { - if len(c.Env) == 0 { - return "" - } - keys := make([]string, 0, len(c.Env)) - for k := range c.Env { - keys = append(keys, k) - } - sort.Strings(keys) - var b strings.Builder - for _, k := range keys { - fmt.Fprintf(&b, "export %s=%q\n", k, c.Env[k]) - } - return b.String() -} - -// Parse reads a profile TOML file and returns a Config with defaults applied. -func Parse(harnessDir, name string) (*Config, error) { - path := filepath.Join(harnessDir, "profiles", name+".toml") - return ParseFile(path) -} - -// ParseFile reads a profile TOML file by path. -func ParseFile(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" - } - return &cfg, nil -} // ValidateProviders checks which profile providers are registered on the // gateway. Returns the list of registered providers and a list of missing ones. @@ -82,20 +40,7 @@ func ValidateProviders(providers []string, gw ProviderChecker) (registered, miss return } -// StageHarnessDir writes sandbox.env to harnessDir. -func StageHarnessDir(cfg *Config, harnessDir string) error { - if err := os.MkdirAll(harnessDir, 0o755); err != nil { - return err - } - - envContent := cfg.BuildSandboxEnv() - if envContent != "" { - if err := os.WriteFile(filepath.Join(harnessDir, "sandbox.env"), []byte(envContent), 0o600); err != nil { - return fmt.Errorf("writing sandbox.env: %w", err) - } - lines := strings.Count(envContent, "\n") - fmt.Printf(" Env: %d vars staged\n", lines) - } - - return nil +// StageHarnessDir creates the harness directory. +func StageHarnessDir(harnessDir string) error { + return os.MkdirAll(harnessDir, 0o755) } diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go index 8c4c380..6f2810c 100644 --- a/internal/profile/profile_test.go +++ b/internal/profile/profile_test.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "testing" ) @@ -20,113 +19,18 @@ func (m *mockProviderChecker) ProviderGet(name string) error { return fmt.Errorf("not found") } -func TestParseFile_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 := ParseFile(path) - if err != nil { - t.Fatalf("ParseFile: %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", cfg.From) - } - if cfg.Command != "claude --bare --model opus" { - t.Errorf("Command = %q", cfg.Command) - } - if cfg.KeepSandbox() { - t.Error("KeepSandbox() = true, want false") - } - if len(cfg.Providers) != 2 || cfg.Providers[0] != "github" { - t.Errorf("Providers = %v", cfg.Providers) - } - if cfg.Env["JIRA_URL"] != "https://example.atlassian.net" { - t.Errorf("Env[JIRA_URL] = %q", cfg.Env["JIRA_URL"]) - } -} - -func TestParseFile_Defaults(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.toml") - os.WriteFile(path, []byte(`image = "quay.io/test:latest"`), 0o644) - - cfg, err := ParseFile(path) - if err != nil { - t.Fatalf("ParseFile: %v", err) - } - if cfg.Name != "agent" { - t.Errorf("Name = %q, want default 'agent'", cfg.Name) - } - if cfg.Command != "claude" { - t.Errorf("Command = %q, want default", cfg.Command) - } +func TestKeepSandbox_Default(t *testing.T) { + cfg := &Config{} if !cfg.KeepSandbox() { t.Error("KeepSandbox() = false, want true (default)") } } -func TestParseFile_Missing(t *testing.T) { - _, err := ParseFile("/nonexistent.toml") - if err == nil { - t.Error("expected error for missing file") - } -} - -func TestParse_ByName(t *testing.T) { - dir := t.TempDir() - os.MkdirAll(filepath.Join(dir, "profiles"), 0o755) - os.WriteFile(filepath.Join(dir, "profiles", "test.toml"), []byte(` -name = "test-agent" -image = "test:latest" -`), 0o644) - - cfg, err := Parse(dir, "test") - if err != nil { - t.Fatalf("Parse: %v", err) - } - if cfg.Name != "test-agent" { - t.Errorf("Name = %q", cfg.Name) - } -} - -func TestBuildSandboxEnv(t *testing.T) { - cfg := &Config{ - Env: map[string]string{ - "ZEBRA": "z", - "APPLE": "a", - }, - } - env := cfg.BuildSandboxEnv() - lines := strings.Split(strings.TrimSpace(env), "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %d: %q", len(lines), env) - } - if lines[0] != `export APPLE="a"` { - t.Errorf("first line = %q (should be sorted and quoted)", lines[0]) - } - if lines[1] != `export ZEBRA="z"` { - t.Errorf("second line = %q", lines[1]) - } -} - -func TestBuildSandboxEnv_Empty(t *testing.T) { - cfg := &Config{} - if env := cfg.BuildSandboxEnv(); env != "" { - t.Errorf("expected empty, got %q", env) +func TestKeepSandbox_Explicit(t *testing.T) { + keepFalse := false + cfg := &Config{Keep: &keepFalse} + if cfg.KeepSandbox() { + t.Error("KeepSandbox() = true, want false") } } @@ -134,19 +38,16 @@ func TestStageHarnessDir(t *testing.T) { dir := t.TempDir() harnessDir := filepath.Join(dir, "harness") - cfg := &Config{ - Env: map[string]string{"FOO": "bar"}, - } - if err := StageHarnessDir(cfg, harnessDir); err != nil { + if err := StageHarnessDir(harnessDir); err != nil { t.Fatalf("StageHarnessDir: %v", err) } - data, err := os.ReadFile(filepath.Join(harnessDir, "sandbox.env")) + stat, err := os.Stat(harnessDir) if err != nil { - t.Fatalf("reading sandbox.env: %v", err) + t.Fatalf("stat harness dir: %v", err) } - if !strings.Contains(string(data), `export FOO="bar"`) { - t.Errorf("sandbox.env = %q", string(data)) + if !stat.IsDir() { + t.Error("expected directory to be created") } } diff --git a/sandbox/policy.yaml b/sandbox/policy.yaml index fb50af6..7a01663 100644 --- a/sandbox/policy.yaml +++ b/sandbox/policy.yaml @@ -84,7 +84,7 @@ network_policies: - { path: /usr/bin/git } - { path: /usr/local/bin/uv } - # GWS (no provider profile active — creds are uploaded files) + # GWS (google-workspace provider with OAuth refresh via gateway) google_workspace: name: google-workspace endpoints: