Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 37 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,41 @@
# 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

[OpenShell](https://github.com/NVIDIA/OpenShell) provides the runtime: gateway, sandboxes, L7 network policy, and credential proxy. It handles sandbox lifecycle and credential injection once providers are registered, but leaves gateway deployment orchestration, credential validation, and multi-target configuration to the user.

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:

Expand All @@ -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`:
Expand All @@ -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 |
|----------|----------|
Expand All @@ -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).
Expand Down Expand Up @@ -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]
Expand Down
17 changes: 17 additions & 0 deletions TODOS.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions agents/builtin.yaml
Original file line number Diff line number Diff line change
@@ -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"
9 changes: 4 additions & 5 deletions cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down
40 changes: 35 additions & 5 deletions cmd/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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)")
Expand All @@ -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
Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions cmd/up_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
Expand Down Expand Up @@ -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")
}
}
Loading
Loading