From e7cc74a35b8c536cfbd73a63cb85d1c2555b38d1 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 08:25:42 -0700 Subject: [PATCH 01/12] X-Smart-Branch-Parent: main From 7c68f4c7e0deafd932b820de7da60262a2b27489 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 09:54:53 -0700 Subject: [PATCH 02/12] feat: harness init and doctor commands harness init generates a customized harness.yaml by asking entrypoint, providers, and gateway target. Discovers available providers from openshell provider list-profiles (falls back to built-in list). Supports --non-interactive, --force, and --output flags. harness doctor validates the environment can run the configured sandbox with a two-phase CheckFunc pipeline: - Phase 1 (offline): openshell binary, target deps (podman/docker for local, kubectl+kind for kind, kubectl+kubeconfig for remote), and provider env vars (via openshell provider profile export). - Phase 2 (online): provider registration status if gateway reachable. Outputs grouped by target/provider/openshell with -o table|json|yaml. 29 tests covering all codepaths including credential masking. --- cmd/doctor.go | 395 +++++++++++++++++++++++++++++++++++++++++++ cmd/doctor_test.go | 248 +++++++++++++++++++++++++++ cmd/init_cmd.go | 273 ++++++++++++++++++++++++++++++ cmd/init_cmd_test.go | 307 +++++++++++++++++++++++++++++++++ main.go | 2 + 5 files changed, 1225 insertions(+) create mode 100644 cmd/doctor.go create mode 100644 cmd/doctor_test.go create mode 100644 cmd/init_cmd.go create mode 100644 cmd/init_cmd_test.go diff --git a/cmd/doctor.go b/cmd/doctor.go new file mode 100644 index 0000000..d07f76b --- /dev/null +++ b/cmd/doctor.go @@ -0,0 +1,395 @@ +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", "remote": + return checkRemoteDeps() + default: + return checkLocalDeps() + } +} + +func checkLocalDeps() []CheckResult { + if _, err := exec.LookPath("podman"); err == nil { + if err := exec.Command("podman", "info", "--format", "{{.Host.Os}}").Run(); err == nil { + return []CheckResult{{Group: "target", Name: "local", Status: "pass", Message: "podman running"}} + } + return []CheckResult{{Group: "target", Name: "local", Status: "warn", Message: "podman installed but not responding"}} + } + 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: "warn", Message: "docker installed but not responding"}} + } + return []CheckResult{{Group: "target", Name: "local", Status: "fail", Message: "no container runtime (podman or docker) found"}} +} + +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 []struct { + Name string `yaml:"name"` + EnvVars []string `yaml:"env_vars"` + Required bool `yaml:"required"` + } `yaml:"credentials"` +} + +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 + } + + allSet := true + var missing []string + for _, cred := range profile.Credentials { + if !cred.Required { + continue + } + 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 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 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..1a69d76 --- /dev/null +++ b/cmd/doctor_test.go @@ -0,0 +1,248 @@ +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) + } + } +} + +// --- 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/init_cmd.go b/cmd/init_cmd.go new file mode 100644 index 0000000..cdfa276 --- /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: "vertex-local", DisplayName: "Google Vertex AI", Category: "inference"}, + {ID: "atlassian", DisplayName: "Atlassian", Category: "knowledge"}, + {ID: "gws", 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/remote] (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", "remote", "ocp": + return input, nil + default: + return "", fmt.Errorf("unknown gateway target: %q (use local, kind, or remote)", 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", "vertex-local", "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..81f91c3 --- /dev/null +++ b/cmd/init_cmd_test.go @@ -0,0 +1,307 @@ +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: vertex-local +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_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_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/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 From 936aced9afba6f5b6d2fc8edd95ace25a81f9f20 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 09:55:36 -0700 Subject: [PATCH 03/12] chore: mark init and doctor as done in TODO.md --- TODO.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/TODO.md b/TODO.md index c9d9486..19b5459 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 @@ -76,4 +76,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 From 854701e57b3d2df1d6bb8dcc699c50e04e4758b3 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 10:14:56 -0700 Subject: [PATCH 04/12] docs: update README for init and doctor commands Rewrite Quick Start to show the three-step onramp: init -> doctor -> apply. Add init and doctor to the commands reference. Update the hero code block and flow diagram. Move advanced examples under "More examples" subsection. --- README.md | 69 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index f1bc06f..91d0697 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@ Declarative configuration harness for [OpenShell](https://github.com/NVIDIA/OpenShell) AI agent sandboxes. ```bash -harness apply -f agent.yaml +harness init # generate a config +harness doctor # check your environment +harness apply -f harness.yaml # launch a sandbox ``` ## OpenShell provides the runtime @@ -12,6 +14,7 @@ harness apply -f agent.yaml ## The harness adds declarative configuration +- **Guided setup** -- `harness init` generates a config, `harness doctor` validates your environment - **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 @@ -47,29 +50,41 @@ Or build from source: `make cli` ## Quick Start ```bash -# Set credentials (missing ones are skipped gracefully) -export GITHUB_TOKEN=ghp_... +# 1. Generate a config (picks entrypoint, providers, gateway target) +harness init -# Deploy a sandbox with the default agent config -harness apply -f profiles/agent-default.yaml +# 2. Check your environment +harness doctor + +# 3. Launch the sandbox +harness apply -f harness.yaml +``` + +That's it. `init` asks three questions and writes a `harness.yaml`. `doctor` tells you if anything is missing. `apply` launches the sandbox. +### More examples + +```bash # Run a task headlessly (agent outputs to stdout) -harness apply -f profiles/agent-default.yaml --task "review this codebase for security issues" +harness apply -f harness.yaml --task "review this codebase for security issues" # Run a task from a file -harness apply -f profiles/agent-default.yaml --task @tasks/review.md +harness apply -f harness.yaml --task @tasks/review.md # Interactive mode -harness apply -f profiles/agent-default.yaml --attach +harness apply -f harness.yaml --attach # Validate without deploying -harness apply -f profiles/agent-default.yaml --dry-run +harness apply -f harness.yaml --dry-run # See the fully resolved config -harness apply -f profiles/agent-default.yaml -o yaml +harness apply -f harness.yaml -o yaml # Override the entrypoint -harness apply -f profiles/agent-default.yaml --entrypoint opencode +harness apply -f harness.yaml --entrypoint opencode + +# Use a profile directly (skip init) +harness apply -f profiles/agent-default.yaml ``` ## The Agent YAML @@ -147,14 +162,16 @@ harness deploy ocp # deploy gatew ## 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 +harness init -----> harness.yaml (your config) +harness doctor ---> validates environment +harness apply ---> openshell CLI --> Gateway (Podman or K8s) + |-- Provider credentials + |-- L7 network policy + |-- inference.local proxy + +-- Sandbox container + |-- claude / opencode + |-- gh, mcp-atlassian, gws + +-- placeholder tokens ``` The harness orchestrates three OpenShell components: @@ -170,9 +187,21 @@ See the [OpenShell docs](https://github.com/NVIDIA/OpenShell) for the full secur ### Commands ``` +harness init [--output FILE] [--force] [--non-interactive] + Generate a harness.yaml config file. + Prompts for entrypoint, providers, and gateway target. + Discovers available providers from openshell. + --non-interactive writes the embedded default without prompts. + --force overwrites an existing file. + +harness doctor [-f FILE] [-o table|json|yaml] + Validate that your environment can run the configured sandbox. + Phase 1 (offline): checks openshell, target deps, provider credentials. + Phase 2 (online): checks provider registration if gateway is reachable. + Exit 0 if ready, exit 1 if something is missing. + 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. From c9bc9d39a99325d3af015325b8449440e1cfc614 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 11:06:39 -0700 Subject: [PATCH 05/12] fix: doctor credential checks for gateway-managed providers Two fixes: - Map harness provider names to OpenShell profile IDs (vertex-local -> google-vertex-ai, gws -> google-workspace) so profile export finds the right profile. - Skip env var checks for gateway-managed credentials (those with a refresh strategy). Instead check the actual auth state: ADC file for vertex, gws auth export for GWS. --- cmd/doctor.go | 77 ++++++++++++++++++++++++++++++++++++++++------ cmd/doctor_test.go | 42 +++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 9 deletions(-) diff --git a/cmd/doctor.go b/cmd/doctor.go index d07f76b..a9ca339 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -197,13 +197,18 @@ func checkRemoteDeps() []CheckResult { } type providerProfile struct { - ID string `yaml:"id"` - DisplayName string `yaml:"display_name"` - Credentials []struct { - Name string `yaml:"name"` - EnvVars []string `yaml:"env_vars"` - Required bool `yaml:"required"` - } `yaml:"credentials"` + 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 { @@ -224,12 +229,19 @@ func checkProviderEnvVars(cfg *agent.AgentConfig, cli, harnessDir string) []Chec 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) != "" { @@ -243,7 +255,10 @@ func checkProviderEnvVars(cfg *agent.AgentConfig, cli, harnessDir string) []Chec } } - if allSet { + if allGatewayManaged { + r := checkGatewayManagedProvider(p.Profile) + results = append(results, r) + } else if allSet { results = append(results, CheckResult{ Group: "provider", Name: p.Profile, @@ -263,10 +278,54 @@ func checkProviderEnvVars(cfg *agent.AgentConfig, cli, harnessDir string) []Chec return results } +func checkGatewayManagedProvider(name string) CheckResult { + switch name { + case "gws": + 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 "vertex-local": + 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"} + } +} + +// providerProfileType maps harness provider names to OpenShell profile IDs. +// Most providers use the same name for both; these are the exceptions. +var providerProfileType = map[string]string{ + "vertex-local": "google-vertex-ai", + "gws": "google-workspace", +} + +func profileTypeFor(name string) string { + if t, ok := providerProfileType[name]; ok { + return t + } + return name +} + func loadProviderProfile(name, cli, harnessDir string) *providerProfile { - if profile := loadProfileFromOpenShell(name, cli); profile != nil { + profileType := profileTypeFor(name) + if profile := loadProfileFromOpenShell(profileType, cli); profile != nil { return profile } + if profileType != name { + if profile := loadProfileFromOpenShell(name, cli); profile != nil { + return profile + } + } return loadProfileFromDisk(name, harnessDir) } diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go index 1a69d76..3fcab27 100644 --- a/cmd/doctor_test.go +++ b/cmd/doctor_test.go @@ -226,6 +226,48 @@ credentials: } } +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) + } +} + +func TestProfileTypeFor(t *testing.T) { + tests := []struct { + name, want string + }{ + {"github", "github"}, + {"vertex-local", "google-vertex-ai"}, + {"gws", "google-workspace"}, + {"atlassian", "atlassian"}, + {"custom", "custom"}, + } + for _, tt := range tests { + got := profileTypeFor(tt.name) + if got != tt.want { + t.Errorf("profileTypeFor(%q) = %q, want %q", tt.name, got, tt.want) + } + } +} + // --- helpers --- func testAgentConfig(t *testing.T) *agent.AgentConfig { From 6ece9108361830265f9b4e6bd3921a9dd97e5550 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 11:09:00 -0700 Subject: [PATCH 06/12] fix: podman health check uses compatible info command podman info --format with .Host.Os fails on podman 5.x (field removed). Use plain podman info instead and get version from podman version. Also fall through to docker when podman is installed but not responding. --- cmd/doctor.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/cmd/doctor.go b/cmd/doctor.go index a9ca339..054b991 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -134,18 +134,20 @@ func checkTargetDeps(cfg *agent.AgentConfig, _, _ string) []CheckResult { func checkLocalDeps() []CheckResult { if _, err := exec.LookPath("podman"); err == nil { - if err := exec.Command("podman", "info", "--format", "{{.Host.Os}}").Run(); err == nil { - return []CheckResult{{Group: "target", Name: "local", Status: "pass", Message: "podman running"}} + 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"}} } - return []CheckResult{{Group: "target", Name: "local", Status: "warn", Message: "podman installed but not responding"}} } 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: "warn", Message: "docker installed but not responding"}} } - return []CheckResult{{Group: "target", Name: "local", Status: "fail", Message: "no container runtime (podman or docker) found"}} + return []CheckResult{{Group: "target", Name: "local", Status: "fail", Message: "no container runtime (podman or docker) responding"}} } func checkKindDeps() []CheckResult { From 783b36226af31b754456dc3159f16301018bd87a Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 11:17:38 -0700 Subject: [PATCH 07/12] fix: gateway and provider name mapping in init Init wrote OpenShell profile IDs (google-vertex-ai, google-workspace) as provider names, but apply expects harness names (vertex-local, gws). Added harnessProviderName mapping to translate on output. Simplified gateway targets to local/kind/ocp. Removed 'remote' alias that had no matching gateway profile. --- cmd/doctor.go | 2 +- cmd/init_cmd.go | 22 ++++++++++++++++---- cmd/init_cmd_test.go | 49 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/cmd/doctor.go b/cmd/doctor.go index 054b991..96d4876 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -125,7 +125,7 @@ func checkTargetDeps(cfg *agent.AgentConfig, _, _ string) []CheckResult { return checkLocalDeps() case "kind": return checkKindDeps() - case "ocp", "remote": + case "ocp": return checkRemoteDeps() default: return checkLocalDeps() diff --git a/cmd/init_cmd.go b/cmd/init_cmd.go index cdfa276..110ac8d 100644 --- a/cmd/init_cmd.go +++ b/cmd/init_cmd.go @@ -142,7 +142,7 @@ func promptProviders(scanner *bufio.Scanner, out io.Writer) ([]agent.ProviderRef } func promptGateway(scanner *bufio.Scanner, out io.Writer) (string, error) { - fmt.Fprint(out, "Gateway target [local/kind/remote] (default: local): ") + fmt.Fprint(out, "Gateway target [local/kind/ocp] (default: local): ") if !scanner.Scan() { return "local", nil } @@ -151,10 +151,10 @@ func promptGateway(scanner *bufio.Scanner, out io.Writer) (string, error) { return "local", nil } switch input { - case "local", "kind", "remote", "ocp": + case "local", "kind", "ocp": return input, nil default: - return "", fmt.Errorf("unknown gateway target: %q (use local, kind, or remote)", input) + return "", fmt.Errorf("unknown gateway target: %q (use local, kind, or ocp)", input) } } @@ -262,11 +262,25 @@ func parseSelection(input string, max int) []int { return indices } +// harnessProviderName maps OpenShell profile IDs to the harness provider +// names that registerProviders expects. Most are the same; these differ. +var harnessProviderName = map[string]string{ + "google-vertex-ai": "vertex-local", + "google-workspace": "gws", +} + +func providerNameFor(profileID string) string { + if name, ok := harnessProviderName[profileID]; ok { + return name + } + return profileID +} + 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}) + refs = append(refs, agent.ProviderRef{Profile: providerNameFor(available[i].ID)}) } } return refs diff --git a/cmd/init_cmd_test.go b/cmd/init_cmd_test.go index 81f91c3..75caf99 100644 --- a/cmd/init_cmd_test.go +++ b/cmd/init_cmd_test.go @@ -203,6 +203,25 @@ func TestInitRun_InteractiveGatewayKind(t *testing.T) { } } +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") @@ -215,6 +234,36 @@ func TestInitRun_InvalidGateway(t *testing.T) { } } +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 TestProviderNameMapping(t *testing.T) { + tests := []struct { + profileID, want string + }{ + {"github", "github"}, + {"google-vertex-ai", "vertex-local"}, + {"google-workspace", "gws"}, + {"atlassian", "atlassian"}, + {"pypi", "pypi"}, + } + for _, tt := range tests { + got := providerNameFor(tt.profileID) + if got != tt.want { + t.Errorf("providerNameFor(%q) = %q, want %q", tt.profileID, got, tt.want) + } + } +} + func TestInitRun_OutputContainsNextSteps(t *testing.T) { dir := t.TempDir() outPath := filepath.Join(dir, "harness.yaml") From 3fd7c555aa61ba5cbaeac67831705dd375b543b0 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 11:27:32 -0700 Subject: [PATCH 08/12] refactor: align provider names with OpenShell profile IDs Rename vertex-local to google-vertex-ai and gws to google-workspace across the entire codebase. Eliminates the providerProfileType and harnessProviderName mapping tables that caused init to generate configs incompatible with apply. One namespace: harness uses the same provider IDs as openshell. --- README.md | 8 +++---- SPEC.md | 4 ++-- cmd/doctor.go | 26 +++------------------ cmd/doctor_test.go | 18 -------------- cmd/executor_test.go | 4 ++-- cmd/helpers_test.go | 2 +- cmd/init_cmd.go | 22 ++++------------- cmd/init_cmd_test.go | 20 +--------------- cmd/providers.go | 24 +++++++++---------- internal/agent/agent_test.go | 4 ++-- internal/gateway/cli_test.go | 16 ++++++------- internal/gateway/config_test.go | 8 +++---- internal/gateway/validate_test.go | 10 ++++---- profiles/agent-basic.yaml | 2 +- profiles/agent-default.yaml | 4 ++-- profiles/agent-ocp.yaml | 4 ++-- profiles/agent-opencode.yaml | 4 ++-- profiles/images/sandbox-default/policy.yaml | 2 +- test/configs/agent-all-providers.yaml | 4 ++-- test/configs/agent-gws.yaml | 2 +- test/configs/agent-opencode-vertex.yaml | 2 +- test/configs/agent-vertex.yaml | 2 +- 22 files changed, 61 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index 91d0697..40d4a3e 100644 --- a/README.md +++ b/README.md @@ -97,12 +97,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 @@ -244,9 +244,9 @@ Each provider requires credentials on 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` + `CLOUD_ML_REGION` | | `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` (OAuth via [gws CLI](https://github.com/googleworkspace/cli)) | ## Documentation Map 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/cmd/doctor.go b/cmd/doctor.go index 96d4876..f5abfbe 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -282,7 +282,7 @@ func checkProviderEnvVars(cfg *agent.AgentConfig, cli, harnessDir string) []Chec func checkGatewayManagedProvider(name string) CheckResult { switch name { - case "gws": + 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)"} @@ -291,7 +291,7 @@ func checkGatewayManagedProvider(name string) CheckResult { 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 "vertex-local": + case "google-vertex-ai": home, _ := os.UserHomeDir() adcPath := envOr("GOOGLE_APPLICATION_CREDENTIALS", filepath.Join(home, ".config", "gcloud", "application_default_credentials.json")) @@ -304,30 +304,10 @@ func checkGatewayManagedProvider(name string) CheckResult { } } -// providerProfileType maps harness provider names to OpenShell profile IDs. -// Most providers use the same name for both; these are the exceptions. -var providerProfileType = map[string]string{ - "vertex-local": "google-vertex-ai", - "gws": "google-workspace", -} - -func profileTypeFor(name string) string { - if t, ok := providerProfileType[name]; ok { - return t - } - return name -} - func loadProviderProfile(name, cli, harnessDir string) *providerProfile { - profileType := profileTypeFor(name) - if profile := loadProfileFromOpenShell(profileType, cli); profile != nil { + if profile := loadProfileFromOpenShell(name, cli); profile != nil { return profile } - if profileType != name { - if profile := loadProfileFromOpenShell(name, cli); profile != nil { - return profile - } - } return loadProfileFromDisk(name, harnessDir) } diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go index 3fcab27..4b3a77c 100644 --- a/cmd/doctor_test.go +++ b/cmd/doctor_test.go @@ -250,24 +250,6 @@ credentials: } } -func TestProfileTypeFor(t *testing.T) { - tests := []struct { - name, want string - }{ - {"github", "github"}, - {"vertex-local", "google-vertex-ai"}, - {"gws", "google-workspace"}, - {"atlassian", "atlassian"}, - {"custom", "custom"}, - } - for _, tt := range tests { - got := profileTypeFor(tt.name) - if got != tt.want { - t.Errorf("profileTypeFor(%q) = %q, want %q", tt.name, got, tt.want) - } - } -} - // --- helpers --- func testAgentConfig(t *testing.T) *agent.AgentConfig { 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 index 110ac8d..814b31e 100644 --- a/cmd/init_cmd.go +++ b/cmd/init_cmd.go @@ -22,9 +22,9 @@ type availableProvider struct { var defaultProviders = []availableProvider{ {ID: "github", DisplayName: "GitHub", Category: "source-control"}, - {ID: "vertex-local", DisplayName: "Google Vertex AI", Category: "inference"}, + {ID: "google-vertex-ai", DisplayName: "Google Vertex AI", Category: "inference"}, {ID: "atlassian", DisplayName: "Atlassian", Category: "knowledge"}, - {ID: "gws", DisplayName: "Google Workspace", Category: "knowledge"}, + {ID: "google-workspace", DisplayName: "Google Workspace", Category: "knowledge"}, } func NewInitCmd() *cobra.Command { @@ -239,7 +239,7 @@ func providerDefaults(available []availableProvider) string { var defaults []string for i, p := range available { switch p.ID { - case "github", "vertex-local", "google-vertex-ai": + case "github", "google-vertex-ai": defaults = append(defaults, strconv.Itoa(i+1)) } } @@ -262,25 +262,11 @@ func parseSelection(input string, max int) []int { return indices } -// harnessProviderName maps OpenShell profile IDs to the harness provider -// names that registerProviders expects. Most are the same; these differ. -var harnessProviderName = map[string]string{ - "google-vertex-ai": "vertex-local", - "google-workspace": "gws", -} - -func providerNameFor(profileID string) string { - if name, ok := harnessProviderName[profileID]; ok { - return name - } - return profileID -} - 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: providerNameFor(available[i].ID)}) + 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 index 75caf99..d8e5f19 100644 --- a/cmd/init_cmd_test.go +++ b/cmd/init_cmd_test.go @@ -15,7 +15,7 @@ var testDefaultConfig = []byte(`name: test-agent entrypoint: claude tty: true providers: - - profile: vertex-local + - profile: google-vertex-ai env: ANTHROPIC_BASE_URL: https://inference.local `) @@ -246,24 +246,6 @@ func TestInitRun_RemoteIsInvalidGateway(t *testing.T) { } } -func TestProviderNameMapping(t *testing.T) { - tests := []struct { - profileID, want string - }{ - {"github", "github"}, - {"google-vertex-ai", "vertex-local"}, - {"google-workspace", "gws"}, - {"atlassian", "atlassian"}, - {"pypi", "pypi"}, - } - for _, tt := range tests { - got := providerNameFor(tt.profileID) - if got != tt.want { - t.Errorf("providerNameFor(%q) = %q, want %q", tt.profileID, got, tt.want) - } - } -} - func TestInitRun_OutputContainsNextSteps(t *testing.T) { dir := t.TempDir() outPath := filepath.Join(dir, "harness.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/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/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/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 From 0bbfcd3e4756df31f73d54936fc4588f6be3b8c0 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 12:14:51 -0700 Subject: [PATCH 09/12] docs: mark project experimental, explain upstream direction README: add experimental banner, rewrite intro to explain OpenShell foundation-layer architecture and the harness as a workflow layer. Reference upstream operator discussion (#1719) and explain that the gateway is narrowing to data-plane while control-plane moves to CRDs. TODO: update upstream issue tracking to reflect core team comments on #1886 (rejected in favor of operator/CRD path) and note #1520/#1814 have no maintainer engagement. --- README.md | 37 +++++++++++++++++++++---------------- TODO.md | 12 ++++++++++-- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 40d4a3e..deb996e 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,34 @@ # harness -Declarative configuration harness for [OpenShell](https://github.com/NVIDIA/OpenShell) AI agent sandboxes. +> **Experimental.** This is a workflow tool built on top of [OpenShell](https://github.com/NVIDIA/OpenShell), which is itself alpha software. Expect breaking changes in both. + +Declarative configuration and deployment harness for OpenShell AI agent sandboxes. ```bash -harness init # generate a config -harness doctor # check your environment -harness apply -f harness.yaml # launch a sandbox +harness init # generate a config +harness doctor # check your environment +harness apply -f harness.yaml # launch a sandbox ``` -## OpenShell provides the runtime +## Why this exists + +OpenShell provides the sandbox runtime: deny-by-default L7 network policy, credential proxy at the network boundary, Landlock filesystem isolation, and inference routing. It is designed as a foundation layer -- a strict, secure base that other tooling builds workflows on top of. + +The harness is one such workflow layer. It explores what a declarative, multi-target deployment experience looks like for teams running AI agent sandboxes across local development (Podman) and Kubernetes (kind, OpenShift). -[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. +OpenShell's upstream direction is toward a [Kubernetes Operator model](https://github.com/NVIDIA/OpenShell/issues/1719) where providers and sandboxes become CRDs, the gateway narrows to a data-plane relay, and control-plane concerns move into the operator. That operator will cover K8s deployments. The harness covers the workflow layer that sits above it -- and the local Podman path that no operator will own. -## The harness adds declarative configuration +### What the harness provides - **Guided setup** -- `harness init` generates a config, `harness doctor` validates your environment -- **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 +- **One-file agent definition** -- agent, providers, gateway, and policy in a single YAML +- **Multi-target deploy** -- same config works on local Podman, kind, and OpenShift +- **Automated provider registration** -- discovers credentials from your host environment (ADC, env vars, OAuth tokens) and registers them with the gateway +- **Payload files** -- upload configs (CLAUDE.md, MCP settings, policies) to sandbox paths without rebuilding images +- **Headless task mode** -- `--task "do something"` runs the agent non-interactively +- **Dry-run and inspection** -- `--dry-run` validates without deploying, `-o yaml` outputs the resolved config -## Use OpenShell directly for runtime operations +### What to use OpenShell directly for ```bash openshell sandbox connect # interactive shell @@ -32,7 +37,7 @@ openshell sandbox logs # view logs openshell policy get # inspect policy ``` -The harness handles setup. OpenShell handles the runtime. +The harness handles setup and deployment. OpenShell handles the runtime. ## Install diff --git a/TODO.md b/TODO.md index 19b5459..c4f83b9 100644 --- a/TODO.md +++ b/TODO.md @@ -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 From 495e25d5414ddd64261e3047b4f937c1a2d39694 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 12:33:53 -0700 Subject: [PATCH 10/12] docs: rewrite README around one-shot task workflow Lead with the primary use case: point a skill at a repo, get results. Example shows cpp-pro skill running against stackrox/collector for highest-priority remediation. Explains OpenShell as a foundation layer and the harness as a workflow layer on top. Trimmed reference section into tables. --- README.md | 255 +++++++++++++++++++++--------------------------------- 1 file changed, 101 insertions(+), 154 deletions(-) diff --git a/README.md b/README.md index deb996e..4d8686e 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,82 @@ # harness -> **Experimental.** This is a workflow tool built on top of [OpenShell](https://github.com/NVIDIA/OpenShell), which is itself alpha software. Expect breaking changes in both. +> **Experimental.** Built on [OpenShell](https://github.com/NVIDIA/OpenShell), which is itself alpha software. Expect breaking changes in both. -Declarative configuration and deployment harness for OpenShell AI agent sandboxes. +One-shot sandboxed agent runs. Point a skill at a repo, get results. ```bash -harness init # generate a config -harness doctor # check your environment -harness apply -f harness.yaml # launch a sandbox +# Run a C++ review skill against a repo +harness apply -f cpp-review.yaml --task "highest priority remediation" ``` -## Why this exists - -OpenShell provides the sandbox runtime: deny-by-default L7 network policy, credential proxy at the network boundary, Landlock filesystem isolation, and inference routing. It is designed as a foundation layer -- a strict, secure base that other tooling builds workflows on top of. - -The harness is one such workflow layer. It explores what a declarative, multi-target deployment experience looks like for teams running AI agent sandboxes across local development (Podman) and Kubernetes (kind, OpenShift). - -OpenShell's upstream direction is toward a [Kubernetes Operator model](https://github.com/NVIDIA/OpenShell/issues/1719) where providers and sandboxes become CRDs, the gateway narrows to a data-plane relay, and control-plane concerns move into the operator. That operator will cover K8s deployments. The harness covers the workflow layer that sits above it -- and the local Podman path that no operator will own. - -### What the harness provides - -- **Guided setup** -- `harness init` generates a config, `harness doctor` validates your environment -- **One-file agent definition** -- agent, providers, gateway, and policy in a single YAML -- **Multi-target deploy** -- same config works on local Podman, kind, and OpenShift -- **Automated provider registration** -- discovers credentials from your host environment (ADC, env vars, OAuth tokens) and registers them with the gateway -- **Payload files** -- upload configs (CLAUDE.md, MCP settings, policies) to sandbox paths without rebuilding images -- **Headless task mode** -- `--task "do something"` runs the agent non-interactively -- **Dry-run and inspection** -- `--dry-run` validates without deploying, `-o yaml` outputs the resolved config +```yaml +# cpp-review.yaml +kind: agent +name: cpp-review +entrypoint: claude +task: @skills/cpp-pro/SKILL.md -### What to use OpenShell directly for +providers: + - profile: github + - profile: google-vertex-ai -```bash -openshell sandbox connect # interactive shell -openshell sandbox exec -- ... # run commands -openshell sandbox logs # view logs -openshell policy get # inspect policy +payloads: + - sandbox_path: /sandbox/.claude/CLAUDE.md + content: | + You are a C++ expert. Clone github.com/stackrox/collector + and apply the cpp-pro skill to identify the highest-priority + remediation. Focus on modern C++ (17/20), RAII, move semantics, + and concurrency safety. ``` -The harness handles setup and deployment. OpenShell handles the runtime. +The harness wires up the sandbox, credentials, and network policy. The agent runs the task and exits. -## Install +## Why this exists -```bash -# macOS -brew tap nvidia/openshell && brew install openshell && brew services start openshell +[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. -# Download the harness binary -curl -L https://github.com/robbycochran/harness-openshell/releases/latest/download/harness_darwin_arm64 -o harness -chmod +x harness -``` +The harness is a workflow layer on top. It bridges the gap between "I have a skill and a target repo" and "the agent is running in a sandbox with the right credentials and network access." One YAML file defines the agent, providers, payloads, and policy. One command deploys it. -Or build from source: `make cli` +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. ## Quick Start ```bash -# 1. Generate a config (picks entrypoint, providers, gateway target) -harness init - -# 2. Check your environment -harness doctor - -# 3. Launch the sandbox -harness apply -f harness.yaml +harness init # generate a config +harness doctor # check your environment +harness apply -f harness.yaml # launch a sandbox ``` -That's it. `init` asks three questions and writes a `harness.yaml`. `doctor` tells you if anything is missing. `apply` launches the sandbox. +`init` asks three questions and writes a `harness.yaml`. `doctor` validates your environment. `apply` deploys the sandbox. -### More examples +### One-shot tasks ```bash -# Run a task headlessly (agent outputs to stdout) +# Inline task harness apply -f harness.yaml --task "review this codebase for security issues" -# Run a task from a file -harness apply -f harness.yaml --task @tasks/review.md +# Task from a file (skill, playbook, checklist) +harness apply -f harness.yaml --task @skills/cpp-pro/SKILL.md # Interactive mode harness apply -f harness.yaml --attach +``` -# Validate without deploying -harness apply -f harness.yaml --dry-run - -# See the fully resolved config -harness apply -f harness.yaml -o yaml - -# Override the entrypoint -harness apply -f harness.yaml --entrypoint opencode +### Multi-target -# Use a profile directly (skip init) -harness apply -f profiles/agent-default.yaml +```bash +harness apply -f harness.yaml # local Podman +harness apply -f harness.yaml --gateway ocp # OpenShift +harness apply -f harness.yaml --gateway kind # kind cluster ``` +Same config, different targets. + ## 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 @@ -107,29 +88,25 @@ providers: env: JIRA_URL: ${JIRA_URL} JIRA_USERNAME: ${JIRA_USERNAME} - - profile: google-workspace 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 @@ -142,7 +119,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: @@ -151,113 +128,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 init -----> harness.yaml (your config) -harness doctor ---> validates environment -harness apply ---> 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 init [--output FILE] [--force] [--non-interactive] - Generate a harness.yaml config file. - Prompts for entrypoint, providers, and gateway target. - Discovers available providers from openshell. - --non-interactive writes the embedded default without prompts. - --force overwrites an existing file. - -harness doctor [-f FILE] [-o table|json|yaml] - Validate that your environment can run the configured sandbox. - Phase 1 (offline): checks openshell, target deps, provider credentials. - Phase 2 (online): checks provider registration if gateway is reachable. - Exit 0 if ready, exit 1 if something is missing. - -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. - --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 | -| `google-vertex-ai` | `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` | -| `google-workspace` | `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 | From cd5d4602b6d04d7b4379d93a3ad7756a0b885e15 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 12:35:57 -0700 Subject: [PATCH 11/12] docs: add README files for profiles, providers, and gateways Document the agent config YAML format with all fields, multi-document composition, provider profiles with credential details for all four supported providers (github, vertex-ai, atlassian, google-workspace), scope policy via refresh.scopes, and gateway profiles for all three deployment targets. --- profiles/README.md | 79 ++++++++++++++++++++++++++ profiles/gateways/README.md | 76 +++++++++++++++++++++++++ profiles/providers/README.md | 106 +++++++++++++++++++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 profiles/README.md create mode 100644 profiles/gateways/README.md create mode 100644 profiles/providers/README.md 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/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/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. From ad899c75b7d2defda171d0ddc164e50e9659af37 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 12:47:56 -0700 Subject: [PATCH 12/12] docs: simplify README intro, split quick start into tasks and coding agent --- README.md | 70 +++++++++++++++++-------------------------------------- 1 file changed, 21 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 4d8686e..446c612 100644 --- a/README.md +++ b/README.md @@ -2,42 +2,7 @@ > **Experimental.** Built on [OpenShell](https://github.com/NVIDIA/OpenShell), which is itself alpha software. Expect breaking changes in both. -One-shot sandboxed agent runs. Point a skill at a repo, get results. - -```bash -# Run a C++ review skill against a repo -harness apply -f cpp-review.yaml --task "highest priority remediation" -``` - -```yaml -# cpp-review.yaml -kind: agent -name: cpp-review -entrypoint: claude -task: @skills/cpp-pro/SKILL.md - -providers: - - profile: github - - profile: google-vertex-ai - -payloads: - - sandbox_path: /sandbox/.claude/CLAUDE.md - content: | - You are a C++ expert. Clone github.com/stackrox/collector - and apply the cpp-pro skill to identify the highest-priority - remediation. Focus on modern C++ (17/20), RAII, move semantics, - and concurrency safety. -``` - -The harness wires up the sandbox, credentials, and network policy. The agent runs the task and exits. - -## Why this exists - -[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. - -The harness is a workflow layer on top. It bridges the gap between "I have a skill and a target repo" and "the agent is running in a sandbox with the right credentials and network access." One YAML file defines the agent, providers, payloads, and policy. One command deploys it. - -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. +Declarative workflow layer for OpenShell AI agent sandboxes. ## Quick Start @@ -47,30 +12,37 @@ harness doctor # check your environment harness apply -f harness.yaml # launch a sandbox ``` -`init` asks three questions and writes a `harness.yaml`. `doctor` validates your environment. `apply` deploys the sandbox. - ### One-shot tasks +Run a task headlessly -- the agent executes in a sandbox and outputs results. + ```bash -# Inline task harness apply -f harness.yaml --task "review this codebase for security issues" - -# Task from a file (skill, playbook, checklist) harness apply -f harness.yaml --task @skills/cpp-pro/SKILL.md - -# Interactive mode -harness apply -f harness.yaml --attach ``` -### Multi-target +### Coding agent + +Launch an interactive coding session with Claude Code or OpenCode. ```bash -harness apply -f harness.yaml # local Podman -harness apply -f harness.yaml --gateway ocp # OpenShift -harness apply -f harness.yaml --gateway kind # kind cluster +# Local (Podman) +harness apply -f harness.yaml --attach + +# On OpenShift +harness apply -f harness.yaml --attach --gateway ocp + +# OpenCode instead of Claude +harness apply -f harness.yaml --attach --entrypoint opencode ``` -Same config, different targets. +## Why this exists + +[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. + +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. + +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