diff --git a/README.md b/README.md index 4963bd5..1a3bb9e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,33 @@ # OpenShell Harness -Orchestration library and CLI for [OpenShell](https://github.com/NVIDIA/OpenShell). -* Wraps the `openshell` CLI -- does not replace it. -* Automates gateway deployment, provider registration, and sandbox creation across local Podman and remote Kubernetes/OpenShift targets. +Orchestration CLI for [OpenShell](https://github.com/NVIDIA/OpenShell) AI agent sandboxes. +Automates gateway deployment, provider registration, and sandbox creation across local Podman and remote Kubernetes/OpenShift targets. + +## Quick Start + +**Prerequisites:** [OpenShell](https://github.com/NVIDIA/OpenShell) installed and running, Podman. + +```bash +# macOS +brew install openshell && brew services start openshell + +# Download the harness binary (macOS ARM64 shown -- see Releases for other platforms) +curl -L https://github.com/robbycochran/harness-openshell/releases/latest/download/harness_darwin_arm64 -o harness +chmod +x harness + +# Set credentials (any combination -- missing ones are skipped gracefully) +export GITHUB_TOKEN=ghp_... # GitHub (gh CLI in sandbox) +export JIRA_API_TOKEN=... # Jira (mcp-atlassian MCP server) +export JIRA_URL=https://your-org.atlassian.net +export JIRA_USERNAME=you@company.com + +# Launch a sandbox +./harness up +``` + +The built-in config registers three providers: GitHub, Jira, and Vertex AI. Providers with missing credentials are skipped with an info message -- you don't need all three to get started. The sandbox runs Claude Code with whatever providers are available. + +To customize providers or add GWS, create an `agents/default.yaml` in your project directory -- it takes precedence over the builtin. See [Agent Configs](#agent-configs) below. ## Where This Fits @@ -10,7 +35,7 @@ Orchestration library and CLI for [OpenShell](https://github.com/NVIDIA/OpenShel This harness fills a different gap: multi-provider credential management (preflight validation, registration, health checks) across deployment targets (local Podman, kind, OpenShift) with declarative agent configs. It is model-agnostic -- the agent config chooses the entrypoint and inference backend. The harness orchestrates the infrastructure around it. -## Example +## Agent Configs An agent config declares the sandbox image, entrypoint, and which providers to attach: @@ -36,11 +61,9 @@ env: ``` ```bash -harness up --local +harness up ``` -This deploys a local gateway, registers four providers, and creates a sandbox running Claude Code. The sandbox has: `gh` CLI (GitHub), `mcp-atlassian` (Jira/Confluence MCP server), `gws` CLI (Gmail, Calendar, Docs), and Vertex AI inference routed through the gateway proxy at `inference.local`. - Credentials are proxy-managed. The sandbox holds placeholder tokens; real secrets are substituted by the gateway at the network boundary. For non-interactive task agents, set `task:` and `tty: false`: @@ -60,11 +83,11 @@ tty: false - [OpenShell CLI](https://github.com/NVIDIA/OpenShell) (`brew install openshell && brew services start openshell` on macOS) - Podman/Docker -- Go 1.23+ +- Go 1.23+ (only needed for building from source) ### Credentials -Each provider requires credentials on the host. The harness validates these before registration. +Each provider requires credentials on the host. The harness validates these before registration. Providers with missing credentials are skipped with an info message. | Provider | Required | |----------|----------| @@ -75,11 +98,11 @@ Each provider requires credentials on the host. The harness validates these befo See `providers.toml` for the full input schema and health checks per provider. -### Run +### Build from Source ```bash make cli -./harness up --local +./harness up ``` For remote OpenShift: `./harness up --remote` (requires `kubectl`, `helm`, cluster access). @@ -120,9 +143,10 @@ See the [OpenShell docs](https://github.com/NVIDIA/OpenShell) for the full secur ## Commands ``` -harness up [--local|--remote] [--agent NAME] [-f FILE] [--name SANDBOX] +harness up [--remote] [--agent NAME] [-f FILE] [--name SANDBOX] Deploy gateway + register providers + create sandbox. - --agent defaults to "default" (reads agents/default.yaml). + Defaults to local gateway (use --remote for OCP). + --agent defaults to "default" (embedded or agents/default.yaml). -f renders any agent YAML file directly. harness create [--agent NAME] [-f FILE] [--name SANDBOX] diff --git a/TODOS.md b/TODOS.md new file mode 100644 index 0000000..32a32cd --- /dev/null +++ b/TODOS.md @@ -0,0 +1,17 @@ +# TODOs + +## registerProviders should filter by agent's provider list + +**What:** `registerProviders()` in `cmd/providers.go` uses the gateway config's provider +list, not the agent config's. When `gwCfg` is nil (common case), it tries to register +all providers regardless of what the agent needs. + +**Why:** Confusing output -- users see "skipped" messages for providers their agent +doesn't reference. No functional impact (missing credentials are silently handled). + +**Fix:** Pass the agent's provider names to `registerProviders` and use them as a +filter alongside (or instead of) the gateway config's list. + +**Files:** `cmd/providers.go` (registerProviders signature), `cmd/up.go` (call site) + +**Depends on:** Nothing. Can be done independently. diff --git a/agents/builtin.yaml b/agents/builtin.yaml new file mode 100644 index 0000000..ddef4c1 --- /dev/null +++ b/agents/builtin.yaml @@ -0,0 +1,20 @@ +# Built-in agent config embedded in the harness binary. +# Used when no agents/ directory exists on disk. +# Covers the most common providers; missing credentials are skipped gracefully. + +name: agent +entrypoint: claude +tty: true + +providers: + - profile: github + - profile: vertex-local + - profile: atlassian + config: + JIRA_URL: ${JIRA_URL} + JIRA_USERNAME: ${JIRA_USERNAME} + +env: + ANTHROPIC_BASE_URL: https://inference.local + ANTHROPIC_API_KEY: sk-ant-openshell-proxy-managed + CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1" diff --git a/cmd/create.go b/cmd/create.go index 68461f4..e8a51c3 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -31,7 +31,10 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { sandboxName = args[0] } - agentPath := resolveAgentPath(harnessDir, agentName, agentFile) + agentCfg, err := resolveAgentConfig(harnessDir, agentName, agentFile) + if err != nil { + return err + } gw := gateway.New(cli) @@ -44,10 +47,6 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { status.Header("Gateway") status.OKf("%s (%s)", activeGW.Name, activeGW.Endpoint) - agentCfg, err := agent.ParseFile(agentPath) - if err != nil { - return err - } name := agentCfg.Name if sandboxName != "" { name = sandboxName diff --git a/cmd/up.go b/cmd/up.go index 70523dc..2e22353 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -39,6 +39,10 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { sandboxName = args[0] } + agentCfg, err := resolveAgentConfig(harnessDir, agentName, agentFile) + if err != nil { + return err + } agentPath := resolveAgentPath(harnessDir, agentName, agentFile) gw := gateway.New(cli) @@ -57,7 +61,8 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { harnessDir: harnessDir, gw: gw, gwCfg: gwCfg, - ensureLocal: local, + ensureLocal: !remote, + agentCfg: agentCfg, agentPath: agentPath, sandboxName: sandboxName, noTTY: noTTY, @@ -66,7 +71,7 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { }, } - cmd.Flags().BoolVar(&local, "local", false, "Ensure local podman gateway") + cmd.Flags().BoolVar(&local, "local", false, "Ensure local podman gateway (default when --remote is not specified)") cmd.Flags().BoolVar(&remote, "remote", false, "Ensure OCP gateway") cmd.Flags().StringVar(&agentName, "agent", "default", "Agent config name (from agents/)") cmd.Flags().StringVarP(&agentFile, "file", "f", "", "Path to agent YAML file (overrides --agent)") @@ -83,11 +88,29 @@ func resolveAgentPath(harnessDir, agentName, agentFile string) string { return filepath.Join(harnessDir, "agents", agentName+".yaml") } +// resolveAgentConfig parses the agent config from disk, falling back to the +// embedded default when the file does not exist and no explicit --file was given. +func resolveAgentConfig(harnessDir, agentName, agentFile string) (*agent.AgentConfig, error) { + path := resolveAgentPath(harnessDir, agentName, agentFile) + cfg, err := agent.ParseFile(path) + if err == nil { + return cfg, nil + } + if agentFile != "" || agentName != "default" || len(DefaultAgentConfig) == 0 { + return nil, err + } + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + return nil, err + } + return agent.Parse(DefaultAgentConfig) +} + type upLocalOpts struct { harnessDir string gw gateway.Gateway gwCfg *gateway.GatewayConfig ensureLocal bool + agentCfg *agent.AgentConfig agentPath string sandboxName string noTTY bool @@ -259,9 +282,13 @@ func upLocal(opts upLocalOpts) error { gw := opts.gw // 1. Parse agent config - agentCfg, err := agent.ParseFile(opts.agentPath) - if err != nil { - return err + agentCfg := opts.agentCfg + if agentCfg == nil { + var err error + agentCfg, err = agent.ParseFile(opts.agentPath) + if err != nil { + return err + } } sandboxName := agentCfg.Name if opts.sandboxName != "" { @@ -356,6 +383,9 @@ func upLocal(opts upLocalOpts) error { var Version = "dev" +// DefaultAgentConfig holds the embedded default agent YAML, set from main.go. +var DefaultAgentConfig []byte + func defaultSandboxImage() string { if v := os.Getenv("SANDBOX_IMAGE"); v != "" { return v diff --git a/cmd/up_test.go b/cmd/up_test.go index 89da377..0302446 100644 --- a/cmd/up_test.go +++ b/cmd/up_test.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "os" "path/filepath" "strings" "testing" @@ -212,3 +213,101 @@ func TestUpLocal_SandboxCreateOpts(t *testing.T) { t.Error("TTY = true, want false (noTTY)") } } + +func TestUpLocal_EnsureLocal_DeploysGateway(t *testing.T) { + dir := setupTestAgent(t) + gw := &mockGW{ + providerList: []string{"github"}, + providers: map[string]bool{"github": true}, + gatewayListResult: []gateway.GatewayInfo{ + {Name: "local", Endpoint: "127.0.0.1:17670", Active: true}, + }, + } + + err := upLocal(upLocalOpts{ + harnessDir: dir, + gw: gw, + agentPath: filepath.Join(dir, "agents", "default.yaml"), + ensureLocal: true, + noTTY: true, + }) + if err != nil { + t.Fatalf("upLocal with ensureLocal=true: %v", err) + } + if gw.createCalls != 1 { + t.Fatalf("createCalls = %d, want 1", gw.createCalls) + } +} + +func TestResolveAgentConfig_EmbeddedFallback(t *testing.T) { + dir := t.TempDir() + DefaultAgentConfig = []byte(`name: embedded-default +entrypoint: claude +providers: + - profile: github +`) + t.Cleanup(func() { DefaultAgentConfig = nil }) + + cfg, err := resolveAgentConfig(dir, "default", "") + if err != nil { + t.Fatalf("resolveAgentConfig: %v", err) + } + if cfg.Name != "embedded-default" { + t.Errorf("Name = %q, want embedded-default", cfg.Name) + } +} + +func TestResolveAgentConfig_DiskOverridesEmbedded(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "agents"), 0o755) + os.WriteFile(filepath.Join(dir, "agents", "default.yaml"), []byte(`name: disk-agent +entrypoint: claude +providers: + - profile: github +`), 0o644) + + DefaultAgentConfig = []byte(`name: embedded-default +entrypoint: claude +providers: + - profile: github +`) + t.Cleanup(func() { DefaultAgentConfig = nil }) + + cfg, err := resolveAgentConfig(dir, "default", "") + if err != nil { + t.Fatalf("resolveAgentConfig: %v", err) + } + if cfg.Name != "disk-agent" { + t.Errorf("Name = %q, want disk-agent (disk should override embedded)", cfg.Name) + } +} + +func TestResolveAgentConfig_ExplicitFileNoFallback(t *testing.T) { + dir := t.TempDir() + DefaultAgentConfig = []byte(`name: embedded-default +entrypoint: claude +providers: + - profile: github +`) + t.Cleanup(func() { DefaultAgentConfig = nil }) + + _, err := resolveAgentConfig(dir, "default", "/nonexistent/agent.yaml") + if err == nil { + t.Fatal("expected error for explicit nonexistent --file, should not fall back to embedded") + } +} + +func TestResolveAgentConfig_NonDefaultNameNoFallback(t *testing.T) { + dir := t.TempDir() + DefaultAgentConfig = []byte(`name: embedded-default +entrypoint: claude +providers: + - profile: github +`) + t.Cleanup(func() { DefaultAgentConfig = nil }) + + _, err := resolveAgentConfig(dir, "research", "") + if err == nil { + t.Fatal("expected error for --agent research when file doesn't exist, should not fall back to embedded") + } +} diff --git a/dev-harness.sh b/dev-harness.sh new file mode 100755 index 0000000..6aec3b8 --- /dev/null +++ b/dev-harness.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build CLI + dev images, then run harness with all args. +# Pushes images only when --remote is passed (OCP pulls from registry). +# Image layers are cached by podman/docker so rebuilds are fast when +# only code changes. First run pulls base layers (~2min); subsequent +# runs finish in seconds. +# +# Usage: +# ./dev-harness.sh up # local: build only, no push +# ./dev-harness.sh up --remote # remote: build + push to registry +# ./dev-harness.sh providers --force +# +# Env overrides: +# REGISTRY=... image registry (default: ghcr.io/robbycochran/harness-openshell) +# CONTAINER_CLI=... podman or docker (default: podman) + +REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" +VERSION=$(git -C "$REPO_ROOT" describe --tags --always 2>/dev/null || echo dev) +REGISTRY=${REGISTRY:-ghcr.io/robbycochran/harness-openshell} +CONTAINER_CLI=${CONTAINER_CLI:-podman} +DEV_SANDBOX_IMAGE="${REGISTRY}:sandbox-${VERSION}" +DEV_RUNNER_IMAGE="${REGISTRY}:runner-${VERSION}" + +# 1. Build CLI +if ! CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION}" \ + -o "$REPO_ROOT/harness" "$REPO_ROOT" 2>/dev/null; then + echo "ERROR: CLI build failed" >&2 + CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION}" \ + -o "$REPO_ROOT/harness" "$REPO_ROOT" + exit 1 +fi + +# 2. Build dev sandbox image (native arch, layer-cached) +if ! $CONTAINER_CLI build -t "$DEV_SANDBOX_IMAGE" "$REPO_ROOT/sandbox/" >/dev/null 2>&1; then + echo "ERROR: sandbox image build failed:" >&2 + $CONTAINER_CLI build -t "$DEV_SANDBOX_IMAGE" "$REPO_ROOT/sandbox/" + exit 1 +fi + +# 3. Cross-compile CLI for runner image + build runner +if ! CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.version=${VERSION}" \ + -o "$REPO_ROOT/build/runner/harness" "$REPO_ROOT" 2>/dev/null; then + echo "ERROR: runner CLI build failed" >&2 + exit 1 +fi +if ! $CONTAINER_CLI build --platform linux/amd64 -t "$DEV_RUNNER_IMAGE" \ + "$REPO_ROOT/build/runner/" >/dev/null 2>&1; then + echo "ERROR: runner image build failed:" >&2 + $CONTAINER_CLI build --platform linux/amd64 -t "$DEV_RUNNER_IMAGE" "$REPO_ROOT/build/runner/" + exit 1 +fi + +# 4. Push images (only when --remote is passed -- OCP pulls from registry) +NEEDS_PUSH=0 +for arg in "$@"; do + [ "$arg" = "--remote" ] && NEEDS_PUSH=1 && break +done +if [ "$NEEDS_PUSH" = "1" ]; then + $CONTAINER_CLI push "$DEV_SANDBOX_IMAGE" >/dev/null 2>&1 || { + echo "ERROR: sandbox image push failed" >&2 + $CONTAINER_CLI push "$DEV_SANDBOX_IMAGE" + exit 1 + } + $CONTAINER_CLI push "$DEV_RUNNER_IMAGE" >/dev/null 2>&1 || { + echo "ERROR: runner image push failed" >&2 + $CONTAINER_CLI push "$DEV_RUNNER_IMAGE" + exit 1 + } +fi + +# 5. Run harness with dev images +export SANDBOX_IMAGE="$DEV_SANDBOX_IMAGE" +export RUNNER_IMAGE="$DEV_RUNNER_IMAGE" +exec "$REPO_ROOT/harness" "$@" diff --git a/main.go b/main.go index 9dc99d9..4256819 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + _ "embed" "fmt" "os" "path/filepath" @@ -12,6 +13,9 @@ import ( var version = "dev" +//go:embed agents/builtin.yaml +var defaultAgentConfig []byte + func main() { harnessDir := detectHarnessDir() @@ -39,6 +43,7 @@ func main() { } cmd.Version = version + cmd.DefaultAgentConfig = defaultAgentConfig root.CompletionOptions.HiddenDefaultCmd = true root.AddCommand(