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
36 changes: 0 additions & 36 deletions .github/workflows/images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,39 +44,3 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.IMAGE_BASE }}:sandbox-cache
cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}:sandbox-cache,mode=max', env.IMAGE_BASE) || '' }}

runner:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build harness binary
run: |
VERSION=$(git describe --tags --always 2>/dev/null || echo dev)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags "-s -w -X main.version=${VERSION}" \
-o build/runner/harness .
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ env.IMAGE_BASE }}
tags: |
type=semver,pattern=runner-v{{version}}
type=sha,prefix=runner-,format=short
- uses: docker/build-push-action@v6
with:
context: build/runner
platforms: linux/amd64
push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.IMAGE_BASE }}:runner-cache
cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}:runner-cache,mode=max', env.IMAGE_BASE) || '' }}
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ __pycache__/
# Build artifacts
harness
harness-openshell
build/runner/harness
build/runner/openshell
infracluster/
.claude/

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,13 @@ Current workarounds and their upstream tracking:
| Custom sandbox image | Adds mcp-atlassian and GWS CLI to community base | Upstreaming MCP integrations |
| `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1` | Vertex AI rejects `context_management` beta header | Anthropic/Google to align APIs |
| Atlassian `JIRA_URL`/`JIRA_USERNAME` as `--config` material | Provider v2 config keys not injected as env vars yet | OpenShell roadmap |
| In-cluster runner Job | OpenShell doesn't have a native K8s-triggered sandbox creation | Potential future CRD |

Previously worked around, now resolved:

| Was | Resolution |
|-----|-----------|
| GWS credential export/upload to sandbox | GWS is now a native provider (`google-workspace`). Gateway manages OAuth refresh via `oauth2-refresh-token` strategy + `request_body_credential_rewrite` on `oauth2.googleapis.com`. Sandbox gets a proxy-resolved placeholder. |
| In-cluster runner Job | Eliminated — all targets now use direct mode. The gateway is accessible via external Route + mTLS, so sandboxes are created from the user's machine. |

## Validation

Expand Down
31 changes: 8 additions & 23 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,10 @@ VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION)

DEV_SANDBOX_IMAGE := $(REGISTRY):sandbox-$(VERSION)
DEV_RUNNER_IMAGE := $(REGISTRY):runner-$(VERSION)

.PHONY: all cli cli-runner \
.PHONY: all cli \
vet lint test test-local test-kind test-remote test-all \
dev-sandbox dev-runner dev-push clean help
dev-sandbox dev-push clean help

## ── CLI ──────────────────────────────────────────────────────────────

Expand All @@ -36,13 +35,6 @@ cli:
CGO_ENABLED=0 go build -ldflags '$(LDFLAGS)' -o harness .
@echo "Built: ./harness ($(VERSION))"

## ── Images ────────────────────────────────────────────────────────────

## Cross-compile harness binary for the runner image
cli-runner:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags '$(LDFLAGS)' -o build/runner/harness .
@echo "Built: build/runner/harness ($(VERSION))"

## ── Lint targets ─────────────────────────────────────────────────────

## Run go vet
Expand Down Expand Up @@ -80,10 +72,10 @@ test-kind: cli
SANDBOX_IMAGE=$(DEV_SANDBOX_IMAGE) CONTAINER_CLI=$(CONTAINER_CLI) ./test/kind-lifecycle.sh $(if $(KEEP),--keep)

## Remote (OCP): requires KUBECONFIG set
test-remote: cli dev-sandbox dev-runner
test-remote: cli dev-sandbox
@test -n "$${KUBECONFIG}" || { echo "ERROR: Set KUBECONFIG for OCP (e.g. export KUBECONFIG=infracluster/kubeconfig)"; exit 1; }
@echo ""
SANDBOX_IMAGE=$(DEV_SANDBOX_IMAGE) RUNNER_IMAGE=$(DEV_RUNNER_IMAGE) ./test/test-flow.sh ocp
SANDBOX_IMAGE=$(DEV_SANDBOX_IMAGE) ./test/test-flow.sh ocp

## All: unit + local + kind + remote
test-all: test test-local test-kind test-remote
Expand All @@ -95,27 +87,20 @@ dev-sandbox:
$(CONTAINER_CLI) build -t $(DEV_SANDBOX_IMAGE) sandbox/
@echo "Built: $(DEV_SANDBOX_IMAGE)"

