From 56020caa4ba418e652ad0b6753d6ca50361c6e49 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Tue, 16 Jun 2026 23:07:13 -0700 Subject: [PATCH 1/7] X-Smart-Branch-Parent: main From e6961fac37cac65cd038523420592c8d4af8a12d Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Tue, 16 Jun 2026 23:11:58 -0700 Subject: [PATCH 2/7] feat: kind: config embeds sandbox files in harness YAML Add kind: config documents to the harness YAML format. Each config document has a name (filename) and content (file body). Configs are written to the payload directory at sandbox creation time, replacing files that would otherwise be baked into the sandbox image. This keeps the sandbox image minimal -- agent-specific config (claude.json, CLAUDE.md, mcp.json, settings.json, opencode.json) lives in the harness YAML and can be changed without rebuilding. Changes: - Harness.Configs map[string][]byte in the Harness struct - ParseHarness handles kind: config with name + content fields - RenderHarness outputs kind: config documents - RenderConfigs writes config files to payload directory - executor.go calls RenderConfigs after RenderPayload - 4 unit tests: parse with configs, name required, content required, render - Test config: harness-with-configs.yaml - Config suite: parse and -o yaml tests for kind: config - TODO.md updated: mark done items, detail kind: config spec --- TODO.md | 169 ++++++++----------------- cmd/executor.go | 6 + internal/agent/agent.go | 46 +++++++ internal/agent/agent_test.go | 95 ++++++++++++++ test/configs/harness-with-configs.yaml | 21 +++ test/suite/run.sh | 6 + 6 files changed, 224 insertions(+), 119 deletions(-) create mode 100644 test/configs/harness-with-configs.yaml diff --git a/TODO.md b/TODO.md index 2451506..138306b 100644 --- a/TODO.md +++ b/TODO.md @@ -9,68 +9,24 @@ - Eliminates: openshell CLI binary dependency, output parsing fragility - Prerequisite: proto files stabilize (OpenShell is alpha) -### Image registry as gateway config vs env override -- `HARNESS_OS_IMAGE` env var overrides the version-based image resolution (for dev/CI) -- Consider: gateway.yaml uses a `registry` field and images are relative to it - ### registerProviders should filter by agent's provider list - `registerProviders()` in `cmd/providers.go` uses the gateway config's provider list, not the agent config's. When `gwCfg` is nil (common case), it tries to register all providers regardless of what the agent needs. -- Why: confusing output — users see "skipped" messages for providers their - agent doesn't reference. No functional impact (missing credentials are - silently handled). - Fix: pass the agent's provider names to `registerProviders` and use them as a filter alongside (or instead of) the gateway config's list. -- Files: `cmd/providers.go` (registerProviders signature), `cmd/up.go` (call site) - -## Config Format - -- [ ] Specify/document the YAML formats (agent config, provider profiles) -- [ ] Document non-secret provider env vars (what `providers[].env` captures - and why it exists alongside secret credentials) - -## CLI — kubectl-style refactor - -Sequenced implementation plan. Each phase builds on the previous. - -### Phase 1: `apply` command (replaces `up` and `create`) -- [ ] `cmd/apply.go` — unified deploy command, delegates to existing `upLocal()` -- [ ] `-f` flag as primary interface (replaces `--agent-profile`) -- [ ] `--attach` flag (default false) for interactive TTY (flips old `up` default) -- [ ] `--dry-run` — resolve everything, report pass/fail per step, don't deploy -- [ ] `-o yaml` — output fully resolved harness YAML with source annotations -- [ ] `-o json` — machine-readable resolved config -- [ ] Env var fallbacks: `OPENSHELL_GATEWAY`, `OPENSHELL_GATEWAY_ENDPOINT` (#1851) -- [ ] Bare `harness apply` uses default agent config (same as old `harness up`) -- [ ] Mark `up` and `create` as hidden deprecated aliases via cobra `Deprecated` field - -### Phase 2: `get` and `describe` commands (replaces `status`) -- [ ] `cmd/get.go` — parent command with subcommands: - - `harness get agents` (aliases: `sandboxes`) — list running sandboxes - - `harness get providers` — list registered providers - - `harness get gateways` — list gateways -- [ ] Shared `OutputFormat` type: `table|json|yaml` via `-o` flag on all `get` subcommands -- [ ] Credential exclusion: `-o json/yaml` never includes secret values (#1830 pattern) -- [ ] `cmd/describe.go` — `harness describe ` for detailed sandbox status -- [ ] Mark `status` as hidden deprecated alias - -### Phase 3: `delete` command (replaces `teardown`) -- [ ] `cmd/delete.go` — targeted + bulk deletion: - - `harness delete ` — delete specific sandbox - - `harness delete --all` — full teardown (sandboxes + providers + k8s) - - `harness delete --providers` — providers only - - `harness delete --k8s` — k8s resources only -- [ ] Reuses existing `teardownSandboxes()`, `teardownProviders()`, `teardownK8s()` -- [ ] Mark `teardown` as hidden deprecated alias - -### Phase 4: Integration + docs -- [ ] Update `test/test-flow.sh` to use new verbs -- [ ] Update SPEC.md command reference -- [ ] Update README.md command reference -- [ ] `render` becomes hidden alias for `apply -o yaml` -- [ ] `deploy` stays as-is (infrastructure action) -- [ ] `start`/`stop` stay as-is (lifecycle actions) +- Files: `cmd/providers.go` (registerProviders signature), `cmd/executor.go` (call site) + +## CLI [DONE] + +kubectl-style refactor complete (PRs #67-#70): +- [x] `harness apply` with `--dry-run`, `-o yaml|json`, `--attach`, `-f` +- [x] `harness get agents|providers|gateways` with `-o table|json|yaml` +- [x] `harness describe ` +- [x] `harness delete ` with `--all`, `--sandboxes`, `--providers`, `--k8s` +- [x] `harness deploy`, `start`, `stop` unchanged +- [x] `teardown` and `status` as hidden deprecated aliases +- [x] Old commands (`up`, `create`, `render`) removed (PR #68) ## Agent Config @@ -79,45 +35,65 @@ Sequenced implementation plan. Each phase builds on the previous. - [x] `Harness` type with `ParseHarness`/`ParseHarnessFile` - [x] `RenderHarness` with built-in vs custom provider labeling - [x] Resolution: harness-local definitions > profiles/ tree > embedded defaults -- [x] Backwards compat: single-doc agent YAMLs without `kind` still work + +### `kind: config` — embed sandbox files in harness YAML +- [ ] `kind: config` documents for `claude.json`, `CLAUDE.md`, `mcp.json`, + `opencode.json`, `settings.json`, `policy.yaml` +- [ ] Parsed by `ParseHarness` and stored as `Harness.Configs map[string][]byte` +- [ ] Rendered to payload directory by `RenderPayload` instead of baking into image +- [ ] Keeps sandbox image minimal — all agent-specific config in the harness YAML +- [ ] Example: + ```yaml + --- + kind: config + name: claude.json + content: | + {"mcpServers": {"atlassian": {"command": "mcp-atlassian"}}} + --- + kind: config + name: CLAUDE.md + content: | + You are working inside an OpenShell sandbox. + ``` ### Config reconciliation (`apply -o yaml`) - [ ] Resolves agent YAML against profiles/, defaults, and running gateway - [ ] Shows where each value came from (default, profile, harness file, env var) - [ ] Credentials rendered as `${VAR}` placeholders — shareable, replayable - [ ] Round-trip: `apply -o yaml > snapshot.yaml && apply -f snapshot.yaml` -- [ ] `--dry-run` without `-o` reports pass/fail (gateway available? providers - resolvable? image exists? env vars resolved?) - -### `kind: config` — embed sandbox files in harness YAML (future) -- [ ] `kind: config` documents for `claude.json`, `CLAUDE.md`, `mcp.json`, etc. -- [ ] Rendered to payload directory instead of baking into sandbox image -- [ ] Keeps sandbox image minimal — all agent-specific config in the harness YAML ### Provider abstraction layer - [ ] `kind: provider` targets `openshell provider create` today (imperative) - [ ] Abstraction supports future backends: gateway.toml (#1886), K8s CRDs (#1719) -- [ ] Do not hard-code execution strategy — upstream is undecided ### Future fields - [ ] `description` — one line of human-readable context per agent config - [ ] `repo` — git URL to clone into the sandbox at start - [ ] `secrets` — non-provider secrets to inject +## Testing [DONE] + +Config test suite (PR #71): 37 tests across 7 categories. +- [x] Config parsing, output formats, env resolution, CLI flags +- [x] Live sandbox lifecycle (create, describe, exec, env injection, delete) +- [x] Provider registration (github, atlassian, vertex, gws, all-providers) +- [x] Agent integration (claude inference, opencode inference, gh cli, jira mcp, gws gmail) +- [x] CI: config-suite in CI workflow, test-suite-live in integration workflow + +### Known gaps +- [ ] OpenCode + Vertex inference in sandbox blocked by policy fields not supported in 0.0.59 + (`allow_encoded_slash`, custom policy sections crash supervisor) +- [ ] Local `podman build` on macOS produces images that behave differently than CI `docker build` + ## Upstream alignment ### Plugin compatibility (#1851) - [ ] Binary naming: plan for `openshell-harness` (PATH-based plugin discovery) -- [ ] Dual invocation: standalone `openshell-harness` and plugin `openshell harness` -- [ ] Env var fallbacks for gateway/endpoint/verbosity when running as plugin -- [ ] Auth via `openshell-bootstrap` APIs (no token forwarding) - [ ] Status: #1851 is `question` label, not accepted. Design standalone first. ### Image building delegation - [ ] Evaluate delegating to `openshell-image-builder` for advanced image composition -- [ ] Harness generates config (`.kaiden/workspace.json` + `config.toml`), builder consumes - [ ] Layered policy composition: base + agent-specific + user overlay -- [ ] Programmatic Containerfile generation from config (stop maintaining static Dockerfiles) ### Upstream issues to track - #1719 — K8s Operator design (affects provider CRDs, declarative config) @@ -126,15 +102,6 @@ Sequenced implementation plan. Each phase builds on the previous. - #1922 — Portable sandbox log collection (affects observability) - #1933 — Centralized audit/event log (affects run recorder) -## Testing - -### Current coverage -- Go unit tests across cmd/ and all internal/ packages (run in CI via `.github/workflows/ci.yml`) -- Integration: local + kind + OCP via `make test-all` - -### Gaps -- [ ] Integration test for `harness up --provider-refresh` - ## Release - [x] Add CHANGELOG.md @@ -143,51 +110,15 @@ Sequenced implementation plan. Each phase builds on the previous. ## 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. +Investigation (Jun 2026) validated paths for capturing agent session data. +Langfuse hooks plugin installed and working. MLflow spiked. SigNoz identified +as strongest OTel backend for full signal coverage. ### 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 apply` auto-injects OTel env vars or configures Langfuse hooks - [ ] `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) - [ ] Multi-agent workflow support (fleet.yaml / workflow.yaml) diff --git a/cmd/executor.go b/cmd/executor.go index 0b0d306..46432a4 100644 --- a/cmd/executor.go +++ b/cmd/executor.go @@ -82,6 +82,12 @@ func upLocal(opts upLocalOpts) error { return fmt.Errorf("rendering payload: %w", err) } + if opts.harness != nil && len(opts.harness.Configs) > 0 { + if err := agent.RenderConfigs(opts.harness.Configs, payloadDir); err != nil { + return fmt.Errorf("rendering configs: %w", err) + } + } + status.Header("Sandbox") var sandboxCmd []string if noTTY { diff --git a/internal/agent/agent.go b/internal/agent/agent.go index af40758..8cf53d5 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -82,6 +82,7 @@ type Harness struct { Agent *AgentConfig Gateways map[string][]byte // name -> raw gateway YAML Providers map[string][]byte // name -> raw provider profile YAML + Configs map[string][]byte // name -> file content (e.g. claude.json, CLAUDE.md) Policy []byte // raw policy YAML } @@ -91,6 +92,13 @@ type kindHeader struct { Name string `yaml:"name"` } +// configDoc holds a kind: config document with file content. +type configDoc struct { + Kind string `yaml:"kind"` + Name string `yaml:"name"` + Content string `yaml:"content"` +} + func ParseHarnessFile(path string) (*Harness, error) { data, err := os.ReadFile(path) if err != nil { @@ -103,6 +111,7 @@ func ParseHarness(data []byte) (*Harness, error) { h := &Harness{ Gateways: make(map[string][]byte), Providers: make(map[string][]byte), + Configs: make(map[string][]byte), } dec := yaml.NewDecoder(bytes.NewReader(data)) @@ -150,6 +159,19 @@ func ParseHarness(data []byte) (*Harness, error) { } h.Gateways[header.Name] = raw + case "config": + var doc configDoc + if err := yaml.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("document %d: parsing config: %w", docIndex, err) + } + if doc.Name == "" { + return nil, fmt.Errorf("document %d: kind: config requires a name field", docIndex) + } + if doc.Content == "" { + return nil, fmt.Errorf("document %d: kind: config requires a content field", docIndex) + } + h.Configs[doc.Name] = []byte(doc.Content) + case "policy": if h.Policy != nil { return nil, fmt.Errorf("multiple policy documents found") @@ -200,6 +222,13 @@ func RenderHarness(h *Harness, builtinProviders map[string][]byte) ([]byte, erro buf.Write(data) } + for name, content := range h.Configs { + buf.WriteString("---\nkind: config\nname: " + name + "\ncontent: |\n") + for _, line := range strings.Split(string(content), "\n") { + buf.WriteString(" " + line + "\n") + } + } + if h.Policy != nil { buf.WriteString("---\nkind: policy\n") buf.Write(h.Policy) @@ -308,3 +337,20 @@ func RenderPayload(cfg *AgentConfig, baseDir, destDir string) error { return nil } + +// RenderConfigs writes harness config files (kind: config) to the payload directory. +func RenderConfigs(configs map[string][]byte, destDir string) error { + for name, content := range configs { + dst := filepath.Join(destDir, name) + dir := filepath.Dir(dst) + if dir != destDir { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("creating dir for config %s: %w", name, err) + } + } + if err := os.WriteFile(dst, content, 0o644); err != nil { + return fmt.Errorf("writing config %s: %w", name, err) + } + } + return nil +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 0fc5fb3..2dbbecb 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -548,6 +548,101 @@ func TestRenderHarness(t *testing.T) { } } +func TestParseHarness_WithConfigs(t *testing.T) { + data := []byte(`--- +kind: agent +name: test +providers: [] +--- +kind: config +name: claude.json +content: | + {"mcpServers": {}} +--- +kind: config +name: CLAUDE.md +content: | + # Test + Hello world. +`) + h, err := ParseHarness(data) + if err != nil { + t.Fatalf("ParseHarness: %v", err) + } + if len(h.Configs) != 2 { + t.Fatalf("Configs = %d, want 2", len(h.Configs)) + } + if _, ok := h.Configs["claude.json"]; !ok { + t.Error("missing config 'claude.json'") + } + if !strings.Contains(string(h.Configs["CLAUDE.md"]), "Hello world") { + t.Errorf("CLAUDE.md content = %q", h.Configs["CLAUDE.md"]) + } +} + +func TestParseHarness_ConfigRequiresName(t *testing.T) { + data := []byte(`--- +kind: agent +name: test +providers: [] +--- +kind: config +content: | + some content +`) + _, err := ParseHarness(data) + if err == nil { + t.Fatal("expected error for config without name") + } + if !strings.Contains(err.Error(), "requires a name") { + t.Errorf("error = %q", err) + } +} + +func TestParseHarness_ConfigRequiresContent(t *testing.T) { + data := []byte(`--- +kind: agent +name: test +providers: [] +--- +kind: config +name: empty.txt +`) + _, err := ParseHarness(data) + if err == nil { + t.Fatal("expected error for config without content") + } + if !strings.Contains(err.Error(), "requires a content") { + t.Errorf("error = %q", err) + } +} + +func TestRenderConfigs(t *testing.T) { + destDir := filepath.Join(t.TempDir(), "payload") + os.MkdirAll(destDir, 0o755) + + configs := map[string][]byte{ + "claude.json": []byte(`{"test": true}`), + "CLAUDE.md": []byte("# Hello\n"), + "settings.json": []byte(`{"perms": {}}`), + } + + if err := RenderConfigs(configs, destDir); err != nil { + t.Fatalf("RenderConfigs: %v", err) + } + + for name, expected := range configs { + data, err := os.ReadFile(filepath.Join(destDir, name)) + if err != nil { + t.Errorf("missing config file %s: %v", name, err) + continue + } + if string(data) != string(expected) { + t.Errorf("%s: got %q, want %q", name, data, expected) + } + } +} + func TestRenderPayload_IncludePathTraversal(t *testing.T) { baseDir := t.TempDir() cfg := &AgentConfig{ diff --git a/test/configs/harness-with-configs.yaml b/test/configs/harness-with-configs.yaml new file mode 100644 index 0000000..0b9c117 --- /dev/null +++ b/test/configs/harness-with-configs.yaml @@ -0,0 +1,21 @@ +--- +kind: agent +name: test-configs +entrypoint: bash +providers: [] +--- +kind: config +name: claude.json +content: | + {"mcpServers": {"test": {"command": "echo"}}} +--- +kind: config +name: CLAUDE.md +content: | + # Test sandbox + You are in a test environment. +--- +kind: config +name: settings.json +content: | + {"permissions": {"allow": ["Bash(*)"]}} diff --git a/test/suite/run.sh b/test/suite/run.sh index 655f291..4b590a6 100755 --- a/test/suite/run.sh +++ b/test/suite/run.sh @@ -117,6 +117,12 @@ run_test "parse: default agent (no -f)" \ run_test "parse: custom provider profile" \ "$HARNESS" apply --dry-run -f "$CONFIGS/agent-groq.yaml" +run_test "parse: harness with kind: config" \ + "$HARNESS" apply --dry-run -f "$CONFIGS/harness-with-configs.yaml" + +run_test "output: kind: config in -o yaml" \ + bash -c '"$1" apply -o yaml -f "$2" | grep "kind: config"' _ "$HARNESS" "$CONFIGS/harness-with-configs.yaml" + run_test_fail "parse: nonexistent file rejects" \ "$HARNESS" apply --dry-run -f "/nonexistent/agent.yaml" From 99d8c4f6cac01567d91374b291776971bb9fffff Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Tue, 16 Jun 2026 23:25:50 -0700 Subject: [PATCH 3/7] feat: rename kind: config to kind: payload with multi-upload Payloads are files uploaded directly to sandbox paths via multiple --upload flags. Each payload has a path (sandbox destination) and either content (inline) or file (local reference). Two ways to declare: 1. In agent document: payloads: [{path: ..., content: ...}] 2. As separate documents: kind: payload with path + content/file Changes: - PayloadEntry struct with path/content/file fields - Harness.Payloads replaces Harness.Configs - AgentConfig.Payloads for inline declaration - Agent-level payloads merged into harness payloads - SandboxCreateOpts.Uploads replaces single UploadSrc/UploadDst - CLI emits multiple --upload flags - ResolvePayloads creates temp files for content, validates paths - kind: config kept as silent alias for backwards compat - 10 unit tests covering parse, validate, resolve, backwards compat --- cmd/executor.go | 13 +- cmd/sandbox.go | 25 ++-- internal/agent/agent.go | 104 ++++++++++----- internal/agent/agent_test.go | 168 ++++++++++++++++++------ internal/gateway/cli.go | 4 +- internal/gateway/cli_test.go | 3 +- internal/gateway/gateway.go | 8 +- test/configs/harness-with-configs.yaml | 21 --- test/configs/harness-with-payloads.yaml | 19 +++ test/suite/run.sh | 8 +- 10 files changed, 260 insertions(+), 113 deletions(-) delete mode 100644 test/configs/harness-with-configs.yaml create mode 100644 test/configs/harness-with-payloads.yaml diff --git a/cmd/executor.go b/cmd/executor.go index 46432a4..c08ebfd 100644 --- a/cmd/executor.go +++ b/cmd/executor.go @@ -82,9 +82,15 @@ func upLocal(opts upLocalOpts) error { return fmt.Errorf("rendering payload: %w", err) } - if opts.harness != nil && len(opts.harness.Configs) > 0 { - if err := agent.RenderConfigs(opts.harness.Configs, payloadDir); err != nil { - return fmt.Errorf("rendering configs: %w", err) + // Resolve payload entries into upload pairs + var extraUploads []gateway.Upload + if opts.harness != nil && len(opts.harness.Payloads) > 0 { + resolved, err := agent.ResolvePayloads(opts.harness.Payloads, opts.harnessDir, payloadDir) + if err != nil { + return fmt.Errorf("resolving payloads: %w", err) + } + for _, u := range resolved { + extraUploads = append(extraUploads, gateway.Upload{Src: u.Src, Dst: u.Dst}) } } @@ -106,6 +112,7 @@ func upLocal(opts upLocalOpts) error { retrySleep: opts.retrySleep, sandboxCmd: sandboxCmd, payloadDir: payloadDir, + uploads: extraUploads, env: agentCfg.BuildEnvMap(), }) } diff --git a/cmd/sandbox.go b/cmd/sandbox.go index dad6c65..e8c0dbd 100644 --- a/cmd/sandbox.go +++ b/cmd/sandbox.go @@ -16,15 +16,16 @@ import ( type sandboxOpts struct { harnessDir string gw gateway.Gateway - name string // sandbox name - image string // sandbox image ref or relative Dockerfile dir - providers []string // registered providers to attach - noTTY bool // true → TTY=false for the sandbox - retrySleep time.Duration // pause between retry attempts - sandboxCmd []string // command to run inside the sandbox - payloadDir string // pre-rendered payload dir to upload - env map[string]string // env vars injected via --env on sandbox create - onSuccess func(name string) // called after successful creation (optional) + name string // sandbox name + image string // sandbox image ref or relative Dockerfile dir + providers []string // registered providers to attach + noTTY bool // true → TTY=false for the sandbox + retrySleep time.Duration // pause between retry attempts + sandboxCmd []string // command to run inside the sandbox + payloadDir string // pre-rendered payload dir to upload + uploads []gateway.Upload // additional uploads (payloads) + env map[string]string // env vars injected via --env on sandbox create + onSuccess func(name string) // called after successful creation (optional) } // createSandbox resolves the image path, stages the payload directory, @@ -57,14 +58,16 @@ func createSandbox(opts sandboxOpts) error { // Create sandbox with retry loop (up to 5 attempts). for attempt := 1; attempt <= 5; attempt++ { + uploads := []gateway.Upload{{Src: uploadDir, Dst: "/sandbox/.config"}} + uploads = append(uploads, opts.uploads...) + err := opts.gw.SandboxCreate(gateway.SandboxCreateOpts{ Name: opts.name, From: image, Providers: opts.providers, TTY: !opts.noTTY, Keep: true, - UploadSrc: uploadDir, - UploadDst: "/sandbox/.config", + Uploads: uploads, Command: opts.sandboxCmd, Env: opts.env, }) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 8cf53d5..63a6437 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -16,6 +16,12 @@ type ProviderRef struct { Env map[string]string `yaml:"env,omitempty"` } +type PayloadEntry struct { + Path string `yaml:"path"` + Content string `yaml:"content,omitempty"` + File string `yaml:"file,omitempty"` +} + type AgentConfig struct { Name string `yaml:"name"` Gateway string `yaml:"gateway,omitempty"` @@ -27,6 +33,7 @@ type AgentConfig struct { Policy string `yaml:"policy,omitempty"` Image string `yaml:"image,omitempty"` Include []string `yaml:"include,omitempty"` + Payloads []PayloadEntry `yaml:"payloads,omitempty"` } func (c *AgentConfig) NoTTY() bool { @@ -82,7 +89,7 @@ type Harness struct { Agent *AgentConfig Gateways map[string][]byte // name -> raw gateway YAML Providers map[string][]byte // name -> raw provider profile YAML - Configs map[string][]byte // name -> file content (e.g. claude.json, CLAUDE.md) + Payloads []PayloadEntry // files to upload to sandbox Policy []byte // raw policy YAML } @@ -92,11 +99,12 @@ type kindHeader struct { Name string `yaml:"name"` } -// configDoc holds a kind: config document with file content. -type configDoc struct { +// payloadDoc holds a kind: payload document. +type payloadDoc struct { Kind string `yaml:"kind"` - Name string `yaml:"name"` - Content string `yaml:"content"` + Path string `yaml:"path"` + Content string `yaml:"content,omitempty"` + File string `yaml:"file,omitempty"` } func ParseHarnessFile(path string) (*Harness, error) { @@ -111,7 +119,6 @@ func ParseHarness(data []byte) (*Harness, error) { h := &Harness{ Gateways: make(map[string][]byte), Providers: make(map[string][]byte), - Configs: make(map[string][]byte), } dec := yaml.NewDecoder(bytes.NewReader(data)) @@ -159,18 +166,25 @@ func ParseHarness(data []byte) (*Harness, error) { } h.Gateways[header.Name] = raw - case "config": - var doc configDoc + case "payload", "config": + var doc payloadDoc if err := yaml.Unmarshal(raw, &doc); err != nil { - return nil, fmt.Errorf("document %d: parsing config: %w", docIndex, err) + return nil, fmt.Errorf("document %d: parsing payload: %w", docIndex, err) } - if doc.Name == "" { - return nil, fmt.Errorf("document %d: kind: config requires a name field", docIndex) + if doc.Path == "" { + return nil, fmt.Errorf("document %d: kind: payload requires a path field", docIndex) } - if doc.Content == "" { - return nil, fmt.Errorf("document %d: kind: config requires a content field", docIndex) + if doc.Content == "" && doc.File == "" { + return nil, fmt.Errorf("document %d: kind: payload requires content or file field", docIndex) } - h.Configs[doc.Name] = []byte(doc.Content) + if doc.Content != "" && doc.File != "" { + return nil, fmt.Errorf("document %d: kind: payload cannot have both content and file", docIndex) + } + h.Payloads = append(h.Payloads, PayloadEntry{ + Path: doc.Path, + Content: doc.Content, + File: doc.File, + }) case "policy": if h.Policy != nil { @@ -187,6 +201,8 @@ func ParseHarness(data []byte) (*Harness, error) { if h.Agent == nil { return nil, fmt.Errorf("no agent document found") } + // Merge agent-level payloads into harness payloads + h.Payloads = append(h.Payloads, h.Agent.Payloads...) return h, nil } @@ -222,10 +238,15 @@ func RenderHarness(h *Harness, builtinProviders map[string][]byte) ([]byte, erro buf.Write(data) } - for name, content := range h.Configs { - buf.WriteString("---\nkind: config\nname: " + name + "\ncontent: |\n") - for _, line := range strings.Split(string(content), "\n") { - buf.WriteString(" " + line + "\n") + for _, p := range h.Payloads { + buf.WriteString("---\nkind: payload\npath: " + p.Path + "\n") + if p.File != "" { + buf.WriteString("file: " + p.File + "\n") + } else if p.Content != "" { + buf.WriteString("content: |\n") + for _, line := range strings.Split(p.Content, "\n") { + buf.WriteString(" " + line + "\n") + } } } @@ -338,19 +359,42 @@ func RenderPayload(cfg *AgentConfig, baseDir, destDir string) error { return nil } -// RenderConfigs writes harness config files (kind: config) to the payload directory. -func RenderConfigs(configs map[string][]byte, destDir string) error { - for name, content := range configs { - dst := filepath.Join(destDir, name) - dir := filepath.Dir(dst) - if dir != destDir { - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("creating dir for config %s: %w", name, err) - } +// ResolvePayloads resolves payload entries into source/destination pairs for upload. +// Content payloads are written to temp files. File payloads are resolved relative to baseDir. +func ResolvePayloads(payloads []PayloadEntry, baseDir, tmpDir string) ([]struct{ Src, Dst string }, error) { + var uploads []struct{ Src, Dst string } + for _, p := range payloads { + if !strings.HasPrefix(p.Path, "/sandbox/") { + return nil, fmt.Errorf("payload path %q must start with /sandbox/", p.Path) } - if err := os.WriteFile(dst, content, 0o644); err != nil { - return fmt.Errorf("writing config %s: %w", name, err) + var src string + if p.Content != "" { + f, err := os.CreateTemp(tmpDir, "payload-*") + if err != nil { + return nil, fmt.Errorf("creating temp file for payload %s: %w", p.Path, err) + } + if _, err := f.WriteString(p.Content); err != nil { + f.Close() + return nil, fmt.Errorf("writing payload %s: %w", p.Path, err) + } + f.Close() + src = f.Name() + } else if p.File != "" { + resolved := p.File + if !filepath.IsAbs(resolved) { + resolved = filepath.Join(baseDir, resolved) + } + clean := filepath.Clean(resolved) + rel, err := filepath.Rel(filepath.Clean(baseDir), clean) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return nil, fmt.Errorf("payload file %q escapes base directory", p.File) + } + if _, err := os.Stat(clean); err != nil { + return nil, fmt.Errorf("payload file %s: %w", p.File, err) + } + src = clean } + uploads = append(uploads, struct{ Src, Dst string }{Src: src, Dst: p.Path}) } - return nil + return uploads, nil } diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 2dbbecb..cd5cada 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -548,19 +548,19 @@ func TestRenderHarness(t *testing.T) { } } -func TestParseHarness_WithConfigs(t *testing.T) { +func TestParseHarness_WithPayloads(t *testing.T) { data := []byte(`--- kind: agent name: test providers: [] --- -kind: config -name: claude.json +kind: payload +path: /sandbox/.claude.json content: | {"mcpServers": {}} --- -kind: config -name: CLAUDE.md +kind: payload +path: /sandbox/.claude/CLAUDE.md content: | # Test Hello world. @@ -569,77 +569,169 @@ content: | if err != nil { t.Fatalf("ParseHarness: %v", err) } - if len(h.Configs) != 2 { - t.Fatalf("Configs = %d, want 2", len(h.Configs)) + if len(h.Payloads) != 2 { + t.Fatalf("Payloads = %d, want 2", len(h.Payloads)) } - if _, ok := h.Configs["claude.json"]; !ok { - t.Error("missing config 'claude.json'") + if h.Payloads[0].Path != "/sandbox/.claude.json" { + t.Errorf("Payloads[0].Path = %q", h.Payloads[0].Path) } - if !strings.Contains(string(h.Configs["CLAUDE.md"]), "Hello world") { - t.Errorf("CLAUDE.md content = %q", h.Configs["CLAUDE.md"]) + if !strings.Contains(h.Payloads[1].Content, "Hello world") { + t.Errorf("Payloads[1].Content = %q", h.Payloads[1].Content) } } -func TestParseHarness_ConfigRequiresName(t *testing.T) { +func TestParseHarness_PayloadRequiresPath(t *testing.T) { data := []byte(`--- kind: agent name: test providers: [] --- -kind: config +kind: payload content: | some content `) _, err := ParseHarness(data) if err == nil { - t.Fatal("expected error for config without name") + t.Fatal("expected error for payload without path") } - if !strings.Contains(err.Error(), "requires a name") { + if !strings.Contains(err.Error(), "requires a path") { t.Errorf("error = %q", err) } } -func TestParseHarness_ConfigRequiresContent(t *testing.T) { +func TestParseHarness_PayloadRequiresContentOrFile(t *testing.T) { data := []byte(`--- kind: agent name: test providers: [] --- -kind: config -name: empty.txt +kind: payload +path: /sandbox/empty.txt `) _, err := ParseHarness(data) if err == nil { - t.Fatal("expected error for config without content") + t.Fatal("expected error for payload without content or file") } - if !strings.Contains(err.Error(), "requires a content") { + if !strings.Contains(err.Error(), "requires content or file") { t.Errorf("error = %q", err) } } -func TestRenderConfigs(t *testing.T) { - destDir := filepath.Join(t.TempDir(), "payload") - os.MkdirAll(destDir, 0o755) +func TestParseHarness_PayloadContentAndFileMutuallyExclusive(t *testing.T) { + data := []byte(`--- +kind: agent +name: test +providers: [] +--- +kind: payload +path: /sandbox/test.txt +content: hello +file: test.txt +`) + _, err := ParseHarness(data) + if err == nil { + t.Fatal("expected error for payload with both content and file") + } + if !strings.Contains(err.Error(), "cannot have both") { + t.Errorf("error = %q", err) + } +} + +func TestParseHarness_KindConfigBackwardsCompat(t *testing.T) { + data := []byte(`--- +kind: agent +name: test +providers: [] +--- +kind: config +path: /sandbox/.claude.json +content: | + {"test": true} +`) + h, err := ParseHarness(data) + if err != nil { + t.Fatalf("ParseHarness: %v", err) + } + if len(h.Payloads) != 1 { + t.Fatalf("Payloads = %d, want 1", len(h.Payloads)) + } +} + +func TestParseHarness_AgentLevelPayloads(t *testing.T) { + data := []byte(`--- +kind: agent +name: test +providers: [] +payloads: + - path: /sandbox/.mcp.json + content: | + {"servers": {}} +`) + h, err := ParseHarness(data) + if err != nil { + t.Fatalf("ParseHarness: %v", err) + } + if len(h.Payloads) != 1 { + t.Fatalf("Payloads = %d, want 1", len(h.Payloads)) + } + if h.Payloads[0].Path != "/sandbox/.mcp.json" { + t.Errorf("Path = %q", h.Payloads[0].Path) + } +} - configs := map[string][]byte{ - "claude.json": []byte(`{"test": true}`), - "CLAUDE.md": []byte("# Hello\n"), - "settings.json": []byte(`{"perms": {}}`), +func TestResolvePayloads_Content(t *testing.T) { + tmpDir := t.TempDir() + payloads := []PayloadEntry{ + {Path: "/sandbox/.claude.json", Content: `{"test": true}`}, + } + uploads, err := ResolvePayloads(payloads, t.TempDir(), tmpDir) + if err != nil { + t.Fatalf("ResolvePayloads: %v", err) } + if len(uploads) != 1 { + t.Fatalf("uploads = %d, want 1", len(uploads)) + } + if uploads[0].Dst != "/sandbox/.claude.json" { + t.Errorf("Dst = %q", uploads[0].Dst) + } + data, err := os.ReadFile(uploads[0].Src) + if err != nil { + t.Fatalf("reading src: %v", err) + } + if string(data) != `{"test": true}` { + t.Errorf("content = %q", data) + } +} - if err := RenderConfigs(configs, destDir); err != nil { - t.Fatalf("RenderConfigs: %v", err) +func TestResolvePayloads_File(t *testing.T) { + baseDir := t.TempDir() + os.WriteFile(filepath.Join(baseDir, "test.json"), []byte(`{"from": "file"}`), 0o644) + payloads := []PayloadEntry{ + {Path: "/sandbox/test.json", File: "test.json"}, + } + uploads, err := ResolvePayloads(payloads, baseDir, t.TempDir()) + if err != nil { + t.Fatalf("ResolvePayloads: %v", err) + } + if len(uploads) != 1 { + t.Fatalf("uploads = %d, want 1", len(uploads)) + } + data, err := os.ReadFile(uploads[0].Src) + if err != nil { + t.Fatalf("reading src: %v", err) } + if string(data) != `{"from": "file"}` { + t.Errorf("content = %q", data) + } +} - for name, expected := range configs { - data, err := os.ReadFile(filepath.Join(destDir, name)) - if err != nil { - t.Errorf("missing config file %s: %v", name, err) - continue - } - if string(data) != string(expected) { - t.Errorf("%s: got %q, want %q", name, data, expected) - } +func TestResolvePayloads_InvalidPath(t *testing.T) { + payloads := []PayloadEntry{ + {Path: "/etc/passwd", Content: "bad"}, + } + _, err := ResolvePayloads(payloads, t.TempDir(), t.TempDir()) + if err == nil { + t.Fatal("expected error for path not starting with /sandbox/") } } diff --git a/internal/gateway/cli.go b/internal/gateway/cli.go index 76d7cf0..e9377d7 100644 --- a/internal/gateway/cli.go +++ b/internal/gateway/cli.go @@ -250,8 +250,8 @@ func (c *CLI) SandboxCreate(opts SandboxCreateOpts) error { if !opts.Keep { args = append(args, "--no-keep") } - if opts.UploadSrc != "" { - args = append(args, "--upload", opts.UploadSrc+":"+opts.UploadDst, "--no-git-ignore") + for _, u := range opts.Uploads { + args = append(args, "--upload", u.Src+":"+u.Dst, "--no-git-ignore") } if len(opts.Env) > 0 { keys := make([]string, 0, len(opts.Env)) diff --git a/internal/gateway/cli_test.go b/internal/gateway/cli_test.go index 9307a90..e0d0a87 100644 --- a/internal/gateway/cli_test.go +++ b/internal/gateway/cli_test.go @@ -140,8 +140,7 @@ echo "$@" > `+argsFile+` Providers: []string{"github", "vertex-local"}, TTY: true, Keep: false, - UploadSrc: "/tmp/openshell", - UploadDst: "/sandbox/.config", + Uploads: []Upload{{Src: "/tmp/openshell", Dst: "/sandbox/.config"}}, Command: []string{"bash", "-c", "exec claude"}, }) data, _ := os.ReadFile(argsFile) diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 6848a16..9418f11 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -79,14 +79,18 @@ type SandboxInfo struct { Phase string } +type Upload struct { + Src string + Dst string +} + type SandboxCreateOpts struct { Name string From string Providers []string TTY bool Keep bool - UploadSrc string - UploadDst string + Uploads []Upload Command []string Env map[string]string } diff --git a/test/configs/harness-with-configs.yaml b/test/configs/harness-with-configs.yaml deleted file mode 100644 index 0b9c117..0000000 --- a/test/configs/harness-with-configs.yaml +++ /dev/null @@ -1,21 +0,0 @@ ---- -kind: agent -name: test-configs -entrypoint: bash -providers: [] ---- -kind: config -name: claude.json -content: | - {"mcpServers": {"test": {"command": "echo"}}} ---- -kind: config -name: CLAUDE.md -content: | - # Test sandbox - You are in a test environment. ---- -kind: config -name: settings.json -content: | - {"permissions": {"allow": ["Bash(*)"]}} diff --git a/test/configs/harness-with-payloads.yaml b/test/configs/harness-with-payloads.yaml new file mode 100644 index 0000000..616f4d6 --- /dev/null +++ b/test/configs/harness-with-payloads.yaml @@ -0,0 +1,19 @@ +--- +kind: agent +name: test-payloads +entrypoint: bash +providers: [] +payloads: + - path: /sandbox/.agent-config.json + content: | + {"inline": true} +--- +kind: payload +path: /sandbox/.claude.json +content: | + {"mcpServers": {"test": {"command": "echo"}}} +--- +kind: payload +path: /sandbox/readme.txt +content: | + This is a test payload. diff --git a/test/suite/run.sh b/test/suite/run.sh index 4b590a6..c544f7e 100755 --- a/test/suite/run.sh +++ b/test/suite/run.sh @@ -117,11 +117,11 @@ run_test "parse: default agent (no -f)" \ run_test "parse: custom provider profile" \ "$HARNESS" apply --dry-run -f "$CONFIGS/agent-groq.yaml" -run_test "parse: harness with kind: config" \ - "$HARNESS" apply --dry-run -f "$CONFIGS/harness-with-configs.yaml" +run_test "parse: harness with payloads" \ + "$HARNESS" apply --dry-run -f "$CONFIGS/harness-with-payloads.yaml" -run_test "output: kind: config in -o yaml" \ - bash -c '"$1" apply -o yaml -f "$2" | grep "kind: config"' _ "$HARNESS" "$CONFIGS/harness-with-configs.yaml" +run_test "output: kind: payload in -o yaml" \ + bash -c '"$1" apply -o yaml -f "$2" | grep "kind: payload"' _ "$HARNESS" "$CONFIGS/harness-with-payloads.yaml" run_test_fail "parse: nonexistent file rejects" \ "$HARNESS" apply --dry-run -f "/nonexistent/agent.yaml" From 2402f6914ac7181f79317d85bdbf3fcad1eb37f1 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Tue, 16 Jun 2026 23:33:38 -0700 Subject: [PATCH 4/7] feat: add payload file references to default and opencode agent configs --- profiles/agent-default.yaml | 20 +++++++++++++++----- profiles/agent-opencode.yaml | 10 ++++++++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/profiles/agent-default.yaml b/profiles/agent-default.yaml index 0b88941..62ad5c3 100644 --- a/profiles/agent-default.yaml +++ b/profiles/agent-default.yaml @@ -1,8 +1,8 @@ -# Default agent configuration. +# Default agent configuration (Claude Code). # # Usage: -# harness up # uses profiles/agent-default.yaml -# harness up --agent research # uses profiles/agent-research.yaml +# harness apply # uses this config +# harness apply --agent opencode # uses agent-opencode.yaml name: agent entrypoint: claude @@ -17,9 +17,19 @@ providers: JIRA_USERNAME: ${JIRA_USERNAME} - profile: gws -# Non-secret env vars injected into sandbox. -# Proxy config: gateway routes inference through its local proxy. env: ANTHROPIC_BASE_URL: https://inference.local ANTHROPIC_API_KEY: sk-ant-openshell-proxy-managed CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1" + +payloads: + - path: /sandbox/.claude/CLAUDE.md + file: profiles/images/sandbox-default/CLAUDE.md + - path: /sandbox/.claude.json + file: profiles/images/sandbox-default/claude.json + - path: /sandbox/.claude/settings.json + file: profiles/images/sandbox-default/settings.json + - path: /sandbox/.mcp.json + file: profiles/images/sandbox-default/mcp.json + - path: /etc/openshell/policy.yaml + file: profiles/images/sandbox-default/policy.yaml diff --git a/profiles/agent-opencode.yaml b/profiles/agent-opencode.yaml index ffd96d4..0afcba4 100644 --- a/profiles/agent-opencode.yaml +++ b/profiles/agent-opencode.yaml @@ -3,7 +3,6 @@ # OpenCode uses ANTHROPIC_BASE_URL with inference.local/v1 (note the /v1 suffix). # Without /v1, OpenCode sends POST /messages instead of POST /v1/messages, # which doesn't match the inference route pattern. -# See: https://github.com/NVIDIA/OpenShell/blob/main/docs/providers/google-vertex-ai.mdx # # Usage: # harness apply --agent opencode @@ -21,7 +20,14 @@ providers: JIRA_USERNAME: ${JIRA_USERNAME} - profile: gws -# OpenCode needs /v1 suffix on ANTHROPIC_BASE_URL (different from Claude Code). env: ANTHROPIC_BASE_URL: https://inference.local/v1 ANTHROPIC_API_KEY: sk-ant-openshell-proxy-managed + +payloads: + - path: /sandbox/opencode.json + file: profiles/images/sandbox-default/opencode.json + - path: /sandbox/.mcp.json + file: profiles/images/sandbox-default/mcp.json + - path: /etc/openshell/policy.yaml + file: profiles/images/sandbox-default/policy.yaml From eb305e4951aa35abeb7b0518e720ec412883f663 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Tue, 16 Jun 2026 23:36:36 -0700 Subject: [PATCH 5/7] refactor: rename payload fields to sandbox_path/local_path/content --- internal/agent/agent.go | 56 +++++++++++----------- internal/agent/agent_test.go | 32 ++++++------- profiles/agent-default.yaml | 20 ++++---- profiles/agent-opencode.yaml | 12 ++--- profiles/images/sandbox-default/Dockerfile | 5 ++ test/configs/harness-with-payloads.yaml | 6 +-- 6 files changed, 68 insertions(+), 63 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 63a6437..c8405da 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -17,9 +17,9 @@ type ProviderRef struct { } type PayloadEntry struct { - Path string `yaml:"path"` - Content string `yaml:"content,omitempty"` - File string `yaml:"file,omitempty"` + SandboxPath string `yaml:"sandbox_path"` + LocalPath string `yaml:"local_path,omitempty"` + Content string `yaml:"content,omitempty"` } type AgentConfig struct { @@ -101,10 +101,10 @@ type kindHeader struct { // payloadDoc holds a kind: payload document. type payloadDoc struct { - Kind string `yaml:"kind"` - Path string `yaml:"path"` - Content string `yaml:"content,omitempty"` - File string `yaml:"file,omitempty"` + Kind string `yaml:"kind"` + SandboxPath string `yaml:"sandbox_path"` + LocalPath string `yaml:"local_path,omitempty"` + Content string `yaml:"content,omitempty"` } func ParseHarnessFile(path string) (*Harness, error) { @@ -171,19 +171,19 @@ func ParseHarness(data []byte) (*Harness, error) { if err := yaml.Unmarshal(raw, &doc); err != nil { return nil, fmt.Errorf("document %d: parsing payload: %w", docIndex, err) } - if doc.Path == "" { - return nil, fmt.Errorf("document %d: kind: payload requires a path field", docIndex) + if doc.SandboxPath == "" { + return nil, fmt.Errorf("document %d: kind: payload requires a sandbox_path field", docIndex) } - if doc.Content == "" && doc.File == "" { - return nil, fmt.Errorf("document %d: kind: payload requires content or file field", docIndex) + if doc.Content == "" && doc.LocalPath == "" { + return nil, fmt.Errorf("document %d: kind: payload requires content or local_path field", docIndex) } - if doc.Content != "" && doc.File != "" { - return nil, fmt.Errorf("document %d: kind: payload cannot have both content and file", docIndex) + if doc.Content != "" && doc.LocalPath != "" { + return nil, fmt.Errorf("document %d: kind: payload cannot have both content and local_path", docIndex) } h.Payloads = append(h.Payloads, PayloadEntry{ - Path: doc.Path, - Content: doc.Content, - File: doc.File, + SandboxPath: doc.SandboxPath, + Content: doc.Content, + LocalPath: doc.LocalPath, }) case "policy": @@ -239,9 +239,9 @@ func RenderHarness(h *Harness, builtinProviders map[string][]byte) ([]byte, erro } for _, p := range h.Payloads { - buf.WriteString("---\nkind: payload\npath: " + p.Path + "\n") - if p.File != "" { - buf.WriteString("file: " + p.File + "\n") + buf.WriteString("---\nkind: payload\nsandbox_path: " + p.SandboxPath + "\n") + if p.LocalPath != "" { + buf.WriteString("local_path: " + p.LocalPath + "\n") } else if p.Content != "" { buf.WriteString("content: |\n") for _, line := range strings.Split(p.Content, "\n") { @@ -364,37 +364,37 @@ func RenderPayload(cfg *AgentConfig, baseDir, destDir string) error { func ResolvePayloads(payloads []PayloadEntry, baseDir, tmpDir string) ([]struct{ Src, Dst string }, error) { var uploads []struct{ Src, Dst string } for _, p := range payloads { - if !strings.HasPrefix(p.Path, "/sandbox/") { - return nil, fmt.Errorf("payload path %q must start with /sandbox/", p.Path) + if !strings.HasPrefix(p.SandboxPath, "/sandbox/") && !strings.HasPrefix(p.SandboxPath, "/etc/openshell/") { + return nil, fmt.Errorf("payload sandbox_path %q must start with /sandbox/ or /etc/openshell/", p.SandboxPath) } var src string if p.Content != "" { f, err := os.CreateTemp(tmpDir, "payload-*") if err != nil { - return nil, fmt.Errorf("creating temp file for payload %s: %w", p.Path, err) + return nil, fmt.Errorf("creating temp file for payload %s: %w", p.SandboxPath, err) } if _, err := f.WriteString(p.Content); err != nil { f.Close() - return nil, fmt.Errorf("writing payload %s: %w", p.Path, err) + return nil, fmt.Errorf("writing payload %s: %w", p.SandboxPath, err) } f.Close() src = f.Name() - } else if p.File != "" { - resolved := p.File + } else if p.LocalPath != "" { + resolved := p.LocalPath if !filepath.IsAbs(resolved) { resolved = filepath.Join(baseDir, resolved) } clean := filepath.Clean(resolved) rel, err := filepath.Rel(filepath.Clean(baseDir), clean) if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return nil, fmt.Errorf("payload file %q escapes base directory", p.File) + return nil, fmt.Errorf("payload local_path %q escapes base directory", p.LocalPath) } if _, err := os.Stat(clean); err != nil { - return nil, fmt.Errorf("payload file %s: %w", p.File, err) + return nil, fmt.Errorf("payload local_path %s: %w", p.LocalPath, err) } src = clean } - uploads = append(uploads, struct{ Src, Dst string }{Src: src, Dst: p.Path}) + uploads = append(uploads, struct{ Src, Dst string }{Src: src, Dst: p.SandboxPath}) } return uploads, nil } diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index cd5cada..1565f1a 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -555,12 +555,12 @@ name: test providers: [] --- kind: payload -path: /sandbox/.claude.json +sandbox_path: /sandbox/.claude.json content: | {"mcpServers": {}} --- kind: payload -path: /sandbox/.claude/CLAUDE.md +sandbox_path: /sandbox/.claude/CLAUDE.md content: | # Test Hello world. @@ -572,8 +572,8 @@ content: | if len(h.Payloads) != 2 { t.Fatalf("Payloads = %d, want 2", len(h.Payloads)) } - if h.Payloads[0].Path != "/sandbox/.claude.json" { - t.Errorf("Payloads[0].Path = %q", h.Payloads[0].Path) + if h.Payloads[0].SandboxPath != "/sandbox/.claude.json" { + t.Errorf("Payloads[0].SandboxPath = %q", h.Payloads[0].SandboxPath) } if !strings.Contains(h.Payloads[1].Content, "Hello world") { t.Errorf("Payloads[1].Content = %q", h.Payloads[1].Content) @@ -594,7 +594,7 @@ content: | if err == nil { t.Fatal("expected error for payload without path") } - if !strings.Contains(err.Error(), "requires a path") { + if !strings.Contains(err.Error(), "requires a sandbox_path") { t.Errorf("error = %q", err) } } @@ -606,13 +606,13 @@ name: test providers: [] --- kind: payload -path: /sandbox/empty.txt +sandbox_path: /sandbox/empty.txt `) _, err := ParseHarness(data) if err == nil { t.Fatal("expected error for payload without content or file") } - if !strings.Contains(err.Error(), "requires content or file") { + if !strings.Contains(err.Error(), "requires content or local_path") { t.Errorf("error = %q", err) } } @@ -624,9 +624,9 @@ name: test providers: [] --- kind: payload -path: /sandbox/test.txt +sandbox_path: /sandbox/test.txt content: hello -file: test.txt +local_path: test.txt `) _, err := ParseHarness(data) if err == nil { @@ -644,7 +644,7 @@ name: test providers: [] --- kind: config -path: /sandbox/.claude.json +sandbox_path: /sandbox/.claude.json content: | {"test": true} `) @@ -663,7 +663,7 @@ kind: agent name: test providers: [] payloads: - - path: /sandbox/.mcp.json + - sandbox_path: /sandbox/.mcp.json content: | {"servers": {}} `) @@ -674,15 +674,15 @@ payloads: if len(h.Payloads) != 1 { t.Fatalf("Payloads = %d, want 1", len(h.Payloads)) } - if h.Payloads[0].Path != "/sandbox/.mcp.json" { - t.Errorf("Path = %q", h.Payloads[0].Path) + if h.Payloads[0].SandboxPath != "/sandbox/.mcp.json" { + t.Errorf("Path = %q", h.Payloads[0].SandboxPath) } } func TestResolvePayloads_Content(t *testing.T) { tmpDir := t.TempDir() payloads := []PayloadEntry{ - {Path: "/sandbox/.claude.json", Content: `{"test": true}`}, + {SandboxPath: "/sandbox/.claude.json", Content: `{"test": true}`}, } uploads, err := ResolvePayloads(payloads, t.TempDir(), tmpDir) if err != nil { @@ -707,7 +707,7 @@ func TestResolvePayloads_File(t *testing.T) { baseDir := t.TempDir() os.WriteFile(filepath.Join(baseDir, "test.json"), []byte(`{"from": "file"}`), 0o644) payloads := []PayloadEntry{ - {Path: "/sandbox/test.json", File: "test.json"}, + {SandboxPath: "/sandbox/test.json", LocalPath: "test.json"}, } uploads, err := ResolvePayloads(payloads, baseDir, t.TempDir()) if err != nil { @@ -727,7 +727,7 @@ func TestResolvePayloads_File(t *testing.T) { func TestResolvePayloads_InvalidPath(t *testing.T) { payloads := []PayloadEntry{ - {Path: "/etc/passwd", Content: "bad"}, + {SandboxPath: "/etc/passwd", Content: "bad"}, } _, err := ResolvePayloads(payloads, t.TempDir(), t.TempDir()) if err == nil { diff --git a/profiles/agent-default.yaml b/profiles/agent-default.yaml index 62ad5c3..1db3f13 100644 --- a/profiles/agent-default.yaml +++ b/profiles/agent-default.yaml @@ -23,13 +23,13 @@ env: CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1" payloads: - - path: /sandbox/.claude/CLAUDE.md - file: profiles/images/sandbox-default/CLAUDE.md - - path: /sandbox/.claude.json - file: profiles/images/sandbox-default/claude.json - - path: /sandbox/.claude/settings.json - file: profiles/images/sandbox-default/settings.json - - path: /sandbox/.mcp.json - file: profiles/images/sandbox-default/mcp.json - - path: /etc/openshell/policy.yaml - file: profiles/images/sandbox-default/policy.yaml + - sandbox_path: /sandbox/.claude/CLAUDE.md + local_path: profiles/images/sandbox-default/CLAUDE.md + - sandbox_path: /sandbox/.claude.json + local_path: profiles/images/sandbox-default/claude.json + - sandbox_path: /sandbox/.claude/settings.json + local_path: profiles/images/sandbox-default/settings.json + - sandbox_path: /sandbox/.mcp.json + local_path: profiles/images/sandbox-default/mcp.json + - sandbox_path: /etc/openshell/policy.yaml + local_path: profiles/images/sandbox-default/policy.yaml diff --git a/profiles/agent-opencode.yaml b/profiles/agent-opencode.yaml index 0afcba4..219841b 100644 --- a/profiles/agent-opencode.yaml +++ b/profiles/agent-opencode.yaml @@ -25,9 +25,9 @@ env: ANTHROPIC_API_KEY: sk-ant-openshell-proxy-managed payloads: - - path: /sandbox/opencode.json - file: profiles/images/sandbox-default/opencode.json - - path: /sandbox/.mcp.json - file: profiles/images/sandbox-default/mcp.json - - path: /etc/openshell/policy.yaml - file: profiles/images/sandbox-default/policy.yaml + - sandbox_path: /sandbox/opencode.json + local_path: profiles/images/sandbox-default/opencode.json + - sandbox_path: /sandbox/.mcp.json + local_path: profiles/images/sandbox-default/mcp.json + - sandbox_path: /etc/openshell/policy.yaml + local_path: profiles/images/sandbox-default/policy.yaml diff --git a/profiles/images/sandbox-default/Dockerfile b/profiles/images/sandbox-default/Dockerfile index 8404274..04608e0 100644 --- a/profiles/images/sandbox-default/Dockerfile +++ b/profiles/images/sandbox-default/Dockerfile @@ -62,6 +62,11 @@ RUN npm install -g opencode-ai@latest COPY policy.yaml /etc/openshell/policy.yaml # Agent instructions + config (Claude Code + OpenCode) +# These are defaults. Override any file via payloads: in the agent YAML: +# payloads: +# - sandbox_path: /sandbox/.claude/CLAUDE.md +# content: | +# Custom instructions here. COPY CLAUDE.md /sandbox/.claude/CLAUDE.md COPY settings.json /sandbox/.claude/settings.json COPY claude.json /sandbox/.claude.json diff --git a/test/configs/harness-with-payloads.yaml b/test/configs/harness-with-payloads.yaml index 616f4d6..d26557f 100644 --- a/test/configs/harness-with-payloads.yaml +++ b/test/configs/harness-with-payloads.yaml @@ -4,16 +4,16 @@ name: test-payloads entrypoint: bash providers: [] payloads: - - path: /sandbox/.agent-config.json + - sandbox_path: /sandbox/.agent-config.json content: | {"inline": true} --- kind: payload -path: /sandbox/.claude.json +sandbox_path: /sandbox/.claude.json content: | {"mcpServers": {"test": {"command": "echo"}}} --- kind: payload -path: /sandbox/readme.txt +sandbox_path: /sandbox/readme.txt content: | This is a test payload. From e1794072b2cbdaa32cd59173c5704ca80bda5514 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 00:09:06 -0700 Subject: [PATCH 6/7] fix: 5 audit fixes + README rewrite for OpenShell relationship Fixes from multi-agent audit: 1. deploy.go: stale 'harness teardown' -> 'harness delete' 2. apply.go: print resolved config path on apply 3. get.go: separate ACTIVE column instead of * prefix on name 4. agent.go: fix expandEnvVar fallback (empty value reads from host, non-empty value expands only - no silent key-name resolution) 5. sandbox.go: use opts.retrySleep in message, extract maxRetries const README rewrite: - Honest about OpenShell relationship (runtime vs config layer) - "What OpenShell provides" vs "What the harness adds" - "Use OpenShell directly for runtime operations" - No inflating what the harness does --- README.md | 31 ++++++++++++++++++++++--------- cmd/apply.go | 2 ++ cmd/deploy.go | 2 +- cmd/get.go | 4 ++-- cmd/sandbox.go | 8 ++++---- internal/agent/agent.go | 7 +++---- 6 files changed, 34 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 3ee45ed..85fac85 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,34 @@ # harness -Deploy a fully sandboxed AI agent in one command. +Declarative configuration harness for [OpenShell](https://github.com/NVIDIA/OpenShell) AI agent sandboxes. ```bash harness apply -f agent.yaml ``` -One file defines your agent, providers, gateway, and policy. The harness resolves credentials, deploys the gateway, registers providers, and launches a sandbox with deny-by-default L7 egress. Works on local Podman and remote Kubernetes/OpenShift with the same config. +## OpenShell provides the runtime -## What You Get +[OpenShell](https://github.com/NVIDIA/OpenShell) runs AI agents in sandboxed containers with deny-by-default L7 network policy, credential proxy at the network boundary, Landlock filesystem isolation, and inference routing. The harness does not replace any of this. -- **Sandbox isolation** -- every agent runs in a container with Landlock filesystem restrictions and deny-by-default network policy -- **Credential proxy** -- secrets are resolved at the gateway boundary, never exposed inside the sandbox -- **Multi-target** -- same agent YAML deploys to local Podman, kind, or OpenShift -- **Declarative config** -- multi-document YAML bundles agent + providers + gateway + policy in one file -- **Dry-run validation** -- `--dry-run` checks gateway, providers, env vars, and image before deploying -- **Config inspection** -- `-o yaml` outputs the fully resolved harness config +## The harness adds declarative configuration + +- **One-file agent definition** -- agent, providers, gateway, policy, and sandbox files in a single YAML +- **Multi-document YAML** -- `kind: agent/provider/gateway/payload/policy` composed in one file +- **Payload files** -- upload configs to sandbox paths without rebuilding the image +- **Multi-target deploy** -- same YAML works on local Podman, kind, and OpenShift +- **Dry-run validation** -- `--dry-run` checks everything before deploying +- **Config inspection** -- `-o yaml` outputs the fully resolved config + +## Use OpenShell directly for runtime operations + +```bash +openshell sandbox connect # interactive shell +openshell sandbox exec -- ... # run commands +openshell sandbox logs # view logs +openshell policy get # inspect policy +``` + +The harness handles setup. OpenShell handles the runtime. ## Install diff --git a/cmd/apply.go b/cmd/apply.go index 150fca7..78761ac 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -46,6 +46,8 @@ then deploy a sandbox. Use --dry-run to validate without deploying, or agentCfg := harness.Agent agentPath := resolveAgentPath(harnessDir, agentName, file) + status.Infof("Config: %s", agentPath) + // Resolve output modes before touching the gateway if output == "yaml" || output == "json" { return renderOutput(harnessDir, harness, output) diff --git a/cmd/deploy.go b/cmd/deploy.go index fae030b..ba7577b 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -139,7 +139,7 @@ func deployLocal(gw gateway.Gateway) error { func deployFromConfig(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gateway, kc, clusterRunner k8s.Runner) (retErr error) { defer func() { if retErr != nil { - fmt.Fprintf(os.Stderr, "\nDeploy failed. Clean up with: harness teardown --k8s\n") + fmt.Fprintf(os.Stderr, "\nDeploy failed. Clean up with: harness delete --k8s\n") } }() ctx := context.Background() diff --git a/cmd/get.go b/cmd/get.go index 8d8e76b..37db09f 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -175,9 +175,9 @@ func newGetGatewaysCmd(cli string) *cobra.Command { if g.Active { active = "*" } - rows[i] = []string{active + g.Name, g.Endpoint} + rows[i] = []string{g.Name, g.Endpoint, active} } - printTable([]string{"Name", "Endpoint"}, rows) + printTable([]string{"Name", "Endpoint", "Active"}, rows) return nil }, } diff --git a/cmd/sandbox.go b/cmd/sandbox.go index e8c0dbd..d3e49bd 100644 --- a/cmd/sandbox.go +++ b/cmd/sandbox.go @@ -56,8 +56,8 @@ func createSandbox(opts sandboxOpts) error { return fmt.Errorf("staging payload: %w", err) } - // Create sandbox with retry loop (up to 5 attempts). - for attempt := 1; attempt <= 5; attempt++ { + const maxRetries = 5 + for attempt := 1; attempt <= maxRetries; attempt++ { uploads := []gateway.Upload{{Src: uploadDir, Dst: "/sandbox/.config"}} uploads = append(uploads, opts.uploads...) @@ -78,10 +78,10 @@ func createSandbox(opts sandboxOpts) error { return nil } - status.Warnf("attempt %d: %v, retrying in 5s", attempt, err) + status.Warnf("attempt %d: %v, retrying in %s", attempt, err, opts.retrySleep) opts.gw.SandboxDelete(opts.name) // best-effort cleanup - if attempt == 5 { + if attempt == maxRetries { return fmt.Errorf("sandbox create failed after 5 attempts: %w", err) } time.Sleep(opts.retrySleep) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index c8405da..01ced01 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -259,11 +259,10 @@ func RenderHarness(h *Harness, builtinProviders map[string][]byte) ([]byte, erro } func expandEnvVar(key, value string) string { - expanded := os.ExpandEnv(value) - if expanded == "" { - expanded = os.Getenv(key) + if value == "" { + return os.Getenv(key) } - return expanded + return os.ExpandEnv(value) } func (c *AgentConfig) BuildEnvMap() map[string]string { From 6766bc178833c7fe8b8d3a9bc5a80c555622c7a1 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Wed, 17 Jun 2026 00:09:49 -0700 Subject: [PATCH 7/7] fix: suppress config info line for structured output --- cmd/apply.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/apply.go b/cmd/apply.go index 78761ac..dc8d3be 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -46,7 +46,10 @@ then deploy a sandbox. Use --dry-run to validate without deploying, or agentCfg := harness.Agent agentPath := resolveAgentPath(harnessDir, agentName, file) - status.Infof("Config: %s", agentPath) + // Print config path (skip for structured output -- goes to stderr) + if output == "" { + status.Infof("Config: %s", agentPath) + } // Resolve output modes before touching the gateway if output == "yaml" || output == "json" {