diff --git a/README.md b/README.md index f1bc06f..446c612 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,84 @@ # harness -Declarative configuration harness for [OpenShell](https://github.com/NVIDIA/OpenShell) AI agent sandboxes. +> **Experimental.** Built on [OpenShell](https://github.com/NVIDIA/OpenShell), which is itself alpha software. Expect breaking changes in both. -```bash -harness apply -f agent.yaml -``` - -## OpenShell provides the runtime - -[OpenShell](https://github.com/NVIDIA/OpenShell) runs AI agents in sandboxed containers with deny-by-default L7 network policy, credential proxy at the network boundary, Landlock filesystem isolation, and inference routing. The harness does not replace any of this. +Declarative workflow layer for OpenShell AI agent sandboxes. -## The harness adds declarative configuration - -- **One-file agent definition** -- agent, providers, gateway, policy, and sandbox files in a single YAML -- **Multi-document YAML** -- `kind: agent/provider/gateway/payload/policy` composed in one file -- **Payload files** -- upload configs to sandbox paths without rebuilding the image -- **Headless task mode** -- `--task "do something"` runs the agent and outputs to stdout -- **Multi-target deploy** -- same YAML works on local Podman, kind, and OpenShell -- **Dry-run validation** -- `--dry-run` checks everything before deploying -- **Config inspection** -- `-o yaml` outputs the fully resolved config - -## Use OpenShell directly for runtime operations +## Quick Start ```bash -openshell sandbox connect # interactive shell -openshell sandbox exec -- ... # run commands -openshell sandbox logs # view logs -openshell policy get # inspect policy +harness init # generate a config +harness doctor # check your environment +harness apply -f harness.yaml # launch a sandbox ``` -The harness handles setup. OpenShell handles the runtime. +### One-shot tasks -## Install +Run a task headlessly -- the agent executes in a sandbox and outputs results. ```bash -# macOS -brew tap nvidia/openshell && brew install openshell && brew services start openshell - -# Download the harness binary -curl -L https://github.com/robbycochran/harness-openshell/releases/latest/download/harness_darwin_arm64 -o harness -chmod +x harness +harness apply -f harness.yaml --task "review this codebase for security issues" +harness apply -f harness.yaml --task @skills/cpp-pro/SKILL.md ``` -Or build from source: `make cli` +### Coding agent -## Quick Start +Launch an interactive coding session with Claude Code or OpenCode. ```bash -# Set credentials (missing ones are skipped gracefully) -export GITHUB_TOKEN=ghp_... - -# Deploy a sandbox with the default agent config -harness apply -f profiles/agent-default.yaml +# Local (Podman) +harness apply -f harness.yaml --attach -# Run a task headlessly (agent outputs to stdout) -harness apply -f profiles/agent-default.yaml --task "review this codebase for security issues" +# On OpenShift +harness apply -f harness.yaml --attach --gateway ocp -# Run a task from a file -harness apply -f profiles/agent-default.yaml --task @tasks/review.md +# OpenCode instead of Claude +harness apply -f harness.yaml --attach --entrypoint opencode +``` -# Interactive mode -harness apply -f profiles/agent-default.yaml --attach +## Why this exists -# Validate without deploying -harness apply -f profiles/agent-default.yaml --dry-run +[OpenShell](https://github.com/NVIDIA/OpenShell) is a foundation layer -- sandboxed containers with deny-by-default L7 network policy, credential proxy, Landlock filesystem isolation, and inference routing. It is designed as a strict, secure base that other tooling builds workflows on top of. -# See the fully resolved config -harness apply -f profiles/agent-default.yaml -o yaml +The harness is one such workflow layer. One YAML file defines the agent, providers, payloads, and policy. One command deploys it -- locally via Podman or remotely on Kubernetes. -# Override the entrypoint -harness apply -f profiles/agent-default.yaml --entrypoint opencode -``` +OpenShell's upstream direction is toward a [Kubernetes Operator](https://github.com/NVIDIA/OpenShell/issues/1719) where providers and sandboxes become CRDs and the gateway narrows to data-plane only. The harness explores what the workflow layer looks like above that -- and covers the local Podman development path that no operator will own. ## The Agent YAML +A single file defines what runs, what credentials it gets, and what files are uploaded to the sandbox. + ```yaml -# profiles/agent-default.yaml name: agent entrypoint: claude tty: true providers: - profile: github - - profile: vertex-local + - profile: google-vertex-ai - profile: atlassian env: 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 payloads: - sandbox_path: /sandbox/.claude/CLAUDE.md local_path: profiles/images/sandbox-default/CLAUDE.md - - sandbox_path: /sandbox/.claude.json - local_path: profiles/images/sandbox-default/claude.json - sandbox_path: /sandbox/.mcp.json local_path: profiles/images/sandbox-default/mcp.json ``` -### Multi-Document Harness YAML +### Multi-document YAML -Bundle everything in one file: +Bundle agent, providers, payloads, and policy in one file: ```yaml --- kind: agent -name: my-agent +name: cpp-reviewer entrypoint: claude providers: - profile: github @@ -122,7 +91,7 @@ credentials: [GITHUB_TOKEN] kind: payload sandbox_path: /sandbox/.claude/CLAUDE.md content: | - You are a security review agent. + You are a C++ security review agent. --- kind: policy network_policies: @@ -131,99 +100,83 @@ network_policies: - { host: "api.github.com", port: 443 } ``` -```bash -harness apply -f harness.yaml +## How It Works + +``` +harness apply -f config.yaml + | + +-> Deploy gateway (Podman container or K8s StatefulSet) + +-> Register providers (credentials from host env) + +-> Upload payloads (CLAUDE.md, MCP config, skills) + +-> Create sandbox (isolated container, deny-by-default network) + +-> Run task (agent executes, outputs results) ``` -## Targets +OpenShell provides the runtime isolation. The harness provides the workflow. +For runtime operations, use openshell directly: ```bash -harness apply -f profiles/agent-default.yaml # local Podman -harness apply -f profiles/agent-default.yaml --gateway ocp # deploy to OpenShift -harness apply -f profiles/agent-opencode.yaml # OpenCode agent -harness deploy ocp # deploy gateway only +openshell sandbox connect # interactive shell +openshell sandbox exec -- ... # run commands +openshell sandbox logs # view logs ``` -## How It Works - -``` -harness CLI --> openshell CLI --> Gateway (Podman or K8s) - |-- Provider credentials - |-- L7 network policy - |-- inference.local proxy - +-- Sandbox container - |-- claude / opencode - |-- gh, mcp-atlassian, gws - +-- placeholder tokens -``` +## Install -The harness orchestrates three OpenShell components: +```bash +# macOS +brew tap nvidia/openshell && brew install openshell && brew services start openshell -- **Gateway** -- credential proxy and L7 network policy engine. Runs as Podman container (local) or K8s StatefulSet (remote). -- **Providers** -- credential registrations. Provider profiles in `profiles/providers/` are imported to the gateway. Missing credentials are skipped. -- **Sandbox** -- isolated container running the agent entrypoint. Credentials are proxy-managed placeholder tokens. Network egress is deny-by-default at L7. +# Download the harness binary +curl -L https://github.com/robbycochran/harness-openshell/releases/latest/download/harness_darwin_arm64 -o harness +chmod +x harness +``` -See the [OpenShell docs](https://github.com/NVIDIA/OpenShell) for the full security model. +Or build from source: `make cli` ## Reference ### Commands -``` -harness apply -f FILE [--task TEXT|@FILE] [--entrypoint NAME] [--gateway NAME] [--attach] [--dry-run] [-o yaml|json] - Deploy a sandboxed agent from a config file. - -f is required -- always specify which config to apply. - --task runs the agent headlessly with a task (inline text or @filepath). - --entrypoint overrides the agent entrypoint (claude, opencode, bash). - --attach enables interactive TTY mode. - --dry-run validates without deploying. - -o yaml outputs the fully resolved config. - -harness deploy [local|ocp|kind] - Deploy or verify the gateway for a target. - -harness get agents|providers|gateways [-o table|json|yaml] - List resources with consistent structured output. - -harness describe [-o table|json|yaml] - Detailed status for a specific sandbox. - -harness delete [--all] [--providers] [--k8s] - Delete sandboxes or other resources. -``` - -For runtime operations, use openshell directly: -``` -openshell sandbox connect [NAME] -openshell sandbox logs [NAME] [--tail] -openshell sandbox exec [NAME] -- ... -``` - -### Config Files - -| File | Purpose | -|------|---------| -| `profiles/agent-*.yaml` | Agent config: image, entrypoint, providers, env, payloads, task | -| `profiles/providers/` | OpenShell provider profiles (imported to gateway on registration) | -| `profiles/gateways/*.yaml` | Gateway profiles: `local.yaml`, `kind.yaml`, `ocp.yaml` | -| `profiles/images/sandbox-default/` | Sandbox image defaults (overridable via payloads) | +| Command | What it does | +|---------|--------------| +| `harness init` | Generate a harness.yaml (interactive or `--non-interactive`) | +| `harness doctor` | Validate environment (offline + online checks) | +| `harness apply -f FILE` | Deploy a sandbox from config | +| `harness apply --task TEXT` | One-shot headless run | +| `harness apply --task @FILE` | One-shot from a skill/playbook file | +| `harness apply --attach` | Interactive TTY mode | +| `harness apply --dry-run` | Validate without deploying | +| `harness apply -o yaml` | Output resolved config | +| `harness deploy [local\|ocp\|kind]` | Deploy gateway only | +| `harness get agents\|providers\|gateways` | List resources | +| `harness describe ` | Sandbox details | +| `harness delete [--all]` | Tear down | ### Credentials -Each provider requires credentials on the host. Missing providers are skipped. +Each provider discovers credentials from the host. Missing providers are skipped. | Provider | Required | |----------|----------| | `github` | `GITHUB_TOKEN` env var | -| `vertex-local` | `gcloud auth application-default login` + `ANTHROPIC_VERTEX_PROJECT_ID` + `CLOUD_ML_REGION` | +| `google-vertex-ai` | `gcloud auth application-default login` + `ANTHROPIC_VERTEX_PROJECT_ID` | | `atlassian` | `JIRA_API_TOKEN` + `JIRA_URL` + `JIRA_USERNAME` | -| `gws` | `gws auth login` (OAuth via [gws CLI](https://github.com/googleworkspace/cli)) | +| `google-workspace` | `gws auth login` ([gws CLI](https://github.com/googleworkspace/cli)) | + +### Config Files + +| File | Purpose | +|------|---------| +| `profiles/agent-*.yaml` | Agent configs | +| `profiles/providers/` | Provider profiles (imported to gateway) | +| `profiles/gateways/*.yaml` | Gateway profiles per target | +| `profiles/images/sandbox-default/` | Sandbox image defaults (overridable via payloads) | -## Documentation Map +## Documentation | Document | What it is | |----------|------------| -| [SPEC.md](SPEC.md) | Authoritative behavior spec for the CLI | -| [AGENTS.md](AGENTS.md) | Contributor guide: coding principles, upstream conventions, validation | -| [TODO.md](TODO.md) | Roadmap and known gaps | -| [docs/archive/](docs/archive/README.md) | Historical design docs | +| [SPEC.md](SPEC.md) | Behavior spec for the CLI | +| [AGENTS.md](AGENTS.md) | Contributor guide | +| [TODO.md](TODO.md) | Roadmap and upstream tracking | diff --git a/SPEC.md b/SPEC.md index f6e3073..91f2653 100644 --- a/SPEC.md +++ b/SPEC.md @@ -24,12 +24,12 @@ tty: true providers: - profile: github - - profile: vertex-local + - profile: google-vertex-ai - profile: atlassian env: JIRA_URL: ${JIRA_URL} JIRA_USERNAME: ${JIRA_USERNAME} - - profile: gws + - profile: google-workspace env: ANTHROPIC_BASE_URL: https://inference.local diff --git a/TODO.md b/TODO.md index c9d9486..c4f83b9 100644 --- a/TODO.md +++ b/TODO.md @@ -2,18 +2,18 @@ ## Next up -### `harness init` -- [ ] Generate a default `harness.yaml` in the current directory -- [ ] Detect available credentials and suggest providers -- [ ] Print next steps ("run `harness apply -f harness.yaml`") -- [ ] Highest-impact missing feature for standalone distribution - -### `harness doctor` -- [ ] Check openshell installed and >= 0.0.59 -- [ ] Check podman/docker running -- [ ] Check gateway reachable -- [ ] Check credentials available (GITHUB_TOKEN, ADC, JIRA, GWS) -- [ ] Actionable error messages ("install with: brew tap nvidia/openshell...") +### `harness init` [DONE] +- [x] Generate a harness.yaml with interactive prompts (entrypoint, providers, gateway) +- [x] Discover providers from `openshell provider list-profiles` +- [x] Print next steps ("run `harness doctor` then `harness apply`") +- [x] `--non-interactive`, `--force`, `--output` flags + +### `harness doctor` [DONE] +- [x] Check openshell installed and version +- [x] Check target-specific deps (podman/docker, kubectl, kind, kubeconfig) +- [x] Check provider credentials via `openshell provider profile export` +- [x] Online phase: check provider registration if gateway reachable +- [x] `-o table|json|yaml` output ### registerProviders should filter by agent's provider list - `registerProviders()` in `cmd/providers.go` registers all providers regardless @@ -62,12 +62,20 @@ - Prerequisite: proto files stabilize (OpenShell is alpha) ### Upstream issues to track -- #1719 -- K8s Operator design (affects provider CRDs) +- #1719 -- K8s Operator design (providers as CRDs, gateway narrows to data-plane) - #1851 -- Plugin system (affects binary naming) -- #1886 -- Declarative provider config in gateway.toml +- #1886 -- Declarative provider config in gateway.toml (core team rejected; redirected to #1719) +- #1520 -- Sandbox specs / apply -f (stale, no maintainer engagement) +- #1814 -- Named sandbox templates (no comments, blocked on #863) - #1922 -- Portable sandbox log collection - #1933 -- Centralized audit/event log +Upstream direction signal (as of 2026-06): the gateway stays a strict foundation +layer. Provider lifecycle and sandbox declaration are moving toward the operator/CRD +model for K8s. johntmyers mentioned hooks/middleware for API calls coming soon. +The harness's provider registration and multi-document YAML have no upstream +replacement on the current roadmap. + ## Observability & Tracing Langfuse hooks plugin working. MLflow spiked. SigNoz identified as strongest @@ -76,4 +84,4 @@ OTel backend. Integration deferred until `init`/`doctor` ship. ## Release - [x] CHANGELOG.md + LICENSE (Apache 2.0) -- [ ] `harness init` for standalone binary distribution +- [x] `harness init` for standalone binary distribution diff --git a/cmd/doctor.go b/cmd/doctor.go new file mode 100644 index 0000000..f5abfbe --- /dev/null +++ b/cmd/doctor.go @@ -0,0 +1,436 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/robbycochran/harness-openshell/internal/agent" + "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +type CheckResult struct { + Group string `json:"group"` + Name string `json:"name"` + Status string `json:"status"` + Message string `json:"message"` +} + +type CheckFunc func(cfg *agent.AgentConfig, cli, harnessDir string) []CheckResult + +func NewDoctorCmd(harnessDir, cli string) *cobra.Command { + var ( + agentFile string + agentName string + output string + ) + + cmd := &cobra.Command{ + Use: "doctor", + Short: "Validate environment for configured sandbox", + Long: `Check that prerequisites are met for running a sandbox. + +Phase 1 (offline): checks openshell binary, target dependencies, and +provider credentials without requiring a running gateway. + +Phase 2 (online): if the gateway is reachable, checks provider registration.`, + RunE: func(cmd *cobra.Command, args []string) error { + format, err := parseOutputFormat(output) + if err != nil { + return err + } + + h, err := resolveHarness(harnessDir, agentName, agentFile) + if err != nil { + return fmt.Errorf("resolving config: %w", err) + } + + checks := []CheckFunc{ + checkOpenShell, + checkTargetDeps, + checkProviderEnvVars, + } + + var results []CheckResult + for _, fn := range checks { + results = append(results, fn(h.Agent, cli, harnessDir)...) + } + + results = append(results, checkOnline(h.Agent, cli)...) + + if format != formatTable { + return printStructured(format, results) + } + + printDoctorTable(results) + + for _, r := range results { + if r.Status == "fail" { + return fmt.Errorf("one or more checks failed") + } + } + return nil + }, + } + + cmd.Flags().StringVarP(&agentFile, "file", "f", "", "Path to harness YAML") + cmd.Flags().StringVar(&agentName, "agent", "default", "Agent config name") + cmd.Flags().StringVarP(&output, "output", "o", "", "Output format (table, json, yaml)") + + return cmd +} + +func checkOpenShell(cfg *agent.AgentConfig, cli, _ string) []CheckResult { + path, err := exec.LookPath(cli) + if err != nil { + return []CheckResult{{ + Group: "openshell", + Name: "binary", + Status: "fail", + Message: fmt.Sprintf("%s not found on PATH", cli), + }} + } + + out, err := exec.Command(path, "--version").Output() + if err != nil { + return []CheckResult{{ + Group: "openshell", + Name: "binary", + Status: "warn", + Message: fmt.Sprintf("found at %s, version unknown", path), + }} + } + + version := strings.TrimSpace(string(out)) + return []CheckResult{{ + Group: "openshell", + Name: "binary", + Status: "pass", + Message: version, + }} +} + +func checkTargetDeps(cfg *agent.AgentConfig, _, _ string) []CheckResult { + target := cfg.Gateway + if target == "" { + target = "local" + } + + switch target { + case "local": + return checkLocalDeps() + case "kind": + return checkKindDeps() + case "ocp": + return checkRemoteDeps() + default: + return checkLocalDeps() + } +} + +func checkLocalDeps() []CheckResult { + if _, err := exec.LookPath("podman"); err == nil { + if err := exec.Command("podman", "info").Run(); err == nil { + ver := "" + if out, e := exec.Command("podman", "version", "--format", "{{.Client.Version}}").Output(); e == nil { + ver = " " + strings.TrimSpace(string(out)) + } + return []CheckResult{{Group: "target", Name: "local", Status: "pass", Message: "podman" + ver + " running"}} + } + } + if _, err := exec.LookPath("docker"); err == nil { + if err := exec.Command("docker", "info").Run(); err == nil { + return []CheckResult{{Group: "target", Name: "local", Status: "pass", Message: "docker running"}} + } + } + return []CheckResult{{Group: "target", Name: "local", Status: "fail", Message: "no container runtime (podman or docker) responding"}} +} + +func checkKindDeps() []CheckResult { + var results []CheckResult + results = append(results, checkLocalDeps()...) + + if _, err := exec.LookPath("kubectl"); err != nil { + results = append(results, CheckResult{Group: "target", Name: "kubectl", Status: "fail", Message: "kubectl not found on PATH"}) + } else { + results = append(results, CheckResult{Group: "target", Name: "kubectl", Status: "pass", Message: "found"}) + } + + if _, err := exec.LookPath("kind"); err != nil { + results = append(results, CheckResult{Group: "target", Name: "kind", Status: "fail", Message: "kind not found on PATH"}) + } else { + results = append(results, CheckResult{Group: "target", Name: "kind", Status: "pass", Message: "found"}) + } + + return results +} + +func checkRemoteDeps() []CheckResult { + var results []CheckResult + + kubectlFound := false + if _, err := exec.LookPath("kubectl"); err == nil { + kubectlFound = true + results = append(results, CheckResult{Group: "target", Name: "kubectl", Status: "pass", Message: "found"}) + } else if _, err := exec.LookPath("oc"); err == nil { + kubectlFound = true + results = append(results, CheckResult{Group: "target", Name: "oc", Status: "pass", Message: "found"}) + } + if !kubectlFound { + results = append(results, CheckResult{Group: "target", Name: "kubectl", Status: "fail", Message: "neither kubectl nor oc found on PATH"}) + } + + kubeconfig := os.Getenv("KUBECONFIG") + if kubeconfig == "" { + home, _ := os.UserHomeDir() + kubeconfig = filepath.Join(home, ".kube", "config") + } + if _, err := os.Stat(kubeconfig); err != nil { + results = append(results, CheckResult{Group: "target", Name: "kubeconfig", Status: "fail", Message: "kubeconfig not found at " + kubeconfig}) + } else { + results = append(results, CheckResult{Group: "target", Name: "kubeconfig", Status: "pass", Message: kubeconfig}) + } + + return results +} + +type providerProfile struct { + ID string `yaml:"id"` + DisplayName string `yaml:"display_name"` + Credentials []providerCredential `yaml:"credentials"` +} + +type providerCredential struct { + Name string `yaml:"name"` + EnvVars []string `yaml:"env_vars"` + Required bool `yaml:"required"` + Refresh *struct { + Strategy string `yaml:"strategy"` + } `yaml:"refresh,omitempty"` +} + +func checkProviderEnvVars(cfg *agent.AgentConfig, cli, harnessDir string) []CheckResult { + if len(cfg.Providers) == 0 { + return nil + } + + var results []CheckResult + for _, p := range cfg.Providers { + profile := loadProviderProfile(p.Profile, cli, harnessDir) + if profile == nil { + results = append(results, CheckResult{ + Group: "provider", + Name: p.Profile, + Status: "warn", + Message: "no profile found, cannot check credentials", + }) + continue + } + + allGatewayManaged := true + allSet := true + var missing []string + for _, cred := range profile.Credentials { + if !cred.Required { + continue + } + // Gateway-managed credentials (OAuth refresh, service account JWT) + // are handled by the gateway, not set by the user as env vars. + if cred.Refresh != nil { + continue + } + allGatewayManaged = false + found := false + for _, ev := range cred.EnvVars { + if os.Getenv(ev) != "" { + found = true + break + } + } + if !found { + allSet = false + missing = append(missing, cred.EnvVars[0]) + } + } + + if allGatewayManaged { + r := checkGatewayManagedProvider(p.Profile) + results = append(results, r) + } else if allSet { + results = append(results, CheckResult{ + Group: "provider", + Name: p.Profile, + Status: "pass", + Message: "credentials set", + }) + } else { + results = append(results, CheckResult{ + Group: "provider", + Name: p.Profile, + Status: "fail", + Message: "missing: " + strings.Join(missing, ", "), + }) + } + } + + return results +} + +func checkGatewayManagedProvider(name string) CheckResult { + switch name { + case "google-workspace": + gwsPath, _ := exec.LookPath("gws") + if gwsPath == "" { + return CheckResult{Group: "provider", Name: name, Status: "fail", Message: "gws CLI not installed (brew install googleworkspace/cli/gws)"} + } + if err := exec.Command(gwsPath, "auth", "export", "--unmasked").Run(); err != nil { + return CheckResult{Group: "provider", Name: name, Status: "fail", Message: "not authenticated (run: gws auth login)"} + } + return CheckResult{Group: "provider", Name: name, Status: "pass", Message: "authenticated (gateway-managed OAuth)"} + case "google-vertex-ai": + home, _ := os.UserHomeDir() + adcPath := envOr("GOOGLE_APPLICATION_CREDENTIALS", + filepath.Join(home, ".config", "gcloud", "application_default_credentials.json")) + if _, err := os.Stat(adcPath); err != nil { + return CheckResult{Group: "provider", Name: name, Status: "fail", Message: "ADC not found (run: gcloud auth application-default login)"} + } + return CheckResult{Group: "provider", Name: name, Status: "pass", Message: "ADC found (gateway-managed refresh)"} + default: + return CheckResult{Group: "provider", Name: name, Status: "pass", Message: "gateway-managed credentials"} + } +} + +func loadProviderProfile(name, cli, harnessDir string) *providerProfile { + if profile := loadProfileFromOpenShell(name, cli); profile != nil { + return profile + } + return loadProfileFromDisk(name, harnessDir) +} + +func loadProfileFromOpenShell(name, cli string) *providerProfile { + path, err := exec.LookPath(cli) + if err != nil { + return nil + } + out, err := exec.Command(path, "provider", "profile", "export", name).Output() + if err != nil { + return nil + } + var p providerProfile + if err := yaml.Unmarshal(out, &p); err != nil { + return nil + } + return &p +} + +func loadProfileFromDisk(name, harnessDir string) *providerProfile { + if harnessDir == "" { + return nil + } + candidates := []string{ + filepath.Join(harnessDir, "profiles", "providers", name+".yaml"), + } + for _, path := range candidates { + data, err := os.ReadFile(path) + if err != nil { + continue + } + var p providerProfile + if err := yaml.Unmarshal(data, &p); err != nil { + continue + } + return &p + } + return nil +} + +func checkOnline(cfg *agent.AgentConfig, cli string) []CheckResult { + gw := gateway.New(cli) + if gw.ActiveGateway() == "" { + return []CheckResult{{ + Group: "gateway", + Name: "status", + Status: "warn", + Message: "no active gateway (Phase 2 checks skipped)", + }} + } + + _, err := gw.ProviderList() + if err != nil { + return []CheckResult{{ + Group: "gateway", + Name: "status", + Status: "warn", + Message: "gateway not reachable (Phase 2 checks skipped)", + }} + } + + var results []CheckResult + results = append(results, CheckResult{ + Group: "gateway", + Name: "status", + Status: "pass", + Message: "connected", + }) + + for _, p := range cfg.Providers { + if gw.ProviderGet(p.Profile) == nil { + results = append(results, CheckResult{ + Group: "gateway", + Name: p.Profile, + Status: "pass", + Message: "registered", + }) + } else { + results = append(results, CheckResult{ + Group: "gateway", + Name: p.Profile, + Status: "warn", + Message: "not registered (will be registered on apply)", + }) + } + } + + return results +} + +func printDoctorTable(results []CheckResult) { + groups := []string{"openshell", "target", "provider", "gateway"} + groupLabels := map[string]string{ + "openshell": "OPENSHELL", + "target": "TARGET", + "provider": "PROVIDER", + "gateway": "GATEWAY", + } + + for _, g := range groups { + var groupResults []CheckResult + for _, r := range results { + if r.Group == g { + groupResults = append(groupResults, r) + } + } + if len(groupResults) == 0 { + continue + } + + fmt.Println(groupLabels[g]) + for _, r := range groupResults { + icon := " " + switch r.Status { + case "pass": + icon = "OK" + case "warn": + icon = "!!" + case "fail": + icon = "XX" + } + fmt.Printf(" %-16s %s %s\n", r.Name, icon, r.Message) + } + fmt.Println() + } +} diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go new file mode 100644 index 0000000..4b3a77c --- /dev/null +++ b/cmd/doctor_test.go @@ -0,0 +1,272 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/robbycochran/harness-openshell/internal/agent" +) + +func TestCheckOpenShell_Found(t *testing.T) { + cfg := testAgentConfig(t) + results := checkOpenShell(cfg, "openshell", "") + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + r := results[0] + if r.Group != "openshell" { + t.Errorf("Group = %q, want openshell", r.Group) + } + // On machines with openshell installed, this should pass. + // On machines without it, it should fail. + if r.Status != "pass" && r.Status != "fail" { + t.Errorf("Status = %q, want pass or fail", r.Status) + } +} + +func TestCheckOpenShell_NotFound(t *testing.T) { + cfg := testAgentConfig(t) + results := checkOpenShell(cfg, "nonexistent-binary-xyz", "") + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Status != "fail" { + t.Errorf("Status = %q, want fail", results[0].Status) + } + if results[0].Name != "binary" { + t.Errorf("Name = %q, want binary", results[0].Name) + } +} + +func TestCheckTargetDeps_Local(t *testing.T) { + cfg := testAgentConfig(t) + cfg.Gateway = "local" + results := checkTargetDeps(cfg, "", "") + if len(results) == 0 { + t.Fatal("expected at least 1 result") + } + if results[0].Group != "target" { + t.Errorf("Group = %q, want target", results[0].Group) + } +} + +func TestCheckTargetDeps_Kind(t *testing.T) { + cfg := testAgentConfig(t) + cfg.Gateway = "kind" + results := checkTargetDeps(cfg, "", "") + if len(results) < 2 { + t.Fatalf("expected at least 2 results for kind, got %d", len(results)) + } + names := make(map[string]bool) + for _, r := range results { + names[r.Name] = true + } + if !names["kubectl"] { + t.Error("missing kubectl check for kind target") + } + if !names["kind"] { + t.Error("missing kind binary check for kind target") + } +} + +func TestCheckTargetDeps_Remote(t *testing.T) { + cfg := testAgentConfig(t) + cfg.Gateway = "ocp" + results := checkTargetDeps(cfg, "", "") + if len(results) < 1 { + t.Fatal("expected at least 1 result for remote") + } + hasKubeconfig := false + for _, r := range results { + if r.Name == "kubeconfig" { + hasKubeconfig = true + } + } + if !hasKubeconfig { + t.Error("missing kubeconfig check for remote target") + } +} + +func TestCheckTargetDeps_EmptyGateway_DefaultsToLocal(t *testing.T) { + cfg := testAgentConfig(t) + cfg.Gateway = "" + results := checkTargetDeps(cfg, "", "") + if len(results) == 0 { + t.Fatal("expected at least 1 result") + } + if results[0].Name != "local" { + t.Errorf("Name = %q, want local (default)", results[0].Name) + } +} + +func TestCheckProviderEnvVars_AllSet(t *testing.T) { + dir := t.TempDir() + writeProviderProfile(t, dir, "github", ` +id: github +credentials: + - name: token + env_vars: [GITHUB_TOKEN] + required: true +`) + t.Setenv("GITHUB_TOKEN", "test-value") + + cfg := testAgentConfig(t) + cfg.Providers = []agent.ProviderRef{{Profile: "github"}} + + results := checkProviderEnvVars(cfg, "nonexistent-cli", dir) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Status != "pass" { + t.Errorf("Status = %q, want pass", results[0].Status) + } +} + +func TestCheckProviderEnvVars_Missing(t *testing.T) { + dir := t.TempDir() + writeProviderProfile(t, dir, "github", ` +id: github +credentials: + - name: token + env_vars: [GITHUB_TOKEN] + required: true +`) + t.Setenv("GITHUB_TOKEN", "") + + cfg := testAgentConfig(t) + cfg.Providers = []agent.ProviderRef{{Profile: "github"}} + + results := checkProviderEnvVars(cfg, "nonexistent-cli", dir) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Status != "fail" { + t.Errorf("Status = %q, want fail", results[0].Status) + } +} + +func TestCheckProviderEnvVars_NoProviders(t *testing.T) { + cfg := testAgentConfig(t) + cfg.Providers = nil + + results := checkProviderEnvVars(cfg, "nonexistent-cli", "") + if len(results) != 0 { + t.Errorf("expected 0 results for no providers, got %d", len(results)) + } +} + +func TestCheckProviderEnvVars_NoProfile(t *testing.T) { + cfg := testAgentConfig(t) + cfg.Providers = []agent.ProviderRef{{Profile: "unknown-provider"}} + + results := checkProviderEnvVars(cfg, "nonexistent-cli", "") + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Status != "warn" { + t.Errorf("Status = %q, want warn for unknown provider", results[0].Status) + } +} + +func TestCheckProviderEnvVars_OptionalCredential(t *testing.T) { + dir := t.TempDir() + writeProviderProfile(t, dir, "vertex", ` +id: google-vertex-ai +credentials: + - name: service_account_key + env_vars: [GOOGLE_SERVICE_ACCOUNT_KEY] + required: false +`) + + cfg := testAgentConfig(t) + cfg.Providers = []agent.ProviderRef{{Profile: "vertex"}} + + results := checkProviderEnvVars(cfg, "nonexistent-cli", dir) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Status != "pass" { + t.Errorf("Status = %q, want pass (all credentials optional)", results[0].Status) + } +} + +func TestDoctorOutputJSON(t *testing.T) { + results := []CheckResult{ + {Group: "openshell", Name: "binary", Status: "pass", Message: "v0.0.63"}, + {Group: "target", Name: "local", Status: "pass", Message: "podman running"}, + } + + err := printStructured(formatJSON, results) + if err != nil { + t.Fatalf("printStructured(json): %v", err) + } +} + +func TestDoctorNoCredentialValues(t *testing.T) { + dir := t.TempDir() + writeProviderProfile(t, dir, "github", ` +id: github +credentials: + - name: token + env_vars: [GITHUB_TOKEN] + required: true +`) + secretValue := "ghp_secret123456789" + t.Setenv("GITHUB_TOKEN", secretValue) + + cfg := testAgentConfig(t) + cfg.Providers = []agent.ProviderRef{{Profile: "github"}} + + results := checkProviderEnvVars(cfg, "nonexistent-cli", dir) + for _, r := range results { + if r.Message == secretValue || r.Name == secretValue { + t.Errorf("credential value leaked in output: %+v", r) + } + } +} + +func TestCheckProviderEnvVars_GatewayManagedSkipsEnvCheck(t *testing.T) { + dir := t.TempDir() + writeProviderProfile(t, dir, "myoauth", ` +id: myoauth +credentials: + - name: access_token + env_vars: [MY_TOKEN] + required: true + refresh: + strategy: oauth2_refresh_token +`) + + cfg := testAgentConfig(t) + cfg.Providers = []agent.ProviderRef{{Profile: "myoauth"}} + + results := checkProviderEnvVars(cfg, "nonexistent-cli", dir) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Status == "fail" { + t.Errorf("gateway-managed credential should not fail env var check, got: %s", results[0].Message) + } +} + +// --- helpers --- + +func testAgentConfig(t *testing.T) *agent.AgentConfig { + t.Helper() + return &agent.AgentConfig{ + Name: "test-agent", + Entrypoint: "claude", + } +} + +func writeProviderProfile(t *testing.T, harnessDir, name, content string) { + t.Helper() + dir := filepath.Join(harnessDir, "profiles", "providers") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name+".yaml"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/executor_test.go b/cmd/executor_test.go index dd470e3..b5c372b 100644 --- a/cmd/executor_test.go +++ b/cmd/executor_test.go @@ -139,8 +139,8 @@ func TestUpLocal_SandboxCreateOpts(t *testing.T) { t.Setenv("HARNESS_OS_IMAGE", "") dir := setupTestAgent(t) gw := &mockGW{ - providerList: []string{"github", "vertex-local"}, - providers: map[string]bool{"github": true, "vertex-local": true}, + providerList: []string{"github", "google-vertex-ai"}, + providers: map[string]bool{"github": true, "google-vertex-ai": true}, } err := upLocal(upLocalOpts{ diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index d6e1851..2608ed7 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -77,7 +77,7 @@ image: quay.io/test:latest entrypoint: claude providers: - profile: github - - profile: vertex-local + - profile: google-vertex-ai - profile: atlassian env: FOO: bar diff --git a/cmd/init_cmd.go b/cmd/init_cmd.go new file mode 100644 index 0000000..814b31e --- /dev/null +++ b/cmd/init_cmd.go @@ -0,0 +1,273 @@ +package cmd + +import ( + "bufio" + "fmt" + "io" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/robbycochran/harness-openshell/internal/agent" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +type availableProvider struct { + ID string + DisplayName string + Category string +} + +var defaultProviders = []availableProvider{ + {ID: "github", DisplayName: "GitHub", Category: "source-control"}, + {ID: "google-vertex-ai", DisplayName: "Google Vertex AI", Category: "inference"}, + {ID: "atlassian", DisplayName: "Atlassian", Category: "knowledge"}, + {ID: "google-workspace", DisplayName: "Google Workspace", Category: "knowledge"}, +} + +func NewInitCmd() *cobra.Command { + var ( + outputPath string + force bool + nonInteractive bool + ) + + cmd := &cobra.Command{ + Use: "init", + Short: "Generate a harness.yaml config file", + Long: `Create a harness.yaml by selecting an entrypoint, providers, and +gateway target. The generated config is yours to version, share, and customize. + +Use --non-interactive to write the embedded default config without prompts.`, + RunE: func(cmd *cobra.Command, args []string) error { + return initRun(os.Stdin, os.Stdout, outputPath, force, nonInteractive, DefaultAgentConfig) + }, + } + + cmd.Flags().StringVarP(&outputPath, "output", "o", "harness.yaml", "Output file path") + cmd.Flags().BoolVar(&force, "force", false, "Overwrite existing file") + cmd.Flags().BoolVar(&nonInteractive, "non-interactive", false, "Use defaults without prompting") + + return cmd +} + +func initRun(in io.Reader, out io.Writer, outputPath string, force, nonInteractive bool, defaultCfg []byte) error { + if _, err := os.Stat(outputPath); err == nil && !force { + return fmt.Errorf("%s already exists (use --force to overwrite)", outputPath) + } + + cfg, err := agent.Parse(defaultCfg) + if err != nil { + return fmt.Errorf("parsing default config: %w", err) + } + + if !nonInteractive { + scanner := bufio.NewScanner(in) + + entrypoint, err := promptEntrypoint(scanner, out) + if err != nil { + return err + } + cfg.Entrypoint = entrypoint + + providers, err := promptProviders(scanner, out) + if err != nil { + return err + } + cfg.Providers = providers + + target, err := promptGateway(scanner, out) + if err != nil { + return err + } + cfg.Gateway = target + } + + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshaling config: %w", err) + } + + if err := os.WriteFile(outputPath, data, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", outputPath, err) + } + + fmt.Fprintf(out, "Config written to %s.\nRun `harness doctor` to validate your environment, then `harness apply -f %s` to launch.\n", outputPath, outputPath) + return nil +} + +func promptEntrypoint(scanner *bufio.Scanner, out io.Writer) (string, error) { + fmt.Fprint(out, "Entrypoint [claude/opencode/custom] (default: claude): ") + if !scanner.Scan() { + return "claude", nil + } + input := strings.TrimSpace(scanner.Text()) + if input == "" { + return "claude", nil + } + return input, nil +} + +func promptProviders(scanner *bufio.Scanner, out io.Writer) ([]agent.ProviderRef, error) { + available := discoverProviders() + + fmt.Fprintln(out, "Available providers:") + for i, p := range available { + fmt.Fprintf(out, " [%d] %-20s (%s)\n", i+1, p.DisplayName, p.Category) + } + + defaults := providerDefaults(available) + fmt.Fprintf(out, "Select (comma-separated, or 'none') [%s]: ", defaults) + + if !scanner.Scan() { + return buildProviderRefs(available, parseSelection(defaults, len(available))), nil + } + + input := strings.TrimSpace(scanner.Text()) + if input == "" { + return buildProviderRefs(available, parseSelection(defaults, len(available))), nil + } + if strings.ToLower(input) == "none" { + return nil, nil + } + + indices := parseSelection(input, len(available)) + if len(indices) == 0 { + return nil, fmt.Errorf("invalid provider selection: %q", input) + } + + return buildProviderRefs(available, indices), nil +} + +func promptGateway(scanner *bufio.Scanner, out io.Writer) (string, error) { + fmt.Fprint(out, "Gateway target [local/kind/ocp] (default: local): ") + if !scanner.Scan() { + return "local", nil + } + input := strings.TrimSpace(strings.ToLower(scanner.Text())) + if input == "" { + return "local", nil + } + switch input { + case "local", "kind", "ocp": + return input, nil + default: + return "", fmt.Errorf("unknown gateway target: %q (use local, kind, or ocp)", input) + } +} + +func discoverProviders() []availableProvider { + if providers := discoverFromOpenShell(); len(providers) > 0 { + return providers + } + return defaultProviders +} + +func discoverFromOpenShell() []availableProvider { + path, err := exec.LookPath("openshell") + if err != nil { + return nil + } + out, err := exec.Command(path, "provider", "list-profiles").Output() + if err != nil { + return nil + } + return parseListProfiles(string(out)) +} + +func parseListProfiles(output string) []availableProvider { + var providers []availableProvider + var currentCategory string + + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + // Category headers are indented with bold markers or all caps + if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") { + continue + } + + // Lines with 2+ spaces of indent and containing actual provider data + // Format: " name Display Name endpoints: N category" + fields := strings.Fields(trimmed) + if len(fields) < 2 { + continue + } + + // Skip category header lines (like "INFERENCE", "AGENT", etc.) + if len(fields) == 1 { + currentCategory = strings.ToLower(fields[0]) + continue + } + + // Check if this looks like a provider line (has "endpoints:" somewhere) + epIdx := -1 + for i, f := range fields { + if f == "endpoints:" { + epIdx = i + break + } + } + if epIdx < 0 { + continue + } + + id := fields[0] + displayName := strings.Join(fields[1:epIdx], " ") + + category := currentCategory + if epIdx+2 < len(fields) { + category = fields[epIdx+2] + } + + providers = append(providers, availableProvider{ + ID: id, + DisplayName: displayName, + Category: category, + }) + } + + return providers +} + +func providerDefaults(available []availableProvider) string { + var defaults []string + for i, p := range available { + switch p.ID { + case "github", "google-vertex-ai": + defaults = append(defaults, strconv.Itoa(i+1)) + } + } + if len(defaults) == 0 && len(available) > 0 { + defaults = append(defaults, "1") + } + return strings.Join(defaults, ",") +} + +func parseSelection(input string, max int) []int { + var indices []int + for _, part := range strings.Split(input, ",") { + s := strings.TrimSpace(part) + n, err := strconv.Atoi(s) + if err != nil || n < 1 || n > max { + continue + } + indices = append(indices, n-1) + } + return indices +} + +func buildProviderRefs(available []availableProvider, indices []int) []agent.ProviderRef { + var refs []agent.ProviderRef + for _, i := range indices { + if i < len(available) { + refs = append(refs, agent.ProviderRef{Profile: available[i].ID}) + } + } + return refs +} diff --git a/cmd/init_cmd_test.go b/cmd/init_cmd_test.go new file mode 100644 index 0000000..d8e5f19 --- /dev/null +++ b/cmd/init_cmd_test.go @@ -0,0 +1,338 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/robbycochran/harness-openshell/internal/agent" + "gopkg.in/yaml.v3" +) + +var testDefaultConfig = []byte(`name: test-agent +entrypoint: claude +tty: true +providers: + - profile: google-vertex-ai +env: + ANTHROPIC_BASE_URL: https://inference.local +`) + +func TestInitRun_NonInteractive(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "harness.yaml") + var buf bytes.Buffer + + err := initRun(strings.NewReader(""), &buf, outPath, false, true, testDefaultConfig) + if err != nil { + t.Fatalf("initRun: %v", err) + } + + data, err := os.ReadFile(outPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + var cfg agent.AgentConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if cfg.Name != "test-agent" { + t.Errorf("Name = %q, want test-agent", cfg.Name) + } + if cfg.Entrypoint != "claude" { + t.Errorf("Entrypoint = %q, want claude", cfg.Entrypoint) + } +} + +func TestInitRun_OverwriteGuard(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "harness.yaml") + os.WriteFile(outPath, []byte("existing"), 0o644) + var buf bytes.Buffer + + err := initRun(strings.NewReader(""), &buf, outPath, false, true, testDefaultConfig) + if err == nil { + t.Fatal("expected error for existing file without --force") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("error = %q, want 'already exists'", err) + } +} + +func TestInitRun_OverwriteWithForce(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "harness.yaml") + os.WriteFile(outPath, []byte("existing"), 0o644) + var buf bytes.Buffer + + err := initRun(strings.NewReader(""), &buf, outPath, true, true, testDefaultConfig) + if err != nil { + t.Fatalf("initRun with --force: %v", err) + } + + data, err := os.ReadFile(outPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) == "existing" { + t.Error("file was not overwritten") + } +} + +func TestInitRun_InteractiveDefaults(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "harness.yaml") + var buf bytes.Buffer + + // Empty input = accept defaults for each prompt + input := "\n\n\n" + err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig) + if err != nil { + t.Fatalf("initRun: %v", err) + } + + data, err := os.ReadFile(outPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + var cfg agent.AgentConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if cfg.Entrypoint != "claude" { + t.Errorf("Entrypoint = %q, want claude (default)", cfg.Entrypoint) + } +} + +func TestInitRun_InteractiveOpenCode(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "harness.yaml") + var buf bytes.Buffer + + input := "opencode\n1\nlocal\n" + err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig) + if err != nil { + t.Fatalf("initRun: %v", err) + } + + data, _ := os.ReadFile(outPath) + var cfg agent.AgentConfig + yaml.Unmarshal(data, &cfg) + if cfg.Entrypoint != "opencode" { + t.Errorf("Entrypoint = %q, want opencode", cfg.Entrypoint) + } +} + +func TestInitRun_InteractiveProvidersSingle(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "harness.yaml") + var buf bytes.Buffer + + input := "claude\n1\nlocal\n" + err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig) + if err != nil { + t.Fatalf("initRun: %v", err) + } + + data, _ := os.ReadFile(outPath) + var cfg agent.AgentConfig + yaml.Unmarshal(data, &cfg) + if len(cfg.Providers) != 1 { + t.Fatalf("Providers count = %d, want 1", len(cfg.Providers)) + } +} + +func TestInitRun_InteractiveProvidersMultiple(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "harness.yaml") + var buf bytes.Buffer + + input := "claude\n1,3\nlocal\n" + err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig) + if err != nil { + t.Fatalf("initRun: %v", err) + } + + data, _ := os.ReadFile(outPath) + var cfg agent.AgentConfig + yaml.Unmarshal(data, &cfg) + if len(cfg.Providers) != 2 { + t.Fatalf("Providers count = %d, want 2", len(cfg.Providers)) + } +} + +func TestInitRun_InteractiveProvidersNone(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "harness.yaml") + var buf bytes.Buffer + + input := "claude\nnone\nlocal\n" + err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig) + if err != nil { + t.Fatalf("initRun: %v", err) + } + + data, _ := os.ReadFile(outPath) + var cfg agent.AgentConfig + yaml.Unmarshal(data, &cfg) + if len(cfg.Providers) != 0 { + t.Errorf("Providers count = %d, want 0 for 'none'", len(cfg.Providers)) + } +} + +func TestInitRun_InteractiveGatewayKind(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "harness.yaml") + var buf bytes.Buffer + + input := "claude\n1\nkind\n" + err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig) + if err != nil { + t.Fatalf("initRun: %v", err) + } + + data, _ := os.ReadFile(outPath) + var cfg agent.AgentConfig + yaml.Unmarshal(data, &cfg) + if cfg.Gateway != "kind" { + t.Errorf("Gateway = %q, want kind", cfg.Gateway) + } +} + +func TestInitRun_InteractiveGatewayOCP(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "harness.yaml") + var buf bytes.Buffer + + input := "claude\n1\nocp\n" + err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig) + if err != nil { + t.Fatalf("initRun: %v", err) + } + + data, _ := os.ReadFile(outPath) + var cfg agent.AgentConfig + yaml.Unmarshal(data, &cfg) + if cfg.Gateway != "ocp" { + t.Errorf("Gateway = %q, want ocp", cfg.Gateway) + } +} + +func TestInitRun_InvalidGateway(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "harness.yaml") + var buf bytes.Buffer + + input := "claude\n1\nbadtarget\n" + err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig) + if err == nil { + t.Fatal("expected error for invalid gateway target") + } +} + +func TestInitRun_RemoteIsInvalidGateway(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "harness.yaml") + var buf bytes.Buffer + + input := "claude\n1\nremote\n" + err := initRun(strings.NewReader(input), &buf, outPath, false, false, testDefaultConfig) + if err == nil { + t.Fatal("expected error: 'remote' is not a valid gateway, use 'ocp'") + } +} + +func TestInitRun_OutputContainsNextSteps(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "harness.yaml") + var buf bytes.Buffer + + err := initRun(strings.NewReader(""), &buf, outPath, false, true, testDefaultConfig) + if err != nil { + t.Fatalf("initRun: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "harness doctor") { + t.Error("output should mention 'harness doctor'") + } + if !strings.Contains(output, "harness apply") { + t.Error("output should mention 'harness apply'") + } +} + +func TestParseSelection_Valid(t *testing.T) { + indices := parseSelection("1,3,4", 4) + if len(indices) != 3 { + t.Fatalf("len = %d, want 3", len(indices)) + } + if indices[0] != 0 || indices[1] != 2 || indices[2] != 3 { + t.Errorf("indices = %v, want [0 2 3]", indices) + } +} + +func TestParseSelection_OutOfRange(t *testing.T) { + indices := parseSelection("0,5,2", 4) + if len(indices) != 1 || indices[0] != 1 { + t.Errorf("indices = %v, want [1] (only valid selection)", indices) + } +} + +func TestParseSelection_Invalid(t *testing.T) { + indices := parseSelection("abc", 4) + if len(indices) != 0 { + t.Errorf("indices = %v, want empty for invalid input", indices) + } +} + +func TestParseListProfiles(t *testing.T) { + output := `Available Provider Profiles: + + INFERENCE + google-vertex-ai Google Vertex AI endpoints: 4 inference + + SOURCE CONTROL + github GitHub endpoints: 3 + + KNOWLEDGE + atlassian Atlassian (Jira + Confluence) endpoints: 3 + google-workspace Google Workspace endpoints: 8 +` + providers := parseListProfiles(output) + if len(providers) < 3 { + t.Fatalf("expected at least 3 providers, got %d: %+v", len(providers), providers) + } + + found := make(map[string]bool) + for _, p := range providers { + found[p.ID] = true + } + for _, id := range []string{"google-vertex-ai", "github", "atlassian"} { + if !found[id] { + t.Errorf("missing provider %q in parsed output", id) + } + } +} + +func TestInitNoCredentialLeak(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "harness.yaml") + var buf bytes.Buffer + + t.Setenv("ANTHROPIC_API_KEY", "sk-secret-key-12345") + + err := initRun(strings.NewReader(""), &buf, outPath, false, true, testDefaultConfig) + if err != nil { + t.Fatalf("initRun: %v", err) + } + + data, _ := os.ReadFile(outPath) + content := string(data) + if strings.Contains(content, "sk-secret-key-12345") { + t.Error("credential value leaked into generated YAML") + } +} diff --git a/cmd/providers.go b/cmd/providers.go index 493d693..0a803c1 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -56,7 +56,7 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, provid return err } } - if _, ok := wanted["vertex-local"]; ok { + if _, ok := wanted["google-vertex-ai"]; ok { home, _ := os.UserHomeDir() adcPath := envOr("GOOGLE_APPLICATION_CREDENTIALS", filepath.Join(home, ".config", "gcloud", "application_default_credentials.json")) @@ -67,7 +67,7 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, provid configs = append(configs, "VERTEX_AI_PROJECT_ID="+project) } configs = append(configs, "VERTEX_AI_REGION="+region) - if err := registerADC("vertex-local", "google-vertex-ai", model, gw, configs); err != nil { + if err := registerADC("google-vertex-ai", "google-vertex-ai", model, gw, configs); err != nil { return err } } @@ -76,7 +76,7 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, provid return err } } - if _, ok := wanted["gws"]; ok { + if _, ok := wanted["google-workspace"]; ok { if err := registerGWS(harnessDir, gw); err != nil { return err } @@ -156,8 +156,8 @@ 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 --provider-refresh to recreate)") + if gw.ProviderGet("google-workspace") == nil { + status.Info("google-workspace: exists (use --provider-refresh to recreate)") return nil } @@ -188,10 +188,10 @@ func registerGWS(harnessDir string, gw gateway.Gateway) error { } // Create provider with a placeholder — the gateway will refresh it immediately. - if err := gw.ProviderCreate("gws", "google-workspace", gateway.ProviderCreateOpts{ + if err := gw.ProviderCreate("google-workspace", "google-workspace", gateway.ProviderCreateOpts{ Credentials: []string{"GOOGLE_WORKSPACE_CLI_TOKEN=pending"}, }); err != nil { - return fmt.Errorf("creating gws provider: %w", err) + return fmt.Errorf("creating google-workspace provider: %w", err) } // Read scopes from the provider profile so they're defined in one place. @@ -209,21 +209,21 @@ func registerGWS(harnessDir string, gw gateway.Gateway) error { if profileScopes != "" { material = append(material, "scopes="+profileScopes) } - if err := gw.ProviderRefreshConfigure("gws", gateway.ProviderRefreshOpts{ + if err := gw.ProviderRefreshConfigure("google-workspace", gateway.ProviderRefreshOpts{ CredentialKey: "GOOGLE_WORKSPACE_CLI_TOKEN", Strategy: "oauth2-refresh-token", Material: material, SecretMaterialKeys: []string{"client_secret", "refresh_token"}, }); err != nil { - return fmt.Errorf("configuring gws refresh: %w", err) + return fmt.Errorf("configuring google-workspace refresh: %w", err) } // Force an immediate refresh so the token is valid before the first sandbox. - if err := gw.ProviderRefreshRotate("gws", "GOOGLE_WORKSPACE_CLI_TOKEN"); err != nil { - status.Infof("gws: refresh rotate failed (token will refresh automatically): %v", err) + if err := gw.ProviderRefreshRotate("google-workspace", "GOOGLE_WORKSPACE_CLI_TOKEN"); err != nil { + status.Infof("google-workspace: refresh rotate failed (token will refresh automatically): %v", err) } - status.OK("gws: registered (gateway-managed token refresh)") + status.OK("google-workspace: registered (gateway-managed token refresh)") return nil } diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index c390e85..ec2aa10 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -101,14 +101,14 @@ func TestProviderNames(t *testing.T) { Providers: []ProviderRef{ {Profile: "github"}, {Profile: "atlassian"}, - {Profile: "gws"}, + {Profile: "google-workspace"}, }, } names := cfg.ProviderNames() if len(names) != 3 { t.Fatalf("len = %d, want 3", len(names)) } - if names[0] != "github" || names[1] != "atlassian" || names[2] != "gws" { + if names[0] != "github" || names[1] != "atlassian" || names[2] != "google-workspace" { t.Errorf("names = %v", names) } } diff --git a/internal/gateway/cli_test.go b/internal/gateway/cli_test.go index 834dc8e..7083906 100644 --- a/internal/gateway/cli_test.go +++ b/internal/gateway/cli_test.go @@ -21,7 +21,7 @@ func TestProviderList_ParsesTable(t *testing.T) { bin := writeStub(t, `#!/bin/bash printf "NAME\tTYPE\tSTATUS\n" printf "github\tgithub\tactive\n" -printf "vertex-local\tgoogle-vertex-ai\tactive\n" +printf "google-vertex-ai\tgoogle-vertex-ai\tactive\n" printf "atlassian\tatlassian\tactive\n" `) gw := New(bin) @@ -32,7 +32,7 @@ printf "atlassian\tatlassian\tactive\n" if len(names) != 3 { t.Fatalf("got %d providers, want 3: %v", len(names), names) } - if names[0] != "github" || names[1] != "vertex-local" || names[2] != "atlassian" { + if names[0] != "github" || names[1] != "google-vertex-ai" || names[2] != "atlassian" { t.Errorf("names = %v", names) } } @@ -137,7 +137,7 @@ echo "$@" > `+argsFile+` gw.SandboxCreate(SandboxCreateOpts{ Name: "my-agent", From: "quay.io/test:latest", - Providers: []string{"github", "vertex-local"}, + Providers: []string{"github", "google-vertex-ai"}, TTY: true, Keep: false, Uploads: []Upload{{Src: "/tmp/openshell", Dst: "/sandbox/.config"}}, @@ -151,7 +151,7 @@ echo "$@" > `+argsFile+` "--tty", "--from quay.io/test:latest", "--provider github", - "--provider vertex-local", + "--provider google-vertex-ai", "--no-keep", "--upload /tmp/openshell:/sandbox/.config", "--no-git-ignore", @@ -466,7 +466,7 @@ func TestProviderCreate_Args(t *testing.T) { printf '%s\n' "$*" > `+argsFile+` `) gw := New(bin) - gw.ProviderCreate("vertex-local", "google-vertex-ai", ProviderCreateOpts{ + gw.ProviderCreate("google-vertex-ai", "google-vertex-ai", ProviderCreateOpts{ FromADC: true, Credentials: []string{"TOKEN=abc"}, Configs: []string{"PROJECT=my-proj", "REGION=us-east5"}, @@ -474,7 +474,7 @@ printf '%s\n' "$*" > `+argsFile+` data, _ := os.ReadFile(argsFile) args := strings.TrimSpace(string(data)) for _, want := range []string{ - "--name vertex-local", + "--name google-vertex-ai", "--type google-vertex-ai", "--from-gcloud-adc", "--credential TOKEN=abc", @@ -494,12 +494,12 @@ func TestInferenceSet_Args(t *testing.T) { printf '%s\n' "$*" > `+argsFile+` `) gw := New(bin) - gw.InferenceSet("vertex-local", "claude-sonnet-4-6") + gw.InferenceSet("google-vertex-ai", "claude-sonnet-4-6") data, _ := os.ReadFile(argsFile) args := strings.TrimSpace(string(data)) for _, want := range []string{ "inference set", - "--provider vertex-local", + "--provider google-vertex-ai", "--model claude-sonnet-4-6", "--no-verify", } { diff --git a/internal/gateway/config_test.go b/internal/gateway/config_test.go index 99505f2..b62e752 100644 --- a/internal/gateway/config_test.go +++ b/internal/gateway/config_test.go @@ -27,8 +27,8 @@ gateway: name: my-ocp providers: - enabled: [github, vertex-local] - custom: [gws] + enabled: [github, google-vertex-ai] + custom: [google-workspace] chart: oci: oci://example.com/chart @@ -71,8 +71,8 @@ secrets: if len(cfg.Providers.Enabled) != 2 { t.Errorf("providers.enabled = %v, want 2 entries", cfg.Providers.Enabled) } - if len(cfg.Providers.Custom) != 1 || cfg.Providers.Custom[0] != "gws" { - t.Errorf("providers.custom = %v, want [gws]", cfg.Providers.Custom) + if len(cfg.Providers.Custom) != 1 || cfg.Providers.Custom[0] != "google-workspace" { + t.Errorf("providers.custom = %v, want [google-workspace]", cfg.Providers.Custom) } if cfg.Chart.OCI != "oci://example.com/chart" { t.Errorf("chart.oci = %q, want oci://example.com/chart", cfg.Chart.OCI) diff --git a/internal/gateway/validate_test.go b/internal/gateway/validate_test.go index 3089630..8446ee5 100644 --- a/internal/gateway/validate_test.go +++ b/internal/gateway/validate_test.go @@ -18,13 +18,13 @@ func (s *stubProviderChecker) ProviderGet(name string) error { func TestValidateProviders(t *testing.T) { gw := &stubProviderChecker{providers: map[string]bool{ - "github": true, - "vertex-local": true, + "github": true, + "google-vertex-ai": true, }} - registered, missing := ValidateProviders([]string{"github", "vertex-local", "atlassian"}, gw) - if len(registered) != 2 || registered[0] != "github" || registered[1] != "vertex-local" { - t.Errorf("registered = %v, want [github vertex-local]", registered) + registered, missing := ValidateProviders([]string{"github", "google-vertex-ai", "atlassian"}, gw) + if len(registered) != 2 || registered[0] != "github" || registered[1] != "google-vertex-ai" { + t.Errorf("registered = %v, want [github google-vertex-ai]", registered) } if len(missing) != 1 || missing[0] != "atlassian" { t.Errorf("missing = %v, want [atlassian]", missing) diff --git a/main.go b/main.go index 2099ee3..e96e2a1 100644 --- a/main.go +++ b/main.go @@ -66,6 +66,8 @@ func main() { cmd.NewDescribeCmd(harnessDir, cli), cmd.NewDeleteCmd(harnessDir, cli), cmd.NewDeployCmd(harnessDir, cli), + cmd.NewDoctorCmd(harnessDir, cli), + cmd.NewInitCmd(), ) // Deprecated aliases diff --git a/profiles/README.md b/profiles/README.md new file mode 100644 index 0000000..28a9e22 --- /dev/null +++ b/profiles/README.md @@ -0,0 +1,79 @@ +# profiles/ + +Agent configs, provider profiles, and gateway profiles. + +## Agent configs (`agent-*.yaml`) + +Define what runs in the sandbox. One agent config = one sandbox. + +```yaml +name: agent # sandbox name +entrypoint: claude # claude, opencode, bash, or any binary on PATH +tty: true # enable TTY (default: true) +gateway: ocp # target gateway (default: local) +task: @tasks/review.md # task file passed to entrypoint via -p +image: ghcr.io/... # override sandbox image +policy: path/to/policy.yaml # network policy file + +providers: # credential providers to register + - profile: github # references profiles/providers/ or OpenShell built-in + - profile: google-vertex-ai + - profile: atlassian + env: # non-secret env vars for this provider + JIRA_URL: ${JIRA_URL} # ${VAR} reads from host environment + +env: # additional env vars injected into sandbox + ANTHROPIC_BASE_URL: https://inference.local + +payloads: # files uploaded to sandbox before start + - sandbox_path: /sandbox/.claude/CLAUDE.md + local_path: images/sandbox-default/CLAUDE.md + - sandbox_path: /sandbox/.config/instructions.md + content: | + Inline content works too. +``` + +All fields except `name` are optional. Minimal config: + +```yaml +name: agent +``` + +### Multi-document format + +Agent configs support multi-document YAML (`---` separated). Each document is dispatched by the `kind` field. No `kind` = agent (backwards compatible). + +```yaml +--- +kind: agent +name: my-agent +entrypoint: claude +providers: + - profile: github +--- +kind: provider +name: github +type: github +credentials: [GITHUB_TOKEN] +--- +kind: payload +sandbox_path: /sandbox/.claude/CLAUDE.md +content: | + You are a code review agent. +--- +kind: policy +network_policies: + github: + endpoints: + - { host: "api.github.com", port: 443 } +``` + +Supported kinds: `agent`, `provider`, `gateway`, `payload` (alias: `config`), `policy`. + +## Providers (`providers/`) + +See [providers/README.md](providers/README.md). + +## Gateways (`gateways/`) + +See [gateways/README.md](gateways/README.md). diff --git a/profiles/agent-basic.yaml b/profiles/agent-basic.yaml index d05702c..0fa6f0d 100644 --- a/profiles/agent-basic.yaml +++ b/profiles/agent-basic.yaml @@ -9,7 +9,7 @@ entrypoint: claude tty: true providers: - - profile: vertex-local + - profile: google-vertex-ai env: ANTHROPIC_BASE_URL: https://inference.local diff --git a/profiles/agent-default.yaml b/profiles/agent-default.yaml index fe7c3c4..5570c7b 100644 --- a/profiles/agent-default.yaml +++ b/profiles/agent-default.yaml @@ -10,12 +10,12 @@ tty: true providers: - profile: github - - profile: vertex-local + - profile: google-vertex-ai - profile: atlassian env: JIRA_URL: ${JIRA_URL} JIRA_USERNAME: ${JIRA_USERNAME} - - profile: gws + - profile: google-workspace env: ANTHROPIC_BASE_URL: https://inference.local diff --git a/profiles/agent-ocp.yaml b/profiles/agent-ocp.yaml index c379241..0687c4b 100644 --- a/profiles/agent-ocp.yaml +++ b/profiles/agent-ocp.yaml @@ -13,12 +13,12 @@ tty: true providers: - profile: github - - profile: vertex-local + - profile: google-vertex-ai - profile: atlassian env: JIRA_URL: ${JIRA_URL} JIRA_USERNAME: ${JIRA_USERNAME} - - profile: gws + - profile: google-workspace env: ANTHROPIC_BASE_URL: https://inference.local diff --git a/profiles/agent-opencode.yaml b/profiles/agent-opencode.yaml index c5c5b14..e9ba575 100644 --- a/profiles/agent-opencode.yaml +++ b/profiles/agent-opencode.yaml @@ -13,12 +13,12 @@ tty: true providers: - profile: github - - profile: vertex-local + - profile: google-vertex-ai - profile: atlassian env: JIRA_URL: ${JIRA_URL} JIRA_USERNAME: ${JIRA_USERNAME} - - profile: gws + - profile: google-workspace env: ANTHROPIC_BASE_URL: https://inference.local/v1 diff --git a/profiles/gateways/README.md b/profiles/gateways/README.md new file mode 100644 index 0000000..4270b28 --- /dev/null +++ b/profiles/gateways/README.md @@ -0,0 +1,76 @@ +# profiles/gateways/ + +Gateway profiles define where and how the OpenShell gateway is deployed. + +## Format + +```yaml +gateway: + type: local # local or remote + platform: ocp # k8s or ocp (remote only) + service: route # route, nodeport, or loadbalancer (remote only) + name: openshell-remote-ocp # gateway name for openshell CLI + mode: direct # direct or launcher (remote only) + +chart: + version: "0.0.59" # Helm chart version + +helm: + values: # Helm values passed to openshell chart + server: + auth: + allowUnauthenticatedUsers: true + pkiInitJob: + enabled: true + +addons: + manifests: # additional K8s manifests applied after install + - apiVersion: route.openshift.io/v1 + kind: Route + metadata: + name: gateway + spec: + tls: + termination: passthrough + to: + kind: Service + name: openshell + +ocp: # OpenShift-specific config + scc-privileged: [openshell] # ServiceAccounts needing privileged SCC + scc-anyuid: [openshell] # ServiceAccounts needing anyuid SCC + +secrets: + mtls: openshell-client-tls # K8s Secret containing mTLS client certs +``` + +## Targets + +### `local.yaml` -- Podman on your machine + +The default. Requires openshell installed and running via `brew services start openshell` or equivalent. No Helm, no K8s. + +### `kind.yaml` -- local kind cluster + +Deploys to a kind cluster. Uses NodePort access (no Ingress needed). TLS disabled for local dev simplicity. Requires `kind create cluster`. + +### `ocp.yaml` -- OpenShift cluster + +Deploys to an OpenShift cluster with Route-based access and mTLS. Requires `oc login` and cluster-admin for SCC grants. + +## Selecting a gateway + +```bash +harness apply -f harness.yaml # uses local (default) +harness apply -f harness.yaml --gateway ocp # uses gateways/ocp.yaml +harness apply -f harness.yaml --gateway kind # uses gateways/kind.yaml +``` + +Agent configs can also set a default gateway: + +```yaml +name: agent +gateway: ocp +``` + +The `OPENSHELL_GATEWAY` env var works as a fallback. diff --git a/profiles/images/sandbox-default/policy.yaml b/profiles/images/sandbox-default/policy.yaml index 04a1952..e091ff2 100644 --- a/profiles/images/sandbox-default/policy.yaml +++ b/profiles/images/sandbox-default/policy.yaml @@ -9,7 +9,7 @@ version: 1 # # Active providers: # github (builtin) → api.github.com, github.com (read-only) -# vertex-local (google-vertex-ai) → Vertex AI + Google OAuth (inference.local routing) +# google-vertex-ai → Vertex AI + Google OAuth (inference.local routing) # atlassian (custom profile) → *.atlassian.net, *.atlassian.com (Basic auth resolved by proxy) # # Endpoints in this file (not covered by any provider profile): diff --git a/profiles/providers/README.md b/profiles/providers/README.md new file mode 100644 index 0000000..5ea6fc8 --- /dev/null +++ b/profiles/providers/README.md @@ -0,0 +1,106 @@ +# profiles/providers/ + +OpenShell provider profile YAMLs. These are imported to the gateway during `harness apply` via `openshell provider profile import`. + +Provider profiles define how credentials are discovered, stored, and injected into sandboxes. The format is defined by [OpenShell](https://github.com/NVIDIA/OpenShell). + +## Format + +```yaml +id: provider-name # unique ID, matches the profile name in agent configs +display_name: Human Name +description: What this provider does +category: knowledge # knowledge, inference, or tools + +credentials: + - name: credential_key # internal name + description: What this credential is + env_vars: [ENV_VAR_NAME] # host env var(s) to discover from + required: true + auth_style: bearer # bearer, basic, or header + header_name: authorization # HTTP header for credential injection + + refresh: # optional: gateway-managed token refresh + strategy: oauth2_refresh_token + token_url: https://oauth2.googleapis.com/token + scopes: [...] + refresh_before_seconds: 300 + max_lifetime_seconds: 3600 + material: # non-injectable refresh inputs + - name: client_secret + secret: true + - name: refresh_token + secret: true + +discovery: + credentials: [credential_key] # which credentials to discover from host + +endpoints: # network policy endpoints this provider needs + - host: "api.example.com" + port: 443 + protocol: rest + access: read-only + enforcement: enforce + request_body_credential_rewrite: false # true for OAuth token endpoints + +binaries: # sandbox binaries this provider needs access to + - /usr/local/bin/tool +``` + +## Supported providers + +### First-class (OpenShell built-in profiles) + +**`github`** -- GitHub API access via `gh` CLI and MCP server. +- Credential: `GITHUB_TOKEN` env var +- Registration: `--from-existing` (OpenShell discovers from host env) +- Sandbox access: `gh` CLI, GitHub MCP server + +**`google-vertex-ai`** -- Inference routing through Vertex AI. +- Credential: Google Application Default Credentials (`gcloud auth application-default login`) +- Config: `ANTHROPIC_VERTEX_PROJECT_ID` (or auto-read from ADC), `CLOUD_ML_REGION` (default: `global`) +- Registration: `--from-gcloud-adc` +- The gateway routes inference through `inference.local` -- the sandbox never sees GCP credentials + +### Custom profiles (this directory) + +**`atlassian`** (`atlassian.yaml`) -- Jira and Confluence via `mcp-atlassian` MCP server. +- Credential: `JIRA_API_TOKEN` env var +- Config: `JIRA_URL` and `JIRA_USERNAME` passed as non-secret env vars in the agent config +- Registration: `--from-existing` +- Endpoints: `*.atlassian.net`, `*.atl-paas.net`, `*.atlassian.com` + +**`google-workspace`** (`gws.yaml`) -- Gmail, Calendar, Drive, Tasks via `gws` CLI. +- Credential: OAuth2 access token, gateway-refreshed from a stored refresh token +- Registration: multi-step -- `gws auth export` extracts client credentials, the gateway manages token refresh. The sandbox never sees client_secret or refresh_token. +- Scopes are defined in the provider profile's `refresh.scopes` list. These control what the gateway-minted access token can do -- even though the underlying refresh token may have broader permissions. This is the access scope policy for Google Workspace. +- Endpoints: `gmail.googleapis.com`, `calendar-json.googleapis.com`, `drive.googleapis.com`, `docs.googleapis.com`, `sheets.googleapis.com`, `tasks.googleapis.com`, `oauth2.googleapis.com` + +### Future + +Additional providers can be added by creating a profile YAML in this directory and adding the registration logic to `cmd/providers.go`. Candidates include Google Cloud (gcloud CLI access), AWS, and Azure. + +## Built-in vs custom profiles + +OpenShell ships built-in profiles for common providers (`github`, `google-vertex-ai`). Profiles in this directory extend or override built-ins. + +The harness imports all profiles from this directory before registering providers. Agent configs reference profiles by name: + +```yaml +providers: + - profile: github # built-in + - profile: atlassian # from providers/atlassian.yaml + - profile: google-workspace # from providers/gws.yaml +``` + +## Registration flows + +The harness uses three registration patterns depending on provider type: + +| Pattern | Providers | How credentials are discovered | +|---------|-----------|-------------------------------| +| `--from-existing` | github, atlassian | Reads from host env vars | +| `--from-gcloud-adc` | google-vertex-ai | Reads Application Default Credentials | +| Custom OAuth | google-workspace | `gws auth export` + gateway-managed refresh | + +Registration happens automatically during `harness apply`. Missing credentials are skipped with a warning. diff --git a/test/configs/agent-all-providers.yaml b/test/configs/agent-all-providers.yaml index 99cfd0b..00b6be7 100644 --- a/test/configs/agent-all-providers.yaml +++ b/test/configs/agent-all-providers.yaml @@ -6,12 +6,12 @@ name: test-all-providers entrypoint: bash providers: - profile: github - - profile: vertex-local + - profile: google-vertex-ai - profile: atlassian env: JIRA_URL: ${JIRA_URL} JIRA_USERNAME: ${JIRA_USERNAME} - - profile: gws + - profile: google-workspace env: ANTHROPIC_BASE_URL: https://inference.local ANTHROPIC_API_KEY: sk-ant-openshell-proxy-managed diff --git a/test/configs/agent-gws.yaml b/test/configs/agent-gws.yaml index 9da80a8..e2d9062 100644 --- a/test/configs/agent-gws.yaml +++ b/test/configs/agent-gws.yaml @@ -4,4 +4,4 @@ name: test-gws entrypoint: bash providers: - - profile: gws + - profile: google-workspace diff --git a/test/configs/agent-opencode-vertex.yaml b/test/configs/agent-opencode-vertex.yaml index 06c9908..c5170f9 100644 --- a/test/configs/agent-opencode-vertex.yaml +++ b/test/configs/agent-opencode-vertex.yaml @@ -4,7 +4,7 @@ name: test-opencode entrypoint: opencode providers: - - profile: vertex-local + - profile: google-vertex-ai env: ANTHROPIC_BASE_URL: https://inference.local/v1 ANTHROPIC_API_KEY: sk-ant-openshell-proxy-managed diff --git a/test/configs/agent-vertex.yaml b/test/configs/agent-vertex.yaml index e46347f..31315f8 100644 --- a/test/configs/agent-vertex.yaml +++ b/test/configs/agent-vertex.yaml @@ -4,7 +4,7 @@ name: test-vertex entrypoint: bash providers: - - profile: vertex-local + - profile: google-vertex-ai env: ANTHROPIC_BASE_URL: https://inference.local ANTHROPIC_API_KEY: sk-ant-openshell-proxy-managed