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/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/apply.go b/cmd/apply.go index 150fca7..dc8d3be 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -46,6 +46,11 @@ then deploy a sandbox. Use --dry-run to validate without deploying, or agentCfg := harness.Agent agentPath := resolveAgentPath(harnessDir, agentName, file) + // 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" { 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/executor.go b/cmd/executor.go index 0b0d306..c08ebfd 100644 --- a/cmd/executor.go +++ b/cmd/executor.go @@ -82,6 +82,18 @@ func upLocal(opts upLocalOpts) error { return fmt.Errorf("rendering payload: %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}) + } + } + status.Header("Sandbox") var sandboxCmd []string if noTTY { @@ -100,6 +112,7 @@ func upLocal(opts upLocalOpts) error { retrySleep: opts.retrySleep, sandboxCmd: sandboxCmd, payloadDir: payloadDir, + uploads: extraUploads, env: agentCfg.BuildEnvMap(), }) } 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 dad6c65..d3e49bd 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, @@ -55,16 +56,18 @@ 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...) + 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, }) @@ -75,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 af40758..01ced01 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 { + SandboxPath string `yaml:"sandbox_path"` + LocalPath string `yaml:"local_path,omitempty"` + Content string `yaml:"content,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,6 +89,7 @@ type Harness struct { Agent *AgentConfig Gateways map[string][]byte // name -> raw gateway YAML Providers map[string][]byte // name -> raw provider profile YAML + Payloads []PayloadEntry // files to upload to sandbox Policy []byte // raw policy YAML } @@ -91,6 +99,14 @@ type kindHeader struct { Name string `yaml:"name"` } +// payloadDoc holds a kind: payload document. +type payloadDoc struct { + 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) { data, err := os.ReadFile(path) if err != nil { @@ -150,6 +166,26 @@ func ParseHarness(data []byte) (*Harness, error) { } h.Gateways[header.Name] = raw + case "payload", "config": + var doc payloadDoc + if err := yaml.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("document %d: parsing payload: %w", docIndex, err) + } + if doc.SandboxPath == "" { + return nil, fmt.Errorf("document %d: kind: payload requires a sandbox_path 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.LocalPath != "" { + return nil, fmt.Errorf("document %d: kind: payload cannot have both content and local_path", docIndex) + } + h.Payloads = append(h.Payloads, PayloadEntry{ + SandboxPath: doc.SandboxPath, + Content: doc.Content, + LocalPath: doc.LocalPath, + }) + case "policy": if h.Policy != nil { return nil, fmt.Errorf("multiple policy documents found") @@ -165,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 } @@ -200,6 +238,18 @@ func RenderHarness(h *Harness, builtinProviders map[string][]byte) ([]byte, erro buf.Write(data) } + for _, p := range h.Payloads { + 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") { + buf.WriteString(" " + line + "\n") + } + } + } + if h.Policy != nil { buf.WriteString("---\nkind: policy\n") buf.Write(h.Policy) @@ -209,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 { @@ -308,3 +357,43 @@ func RenderPayload(cfg *AgentConfig, baseDir, destDir string) error { return nil } + +// 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.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.SandboxPath, err) + } + if _, err := f.WriteString(p.Content); err != nil { + f.Close() + return nil, fmt.Errorf("writing payload %s: %w", p.SandboxPath, err) + } + f.Close() + src = f.Name() + } 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 local_path %q escapes base directory", p.LocalPath) + } + if _, err := os.Stat(clean); err != nil { + 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.SandboxPath}) + } + return uploads, nil +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 0fc5fb3..1565f1a 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -548,6 +548,193 @@ func TestRenderHarness(t *testing.T) { } } +func TestParseHarness_WithPayloads(t *testing.T) { + data := []byte(`--- +kind: agent +name: test +providers: [] +--- +kind: payload +sandbox_path: /sandbox/.claude.json +content: | + {"mcpServers": {}} +--- +kind: payload +sandbox_path: /sandbox/.claude/CLAUDE.md +content: | + # Test + Hello world. +`) + h, err := ParseHarness(data) + if err != nil { + t.Fatalf("ParseHarness: %v", err) + } + if len(h.Payloads) != 2 { + t.Fatalf("Payloads = %d, want 2", len(h.Payloads)) + } + 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) + } +} + +func TestParseHarness_PayloadRequiresPath(t *testing.T) { + data := []byte(`--- +kind: agent +name: test +providers: [] +--- +kind: payload +content: | + some content +`) + _, err := ParseHarness(data) + if err == nil { + t.Fatal("expected error for payload without path") + } + if !strings.Contains(err.Error(), "requires a sandbox_path") { + t.Errorf("error = %q", err) + } +} + +func TestParseHarness_PayloadRequiresContentOrFile(t *testing.T) { + data := []byte(`--- +kind: agent +name: test +providers: [] +--- +kind: payload +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 local_path") { + t.Errorf("error = %q", err) + } +} + +func TestParseHarness_PayloadContentAndFileMutuallyExclusive(t *testing.T) { + data := []byte(`--- +kind: agent +name: test +providers: [] +--- +kind: payload +sandbox_path: /sandbox/test.txt +content: hello +local_path: 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 +sandbox_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: + - sandbox_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].SandboxPath != "/sandbox/.mcp.json" { + t.Errorf("Path = %q", h.Payloads[0].SandboxPath) + } +} + +func TestResolvePayloads_Content(t *testing.T) { + tmpDir := t.TempDir() + payloads := []PayloadEntry{ + {SandboxPath: "/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) + } +} + +func TestResolvePayloads_File(t *testing.T) { + baseDir := t.TempDir() + os.WriteFile(filepath.Join(baseDir, "test.json"), []byte(`{"from": "file"}`), 0o644) + payloads := []PayloadEntry{ + {SandboxPath: "/sandbox/test.json", LocalPath: "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) + } +} + +func TestResolvePayloads_InvalidPath(t *testing.T) { + payloads := []PayloadEntry{ + {SandboxPath: "/etc/passwd", Content: "bad"}, + } + _, err := ResolvePayloads(payloads, t.TempDir(), t.TempDir()) + if err == nil { + t.Fatal("expected error for path not starting with /sandbox/") + } +} + func TestRenderPayload_IncludePathTraversal(t *testing.T) { baseDir := t.TempDir() cfg := &AgentConfig{ 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/profiles/agent-default.yaml b/profiles/agent-default.yaml index 0b88941..1db3f13 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: + - 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 ffd96d4..219841b 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: + - 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 new file mode 100644 index 0000000..d26557f --- /dev/null +++ b/test/configs/harness-with-payloads.yaml @@ -0,0 +1,19 @@ +--- +kind: agent +name: test-payloads +entrypoint: bash +providers: [] +payloads: + - sandbox_path: /sandbox/.agent-config.json + content: | + {"inline": true} +--- +kind: payload +sandbox_path: /sandbox/.claude.json +content: | + {"mcpServers": {"test": {"command": "echo"}}} +--- +kind: payload +sandbox_path: /sandbox/readme.txt +content: | + This is a test payload. diff --git a/test/suite/run.sh b/test/suite/run.sh index 655f291..c544f7e 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 payloads" \ + "$HARNESS" apply --dry-run -f "$CONFIGS/harness-with-payloads.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"