## Build dev runner image locally
dev-runner: cli-runner
$(CONTAINER_CLI) build --platform $(PLATFORM) -t $(DEV_RUNNER_IMAGE) build/runner/
@echo "Built: $(DEV_RUNNER_IMAGE)"

## Build and push dev images (sandbox: multi-arch, runner: amd64)
dev-push: cli-runner
## Build and push dev sandbox image (multi-arch)
dev-push:
@$(CONTAINER_CLI) rmi --force $(DEV_SANDBOX_IMAGE) 2>/dev/null || true
@$(CONTAINER_CLI) manifest rm $(DEV_SANDBOX_IMAGE) 2>/dev/null || true
$(CONTAINER_CLI) build --platform linux/amd64 --manifest $(DEV_SANDBOX_IMAGE) sandbox/
$(CONTAINER_CLI) build --platform linux/arm64 --manifest $(DEV_SANDBOX_IMAGE) sandbox/
$(CONTAINER_CLI) manifest push $(DEV_SANDBOX_IMAGE)
$(CONTAINER_CLI) build --platform $(PLATFORM) -t $(DEV_RUNNER_IMAGE) build/runner/
$(CONTAINER_CLI) push $(DEV_RUNNER_IMAGE)
@echo "Pushed: $(DEV_SANDBOX_IMAGE) (multi-arch) $(DEV_RUNNER_IMAGE) (amd64)"
@echo "Pushed: $(DEV_SANDBOX_IMAGE) (multi-arch)"

## ── Convenience targets ───────────────────────────────────────────────

## Clean built binaries
clean:
rm -f harness build/runner/harness build/runner/openshell
rm -f harness
@echo "Cleaned binaries"

