From 5b599f2b3e9368b5232d254f3a868ff0604a45eb Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 12 Jun 2026 10:44:39 -0700 Subject: [PATCH 1/8] X-Smart-Branch-Parent: main From 8a18e6f184702a2018b6d0685bab0c13cd65b9bd Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 12 Jun 2026 12:25:45 -0700 Subject: [PATCH 2/8] refactor: remove wrapper commands, dead code, and output inconsistencies Remove connect and logs commands (pure openshell wrappers with zero value-add). Remove dead code from Gateway interface (InferenceModel, SandboxConnect, SandboxLogs), agent (BuildEnvSh), gateway config (HasProviders, AllProviders), k8s (RunKubectlPassthrough), and status (ShowEquivalentCmd, Detailf). Gateway interface shrinks from 28 to 24 methods. Add status.Warnf and standardize all output through the status package. Unify provider messages to "%s: registered" across commands. Extract ensureProviders helper to deduplicate validate-register-revalidate pattern. Relocate shared helpers to cmd/resolve.go. Fix docs: add missing --local/--no-tty flags to README, remove stale TOML references from TODO, add gateway field to SPEC, sync all docs to current CLI state. Delete stale docs/proto-migration.md. Net: -654 lines, +47 lines. --- README.md | 13 +- SPEC.md | 9 +- TODO.md | 13 +- cmd/connect.go | 22 --- cmd/create.go | 20 +-- cmd/helpers_test.go | 3 - cmd/logs.go | 27 ---- cmd/providers.go | 28 +++- cmd/resolve.go | 57 ++++++++ cmd/sandbox.go | 3 +- cmd/stop.go | 17 --- cmd/teardown.go | 4 +- cmd/up.go | 51 +------ docs/proto-migration.md | 231 -------------------------------- internal/agent/agent.go | 18 --- internal/agent/agent_test.go | 50 ------- internal/gateway/cli.go | 38 ------ internal/gateway/cli_test.go | 43 ------ internal/gateway/config.go | 11 -- internal/gateway/config_test.go | 41 ------ internal/gateway/gateway.go | 3 - internal/k8s/kubectl.go | 16 --- internal/k8s/mock.go | 5 - internal/status/status.go | 12 +- internal/status/status_test.go | 21 --- main.go | 2 - 26 files changed, 104 insertions(+), 654 deletions(-) delete mode 100644 cmd/connect.go delete mode 100644 cmd/logs.go create mode 100644 cmd/resolve.go delete mode 100644 docs/proto-migration.md diff --git a/README.md b/README.md index d92ef68..7d122f4 100644 --- a/README.md +++ b/README.md @@ -277,34 +277,33 @@ See the [OpenShell docs](https://github.com/NVIDIA/OpenShell) for the full secur ## Commands ``` -harness up [--remote] [--agent NAME] [-f FILE] [--name SANDBOX] [--provider-refresh] +harness up [--local|--remote] [--agent NAME] [-f FILE] [--name SANDBOX] [--no-tty] [--provider-refresh] Deploy gateway + register providers + create sandbox. Defaults to local gateway (use --remote for OCP). --agent defaults to "default" (embedded or agents/default.yaml). -f renders any agent YAML file directly. + --no-tty disables TTY allocation. --provider-refresh deletes and recreates all providers. harness create [--agent NAME] [-f FILE] [--name SANDBOX] Create a sandbox without deploying the gateway. Assumes gateway is running. Auto-registers missing providers. -harness connect [NAME] - Reconnect to a running sandbox. - harness deploy [local|ocp|kind] Deploy or verify the gateway for a target. harness status Show sandbox status. -harness logs [NAME] [-f] - Stream sandbox logs (-f to follow). - harness stop [NAME] / harness start [NAME] Stop or start a sandbox without deleting it. harness teardown [--sandboxes] [--providers] [--k8s] Tear down resources. At least one flag required. + +For sandbox connect/logs, use openshell directly: + openshell sandbox connect [NAME] + openshell sandbox logs [NAME] [--follow] ``` ## Documentation Map diff --git a/SPEC.md b/SPEC.md index 08d59db..d699fb2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -47,6 +47,7 @@ Fields: - `env` -- additional environment variables injected via `--env` on sandbox create (empty values read from host env) - `include` -- extra files to include in the payload - `policy` -- path to a network policy YAML +- `gateway` -- target gateway name (overrides active gateway) Provider profiles live in `agents/providers/profiles/`. These are imported to the gateway during provider registration. @@ -72,10 +73,6 @@ Full flow: deploy gateway, register providers, render agent config, create sandb Create a sandbox without deploying the gateway. Assumes gateway is running. Auto-registers missing providers. -### `harness connect [NAME]` - -Reconnect to a running sandbox via `openshell sandbox connect`. - ### `harness deploy [local|ocp|kind]` Deploy or verify the gateway for a target. Reads `gateways//gateway.yaml`. @@ -84,10 +81,6 @@ Deploy or verify the gateway for a target. Reads `gateways//gateway.yaml Show sandbox status. Read-only. -### `harness logs [NAME] [-f|--follow]` - -Stream logs for a sandbox. - ### `harness stop [NAME]` / `harness start [NAME]` Stop or start a sandbox without deleting it. diff --git a/TODO.md b/TODO.md index f70516a..28dc104 100644 --- a/TODO.md +++ b/TODO.md @@ -11,7 +11,7 @@ ### Image registry as gateway config vs env override - `SANDBOX_IMAGE` env var overrides the version-based image resolution (for dev/CI) -- Consider: gateway.toml uses a `registry` field and images are relative to it +- Consider: gateway.yaml uses a `registry` field and images are relative to it ### registerProviders should filter by agent's provider list - `registerProviders()` in `cmd/providers.go` uses the gateway config's provider @@ -26,10 +26,8 @@ ## Config Format -- [ ] Remove `providers.toml`; add provider profile validation in its place -- [ ] Convert gateway configs from TOML to YAML - [ ] Specify/document the YAML formats (agent config, provider profiles) -- [ ] Document non-secret provider env vars (what `providers[].config` captures +- [ ] Document non-secret provider env vars (what `providers[].env` captures and why it exists alongside secret credentials) ## CLI @@ -47,12 +45,11 @@ ## Testing ### Current coverage -- Go unit tests across cmd/ and all internal/ packages -- 29 bats preflight tests (run in CI via `.github/workflows/ci.yml`) +- Go unit tests across cmd/ and all internal/ packages (run in CI via `.github/workflows/ci.yml`) - Integration: local + kind + OCP via `make test-all` ### Gaps -- [ ] Integration test for `providers --force` +- [ ] Integration test for `harness up --provider-refresh` ## Release @@ -62,7 +59,7 @@ ## Deferred (post-0.1) -- [ ] Gateway-level LLM proxy/logging (gateway.toml `[proxy]` section) +- [ ] Gateway-level LLM proxy/logging (gateway.yaml proxy section) - [ ] Multi-agent workflow support (fleet.yaml / workflow.yaml) - [ ] `harness policy suggest` (DenialEvent stream -> policy proposals) - [ ] Fleet management (multi-gateway kubectl-context style) diff --git a/cmd/connect.go b/cmd/connect.go deleted file mode 100644 index facd52c..0000000 --- a/cmd/connect.go +++ /dev/null @@ -1,22 +0,0 @@ -package cmd - -import ( - "github.com/robbycochran/harness-openshell/internal/gateway" - "github.com/spf13/cobra" -) - -func NewConnectCmd(cli string) *cobra.Command { - return &cobra.Command{ - Use: "connect [SANDBOX_NAME]", - Short: "Reconnect to a running sandbox", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - gw := gateway.New(cli) - name := "" - if len(args) > 0 { - name = args[0] - } - return gw.SandboxConnect(name) - }, - } -} diff --git a/cmd/create.go b/cmd/create.go index a2159b0..d3b3b6f 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -54,24 +54,10 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { status.Infof("Image: %s", sandboxImage) // 3. Ensure providers are registered - status.Header("Providers") - providerNames := agentCfg.ProviderNames() - registered, missing := gateway.ValidateProviders(providerNames, gw) - if len(missing) > 0 { - if err := registerProviders(harnessDir, gw, false, agentCfg.Providers); err != nil { - status.Warn(fmt.Sprintf("provider registration: %v", err)) - } - registered, missing = gateway.ValidateProviders(providerNames, gw) - } - for _, n := range registered { - status.OKf("%s: attached", n) - } - for _, n := range missing { - status.Failf("%s: not registered", n) - } + registered := ensureProviders(harnessDir, gw, agentCfg, false) // 4. Deploy the sandbox - status.Header("Creating sandbox") + status.Header("Sandbox") payloadDir, err := os.MkdirTemp("", "harness-payload-") if err != nil { return fmt.Errorf("creating payload dir: %w", err) @@ -95,7 +81,7 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { env: agentCfg.BuildEnvMap(), onSuccess: func(n string) { fmt.Println() - status.OKf("Sandbox created: %s — connect with: harness connect %s", n, n) + status.OKf("Sandbox created: %s — connect with: openshell sandbox connect %s", n, n) }, }) }, diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index eaa8540..7d8b6d3 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -44,7 +44,6 @@ func (m *mockGW) SandboxDelete(name string) error { } func (m *mockGW) CLIVersion() string { return "openshell v0.0.59" } func (m *mockGW) CLIPath() string { return "/usr/bin/openshell" } -func (m *mockGW) InferenceModel() string { return "" } func (m *mockGW) InferenceSet(string, string) error { return nil } func (m *mockGW) InferenceRemove() error { return nil } func (m *mockGW) ActiveGateway() string { return "" } @@ -55,8 +54,6 @@ func (m *mockGW) ProviderProfileDelete(string) error func (m *mockGW) SettingsSet(string, string) error { return nil } func (m *mockGW) SandboxList() ([]string, error) { return nil, nil } func (m *mockGW) SandboxStatus() ([]gateway.SandboxInfo, error) { return nil, nil } -func (m *mockGW) SandboxConnect(string) error { return nil } -func (m *mockGW) SandboxLogs(string, bool) error { return nil } func (m *mockGW) SandboxStop(string) error { return nil } func (m *mockGW) SandboxStart(string) error { return nil } func (m *mockGW) GatewayAdd(string, string, bool, bool) error { return nil } diff --git a/cmd/logs.go b/cmd/logs.go deleted file mode 100644 index cc8254e..0000000 --- a/cmd/logs.go +++ /dev/null @@ -1,27 +0,0 @@ -package cmd - -import ( - "github.com/robbycochran/harness-openshell/internal/gateway" - "github.com/spf13/cobra" -) - -func NewLogsCmd(harnessDir, cli string) *cobra.Command { - var follow bool - - cmd := &cobra.Command{ - Use: "logs [SANDBOX_NAME]", - Short: "Stream sandbox logs", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - gw := gateway.New(cli) - name := "" - if len(args) > 0 { - name = args[0] - } - return gw.SandboxLogs(name, follow) - }, - } - - cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output") - return cmd -} diff --git a/cmd/providers.go b/cmd/providers.go index 4c6eefa..21b745b 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -47,7 +47,9 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, provid } profilesDir := filepath.Join(harnessDir, "agents", "providers", "profiles") - gw.ProviderProfileImport(profilesDir) + if err := gw.ProviderProfileImport(profilesDir); err != nil { + status.Warnf("provider profile import: %v", err) + } if _, ok := wanted["github"]; ok { if err := registerStandard("github", "github", gw, nil); err != nil { @@ -83,6 +85,28 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, provid return nil } +func ensureProviders(harnessDir string, gw gateway.Gateway, agentCfg *agent.AgentConfig, forceRefresh bool) []string { + providerNames := agentCfg.ProviderNames() + if len(providerNames) == 0 { + return nil + } + registered, missing := gateway.ValidateProviders(providerNames, gw) + if len(missing) > 0 || forceRefresh { + if err := registerProviders(harnessDir, gw, forceRefresh, agentCfg.Providers); err != nil { + status.Warnf("provider registration: %v", err) + } + registered, missing = gateway.ValidateProviders(providerNames, gw) + } + status.Header("Providers") + for _, name := range registered { + status.OKf("%s: registered", name) + } + for _, name := range missing { + status.Failf("%s: not registered", name) + } + return registered +} + func registerStandard(name, profileType string, gw gateway.Gateway, configs []string) error { if gw.ProviderGet(name) == nil { status.Infof("%s: exists", name) @@ -119,7 +143,7 @@ func registerADC(name, profileType, model string, gw gateway.Gateway, configs [] func registerGWS(harnessDir string, gw gateway.Gateway) error { if gw.ProviderGet("gws") == nil { - status.Info("gws: exists (use --force to recreate)") + status.Info("gws: exists (use --provider-refresh to recreate)") return nil } diff --git a/cmd/resolve.go b/cmd/resolve.go new file mode 100644 index 0000000..6764492 --- /dev/null +++ b/cmd/resolve.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/robbycochran/harness-openshell/internal/agent" + "github.com/robbycochran/harness-openshell/internal/gateway" +) + +func resolveSandboxName(gw gateway.Gateway, args []string) (string, error) { + if len(args) > 0 { + return args[0], nil + } + names, err := gw.SandboxList() + if err != nil { + return "", fmt.Errorf("listing sandboxes: %w", err) + } + if len(names) == 0 { + return "", fmt.Errorf("no sandboxes running") + } + if len(names) > 1 { + return "", fmt.Errorf("multiple sandboxes running, specify one: %v", names) + } + return names[0], nil +} + +func resolveAgentPath(harnessDir, agentName, agentFile string) string { + if agentFile != "" { + return agentFile + } + return filepath.Join(harnessDir, "agents", agentName+".yaml") +} + +func resolveAgentConfig(harnessDir, agentName, agentFile string) (*agent.AgentConfig, error) { + path := resolveAgentPath(harnessDir, agentName, agentFile) + cfg, err := agent.ParseFile(path) + if err == nil { + return cfg, nil + } + if agentFile != "" || agentName != "default" || len(DefaultAgentConfig) == 0 { + return nil, err + } + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + return nil, err + } + return agent.Parse(DefaultAgentConfig) +} + +func versionedImage(name string) string { + base := "ghcr.io/robbycochran/harness-openshell" + if Version == "" || Version == "dev" { + return base + ":" + name + } + return base + ":" + name + "-" + Version +} diff --git a/cmd/sandbox.go b/cmd/sandbox.go index 27c57be..dad6c65 100644 --- a/cmd/sandbox.go +++ b/cmd/sandbox.go @@ -7,6 +7,7 @@ import ( "time" "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/robbycochran/harness-openshell/internal/status" ) // sandboxOpts holds the parameters that vary between callers of @@ -74,7 +75,7 @@ func createSandbox(opts sandboxOpts) error { return nil } - fmt.Printf(" Attempt %d failed: %v, retrying in 5s...\n", attempt, err) + status.Warnf("attempt %d: %v, retrying in 5s", attempt, err) opts.gw.SandboxDelete(opts.name) // best-effort cleanup if attempt == 5 { diff --git a/cmd/stop.go b/cmd/stop.go index eb5944d..229cc62 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -27,20 +27,3 @@ func NewStopCmd(harnessDir, cli string) *cobra.Command { }, } } - -func resolveSandboxName(gw gateway.Gateway, args []string) (string, error) { - if len(args) > 0 { - return args[0], nil - } - names, err := gw.SandboxList() - if err != nil { - return "", fmt.Errorf("listing sandboxes: %w", err) - } - if len(names) == 0 { - return "", fmt.Errorf("no sandboxes running") - } - if len(names) > 1 { - return "", fmt.Errorf("multiple sandboxes running, specify one: %v", names) - } - return names[0], nil -} diff --git a/cmd/teardown.go b/cmd/teardown.go index 7f35f51..09dfebf 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -32,9 +32,9 @@ func NewTeardownCmd(harnessDir, cli string) *cobra.Command { activeGW := gw.ActiveGateway() if activeGW != "" { - fmt.Printf("Active gateway: %s\n", activeGW) + status.Infof("Active gateway: %s", activeGW) } else { - fmt.Println("Active gateway: none") + status.Info("Active gateway: none") } fmt.Println() diff --git a/cmd/up.go b/cmd/up.go index ccba17f..d36f68e 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -83,30 +83,6 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { return cmd } -func resolveAgentPath(harnessDir, agentName, agentFile string) string { - if agentFile != "" { - return agentFile - } - return filepath.Join(harnessDir, "agents", agentName+".yaml") -} - -// resolveAgentConfig parses the agent config from disk, falling back to the -// embedded default when the file does not exist and no explicit --file was given. -func resolveAgentConfig(harnessDir, agentName, agentFile string) (*agent.AgentConfig, error) { - path := resolveAgentPath(harnessDir, agentName, agentFile) - cfg, err := agent.ParseFile(path) - if err == nil { - return cfg, nil - } - if agentFile != "" || agentName != "default" || len(DefaultAgentConfig) == 0 { - return nil, err - } - if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { - return nil, err - } - return agent.Parse(DefaultAgentConfig) -} - type upLocalOpts struct { harnessDir string gw gateway.Gateway @@ -164,25 +140,7 @@ func upLocal(opts upLocalOpts) error { } // 3. Ensure providers needed by the agent are registered - providerNames := agentCfg.ProviderNames() - var registered []string - if len(providerNames) > 0 { - var missing []string - registered, missing = gateway.ValidateProviders(providerNames, gw) - if len(missing) > 0 || opts.providerRefresh { - if err := registerProviders(opts.harnessDir, gw, opts.providerRefresh, agentCfg.Providers); err != nil { - status.Warn(fmt.Sprintf("provider registration: %v", err)) - } - registered, missing = gateway.ValidateProviders(providerNames, gw) - } - status.Header("Providers") - for _, name := range registered { - status.OKf("%s", name) - } - for _, name := range missing { - status.Failf("%s (not registered)", name) - } - } + registered := ensureProviders(opts.harnessDir, gw, agentCfg, opts.providerRefresh) // 4. Render payload payloadDir, err := os.MkdirTemp("", "harness-payload-") @@ -223,10 +181,3 @@ var Version = "dev" // DefaultAgentConfig holds the embedded default agent YAML, set from main.go. var DefaultAgentConfig []byte -func versionedImage(name string) string { - base := "ghcr.io/robbycochran/harness-openshell" - if Version == "" || Version == "dev" { - return base + ":" + name - } - return base + ":" + name + "-" + Version -} diff --git a/docs/proto-migration.md b/docs/proto-migration.md deleted file mode 100644 index 109b8e1..0000000 --- a/docs/proto-migration.md +++ /dev/null @@ -1,231 +0,0 @@ -# Proto-Based Profile Format Migration - -Migrate harness-openshell's profile and provider profile formats from -hand-rolled TOML/YAML to OpenShell's proto-generated Go types. The proto -becomes the schema; the serialization format (textproto, JSON, or TOML -with a mapping layer) is a separate decision. - -## Why - -### Upstream alignment - -OpenShell's `SandboxSpec`, `ProviderProfile`, and related messages are the -canonical types that the gateway operates on. Every field harness-openshell -cares about already exists in the proto: - -| harness-openshell concept | Proto message | -|---------------------------|---------------| -| Sandbox profile (`profiles/*.toml`) | `CreateSandboxRequest` → `SandboxSpec` → `SandboxTemplate` | -| Provider profile (`sandbox/profiles/*.yaml`) | `ProviderProfile` | -| Provider credentials | `ProviderProfileCredential` | -| Network endpoints | `NetworkEndpoint` (from `sandbox.proto`) | -| Provider catalog (`providers.toml`) | Partially: `ProviderProfileDiscovery` + `ProviderProfileCredential.env_vars` | - -When OpenShell adds fields (e.g., `user_namespaces`, `volume_claim_templates`, -credential refresh material), re-running `protoc` makes them available -immediately. No manual struct updates, no drift. - -### Compile-time safety - -Today, profile fields are parsed from TOML into a hand-written `Config` -struct. If OpenShell renames or restructures a field, the harness discovers -this at runtime when CLI flags fail. With proto-generated types, a field -change is a compile error. - -### Format for the future - -The proto types are the same ones a gRPC client would use. If/when -harness-openshell moves from CLI exec to direct gRPC, the profile structs -are already the request payloads — no translation step. - -## What changes - -### Sandbox profiles - -**Today:** `profiles/default.toml` → parsed into `profile.Config` struct → -translated to `openshell sandbox create --name X --image Y --provider Z` flags. - -**After:** `profiles/default.textproto` (or `.json`) → unmarshalled into -generated `openshellv1.CreateSandboxRequest` + a `HarnessConfig` wrapper → -same CLI flags, but sourced from the proto struct. - -Harness-only fields (`command`, `keep`) have no proto equivalent. These -live in a wrapper: - -```go -type Profile struct { - Request *openshellv1.CreateSandboxRequest - Command string - Keep bool -} -``` - -### Provider profiles - -**Today:** `sandbox/profiles/atlassian.yaml` → hand-written YAML matching -OpenShell's profile schema → imported via `openshell provider profile import`. - -**After:** `sandbox/profiles/atlassian.textproto` → unmarshalled into -generated `openshellv1.ProviderProfile` → serialized to YAML or JSON for -`openshell provider profile import`, or passed directly if/when using gRPC. - -### Provider catalog (providers.toml) - -`providers.toml` tracks preflight validation inputs and custom provider -workarounds. The proto doesn't cover preflight checks (`kind = "file"`, -`kind = "check"`) or the `upstream` tracking field. This file stays as-is — -it's harness-specific orchestration, not an upstream type. - -Over time, `providers.toml` shrinks as: -- OpenShell's `ProviderProfileDiscovery` handles more credential discovery -- Credential verification on create ships upstream (#896 roadmap) -- Custom providers migrate to native OpenShell provider profiles - -### Bash path - -The bash path (`profile.sh` + `parse-profile.py`) currently reads TOML. -Options when the format changes: - -| Option | Tradeoff | -|--------|----------| -| **Go shim** (`harness profile dump NAME` → shell vars) | One parser, two consumers. Bash calls Go. Cleanest. | -| **Swap Python parser** (protobuf or json module) | Keeps bash self-contained but adds protobuf pip dependency. | -| **Drop bash path** | Simplest, but premature if Go migration isn't complete. | - -**Recommendation:** Go shim. Add a `harness profile dump` subcommand that -reads the proto-format profile and emits `SANDBOX_NAME=...` shell variable -assignments. `parse-profile.py` is replaced by a one-line call to the Go -binary. No new dependencies in the bash path, no duplicated parsing logic. - -## Tradeoffs - -### Serialization format - -| Format | Pros | Cons | -|--------|------|------| -| **Textproto** | Comments. Snake_case field names match proto exactly. `prototext.Unmarshal` in Go. | Map entries are verbose (`key:` / `value:` pairs). Less familiar to most users. | -| **Proto JSON** | Universal. `protojson.Unmarshal` in Go. Easy to generate/consume from other tools. | No comments. camelCase field names (protobuf JSON convention). | -| **TOML (keep, marshal internally)** | Best authoring UX. Users don't need to learn a new format. | Manual mapping layer between TOML keys and proto fields. Defeats some of the alignment benefit. | - -**Recommendation:** Textproto for provider profiles (they read cleanly, -comments are valuable for documenting credential vs. config distinctions). -For sandbox profiles, textproto works but the `environment` map is ugly — -proto JSON may be more readable there. Support both via file extension -detection (`.textproto` → `prototext`, `.json` → `protojson`). - -### Proto stability - -OpenShell is alpha (v0.0.58). The proto could change. However: - -- `SandboxSpec` core fields (`image`, `environment`, `providers`) are stable - — they map directly to the CLI's `sandbox create` flags which have been - stable since v0.0.20+. -- `ProviderProfile` shipped with providers v2 and has a published schema in - the docs. Breaking changes would affect all provider profile YAML users, - not just harness-openshell. -- Proto changes produce compile errors, which is strictly better than - discovering CLI output format changes at runtime. -- If a field is removed or renamed, the fix is: re-generate, update the one - or two references in harness code, done. - -### Complexity budget - -Adding `protoc` to the build introduces: -- Proto files vendored (4 files: `openshell.proto`, `datamodel.proto`, - `sandbox.proto`, `inference.proto`) -- `protoc-gen-go` and `protoc-gen-go-grpc` as build-time dependencies -- A `make proto` (or `buf generate`) target -- Generated `.pb.go` files checked in (or generated in CI) - -This is standard Go infrastructure but it's not zero. The project currently -has no protobuf dependency. - -## Implementation plan - -### Phase 1: Vendor proto, generate types - -1. Create `proto/` directory, vendor the 4 proto files from NVIDIA/OpenShell -2. Add `buf.yaml` + `buf.gen.yaml` (or a `make proto` target with `protoc`) -3. Generate Go types into `internal/openshell/` (or `pkg/openshell/`) -4. Add generated files to the repo (avoid requiring protoc for contributors) -5. Verify: generated `CreateSandboxRequest`, `SandboxSpec`, `ProviderProfile` - types compile and match the upstream schema - -### Phase 2: Migrate provider profiles - -Start here because the provider profile format is the cleanest mapping — -almost 1:1 with the existing `atlassian.yaml`. - -1. Convert `sandbox/profiles/atlassian.yaml` → `sandbox/profiles/atlassian.textproto` -2. Update `cmd/providers.go` to unmarshal via `prototext.Unmarshal` into - `*openshellv1.ProviderProfile` -3. For `openshell provider profile import`: serialize the proto struct to - YAML (or JSON) since the CLI expects that format, or write a temp file -4. Update tests -5. Verify: `harness providers` still registers the atlassian profile correctly - -### Phase 3: Migrate sandbox profiles - -1. Define the `Profile` wrapper struct (proto `CreateSandboxRequest` + - harness-only `Command` and `Keep` fields) -2. Convert `profiles/default.toml` → `profiles/default.textproto` (or `.json`) - with a sidecar section or separate file for harness-only fields -3. Update `internal/profile/` to parse the new format into the wrapper struct -4. Update `internal/profile/` to translate the proto struct into CLI flags - (same logic as today, different source struct) -5. Add `harness profile dump NAME` subcommand for bash path compatibility -6. Replace `parse-profile.py` call in `profile.sh` with `harness profile dump` -7. Update tests (Go unit tests + bats integration) -8. Verify: `make validate` passes on all matrix combinations - -### Phase 4: Cleanup - -1. Remove `parse-profile.py` -2. Remove old `profile.Config` struct (replaced by wrapper + proto types) -3. Update docs and `profile-concepts.md` to reflect the new format -4. Document the format in a comment block or README section - -## Decision: harness-only fields - -Two fields exist in harness profiles that have no proto equivalent: - -| Field | Why it's harness-only | Where it goes | -|-------|----------------------|---------------| -| `command` | Passed at `sandbox connect` / `sandbox exec` time, not at creation | Wrapper struct | -| `keep` | Client-side lifecycle decision (keep sandbox after command exits) | Wrapper struct | - -Options for encoding these alongside the proto: - -1. **Wrapper struct with separate parsing** — proto fields in `.textproto`, - harness fields as Go defaults or a small sidecar `harness.toml`. -2. **Proto extensions** — technically possible but overkill and non-standard. -3. **Comments-as-config** — parse specially formatted comments in the - textproto. Fragile, don't do this. -4. **Custom proto message** — define a `HarnessProfile` message that embeds - `CreateSandboxRequest`. Cleanest if we're already running protoc. - -**Recommendation:** Option 4. Define a small `harness.proto`: - -```protobuf -syntax = "proto3"; -package harness.v1; -import "openshell.proto"; - -message Profile { - openshell.v1.CreateSandboxRequest request = 1; - string command = 2; // default: "claude --bare" - bool keep = 3; // default: true - string description = 4; // human-readable profile description -} -``` - -This gives you one file, one parse call, full type safety, and a natural -place for any future harness-only fields. - -## References - -- [OpenShell proto](https://github.com/NVIDIA/OpenShell/blob/main/proto/openshell.proto) -- [OpenShell providers v2 docs](https://github.com/NVIDIA/OpenShell/blob/main/docs/sandboxes/providers-v2.mdx) -- [OpenShell #896 — Enhanced Provider Management](https://github.com/NVIDIA/OpenShell/issues/896) -- [Kaiden #1272 — Projects management](https://github.com/openkaiden/kaiden/issues/1272) -- [profile-concepts.md](profile-concepts.md) — cross-project profile concept analysis diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 6f71f2b..a62dcc0 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "gopkg.in/yaml.v3" @@ -99,23 +98,6 @@ func (c *AgentConfig) BuildEnvMap() map[string]string { return env } -func (c *AgentConfig) BuildEnvSh() string { - env := c.BuildEnvMap() - if len(env) == 0 { - return "" - } - keys := make([]string, 0, len(env)) - for k := range env { - keys = append(keys, k) - } - sort.Strings(keys) - var b strings.Builder - for _, k := range keys { - fmt.Fprintf(&b, "export %s=%q\n", k, env[k]) - } - return b.String() -} - func (c *AgentConfig) BuildRunSh() string { var b strings.Builder b.WriteString("#!/usr/bin/env bash\nset -euo pipefail\n\n") diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index cc3fbe8..ae7635a 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -141,44 +141,6 @@ func TestNoTTY(t *testing.T) { } } -func TestBuildEnvSh(t *testing.T) { - cfg := &AgentConfig{ - Env: map[string]string{ - "ANTHROPIC_BASE_URL": "https://inference.local", - "ANTHROPIC_API_KEY": "sk-proxy", - }, - } - env := cfg.BuildEnvSh() - if !strings.Contains(env, `export ANTHROPIC_BASE_URL="https://inference.local"`) { - t.Errorf("missing ANTHROPIC_BASE_URL in:\n%s", env) - } -} - -func TestBuildEnvSh_Empty(t *testing.T) { - cfg := &AgentConfig{Providers: []ProviderRef{{Profile: "github"}}} - if env := cfg.BuildEnvSh(); env != "" { - t.Errorf("expected empty env.sh, got:\n%s", env) - } -} - -func TestBuildEnvSh_IncludesProviderEnv(t *testing.T) { - cfg := &AgentConfig{ - Env: map[string]string{ - "ANTHROPIC_BASE_URL": "https://inference.local", - }, - Providers: []ProviderRef{ - {Profile: "atlassian", Env: map[string]string{"JIRA_URL": "https://jira.example.com"}}, - }, - } - env := cfg.BuildEnvSh() - if !strings.Contains(env, `ANTHROPIC_BASE_URL`) { - t.Errorf("missing top-level env var in:\n%s", env) - } - if !strings.Contains(env, `JIRA_URL`) { - t.Errorf("missing provider env var in:\n%s", env) - } -} - func TestBuildEnvMap_IncludesProviderEnv(t *testing.T) { t.Setenv("JIRA_URL", "https://test.atlassian.net") cfg := &AgentConfig{ @@ -304,18 +266,6 @@ func TestBuildEnvMap_Empty(t *testing.T) { } } -func TestBuildEnvSh_Sorted(t *testing.T) { - cfg := &AgentConfig{ - Env: map[string]string{"Z_VAR": "z", "A_VAR": "a"}, - } - env := cfg.BuildEnvSh() - aIdx := strings.Index(env, "A_VAR") - zIdx := strings.Index(env, "Z_VAR") - if aIdx > zIdx { - t.Error("env.sh should be sorted alphabetically") - } -} - func TestBuildRunSh(t *testing.T) { cfg := &AgentConfig{ Entrypoint: "claude --bare", diff --git a/internal/gateway/cli.go b/internal/gateway/cli.go index aff447d..76d7cf0 100644 --- a/internal/gateway/cli.go +++ b/internal/gateway/cli.go @@ -9,7 +9,6 @@ import ( "sort" "strconv" "strings" - "syscall" "github.com/robbycochran/harness-openshell/internal/status" ) @@ -97,20 +96,6 @@ func (c *CLI) InferenceGet() error { return c.silent("inference", "get") } -func (c *CLI) InferenceModel() string { - out, err := c.output("inference", "get") - if err != nil { - return "" - } - for _, line := range strings.Split(string(out), "\n") { - cleaned := ansiRE.ReplaceAllString(line, "") - if strings.Contains(cleaned, "Model:") { - return strings.TrimSpace(strings.SplitN(cleaned, "Model:", 2)[1]) - } - } - return "" -} - func (c *CLI) ActiveGateway() string { out, err := c.output("gateway", "list") if err != nil { @@ -306,17 +291,6 @@ func (c *CLI) SandboxStatus() ([]SandboxInfo, error) { return infos, nil } -func (c *CLI) SandboxLogs(name string, follow bool) error { - args := []string{"sandbox", "logs"} - if name != "" { - args = append(args, name) - } - if follow { - args = append(args, "--follow") - } - return c.passthrough(args...) -} - func (c *CLI) SandboxStop(name string) error { return c.silent("sandbox", "stop", name) } @@ -329,18 +303,6 @@ func (c *CLI) SandboxDelete(name string) error { return c.silent("sandbox", "delete", name) } -func (c *CLI) SandboxConnect(name string) error { - path, err := exec.LookPath(c.bin) - if err != nil { - return err - } - args := []string{c.bin, "sandbox", "connect"} - if name != "" { - args = append(args, name) - } - return syscall.Exec(path, args, os.Environ()) -} - func parseFirstColumn(out []byte) []string { var names []string for i, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { diff --git a/internal/gateway/cli_test.go b/internal/gateway/cli_test.go index c6f7ace..9307a90 100644 --- a/internal/gateway/cli_test.go +++ b/internal/gateway/cli_test.go @@ -284,37 +284,6 @@ exit 0 } } -func TestSandboxLogs_PassesFollow(t *testing.T) { - argsFile := filepath.Join(t.TempDir(), "args") - bin := writeStub(t, `#!/bin/bash -echo "$@" > `+argsFile+` -`) - gw := New(bin) - gw.SandboxLogs("my-agent", true) - data, _ := os.ReadFile(argsFile) - args := strings.TrimSpace(string(data)) - if !strings.Contains(args, "sandbox logs my-agent --follow") { - t.Errorf("expected sandbox logs with --follow, got: %s", args) - } -} - -func TestSandboxLogs_NoFollow(t *testing.T) { - argsFile := filepath.Join(t.TempDir(), "args") - bin := writeStub(t, `#!/bin/bash -echo "$@" > `+argsFile+` -`) - gw := New(bin) - gw.SandboxLogs("my-agent", false) - data, _ := os.ReadFile(argsFile) - args := strings.TrimSpace(string(data)) - if strings.Contains(args, "--follow") { - t.Errorf("should not have --follow: %s", args) - } - if !strings.Contains(args, "sandbox logs my-agent") { - t.Errorf("missing sandbox logs: %s", args) - } -} - func TestSandboxList_Empty(t *testing.T) { bin := writeStub(t, `#!/bin/bash printf "NAME\tPHASE\n" @@ -354,18 +323,6 @@ printf " local\thttps://127.0.0.1:17670\n" } } -func TestInferenceModel_ParsesModel(t *testing.T) { - bin := writeStub(t, `#!/bin/bash -echo "Provider: vertex-local" -echo "Model: claude-sonnet-4-6" -`) - gw := New(bin) - model := gw.InferenceModel() - if model != "claude-sonnet-4-6" { - t.Errorf("InferenceModel = %q, want claude-sonnet-4-6", model) - } -} - func TestCLIVersion(t *testing.T) { bin := writeStub(t, `#!/bin/bash echo "openshell v0.0.58" diff --git a/internal/gateway/config.go b/internal/gateway/config.go index ab6efc5..d56a301 100644 --- a/internal/gateway/config.go +++ b/internal/gateway/config.go @@ -100,17 +100,6 @@ func (c *GatewayConfig) IsOCP() bool { return c.Gateway.Platform == "ocp" } -func (c *GatewayConfig) HasProviders() bool { - return len(c.Providers.Enabled) > 0 || len(c.Providers.Custom) > 0 -} - -func (c *GatewayConfig) AllProviders() []string { - all := make([]string, 0, len(c.Providers.Enabled)+len(c.Providers.Custom)) - all = append(all, c.Providers.Enabled...) - all = append(all, c.Providers.Custom...) - return all -} - func (c *GatewayConfig) HelmValuesPath() string { if c.Helm.Values == "" { return "" diff --git a/internal/gateway/config_test.go b/internal/gateway/config_test.go index ee07380..de85771 100644 --- a/internal/gateway/config_test.go +++ b/internal/gateway/config_test.go @@ -327,44 +327,3 @@ func TestPredicates(t *testing.T) { } } -func TestHasProviders(t *testing.T) { - dir := t.TempDir() - writeGatewayYAML(t, dir, ` -gateway: - type: local -providers: - enabled: [github] - custom: [gws] -`) - - cfg, err := LoadConfig(dir) - if err != nil { - t.Fatal(err) - } - - if !cfg.HasProviders() { - t.Error("HasProviders() = false, want true") - } - - all := cfg.AllProviders() - if len(all) != 2 { - t.Errorf("AllProviders() = %v, want [github gws]", all) - } -} - -func TestHasProviders_Empty(t *testing.T) { - dir := t.TempDir() - writeGatewayYAML(t, dir, ` -gateway: - type: local -`) - - cfg, err := LoadConfig(dir) - if err != nil { - t.Fatal(err) - } - - if cfg.HasProviders() { - t.Error("HasProviders() = true with no providers section") - } -} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index e65ecc8..6848a16 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -17,14 +17,11 @@ type Gateway interface { SandboxStatus() ([]SandboxInfo, error) SandboxCreate(opts SandboxCreateOpts) error SandboxDelete(name string) error - SandboxConnect(name string) error - SandboxLogs(name string, follow bool) error SandboxStop(name string) error SandboxStart(name string) error // Inference InferenceGet() error - InferenceModel() string InferenceSet(provider, model string) error InferenceRemove() error diff --git a/internal/k8s/kubectl.go b/internal/k8s/kubectl.go index d6433b5..ed45093 100644 --- a/internal/k8s/kubectl.go +++ b/internal/k8s/kubectl.go @@ -31,7 +31,6 @@ type Runner interface { RunKubectl(ctx context.Context, args ...string) (string, error) RunKubectlOpts(ctx context.Context, opts KubectlOpts) (string, error) RunKubectlQuiet(ctx context.Context, args ...string) error - RunKubectlPassthrough(ctx context.Context, args ...string) error RunHelm(ctx context.Context, args ...string) error RunOC(ctx context.Context, args ...string) error ApplyYAML(ctx context.Context, resources ...map[string]any) error @@ -118,21 +117,6 @@ func (c *Client) RunKubectlQuiet(ctx context.Context, args ...string) error { return err } -func (c *Client) RunKubectlPassthrough(ctx context.Context, args ...string) error { - if c.namespace != "" && !containsFlag(args, "-n", "--namespace") { - args = append([]string{"-n", c.namespace}, args...) - } - if c.kubeconfig != "" { - args = append([]string{"--kubeconfig", c.kubeconfig}, args...) - } - status.Cmd("kubectl", args...) - cmd := exec.CommandContext(ctx, "kubectl", args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - func (c *Client) RunHelm(ctx context.Context, args ...string) error { if c.namespace != "" && !containsFlag(args, "-n", "--namespace") { args = append(args, "-n", c.namespace) diff --git a/internal/k8s/mock.go b/internal/k8s/mock.go index b594f9e..cc89ca6 100644 --- a/internal/k8s/mock.go +++ b/internal/k8s/mock.go @@ -53,11 +53,6 @@ func (m *MockRunner) RunKubectlQuiet(_ context.Context, args ...string) error { return err } -func (m *MockRunner) RunKubectlPassthrough(_ context.Context, args ...string) error { - _, err := m.respond(m.record(args...)) - return err -} - func (m *MockRunner) RunHelm(_ context.Context, args ...string) error { _, err := m.respond(m.record(append([]string{"helm"}, args...)...)) return err diff --git a/internal/status/status.go b/internal/status/status.go index e1f1bff..ad18b12 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -22,16 +22,6 @@ func Cmd(name string, args ...string) { } } -// ShowEquivalentCmd displays the equivalent openshell CLI command for -// an operation, regardless of how it was actually executed. Use this -// in gRPC Gateway implementations to show the CLI equivalent. -func ShowEquivalentCmd(name string, args ...string) { - if !ShowCommands { - return - } - fmt.Printf(" %s\n", formatCmdLine(name, args)) -} - func formatCmdLine(name string, args []string) string { var b strings.Builder b.WriteString("$ ") @@ -93,10 +83,10 @@ func OKf(format string, a ...any) { fmt.Printf(" ✓ "+format+"\n", a...) } func Fail(msg string) { fmt.Println(" ✗ " + msg) } func Failf(format string, a ...any) { fmt.Printf(" ✗ "+format+"\n", a...) } func Warn(msg string) { fmt.Println(" ! " + msg) } +func Warnf(format string, a ...any) { fmt.Printf(" ! "+format+"\n", a...) } func Info(msg string) { fmt.Println(" - " + msg) } func Infof(format string, a ...any) { fmt.Printf(" - "+format+"\n", a...) } func Detail(msg string) { fmt.Println(" " + msg) } -func Detailf(format string, a ...any){ fmt.Printf(" "+format+"\n", a...) } func Sub(msg string) { fmt.Println(" " + msg) } func Step(n int, msg string) { fmt.Printf("\n=== Step %d: %s ===\n", n, msg) } func Section(title string) { fmt.Printf("\n=== %s ===\n", title) } diff --git a/internal/status/status_test.go b/internal/status/status_test.go index 1a6f421..112a039 100644 --- a/internal/status/status_test.go +++ b/internal/status/status_test.go @@ -139,27 +139,6 @@ func TestCmdShowCommands_RedactsCredentials(t *testing.T) { } } -func TestShowEquivalentCmd_OnlyWhenEnabled(t *testing.T) { - ShowCommands = false - out := captureStdout(func() { - ShowEquivalentCmd("openshell", "sandbox", "list") - }) - if out != "" { - t.Errorf("expected no output when ShowCommands=false, got: %q", out) - } -} - -func TestShowEquivalentCmd_Prints(t *testing.T) { - ShowCommands = true - defer func() { ShowCommands = false }() - out := captureStdout(func() { - ShowEquivalentCmd("openshell", "sandbox", "list") - }) - if !strings.Contains(out, "$ openshell sandbox list") { - t.Errorf("expected equivalent command, got: %q", out) - } -} - func captureStdout(fn func()) string { old := os.Stdout r, w, _ := os.Pipe() diff --git a/main.go b/main.go index 93a2b30..f19cd13 100644 --- a/main.go +++ b/main.go @@ -49,11 +49,9 @@ func main() { root.AddCommand( cmd.NewUpCmd(harnessDir, cli), cmd.NewCreateCmd(harnessDir, cli), - cmd.NewConnectCmd(cli), cmd.NewDeployCmd(harnessDir, cli), cmd.NewTeardownCmd(harnessDir, cli), cmd.NewStatusCmd(harnessDir, cli), - cmd.NewLogsCmd(harnessDir, cli), cmd.NewStopCmd(harnessDir, cli), cmd.NewStartCmd(harnessDir, cli), ) From 97051927118237cc2b78c207f0419196459d0371 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 12 Jun 2026 13:03:38 -0700 Subject: [PATCH 3/8] refactor: flatten gateway configs, move profiles to profiles/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway configs now support inline helm values and addon manifests, making each config a single self-contained YAML file. Moved from gateways//gateway.yaml (directory per target with separate helm values and addon files) to profiles/gateways/.yaml (one flat file per target). Provider profiles moved from agents/providers/profiles/ to profiles/providers/ for consistency. Gateway configs are embedded in the binary and resolved with fallback: profiles/gateways/ on disk → legacy gateways/ dir → embedded default. Added LoadConfigFromBytes, LoadProfile, and resolveGatewayConfig. HelmSection and AddonsSection now accept both file paths and inline YAML via custom UnmarshalYAML. --- README.md | 16 +-- SPEC.md | 8 +- cmd/deploy.go | 20 ++- cmd/providers.go | 6 +- cmd/providers_test.go | 2 +- cmd/resolve.go | 17 +++ cmd/teardown.go | 4 +- cmd/up.go | 3 +- gateways/kind/helm/values.yaml | 20 --- gateways/ocp/addons/route.yaml | 13 -- gateways/ocp/gateway.yaml | 33 ----- gateways/ocp/helm/values.yaml | 34 ----- internal/gateway/config.go | 122 +++++++++++++++--- internal/gateway/config_test.go | 120 +++++++++++++++-- main.go | 14 ++ .../gateways/kind.yaml | 16 ++- .../gateways/local.yaml | 6 - profiles/gateways/ocp.yaml | 53 ++++++++ .../providers}/atlassian-user.yaml | 0 .../providers}/atlassian.yaml | 0 .../profiles => profiles/providers}/gws.yaml | 0 21 files changed, 342 insertions(+), 165 deletions(-) delete mode 100644 gateways/kind/helm/values.yaml delete mode 100644 gateways/ocp/addons/route.yaml delete mode 100644 gateways/ocp/gateway.yaml delete mode 100644 gateways/ocp/helm/values.yaml rename gateways/kind/gateway.yaml => profiles/gateways/kind.yaml (70%) rename gateways/local/gateway.yaml => profiles/gateways/local.yaml (66%) create mode 100644 profiles/gateways/ocp.yaml rename {agents/providers/profiles => profiles/providers}/atlassian-user.yaml (100%) rename {agents/providers/profiles => profiles/providers}/atlassian.yaml (100%) rename {agents/providers/profiles => profiles/providers}/gws.yaml (100%) diff --git a/README.md b/README.md index 7d122f4..58b9fdb 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ and replaces this sequence of `openshell` commands (captured from `harness up -- openshell settings set --global --key providers_v2_enabled --value true --yes # 2. Import custom provider profiles (atlassian, gws) -openshell provider profile import --from agents/providers/profiles/ +openshell provider profile import --from profiles/providers/ # 3. Register GitHub (discovers GITHUB_TOKEN from environment) openshell provider create --name github --type github --from-existing @@ -144,7 +144,7 @@ env: ``` ```bash -harness up -f agents/ocp.yaml # deploys to OCP (reads gateways/ocp/gateway.yaml) +harness up -f agents/ocp.yaml # deploys to OCP (reads profiles/gateways/ocp.yaml) harness up # defaults to local Podman ``` @@ -160,7 +160,7 @@ kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/.../m oc adm policy add-scc-to-user privileged -z openshell -n openshell oc adm policy add-scc-to-user anyuid -z openshell -n openshell helm upgrade --install openshell oci://ghcr.io/nvidia/openshell/helm-chart \ - --version 0.0.59 -n openshell -f gateways/ocp/helm/values.yaml + --version 0.0.59 -n openshell -f kubectl rollout status statefulset/openshell -n openshell --timeout=300s # 2. Register gateway with CLI (mTLS certs extracted from K8s secrets) @@ -170,7 +170,7 @@ openshell gateway select openshell-remote-ocp # 3-9. Same as local: settings, profiles, providers, inference, sandbox create openshell settings set --global --key providers_v2_enabled --value true -openshell provider profile import --from agents/providers/profiles/ +openshell provider profile import --from profiles/providers/ openshell provider create --name github --type github --from-existing openshell provider create --name vertex-local --type google-vertex-ai \ --from-gcloud-adc --config VERTEX_AI_PROJECT_ID=... --config VERTEX_AI_REGION=global @@ -231,7 +231,7 @@ Each provider requires credentials on the host. The harness validates these inli | `atlassian` | `JIRA_API_TOKEN` + `JIRA_URL` + `JIRA_USERNAME` env vars | | `gws` | `gws auth login` (OAuth via [gws CLI](https://github.com/googleworkspace/cli)) | -Provider profiles are defined in `agents/providers/profiles/` and validated inline during registration. +Provider profiles are defined in `profiles/providers/` and validated inline during registration. ### Build from Source @@ -247,7 +247,7 @@ For remote OpenShift: `./harness up --remote` (requires `kubectl`, `helm`, clust The harness orchestrates three OpenShell components via the `openshell` CLI: - **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. Provider profiles are declared in `agents/providers/profiles/`. The harness validates credentials inline during registration -- providers with missing credentials are skipped. +- **Providers** -- Credential registrations on the gateway. Provider profiles are declared in `profiles/providers/`. The harness validates credentials inline during registration -- providers with missing credentials are skipped. - **Sandbox** -- Container running the agent entrypoint (Claude Code or OpenCode), 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. ``` @@ -268,8 +268,8 @@ See the [OpenShell docs](https://github.com/NVIDIA/OpenShell) for the full secur | File | Purpose | |------|---------| | `agents/*.yaml` | Agent config: image, entrypoint, providers, env, optional task file | -| `agents/providers/profiles/` | OpenShell provider profiles (imported to gateway on registration) | -| `gateways/*/gateway.yaml` | Deployment target config: `local/` (Podman), `kind/`, `ocp/` (OpenShift) | +| `profiles/providers/` | OpenShell provider profiles (imported to gateway on registration) | +| `profiles/gateways/*.yaml` | Gateway profiles: `local.yaml`, `kind.yaml`, `ocp.yaml` | | `sandbox/Dockerfile` | Sandbox image: OpenShell base + MCP servers + CLI tools | | `sandbox/policy.yaml` | Network egress rules applied to sandboxes | | `sandbox/opencode.json` | MCP server config for OpenCode agent | diff --git a/SPEC.md b/SPEC.md index d699fb2..e7e0894 100644 --- a/SPEC.md +++ b/SPEC.md @@ -49,7 +49,7 @@ Fields: - `policy` -- path to a network policy YAML - `gateway` -- target gateway name (overrides active gateway) -Provider profiles live in `agents/providers/profiles/`. These are imported to the gateway during provider registration. +Provider profiles live in `profiles/providers/`. These are imported to the gateway during provider registration. ## CLI @@ -75,7 +75,7 @@ Create a sandbox without deploying the gateway. Assumes gateway is running. Auto ### `harness deploy [local|ocp|kind]` -Deploy or verify the gateway for a target. Reads `gateways//gateway.yaml`. +Deploy or verify the gateway for a target. Reads `profiles/gateways/.yaml`. ### `harness status` @@ -94,8 +94,8 @@ Tear down resources. At least one flag required. | File | Purpose | |------|---------| | `agents/*.yaml` | Agent config: image, entrypoint, providers, env, task | -| `agents/providers/profiles/` | OpenShell provider profile YAMLs | -| `gateways/*/gateway.yaml` | Deployment target config with Helm, images, RBAC | +| `profiles/providers/` | OpenShell provider profile YAMLs | +| `profiles/gateways/*.yaml` | Gateway profiles: deployment target config with inline Helm values | | `sandbox/Dockerfile` | Sandbox image: OpenShell base + MCP servers + CLI tools | | `sandbox/policy.yaml` | Network egress rules applied to sandboxes | | `sandbox/opencode.json` | MCP server config for OpenCode agent | diff --git a/cmd/deploy.go b/cmd/deploy.go index 9ffda50..0ae62e2 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -33,8 +33,7 @@ func NewDeployCmd(harnessDir, cli string) *cobra.Command { return err } - gwDir := filepath.Join(harnessDir, "gateways", gatewayName) - gwCfg, err := gateway.LoadConfig(gwDir) + gwCfg, err := resolveGatewayConfig(harnessDir, gatewayName) if err != nil { return fmt.Errorf("loading gateway config %q: %w", gatewayName, err) } @@ -146,6 +145,12 @@ func deployFromConfig(harnessDir string, gwCfg *gateway.GatewayConfig, gw gatewa ctx := context.Background() namespace := k8s.DefaultNamespace() + tmpDir, err := os.MkdirTemp("", "harness-deploy-") + if err != nil { + return fmt.Errorf("creating temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + chartVersion := os.Getenv("OPENSHELL_CHART_VERSION") if chartVersion == "" { chartVersion = gwCfg.Chart.Version @@ -187,11 +192,16 @@ func deployFromConfig(harnessDir string, gwCfg *gateway.GatewayConfig, gw gatewa } // Addon manifests (RBAC, etc.) - for _, manifestPath := range gwCfg.ManifestPaths() { + for _, manifestPath := range gwCfg.ManifestFilePaths() { if _, err := kc.RunKubectl(ctx, "apply", "-f", manifestPath); err != nil { return fmt.Errorf("applying %s: %w", filepath.Base(manifestPath), err) } } + for _, manifest := range gwCfg.ManifestInline() { + if err := kc.ApplyYAML(ctx, manifest); err != nil { + return fmt.Errorf("applying inline manifest: %w", err) + } + } // Step 4: Helm install status.Step(4, "Helm install") @@ -211,7 +221,9 @@ func deployFromConfig(harnessDir string, gwCfg *gateway.GatewayConfig, gw gatewa "upgrade", "--install", "openshell", gwCfg.Chart.OCI, "--version", chartVersion, } - if valuesPath := gwCfg.HelmValuesPath(); valuesPath != "" { + if valuesPath, err := gwCfg.HelmValuesFile(tmpDir); err != nil { + return fmt.Errorf("helm values: %w", err) + } else if valuesPath != "" { helmArgs = append(helmArgs, "--values", valuesPath) } if sandboxImage := os.Getenv("SANDBOX_IMAGE"); sandboxImage != "" { diff --git a/cmd/providers.go b/cmd/providers.go index 21b745b..559a958 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -46,7 +46,7 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, provid return fmt.Errorf("enabling providers v2: %w", err) } - profilesDir := filepath.Join(harnessDir, "agents", "providers", "profiles") + profilesDir := filepath.Join(harnessDir, "profiles", "providers") if err := gw.ProviderProfileImport(profilesDir); err != nil { status.Warnf("provider profile import: %v", err) } @@ -213,7 +213,7 @@ func registerGWS(harnessDir string, gw gateway.Gateway) error { return nil } -// gwsProfileScopes reads the refresh.scopes list from agents/providers/profiles/gws.yaml +// gwsProfileScopes reads the refresh.scopes list from profiles/providers/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, "agents", "providers", "profiles", "gws.yaml") @@ -235,7 +235,7 @@ func gwsProfileScopes(harnessDir string) string { } func deleteCustomProfiles(harnessDir string, gw gateway.Gateway) { - profilesDir := filepath.Join(harnessDir, "agents", "providers", "profiles") + profilesDir := filepath.Join(harnessDir, "profiles", "providers") entries, err := os.ReadDir(profilesDir) if err != nil { return diff --git a/cmd/providers_test.go b/cmd/providers_test.go index 8506bb3..e01ecc6 100644 --- a/cmd/providers_test.go +++ b/cmd/providers_test.go @@ -12,7 +12,7 @@ import ( func setupProvidersTest(t *testing.T) string { t.Helper() dir := t.TempDir() - os.MkdirAll(filepath.Join(dir, "agents", "providers", "profiles"), 0o755) + os.MkdirAll(filepath.Join(dir, "profiles", "providers"), 0o755) return dir } diff --git a/cmd/resolve.go b/cmd/resolve.go index 6764492..4561f5b 100644 --- a/cmd/resolve.go +++ b/cmd/resolve.go @@ -55,3 +55,20 @@ func versionedImage(name string) string { } return base + ":" + name + "-" + Version } + +// EmbeddedGatewayProfiles holds embedded gateway profile YAML, set from main.go. +var EmbeddedGatewayProfiles map[string][]byte + +func resolveGatewayConfig(harnessDir, name string) (*gateway.GatewayConfig, error) { + if cfg, err := gateway.LoadProfile(harnessDir, name); err == nil { + return cfg, nil + } + gwDir := filepath.Join(harnessDir, "gateways", name) + if cfg, err := gateway.LoadConfig(gwDir); err == nil { + return cfg, nil + } + if data, ok := EmbeddedGatewayProfiles[name]; ok { + return gateway.LoadConfigFromBytes(data) + } + return nil, fmt.Errorf("gateway profile %q not found", name) +} diff --git a/cmd/teardown.go b/cmd/teardown.go index 09dfebf..c49188a 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -3,7 +3,6 @@ package cmd import ( "context" "fmt" - "path/filepath" "strings" "time" @@ -49,8 +48,7 @@ func NewTeardownCmd(harnessDir, cli string) *cobra.Command { if k8sFlag { ns := k8s.DefaultNamespace() // Load OCP gateway config for SCC/secret names; fall back to defaults - gwDir := filepath.Join(harnessDir, "gateways", "ocp") - gwCfg, _ := gateway.LoadConfig(gwDir) + gwCfg, _ := resolveGatewayConfig(harnessDir, "ocp") teardownK8s(gw, gwCfg, k8s.New("", ns), k8s.New("", "")) } diff --git a/cmd/up.go b/cmd/up.go index d36f68e..1c28dfa 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -54,8 +54,7 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { gwName = agentCfg.Gateway } isRemote := gwName != "local" - gwDir := filepath.Join(harnessDir, "gateways", gwName) - gwCfg, _ := gateway.LoadConfig(gwDir) + gwCfg, _ := resolveGatewayConfig(harnessDir, gwName) return upLocal(upLocalOpts{ harnessDir: harnessDir, diff --git a/gateways/kind/helm/values.yaml b/gateways/kind/helm/values.yaml deleted file mode 100644 index 4d5b208..0000000 --- a/gateways/kind/helm/values.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# kind gateway Helm values. -# -# NodePort service, plaintext HTTP (no TLS) for local dev. -# The openshell CLI registers the gateway via http:// which skips mTLS and -# browser auth entirely. The PKI init job still runs to create the JWT -# signing key secret the gateway requires. - -service: - type: NodePort - -server: - disableTls: true - auth: - allowUnauthenticatedUsers: true - -# PKI init job creates the JWT signing key (openshell-jwt-keys secret). -# Even with TLS disabled, the gateway needs the JWT signing key to -# issue sandbox tokens. -pkiInitJob: - enabled: true diff --git a/gateways/ocp/addons/route.yaml b/gateways/ocp/addons/route.yaml deleted file mode 100644 index 9ee1f4c..0000000 --- a/gateways/ocp/addons/route.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# OpenShift route — namespace set by kubectl -n flag. -apiVersion: route.openshift.io/v1 -kind: Route -metadata: - name: gateway -spec: - tls: - termination: passthrough - to: - kind: Service - name: openshell - port: - targetPort: grpc diff --git a/gateways/ocp/gateway.yaml b/gateways/ocp/gateway.yaml deleted file mode 100644 index d97d720..0000000 --- a/gateways/ocp/gateway.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# OpenShift gateway — deploys openshell to an OCP cluster. -# -# Prerequisites: -# - oc login to the target cluster -# - KUBECONFIG set or default context pointing to OCP - -gateway: - type: remote - platform: ocp - service: route - name: openshell-remote-ocp - -providers: - enabled: [github, vertex-local, atlassian, gws] - -chart: - oci: oci://ghcr.io/nvidia/openshell/helm-chart - version: "0.0.59" - crd: - url: https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/manifest.yaml - -helm: - values: values.yaml - -addons: - manifests: [addons/route.yaml] - -ocp: - scc-privileged: [openshell, openshell-sandbox] - scc-anyuid: [openshell] - -secrets: - mtls: openshell-client-tls diff --git a/gateways/ocp/helm/values.yaml b/gateways/ocp/helm/values.yaml deleted file mode 100644 index 82c08b6..0000000 --- a/gateways/ocp/helm/values.yaml +++ /dev/null @@ -1,34 +0,0 @@ -# OpenShell Helm values for OpenShift deployment. -# -# Override image repos/tags via environment variables in deploy-ocp.sh. -# Only non-default values are listed here. - -# Always pull images so updates are picked up without manual pod restarts. -image: - pullPolicy: Always - -supervisor: - image: - pullPolicy: Always - -# OpenShift security context: let OpenShift assign UIDs via SCC. -# runAsUser and runAsNonRoot are nulled so OpenShift's SCC assigns the UID. -# fsGroup is kept so the PVC at /var/openshell is group-writable. -securityContext: - runAsUser: null - runAsNonRoot: null - -# Always pull sandbox images (development workflow). -server: - sandboxImagePullPolicy: Always - # allowUnauthenticatedUsers: the gateway is not internet-exposed — it is - # only accessible via the OpenShift route with TLS passthrough + mTLS. - # The client cert in ~/.config/openshell/gateways/ocp/mtls/ is the gate. - # To add OIDC on top of mTLS, configure server.oidc.issuer instead. - auth: - allowUnauthenticatedUsers: true - -# PKI: auto-generate mTLS secrets on first install. -# serverDnsNames is patched at deploy time to include the Route hostname. -pkiInitJob: - enabled: true diff --git a/internal/gateway/config.go b/internal/gateway/config.go index d56a301..52e125c 100644 --- a/internal/gateway/config.go +++ b/internal/gateway/config.go @@ -44,11 +44,64 @@ type CRDConfig struct { } type HelmSection struct { - Values string `yaml:"values"` + ValuesPath string + ValuesInline map[string]any +} + +func (h *HelmSection) UnmarshalYAML(value *yaml.Node) error { + var raw struct { + Values yaml.Node `yaml:"values"` + } + if err := value.Decode(&raw); err != nil { + return err + } + if raw.Values.Kind == 0 { + return nil + } + switch raw.Values.Kind { + case yaml.ScalarNode: + h.ValuesPath = raw.Values.Value + case yaml.MappingNode: + var m map[string]any + if err := raw.Values.Decode(&m); err != nil { + return fmt.Errorf("decoding inline helm values: %w", err) + } + h.ValuesInline = m + } + return nil +} + +type ManifestRef struct { + Path string + Inline map[string]any } type AddonsSection struct { - Manifests []string `yaml:"manifests"` + Manifests []ManifestRef +} + +func (a *AddonsSection) UnmarshalYAML(value *yaml.Node) error { + var raw struct { + Manifests []yaml.Node `yaml:"manifests"` + } + if err := value.Decode(&raw); err != nil { + return err + } + for _, node := range raw.Manifests { + var ref ManifestRef + switch node.Kind { + case yaml.ScalarNode: + ref.Path = node.Value + case yaml.MappingNode: + var m map[string]any + if err := node.Decode(&m); err != nil { + return fmt.Errorf("decoding inline manifest: %w", err) + } + ref.Inline = m + } + a.Manifests = append(a.Manifests, ref) + } + return nil } type OCPSection struct { @@ -62,21 +115,37 @@ type SecretsSection struct { } func LoadConfig(dir string) (*GatewayConfig, error) { - path := filepath.Join(dir, "gateway.yaml") - data, err := os.ReadFile(path) + data, err := os.ReadFile(filepath.Join(dir, "gateway.yaml")) if err != nil { - return nil, fmt.Errorf("reading %s: %w", path, err) + return nil, fmt.Errorf("reading gateway config: %w", err) } + cfg, err := LoadConfigFromBytes(data) + if err != nil { + return nil, err + } + cfg.Dir = dir + return cfg, nil +} + +func LoadConfigFromBytes(data []byte) (*GatewayConfig, error) { var cfg GatewayConfig if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("parsing %s: %w", path, err) + return nil, fmt.Errorf("parsing gateway config: %w", err) } - cfg.Dir = dir cfg.applyDefaults() cfg.applyEnvOverrides() return &cfg, nil } +func LoadProfile(harnessDir, name string) (*GatewayConfig, error) { + path := filepath.Join(harnessDir, "profiles", "gateways", name+".yaml") + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return LoadConfigFromBytes(data) +} + func (c *GatewayConfig) applyDefaults() { if c.Chart.OCI == "" { c.Chart.OCI = "oci://ghcr.io/nvidia/openshell/helm-chart" @@ -100,17 +169,40 @@ func (c *GatewayConfig) IsOCP() bool { return c.Gateway.Platform == "ocp" } -func (c *GatewayConfig) HelmValuesPath() string { - if c.Helm.Values == "" { - return "" +func (c *GatewayConfig) HelmValuesFile(tmpDir string) (string, error) { + if c.Helm.ValuesInline != nil { + data, err := yaml.Marshal(c.Helm.ValuesInline) + if err != nil { + return "", fmt.Errorf("marshaling inline helm values: %w", err) + } + path := filepath.Join(tmpDir, "values.yaml") + if err := os.WriteFile(path, data, 0o644); err != nil { + return "", err + } + return path, nil + } + if c.Helm.ValuesPath == "" { + return "", nil } - return filepath.Join(c.Dir, "helm", c.Helm.Values) + return filepath.Join(c.Dir, "helm", c.Helm.ValuesPath), nil } -func (c *GatewayConfig) ManifestPaths() []string { - paths := make([]string, len(c.Addons.Manifests)) - for i, m := range c.Addons.Manifests { - paths[i] = filepath.Join(c.Dir, m) +func (c *GatewayConfig) ManifestFilePaths() []string { + var paths []string + for _, m := range c.Addons.Manifests { + if m.Path != "" { + paths = append(paths, filepath.Join(c.Dir, m.Path)) + } } return paths } + +func (c *GatewayConfig) ManifestInline() []map[string]any { + var manifests []map[string]any + for _, m := range c.Addons.Manifests { + if m.Inline != nil { + manifests = append(manifests, m.Inline) + } + } + return manifests +} diff --git a/internal/gateway/config_test.go b/internal/gateway/config_test.go index de85771..cd70e7a 100644 --- a/internal/gateway/config_test.go +++ b/internal/gateway/config_test.go @@ -3,6 +3,7 @@ package gateway import ( "os" "path/filepath" + "strings" "testing" ) @@ -198,7 +199,7 @@ gateway: } } -func TestHelmValuesPath(t *testing.T) { +func TestHelmValuesFile_Path(t *testing.T) { dir := t.TempDir() writeGatewayYAML(t, dir, ` gateway: @@ -212,13 +213,53 @@ helm: t.Fatal(err) } + tmpDir := t.TempDir() want := filepath.Join(dir, "helm", "values.yaml") - if got := cfg.HelmValuesPath(); got != want { - t.Errorf("HelmValuesPath() = %q, want %q", got, want) + got, err := cfg.HelmValuesFile(tmpDir) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("HelmValuesFile() = %q, want %q", got, want) + } +} + +func TestHelmValuesFile_Inline(t *testing.T) { + dir := t.TempDir() + writeGatewayYAML(t, dir, ` +gateway: + type: remote +helm: + values: + service: + type: NodePort + server: + disableTls: true +`) + + cfg, err := LoadConfig(dir) + if err != nil { + t.Fatal(err) + } + + tmpDir := t.TempDir() + got, err := cfg.HelmValuesFile(tmpDir) + if err != nil { + t.Fatal(err) + } + if got == "" { + t.Fatal("HelmValuesFile() returned empty path for inline values") + } + data, err := os.ReadFile(got) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "NodePort") { + t.Errorf("inline values file missing NodePort: %s", data) } } -func TestHelmValuesPath_Empty(t *testing.T) { +func TestHelmValuesFile_Empty(t *testing.T) { dir := t.TempDir() writeGatewayYAML(t, dir, ` gateway: @@ -230,12 +271,17 @@ gateway: t.Fatal(err) } - if got := cfg.HelmValuesPath(); got != "" { - t.Errorf("HelmValuesPath() = %q, want empty", got) + tmpDir := t.TempDir() + got, err := cfg.HelmValuesFile(tmpDir) + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Errorf("HelmValuesFile() = %q, want empty", got) } } -func TestManifestPaths(t *testing.T) { +func TestManifestFilePaths(t *testing.T) { dir := t.TempDir() writeGatewayYAML(t, dir, ` gateway: @@ -251,9 +297,9 @@ addons: t.Fatal(err) } - paths := cfg.ManifestPaths() + paths := cfg.ManifestFilePaths() if len(paths) != 2 { - t.Fatalf("ManifestPaths() returned %d paths, want 2", len(paths)) + t.Fatalf("ManifestFilePaths() returned %d paths, want 2", len(paths)) } if paths[0] != filepath.Join(dir, "addons", "rbac.yaml") { t.Errorf("paths[0] = %q", paths[0]) @@ -263,7 +309,34 @@ addons: } } -func TestManifestPaths_Empty(t *testing.T) { +func TestManifestInline(t *testing.T) { + dir := t.TempDir() + writeGatewayYAML(t, dir, ` +gateway: + type: remote +addons: + manifests: + - apiVersion: route.openshift.io/v1 + kind: Route + metadata: + name: gateway +`) + + cfg, err := LoadConfig(dir) + if err != nil { + t.Fatal(err) + } + + inline := cfg.ManifestInline() + if len(inline) != 1 { + t.Fatalf("ManifestInline() returned %d, want 1", len(inline)) + } + if inline[0]["kind"] != "Route" { + t.Errorf("expected kind=Route, got %v", inline[0]["kind"]) + } +} + +func TestManifests_Empty(t *testing.T) { dir := t.TempDir() writeGatewayYAML(t, dir, ` gateway: @@ -275,8 +348,31 @@ gateway: t.Fatal(err) } - if len(cfg.ManifestPaths()) != 0 { - t.Errorf("ManifestPaths() should be empty, got %v", cfg.ManifestPaths()) + if len(cfg.ManifestFilePaths()) != 0 { + t.Errorf("ManifestFilePaths() should be empty") + } + if len(cfg.ManifestInline()) != 0 { + t.Errorf("ManifestInline() should be empty") + } +} + +func TestLoadConfigFromBytes(t *testing.T) { + data := []byte(` +gateway: + type: remote + platform: ocp +chart: + version: "0.0.59" +`) + cfg, err := LoadConfigFromBytes(data) + if err != nil { + t.Fatal(err) + } + if cfg.Gateway.Platform != "ocp" { + t.Errorf("platform = %q, want ocp", cfg.Gateway.Platform) + } + if cfg.Chart.OCI == "" { + t.Error("defaults not applied") } } diff --git a/main.go b/main.go index f19cd13..e6b7675 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,15 @@ var version = "dev" //go:embed agents/builtin.yaml var defaultAgentConfig []byte +//go:embed profiles/gateways/local.yaml +var localGatewayProfile []byte + +//go:embed profiles/gateways/kind.yaml +var kindGatewayProfile []byte + +//go:embed profiles/gateways/ocp.yaml +var ocpGatewayProfile []byte + func main() { harnessDir := detectHarnessDir() @@ -44,6 +53,11 @@ func main() { cmd.Version = version cmd.DefaultAgentConfig = defaultAgentConfig + cmd.EmbeddedGatewayProfiles = map[string][]byte{ + "local": localGatewayProfile, + "kind": kindGatewayProfile, + "ocp": ocpGatewayProfile, + } root.CompletionOptions.HiddenDefaultCmd = true root.AddCommand( diff --git a/gateways/kind/gateway.yaml b/profiles/gateways/kind.yaml similarity index 70% rename from gateways/kind/gateway.yaml rename to profiles/gateways/kind.yaml index 730a4f5..a69a2e5 100644 --- a/gateways/kind/gateway.yaml +++ b/profiles/gateways/kind.yaml @@ -16,14 +16,16 @@ gateway: name: openshell-kind mode: direct -providers: - enabled: [github, vertex-local, atlassian, gws] - chart: - oci: oci://ghcr.io/nvidia/openshell/helm-chart version: "0.0.59" - crd: - url: https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/manifest.yaml helm: - values: values.yaml + values: + service: + type: NodePort + server: + disableTls: true + auth: + allowUnauthenticatedUsers: true + pkiInitJob: + enabled: true diff --git a/gateways/local/gateway.yaml b/profiles/gateways/local.yaml similarity index 66% rename from gateways/local/gateway.yaml rename to profiles/gateways/local.yaml index ab0494f..26763b1 100644 --- a/gateways/local/gateway.yaml +++ b/profiles/gateways/local.yaml @@ -3,12 +3,6 @@ # The openshell gateway must be installed and running: # macOS: brew install openshell && brew services start openshell # Linux: curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh -# -# The harness verifies the gateway is reachable but does not manage its lifecycle. gateway: type: local - -providers: - enabled: [github, vertex-local, atlassian] - custom: [gws] diff --git a/profiles/gateways/ocp.yaml b/profiles/gateways/ocp.yaml new file mode 100644 index 0000000..4806ffd --- /dev/null +++ b/profiles/gateways/ocp.yaml @@ -0,0 +1,53 @@ +# OpenShift gateway — deploys openshell to an OCP cluster. +# +# Prerequisites: +# - oc login to the target cluster +# - KUBECONFIG set or default context pointing to OCP + +gateway: + type: remote + platform: ocp + service: route + name: openshell-remote-ocp + +chart: + version: "0.0.59" + +helm: + values: + image: + pullPolicy: Always + supervisor: + image: + pullPolicy: Always + securityContext: + runAsUser: null + runAsNonRoot: null + server: + sandboxImagePullPolicy: Always + auth: + allowUnauthenticatedUsers: true + pkiInitJob: + enabled: true + +addons: + manifests: + - apiVersion: route.openshift.io/v1 + kind: Route + metadata: + name: gateway + spec: + tls: + termination: passthrough + to: + kind: Service + name: openshell + port: + targetPort: grpc + +ocp: + scc-privileged: [openshell, openshell-sandbox] + scc-anyuid: [openshell] + +secrets: + mtls: openshell-client-tls diff --git a/agents/providers/profiles/atlassian-user.yaml b/profiles/providers/atlassian-user.yaml similarity index 100% rename from agents/providers/profiles/atlassian-user.yaml rename to profiles/providers/atlassian-user.yaml diff --git a/agents/providers/profiles/atlassian.yaml b/profiles/providers/atlassian.yaml similarity index 100% rename from agents/providers/profiles/atlassian.yaml rename to profiles/providers/atlassian.yaml diff --git a/agents/providers/profiles/gws.yaml b/profiles/providers/gws.yaml similarity index 100% rename from agents/providers/profiles/gws.yaml rename to profiles/providers/gws.yaml From 20225abdd89a3369a3b8593c1b384590f8906f96 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 12 Jun 2026 13:14:25 -0700 Subject: [PATCH 4/8] refactor: replace --local/--remote with --gateway, rename -f to --agent-profile Replace --local and --remote boolean flags on `harness up` with --gateway NAME (resolves from profiles/gateways/) and --gateway-profile PATH (loads an explicit file). Rename --file/-f to --agent-profile/-f on both up and create for consistency. Update test-flow.sh to use the new flag names. --- cmd/create.go | 8 ++++---- cmd/deploy.go | 2 +- cmd/resolve.go | 8 ++++++++ cmd/up.go | 45 ++++++++++++++++++++++++++++----------------- test/test-flow.sh | 14 +++++++------- 5 files changed, 48 insertions(+), 29 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index d3b3b6f..ea94bd9 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -14,20 +14,20 @@ import ( func NewCreateCmd(harnessDir, cli string) *cobra.Command { var ( agentName string - agentFile string + agentProfile 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.", + Long: "Validate gateway readiness, run preflight checks, and deploy a sandbox. Does not attach interactively — use 'openshell sandbox connect' afterward.", RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 && sandboxName == "" { sandboxName = args[0] } - agentCfg, err := resolveAgentConfig(harnessDir, agentName, agentFile) + agentCfg, err := resolveAgentConfig(harnessDir, agentName, agentProfile) if err != nil { return err } @@ -88,7 +88,7 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { } cmd.Flags().StringVar(&agentName, "agent", "default", "Agent config name (from agents/)") - cmd.Flags().StringVarP(&agentFile, "file", "f", "", "Path to agent YAML file (overrides --agent)") + cmd.Flags().StringVarP(&agentProfile, "agent-profile", "f", "", "Path to agent YAML file (overrides --agent)") cmd.Flags().StringVar(&sandboxName, "name", "", "Sandbox name (overrides agent config)") return cmd diff --git a/cmd/deploy.go b/cmd/deploy.go index 0ae62e2..7f03b9a 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -25,7 +25,7 @@ func NewDeployCmd(harnessDir, cli string) *cobra.Command { cmd := &cobra.Command{ Use: "deploy [gateway]", Short: "Deploy or verify the gateway", - Long: "Deploy a gateway by name (e.g., local, ocp, kind). Reads configuration from gateways//gateway.yaml.", + Long: "Deploy a gateway by name (e.g., local, ocp, kind). Reads configuration from profiles/gateways/.yaml.", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { gatewayName, err := resolveGatewayName(args, local, remote) diff --git a/cmd/resolve.go b/cmd/resolve.go index 4561f5b..194f173 100644 --- a/cmd/resolve.go +++ b/cmd/resolve.go @@ -72,3 +72,11 @@ func resolveGatewayConfig(harnessDir, name string) (*gateway.GatewayConfig, erro } return nil, fmt.Errorf("gateway profile %q not found", name) } + +func resolveGatewayConfigFromFile(path string) (*gateway.GatewayConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading gateway profile %s: %w", path, err) + } + return gateway.LoadConfigFromBytes(data) +} diff --git a/cmd/up.go b/cmd/up.go index 1c28dfa..d31b18d 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -15,10 +15,10 @@ import ( func NewUpCmd(harnessDir, cli string) *cobra.Command { var ( - local bool - remote bool + gatewayName string + gatewayProfile string agentName string - agentFile string + agentProfile string sandboxName string noTTY bool providerRefresh bool @@ -29,32 +29,43 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { Short: "Deploy gateway, register providers, and create a sandbox", 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") + if gatewayName != "" && gatewayProfile != "" { + return fmt.Errorf("--gateway and --gateway-profile are mutually exclusive") } if len(args) > 0 && sandboxName == "" { sandboxName = args[0] } - agentCfg, err := resolveAgentConfig(harnessDir, agentName, agentFile) + agentCfg, err := resolveAgentConfig(harnessDir, agentName, agentProfile) if err != nil { return err } - agentPath := resolveAgentPath(harnessDir, agentName, agentFile) + agentPath := resolveAgentPath(harnessDir, agentName, agentProfile) gw := gateway.New(cli) if err := gw.CheckMinVersion("0.0.59"); err != nil { status.Warn(fmt.Sprintf("OpenShell version: %v", err)) } - gwName := "local" - if remote { - gwName = "ocp" - } else if !local && agentCfg.Gateway != "" { - gwName = agentCfg.Gateway + var gwCfg *gateway.GatewayConfig + gwTarget := gatewayName + if gatewayProfile != "" { + gwCfg, err = resolveGatewayConfigFromFile(gatewayProfile) + if err != nil { + return err + } + gwTarget = gwCfg.Gateway.Type + } else { + if gwTarget == "" { + if agentCfg.Gateway != "" { + gwTarget = agentCfg.Gateway + } else { + gwTarget = "local" + } + } + gwCfg, _ = resolveGatewayConfig(harnessDir, gwTarget) } - isRemote := gwName != "local" - gwCfg, _ := resolveGatewayConfig(harnessDir, gwName) + isRemote := gwTarget != "local" return upLocal(upLocalOpts{ harnessDir: harnessDir, @@ -71,10 +82,10 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { }, } - cmd.Flags().BoolVar(&local, "local", false, "Ensure local podman gateway (default when --remote is not specified)") - cmd.Flags().BoolVar(&remote, "remote", false, "Ensure OCP gateway") + cmd.Flags().StringVar(&gatewayName, "gateway", "", "Gateway profile name (local, kind, ocp)") + cmd.Flags().StringVar(&gatewayProfile, "gateway-profile", "", "Path to gateway profile YAML (overrides --gateway)") cmd.Flags().StringVar(&agentName, "agent", "default", "Agent config name (from agents/)") - cmd.Flags().StringVarP(&agentFile, "file", "f", "", "Path to agent YAML file (overrides --agent)") + cmd.Flags().StringVarP(&agentProfile, "agent-profile", "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)") cmd.Flags().BoolVar(&providerRefresh, "provider-refresh", false, "Delete and recreate all providers") diff --git a/test/test-flow.sh b/test/test-flow.sh index c5e333d..34a29bc 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -195,7 +195,7 @@ summary() { test_errors() { echo "=== test: error scenarios ===" - step_fail "nonexistent profile" "$HARNESS" up --local --agent nonexistent --no-tty + step_fail "nonexistent profile" "$HARNESS" up --gateway local --agent nonexistent --no-tty if $REUSE_GATEWAY; then step "teardown (first)" "$HARNESS" teardown --sandboxes --providers @@ -216,12 +216,12 @@ test_local() { echo "=== test-flow: local ($mode) ===" step "teardown" "$HARNESS" teardown --sandboxes --providers - step "deploy" "$HARNESS" deploy --local + step "deploy" "$HARNESS" deploy local step "gateway reachable" "$CLI" inference get # up auto-registers providers when missing local sandbox_name="test-agent" - step_output "sandbox create (up)" "$HARNESS" up --local --name "$sandbox_name" --agent "$PROFILE" --no-tty + step_output "sandbox create (up)" "$HARNESS" up --gateway local --name "$sandbox_name" --agent "$PROFILE" --no-tty sandbox_verify "$sandbox_name" step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" @@ -234,7 +234,7 @@ test_local() { echo "" echo "=== test: missing providers ===" step "teardown providers" "$HARNESS" teardown --providers - step_output "up with no providers" "$HARNESS" up --local --name test-noprov --no-tty + step_output "up with no providers" "$HARNESS" up --gateway local --name test-noprov --no-tty step "cleanup" "$HARNESS" teardown --sandboxes fi @@ -306,13 +306,13 @@ test_ocp() { step "teardown sandboxes+providers" "$HARNESS" teardown --sandboxes --providers if ! "$CLI" inference get &>/dev/null; then - step "deploy" "$HARNESS" deploy --remote + step "deploy" "$HARNESS" deploy ocp else step "gateway reachable" "$CLI" inference get fi else step "teardown" "$HARNESS" teardown --sandboxes --providers --k8s - step "deploy" "$HARNESS" deploy --remote + step "deploy" "$HARNESS" deploy ocp fi local sandbox_name @@ -321,7 +321,7 @@ test_ocp() { step_output "sandbox create" "$HARNESS" create --agent=ci --name "$sandbox_name" else sandbox_name="agent" - step_output "sandbox create (up)" "$HARNESS" up --remote --name "$sandbox_name" --no-tty + step_output "sandbox create (up)" "$HARNESS" up --gateway ocp --name "$sandbox_name" --no-tty fi sandbox_verify "$sandbox_name" From 6f018541a1328fb365c191ae4df0d363fd1a441b Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 12 Jun 2026 13:16:28 -0700 Subject: [PATCH 5/8] ci: capture verbose test-flow logs as CI artifacts When TEST_LOG_FILE is set, test-flow.sh enables --verbose on the harness binary and tees all output (including openshell commands) to the log file. Individual test steps no longer suppress output when logging is active. Integration CI jobs now set TEST_LOG_FILE and upload the combined test-flow + gateway logs as artifacts for debugging. --- .github/workflows/integration.yml | 30 +++++++++++++++++------------- test/test-flow.sh | 9 ++++++++- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 3b58d6b..8eb3ef2 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -30,20 +30,22 @@ jobs: - name: Run local integration run: make test-local + env: + TEST_LOG_FILE: /tmp/test-logs/test-flow-local.log - - name: Export gateway logs + - name: Export logs if: always() run: | - mkdir -p /tmp/gateway-logs - journalctl --user -u openshell-gateway --no-pager > /tmp/gateway-logs/gateway.log 2>/dev/null || true - openshell status > /tmp/gateway-logs/status.txt 2>&1 || true - openshell gateway list > /tmp/gateway-logs/gateways.txt 2>&1 || true + mkdir -p /tmp/test-logs + journalctl --user -u openshell-gateway --no-pager > /tmp/test-logs/gateway.log 2>/dev/null || true + openshell status > /tmp/test-logs/status.txt 2>&1 || true + openshell gateway list > /tmp/test-logs/gateways.txt 2>&1 || true - uses: actions/upload-artifact@v4 if: always() with: - name: gateway-logs-local - path: /tmp/gateway-logs/ + name: test-logs-local + path: /tmp/test-logs/ kind: runs-on: ubuntu-latest @@ -75,17 +77,19 @@ jobs: - name: Run kind integration run: make test-kind + env: + TEST_LOG_FILE: /tmp/test-logs/test-flow-kind.log - name: Export logs if: always() run: | - mkdir -p /tmp/kind-logs - kubectl -n openshell logs statefulset/openshell > /tmp/kind-logs/gateway.log 2>/dev/null || true - kubectl -n openshell get all > /tmp/kind-logs/resources.txt 2>&1 || true - openshell gateway list > /tmp/kind-logs/gateways.txt 2>&1 || true + mkdir -p /tmp/test-logs + kubectl -n openshell logs statefulset/openshell > /tmp/test-logs/gateway.log 2>/dev/null || true + kubectl -n openshell get all > /tmp/test-logs/resources.txt 2>&1 || true + openshell gateway list > /tmp/test-logs/gateways.txt 2>&1 || true - uses: actions/upload-artifact@v4 if: always() with: - name: gateway-logs-kind - path: /tmp/kind-logs/ + name: test-logs-kind + path: /tmp/test-logs/ diff --git a/test/test-flow.sh b/test/test-flow.sh index 34a29bc..ea194b2 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -56,6 +56,13 @@ if $DEBUG; then HARNESS="$HARNESS --show-commands" fi +LOG_FILE="${TEST_LOG_FILE:-}" +if [[ -n "$LOG_FILE" ]]; then + mkdir -p "$(dirname "$LOG_FILE")" + HARNESS="$HARNESS --verbose" + exec > >(tee -a "$LOG_FILE") 2>&1 +fi + # ── Helpers ────────────────────────────────────────────────────────── strip_ansi() { @@ -69,7 +76,7 @@ TOTAL_START=$(date +%s) step() { local label="$1"; shift local start=$(date +%s) - if $DEBUG; then + if $DEBUG || [[ -n "$LOG_FILE" ]]; then if "$@"; then local elapsed=$(( $(date +%s) - start )) printf " ✓ %-35s (%ds)\n" "$label" "$elapsed" From 05f6cf172f8b3e2c4c58f5407241b212197b1454 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 12 Jun 2026 13:22:58 -0700 Subject: [PATCH 6/8] fix: use shell function for harness invocations in test-flow The previous approach appended --verbose to the HARNESS string variable, which broke when the shell tried to execute a file literally named "harness --verbose". Use a harness() shell function that expands the flags array properly. --- cmd/up.go | 2 +- test/test-flow.sh | 59 +++++++++++++++++++++++++---------------------- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/cmd/up.go b/cmd/up.go index d31b18d..ca72023 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -140,7 +140,7 @@ func upLocal(opts upLocalOpts) error { } } else if gw.InferenceGet() != nil { if opts.gwCfg == nil { - return fmt.Errorf("no active gateway — use --local or: harness deploy ocp") + return fmt.Errorf("no active gateway — use --gateway local or: harness deploy ocp") } kc := k8s.New("", k8s.DefaultNamespace()) clusterRunner := k8s.New("", "") diff --git a/test/test-flow.sh b/test/test-flow.sh index ea194b2..dfdf9c3 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -52,17 +52,22 @@ if [[ -z "$TARGET" ]]; then exit 1 fi +HARNESS_FLAGS=() if $DEBUG; then - HARNESS="$HARNESS --show-commands" + HARNESS_FLAGS+=(--show-commands) fi LOG_FILE="${TEST_LOG_FILE:-}" if [[ -n "$LOG_FILE" ]]; then mkdir -p "$(dirname "$LOG_FILE")" - HARNESS="$HARNESS --verbose" + HARNESS_FLAGS+=(--verbose) exec > >(tee -a "$LOG_FILE") 2>&1 fi +harness() { + "$HARNESS" "${HARNESS_FLAGS[@]}" "$@" +} + # ── Helpers ────────────────────────────────────────────────────────── strip_ansi() { @@ -202,14 +207,14 @@ summary() { test_errors() { echo "=== test: error scenarios ===" - step_fail "nonexistent profile" "$HARNESS" up --gateway local --agent nonexistent --no-tty + step_fail "nonexistent profile" harness up --gateway local --agent nonexistent --no-tty if $REUSE_GATEWAY; then - step "teardown (first)" "$HARNESS" teardown --sandboxes --providers - step "teardown (second)" "$HARNESS" teardown --sandboxes --providers + step "teardown (first)" harness teardown --sandboxes --providers + step "teardown (second)" harness teardown --sandboxes --providers else - step "teardown (first)" "$HARNESS" teardown --sandboxes --providers --k8s - step "teardown (second)" "$HARNESS" teardown --sandboxes --providers --k8s + step "teardown (first)" harness teardown --sandboxes --providers --k8s + step "teardown (second)" harness teardown --sandboxes --providers --k8s fi echo "" @@ -222,30 +227,30 @@ test_local() { $NO_PROVIDERS && mode="$mode, no-providers" echo "=== test-flow: local ($mode) ===" - step "teardown" "$HARNESS" teardown --sandboxes --providers - step "deploy" "$HARNESS" deploy local + step "teardown" harness teardown --sandboxes --providers + step "deploy" harness deploy local step "gateway reachable" "$CLI" inference get # up auto-registers providers when missing local sandbox_name="test-agent" - step_output "sandbox create (up)" "$HARNESS" up --gateway local --name "$sandbox_name" --agent "$PROFILE" --no-tty + step_output "sandbox create (up)" harness up --gateway local --name "$sandbox_name" --agent "$PROFILE" --no-tty sandbox_verify "$sandbox_name" step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" local create_name="test-create" - step_output "sandbox create (create)" "$HARNESS" create --name "$create_name" --agent ci + step_output "sandbox create (create)" harness create --name "$create_name" --agent ci step "sandbox verify (create)" "$CLI" sandbox exec --name "$create_name" -- echo "hello" step "sandbox delete (create)" "$CLI" sandbox delete "$create_name" if ! $NO_PROVIDERS; then echo "" echo "=== test: missing providers ===" - step "teardown providers" "$HARNESS" teardown --providers - step_output "up with no providers" "$HARNESS" up --gateway local --name test-noprov --no-tty - step "cleanup" "$HARNESS" teardown --sandboxes + step "teardown providers" harness teardown --providers + step_output "up with no providers" harness up --gateway local --name test-noprov --no-tty + step "cleanup" harness teardown --sandboxes fi - step "teardown (clean)" "$HARNESS" teardown --sandboxes --providers + step "teardown (clean)" harness teardown --sandboxes --providers } # ── GWS lifecycle test ─────────────────────────────────────────────── @@ -282,12 +287,12 @@ test_kind() { return fi - step "teardown" "$HARNESS" teardown --sandboxes --providers --k8s - step "deploy" "$HARNESS" deploy kind + step "teardown" harness teardown --sandboxes --providers --k8s + step "deploy" harness deploy kind step "gateway reachable" "$CLI" inference get local sandbox_name="test-kind" - step_output "sandbox create" "$HARNESS" up --name "$sandbox_name" --agent "$PROFILE" --no-tty + step_output "sandbox create" harness up --name "$sandbox_name" --agent "$PROFILE" --no-tty sandbox_verify "$sandbox_name" if ! $NO_PROVIDERS; then @@ -296,7 +301,7 @@ test_kind() { step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" - step "teardown (clean)" "$HARNESS" teardown --sandboxes --providers --k8s + step "teardown (clean)" harness teardown --sandboxes --providers --k8s echo "" } @@ -311,33 +316,33 @@ test_ocp() { OCP_GW=$("$CLI" gateway list 2>/dev/null | strip_ansi | awk '/-remote-/ {gsub(/^\*/, ""); print $1; exit}') [[ -n "$OCP_GW" ]] && "$CLI" gateway select "$OCP_GW" 2>/dev/null || true - step "teardown sandboxes+providers" "$HARNESS" teardown --sandboxes --providers + step "teardown sandboxes+providers" harness teardown --sandboxes --providers if ! "$CLI" inference get &>/dev/null; then - step "deploy" "$HARNESS" deploy ocp + step "deploy" harness deploy ocp else step "gateway reachable" "$CLI" inference get fi else - step "teardown" "$HARNESS" teardown --sandboxes --providers --k8s - step "deploy" "$HARNESS" deploy ocp + step "teardown" harness teardown --sandboxes --providers --k8s + step "deploy" harness deploy ocp fi local sandbox_name if $NO_PROVIDERS; then sandbox_name="test-ocp" - step_output "sandbox create" "$HARNESS" create --agent=ci --name "$sandbox_name" + step_output "sandbox create" harness create --agent=ci --name "$sandbox_name" else sandbox_name="agent" - step_output "sandbox create (up)" "$HARNESS" up --gateway ocp --name "$sandbox_name" --no-tty + step_output "sandbox create (up)" harness up --gateway ocp --name "$sandbox_name" --no-tty fi sandbox_verify "$sandbox_name" step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" if $REUSE_GATEWAY; then - step "teardown (sandboxes+providers)" "$HARNESS" teardown --sandboxes --providers + step "teardown (sandboxes+providers)" harness teardown --sandboxes --providers else - step "teardown (clean)" "$HARNESS" teardown --sandboxes --providers --k8s + step "teardown (clean)" harness teardown --sandboxes --providers --k8s fi } From 0c22e09ddff281e0111943fc18a8cfb1b5e06148 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 12 Jun 2026 13:29:42 -0700 Subject: [PATCH 7/8] ci: make verbose output the default in test-flow Always pass --verbose to harness. Remove step_output (was identical to step after simplification). Remove output suppression from step and step_fail. When TEST_LOG_FILE is set, tee captures everything to the log file automatically. --- test/test-flow.sh | 62 ++++++++++++----------------------------------- 1 file changed, 16 insertions(+), 46 deletions(-) diff --git a/test/test-flow.sh b/test/test-flow.sh index dfdf9c3..464faf6 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -52,7 +52,7 @@ if [[ -z "$TARGET" ]]; then exit 1 fi -HARNESS_FLAGS=() +HARNESS_FLAGS=(--verbose) if $DEBUG; then HARNESS_FLAGS+=(--show-commands) fi @@ -60,7 +60,6 @@ fi LOG_FILE="${TEST_LOG_FILE:-}" if [[ -n "$LOG_FILE" ]]; then mkdir -p "$(dirname "$LOG_FILE")" - HARNESS_FLAGS+=(--verbose) exec > >(tee -a "$LOG_FILE") 2>&1 fi @@ -81,33 +80,21 @@ TOTAL_START=$(date +%s) step() { local label="$1"; shift local start=$(date +%s) - if $DEBUG || [[ -n "$LOG_FILE" ]]; then - if "$@"; then - local elapsed=$(( $(date +%s) - start )) - printf " ✓ %-35s (%ds)\n" "$label" "$elapsed" - ((PASS++)) - else - local elapsed=$(( $(date +%s) - start )) - printf " ✗ %-35s (%ds)\n" "$label" "$elapsed" - ((FAIL++)) - fi + if "$@"; then + local elapsed=$(( $(date +%s) - start )) + printf " ✓ %-35s (%ds)\n" "$label" "$elapsed" + ((PASS++)) else - if "$@" &>/dev/null; then - local elapsed=$(( $(date +%s) - start )) - printf " ✓ %-35s (%ds)\n" "$label" "$elapsed" - ((PASS++)) - else - local elapsed=$(( $(date +%s) - start )) - printf " ✗ %-35s (%ds)\n" "$label" "$elapsed" - ((FAIL++)) - fi + local elapsed=$(( $(date +%s) - start )) + printf " ✗ %-35s (%ds)\n" "$label" "$elapsed" + ((FAIL++)) fi } step_fail() { local label="$1"; shift local start=$(date +%s) - if ! "$@" &>/dev/null; then + if ! "$@"; then local elapsed=$(( $(date +%s) - start )) printf " ✓ %-35s (expected failure, %ds)\n" "$label" "$elapsed" ((PASS++)) @@ -118,23 +105,6 @@ step_fail() { fi } -step_output() { - local label="$1"; shift - local start=$(date +%s) - local out - out=$("$@" 2>&1) - local rc=$? - local elapsed=$(( $(date +%s) - start )) - if [[ $rc -eq 0 ]]; then - printf " ✓ %-35s (%ds)\n" "$label" "$elapsed" - ((PASS++)) - else - printf " ✗ %-35s (%ds)\n" "$label" "$elapsed" - echo " $out" | head -3 - ((FAIL++)) - fi -} - check_providers() { local count count=$("$CLI" provider list 2>/dev/null | awk 'NR>1' | wc -l | tr -d ' ') @@ -188,7 +158,7 @@ sandbox_verify() { 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 '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' + step "sandbox: claude responds" "$CLI" sandbox exec --name "$name" -- bash -c 'echo "respond with ok" | claude --bare --print 2>&1 | head -1' } summary() { @@ -233,12 +203,12 @@ test_local() { # up auto-registers providers when missing local sandbox_name="test-agent" - step_output "sandbox create (up)" harness up --gateway local --name "$sandbox_name" --agent "$PROFILE" --no-tty + step "sandbox create (up)" harness up --gateway local --name "$sandbox_name" --agent "$PROFILE" --no-tty sandbox_verify "$sandbox_name" step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" local create_name="test-create" - step_output "sandbox create (create)" harness create --name "$create_name" --agent ci + step "sandbox create (create)" harness create --name "$create_name" --agent ci step "sandbox verify (create)" "$CLI" sandbox exec --name "$create_name" -- echo "hello" step "sandbox delete (create)" "$CLI" sandbox delete "$create_name" @@ -246,7 +216,7 @@ test_local() { echo "" echo "=== test: missing providers ===" step "teardown providers" harness teardown --providers - step_output "up with no providers" harness up --gateway local --name test-noprov --no-tty + step "up with no providers" harness up --gateway local --name test-noprov --no-tty step "cleanup" harness teardown --sandboxes fi @@ -292,7 +262,7 @@ test_kind() { step "gateway reachable" "$CLI" inference get local sandbox_name="test-kind" - step_output "sandbox create" harness up --name "$sandbox_name" --agent "$PROFILE" --no-tty + step "sandbox create" harness up --name "$sandbox_name" --agent "$PROFILE" --no-tty sandbox_verify "$sandbox_name" if ! $NO_PROVIDERS; then @@ -330,10 +300,10 @@ test_ocp() { local sandbox_name if $NO_PROVIDERS; then sandbox_name="test-ocp" - step_output "sandbox create" harness create --agent=ci --name "$sandbox_name" + step "sandbox create" harness create --agent=ci --name "$sandbox_name" else sandbox_name="agent" - step_output "sandbox create (up)" harness up --gateway ocp --name "$sandbox_name" --no-tty + step "sandbox create (up)" harness up --gateway ocp --name "$sandbox_name" --no-tty fi sandbox_verify "$sandbox_name" From 232987935b9dbe144299f60b0e4a2435c5159416 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 12 Jun 2026 14:01:04 -0700 Subject: [PATCH 8/8] fix: sync docs with new CLI flags, fix gateway config resolution Update README and SPEC command references to use --gateway/--agent-profile instead of removed --local/--remote/-f flags. Remove deleted docs/proto-migration.md from Documentation Map. Fix stale profiles/default.toml references in release-plan.md. Fix resolveGatewayConfigFromFile to set cfg.Dir for file-path-based helm values resolution. Fix resolveGatewayConfig to surface parse errors instead of silently falling through. Add --gateway kind to kind test flow. Add yaml:"-" tags to custom-unmarshaled structs. --- README.md | 15 ++++++++------- SPEC.md | 8 ++++---- cmd/resolve.go | 21 ++++++++++++++++++--- docs/release-plan.md | 6 +++--- internal/gateway/config.go | 8 ++++---- test/test-flow.sh | 2 +- 6 files changed, 38 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 58b9fdb..2750a1f 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ harness up -f agents/ocp.yaml # deploys to OCP (reads profiles/gateways/ocp.y harness up # defaults to local Podman ``` -`--local` and `--remote` flags override the `gateway:` field. +`--gateway local` and `--gateway ocp` flags override the `gateway:` field. For OCP, the harness first deploys the gateway via Helm, then runs the same provider registration and sandbox creation. The full sequence: @@ -240,7 +240,7 @@ make cli ./harness up ``` -For remote OpenShift: `./harness up --remote` (requires `kubectl`, `helm`, cluster access). +For remote OpenShift: `./harness up --gateway ocp` (requires `kubectl`, `helm`, cluster access). ## How It Works @@ -277,15 +277,17 @@ See the [OpenShell docs](https://github.com/NVIDIA/OpenShell) for the full secur ## Commands ``` -harness up [--local|--remote] [--agent NAME] [-f FILE] [--name SANDBOX] [--no-tty] [--provider-refresh] +harness up [--gateway NAME] [--gateway-profile FILE] [--agent NAME] [--agent-profile|-f FILE] [--name SANDBOX] [--no-tty] [--provider-refresh] Deploy gateway + register providers + create sandbox. - Defaults to local gateway (use --remote for OCP). + Defaults to local gateway (use --gateway ocp for OCP). + --gateway selects a gateway profile by name (local, kind, ocp). + --gateway-profile loads a gateway profile from a file path. --agent defaults to "default" (embedded or agents/default.yaml). - -f renders any agent YAML file directly. + --agent-profile (-f) renders any agent YAML file directly. --no-tty disables TTY allocation. --provider-refresh deletes and recreates all providers. -harness create [--agent NAME] [-f FILE] [--name SANDBOX] +harness create [--agent NAME] [--agent-profile|-f FILE] [--name SANDBOX] Create a sandbox without deploying the gateway. Assumes gateway is running. Auto-registers missing providers. @@ -315,4 +317,3 @@ For sandbox connect/logs, use openshell directly: | [TODO.md](TODO.md) | Roadmap and known gaps | | [docs/archive/](docs/archive/README.md) | Historical design docs (e.g. the June 2026 design-v1 proposal) -- outdated, kept for context | | [docs/release-plan.md](docs/release-plan.md) | Release phases: CI (done), embed + `harness init`, GoReleaser | -| [docs/proto-migration.md](docs/proto-migration.md) | Deferred plan to adopt proto-generated config types | diff --git a/SPEC.md b/SPEC.md index e7e0894..8267b3c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -53,13 +53,13 @@ Provider profiles live in `profiles/providers/`. These are imported to the gatew ## CLI -### `harness up [--local|--remote] [--agent NAME] [-f FILE] [--name SANDBOX] [--no-tty] [--provider-refresh]` +### `harness up [--gateway NAME] [--gateway-profile FILE] [--agent NAME] [--agent-profile|-f FILE] [--name SANDBOX] [--no-tty] [--provider-refresh]` Full flow: deploy gateway, register providers, render agent config, create sandbox. 1. **Check version** -- warn if openshell CLI is below v0.0.59. -2. **Ensure gateway** -- deploy if needed (local: Podman, remote: Helm to K8s/OCP). -3. **Parse agent config** -- read `agents/.yaml` (default: `default`). `-f` overrides with a direct file path. +2. **Ensure gateway** -- deploy if needed (local: Podman, remote: Helm to K8s/OCP). `--gateway` selects a profile by name; `--gateway-profile` loads from a file path. +3. **Parse agent config** -- read `agents/.yaml` (default: `default`). `--agent-profile` (`-f`) overrides with a direct file path. 4. **Ensure providers** -- auto-register missing providers. Three registration flows: - **Standard** (`--from-existing`): GitHub, Atlassian -- OpenShell discovers credentials from local env. - **ADC** (`--from-gcloud-adc`): Vertex AI -- reads ADC file, configures inference routing. @@ -69,7 +69,7 @@ Full flow: deploy gateway, register providers, render agent config, create sandb `--provider-refresh` deletes and recreates all providers (replaces the old `harness providers --force`). -### `harness create [--agent NAME] [-f FILE] [--name SANDBOX]` +### `harness create [--agent NAME] [--agent-profile|-f FILE] [--name SANDBOX]` Create a sandbox without deploying the gateway. Assumes gateway is running. Auto-registers missing providers. diff --git a/cmd/resolve.go b/cmd/resolve.go index 194f173..4ed7a60 100644 --- a/cmd/resolve.go +++ b/cmd/resolve.go @@ -1,7 +1,9 @@ package cmd import ( + "errors" "fmt" + "io/fs" "os" "path/filepath" @@ -60,13 +62,21 @@ func versionedImage(name string) string { var EmbeddedGatewayProfiles map[string][]byte func resolveGatewayConfig(harnessDir, name string) (*gateway.GatewayConfig, error) { - if cfg, err := gateway.LoadProfile(harnessDir, name); err == nil { + cfg, err := gateway.LoadProfile(harnessDir, name) + if err == nil { return cfg, nil } + if !errors.Is(err, fs.ErrNotExist) { + return nil, err + } gwDir := filepath.Join(harnessDir, "gateways", name) - if cfg, err := gateway.LoadConfig(gwDir); err == nil { + cfg, err = gateway.LoadConfig(gwDir) + if err == nil { return cfg, nil } + if !errors.Is(err, fs.ErrNotExist) { + return nil, err + } if data, ok := EmbeddedGatewayProfiles[name]; ok { return gateway.LoadConfigFromBytes(data) } @@ -78,5 +88,10 @@ func resolveGatewayConfigFromFile(path string) (*gateway.GatewayConfig, error) { if err != nil { return nil, fmt.Errorf("reading gateway profile %s: %w", path, err) } - return gateway.LoadConfigFromBytes(data) + cfg, err := gateway.LoadConfigFromBytes(data) + if err != nil { + return nil, err + } + cfg.Dir = filepath.Dir(path) + return cfg, nil } diff --git a/docs/release-plan.md b/docs/release-plan.md index ee7bef4..f6d698a 100644 --- a/docs/release-plan.md +++ b/docs/release-plan.md @@ -43,7 +43,7 @@ var Files embed.FS Create directory `internal/embed/files/` populated by `make embed-sync`. Runtime files to embed: - `sandbox/profiles/atlassian.yaml` - `sandbox/CLAUDE.md`, `settings.json`, `mcp.json`, `policy.yaml`, `startup.sh` -- `profiles/default.toml` +- `agents/default.yaml` - `values-ocp.yaml` **Decision: copy, not symlink.** `go:embed` doesn't follow symlinks. `make embed-sync` copies originals into `internal/embed/files/`. GoReleaser hooks call this automatically. @@ -64,7 +64,7 @@ Add `~/.openshell/harness/` as fallback: 1. $HARNESS_DIR env var 2. Walk up from executable location 3. Walk up from cwd -4. ~/.openshell/harness/ (if profiles/default.toml exists there) +4. ~/.openshell/harness/ (if agents/default.yaml exists there) 5. Error + exit ``` @@ -108,7 +108,7 @@ harness init ```bash git clone ... && cd harness-openshell make cli -./harness up --local +./harness up ``` --- diff --git a/internal/gateway/config.go b/internal/gateway/config.go index 52e125c..fac18bf 100644 --- a/internal/gateway/config.go +++ b/internal/gateway/config.go @@ -44,8 +44,8 @@ type CRDConfig struct { } type HelmSection struct { - ValuesPath string - ValuesInline map[string]any + ValuesPath string `yaml:"-"` + ValuesInline map[string]any `yaml:"-"` } func (h *HelmSection) UnmarshalYAML(value *yaml.Node) error { @@ -72,8 +72,8 @@ func (h *HelmSection) UnmarshalYAML(value *yaml.Node) error { } type ManifestRef struct { - Path string - Inline map[string]any + Path string `yaml:"-"` + Inline map[string]any `yaml:"-"` } type AddonsSection struct { diff --git a/test/test-flow.sh b/test/test-flow.sh index 464faf6..01cb0f5 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -262,7 +262,7 @@ test_kind() { step "gateway reachable" "$CLI" inference get local sandbox_name="test-kind" - step "sandbox create" harness up --name "$sandbox_name" --agent "$PROFILE" --no-tty + step "sandbox create" harness up --gateway kind --name "$sandbox_name" --agent "$PROFILE" --no-tty sandbox_verify "$sandbox_name" if ! $NO_PROVIDERS; then