From 73ed30a1f16e67da81dd02ce5ddc63be2805d8b0 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sat, 13 Jun 2026 07:09:00 -0700 Subject: [PATCH 01/10] X-Smart-Branch-Parent: main From 7a15aee296865fec98a1676d42319fbc5c0c8978 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Tue, 16 Jun 2026 15:36:48 -0700 Subject: [PATCH 02/10] refactor: consolidate profiles layout, add observability roadmap - Move agent configs from agents/ to profiles/agent-*.yaml (flat, prefixed) - Move sandbox/ to profiles/images/sandbox-default/ - Move agents/ci.yaml to test/ci-agent.yaml (test fixture) - Remove unused profiles/providers/atlassian-user.yaml - Agent resolution walks harnessDir to find agent-.yaml - Fix gwsProfileScopes reading from wrong path (code bug) - Fix SPEC.md: correct step order, TTY default, payload description, file paths, config table completeness - Add observability & tracing section to TODO.md (Langfuse, MLflow, SigNoz, OCSF, OTel investigation results) - Add multi-document YAML design (k8s-style kind: field) to TODO.md - Add docs/langfuse-setup.md for local Langfuse + Claude Code OTel --- Makefile | 8 +- README.md | 24 ++--- SPEC.md | 26 ++--- TODO.md | 94 ++++++++++++++++++- cmd/create.go | 2 +- cmd/providers.go | 2 +- cmd/resolve.go | 25 ++++- cmd/up.go | 2 +- docs/langfuse-setup.md | 66 +++++++++++++ main.go | 7 +- .../builtin.yaml => profiles/agent-basic.yaml | 0 .../agent-default.yaml | 4 +- agents/ocp.yaml => profiles/agent-ocp.yaml | 0 .../agent-opencode.yaml | 0 .../images/sandbox-default}/CLAUDE.md | 0 .../images/sandbox-default}/Dockerfile | 0 .../images/sandbox-default}/claude.json | 0 .../images/sandbox-default}/mcp.json | 0 .../images/sandbox-default}/opencode.json | 0 .../images/sandbox-default}/policy.yaml | 0 .../images/sandbox-default}/settings.json | 0 profiles/providers/atlassian-user.yaml | 39 -------- agents/ci.yaml => test/ci-agent.yaml | 0 test/test-flow.sh | 4 +- 24 files changed, 226 insertions(+), 77 deletions(-) create mode 100644 docs/langfuse-setup.md rename agents/builtin.yaml => profiles/agent-basic.yaml (100%) rename agents/default.yaml => profiles/agent-default.yaml (77%) rename agents/ocp.yaml => profiles/agent-ocp.yaml (100%) rename agents/opencode.yaml => profiles/agent-opencode.yaml (100%) rename {sandbox => profiles/images/sandbox-default}/CLAUDE.md (100%) rename {sandbox => profiles/images/sandbox-default}/Dockerfile (100%) rename {sandbox => profiles/images/sandbox-default}/claude.json (100%) rename {sandbox => profiles/images/sandbox-default}/mcp.json (100%) rename {sandbox => profiles/images/sandbox-default}/opencode.json (100%) rename {sandbox => profiles/images/sandbox-default}/policy.yaml (100%) rename {sandbox => profiles/images/sandbox-default}/settings.json (100%) delete mode 100644 profiles/providers/atlassian-user.yaml rename agents/ci.yaml => test/ci-agent.yaml (100%) diff --git a/Makefile b/Makefile index e8a53e3..2502b52 100644 --- a/Makefile +++ b/Makefile @@ -65,7 +65,7 @@ test-local: cli ## Builds sandbox image locally and pre-loads into kind (no registry push needed). ## Use KEEP=1 to keep the cluster after tests (for debugging). test-kind: cli - $(CONTAINER_CLI) build -t $(IMAGE) sandbox/ + $(CONTAINER_CLI) build -t $(IMAGE) profiles/images/sandbox-default/ @echo "" HARNESS_OS_IMAGE=$(IMAGE) CONTAINER_CLI=$(CONTAINER_CLI) ./test/kind-lifecycle.sh $(if $(KEEP),--keep) @@ -82,15 +82,15 @@ test-all: test test-local test-kind test-remote ## Build dev sandbox image locally (native arch only) dev-sandbox: - $(CONTAINER_CLI) build -t $(IMAGE) sandbox/ + $(CONTAINER_CLI) build -t $(IMAGE) profiles/images/sandbox-default/ @echo "Built: $(IMAGE)" ## Build and push dev sandbox image (multi-arch) dev-push: @$(CONTAINER_CLI) rmi --force $(IMAGE) 2>/dev/null || true @$(CONTAINER_CLI) manifest rm $(IMAGE) 2>/dev/null || true - $(CONTAINER_CLI) build --platform linux/amd64 --manifest $(IMAGE) sandbox/ - $(CONTAINER_CLI) build --platform linux/arm64 --manifest $(IMAGE) sandbox/ + $(CONTAINER_CLI) build --platform linux/amd64 --manifest $(IMAGE) profiles/images/sandbox-default/ + $(CONTAINER_CLI) build --platform linux/arm64 --manifest $(IMAGE) profiles/images/sandbox-default/ $(CONTAINER_CLI) manifest push $(IMAGE) @echo "Pushed: $(IMAGE) (multi-arch)" diff --git a/README.md b/README.md index 2750a1f..f7c43f9 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ export JIRA_USERNAME=you@company.com The built-in config registers three providers: GitHub, Jira, and Vertex AI. Providers with missing credentials are skipped with an info message -- you don't need all three to get started. The sandbox runs Claude Code with whatever providers are available. -To customize providers or add GWS, create an `agents/default.yaml` in your project directory -- it takes precedence over the builtin. See [Agent Configs](#agent-configs) below. +To customize providers or add GWS, create an `profiles/agent-default.yaml` in your project directory -- it takes precedence over the builtin. See [Agent Configs](#agent-configs) below. ## Where This Fits @@ -43,10 +43,10 @@ One command: harness up ``` -reads `agents/default.yaml`: +reads `profiles/agent-default.yaml`: ```yaml -# agents/default.yaml +# profiles/agent-default.yaml name: agent entrypoint: claude tty: true @@ -122,7 +122,7 @@ Credentials are proxy-managed -- the sandbox holds placeholder tokens; real secr The `gateway:` field tells the harness which deployment target to use: ```yaml -# agents/ocp.yaml +# profiles/agent-ocp.yaml name: agent gateway: ocp entrypoint: claude @@ -144,7 +144,7 @@ env: ``` ```bash -harness up -f agents/ocp.yaml # deploys to OCP (reads profiles/gateways/ocp.yaml) +harness up -f profiles/agent-ocp.yaml # deploys to OCP (reads profiles/gateways/ocp.yaml) harness up # defaults to local Podman ``` @@ -210,7 +210,7 @@ When `task:` is set, the harness passes its content to the entrypoint via `-p`. harness up --agent opencode ``` -Same providers and gateway -- just a different agent binary. See `agents/opencode.yaml`. +Same providers and gateway -- just a different agent binary. See `profiles/agent-opencode.yaml`. ## Local Setup @@ -248,7 +248,7 @@ 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. Provider profiles are declared in `profiles/providers/`. 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. +- **Sandbox** -- Container running the agent entrypoint (Claude Code or OpenCode), configured by `profiles/agent-*.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) @@ -267,12 +267,12 @@ See the [OpenShell docs](https://github.com/NVIDIA/OpenShell) for the full secur | File | Purpose | |------|---------| -| `agents/*.yaml` | Agent config: image, entrypoint, providers, env, optional task file | +| `profiles/agent-*.yaml` | Agent config: image, entrypoint, providers, env, optional task file | | `profiles/providers/` | OpenShell provider profiles (imported to gateway on registration) | | `profiles/gateways/*.yaml` | Gateway profiles: `local.yaml`, `kind.yaml`, `ocp.yaml` | -| `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 | +| `profiles/images/sandbox-default/Dockerfile` | Sandbox image: OpenShell base + MCP servers + CLI tools | +| `profiles/images/sandbox-default/policy.yaml` | Network egress rules applied to sandboxes | +| `profiles/images/sandbox-default/opencode.json` | MCP server config for OpenCode agent | ## Commands @@ -282,7 +282,7 @@ harness up [--gateway NAME] [--gateway-profile FILE] [--agent NAME] [--agent-pro Defaults to local gateway (use --gateway ocp for OCP). --gateway selects a gateway profile by name (local, kind, ocp). --gateway-profile loads a gateway profile from a file path. - --agent defaults to "default" (embedded or agents/default.yaml). + --agent defaults to "default" (embedded or profiles/agent-default.yaml). --agent-profile (-f) renders any agent YAML file directly. --no-tty disables TTY allocation. --provider-refresh deletes and recreates all providers. diff --git a/SPEC.md b/SPEC.md index 2667567..3905c2c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -15,7 +15,7 @@ Requires OpenShell v0.0.59+. ## Agent Config -Agent configs live in `agents/*.yaml`. Each declares the sandbox image, entrypoint, providers, and environment: +Agent configs live in `profiles/agent-.yaml`. Each declares the sandbox image, entrypoint, providers, and environment: ```yaml name: agent @@ -39,7 +39,7 @@ Fields: - `name` (required) -- sandbox name, used for `openshell sandbox connect` - `image` -- container image for the sandbox (default: version-matched from ghcr.io, override with `HARNESS_OS_IMAGE` env) - `entrypoint` -- command to run (default: `claude`). Supports `claude`, `opencode`, `bash`, or any binary on PATH. -- `tty` -- enable TTY (default: false) +- `tty` -- enable TTY (default: true) - `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 @@ -57,21 +57,21 @@ Provider profiles live in `profiles/providers/`. These are imported to the gatew Full flow: deploy gateway, register providers, render agent config, create sandbox. -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). `--gateway` selects a profile by name; `--gateway-profile` loads from a file path. -3. **Parse agent config** -- read `agents/.yaml` (default: `default`). `--agent-profile` (`-f`) overrides with a direct file path. +1. **Parse agent config** -- resolve `agent-.yaml` from harness directory (default: `default`). `--agent-profile` (`-f`) overrides with a direct file path. Falls back to embedded `agent-basic.yaml` when `agent-default.yaml` is not found on disk. +2. **Check version** -- warn if openshell CLI is below v0.0.59. +3. **Ensure gateway** -- deploy if needed (local: Podman, remote: Helm to K8s/OCP). `--gateway` selects a profile by name; `--gateway-profile` loads from a file path. Gateway target can come from agent config's `gateway` field. 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). +5. **Render payload** -- `run.sh` (entrypoint wrapper with PATH setup, entrypoint validation, `-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. `--provider-refresh` deletes and recreates all providers (replaces the old `harness providers --force`). ### `harness create [--agent NAME] [--agent-profile|-f FILE] [--name SANDBOX]` -Create a sandbox without deploying the gateway. Assumes gateway is running. Auto-registers missing providers. +Create a sandbox without deploying the gateway. Always non-interactive (no TTY). Assumes gateway is running. Auto-registers missing providers. ### `harness deploy [local|ocp|kind]` @@ -93,12 +93,16 @@ Tear down resources. At least one flag required. | File | Purpose | |------|---------| -| `agents/*.yaml` | Agent config: image, entrypoint, providers, env, task | +| `profiles/agent-*.yaml` | Agent config: image, entrypoint, providers, env, task | | `profiles/providers/` | OpenShell provider profile YAMLs | | `profiles/gateways/*.yaml` | Gateway profiles: deployment target config with inline Helm values | -| `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 | +| `profiles/images/sandbox-default/Dockerfile` | Sandbox image: OpenShell base + MCP servers + CLI tools | +| `profiles/images/sandbox-default/CLAUDE.md` | Claude Code project instructions for sandbox | +| `profiles/images/sandbox-default/claude.json` | Claude Code settings | +| `profiles/images/sandbox-default/mcp.json` | MCP server config for Claude agent | +| `profiles/images/sandbox-default/opencode.json` | MCP server config for OpenCode agent | +| `profiles/images/sandbox-default/policy.yaml` | Network egress rules applied to sandboxes | +| `profiles/images/sandbox-default/settings.json` | Claude Code settings overlay | ## Image Tags diff --git a/TODO.md b/TODO.md index 86369ac..81854af 100644 --- a/TODO.md +++ b/TODO.md @@ -37,6 +37,52 @@ ## Agent Config +### Self-contained agent YAML (multi-document, k8s-style) +- [ ] Support multi-document YAML (`---` separated) where all objects live in one file +- Agent still references everything by name (`profile: github`), but provider/gateway/policy + definitions can be co-located in the same file instead of separate files in profiles/ +- Parser reads all documents via `yaml.Decoder` loop, indexes by `kind`+`name`, resolves + references against local set first, falls back to profiles/ tree +- Composes naturally: split the file up and drop objects into `profiles/` to share across agents +- Example: + ```yaml + --- + kind: agent + name: my-agent + entrypoint: claude + gateway: local + providers: + - profile: github + - profile: vertex + env: + ANTHROPIC_BASE_URL: https://inference.local + --- + kind: provider + name: github + type: github + credentials: [GITHUB_TOKEN] + endpoints: + - { host: "api.github.com", port: 443 } + --- + kind: provider + name: vertex + type: google-vertex-ai + credentials: [GOOGLE_APPLICATION_CREDENTIALS] + --- + kind: gateway + name: local + type: local + insecure: true + --- + kind: policy + network_policies: + github: + endpoints: + - { host: "api.github.com", port: 443 } + ``` +- Goal: `harness up -f agent.yaml` with one file. Zero to working sandboxed agent. +- Existing single-document agent YAMLs (no `kind` field) continue to work unchanged + ### Future fields - [ ] `description` -- one line of human-readable context per agent config - [ ] `repo` -- git URL to clone into the sandbox at start @@ -57,9 +103,55 @@ - [x] Add LICENSE file (Apache 2.0) - [ ] `harness init` command for standalone binary distribution (no repo clone) +## Observability & Tracing + +Investigation (Jun 2026) validated two paths for capturing full agent session +data (prompts, responses, tool calls, token counts, cost): + +### What works today +- **OpenShell OCSF JSONL** (`ocsf_json_enabled` setting) captures network/process/policy + events inside the sandbox. Structured, OCSF v1.7.0 compliant. No conversation content. +- **Claude Code OTel export** sends traces (span structure), logs (full API request/response + bodies), and metrics (token counts, cost) via standard OTLP env vars. +- **Langfuse hooks plugin** (`langfuse-observability`) reads Claude Code transcript files + directly and creates Langfuse traces with full input/output. Best LLM-specific UI. + Setup: `docs/langfuse-setup.md`. +- **MLflow** accepts OTel traces at `/v1/traces` (not logs). Proven working with Claude Code + via `OTEL_EXPORTER_OTLP_ENDPOINT`. Good for span structure + token counts but not + conversation content (that lives in the OTel logs signal, which MLflow doesn't ingest). + +### Integration options (pick one or combine) +- [ ] **Langfuse (hooks)** -- full conversation content, best UI, self-hosted Docker. + No OTel plumbing needed. Plugin reads transcripts post-hoc. +- [ ] **Langfuse (OTel)** -- span structure via OTLP/HTTP at `/api/public/otel`. + Prompts land in metadata (not input field) because Claude Code uses `user_prompt` + attribute, not `gen_ai.prompt`. Response content not captured via this path. +- [ ] **MLflow (OTel)** -- traces only, no logs. Good for span structure + AI Gateway + (inference routing with budget/rate limiting). Self-hosted SQLite or Postgres. +- [ ] **SigNoz (OTel)** -- accepts traces + logs + metrics on same OTLP endpoint. + Self-hosted Docker, 4GB RAM. Only backend that ingests all three Claude Code signals. +- [ ] **OTel Collector fan-out** -- route traces to Langfuse/MLflow, logs to SigNoz/Loki, + metrics to SigNoz/Prometheus. Best-of-breed but more infrastructure. + +### Harness integration (future) +- [ ] `harness deploy` starts observability backend (Langfuse/MLflow/SigNoz) if not running +- [ ] `harness up` injects OTel env vars or configures hooks automatically +- [ ] `harness runs list/show` queries traces from the backend +- [ ] Headless mode (`harness run --task '...'`) records automatically + +### Upstream to watch +- OpenShell portable sandbox log collection (#1922) and centralized audit/event log (#1933) + are in early investigation. No concrete implementation yet -- this is where the harness + recorder fits. +- AgentGateway (Linux Foundation) proposed for embedding in OpenShell (#998, rejected by + NVIDIA). Could be deployed as a companion proxy for LLM traffic observability with + token-level detail. See design doc. + +### Design doc +`~/.gstack/projects/robbycochran-harness-openshell/rc-rc-nextnext-design-20260613-130837.md` + ## Deferred (post-0.1) -- [ ] Gateway-level LLM proxy/logging (gateway.yaml proxy section) - [ ] Multi-agent workflow support (fleet.yaml / workflow.yaml) - [ ] `harness policy suggest` (DenialEvent stream -> policy proposals) - [ ] Fleet management (multi-gateway kubectl-context style) diff --git a/cmd/create.go b/cmd/create.go index ea94bd9..0a94021 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -87,7 +87,7 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { }, } - cmd.Flags().StringVar(&agentName, "agent", "default", "Agent config name (from agents/)") + cmd.Flags().StringVar(&agentName, "agent", "default", "Agent config name (from profiles/agent-.yaml)") cmd.Flags().StringVarP(&agentProfile, "agent-profile", "f", "", "Path to agent YAML file (overrides --agent)") cmd.Flags().StringVar(&sandboxName, "name", "", "Sandbox name (overrides agent config)") diff --git a/cmd/providers.go b/cmd/providers.go index 559a958..a026ea5 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -216,7 +216,7 @@ func registerGWS(harnessDir string, gw gateway.Gateway) error { // gwsProfileScopes reads the refresh.scopes list from profiles/providers/gws.yaml // and returns them as a space-separated string for use as OAuth scope material. func gwsProfileScopes(harnessDir string) string { - profilePath := filepath.Join(harnessDir, "agents", "providers", "profiles", "gws.yaml") + profilePath := filepath.Join(harnessDir, "profiles", "providers", "gws.yaml") data, err := os.ReadFile(profilePath) if err != nil { return "" diff --git a/cmd/resolve.go b/cmd/resolve.go index 4ed7a60..5743272 100644 --- a/cmd/resolve.go +++ b/cmd/resolve.go @@ -32,7 +32,30 @@ func resolveAgentPath(harnessDir, agentName, agentFile string) string { if agentFile != "" { return agentFile } - return filepath.Join(harnessDir, "agents", agentName+".yaml") + filename := "agent-" + agentName + ".yaml" + match, _ := findFile(harnessDir, filename) + if match != "" { + return match + } + return filepath.Join(harnessDir, filename) +} + +func findFile(root, name string) (string, error) { + var match string + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.Name() == ".git" || d.Name() == "node_modules" { + return filepath.SkipDir + } + if d.Name() == name { + match = path + return filepath.SkipAll + } + return nil + }) + return match, err } func resolveAgentConfig(harnessDir, agentName, agentFile string) (*agent.AgentConfig, error) { diff --git a/cmd/up.go b/cmd/up.go index ca72023..12ac449 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -84,7 +84,7 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { cmd.Flags().StringVar(&gatewayName, "gateway", "", "Gateway profile name (local, kind, ocp)") cmd.Flags().StringVar(&gatewayProfile, "gateway-profile", "", "Path to gateway profile YAML (overrides --gateway)") - cmd.Flags().StringVar(&agentName, "agent", "default", "Agent config name (from agents/)") + cmd.Flags().StringVar(&agentName, "agent", "default", "Agent config name (from profiles/agent-.yaml)") cmd.Flags().StringVarP(&agentProfile, "agent-profile", "f", "", "Path to agent YAML file (overrides --agent)") cmd.Flags().StringVar(&sandboxName, "name", "", "Sandbox name (overrides agent config)") cmd.Flags().BoolVar(&noTTY, "no-tty", false, "Non-interactive mode (for testing)") diff --git a/docs/langfuse-setup.md b/docs/langfuse-setup.md new file mode 100644 index 0000000..c905386 --- /dev/null +++ b/docs/langfuse-setup.md @@ -0,0 +1,66 @@ +# Langfuse Local Setup + +## Prerequisites + +- Langfuse running locally via `podman-compose up -d` from `/tmp/langfuse-spike/` +- API keys from Langfuse UI (Settings > API Keys) + +## Environment Variables + +```bash +export LANGFUSE_PUBLIC_KEY="pk-lf-5c833482-38cc-4ba9-badb-bcbd56e9be2e" +export LANGFUSE_SECRET_KEY="sk-lf-bb5f27ad-c72d-4def-8275-98e4ffecd9d9" +export LANGFUSE_BASE_URL="http://localhost:3000" + +export CLAUDE_CODE_ENABLE_TELEMETRY=1 +export CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1 +export OTEL_TRACES_EXPORTER=otlp +export OTEL_LOGS_EXPORTER=otlp +export OTEL_METRICS_EXPORTER=none +export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:3000/api/public/otel +export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic cGstbGYtNWM4MzM0ODItMzhjYy00YmE5LWJhZGItYmNiZDU2ZTliZTJlOnNrLWxmLWJiNWYyN2FkLWM3MmQtNGRlZi04Mjc1LTk4ZTRmZmVjZDlkOQ==" +export OTEL_LOG_USER_PROMPTS=1 +export OTEL_LOG_TOOL_DETAILS=1 +export OTEL_LOG_TOOL_CONTENT=1 +export OTEL_LOG_RAW_API_BODIES=1 +``` + +## What each variable does + +| Variable | Purpose | +|---|---| +| `CLAUDE_CODE_ENABLE_TELEMETRY` | Master switch for Claude Code telemetry | +| `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA` | Required for trace export (not just metrics) | +| `OTEL_TRACES_EXPORTER=otlp` | Send span structure (interaction -> LLM request -> tool calls) | +| `OTEL_LOGS_EXPORTER=otlp` | Send full API request/response bodies (conversation content) | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Langfuse OTel endpoint | +| `OTEL_EXPORTER_OTLP_HEADERS` | Basic auth (base64 of `public_key:secret_key`) | +| `OTEL_LOG_USER_PROMPTS` | Include prompt text in traces | +| `OTEL_LOG_TOOL_DETAILS` | Include tool call arguments | +| `OTEL_LOG_TOOL_CONTENT` | Include tool output | +| `OTEL_LOG_RAW_API_BODIES` | Include full API request/response JSON | + +## Regenerating the auth header + +```bash +echo -n "${LANGFUSE_PUBLIC_KEY}:${LANGFUSE_SECRET_KEY}" | base64 +``` + +## Starting Langfuse + +```bash +cd /tmp/langfuse-spike +podman-compose up -d +``` + +UI at http://localhost:3000 + +## Stopping Langfuse + +```bash +cd /tmp/langfuse-spike +podman-compose down +``` + +Data persists in podman volumes across restarts. diff --git a/main.go b/main.go index 72bd548..f65c5d4 100644 --- a/main.go +++ b/main.go @@ -13,7 +13,7 @@ import ( var version = "dev" -//go:embed agents/builtin.yaml +//go:embed profiles/agent-basic.yaml var defaultAgentConfig []byte //go:embed profiles/gateways/local.yaml @@ -90,7 +90,10 @@ func detectHarnessDir() string { for _, root := range roots { dir := root for range 5 { - if _, err := os.Stat(filepath.Join(dir, "agents", "default.yaml")); err == nil { + if _, err := os.Stat(filepath.Join(dir, "agent-default.yaml")); err == nil { + return dir + } + if _, err := os.Stat(filepath.Join(dir, "profiles", "agent-default.yaml")); err == nil { return dir } dir = filepath.Dir(dir) diff --git a/agents/builtin.yaml b/profiles/agent-basic.yaml similarity index 100% rename from agents/builtin.yaml rename to profiles/agent-basic.yaml diff --git a/agents/default.yaml b/profiles/agent-default.yaml similarity index 77% rename from agents/default.yaml rename to profiles/agent-default.yaml index 37cbd0e..0b88941 100644 --- a/agents/default.yaml +++ b/profiles/agent-default.yaml @@ -1,8 +1,8 @@ # Default agent configuration. # # Usage: -# harness up # uses agents/default.yaml -# harness up --agent research # uses agents/research.yaml +# harness up # uses profiles/agent-default.yaml +# harness up --agent research # uses profiles/agent-research.yaml name: agent entrypoint: claude diff --git a/agents/ocp.yaml b/profiles/agent-ocp.yaml similarity index 100% rename from agents/ocp.yaml rename to profiles/agent-ocp.yaml diff --git a/agents/opencode.yaml b/profiles/agent-opencode.yaml similarity index 100% rename from agents/opencode.yaml rename to profiles/agent-opencode.yaml diff --git a/sandbox/CLAUDE.md b/profiles/images/sandbox-default/CLAUDE.md similarity index 100% rename from sandbox/CLAUDE.md rename to profiles/images/sandbox-default/CLAUDE.md diff --git a/sandbox/Dockerfile b/profiles/images/sandbox-default/Dockerfile similarity index 100% rename from sandbox/Dockerfile rename to profiles/images/sandbox-default/Dockerfile diff --git a/sandbox/claude.json b/profiles/images/sandbox-default/claude.json similarity index 100% rename from sandbox/claude.json rename to profiles/images/sandbox-default/claude.json diff --git a/sandbox/mcp.json b/profiles/images/sandbox-default/mcp.json similarity index 100% rename from sandbox/mcp.json rename to profiles/images/sandbox-default/mcp.json diff --git a/sandbox/opencode.json b/profiles/images/sandbox-default/opencode.json similarity index 100% rename from sandbox/opencode.json rename to profiles/images/sandbox-default/opencode.json diff --git a/sandbox/policy.yaml b/profiles/images/sandbox-default/policy.yaml similarity index 100% rename from sandbox/policy.yaml rename to profiles/images/sandbox-default/policy.yaml diff --git a/sandbox/settings.json b/profiles/images/sandbox-default/settings.json similarity index 100% rename from sandbox/settings.json rename to profiles/images/sandbox-default/settings.json diff --git a/profiles/providers/atlassian-user.yaml b/profiles/providers/atlassian-user.yaml deleted file mode 100644 index ca6e2ba..0000000 --- a/profiles/providers/atlassian-user.yaml +++ /dev/null @@ -1,39 +0,0 @@ -# Atlassian provider profile with user-scoped config. -# -# JIRA_API_TOKEN is a credential (proxy-resolved, never visible in sandbox). -# JIRA_URL and JIRA_USERNAME are configs (non-secret, injected as env vars). - -id: atlassian-user -display_name: Atlassian (Jira + Confluence) -description: Jira and Confluence via mcp-atlassian MCP server -category: knowledge -credentials: - - name: api_token - description: Atlassian API token - env_vars: [JIRA_API_TOKEN] - required: true - auth_style: basic -configs: - - name: jira_url - description: Jira instance URL (e.g. https://mysite.atlassian.net) - env_vars: [JIRA_URL] - required: true - - name: jira_username - description: Jira username (email address) - env_vars: [JIRA_USERNAME] - required: true -discovery: - credentials: [api_token] - configs: [jira_url, jira_username] -endpoints: - - host: "*.atlassian.net" - port: 443 - - host: "*.atl-paas.net" - port: 443 - - host: "*.atlassian.com" - port: 443 -binaries: - - /sandbox/.venv/bin/python - - /sandbox/.venv/bin/python3 - - /sandbox/.uv/python/** - - /sandbox/.venv/bin/mcp-atlassian diff --git a/agents/ci.yaml b/test/ci-agent.yaml similarity index 100% rename from agents/ci.yaml rename to test/ci-agent.yaml diff --git a/test/test-flow.sh b/test/test-flow.sh index 93a0618..cfe850f 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -208,7 +208,7 @@ test_local() { step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" local create_name="test-create" - step "sandbox create (create)" harness create --name "$create_name" --agent ci + step "sandbox create (create)" harness create --name "$create_name" --agent-profile test/ci-agent.yaml step "sandbox verify (create)" "$CLI" sandbox exec --name "$create_name" -- echo "hello" step "sandbox delete (create)" "$CLI" sandbox delete "$create_name" @@ -300,7 +300,7 @@ test_ocp() { local sandbox_name if $NO_PROVIDERS; then sandbox_name="test-ocp" - step "sandbox create" harness create --agent=ci --name "$sandbox_name" + step "sandbox create" harness create --agent-profile=test/ci-agent.yaml --name "$sandbox_name" else sandbox_name="agent" step "sandbox create (up)" harness up --gateway ocp --name "$sandbox_name" --no-tty From 35a442ffd94a14ffe4d44f1d14462aa88b8a7e4b Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Tue, 16 Jun 2026 15:52:29 -0700 Subject: [PATCH 03/10] feat: multi-document harness YAML and render command Add Harness type that supports k8s-style multi-document YAML with kind field dispatch (agent, provider, gateway, policy). Single-document agent YAMLs continue to work unchanged (backwards compatible). - Harness struct holds agent config + co-located provider/gateway/policy definitions as raw YAML blobs - ParseHarness/ParseHarnessFile parse multi-doc YAML via yaml.Decoder loop - RenderHarness outputs complete harness YAML with built-in vs custom provider distinction - harness render command outputs full harness YAML to stdout or file --include-defaults adds the effective gateway even when not set in config - Resolution chain: harness-local definitions take priority over profiles/ - Provider profiles from harness YAML imported to temp dir before profiles/ Example: harness up -f harness.yaml with a single file containing agent + provider + gateway + policy definitions. --- cmd/create.go | 5 +- cmd/providers.go | 16 +++- cmd/render.go | 103 ++++++++++++++++++++++ cmd/resolve.go | 25 +++++- cmd/up.go | 9 +- internal/agent/agent.go | 135 ++++++++++++++++++++++++++++ internal/agent/agent_test.go | 166 +++++++++++++++++++++++++++++++++++ main.go | 1 + 8 files changed, 450 insertions(+), 10 deletions(-) create mode 100644 cmd/render.go diff --git a/cmd/create.go b/cmd/create.go index 0a94021..4f6ef23 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -27,10 +27,11 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { sandboxName = args[0] } - agentCfg, err := resolveAgentConfig(harnessDir, agentName, agentProfile) + harness, err := resolveHarness(harnessDir, agentName, agentProfile) if err != nil { return err } + agentCfg := harness.Agent gw := gateway.New(cli) @@ -54,7 +55,7 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { status.Infof("Image: %s", sandboxImage) // 3. Ensure providers are registered - registered := ensureProviders(harnessDir, gw, agentCfg, false) + registered := ensureProviders(harnessDir, gw, agentCfg, false, harness) // 4. Deploy the sandbox status.Header("Sandbox") diff --git a/cmd/providers.go b/cmd/providers.go index a026ea5..493d693 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -85,11 +85,25 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, provid return nil } -func ensureProviders(harnessDir string, gw gateway.Gateway, agentCfg *agent.AgentConfig, forceRefresh bool) []string { +func ensureProviders(harnessDir string, gw gateway.Gateway, agentCfg *agent.AgentConfig, forceRefresh bool, h *agent.Harness) []string { providerNames := agentCfg.ProviderNames() if len(providerNames) == 0 { return nil } + // Import harness-local provider profiles before checking registration + if h != nil && len(h.Providers) > 0 { + tmpDir, err := os.MkdirTemp("", "harness-providers-") + if err == nil { + defer os.RemoveAll(tmpDir) + for name, data := range h.Providers { + os.WriteFile(filepath.Join(tmpDir, name+".yaml"), data, 0o644) + } + if err := gw.ProviderProfileImport(tmpDir); err != nil { + status.Warnf("harness provider import: %v", err) + } + } + } + registered, missing := gateway.ValidateProviders(providerNames, gw) if len(missing) > 0 || forceRefresh { if err := registerProviders(harnessDir, gw, forceRefresh, agentCfg.Providers); err != nil { diff --git a/cmd/render.go b/cmd/render.go new file mode 100644 index 0000000..da37f9e --- /dev/null +++ b/cmd/render.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/robbycochran/harness-openshell/internal/agent" + "github.com/robbycochran/harness-openshell/internal/status" + "github.com/spf13/cobra" +) + +func NewRenderCmd(harnessDir, cli string) *cobra.Command { + var ( + agentName string + agentProfile string + outputFile string + includeDefaults bool + ) + + cmd := &cobra.Command{ + Use: "render [flags]", + Short: "Render a complete harness YAML from an agent config", + Long: "Reads an agent config and its referenced providers/gateways, then outputs a single multi-document YAML with all definitions included. Built-in OpenShell provider profiles are labeled separately from custom ones.", + RunE: func(cmd *cobra.Command, args []string) error { + h, err := resolveHarness(harnessDir, agentName, agentProfile) + if err != nil { + return err + } + + // Collect built-in provider profiles from the profiles/providers/ directory + builtinProviders := loadProviderProfiles(harnessDir) + + // Collect gateway profiles referenced by the agent + gwName := h.Agent.Gateway + if gwName == "" && includeDefaults { + gwName = "local" + } + if gwName != "" && len(h.Gateways) == 0 { + gwData := loadGatewayProfile(harnessDir, gwName) + if gwData != nil { + h.Gateways[gwName] = gwData + } + } + + out, err := agent.RenderHarness(h, builtinProviders) + if err != nil { + return fmt.Errorf("rendering harness: %w", err) + } + + if outputFile != "" { + if err := os.WriteFile(outputFile, out, 0o644); err != nil { + return fmt.Errorf("writing output: %w", err) + } + status.OKf("Rendered to %s", outputFile) + } else { + fmt.Print(string(out)) + } + return nil + }, + } + + cmd.Flags().StringVar(&agentName, "agent", "default", "Agent config name") + cmd.Flags().StringVarP(&agentProfile, "agent-profile", "f", "", "Path to agent YAML file") + cmd.Flags().StringVarP(&outputFile, "output", "o", "", "Write to file instead of stdout") + cmd.Flags().BoolVar(&includeDefaults, "include-defaults", false, "Include default gateway even if not set in agent config") + + return cmd +} + +func loadProviderProfiles(harnessDir string) map[string][]byte { + profiles := make(map[string][]byte) + dir := filepath.Join(harnessDir, "profiles", "providers") + entries, err := os.ReadDir(dir) + if err != nil { + return profiles + } + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" { + continue + } + name := e.Name()[:len(e.Name())-5] // strip .yaml + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err == nil { + profiles[name] = data + } + } + return profiles +} + +func loadGatewayProfile(harnessDir, name string) []byte { + // Try profiles/gateways/.yaml + path := filepath.Join(harnessDir, "profiles", "gateways", name+".yaml") + data, err := os.ReadFile(path) + if err == nil { + return data + } + // Try embedded + if d, ok := EmbeddedGatewayProfiles[name]; ok { + return d + } + return nil +} diff --git a/cmd/resolve.go b/cmd/resolve.go index 5743272..9c040e5 100644 --- a/cmd/resolve.go +++ b/cmd/resolve.go @@ -58,11 +58,11 @@ func findFile(root, name string) (string, error) { return match, err } -func resolveAgentConfig(harnessDir, agentName, agentFile string) (*agent.AgentConfig, error) { +func resolveHarness(harnessDir, agentName, agentFile string) (*agent.Harness, error) { path := resolveAgentPath(harnessDir, agentName, agentFile) - cfg, err := agent.ParseFile(path) + h, err := agent.ParseHarnessFile(path) if err == nil { - return cfg, nil + return h, nil } if agentFile != "" || agentName != "default" || len(DefaultAgentConfig) == 0 { return nil, err @@ -70,7 +70,24 @@ func resolveAgentConfig(harnessDir, agentName, agentFile string) (*agent.AgentCo if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { return nil, err } - return agent.Parse(DefaultAgentConfig) + return agent.ParseHarness(DefaultAgentConfig) +} + +func resolveAgentConfig(harnessDir, agentName, agentFile string) (*agent.AgentConfig, error) { + h, err := resolveHarness(harnessDir, agentName, agentFile) + if err != nil { + return nil, err + } + return h.Agent, nil +} + +func resolveGatewayConfigWithHarness(harnessDir, name string, h *agent.Harness) (*gateway.GatewayConfig, error) { + if h != nil { + if data, ok := h.Gateways[name]; ok { + return gateway.LoadConfigFromBytes(data) + } + } + return resolveGatewayConfig(harnessDir, name) } func versionedImage(name string) string { diff --git a/cmd/up.go b/cmd/up.go index 12ac449..bc88d83 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -36,10 +36,11 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { sandboxName = args[0] } - agentCfg, err := resolveAgentConfig(harnessDir, agentName, agentProfile) + harness, err := resolveHarness(harnessDir, agentName, agentProfile) if err != nil { return err } + agentCfg := harness.Agent agentPath := resolveAgentPath(harnessDir, agentName, agentProfile) gw := gateway.New(cli) @@ -63,7 +64,7 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { gwTarget = "local" } } - gwCfg, _ = resolveGatewayConfig(harnessDir, gwTarget) + gwCfg, _ = resolveGatewayConfigWithHarness(harnessDir, gwTarget, harness) } isRemote := gwTarget != "local" @@ -77,6 +78,7 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { sandboxName: sandboxName, noTTY: noTTY, providerRefresh: providerRefresh, + harness: harness, retrySleep: 5 * time.Second, }) }, @@ -103,6 +105,7 @@ type upLocalOpts struct { sandboxName string noTTY bool providerRefresh bool + harness *agent.Harness retrySleep time.Duration } @@ -150,7 +153,7 @@ func upLocal(opts upLocalOpts) error { } // 3. Ensure providers needed by the agent are registered - registered := ensureProviders(opts.harnessDir, gw, agentCfg, opts.providerRefresh) + registered := ensureProviders(opts.harnessDir, gw, agentCfg, opts.providerRefresh, opts.harness) // 4. Render payload payloadDir, err := os.MkdirTemp("", "harness-payload-") diff --git a/internal/agent/agent.go b/internal/agent/agent.go index c446829..af40758 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -1,7 +1,9 @@ package agent import ( + "bytes" "fmt" + "io" "os" "path/filepath" "strings" @@ -73,6 +75,139 @@ func Parse(data []byte) (*AgentConfig, error) { return &cfg, nil } +// Harness holds all documents parsed from a multi-document YAML file. +// A single-document agent YAML (no kind field) produces a Harness with +// just the Agent field populated. +type Harness struct { + Agent *AgentConfig + Gateways map[string][]byte // name -> raw gateway YAML + Providers map[string][]byte // name -> raw provider profile YAML + Policy []byte // raw policy YAML +} + +// kindHeader peeks at the kind and name fields of a YAML document. +type kindHeader struct { + Kind string `yaml:"kind"` + Name string `yaml:"name"` +} + +func ParseHarnessFile(path string) (*Harness, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading harness file: %w", err) + } + return ParseHarness(data) +} + +func ParseHarness(data []byte) (*Harness, error) { + h := &Harness{ + Gateways: make(map[string][]byte), + Providers: make(map[string][]byte), + } + + dec := yaml.NewDecoder(bytes.NewReader(data)) + docIndex := 0 + for { + var node yaml.Node + err := dec.Decode(&node) + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("parsing document %d: %w", docIndex, err) + } + + raw, err := yaml.Marshal(&node) + if err != nil { + return nil, fmt.Errorf("re-marshaling document %d: %w", docIndex, err) + } + + var header kindHeader + if err := yaml.Unmarshal(raw, &header); err != nil { + return nil, fmt.Errorf("reading kind from document %d: %w", docIndex, err) + } + + switch header.Kind { + case "", "agent": + if h.Agent != nil { + return nil, fmt.Errorf("multiple agent documents found") + } + cfg, err := Parse(raw) + if err != nil { + return nil, err + } + h.Agent = cfg + + case "provider": + if header.Name == "" { + return nil, fmt.Errorf("document %d: kind: provider requires a name field", docIndex) + } + h.Providers[header.Name] = raw + + case "gateway": + if header.Name == "" { + return nil, fmt.Errorf("document %d: kind: gateway requires a name field", docIndex) + } + h.Gateways[header.Name] = raw + + case "policy": + if h.Policy != nil { + return nil, fmt.Errorf("multiple policy documents found") + } + h.Policy = raw + + default: + return nil, fmt.Errorf("document %d: unknown kind %q", docIndex, header.Kind) + } + docIndex++ + } + + if h.Agent == nil { + return nil, fmt.Errorf("no agent document found") + } + return h, nil +} + +// RenderHarness writes a complete multi-document YAML from a Harness. +// builtinProviders are labeled with a comment; custom providers are included as-is. +func RenderHarness(h *Harness, builtinProviders map[string][]byte) ([]byte, error) { + var buf bytes.Buffer + + agentData, err := yaml.Marshal(h.Agent) + if err != nil { + return nil, fmt.Errorf("marshaling agent: %w", err) + } + buf.WriteString("---\nkind: agent\n") + buf.Write(agentData) + + for name, data := range h.Gateways { + buf.WriteString("---\nkind: gateway\nname: " + name + "\n") + buf.Write(data) + } + + // Built-in providers (from OpenShell profiles) + for name, data := range builtinProviders { + if _, custom := h.Providers[name]; custom { + continue // custom override takes precedence + } + buf.WriteString("---\n# built-in (from OpenShell provider profiles)\nkind: provider\nname: " + name + "\n") + buf.Write(data) + } + + // Custom providers (from harness config) + for name, data := range h.Providers { + buf.WriteString("---\n# custom\nkind: provider\nname: " + name + "\n") + buf.Write(data) + } + + if h.Policy != nil { + buf.WriteString("---\nkind: policy\n") + buf.Write(h.Policy) + } + + return buf.Bytes(), nil +} + func expandEnvVar(key, value string) string { expanded := os.ExpandEnv(value) if expanded == "" { diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 0c068db..0fc5fb3 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -382,6 +382,172 @@ func TestRenderPayload_Include(t *testing.T) { } } +func TestParseHarness_SingleDoc(t *testing.T) { + data := []byte(` +name: test +entrypoint: claude +providers: + - profile: github +`) + h, err := ParseHarness(data) + if err != nil { + t.Fatalf("ParseHarness: %v", err) + } + if h.Agent.Name != "test" { + t.Errorf("Agent.Name = %q", h.Agent.Name) + } + if len(h.Gateways) != 0 { + t.Errorf("Gateways = %d, want 0", len(h.Gateways)) + } + if len(h.Providers) != 0 { + t.Errorf("Providers = %d, want 0", len(h.Providers)) + } +} + +func TestParseHarness_MultiDoc(t *testing.T) { + data := []byte(`--- +kind: agent +name: my-agent +entrypoint: claude +providers: + - profile: github +--- +kind: provider +name: github +type: github +credentials: [GITHUB_TOKEN] +--- +kind: gateway +name: local +type: local +--- +kind: policy +network_policies: + github: + endpoints: + - host: api.github.com + port: 443 +`) + h, err := ParseHarness(data) + if err != nil { + t.Fatalf("ParseHarness: %v", err) + } + if h.Agent.Name != "my-agent" { + t.Errorf("Agent.Name = %q", h.Agent.Name) + } + if _, ok := h.Providers["github"]; !ok { + t.Error("missing provider 'github'") + } + if _, ok := h.Gateways["local"]; !ok { + t.Error("missing gateway 'local'") + } + if h.Policy == nil { + t.Error("missing policy") + } +} + +func TestParseHarness_DuplicateAgent(t *testing.T) { + data := []byte(`--- +kind: agent +name: first +providers: [] +--- +kind: agent +name: second +providers: [] +`) + _, err := ParseHarness(data) + if err == nil { + t.Fatal("expected error for duplicate agent") + } + if !strings.Contains(err.Error(), "multiple agent") { + t.Errorf("error = %q", err) + } +} + +func TestParseHarness_UnknownKind(t *testing.T) { + data := []byte(`--- +kind: agent +name: test +providers: [] +--- +kind: spaceship +name: enterprise +`) + _, err := ParseHarness(data) + if err == nil { + t.Fatal("expected error for unknown kind") + } + if !strings.Contains(err.Error(), "unknown kind") { + t.Errorf("error = %q", err) + } +} + +func TestParseHarness_ProviderRequiresName(t *testing.T) { + data := []byte(`--- +kind: agent +name: test +providers: [] +--- +kind: provider +type: github +`) + _, err := ParseHarness(data) + if err == nil { + t.Fatal("expected error for provider without name") + } + if !strings.Contains(err.Error(), "requires a name") { + t.Errorf("error = %q", err) + } +} + +func TestParseHarness_NoAgent(t *testing.T) { + data := []byte(`--- +kind: provider +name: github +type: github +`) + _, err := ParseHarness(data) + if err == nil { + t.Fatal("expected error for no agent document") + } + if !strings.Contains(err.Error(), "no agent") { + t.Errorf("error = %q", err) + } +} + +func TestRenderHarness(t *testing.T) { + h := &Harness{ + Agent: &AgentConfig{ + Name: "test", + Entrypoint: "claude", + Providers: []ProviderRef{{Profile: "github"}}, + }, + Gateways: map[string][]byte{"local": []byte("type: local\n")}, + Providers: map[string][]byte{"custom-prov": []byte("type: custom\n")}, + } + builtin := map[string][]byte{ + "github": []byte("type: github\ncredentials: [GITHUB_TOKEN]\n"), + } + out, err := RenderHarness(h, builtin) + if err != nil { + t.Fatalf("RenderHarness: %v", err) + } + s := string(out) + if !strings.Contains(s, "kind: agent") { + t.Error("missing agent document") + } + if !strings.Contains(s, "kind: gateway") { + t.Error("missing gateway document") + } + if !strings.Contains(s, "# built-in") { + t.Error("missing built-in provider comment") + } + if !strings.Contains(s, "# custom") { + t.Error("missing custom provider comment") + } +} + func TestRenderPayload_IncludePathTraversal(t *testing.T) { baseDir := t.TempDir() cfg := &AgentConfig{ diff --git a/main.go b/main.go index f65c5d4..dd2386a 100644 --- a/main.go +++ b/main.go @@ -68,6 +68,7 @@ func main() { cmd.NewStatusCmd(harnessDir, cli), cmd.NewStopCmd(harnessDir, cli), cmd.NewStartCmd(harnessDir, cli), + cmd.NewRenderCmd(harnessDir, cli), ) if err := root.Execute(); err != nil { From f0c889a12ed5f67ae4749e6d7926e83148008082 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Tue, 16 Jun 2026 15:53:37 -0700 Subject: [PATCH 04/10] fix: update test to use new agent config path pattern --- cmd/up_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd/up_test.go b/cmd/up_test.go index 12ec0b9..bf1e6d6 100644 --- a/cmd/up_test.go +++ b/cmd/up_test.go @@ -244,8 +244,7 @@ providers: func TestResolveAgentConfig_DiskOverridesEmbedded(t *testing.T) { dir := t.TempDir() - os.MkdirAll(filepath.Join(dir, "agents"), 0o755) - os.WriteFile(filepath.Join(dir, "agents", "default.yaml"), []byte(`name: disk-agent + os.WriteFile(filepath.Join(dir, "agent-default.yaml"), []byte(`name: disk-agent entrypoint: claude providers: - profile: github From 85d769627b41ee2fcb1bf8d33b4b6d9cff57ec48 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Tue, 16 Jun 2026 15:55:00 -0700 Subject: [PATCH 05/10] fix: update CI image build context to new sandbox path --- .github/workflows/images.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index da9c7ce..2aec297 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -42,7 +42,7 @@ jobs: # integration (kind) job can pull the image without a local preload. - uses: docker/build-push-action@v6 with: - context: sandbox + context: profiles/images/sandbox-default platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} tags: ${{ steps.meta.outputs.tags }} From 74e3fd8aad94ac5194b8d00a921bc9decfa61535 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Tue, 16 Jun 2026 16:02:41 -0700 Subject: [PATCH 06/10] fix: use --agent-profile for CI test agent config --- test/test-flow.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/test-flow.sh b/test/test-flow.sh index cfe850f..e08adb7 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -28,16 +28,18 @@ REUSE_GATEWAY=false NO_PROVIDERS=false DEBUG=false PROFILE="default" +AGENT_FLAG="--agent" # Auto-detect CI mode if [[ "${CI:-}" == "true" ]]; then NO_PROVIDERS=true - PROFILE="ci" + PROFILE="test/ci-agent.yaml" + AGENT_FLAG="--agent-profile" fi for arg in "$@"; do case "$arg" in - --ci) NO_PROVIDERS=true; PROFILE="ci" ;; + --ci) NO_PROVIDERS=true; PROFILE="test/ci-agent.yaml"; AGENT_FLAG="--agent-profile" ;; --reuse-gateway) REUSE_GATEWAY=true ;; --no-providers) NO_PROVIDERS=true ;; --debug) DEBUG=true ;; @@ -203,7 +205,7 @@ test_local() { # up auto-registers providers when missing local sandbox_name="test-agent" - step "sandbox create (up)" harness up --gateway local --name "$sandbox_name" --agent "$PROFILE" --no-tty + step "sandbox create (up)" harness up --gateway local --name "$sandbox_name" $AGENT_FLAG "$PROFILE" --no-tty sandbox_verify "$sandbox_name" step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" @@ -262,7 +264,7 @@ test_kind() { step "gateway reachable" "$CLI" inference get local sandbox_name="test-kind" - step "sandbox create" harness up --gateway kind --name "$sandbox_name" --agent "$PROFILE" --no-tty + step "sandbox create" harness up --gateway kind --name "$sandbox_name" $AGENT_FLAG "$PROFILE" --no-tty sandbox_verify "$sandbox_name" if ! $NO_PROVIDERS; then From 1d92ba338e2ca0847c451d368421ac32c45ca85e Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Tue, 16 Jun 2026 16:20:22 -0700 Subject: [PATCH 07/10] docs: add harness render command and multi-doc YAML to README --- README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/README.md b/README.md index f7c43f9..9704ed6 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,47 @@ harness up --agent opencode Same providers and gateway -- just a different agent binary. See `profiles/agent-opencode.yaml`. +## Multi-Document Harness YAML + +A harness YAML can bundle all definitions into a single file using `---` separators and a `kind` field (similar to Kubernetes manifests). This enables fully declarative, self-contained agent configurations: + +```yaml +--- +kind: agent +name: my-agent +entrypoint: claude +gateway: local +providers: + - profile: github + - profile: vertex +env: + ANTHROPIC_BASE_URL: https://inference.local +--- +kind: provider +name: github +type: github +credentials: [GITHUB_TOKEN] +endpoints: + - { host: "api.github.com", port: 443 } +--- +kind: gateway +name: local +type: local +``` + +```bash +harness up -f harness.yaml # everything in one file +``` + +Definitions in the harness file take priority over the `profiles/` tree. Single-document agent YAMLs (without `kind`) continue to work unchanged. + +Use `harness render` to export an existing agent config as a complete harness YAML: + +```bash +harness render --include-defaults # outputs agent + gateway + all providers +harness render -o harness.yaml # write to file +``` + ## Local Setup ### Prerequisites @@ -300,6 +341,12 @@ harness status harness stop [NAME] / harness start [NAME] Stop or start a sandbox without deleting it. +harness render [--agent NAME] [--agent-profile|-f FILE] [--output|-o FILE] [--include-defaults] + Render a complete multi-document harness YAML from an agent config. + Includes all referenced providers and gateways in one file. + Built-in OpenShell providers are labeled separately from custom ones. + --include-defaults adds the effective gateway even if not set in the agent config. + harness teardown [--sandboxes] [--providers] [--k8s] Tear down resources. At least one flag required. From 5d850586c2cc20ca5954013a0558d7e60e9484ef Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Tue, 16 Jun 2026 16:25:58 -0700 Subject: [PATCH 08/10] chore: remove langfuse-setup.md with hardcoded keys from PR --- docs/langfuse-setup.md | 66 ------------------------------------------ 1 file changed, 66 deletions(-) delete mode 100644 docs/langfuse-setup.md diff --git a/docs/langfuse-setup.md b/docs/langfuse-setup.md deleted file mode 100644 index c905386..0000000 --- a/docs/langfuse-setup.md +++ /dev/null @@ -1,66 +0,0 @@ -# Langfuse Local Setup - -## Prerequisites - -- Langfuse running locally via `podman-compose up -d` from `/tmp/langfuse-spike/` -- API keys from Langfuse UI (Settings > API Keys) - -## Environment Variables - -```bash -export LANGFUSE_PUBLIC_KEY="pk-lf-5c833482-38cc-4ba9-badb-bcbd56e9be2e" -export LANGFUSE_SECRET_KEY="sk-lf-bb5f27ad-c72d-4def-8275-98e4ffecd9d9" -export LANGFUSE_BASE_URL="http://localhost:3000" - -export CLAUDE_CODE_ENABLE_TELEMETRY=1 -export CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1 -export OTEL_TRACES_EXPORTER=otlp -export OTEL_LOGS_EXPORTER=otlp -export OTEL_METRICS_EXPORTER=none -export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf -export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:3000/api/public/otel -export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic cGstbGYtNWM4MzM0ODItMzhjYy00YmE5LWJhZGItYmNiZDU2ZTliZTJlOnNrLWxmLWJiNWYyN2FkLWM3MmQtNGRlZi04Mjc1LTk4ZTRmZmVjZDlkOQ==" -export OTEL_LOG_USER_PROMPTS=1 -export OTEL_LOG_TOOL_DETAILS=1 -export OTEL_LOG_TOOL_CONTENT=1 -export OTEL_LOG_RAW_API_BODIES=1 -``` - -## What each variable does - -| Variable | Purpose | -|---|---| -| `CLAUDE_CODE_ENABLE_TELEMETRY` | Master switch for Claude Code telemetry | -| `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA` | Required for trace export (not just metrics) | -| `OTEL_TRACES_EXPORTER=otlp` | Send span structure (interaction -> LLM request -> tool calls) | -| `OTEL_LOGS_EXPORTER=otlp` | Send full API request/response bodies (conversation content) | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | Langfuse OTel endpoint | -| `OTEL_EXPORTER_OTLP_HEADERS` | Basic auth (base64 of `public_key:secret_key`) | -| `OTEL_LOG_USER_PROMPTS` | Include prompt text in traces | -| `OTEL_LOG_TOOL_DETAILS` | Include tool call arguments | -| `OTEL_LOG_TOOL_CONTENT` | Include tool output | -| `OTEL_LOG_RAW_API_BODIES` | Include full API request/response JSON | - -## Regenerating the auth header - -```bash -echo -n "${LANGFUSE_PUBLIC_KEY}:${LANGFUSE_SECRET_KEY}" | base64 -``` - -## Starting Langfuse - -```bash -cd /tmp/langfuse-spike -podman-compose up -d -``` - -UI at http://localhost:3000 - -## Stopping Langfuse - -```bash -cd /tmp/langfuse-spike -podman-compose down -``` - -Data persists in podman volumes across restarts. From 1bc80691fad66bc97d447d3a546af98c0900ebba Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Tue, 16 Jun 2026 16:27:25 -0700 Subject: [PATCH 09/10] docs: add harness render as live config snapshot to TODO --- TODO.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/TODO.md b/TODO.md index 81854af..7e408bb 100644 --- a/TODO.md +++ b/TODO.md @@ -83,6 +83,16 @@ - Goal: `harness up -f agent.yaml` with one file. Zero to working sandboxed agent. - Existing single-document agent YAMLs (no `kind` field) continue to work unchanged +### `harness render` as live config snapshot +- [ ] `harness render` queries the running gateway for effective state, not just YAML files +- Outputs what is actually configured: registered providers, active gateway, inference + config, sandbox policy, env structure +- Credentials replaced with `${VAR}` placeholders -- the snapshot is shareable +- Replay with different creds: `GITHUB_TOKEN=theirs harness up -f snapshot.yaml` +- Like `kubectl get -o yaml` -- captures the running shape, not the source config +- Round-trip: `harness render > snapshot.yaml && harness up -f snapshot.yaml` should + reproduce the same agent setup (with different credentials from env) + ### Future fields - [ ] `description` -- one line of human-readable context per agent config - [ ] `repo` -- git URL to clone into the sandbox at start From f4189eb1a187b45515d92e38b56206a9632629dc Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Tue, 16 Jun 2026 16:28:00 -0700 Subject: [PATCH 10/10] docs: add harness render preview (dry-run) to TODO --- TODO.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/TODO.md b/TODO.md index 7e408bb..fa5fba0 100644 --- a/TODO.md +++ b/TODO.md @@ -92,6 +92,9 @@ - Like `kubectl get -o yaml` -- captures the running shape, not the source config - Round-trip: `harness render > snapshot.yaml && harness up -f snapshot.yaml` should reproduce the same agent setup (with different credentials from env) +- [ ] `harness render preview -f harness.yaml` -- dry-run that resolves all references + (providers, gateway, env vars) and shows the fully resolved config without deploying. + Like `terraform plan` or `helm template`. Shows what `harness up` would do. ### Future fields - [ ] `description` -- one line of human-readable context per agent config