diff --git a/.gitignore b/.gitignore index 201e825..bb80933 100644 --- a/.gitignore +++ b/.gitignore @@ -1,28 +1,26 @@ -# Credentials — never commit + +__pycache__/ +.certs/ +.claude/ +.gateway-env *-token.txt *.json.key -acs-ai-sa.json -secrets.yaml -kubeconfig -quay-pull-secret.yaml - -# Local state *.log *.tmp -__pycache__/ -.gateway-env -.certs/ - # Build artifacts +# Credentials — never commit +# Local state +# Planning artifacts (local only) +acs-ai-sa.json +dashboard.html harness harness-openshell infracluster/ -.claude/ - -# Planning artifacts (local only) -plans/ -openshell-arch/ -strategy-dashboard.html -dashboard.html +kubeconfig mlflow.db +openshell-arch/ plan_file_ga.md +plans/ +quay-pull-secret.yaml +secrets.yaml +strategy-dashboard.html diff --git a/README.md b/README.md index 85fac85..f1bc06f 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ harness apply -f agent.yaml - **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 +- **Headless task mode** -- `--task "do something"` runs the agent and outputs to stdout +- **Multi-target deploy** -- same YAML works on local Podman, kind, and OpenShell - **Dry-run validation** -- `--dry-run` checks everything before deploying - **Config inspection** -- `-o yaml` outputs the fully resolved config @@ -49,20 +50,27 @@ Or build from source: `make cli` # Set credentials (missing ones are skipped gracefully) export GITHUB_TOKEN=ghp_... -# Deploy a sandbox -./harness apply +# Deploy a sandbox with the default agent config +harness apply -f profiles/agent-default.yaml + +# Run a task headlessly (agent outputs to stdout) +harness apply -f profiles/agent-default.yaml --task "review this codebase for security issues" + +# Run a task from a file +harness apply -f profiles/agent-default.yaml --task @tasks/review.md # Interactive mode -./harness apply --attach +harness apply -f profiles/agent-default.yaml --attach # Validate without deploying -./harness apply --dry-run +harness apply -f profiles/agent-default.yaml --dry-run # See the fully resolved config -./harness apply -o yaml -``` +harness apply -f profiles/agent-default.yaml -o yaml -The built-in config registers providers for GitHub, Jira, Vertex AI, and Google Workspace. Providers with missing credentials are skipped with an info message. +# Override the entrypoint +harness apply -f profiles/agent-default.yaml --entrypoint opencode +``` ## The Agent YAML @@ -84,6 +92,14 @@ providers: env: ANTHROPIC_BASE_URL: https://inference.local ANTHROPIC_API_KEY: sk-ant-openshell-proxy-managed + +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/.mcp.json + local_path: profiles/images/sandbox-default/mcp.json ``` ### Multi-Document Harness YAML @@ -95,7 +111,6 @@ Bundle everything in one file: kind: agent name: my-agent entrypoint: claude -gateway: local providers: - profile: github --- @@ -103,12 +118,17 @@ kind: provider name: github type: github credentials: [GITHUB_TOKEN] -endpoints: - - { host: "api.github.com", port: 443 } --- -kind: gateway -name: local -type: local +kind: payload +sandbox_path: /sandbox/.claude/CLAUDE.md +content: | + You are a security review agent. +--- +kind: policy +network_policies: + github: + endpoints: + - { host: "api.github.com", port: 443 } ``` ```bash @@ -118,21 +138,12 @@ harness apply -f harness.yaml ## Targets ```bash -harness apply # local Podman (default) -harness apply --gateway ocp # deploy to OpenShift -harness deploy ocp # deploy gateway only +harness apply -f profiles/agent-default.yaml # local Podman +harness apply -f profiles/agent-default.yaml --gateway ocp # deploy to OpenShift +harness apply -f profiles/agent-opencode.yaml # OpenCode agent +harness deploy ocp # deploy gateway only ``` -The `gateway:` field in the agent YAML or `--gateway` flag selects the target. Gateway profiles live in `profiles/gateways/`. - -### OpenCode - -```bash -harness apply --agent opencode -``` - -Same providers and gateway, different agent binary. See `profiles/agent-opencode.yaml`. - ## How It Works ``` @@ -159,10 +170,11 @@ See the [OpenShell docs](https://github.com/NVIDIA/OpenShell) for the full secur ### Commands ``` -harness apply [-f FILE] [--agent NAME] [--gateway NAME] [--attach] [--dry-run] [-o yaml|json] - Deploy a sandboxed agent. Primary command. - -f loads a harness/agent YAML file directly. - --agent selects from profiles/ by name (default: "default"). +harness apply -f FILE [--task TEXT|@FILE] [--entrypoint NAME] [--gateway NAME] [--attach] [--dry-run] [-o yaml|json] + Deploy a sandboxed agent from a config file. + -f is required -- always specify which config to apply. + --task runs the agent headlessly with a task (inline text or @filepath). + --entrypoint overrides the agent entrypoint (claude, opencode, bash). --attach enables interactive TTY mode. --dry-run validates without deploying. -o yaml outputs the fully resolved config. @@ -170,46 +182,31 @@ harness apply [-f FILE] [--agent NAME] [--gateway NAME] [--attach] [--dry-run] [ harness deploy [local|ocp|kind] Deploy or verify the gateway for a target. -harness get agents [-o table|json|yaml] - List running sandboxes. Wraps openshell sandbox list with - consistent structured output. Aliases: sandboxes, sandbox. - -harness get providers [-o table|json|yaml] - List registered providers. Credentials never included in output. - -harness get gateways [-o table|json|yaml] - List gateways. Aliases: gateway, gw. - -harness describe - Detailed status for a specific sandbox (phase, gateway, providers). - -harness delete [...] - Delete specific sandboxes by name. - -harness delete --all - Delete all sandboxes, providers, and k8s resources. +harness get agents|providers|gateways [-o table|json|yaml] + List resources with consistent structured output. -harness delete --providers / --k8s - Delete providers or k8s resources selectively. +harness describe [-o table|json|yaml] + Detailed status for a specific sandbox. -harness stop [NAME] / harness start [NAME] - Stop or start a sandbox without deleting it. +harness delete [--all] [--providers] [--k8s] + Delete sandboxes or other resources. ``` -For sandbox connect/logs, use openshell directly: +For runtime operations, use openshell directly: ``` openshell sandbox connect [NAME] openshell sandbox logs [NAME] [--tail] +openshell sandbox exec [NAME] -- ... ``` ### Config Files | File | Purpose | |------|---------| -| `profiles/agent-*.yaml` | Agent config: image, entrypoint, providers, env, optional task file | +| `profiles/agent-*.yaml` | Agent config: image, entrypoint, providers, env, payloads, task | | `profiles/providers/` | OpenShell provider profiles (imported to gateway on registration) | | `profiles/gateways/*.yaml` | Gateway profiles: `local.yaml`, `kind.yaml`, `ocp.yaml` | -| `profiles/images/sandbox-default/` | Sandbox image: Dockerfile, policy, MCP configs, Claude settings | +| `profiles/images/sandbox-default/` | Sandbox image defaults (overridable via payloads) | ### Credentials diff --git a/SPEC.md b/SPEC.md index 191c0f4..251df1f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -122,9 +122,7 @@ Delete sandboxes by name, or use flags for bulk operations. `--all` deletes sand Deploy or verify the gateway for a target. Reads `profiles/gateways/.yaml`. -### `harness stop [NAME]` / `harness start [NAME]` -Stop or start a sandbox without deleting it. ### Deprecated Aliases diff --git a/TODO.md b/TODO.md index 138306b..6a02c44 100644 --- a/TODO.md +++ b/TODO.md @@ -119,8 +119,7 @@ as strongest OTel backend for full signal coverage. - [ ] `harness runs list/show` queries traces from the backend - [ ] Headless mode (`harness run --task '...'`) records automatically -## Deferred (post-0.1) +## Deferred -- [ ] Multi-agent workflow support (fleet.yaml / workflow.yaml) -- [ ] `harness policy suggest` (DenialEvent stream -> policy proposals) -- [ ] Fleet management (multi-gateway kubectl-context style) +- [ ] `harness init` -- generate default harness.yaml, print next steps (highest-impact missing feature) +- [ ] Pre-flight checks in apply (openshell exists, podman running, credentials present) diff --git a/cmd/apply.go b/cmd/apply.go index dc8d3be..ca32337 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "time" "github.com/robbycochran/harness-openshell/internal/agent" @@ -19,6 +20,8 @@ func NewApplyCmd(harnessDir, cli string) *cobra.Command { gatewayName string gatewayProfile string sandboxName string + task string + entrypoint string attach bool providerRefresh bool dryRun bool @@ -46,7 +49,38 @@ 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) + // CLI overrides + if entrypoint != "" { + agentCfg.Entrypoint = entrypoint + } + if task != "" && !attach { + // Headless task: set TTY=false so BuildRunSh generates --print + f := false + agentCfg.TTY = &f + } + if task != "" { + if strings.HasPrefix(task, "@") { + path := task[1:] + if path == "" { + return fmt.Errorf("--task @: missing file path after @") + } + agentCfg.Task = path + } else { + tmpTask, err := os.CreateTemp("", "harness-task-*.md") + if err != nil { + return fmt.Errorf("creating task file: %w", err) + } + defer os.Remove(tmpTask.Name()) + if _, err := tmpTask.WriteString(task); err != nil { + tmpTask.Close() + return fmt.Errorf("writing task: %w", err) + } + tmpTask.Close() + agentCfg.Task = tmpTask.Name() + } + } + + // Print config path (skip for structured output) if output == "" { status.Infof("Config: %s", agentPath) } @@ -107,6 +141,8 @@ then deploy a sandbox. Use --dry-run to validate without deploying, or cmd.Flags().StringVar(&gatewayName, "gateway", envOr("OPENSHELL_GATEWAY", ""), "Gateway profile name (local, kind, ocp)") cmd.Flags().StringVar(&gatewayProfile, "gateway-profile", "", "Path to gateway profile YAML") cmd.Flags().StringVar(&sandboxName, "name", "", "Sandbox name (overrides agent config)") + cmd.Flags().StringVar(&task, "task", "", "Task to pass to the agent (inline text or @filepath)") + cmd.Flags().StringVar(&entrypoint, "entrypoint", "", "Override agent entrypoint (claude, opencode, bash)") cmd.Flags().BoolVar(&attach, "attach", false, "Attach TTY after creation (interactive mode)") cmd.Flags().BoolVar(&providerRefresh, "provider-refresh", false, "Delete and recreate all providers") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Validate configuration without deploying") diff --git a/cmd/deploy.go b/cmd/deploy.go index ba7577b..81babcc 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -16,11 +16,7 @@ import ( ) func NewDeployCmd(harnessDir, cli string) *cobra.Command { - var ( - local bool - remote bool - kubeconfig string - ) + var kubeconfig string cmd := &cobra.Command{ Use: "deploy [gateway]", @@ -28,7 +24,7 @@ func NewDeployCmd(harnessDir, cli string) *cobra.Command { Long: "Deploy a gateway by name (e.g., local, ocp, kind). Reads configuration from profiles/gateways/.yaml.", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - gatewayName, err := resolveGatewayName(args, local, remote) + gatewayName, err := resolveGatewayName(args) if err != nil { return err } @@ -50,25 +46,15 @@ func NewDeployCmd(harnessDir, cli string) *cobra.Command { }, } - cmd.Flags().BoolVar(&local, "local", false, "Alias for 'harness deploy local'") - cmd.Flags().BoolVar(&remote, "remote", false, "Alias for 'harness deploy ocp'") cmd.Flags().StringVar(&kubeconfig, "kubeconfig", "", "Path to kubeconfig (remote only)") - cmd.Flags().MarkHidden("local") - cmd.Flags().MarkHidden("remote") return cmd } -func resolveGatewayName(args []string, local, remote bool) (string, error) { +func resolveGatewayName(args []string) (string, error) { if len(args) > 0 { return args[0], nil } - if local { - return "local", nil - } - if remote { - return "ocp", nil - } return "", fmt.Errorf("specify a gateway: harness deploy ") } diff --git a/cmd/describe.go b/cmd/describe.go index 587ded6..1356254 100644 --- a/cmd/describe.go +++ b/cmd/describe.go @@ -9,6 +9,8 @@ import ( ) func NewDescribeCmd(harnessDir, cli string) *cobra.Command { + var output string + cmd := &cobra.Command{ Use: "describe [NAME]", Short: "Show detailed status for a sandbox", @@ -33,21 +35,54 @@ func NewDescribeCmd(harnessDir, cli string) *cobra.Command { return fmt.Errorf("sandbox %q not found", name) } - status.Header(found.Name) - status.Infof("Phase: %s", found.Phase) - + // Find active gateway + var activeGW *gateway.GatewayInfo gateways, err := gw.GatewayList() if err == nil { - for _, g := range gateways { - if g.Active { - status.Infof("Gateway: %s (%s)", g.Name, g.Endpoint) + for i := range gateways { + if gateways[i].Active { + activeGW = &gateways[i] break } } } - providers, err := gw.ProviderList() - if err == nil && len(providers) > 0 { + // Find providers + providers, _ := gw.ProviderList() + + format, err := parseOutputFormat(output) + if err != nil { + return err + } + + if format != formatTable { + type describeOut struct { + Name string `json:"name" yaml:"name"` + Phase string `json:"phase" yaml:"phase"` + Gateway string `json:"gateway,omitempty" yaml:"gateway,omitempty"` + Endpoint string `json:"endpoint,omitempty" yaml:"endpoint,omitempty"` + Providers []string `json:"providers,omitempty" yaml:"providers,omitempty"` + } + out := describeOut{ + Name: found.Name, + Phase: found.Phase, + Providers: providers, + } + if activeGW != nil { + out.Gateway = activeGW.Name + out.Endpoint = activeGW.Endpoint + } + return printStructured(format, out) + } + + status.Header(found.Name) + status.Infof("Phase: %s", found.Phase) + + if activeGW != nil { + status.Infof("Gateway: %s (%s)", activeGW.Name, activeGW.Endpoint) + } + + if len(providers) > 0 { status.Infof("Providers: %d registered", len(providers)) for _, p := range providers { fmt.Printf(" - %s\n", p) @@ -58,5 +93,6 @@ func NewDescribeCmd(harnessDir, cli string) *cobra.Command { }, } + cmd.Flags().StringVarP(&output, "output", "o", "", "Output format: table, json, or yaml") return cmd } diff --git a/cmd/executor.go b/cmd/executor.go index c08ebfd..bf672a7 100644 --- a/cmd/executor.go +++ b/cmd/executor.go @@ -96,13 +96,13 @@ func upLocal(opts upLocalOpts) error { status.Header("Sandbox") var sandboxCmd []string - if noTTY { + if noTTY && agentCfg.Task == "" { sandboxCmd = []string{"true"} } else { sandboxCmd = []string{"bash", "/sandbox/.config/openshell/run.sh"} } - return createSandbox(sandboxOpts{ + err = createSandbox(sandboxOpts{ harnessDir: opts.harnessDir, gw: gw, name: sandboxName, @@ -115,4 +115,31 @@ func upLocal(opts upLocalOpts) error { uploads: extraUploads, env: agentCfg.BuildEnvMap(), }) + if err != nil { + return err + } + + // Apply custom policy after sandbox creation (kind: policy in harness YAML). + // /etc/openshell/policy.yaml is read-only in the image, so policy changes + // must go through the openshell CLI which hot-reloads the policy. + if opts.harness != nil && opts.harness.Policy != nil { + policyFile, writeErr := os.CreateTemp("", "harness-policy-*.yaml") + if writeErr != nil { + return fmt.Errorf("creating policy temp file: %w", writeErr) + } + defer os.Remove(policyFile.Name()) + if _, writeErr := policyFile.Write(opts.harness.Policy); writeErr != nil { + policyFile.Close() + return fmt.Errorf("writing policy: %w", writeErr) + } + policyFile.Close() + + status.Info("Applying custom policy...") + if err := gw.PolicySet(sandboxName, policyFile.Name()); err != nil { + return fmt.Errorf("applying policy: %w", err) + } + status.OK("Policy applied") + } + + return nil } diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index f98c417..d6e1851 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -54,8 +54,6 @@ func (m *mockGW) ProviderProfileDelete(string) error func (m *mockGW) SettingsSet(string, string) error { return nil } func (m *mockGW) SandboxList() ([]string, error) { return nil, nil } func (m *mockGW) SandboxStatus() ([]gateway.SandboxInfo, error) { return nil, nil } -func (m *mockGW) SandboxStop(string) error { return nil } -func (m *mockGW) SandboxStart(string) error { return nil } func (m *mockGW) GatewayAdd(string, string, bool, bool) error { return nil } func (m *mockGW) GatewayRemove(name string) error { if m.onGatewayRemove != nil { @@ -86,3 +84,4 @@ env: `), 0o644) return dir } +func (m *mockGW) PolicySet(string, string) error { return nil } diff --git a/cmd/providers_test.go b/cmd/providers_test.go index e01ecc6..ed29917 100644 --- a/cmd/providers_test.go +++ b/cmd/providers_test.go @@ -144,3 +144,4 @@ type mockGWWithSandboxes struct { func (m *mockGWWithSandboxes) SandboxList() ([]string, error) { return m.sandboxes, nil } +func (m *mockGWWithSandboxes) PolicySet(string, string) error { return nil } diff --git a/cmd/resolve.go b/cmd/resolve.go index 2be1871..2b17c9b 100644 --- a/cmd/resolve.go +++ b/cmd/resolve.go @@ -11,22 +11,6 @@ import ( "github.com/robbycochran/harness-openshell/internal/gateway" ) -func resolveSandboxName(gw gateway.Gateway, args []string) (string, error) { - if len(args) > 0 { - return args[0], nil - } - names, err := gw.SandboxList() - if err != nil { - return "", fmt.Errorf("listing sandboxes: %w", err) - } - if len(names) == 0 { - return "", fmt.Errorf("no sandboxes running") - } - if len(names) > 1 { - return "", fmt.Errorf("multiple sandboxes running, specify one: %v", names) - } - return names[0], nil -} func resolveAgentPath(harnessDir, agentName, agentFile string) string { if agentFile != "" { diff --git a/cmd/start.go b/cmd/start.go deleted file mode 100644 index c9f00be..0000000 --- a/cmd/start.go +++ /dev/null @@ -1,29 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/robbycochran/harness-openshell/internal/gateway" - "github.com/robbycochran/harness-openshell/internal/status" - "github.com/spf13/cobra" -) - -func NewStartCmd(harnessDir, cli string) *cobra.Command { - return &cobra.Command{ - Use: "start [SANDBOX_NAME]", - Short: "Start a stopped sandbox", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - gw := gateway.New(cli) - name, err := resolveSandboxName(gw, args) - if err != nil { - return err - } - if err := gw.SandboxStart(name); err != nil { - return fmt.Errorf("starting %s: %w", name, err) - } - status.OKf("Started %s", name) - return nil - }, - } -} diff --git a/cmd/start_test.go b/cmd/start_test.go deleted file mode 100644 index 0092eb8..0000000 --- a/cmd/start_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package cmd - -import ( - "fmt" - "testing" -) - -func TestStart_Success(t *testing.T) { - gw := &stopStartMockGW{sandboxNames: []string{"agent"}} - name, _ := resolveSandboxName(gw, nil) - if err := gw.SandboxStart(name); err != nil { - t.Fatal(err) - } - if len(gw.startedNames) != 1 || gw.startedNames[0] != "agent" { - t.Errorf("started = %v", gw.startedNames) - } -} - -func TestStart_Error(t *testing.T) { - gw := &stopStartMockGW{startErr: fmt.Errorf("sandbox not found")} - if err := gw.SandboxStart("missing"); err == nil { - t.Fatal("expected error") - } -} diff --git a/cmd/status_cmd_test.go b/cmd/status_cmd_test.go index efc864f..e61fdbd 100644 --- a/cmd/status_cmd_test.go +++ b/cmd/status_cmd_test.go @@ -50,3 +50,4 @@ func TestRunStatus_NoGateway(t *testing.T) { t.Fatalf("runStatus: %v", err) } } +func (m *statusMockGW) PolicySet(string, string) error { return nil } diff --git a/cmd/stop.go b/cmd/stop.go deleted file mode 100644 index 229cc62..0000000 --- a/cmd/stop.go +++ /dev/null @@ -1,29 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/robbycochran/harness-openshell/internal/gateway" - "github.com/robbycochran/harness-openshell/internal/status" - "github.com/spf13/cobra" -) - -func NewStopCmd(harnessDir, cli string) *cobra.Command { - return &cobra.Command{ - Use: "stop [SANDBOX_NAME]", - Short: "Stop a running sandbox", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - gw := gateway.New(cli) - name, err := resolveSandboxName(gw, args) - if err != nil { - return err - } - if err := gw.SandboxStop(name); err != nil { - return fmt.Errorf("stopping %s: %w", name, err) - } - status.OKf("Stopped %s", name) - return nil - }, - } -} diff --git a/cmd/stop_test.go b/cmd/stop_test.go deleted file mode 100644 index eb69f81..0000000 --- a/cmd/stop_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package cmd - -import ( - "fmt" - "testing" -) - -type stopStartMockGW struct { - mockGW - sandboxNames []string - stoppedNames []string - startedNames []string - stopErr error - startErr error -} - -func (m *stopStartMockGW) SandboxList() ([]string, error) { return m.sandboxNames, nil } -func (m *stopStartMockGW) SandboxStop(name string) error { - m.stoppedNames = append(m.stoppedNames, name) - return m.stopErr -} -func (m *stopStartMockGW) SandboxStart(name string) error { - m.startedNames = append(m.startedNames, name) - return m.startErr -} - -func TestResolveSandboxName_Explicit(t *testing.T) { - gw := &stopStartMockGW{} - name, err := resolveSandboxName(gw, []string{"my-agent"}) - if err != nil { - t.Fatal(err) - } - if name != "my-agent" { - t.Errorf("got %q, want my-agent", name) - } -} - -func TestResolveSandboxName_AutoSingle(t *testing.T) { - gw := &stopStartMockGW{sandboxNames: []string{"agent"}} - name, err := resolveSandboxName(gw, nil) - if err != nil { - t.Fatal(err) - } - if name != "agent" { - t.Errorf("got %q, want agent", name) - } -} - -func TestResolveSandboxName_AmbiguousError(t *testing.T) { - gw := &stopStartMockGW{sandboxNames: []string{"a", "b"}} - _, err := resolveSandboxName(gw, nil) - if err == nil { - t.Fatal("expected error for multiple sandboxes") - } -} - -func TestResolveSandboxName_NoneError(t *testing.T) { - gw := &stopStartMockGW{} - _, err := resolveSandboxName(gw, nil) - if err == nil { - t.Fatal("expected error for no sandboxes") - } -} - -func TestStop_Success(t *testing.T) { - gw := &stopStartMockGW{sandboxNames: []string{"agent"}} - name, _ := resolveSandboxName(gw, nil) - if err := gw.SandboxStop(name); err != nil { - t.Fatal(err) - } - if len(gw.stoppedNames) != 1 || gw.stoppedNames[0] != "agent" { - t.Errorf("stopped = %v", gw.stoppedNames) - } -} - -func TestStop_Error(t *testing.T) { - gw := &stopStartMockGW{stopErr: fmt.Errorf("sandbox not found")} - if err := gw.SandboxStop("missing"); err == nil { - t.Fatal("expected error") - } -} diff --git a/dev-harness.sh b/dev-harness.sh deleted file mode 100755 index e784929..0000000 --- a/dev-harness.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Build CLI + dev images via make, then run harness with all args. -# Pushes images only when --remote is passed (OCP pulls from registry). -# Image layers are cached by podman/docker so rebuilds are fast when -# only code changes. First run pulls base layers (~2min); subsequent -# runs finish in seconds. -# -# Usage: -# ./dev-harness.sh up # local: build only, no push -# ./dev-harness.sh up --remote # remote: build + push to registry -# ./dev-harness.sh providers --force -# -# Env overrides: -# REGISTRY=... image registry (default: ghcr.io/robbycochran/harness-openshell) -# CONTAINER_CLI=... podman or docker (default: podman) - -REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" -VERSION=$(git -C "$REPO_ROOT" describe --tags --always 2>/dev/null || echo dev) -REGISTRY=${REGISTRY:-ghcr.io/robbycochran/harness-openshell} - -if [[ " $* " == *" --remote "* ]]; then - make -C "$REPO_ROOT" cli dev-push -else - make -C "$REPO_ROOT" cli dev-sandbox -fi - -export HARNESS_OS_IMAGE="${REGISTRY}:sandbox-${VERSION}" -exec "$REPO_ROOT/harness" "$@" diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 01ced01..911fe7e 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -297,7 +297,18 @@ func (c *AgentConfig) BuildRunSh() string { b.WriteString("fi\n\n") b.WriteString("# Execute entrypoint\n") if c.Task != "" { - fmt.Fprintf(&b, "exec %s -p \"$(cat \"$PAYLOAD_DIR/task.md\")\"\n", entrypoint) + b.WriteString("TASK=\"$(cat \"$PAYLOAD_DIR/task.md\")\"\n") + if c.NoTTY() { + // Headless: use --print (claude) or run (opencode) for stdout output + switch epBin { + case "opencode": + fmt.Fprintf(&b, "exec %s run \"$TASK\"\n", entrypoint) + default: + fmt.Fprintf(&b, "exec %s --print \"$TASK\"\n", entrypoint) + } + } else { + fmt.Fprintf(&b, "exec %s -p \"$TASK\"\n", entrypoint) + } } else { fmt.Fprintf(&b, "exec %s\n", entrypoint) } @@ -360,8 +371,14 @@ func RenderPayload(cfg *AgentConfig, baseDir, destDir string) error { // 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 } +// ResolvedUpload is a source/destination pair for sandbox file upload. +type ResolvedUpload struct { + Src string + Dst string +} + +func ResolvePayloads(payloads []PayloadEntry, baseDir, tmpDir string) ([]ResolvedUpload, error) { + var uploads []ResolvedUpload 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) @@ -393,7 +410,7 @@ func ResolvePayloads(payloads []PayloadEntry, baseDir, tmpDir string) ([]struct{ } src = clean } - uploads = append(uploads, struct{ Src, Dst string }{Src: src, Dst: p.SandboxPath}) + uploads = append(uploads, ResolvedUpload{Src: src, Dst: p.SandboxPath}) } return uploads, nil } diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 1565f1a..c390e85 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -281,7 +281,7 @@ func TestBuildRunSh(t *testing.T) { if !strings.Contains(runSh, `command -v "claude"`) { t.Error("missing entrypoint validation") } - if !strings.Contains(runSh, `exec claude -p "$(cat "$PAYLOAD_DIR/task.md")"`) { + if !strings.Contains(runSh, `exec claude -p "$TASK"`) { t.Errorf("missing task exec with -p in:\n%s", runSh) } } diff --git a/internal/gateway/cli.go b/internal/gateway/cli.go index e9377d7..7316980 100644 --- a/internal/gateway/cli.go +++ b/internal/gateway/cli.go @@ -92,6 +92,10 @@ func (c *CLI) CheckMinVersion(minVersion string) error { return nil } +func (c *CLI) PolicySet(name, policyFile string) error { + return c.passthrough("policy", "set", name, "--policy", policyFile, "--wait") +} + func (c *CLI) InferenceGet() error { return c.silent("inference", "get") } @@ -250,8 +254,11 @@ func (c *CLI) SandboxCreate(opts SandboxCreateOpts) error { if !opts.Keep { args = append(args, "--no-keep") } - for _, u := range opts.Uploads { - args = append(args, "--upload", u.Src+":"+u.Dst, "--no-git-ignore") + if len(opts.Uploads) > 0 { + for _, u := range opts.Uploads { + args = append(args, "--upload", u.Src+":"+u.Dst) + } + args = append(args, "--no-git-ignore") } if len(opts.Env) > 0 { keys := make([]string, 0, len(opts.Env)) @@ -291,14 +298,6 @@ func (c *CLI) SandboxStatus() ([]SandboxInfo, error) { return infos, nil } -func (c *CLI) SandboxStop(name string) error { - return c.silent("sandbox", "stop", name) -} - -func (c *CLI) SandboxStart(name string) error { - return c.silent("sandbox", "start", name) -} - func (c *CLI) SandboxDelete(name string) error { return c.silent("sandbox", "delete", name) } diff --git a/internal/gateway/cli_test.go b/internal/gateway/cli_test.go index e0d0a87..834dc8e 100644 --- a/internal/gateway/cli_test.go +++ b/internal/gateway/cli_test.go @@ -263,26 +263,6 @@ printf "NAME\tPHASE\n" } } -func TestSandboxStop_Silent(t *testing.T) { - bin := writeStub(t, `#!/bin/bash -exit 0 -`) - gw := New(bin) - if err := gw.SandboxStop("test"); err != nil { - t.Errorf("SandboxStop = %v", err) - } -} - -func TestSandboxStart_Silent(t *testing.T) { - bin := writeStub(t, `#!/bin/bash -exit 0 -`) - gw := New(bin) - if err := gw.SandboxStart("test"); err != nil { - t.Errorf("SandboxStart = %v", err) - } -} - func TestSandboxList_Empty(t *testing.T) { bin := writeStub(t, `#!/bin/bash printf "NAME\tPHASE\n" diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 9418f11..07537bd 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -17,8 +17,9 @@ type Gateway interface { SandboxStatus() ([]SandboxInfo, error) SandboxCreate(opts SandboxCreateOpts) error SandboxDelete(name string) error - SandboxStop(name string) error - SandboxStart(name string) error + + // Policy + PolicySet(name, policyFile string) error // Inference InferenceGet() error diff --git a/main.go b/main.go index 35c60b2..2099ee3 100644 --- a/main.go +++ b/main.go @@ -66,8 +66,6 @@ func main() { cmd.NewDescribeCmd(harnessDir, cli), cmd.NewDeleteCmd(harnessDir, cli), cmd.NewDeployCmd(harnessDir, cli), - cmd.NewStopCmd(harnessDir, cli), - cmd.NewStartCmd(harnessDir, cli), ) // Deprecated aliases diff --git a/profiles/agent-default.yaml b/profiles/agent-default.yaml index 1db3f13..fe7c3c4 100644 --- a/profiles/agent-default.yaml +++ b/profiles/agent-default.yaml @@ -31,5 +31,3 @@ payloads: 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 219841b..c5c5b14 100644 --- a/profiles/agent-opencode.yaml +++ b/profiles/agent-opencode.yaml @@ -29,5 +29,3 @@ payloads: 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/scripts/dev-harness.sh b/scripts/dev-harness.sh new file mode 100755 index 0000000..5391210 --- /dev/null +++ b/scripts/dev-harness.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build CLI and run harness with the latest CI-built sandbox image. +# +# The sandbox image is built by CI and pushed to GHCR. Local podman +# builds on macOS produce images that don't work in OpenShell's sandbox +# runtime, so we pull from the registry instead of building locally. +# +# Usage: +# ./scripts/dev-harness.sh apply +# ./scripts/dev-harness.sh apply --task "review this code" +# ./scripts/dev-harness.sh apply --agent opencode +# +# Env overrides: +# HARNESS_OS_IMAGE=... use a specific image tag +# CONTAINER_CLI=... podman or docker (default: podman) + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +# Build the CLI +make -C "$REPO_ROOT" cli + +# Use CI image: find the latest sandbox tag from GHCR +if [[ -z "${HARNESS_OS_IMAGE:-}" ]]; then + LATEST_TAG=$(gh api user/packages/container/harness-openshell/versions \ + --jq '[.[].metadata.container.tags[] | select(startswith("sandbox-v"))] | first // empty' 2>/dev/null || true) + + if [[ -n "$LATEST_TAG" ]]; then + export HARNESS_OS_IMAGE="ghcr.io/robbycochran/harness-openshell:${LATEST_TAG}" + else + echo "WARNING: could not find CI image, using version-based default" >&2 + fi +fi + +if [[ -n "${HARNESS_OS_IMAGE:-}" ]]; then + echo "Image: ${HARNESS_OS_IMAGE}" >&2 +fi + +exec "$REPO_ROOT/harness" "$@"