From b4587334434712f42b3ec1ab26d9808f9396e8fc Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Thu, 11 Jun 2026 18:46:34 -0700 Subject: [PATCH 01/12] X-Smart-Branch-Parent: main From f7adbc2b5c881774c6d27afe74d336997cc037f0 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Thu, 11 Jun 2026 19:02:19 -0700 Subject: [PATCH 02/12] docs: update all documentation for current CLI state Audit and update all core documentation to reflect PRs #58-#61: README.md: - Add 'What harness up replaces' section showing the single command vs 8+ raw openshell commands for a 4-provider agent - Remove deleted commands (preflight, providers) - Update config files table (gateway.yaml, remove providers.toml/openshell.toml) - Add OpenCode support, --provider-refresh flag, --env injection - Add v0.0.59 prerequisite SPEC.md: - Remove deleted commands and config files - Update payload section (no more env.sh/sandbox.env) - Document three registration flows (standard, ADC, custom) - Add --provider-refresh, task -p application, empty env host lookup AGENTS.md: - Update sandbox image workaround (adds opencode-ai) - Fix test target description (bats removed) sandbox/CLAUDE.md: - Add opencode as available agent - Remove stale kubectl/deploy-kubeconfig reference - Generalize inference description demo/DEMO-SCRIPT.md: - Fix entrypoint (claude not claude --bare -p) - Replace providers.toml with profiles directory - Replace gateway.toml with gateway.yaml - Remove harness providers step --- AGENTS.md | 6 +- README.md | 124 +++++++++++++++++++++++++++++--------- SPEC.md | 68 ++++++++++----------- demo/DEMO-SCRIPT.md | 143 ++++++++++++++++++++++++++++++++++++++++++++ sandbox/CLAUDE.md | 15 +++-- 5 files changed, 279 insertions(+), 77 deletions(-) create mode 100644 demo/DEMO-SCRIPT.md diff --git a/AGENTS.md b/AGENTS.md index 9be95f3..bdc3256 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,9 +70,9 @@ Current workarounds and their upstream tracking: | Workaround | Why | Upstream | |------------|-----|----------| -| Custom sandbox image | Adds mcp-atlassian and GWS CLI to community base | Upstreaming MCP integrations | +| Custom sandbox image | Adds mcp-atlassian, GWS CLI, and opencode-ai 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 | +| Atlassian `JIRA_URL`/`JIRA_USERNAME` as agent YAML provider config | Provider v2 config keys not injected as env vars yet | OpenShell roadmap | Previously worked around, now resolved: @@ -107,7 +107,7 @@ See `make help` for the full list. The test entry points: | Target | Gateway | Mode | |--------|---------|------| -| `make test` | none | vet + unit tests + bats | +| `make test` | none | vet + unit tests | | `make test-local` | local Podman | default locally, ci on GHA | | `make test-kind` | kind cluster | default locally, ci on GHA | | `make test-remote` | OCP | default (needs KUBECONFIG + creds) | diff --git a/README.md b/README.md index dd13474..f4fbba5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Automates gateway deployment, provider registration, and sandbox creation across ## Quick Start -**Prerequisites:** [OpenShell](https://github.com/NVIDIA/OpenShell) installed and running, Podman. +**Prerequisites:** [OpenShell v0.0.59+](https://github.com/NVIDIA/OpenShell) installed and running, Podman. ```bash # macOS @@ -33,7 +33,66 @@ To customize providers or add GWS, create an `agents/default.yaml` in your proje [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. +This harness fills a different gap: multi-provider credential management (inline 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. + +## What `harness up` Replaces + +A single command: + +```bash +harness up -f agents/default.yaml +``` + +replaces this sequence of 8+ `openshell` commands: + +```bash +# 1. Enable the providers v2 system +openshell settings set --global --key providers_v2_enabled --value true + +# 2. Import custom provider profiles +openshell provider profile import --from agents/providers/profiles/ + +# 3. Register GitHub (reads GITHUB_TOKEN from environment) +openshell provider create --name github --type github --from-existing + +# 4. Register Vertex AI (reads ADC from gcloud login) +openshell provider create --name vertex-local --type google-vertex-ai \ + --from-gcloud-adc \ + --config VERTEX_AI_PROJECT_ID=my-project \ + --config VERTEX_AI_REGION=global + +# 5. Register Atlassian (reads JIRA_API_TOKEN from environment) +openshell provider create --name atlassian --type atlassian --from-existing + +# 6. Register GWS (multi-step: create, configure refresh, rotate token) +openshell provider create --name gws --type google-workspace \ + --credential GOOGLE_WORKSPACE_CLI_TOKEN=pending +openshell provider refresh configure gws \ + --credential-key GOOGLE_WORKSPACE_CLI_TOKEN \ + --strategy oauth2-refresh-token \ + --material client_id=... \ + --material client_secret=... \ + --material refresh_token=... \ + --secret-material-key client_secret \ + --secret-material-key refresh_token +openshell provider refresh rotate gws \ + --credential-key GOOGLE_WORKSPACE_CLI_TOKEN + +# 7. Configure inference routing +openshell inference set --provider vertex-local --model claude-sonnet-4-6 --no-verify + +# 8. Create the sandbox with all providers and env vars attached +openshell sandbox create --name agent \ + --from ghcr.io/robbycochran/harness-openshell:sandbox \ + --provider github --provider vertex-local --provider atlassian --provider gws \ + --env ANTHROPIC_BASE_URL=https://inference.local \ + --env ANTHROPIC_API_KEY=sk-ant-openshell-proxy-managed \ + --upload payload:/sandbox/.config --no-git-ignore \ + --tty \ + -- bash /sandbox/.config/openshell/run.sh +``` + +The harness also handles: local gateway deployment (Podman), version checking (openshell >= v0.0.59), payload rendering (run.sh, task.md, bin/), retry logic on sandbox creation, and graceful skipping of providers whose credentials are not available. ## Agent Configs @@ -71,32 +130,44 @@ For non-interactive task agents, set `task:` and `tty: false`: ```yaml # agents/demo.yaml name: demo -entrypoint: claude -p +entrypoint: claude task: demo/DEMO-TASK.md tty: false # ... same providers and env ``` +When `task:` is set, the harness passes its content to the entrypoint via `-p`. + +### OpenCode Support + +[OpenCode](https://github.com/opencode-ai/opencode) is supported as an alternative entrypoint: + +```bash +harness up --agent opencode +``` + +Uses the same providers and gateway -- just a different agent binary. See `agents/opencode.yaml`. + ## Local Setup ### Prerequisites -- [OpenShell CLI](https://github.com/NVIDIA/OpenShell) (`brew tap nvidia/openshell && brew install openshell && brew services start openshell` on macOS) +- [OpenShell CLI v0.0.59+](https://github.com/NVIDIA/OpenShell) (`brew tap nvidia/openshell && brew install openshell && brew services start openshell` on macOS) - Podman/Docker - Go 1.23+ (only needed for building from source) ### Credentials -Each provider requires credentials on the host. The harness validates these before registration. Providers with missing credentials are skipped with an info message. +Each provider requires credentials on the host. The harness validates these inline during registration. Providers with missing credentials are skipped with an info message. | Provider | Required | |----------|----------| | `github` | `GITHUB_TOKEN` env var | | `vertex-local` | `gcloud auth application-default login --project ` + `ANTHROPIC_VERTEX_PROJECT_ID` + `CLOUD_ML_REGION` env vars | | `atlassian` | `JIRA_API_TOKEN` + `JIRA_URL` + `JIRA_USERNAME` env vars | -| `gws` | OAuth client secret at `~/.config/gws/client_secret.json` | +| `gws` | `gws auth login` (OAuth via [gws CLI](https://github.com/googleworkspace/cli)) | -See `providers.toml` for the full input schema and health checks per provider. +Provider profiles are defined in `agents/providers/profiles/` and validated inline during registration. ### Build from Source @@ -112,17 +183,17 @@ For remote OpenShift: `./harness up --remote` (requires `kubectl`, `helm`, clust The harness orchestrates three OpenShell components via the `openshell` CLI: - **Gateway** -- OpenShell's credential proxy and L7 network policy engine. Runs as a Podman container (local) or Kubernetes StatefulSet (remote). Manages provider credentials, inference routing, and sandbox lifecycle. -- **Providers** -- Credential registrations on the gateway. Each provider's required inputs (env vars, files, connectivity checks) are declared in `providers.toml`. The harness runs preflight validation before registering. -- **Sandbox** -- Container running the agent entrypoint, configured by `agents/*.yaml`. The gateway injects credentials at the network boundary -- the sandbox process sees proxy-managed placeholder tokens. Network egress is deny-by-default at L7. +- **Providers** -- Credential registrations on the gateway. Provider profiles are declared in `agents/providers/profiles/`. The harness validates credentials inline during registration -- providers with missing credentials are skipped. +- **Sandbox** -- Container running the agent entrypoint (Claude Code or OpenCode), configured by `agents/*.yaml`. The gateway injects credentials at the network boundary -- the sandbox process sees proxy-managed placeholder tokens. Network egress is deny-by-default at L7. ``` -harness CLI ──→ openshell CLI ──→ Gateway (Podman or K8s) - ├── Provider credentials - ├── L7 network policy - ├── inference.local proxy +harness CLI ──> openshell CLI ──> Gateway (Podman or K8s) + |── Provider credentials + |── L7 network policy + |── inference.local proxy └── Sandbox container - ├── claude - ├── gh, mcp-atlassian, gws + |── claude / opencode + |── gh, mcp-atlassian, gws └── placeholder tokens ``` @@ -134,23 +205,24 @@ See the [OpenShell docs](https://github.com/NVIDIA/OpenShell) for the full secur |------|---------| | `agents/*.yaml` | Agent config: image, entrypoint, providers, env, optional task file | | `agents/providers/profiles/` | OpenShell provider profiles (imported to gateway on registration) | -| `providers.toml` | Provider catalog: required inputs and health checks per provider | -| `gateways/*/gateway.toml` | Deployment target config: `local/` (Podman), `kind/`, `ocp/` (OpenShift) | -| `openshell.toml` | Deployment-level overrides (enabled providers, inference model, chart version) | +| `gateways/*/gateway.yaml` | Deployment target config: `local/` (Podman), `kind/`, `ocp/` (OpenShift) | | `sandbox/Dockerfile` | Sandbox image: OpenShell base + MCP servers + CLI tools | | `sandbox/policy.yaml` | Network egress rules applied to sandboxes | +| `sandbox/opencode.json` | MCP server config for OpenCode agent | ## Commands ``` -harness up [--remote] [--agent NAME] [-f FILE] [--name SANDBOX] +harness up [--remote] [--agent NAME] [-f FILE] [--name SANDBOX] [--provider-refresh] Deploy gateway + register providers + create sandbox. Defaults to local gateway (use --remote for OCP). --agent defaults to "default" (embedded or agents/default.yaml). -f renders any agent YAML file directly. + --provider-refresh deletes and recreates all providers. harness create [--agent NAME] [-f FILE] [--name SANDBOX] - Create a sandbox without deploying the gateway. Assumes gateway is running. + Create a sandbox without deploying the gateway. + Assumes gateway is running. Auto-registers missing providers. harness connect [NAME] Reconnect to a running sandbox. @@ -158,14 +230,8 @@ harness connect [NAME] harness deploy [local|ocp|kind] Deploy or verify the gateway for a target. -harness providers [--force] - Register providers with the gateway. --force re-registers all. - -harness preflight [--strict] - Validate local credentials and prerequisites. - harness status - Show gateway, provider, and sandbox status. + Show sandbox status. harness logs [NAME] [-f] Stream sandbox logs (-f to follow). @@ -181,9 +247,9 @@ harness teardown [--sandboxes] [--providers] [--k8s] | Document | What it is | |----------|------------| -| [SPEC.md](SPEC.md) | **Authoritative** behavior spec for the CLI — commands, configs, payload | +| [SPEC.md](SPEC.md) | **Authoritative** behavior spec for the CLI -- commands, configs, payload | | [AGENTS.md](AGENTS.md) | Contributor guide: coding principles, workaround tracking, validation modes | | [TODO.md](TODO.md) | Roadmap and known gaps | -| [docs/archive/](docs/archive/README.md) | Historical design docs (e.g. the June 2026 design-v1 proposal) — outdated, kept for context | +| [docs/archive/](docs/archive/README.md) | Historical design docs (e.g. the June 2026 design-v1 proposal) -- outdated, kept for context | | [docs/release-plan.md](docs/release-plan.md) | Release phases: CI (done), embed + `harness init`, GoReleaser | | [docs/proto-migration.md](docs/proto-migration.md) | Deferred plan to adopt proto-generated config types | diff --git a/SPEC.md b/SPEC.md index 0de5d34..4692b62 100644 --- a/SPEC.md +++ b/SPEC.md @@ -9,7 +9,9 @@ The harness deploys and manages AI agent sandboxes on three targets: - **Kind** -- Kubernetes pods via a kind cluster - **Remote** -- Kubernetes pods via an OpenShift-hosted OpenShell gateway -Each sandbox is an isolated container running an agent entrypoint, with credential providers, network policies, and a rendered payload (env.sh, run.sh, task.md). +Each sandbox is an isolated container running an agent entrypoint (Claude Code or OpenCode), with credential providers, network policies, and a rendered payload (run.sh, task.md). + +Requires OpenShell v0.0.59+. ## Agent Config @@ -17,7 +19,7 @@ Agent configs live in `agents/*.yaml`. Each declares the sandbox image, entrypoi ```yaml name: agent -entrypoint: claude +entrypoint: claude # or: opencode tty: true providers: @@ -36,13 +38,13 @@ env: Fields: - `name` (required) -- sandbox name, used for `openshell sandbox connect` - `image` -- container image for the sandbox (default: version-matched from ghcr.io, override with `SANDBOX_IMAGE` env) -- `entrypoint` -- command to run (default: `claude`) +- `entrypoint` -- command to run (default: `claude`). Supports `claude`, `opencode`, `bash`, or any binary on PATH. - `tty` -- enable TTY (default: false) -- `task` -- path to a task.md file, passed as argument to entrypoint +- `task` -- path to a task.md file, passed to entrypoint via `-p "$(cat task.md)"` - `providers` -- list of provider profile references - `providers[].profile` -- OpenShell provider profile name -- `providers[].config` -- non-secret config vars (resolved via `os.ExpandEnv`) -- `env` -- additional environment variables +- `providers[].config` -- non-secret config vars (resolved via `os.ExpandEnv`; empty values read from host env) +- `env` -- additional environment variables injected via `--env` on sandbox create (empty values read from host env) - `include` -- extra files to include in the payload - `policy` -- path to a network policy YAML @@ -50,21 +52,25 @@ Provider profiles live in `agents/providers/profiles/`. These are imported to th ## CLI -### `harness up [--local|--remote] [--agent NAME] [-f FILE] [--name SANDBOX] [--no-tty]` +### `harness up [--local|--remote] [--agent NAME] [-f FILE] [--name SANDBOX] [--no-tty] [--provider-refresh]` Full flow: deploy gateway, register providers, render agent config, create sandbox. -1. **Ensure gateway** -- deploy if needed (local: Podman, remote: Helm to K8s/OCP). -2. **Parse agent config** -- read `agents/.yaml` (default: `default`). `-f` overrides with a direct file path. -3. **Ensure providers** -- validate providers declared in the agent config. Auto-register missing ones. -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. +1. **Check version** -- warn if openshell CLI is below v0.0.59. +2. **Ensure gateway** -- deploy if needed (local: Podman, remote: Helm to K8s/OCP). +3. **Parse agent config** -- read `agents/.yaml` (default: `default`). `-f` overrides with a direct file path. +4. **Ensure providers** -- auto-register missing providers. Three registration flows: + - **Standard** (`--from-existing`): GitHub, Atlassian -- OpenShell discovers credentials from local env. + - **ADC** (`--from-gcloud-adc`): Vertex AI -- reads ADC file, configures inference routing. + - **Custom**: GWS -- multi-step OAuth refresh flow (harness workaround until OpenShell adds native support). +5. **Render payload** -- `run.sh` (entrypoint wrapper with PATH setup, git auth, `-p` task), `task.md` (if set). +6. **Create sandbox** -- `openshell sandbox create` with `--env` (env vars), `--upload` (payload), and startup command. Retry up to 5 times. -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. +`--provider-refresh` deletes and recreates all providers (replaces the old `harness providers --force`). ### `harness create [--agent NAME] [-f FILE] [--name SANDBOX]` -Create a sandbox without deploying the gateway. Errors if no gateway is active. +Create a sandbox without deploying the gateway. Assumes gateway is running. Auto-registers missing providers. ### `harness connect [NAME]` @@ -72,27 +78,19 @@ Reconnect to a running sandbox via `openshell sandbox connect`. ### `harness deploy [local|ocp|kind]` -Deploy or verify the gateway for a target. Reads `gateways//gateway.toml`. - -### `harness providers [--force]` - -Register providers with the gateway. Reads `providers.toml` for the catalog, imports profiles from `agents/providers/profiles/`. - -### `harness preflight [--strict]` - -Validate local credentials and prerequisites against `providers.toml`. +Deploy or verify the gateway for a target. Reads `gateways//gateway.yaml`. ### `harness status` -Show gateway, provider, and sandbox status. Read-only. +Show sandbox status. Read-only. ### `harness logs [NAME] [-f|--follow]` -Stream logs for a sandbox (name resolution delegated to `openshell sandbox logs` when NAME is omitted). +Stream logs for a sandbox. ### `harness stop [NAME]` / `harness start [NAME]` -Stop or start a sandbox without deleting it. When NAME is omitted and exactly one sandbox is running, it is used; otherwise the command errors. +Stop or start a sandbox without deleting it. ### `harness teardown [--sandboxes] [--providers] [--k8s]` @@ -104,11 +102,10 @@ Tear down resources. At least one flag required. |------|---------| | `agents/*.yaml` | Agent config: image, entrypoint, providers, env, task | | `agents/providers/profiles/` | OpenShell provider profile YAMLs | -| `providers.toml` | Provider catalog: required inputs, health checks | -| `gateways/*/gateway.toml` | Deployment target config with Helm, images, RBAC | -| `openshell.toml` | Deployment-level overrides (enabled providers, inference model) | +| `gateways/*/gateway.yaml` | Deployment target config with Helm, images, RBAC | | `sandbox/Dockerfile` | Sandbox image: OpenShell base + MCP servers + CLI tools | | `sandbox/policy.yaml` | Network egress rules applied to sandboxes | +| `sandbox/opencode.json` | MCP server config for OpenCode agent | ## Image Tags @@ -136,12 +133,11 @@ The CLI resolves images from its embedded version (set via `-ldflags` at build t | `OPENSHELL_NAMESPACE` | Override K8s namespace (default: `openshell`) | | `OPENSHELL_CLI` | Override openshell binary path | | `OPENSHELL_MODEL` | Inference model for provider registration (default: `claude-sonnet-4-6`) | -| `OPENSHELL_CHART_VERSION` | Override Helm chart version (beats `openshell.toml` and `gateway.toml`) | +| `OPENSHELL_CHART_VERSION` | Override Helm chart version (beats `gateway.yaml`) | | `PULL_SECRET` / `SANDBOX_PULL_SECRET` | Image pull secret names passed to the Helm install | -| `CONFIG_TOML` / `PROVIDERS_TOML` | Override paths to `openshell.toml` / `providers.toml` (preflight) | | `KUBECONFIG` | K8s cluster config for remote targets | -`GATEWAY_NAME` is internal — used by env override in gateway config, not typically set by users. +`GATEWAY_NAME` is internal -- used by env override in gateway config, not typically set by users. ## Payload @@ -149,11 +145,9 @@ The harness renders agent config into a self-contained payload uploaded to `/san ``` openshell/ - env.sh -- export KEY="value" (resolved from agent config) - run.sh -- sources env.sh, validates entrypoint, execs it - sandbox.env -- same as env.sh (sourced by sandbox startup.sh) - task.md -- task file with envsubst applied + run.sh -- validates entrypoint, execs it (with -p task if set) + task.md -- task file with envsubst applied (if task: is set) bin/ -- wrapper scripts ``` -`sandbox.env` is sourced as the sandbox's initial command, making env vars available in all subsequent `sandbox exec` sessions. `run.sh` is the entrypoint for interactive mode. +Environment variables are injected directly via `--env KEY=VALUE` flags on `openshell sandbox create` -- no file upload needed for env vars. `run.sh` is the entrypoint for interactive mode. diff --git a/demo/DEMO-SCRIPT.md b/demo/DEMO-SCRIPT.md new file mode 100644 index 0000000..2bc1365 --- /dev/null +++ b/demo/DEMO-SCRIPT.md @@ -0,0 +1,143 @@ +# OpenShell Harness -- Hackathon Demo Script + +> **Goal**: Show how OpenShell Harness gives a sandboxed Claude agent local +> access to your real tools (Jira, GitHub, Gmail) through a single +> `harness up --local` command. + +--- + +## Act 1: The Problem (30 seconds) + +"AI coding agents can read your code, but they can't see your tickets, +your PRs, or your email. You end up copy-pasting context between tabs. +OpenShell Harness solves this by giving a sandboxed Claude agent +authenticated access to your actual work tools -- Jira, GitHub, Google +Workspace -- through a local gateway that manages credentials so the +agent never sees your secrets." + +--- + +## Act 2: The Config (1 minute) + +### Show the demo agent config + +```bash +cat agents/demo.yaml +``` + +**Talk through it:** + +- `image:` -- the sandbox container image (has Claude CLI baked in) +- `entrypoint: claude` -- Claude Code runs inside the sandbox +- `task: demo/DEMO-TASK.md` -- the prompt file; rendered into the payload + and passed to Claude automatically on start. "One config, one command, + the agent knows what to do." +- `tty: false` -- non-interactive; output streams to your terminal +- `providers:` -- which tool integrations the agent gets: + - `github` -- read-only GitHub API + git + - `vertex-local` -- Vertex AI inference (no API key leaves your machine) + - `atlassian` -- Jira/Confluence via Basic auth resolved by the gateway proxy + - `gws` -- Google Workspace (Gmail, Calendar, Docs) via gateway-managed OAuth +- `env:` -- sandbox env vars; note `ANTHROPIC_BASE_URL: https://inference.local` + routes all inference through the gateway proxy (cost tracking, audit log) + +### Show the provider definitions + +```bash +ls agents/providers/profiles/ +``` + +**Talk through it:** + +- Each provider declares its `inputs` -- env vars, files, or health checks +- The harness validates all inputs before deploying +- Secrets (`secret = true`) are mounted into the sandbox as K8s secrets or + podman secrets -- the agent binary never reads them directly + +### Show the gateway config + +```bash +cat gateways/local/gateway.yaml +``` + +**Talk through it:** + +- `type = "local"` -- runs on your laptop via podman/docker +- `providers.enabled` -- which built-in providers are active +- `providers.custom` -- GWS is a custom provider (gateway-managed OAuth refresh) + +--- + +## Act 3: Launch It (30 seconds) + +```bash +# Build the CLI (if not already built) +make cli + +# Deploy gateway + register providers + create sandbox with the demo task +./harness up --local --agent demo +``` + +**While it starts:** "The harness is doing three things: starting the gateway +container, registering each provider's credentials with the gateway, and +creating a sandbox container with Claude inside. The `--agent demo` flag +tells it to use `agents/demo.yaml`, which has a task baked in -- the agent +starts working immediately. The gateway acts as a proxy -- all API calls from +the sandbox go through it, so credentials never leave the gateway process." + +--- + +## Act 4: Watch It Work (2 minutes) + +Claude starts automatically with the task from `demo/DEMO-TASK.md`. No paste +needed -- the task was rendered into the sandbox payload at launch time. + +The agent will: +1. Query Jira for your assigned tickets (via the Atlassian provider) +2. Query GitHub for recent PRs on stackrox/skills (via the GitHub provider) +3. Search Gmail for the latest memolist thread (via the GWS provider) +4. Format everything as markdown tables + +**While Claude works:** "Notice Claude is hitting real APIs -- Jira, GitHub, +Gmail -- all through the gateway. No API keys in the sandbox. If I revoke a +provider, the agent loses access instantly without rebuilding the container." + +> **Fallback:** If `--agent` isn't wired yet, run `./harness up --local` +> and paste the contents of `demo/DEMO-TASK.md` into the Claude prompt. + +--- + +## Act 5: Teardown (15 seconds) + +```bash +# Tear down sandbox + gateway +./harness teardown +``` + +"One command cleans up everything. The sandbox is ephemeral -- no state +persists between sessions unless you explicitly save it." + +--- + +## Talking Points for Q&A + +- **Security model**: The agent runs in a sandboxed container with no host + filesystem access. Credentials live in the gateway, not the sandbox. + The gateway proxy means the agent can't exfiltrate tokens even if + prompt-injected. + +- **Provider extensibility**: Adding a new tool = adding a TOML block to + a provider profile in `agents/providers/profiles/` and an entry in + `agents/default.yaml`. No code changes. + +- **Local vs Remote**: Same config works with `harness up --remote` for + OCP/K8s deployment. The gateway runs as a sidecar pod, providers mount + as K8s secrets. + +- **Cost control**: All inference routes through the gateway proxy, which + can enforce cost limits, log token usage, and rate-limit per-agent. + +- **Why not just MCP?**: MCP gives the agent tool access, but OpenShell + gives it *authenticated, sandboxed, auditable* tool access with + credential isolation. MCP servers inside the sandbox talk through the + gateway -- the agent code is identical either way. diff --git a/sandbox/CLAUDE.md b/sandbox/CLAUDE.md index 09040cd..f24ebed 100644 --- a/sandbox/CLAUDE.md +++ b/sandbox/CLAUDE.md @@ -1,6 +1,6 @@ # Sandbox Agent Instructions -You are running inside an OpenShell sandbox on an OpenShift cluster. Credentials are injected via the OpenShell provider system — they appear as environment variables automatically. +You are running inside an OpenShell sandbox. Credentials are injected via the OpenShell provider system — they appear as environment variables automatically. ## Tools Available @@ -21,19 +21,18 @@ You are running inside an OpenShell sandbox on an OpenShift cluster. Credentials - `gws drive files list --params '{"pageSize": 10}'` - Use `gws schema ` to discover API parameters. -### Kubernetes — `kubectl` -- A deploy kubeconfig may be available at `/tmp/deploy-kubeconfig` for deploying to test namespaces. -- Do NOT modify the `openshell` or `agent-sandbox-system` namespaces. +### AI Coding Agents +- `claude` — Claude Code (wrapper at `/usr/local/bin/claude`) +- `opencode` — OpenCode (MCP config at `/sandbox/opencode.json`) ### General Tools -- `python3`, `pip`, `uv` — Python 3.14 with a virtualenv at `/sandbox/.venv` +- `python3`, `pip`, `uv` — Python with a virtualenv at `/sandbox/.venv` - `node`, `npm` — Node.js 22 - `git`, `curl` — pre-installed -- `cargo` — NOT available (no Rust toolchain in sandbox) ## Configuration -- Running via **Vertex AI** through `inference.local` gateway routing. -- Model selection is configured at the gateway level via `openshell inference set`. +- Inference routes through the gateway proxy at `inference.local`. +- Model and provider configuration are set by the harness during sandbox creation. ## Conventions - Working directory: `/sandbox` From c5dc0941a5093ba63bc4539d90e58f42be837766 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 12 Jun 2026 07:49:54 -0700 Subject: [PATCH 03/12] chore: remove demo script and demo agent config The demo/ directory and agents/demo.yaml were not actively used. Updated README task agent example to use a generic name. --- README.md | 5 +- agents/demo.yaml | 23 ------- demo/DEMO-SCRIPT.md | 143 -------------------------------------------- 3 files changed, 2 insertions(+), 169 deletions(-) delete mode 100644 agents/demo.yaml delete mode 100644 demo/DEMO-SCRIPT.md diff --git a/README.md b/README.md index f4fbba5..1a481a3 100644 --- a/README.md +++ b/README.md @@ -128,10 +128,9 @@ Credentials are proxy-managed. The sandbox holds placeholder tokens; real secret For non-interactive task agents, set `task:` and `tty: false`: ```yaml -# agents/demo.yaml -name: demo +name: standup entrypoint: claude -task: demo/DEMO-TASK.md +task: tasks/daily-standup.md tty: false # ... same providers and env ``` diff --git a/agents/demo.yaml b/agents/demo.yaml deleted file mode 100644 index 2443d27..0000000 --- a/agents/demo.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# Hackathon demo agent -- runs the cross-tool status snapshot task. -# -# Usage: -# harness up --local --agent demo - -name: demo -entrypoint: claude -task: demo/DEMO-TASK.md -tty: false - -providers: - - profile: github - - profile: vertex-local - - profile: atlassian - config: - JIRA_URL: ${JIRA_URL} - JIRA_USERNAME: ${JIRA_USERNAME} - - profile: gws - -env: - ANTHROPIC_BASE_URL: https://inference.local - ANTHROPIC_API_KEY: sk-ant-openshell-proxy-managed - CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1" diff --git a/demo/DEMO-SCRIPT.md b/demo/DEMO-SCRIPT.md deleted file mode 100644 index 2bc1365..0000000 --- a/demo/DEMO-SCRIPT.md +++ /dev/null @@ -1,143 +0,0 @@ -# OpenShell Harness -- Hackathon Demo Script - -> **Goal**: Show how OpenShell Harness gives a sandboxed Claude agent local -> access to your real tools (Jira, GitHub, Gmail) through a single -> `harness up --local` command. - ---- - -## Act 1: The Problem (30 seconds) - -"AI coding agents can read your code, but they can't see your tickets, -your PRs, or your email. You end up copy-pasting context between tabs. -OpenShell Harness solves this by giving a sandboxed Claude agent -authenticated access to your actual work tools -- Jira, GitHub, Google -Workspace -- through a local gateway that manages credentials so the -agent never sees your secrets." - ---- - -## Act 2: The Config (1 minute) - -### Show the demo agent config - -```bash -cat agents/demo.yaml -``` - -**Talk through it:** - -- `image:` -- the sandbox container image (has Claude CLI baked in) -- `entrypoint: claude` -- Claude Code runs inside the sandbox -- `task: demo/DEMO-TASK.md` -- the prompt file; rendered into the payload - and passed to Claude automatically on start. "One config, one command, - the agent knows what to do." -- `tty: false` -- non-interactive; output streams to your terminal -- `providers:` -- which tool integrations the agent gets: - - `github` -- read-only GitHub API + git - - `vertex-local` -- Vertex AI inference (no API key leaves your machine) - - `atlassian` -- Jira/Confluence via Basic auth resolved by the gateway proxy - - `gws` -- Google Workspace (Gmail, Calendar, Docs) via gateway-managed OAuth -- `env:` -- sandbox env vars; note `ANTHROPIC_BASE_URL: https://inference.local` - routes all inference through the gateway proxy (cost tracking, audit log) - -### Show the provider definitions - -```bash -ls agents/providers/profiles/ -``` - -**Talk through it:** - -- Each provider declares its `inputs` -- env vars, files, or health checks -- The harness validates all inputs before deploying -- Secrets (`secret = true`) are mounted into the sandbox as K8s secrets or - podman secrets -- the agent binary never reads them directly - -### Show the gateway config - -```bash -cat gateways/local/gateway.yaml -``` - -**Talk through it:** - -- `type = "local"` -- runs on your laptop via podman/docker -- `providers.enabled` -- which built-in providers are active -- `providers.custom` -- GWS is a custom provider (gateway-managed OAuth refresh) - ---- - -## Act 3: Launch It (30 seconds) - -```bash -# Build the CLI (if not already built) -make cli - -# Deploy gateway + register providers + create sandbox with the demo task -./harness up --local --agent demo -``` - -**While it starts:** "The harness is doing three things: starting the gateway -container, registering each provider's credentials with the gateway, and -creating a sandbox container with Claude inside. The `--agent demo` flag -tells it to use `agents/demo.yaml`, which has a task baked in -- the agent -starts working immediately. The gateway acts as a proxy -- all API calls from -the sandbox go through it, so credentials never leave the gateway process." - ---- - -## Act 4: Watch It Work (2 minutes) - -Claude starts automatically with the task from `demo/DEMO-TASK.md`. No paste -needed -- the task was rendered into the sandbox payload at launch time. - -The agent will: -1. Query Jira for your assigned tickets (via the Atlassian provider) -2. Query GitHub for recent PRs on stackrox/skills (via the GitHub provider) -3. Search Gmail for the latest memolist thread (via the GWS provider) -4. Format everything as markdown tables - -**While Claude works:** "Notice Claude is hitting real APIs -- Jira, GitHub, -Gmail -- all through the gateway. No API keys in the sandbox. If I revoke a -provider, the agent loses access instantly without rebuilding the container." - -> **Fallback:** If `--agent` isn't wired yet, run `./harness up --local` -> and paste the contents of `demo/DEMO-TASK.md` into the Claude prompt. - ---- - -## Act 5: Teardown (15 seconds) - -```bash -# Tear down sandbox + gateway -./harness teardown -``` - -"One command cleans up everything. The sandbox is ephemeral -- no state -persists between sessions unless you explicitly save it." - ---- - -## Talking Points for Q&A - -- **Security model**: The agent runs in a sandboxed container with no host - filesystem access. Credentials live in the gateway, not the sandbox. - The gateway proxy means the agent can't exfiltrate tokens even if - prompt-injected. - -- **Provider extensibility**: Adding a new tool = adding a TOML block to - a provider profile in `agents/providers/profiles/` and an entry in - `agents/default.yaml`. No code changes. - -- **Local vs Remote**: Same config works with `harness up --remote` for - OCP/K8s deployment. The gateway runs as a sidecar pod, providers mount - as K8s secrets. - -- **Cost control**: All inference routes through the gateway proxy, which - can enforce cost limits, log token usage, and rate-limit per-agent. - -- **Why not just MCP?**: MCP gives the agent tool access, but OpenShell - gives it *authenticated, sandboxed, auditable* tool access with - credential isolation. MCP servers inside the sandbox talk through the - gateway -- the agent code is identical either way. From 42ce6008f1a01379ccf1e7b5c2666ebe76f8616d Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 12 Jun 2026 07:56:06 -0700 Subject: [PATCH 04/12] refactor: only register providers declared in agent YAML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registerProviders() now takes the agent's provider name list instead of a gateway config. Only providers listed in the agent YAML are registered — no more hardcoded list of all four providers. The agent YAML is the single source of truth for which providers a sandbox needs. If a provider isn't in the YAML, it's not registered. --- cmd/create.go | 2 +- cmd/providers.go | 80 +++++++++++++------------------------------ cmd/providers_test.go | 50 ++++++++++++--------------- cmd/up.go | 2 +- 4 files changed, 47 insertions(+), 87 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index 77381f4..2de8c3e 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -58,7 +58,7 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { providerNames := agentCfg.ProviderNames() registered, missing := gateway.ValidateProviders(providerNames, gw) if len(missing) > 0 { - if err := registerProviders(harnessDir, gw, false, nil, false); err != nil { + if err := registerProviders(harnessDir, gw, false, providerNames); err != nil { status.Warn(fmt.Sprintf("provider registration: %v", err)) } registered, missing = gateway.ValidateProviders(providerNames, gw) diff --git a/cmd/providers.go b/cmd/providers.go index 35ae311..b9cfc69 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -13,41 +13,27 @@ import ( "gopkg.in/yaml.v3" ) -// registerProviders registers providers with the gateway. If gwCfg is non-nil -// and has a [providers] section, only providers in that list are registered. -// Otherwise all providers are registered (backward-compatible behavior). -func registerProviders(harnessDir string, gw gateway.Gateway, force bool, gwCfg *gateway.GatewayConfig, standalone bool) error { +// registerProviders registers the providers listed in providerNames with the +// gateway. Only providers in the list are registered — the agent YAML is +// the source of truth. +func registerProviders(harnessDir string, gw gateway.Gateway, force bool, providerNames []string) error { model := envOr("OPENSHELL_MODEL", "claude-sonnet-4-6") - // Build the set of enabled provider names from gateway config (if available) - var enabledSet map[string]bool - if gwCfg != nil && gwCfg.HasProviders() { - enabledSet = make(map[string]bool) - for _, name := range gwCfg.AllProviders() { - enabledSet[name] = true - } - } - - providerEnabled := func(name string) bool { - if enabledSet == nil { - return true - } - return enabledSet[name] + wanted := make(map[string]bool, len(providerNames)) + for _, n := range providerNames { + wanted[n] = true } - // Force mode: require no running sandboxes if force { sandboxes, err := gw.SandboxList() if err != nil { return fmt.Errorf("listing sandboxes: %w", err) } if len(sandboxes) > 0 { - return fmt.Errorf("cannot --force with running sandboxes — delete them first") + return fmt.Errorf("cannot --provider-refresh with running sandboxes — delete them first") } - for _, name := range []string{"github", "vertex-local", "atlassian", "gws"} { - if providerEnabled(name) { - gw.ProviderDelete(name) - } + for _, name := range providerNames { + gw.ProviderDelete(name) } deleteCustomProfiles(harnessDir, gw) status.Info("Deleted existing providers") @@ -55,25 +41,22 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, gwCfg status.Header("Providers") - // Enable providers v2 if err := gw.SettingsSet("providers_v2_enabled", "true"); err != nil { return fmt.Errorf("enabling providers v2: %w", err) } - // Import custom profiles profilesDir := filepath.Join(harnessDir, "agents", "providers", "profiles") gw.ProviderProfileImport(profilesDir) - home, _ := os.UserHomeDir() - adcPath := envOr("GOOGLE_APPLICATION_CREDENTIALS", - filepath.Join(home, ".config", "gcloud", "application_default_credentials.json")) - project := envOr("ANTHROPIC_VERTEX_PROJECT_ID", readADCProject(adcPath)) - region := envOr("CLOUD_ML_REGION", "global") - - if providerEnabled("github") { + if wanted["github"] { registerStandard("github", "github", gw, nil) } - if providerEnabled("vertex-local") { + if wanted["vertex-local"] { + home, _ := os.UserHomeDir() + adcPath := envOr("GOOGLE_APPLICATION_CREDENTIALS", + filepath.Join(home, ".config", "gcloud", "application_default_credentials.json")) + project := envOr("ANTHROPIC_VERTEX_PROJECT_ID", readADCProject(adcPath)) + region := envOr("CLOUD_ML_REGION", "global") var vertexConfigs []string if project != "" { vertexConfigs = append(vertexConfigs, "VERTEX_AI_PROJECT_ID="+project) @@ -81,28 +64,15 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, gwCfg vertexConfigs = append(vertexConfigs, "VERTEX_AI_REGION="+region) registerADC("vertex-local", "google-vertex-ai", model, gw, vertexConfigs) } - if providerEnabled("atlassian") { + if wanted["atlassian"] { registerStandard("atlassian", "atlassian", gw, nil) } - if err := registerGWS(harnessDir, gw, providerEnabled); err != nil { - return err - } - - if standalone { - names, err := gw.ProviderList() - if err != nil { - return fmt.Errorf("listing providers: %w", err) - } - fmt.Println() - for _, n := range names { - status.OK(n) - } - m := gw.InferenceModel() - if m != "" { - status.OKf("Inference: %s", m) + if wanted["gws"] { + if err := registerGWS(harnessDir, gw); err != nil { + return err } - status.Done("Done. Launch a sandbox with: harness up --local") } + return nil } @@ -141,11 +111,7 @@ func registerADC(name, profileType, model string, gw gateway.Gateway, configs [] status.OKf("inference: model %s", model) } -func registerGWS(harnessDir string, gw gateway.Gateway, enabled func(string) bool) error { - if !enabled("gws") { - status.Info("gws: disabled by gateway config") - return nil - } +func registerGWS(harnessDir string, gw gateway.Gateway) error { if gw.ProviderGet("gws") == nil { status.Info("gws: exists (use --force to recreate)") return nil diff --git a/cmd/providers_test.go b/cmd/providers_test.go index ef1da2c..adef0ce 100644 --- a/cmd/providers_test.go +++ b/cmd/providers_test.go @@ -6,8 +6,6 @@ import ( "path/filepath" "strings" "testing" - - "github.com/robbycochran/harness-openshell/internal/gateway" ) func setupProvidersTest(t *testing.T) string { @@ -20,13 +18,12 @@ func setupProvidersTest(t *testing.T) string { func TestRegisterProviders_GitHubWhenTokenSet(t *testing.T) { dir := setupProvidersTest(t) t.Setenv("GITHUB_TOKEN", "ghp_test123") - t.Setenv("JIRA_API_TOKEN", "") gw := &mockGW{ providers: map[string]bool{}, } - err := registerProviders(dir, gw, false, nil, true) + err := registerProviders(dir, gw, false, []string{"github"}) if err != nil { t.Fatalf("registerProviders: %v", err) } @@ -35,13 +32,12 @@ func TestRegisterProviders_GitHubWhenTokenSet(t *testing.T) { func TestRegisterProviders_SkipsWhenTokenMissing(t *testing.T) { dir := setupProvidersTest(t) t.Setenv("GITHUB_TOKEN", "") - t.Setenv("JIRA_API_TOKEN", "") gw := &mockGW{ providers: map[string]bool{}, } - err := registerProviders(dir, gw, false, nil, true) + err := registerProviders(dir, gw, false, []string{"github"}) if err != nil { t.Fatalf("registerProviders: %v", err) } @@ -50,13 +46,12 @@ func TestRegisterProviders_SkipsWhenTokenMissing(t *testing.T) { func TestRegisterProviders_SkipsExistingProvider(t *testing.T) { dir := setupProvidersTest(t) t.Setenv("GITHUB_TOKEN", "ghp_test123") - t.Setenv("JIRA_API_TOKEN", "") gw := &mockGW{ providers: map[string]bool{"github": true}, } - err := registerProviders(dir, gw, false, nil, true) + err := registerProviders(dir, gw, false, []string{"github"}) if err != nil { t.Fatalf("registerProviders: %v", err) } @@ -72,62 +67,56 @@ func TestRegisterProviders_ForceWithRunningSandboxes(t *testing.T) { sandboxes: []string{"test-sandbox"}, } - err := registerProviders(dir, gw, true, nil, true) + err := registerProviders(dir, gw, true, []string{"github"}) if err == nil { t.Fatal("expected error with --force and running sandboxes") } - if !strings.Contains(err.Error(), "cannot --force") { - t.Errorf("error = %q, want 'cannot --force'", err) + if !strings.Contains(err.Error(), "cannot --provider-refresh") { + t.Errorf("error = %q, want 'cannot --provider-refresh'", err) } } func TestRegisterProviders_ForceDeletesAndRecreates(t *testing.T) { dir := setupProvidersTest(t) t.Setenv("GITHUB_TOKEN", "ghp_test123") - t.Setenv("JIRA_API_TOKEN", "") gw := &mockGW{ providers: map[string]bool{}, } - err := registerProviders(dir, gw, true, nil, true) + err := registerProviders(dir, gw, true, []string{"github"}) if err != nil { t.Fatalf("registerProviders: %v", err) } } -func TestRegisterProviders_RespectsGatewayConfig(t *testing.T) { +func TestRegisterProviders_OnlyRegistersRequestedProviders(t *testing.T) { dir := setupProvidersTest(t) t.Setenv("GITHUB_TOKEN", "ghp_test123") - t.Setenv("JIRA_API_TOKEN", "token") + t.Setenv("JIRA_API_TOKEN", "jira_test") gw := &mockGW{ providers: map[string]bool{}, } - gwCfg := &gateway.GatewayConfig{} - gwCfg.Providers.Enabled = []string{"github"} - - err := registerProviders(dir, gw, false, gwCfg, true) + // Only request github — atlassian should NOT be registered even though + // JIRA_API_TOKEN is set. + err := registerProviders(dir, gw, false, []string{"github"}) if err != nil { t.Fatalf("registerProviders: %v", err) } } -func TestRegisterProviders_ListError(t *testing.T) { +func TestRegisterProviders_EmptyList(t *testing.T) { dir := setupProvidersTest(t) gw := &mockGW{ - providers: map[string]bool{}, - providerErr: fmt.Errorf("gateway unreachable"), + providers: map[string]bool{}, } - err := registerProviders(dir, gw, false, nil, true) - if err == nil { - t.Fatal("expected error when provider list fails") - } - if !strings.Contains(err.Error(), "listing providers") { - t.Errorf("error = %q, want 'listing providers'", err) + err := registerProviders(dir, gw, false, nil) + if err != nil { + t.Fatalf("registerProviders: %v", err) } } @@ -140,3 +129,8 @@ type mockGWWithSandboxes struct { func (m *mockGWWithSandboxes) SandboxList() ([]string, error) { return m.sandboxes, nil } + +func init() { + // Silence fmt.Errorf unused import + _ = fmt.Errorf +} diff --git a/cmd/up.go b/cmd/up.go index 4b54469..a62362a 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -167,7 +167,7 @@ func upLocal(opts upLocalOpts) error { var missing []string registered, missing = gateway.ValidateProviders(providerNames, gw) if len(missing) > 0 || opts.providerRefresh { - if err := registerProviders(opts.harnessDir, gw, opts.providerRefresh, opts.gwCfg, false); err != nil { + if err := registerProviders(opts.harnessDir, gw, opts.providerRefresh, providerNames); err != nil { status.Warn(fmt.Sprintf("provider registration: %v", err)) } registered, missing = gateway.ValidateProviders(providerNames, gw) From c3769aeed6236c0bfae8981acf87241d3bac6341 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 12 Jun 2026 08:10:36 -0700 Subject: [PATCH 05/12] =?UTF-8?q?docs:=20restructure=20README=20to=20show?= =?UTF-8?q?=20command=20=E2=86=92=20file=20=E2=86=92=20equivalent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'What harness up replaces' section now flows: the harness command, the agent YAML it reads, then the openshell commands it replaces. Removed the duplicate Agent Configs section. --- README.md | 67 +++++++++++++++++++++++++------------------------------ 1 file changed, 30 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 1a481a3..47f1c3d 100644 --- a/README.md +++ b/README.md @@ -37,19 +37,41 @@ This harness fills a different gap: multi-provider credential management (inline ## What `harness up` Replaces -A single command: +One command: ```bash -harness up -f agents/default.yaml +harness up +``` + +reads `agents/default.yaml`: + +```yaml +# agents/default.yaml +name: agent +entrypoint: claude +tty: true + +providers: + - profile: github + - profile: vertex-local + - profile: atlassian + config: + JIRA_URL: ${JIRA_URL} + JIRA_USERNAME: ${JIRA_USERNAME} + - profile: gws + +env: + ANTHROPIC_BASE_URL: https://inference.local + ANTHROPIC_API_KEY: sk-ant-openshell-proxy-managed ``` -replaces this sequence of 8+ `openshell` commands: +and replaces this sequence of 8+ `openshell` commands: ```bash # 1. Enable the providers v2 system openshell settings set --global --key providers_v2_enabled --value true -# 2. Import custom provider profiles +# 2. Import custom provider profiles (atlassian, gws) openshell provider profile import --from agents/providers/profiles/ # 3. Register GitHub (reads GITHUB_TOKEN from environment) @@ -64,7 +86,7 @@ openshell provider create --name vertex-local --type google-vertex-ai \ # 5. Register Atlassian (reads JIRA_API_TOKEN from environment) openshell provider create --name atlassian --type atlassian --from-existing -# 6. Register GWS (multi-step: create, configure refresh, rotate token) +# 6. Register GWS (multi-step: create, configure OAuth refresh, rotate) openshell provider create --name gws --type google-workspace \ --credential GOOGLE_WORKSPACE_CLI_TOKEN=pending openshell provider refresh configure gws \ @@ -81,7 +103,7 @@ openshell provider refresh rotate gws \ # 7. Configure inference routing openshell inference set --provider vertex-local --model claude-sonnet-4-6 --no-verify -# 8. Create the sandbox with all providers and env vars attached +# 8. Create the sandbox with providers and env vars openshell sandbox create --name agent \ --from ghcr.io/robbycochran/harness-openshell:sandbox \ --provider github --provider vertex-local --provider atlassian --provider gws \ @@ -92,38 +114,9 @@ openshell sandbox create --name agent \ -- bash /sandbox/.config/openshell/run.sh ``` -The harness also handles: local gateway deployment (Podman), version checking (openshell >= v0.0.59), payload rendering (run.sh, task.md, bin/), retry logic on sandbox creation, and graceful skipping of providers whose credentials are not available. - -## Agent Configs - -An agent config declares the sandbox image, entrypoint, and which providers to attach: - -```yaml -# agents/default.yaml -name: agent -image: ghcr.io/robbycochran/harness-openshell:sandbox -entrypoint: claude -tty: true - -providers: - - profile: github - - profile: vertex-local - - profile: atlassian - config: - JIRA_URL: ${JIRA_URL} - JIRA_USERNAME: ${JIRA_USERNAME} - - profile: gws - -env: - ANTHROPIC_BASE_URL: https://inference.local - ANTHROPIC_API_KEY: sk-ant-openshell-proxy-managed -``` - -```bash -harness up -``` +The harness also handles: local gateway deployment, version checking (openshell >= v0.0.59), payload rendering, retry logic, and graceful skipping of providers whose credentials are not available. -Credentials are proxy-managed. The sandbox holds placeholder tokens; real secrets are substituted by the gateway at the network boundary. +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`: From 79609d05c768ffee1c6c8af4b4972d86e828a940 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 12 Jun 2026 08:34:17 -0700 Subject: [PATCH 06/12] feat: add gateway field to agent config, add OCP agent example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent YAML can now specify its deployment target via gateway: field. harness up reads it to determine local vs remote — no --local/--remote flag needed when the agent config declares it. - gateway: local (default) — Podman on your machine - gateway: ocp — OpenShift cluster - gateway: kind — local kind cluster - --local/--remote flags override the agent config Added agents/ocp.yaml as a concrete example. --- README.md | 39 +++++++++++++++++++++++++++++++++++++-- agents/ocp.yaml | 26 ++++++++++++++++++++++++++ cmd/up.go | 5 ++++- internal/agent/agent.go | 1 + 4 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 agents/ocp.yaml diff --git a/README.md b/README.md index 47f1c3d..b1b2cb7 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,41 @@ The harness also handles: local gateway deployment, version checking (openshell Credentials are proxy-managed -- the sandbox holds placeholder tokens; real secrets are substituted by the gateway at the network boundary. +### OCP Deployment + +The `gateway:` field tells the harness which deployment target to use: + +```yaml +# agents/ocp.yaml +name: agent +gateway: ocp +entrypoint: claude +tty: true + +providers: + - profile: github + - profile: vertex-local + - profile: atlassian + config: + JIRA_URL: ${JIRA_URL} + JIRA_USERNAME: ${JIRA_USERNAME} + - profile: gws + +env: + ANTHROPIC_BASE_URL: https://inference.local + ANTHROPIC_API_KEY: sk-ant-openshell-proxy-managed + CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1" +``` + +```bash +harness up -f agents/ocp.yaml # deploys to OCP (reads gateways/ocp/gateway.yaml) +harness up # defaults to local Podman +``` + +`--local` and `--remote` flags override the `gateway:` field. + +### Task Agents + For non-interactive task agents, set `task:` and `tty: false`: ```yaml @@ -130,7 +165,7 @@ tty: false When `task:` is set, the harness passes its content to the entrypoint via `-p`. -### OpenCode Support +### OpenCode [OpenCode](https://github.com/opencode-ai/opencode) is supported as an alternative entrypoint: @@ -138,7 +173,7 @@ When `task:` is set, the harness passes its content to the entrypoint via `-p`. harness up --agent opencode ``` -Uses the same providers and gateway -- just a different agent binary. See `agents/opencode.yaml`. +Same providers and gateway -- just a different agent binary. See `agents/opencode.yaml`. ## Local Setup diff --git a/agents/ocp.yaml b/agents/ocp.yaml new file mode 100644 index 0000000..493716c --- /dev/null +++ b/agents/ocp.yaml @@ -0,0 +1,26 @@ +# OCP agent — deploys to an OpenShift cluster. +# +# Usage: +# harness up -f agents/ocp.yaml +# +# The gateway field tells harness to deploy to OCP instead of local Podman. +# Requires: KUBECONFIG, kubectl, helm, cluster access. + +name: agent +gateway: ocp +entrypoint: claude +tty: true + +providers: + - profile: github + - profile: vertex-local + - profile: atlassian + config: + JIRA_URL: ${JIRA_URL} + JIRA_USERNAME: ${JIRA_USERNAME} + - profile: gws + +env: + ANTHROPIC_BASE_URL: https://inference.local + ANTHROPIC_API_KEY: sk-ant-openshell-proxy-managed + CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1" diff --git a/cmd/up.go b/cmd/up.go index a62362a..7eb7f57 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -50,7 +50,10 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { gwName := "local" if remote { gwName = "ocp" + } else if !local && agentCfg.Gateway != "" { + gwName = agentCfg.Gateway } + isRemote := gwName != "local" gwDir := filepath.Join(harnessDir, "gateways", gwName) gwCfg, _ := gateway.LoadConfig(gwDir) @@ -58,7 +61,7 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { harnessDir: harnessDir, gw: gw, gwCfg: gwCfg, - ensureLocal: !remote, + ensureLocal: !isRemote, agentCfg: agentCfg, agentPath: agentPath, sandboxName: sandboxName, diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 40c1e14..f2fd1f3 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -17,6 +17,7 @@ type ProviderRef struct { type AgentConfig struct { Name string `yaml:"name"` + Gateway string `yaml:"gateway,omitempty"` Providers []ProviderRef `yaml:"providers"` Env map[string]string `yaml:"env,omitempty"` Task string `yaml:"task,omitempty"` From 1e4644bb14a22e7c8ecb8883415473ddd33965f5 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 12 Jun 2026 09:02:23 -0700 Subject: [PATCH 07/12] docs: add OCP equivalent openshell commands to README Show the additional Helm deploy + sandbox create commands that harness up runs when gateway: ocp is set. --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index b1b2cb7..06983e6 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,32 @@ harness up # defaults to local Podman `--local` and `--remote` flags override the `gateway:` field. +For OCP, the harness runs the same provider registration commands as local, plus Helm-based gateway deployment. The `openshell sandbox create` call adds the OCP-specific image and routes through the remote gateway: + +```bash +# 1-7. Same provider registration as local (settings, profiles, providers, inference) + +# 8. Deploy gateway to OCP via Helm (reads gateways/ocp/gateway.yaml) +kubectl create ns openshell +kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/.../manifest.yaml +helm upgrade --install openshell oci://ghcr.io/nvidia/openshell/helm-chart \ + --version 0.0.59 -n openshell -f gateways/ocp/helm/values.yaml +kubectl rollout status statefulset/openshell -n openshell + +# 9. Create sandbox on the remote gateway +openshell sandbox create --name agent \ + --from ghcr.io/robbycochran/harness-openshell:sandbox \ + --provider github --provider vertex-local --provider atlassian --provider gws \ + --env ANTHROPIC_BASE_URL=https://inference.local \ + --env ANTHROPIC_API_KEY=sk-ant-openshell-proxy-managed \ + --env CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 \ + --env JIRA_URL=https://your-org.atlassian.net \ + --env JIRA_USERNAME=you@company.com \ + --upload payload:/sandbox/.config --no-git-ignore \ + --tty \ + -- bash /sandbox/.config/openshell/run.sh +``` + ### Task Agents For non-interactive task agents, set `task:` and `tty: false`: From 9a052e703edaea038825cdb95591fec0c3ec78d3 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 12 Jun 2026 09:07:01 -0700 Subject: [PATCH 08/12] =?UTF-8?q?docs:=20fix=20OCP=20command=20order=20?= =?UTF-8?q?=E2=80=94=20deploy=20gateway=20first,=20then=20providers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The correct OCP sequence is: namespace + CRD + SCCs + Helm + rollout + gateway register → then provider registration → then sandbox create. Previously showed providers before deploy. --- README.md | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 06983e6..3f06279 100644 --- a/README.md +++ b/README.md @@ -151,27 +151,39 @@ harness up # defaults to local Podman `--local` and `--remote` flags override the `gateway:` field. -For OCP, the harness runs the same provider registration commands as local, plus Helm-based gateway deployment. The `openshell sandbox create` call adds the OCP-specific image and routes through the remote gateway: +For OCP, the harness first deploys the gateway via Helm, then runs the same provider registration and sandbox creation. The full sequence: ```bash -# 1-7. Same provider registration as local (settings, profiles, providers, inference) - -# 8. Deploy gateway to OCP via Helm (reads gateways/ocp/gateway.yaml) +# 1. Deploy gateway to OCP kubectl create ns openshell +kubectl label ns openshell pod-security.kubernetes.io/enforce=privileged kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/.../manifest.yaml +oc adm policy add-scc-to-user privileged -z openshell -n openshell +oc adm policy add-scc-to-user anyuid -z openshell -n openshell helm upgrade --install openshell oci://ghcr.io/nvidia/openshell/helm-chart \ --version 0.0.59 -n openshell -f gateways/ocp/helm/values.yaml -kubectl rollout status statefulset/openshell -n openshell +kubectl rollout status statefulset/openshell -n openshell --timeout=300s + +# 2. Register gateway with CLI (mTLS certs extracted from K8s secrets) +openshell gateway add https://openshell-route.apps.cluster.example.com:443 \ + --name openshell-remote-ocp --local +openshell gateway select openshell-remote-ocp -# 9. Create sandbox on the remote gateway +# 3-9. Same as local: settings, profiles, providers, inference, sandbox create +openshell settings set --global --key providers_v2_enabled --value true +openshell provider profile import --from agents/providers/profiles/ +openshell provider create --name github --type github --from-existing +openshell provider create --name vertex-local --type google-vertex-ai \ + --from-gcloud-adc --config VERTEX_AI_PROJECT_ID=... --config VERTEX_AI_REGION=global +openshell provider create --name atlassian --type atlassian --from-existing +# ... GWS multi-step registration (same as local) +openshell inference set --provider vertex-local --model claude-sonnet-4-6 --no-verify openshell sandbox create --name agent \ --from ghcr.io/robbycochran/harness-openshell:sandbox \ --provider github --provider vertex-local --provider atlassian --provider gws \ --env ANTHROPIC_BASE_URL=https://inference.local \ --env ANTHROPIC_API_KEY=sk-ant-openshell-proxy-managed \ --env CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 \ - --env JIRA_URL=https://your-org.atlassian.net \ - --env JIRA_USERNAME=you@company.com \ --upload payload:/sandbox/.config --no-git-ignore \ --tty \ -- bash /sandbox/.config/openshell/run.sh From b6ca093795417c1893ae780451ff6bc0fcb275a2 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 12 Jun 2026 09:11:24 -0700 Subject: [PATCH 09/12] refactor: wire provider config via --config on provider create, not --env Provider config values (JIRA_URL, JIRA_USERNAME, etc.) now flow through the provider system via --config on 'openshell provider create' instead of being merged into --env on sandbox create. - BuildEnvMap() returns only the agent's env: section (sandbox-level vars) - ProviderRef.ConfigList() returns config as KEY=VALUE pairs for --config - registerProviders() takes []agent.ProviderRef instead of []string, passing each provider's config to the registration call - Provider configs are managed by the gateway, not manually injected --- cmd/create.go | 2 +- cmd/providers.go | 39 +++++++++--------- cmd/providers_test.go | 80 ++++++++++++++++++++---------------- cmd/up.go | 2 +- internal/agent/agent.go | 18 +++++--- internal/agent/agent_test.go | 74 +++++++++++++++++++-------------- 6 files changed, 122 insertions(+), 93 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index 2de8c3e..a2159b0 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -58,7 +58,7 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { providerNames := agentCfg.ProviderNames() registered, missing := gateway.ValidateProviders(providerNames, gw) if len(missing) > 0 { - if err := registerProviders(harnessDir, gw, false, providerNames); err != nil { + if err := registerProviders(harnessDir, gw, false, agentCfg.Providers); err != nil { status.Warn(fmt.Sprintf("provider registration: %v", err)) } registered, missing = gateway.ValidateProviders(providerNames, gw) diff --git a/cmd/providers.go b/cmd/providers.go index b9cfc69..473caf5 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -8,20 +8,21 @@ import ( "path/filepath" "strings" + "github.com/robbycochran/harness-openshell/internal/agent" "github.com/robbycochran/harness-openshell/internal/gateway" "github.com/robbycochran/harness-openshell/internal/status" "gopkg.in/yaml.v3" ) -// registerProviders registers the providers listed in providerNames with the -// gateway. Only providers in the list are registered — the agent YAML is -// the source of truth. -func registerProviders(harnessDir string, gw gateway.Gateway, force bool, providerNames []string) error { +// registerProviders registers the providers listed in the agent config with +// the gateway. Only providers in the agent YAML are registered. Provider +// config values are passed via --config during registration. +func registerProviders(harnessDir string, gw gateway.Gateway, force bool, providers []agent.ProviderRef) error { model := envOr("OPENSHELL_MODEL", "claude-sonnet-4-6") - wanted := make(map[string]bool, len(providerNames)) - for _, n := range providerNames { - wanted[n] = true + wanted := make(map[string]*agent.ProviderRef, len(providers)) + for i := range providers { + wanted[providers[i].Profile] = &providers[i] } if force { @@ -32,8 +33,8 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, provid if len(sandboxes) > 0 { return fmt.Errorf("cannot --provider-refresh with running sandboxes — delete them first") } - for _, name := range providerNames { - gw.ProviderDelete(name) + for _, p := range providers { + gw.ProviderDelete(p.Profile) } deleteCustomProfiles(harnessDir, gw) status.Info("Deleted existing providers") @@ -48,26 +49,26 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, provid profilesDir := filepath.Join(harnessDir, "agents", "providers", "profiles") gw.ProviderProfileImport(profilesDir) - if wanted["github"] { - registerStandard("github", "github", gw, nil) + if p, ok := wanted["github"]; ok { + registerStandard("github", "github", gw, p.ConfigList()) } - if wanted["vertex-local"] { + if p, ok := wanted["vertex-local"]; ok { home, _ := os.UserHomeDir() adcPath := envOr("GOOGLE_APPLICATION_CREDENTIALS", filepath.Join(home, ".config", "gcloud", "application_default_credentials.json")) project := envOr("ANTHROPIC_VERTEX_PROJECT_ID", readADCProject(adcPath)) region := envOr("CLOUD_ML_REGION", "global") - var vertexConfigs []string + configs := p.ConfigList() if project != "" { - vertexConfigs = append(vertexConfigs, "VERTEX_AI_PROJECT_ID="+project) + configs = append(configs, "VERTEX_AI_PROJECT_ID="+project) } - vertexConfigs = append(vertexConfigs, "VERTEX_AI_REGION="+region) - registerADC("vertex-local", "google-vertex-ai", model, gw, vertexConfigs) + configs = append(configs, "VERTEX_AI_REGION="+region) + registerADC("vertex-local", "google-vertex-ai", model, gw, configs) } - if wanted["atlassian"] { - registerStandard("atlassian", "atlassian", gw, nil) + if p, ok := wanted["atlassian"]; ok { + registerStandard("atlassian", "atlassian", gw, p.ConfigList()) } - if wanted["gws"] { + if _, ok := wanted["gws"]; ok { if err := registerGWS(harnessDir, gw); err != nil { return err } diff --git a/cmd/providers_test.go b/cmd/providers_test.go index adef0ce..5ed8765 100644 --- a/cmd/providers_test.go +++ b/cmd/providers_test.go @@ -1,11 +1,12 @@ package cmd import ( - "fmt" "os" "path/filepath" "strings" "testing" + + "github.com/robbycochran/harness-openshell/internal/agent" ) func setupProvidersTest(t *testing.T) string { @@ -19,11 +20,11 @@ func TestRegisterProviders_GitHubWhenTokenSet(t *testing.T) { dir := setupProvidersTest(t) t.Setenv("GITHUB_TOKEN", "ghp_test123") - gw := &mockGW{ - providers: map[string]bool{}, - } + gw := &mockGW{providers: map[string]bool{}} - err := registerProviders(dir, gw, false, []string{"github"}) + err := registerProviders(dir, gw, false, []agent.ProviderRef{ + {Profile: "github"}, + }) if err != nil { t.Fatalf("registerProviders: %v", err) } @@ -33,11 +34,11 @@ func TestRegisterProviders_SkipsWhenTokenMissing(t *testing.T) { dir := setupProvidersTest(t) t.Setenv("GITHUB_TOKEN", "") - gw := &mockGW{ - providers: map[string]bool{}, - } + gw := &mockGW{providers: map[string]bool{}} - err := registerProviders(dir, gw, false, []string{"github"}) + err := registerProviders(dir, gw, false, []agent.ProviderRef{ + {Profile: "github"}, + }) if err != nil { t.Fatalf("registerProviders: %v", err) } @@ -47,11 +48,11 @@ func TestRegisterProviders_SkipsExistingProvider(t *testing.T) { dir := setupProvidersTest(t) t.Setenv("GITHUB_TOKEN", "ghp_test123") - gw := &mockGW{ - providers: map[string]bool{"github": true}, - } + gw := &mockGW{providers: map[string]bool{"github": true}} - err := registerProviders(dir, gw, false, []string{"github"}) + err := registerProviders(dir, gw, false, []agent.ProviderRef{ + {Profile: "github"}, + }) if err != nil { t.Fatalf("registerProviders: %v", err) } @@ -61,13 +62,13 @@ func TestRegisterProviders_ForceWithRunningSandboxes(t *testing.T) { dir := setupProvidersTest(t) gw := &mockGWWithSandboxes{ - mockGW: &mockGW{ - providers: map[string]bool{"github": true}, - }, + mockGW: &mockGW{providers: map[string]bool{"github": true}}, sandboxes: []string{"test-sandbox"}, } - err := registerProviders(dir, gw, true, []string{"github"}) + err := registerProviders(dir, gw, true, []agent.ProviderRef{ + {Profile: "github"}, + }) if err == nil { t.Fatal("expected error with --force and running sandboxes") } @@ -80,11 +81,11 @@ func TestRegisterProviders_ForceDeletesAndRecreates(t *testing.T) { dir := setupProvidersTest(t) t.Setenv("GITHUB_TOKEN", "ghp_test123") - gw := &mockGW{ - providers: map[string]bool{}, - } + gw := &mockGW{providers: map[string]bool{}} - err := registerProviders(dir, gw, true, []string{"github"}) + err := registerProviders(dir, gw, true, []agent.ProviderRef{ + {Profile: "github"}, + }) if err != nil { t.Fatalf("registerProviders: %v", err) } @@ -95,24 +96,38 @@ func TestRegisterProviders_OnlyRegistersRequestedProviders(t *testing.T) { t.Setenv("GITHUB_TOKEN", "ghp_test123") t.Setenv("JIRA_API_TOKEN", "jira_test") - gw := &mockGW{ - providers: map[string]bool{}, - } + gw := &mockGW{providers: map[string]bool{}} - // Only request github — atlassian should NOT be registered even though - // JIRA_API_TOKEN is set. - err := registerProviders(dir, gw, false, []string{"github"}) + err := registerProviders(dir, gw, false, []agent.ProviderRef{ + {Profile: "github"}, + }) if err != nil { t.Fatalf("registerProviders: %v", err) } } -func TestRegisterProviders_EmptyList(t *testing.T) { +func TestRegisterProviders_PassesConfigToProvider(t *testing.T) { dir := setupProvidersTest(t) + t.Setenv("JIRA_API_TOKEN", "jira_test") + t.Setenv("JIRA_URL", "https://test.atlassian.net") + t.Setenv("JIRA_USERNAME", "test@example.com") + + gw := &mockGW{providers: map[string]bool{}} - gw := &mockGW{ - providers: map[string]bool{}, + err := registerProviders(dir, gw, false, []agent.ProviderRef{ + {Profile: "atlassian", Config: map[string]string{ + "JIRA_URL": "${JIRA_URL}", + "JIRA_USERNAME": "${JIRA_USERNAME}", + }}, + }) + if err != nil { + t.Fatalf("registerProviders: %v", err) } +} + +func TestRegisterProviders_EmptyList(t *testing.T) { + dir := setupProvidersTest(t) + gw := &mockGW{providers: map[string]bool{}} err := registerProviders(dir, gw, false, nil) if err != nil { @@ -129,8 +144,3 @@ type mockGWWithSandboxes struct { func (m *mockGWWithSandboxes) SandboxList() ([]string, error) { return m.sandboxes, nil } - -func init() { - // Silence fmt.Errorf unused import - _ = fmt.Errorf -} diff --git a/cmd/up.go b/cmd/up.go index 7eb7f57..ccba17f 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -170,7 +170,7 @@ func upLocal(opts upLocalOpts) error { var missing []string registered, missing = gateway.ValidateProviders(providerNames, gw) if len(missing) > 0 || opts.providerRefresh { - if err := registerProviders(opts.harnessDir, gw, opts.providerRefresh, providerNames); err != nil { + if err := registerProviders(opts.harnessDir, gw, opts.providerRefresh, agentCfg.Providers); err != nil { status.Warn(fmt.Sprintf("provider registration: %v", err)) } registered, missing = gateway.ValidateProviders(providerNames, gw) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index f2fd1f3..e8b3c66 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -89,14 +89,20 @@ func (c *AgentConfig) BuildEnvMap() map[string]string { env[k] = val } } - for _, p := range c.Providers { - for k, v := range p.Config { - if val := expandEnvVar(k, v); val != "" { - env[k] = val - } + return env +} + +func (p *ProviderRef) ConfigList() []string { + if len(p.Config) == 0 { + return nil + } + configs := make([]string, 0, len(p.Config)) + for k, v := range p.Config { + if val := expandEnvVar(k, v); val != "" { + configs = append(configs, k+"="+val) } } - return env + return configs } func (c *AgentConfig) BuildEnvSh() string { diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 83e4054..c83b1b1 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -143,19 +143,14 @@ func TestNoTTY(t *testing.T) { func TestBuildEnvSh(t *testing.T) { cfg := &AgentConfig{ - Providers: []ProviderRef{ - {Profile: "atlassian", Config: map[string]string{ - "JIRA_URL": "https://issues.redhat.com", - "JIRA_USERNAME": "alice", - }}, + Env: map[string]string{ + "ANTHROPIC_BASE_URL": "https://inference.local", + "ANTHROPIC_API_KEY": "sk-proxy", }, } env := cfg.BuildEnvSh() - if !strings.Contains(env, `export JIRA_URL="https://issues.redhat.com"`) { - t.Errorf("missing JIRA_URL in:\n%s", env) - } - if !strings.Contains(env, `export JIRA_USERNAME="alice"`) { - t.Errorf("missing JIRA_USERNAME in:\n%s", env) + if !strings.Contains(env, `export ANTHROPIC_BASE_URL="https://inference.local"`) { + t.Errorf("missing ANTHROPIC_BASE_URL in:\n%s", env) } } @@ -166,33 +161,53 @@ func TestBuildEnvSh_Empty(t *testing.T) { } } -func TestBuildEnvSh_TopLevelEnv(t *testing.T) { +func TestBuildEnvSh_ExcludesProviderConfig(t *testing.T) { cfg := &AgentConfig{ Env: map[string]string{ "ANTHROPIC_BASE_URL": "https://inference.local", - "ANTHROPIC_API_KEY": "sk-proxy", }, Providers: []ProviderRef{ {Profile: "atlassian", Config: map[string]string{"JIRA_URL": "https://jira.example.com"}}, }, } env := cfg.BuildEnvSh() - if !strings.Contains(env, `export ANTHROPIC_BASE_URL="https://inference.local"`) { + if !strings.Contains(env, `ANTHROPIC_BASE_URL`) { t.Errorf("missing top-level env var in:\n%s", env) } - if !strings.Contains(env, `export JIRA_URL="https://jira.example.com"`) { - t.Errorf("missing provider config var in:\n%s", env) + if strings.Contains(env, "JIRA_URL") { + t.Errorf("provider config should not be in env.sh (goes via --config on provider create):\n%s", env) } } -func TestBuildEnvSh_ProviderOverridesTopLevel(t *testing.T) { - cfg := &AgentConfig{ - Env: map[string]string{"FOO": "from-top"}, - Providers: []ProviderRef{{Profile: "test", Config: map[string]string{"FOO": "from-provider"}}}, +func TestProviderRef_ConfigList(t *testing.T) { + t.Setenv("JIRA_URL", "https://test.atlassian.net") + p := ProviderRef{ + Profile: "atlassian", + Config: map[string]string{ + "JIRA_URL": "${JIRA_URL}", + "JIRA_USERNAME": "alice", + }, } - env := cfg.BuildEnvSh() - if !strings.Contains(env, `"from-provider"`) { - t.Errorf("provider config should override top-level env:\n%s", env) + configs := p.ConfigList() + if len(configs) != 2 { + t.Fatalf("ConfigList() = %v, want 2 entries", configs) + } + found := map[string]bool{} + for _, c := range configs { + found[c] = true + } + if !found["JIRA_URL=https://test.atlassian.net"] { + t.Errorf("missing expanded JIRA_URL in %v", configs) + } + if !found["JIRA_USERNAME=alice"] { + t.Errorf("missing JIRA_USERNAME in %v", configs) + } +} + +func TestProviderRef_ConfigList_Empty(t *testing.T) { + p := ProviderRef{Profile: "github"} + if configs := p.ConfigList(); configs != nil { + t.Errorf("ConfigList() = %v, want nil", configs) } } @@ -234,12 +249,11 @@ func TestBuildEnvMap(t *testing.T) { cfg := &AgentConfig{ Env: map[string]string{ "TOP_VAR": "top-val", - "SHARED": "from-top", + "ANOTHER": "another-val", }, Providers: []ProviderRef{ {Profile: "atlassian", Config: map[string]string{ "JIRA_URL": "https://issues.redhat.com", - "SHARED": "from-provider", }}, }, } @@ -247,11 +261,11 @@ func TestBuildEnvMap(t *testing.T) { if env["TOP_VAR"] != "top-val" { t.Errorf("TOP_VAR = %q, want top-val", env["TOP_VAR"]) } - if env["JIRA_URL"] != "https://issues.redhat.com" { - t.Errorf("JIRA_URL = %q", env["JIRA_URL"]) + if env["ANOTHER"] != "another-val" { + t.Errorf("ANOTHER = %q", env["ANOTHER"]) } - if env["SHARED"] != "from-provider" { - t.Errorf("SHARED = %q, want from-provider (provider should override top-level)", env["SHARED"]) + if _, ok := env["JIRA_URL"]; ok { + t.Error("provider config should not be in BuildEnvMap — goes via --config on provider create") } } @@ -290,9 +304,7 @@ func TestBuildEnvMap_Empty(t *testing.T) { func TestBuildEnvSh_Sorted(t *testing.T) { cfg := &AgentConfig{ - Providers: []ProviderRef{ - {Profile: "test", Config: map[string]string{"Z_VAR": "z", "A_VAR": "a"}}, - }, + Env: map[string]string{"Z_VAR": "z", "A_VAR": "a"}, } env := cfg.BuildEnvSh() aIdx := strings.Index(env, "A_VAR") From 293970862d28729b096f3362994c78a003aa95eb Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 12 Jun 2026 09:16:42 -0700 Subject: [PATCH 10/12] refactor: rename providers[].config to providers[].env, inject via --env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider non-secret vars (JIRA_URL, JIRA_USERNAME) are sandbox env vars, not gateway-managed config. Rename the field from 'config' to 'env' to make this clear. Both top-level env: and providers[].env: merge into BuildEnvMap() and are injected via --env on sandbox create. Provider env overrides top-level env on collision. The gateway only manages secrets (credentials) — non-secret vars go directly to the container. --- README.md | 4 +-- SPEC.md | 4 +-- agents/builtin.yaml | 2 +- agents/default.yaml | 2 +- agents/ocp.yaml | 2 +- agents/opencode.yaml | 2 +- cmd/providers.go | 12 +++---- cmd/providers_test.go | 2 +- internal/agent/agent.go | 20 ++++------- internal/agent/agent_test.go | 70 ++++++++++++++++++------------------ 10 files changed, 58 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 3f06279..5d5b57d 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ providers: - profile: github - profile: vertex-local - profile: atlassian - config: + env: JIRA_URL: ${JIRA_URL} JIRA_USERNAME: ${JIRA_USERNAME} - profile: gws @@ -133,7 +133,7 @@ providers: - profile: github - profile: vertex-local - profile: atlassian - config: + env: JIRA_URL: ${JIRA_URL} JIRA_USERNAME: ${JIRA_USERNAME} - profile: gws diff --git a/SPEC.md b/SPEC.md index 4692b62..08d59db 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,7 +26,7 @@ providers: - profile: github - profile: vertex-local - profile: atlassian - config: + env: JIRA_URL: ${JIRA_URL} JIRA_USERNAME: ${JIRA_USERNAME} - profile: gws @@ -43,7 +43,7 @@ Fields: - `task` -- path to a task.md file, passed to entrypoint via `-p "$(cat task.md)"` - `providers` -- list of provider profile references - `providers[].profile` -- OpenShell provider profile name -- `providers[].config` -- non-secret config vars (resolved via `os.ExpandEnv`; empty values read from host env) +- `providers[].env` -- non-secret env vars for this provider (resolved via `os.ExpandEnv`; empty values read from host env; injected via `--env` on sandbox create) - `env` -- additional environment variables injected via `--env` on sandbox create (empty values read from host env) - `include` -- extra files to include in the payload - `policy` -- path to a network policy YAML diff --git a/agents/builtin.yaml b/agents/builtin.yaml index ddef4c1..ea4732c 100644 --- a/agents/builtin.yaml +++ b/agents/builtin.yaml @@ -10,7 +10,7 @@ providers: - profile: github - profile: vertex-local - profile: atlassian - config: + env: JIRA_URL: ${JIRA_URL} JIRA_USERNAME: ${JIRA_USERNAME} diff --git a/agents/default.yaml b/agents/default.yaml index ce46633..37cbd0e 100644 --- a/agents/default.yaml +++ b/agents/default.yaml @@ -12,7 +12,7 @@ providers: - profile: github - profile: vertex-local - profile: atlassian - config: + env: JIRA_URL: ${JIRA_URL} JIRA_USERNAME: ${JIRA_USERNAME} - profile: gws diff --git a/agents/ocp.yaml b/agents/ocp.yaml index 493716c..c379241 100644 --- a/agents/ocp.yaml +++ b/agents/ocp.yaml @@ -15,7 +15,7 @@ providers: - profile: github - profile: vertex-local - profile: atlassian - config: + env: JIRA_URL: ${JIRA_URL} JIRA_USERNAME: ${JIRA_USERNAME} - profile: gws diff --git a/agents/opencode.yaml b/agents/opencode.yaml index a03f443..f892369 100644 --- a/agents/opencode.yaml +++ b/agents/opencode.yaml @@ -12,7 +12,7 @@ providers: - profile: github - profile: vertex-local - profile: atlassian - config: + env: JIRA_URL: ${JIRA_URL} JIRA_USERNAME: ${JIRA_USERNAME} - profile: gws diff --git a/cmd/providers.go b/cmd/providers.go index 473caf5..e6465e0 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -49,24 +49,24 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, provid profilesDir := filepath.Join(harnessDir, "agents", "providers", "profiles") gw.ProviderProfileImport(profilesDir) - if p, ok := wanted["github"]; ok { - registerStandard("github", "github", gw, p.ConfigList()) + if _, ok := wanted["github"]; ok { + registerStandard("github", "github", gw, nil) } - if p, ok := wanted["vertex-local"]; ok { + if _, ok := wanted["vertex-local"]; ok { home, _ := os.UserHomeDir() adcPath := envOr("GOOGLE_APPLICATION_CREDENTIALS", filepath.Join(home, ".config", "gcloud", "application_default_credentials.json")) project := envOr("ANTHROPIC_VERTEX_PROJECT_ID", readADCProject(adcPath)) region := envOr("CLOUD_ML_REGION", "global") - configs := p.ConfigList() + var configs []string if project != "" { configs = append(configs, "VERTEX_AI_PROJECT_ID="+project) } configs = append(configs, "VERTEX_AI_REGION="+region) registerADC("vertex-local", "google-vertex-ai", model, gw, configs) } - if p, ok := wanted["atlassian"]; ok { - registerStandard("atlassian", "atlassian", gw, p.ConfigList()) + if _, ok := wanted["atlassian"]; ok { + registerStandard("atlassian", "atlassian", gw, nil) } if _, ok := wanted["gws"]; ok { if err := registerGWS(harnessDir, gw); err != nil { diff --git a/cmd/providers_test.go b/cmd/providers_test.go index 5ed8765..8506bb3 100644 --- a/cmd/providers_test.go +++ b/cmd/providers_test.go @@ -115,7 +115,7 @@ func TestRegisterProviders_PassesConfigToProvider(t *testing.T) { gw := &mockGW{providers: map[string]bool{}} err := registerProviders(dir, gw, false, []agent.ProviderRef{ - {Profile: "atlassian", Config: map[string]string{ + {Profile: "atlassian", Env: map[string]string{ "JIRA_URL": "${JIRA_URL}", "JIRA_USERNAME": "${JIRA_USERNAME}", }}, diff --git a/internal/agent/agent.go b/internal/agent/agent.go index e8b3c66..6f71f2b 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -12,7 +12,7 @@ import ( type ProviderRef struct { Profile string `yaml:"profile"` - Config map[string]string `yaml:"config,omitempty"` + Env map[string]string `yaml:"env,omitempty"` } type AgentConfig struct { @@ -89,20 +89,14 @@ func (c *AgentConfig) BuildEnvMap() map[string]string { env[k] = val } } - return env -} - -func (p *ProviderRef) ConfigList() []string { - if len(p.Config) == 0 { - return nil - } - configs := make([]string, 0, len(p.Config)) - for k, v := range p.Config { - if val := expandEnvVar(k, v); val != "" { - configs = append(configs, k+"="+val) + for _, p := range c.Providers { + for k, v := range p.Env { + if val := expandEnvVar(k, v); val != "" { + env[k] = val + } } } - return configs + return env } func (c *AgentConfig) BuildEnvSh() string { diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index c83b1b1..cc3fbe8 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -12,7 +12,7 @@ func TestParse_Valid(t *testing.T) { name: daily-standup providers: - profile: atlassian - config: + env: JIRA_USERNAME: alice@example.com JIRA_URL: https://issues.redhat.com - profile: github @@ -32,8 +32,8 @@ entrypoint: claude --bare if cfg.Providers[0].Profile != "atlassian" { t.Errorf("Providers[0].Profile = %q, want atlassian", cfg.Providers[0].Profile) } - if cfg.Providers[0].Config["JIRA_USERNAME"] != "alice@example.com" { - t.Errorf("JIRA_USERNAME = %q", cfg.Providers[0].Config["JIRA_USERNAME"]) + if cfg.Providers[0].Env["JIRA_USERNAME"] != "alice@example.com" { + t.Errorf("JIRA_USERNAME = %q", cfg.Providers[0].Env["JIRA_USERNAME"]) } if cfg.Task != "tasks/daily-standup.md" { t.Errorf("Task = %q", cfg.Task) @@ -55,7 +55,7 @@ func TestParse_MissingProviderProfile(t *testing.T) { data := []byte(` name: test providers: - - config: + - env: FOO: bar `) _, err := Parse(data) @@ -161,53 +161,55 @@ func TestBuildEnvSh_Empty(t *testing.T) { } } -func TestBuildEnvSh_ExcludesProviderConfig(t *testing.T) { +func TestBuildEnvSh_IncludesProviderEnv(t *testing.T) { cfg := &AgentConfig{ Env: map[string]string{ "ANTHROPIC_BASE_URL": "https://inference.local", }, Providers: []ProviderRef{ - {Profile: "atlassian", Config: map[string]string{"JIRA_URL": "https://jira.example.com"}}, + {Profile: "atlassian", Env: map[string]string{"JIRA_URL": "https://jira.example.com"}}, }, } env := cfg.BuildEnvSh() if !strings.Contains(env, `ANTHROPIC_BASE_URL`) { t.Errorf("missing top-level env var in:\n%s", env) } - if strings.Contains(env, "JIRA_URL") { - t.Errorf("provider config should not be in env.sh (goes via --config on provider create):\n%s", env) + if !strings.Contains(env, `JIRA_URL`) { + t.Errorf("missing provider env var in:\n%s", env) } } -func TestProviderRef_ConfigList(t *testing.T) { +func TestBuildEnvMap_IncludesProviderEnv(t *testing.T) { t.Setenv("JIRA_URL", "https://test.atlassian.net") - p := ProviderRef{ - Profile: "atlassian", - Config: map[string]string{ - "JIRA_URL": "${JIRA_URL}", - "JIRA_USERNAME": "alice", + cfg := &AgentConfig{ + Env: map[string]string{"TOP": "top-val"}, + Providers: []ProviderRef{ + {Profile: "atlassian", Env: map[string]string{ + "JIRA_URL": "${JIRA_URL}", + "JIRA_USERNAME": "alice", + }}, }, } - configs := p.ConfigList() - if len(configs) != 2 { - t.Fatalf("ConfigList() = %v, want 2 entries", configs) - } - found := map[string]bool{} - for _, c := range configs { - found[c] = true + env := cfg.BuildEnvMap() + if env["TOP"] != "top-val" { + t.Errorf("TOP = %q", env["TOP"]) } - if !found["JIRA_URL=https://test.atlassian.net"] { - t.Errorf("missing expanded JIRA_URL in %v", configs) + if env["JIRA_URL"] != "https://test.atlassian.net" { + t.Errorf("JIRA_URL = %q, want expanded value", env["JIRA_URL"]) } - if !found["JIRA_USERNAME=alice"] { - t.Errorf("missing JIRA_USERNAME in %v", configs) + if env["JIRA_USERNAME"] != "alice" { + t.Errorf("JIRA_USERNAME = %q", env["JIRA_USERNAME"]) } } -func TestProviderRef_ConfigList_Empty(t *testing.T) { - p := ProviderRef{Profile: "github"} - if configs := p.ConfigList(); configs != nil { - t.Errorf("ConfigList() = %v, want nil", configs) +func TestBuildEnvMap_ProviderEnvOverridesTopLevel(t *testing.T) { + cfg := &AgentConfig{ + Env: map[string]string{"SHARED": "from-top"}, + Providers: []ProviderRef{{Profile: "test", Env: map[string]string{"SHARED": "from-provider"}}}, + } + env := cfg.BuildEnvMap() + if env["SHARED"] != "from-provider" { + t.Errorf("SHARED = %q, want from-provider (provider env should override top-level)", env["SHARED"]) } } @@ -220,7 +222,7 @@ tty: true providers: - profile: github - profile: atlassian - config: + env: JIRA_URL: https://jira.example.com env: ANTHROPIC_BASE_URL: https://inference.local @@ -252,7 +254,7 @@ func TestBuildEnvMap(t *testing.T) { "ANOTHER": "another-val", }, Providers: []ProviderRef{ - {Profile: "atlassian", Config: map[string]string{ + {Profile: "atlassian", Env: map[string]string{ "JIRA_URL": "https://issues.redhat.com", }}, }, @@ -264,8 +266,8 @@ func TestBuildEnvMap(t *testing.T) { if env["ANOTHER"] != "another-val" { t.Errorf("ANOTHER = %q", env["ANOTHER"]) } - if _, ok := env["JIRA_URL"]; ok { - t.Error("provider config should not be in BuildEnvMap — goes via --config on provider create") + if env["JIRA_URL"] != "https://issues.redhat.com" { + t.Errorf("JIRA_URL = %q, want provider env included", env["JIRA_URL"]) } } @@ -355,7 +357,7 @@ func TestRenderPayload(t *testing.T) { cfg := &AgentConfig{ Name: "test-agent", Providers: []ProviderRef{ - {Profile: "atlassian", Config: map[string]string{"JIRA_URL": "https://jira.example.com"}}, + {Profile: "atlassian", Env: map[string]string{"JIRA_URL": "https://jira.example.com"}}, }, Task: "my-task.md", Entrypoint: "claude --bare", From 715fcca23d322f50ff307cdd13793349e654db5d Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 12 Jun 2026 10:35:10 -0700 Subject: [PATCH 11/12] fix: add gws to status.Cmd logging, sync builtin.yaml with default, update README commands - Add status.Cmd() call for 'gws auth export --unmasked' so it shows with --show-commands - Add gws provider to builtin.yaml (was missing vs default.yaml) - Update README 'What harness up replaces' with actual captured output from --show-commands (includes gws export, inference set order, env var alphabetical order) - Add --debug flag to test-flow.sh (passes --show-commands, shows output) --- README.md | 37 ++++++++++++++++++------------------- agents/builtin.yaml | 1 + cmd/providers.go | 1 + test/test-flow.sh | 34 ++++++++++++++++++++++++++-------- 4 files changed, 46 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 5d5b57d..d92ef68 100644 --- a/README.md +++ b/README.md @@ -65,53 +65,52 @@ env: ANTHROPIC_API_KEY: sk-ant-openshell-proxy-managed ``` -and replaces this sequence of 8+ `openshell` commands: +and replaces this sequence of `openshell` commands (captured from `harness up --show-commands`): ```bash -# 1. Enable the providers v2 system -openshell settings set --global --key providers_v2_enabled --value true +# 1. Enable providers v2 +openshell settings set --global --key providers_v2_enabled --value true --yes # 2. Import custom provider profiles (atlassian, gws) openshell provider profile import --from agents/providers/profiles/ -# 3. Register GitHub (reads GITHUB_TOKEN from environment) +# 3. Register GitHub (discovers GITHUB_TOKEN from environment) openshell provider create --name github --type github --from-existing -# 4. Register Vertex AI (reads ADC from gcloud login) +# 4. Register Vertex AI (reads ADC, configures inference routing) openshell provider create --name vertex-local --type google-vertex-ai \ --from-gcloud-adc \ --config VERTEX_AI_PROJECT_ID=my-project \ --config VERTEX_AI_REGION=global +openshell inference set --provider vertex-local --model claude-sonnet-4-6 --no-verify -# 5. Register Atlassian (reads JIRA_API_TOKEN from environment) +# 5. Register Atlassian (discovers JIRA_API_TOKEN from environment) openshell provider create --name atlassian --type atlassian --from-existing -# 6. Register GWS (multi-step: create, configure OAuth refresh, rotate) +# 6. Register GWS (export OAuth creds, create provider, configure refresh) +gws auth export --unmasked openshell provider create --name gws --type google-workspace \ --credential GOOGLE_WORKSPACE_CLI_TOKEN=pending openshell provider refresh configure gws \ --credential-key GOOGLE_WORKSPACE_CLI_TOKEN \ --strategy oauth2-refresh-token \ - --material client_id=... \ - --material client_secret=... \ - --material refresh_token=... \ - --secret-material-key client_secret \ - --secret-material-key refresh_token + --material client_id=... --material client_secret=... \ + --material refresh_token=... --material scopes=... \ + --secret-material-key client_secret --secret-material-key refresh_token openshell provider refresh rotate gws \ --credential-key GOOGLE_WORKSPACE_CLI_TOKEN -# 7. Configure inference routing -openshell inference set --provider vertex-local --model claude-sonnet-4-6 --no-verify - -# 8. Create the sandbox with providers and env vars +# 7. Create sandbox with all providers and env vars openshell sandbox create --name agent \ --from ghcr.io/robbycochran/harness-openshell:sandbox \ --provider github --provider vertex-local --provider atlassian --provider gws \ - --env ANTHROPIC_BASE_URL=https://inference.local \ --env ANTHROPIC_API_KEY=sk-ant-openshell-proxy-managed \ + --env ANTHROPIC_BASE_URL=https://inference.local \ + --env CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 \ + --env JIRA_URL=https://your-org.atlassian.net \ + --env JIRA_USERNAME=you@company.com \ --upload payload:/sandbox/.config --no-git-ignore \ - --tty \ - -- bash /sandbox/.config/openshell/run.sh + --tty -- bash /sandbox/.config/openshell/run.sh ``` The harness also handles: local gateway deployment, version checking (openshell >= v0.0.59), payload rendering, retry logic, and graceful skipping of providers whose credentials are not available. diff --git a/agents/builtin.yaml b/agents/builtin.yaml index ea4732c..6db5ff8 100644 --- a/agents/builtin.yaml +++ b/agents/builtin.yaml @@ -13,6 +13,7 @@ providers: env: JIRA_URL: ${JIRA_URL} JIRA_USERNAME: ${JIRA_USERNAME} + - profile: gws env: ANTHROPIC_BASE_URL: https://inference.local diff --git a/cmd/providers.go b/cmd/providers.go index e6465e0..307a56f 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -124,6 +124,7 @@ func registerGWS(harnessDir string, gw gateway.Gateway) error { return nil } + status.Cmd("gws", "auth", "export", "--unmasked") out, err := exec.Command(gwsPath, "auth", "export", "--unmasked").Output() if err != nil { status.Info("gws: not authenticated (run 'gws auth login')") diff --git a/test/test-flow.sh b/test/test-flow.sh index 6a67c76..c5e333d 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -26,6 +26,7 @@ fi TARGET="" REUSE_GATEWAY=false NO_PROVIDERS=false +DEBUG=false PROFILE="default" # Auto-detect CI mode @@ -39,6 +40,7 @@ for arg in "$@"; do --ci) NO_PROVIDERS=true; PROFILE="ci" ;; --reuse-gateway) REUSE_GATEWAY=true ;; --no-providers) NO_PROVIDERS=true ;; + --debug) DEBUG=true ;; --agent=*) PROFILE="${arg#--agent=}" ;; -*) echo "Unknown flag: $arg"; exit 1 ;; *) [[ -z "$TARGET" ]] && TARGET="$arg" ;; @@ -46,10 +48,14 @@ for arg in "$@"; do done if [[ -z "$TARGET" ]]; then - echo "Usage: $0 [--ci] [--reuse-gateway]" + echo "Usage: $0 [--ci] [--reuse-gateway] [--debug]" exit 1 fi +if $DEBUG; then + HARNESS="$HARNESS --show-commands" +fi + # ── Helpers ────────────────────────────────────────────────────────── strip_ansi() { @@ -63,14 +69,26 @@ TOTAL_START=$(date +%s) step() { local label="$1"; shift local start=$(date +%s) - if "$@" &>/dev/null; then - local elapsed=$(( $(date +%s) - start )) - printf " ✓ %-35s (%ds)\n" "$label" "$elapsed" - ((PASS++)) + if $DEBUG; then + if "$@"; then + local elapsed=$(( $(date +%s) - start )) + printf " ✓ %-35s (%ds)\n" "$label" "$elapsed" + ((PASS++)) + else + local elapsed=$(( $(date +%s) - start )) + printf " ✗ %-35s (%ds)\n" "$label" "$elapsed" + ((FAIL++)) + fi else - local elapsed=$(( $(date +%s) - start )) - printf " ✗ %-35s (%ds)\n" "$label" "$elapsed" - ((FAIL++)) + if "$@" &>/dev/null; then + local elapsed=$(( $(date +%s) - start )) + printf " ✓ %-35s (%ds)\n" "$label" "$elapsed" + ((PASS++)) + else + local elapsed=$(( $(date +%s) - start )) + printf " ✗ %-35s (%ds)\n" "$label" "$elapsed" + ((FAIL++)) + fi fi } From 14ba28a24518923fe6563556a5f34e21ee10ae70 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 12 Jun 2026 10:40:01 -0700 Subject: [PATCH 12/12] fix: fail fast on provider registration errors, slim down builtin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider registration now returns errors instead of silently skipping. If you declare a provider in your agent YAML, you need it — missing credentials are a configuration error, not a graceful degradation. builtin.yaml slimmed to vertex-local only (inference). Users who need github/atlassian/gws create agents/default.yaml with the full list. --- agents/builtin.yaml | 10 +++------- cmd/providers.go | 31 ++++++++++++++++++------------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/agents/builtin.yaml b/agents/builtin.yaml index 6db5ff8..d05702c 100644 --- a/agents/builtin.yaml +++ b/agents/builtin.yaml @@ -1,19 +1,15 @@ # 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. +# +# Minimal: just Vertex AI for inference. Add providers by creating +# an agents/default.yaml with the full provider list. name: agent entrypoint: claude tty: true providers: - - profile: github - profile: vertex-local - - profile: atlassian - env: - JIRA_URL: ${JIRA_URL} - JIRA_USERNAME: ${JIRA_USERNAME} - - profile: gws env: ANTHROPIC_BASE_URL: https://inference.local diff --git a/cmd/providers.go b/cmd/providers.go index 307a56f..4c6eefa 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -50,7 +50,9 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, provid gw.ProviderProfileImport(profilesDir) if _, ok := wanted["github"]; ok { - registerStandard("github", "github", gw, nil) + if err := registerStandard("github", "github", gw, nil); err != nil { + return err + } } if _, ok := wanted["vertex-local"]; ok { home, _ := os.UserHomeDir() @@ -63,10 +65,14 @@ 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) - registerADC("vertex-local", "google-vertex-ai", model, gw, configs) + if err := registerADC("vertex-local", "google-vertex-ai", model, gw, configs); err != nil { + return err + } } if _, ok := wanted["atlassian"]; ok { - registerStandard("atlassian", "atlassian", gw, nil) + if err := registerStandard("atlassian", "atlassian", gw, nil); err != nil { + return err + } } if _, ok := wanted["gws"]; ok { if err := registerGWS(harnessDir, gw); err != nil { @@ -77,39 +83,38 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, provid return nil } -func registerStandard(name, profileType string, gw gateway.Gateway, configs []string) { +func registerStandard(name, profileType string, gw gateway.Gateway, configs []string) error { if gw.ProviderGet(name) == nil { status.Infof("%s: exists", name) - return + return nil } if err := gw.ProviderCreate(name, profileType, gateway.ProviderCreateOpts{ FromExisting: true, Configs: configs, }); err != nil { - status.Infof("%s: skipped (%v)", name, err) - return + return fmt.Errorf("%s: registration failed: %w", name, err) } status.OKf("%s: registered", name) + return nil } -func registerADC(name, profileType, model string, gw gateway.Gateway, configs []string) { +func registerADC(name, profileType, model string, gw gateway.Gateway, configs []string) error { if gw.ProviderGet(name) == nil { status.Infof("%s: exists", name) - return + return nil } if err := gw.ProviderCreate(name, profileType, gateway.ProviderCreateOpts{ FromADC: true, Configs: configs, }); err != nil { - status.Infof("%s: skipped (%v)", name, err) - return + return fmt.Errorf("%s: registration failed: %w", name, err) } status.OKf("%s: registered", name) if err := gw.InferenceSet(name, model); err != nil { - status.Infof("inference: %v", err) - return + return fmt.Errorf("inference: %w", err) } status.OKf("inference: model %s", model) + return nil } func registerGWS(harnessDir string, gw gateway.Gateway) error {