## Show available targets
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ See the [OpenShell docs](https://github.com/NVIDIA/OpenShell) for the full secur
| `gateways/*/gateway.toml` | Deployment target config: `local/` (Podman), `kind/`, `ocp/` (OpenShift) |
| `openshell.toml` | Deployment-level overrides (enabled providers, inference model, chart version) |
| `sandbox/Dockerfile` | Sandbox image: OpenShell base + MCP servers + CLI tools |
| `build/runner/Dockerfile` | Runner image: harness binary for in-cluster sandbox creation |
| `sandbox/policy.yaml` | Network egress rules applied to sandboxes |

## Commands
Expand Down
23 changes: 8 additions & 15 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,7 @@ Full flow: deploy gateway, register providers, render agent config, create sandb
4. **Render payload** -- `env.sh` (resolved env vars), `run.sh` (entrypoint wrapper), `task.md` (if set).
5. **Create sandbox** -- `openshell sandbox create` with `--upload` (payload) and the startup command. Retry up to 5 times for supervisor race conditions.

Local: sandbox created directly via the openshell CLI on the user's machine.
Remote: a runner Job is deployed to the cluster (`harness launch`), which creates the sandbox from inside the cluster with mTLS gateway access.
Both local and remote targets create the sandbox directly from the user's machine. Remote gateways are accessed via an external Route endpoint with mTLS authentication.

### `harness create [--agent NAME] [-f FILE] [--name SANDBOX]`

Expand Down Expand Up @@ -99,10 +98,6 @@ Stop or start a sandbox without deleting it. When NAME is omitted and exactly on

Tear down resources. At least one flag required.

### `harness launch` (hidden)

In-cluster command for the runner Job. Reads agent config from `/etc/openshell/sandbox/agent.yaml` (mounted ConfigMap), configures mTLS gateway, renders payload, creates sandbox, sets up environment. Not meant for direct user invocation.

## Config Files

| File | Purpose |
Expand All @@ -113,32 +108,30 @@ In-cluster command for the runner Job. Reads agent config from `/etc/openshell/s
| `gateways/*/gateway.toml` | Deployment target config with Helm, images, RBAC |
| `openshell.toml` | Deployment-level overrides (enabled providers, inference model) |
| `sandbox/Dockerfile` | Sandbox image: OpenShell base + MCP servers + CLI tools |
| `build/runner/Dockerfile` | Runner image: harness binary + openshell CLI |
| `sandbox/policy.yaml` | Network egress rules applied to sandboxes |

## Image Tags

All images are published to `ghcr.io/robbycochran/harness-openshell`. CI never publishes floating tags (`:latest`, `:sandbox`, `:runner`); the bare `:sandbox` fallback below exists only for local `go build` binaries without version ldflags.
All images are published to `ghcr.io/robbycochran/harness-openshell`. CI never publishes floating tags (`:latest`, `:sandbox`); the bare `:sandbox` fallback below exists only for local `go build` binaries without version ldflags.

| Trigger | Sandbox | Runner |
|---------|---------|--------|
| Release `v0.1.2` | `:sandbox-v0.1.2` | `:runner-v0.1.2` |
| Any push/PR | `:sandbox-<sha>` | `:runner-<sha>` |
| Trigger | Sandbox |
|---------|---------|
| Release `v0.1.2` | `:sandbox-v0.1.2` |
| Any push/PR | `:sandbox-<sha>` |

The CLI resolves images from its embedded version (set via `-ldflags` at build time):

- `v0.1.2` → `:sandbox-v0.1.2` (tagged release)
- `v0.1.2-5-gabc1234` → `:sandbox-v0.1.2-5-gabc1234` (dev build, matches `make dev-sandbox`)
- `dev` → `:sandbox` (bare `go build` without ldflags)

`SANDBOX_IMAGE` and `RUNNER_IMAGE` env vars override the version-based resolution.
`SANDBOX_IMAGE` env var overrides the version-based resolution.

## Environment Variables

| Variable | Purpose |
|----------|---------|
| `SANDBOX_IMAGE` | Override sandbox image (dev/CI builds) |
| `RUNNER_IMAGE` | Override runner image (dev/CI builds) |
| `HARNESS_DIR` | Override harness directory detection |
| `OPENSHELL_NAMESPACE` | Override K8s namespace (default: `openshell`) |
| `OPENSHELL_CLI` | Override openshell binary path |
Expand All @@ -148,7 +141,7 @@ The CLI resolves images from its embedded version (set via `-ldflags` at build t
| `CONFIG_TOML` / `PROVIDERS_TOML` | Override paths to `openshell.toml` / `providers.toml` (preflight) |
| `KUBECONFIG` | K8s cluster config for remote targets |

`GATEWAY_ENDPOINT` and `GATEWAY_NAME` are internal — set on the in-cluster runner Job, not by users.
`GATEWAY_NAME` is internal — used by env override in gateway config, not typically set by users.

## Payload

Expand Down
10 changes: 2 additions & 8 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@
- Prerequisite: proto files stabilize (OpenShell is alpha)

### Image registry as gateway config vs env override
- gateway.toml `[images]` section sets sandbox/runner image refs
- `SANDBOX_IMAGE`/`RUNNER_IMAGE` env vars override config (for dev/CI)
- Two sources of truth: gateway.toml hardcodes a registry, env vars override it
- `SANDBOX_IMAGE` env var overrides the version-based image resolution (for dev/CI)
- Consider: gateway.toml uses a `registry` field and images are relative to it

### registerProviders should filter by agent's provider list
Expand Down Expand Up @@ -49,14 +47,12 @@
## Testing

### Current coverage
- Go unit tests across cmd/ (including launch.go) and all internal/ packages
- Go unit tests across cmd/ and all internal/ packages
- 29 bats preflight tests (run in CI via `.github/workflows/ci.yml`)
- Integration: local + kind + OCP via `make test-all`

### Gaps
- [ ] Integration test for `providers --force`
- [ ] Unit test for the full `runLaunch` orchestration (currently only its
helpers — configureGateway, checkProviders, launchCreateSandbox — are tested)

## Release

Expand All @@ -66,8 +62,6 @@

## Deferred (post-0.1)

- [ ] Rename K8s SA `openshell-launcher` -> `openshell-runner` (breaking for deployed OCP clusters)
- [ ] Rename `LauncherSection` -> `RunnerSection` in gateway config TOML
- [ ] Gateway-level LLM proxy/logging (gateway.toml `[proxy]` section)
- [ ] Multi-agent workflow support (fleet.yaml / workflow.yaml)
- [ ] `harness policy suggest` (DenialEvent stream -> policy proposals)
Expand Down
13 changes: 0 additions & 13 deletions build/runner/Dockerfile

This file was deleted.

62 changes: 1 addition & 61 deletions cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"os"
"path/filepath"
"slices"
"strings"
"time"

"github.com/robbycochran/harness-openshell/internal/agent"
Expand Down Expand Up @@ -38,12 +37,11 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command {

gw := gateway.New(cli)

// 1. Check which gateway is active and whether it's local or remote.
// 1. Check which gateway is active.
activeGW, err := activeGatewayInfo(gw)
if err != nil {
return err
}
isLocal := strings.Contains(activeGW.Endpoint, "127.0.0.1")

status.Header("Gateway")
status.OKf("%s (%s)", activeGW.Name, activeGW.Endpoint)
Expand Down Expand Up @@ -102,21 +100,8 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command {
}
}

// 6. Determine whether the in-cluster runner is needed.
needsRunner := false
if !isLocal {
needsRunner = profileHasCustomProviders(providerNames, allProviders)
}

// 6. Deploy the sandbox
status.Header("Creating sandbox")
if needsRunner {
status.Info("Custom providers detected — using in-cluster runner")
gwCfg := loadGatewayConfigForActive(harnessDir, activeGW)
return createViaRunner(harnessDir, gwCfg, gw, agentName, name)
}

// Render payload and create directly
payloadDir, err := os.MkdirTemp("", "harness-payload-")
if err != nil {
return fmt.Errorf("creating payload dir: %w", err)
Expand Down Expand Up @@ -167,49 +152,4 @@ func activeGatewayInfo(gw gateway.Gateway) (*gateway.GatewayInfo, error) {
return nil, fmt.Errorf("no active gateway — deploy one first: harness deploy")
}

func profileHasCustomProviders(providerNames []string, allProviders []preflight.Provider) bool {
custom := make(map[string]bool)
for _, p := range allProviders {
if p.Type == "custom" {
custom[p.Name] = true
}
}
for _, name := range providerNames {
if custom[name] {
return true
}
}
return false
}

func loadGatewayConfigForActive(harnessDir string, active *gateway.GatewayInfo) *gateway.GatewayConfig {
if active != nil && active.Name != "" {
dir := filepath.Join(harnessDir, "gateways", active.Name)
if cfg, err := gateway.LoadConfig(dir); err == nil {
return cfg
}
}
gwDir := filepath.Join(harnessDir, "gateways")
entries, err := os.ReadDir(gwDir)
if err != nil {
return nil
}
for _, e := range entries {
if !e.IsDir() {
continue
}
cfg, err := gateway.LoadConfig(filepath.Join(gwDir, e.Name()))
if err == nil && !cfg.IsLocal() {
return cfg
}
}
return nil
}

func createViaRunner(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gateway, agentPath, sandboxName string) error {
if gwCfg == nil {
return fmt.Errorf("no gateway config found for remote gateway — expected gateways/<name>/gateway.toml")
}
return upRemote(harnessDir, gwCfg, gw, agentPath, sandboxName)
}

20 changes: 0 additions & 20 deletions cmd/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"testing"

"github.com/robbycochran/harness-openshell/internal/gateway"
"github.com/robbycochran/harness-openshell/internal/preflight"
)

func TestActiveGatewayInfo_ListError(t *testing.T) {
Expand Down Expand Up @@ -36,23 +35,4 @@ func TestActiveGatewayInfo_RemoteGateway(t *testing.T) {
}
}

func TestProfileHasCustomProviders_NoCustom(t *testing.T) {
allProviders := []preflight.Provider{
{Name: "github", Type: "openshell"},
{Name: "vertex-local", Type: "openshell"},
}
if profileHasCustomProviders([]string{"github", "vertex-local"}, allProviders) {
t.Error("no custom providers, should return false")
}
}

func TestProfileHasCustomProviders_WithCustom(t *testing.T) {
allProviders := []preflight.Provider{
{Name: "github", Type: "openshell"},
{Name: "gws", Type: "custom"},
}
if !profileHasCustomProviders([]string{"github", "gws"}, allProviders) {
t.Error("gws is custom, should return true")
}
}

Loading
Loading