Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 44 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ environment, then config precedence. Provisioning the gateway is OpenShell's or
HyperShell's job, not the harness's (see [Install](#install)). When none is
declared, apply uses the active OpenShell gateway registration.

### Target resolution

Effective target resolution is:

1. explicit flags (`--gateway`, `--workspace`)
2. environment (`OPENSHELL_GATEWAY`, `OPENSHELL_WORKSPACE`)
3. workflow config (`spec.target.gateway`, `spec.target.workspace`)
4. OpenShell active gateway selection (gateway only)

When no flag, `OPENSHELL_GATEWAY`, or `spec.target.gateway` selects a named
gateway, direct SDK/OIDC targeting can come from
`OPENSHELL_GATEWAY_ENDPOINT` plus all three of `OPENSHELL_OIDC_ISSUER`,
`OPENSHELL_OIDC_CLIENT_ID`, and `OPENSHELL_OIDC_AUDIENCE`.
All direct-target fields are required; otherwise Harness falls back to the
CLI-managed gateway configuration. `OPENSHELL_OIDC_CLIENT_SECRET` remains
required at runtime and is never part of the workflow document.

### One-shot tasks

Run a task headlessly -- the agent executes in a sandbox and outputs results.
Expand Down Expand Up @@ -117,6 +134,13 @@ Managed providers may be updated or explicitly adopted, but apply does not creat
credentialed providers; platform bootstrap owns their creation. Relative payload
and policy paths resolve from the workflow file's directory.

Workflow schema essentials:

- `spec.providers` declares provider resources; `spec.sandbox.providers` attaches provider capabilities to the sandbox runtime.
- `management: referenced` requires an already-registered provider; managed providers can set `adopt: true` to take ownership of a pre-existing provider.
- `spec.inference.verify: true` enforces inference-route endpoint checks during inference route writes.
- `spec.source.repo` is cloned outside the sandbox and uploaded; `spec.payloads[*].source` and `spec.sandbox.policy.file` resolve relative to the workflow file.

Canonical workflows use the OpenShell SDK for sandbox creation, policy
application, readiness, source and payload uploads, execution, and cleanup.
Interactive workflows use the same path with host terminal resize and raw-mode
Expand Down Expand Up @@ -148,6 +172,12 @@ openshell term # interactive policy terminal

`openshell term` provides a live view of policy decisions -- which requests are allowed, denied, or pending review. This is how you audit and tune the deny-by-default L7 network policy while an agent is running.

## Prerequisites

- OpenShell CLI and gateway service at the repo-pinned version (see `make openshell` and `.openshell-version`).
- An active OpenShell gateway registration (`openshell gateway add ...`, `openshell gateway select ...`).
- Provider credentials already reconciled on the gateway for any referenced providers.

## Install

```bash
Expand All @@ -156,8 +186,14 @@ openshell term # interactive policy terminal
# managed gateway service (Homebrew/launchd on macOS, systemd on Linux).
make openshell

# Download the harness binary
curl -L https://github.com/stackrox/harness-openshell/releases/latest/download/harness_darwin_arm64 -o harness
# Download the harness binary for your OS/arch
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) ARCH=amd64 ;;
arm64|aarch64) ARCH=arm64 ;;
esac
curl -L "https://github.com/stackrox/harness-openshell/releases/latest/download/harness_${OS}_${ARCH}" -o harness
chmod +x harness
```

Expand All @@ -175,7 +211,7 @@ openshell gateway select openshell
If you need to restart the service later: `brew services restart openshell`
(macOS) or `systemctl --user restart openshell-gateway` (Linux).

Or build the harness from source: `make cli`
Or build from source with `make cli` (uses your local Go toolchain).

### On a cluster

Expand Down Expand Up @@ -209,11 +245,13 @@ removes the gateway.
| `harness doctor` | Validate gateway reachability and referenced providers |
| `harness apply -f FILE` | Deploy a sandbox from config |
| `harness apply -f FILE --attach` | Interactive TTY mode |
| `harness apply -f FILE --setup-only` | Reconcile providers and inference only (skip sandbox run) |
| `harness apply -f FILE --dry-run` | Render the v1alpha1 action plan without mutating |
| `harness apply -f FILE -o yaml` | Output resolved config with interpolated and credential-bearing map values redacted |
| `harness get agents\|providers\|gateways` | List resources |
| `harness get gateways` | Show active gateway only (name, endpoint, status, version) |
| `harness get agents\|providers` | List resources |
| `harness describe <name>` | Sandbox details |
| `harness delete <name> [--all]` | Tear down |
| `harness delete <name> [--all\|--sandboxes\|--providers]` | Delete targeted or bulk resources |
| `harness plan -f FILE` | Read-only reconciliation plan (mutates nothing) |

### Credentials
Expand Down Expand Up @@ -269,3 +307,4 @@ TTY, so it does not claim a live interactive proof.
| [docs/](docs/) | Repo-facing docs index |
| [docs/ci.md](docs/ci.md) | HyperShell CI bootstrap and repository contract |
| [docs/compatibility.md](docs/compatibility.md) | Tested OpenShell, ACP, and Go versions |
| [profiles/README.md](profiles/README.md) | Profile layout and examples |
50 changes: 11 additions & 39 deletions cmd/apply.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"
"github.com/stackrox/harness-openshell/internal/openshell"
)
Expand All @@ -22,46 +20,20 @@ mutating anything, or -o yaml to output the resolved configuration with
host-interpolated and credential-bearing map values redacted.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if file == "" {
return fmt.Errorf("flag -f/--file is required")
}
if len(args) == 1 && sandboxName == "" {
sandboxName = args[0]
}

workflow, err := loadWorkflow(file, *gatewayName, *workspace, applyOverrides{
Name: sandboxName, AgentType: entrypoint, ForceTTY: attach,
})
if err != nil {
return err
}
if output != "" && !dryRun {
return renderWorkflow(workflow, output)
}

var client openshell.Client
// A non-dry-run apply always asks the SDK factory to resolve its target.
// An empty target means the active CLI-compatible gateway registration;
// dry-run remains fully offline when no target was requested.
if !dryRun || workflow.Target.Direct != nil || workflow.Target.Gateway != "" {
client, err = newClient(cmd.Context(), workflow.Target)
if err != nil {
desc := targetDescription(workflow.Target)
if !dryRun {
return fmt.Errorf("connecting to %s: %w", desc, err)
}
fmt.Fprintf(cmd.ErrOrStderr(), "warning: %s unreachable: %v (rendering desired config only)\n", desc, err)
} else {
defer client.Close()
}
}
planned, current, err := workflow.buildPlan(cmd.Context(), client)
if err != nil {
return err
}
return applyWorkflow(cmd.Context(), workflow, planned, current, client, applyOptions{
SetupOnly: setupOnly, DryRun: dryRun, Output: output,
})
return runApply(cmd.Context(), newClient, applyRequest{
File: file,
Name: sandboxName,
Entrypoint: entrypoint,
Attach: attach,
DryRun: dryRun,
SetupOnly: setupOnly,
Output: output,
Gateway: *gatewayName,
Workspace: *workspace,
}, cmd.ErrOrStderr())
},
}

