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..d92ef68 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,16 +33,21 @@ 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. -## Agent Configs +## What `harness up` Replaces -An agent config declares the sandbox image, entrypoint, and which providers to attach: +One command: + +```bash +harness up +``` + +reads `agents/default.yaml`: ```yaml # agents/default.yaml name: agent -image: ghcr.io/robbycochran/harness-openshell:sandbox entrypoint: claude tty: true @@ -50,7 +55,7 @@ providers: - profile: github - profile: vertex-local - profile: atlassian - config: + env: JIRA_URL: ${JIRA_URL} JIRA_USERNAME: ${JIRA_USERNAME} - profile: gws @@ -60,43 +65,173 @@ env: ANTHROPIC_API_KEY: sk-ant-openshell-proxy-managed ``` +and replaces this sequence of `openshell` commands (captured from `harness up --show-commands`): + ```bash -harness up +# 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 (discovers GITHUB_TOKEN from environment) +openshell provider create --name github --type github --from-existing + +# 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 (discovers JIRA_API_TOKEN from environment) +openshell provider create --name atlassian --type atlassian --from-existing + +# 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=... --material scopes=... \ + --secret-material-key client_secret --secret-material-key refresh_token +openshell provider refresh rotate gws \ + --credential-key GOOGLE_WORKSPACE_CLI_TOKEN + +# 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_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 ``` -Credentials are proxy-managed. The sandbox holds placeholder tokens; real secrets are substituted by the gateway at the network boundary. +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. + +### 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 + env: + 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. + +For OCP, the harness first deploys the gateway via Helm, then runs the same provider registration and sandbox creation. The full sequence: + +```bash +# 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 --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 + +# 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 \ + --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`: ```yaml -# agents/demo.yaml -name: demo -entrypoint: claude -p -task: demo/DEMO-TASK.md +name: standup +entrypoint: claude +task: tasks/daily-standup.md tty: false # ... same providers and env ``` +When `task:` is set, the harness passes its content to the entrypoint via `-p`. + +### OpenCode + +[OpenCode](https://github.com/opencode-ai/opencode) is supported as an alternative entrypoint: + +```bash +harness up --agent opencode +``` + +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 +247,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 +269,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 +294,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 +311,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..08d59db 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,14 +19,14 @@ Agent configs live in `agents/*.yaml`. Each declares the sandbox image, entrypoi ```yaml name: agent -entrypoint: claude +entrypoint: claude # or: opencode tty: true providers: - profile: github - profile: vertex-local - profile: atlassian - config: + env: JIRA_URL: ${JIRA_URL} JIRA_USERNAME: ${JIRA_USERNAME} - profile: gws @@ -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[].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 @@ -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/agents/builtin.yaml b/agents/builtin.yaml index ddef4c1..d05702c 100644 --- a/agents/builtin.yaml +++ b/agents/builtin.yaml @@ -1,18 +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 - config: - JIRA_URL: ${JIRA_URL} - JIRA_USERNAME: ${JIRA_USERNAME} env: ANTHROPIC_BASE_URL: https://inference.local 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/demo.yaml b/agents/ocp.yaml similarity index 57% rename from agents/demo.yaml rename to agents/ocp.yaml index 2443d27..c379241 100644 --- a/agents/demo.yaml +++ b/agents/ocp.yaml @@ -1,18 +1,21 @@ -# Hackathon demo agent -- runs the cross-tool status snapshot task. +# OCP agent — deploys to an OpenShift cluster. # # Usage: -# harness up --local --agent demo +# 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: demo +name: agent +gateway: ocp entrypoint: claude -task: demo/DEMO-TASK.md -tty: false +tty: true 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/create.go b/cmd/create.go index 77381f4..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, nil, false); 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 35ae311..4c6eefa 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -8,46 +8,33 @@ 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 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 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") - // 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]*agent.ProviderRef, len(providers)) + for i := range providers { + wanted[providers[i].Profile] = &providers[i] } - // 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 _, p := range providers { + gw.ProviderDelete(p.Profile) } deleteCustomProfiles(harnessDir, gw) status.Info("Deleted existing providers") @@ -55,97 +42,82 @@ 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") { - registerStandard("github", "github", gw, nil) + if _, ok := wanted["github"]; ok { + if err := registerStandard("github", "github", gw, nil); err != nil { + return err + } } - if providerEnabled("vertex-local") { - var vertexConfigs []string + 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") + var configs []string 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) - } - if providerEnabled("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) + configs = append(configs, "VERTEX_AI_REGION="+region) + if err := registerADC("vertex-local", "google-vertex-ai", model, gw, configs); err != nil { + return err } - fmt.Println() - for _, n := range names { - status.OK(n) + } + if _, ok := wanted["atlassian"]; ok { + if err := registerStandard("atlassian", "atlassian", gw, nil); err != nil { + return err } - m := gw.InferenceModel() - if m != "" { - status.OKf("Inference: %s", m) + } + if _, ok := wanted["gws"]; ok { + if err := registerGWS(harnessDir, gw); err != nil { + return err } - status.Done("Done. Launch a sandbox with: harness up --local") } + 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, 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 @@ -157,6 +129,7 @@ func registerGWS(harnessDir string, gw gateway.Gateway, enabled func(string) boo 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/cmd/providers_test.go b/cmd/providers_test.go index ef1da2c..8506bb3 100644 --- a/cmd/providers_test.go +++ b/cmd/providers_test.go @@ -1,13 +1,12 @@ package cmd import ( - "fmt" "os" "path/filepath" "strings" "testing" - "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/robbycochran/harness-openshell/internal/agent" ) func setupProvidersTest(t *testing.T) string { @@ -20,13 +19,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{}, - } + gw := &mockGW{providers: map[string]bool{}} - err := registerProviders(dir, gw, false, nil, true) + err := registerProviders(dir, gw, false, []agent.ProviderRef{ + {Profile: "github"}, + }) if err != nil { t.Fatalf("registerProviders: %v", err) } @@ -35,13 +33,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{}, - } + gw := &mockGW{providers: map[string]bool{}} - err := registerProviders(dir, gw, false, nil, true) + err := registerProviders(dir, gw, false, []agent.ProviderRef{ + {Profile: "github"}, + }) if err != nil { t.Fatalf("registerProviders: %v", err) } @@ -50,13 +47,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}, - } + gw := &mockGW{providers: map[string]bool{"github": true}} - err := registerProviders(dir, gw, false, nil, true) + err := registerProviders(dir, gw, false, []agent.ProviderRef{ + {Profile: "github"}, + }) if err != nil { t.Fatalf("registerProviders: %v", err) } @@ -66,68 +62,76 @@ 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, nil, true) + err := registerProviders(dir, gw, true, []agent.ProviderRef{ + {Profile: "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{}, - } + gw := &mockGW{providers: map[string]bool{}} - err := registerProviders(dir, gw, true, nil, true) + err := registerProviders(dir, gw, true, []agent.ProviderRef{ + {Profile: "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"} + gw := &mockGW{providers: map[string]bool{}} - err := registerProviders(dir, gw, false, gwCfg, true) + err := registerProviders(dir, gw, false, []agent.ProviderRef{ + {Profile: "github"}, + }) if err != nil { t.Fatalf("registerProviders: %v", err) } } -func TestRegisterProviders_ListError(t *testing.T) { +func TestRegisterProviders_PassesConfigToProvider(t *testing.T) { dir := setupProvidersTest(t) - - gw := &mockGW{ - providers: map[string]bool{}, - providerErr: fmt.Errorf("gateway unreachable"), + 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{}} + + err := registerProviders(dir, gw, false, []agent.ProviderRef{ + {Profile: "atlassian", Env: map[string]string{ + "JIRA_URL": "${JIRA_URL}", + "JIRA_USERNAME": "${JIRA_USERNAME}", + }}, + }) + if err != nil { + t.Fatalf("registerProviders: %v", err) } +} - 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) +func TestRegisterProviders_EmptyList(t *testing.T) { + dir := setupProvidersTest(t) + gw := &mockGW{providers: map[string]bool{}} + + err := registerProviders(dir, gw, false, nil) + if err != nil { + t.Fatalf("registerProviders: %v", err) } } diff --git a/cmd/up.go b/cmd/up.go index 4b54469..ccba17f 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, @@ -167,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, opts.gwCfg, false); 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 40c1e14..6f71f2b 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -12,11 +12,12 @@ 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 { 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"` @@ -89,7 +90,7 @@ func (c *AgentConfig) BuildEnvMap() map[string]string { } } for _, p := range c.Providers { - for k, v := range p.Config { + for k, v := range p.Env { if val := expandEnvVar(k, v); val != "" { env[k] = val } diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 83e4054..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) @@ -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,55 @@ func TestBuildEnvSh_Empty(t *testing.T) { } } -func TestBuildEnvSh_TopLevelEnv(t *testing.T) { +func TestBuildEnvSh_IncludesProviderEnv(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"}}, + {Profile: "atlassian", Env: 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("missing provider env var in:\n%s", env) } } -func TestBuildEnvSh_ProviderOverridesTopLevel(t *testing.T) { +func TestBuildEnvMap_IncludesProviderEnv(t *testing.T) { + t.Setenv("JIRA_URL", "https://test.atlassian.net") cfg := &AgentConfig{ - Env: map[string]string{"FOO": "from-top"}, - Providers: []ProviderRef{{Profile: "test", Config: map[string]string{"FOO": "from-provider"}}}, + Env: map[string]string{"TOP": "top-val"}, + Providers: []ProviderRef{ + {Profile: "atlassian", Env: 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) + env := cfg.BuildEnvMap() + if env["TOP"] != "top-val" { + t.Errorf("TOP = %q", env["TOP"]) + } + if env["JIRA_URL"] != "https://test.atlassian.net" { + t.Errorf("JIRA_URL = %q, want expanded value", env["JIRA_URL"]) + } + if env["JIRA_USERNAME"] != "alice" { + t.Errorf("JIRA_USERNAME = %q", env["JIRA_USERNAME"]) + } +} + +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"]) } } @@ -205,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 @@ -234,12 +251,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{ + {Profile: "atlassian", Env: map[string]string{ "JIRA_URL": "https://issues.redhat.com", - "SHARED": "from-provider", }}, }, } @@ -247,11 +263,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 env["JIRA_URL"] != "https://issues.redhat.com" { + t.Errorf("JIRA_URL = %q, want provider env included", env["JIRA_URL"]) } } @@ -290,9 +306,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") @@ -343,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", 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` 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 }