diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b09155..6acd464 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,13 +16,8 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: go.mod - - name: Install bats - run: sudo apt-get install -y bats - run: go vet ./... - run: CGO_ENABLED=0 go test ./... - - name: Build binary for bats - run: CGO_ENABLED=0 go build -o harness . - - run: bats test/preflight.bats lint: runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index ad61e87..2b7bbd0 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ ## OpenShell Harness — build, push, and test ## ## Tests (CI mode auto-detects from CI env var): -## make test # vet + unit + bats (~2min) +## make test # vet + unit tests ## make test-local # local gateway integration ## make test-kind # kind integration (self-contained cluster) ## make test-remote # OCP integration (needs KUBECONFIG) @@ -54,10 +54,9 @@ lint: ## CI mode auto-detects from the CI env var (set by GitHub Actions). ## Locally: full tests with credentials. On GHA: no-credential mode. -## Vet + unit tests + bats (fast, ~2min, no gateway needed) +## Vet + unit tests test: vet CGO_ENABLED=0 go test ./... - bats test/preflight.bats ## Local gateway integration (unit tests run separately via 'make test') test-local: cli diff --git a/agents/demo.yaml b/agents/demo.yaml index f021aa1..2443d27 100644 --- a/agents/demo.yaml +++ b/agents/demo.yaml @@ -4,7 +4,7 @@ # harness up --local --agent demo name: demo -entrypoint: claude -p +entrypoint: claude task: demo/DEMO-TASK.md tty: false diff --git a/agents/opencode.yaml b/agents/opencode.yaml new file mode 100644 index 0000000..a03f443 --- /dev/null +++ b/agents/opencode.yaml @@ -0,0 +1,22 @@ +# OpenCode agent configuration. +# +# Usage: +# harness up -f agents/opencode.yaml +# harness up --agent opencode + +name: opencode-agent +entrypoint: opencode +tty: true + +providers: + - profile: github + - profile: vertex-local + - profile: atlassian + config: + JIRA_URL: ${JIRA_URL} + JIRA_USERNAME: ${JIRA_USERNAME} + - profile: gws + +env: + ANTHROPIC_BASE_URL: https://inference.local + ANTHROPIC_API_KEY: sk-ant-openshell-proxy-managed diff --git a/cmd/create.go b/cmd/create.go index bb2194d..77381f4 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -3,13 +3,10 @@ package cmd import ( "fmt" "os" - "path/filepath" - "slices" "time" "github.com/robbycochran/harness-openshell/internal/agent" "github.com/robbycochran/harness-openshell/internal/gateway" - "github.com/robbycochran/harness-openshell/internal/preflight" "github.com/robbycochran/harness-openshell/internal/status" "github.com/spf13/cobra" ) @@ -56,51 +53,24 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { status.Infof("Name: %s", name) status.Infof("Image: %s", sandboxImage) - // 3. Validate providers are registered + // 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, nil, false); 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) } - if len(missing) > 0 && len(registered) == 0 { - return fmt.Errorf("no providers available — run: harness providers") - } - - // 4. Load provider definitions - providersPath := filepath.Join(harnessDir, "providers.toml") - allProviders, _ := preflight.LoadProviders(providersPath) - - // 5. Run preflight checks (only for unregistered providers) - if len(missing) > 0 && allProviders != nil { - status.Header("Preflight") - preflightOK := true - for _, p := range allProviders { - if !slices.Contains(missing, p.Name) { - continue - } - ok, details := preflight.CheckProvider(p) - if ok { - status.OKf("%s: ready", p.Name) - } else { - status.Failf("%s: prerequisites missing", p.Name) - if p.Required { - preflightOK = false - } - } - for _, d := range details { - status.Detail(d) - } - } - if !preflightOK { - return fmt.Errorf("preflight checks failed — fix issues above") - } - } - // 6. Deploy the sandbox + // 4. Deploy the sandbox status.Header("Creating sandbox") payloadDir, err := os.MkdirTemp("", "harness-payload-") if err != nil { diff --git a/cmd/deploy.go b/cmd/deploy.go index 31b06e6..9ffda50 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -11,7 +11,6 @@ import ( "github.com/robbycochran/harness-openshell/internal/gateway" "github.com/robbycochran/harness-openshell/internal/k8s" - "github.com/robbycochran/harness-openshell/internal/preflight" "github.com/robbycochran/harness-openshell/internal/status" "github.com/spf13/cobra" ) @@ -26,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.toml.", + Long: "Deploy a gateway by name (e.g., local, ocp, kind). Reads configuration from gateways//gateway.yaml.", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { gatewayName, err := resolveGatewayName(args, local, remote) @@ -148,12 +147,6 @@ func deployFromConfig(harnessDir string, gwCfg *gateway.GatewayConfig, gw gatewa namespace := k8s.DefaultNamespace() chartVersion := os.Getenv("OPENSHELL_CHART_VERSION") - if chartVersion == "" { - cfg, _ := preflight.LoadConfig(filepath.Join(harnessDir, "openshell.toml")) - if cfg != nil && cfg.Upstream.ChartVersion != "" { - chartVersion = cfg.Upstream.ChartVersion - } - } if chartVersion == "" { chartVersion = gwCfg.Chart.Version } diff --git a/cmd/deploy_test.go b/cmd/deploy_test.go index 2dcd76d..385d05e 100644 --- a/cmd/deploy_test.go +++ b/cmd/deploy_test.go @@ -23,32 +23,27 @@ func setupOCPGatewayConfig(t *testing.T, dir string) string { gwDir := filepath.Join(dir, "gateways", "ocp") os.MkdirAll(filepath.Join(gwDir, "helm"), 0o755) os.MkdirAll(filepath.Join(gwDir, "addons"), 0o755) - os.WriteFile(filepath.Join(gwDir, "gateway.toml"), []byte(` -[gateway] -type = "remote" -platform = "ocp" -service = "route" -name = "test-ocp" - -[chart] -oci = "oci://ghcr.io/nvidia/openshell/helm-chart" -version = "0.0.58" -[chart.crd] -url = "https://example.com/crd.yaml" - -[helm] -values = "values.yaml" - -[addons] -manifests = ["addons/rbac.yaml", "addons/route.yaml"] - -[ocp] -scc-privileged = ["sa1", "sa2"] -scc-anyuid = ["sa1"] - -[secrets] -names = ["secret-a"] -mtls = "test-client-tls" + os.WriteFile(filepath.Join(gwDir, "gateway.yaml"), []byte(` +gateway: + type: remote + platform: ocp + service: route + name: test-ocp +chart: + oci: oci://ghcr.io/nvidia/openshell/helm-chart + version: "0.0.58" + crd: + url: https://example.com/crd.yaml +helm: + values: values.yaml +addons: + manifests: [addons/rbac.yaml, addons/route.yaml] +ocp: + scc-privileged: [sa1, sa2] + scc-anyuid: [sa1] +secrets: + names: [secret-a] + mtls: test-client-tls `), 0o644) os.WriteFile(filepath.Join(gwDir, "helm", "values.yaml"), []byte("image:\n pullPolicy: Always\n"), 0o644) os.WriteFile(filepath.Join(gwDir, "addons", "rbac.yaml"), []byte("# rbac\n"), 0o644) @@ -60,20 +55,18 @@ func setupK8sGatewayConfig(t *testing.T, dir string) string { t.Helper() gwDir := filepath.Join(dir, "gateways", "kind") os.MkdirAll(gwDir, 0o755) - os.WriteFile(filepath.Join(gwDir, "gateway.toml"), []byte(` -[gateway] -type = "remote" -platform = "k8s" -service = "nodeport" -name = "test-kind" -mode = "direct" - -[chart] -oci = "oci://ghcr.io/nvidia/openshell/helm-chart" -version = "0.0.58" -[chart.crd] -url = "https://example.com/crd.yaml" - + os.WriteFile(filepath.Join(gwDir, "gateway.yaml"), []byte(` +gateway: + type: remote + platform: k8s + service: nodeport + name: test-kind + mode: direct +chart: + oci: oci://ghcr.io/nvidia/openshell/helm-chart + version: "0.0.58" + crd: + url: https://example.com/crd.yaml `), 0o644) return gwDir } diff --git a/cmd/preflight.go b/cmd/preflight.go deleted file mode 100644 index ace111b..0000000 --- a/cmd/preflight.go +++ /dev/null @@ -1,36 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/robbycochran/harness-openshell/internal/gateway" - "github.com/robbycochran/harness-openshell/internal/preflight" - "github.com/spf13/cobra" -) - -func NewPreflightCmd(harnessDir, cli string) *cobra.Command { - var strict bool - - cmd := &cobra.Command{ - Use: "preflight", - Short: "Check environment prerequisites", - RunE: func(cmd *cobra.Command, args []string) error { - if len(args) > 0 { - switch args[0] { - case "available": - return preflight.RunAvailable(harnessDir) - case "names": - return preflight.RunNames(harnessDir) - default: - return fmt.Errorf("unknown preflight subcommand: %s (use 'available' or 'names')", args[0]) - } - } - gw := gateway.New(cli) - return preflight.RunCheck(harnessDir, gw, strict) - }, - } - - cmd.Flags().BoolVar(&strict, "strict", false, "Exit 1 if required providers missing") - - return cmd -} diff --git a/cmd/providers.go b/cmd/providers.go index b5b45b4..1a1b5b9 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -10,27 +10,9 @@ import ( "github.com/robbycochran/harness-openshell/internal/gateway" "github.com/robbycochran/harness-openshell/internal/status" - "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) -func NewProvidersCmd(harnessDir, cli string) *cobra.Command { - var force bool - - cmd := &cobra.Command{ - Use: "providers", - Short: "Register providers with the gateway", - RunE: func(cmd *cobra.Command, args []string) error { - gw := gateway.New(cli) - return registerProviders(harnessDir, gw, force, nil, true) - }, - } - - cmd.Flags().BoolVar(&force, "force", false, "Delete and recreate all providers") - - return cmd -} - // registerProviders registers providers with the gateway. If gwCfg is non-nil // and has a [providers] section, only providers in that list are registered. // Otherwise all providers are registered (backward-compatible behavior). diff --git a/cmd/up.go b/cmd/up.go index 4131207..4b54469 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -16,11 +16,12 @@ import ( func NewUpCmd(harnessDir, cli string) *cobra.Command { var ( local bool - remote bool - agentName string - agentFile string - sandboxName string - noTTY bool + remote bool + agentName string + agentFile string + sandboxName string + noTTY bool + providerRefresh bool ) cmd := &cobra.Command{ @@ -54,15 +55,16 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { gwCfg, _ := gateway.LoadConfig(gwDir) return upLocal(upLocalOpts{ - harnessDir: harnessDir, - gw: gw, - gwCfg: gwCfg, - ensureLocal: !remote, - agentCfg: agentCfg, - agentPath: agentPath, - sandboxName: sandboxName, - noTTY: noTTY, - retrySleep: 5 * time.Second, + harnessDir: harnessDir, + gw: gw, + gwCfg: gwCfg, + ensureLocal: !remote, + agentCfg: agentCfg, + agentPath: agentPath, + sandboxName: sandboxName, + noTTY: noTTY, + providerRefresh: providerRefresh, + retrySleep: 5 * time.Second, }) }, } @@ -73,6 +75,7 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { cmd.Flags().StringVarP(&agentFile, "file", "f", "", "Path to agent YAML file (overrides --agent)") cmd.Flags().StringVar(&sandboxName, "name", "", "Sandbox name (overrides agent config)") cmd.Flags().BoolVar(&noTTY, "no-tty", false, "Non-interactive mode (for testing)") + cmd.Flags().BoolVar(&providerRefresh, "provider-refresh", false, "Delete and recreate all providers") return cmd } @@ -102,15 +105,16 @@ func resolveAgentConfig(harnessDir, agentName, agentFile string) (*agent.AgentCo } type upLocalOpts struct { - harnessDir string - gw gateway.Gateway - gwCfg *gateway.GatewayConfig - ensureLocal bool - agentCfg *agent.AgentConfig - agentPath string - sandboxName string - noTTY bool - retrySleep time.Duration + harnessDir string + gw gateway.Gateway + gwCfg *gateway.GatewayConfig + ensureLocal bool + agentCfg *agent.AgentConfig + agentPath string + sandboxName string + noTTY bool + providerRefresh bool + retrySleep time.Duration } func upLocal(opts upLocalOpts) error { @@ -162,8 +166,8 @@ func upLocal(opts upLocalOpts) error { if len(providerNames) > 0 { var missing []string registered, missing = gateway.ValidateProviders(providerNames, gw) - if len(missing) > 0 { - if err := registerProviders(opts.harnessDir, gw, false, opts.gwCfg, false); err != nil { + if len(missing) > 0 || opts.providerRefresh { + if err := registerProviders(opts.harnessDir, gw, opts.providerRefresh, opts.gwCfg, false); err != nil { status.Warn(fmt.Sprintf("provider registration: %v", err)) } registered, missing = gateway.ValidateProviders(providerNames, gw) diff --git a/gateways/kind/gateway.toml b/gateways/kind/gateway.yaml similarity index 52% rename from gateways/kind/gateway.toml rename to gateways/kind/gateway.yaml index 8a2b850..730a4f5 100644 --- a/gateways/kind/gateway.toml +++ b/gateways/kind/gateway.yaml @@ -9,21 +9,21 @@ # Direct mode: no launcher Job, providers registered from workstation. # No mTLS: the gateway runs HTTP for local dev simplicity. -[gateway] -type = "remote" -platform = "k8s" -service = "nodeport" -name = "openshell-kind" -mode = "direct" +gateway: + type: remote + platform: k8s + service: nodeport + name: openshell-kind + mode: direct -[providers] -enabled = ["github", "vertex-local", "atlassian", "gws"] +providers: + enabled: [github, vertex-local, atlassian, gws] -[chart] -oci = "oci://ghcr.io/nvidia/openshell/helm-chart" -version = "0.0.58" -[chart.crd] -url = "https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/manifest.yaml" +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" +helm: + values: values.yaml diff --git a/gateways/local/gateway.toml b/gateways/local/gateway.yaml similarity index 78% rename from gateways/local/gateway.toml rename to gateways/local/gateway.yaml index 51970a2..ab0494f 100644 --- a/gateways/local/gateway.toml +++ b/gateways/local/gateway.yaml @@ -6,9 +6,9 @@ # # The harness verifies the gateway is reachable but does not manage its lifecycle. -[gateway] -type = "local" +gateway: + type: local -[providers] -enabled = ["github", "vertex-local", "atlassian"] -custom = ["gws"] +providers: + enabled: [github, vertex-local, atlassian] + custom: [gws] diff --git a/gateways/ocp/gateway.toml b/gateways/ocp/gateway.toml deleted file mode 100644 index b4d5aea..0000000 --- a/gateways/ocp/gateway.toml +++ /dev/null @@ -1,37 +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 -# -# Addons applied in order: -# - addons/route.yaml — OpenShift Route for external access -# - SCC grants for openshell service accounts (via oc CLI) - -[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.58" -[chart.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/gateway.yaml b/gateways/ocp/gateway.yaml new file mode 100644 index 0000000..d97d720 --- /dev/null +++ b/gateways/ocp/gateway.yaml @@ -0,0 +1,33 @@ +# 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/go.mod b/go.mod index 60edbb8..6122204 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/robbycochran/harness-openshell go 1.22.4 require ( - github.com/BurntSushi/toml v1.6.0 github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 73408f2..47edb24 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= -github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 09d3843..40c1e14 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -73,14 +73,26 @@ func Parse(data []byte) (*AgentConfig, error) { return &cfg, nil } +func expandEnvVar(key, value string) string { + expanded := os.ExpandEnv(value) + if expanded == "" { + expanded = os.Getenv(key) + } + return expanded +} + func (c *AgentConfig) BuildEnvMap() map[string]string { env := make(map[string]string) for k, v := range c.Env { - env[k] = os.ExpandEnv(v) + if val := expandEnvVar(k, v); val != "" { + env[k] = val + } } for _, p := range c.Providers { for k, v := range p.Config { - env[k] = os.ExpandEnv(v) + if val := expandEnvVar(k, v); val != "" { + env[k] = val + } } } return env @@ -120,7 +132,7 @@ func (c *AgentConfig) BuildRunSh() string { b.WriteString("fi\n\n") b.WriteString("# Execute entrypoint\n") if c.Task != "" { - fmt.Fprintf(&b, "exec %s \"$PAYLOAD_DIR/task.md\"\n", entrypoint) + fmt.Fprintf(&b, "exec %s -p \"$(cat \"$PAYLOAD_DIR/task.md\")\"\n", entrypoint) } else { fmt.Fprintf(&b, "exec %s\n", entrypoint) } diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index b22e006..83e4054 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -255,6 +255,31 @@ func TestBuildEnvMap(t *testing.T) { } } +func TestBuildEnvMap_EmptyValueReadsFromHost(t *testing.T) { + t.Setenv("MY_HOST_VAR", "from-host") + cfg := &AgentConfig{ + Env: map[string]string{ + "MY_HOST_VAR": "", + }, + } + env := cfg.BuildEnvMap() + if env["MY_HOST_VAR"] != "from-host" { + t.Errorf("MY_HOST_VAR = %q, want from-host", env["MY_HOST_VAR"]) + } +} + +func TestBuildEnvMap_EmptyValueNotInHost(t *testing.T) { + cfg := &AgentConfig{ + Env: map[string]string{ + "NONEXISTENT_VAR_12345": "", + }, + } + env := cfg.BuildEnvMap() + if _, ok := env["NONEXISTENT_VAR_12345"]; ok { + t.Error("empty env var not in host should be omitted from map") + } +} + func TestBuildEnvMap_Empty(t *testing.T) { cfg := &AgentConfig{Providers: []ProviderRef{{Profile: "github"}}} env := cfg.BuildEnvMap() @@ -295,8 +320,8 @@ func TestBuildRunSh(t *testing.T) { if !strings.Contains(runSh, `command -v "claude"`) { t.Error("missing entrypoint validation") } - if !strings.Contains(runSh, `exec claude --bare "$PAYLOAD_DIR/task.md"`) { - t.Errorf("missing task exec in:\n%s", runSh) + if !strings.Contains(runSh, `exec claude --bare -p "$(cat "$PAYLOAD_DIR/task.md")"`) { + t.Errorf("missing task exec with -p in:\n%s", runSh) } } diff --git a/internal/gateway/cli.go b/internal/gateway/cli.go index 05b7c4d..e5d8ebe 100644 --- a/internal/gateway/cli.go +++ b/internal/gateway/cli.go @@ -46,6 +46,9 @@ func ParseCLIVersion(raw string) string { if i := strings.LastIndex(raw, "v"); i >= 0 { return raw[i+1:] } + if i := strings.LastIndex(raw, " "); i >= 0 { + return raw[i+1:] + } return raw } diff --git a/internal/gateway/cli_test.go b/internal/gateway/cli_test.go index 999c5f8..5332f00 100644 --- a/internal/gateway/cli_test.go +++ b/internal/gateway/cli_test.go @@ -454,6 +454,7 @@ func TestParseCLIVersion(t *testing.T) { }{ {"openshell v0.0.59", "0.0.59"}, {"openshell v0.0.58", "0.0.58"}, + {"openshell 0.0.59", "0.0.59"}, {"v1.2.3", "1.2.3"}, {"0.0.59", "0.0.59"}, } diff --git a/internal/gateway/config.go b/internal/gateway/config.go index e8ac936..ab6efc5 100644 --- a/internal/gateway/config.go +++ b/internal/gateway/config.go @@ -5,67 +5,70 @@ import ( "os" "path/filepath" - "github.com/BurntSushi/toml" + "gopkg.in/yaml.v3" ) type GatewayConfig struct { - Gateway GatewaySection `toml:"gateway"` - Providers ProvidersSection `toml:"providers"` - Chart ChartSection `toml:"chart"` - Helm HelmSection `toml:"helm"` - Addons AddonsSection `toml:"addons"` - OCP OCPSection `toml:"ocp"` - Secrets SecretsSection `toml:"secrets"` + Gateway GatewaySection `yaml:"gateway"` + Providers ProvidersSection `yaml:"providers"` + Chart ChartSection `yaml:"chart"` + Helm HelmSection `yaml:"helm"` + Addons AddonsSection `yaml:"addons"` + OCP OCPSection `yaml:"ocp"` + Secrets SecretsSection `yaml:"secrets"` - // Dir is the directory containing the gateway.toml (set after parsing). - // Used to resolve relative paths (helm values, addon manifests). - Dir string `toml:"-"` + Dir string `yaml:"-"` } type GatewaySection struct { - Type string `toml:"type"` // "local" or "remote" - Platform string `toml:"platform"` // "ocp" or "k8s" - Service string `toml:"service"` // "route", "nodeport", "loadbalancer" - Name string `toml:"name"` // CLI gateway registration name + Type string `yaml:"type"` + Platform string `yaml:"platform"` + Service string `yaml:"service"` + Name string `yaml:"name"` + Mode string `yaml:"mode"` } type ProvidersSection struct { - Enabled []string `toml:"enabled"` - Custom []string `toml:"custom"` + Enabled []string `yaml:"enabled"` + Custom []string `yaml:"custom"` } type ChartSection struct { - OCI string `toml:"oci"` - Version string `toml:"version"` - CRD CRDConfig `toml:"crd"` + OCI string `yaml:"oci"` + Version string `yaml:"version"` + CRD CRDConfig `yaml:"crd"` } type CRDConfig struct { - URL string `toml:"url"` + URL string `yaml:"url"` } type HelmSection struct { - Values string `toml:"values"` // relative to /helm/ + Values string `yaml:"values"` } type AddonsSection struct { - Manifests []string `toml:"manifests"` // relative to / + Manifests []string `yaml:"manifests"` } type OCPSection struct { - SCCPrivileged []string `toml:"scc-privileged"` - SCCAnyuid []string `toml:"scc-anyuid"` + SCCPrivileged []string `yaml:"scc-privileged"` + SCCAnyuid []string `yaml:"scc-anyuid"` } type SecretsSection struct { - Names []string `toml:"names"` - MTLS string `toml:"mtls"` + Names []string `yaml:"names"` + MTLS string `yaml:"mtls"` } func LoadConfig(dir string) (*GatewayConfig, error) { - path := filepath.Join(dir, "gateway.toml") + path := filepath.Join(dir, "gateway.yaml") + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } var cfg GatewayConfig - if _, err := toml.DecodeFile(path, &cfg); err != nil { + if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parsing %s: %w", path, err) } cfg.Dir = dir @@ -97,13 +100,10 @@ func (c *GatewayConfig) IsOCP() bool { return c.Gateway.Platform == "ocp" } -// HasProviders returns true if the gateway config specifies its own provider lists, -// overriding the global openshell.toml. func (c *GatewayConfig) HasProviders() bool { return len(c.Providers.Enabled) > 0 || len(c.Providers.Custom) > 0 } -// AllProviders returns the combined enabled + custom provider names. func (c *GatewayConfig) AllProviders() []string { all := make([]string, 0, len(c.Providers.Enabled)+len(c.Providers.Custom)) all = append(all, c.Providers.Enabled...) diff --git a/internal/gateway/config_test.go b/internal/gateway/config_test.go index bed4ac6..ee07380 100644 --- a/internal/gateway/config_test.go +++ b/internal/gateway/config_test.go @@ -6,48 +6,48 @@ import ( "testing" ) -func writeGatewayTOML(t *testing.T, dir, content string) { +func writeGatewayYAML(t *testing.T, dir, content string) { t.Helper() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "gateway.toml"), []byte(content), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "gateway.yaml"), []byte(content), 0o644); err != nil { t.Fatal(err) } } func TestLoadConfig_FullOCP(t *testing.T) { dir := t.TempDir() - writeGatewayTOML(t, dir, ` -[gateway] -type = "remote" -platform = "ocp" -service = "route" -name = "my-ocp" - -[providers] -enabled = ["github", "vertex-local"] -custom = ["gws"] - -[chart] -oci = "oci://example.com/chart" -version = "1.2.3" -[chart.crd] -url = "https://example.com/crd.yaml" - -[helm] -values = "values.yaml" - -[addons] -manifests = ["addons/route.yaml"] - -[ocp] -scc-privileged = ["sa1", "sa2"] -scc-anyuid = ["sa1"] - -[secrets] -names = ["secret-a", "secret-b"] -mtls = "my-mtls-secret" + writeGatewayYAML(t, dir, ` +gateway: + type: remote + platform: ocp + service: route + name: my-ocp + +providers: + enabled: [github, vertex-local] + custom: [gws] + +chart: + oci: oci://example.com/chart + version: "1.2.3" + crd: + url: https://example.com/crd.yaml + +helm: + values: values.yaml + +addons: + manifests: [addons/route.yaml] + +ocp: + scc-privileged: [sa1, sa2] + scc-anyuid: [sa1] + +secrets: + names: [secret-a, secret-b] + mtls: my-mtls-secret `) cfg, err := LoadConfig(dir) @@ -98,9 +98,9 @@ mtls = "my-mtls-secret" func TestLoadConfig_MinimalLocal(t *testing.T) { dir := t.TempDir() - writeGatewayTOML(t, dir, ` -[gateway] -type = "local" + writeGatewayYAML(t, dir, ` +gateway: + type: local `) cfg, err := LoadConfig(dir) @@ -112,7 +112,6 @@ type = "local" t.Error("IsLocal() = false, want true") } - // Defaults applied if cfg.Chart.OCI != "oci://ghcr.io/nvidia/openshell/helm-chart" { t.Errorf("default chart.oci = %q", cfg.Chart.OCI) } @@ -123,10 +122,10 @@ type = "local" func TestLoadConfig_MinimalRemote(t *testing.T) { dir := t.TempDir() - writeGatewayTOML(t, dir, ` -[gateway] -type = "remote" -platform = "k8s" + writeGatewayYAML(t, dir, ` +gateway: + type: remote + platform: k8s `) cfg, err := LoadConfig(dir) @@ -145,27 +144,26 @@ platform = "k8s" func TestLoadConfig_Missing(t *testing.T) { _, err := LoadConfig(t.TempDir()) if err == nil { - t.Error("expected error for missing gateway.toml") + t.Error("expected error for missing gateway.yaml") } } -func TestLoadConfig_InvalidTOML(t *testing.T) { +func TestLoadConfig_InvalidYAML(t *testing.T) { dir := t.TempDir() - writeGatewayTOML(t, dir, `[gateway -broken toml`) + writeGatewayYAML(t, dir, `gateway: [broken yaml`) _, err := LoadConfig(dir) if err == nil { - t.Error("expected error for invalid TOML") + t.Error("expected error for invalid YAML") } } func TestEnvOverrides(t *testing.T) { dir := t.TempDir() - writeGatewayTOML(t, dir, ` -[gateway] -type = "remote" -name = "original-name" + writeGatewayYAML(t, dir, ` +gateway: + type: remote + name: original-name `) t.Setenv("GATEWAY_NAME", "env-gw-name") @@ -182,10 +180,10 @@ name = "original-name" func TestEnvOverrides_NotSet(t *testing.T) { dir := t.TempDir() - writeGatewayTOML(t, dir, ` -[gateway] -type = "remote" -name = "original-name" + writeGatewayYAML(t, dir, ` +gateway: + type: remote + name: original-name `) t.Setenv("GATEWAY_NAME", "") @@ -202,12 +200,11 @@ name = "original-name" func TestHelmValuesPath(t *testing.T) { dir := t.TempDir() - writeGatewayTOML(t, dir, ` -[gateway] -type = "remote" - -[helm] -values = "values.yaml" + writeGatewayYAML(t, dir, ` +gateway: + type: remote +helm: + values: values.yaml `) cfg, err := LoadConfig(dir) @@ -223,9 +220,9 @@ values = "values.yaml" func TestHelmValuesPath_Empty(t *testing.T) { dir := t.TempDir() - writeGatewayTOML(t, dir, ` -[gateway] -type = "remote" + writeGatewayYAML(t, dir, ` +gateway: + type: remote `) cfg, err := LoadConfig(dir) @@ -240,12 +237,13 @@ type = "remote" func TestManifestPaths(t *testing.T) { dir := t.TempDir() - writeGatewayTOML(t, dir, ` -[gateway] -type = "remote" - -[addons] -manifests = ["addons/rbac.yaml", "addons/route.yaml"] + writeGatewayYAML(t, dir, ` +gateway: + type: remote +addons: + manifests: + - addons/rbac.yaml + - addons/route.yaml `) cfg, err := LoadConfig(dir) @@ -267,9 +265,9 @@ manifests = ["addons/rbac.yaml", "addons/route.yaml"] func TestManifestPaths_Empty(t *testing.T) { dir := t.TempDir() - writeGatewayTOML(t, dir, ` -[gateway] -type = "remote" + writeGatewayYAML(t, dir, ` +gateway: + type: remote `) cfg, err := LoadConfig(dir) @@ -285,25 +283,25 @@ type = "remote" func TestPredicates(t *testing.T) { tests := []struct { name string - toml string + yaml string isLocal bool isOCP bool }{ { name: "local", - toml: "[gateway]\ntype = \"local\"", + yaml: "gateway:\n type: local", isLocal: true, isOCP: false, }, { - name: "remote ocp", - toml: "[gateway]\ntype = \"remote\"\nplatform = \"ocp\"", + name: "remote ocp launcher", + yaml: "gateway:\n type: remote\n platform: ocp", isLocal: false, isOCP: true, }, { - name: "remote k8s", - toml: "[gateway]\ntype = \"remote\"\nplatform = \"k8s\"", + name: "remote k8s direct", + yaml: "gateway:\n type: remote\n platform: k8s", isLocal: false, isOCP: false, }, @@ -312,7 +310,7 @@ func TestPredicates(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dir := t.TempDir() - writeGatewayTOML(t, dir, tt.toml) + writeGatewayYAML(t, dir, tt.yaml) cfg, err := LoadConfig(dir) if err != nil { @@ -331,13 +329,12 @@ func TestPredicates(t *testing.T) { func TestHasProviders(t *testing.T) { dir := t.TempDir() - writeGatewayTOML(t, dir, ` -[gateway] -type = "local" - -[providers] -enabled = ["github"] -custom = ["gws"] + writeGatewayYAML(t, dir, ` +gateway: + type: local +providers: + enabled: [github] + custom: [gws] `) cfg, err := LoadConfig(dir) @@ -357,9 +354,9 @@ custom = ["gws"] func TestHasProviders_Empty(t *testing.T) { dir := t.TempDir() - writeGatewayTOML(t, dir, ` -[gateway] -type = "local" + writeGatewayYAML(t, dir, ` +gateway: + type: local `) cfg, err := LoadConfig(dir) diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go deleted file mode 100644 index 14fa014..0000000 --- a/internal/preflight/preflight.go +++ /dev/null @@ -1,390 +0,0 @@ -package preflight - -import ( - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "github.com/BurntSushi/toml" - "github.com/robbycochran/harness-openshell/internal/gateway" - "github.com/robbycochran/harness-openshell/internal/status" -) - -type Provider struct { - Name string `toml:"name"` - Type string `toml:"type"` - Description string `toml:"description"` - Required bool `toml:"required"` - Method string `toml:"method"` - Upstream string `toml:"upstream"` - Inputs []Input `toml:"inputs"` -} - -type Input struct { - Key string `toml:"key"` - Kind string `toml:"kind"` - Secret bool `toml:"secret"` -} - -type ProvidersFile struct { - Providers []Provider `toml:"providers"` -} - -type ConfigFile struct { - Providers []string `toml:"providers"` - ProvidersCustom []string `toml:"providers-custom"` - Upstream UpstreamConfig `toml:"upstream"` -} - -type UpstreamConfig struct { - ChartVersion string `toml:"chart-version"` -} - -func LoadProviders(path string) ([]Provider, error) { - var pf ProvidersFile - if _, err := toml.DecodeFile(path, &pf); err != nil { - return nil, fmt.Errorf("reading %s: %w", path, err) - } - return pf.Providers, nil -} - -func LoadConfig(path string) (*ConfigFile, error) { - if _, err := os.Stat(path); err != nil { - return nil, nil - } - var cf ConfigFile - if _, err := toml.DecodeFile(path, &cf); err != nil { - return nil, fmt.Errorf("reading %s: %w", path, err) - } - return &cf, nil -} - -func EnabledProviders(all []Provider, config *ConfigFile) []Provider { - if config == nil { - return all - } - enabled := make(map[string]bool) - for _, n := range config.Providers { - enabled[n] = true - } - for _, n := range config.ProvidersCustom { - enabled[n] = true - } - var result []Provider - for _, p := range all { - if enabled[p.Name] { - result = append(result, p) - } - } - return result -} - -func CheckInput(inp Input) (bool, string) { - switch inp.Kind { - case "env": - val := os.Getenv(inp.Key) - if val != "" { - display := val - if inp.Secret { - display = MaskValue(val, 4) - } - return true, fmt.Sprintf("✓ local env: %s=%s", inp.Key, display) - } - return false, fmt.Sprintf("✗ local env: %s not set → export %s=...", inp.Key, inp.Key) - - case "file": - path := expandPath(inp.Key) - if _, err := os.Stat(path); err != nil { - return false, fmt.Sprintf("✗ local file: %s not found", inp.Key) - } - meta := FileMetadata(path) - if meta == nil { - return true, fmt.Sprintf("✓ local file: %s", inp.Key) - } - var parts []string - for k, v := range meta { - if v != "" { - parts = append(parts, fmt.Sprintf("%s=%s", k, v)) - } - } - if len(parts) > 0 { - return true, fmt.Sprintf("✓ local file: %s (%s)", inp.Key, strings.Join(parts, ", ")) - } - return true, fmt.Sprintf("✓ local file: %s", inp.Key) - - case "check": - expanded := os.ExpandEnv(inp.Key) - ok := runQuiet(expanded) - sym := "✓" - if !ok { - sym = "✗" - } - return ok, fmt.Sprintf("%s check: %s", sym, inp.Key) - } - - return false, fmt.Sprintf("%s: unknown kind '%s'", inp.Key, inp.Kind) -} - -func CheckProvider(p Provider) (bool, []string) { - issues := 0 - var details []string - for _, inp := range p.Inputs { - ok, detail := CheckInput(inp) - if !ok { - issues++ - } - details = append(details, detail) - } - return issues == 0, details -} - -func MaskValue(val string, show int) string { - if val == "" || len(val) <= show { - return "***" - } - return val[:show] + "***" -} - -func FileMetadata(path string) map[string]string { - data, err := os.ReadFile(path) - if err != nil { - return nil - } - var raw map[string]any - if err := json.Unmarshal(data, &raw); err != nil { - return nil - } - meta := make(map[string]string) - if qp, ok := raw["quota_project_id"].(string); ok { - meta["project"] = qp - if t, ok := raw["type"].(string); ok { - meta["type"] = t - } - } - if installed, ok := raw["installed"].(map[string]any); ok { - if cid, ok := installed["client_id"].(string); ok { - meta["client_id"] = MaskValue(cid, 4) - } - } - if len(meta) == 0 { - return nil - } - return meta -} - -func expandPath(p string) string { - if strings.HasPrefix(p, "~/") { - home, _ := os.UserHomeDir() - return filepath.Join(home, p[2:]) - } - return os.ExpandEnv(p) -} - -func runQuiet(command string) bool { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - return exec.CommandContext(ctx, "bash", "-c", command).Run() == nil -} - -func loadEnabledProviders(harnessDir string) ([]Provider, error) { - providersPath := os.Getenv("PROVIDERS_TOML") - if providersPath == "" { - providersPath = filepath.Join(harnessDir, "providers.toml") - } - configPath := os.Getenv("CONFIG_TOML") - if configPath == "" { - configPath = filepath.Join(harnessDir, "openshell.toml") - } - all, err := LoadProviders(providersPath) - if err != nil { - return nil, err - } - config, _ := LoadConfig(configPath) - return EnabledProviders(all, config), nil -} - -func RunCheck(harnessDir string, gw gateway.Gateway, strict bool) error { - providers, err := loadEnabledProviders(harnessDir) - if err != nil { - return err - } - - hasFailures := false - - fmt.Println("=== OpenShell CLI ===") - cliPath := gw.CLIPath() - cliFound := cliPath != "" - if !cliFound { - status.Fail("not found on PATH") - hasFailures = true - } else { - ver := gw.CLIVersion() - if ver != "" { - status.OK(ver) - } else { - status.OK("openshell") - } - status.Detail(cliPath) - } - - activeGW := "" - if cliFound { - activeGW = gw.ActiveGateway() - } - isK8s := strings.Contains(activeGW, "-remote-") - - gwOK := false - if isK8s { - status.Section("K8s gateway") - kubectlPath, _ := exec.LookPath("kubectl") - if kubectlPath == "" { - status.Fail("kubectl not found") - hasFailures = true - } else { - ctx := runOutput("kubectl", "config", "current-context") - if ctx != "" { - status.OKf("Cluster: %s", ctx) - if cliFound { - if gw.InferenceGet() == nil { - gwOK = true - model := gw.InferenceModel() - if model != "" { - status.OKf("Gateway reachable (model: %s)", model) - } else { - status.OK("Gateway reachable") - } - } else { - status.Fail("Gateway unreachable") - } - } - } else { - status.Fail("No cluster (kubectl not configured)") - hasFailures = true - } - } - } else { - status.Section("Podman gateway") - if cliFound { - if gw.InferenceGet() == nil { - gwOK = true - model := gw.InferenceModel() - if model != "" { - status.OKf("Reachable (model: %s)", model) - } else { - status.OK("Reachable") - } - } else { - status.Info("Not running") - } - - podmanPath, _ := exec.LookPath("podman") - if podmanPath != "" { - ver := runOutput("podman", "--version") - status.OKf("Podman: %s", ver) - } else { - status.Fail("Podman not found") - hasFailures = true - } - } else { - status.Info("CLI not available") - } - } - - if cliFound && gwOK { - gwLabel := "podman" - if isK8s { - gwLabel = "k8s" - } - status.Section(fmt.Sprintf("Registered providers (%s)", gwLabel)) - for _, p := range providers { - if p.Type != "openshell" { - continue - } - if gw.ProviderGet(p.Name) == nil { - status.OK(p.Name) - } else { - status.Failf("%s: not registered — run: harness providers", p.Name) - hasFailures = true - } - } - } - - status.Section("Provider inputs") - for _, p := range providers { - ok, details := CheckProvider(p) - if ok { - status.OK(p.Name) - } else { - status.Fail(p.Name) - if p.Required { - hasFailures = true - } - } - status.Detail(p.Description) - - for _, d := range details { - status.Sub(d) - } - - if p.Upstream != "" && !ok { - status.Sub(fmt.Sprintf("upstream: %s", p.Upstream)) - } - fmt.Println() - } - - status.Summary(!hasFailures) - if hasFailures && strict { - return fmt.Errorf("preflight: required checks failed") - } - return nil -} - -func RunAvailable(harnessDir string) error { - providers, err := loadEnabledProviders(harnessDir) - if err != nil { - return err - } - - var available []string - for _, p := range providers { - if p.Type != "openshell" { - continue - } - ok, _ := CheckProvider(p) - if ok { - available = append(available, p.Name) - } - } - fmt.Println(strings.Join(available, " ")) - return nil -} - -func RunNames(harnessDir string) error { - providers, err := loadEnabledProviders(harnessDir) - if err != nil { - return err - } - - var names []string - for _, p := range providers { - if p.Type == "openshell" { - names = append(names, p.Name) - } - } - fmt.Println(strings.Join(names, " ")) - return nil -} - -func runOutput(name string, args ...string) string { - cmd := exec.Command(name, args...) - out, err := cmd.Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(out)) -} diff --git a/internal/preflight/preflight_test.go b/internal/preflight/preflight_test.go deleted file mode 100644 index 161d48a..0000000 --- a/internal/preflight/preflight_test.go +++ /dev/null @@ -1,311 +0,0 @@ -package preflight - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoadProviders_Valid(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "providers.toml") - os.WriteFile(path, []byte(` -[[providers]] -name = "github" -type = "openshell" -description = "GitHub" -required = true -inputs = [ - { key = "GITHUB_TOKEN", kind = "env", secret = true }, -] - -[[providers]] -name = "gws" -type = "custom" -description = "Google Workspace" -upstream = "https://github.com/NVIDIA/OpenShell/issues/1268" -inputs = [] -`), 0o644) - - providers, err := LoadProviders(path) - if err != nil { - t.Fatalf("LoadProviders: %v", err) - } - if len(providers) != 2 { - t.Fatalf("got %d providers, want 2", len(providers)) - } - if providers[0].Name != "github" || providers[0].Type != "openshell" { - t.Errorf("providers[0] = %+v", providers[0]) - } - if !providers[0].Required { - t.Error("github should be required") - } - if len(providers[0].Inputs) != 1 || providers[0].Inputs[0].Kind != "env" { - t.Errorf("github inputs = %+v", providers[0].Inputs) - } - if providers[1].Upstream != "https://github.com/NVIDIA/OpenShell/issues/1268" { - t.Errorf("gws upstream = %q", providers[1].Upstream) - } -} - -func TestLoadProviders_Missing(t *testing.T) { - _, err := LoadProviders("/nonexistent.toml") - if err == nil { - t.Error("expected error for missing file") - } -} - -func TestLoadProviders_Invalid(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "bad.toml") - os.WriteFile(path, []byte(`not valid {{{{`), 0o644) - - _, err := LoadProviders(path) - if err == nil { - t.Error("expected error for invalid TOML") - } -} - -func TestLoadConfig_Valid(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "openshell.toml") - os.WriteFile(path, []byte(` -providers = ["github", "vertex-local"] -providers-custom = ["gws"] - -[upstream] -chart-version = "0.0.58" -`), 0o644) - - cfg, err := LoadConfig(path) - if err != nil { - t.Fatalf("LoadConfig: %v", err) - } - if cfg == nil { - t.Fatal("config is nil") - } - if len(cfg.Providers) != 2 { - t.Errorf("providers = %v", cfg.Providers) - } - if len(cfg.ProvidersCustom) != 1 || cfg.ProvidersCustom[0] != "gws" { - t.Errorf("providers-custom = %v", cfg.ProvidersCustom) - } - if cfg.Upstream.ChartVersion != "0.0.58" { - t.Errorf("Upstream.ChartVersion = %q", cfg.Upstream.ChartVersion) - } -} - -func TestLoadConfig_Missing(t *testing.T) { - cfg, err := LoadConfig("/nonexistent.toml") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if cfg != nil { - t.Error("expected nil for missing config") - } -} - -func TestEnabledProviders_WithConfig(t *testing.T) { - all := []Provider{ - {Name: "github", Type: "openshell"}, - {Name: "vertex", Type: "openshell"}, - {Name: "gws", Type: "custom"}, - } - cfg := &ConfigFile{ - Providers: []string{"github"}, - ProvidersCustom: []string{"gws"}, - } - enabled := EnabledProviders(all, cfg) - if len(enabled) != 2 { - t.Fatalf("got %d, want 2", len(enabled)) - } - if enabled[0].Name != "github" || enabled[1].Name != "gws" { - t.Errorf("enabled = %v", enabled) - } -} - -func TestEnabledProviders_NilConfig(t *testing.T) { - all := []Provider{{Name: "a"}, {Name: "b"}} - enabled := EnabledProviders(all, nil) - if len(enabled) != 2 { - t.Errorf("nil config should return all, got %d", len(enabled)) - } -} - -func TestMaskValue(t *testing.T) { - tests := []struct { - val string - show int - want string - }{ - {"super-secret-token", 4, "supe***"}, - {"abc", 4, "***"}, - {"", 4, "***"}, - {"abcdef", 4, "abcd***"}, - {"ab", 4, "***"}, - } - for _, tt := range tests { - got := MaskValue(tt.val, tt.show) - if got != tt.want { - t.Errorf("MaskValue(%q, %d) = %q, want %q", tt.val, tt.show, got, tt.want) - } - } -} - -func TestFileMetadata_ADC(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "adc.json") - os.WriteFile(path, []byte(`{"quota_project_id": "my-project", "type": "authorized_user"}`), 0o644) - - meta := FileMetadata(path) - if meta == nil { - t.Fatal("expected metadata") - } - if meta["project"] != "my-project" { - t.Errorf("project = %q", meta["project"]) - } - if meta["type"] != "authorized_user" { - t.Errorf("type = %q", meta["type"]) - } -} - -func TestFileMetadata_GWS(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "client_secret.json") - os.WriteFile(path, []byte(`{"installed": {"client_id": "1715999888.apps.googleusercontent.com"}}`), 0o644) - - meta := FileMetadata(path) - if meta == nil { - t.Fatal("expected metadata") - } - if meta["client_id"] != "1715***" { - t.Errorf("client_id = %q, want masked", meta["client_id"]) - } -} - -func TestFileMetadata_NotJSON(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "plain.txt") - os.WriteFile(path, []byte("not json"), 0o644) - - meta := FileMetadata(path) - if meta != nil { - t.Errorf("expected nil for non-JSON, got %v", meta) - } -} - -func TestFileMetadata_Missing(t *testing.T) { - meta := FileMetadata("/nonexistent.json") - if meta != nil { - t.Errorf("expected nil for missing file, got %v", meta) - } -} - -func TestCheckInput_EnvSet(t *testing.T) { - t.Setenv("TEST_VAR", "hello") - ok, detail := CheckInput(Input{Key: "TEST_VAR", Kind: "env"}) - if !ok { - t.Error("expected ok") - } - if detail != "✓ local env: TEST_VAR=hello" { - t.Errorf("detail = %q", detail) - } -} - -func TestCheckInput_EnvMissing(t *testing.T) { - ok, detail := CheckInput(Input{Key: "NONEXISTENT_VAR_XYZ", Kind: "env"}) - if ok { - t.Error("expected not ok") - } - if detail != "✗ local env: NONEXISTENT_VAR_XYZ not set → export NONEXISTENT_VAR_XYZ=..." { - t.Errorf("detail = %q", detail) - } -} - -func TestCheckInput_EnvSecret(t *testing.T) { - t.Setenv("SECRET_VAR", "super-secret-value") - ok, detail := CheckInput(Input{Key: "SECRET_VAR", Kind: "env", Secret: true}) - if !ok { - t.Error("expected ok") - } - if detail != "✓ local env: SECRET_VAR=supe***" { - t.Errorf("detail = %q", detail) - } -} - -func TestCheckInput_FileExists(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "test.txt") - os.WriteFile(path, []byte("data"), 0o644) - - ok, detail := CheckInput(Input{Key: path, Kind: "file"}) - if !ok { - t.Error("expected ok") - } - if detail != "✓ local file: "+path { - t.Errorf("detail = %q", detail) - } -} - -func TestCheckInput_FileMissing(t *testing.T) { - ok, detail := CheckInput(Input{Key: "/nonexistent/file.json", Kind: "file"}) - if ok { - t.Error("expected not ok") - } - if detail != "✗ local file: /nonexistent/file.json not found" { - t.Errorf("detail = %q", detail) - } -} - -func TestCheckInput_CheckPass(t *testing.T) { - ok, detail := CheckInput(Input{Key: "true", Kind: "check"}) - if !ok { - t.Error("expected ok") - } - if detail != "✓ check: true" { - t.Errorf("detail = %q", detail) - } -} - -func TestCheckInput_CheckFail(t *testing.T) { - ok, detail := CheckInput(Input{Key: "false", Kind: "check"}) - if ok { - t.Error("expected not ok") - } - if detail != "✗ check: false" { - t.Errorf("detail = %q", detail) - } -} - -func TestCheckProvider_AllPass(t *testing.T) { - t.Setenv("GOOD_VAR", "yes") - p := Provider{ - Name: "test", - Inputs: []Input{{Key: "GOOD_VAR", Kind: "env"}}, - } - ok, details := CheckProvider(p) - if !ok { - t.Error("expected ok") - } - if len(details) != 1 { - t.Errorf("details = %v", details) - } -} - -func TestCheckProvider_SomeFail(t *testing.T) { - t.Setenv("SET_VAR", "yes") - p := Provider{ - Name: "test", - Inputs: []Input{ - {Key: "SET_VAR", Kind: "env"}, - {Key: "MISSING_VAR_XYZ", Kind: "env"}, - }, - } - ok, details := CheckProvider(p) - if ok { - t.Error("expected not ok") - } - if len(details) != 2 { - t.Errorf("details = %v", details) - } -} diff --git a/main.go b/main.go index f133c35..93a2b30 100644 --- a/main.go +++ b/main.go @@ -52,8 +52,6 @@ func main() { cmd.NewConnectCmd(cli), cmd.NewDeployCmd(harnessDir, cli), cmd.NewTeardownCmd(harnessDir, cli), - cmd.NewPreflightCmd(harnessDir, cli), - cmd.NewProvidersCmd(harnessDir, cli), cmd.NewStatusCmd(harnessDir, cli), cmd.NewLogsCmd(harnessDir, cli), cmd.NewStopCmd(harnessDir, cli), diff --git a/openshell.toml b/openshell.toml deleted file mode 100644 index 08441e7..0000000 --- a/openshell.toml +++ /dev/null @@ -1,22 +0,0 @@ -# Deployment configuration — what's enabled for this environment. -# -# providers.toml defines the full catalog of available providers and -# their requirements. This file selects which ones to use. -# -# Copy this file and customize per deployment: -# cp openshell.toml my-team.toml -# harness preflight - -# Which providers to register and attach to sandboxes. -# Must match names defined in providers.toml or imported provider profiles. -providers = ["github", "vertex-local", "atlassian", "gws"] - -# Fallback: move gws back to providers-custom to use legacy file staging. -# providers = ["github", "vertex-local", "atlassian"] -# providers-custom = ["gws"] - -[inference] -model = "claude-sonnet-4-6" - -[upstream] -chart-version = "0.0.59" diff --git a/providers.toml b/providers.toml deleted file mode 100644 index 9001b94..0000000 --- a/providers.toml +++ /dev/null @@ -1,50 +0,0 @@ -# Provider definitions for the OpenShell harness. -# -# Each [[providers]] entry declares preflight inputs validated on the host -# before registration: kind = "env" (env var), "file" (path exists), or -# "check" (command exits 0). secret = true marks values that must never be -# echoed. See SPEC.md "Config Files" for where this fits. - -[[providers]] -name = "github" -type = "openshell" -description = "GitHub API and git operations (read-only)" -required = true -inputs = [ - { key = "GITHUB_TOKEN", kind = "env", secret = true }, -] - -[[providers]] -name = "vertex-local" -type = "openshell" -method = "from-gcloud-adc" -description = "Google Vertex AI inference via gateway-managed OAuth" -required = true -inputs = [ - { key = "ANTHROPIC_VERTEX_PROJECT_ID", kind = "env" }, - { key = "CLOUD_ML_REGION", kind = "env" }, - { key = "~/.config/gcloud/application_default_credentials.json", kind = "file", secret = true }, - { key = "gcloud auth application-default print-access-token", kind = "check" }, -] - -[[providers]] -name = "atlassian" -type = "openshell" -description = "Jira and Confluence (read-only, Basic auth resolved by proxy)" -inputs = [ - { key = "JIRA_API_TOKEN", kind = "env", secret = true }, - { key = "JIRA_URL", kind = "env" }, - { key = "JIRA_USERNAME", kind = "env" }, - { key = "curl -sf ${JIRA_URL}/rest/api/2/serverInfo -o /dev/null", kind = "check" }, - { key = "curl -sf -u ${JIRA_USERNAME}:${JIRA_API_TOKEN} ${JIRA_URL}/rest/api/2/myself -o /dev/null", kind = "check" }, -] - -[[providers]] -name = "gws" -type = "openshell" -description = "Google Workspace (Gmail, Calendar, Drive)" -upstream = "https://github.com/NVIDIA/OpenShell/issues/1268" -inputs = [ - { key = "~/.config/gws/client_secret.json", kind = "file", secret = true }, - { key = "gws auth status", kind = "check" }, -] diff --git a/sandbox/Dockerfile b/sandbox/Dockerfile index 4f75f2a..1c30a28 100644 --- a/sandbox/Dockerfile +++ b/sandbox/Dockerfile @@ -57,15 +57,20 @@ RUN ARCH=$(uname -m | sed 's/arm64/aarch64/') && \ curl -fsSL "https://github.com/googleworkspace/cli/releases/download/v0.22.5/google-workspace-cli-${ARCH}-unknown-linux-gnu.tar.gz" \ | tar xz -C /usr/local/bin +# OpenCode AI coding agent +# https://github.com/opencode-ai/opencode +RUN npm install -g opencode-ai@latest + # Sandbox policy (network egress rules — provider profiles contribute # additional endpoints at runtime via providers v2 composition) COPY policy.yaml /etc/openshell/policy.yaml -# Agent instructions + Claude config +# Agent instructions + config (Claude Code + OpenCode) COPY CLAUDE.md /sandbox/CLAUDE.md COPY settings.json /sandbox/.claude/settings.json COPY claude.json /sandbox/.claude.json COPY mcp.json /sandbox/.mcp.json +COPY opencode.json /sandbox/opencode.json # Runtime script COPY startup.sh /sandbox/startup.sh @@ -81,7 +86,7 @@ RUN mkdir -p /sandbox/.config/openshell && chown sandbox:sandbox /sandbox/.confi RUN chmod 755 /usr/local/bin/claude && \ chmod +x /sandbox/startup.sh && \ - chown -R sandbox:sandbox /sandbox/.claude /sandbox/.claude.json /sandbox/.mcp.json /sandbox/.config /sandbox/CLAUDE.md /sandbox/startup.sh + chown -R sandbox:sandbox /sandbox/.claude /sandbox/.claude.json /sandbox/.mcp.json /sandbox/opencode.json /sandbox/.config /sandbox/CLAUDE.md /sandbox/startup.sh USER sandbox diff --git a/sandbox/opencode.json b/sandbox/opencode.json new file mode 100644 index 0000000..552c0f7 --- /dev/null +++ b/sandbox/opencode.json @@ -0,0 +1,12 @@ +{ + "mcp": { + "atlassian": { + "type": "stdio", + "command": "/sandbox/.venv/bin/mcp-atlassian", + "args": [], + "env": { + "READ_ONLY_MODE": "true" + } + } + } +} diff --git a/sandbox/policy.yaml b/sandbox/policy.yaml index 7a01663..8226f3f 100644 --- a/sandbox/policy.yaml +++ b/sandbox/policy.yaml @@ -37,17 +37,19 @@ process: run_as_group: sandbox network_policies: - # Claude Code telemetry (inference goes through inference.local, - # but telemetry/updates still hit Anthropic directly) - claude_telemetry: - name: claude-telemetry + # Agent telemetry (inference goes through inference.local, + # but telemetry/updates still hit upstream directly) + agent_telemetry: + name: agent-telemetry endpoints: - { host: "*.anthropic.com", port: 443 } - { host: downloads.claude.ai, port: 443 } - { host: platform.claude.com, port: 443 } - { host: sentry.io, port: 443 } + - { host: opencode.ai, port: 443 } binaries: - { path: /usr/local/bin/claude } + - { path: /usr/local/bin/opencode } - { path: /usr/bin/node } # Git Smart HTTP transport — allows clone and fetch. diff --git a/test/preflight.bats b/test/preflight.bats deleted file mode 100644 index 0453724..0000000 --- a/test/preflight.bats +++ /dev/null @@ -1,791 +0,0 @@ -#!/usr/bin/env bats -# Tests for preflight check (Go implementation). -# -# Stubs external CLIs (openshell, kubectl, gcloud, podman, curl, gws) -# and uses temp TOML files to test every configuration path. - -REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" - -setup() { - TEST_TMPDIR="$(mktemp -d)" - export STUB_DIR="$TEST_TMPDIR/stubs" - mkdir -p "$STUB_DIR" - - # Isolate from real environment - unset GITHUB_TOKEN JIRA_API_TOKEN JIRA_URL JIRA_USERNAME - unset ANTHROPIC_VERTEX_PROJECT_ID CLOUD_ML_REGION - unset OPENSHELL_GATEWAY OPENSHELL_NAMESPACE OPENSHELL_CLI - unset GOOGLE_APPLICATION_CREDENTIALS - - # Point preflight at temp TOML files - export PROVIDERS_TOML="$TEST_TMPDIR/providers.toml" - export CONFIG_TOML="$TEST_TMPDIR/openshell.toml" - - # Put stubs first on PATH, strip real openshell/kubectl/podman/docker - # Keep /opt/homebrew for python3 with tomllib (3.11+) - export PATH="$STUB_DIR:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin" -} - -teardown() { - rm -rf "$TEST_TMPDIR" -} - -# ── Helpers ────────────────────────────────────────────────────────── - -write_providers_toml() { - cat > "$PROVIDERS_TOML" "$@" -} - -write_config_toml() { - cat > "$CONFIG_TOML" "$@" -} - -make_stub() { - local name="$1"; shift - cat > "$STUB_DIR/$name" </dev/null || true - case "$cmd" in - check) - PROVIDERS_TOML="$PROVIDERS_TOML" CONFIG_TOML="$CONFIG_TOML" \ - "$harness" preflight "$@" - ;; - available|names) - PROVIDERS_TOML="$PROVIDERS_TOML" CONFIG_TOML="$CONFIG_TOML" \ - "$harness" preflight "$cmd" - ;; - esac -} - -# ── ENV input tests ────────────────────────────────────────────────── - -@test "env input: set non-secret shows full value" { - write_providers_toml <<'EOF' -[[providers]] -name = "test" -type = "openshell" -description = "test provider" -inputs = [ - { key = "MY_VAR", kind = "env" }, -] -EOF - write_config_toml <<'EOF' -providers = ["test"] -EOF - - export MY_VAR="hello-world" - make_stub openshell 'echo "openshell v0.0.54"' - - run run_preflight check - [[ "$output" == *"✓ local env: MY_VAR=hello-world"* ]] -} - -@test "env input: set secret shows masked value" { - write_providers_toml <<'EOF' -[[providers]] -name = "test" -type = "openshell" -description = "test provider" -inputs = [ - { key = "MY_SECRET", kind = "env", secret = true }, -] -EOF - write_config_toml <<'EOF' -providers = ["test"] -EOF - - export MY_SECRET="super-secret-token-12345" - make_stub openshell 'echo "openshell v0.0.54"' - - run run_preflight check - [[ "$output" == *"✓ local env: MY_SECRET=supe***"* ]] - [[ "$output" != *"super-secret-token-12345"* ]] -} - -@test "env input: missing shows error with export hint" { - write_providers_toml <<'EOF' -[[providers]] -name = "test" -type = "openshell" -description = "test provider" -inputs = [ - { key = "MISSING_VAR", kind = "env" }, -] -EOF - write_config_toml <<'EOF' -providers = ["test"] -EOF - make_stub openshell 'echo "openshell v0.0.54"' - - run run_preflight check - [[ "$output" == *"✗ local env: MISSING_VAR not set"* ]] - [[ "$output" == *"export MISSING_VAR="* ]] -} - -@test "env input: short secret fully masked" { - write_providers_toml <<'EOF' -[[providers]] -name = "test" -type = "openshell" -description = "test provider" -inputs = [ - { key = "SHORT", kind = "env", secret = true }, -] -EOF - write_config_toml <<'EOF' -providers = ["test"] -EOF - - export SHORT="abc" - make_stub openshell 'echo "openshell v0.0.54"' - - run run_preflight check - [[ "$output" == *"✓ local env: SHORT=***"* ]] - [[ "$output" != *"abc"* ]] -} - -# ── FILE input tests ───────────────────────────────────────────────── - -@test "file input: existing file shows check" { - local fakefile="$TEST_TMPDIR/somefile.txt" - echo "data" > "$fakefile" - - write_providers_toml < "$adc" <<'JSON' -{"quota_project_id": "my-project", "type": "authorized_user"} -JSON - - write_providers_toml < "$gws" <<'JSON' -{"installed": {"client_id": "1715999888.apps.googleusercontent.com"}} -JSON - - write_providers_toml <