Expand Down
150 changes: 150 additions & 0 deletions cmd/apply_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package cmd

import (
"context"
"fmt"
"io"
"os"

"github.com/stackrox/harness-openshell/internal/openshell"
"github.com/stackrox/harness-openshell/internal/plan"
"github.com/stackrox/harness-openshell/internal/reconcile"
"github.com/stackrox/harness-openshell/internal/run"
"github.com/stackrox/harness-openshell/internal/status"
)

type applyRequest struct {
File string
Name string
Entrypoint string
Attach bool
DryRun bool
SetupOnly bool
Output string
Gateway string
Workspace string
}

type applyService struct {
newClient openshell.Factory
stderr io.Writer
}

// runApply executes an apply request through the service layer.
func runApply(ctx context.Context, newClient openshell.Factory, req applyRequest, stderr io.Writer) error {
return applyService{newClient: newClient, stderr: stderr}.run(ctx, req)
}

// run loads, resolves, plans, and executes one workflow request.
func (s applyService) run(ctx context.Context, req applyRequest) error {
if req.File == "" {
return fmt.Errorf("flag -f/--file is required")
}

workflow, err := loadWorkflow(req.File, req.Gateway, req.Workspace, applyOverrides{
Name: req.Name, AgentType: req.Entrypoint, ForceTTY: req.Attach,
})
if err != nil {
return err
}
if req.Output != "" && !req.DryRun {
return renderWorkflow(workflow, req.Output)
}

client, planned, current, err := s.connectAndPlan(ctx, workflow, req.DryRun)
if err != nil {
return err
}
if client != nil {
defer client.Close()
}
return executeResolvedWorkflow(ctx, workflow, planned, current, client, applyOptions{
SetupOnly: req.SetupOnly, DryRun: req.DryRun, Output: req.Output,
})
}

// connectAndPlan connects to the selected target when needed and builds the
// plan used by the subsequent execution step.
func (s applyService) connectAndPlan(ctx context.Context, workflow *resolvedWorkflow, dryRun bool) (openshell.Client, *plan.Plan, plan.CurrentState, error) {
var (
client openshell.Client
err error
)
if !dryRun || workflow.Target.Direct != nil || workflow.Target.Gateway != "" {
client, err = s.newClient(ctx, workflow.Target)
if err != nil {
desc := targetDescription(workflow.Target)
if !dryRun {
return nil, nil, plan.CurrentState{}, fmt.Errorf("connecting to %s: %w", desc, err)
}
out := s.stderr
if out == nil {
out = io.Discard
}
fmt.Fprintf(out, "warning: %s unreachable: %v (rendering desired config only)\n", desc, err)
}
}

planned, current, err := workflow.buildPlan(ctx, client)
if err != nil {
if client != nil {
_ = client.Close()
}
return nil, nil, plan.CurrentState{}, err
}
return client, planned, current, nil
}

// executeResolvedWorkflow runs the fully resolved and planned workflow through
// preflight, reconcile, and optional sandbox execution.
func executeResolvedWorkflow(ctx context.Context, workflow *resolvedWorkflow, p *plan.Plan, current plan.CurrentState, client openshell.Client, opts applyOptions) error {
if opts.DryRun {
return renderPlan(p, opts.Output)
}
if client == nil || !current.Reachable {
return fmt.Errorf("%s is not reachable or authenticated", targetDescription(workflow.Target))
}
if err := preflightPlan(workflow.Desired, p); err != nil {
return err
}
if err := verifySandboxProviders(ctx, client, workflow.Desired); err != nil {
return err
}

var req run.SandboxRunRequest
if !opts.SetupOnly && runConfigured(workflow.Desired) {
var (
cleanup func()
err error
)
req, cleanup, err = buildRunRequest(workflow)
if err != nil {
return err
}
defer cleanup()
}

if err := reconcileProviders(ctx, client, workflow.Desired.Spec.Providers); err != nil {
return err
}
if inferenceConfigured(workflow.Desired.Spec.Inference) {
result, err := reconcile.ReconcileInference(ctx, client, workflow.Desired.Spec.Inference)
if err != nil {
return fmt.Errorf("reconciling inference: %w", err)
}
status.OKf("inference: %s (model %s)", result.Action, workflow.Desired.Spec.Inference.Model)
}
if opts.SetupOnly {
status.OK("Setup complete (--setup-only): skipping sandbox creation")
return nil
}
if !runConfigured(workflow.Desired) {
status.OK("Reconciliation complete: workflow declares no sandbox run")
return nil
}
executor, ok := client.(openshell.SandboxExecutionClient)
if !ok {
return fmt.Errorf("configured OpenShell client does not support SDK sandbox execution")
}
return run.Run(ctx, executor, req, os.Stdin, os.Stdout, os.Stderr)
}
41 changes: 13 additions & 28 deletions cmd/describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/stackrox/harness-openshell/internal/status"
)

// NewDescribeCmd constructs the sandbox detail command.
func NewDescribeCmd(newClient openshell.Factory) *cobra.Command {
var output string
var gatewayName, workspace *string
Expand Down Expand Up @@ -42,47 +43,31 @@ func NewDescribeCmd(newClient openshell.Factory) *cobra.Command {
// Gateway context and providers are best-effort: a describe still
// shows the sandbox even if gateway introspection or the provider
// list fails (behavior-preserving with the former CLI path).
var gwName, gwEndpoint string
var gatewayInfo openshell.GatewayInfo
if info, err := client.GatewayInfo(cmd.Context()); err == nil {
gwName = info.Name
gwEndpoint = info.Endpoint
gatewayInfo = info
}

var providerNames []string
if providers, err := client.Providers(cmd.Context()); err == nil {
providerNames = make([]string, len(providers))
for i, p := range providers {
providerNames[i] = p.Name
}
var providers []openshell.Provider
if listedProviders, err := client.Providers(cmd.Context()); err == nil {
providers = listedProviders
}

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"`
}
return printStructured(format, describeOut{
Name: sandbox.Name,
Phase: sandbox.Phase,
Gateway: gwName,
Endpoint: gwEndpoint,
Providers: providerNames,
})
return printStructured(format, describeRecord(sandbox, gatewayInfo, providers))
}

status.Header(sandbox.Name)
status.Infof("Phase: %s", sandbox.Phase)

if gwName != "" {
status.Infof("Gateway: %s (%s)", gwName, gwEndpoint)
if gatewayInfo.Name != "" {
status.Infof("Gateway: %s (%s)", gatewayInfo.Name, gatewayInfo.Endpoint)
}

if len(providerNames) > 0 {
status.Infof("Providers: %d registered", len(providerNames))
for _, p := range providerNames {
providerIDs := providerNames(providers)
if len(providerIDs) > 0 {
status.Infof("Providers: %d registered", len(providerIDs))
for _, p := range providerIDs {
fmt.Printf(" - %s\n", p)
}
}
Expand Down
Loading
Loading