diff --git a/.agents/skills/build-from-issue/SKILL.md b/.agents/skills/build-from-issue/SKILL.md
index 4e73a61be3..c91c712b67 100644
--- a/.agents/skills/build-from-issue/SKILL.md
+++ b/.agents/skills/build-from-issue/SKILL.md
@@ -402,29 +402,24 @@ git diff --name-only main -- e2e/
If there are no changes under `e2e/`, skip this phase entirely.
-If E2E files were modified, deploy to the local cluster and run the E2E test suite:
+If E2E files were modified, run the relevant E2E lane for the driver touched by the change:
```bash
-# Deploy all changes to the local k3s cluster
-mise run cluster:deploy
-
-# Run the E2E sandbox tests
-mise run test:e2e:sandbox
+# Docker-backed gateway smoke E2E
+mise run e2e:docker
```
-`mise run test:e2e:sandbox` depends on `cluster:deploy` and `python:proto`, then runs `uv run pytest -o python_files='test_*.py' e2e/python`. However, since the cluster may need explicit deploy for code changes beyond just E2E test files, always run `mise run cluster:deploy` first as a separate step to ensure all sandbox/proxy/policy changes are live on the cluster before running E2E tests.
+Use `mise run e2e:podman`, `mise run e2e:vm`, or a Helm-backed Kubernetes E2E lane when the change targets those drivers.
**E2E retry loop** (up to 3 attempts):
-1. Run `mise run cluster:deploy` (only on the first attempt, or if code was changed between attempts).
-2. Run `mise run test:e2e:sandbox`.
-3. If tests fail:
+1. Run the selected E2E lane.
+2. If tests fail:
- Read the pytest output carefully — identify which tests failed and why.
- Distinguish between **test bugs** (the test itself is wrong) and **implementation bugs** (the code under test is wrong).
- Fix the failing code or tests.
- - If code changes were made (not just test fixes), re-run `mise run cluster:deploy` before retrying.
- Decrement the retry counter and try again.
-4. If tests pass, Phase 2 is green.
+3. If tests pass, Phase 2 is green.
**If all 3 E2E attempts fail**, stop and report to the user:
- Which E2E tests are failing
@@ -478,7 +473,6 @@ Create the PR:
```bash
gh pr create \
--title "(): " \
- --assignee "@me" \
--body "$(cat <<'EOF'
> **🏗️ build-from-issue-agent**
@@ -577,8 +571,8 @@ Local E2E tests passed. CI does not currently run E2E tests, so this comment ser
| Field | Value |
|-------|-------|
| **Commit** | `` |
-| **Command** | `mise run test:e2e:sandbox` |
-| **Cluster deploy** | `mise run cluster:deploy` (completed before test run) |
+| **Command** | `` |
+| **Gateway mode** | `` |
| **Result** | ✅ All passed |
### Test Summary
@@ -646,8 +640,9 @@ If the `state:in-progress` label is present, the skill was previously started bu
| `gh pr create --title "..." --body "..."` | Create a pull request |
| `gh api user --jq '.login'` | Get current GitHub username |
| `mise run pre-commit` | Run pre-commit checks (includes unit tests, lint, format) |
-| `mise run cluster:deploy` | Deploy all changes to local k3s cluster |
-| `mise run test:e2e:sandbox` | Run E2E sandbox tests (depends on cluster:deploy) |
+| `mise run e2e:docker` | Run smoke E2E against a standalone Docker-backed gateway |
+| `mise run e2e:podman` | Run smoke E2E against a Podman-backed gateway |
+| `mise run e2e:vm` | Run smoke E2E against the VM compute driver |
## Example Usage
diff --git a/.agents/skills/create-github-issue/SKILL.md b/.agents/skills/create-github-issue/SKILL.md
index 15063d2c7f..b1311a297e 100644
--- a/.agents/skills/create-github-issue/SKILL.md
+++ b/.agents/skills/create-github-issue/SKILL.md
@@ -114,7 +114,6 @@ GitHub built-in issue types (`Bug`, `Feature`, `Task`) should come from the matc
| `--title, -t` | Issue title (required) |
| `--body, -b` | Issue description |
| `--label, -l` | Add label (can use multiple times) |
-| `--assignee, -a` | Assign to user |
| `--milestone, -m` | Add to milestone |
| `--project, -p` | Add to project |
| `--web` | Open in browser after creation |
diff --git a/.agents/skills/create-github-pr/SKILL.md b/.agents/skills/create-github-pr/SKILL.md
index 63f8dd5ab6..9050db6de5 100644
--- a/.agents/skills/create-github-pr/SKILL.md
+++ b/.agents/skills/create-github-pr/SKILL.md
@@ -99,22 +99,6 @@ gh pr create --title "PR title" --body "PR description"
- `refactor(models): simplify deployment logic`
- `chore(ci): update Python version in pipeline`
-## Required PR Fields
-
-Every PR **must** have:
-
-1. **Assignee** - Always assign to yourself
-
-## Assignee and Reviewer
-
-### Always Assign to Yourself
-
-**Every PR must be assigned to the user creating it.** Use the `--assignee` flag:
-
-```bash
-gh pr create --title "Title" --assignee "@me"
-```
-
### Link to an Issue
Use `Closes #` in the body to auto-close the issue when merged:
@@ -122,7 +106,6 @@ Use `Closes #` in the body to auto-close the issue when merged:
```bash
gh pr create \
--title "Fix validation error for empty requests" \
- --assignee "@me" \
--body "Closes #123
## Summary
@@ -135,7 +118,7 @@ gh pr create \
For work-in-progress that's not ready for review:
```bash
-gh pr create --draft --title "WIP: New feature" --assignee "@me"
+gh pr create --draft --title "WIP: New feature"
```
### With Labels
@@ -185,7 +168,6 @@ Populate the testing checklist based on what was actually run. Check boxes for s
```bash
gh pr create \
--title "feat(cli): add pagination to sandbox list" \
- --assignee "@me" \
--body "$(cat <<'EOF'
## Summary
@@ -222,7 +204,6 @@ EOF
| ------------------- | ------------------------------------------ |
| `--title, -t` | PR title (use conventional commit format) |
| `--body, -b` | PR description |
-| `--assignee, -a` | Assign to user (use `@me` for yourself) |
| `--reviewer, -r` | Request review from user |
| `--draft` | Create as draft (WIP) |
| `--label, -l` | Add label (can use multiple times) |
diff --git a/.agents/skills/debug-inference/SKILL.md b/.agents/skills/debug-inference/SKILL.md
index 26f87b9166..6770da5987 100644
--- a/.agents/skills/debug-inference/SKILL.md
+++ b/.agents/skills/debug-inference/SKILL.md
@@ -174,7 +174,7 @@ openshell sandbox create -- curl https://inference.local/v1/chat/completions --j
Interpretation:
-- **`cluster inference is not configured`**: set the managed route with `openshell inference set`
+- **`cluster inference is not configured`**: set the managed gateway route with `openshell inference set`
- **`connection not allowed by policy`** on `inference.local`: unsupported method or path
- **`no compatible route`**: provider type and client API shape do not match
- **Connection refused / upstream unavailable / verification failures**: base URL, bind address, topology, or credentials are wrong
@@ -232,7 +232,7 @@ In this case, OpenShell routing is usually working correctly. The failing hop is
This is not the same issue as the Colima CoreDNS fix.
-OpenShell injects `host.docker.internal` and `host.openshell.internal` into sandbox pods with `hostAliases`. That path bypasses cluster DNS lookup. If the request still times out, the usual cause is host firewall or network policy, not CoreDNS.
+OpenShell injects `host.docker.internal` and `host.openshell.internal` into sandbox workloads when the selected compute platform supports it. That path bypasses runtime DNS lookup. If the request still times out, the usual cause is host firewall or network policy, not DNS.
### Verify the Problem
@@ -248,40 +248,41 @@ OpenShell injects `host.docker.internal` and `host.openshell.internal` into sand
curl -sS http://172.17.0.1:11434/v1/models
```
-3. Test the same endpoint from the OpenShell cluster container:
+3. Test the same endpoint from a gateway or sandbox container on the Docker network:
```bash
- docker exec openshell-cluster- wget -qO- -T 5 http://host.docker.internal:11434/v1/models
+ docker ps --filter name=openshell --format '{{.Names}}'
+ docker exec wget -qO- -T 5 http://host.docker.internal:11434/v1/models
```
If steps 1 and 2 succeed but step 3 times out, the host firewall or network configuration is blocking the container-to-host path.
### Fix
-Allow the Docker bridge network used by the OpenShell cluster to reach the host-local inference port. The exact command depends on your firewall tooling (iptables, nftables, firewalld, UFW, etc.), but the rule should allow:
+Allow the Docker bridge network used by the OpenShell gateway and sandbox containers to reach the host-local inference port. The exact command depends on your firewall tooling (iptables, nftables, firewalld, UFW, etc.), but the rule should allow:
-- **Source**: the Docker bridge subnet used by the OpenShell cluster container (commonly `172.18.0.0/16`)
-- **Destination**: the host gateway IP injected into sandbox pods for `host.docker.internal` (commonly `172.17.0.1`)
+- **Source**: the Docker bridge subnet used by OpenShell containers (commonly `172.18.0.0/16`)
+- **Destination**: the host gateway IP injected into sandbox workloads for `host.docker.internal` (commonly `172.17.0.1`)
- **Port**: the inference server port (e.g. `11434/tcp` for Ollama)
To find the actual values on your system:
```bash
-# Docker bridge subnet for the OpenShell cluster network
+# Docker bridge subnet for the OpenShell network
docker network inspect $(docker network ls --filter name=openshell -q) --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'
# Host gateway IP visible from inside the container
-docker exec openshell-cluster- cat /etc/hosts | grep host.docker.internal
+docker exec cat /etc/hosts | grep host.docker.internal
```
Adjust the source subnet, destination IP, or port to match your local Docker network layout.
### Verify the Fix
-1. Re-run the cluster container check:
+1. Re-run the container network check:
```bash
- docker exec openshell-cluster- wget -qO- -T 5 http://host.docker.internal:11434/v1/models
+ docker exec wget -qO- -T 5 http://host.docker.internal:11434/v1/models
```
2. Re-test from a sandbox:
diff --git a/.agents/skills/debug-openshell-cluster/SKILL.md b/.agents/skills/debug-openshell-cluster/SKILL.md
index f4c5672a20..c008ee611e 100644
--- a/.agents/skills/debug-openshell-cluster/SKILL.md
+++ b/.agents/skills/debug-openshell-cluster/SKILL.md
@@ -1,417 +1,214 @@
---
name: debug-openshell-cluster
-description: Debug why a openshell cluster failed to start or is unhealthy. Use when the user has a failed `openshell gateway start`, cluster health check failure, or wants to diagnose cluster infrastructure issues. Trigger keywords - debug cluster, cluster failing, cluster not starting, deploy failed, cluster troubleshoot, cluster health, cluster diagnose, why won't my cluster start, health check failed, gateway start failed, gateway not starting.
+description: Debug why an OpenShell gateway deployment is unhealthy, unreachable, or unable to create sandboxes. Use when the user has a gateway health failure, Docker/Podman runtime issue, Helm install failure, Kubernetes scheduling issue, TLS secret issue, VM driver issue, or sandbox startup problem. Trigger keywords - debug gateway, gateway failing, deployment failing, helm install failing, cluster health, gateway health, gateway not starting, health check failed, sandbox pending, docker driver, podman driver, vm driver.
---
-# Debug OpenShell Cluster
+# Debug OpenShell Gateway Deployment
-Diagnose why a openshell cluster failed to start after `openshell gateway start`.
+Diagnose a gateway and its selected compute platform. Do not assume OpenShell provisions Kubernetes or runs a k3s container. OpenShell targets a reachable gateway endpoint backed by Docker, Podman, Kubernetes, or the experimental VM driver.
-Use **only** `openshell` CLI commands (`openshell status`, `openshell doctor logs`, `openshell doctor exec`) to inspect and fix the cluster. Do **not** use raw `docker`, `ssh`, or `kubectl` commands directly — always go through the `openshell doctor` interface. The CLI auto-resolves local vs remote gateways, so the same commands work everywhere.
+Use `openshell` first to identify the active endpoint. Then use the platform tools that match the gateway's compute driver: `docker`, `podman`, `kubectl`/`helm`, or VM driver logs.
## Overview
-`openshell gateway start` creates a Docker container running k3s with the OpenShell server deployed via Helm. The deployment stages, in order, are:
+The target deployment flow is:
-1. **Pre-deploy check**: `openshell gateway start` in interactive mode prompts to **reuse** (keep volume, clean stale nodes) or **recreate** (destroy everything, fresh start). `mise run cluster` always recreates before deploy.
-2. Ensure cluster image is available (local build or remote pull)
-3. Create Docker network (`openshell-cluster`) and volume (`openshell-cluster-{name}`)
-4. Create and start a privileged Docker container (`openshell-cluster-{name}`)
-5. Wait for k3s to generate kubeconfig (up to 60s)
-6. **Clean stale nodes**: Remove any `NotReady` k3s nodes left over from previous container instances that reused the same persistent volume
-7. **Prepare local images** (if `OPENSHELL_PUSH_IMAGES` is set): In `internal` registry mode, bootstrap waits for the in-cluster registry and pushes tagged images there. In `external` mode, bootstrap uses legacy `ctr -n k8s.io images import` push-mode behavior.
-8. **Reconcile TLS PKI**: Load existing TLS secrets from the cluster; if missing, incomplete, or malformed, generate fresh PKI (CA + server + client certs). Apply secrets to cluster. If rotation happened and the OpenShell workload is already running, rollout restart and wait for completion (failed rollout aborts deploy).
-9. **Store CLI mTLS credentials**: Persist client cert/key/CA locally for CLI authentication.
-10. Wait for cluster health checks to pass (up to 6 min):
- - k3s API server readiness (`/readyz`)
- - `openshell` statefulset ready in `openshell` namespace
- - TLS secrets `openshell-server-tls` and `openshell-client-tls` exist in `openshell` namespace
- - Sandbox supervisor binary exists at `/opt/openshell/bin/openshell-sandbox` (emits `HEALTHCHECK_MISSING_SUPERVISOR` marker if absent)
+1. Operator starts or deploys the gateway.
+2. Operator configures the compute driver.
+3. Operator provides TLS and SSH relay material for the deployment mode.
+4. The CLI registers a reachable gateway endpoint with `openshell gateway add`.
+5. The gateway creates sandboxes through the selected compute driver.
-For local deploys, metadata endpoint selection now depends on Docker connectivity:
-
-- default local Docker socket (`unix:///var/run/docker.sock`): `https://127.0.0.1:{port}` (default port 8080)
-- TCP Docker daemon (`DOCKER_HOST=tcp://:`): `https://:{port}` for non-loopback hosts
-
-The host port is configurable via `--port` on `openshell gateway start` (default 8080) and is stored in `ClusterMetadata.gateway_port`.
-
-The TCP host is also added as an extra gateway TLS SAN so mTLS hostname validation succeeds.
-
-The default cluster name is `openshell`. The container is `openshell-cluster-{name}`.
+For local evaluation only, TLS may be disabled and the gateway can be reached through `http://127.0.0.1:`.
## Prerequisites
-- Docker must be running (locally or on the remote host)
-- The `openshell` CLI must be available
-- For remote clusters: SSH access to the remote host
-
-## Tools Available
-
-All diagnostics go through three `openshell` commands. They auto-resolve local vs remote gateways — the same commands work for both:
-
-```bash
-# Quick connectivity check
-openshell status
-
-# Fetch container logs
-openshell doctor logs --lines 100
-openshell doctor logs --tail # stream live
-
-# Run any command inside the gateway container (KUBECONFIG is pre-configured)
-openshell doctor exec -- kubectl get pods -A
-openshell doctor exec -- kubectl -n openshell logs statefulset/openshell --tail=100
-openshell doctor exec -- cat /etc/rancher/k3s/registries.yaml
-openshell doctor exec -- df -h /
-openshell doctor exec -- free -h
-openshell doctor exec -- sh # interactive shell
-```
+- The `openshell` CLI must be available for endpoint checks.
+- Know the active gateway name and endpoint, or be able to inspect local gateway metadata.
+- Know the compute platform: Docker, Podman, Kubernetes, or VM.
+- For Kubernetes: `kubectl` must target the cluster that hosts OpenShell and Helm version 3 or later must be available.
+- For Docker or Podman: the runtime socket must be reachable from the gateway host.
## Workflow
-When the user asks to debug a cluster failure, **run diagnostics automatically** through the steps below in order. Stop and report findings as soon as a root cause is identified. Do not ask the user to choose which checks to run.
-
-### Determine Context
-
-Before running commands, establish:
-
-1. **Cluster name**: Default is `openshell`, giving container name `openshell-cluster-openshell`
-2. **Remote or local**: The `openshell doctor` commands auto-resolve this from gateway metadata — no special flags needed for the active gateway
-3. **Config directory**: `~/.config/openshell/gateways/{name}/`
-
-### Step 0: Quick Connectivity Check
+Run diagnostics in order and stop once the root cause is clear.
-Run `openshell status` first. This immediately reveals:
-- Which gateway and endpoint the CLI is targeting
-- Whether the CLI can reach the server (mTLS handshake success/failure)
-- The server version if connected
-
-Common errors at this stage:
-- **`tls handshake eof`**: The server isn't running or mTLS credentials are missing/mismatched
-- **`connection refused`**: The container isn't running or port mapping is broken
-- **`No gateway configured`**: No gateway has been deployed yet
-
-### Step 1: Check Container Logs
-
-Get recent container logs to identify startup failures:
+### Step 1: Check CLI Reachability
```bash
-openshell doctor logs --lines 100
+openshell gateway info
+openshell status
```
-Look for:
-
-- DNS resolution failures in the entrypoint script
-- k3s startup errors (certificate issues, port binding failures)
-- Manifest copy errors from `/opt/openshell/manifests/`
-- `iptables` or `cgroup` errors (privilege/capability issues)
-- `Warning: br_netfilter does not appear to be loaded` — this is advisory only; many kernels work without the explicit module. Only act on it if you also see DNS failures or pod-to-service connectivity problems (see Common Failure Patterns).
+Common findings:
-### Step 2: Check k3s Cluster Health
+- `No active gateway`: register one with `openshell gateway add `.
+- Connection refused: gateway process is not running, service exposure is wrong, or a port-forward/proxy is not active.
+- TLS/certificate errors: CLI mTLS bundle does not match the gateway CA, or the gateway is running with unexpected TLS settings.
-Verify k3s itself is functional:
+### Step 2: Identify the Compute Platform
-```bash
-# API server readiness
-openshell doctor exec -- kubectl get --raw="/readyz"
-
-# Node status
-openshell doctor exec -- kubectl get nodes -o wide
-
-# All pods
-openshell doctor exec -- kubectl get pods -A -o wide
-```
-
-If `/readyz` fails, k3s is still starting or has crashed. Check container logs (Step 1).
+Use gateway metadata, deployment values, or the user's setup notes to identify the driver.
-If pods are in `CrashLoopBackOff`, `ImagePullBackOff`, or `Pending`, investigate those pods specifically.
+| Platform | Primary checks |
+|---|---|
+| Docker | Gateway process logs, Docker daemon health, sandbox containers, image pulls. |
+| Podman | Podman socket, rootless networking, sandbox containers, image pulls. |
+| Kubernetes | Helm release, StatefulSet, service, secrets, sandbox pods, events. |
+| VM | VM driver logs, rootfs availability, host virtualization support. |
-Also check for node pressure conditions that cause the kubelet to evict pods and reject scheduling:
+### Step 3: Check Docker-Backed Gateways
```bash
-# Check node conditions (DiskPressure, MemoryPressure, PIDPressure)
-openshell doctor exec -- kubectl get nodes -o jsonpath="{range .items[*]}{.metadata.name}{range .status.conditions[*]} {.type}={.status}{end}{\"\n\"}{end}"
-
-# Check disk usage inside the container
-openshell doctor exec -- df -h /
-
-# Check memory usage
-openshell doctor exec -- free -h
+docker info
+docker ps --filter name=openshell
+docker logs --tail=200
+openshell status
```
-If any pressure condition is `True`, pods will be evicted and new ones rejected. The bootstrap now detects `HEALTHCHECK_NODE_PRESSURE` markers from the health-check script and aborts early with a clear diagnosis. To fix: free disk/memory on the host, then recreate the gateway.
+Common findings:
-### Step 3: Check OpenShell Server StatefulSet
+- Docker daemon unavailable: start Docker Desktop or Docker Engine.
+- Gateway process stopped: inspect exit status and logs.
+- Sandbox image missing or pull denied: verify image reference and registry credentials.
+- Sandbox never registers: check gateway logs and supervisor callback endpoint.
-The OpenShell server is deployed via a HelmChart CR as a StatefulSet named `openshell` in the `openshell` namespace. Check its status:
+For source checkout development, restart the local gateway with:
```bash
-# StatefulSet status
-openshell doctor exec -- kubectl -n openshell get statefulset/openshell -o wide
-
-# OpenShell pod logs
-openshell doctor exec -- kubectl -n openshell logs statefulset/openshell --tail=100
-
-# Describe statefulset for events
-openshell doctor exec -- kubectl -n openshell describe statefulset/openshell
-
-# Helm install job logs (the job that installs the OpenShell chart)
-openshell doctor exec -- kubectl -n kube-system logs -l job-name=helm-install-openshell --tail=200
+mise run gateway:docker
```
-Common issues:
-
-- **Replicas 0/0**: The StatefulSet has been scaled to zero — no pods are running. This can happen after a failed deploy, manual scale-down, or Helm values misconfiguration. Fix: `openshell doctor exec -- kubectl -n openshell scale statefulset openshell --replicas=1`
-- **ImagePullBackOff**: The component image failed to pull. In `internal` mode, verify internal registry readiness and pushed image tags (Step 5). In `external` mode, check `/etc/rancher/k3s/registries.yaml` credentials/endpoints and DNS (Step 8). Default external registry is `ghcr.io/nvidia/openshell/` (public, no auth required). If using a private registry, ensure `--registry-username` and `--registry-token` (or `OPENSHELL_REGISTRY_USERNAME`/`OPENSHELL_REGISTRY_TOKEN`) were provided during deploy.
-- **CrashLoopBackOff**: The server is crashing. Check pod logs for the actual error.
-- **Pending**: Insufficient resources or scheduling constraints.
-
-### Step 4: Check Networking
-
-The OpenShell server is exposed via a NodePort service on port `30051`:
+### Step 4: Check Podman-Backed Gateways
```bash
-# Service status
-openshell doctor exec -- kubectl -n openshell get service/openshell
+podman info
+podman ps --filter name=openshell
+podman logs --tail=200
+openshell status
```
-Expected port: `30051/tcp` (mapped to configurable host port, default 8080; set via `--port` on deploy).
+Common findings:
-### Step 5: Check Image Availability
+- Podman socket unavailable: start or expose the user socket.
+- Rootless networking unavailable: inspect Podman network configuration.
+- Sandbox image missing or pull denied: verify image reference and registry credentials.
+- Supervisor cannot call back: check callback endpoint and gateway logs.
-Component images (server, sandbox) can reach kubelet via two paths:
-
-**Local/external pull mode** (default local via `mise run cluster`): Local images are tagged to the configured local registry base (default `127.0.0.1:5000/openshell/*`), pushed to that registry, and pulled by k3s via `registries.yaml` mirror endpoint (typically `host.docker.internal:5000`). The `cluster` task pushes prebuilt local tags (`openshell/*:dev`, falling back to `localhost:5000/openshell/*:dev` or `127.0.0.1:5000/openshell/*:dev`).
-
-Gateway image builds now stage a partial Rust workspace from `deploy/docker/Dockerfile.images`. If cargo fails with a missing manifest under `/build/crates/...`, verify that every current gateway dependency crate (including `openshell-driver-kubernetes`) is copied into the staged workspace there.
+### Step 5: Check Kubernetes Helm Gateways
```bash
-# Verify image refs currently used by openshell deployment
-openshell doctor exec -- kubectl -n openshell get statefulset openshell -o jsonpath="{.spec.template.spec.containers[*].image}"
-
-# Verify registry mirror/auth endpoint configuration
-openshell doctor exec -- cat /etc/rancher/k3s/registries.yaml
+helm -n openshell status openshell
+helm -n openshell get values openshell
+kubectl -n openshell get statefulset,pod,svc,pvc
+kubectl -n openshell logs statefulset/openshell --tail=200
+kubectl -n openshell rollout status statefulset/openshell
```
-**Legacy push mode**: Images are imported into the k3s containerd `k8s.io` namespace.
+Look for failed installs, unexpected values, missing namespace, wrong image tag, TLS settings that do not match the registered endpoint, and scheduling failures.
-```bash
-# Check if images were imported into containerd (k3s default namespace is k8s.io)
-openshell doctor exec -- ctr -a /run/k3s/containerd/containerd.sock images ls | grep openshell
-```
-
-**External pull mode** (remote deploy, or local with `OPENSHELL_REGISTRY_HOST`/`IMAGE_REPO_BASE` pointing at a non-local registry): Images are pulled from an external registry at runtime. The entrypoint generates `/etc/rancher/k3s/registries.yaml`.
+Check required Helm deployment secrets:
```bash
-# Verify registries.yaml exists and has credentials
-openshell doctor exec -- cat /etc/rancher/k3s/registries.yaml
-
-# Test pulling an image manually from inside the cluster
-openshell doctor exec -- crictl pull ghcr.io/nvidia/openshell/gateway:latest
+kubectl -n openshell get secret \
+ openshell-ssh-handshake \
+ openshell-server-tls \
+ openshell-server-client-ca \
+ openshell-client-tls
```
-If `registries.yaml` is missing or has wrong values, verify env wiring (`OPENSHELL_REGISTRY_HOST`, `OPENSHELL_REGISTRY_INSECURE`, username/password for authenticated registries).
-
-### Step 6: Check mTLS / PKI
-
-TLS certificates are generated by the `openshell-bootstrap` crate (using `rcgen`) and stored as K8s secrets before the Helm release installs. There is no PKI job or cert-manager — certificates are applied directly via `kubectl apply`.
+Check the image references currently used by the gateway deployment:
```bash
-# Check if the three TLS secrets exist
-openshell doctor exec -- kubectl -n openshell get secret openshell-server-tls openshell-server-client-ca openshell-client-tls
-
-# Inspect server cert expiry (if openssl is available in the container)
-openshell doctor exec -- sh -c 'kubectl -n openshell get secret openshell-server-tls -o jsonpath="{.data.tls\.crt}" | base64 -d | openssl x509 -noout -dates 2>/dev/null || echo "openssl not available"'
-
-# Check if CLI-side mTLS files exist locally
-ls -la ~/.config/openshell/gateways//mtls/
+kubectl -n openshell get statefulset openshell -o jsonpath="{.spec.template.spec.containers[*].image}{\"\n\"}{.spec.template.spec.containers[*].env[?(@.name==\"OPENSHELL_SUPERVISOR_IMAGE\")].value}{\"\n\"}"
+helm -n openshell get values openshell | grep -E 'repository|tag|supervisorImage'
```
-On redeploy, bootstrap reuses existing secrets if they are valid PEM. If secrets are missing or malformed, fresh PKI is generated and the OpenShell workload is automatically restarted. If the rollout restart fails after rotation, the deploy aborts and CLI-side certs are not updated. Certificates use rcgen defaults (effectively never expire).
+The gateway image and `server.supervisorImage` should use the same build tag in branch and E2E deploys. A stale supervisor image can make sandbox behavior lag behind gateway policy or proto changes.
-If the local mTLS files are missing but the secrets exist in the cluster, you can extract them manually:
+For plaintext local evaluation, confirm the chart has:
```bash
-mkdir -p ~/.config/openshell/gateways//mtls
-openshell doctor exec -- kubectl -n openshell get secret openshell-client-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > ~/.config/openshell/gateways//mtls/ca.crt
-openshell doctor exec -- kubectl -n openshell get secret openshell-client-tls -o jsonpath='{.data.tls\.crt}' | base64 -d > ~/.config/openshell/gateways//mtls/tls.crt
-openshell doctor exec -- kubectl -n openshell get secret openshell-client-tls -o jsonpath='{.data.tls\.key}' | base64 -d > ~/.config/openshell/gateways//mtls/tls.key
+helm -n openshell get values openshell | grep -E 'disableTls|grpcEndpoint'
```
-Common mTLS issues:
-- **Secrets missing**: The `openshell` namespace may not have been created yet (Helm controller race). Bootstrap waits up to 2 minutes for the namespace.
-- **mTLS mismatch after manual secret deletion**: Delete all three secrets and redeploy — bootstrap will regenerate and restart the workload.
-- **CLI can't connect after redeploy**: Check that `~/.config/openshell/gateways//mtls/` contains `ca.crt`, `tls.crt`, `tls.key` and that they were updated at deploy time.
-- **Local mTLS files missing**: The gateway was deployed but CLI credentials weren't persisted (e.g., interrupted deploy). Extract from the cluster secret as shown above.
+Expected shape:
-### Step 7: Check Kubernetes Events
+```yaml
+server:
+ disableTls: true
+ grpcEndpoint: http://openshell.openshell.svc.cluster.local:8080
+```
-Events catch scheduling failures, image pull errors, and resource issues:
+Check service exposure:
```bash
-openshell doctor exec -- kubectl get events -A --sort-by=.lastTimestamp | tail -n 50
+kubectl -n openshell get svc openshell -o wide
+kubectl -n openshell get endpoints openshell
```
-Look for:
-
-- `FailedScheduling` — resource constraints
-- `ImagePullBackOff` / `ErrImagePull` — registry auth failure or DNS issue (check `/etc/rancher/k3s/registries.yaml`)
-- `CrashLoopBackOff` — application crashes
-- `OOMKilled` — memory limits too low
-- `FailedMount` — volume issues
-
-### Step 8: Check GPU Device Plugin and CDI (GPU gateways only)
-
-Skip this step for non-GPU gateways.
-
-The NVIDIA device plugin DaemonSet must be running and healthy before GPU sandboxes can be created. It uses CDI injection (`deviceListStrategy: cdi-cri`) to inject GPU devices into sandbox pods — no `runtimeClassName` is set on sandbox pods.
+For local port-forward testing:
```bash
-# DaemonSet status — numberReady must be >= 1
-openshell doctor exec -- kubectl get daemonset -n nvidia-device-plugin
-
-# Device plugin pod logs — look for "CDI" lines confirming CDI mode is active
-openshell doctor exec -- kubectl logs -n nvidia-device-plugin -l app.kubernetes.io/name=nvidia-device-plugin --tail=50
-
-# List CDI devices registered by the device plugin (requires nvidia-ctk in the cluster image).
-# Device plugin CDI entries use the vendor string "k8s.device-plugin.nvidia.com" so entries
-# will be prefixed "k8s.device-plugin.nvidia.com/gpu=". If the list is empty, CDI spec
-# generation has not completed yet.
-openshell doctor exec -- nvidia-ctk cdi list
-
-# Verify CDI spec files were generated on the node
-openshell doctor exec -- ls /var/run/cdi/
-
-# Helm install job logs for the device plugin chart
-openshell doctor exec -- kubectl -n kube-system logs -l job-name=helm-install-nvidia-device-plugin --tail=100
-
-# Confirm a GPU sandbox pod has no runtimeClassName (CDI injection, not runtime class)
-openshell doctor exec -- kubectl get pod -n openshell -o jsonpath='{range .items[*]}{.metadata.name}{" runtimeClassName="}{.spec.runtimeClassName}{"\n"}{end}'
+kubectl -n openshell port-forward svc/openshell 8080:8080
+openshell gateway add http://127.0.0.1:8080 --local --name local
+openshell status
```
-Common issues:
-
-- **DaemonSet 0/N ready**: The device plugin chart may still be deploying (k3s Helm controller can take 1–2 min) or the pod is crashing. Check pod logs.
-- **`nvidia-ctk cdi list` returns no `k8s.device-plugin.nvidia.com/gpu=` entries**: CDI spec generation has not completed. The device plugin may still be starting or the `cdi-cri` strategy isn't active. Verify `deviceListStrategy: cdi-cri` is in the rendered Helm values.
-- **No CDI spec files at `/var/run/cdi/`**: Same as above — device plugin hasn't written CDI specs yet.
-- **`HEALTHCHECK_GPU_DEVICE_PLUGIN_NOT_READY` in health check logs**: Device plugin has no ready pods. Check DaemonSet events and pod logs.
-
-### Step 9: Check DNS Resolution
-
-DNS misconfiguration is a common root cause, especially on remote/Linux hosts:
+If the gateway is healthy but sandbox creation fails:
```bash
-# Check the resolv.conf k3s is using
-openshell doctor exec -- cat /etc/rancher/k3s/resolv.conf
-
-# Test DNS resolution from inside the container
-openshell doctor exec -- sh -c 'nslookup google.com || wget -q -O /dev/null http://google.com && echo "network ok" || echo "network unreachable"'
+kubectl -n openshell get pods
+kubectl -n openshell get events --sort-by=.lastTimestamp | tail -n 50
+kubectl -n openshell logs statefulset/openshell --tail=200
```
-Check the entrypoint's DNS decision in the container logs:
+Check the configured sandbox namespace:
```bash
-openshell doctor logs --lines 20
+helm -n openshell get values openshell | grep sandboxNamespace
```
-The entrypoint script selects DNS resolvers in this priority:
+Then inspect sandbox resources in that namespace.
-1. Viable nameservers from `/etc/resolv.conf` (not loopback/link-local)
-2. Docker `ExtServers` from `/etc/resolv.conf` comments
-3. Host gateway IP (Docker Desktop only, `192.168.*`)
-4. Fallback to `8.8.8.8` / `8.8.4.4`
+### Step 6: Check VM-Backed Gateways
-If DNS is broken, all image pulls from the distribution registry will fail, as will pods that need external network access.
+Use the VM driver logs and host diagnostics available in the user's environment. Verify:
-## Common Failure Patterns
+- The VM driver process is running and reachable by the gateway.
+- The runtime rootfs exists and matches the expected architecture.
+- Host virtualization support is enabled.
+- The sandbox supervisor can establish its callback connection to the gateway.
-| Symptom | Likely Cause | Fix |
-|---------|-------------|-----|
-| `tls handshake eof` from `openshell status` | Server not running or mTLS credentials missing/mismatched | Check StatefulSet replicas (Step 3) and mTLS files (Step 6) |
-| StatefulSet `0/0` replicas | StatefulSet scaled to zero (failed deploy, manual scale-down, or Helm misconfiguration) | `openshell doctor exec -- kubectl -n openshell scale statefulset openshell --replicas=1` |
-| Local mTLS files missing | Deploy was interrupted before credentials were persisted | Extract from cluster secret `openshell-client-tls` (Step 6) |
-| Container not found | Image not built | `mise run docker:build:cluster` (local) or re-deploy (remote) |
-| Container exited, OOMKilled | Insufficient memory | Increase host memory or reduce workload |
-| Container exited, non-zero exit | k3s crash, port conflict, privilege issue | Check `openshell doctor logs` for details |
-| `/readyz` fails | k3s still starting or crashed | Wait longer or check container logs for k3s errors |
-| OpenShell pods `Pending` | Insufficient CPU/memory for scheduling, or PVC not bound | `openshell doctor exec -- kubectl describe pod -n openshell` and `openshell doctor exec -- kubectl get pvc -n openshell` |
-| OpenShell pods `CrashLoopBackOff` | Server application error | `openshell doctor exec -- kubectl -n openshell logs statefulset/openshell` |
-| OpenShell pods `ImagePullBackOff` (push mode) | Images not imported or wrong containerd namespace | Check `openshell doctor exec -- ctr -a /run/k3s/containerd/containerd.sock -n k8s.io images ls` (Step 5) |
-| OpenShell pods `ImagePullBackOff` (pull mode) | Registry auth or DNS issue | Check `openshell doctor exec -- cat /etc/rancher/k3s/registries.yaml` and DNS (Step 8) |
-| Image import fails | Corrupt tar stream or containerd not ready | Retry after k3s is fully started; check container logs |
-| Push mode images not found by kubelet | Imported into wrong containerd namespace | Must use `k3s ctr -n k8s.io images import`, not `k3s ctr images import` |
-| mTLS secrets missing | Bootstrap couldn't apply secrets (namespace not ready) | Check deploy logs and verify `openshell` namespace exists (Step 6) |
-| mTLS mismatch after redeploy | PKI rotated but workload not restarted, or rollout failed | Check that all three TLS secrets exist and that the openshell pod restarted after cert rotation (Step 6) |
-| Helm install job failed | Chart values error or dependency issue | `openshell doctor exec -- kubectl -n kube-system logs -l job-name=helm-install-openshell` |
-| NFD/GFD DaemonSets present (`node-feature-discovery`, `gpu-feature-discovery`) | Cluster was deployed before NFD/GFD were disabled (pre-simplify-device-plugin change) | These are harmless but add overhead. Clean up: `openshell doctor exec -- kubectl delete daemonset -n nvidia-device-plugin -l app.kubernetes.io/name=node-feature-discovery` and similarly for GFD. The `nvidia.com/gpu.present` node label is no longer applied; device plugin scheduling no longer requires it. |
-| Architecture mismatch (remote) | Built on arm64, deploying to amd64 | Cross-build the image for the target architecture |
-| Port conflict | Another service on the configured gateway host port (default 8080) | Stop conflicting service or use `--port` on `openshell gateway start` to pick a different host port |
-| gRPC connect refused to `127.0.0.1:443` in CI | Docker daemon is remote (`DOCKER_HOST=tcp://...`) but metadata still points to loopback | Verify metadata endpoint host matches `DOCKER_HOST` and includes non-loopback host |
-| DNS failures inside container | Entrypoint DNS detection failed | `openshell doctor exec -- cat /etc/rancher/k3s/resolv.conf` and `openshell doctor logs --lines 20` |
-| Pods can't reach kube-dns / ClusterIP services | `br_netfilter` not loaded; bridge traffic bypasses iptables DNAT rules | `sudo modprobe br_netfilter` on the host, then `echo br_netfilter \| sudo tee /etc/modules-load.d/br_netfilter.conf` to persist. Known to be required on Jetson Linux 5.15-tegra; other kernels (e.g. standard x86/aarch64 Linux) may have bridge netfilter built in and work without the module. The entrypoint logs a warning when `/proc/sys/net/bridge/bridge-nf-call-iptables` is absent but does not abort — only act on it if DNS or service connectivity is actually broken. |
-| Node DiskPressure / MemoryPressure / PIDPressure | Insufficient disk, memory, or PIDs on host | Free disk (`docker system prune -a --volumes`), increase memory, or expand host resources. Bootstrap auto-detects via `HEALTHCHECK_NODE_PRESSURE` marker |
-| Pods evicted with "The node had condition: [DiskPressure]" | Host disk full, kubelet evicting pods | Free disk space on host, then `openshell gateway destroy && openshell gateway start` |
-| `metrics-server` errors in logs | Normal k3s noise, not the root cause | These errors are benign — look for the actual failing health check component |
-| Stale NotReady nodes from previous deploys | Volume reused across container recreations | The deploy flow now auto-cleans stale nodes; if it still fails, manually delete NotReady nodes (see Step 2) or choose "Recreate" when prompted |
-| gRPC `UNIMPLEMENTED` for newer RPCs in push mode | Helm values still point at older pulled images instead of the pushed refs | Verify rendered `openshell-helmchart.yaml` uses the expected push refs (`server`, `sandbox`, `pki-job`) and not `:latest` |
-| Sandbox pods crash with `/opt/openshell/bin/openshell-sandbox: no such file or directory` | Supervisor binary missing from cluster image | The cluster image was built/published without the `supervisor-builder` target in `deploy/docker/Dockerfile.images`. Rebuild with `mise run docker:build:cluster` and recreate gateway. Bootstrap auto-detects via `HEALTHCHECK_MISSING_SUPERVISOR` marker |
-| `HEALTHCHECK_MISSING_SUPERVISOR` in health check logs | `/opt/openshell/bin/openshell-sandbox` not found in gateway container | Rebuild cluster image: `mise run docker:build:cluster`, then `openshell gateway destroy && openshell gateway start` |
-| `nvidia-ctk cdi list` returns no `k8s.device-plugin.nvidia.com/gpu=` entries | CDI specs not yet generated by device plugin | Device plugin may still be starting; wait and retry, or check pod logs (Step 8) |
-
-## Full Diagnostic Dump
-
-Run all diagnostics at once for a comprehensive report:
+Then run:
```bash
-echo "=== Connectivity Check ==="
openshell status
+openshell logs
+```
-echo "=== Container Logs (last 50 lines) ==="
-openshell doctor logs --lines 50
-
-echo "=== k3s Readiness ==="
-openshell doctor exec -- kubectl get --raw='/readyz'
-
-echo "=== Nodes ==="
-openshell doctor exec -- kubectl get nodes -o wide
-
-echo "=== Node Conditions ==="
-openshell doctor exec -- kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{range .status.conditions[*]} {.type}={.status}{end}{"\n"}{end}'
-
-echo "=== Disk Usage ==="
-openshell doctor exec -- df -h /
-
-echo "=== All Pods ==="
-openshell doctor exec -- kubectl get pods -A -o wide
-
-echo "=== Failing Pods ==="
-openshell doctor exec -- kubectl get pods -A --field-selector=status.phase!=Running,status.phase!=Succeeded
-
-echo "=== OpenShell StatefulSet ==="
-openshell doctor exec -- kubectl -n openshell get statefulset/openshell -o wide
-
-echo "=== OpenShell Service ==="
-openshell doctor exec -- kubectl -n openshell get service/openshell
-
-echo "=== TLS Secrets ==="
-openshell doctor exec -- kubectl -n openshell get secret openshell-server-tls openshell-server-client-ca openshell-client-tls
-
-echo "=== Recent Events ==="
-openshell doctor exec -- kubectl get events -A --sort-by=.lastTimestamp | tail -n 50
-
-echo "=== Helm Install OpenShell Logs ==="
-openshell doctor exec -- kubectl -n kube-system logs -l job-name=helm-install-openshell --tail=100
-
-echo "=== Registry Configuration ==="
-openshell doctor exec -- cat /etc/rancher/k3s/registries.yaml
-
-echo "=== Supervisor Binary ==="
-openshell doctor exec -- ls -la /opt/openshell/bin/openshell-sandbox
-
-echo "=== DNS Configuration ==="
-openshell doctor exec -- cat /etc/rancher/k3s/resolv.conf
+## Common Failure Patterns
-# GPU gateways only
-echo "=== GPU Device Plugin ==="
-openshell doctor exec -- kubectl get daemonset -n nvidia-device-plugin
-openshell doctor exec -- nvidia-ctk cdi list
-```
+| Symptom | Likely cause | Check |
+|---|---|---|
+| `openshell status` fails | Gateway endpoint unreachable or auth mismatch | `openshell gateway info`, gateway logs |
+| Gateway starts but sandbox create fails | Compute driver cannot reach runtime | Docker/Podman/Kubernetes/VM driver logs |
+| Docker or Podman sandbox never registers | Wrong callback endpoint or supervisor startup failure | Gateway logs and sandbox container logs |
+| Kubernetes gateway pod pending | PVC unbound, taint, selector, or insufficient resources | `kubectl -n openshell describe pod ` |
+| Kubernetes gateway pod crash loops | Missing secret, bad DB URL, bad TLS config | `kubectl -n openshell logs statefulset/openshell` |
+| CLI TLS error | Local mTLS bundle does not match server cert/CA | Check `~/.config/openshell/gateways//mtls/` |
+| Image pull failure | Gateway or sandbox image cannot be pulled | Runtime events and image pull credentials |
+| `K8s namespace not ready` with `envoy-gateway-openshell.yaml: the server could not find the requested resource` | Optional Gateway API manifest was auto-applied without Envoy Gateway CRDs, or k3s Helm controller startup exceeded the namespace wait | Confirm the cluster image only bundles core manifests; apply `deploy/kube/manifests/envoy-gateway-openshell.yaml` manually only when `grpcRoute` is enabled |
+
+## Reporting
+
+When handing results back to the user, include:
+
+- Active gateway endpoint and auth mode.
+- Compute platform and driver.
+- Gateway process or workload status.
+- Recent gateway log summary.
+- Missing or malformed TLS or SSH relay material.
+- Service exposure status.
+- Sandbox workload status.
+- The exact command that failed and the shortest fix.
diff --git a/.agents/skills/fix-security-issue/SKILL.md b/.agents/skills/fix-security-issue/SKILL.md
index 7df9b4178c..22ffd42541 100644
--- a/.agents/skills/fix-security-issue/SKILL.md
+++ b/.agents/skills/fix-security-issue/SKILL.md
@@ -207,7 +207,6 @@ Create a PR that closes the security issue. Put the full fix summary in the PR d
```bash
gh pr create \
--title "fix(security): " \
- --assignee "@me" \
--label "topic:security" \
--body "$(cat <<'EOF'
> **🔧 security-fix-agent**
diff --git a/.agents/skills/generate-sandbox-policy/SKILL.md b/.agents/skills/generate-sandbox-policy/SKILL.md
index 7149bbd610..95767fe3fb 100644
--- a/.agents/skills/generate-sandbox-policy/SKILL.md
+++ b/.agents/skills/generate-sandbox-policy/SKILL.md
@@ -439,7 +439,7 @@ network_policies:
#
```
-The `filesystem_policy`, `landlock`, and `process` sections above are sensible defaults. Tell the user these are defaults and may need adjustment for their environment. Cluster inference is configured separately through `openshell cluster inference set/get`. The generated `network_policies` block is the primary output.
+The `filesystem_policy`, `landlock`, and `process` sections above are sensible defaults. Tell the user these are defaults and may need adjustment for their environment. Gateway inference is configured separately through `openshell inference set/get`. The generated `network_policies` block is the primary output.
If the user provides a file path, write to it. Otherwise, ask where to place it. A common convention is a project-local policy file (e.g., `sandbox-policy.yaml`) passed to `openshell sandbox create --policy ` or set via the `OPENSHELL_SANDBOX_POLICY` env var.
diff --git a/.agents/skills/generate-sandbox-policy/examples.md b/.agents/skills/generate-sandbox-policy/examples.md
index ede5111343..b6acbee8bf 100644
--- a/.agents/skills/generate-sandbox-policy/examples.md
+++ b/.agents/skills/generate-sandbox-policy/examples.md
@@ -858,7 +858,7 @@ network_policies:
- { path: /usr/local/bin/claude }
```
-The agent notes that `filesystem_policy`, `landlock`, and `process` are sensible defaults that may need adjustment, and that cluster inference is configured separately via `openshell cluster inference set/get` rather than an `inference` policy block.
+The agent notes that `filesystem_policy`, `landlock`, and `process` are sensible defaults that may need adjustment, and that gateway inference is configured separately via `openshell inference set/get` rather than an `inference` policy block.
---
diff --git a/.agents/skills/helm-dev-environment/SKILL.md b/.agents/skills/helm-dev-environment/SKILL.md
new file mode 100644
index 0000000000..986ce14905
--- /dev/null
+++ b/.agents/skills/helm-dev-environment/SKILL.md
@@ -0,0 +1,200 @@
+---
+name: helm-dev-environment
+description: Start up, tear down, and configure the local Kubernetes development environment for OpenShell. Uses k3d (Docker-backed k3s) + Skaffold + Helm. Covers cluster lifecycle, optional add-ons (Keycloak OIDC, Envoy Gateway), and port mappings. Trigger keywords - local k8s, local cluster, k3d, skaffold, helm dev, start cluster, stop cluster, tear down cluster, delete cluster, create cluster, helm:k3s, helm:skaffold, local dev environment, dev cluster, k8s dev, envoy gateway local, keycloak local.
+---
+
+# Helm Dev Environment
+
+Set up, run, and tear down the local Kubernetes development environment for OpenShell.
+The stack is: **k3d** (Docker-backed k3s) for the cluster, **Skaffold** for image builds and Helm deploys, and the **OpenShell Helm chart** (`deploy/helm/openshell/`).
+
+---
+
+## Prerequisites
+
+- Docker Desktop (macOS) or Docker Engine (Linux) running
+- `mise install` completed (provides `k3d`, `kubectl`, `skaffold`, `helm`)
+
+---
+
+## Startup
+
+### 1. Create the cluster
+
+```bash
+mise run helm:k3s:create
+```
+
+Creates a k3d cluster and merges its kubeconfig into the worktree-local `kubeconfig` file.
+Also applies base manifests (`deploy/kube/manifests/agent-sandbox.yaml`). Traefik is
+disabled at cluster creation time.
+
+**Multi-worktree support:** the cluster name is derived from the last component of the
+current git branch (e.g. branch `kube-support/local-dev/tmutch` → cluster
+`openshell-dev-tmutch`). Each worktree therefore gets its own isolated cluster and its
+own `kubeconfig` file. Override with `HELM_K3S_CLUSTER_NAME` to force a specific name
+or share one cluster across worktrees.
+
+Port mappings created at cluster time (cannot be changed without recreating):
+
+| Host port | Target | Used by |
+|-----------|--------|---------|
+| `8080` | Port `80` via k3d load balancer | Envoy Gateway LoadBalancer service (`values-gateway.yaml`) |
+
+Override with env vars before running `helm:k3s:create`:
+- `HELM_K3S_LB_HOST_PORT` (default: `8080`)
+
+### 2. Deploy OpenShell
+
+**Iterative dev** (rebuilds on file changes, recommended during active development):
+```bash
+mise run helm:skaffold:dev
+```
+
+**One-shot deploy** (build once and leave running):
+```bash
+mise run helm:skaffold:run
+```
+
+Both commands build the `gateway` and `supervisor` images and deploy the OpenShell Helm
+chart. The `pkiInitJob` hook runs on first install to generate mTLS secrets. Envoy Gateway opt-in; see the Optional Add-ons section below.
+
+The gateway Service uses ClusterIP. Access is via Envoy Gateway (port `8080`) or `kubectl port-forward`.
+
+### TLS behaviour
+
+`values-skaffold.yaml` sets `server.disableTls: true`, so Skaffold-based deploys run
+plaintext by default. To test with TLS enabled, comment out that line and redeploy.
+
+| Mode | `server.disableTls` | Gateway scheme |
+|------|---------------------|----------------|
+| Skaffold dev (default) | `true` | `http://` |
+| TLS enabled | `false` (or omitted) | `https://` |
+
+### Connecting via port-forward
+
+Port `8080` is already bound by the k3d load balancer when Envoy Gateway is active, so
+the port-forward uses local port `8090` to avoid a collision:
+
+```bash
+KUBECONFIG=kubeconfig kubectl port-forward -n openshell svc/openshell 8090:8080
+```
+
+**Plaintext (default Skaffold deploy):**
+
+```bash
+openshell sandbox list --gateway-endpoint http://localhost:8090
+```
+
+**With mTLS enabled** — extract the client cert the PKI hook wrote to the cluster,
+then place it where the CLI expects it. Run once after each fresh install:
+
+```bash
+mkdir -p ~/.config/openshell/gateways/openshell/mtls
+KUBECONFIG=kubeconfig kubectl get secret openshell-client-tls -n openshell \
+ -o jsonpath='{.data.ca\.crt}' | base64 -d > ~/.config/openshell/gateways/openshell/mtls/ca.crt
+KUBECONFIG=kubeconfig kubectl get secret openshell-client-tls -n openshell \
+ -o jsonpath='{.data.tls\.crt}' | base64 -d > ~/.config/openshell/gateways/openshell/mtls/tls.crt
+KUBECONFIG=kubeconfig kubectl get secret openshell-client-tls -n openshell \
+ -o jsonpath='{.data.tls\.key}' | base64 -d > ~/.config/openshell/gateways/openshell/mtls/tls.key
+```
+
+The server cert SANs include `localhost` and `127.0.0.1`, so hostname verification
+passes over a port-forward without any extra flags:
+
+```bash
+openshell sandbox list --gateway-endpoint https://localhost:8090
+```
+
+---
+
+## Teardown
+
+### Remove the Helm releases (keep cluster)
+
+```bash
+mise run helm:skaffold:delete
+```
+
+### Delete the cluster entirely
+
+```bash
+mise run helm:k3s:delete
+```
+
+This removes the k3d cluster and all resources. Kubeconfig context is left behind
+but will point to a deleted cluster — safe to ignore or clean up manually.
+
+---
+
+## Optional Add-ons
+
+Each add-on requires uncommenting the corresponding `valuesFiles` entry in
+`deploy/helm/openshell/skaffold.yaml` before running `helm:skaffold:dev` or `helm:skaffold:run`.
+
+### Envoy Gateway (Gateway API / GRPCRoute)
+
+Envoy Gateway is already installed by Skaffold (the `envoy-gateway` Helm release in
+`skaffold.yaml`). To activate routing:
+
+1. Uncomment `#- values-gateway.yaml` in `skaffold.yaml`
+2. Redeploy: `mise run helm:skaffold:run`
+3. Apply the GatewayClass: `mise run helm:gateway:apply`
+4. Access: `http://127.0.0.1:8080`
+
+`values-gateway.yaml` creates a `Gateway` (listener on port 80, class `eg`) and a
+`GRPCRoute` in the `openshell` namespace. Envoy Gateway provisions a LoadBalancer
+service for the proxy; klipper-lb binds it to hostPort 80, reachable via the
+`8080:80` load balancer port mapping.
+
+### Keycloak OIDC
+
+One-time setup — only needed once per cluster lifetime:
+
+```bash
+mise run keycloak:k8s:setup
+```
+
+This deploys Keycloak (`quay.io/keycloak/keycloak:24.0`) into the `keycloak` namespace,
+imports the openshell realm from `scripts/keycloak-realm.json`, and prints a port-forward
+command for acquiring tokens from the CLI.
+
+Then activate OIDC in the OpenShell Helm chart:
+1. Uncomment `#- values-keycloak.yaml` in `skaffold.yaml`
+2. Redeploy: `mise run helm:skaffold:run`
+
+To remove Keycloak:
+```bash
+mise run keycloak:k8s:teardown
+```
+
+---
+
+## Cluster Lifecycle (suspend/resume)
+
+Stop the cluster without losing state (faster than delete/recreate):
+```bash
+mise run helm:k3s:stop
+mise run helm:k3s:start
+```
+
+Check cluster status:
+```bash
+mise run helm:k3s:status
+```
+
+---
+
+## Key Files
+
+| Path | Purpose |
+|------|---------|
+| `deploy/helm/openshell/skaffold.yaml` | Skaffold config — images, Helm releases, values overlays |
+| `deploy/helm/openshell/values.yaml` | Default Helm values |
+| `deploy/helm/openshell/values-skaffold.yaml` | Dev overrides (image pull policy, local image names) |
+| `deploy/helm/openshell/values-cert-manager.yaml` | cert-manager TLS overlay (opt-in; disables pkiInitJob) |
+| `deploy/helm/openshell/values-gateway.yaml` | Envoy Gateway GRPCRoute + Gateway overlay |
+| `deploy/helm/openshell/values-keycloak.yaml` | Keycloak OIDC overlay |
+| `deploy/kube/manifests/envoy-gateway-openshell.yaml` | GatewayClass for Envoy Gateway (`mise run helm:gateway:apply`) |
+| `tasks/scripts/helm-k3s-local.sh` | k3d cluster create/delete/start/stop/status |
+| `tasks/scripts/keycloak-k8s-setup.sh` | Keycloak deploy + realm import |
diff --git a/.agents/skills/openshell-cli/SKILL.md b/.agents/skills/openshell-cli/SKILL.md
index 132c996861..39af09e082 100644
--- a/.agents/skills/openshell-cli/SKILL.md
+++ b/.agents/skills/openshell-cli/SKILL.md
@@ -1,6 +1,6 @@
---
name: openshell-cli
-description: Guide agents through using the OpenShell CLI (openshell) for sandbox management, provider configuration, policy iteration, BYOC workflows, and inference routing. Covers basic through advanced multi-step workflows. Trigger keywords - openshell, sandbox create, sandbox connect, logs, provider create, policy set, policy get, image push, forward, port forward, BYOC, bring your own container, use openshell, run openshell, CLI usage, manage sandbox, manage provider, gateway start, gateway select.
+description: Guide agents through using the OpenShell CLI (openshell) for sandbox management, gateway registration, provider configuration, policy iteration, BYOC workflows, and inference routing. Covers basic through advanced multi-step workflows. Trigger keywords - openshell, sandbox create, sandbox connect, logs, provider create, policy set, policy get, image push, forward, port forward, BYOC, bring your own container, use openshell, run openshell, CLI usage, manage sandbox, manage provider, gateway add, gateway select.
---
# OpenShell CLI
@@ -26,8 +26,9 @@ This is your primary fallback. Use it freely -- the CLI's help output is authori
## Prerequisites
- `openshell` is on the PATH (install via `cargo install --path crates/openshell-cli`)
-- Docker is running (required for gateway operations and BYOC)
-- For remote clusters: SSH access to the target host
+- A reachable OpenShell gateway backed by Docker, Podman, Kubernetes, or the experimental VM driver
+- Docker is running only when using BYOC local builds or Docker-backed development workflows
+- For Kubernetes deployments: `kubectl` and Helm access to the target cluster
## Command Reference
@@ -37,29 +38,23 @@ See [cli-reference.md](cli-reference.md) for the full command tree with all flag
## Workflow 1: Getting Started
-Use this workflow when no cluster exists yet and the user wants to get a sandbox running for the first time.
+Use this workflow when the user has a gateway endpoint and wants to get a sandbox running for the first time.
-### Step 1: Bootstrap a cluster
+### Step 1: Register a gateway
```bash
-openshell gateway start
+openshell gateway add http://127.0.0.1:8080 --local --name local
```
-This provisions a local k3s cluster in Docker. The CLI will prompt interactively if a cluster already exists. The cluster is automatically set as the active gateway.
+Use an `http://` endpoint only for trusted local port-forwarding or a protected private path. For a gateway behind an authenticated reverse proxy, register its HTTPS endpoint with `openshell gateway add https://gateway.example.com`.
-For remote deployment:
-
-```bash
-openshell gateway start --remote user@host --ssh-key ~/.ssh/id_rsa
-```
-
-### Step 2: Verify the cluster
+### Step 2: Verify the gateway
```bash
openshell status
```
-Confirm the cluster is reachable and shows a version.
+Confirm the gateway is reachable and shows a version.
### Step 3: Create a sandbox
@@ -69,7 +64,7 @@ The simplest way to get a sandbox running:
openshell sandbox create
```
-This creates a sandbox with defaults and drops you into an interactive shell. The CLI auto-bootstraps a cluster if none exists.
+This creates a sandbox with defaults and drops you into an interactive shell.
**Shortcut for known tools**: When the trailing command is a recognized tool, the CLI auto-creates the required provider from local credentials:
@@ -325,7 +320,7 @@ openshell sandbox create --from ./Dockerfile --name my-app
The `--from` flag accepts a Dockerfile path, a directory containing a Dockerfile, a full image reference (e.g. `myregistry.com/img:tag`), or a community sandbox name (e.g. `openclaw`).
-When given a Dockerfile or directory, the image is built locally via Docker and imported directly into the cluster's containerd runtime. No external registry needed.
+When given a Dockerfile or directory, the image is built locally via Docker and delivered through the selected compute driver. Docker and Podman-backed gateways can use local images directly. Kubernetes gateways usually need the image available to the cluster through a registry or driver-supported image push path.
When `--from` is specified, the CLI:
- Clears default `run_as_user`/`run_as_group` (custom images may not have the `sandbox` user)
@@ -421,10 +416,14 @@ Watch for `deny` actions that indicate the user's work is being blocked by polic
When denied actions are observed:
-1. Pull current policy: `openshell policy get work-session --full > policy.yaml`
-2. Modify the policy to allow the blocked actions (use `generate-sandbox-policy` skill for content)
-3. Push the update: `openshell policy set work-session --policy policy.yaml --wait`
-4. Verify: `openshell policy list work-session`
+1. Prefer incremental updates for additive network changes:
+ `openshell policy update work-session --add-endpoint api.github.com:443:read-only:rest:enforce --binary /usr/bin/gh --wait`
+ `openshell policy update work-session --add-allow 'api.github.com:443:POST:/repos/*/issues' --wait`
+2. Use full YAML replacement when the change is broad or touches non-network fields:
+ `openshell policy get work-session --full > policy.yaml`
+ Modify the policy to allow the blocked actions (use `generate-sandbox-policy` skill for content)
+ `openshell policy set work-session --policy policy.yaml --wait`
+3. Verify: `openshell policy list work-session`
The user does not need to disconnect -- policy updates are hot-reloaded within ~30 seconds (or immediately when using `--wait`, which polls for confirmation).
@@ -467,8 +466,8 @@ openshell inference get
### How sandboxes use it
- Agents send HTTPS requests to `inference.local`.
-- The sandbox intercepts those requests locally and routes them through the cluster inference config.
-- Sandbox policy is separate from cluster inference configuration.
+- The sandbox intercepts those requests locally and routes them through the gateway inference config.
+- Sandbox policy is separate from gateway inference configuration.
---
@@ -478,35 +477,29 @@ openshell inference get
```bash
openshell gateway select # See all gateways (no args shows list)
-openshell gateway select my-cluster # Switch active gateway
+openshell gateway select production # Switch active gateway
openshell status # Verify connectivity
```
-### Lifecycle
+### Registration
```bash
-openshell gateway start # Start local cluster
-openshell gateway stop # Stop (preserves state)
-openshell gateway start # Restart (reuses state)
-openshell gateway destroy # Destroy permanently
+openshell gateway add http://127.0.0.1:8080 --local --name local
+openshell gateway add https://gateway.example.com --name production
+openshell gateway destroy --name local # Remove local registration
```
-### Remote clusters
+### Platform-specific deployment inspection
```bash
-# Deploy to remote host
-openshell gateway start --remote user@host --ssh-key ~/.ssh/id_rsa --name remote-cluster
-
-# View gateway container logs
-openshell doctor logs --name remote-cluster
-
-# Run kubectl inside the remote gateway container
-openshell doctor exec --name remote-cluster -- kubectl get pods -A
-
-# Get cluster info
-openshell gateway info --name remote-cluster
+# Inspect a Kubernetes Helm release and gateway pod
+helm -n openshell status openshell
+kubectl -n openshell get pods,svc
+kubectl -n openshell logs statefulset/openshell --tail=100
```
+For Docker, Podman, and VM-backed gateways, inspect the gateway process or container logs and the selected runtime directly.
+
---
## Self-Teaching via `--help`
@@ -535,14 +528,15 @@ $ openshell sandbox upload --help
| Task | Command |
|------|---------|
-| Deploy local cluster | `openshell gateway start` |
-| Check cluster health | `openshell status` |
+| Register local port-forwarded gateway | `openshell gateway add http://127.0.0.1:8080 --local --name local` |
+| Check gateway health | `openshell status` |
| List/switch gateways | `openshell gateway select [name]` |
| Create sandbox (interactive) | `openshell sandbox create` |
| Create sandbox with tool | `openshell sandbox create -- claude` |
| Create with custom policy | `openshell sandbox create --policy ./p.yaml` |
| Connect to sandbox | `openshell sandbox connect ` |
| Stream live logs | `openshell logs --tail` |
+| Incremental policy update | `openshell policy update --add-endpoint host:443:read-only:rest:enforce --binary /usr/bin/curl --wait` |
| Pull current policy | `openshell policy get --full > p.yaml` |
| Push updated policy | `openshell policy set --policy p.yaml --wait` |
| Policy revision history | `openshell policy list ` |
@@ -555,7 +549,7 @@ $ openshell sandbox upload --help
| Configure gateway inference | `openshell inference set --provider P --model M` |
| View gateway inference | `openshell inference get` |
| Delete sandbox | `openshell sandbox delete ` |
-| Destroy cluster | `openshell gateway destroy` |
+| Remove gateway registration | `openshell gateway destroy --name ` |
| Self-teach any command | `openshell --help` |
## Companion Skills
@@ -563,6 +557,6 @@ $ openshell sandbox upload --help
| Skill | When to use |
|-------|------------|
| `generate-sandbox-policy` | Creating or modifying policy YAML content (network rules, L7 inspection, access presets, endpoint configuration) |
-| `debug-openshell-cluster` | Diagnosing cluster startup or health failures |
+| `debug-openshell-cluster` | Diagnosing gateway deployment, runtime, or health failures |
| `debug-inference` | Diagnosing `inference.local`, host-backed local inference, and provider base URL issues |
| `tui-development` | Developing features for the OpenShell TUI (`openshell term`) |
diff --git a/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md
index e344f20dfd..3256aef423 100644
--- a/.agents/skills/openshell-cli/cli-reference.md
+++ b/.agents/skills/openshell-cli/cli-reference.md
@@ -25,8 +25,8 @@ Quick-reference for the `openshell` command-line interface. For workflow guidanc
```
openshell
├── gateway
-│ ├── start [opts]
-│ ├── stop [opts]
+│ ├── add [opts]
+│ ├── login [name]
│ ├── destroy [opts]
│ ├── info [--name]
│ └── select [name]
@@ -73,40 +73,37 @@ openshell
## Gateway Commands
-### `openshell gateway start`
+### `openshell gateway add `
-Provision or start a cluster (local or remote).
-
-| Flag | Default | Description |
-|------|---------|-------------|
-| `--name ` | `openshell` | Cluster name |
-| `--remote ` | none | SSH destination for remote deployment |
-| `--ssh-key ` | none | SSH private key for remote deployment |
-| `--port ` | 8080 | Host port mapped to gateway |
-| `--gateway-host ` | none | Override gateway host in metadata |
-| `--recreate` | false | Destroy and recreate from scratch if a gateway already exists (skips interactive prompt) |
-
-### `openshell gateway stop`
-
-Stop a cluster (preserves state for later restart).
+Register an existing gateway endpoint.
| Flag | Description |
|------|-------------|
-| `--name ` | Cluster name (defaults to active) |
-| `--remote ` | SSH destination |
-| `--ssh-key ` | SSH private key |
+| `--name ` | Gateway name |
+| `--local` | Register a local endpoint, commonly a trusted port-forward |
+| `--remote ` | Register a remote gateway associated with an SSH destination |
+| `--ssh-key ` | SSH private key for the remote host |
+
+Examples:
+
+- `openshell gateway add http://127.0.0.1:8080 --local --name local`
+- `openshell gateway add https://gateway.example.com --name production`
### `openshell gateway destroy`
-Destroy a cluster and all its state. Same flags as `stop`.
+Remove a gateway registration. For Helm deployments this affects local CLI metadata only; it does not uninstall the Helm release.
+
+### `openshell gateway login [name]`
+
+Refresh browser-based authentication for a gateway behind an edge proxy.
### `openshell gateway info`
-Show deployment details: endpoint and remote host.
+Show gateway details: endpoint, auth mode, and remote host metadata when present.
| Flag | Description |
|------|-------------|
-| `--name ` | Cluster name (defaults to active) |
+| `--name ` | Gateway name (defaults to active) |
### `openshell gateway select [name]`
@@ -118,7 +115,7 @@ Set the active gateway. Writes to `~/.config/openshell/active_gateway`. When cal
### `openshell doctor logs`
-Fetch logs from the gateway Docker container.
+Fetch logs when gateway metadata supports it. For Helm deployments, prefer `kubectl -n openshell logs statefulset/openshell`.
| Flag | Default | Description |
|------|---------|-------------|
@@ -130,8 +127,7 @@ Fetch logs from the gateway Docker container.
### `openshell doctor exec -- `
-Run a command inside the gateway container with KUBECONFIG pre-configured.
-Launches an interactive `docker exec` session (tunnelled over SSH for remote gateways).
+Run a diagnostic command when gateway metadata supports it. For Helm deployments, prefer direct `kubectl` and `helm` commands.
| Flag | Default | Description |
|------|---------|-------------|
@@ -140,9 +136,9 @@ Launches an interactive `docker exec` session (tunnelled over SSH for remote gat
| `--ssh-key ` | none | SSH private key for remote gateways |
Examples:
-- `openshell doctor exec -- kubectl get pods -A`
-- `openshell doctor exec -- k9s`
-- `openshell doctor exec -- sh` (interactive shell)
+- `kubectl -n openshell get pods`
+- `kubectl -n openshell logs statefulset/openshell`
+- `helm -n openshell status openshell`
---
@@ -158,7 +154,7 @@ Show server connectivity and version for the active gateway.
### `openshell sandbox create [OPTIONS] [-- COMMAND...]`
-Create a sandbox, wait for readiness, then connect or execute the trailing command. Auto-bootstraps a cluster if none exists.
+Create a sandbox through the active gateway, wait for readiness, then connect or execute the trailing command.
| Flag | Description |
|------|-------------|
@@ -169,19 +165,19 @@ Create a sandbox, wait for readiness, then connect or execute the trailing comma
| `--provider ` | Provider to attach (repeatable) |
| `--policy ` | Path to custom policy YAML |
| `--forward ` | Forward local port to sandbox (keeps the sandbox alive) |
-| `--remote ` | SSH destination for auto-bootstrap |
-| `--ssh-key ` | SSH private key for auto-bootstrap |
| `--tty` | Force pseudo-terminal allocation |
| `--no-tty` | Disable pseudo-terminal allocation |
-| `--bootstrap` | Auto-bootstrap a gateway if none is available (skips interactive prompt) |
-| `--no-bootstrap` | Never auto-bootstrap; error immediately if no gateway is available |
| `--auto-providers` | Auto-create missing providers from local credentials (skips interactive prompt) |
| `--no-auto-providers` | Never auto-create providers; skip missing providers silently |
| `[-- COMMAND...]` | Command to execute (defaults to interactive shell) |
### `openshell sandbox get `
-Show sandbox details (id, name, namespace, phase, policy).
+Show sandbox details (id, name, namespace, phase) and the **active** policy from the gateway (same source whether policy is sandbox-scoped or global). Metadata includes **Policy source** (`sandbox` or `global`) and **Revision** (global policy row when source is global, otherwise sandbox policy row).
+
+| Flag | Description |
+|------|-------------|
+| `--policy-only` | Print only the active policy YAML to stdout (same policy as above; use for scripts and piping) |
### `openshell sandbox list`
@@ -268,9 +264,32 @@ View sandbox logs. Supports one-shot and streaming.
## Policy Commands
+### `openshell policy update `
+
+Incrementally merge live network policy changes into the current sandbox policy. Multiple flags in one invocation are applied as one atomic batch and create at most one new revision.
+
+| Flag | Default | Description |
+|------|---------|-------------|
+| `--add-endpoint ` | repeatable | `host:port[:access[:protocol[:enforcement]]]`. Adds or merges an endpoint. `access`: `read-only`, `read-write`, `full`. `protocol`: `rest`, `sql`. `enforcement`: `enforce`, `audit`. |
+| `--remove-endpoint ` | repeatable | `host:port`. Removes the endpoint or just the requested port from a multi-port endpoint. |
+| `--add-allow ` | repeatable | `host:port:METHOD:path_glob`. Adds REST allow rules to an existing `protocol: rest` endpoint. |
+| `--add-deny ` | repeatable | `host:port:METHOD:path_glob`. Adds REST deny rules to an existing `protocol: rest` endpoint that already has an allow base. |
+| `--remove-rule ` | repeatable | Deletes a named network rule. |
+| `--binary ` | repeatable | Adds binaries to each `--add-endpoint` rule. Valid only with `--add-endpoint`. |
+| `--rule-name ` | none | Overrides the generated rule name. Valid only when exactly one `--add-endpoint` is provided. |
+| `--dry-run` | false | Preview the merged policy locally without sending an update to the gateway. |
+| `--wait` | false | Wait for the sandbox to confirm the new policy revision is loaded. |
+| `--timeout ` | 60 | Timeout for `--wait`. |
+
+Notes:
+
+- `--add-allow` and `--add-deny` currently operate only on `protocol: rest` endpoints.
+- `--wait` cannot be combined with `--dry-run`.
+- Use `policy set` when replacing the full policy or changing static sections.
+
### `openshell policy set --policy `
-Update the policy on a live sandbox. Only the dynamic `network_policies` field can be changed at runtime.
+Replace the full policy on a live sandbox. Only the dynamic `network_policies` field can be changed at runtime.
| Flag | Default | Description |
|------|---------|-------------|
diff --git a/.agents/skills/triage-issue/SKILL.md b/.agents/skills/triage-issue/SKILL.md
index e4998a0569..54e726a5d9 100644
--- a/.agents/skills/triage-issue/SKILL.md
+++ b/.agents/skills/triage-issue/SKILL.md
@@ -85,19 +85,7 @@ Check whether the issue body contains a substantive agent diagnostic section. Lo
```bash
gh issue edit --add-label "state:triage-needed"
```
-2. Post a comment with the triage marker:
- ```
- > **📋 triage-agent**
- >
- > This issue was opened without an agent investigation.
- >
- > OpenShell is an agent-first project - before we triage this, please point your coding agent at the repo and have it investigate. Your agent can load skills like `debug-openshell-cluster` (for cluster issues), `debug-inference` (for inference setup issues), `openshell-cli` (for usage questions), or `generate-sandbox-policy` (for policy help).
- >
- > See [CONTRIBUTING.md](https://github.com/NVIDIA/OpenShell/blob/main/CONTRIBUTING.md#before-you-open-an-issue) for the full workflow.
- >
- > **Classification:** needs-more-info (agent diagnostic required)
- ```
-3. Stop. Do not proceed with diagnosis until the reporter provides diagnostics.
+2. Do not post a standalone redirect comment. Report the missing diagnostic to the operator and stop unless a human explicitly asks you to continue triage anyway.
**If the diagnostic section is substantive**, proceed to Step 4.
@@ -122,7 +110,7 @@ Based on the sub-agent's analysis, also attempt to validate the report directly:
- For bug reports: check the relevant code paths, look for the described failure mode
- For feature requests: assess feasibility against the existing architecture
-- For cluster/infrastructure issues: reference the `debug-openshell-cluster` skill's known failure patterns
+- For gateway deployment or infrastructure issues: reference the `debug-openshell-cluster` skill's known failure patterns
- For inference and provider-topology issues: reference the `debug-inference` skill's known failure patterns
- For CLI/usage issues: reference the `openshell-cli` skill's command reference
diff --git a/.agents/skills/tui-development/SKILL.md b/.agents/skills/tui-development/SKILL.md
index 80984759c5..bbd9f1ecd4 100644
--- a/.agents/skills/tui-development/SKILL.md
+++ b/.agents/skills/tui-development/SKILL.md
@@ -19,7 +19,7 @@ The OpenShell TUI is a ratatui-based terminal UI for the OpenShell platform. It
- `tonic` with TLS — gRPC client for the OpenShell gateway
- `tokio` — async runtime for event loop, spawned tasks, and mpsc channels
- `openshell-core` — proto-generated types (`OpenShellClient`, request/response structs)
- - `openshell-bootstrap` — cluster discovery (`list_clusters()`)
+ - `openshell-bootstrap` — gateway discovery (`list_gateways()`)
- **Theme:** Adaptive dark/light via `Theme` struct — NVIDIA-branded green accents. Controlled by `--theme` flag, `OPENSHELL_THEME` env var, or auto-detection.
## 2. Domain Object Hierarchy
@@ -33,7 +33,7 @@ Gateway (discovered via openshell_bootstrap::list_gateways())
```
- **Gateways** are discovered from on-disk config via `openshell_bootstrap::list_gateways()`. Each gateway has a name, endpoint, and local/remote flag.
-- **Sandboxes** belong to the active cluster. Fetched via `ListSandboxes` gRPC call with a periodic tick refresh. Each sandbox has: `id`, `name`, `phase`, `created_at_ms`, and `spec.template.image`.
+- **Sandboxes** belong to the active gateway. Fetched via `ListSandboxes` gRPC call with a periodic tick refresh. Each sandbox has: `id`, `name`, `phase`, `created_at_ms`, and `spec.template.image`.
- **Logs** belong to a single sandbox. Initial batch fetched via `GetSandboxLogs` (500 lines), then live-tailed via `WatchSandbox` with `follow_logs: true`.
The **title bar** always reflects this hierarchy, reading left-to-right from general to specific:
@@ -90,7 +90,7 @@ Every frame renders four vertical regions:
```
┌─────────────────────────────────────────────┐
-│ Title bar (1 row) — brand + cluster + context│
+│ Title bar (1 row) — brand + gateway + context│
├─────────────────────────────────────────────┤
│ │
│ Main content (flexible) │
@@ -270,7 +270,7 @@ TUI actions should parallel `openshell` CLI commands so users have familiar ment
| `openshell sandbox list` | Sandbox table on Dashboard |
| `openshell sandbox delete ` | `[d]` on sandbox detail, then `[y]` to confirm |
| `openshell logs ` | `[l]` on sandbox detail to open log viewer |
-| `openshell status` | Status in title bar + cluster list |
+| `openshell status` | Status in title bar + gateway list |
When adding new TUI features, check what the CLI offers and maintain consistency.
@@ -364,7 +364,7 @@ lib.rs (event loop, gRPC, async tasks)
├── theme.rs (colors + styles)
└── ui/
├── mod.rs (draw dispatcher, chrome)
- ├── dashboard.rs (cluster list + sandbox table layout)
+ ├── dashboard.rs (gateway list + sandbox table layout)
├── sandboxes.rs (sandbox table widget)
├── sandbox_detail.rs (detail view)
└── sandbox_logs.rs (log viewer)
@@ -415,7 +415,7 @@ All gRPC calls use a 5-second timeout:
tokio::time::timeout(Duration::from_secs(5), client.health(req)).await
```
-The connect timeout for cluster switching is 10 seconds with HTTP/2 keepalive at 10-second intervals.
+The connect timeout for gateway switching is 10 seconds with HTTP/2 keepalive at 10-second intervals.
### Log streaming lifecycle
@@ -443,7 +443,7 @@ The connect timeout for cluster switching is 10 seconds with HTTP/2 keepalive at
# Build the crate
cargo build -p openshell-tui
-# Run the TUI against the active cluster
+# Run the TUI against the active gateway
mise run term
# Run with cargo-watch for hot-reload during development
@@ -466,13 +466,21 @@ mise run pre-commit
### Gateway changes
-If you change sandbox or server code that affects the backend, redeploy the gateway:
+If you change sandbox or server code that affects the backend, restart or redeploy the gateway for the compute platform you are using.
+
+For Docker-backed local development:
+
+```bash
+mise run gateway:docker
+```
+
+For Kubernetes Helm deployments:
```bash
-mise run cluster:deploy all
+helm upgrade --install openshell deploy/helm/openshell --namespace openshell
```
-To pick up new sandbox images after changing sandbox code, delete the pod manually so it gets recreated:
+For Kubernetes, pick up new sandbox images after changing sandbox code by deleting the pod manually so it gets recreated:
```bash
kubectl delete pod -n
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index b73df3d205..b569db2a7c 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,9 +1,5 @@
# Broad ownership — core team reviews everything
-* @NVIDIA/openshell-codeowners
-
-# Agent infrastructure — tighter review
-.agents/ @NVIDIA/openshell-codeowners
-AGENTS.md @NVIDIA/openshell-codeowners
+* @NVIDIA/openshell-codeowners @mrunalp @maxamillion @derekwaynecarr
# Vouch list — maintainers only (bot commits bypass, but manual edits need review)
.github/VOUCHED.td @NVIDIA/openshell-codeowners
diff --git a/.github/actions/pr-gate/action.yml b/.github/actions/pr-gate/action.yml
new file mode 100644
index 0000000000..5d4b9c1833
--- /dev/null
+++ b/.github/actions/pr-gate/action.yml
@@ -0,0 +1,62 @@
+name: PR Gate
+description: >
+ Resolve PR metadata for a `pull-request/` push from copy-pr-bot and decide
+ whether the workflow should run. Sets `should-run=true` only when the pushed
+ SHA still matches the PR head SHA. If `required_label` is provided, the PR
+ must also carry that label. For non-`push` events (e.g. `workflow_dispatch`),
+ always sets `should-run=true`.
+
+inputs:
+ required_label:
+ description: Optional PR label required to enable the run (e.g. "test:e2e").
+ required: false
+ default: ""
+
+outputs:
+ should_run:
+ description: "true if the workflow should proceed, false otherwise"
+ value: ${{ steps.gate.outputs.should_run }}
+
+runs:
+ using: composite
+ steps:
+ - id: get_pr_info
+ if: github.event_name == 'push'
+ continue-on-error: true
+ uses: nv-gha-runners/get-pr-info@main
+
+ - id: gate
+ shell: bash
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ GITHUB_SHA_VALUE: ${{ github.sha }}
+ GET_PR_INFO_OUTCOME: ${{ steps.get_pr_info.outcome }}
+ PR_INFO: ${{ steps.get_pr_info.outputs.pr-info }}
+ REQUIRED_LABEL: ${{ inputs.required_label }}
+ run: |
+ if [ "$EVENT_NAME" != "push" ]; then
+ echo "should_run=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ if [ "$GET_PR_INFO_OUTCOME" != "success" ]; then
+ echo "should_run=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ head_sha="$(jq -r '.head.sha' <<< "$PR_INFO")"
+ if [ -z "$REQUIRED_LABEL" ]; then
+ has_label=true
+ else
+ has_label="$(jq -r --arg L "$REQUIRED_LABEL" '[.labels[].name] | index($L) != null' <<< "$PR_INFO")"
+ fi
+
+ # Only trust copied pull-request/* pushes that still match the PR head
+ # SHA and, when configured, carry the required label.
+ if [ "$head_sha" = "$GITHUB_SHA_VALUE" ] && [ "$has_label" = "true" ]; then
+ should_run=true
+ else
+ should_run=false
+ fi
+
+ echo "should_run=$should_run" >> "$GITHUB_OUTPUT"
diff --git a/.github/actions/setup-buildx/action.yml b/.github/actions/setup-buildx/action.yml
index f7c8dbbd01..41210035fb 100644
--- a/.github/actions/setup-buildx/action.yml
+++ b/.github/actions/setup-buildx/action.yml
@@ -1,24 +1,41 @@
name: Setup Docker Buildx
description: >
- Create a multi-arch Docker Buildx builder using remote BuildKit nodes.
- The builder is automatically removed when the job finishes (cleanup is
- enabled by default in docker/setup-buildx-action).
+ Create a Docker Buildx builder. Two modes:
+ * driver=remote (default) — multi-arch builder against in-cluster BuildKit
+ pods. Requires EKS connectivity. Behaviour unchanged from prior versions.
+ * driver=local — single-node buildx on the local docker-container driver.
+ Pair with cache-to/cache-from=type=gha on build steps for persistence.
+ Works on nv-gha-runners; no EKS needed.
+ Cleanup is automatic when the job finishes (docker/setup-buildx-action default).
inputs:
+ driver:
+ description: "buildx driver: 'remote' or 'local'"
+ default: remote
amd64-endpoint:
- description: BuildKit endpoint for linux/amd64
+ description: BuildKit endpoint for linux/amd64 (remote driver only)
default: tcp://buildkit-amd64.buildkit:1234
arm64-endpoint:
- description: BuildKit endpoint for linux/arm64
+ description: BuildKit endpoint for linux/arm64 (remote driver only)
default: tcp://buildkit-arm64.buildkit:1234
name:
description: Builder instance name
default: openshell
+ buildkitd-config:
+ description: >
+ Path to a buildkitd.toml to configure the builder with (e.g. the
+ nv-gha-runners Docker Hub mirror at /etc/buildkit/buildkitd.toml).
+ Must be readable *from where this action runs* — in a containerized
+ job that means the caller must bind-mount the host path into the job
+ container (e.g. `volumes: [/etc/buildkit:/etc/buildkit:ro]`). Empty
+ disables the config (default).
+ default: ""
runs:
using: composite
steps:
- - name: Set up Docker Buildx
+ - name: Set up Docker Buildx (remote)
+ if: inputs.driver == 'remote'
uses: docker/setup-buildx-action@v3
with:
name: ${{ inputs.name }}
@@ -28,3 +45,13 @@ runs:
append: |
- endpoint: ${{ inputs.arm64-endpoint }}
platforms: linux/arm64
+ buildkitd-config: ${{ inputs.buildkitd-config }}
+
+ - name: Set up Docker Buildx (local)
+ if: inputs.driver == 'local'
+ uses: docker/setup-buildx-action@v3
+ with:
+ name: ${{ inputs.name }}
+ driver: docker-container
+ platforms: linux/amd64,linux/arm64
+ buildkitd-config: ${{ inputs.buildkitd-config }}
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000000..29aeab9aa1
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,8 @@
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ cooldown:
+ default-days: 2
diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml
index 3d31f6e9f6..115e0b5c17 100644
--- a/.github/workflows/branch-checks.yml
+++ b/.github/workflows/branch-checks.yml
@@ -1,56 +1,107 @@
name: Branch Checks
on:
- pull_request:
+ push:
+ branches:
+ - "pull-request/[0-9]+"
+ workflow_dispatch:
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: "0"
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ SCCACHE_GHA_ENABLED: "true"
permissions:
contents: read
packages: read
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
+ pr_metadata:
+ name: Resolve PR metadata
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ outputs:
+ should_run: ${{ steps.gate.outputs.should_run }}
+ steps:
+ - uses: actions/checkout@v6
+
+ - id: gate
+ uses: ./.github/actions/pr-gate
+
+ mise-lockfile:
+ name: mise Lockfile
+ needs: pr_metadata
+ if: needs.pr_metadata.outputs.should_run == 'true'
+ runs-on: linux-amd64-cpu8
+ container:
+ image: ghcr.io/nvidia/openshell/ci:latest
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Mark workspace as safe for git
+ run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
+
+ - name: Verify mise.lock is in sync with mise.toml
+ run: |
+ mise lock
+ if ! git diff --exit-code mise.lock; then
+ echo "::error::mise.lock is out of sync with mise.toml. Run 'mise lock' locally and commit the result." >&2
+ exit 1
+ fi
+
license-headers:
name: License Headers
- runs-on: build-amd64
+ needs: pr_metadata
+ if: needs.pr_metadata.outputs.should_run == 'true'
+ runs-on: linux-amd64-cpu8
container:
image: ghcr.io/nvidia/openshell/ci:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Install tools
- run: mise install
+ run: mise install --locked
- name: Check license headers
run: mise run license:check
rust:
name: Rust (${{ matrix.runner }})
+ needs: pr_metadata
+ if: needs.pr_metadata.outputs.should_run == 'true'
strategy:
fail-fast: false
matrix:
- runner: [build-amd64, build-arm64]
+ runner: [linux-amd64-cpu8, linux-arm64-cpu8]
runs-on: ${{ matrix.runner }}
+ env:
+ SCCACHE_GHA_VERSION: branch-checks-rust-${{ matrix.runner }}
container:
image: ghcr.io/nvidia/openshell/ci:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Install tools
- run: mise install
+ run: mise install --locked
- - name: Configure sccache remote cache
- if: vars.SCCACHE_MEMCACHED_ENDPOINT != ''
- run: echo "SCCACHE_MEMCACHED_ENDPOINT=${{ vars.SCCACHE_MEMCACHED_ENDPOINT }}" >> "$GITHUB_ENV"
+ - name: Configure GHA sccache backend
+ uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
- name: Cache Rust target and registry
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
@@ -72,14 +123,24 @@ jobs:
- name: sccache stats
if: always()
- run: mise x -- sccache --show-stats
+ run: |
+ set +e
+ stats_bin="${SCCACHE_PATH:-sccache}"
+ "$stats_bin" --show-stats
+ status=$?
+ if [[ $status -ne 0 ]]; then
+ echo "::warning::sccache stats unavailable (exit $status)"
+ fi
+ exit 0
python:
name: Python (${{ matrix.runner }})
+ needs: pr_metadata
+ if: needs.pr_metadata.outputs.should_run == 'true'
strategy:
fail-fast: false
matrix:
- runner: [build-amd64, build-arm64]
+ runner: [linux-amd64-cpu8, linux-arm64-cpu8]
runs-on: ${{ matrix.runner }}
container:
image: ghcr.io/nvidia/openshell/ci:latest
@@ -87,10 +148,10 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Install tools
- run: mise install
+ run: mise install --locked
- name: Install dependencies
run: uv sync --frozen
@@ -100,3 +161,22 @@ jobs:
- name: Test
run: mise run test:python
+
+ markdown:
+ name: Markdown
+ needs: pr_metadata
+ if: needs.pr_metadata.outputs.should_run == 'true'
+ runs-on: linux-amd64-cpu8
+ container:
+ image: ghcr.io/nvidia/openshell/ci:latest
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Install tools
+ run: mise install --locked
+
+ - name: Lint
+ run: mise run markdown:lint
diff --git a/.github/workflows/branch-docs.yml b/.github/workflows/branch-docs.yml
index 762d2d1fbb..1368bc775b 100644
--- a/.github/workflows/branch-docs.yml
+++ b/.github/workflows/branch-docs.yml
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Check Fern preview availability
id: fern-preview
diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml
index ad53bb6358..cb2f5440e3 100644
--- a/.github/workflows/branch-e2e.yml
+++ b/.github/workflows/branch-e2e.yml
@@ -1,33 +1,47 @@
name: Branch E2E Checks
on:
- pull_request:
- types: [opened, synchronize, reopened, labeled]
+ push:
+ branches:
+ - "pull-request/[0-9]+"
+ workflow_dispatch: {}
-permissions:
- contents: read
- packages: write
+permissions: {}
jobs:
+ pr_metadata:
+ name: Resolve PR metadata
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ outputs:
+ should_run: ${{ steps.gate.outputs.should_run }}
+ steps:
+ - uses: actions/checkout@v6
+ - id: gate
+ uses: ./.github/actions/pr-gate
+ with:
+ required_label: test:e2e
+
build-gateway:
- if: contains(github.event.pull_request.labels.*.name, 'test:e2e')
+ needs: [pr_metadata]
+ if: needs.pr_metadata.outputs.should_run == 'true'
+ permissions:
+ contents: read
+ packages: write
uses: ./.github/workflows/docker-build.yml
with:
component: gateway
platform: linux/arm64
- runner: build-arm64
-
- build-cluster:
- if: contains(github.event.pull_request.labels.*.name, 'test:e2e')
- uses: ./.github/workflows/docker-build.yml
- with:
- component: cluster
- platform: linux/arm64
- runner: build-arm64
e2e:
- needs: [build-gateway, build-cluster]
+ needs: [pr_metadata, build-gateway]
+ if: needs.pr_metadata.outputs.should_run == 'true'
+ permissions:
+ contents: read
+ packages: read
uses: ./.github/workflows/e2e-test.yml
with:
image-tag: ${{ github.sha }}
- runner: build-arm64
+ runner: linux-arm64-cpu8
diff --git a/.github/workflows/ci-image.yml b/.github/workflows/ci-image.yml
index 2f7737d961..8b05091697 100644
--- a/.github/workflows/ci-image.yml
+++ b/.github/workflows/ci-image.yml
@@ -6,6 +6,7 @@ on:
paths:
- 'deploy/docker/Dockerfile.ci'
- 'mise.toml'
+ - 'mise.lock'
- 'tasks/**'
- '.github/workflows/ci-image.yml'
workflow_dispatch:
@@ -20,10 +21,21 @@ permissions:
jobs:
build-ci-image:
- name: Build
- runs-on: build-amd64
+ name: Build (${{ matrix.arch }})
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: amd64
+ platform: linux/amd64
+ runner: linux-amd64-cpu8
+ - arch: arm64
+ platform: linux/arm64
+ runner: linux-arm64-cpu8
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 60
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
@@ -32,18 +44,66 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Resolve BuildKit config
+ id: buildkit
+ run: |
+ if [[ -r /etc/buildkit/buildkitd.toml ]]; then
+ echo "config=/etc/buildkit/buildkitd.toml" >> "$GITHUB_OUTPUT"
+ else
+ echo "config=" >> "$GITHUB_OUTPUT"
+ fi
+
- name: Set up Docker Buildx
uses: ./.github/actions/setup-buildx
+ with:
+ driver: local
+ buildkitd-config: ${{ steps.buildkit.outputs.config }}
- name: Build and push CI image
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ ARCH_IMAGE: ${{ env.CI_IMAGE }}:${{ github.sha }}-${{ matrix.arch }}
run: |
+ set -euo pipefail
docker buildx build \
- --platform linux/amd64,linux/arm64 \
+ --builder openshell \
+ --platform "${{ matrix.platform }}" \
--secret id=MISE_GITHUB_TOKEN,env=MISE_GITHUB_TOKEN \
+ --cache-from "type=gha,scope=ci-image-${{ matrix.arch }}" \
+ --cache-to "type=gha,mode=max,scope=ci-image-${{ matrix.arch }}" \
--push \
- -t ${{ env.CI_IMAGE }}:${{ github.sha }} \
- -t ${{ env.CI_IMAGE }}:latest \
+ -t "$ARCH_IMAGE" \
-f deploy/docker/Dockerfile.ci \
.
+
+ - name: Smoke check CI image
+ env:
+ ARCH_IMAGE: ${{ env.CI_IMAGE }}:${{ github.sha }}-${{ matrix.arch }}
+ run: |
+ set -euo pipefail
+ docker run --rm --platform "${{ matrix.platform }}" "$ARCH_IMAGE" mise --version
+ docker run --rm --platform "${{ matrix.platform }}" "$ARCH_IMAGE" gh --version
+ docker run --rm --platform "${{ matrix.platform }}" "$ARCH_IMAGE" docker buildx version
+
+ merge-ci-image:
+ name: Merge manifest
+ needs: build-ci-image
+ runs-on: linux-amd64-cpu8
+ timeout-minutes: 10
+ steps:
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Create multi-arch manifests
+ run: |
+ set -euo pipefail
+ docker buildx imagetools create \
+ --prefer-index=false \
+ -t "${CI_IMAGE}:${GITHUB_SHA}" \
+ -t "${CI_IMAGE}:latest" \
+ "${CI_IMAGE}:${GITHUB_SHA}-amd64" \
+ "${CI_IMAGE}:${GITHUB_SHA}-arm64"
diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml
index 88660b1e54..53339f4bf9 100644
--- a/.github/workflows/dco.yml
+++ b/.github/workflows/dco.yml
@@ -38,7 +38,7 @@ jobs:
path-to-signatures: "dco-signatures.json"
path-to-document: "https://github.com/NVIDIA/OpenShell/blob/main/DCO"
branch: "signatures"
- allowlist: dependabot
+ allowlist: "dependabot[bot]"
create-file-commit-message: "chore: create file to store dco signatures"
signed-commit-message: "chore: $contributorName has signed the dco in #$pullRequestNo"
custom-notsigned-prcomment: >-
diff --git a/.github/workflows/deb-package.yml b/.github/workflows/deb-package.yml
new file mode 100644
index 0000000000..2877487582
--- /dev/null
+++ b/.github/workflows/deb-package.yml
@@ -0,0 +1,92 @@
+name: Debian Package
+
+on:
+ workflow_call:
+ inputs:
+ deb-version:
+ required: true
+ type: string
+ checkout-ref:
+ required: true
+ type: string
+
+permissions:
+ contents: read
+ packages: read
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ build-deb-linux:
+ name: Build Debian Package (Linux ${{ matrix.arch }})
+ strategy:
+ matrix:
+ include:
+ - arch: amd64
+ runner: linux-amd64-cpu8
+ deb_arch: amd64
+ cli_target: x86_64-unknown-linux-musl
+ gnu_target: x86_64-unknown-linux-gnu
+ - arch: arm64
+ runner: linux-arm64-cpu8
+ deb_arch: arm64
+ cli_target: aarch64-unknown-linux-musl
+ gnu_target: aarch64-unknown-linux-gnu
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 20
+ container:
+ image: ghcr.io/nvidia/openshell/ci:latest
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs['checkout-ref'] }}
+
+ - name: Download CLI artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: cli-linux-${{ matrix.arch }}
+ path: package-input/
+
+ - name: Download gateway artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: gateway-binary-linux-${{ matrix.arch }}
+ path: package-input/
+
+ - name: Download VM driver artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: driver-vm-linux-${{ matrix.arch }}
+ path: package-input/
+
+ - name: Extract package inputs
+ run: |
+ set -euo pipefail
+ mkdir -p package-binaries
+ tar -xzf "package-input/openshell-${{ matrix.cli_target }}.tar.gz" -C package-binaries
+ tar -xzf "package-input/openshell-gateway-${{ matrix.gnu_target }}.tar.gz" -C package-binaries
+ tar -xzf "package-input/openshell-driver-vm-${{ matrix.gnu_target }}.tar.gz" -C package-binaries
+ ls -lah package-binaries
+
+ - name: Build Debian package
+ run: |
+ set -euo pipefail
+ OPENSHELL_CLI_BINARY="${PWD}/package-binaries/openshell" \
+ OPENSHELL_GATEWAY_BINARY="${PWD}/package-binaries/openshell-gateway" \
+ OPENSHELL_DRIVER_VM_BINARY="${PWD}/package-binaries/openshell-driver-vm" \
+ OPENSHELL_DEB_VERSION="${{ inputs['deb-version'] }}" \
+ OPENSHELL_DEB_ARCH="${{ matrix.deb_arch }}" \
+ OPENSHELL_OUTPUT_DIR=artifacts \
+ tasks/scripts/package-deb.sh
+
+ - name: Upload Debian package artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: deb-linux-${{ matrix.arch }}
+ path: artifacts/*.deb
+ retention-days: 5
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index 16a8447c99..3b3aa1cb82 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -4,11 +4,11 @@ on:
workflow_call:
inputs:
component:
- description: "Component to build (gateway, cluster)"
+ description: "Component to build (gateway, supervisor, cluster)"
required: true
type: string
timeout-minutes:
- description: "Job timeout in minutes"
+ description: "Per-arch Docker image job timeout in minutes"
required: false
type: number
default: 20
@@ -23,29 +23,145 @@ on:
type: string
default: "linux/amd64,linux/arm64"
runner:
- description: "GitHub Actions runner label"
+ description: "Deprecated; per-arch native runners are selected automatically"
required: false
type: string
- default: "build-amd64"
+ default: "linux-amd64-cpu8"
cargo-version:
description: "Pre-computed cargo version (skips internal git-based computation)"
required: false
type: string
default: ""
+ image-tag:
+ description: "Image tag base to build/push (defaults to the GitHub SHA)"
+ required: false
+ type: string
+ default: ""
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- SCCACHE_MEMCACHED_ENDPOINT: ${{ vars.SCCACHE_MEMCACHED_ENDPOINT }}
permissions:
contents: read
packages: write
+defaults:
+ run:
+ shell: bash
+
jobs:
+ resolve:
+ name: Resolve build plan
+ runs-on: ubuntu-latest
+ outputs:
+ matrix: ${{ steps.resolve.outputs.matrix }}
+ platform_count: ${{ steps.resolve.outputs.platform_count }}
+ arches: ${{ steps.resolve.outputs.arches }}
+ binary_component: ${{ steps.resolve.outputs.binary_component }}
+ binary_name: ${{ steps.resolve.outputs.binary_name }}
+ artifact_prefix: ${{ steps.resolve.outputs.artifact_prefix }}
+ image_tag_base: ${{ steps.resolve.outputs.image_tag_base }}
+ steps:
+ - name: Resolve component and platform matrix
+ id: resolve
+ run: |
+ set -euo pipefail
+
+ component="${{ inputs.component }}"
+ case "$component" in
+ gateway)
+ binary_component=gateway
+ binary_name=openshell-gateway
+ ;;
+ supervisor|cluster)
+ binary_component=sandbox
+ binary_name=openshell-sandbox
+ ;;
+ *)
+ echo "unsupported component: $component" >&2
+ exit 1
+ ;;
+ esac
+
+ image_tag_base="${{ inputs['image-tag'] }}"
+ if [[ -z "$image_tag_base" ]]; then
+ image_tag_base="$GITHUB_SHA"
+ fi
+
+ platform_input="${{ inputs.platform }}"
+ platform_input="${platform_input//[[:space:]]/}"
+ if [[ -z "$platform_input" ]]; then
+ echo "platform input must not be empty" >&2
+ exit 1
+ fi
+
+ IFS=',' read -r -a platforms <<< "$platform_input"
+ matrix='{"include":['
+ arches=()
+ count=0
+
+ for platform in "${platforms[@]}"; do
+ case "$platform" in
+ linux/amd64)
+ arch=amd64
+ runner=linux-amd64-cpu8
+ ;;
+ linux/arm64)
+ arch=arm64
+ runner=linux-arm64-cpu8
+ ;;
+ *)
+ echo "unsupported platform: $platform" >&2
+ echo "supported platforms: linux/amd64, linux/arm64" >&2
+ exit 1
+ ;;
+ esac
+
+ if [[ $count -gt 0 ]]; then
+ matrix+=','
+ fi
+ matrix+='{"platform":"'"$platform"'","arch":"'"$arch"'","runner":"'"$runner"'"}'
+ arches+=("$arch")
+ count=$((count + 1))
+ done
+
+ matrix+=']}'
+ {
+ echo "matrix=$matrix"
+ echo "platform_count=$count"
+ echo "arches=${arches[*]}"
+ echo "binary_component=$binary_component"
+ echo "binary_name=$binary_name"
+ echo "artifact_prefix=rust-binary-${component}-${binary_component}"
+ echo "image_tag_base=$image_tag_base"
+ } >> "$GITHUB_OUTPUT"
+
+ rust-binary:
+ name: Rust ${{ needs.resolve.outputs.binary_component }} (${{ matrix.arch }})
+ needs: resolve
+ permissions:
+ contents: read
+ packages: read
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJSON(needs.resolve.outputs.matrix) }}
+ uses: ./.github/workflows/shadow-rust-native-build.yml
+ with:
+ component: ${{ needs.resolve.outputs.binary_component }}
+ arch: ${{ matrix.arch }}
+ cargo-version: ${{ inputs['cargo-version'] }}
+ features: openshell-core/dev-settings
+ artifact-name: ${{ needs.resolve.outputs.artifact_prefix }}-linux-${{ matrix.arch }}
+ secrets: inherit
+
build:
- name: Build ${{ inputs.component }}
- runs-on: ${{ inputs.runner }}
- timeout-minutes: ${{ inputs.timeout-minutes }}
+ name: Build ${{ inputs.component }} (${{ matrix.arch }})
+ needs: [resolve, rust-binary]
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: ${{ inputs['timeout-minutes'] }}
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJSON(needs.resolve.outputs.matrix) }}
container:
image: ghcr.io/nvidia/openshell/ci:latest
credentials:
@@ -54,43 +170,115 @@ jobs:
options: --privileged
volumes:
- /var/run/docker.sock:/var/run/docker.sock
+ # Expose the nv-gha-runners buildkitd.toml registry mirror config
+ # inside the container so setup-buildx can read it.
+ - /etc/buildkit:/etc/buildkit:ro
env:
- IMAGE_TAG: ${{ github.sha }}
+ IMAGE_TAG: ${{ needs.resolve.outputs.platform_count == '1' && needs.resolve.outputs.image_tag_base || format('{0}-{1}', needs.resolve.outputs.image_tag_base, matrix.arch) }}
IMAGE_REGISTRY: ghcr.io/nvidia/openshell
DOCKER_PUSH: ${{ inputs.push && '1' || '0' }}
- DOCKER_PLATFORM: ${{ inputs.platform }}
+ DOCKER_PLATFORM: ${{ matrix.platform }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Mark workspace safe for git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- - name: Fetch tags
- run: git fetch --tags --force
-
- - name: Compute cargo version
- id: version
- run: |
- set -eu
- if [[ -n "${{ inputs.cargo-version }}" ]]; then
- echo "cargo_version=${{ inputs.cargo-version }}" >> "$GITHUB_OUTPUT"
- else
- echo "cargo_version=$(uv run python tasks/scripts/release.py get-version --cargo)" >> "$GITHUB_OUTPUT"
- fi
+ - name: Install tools
+ run: mise install --locked
- name: Log in to GHCR
+ if: ${{ inputs.push }}
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
- - name: Set up Docker Buildx
+ - name: Set up buildx (local driver)
uses: ./.github/actions/setup-buildx
+ with:
+ driver: local
+ buildkitd-config: /etc/buildkit/buildkitd.toml
+
+ - name: Download Rust binary artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: ${{ needs.resolve.outputs.artifact_prefix }}-linux-${{ matrix.arch }}
+ path: prebuilt-rust-binary
+
+ - name: Stage Rust binary in Docker build context
+ run: |
+ set -euo pipefail
+ binary="${{ needs.resolve.outputs.binary_name }}"
+ download_dir="prebuilt-rust-binary"
+ stage="deploy/docker/.build/prebuilt-binaries/${{ matrix.arch }}"
+ found="$(find "$download_dir" -type f -name "$binary" -print -quit)"
+ if [[ -z "$found" ]]; then
+ echo "missing downloaded artifact file: $binary" >&2
+ find "$download_dir" -maxdepth 4 -type f -print >&2 || true
+ exit 1
+ fi
+ mkdir -p "$stage"
+ install -m 0755 "$found" "$stage/$binary"
+ ls -lh "$stage/"
- name: Build ${{ inputs.component }} image
env:
DOCKER_BUILDER: openshell
- OPENSHELL_CARGO_VERSION: ${{ steps.version.outputs.cargo_version }}
- # Enable dev-settings feature for test settings (dummy_bool, dummy_int)
- # used by e2e tests.
- EXTRA_CARGO_FEATURES: openshell-core/dev-settings
- run: mise run --no-prepare docker:build:${{ inputs.component }}
+ run: |
+ set -euo pipefail
+ mise exec -- tasks/scripts/docker-build-image.sh "${{ inputs.component }}" \
+ --cache-from "type=gha,scope=${{ inputs.component }}-${{ matrix.arch }}" \
+ --cache-to "type=gha,mode=max,scope=${{ inputs.component }}-${{ matrix.arch }}"
+
+ - name: Smoke check ${{ inputs.component }} image
+ if: ${{ !inputs.push }}
+ run: |
+ set -euo pipefail
+ image="${IMAGE_REGISTRY}/${{ inputs.component }}:${IMAGE_TAG}"
+ case "${{ inputs.component }}" in
+ gateway)
+ output="$(docker run --rm --platform "${{ matrix.platform }}" "$image" --version)"
+ echo "$output"
+ grep -q '^openshell-gateway ' <<<"$output"
+ ;;
+ supervisor)
+ output="$(docker run --rm --platform "${{ matrix.platform }}" "$image" --version)"
+ echo "$output"
+ grep -q '^openshell-sandbox ' <<<"$output"
+ ;;
+ cluster)
+ output="$(docker run --rm --platform "${{ matrix.platform }}" --entrypoint /opt/openshell/bin/openshell-sandbox "$image" --version)"
+ echo "$output"
+ grep -q '^openshell-sandbox ' <<<"$output"
+ ;;
+ esac
+
+ merge:
+ name: Merge ${{ inputs.component }} manifest
+ needs: [resolve, build]
+ if: ${{ inputs.push && needs.resolve.outputs.platform_count != '1' }}
+ runs-on: linux-amd64-cpu8
+ timeout-minutes: 10
+ container:
+ image: ghcr.io/nvidia/openshell/ci:latest
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ steps:
+ - name: Log in to GHCR
+ run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
+
+ - name: Create multi-arch manifest
+ run: |
+ set -euo pipefail
+ image="ghcr.io/nvidia/openshell/${{ inputs.component }}"
+ refs=()
+ for arch in ${{ needs.resolve.outputs.arches }}; do
+ refs+=("${image}:${{ needs.resolve.outputs.image_tag_base }}-${arch}")
+ done
+ docker buildx imagetools create \
+ --prefer-index=false \
+ -t "${image}:${{ needs.resolve.outputs.image_tag_base }}" \
+ "${refs[@]}"
diff --git a/.github/workflows/driver-vm-linux.yml b/.github/workflows/driver-vm-linux.yml
new file mode 100644
index 0000000000..9647bda05a
--- /dev/null
+++ b/.github/workflows/driver-vm-linux.yml
@@ -0,0 +1,209 @@
+name: Driver VM Linux
+
+on:
+ workflow_call:
+ inputs:
+ cargo-version:
+ required: true
+ type: string
+ image-tag:
+ required: true
+ type: string
+ checkout-ref:
+ required: true
+ type: string
+
+permissions:
+ contents: read
+ packages: read
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ download-kernel-runtime:
+ name: Download Kernel Runtime
+ runs-on: linux-amd64-cpu8
+ timeout-minutes: 10
+ container:
+ image: ghcr.io/nvidia/openshell/ci:latest
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs['checkout-ref'] }}
+
+ - name: Download Linux runtime tarballs
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+ mkdir -p runtime-artifacts
+
+ for platform in linux-aarch64 linux-x86_64; do
+ asset="vm-runtime-${platform}.tar.zst"
+ echo "Downloading ${asset}..."
+ if ! gh release download vm-runtime \
+ --repo "${GITHUB_REPOSITORY}" \
+ --pattern "${asset}" \
+ --dir runtime-artifacts \
+ --clobber; then
+ echo "::error::No ${asset} asset found on vm-runtime release"
+ exit 1
+ fi
+ done
+
+ ls -lah runtime-artifacts/
+
+ - name: Verify downloads
+ run: |
+ set -euo pipefail
+ for platform in linux-aarch64 linux-x86_64; do
+ test -f "runtime-artifacts/vm-runtime-${platform}.tar.zst"
+ done
+
+ - name: Upload runtime artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: vm-driver-kernel-runtime-tarballs
+ path: runtime-artifacts/vm-runtime-*.tar.zst
+ retention-days: 1
+
+ build-driver-vm-linux:
+ name: Build Driver VM (Linux ${{ matrix.arch }})
+ needs: [download-kernel-runtime]
+ strategy:
+ matrix:
+ include:
+ - arch: arm64
+ runner: linux-arm64-cpu8
+ target: aarch64-unknown-linux-gnu
+ platform: linux-aarch64
+ guest_arch: aarch64
+ - arch: amd64
+ runner: linux-amd64-cpu8
+ target: x86_64-unknown-linux-gnu
+ platform: linux-x86_64
+ guest_arch: x86_64
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 30
+ container:
+ image: ghcr.io/nvidia/openshell/ci:latest
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ options: --privileged
+ env:
+ MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ OPENSHELL_IMAGE_TAG: ${{ inputs['image-tag'] }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs['checkout-ref'] }}
+ fetch-depth: 0
+
+ - name: Mark workspace safe for git
+ run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
+
+ - name: Fetch tags
+ run: git fetch --tags --force
+
+ - name: Install tools
+ run: mise install --locked
+
+ - name: Cache Rust target and registry
+ uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
+ with:
+ shared-key: driver-vm-linux-${{ matrix.arch }}
+ cache-directories: .cache/sccache
+ cache-targets: "true"
+
+ - name: Install zstd
+ run: apt-get update && apt-get install -y --no-install-recommends zstd && rm -rf /var/lib/apt/lists/*
+
+ - name: Download kernel runtime tarball
+ uses: actions/download-artifact@v4
+ with:
+ name: vm-driver-kernel-runtime-tarballs
+ path: runtime-download/
+
+ - name: Stage compressed runtime for embedding
+ run: |
+ set -euo pipefail
+ COMPRESSED_DIR="${PWD}/target/vm-runtime-compressed"
+ mkdir -p "$COMPRESSED_DIR"
+
+ EXTRACT_DIR=$(mktemp -d)
+ zstd -d "runtime-download/vm-runtime-${{ matrix.platform }}.tar.zst" --stdout \
+ | tar -xf - -C "$EXTRACT_DIR"
+
+ echo "Extracted runtime files:"
+ ls -lah "$EXTRACT_DIR"
+
+ for file in "$EXTRACT_DIR"/*; do
+ [ -f "$file" ] || continue
+ name=$(basename "$file")
+ [ "$name" = "provenance.json" ] && continue
+ zstd -19 -f -q -T0 -o "${COMPRESSED_DIR}/${name}.zst" "$file"
+ done
+
+ echo "Staged compressed runtime artifacts:"
+ ls -lah "$COMPRESSED_DIR"
+
+ - name: Build bundled supervisor
+ run: |
+ set -euo pipefail
+ OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="${PWD}/target/vm-runtime-compressed" \
+ tasks/scripts/vm/build-supervisor-bundle.sh --arch "${{ matrix.guest_arch }}"
+
+ - name: Verify embedded driver inputs
+ run: |
+ set -euo pipefail
+ for file in libkrun.so.zst libkrunfw.so.5.zst gvproxy.zst openshell-sandbox.zst; do
+ test -s "target/vm-runtime-compressed/${file}"
+ done
+
+ - name: Scope workspace to driver-vm crates
+ run: |
+ set -euo pipefail
+ sed -i 's|members = \["crates/\*"\]|members = ["crates/openshell-driver-vm", "crates/openshell-core"]|' Cargo.toml
+
+ - name: Patch workspace version
+ if: ${{ inputs['cargo-version'] != '' }}
+ run: |
+ set -euo pipefail
+ sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ inputs['cargo-version'] }}"'"/}' Cargo.toml
+
+ - name: Build openshell-driver-vm
+ run: |
+ set -euo pipefail
+ OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="${PWD}/target/vm-runtime-compressed" \
+ mise x -- cargo build --release -p openshell-driver-vm
+
+ - name: Verify packaged binary
+ run: |
+ set -euo pipefail
+ OUTPUT="$(target/release/openshell-driver-vm --version)"
+ echo "$OUTPUT"
+ grep -q '^openshell-driver-vm ' <<<"$OUTPUT"
+
+ - name: sccache stats
+ if: always()
+ run: mise x -- sccache --show-stats
+
+ - name: Package binary
+ run: |
+ set -euo pipefail
+ mkdir -p artifacts
+ tar -czf "artifacts/openshell-driver-vm-${{ matrix.target }}.tar.gz" \
+ -C target/release openshell-driver-vm
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: driver-vm-linux-${{ matrix.arch }}
+ path: artifacts/*.tar.gz
+ retention-days: 5
diff --git a/.github/workflows/driver-vm-macos.yml b/.github/workflows/driver-vm-macos.yml
new file mode 100644
index 0000000000..cfb041366d
--- /dev/null
+++ b/.github/workflows/driver-vm-macos.yml
@@ -0,0 +1,237 @@
+name: Driver VM macOS
+
+on:
+ workflow_call:
+ inputs:
+ cargo-version:
+ required: true
+ type: string
+ image-tag:
+ required: true
+ type: string
+ checkout-ref:
+ required: true
+ type: string
+
+permissions:
+ contents: read
+ packages: read
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ download-kernel-runtime:
+ name: Download Kernel Runtime
+ runs-on: linux-amd64-cpu8
+ timeout-minutes: 10
+ container:
+ image: ghcr.io/nvidia/openshell/ci:latest
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs['checkout-ref'] }}
+
+ - name: Download macOS runtime tarball
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+ mkdir -p runtime-artifacts
+
+ asset="vm-runtime-darwin-aarch64.tar.zst"
+ echo "Downloading ${asset}..."
+ if ! gh release download vm-runtime \
+ --repo "${GITHUB_REPOSITORY}" \
+ --pattern "${asset}" \
+ --dir runtime-artifacts \
+ --clobber; then
+ echo "::error::No ${asset} asset found on vm-runtime release"
+ exit 1
+ fi
+
+ ls -lah runtime-artifacts/
+
+ - name: Verify download
+ run: test -f runtime-artifacts/vm-runtime-darwin-aarch64.tar.zst
+
+ - name: Upload runtime artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: vm-driver-macos-kernel-runtime-tarball
+ path: runtime-artifacts/vm-runtime-darwin-aarch64.tar.zst
+ retention-days: 1
+
+ build-supervisor-arm64:
+ name: Build Supervisor Bundle (arm64)
+ runs-on: linux-arm64-cpu8
+ timeout-minutes: 30
+ container:
+ image: ghcr.io/nvidia/openshell/ci:latest
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ env:
+ MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ OPENSHELL_IMAGE_TAG: ${{ inputs['image-tag'] }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs['checkout-ref'] }}
+ fetch-depth: 0
+
+ - name: Mark workspace safe for git
+ run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
+
+ - name: Fetch tags
+ run: git fetch --tags --force
+
+ - name: Install tools
+ run: mise install --locked
+
+ - name: Cache Rust target and registry
+ uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
+ with:
+ shared-key: driver-vm-supervisor-arm64
+ cache-directories: .cache/sccache
+ cache-targets: "true"
+
+ - name: Install zstd
+ run: apt-get update && apt-get install -y --no-install-recommends zstd && rm -rf /var/lib/apt/lists/*
+
+ - name: Build bundled supervisor
+ run: |
+ set -euo pipefail
+ tasks/scripts/vm/build-supervisor-bundle.sh --arch aarch64
+
+ - name: sccache stats
+ if: always()
+ run: mise x -- sccache --show-stats
+
+ - name: Upload supervisor bundle
+ uses: actions/upload-artifact@v4
+ with:
+ name: driver-vm-supervisor-arm64
+ path: target/vm-runtime-compressed/openshell-sandbox.zst
+ retention-days: 1
+
+ build-driver-vm-macos:
+ name: Build Driver VM (macOS)
+ needs: [download-kernel-runtime, build-supervisor-arm64]
+ runs-on: linux-amd64-cpu8
+ timeout-minutes: 60
+ container:
+ image: ghcr.io/nvidia/openshell/ci:latest
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ options: --privileged
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ env:
+ MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs['checkout-ref'] }}
+ fetch-depth: 0
+
+ - name: Mark workspace safe for git
+ run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
+
+ - name: Fetch tags
+ run: git fetch --tags --force
+
+ - name: Log in to GHCR
+ run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
+
+ - name: Set up Docker Buildx
+ uses: ./.github/actions/setup-buildx
+ with:
+ driver: local
+
+ - name: Install zstd
+ run: apt-get update && apt-get install -y --no-install-recommends zstd && rm -rf /var/lib/apt/lists/*
+
+ - name: Download kernel runtime tarball
+ uses: actions/download-artifact@v4
+ with:
+ name: vm-driver-macos-kernel-runtime-tarball
+ path: runtime-download/
+
+ - name: Prepare compressed runtime directory
+ run: |
+ set -euo pipefail
+ COMPRESSED_DIR="${PWD}/target/vm-runtime-compressed-macos"
+ mkdir -p "$COMPRESSED_DIR"
+
+ EXTRACT_DIR=$(mktemp -d)
+ zstd -d "runtime-download/vm-runtime-darwin-aarch64.tar.zst" --stdout \
+ | tar -xf - -C "$EXTRACT_DIR"
+
+ echo "Extracted darwin runtime files:"
+ ls -lah "$EXTRACT_DIR"
+
+ for file in "$EXTRACT_DIR"/*; do
+ [ -f "$file" ] || continue
+ name=$(basename "$file")
+ [ "$name" = "provenance.json" ] && continue
+ zstd -19 -f -q -T0 -o "${COMPRESSED_DIR}/${name}.zst" "$file"
+ done
+
+ echo "Staged macOS compressed runtime artifacts:"
+ ls -lah "$COMPRESSED_DIR"
+
+ - name: Download bundled supervisor
+ uses: actions/download-artifact@v4
+ with:
+ name: driver-vm-supervisor-arm64
+ path: target/vm-runtime-compressed-macos/
+
+ - name: Verify bundled supervisor
+ run: |
+ set -euo pipefail
+ test -f target/vm-runtime-compressed-macos/openshell-sandbox.zst
+ ls -lh target/vm-runtime-compressed-macos/openshell-sandbox.zst
+
+ - name: Verify embedded driver inputs
+ run: |
+ set -euo pipefail
+ for file in libkrun.dylib.zst libkrunfw.5.dylib.zst gvproxy.zst openshell-sandbox.zst; do
+ test -s "target/vm-runtime-compressed-macos/${file}"
+ done
+
+ - name: Build macOS binary via Docker
+ run: |
+ set -euo pipefail
+ docker buildx build \
+ --file deploy/docker/Dockerfile.driver-vm-macos \
+ --build-arg OPENSHELL_CARGO_VERSION="${{ inputs['cargo-version'] }}" \
+ --build-arg OPENSHELL_IMAGE_TAG="${{ inputs['image-tag'] }}" \
+ --build-arg CARGO_TARGET_CACHE_SCOPE="${{ github.sha }}" \
+ --build-context vm-runtime-compressed="${PWD}/target/vm-runtime-compressed-macos" \
+ --target binary \
+ --output type=local,dest=out/ \
+ .
+
+ - name: Verify packaged binary shape
+ run: test -x out/openshell-driver-vm
+
+ - name: Package binary
+ run: |
+ set -euo pipefail
+ mkdir -p artifacts
+ tar -czf artifacts/openshell-driver-vm-aarch64-apple-darwin.tar.gz \
+ -C out openshell-driver-vm
+ ls -lh artifacts/
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: driver-vm-macos
+ path: artifacts/*.tar.gz
+ retention-days: 5
diff --git a/.github/workflows/e2e-gate-check.yml b/.github/workflows/e2e-gate-check.yml
new file mode 100644
index 0000000000..5065663c17
--- /dev/null
+++ b/.github/workflows/e2e-gate-check.yml
@@ -0,0 +1,113 @@
+name: E2E Gate Check
+
+# Reusable gate that enforces: when `required_label` is present on a PR,
+# `workflow_file` must have completed successfully for the PR head SHA.
+#
+# Callers wire their own triggers (`pull_request` + `workflow_run` for the
+# workflow this gate guards) and pass in the label and workflow filename.
+
+on:
+ workflow_call:
+ inputs:
+ required_label:
+ description: PR label that makes the gated workflow mandatory.
+ required: true
+ type: string
+ workflow_file:
+ description: Filename of the workflow whose run must have succeeded (e.g. "branch-e2e.yml").
+ required: true
+ type: string
+
+permissions: {}
+
+jobs:
+ check:
+ name: Enforce ${{ inputs.required_label }} runs when labeled
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ actions: read
+ steps:
+ - name: Resolve PR context
+ id: pr
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ EVENT_NAME: ${{ github.event_name }}
+ PR_HEAD_SHA_FROM_EVENT: ${{ github.event.pull_request.head.sha }}
+ PR_LABELS_FROM_EVENT: ${{ toJSON(github.event.pull_request.labels.*.name) }}
+ WORKFLOW_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ if [ "$EVENT_NAME" = "pull_request" ]; then
+ head_sha="$PR_HEAD_SHA_FROM_EVENT"
+ labels_json=$(jq -c . <<< "$PR_LABELS_FROM_EVENT")
+ else
+ head_sha="$WORKFLOW_RUN_HEAD_SHA"
+ pr=$(gh api "repos/$GH_REPO/commits/$head_sha/pulls" --jq '.[0] // empty')
+ if [ -z "$pr" ]; then
+ echo "No PR associated with $head_sha; gate is a no-op."
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ labels_json=$(jq -c '[.labels[].name]' <<< "$pr")
+ fi
+ echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"
+ echo "labels_json=$labels_json" >> "$GITHUB_OUTPUT"
+
+ - name: Evaluate gate
+ if: steps.pr.outputs.skip != 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
+ LABELS_JSON: ${{ steps.pr.outputs.labels_json }}
+ REQUIRED_LABEL: ${{ inputs.required_label }}
+ WORKFLOW_FILE: ${{ inputs.workflow_file }}
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ has_label=$(jq -r --arg L "$REQUIRED_LABEL" 'any(.[]; . == $L)' <<< "$LABELS_JSON")
+ if [ "$has_label" != "true" ]; then
+ echo "::notice::$REQUIRED_LABEL not applied; gate passes."
+ exit 0
+ fi
+
+ runs=$(gh api "repos/$GH_REPO/actions/workflows/$WORKFLOW_FILE/runs?head_sha=$HEAD_SHA&event=push" --jq '.workflow_runs')
+ latest=$(jq -c 'sort_by(.created_at) | reverse | .[0] // empty' <<< "$runs")
+
+ if [ -z "$latest" ]; then
+ echo "::error::$REQUIRED_LABEL is applied but $WORKFLOW_FILE has not run for $HEAD_SHA. Wait for copy-pr-bot to mirror the PR, or re-run the gate once the workflow completes."
+ exit 1
+ fi
+
+ run_id=$(jq -r '.id' <<< "$latest")
+ status=$(jq -r '.status' <<< "$latest")
+ conclusion=$(jq -r '.conclusion' <<< "$latest")
+
+ if [ "$status" != "completed" ]; then
+ echo "::error::$WORKFLOW_FILE is $status for $HEAD_SHA. This gate will re-evaluate on completion."
+ exit 1
+ fi
+
+ if [ "$conclusion" != "success" ]; then
+ echo "::error::$WORKFLOW_FILE concluded as $conclusion for $HEAD_SHA."
+ exit 1
+ fi
+
+ # Top-level success isn't enough: if `pr_metadata` gated downstream
+ # jobs out (label wasn't set at run time), only the gate job itself
+ # concludes `success` and the workflow still reports `success`.
+ # Require at least one non-gate job to have succeeded as proof the
+ # label was present when the workflow ran.
+ real_success=$(gh api "repos/$GH_REPO/actions/runs/$run_id/jobs" --jq '[.jobs[] | select(.conclusion == "success" and .name != "Resolve PR metadata")] | length')
+ if [ "$real_success" -lt 1 ]; then
+ echo "::error::$WORKFLOW_FILE run $run_id only ran the metadata gate — $REQUIRED_LABEL was not set when the workflow last executed. Re-run $WORKFLOW_FILE so the gate re-evaluates with the label present."
+ exit 1
+ fi
+
+ echo "$WORKFLOW_FILE run $run_id executed and succeeded for $HEAD_SHA ($real_success non-gate job(s) passed)."
+ exit 0
diff --git a/.github/workflows/e2e-gate.yml b/.github/workflows/e2e-gate.yml
new file mode 100644
index 0000000000..67959fa8dc
--- /dev/null
+++ b/.github/workflows/e2e-gate.yml
@@ -0,0 +1,67 @@
+name: E2E Gate
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, labeled, unlabeled, ready_for_review]
+ workflow_run:
+ workflows: ["Branch E2E Checks", "GPU Test"]
+ types: [completed]
+
+permissions: {}
+
+jobs:
+ # On PR events, actually evaluate the gate — these runs post their check
+ # result to the PR's head SHA, which is what branch protection sees.
+ e2e:
+ name: E2E
+ if: github.event_name == 'pull_request'
+ permissions:
+ contents: read
+ pull-requests: read
+ actions: read
+ uses: ./.github/workflows/e2e-gate-check.yml
+ with:
+ required_label: test:e2e
+ workflow_file: branch-e2e.yml
+
+ gpu:
+ name: GPU E2E
+ if: github.event_name == 'pull_request'
+ permissions:
+ contents: read
+ pull-requests: read
+ actions: read
+ uses: ./.github/workflows/e2e-gate-check.yml
+ with:
+ required_label: test:e2e-gpu
+ workflow_file: test-gpu.yml
+
+ # When the guarded workflow finishes, GitHub fires `workflow_run` in the
+ # default-branch context — any check posted from here would land on `main`,
+ # not on the PR. Instead, find the latest `pull_request`-triggered gate run
+ # for the same head SHA and ask the API to re-run it. The re-run replays the
+ # original event (labels, head SHA) so the check posts to the PR again, and
+ # this time the gate sees the successful upstream run.
+ rerun-on-completion:
+ name: Re-run gate in PR context
+ if: github.event_name == 'workflow_run'
+ runs-on: ubuntu-latest
+ permissions:
+ actions: write
+ steps:
+ - name: Rerun latest PR-context gate for this SHA
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ run_id=$(gh api "repos/$GH_REPO/actions/workflows/e2e-gate.yml/runs?event=pull_request&head_sha=$HEAD_SHA" \
+ --jq '.workflow_runs | sort_by(.created_at) | reverse | .[0].id // empty')
+ if [ -z "$run_id" ]; then
+ echo "No pull_request-triggered E2E Gate run found for $HEAD_SHA — nothing to re-run."
+ exit 0
+ fi
+ echo "Re-running E2E Gate run $run_id so it re-evaluates and posts a fresh check on the PR."
+ gh api --method POST "repos/$GH_REPO/actions/runs/$run_id/rerun"
diff --git a/.github/workflows/e2e-gpu-test.yaml b/.github/workflows/e2e-gpu-test.yaml
index 9feda37695..6a296f5e30 100644
--- a/.github/workflows/e2e-gpu-test.yaml
+++ b/.github/workflows/e2e-gpu-test.yaml
@@ -55,7 +55,7 @@ jobs:
OPENSHELL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
OPENSHELL_GATEWAY: ${{ matrix.cluster }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Log in to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
@@ -64,7 +64,7 @@ jobs:
run: docker pull ghcr.io/nvidia/openshell/cluster:${{ inputs.image-tag }}
- name: Install Python dependencies and generate protobuf stubs
- run: uv sync --frozen && mise run --no-prepare python:proto
+ run: uv sync --frozen && mise run --no-deps python:proto
- name: Bootstrap GPU cluster
env:
@@ -76,7 +76,7 @@ jobs:
SKIP_IMAGE_PUSH: "1"
SKIP_CLUSTER_IMAGE_BUILD: "1"
OPENSHELL_CLUSTER_IMAGE: ghcr.io/nvidia/openshell/cluster:${{ inputs.image-tag }}
- run: mise run --no-prepare --skip-deps cluster
+ run: mise run --no-deps --skip-deps cluster
- name: Run tests
- run: mise run --no-prepare --skip-deps e2e:python:gpu
+ run: mise run --no-deps --skip-deps e2e:python:gpu
diff --git a/.github/workflows/e2e-label-help.yml b/.github/workflows/e2e-label-help.yml
new file mode 100644
index 0000000000..2a61660d2a
--- /dev/null
+++ b/.github/workflows/e2e-label-help.yml
@@ -0,0 +1,67 @@
+name: E2E Label Help
+
+# When a `test:e2e` / `test:e2e-gpu` label is applied, post a PR comment
+# telling the maintainer the next manual step. We don't dispatch the workflow
+# ourselves: a workflow_dispatch-triggered run does not surface in the PR's
+# Checks tab, so we'd lose in-progress visibility. Instead we point the
+# maintainer at either the existing run (re-run from the UI) or the
+# `/ok to test ` command needed to refresh the mirror.
+#
+# Uses `pull_request_target` so forked PRs get a token capable of posting
+# comments. The job never checks out PR code; it only calls the GitHub API.
+
+on:
+ pull_request_target:
+ types: [labeled]
+
+permissions: {}
+
+jobs:
+ hint:
+ name: Post next-step hint for E2E label
+ if: github.event.label.name == 'test:e2e' || github.event.label.name == 'test:e2e-gpu'
+ runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write
+ actions: read
+ contents: read
+ steps:
+ - name: Post comment
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ LABEL_NAME: ${{ github.event.label.name }}
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ case "$LABEL_NAME" in
+ test:e2e) workflow_file=branch-e2e.yml; workflow_name="Branch E2E Checks" ;;
+ test:e2e-gpu) workflow_file=test-gpu.yml; workflow_name="GPU Test" ;;
+ *) echo "Unrecognized label $LABEL_NAME"; exit 1 ;;
+ esac
+
+ mirror_ref="pull-request/$PR_NUMBER"
+ mirror_sha=$(gh api "repos/$GH_REPO/branches/$mirror_ref" --jq '.commit.sha' 2>/dev/null || echo "")
+ short_pr=${PR_HEAD_SHA:0:7}
+
+ if [ -z "$mirror_sha" ]; then
+ body="Label \`$LABEL_NAME\` applied, but \`$mirror_ref\` does not exist yet. A maintainer needs to comment \`/ok to test $PR_HEAD_SHA\` to mirror this PR. Once the mirror exists, re-apply the label or re-run [$workflow_name](https://github.com/$GH_REPO/actions/workflows/$workflow_file) from the Actions tab."
+ elif [ "$mirror_sha" != "$PR_HEAD_SHA" ]; then
+ short_mirror=${mirror_sha:0:7}
+ body="Label \`$LABEL_NAME\` applied, but \`$mirror_ref\` is at \`$short_mirror\` while the PR head is \`$short_pr\`. A maintainer needs to comment \`/ok to test $PR_HEAD_SHA\` to refresh the mirror. Once the mirror catches up, re-run [$workflow_name](https://github.com/$GH_REPO/actions/workflows/$workflow_file) from the Actions tab."
+ else
+ run_id=$(gh api "repos/$GH_REPO/actions/workflows/$workflow_file/runs?head_sha=$PR_HEAD_SHA&event=push" \
+ --jq '.workflow_runs | sort_by(.created_at) | reverse | .[0].id // empty')
+ if [ -n "$run_id" ]; then
+ instructions="Open [the existing run](https://github.com/$GH_REPO/actions/runs/$run_id) and click **Re-run all jobs** to execute with the label set."
+ else
+ workflow_link="[$workflow_name](https://github.com/$GH_REPO/actions/workflows/$workflow_file)"
+ instructions="Open $workflow_link, find the run for commit \`$short_pr\`, and click **Re-run all jobs** to execute with the label set."
+ fi
+ body="Label \`$LABEL_NAME\` applied for \`$short_pr\`. $instructions The \`E2E Gate\` check on this PR will flip green automatically once the run finishes."
+ fi
+
+ gh pr comment "$PR_NUMBER" --body "$body"
diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml
index a89f4508fc..536cfb2a50 100644
--- a/.github/workflows/e2e-test.yml
+++ b/.github/workflows/e2e-test.yml
@@ -11,7 +11,7 @@ on:
description: "GitHub Actions runner label"
required: false
type: string
- default: "build-amd64"
+ default: "linux-amd64-cpu8"
permissions:
contents: read
@@ -27,17 +27,9 @@ jobs:
matrix:
include:
- suite: python
- cluster: e2e-python
- port: "8080"
- cmd: "mise run --no-prepare --skip-deps e2e:python"
+ cmd: "mise run --no-deps --skip-deps e2e:python"
- suite: rust
- cluster: e2e-rust
- port: "8081"
- cmd: "mise run --no-prepare --skip-deps e2e:rust"
- - suite: gateway-resume
- cluster: e2e-resume
- port: "8082"
- cmd: "cargo test --manifest-path e2e/rust/Cargo.toml --features e2e --test gateway_resume"
+ cmd: "mise run --no-deps --skip-deps e2e:rust"
container:
image: ghcr.io/nvidia/openshell/ci:latest
credentials:
@@ -46,6 +38,7 @@ jobs:
options: --privileged
volumes:
- /var/run/docker.sock:/var/run/docker.sock
+ - /home/runner/_work:/home/runner/_work
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
IMAGE_TAG: ${{ inputs.image-tag }}
@@ -54,37 +47,19 @@ jobs:
OPENSHELL_REGISTRY_NAMESPACE: nvidia/openshell
OPENSHELL_REGISTRY_USERNAME: ${{ github.actor }}
OPENSHELL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
- OPENSHELL_GATEWAY: ${{ matrix.cluster }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Log in to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
- - name: Pull cluster image
- run: docker pull ghcr.io/nvidia/openshell/cluster:${{ inputs.image-tag }}
-
- name: Install Python dependencies and generate protobuf stubs
if: matrix.suite == 'python'
- run: uv sync --frozen && mise run --no-prepare python:proto
-
- - name: Build Rust CLI
- if: matrix.suite != 'python'
- run: cargo build -p openshell-cli --features openshell-core/dev-settings
+ run: uv sync --frozen && mise run --no-deps python:proto
- name: Install SSH client
if: matrix.suite != 'python'
run: apt-get update && apt-get install -y --no-install-recommends openssh-client && rm -rf /var/lib/apt/lists/*
- - name: Bootstrap cluster
- env:
- GATEWAY_HOST: host.docker.internal
- GATEWAY_PORT: ${{ matrix.port }}
- CLUSTER_NAME: ${{ matrix.cluster }}
- SKIP_IMAGE_PUSH: "1"
- SKIP_CLUSTER_IMAGE_BUILD: "1"
- OPENSHELL_CLUSTER_IMAGE: ghcr.io/nvidia/openshell/cluster:${{ inputs.image-tag }}
- run: mise run --no-prepare --skip-deps cluster
-
- name: Run tests
run: ${{ matrix.cmd }}
diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml
index ec87af503c..2549172596 100644
--- a/.github/workflows/issue-triage.yml
+++ b/.github/workflows/issue-triage.yml
@@ -8,53 +8,50 @@ permissions:
issues: write
jobs:
- check-agent-diagnostic:
+ label-non-maintainer-issues:
runs-on: ubuntu-latest
- # Only run on bug reports (title starts with "bug:")
- if: startsWith(github.event.issue.title, 'bug:')
+ if: github.repository_owner == 'NVIDIA'
steps:
- - name: Check for agent diagnostic section
+ - name: Check contributor permissions
+ id: contributor
uses: actions/github-script@v7
with:
+ result-encoding: string
script: |
- const body = context.payload.issue.body || '';
-
- // Check if the Agent Diagnostic section has substantive content.
- // The template placeholder starts with "Example:" — if that's still
- // there or the section is empty, the reporter didn't fill it in.
- const diagnosticMatch = body.match(
- /### Agent Diagnostic\s*\n([\s\S]*?)(?=\n### |\n$)/
- );
-
- const hasSubstantiveDiagnostic = diagnosticMatch
- && diagnosticMatch[1].trim().length > 0
- && !diagnosticMatch[1].trim().startsWith('Example:');
-
- if (hasSubstantiveDiagnostic) {
- console.log('Agent diagnostic section found with content. Passing.');
- return;
+ const author = context.payload.issue.user.login;
+ const maintainerPermissions = ['admin', 'maintain', 'write'];
+
+ try {
+ const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ username: author,
+ });
+
+ if (maintainerPermissions.includes(data.permission)) {
+ console.log(`${author} has maintainer permissions: ${data.permission}.`);
+ return 'false';
+ }
+
+ console.log(`${author} is not a maintainer; permission is ${data.permission}.`);
+ return 'true';
+ } catch (e) {
+ if (e.status === 404) {
+ console.log(`${author} is not a repository collaborator; labeling for triage.`);
+ return 'true';
+ }
+
+ throw e;
}
- console.log('Agent diagnostic section missing or placeholder. Flagging.');
-
- // Add state:triage-needed label
+ - name: Add triage label
+ if: steps.contributor.outputs.result == 'true'
+ uses: actions/github-script@v7
+ with:
+ script: |
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
- labels: ['state:triage-needed']
- });
-
- // Post redirect comment
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.issue.number,
- body: [
- 'This issue appears to have been opened without an agent investigation.',
- '',
- 'OpenShell is an agent-first project - please point your coding agent at the repo and have it diagnose this before we triage. Your agent can load skills like `debug-openshell-cluster`, `debug-inference`, `openshell-cli`, and `generate-sandbox-policy`.',
- '',
- 'See [CONTRIBUTING.md](https://github.com/NVIDIA/OpenShell/blob/main/CONTRIBUTING.md#before-you-open-an-issue) for the full workflow.',
- ].join('\n')
+ labels: ['state:triage-needed'],
});
diff --git a/.github/workflows/release-auto-tag.yml b/.github/workflows/release-auto-tag.yml
index 569c333887..f89c506d7b 100644
--- a/.github/workflows/release-auto-tag.yml
+++ b/.github/workflows/release-auto-tag.yml
@@ -20,7 +20,7 @@ jobs:
create-tag:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml
index 0ef1e0355e..8f7284aabf 100644
--- a/.github/workflows/release-canary.yml
+++ b/.github/workflows/release-canary.yml
@@ -33,9 +33,9 @@ jobs:
matrix:
include:
- arch: amd64
- runner: build-amd64
+ runner: linux-amd64-cpu8
- arch: arm64
- runner: build-arm64
+ runner: linux-arm64-cpu8
runs-on: ${{ matrix.runner }}
timeout-minutes: 10
container:
@@ -67,6 +67,79 @@ jobs:
echo "Version check passed: found $EXPECTED in output"
fi
+ install-dev:
+ name: Install Debian package (${{ matrix.arch }})
+ if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: amd64
+ runner: linux-amd64-cpu8
+ - arch: arm64
+ runner: linux-arm64-cpu8
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 10
+ container:
+ image: ghcr.io/nvidia/openshell/ci:latest
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - name: Determine release tag
+ id: release
+ run: |
+ set -euo pipefail
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
+ else
+ WORKFLOW_NAME="${{ github.event.workflow_run.name }}"
+ if [ "$WORKFLOW_NAME" = "Release Dev" ]; then
+ echo "tag=dev" >> "$GITHUB_OUTPUT"
+ elif [ "$WORKFLOW_NAME" = "Release Tag" ]; then
+ TAG="${{ github.event.workflow_run.head_branch }}"
+ if [ -z "$TAG" ]; then
+ echo "::error::Could not determine release tag from workflow_run"
+ exit 1
+ fi
+ echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
+ else
+ echo "::error::Unexpected triggering workflow: ${WORKFLOW_NAME}"
+ exit 1
+ fi
+ fi
+
+ - name: Install Debian package
+ run: |
+ set -euo pipefail
+ curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install-dev.sh \
+ | OPENSHELL_VERSION=${{ steps.release.outputs.tag }} sh
+
+ - name: Verify gateway and VM driver versions
+ run: |
+ set -euo pipefail
+ command -v openshell-gateway
+ test -x /usr/libexec/openshell/openshell-driver-vm
+
+ GATEWAY_ACTUAL="$(openshell-gateway --version)"
+ DRIVER_ACTUAL="$(/usr/libexec/openshell/openshell-driver-vm --version)"
+ echo "Gateway: ${GATEWAY_ACTUAL}"
+ echo "Driver: ${DRIVER_ACTUAL}"
+
+ TAG="${{ steps.release.outputs.tag }}"
+ if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ EXPECTED="${TAG#v}"
+ for actual in "$GATEWAY_ACTUAL" "$DRIVER_ACTUAL"; do
+ if [[ "$actual" != *"$EXPECTED"* ]]; then
+ echo "::error::Version mismatch: expected '$EXPECTED' in '$actual'"
+ exit 1
+ fi
+ done
+ echo "Version check passed: found $EXPECTED in both binaries"
+ else
+ echo "Non-release tag ($TAG), skipping version check"
+ fi
+
canary:
name: Canary ${{ matrix.mode }} (${{ matrix.arch }})
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
@@ -81,10 +154,10 @@ jobs:
- two-step
include:
- arch: amd64
- runner: build-amd64
+ runner: linux-amd64-cpu8
target: x86_64-unknown-linux-musl
- arch: arm64
- runner: build-arm64
+ runner: linux-arm64-cpu8
target: aarch64-unknown-linux-musl
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
@@ -104,7 +177,7 @@ jobs:
# to advertise a reachable address instead.
OPENSHELL_GATEWAY_HOST: host.docker.internal
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Determine release tag
id: release
diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml
index bdc8b1a719..a359dde599 100644
--- a/.github/workflows/release-dev.yml
+++ b/.github/workflows/release-dev.yml
@@ -19,7 +19,7 @@ jobs:
# ---------------------------------------------------------------------------
compute-versions:
name: Compute Versions
- runs-on: build-amd64
+ runs-on: linux-amd64-cpu8
timeout-minutes: 5
container:
image: ghcr.io/nvidia/openshell/ci:latest
@@ -29,8 +29,9 @@ jobs:
outputs:
python_version: ${{ steps.v.outputs.python }}
cargo_version: ${{ steps.v.outputs.cargo }}
+ deb_version: ${{ steps.v.outputs.deb }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -46,6 +47,7 @@ jobs:
set -euo pipefail
echo "python=$(uv run python tasks/scripts/release.py get-version --python)" >> "$GITHUB_OUTPUT"
echo "cargo=$(uv run python tasks/scripts/release.py get-version --cargo)" >> "$GITHUB_OUTPUT"
+ echo "deb=$(uv run python tasks/scripts/release.py get-version --deb)" >> "$GITHUB_OUTPUT"
build-gateway:
needs: [compute-versions]
@@ -54,6 +56,13 @@ jobs:
component: gateway
cargo-version: ${{ needs.compute-versions.outputs.cargo_version }}
+ build-supervisor:
+ needs: [compute-versions]
+ uses: ./.github/workflows/docker-build.yml
+ with:
+ component: supervisor
+ cargo-version: ${{ needs.compute-versions.outputs.cargo_version }}
+
build-cluster:
needs: [compute-versions]
uses: ./.github/workflows/docker-build.yml
@@ -66,12 +75,12 @@ jobs:
uses: ./.github/workflows/e2e-test.yml
with:
image-tag: ${{ github.sha }}
- runner: build-arm64
+ runner: linux-arm64-cpu8
tag-ghcr-dev:
name: Tag GHCR Images as Dev
- needs: [build-gateway, build-cluster]
- runs-on: build-amd64
+ needs: [build-gateway, build-supervisor, build-cluster]
+ runs-on: linux-amd64-cpu8
timeout-minutes: 10
steps:
- name: Log in to GHCR
@@ -81,7 +90,7 @@ jobs:
run: |
set -euo pipefail
REGISTRY="ghcr.io/nvidia/openshell"
- for component in gateway cluster; do
+ for component in gateway supervisor cluster; do
echo "Tagging ${REGISTRY}/${component}:${{ github.sha }} as dev..."
docker buildx imagetools create \
--prefer-index=false \
@@ -96,12 +105,12 @@ jobs:
matrix:
include:
- arch: amd64
- runner: build-amd64
+ runner: linux-amd64-cpu8
artifact: linux-amd64
task: python:build:linux:amd64
output_path: target/wheels/linux-amd64/*.whl
- arch: arm64
- runner: build-arm64
+ runner: linux-arm64-cpu8
artifact: linux-arm64
task: python:build:linux:arm64
output_path: target/wheels/linux-arm64/*.whl
@@ -115,10 +124,9 @@ jobs:
options: --privileged
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- SCCACHE_MEMCACHED_ENDPOINT: ${{ vars.SCCACHE_MEMCACHED_ENDPOINT }}
OPENSHELL_IMAGE_TAG: dev
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -151,7 +159,7 @@ jobs:
build-python-wheel-macos:
name: Build Python Wheel (macOS)
needs: [compute-versions]
- runs-on: build-amd64
+ runs-on: linux-amd64-cpu8
timeout-minutes: 120
container:
image: ghcr.io/nvidia/openshell/ci:latest
@@ -163,10 +171,9 @@ jobs:
- /var/run/docker.sock:/var/run/docker.sock
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- SCCACHE_MEMCACHED_ENDPOINT: ${{ vars.SCCACHE_MEMCACHED_ENDPOINT }}
OPENSHELL_IMAGE_TAG: dev
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -175,6 +182,8 @@ jobs:
- name: Set up Docker Buildx
uses: ./.github/actions/setup-buildx
+ with:
+ driver: local
- name: Mark workspace safe for git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
@@ -208,11 +217,11 @@ jobs:
matrix:
include:
- arch: amd64
- runner: build-amd64
+ runner: linux-amd64-cpu8
target: x86_64-unknown-linux-musl
zig_target: x86_64-linux-musl
- arch: arm64
- runner: build-arm64
+ runner: linux-arm64-cpu8
target: aarch64-unknown-linux-musl
zig_target: aarch64-linux-musl
runs-on: ${{ matrix.runner }}
@@ -225,10 +234,9 @@ jobs:
options: --privileged
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- SCCACHE_MEMCACHED_ENDPOINT: ${{ vars.SCCACHE_MEMCACHED_ENDPOINT }}
OPENSHELL_IMAGE_TAG: dev
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -239,7 +247,7 @@ jobs:
run: git fetch --tags --force
- name: Install tools
- run: mise install
+ run: mise install --locked
- name: Cache Rust target and registry
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
@@ -282,11 +290,6 @@ jobs:
# Override z3-sys default (stdc++) so Rust links the matching runtime.
echo "CXXSTDLIB=c++" >> "$GITHUB_ENV"
- - name: Scope workspace to CLI crates
- run: |
- set -euo pipefail
- sed -i 's|members = \["crates/\*"\]|members = ["crates/openshell-cli", "crates/openshell-core", "crates/openshell-bootstrap", "crates/openshell-policy", "crates/openshell-prover", "crates/openshell-providers", "crates/openshell-tui"]|' Cargo.toml
-
- name: Patch workspace version
if: needs.compute-versions.outputs.cargo_version != ''
run: |
@@ -321,7 +324,7 @@ jobs:
build-cli-macos:
name: Build CLI (macOS)
needs: [compute-versions]
- runs-on: build-amd64
+ runs-on: linux-amd64-cpu8
timeout-minutes: 60
container:
image: ghcr.io/nvidia/openshell/ci:latest
@@ -333,9 +336,8 @@ jobs:
- /var/run/docker.sock:/var/run/docker.sock
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- SCCACHE_MEMCACHED_ENDPOINT: ${{ vars.SCCACHE_MEMCACHED_ENDPOINT }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -350,6 +352,8 @@ jobs:
- name: Set up Docker Buildx
uses: ./.github/actions/setup-buildx
+ with:
+ driver: local
- name: Build macOS binary via Docker
run: |
@@ -379,17 +383,293 @@ jobs:
retention-days: 5
# ---------------------------------------------------------------------------
- # Create / update the dev GitHub Release with CLI binaries and wheels
+ # Build standalone gateway binaries (Linux GNU — native on each arch)
+ # ---------------------------------------------------------------------------
+ build-gateway-binary-linux:
+ name: Build Gateway Binary (Linux ${{ matrix.arch }})
+ needs: [compute-versions]
+ strategy:
+ matrix:
+ include:
+ - arch: amd64
+ runner: linux-amd64-cpu8
+ target: x86_64-unknown-linux-gnu
+ - arch: arm64
+ runner: linux-arm64-cpu8
+ target: aarch64-unknown-linux-gnu
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 60
+ container:
+ image: ghcr.io/nvidia/openshell/ci:latest
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ options: --privileged
+ env:
+ MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Mark workspace safe for git
+ run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
+
+ - name: Fetch tags
+ run: git fetch --tags --force
+
+ - name: Install tools
+ run: mise install --locked
+
+ - name: Cache Rust target and registry
+ uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
+ with:
+ shared-key: gateway-binary-gnu-${{ matrix.arch }}
+ cache-directories: .cache/sccache
+ cache-targets: "true"
+
+ - name: Patch workspace version
+ if: needs.compute-versions.outputs.cargo_version != ''
+ run: |
+ set -euo pipefail
+ sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml
+
+ - name: Build ${{ matrix.target }}
+ run: |
+ set -euo pipefail
+ mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-server
+
+ - name: Verify packaged binary
+ run: |
+ set -euo pipefail
+ OUTPUT="$(target/${{ matrix.target }}/release/openshell-gateway --version)"
+ echo "$OUTPUT"
+ grep -q '^openshell-gateway ' <<<"$OUTPUT"
+
+ - name: sccache stats
+ if: always()
+ run: mise x -- sccache --show-stats
+
+ - name: Package binary
+ run: |
+ set -euo pipefail
+ mkdir -p artifacts
+ tar -czf artifacts/openshell-gateway-${{ matrix.target }}.tar.gz \
+ -C target/${{ matrix.target }}/release openshell-gateway
+ ls -lh artifacts/
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: gateway-binary-linux-${{ matrix.arch }}
+ path: artifacts/*.tar.gz
+ retention-days: 5
+
+ # ---------------------------------------------------------------------------
+ # Build standalone gateway binary (macOS aarch64 via osxcross)
+ # ---------------------------------------------------------------------------
+ build-gateway-binary-macos:
+ name: Build Gateway Binary (macOS)
+ needs: [compute-versions]
+ runs-on: linux-amd64-cpu8
+ timeout-minutes: 60
+ container:
+ image: ghcr.io/nvidia/openshell/ci:latest
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ options: --privileged
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ env:
+ MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Mark workspace safe for git
+ run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
+
+ - name: Fetch tags
+ run: git fetch --tags --force
+
+ - name: Log in to GHCR
+ run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
+
+ - name: Set up Docker Buildx
+ uses: ./.github/actions/setup-buildx
+ with:
+ driver: local
+
+ - name: Build macOS binary via Docker
+ run: |
+ set -euo pipefail
+ docker buildx build \
+ --file deploy/docker/Dockerfile.gateway-macos \
+ --build-arg OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" \
+ --build-arg CARGO_TARGET_CACHE_SCOPE="${{ github.sha }}" \
+ --target binary \
+ --output type=local,dest=out/ \
+ .
+
+ - name: Verify packaged binary shape
+ run: |
+ set -euo pipefail
+ test -x out/openshell-gateway
+
+ - name: Package binary
+ run: |
+ set -euo pipefail
+ mkdir -p artifacts
+ tar -czf artifacts/openshell-gateway-aarch64-apple-darwin.tar.gz \
+ -C out openshell-gateway
+ ls -lh artifacts/
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: gateway-binary-macos
+ path: artifacts/*.tar.gz
+ retention-days: 5
+
+ # ---------------------------------------------------------------------------
+ # Build standalone supervisor binaries (Linux GNU — native on each arch)
+ # ---------------------------------------------------------------------------
+ build-supervisor-binary-linux:
+ name: Build Supervisor Binary (Linux ${{ matrix.arch }})
+ needs: [compute-versions]
+ strategy:
+ matrix:
+ include:
+ - arch: amd64
+ runner: linux-amd64-cpu8
+ target: x86_64-unknown-linux-gnu
+ - arch: arm64
+ runner: linux-arm64-cpu8
+ target: aarch64-unknown-linux-gnu
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 60
+ container:
+ image: ghcr.io/nvidia/openshell/ci:latest
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ options: --privileged
+ env:
+ MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Mark workspace safe for git
+ run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
+
+ - name: Fetch tags
+ run: git fetch --tags --force
+
+ - name: Install tools
+ run: mise install --locked
+
+ - name: Cache Rust target and registry
+ uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
+ with:
+ shared-key: supervisor-binary-gnu-${{ matrix.arch }}
+ cache-directories: .cache/sccache
+ cache-targets: "true"
+
+ - name: Patch workspace version
+ if: needs.compute-versions.outputs.cargo_version != ''
+ run: |
+ set -euo pipefail
+ sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml
+
+ - name: Build ${{ matrix.target }}
+ run: |
+ set -euo pipefail
+ mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-sandbox --bin openshell-sandbox
+
+ - name: Verify packaged binary
+ run: |
+ set -euo pipefail
+ OUTPUT="$(target/${{ matrix.target }}/release/openshell-sandbox --version)"
+ echo "$OUTPUT"
+ grep -q '^openshell-sandbox ' <<<"$OUTPUT"
+
+ - name: sccache stats
+ if: always()
+ run: mise x -- sccache --show-stats
+
+ - name: Package binary
+ run: |
+ set -euo pipefail
+ mkdir -p artifacts
+ tar -czf artifacts/openshell-sandbox-${{ matrix.target }}.tar.gz \
+ -C target/${{ matrix.target }}/release openshell-sandbox
+ ls -lh artifacts/
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: supervisor-binary-linux-${{ matrix.arch }}
+ path: artifacts/*.tar.gz
+ retention-days: 5
+
+ build-driver-vm-linux:
+ name: Build Driver VM Linux
+ needs: [compute-versions]
+ uses: ./.github/workflows/driver-vm-linux.yml
+ with:
+ cargo-version: ${{ needs.compute-versions.outputs.cargo_version }}
+ image-tag: dev
+ checkout-ref: ${{ github.sha }}
+ secrets: inherit
+
+ build-driver-vm-macos:
+ name: Build Driver VM macOS
+ needs: [compute-versions]
+ uses: ./.github/workflows/driver-vm-macos.yml
+ with:
+ cargo-version: ${{ needs.compute-versions.outputs.cargo_version }}
+ image-tag: dev
+ checkout-ref: ${{ github.sha }}
+ secrets: inherit
+
+ build-deb:
+ name: Build Debian Packages
+ needs: [compute-versions, build-cli-linux, build-gateway-binary-linux, build-driver-vm-linux]
+ uses: ./.github/workflows/deb-package.yml
+ with:
+ deb-version: ${{ needs.compute-versions.outputs.deb_version }}
+ checkout-ref: ${{ github.sha }}
+ secrets: inherit
+
+ build-rpm:
+ name: Build RPM Packages
+ needs: [compute-versions]
+ uses: ./.github/workflows/rpm-package.yml
+ with:
+ checkout-ref: ${{ github.sha }}
+ secrets: inherit
+
+ # ---------------------------------------------------------------------------
+ # Create / update the dev GitHub Release with CLI, gateway, driver, and wheels
# ---------------------------------------------------------------------------
release-dev:
name: Release Dev
- needs: [compute-versions, build-cli-linux, build-cli-macos, build-python-wheels-linux, build-python-wheel-macos]
- runs-on: build-amd64
+ needs: [compute-versions, build-cli-linux, build-cli-macos, build-gateway-binary-linux, build-gateway-binary-macos, build-supervisor-binary-linux, build-python-wheels-linux, build-python-wheel-macos, build-driver-vm-linux, build-driver-vm-macos, build-deb, build-rpm]
+ runs-on: linux-amd64-cpu8
timeout-minutes: 10
+ permissions:
+ contents: write
+ id-token: write
+ attestations: write
+ artifact-metadata: write
outputs:
wheel_filenames: ${{ steps.wheel_filenames.outputs.wheel_filenames }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Download all CLI artifacts
uses: actions/download-artifact@v4
@@ -398,6 +678,27 @@ jobs:
path: release/
merge-multiple: true
+ - name: Download gateway binary artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: gateway-binary-*
+ path: release/
+ merge-multiple: true
+
+ - name: Download supervisor binary artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: supervisor-binary-*
+ path: release/
+ merge-multiple: true
+
+ - name: Download VM driver artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: driver-vm-*
+ path: release/
+ merge-multiple: true
+
- name: Download wheel artifacts
uses: actions/download-artifact@v4
with:
@@ -405,6 +706,20 @@ jobs:
path: release/
merge-multiple: true
+ - name: Download Debian package artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: deb-linux-*
+ path: release/
+ merge-multiple: true
+
+ - name: Download RPM package artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: rpm-linux-*
+ path: release/
+ merge-multiple: true
+
- name: Capture wheel filenames
id: wheel_filenames
run: |
@@ -417,10 +732,33 @@ jobs:
run: |
set -euo pipefail
cd release
- sha256sum *.tar.gz *.whl > openshell-checksums-sha256.txt
+ sha256sum \
+ openshell-x86_64-unknown-linux-musl.tar.gz \
+ openshell-aarch64-unknown-linux-musl.tar.gz \
+ openshell-aarch64-apple-darwin.tar.gz \
+ openshell_*.deb \
+ openshell-*.rpm \
+ *.whl > openshell-checksums-sha256.txt
cat openshell-checksums-sha256.txt
+ sha256sum \
+ openshell-gateway-x86_64-unknown-linux-gnu.tar.gz \
+ openshell-gateway-aarch64-unknown-linux-gnu.tar.gz \
+ openshell-gateway-aarch64-apple-darwin.tar.gz > openshell-gateway-checksums-sha256.txt
+ cat openshell-gateway-checksums-sha256.txt
+ sha256sum \
+ openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz \
+ openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz > openshell-sandbox-checksums-sha256.txt
+ cat openshell-sandbox-checksums-sha256.txt
+
+ - name: Attest VM driver artifacts
+ uses: actions/attest@v4
+ with:
+ subject-path: |
+ release/openshell-driver-vm-x86_64-unknown-linux-gnu.tar.gz
+ release/openshell-driver-vm-aarch64-unknown-linux-gnu.tar.gz
+ release/openshell-driver-vm-aarch64-apple-darwin.tar.gz
- - name: Prune stale wheel assets from dev release
+ - name: Prune stale wheel, deb, and rpm assets from dev release
uses: actions/github-script@v7
env:
WHEEL_VERSION: ${{ needs.compute-versions.outputs.python_version }}
@@ -452,20 +790,31 @@ jobs:
core.info(` ${String(a.id).padStart(12)} ${a.name}`);
}
- // Delete stale wheels
- let kept = 0, deleted = 0;
+ // Delete stale wheels, debs, rpms, and removed VM checksum assets.
+ let kept = 0, deleted = 0, debDeleted = 0, rpmDeleted = 0, removedVmChecksums = 0;
for (const asset of assets) {
- if (!asset.name.endsWith('.whl')) continue;
- if (asset.name.startsWith(currentPrefix)) {
+ if (asset.name.endsWith('.deb')) {
+ core.info(`Deleting stale deb package: ${asset.name} (id=${asset.id})`);
+ await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: asset.id });
+ debDeleted++;
+ } else if (asset.name.endsWith('.rpm')) {
+ core.info(`Deleting stale rpm package: ${asset.name} (id=${asset.id})`);
+ await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: asset.id });
+ rpmDeleted++;
+ } else if (asset.name === 'openshell-driver-vm-checksums-sha256.txt') {
+ core.info(`Deleting removed VM checksum asset: ${asset.name} (id=${asset.id})`);
+ await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: asset.id });
+ removedVmChecksums++;
+ } else if (asset.name.endsWith('.whl') && asset.name.startsWith(currentPrefix)) {
core.info(`Keeping current wheel: ${asset.name}`);
kept++;
- } else {
+ } else if (asset.name.endsWith('.whl')) {
core.info(`Deleting stale wheel: ${asset.name} (id=${asset.id})`);
await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: asset.id });
deleted++;
}
}
- core.info(`Summary: kept=${kept}, deleted=${deleted}`);
+ core.info(`Summary: kept_wheels=${kept}, deleted_wheels=${deleted}, deleted_debs=${debDeleted}, deleted_rpms=${rpmDeleted}, deleted_removed_vm_checksums=${removedVmChecksums}`);
- name: Move dev tag
run: |
@@ -496,8 +845,20 @@ jobs:
release/openshell-x86_64-unknown-linux-musl.tar.gz
release/openshell-aarch64-unknown-linux-musl.tar.gz
release/openshell-aarch64-apple-darwin.tar.gz
+ release/openshell_*.deb
+ release/openshell-*.rpm
+ release/openshell-gateway-x86_64-unknown-linux-gnu.tar.gz
+ release/openshell-gateway-aarch64-unknown-linux-gnu.tar.gz
+ release/openshell-gateway-aarch64-apple-darwin.tar.gz
+ release/openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz
+ release/openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz
+ release/openshell-driver-vm-x86_64-unknown-linux-gnu.tar.gz
+ release/openshell-driver-vm-aarch64-unknown-linux-gnu.tar.gz
+ release/openshell-driver-vm-aarch64-apple-darwin.tar.gz
release/*.whl
release/openshell-checksums-sha256.txt
+ release/openshell-gateway-checksums-sha256.txt
+ release/openshell-sandbox-checksums-sha256.txt
trigger-wheel-publish:
name: Trigger Wheel Publish
diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml
index d89d67032b..b8abb5a899 100644
--- a/.github/workflows/release-tag.yml
+++ b/.github/workflows/release-tag.yml
@@ -30,7 +30,7 @@ jobs:
# ---------------------------------------------------------------------------
compute-versions:
name: Compute Versions
- runs-on: build-amd64
+ runs-on: linux-amd64-cpu8
timeout-minutes: 5
container:
image: ghcr.io/nvidia/openshell/ci:latest
@@ -40,10 +40,11 @@ jobs:
outputs:
python_version: ${{ steps.v.outputs.python }}
cargo_version: ${{ steps.v.outputs.cargo }}
+ deb_version: ${{ steps.v.outputs.deb }}
# Semver without 'v' prefix (e.g. 0.6.0), used for image tags and release body
semver: ${{ steps.v.outputs.semver }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
@@ -60,6 +61,7 @@ jobs:
set -euo pipefail
echo "python=$(uv run python tasks/scripts/release.py get-version --python)" >> "$GITHUB_OUTPUT"
echo "cargo=$(uv run python tasks/scripts/release.py get-version --cargo)" >> "$GITHUB_OUTPUT"
+ echo "deb=$(uv run python tasks/scripts/release.py get-version --deb)" >> "$GITHUB_OUTPUT"
echo "semver=${RELEASE_TAG#v}" >> "$GITHUB_OUTPUT"
build-gateway:
@@ -69,6 +71,13 @@ jobs:
component: gateway
cargo-version: ${{ needs.compute-versions.outputs.cargo_version }}
+ build-supervisor:
+ needs: [compute-versions]
+ uses: ./.github/workflows/docker-build.yml
+ with:
+ component: supervisor
+ cargo-version: ${{ needs.compute-versions.outputs.cargo_version }}
+
build-cluster:
needs: [compute-versions]
uses: ./.github/workflows/docker-build.yml
@@ -81,12 +90,12 @@ jobs:
uses: ./.github/workflows/e2e-test.yml
with:
image-tag: ${{ github.sha }}
- runner: build-arm64
+ runner: linux-arm64-cpu8
tag-ghcr-release:
name: Tag GHCR Images for Release
- needs: [compute-versions, build-gateway, build-cluster, e2e]
- runs-on: build-amd64
+ needs: [compute-versions, build-gateway, build-supervisor, build-cluster, e2e]
+ runs-on: linux-amd64-cpu8
timeout-minutes: 10
steps:
- name: Log in to GHCR
@@ -97,7 +106,7 @@ jobs:
set -euo pipefail
REGISTRY="ghcr.io/nvidia/openshell"
VERSION="${{ needs.compute-versions.outputs.semver }}"
- for component in gateway cluster; do
+ for component in gateway supervisor cluster; do
echo "Tagging ${REGISTRY}/${component}:${{ github.sha }} as ${VERSION} and latest..."
docker buildx imagetools create \
--prefer-index=false \
@@ -116,12 +125,12 @@ jobs:
matrix:
include:
- arch: amd64
- runner: build-amd64
+ runner: linux-amd64-cpu8
artifact: linux-amd64
task: python:build:linux:amd64
output_path: target/wheels/linux-amd64/*.whl
- arch: arm64
- runner: build-arm64
+ runner: linux-arm64-cpu8
artifact: linux-arm64
task: python:build:linux:arm64
output_path: target/wheels/linux-arm64/*.whl
@@ -135,10 +144,9 @@ jobs:
options: --privileged
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- SCCACHE_MEMCACHED_ENDPOINT: ${{ vars.SCCACHE_MEMCACHED_ENDPOINT }}
OPENSHELL_IMAGE_TAG: ${{ needs.compute-versions.outputs.semver }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
@@ -172,7 +180,7 @@ jobs:
build-python-wheel-macos:
name: Build Python Wheel (macOS)
needs: [compute-versions]
- runs-on: build-amd64
+ runs-on: linux-amd64-cpu8
timeout-minutes: 120
container:
image: ghcr.io/nvidia/openshell/ci:latest
@@ -184,10 +192,9 @@ jobs:
- /var/run/docker.sock:/var/run/docker.sock
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- SCCACHE_MEMCACHED_ENDPOINT: ${{ vars.SCCACHE_MEMCACHED_ENDPOINT }}
OPENSHELL_IMAGE_TAG: ${{ needs.compute-versions.outputs.semver }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
@@ -197,6 +204,8 @@ jobs:
- name: Set up Docker Buildx
uses: ./.github/actions/setup-buildx
+ with:
+ driver: local
- name: Mark workspace safe for git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
@@ -230,11 +239,11 @@ jobs:
matrix:
include:
- arch: amd64
- runner: build-amd64
+ runner: linux-amd64-cpu8
target: x86_64-unknown-linux-musl
zig_target: x86_64-linux-musl
- arch: arm64
- runner: build-arm64
+ runner: linux-arm64-cpu8
target: aarch64-unknown-linux-musl
zig_target: aarch64-linux-musl
runs-on: ${{ matrix.runner }}
@@ -247,10 +256,9 @@ jobs:
options: --privileged
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- SCCACHE_MEMCACHED_ENDPOINT: ${{ vars.SCCACHE_MEMCACHED_ENDPOINT }}
OPENSHELL_IMAGE_TAG: ${{ needs.compute-versions.outputs.semver }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
@@ -262,7 +270,7 @@ jobs:
run: git fetch --tags --force
- name: Install tools
- run: mise install
+ run: mise install --locked
- name: Cache Rust target and registry
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
@@ -305,11 +313,6 @@ jobs:
# Override z3-sys default (stdc++) so Rust links the matching runtime.
echo "CXXSTDLIB=c++" >> "$GITHUB_ENV"
- - name: Scope workspace to CLI crates
- run: |
- set -euo pipefail
- sed -i 's|members = \["crates/\*"\]|members = ["crates/openshell-cli", "crates/openshell-core", "crates/openshell-bootstrap", "crates/openshell-policy", "crates/openshell-prover", "crates/openshell-providers", "crates/openshell-tui"]|' Cargo.toml
-
- name: Patch workspace version
if: needs.compute-versions.outputs.cargo_version != ''
run: |
@@ -344,7 +347,7 @@ jobs:
build-cli-macos:
name: Build CLI (macOS)
needs: [compute-versions]
- runs-on: build-amd64
+ runs-on: linux-amd64-cpu8
timeout-minutes: 60
container:
image: ghcr.io/nvidia/openshell/ci:latest
@@ -356,9 +359,8 @@ jobs:
- /var/run/docker.sock:/var/run/docker.sock
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- SCCACHE_MEMCACHED_ENDPOINT: ${{ vars.SCCACHE_MEMCACHED_ENDPOINT }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
@@ -374,6 +376,8 @@ jobs:
- name: Set up Docker Buildx
uses: ./.github/actions/setup-buildx
+ with:
+ driver: local
- name: Build macOS binary via Docker
run: |
@@ -403,17 +407,296 @@ jobs:
retention-days: 5
# ---------------------------------------------------------------------------
- # Create a tagged GitHub Release with CLI binaries and wheels
+ # Build standalone gateway binaries (Linux GNU — native on each arch)
+ # ---------------------------------------------------------------------------
+ build-gateway-binary-linux:
+ name: Build Gateway Binary (Linux ${{ matrix.arch }})
+ needs: [compute-versions]
+ strategy:
+ matrix:
+ include:
+ - arch: amd64
+ runner: linux-amd64-cpu8
+ target: x86_64-unknown-linux-gnu
+ - arch: arm64
+ runner: linux-arm64-cpu8
+ target: aarch64-unknown-linux-gnu
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 60
+ container:
+ image: ghcr.io/nvidia/openshell/ci:latest
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ options: --privileged
+ env:
+ MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.tag || github.ref }}
+ fetch-depth: 0
+
+ - name: Mark workspace safe for git
+ run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
+
+ - name: Fetch tags
+ run: git fetch --tags --force
+
+ - name: Install tools
+ run: mise install --locked
+
+ - name: Cache Rust target and registry
+ uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
+ with:
+ shared-key: gateway-binary-gnu-${{ matrix.arch }}
+ cache-directories: .cache/sccache
+ cache-targets: "true"
+
+ - name: Patch workspace version
+ if: needs.compute-versions.outputs.cargo_version != ''
+ run: |
+ set -euo pipefail
+ sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml
+
+ - name: Build ${{ matrix.target }}
+ run: |
+ set -euo pipefail
+ mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-server
+
+ - name: Verify packaged binary
+ run: |
+ set -euo pipefail
+ OUTPUT="$(target/${{ matrix.target }}/release/openshell-gateway --version)"
+ echo "$OUTPUT"
+ grep -q '^openshell-gateway ' <<<"$OUTPUT"
+
+ - name: sccache stats
+ if: always()
+ run: mise x -- sccache --show-stats
+
+ - name: Package binary
+ run: |
+ set -euo pipefail
+ mkdir -p artifacts
+ tar -czf artifacts/openshell-gateway-${{ matrix.target }}.tar.gz \
+ -C target/${{ matrix.target }}/release openshell-gateway
+ ls -lh artifacts/
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: gateway-binary-linux-${{ matrix.arch }}
+ path: artifacts/*.tar.gz
+ retention-days: 5
+
+ # ---------------------------------------------------------------------------
+ # Build standalone supervisor binaries (Linux GNU — native on each arch)
+ # ---------------------------------------------------------------------------
+ build-supervisor-binary-linux:
+ name: Build Supervisor Binary (Linux ${{ matrix.arch }})
+ needs: [compute-versions]
+ strategy:
+ matrix:
+ include:
+ - arch: amd64
+ runner: linux-amd64-cpu8
+ target: x86_64-unknown-linux-gnu
+ - arch: arm64
+ runner: linux-arm64-cpu8
+ target: aarch64-unknown-linux-gnu
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 60
+ container:
+ image: ghcr.io/nvidia/openshell/ci:latest
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ options: --privileged
+ env:
+ MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.tag || github.ref }}
+ fetch-depth: 0
+
+ - name: Mark workspace safe for git
+ run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
+
+ - name: Fetch tags
+ run: git fetch --tags --force
+
+ - name: Install tools
+ run: mise install --locked
+
+ - name: Cache Rust target and registry
+ uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
+ with:
+ shared-key: supervisor-binary-gnu-${{ matrix.arch }}
+ cache-directories: .cache/sccache
+ cache-targets: "true"
+
+ - name: Patch workspace version
+ if: needs.compute-versions.outputs.cargo_version != ''
+ run: |
+ set -euo pipefail
+ sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml
+
+ - name: Build ${{ matrix.target }}
+ run: |
+ set -euo pipefail
+ mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-sandbox --bin openshell-sandbox
+
+ - name: Verify packaged binary
+ run: |
+ set -euo pipefail
+ OUTPUT="$(target/${{ matrix.target }}/release/openshell-sandbox --version)"
+ echo "$OUTPUT"
+ grep -q '^openshell-sandbox ' <<<"$OUTPUT"
+
+ - name: sccache stats
+ if: always()
+ run: mise x -- sccache --show-stats
+
+ - name: Package binary
+ run: |
+ set -euo pipefail
+ mkdir -p artifacts
+ tar -czf artifacts/openshell-sandbox-${{ matrix.target }}.tar.gz \
+ -C target/${{ matrix.target }}/release openshell-sandbox
+ ls -lh artifacts/
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: supervisor-binary-linux-${{ matrix.arch }}
+ path: artifacts/*.tar.gz
+ retention-days: 5
+
+ # ---------------------------------------------------------------------------
+ # Build standalone gateway binary (macOS aarch64 via osxcross)
+ # ---------------------------------------------------------------------------
+ build-gateway-binary-macos:
+ name: Build Gateway Binary (macOS)
+ needs: [compute-versions]
+ runs-on: linux-amd64-cpu8
+ timeout-minutes: 60
+ container:
+ image: ghcr.io/nvidia/openshell/ci:latest
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ options: --privileged
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ env:
+ MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.tag || github.ref }}
+ fetch-depth: 0
+
+ - name: Mark workspace safe for git
+ run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
+
+ - name: Fetch tags
+ run: git fetch --tags --force
+
+ - name: Log in to GHCR
+ run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
+
+ - name: Set up Docker Buildx
+ uses: ./.github/actions/setup-buildx
+ with:
+ driver: local
+
+ - name: Build macOS binary via Docker
+ run: |
+ set -euo pipefail
+ docker buildx build \
+ --file deploy/docker/Dockerfile.gateway-macos \
+ --build-arg OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" \
+ --build-arg CARGO_TARGET_CACHE_SCOPE="${{ github.sha }}" \
+ --target binary \
+ --output type=local,dest=out/ \
+ .
+
+ - name: Verify packaged binary shape
+ run: |
+ set -euo pipefail
+ test -x out/openshell-gateway
+
+ - name: Package binary
+ run: |
+ set -euo pipefail
+ mkdir -p artifacts
+ tar -czf artifacts/openshell-gateway-aarch64-apple-darwin.tar.gz \
+ -C out openshell-gateway
+ ls -lh artifacts/
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: gateway-binary-macos
+ path: artifacts/*.tar.gz
+ retention-days: 5
+
+ build-driver-vm-linux:
+ name: Build Driver VM Linux
+ needs: [compute-versions]
+ uses: ./.github/workflows/driver-vm-linux.yml
+ with:
+ cargo-version: ${{ needs.compute-versions.outputs.cargo_version }}
+ image-tag: ${{ needs.compute-versions.outputs.semver }}
+ checkout-ref: ${{ inputs.tag || github.ref }}
+ secrets: inherit
+
+ build-driver-vm-macos:
+ name: Build Driver VM macOS
+ needs: [compute-versions]
+ uses: ./.github/workflows/driver-vm-macos.yml
+ with:
+ cargo-version: ${{ needs.compute-versions.outputs.cargo_version }}
+ image-tag: ${{ needs.compute-versions.outputs.semver }}
+ checkout-ref: ${{ inputs.tag || github.ref }}
+ secrets: inherit
+
+ build-deb:
+ name: Build Debian Packages
+ needs: [compute-versions, build-cli-linux, build-gateway-binary-linux, build-driver-vm-linux]
+ uses: ./.github/workflows/deb-package.yml
+ with:
+ deb-version: ${{ needs.compute-versions.outputs.deb_version }}
+ checkout-ref: ${{ inputs.tag || github.ref }}
+ secrets: inherit
+
+ build-rpm:
+ name: Build RPM Packages
+ needs: [compute-versions]
+ uses: ./.github/workflows/rpm-package.yml
+ with:
+ checkout-ref: ${{ inputs.tag || github.ref }}
+ secrets: inherit
+
+ # ---------------------------------------------------------------------------
+ # Create a tagged GitHub Release with CLI, gateway, driver, and wheels
# ---------------------------------------------------------------------------
release:
name: Release
- needs: [compute-versions, build-cli-linux, build-cli-macos, build-python-wheels-linux, build-python-wheel-macos, tag-ghcr-release]
- runs-on: build-amd64
+ needs: [compute-versions, build-cli-linux, build-cli-macos, build-gateway-binary-linux, build-gateway-binary-macos, build-supervisor-binary-linux, build-python-wheels-linux, build-python-wheel-macos, tag-ghcr-release, build-driver-vm-linux, build-driver-vm-macos, build-deb, build-rpm]
+ runs-on: linux-amd64-cpu8
timeout-minutes: 10
+ permissions:
+ contents: write
+ id-token: write
+ attestations: write
+ artifact-metadata: write
outputs:
wheel_filenames: ${{ steps.wheel_filenames.outputs.wheel_filenames }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.ref }}
@@ -424,6 +707,27 @@ jobs:
path: release/
merge-multiple: true
+ - name: Download gateway binary artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: gateway-binary-*
+ path: release/
+ merge-multiple: true
+
+ - name: Download supervisor binary artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: supervisor-binary-*
+ path: release/
+ merge-multiple: true
+
+ - name: Download VM driver artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: driver-vm-*
+ path: release/
+ merge-multiple: true
+
- name: Download wheel artifacts
uses: actions/download-artifact@v4
with:
@@ -431,6 +735,20 @@ jobs:
path: release/
merge-multiple: true
+ - name: Download Debian package artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: deb-linux-*
+ path: release/
+ merge-multiple: true
+
+ - name: Download RPM package artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: rpm-linux-*
+ path: release/
+ merge-multiple: true
+
- name: Capture wheel filenames
id: wheel_filenames
run: |
@@ -443,8 +761,53 @@ jobs:
run: |
set -euo pipefail
cd release
- sha256sum *.tar.gz *.whl > openshell-checksums-sha256.txt
+ sha256sum \
+ openshell-x86_64-unknown-linux-musl.tar.gz \
+ openshell-aarch64-unknown-linux-musl.tar.gz \
+ openshell-aarch64-apple-darwin.tar.gz \
+ openshell_*.deb \
+ openshell-*.rpm \
+ *.whl > openshell-checksums-sha256.txt
cat openshell-checksums-sha256.txt
+ sha256sum \
+ openshell-gateway-x86_64-unknown-linux-gnu.tar.gz \
+ openshell-gateway-aarch64-unknown-linux-gnu.tar.gz \
+ openshell-gateway-aarch64-apple-darwin.tar.gz > openshell-gateway-checksums-sha256.txt
+ cat openshell-gateway-checksums-sha256.txt
+ sha256sum \
+ openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz \
+ openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz > openshell-sandbox-checksums-sha256.txt
+ cat openshell-sandbox-checksums-sha256.txt
+
+ - name: Attest VM driver artifacts
+ uses: actions/attest@v4
+ with:
+ subject-path: |
+ release/openshell-driver-vm-x86_64-unknown-linux-gnu.tar.gz
+ release/openshell-driver-vm-aarch64-unknown-linux-gnu.tar.gz
+ release/openshell-driver-vm-aarch64-apple-darwin.tar.gz
+
+ - name: Prune removed VM checksum asset
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/');
+ let release;
+ try {
+ release = await github.rest.repos.getReleaseByTag({ owner, repo, tag: process.env.RELEASE_TAG });
+ } catch (err) {
+ if (err.status === 404) {
+ core.info(`No existing ${process.env.RELEASE_TAG} release; skipping VM checksum pruning.`);
+ return;
+ }
+ throw err;
+ }
+ for (const asset of release.data.assets) {
+ if (asset.name === 'openshell-driver-vm-checksums-sha256.txt') {
+ core.info(`Deleting removed VM checksum asset: ${asset.name}`);
+ await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: asset.id });
+ }
+ }
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
@@ -466,8 +829,20 @@ jobs:
release/openshell-x86_64-unknown-linux-musl.tar.gz
release/openshell-aarch64-unknown-linux-musl.tar.gz
release/openshell-aarch64-apple-darwin.tar.gz
+ release/openshell_*.deb
+ release/openshell-*.rpm
+ release/openshell-gateway-x86_64-unknown-linux-gnu.tar.gz
+ release/openshell-gateway-aarch64-unknown-linux-gnu.tar.gz
+ release/openshell-gateway-aarch64-apple-darwin.tar.gz
+ release/openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz
+ release/openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz
+ release/openshell-driver-vm-x86_64-unknown-linux-gnu.tar.gz
+ release/openshell-driver-vm-aarch64-unknown-linux-gnu.tar.gz
+ release/openshell-driver-vm-aarch64-apple-darwin.tar.gz
release/*.whl
release/openshell-checksums-sha256.txt
+ release/openshell-gateway-checksums-sha256.txt
+ release/openshell-sandbox-checksums-sha256.txt
publish-fern-docs:
name: Publish Fern Docs
@@ -475,7 +850,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
ref: ${{ inputs.tag || github.ref }}
diff --git a/.github/workflows/release-vm-dev.yml b/.github/workflows/release-vm-dev.yml
deleted file mode 100644
index 06ba474a07..0000000000
--- a/.github/workflows/release-vm-dev.yml
+++ /dev/null
@@ -1,535 +0,0 @@
-name: Release VM Dev
-
-# Build openshell-vm binaries for all supported platforms and upload them to
-# the rolling "vm-dev" GitHub Release. Each binary is self-extracting: it
-# embeds pre-built kernel runtime artifacts (from release-vm-kernel.yml) and a
-# base rootfs tarball.
-#
-# Prerequisites: the vm-dev release must already contain kernel runtime
-# tarballs. Run the "Release VM Kernel" workflow first if they are missing.
-
-on:
- push:
- branches: [main]
- workflow_dispatch:
-
-permissions:
- contents: write
- packages: read
-
-# Serialize with release-vm-kernel.yml — both update the vm-dev release.
-concurrency:
- group: vm-dev-release
- cancel-in-progress: false
-
-defaults:
- run:
- shell: bash
-
-jobs:
- # ---------------------------------------------------------------------------
- # Compute versions (reuse the same logic as release-dev.yml)
- # ---------------------------------------------------------------------------
- compute-versions:
- name: Compute Versions
- runs-on: build-amd64
- timeout-minutes: 5
- container:
- image: ghcr.io/nvidia/openshell/ci:latest
- credentials:
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
- outputs:
- cargo_version: ${{ steps.v.outputs.cargo }}
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Mark workspace safe for git
- run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
-
- - name: Fetch tags
- run: git fetch --tags --force
-
- - name: Compute versions
- id: v
- run: |
- set -euo pipefail
- echo "cargo=$(uv run python tasks/scripts/release.py get-version --cargo)" >> "$GITHUB_OUTPUT"
-
- # ---------------------------------------------------------------------------
- # Download kernel runtime tarballs from the vm-dev release
- # ---------------------------------------------------------------------------
- download-kernel-runtime:
- name: Download Kernel Runtime
- runs-on: build-amd64
- timeout-minutes: 10
- container:
- image: ghcr.io/nvidia/openshell/ci:latest
- credentials:
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
- steps:
- - uses: actions/checkout@v4
-
- - name: Download all runtime tarballs
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- set -euo pipefail
- mkdir -p runtime-artifacts
-
- for platform in linux-aarch64 linux-x86_64 darwin-aarch64; do
- echo "Downloading vm-runtime-${platform}.tar.zst..."
- gh release download vm-dev \
- --repo "${GITHUB_REPOSITORY}" \
- --pattern "vm-runtime-${platform}.tar.zst" \
- --dir runtime-artifacts \
- --clobber
- done
-
- echo "Downloaded runtime artifacts:"
- ls -lah runtime-artifacts/
-
- - name: Verify downloads
- run: |
- set -euo pipefail
- for platform in linux-aarch64 linux-x86_64 darwin-aarch64; do
- file="runtime-artifacts/vm-runtime-${platform}.tar.zst"
- if [ ! -f "$file" ]; then
- echo "ERROR: Missing ${file}" >&2
- echo "" >&2
- echo "The vm-dev release does not have kernel runtime artifacts." >&2
- echo "Run the 'Release VM Kernel' workflow first:" >&2
- echo " gh workflow run release-vm-kernel.yml" >&2
- exit 1
- fi
- echo "OK: ${file} ($(du -sh "$file" | cut -f1))"
- done
-
- - name: Upload as workflow artifact
- uses: actions/upload-artifact@v4
- with:
- name: kernel-runtime-tarballs
- path: runtime-artifacts/vm-runtime-*.tar.zst
- retention-days: 1
-
- # ---------------------------------------------------------------------------
- # Build base rootfs tarballs (architecture-specific)
- # ---------------------------------------------------------------------------
- build-rootfs:
- name: Build Rootfs (${{ matrix.arch }})
- needs: [compute-versions]
- strategy:
- matrix:
- include:
- - arch: arm64
- runner: build-arm64
- guest_arch: aarch64
- - arch: amd64
- runner: build-amd64
- guest_arch: x86_64
- runs-on: ${{ matrix.runner }}
- timeout-minutes: 30
- container:
- image: ghcr.io/nvidia/openshell/ci:latest
- credentials:
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
- options: --privileged
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- env:
- MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- OPENSHELL_IMAGE_TAG: dev
- steps:
- - uses: actions/checkout@v4
-
- - name: Mark workspace safe for git
- run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
-
- - name: Log in to GHCR
- run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
-
- - name: Install tools
- run: mise install
-
- - name: Install zstd
- run: apt-get update && apt-get install -y --no-install-recommends zstd && rm -rf /var/lib/apt/lists/*
-
- - name: Build base rootfs tarball
- run: |
- set -euo pipefail
- crates/openshell-vm/scripts/build-rootfs.sh \
- --base \
- --arch ${{ matrix.guest_arch }} \
- target/rootfs-build
-
- mkdir -p target/vm-runtime-compressed
- tar -C target/rootfs-build -cf - . \
- | zstd -19 -T0 -o target/vm-runtime-compressed/rootfs.tar.zst
-
- echo "Rootfs tarball: $(du -sh target/vm-runtime-compressed/rootfs.tar.zst | cut -f1)"
-
- - name: Upload rootfs artifact
- uses: actions/upload-artifact@v4
- with:
- name: rootfs-${{ matrix.arch }}
- path: target/vm-runtime-compressed/rootfs.tar.zst
- retention-days: 1
-
- # ---------------------------------------------------------------------------
- # Build openshell-vm binary (Linux — native on each arch)
- # ---------------------------------------------------------------------------
- build-vm-linux:
- name: Build VM (Linux ${{ matrix.arch }})
- needs: [compute-versions, download-kernel-runtime, build-rootfs]
- strategy:
- matrix:
- include:
- - arch: arm64
- runner: build-arm64
- target: aarch64-unknown-linux-gnu
- platform: linux-aarch64
- guest_arch: aarch64
- - arch: amd64
- runner: build-amd64
- target: x86_64-unknown-linux-gnu
- platform: linux-x86_64
- guest_arch: x86_64
- runs-on: ${{ matrix.runner }}
- timeout-minutes: 30
- container:
- image: ghcr.io/nvidia/openshell/ci:latest
- credentials:
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
- options: --privileged
- env:
- MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- SCCACHE_MEMCACHED_ENDPOINT: ${{ vars.SCCACHE_MEMCACHED_ENDPOINT }}
- OPENSHELL_IMAGE_TAG: dev
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Mark workspace safe for git
- run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
-
- - name: Fetch tags
- run: git fetch --tags --force
-
- - name: Install tools
- run: mise install
-
- - name: Cache Rust target and registry
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
- with:
- shared-key: vm-linux-${{ matrix.arch }}
- cache-directories: .cache/sccache
- cache-targets: "true"
-
- - name: Install zstd
- run: apt-get update && apt-get install -y --no-install-recommends zstd && rm -rf /var/lib/apt/lists/*
-
- - name: Download kernel runtime tarball
- uses: actions/download-artifact@v4
- with:
- name: kernel-runtime-tarballs
- path: runtime-download/
-
- - name: Download rootfs tarball
- uses: actions/download-artifact@v4
- with:
- name: rootfs-${{ matrix.arch }}
- path: rootfs-download/
-
- - name: Stage compressed runtime for embedding
- run: |
- set -euo pipefail
- COMPRESSED_DIR="${PWD}/target/vm-runtime-compressed"
- mkdir -p "$COMPRESSED_DIR"
-
- # Extract kernel runtime tarball and re-compress individual files
- EXTRACT_DIR=$(mktemp -d)
- zstd -d "runtime-download/vm-runtime-${{ matrix.platform }}.tar.zst" --stdout \
- | tar -xf - -C "$EXTRACT_DIR"
-
- echo "Extracted runtime files:"
- ls -lah "$EXTRACT_DIR"
-
- for file in "$EXTRACT_DIR"/*; do
- [ -f "$file" ] || continue
- name=$(basename "$file")
- [ "$name" = "provenance.json" ] && continue
- zstd -19 -f -q -T0 -o "${COMPRESSED_DIR}/${name}.zst" "$file"
- done
-
- # Copy rootfs tarball (already zstd-compressed)
- cp rootfs-download/rootfs.tar.zst "${COMPRESSED_DIR}/rootfs.tar.zst"
-
- echo "Staged compressed artifacts:"
- ls -lah "$COMPRESSED_DIR"
-
- - name: Scope workspace to VM crates
- run: |
- set -euo pipefail
- sed -i 's|members = \["crates/\*"\]|members = ["crates/openshell-vm", "crates/openshell-core", "crates/openshell-bootstrap", "crates/openshell-policy"]|' Cargo.toml
-
- - name: Patch workspace version
- if: needs.compute-versions.outputs.cargo_version != ''
- run: |
- set -euo pipefail
- sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ needs.compute-versions.outputs.cargo_version }}"'"/}' Cargo.toml
-
- - name: Build openshell-vm
- run: |
- set -euo pipefail
- OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="${PWD}/target/vm-runtime-compressed" \
- mise x -- cargo build --release -p openshell-vm
-
- - name: sccache stats
- if: always()
- run: mise x -- sccache --show-stats
-
- - name: Package binary
- run: |
- set -euo pipefail
- mkdir -p artifacts
- tar -czf "artifacts/openshell-vm-${{ matrix.target }}.tar.gz" \
- -C target/release openshell-vm
- ls -lh artifacts/
-
- - name: Upload artifact
- uses: actions/upload-artifact@v4
- with:
- name: vm-linux-${{ matrix.arch }}
- path: artifacts/*.tar.gz
- retention-days: 5
-
- # ---------------------------------------------------------------------------
- # Build openshell-vm binary (macOS ARM64 via osxcross)
- # ---------------------------------------------------------------------------
- build-vm-macos:
- name: Build VM (macOS)
- needs: [compute-versions, download-kernel-runtime, build-rootfs]
- runs-on: build-amd64
- timeout-minutes: 60
- container:
- image: ghcr.io/nvidia/openshell/ci:latest
- credentials:
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
- options: --privileged
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- env:
- MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- SCCACHE_MEMCACHED_ENDPOINT: ${{ vars.SCCACHE_MEMCACHED_ENDPOINT }}
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Mark workspace safe for git
- run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
-
- - name: Fetch tags
- run: git fetch --tags --force
-
- - name: Log in to GHCR
- run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
-
- - name: Set up Docker Buildx
- uses: ./.github/actions/setup-buildx
-
- - name: Install zstd
- run: apt-get update && apt-get install -y --no-install-recommends zstd && rm -rf /var/lib/apt/lists/*
-
- - name: Download kernel runtime tarball
- uses: actions/download-artifact@v4
- with:
- name: kernel-runtime-tarballs
- path: runtime-download/
-
- - name: Download rootfs tarball (arm64)
- uses: actions/download-artifact@v4
- with:
- name: rootfs-arm64
- path: rootfs-download/
-
- - name: Prepare compressed runtime directory
- run: |
- set -euo pipefail
- COMPRESSED_DIR="${PWD}/target/vm-runtime-compressed-macos"
- mkdir -p "$COMPRESSED_DIR"
-
- # Extract the darwin runtime tarball and re-compress for embedding.
- # The macOS embedded.rs expects: libkrun.dylib.zst, libkrunfw.5.dylib.zst, gvproxy.zst
- EXTRACT_DIR=$(mktemp -d)
- zstd -d "runtime-download/vm-runtime-darwin-aarch64.tar.zst" --stdout \
- | tar -xf - -C "$EXTRACT_DIR"
-
- echo "Extracted darwin runtime files:"
- ls -lah "$EXTRACT_DIR"
-
- for file in "$EXTRACT_DIR"/*; do
- [ -f "$file" ] || continue
- name=$(basename "$file")
- [ "$name" = "provenance.json" ] && continue
- zstd -19 -f -q -T0 -o "${COMPRESSED_DIR}/${name}.zst" "$file"
- done
-
- # The macOS VM guest is always Linux ARM64, so use the arm64 rootfs
- cp rootfs-download/rootfs.tar.zst "${COMPRESSED_DIR}/rootfs.tar.zst"
-
- echo "Staged macOS compressed artifacts:"
- ls -lah "$COMPRESSED_DIR"
-
- - name: Build macOS binary via Docker (osxcross)
- run: |
- set -euo pipefail
- docker buildx build \
- --file deploy/docker/Dockerfile.vm-macos \
- --build-arg OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" \
- --build-arg OPENSHELL_IMAGE_TAG=dev \
- --build-arg CARGO_TARGET_CACHE_SCOPE="${{ github.sha }}" \
- --build-context vm-runtime-compressed="${PWD}/target/vm-runtime-compressed-macos" \
- --target binary \
- --output type=local,dest=out/ \
- .
-
- - name: Package binary
- run: |
- set -euo pipefail
- mkdir -p artifacts
- tar -czf artifacts/openshell-vm-aarch64-apple-darwin.tar.gz \
- -C out openshell-vm
- ls -lh artifacts/
-
- - name: Upload artifact
- uses: actions/upload-artifact@v4
- with:
- name: vm-macos
- path: artifacts/*.tar.gz
- retention-days: 5
-
- # ---------------------------------------------------------------------------
- # Upload all VM binaries to the vm-dev rolling release
- # ---------------------------------------------------------------------------
- release-vm-dev:
- name: Release VM Dev
- needs: [build-vm-linux, build-vm-macos]
- runs-on: build-amd64
- timeout-minutes: 10
- steps:
- - uses: actions/checkout@v4
-
- - name: Download all VM binary artifacts
- uses: actions/download-artifact@v4
- with:
- pattern: vm-*
- path: release/
- merge-multiple: true
-
- - name: Filter to only binary tarballs
- run: |
- set -euo pipefail
- mkdir -p release-final
- # Only include the openshell-vm binary tarballs, not kernel runtime
- cp release/openshell-vm-*.tar.gz release-final/
- count=$(ls release-final/openshell-vm-*.tar.gz 2>/dev/null | wc -l)
- if [ "$count" -eq 0 ]; then
- echo "ERROR: No VM binary tarballs found in release/" >&2
- exit 1
- fi
- echo "Release artifacts (${count} binaries):"
- ls -lh release-final/
-
- - name: Generate checksums
- run: |
- set -euo pipefail
- cd release-final
- sha256sum openshell-vm-*.tar.gz > vm-binary-checksums-sha256.txt
- cat vm-binary-checksums-sha256.txt
-
- - name: Ensure vm-dev tag exists
- run: |
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
- git tag -fa vm-dev -m "VM Development Build" "${GITHUB_SHA}"
- git push --force origin vm-dev
-
- - name: Prune stale VM binary assets from vm-dev release
- uses: actions/github-script@v7
- with:
- script: |
- const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/');
- let release;
- try {
- release = await github.rest.repos.getReleaseByTag({ owner, repo, tag: 'vm-dev' });
- } catch (err) {
- if (err.status === 404) {
- core.info('No existing vm-dev release; will create fresh.');
- return;
- }
- throw err;
- }
- // Delete old VM binary assets (keep kernel runtime assets)
- for (const asset of release.data.assets) {
- if (asset.name.startsWith('openshell-vm-') || asset.name === 'vm-binary-checksums-sha256.txt') {
- core.info(`Deleting stale asset: ${asset.name}`);
- await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: asset.id });
- }
- }
-
- - name: Upload to vm-dev GitHub Release
- uses: softprops/action-gh-release@v2
- with:
- name: OpenShell VM Development Build
- prerelease: true
- tag_name: vm-dev
- target_commitish: ${{ github.sha }}
- body: |
- Rolling development build of **openshell-vm** — the MicroVM runtime for OpenShell.
-
- > **NOTE**: This is a development build, not a tagged release, and may be unstable.
-
- ### Kernel Runtime Artifacts
-
- Pre-built kernel runtime (libkrunfw + libkrun + gvproxy) for embedding into
- the openshell-vm binary. These are rebuilt when the kernel config or pinned
- dependency versions change.
-
- | Platform | Artifact |
- |----------|----------|
- | Linux ARM64 | `vm-runtime-linux-aarch64.tar.zst` |
- | Linux x86_64 | `vm-runtime-linux-x86_64.tar.zst` |
- | macOS ARM64 | `vm-runtime-darwin-aarch64.tar.zst` |
-
- ### VM Binaries
-
- Self-extracting openshell-vm binaries with embedded kernel runtime and base
- rootfs. These are rebuilt on every push to main.
-
- | Platform | Artifact |
- |----------|----------|
- | Linux ARM64 | `openshell-vm-aarch64-unknown-linux-gnu.tar.gz` |
- | Linux x86_64 | `openshell-vm-x86_64-unknown-linux-gnu.tar.gz` |
- | macOS ARM64 | `openshell-vm-aarch64-apple-darwin.tar.gz` |
-
- ### Quick install
-
- ```
- curl -fsSL https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install-vm.sh | sh
- ```
-
- Auto-detects your platform, verifies checksums, and codesigns on macOS.
-
- files: |
- release-final/openshell-vm-aarch64-unknown-linux-gnu.tar.gz
- release-final/openshell-vm-x86_64-unknown-linux-gnu.tar.gz
- release-final/openshell-vm-aarch64-apple-darwin.tar.gz
- release-final/vm-binary-checksums-sha256.txt
diff --git a/.github/workflows/release-vm-kernel.yml b/.github/workflows/release-vm-kernel.yml
index c1593da313..8bdaab11f7 100644
--- a/.github/workflows/release-vm-kernel.yml
+++ b/.github/workflows/release-vm-kernel.yml
@@ -1,16 +1,16 @@
name: Release VM Kernel
# Build custom libkrunfw (kernel firmware) + libkrun (VMM) + gvproxy for all
-# supported openshell-vm platforms. Artifacts are uploaded to the rolling
-# "vm-dev" GitHub Release and consumed by release-vm-dev.yml when building the
-# openshell-vm binary.
+# supported openshell-driver-vm platforms. Artifacts are uploaded to the
+# rolling "vm-runtime" GitHub Release and consumed by normal dev/tag release
+# workflows when building the openshell-driver-vm binary.
#
# The Linux kernel is compiled once on aarch64 Linux. The resulting kernel.c
# (a C source file containing the kernel as a byte array) is shared with the
# macOS job, which only needs to compile it into a .dylib — no krunvm, no
# Fedora VM, no kernel rebuild. This cuts macOS CI from ~45 min to ~5 min.
#
-# This workflow runs on-demand (or when kernel config / pins change). It is
+# This workflow runs on demand when kernel config or pins change. It is
# intentionally decoupled from the per-commit VM binary build because the
# kernel rarely changes and takes 15-45 minutes to compile.
@@ -21,9 +21,9 @@ permissions:
contents: write
packages: read
-# Serialize with release-vm-dev.yml — both update the vm-dev release.
+# Serialize runtime release updates.
concurrency:
- group: vm-dev-release
+ group: vm-runtime-release
cancel-in-progress: false
defaults:
@@ -36,7 +36,7 @@ jobs:
# ---------------------------------------------------------------------------
build-runtime-linux-arm64:
name: Build Runtime (Linux ARM64)
- runs-on: build-arm64
+ runs-on: linux-arm64-cpu8
timeout-minutes: 60
container:
image: ghcr.io/nvidia/openshell/ci:latest
@@ -47,7 +47,7 @@ jobs:
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Mark workspace safe for git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
@@ -86,7 +86,7 @@ jobs:
# ---------------------------------------------------------------------------
build-runtime-linux-amd64:
name: Build Runtime (Linux AMD64)
- runs-on: build-amd64
+ runs-on: linux-amd64-cpu8
timeout-minutes: 60
container:
image: ghcr.io/nvidia/openshell/ci:latest
@@ -97,7 +97,7 @@ jobs:
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Mark workspace safe for git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
@@ -130,12 +130,12 @@ jobs:
env:
RUSTC_WRAPPER: ""
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Install dependencies
run: |
set -euo pipefail
- curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.95.0
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
brew install lld dtc xz
@@ -163,15 +163,20 @@ jobs:
retention-days: 5
# ---------------------------------------------------------------------------
- # Upload all runtime tarballs to the vm-dev rolling release
+ # Upload all runtime tarballs to the vm-runtime rolling release
# ---------------------------------------------------------------------------
release-kernel:
name: Release Kernel Runtime
needs: [build-runtime-linux-arm64, build-runtime-linux-amd64, build-runtime-macos-arm64]
- runs-on: build-amd64
+ runs-on: linux-amd64-cpu8
timeout-minutes: 10
+ permissions:
+ contents: write
+ id-token: write
+ attestations: write
+ artifact-metadata: write
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Download all runtime artifacts
uses: actions/download-artifact@v4
@@ -180,36 +185,37 @@ jobs:
path: release/
merge-multiple: true
- - name: Generate checksums
- run: |
- set -euo pipefail
- cd release
- sha256sum vm-runtime-*.tar.zst > vm-runtime-checksums-sha256.txt
- cat vm-runtime-checksums-sha256.txt
+ - name: Attest VM runtime artifacts
+ uses: actions/attest@v4
+ with:
+ subject-path: |
+ release/vm-runtime-linux-aarch64.tar.zst
+ release/vm-runtime-linux-x86_64.tar.zst
+ release/vm-runtime-darwin-aarch64.tar.zst
- - name: Ensure vm-dev tag exists
+ - name: Ensure vm-runtime tag exists
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- git tag -fa vm-dev -m "VM Development Build" "${GITHUB_SHA}"
- git push --force origin vm-dev
+ git tag -fa vm-runtime -m "VM Runtime Development Build" "${GITHUB_SHA}"
+ git push --force origin vm-runtime
- - name: Prune stale runtime assets from vm-dev release
+ - name: Prune stale runtime assets from vm-runtime release
uses: actions/github-script@v7
with:
script: |
const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/');
let release;
try {
- release = await github.rest.repos.getReleaseByTag({ owner, repo, tag: 'vm-dev' });
+ release = await github.rest.repos.getReleaseByTag({ owner, repo, tag: 'vm-runtime' });
} catch (err) {
if (err.status === 404) {
- core.info('No existing vm-dev release; will create fresh.');
+ core.info('No existing vm-runtime release; will create fresh.');
return;
}
throw err;
}
- // Delete old runtime tarballs and checksums (keep vm binary assets)
+ // Delete old runtime assets, including removed checksum files.
for (const asset of release.data.assets) {
if (asset.name.startsWith('vm-runtime-')) {
core.info(`Deleting stale asset: ${asset.name}`);
@@ -217,25 +223,23 @@ jobs:
}
}
- - name: Create / update vm-dev GitHub Release
+ - name: Create / update vm-runtime GitHub Release
uses: softprops/action-gh-release@v2
with:
- name: OpenShell VM Development Build
+ name: OpenShell VM Runtime
prerelease: true
- tag_name: vm-dev
+ tag_name: vm-runtime
target_commitish: ${{ github.sha }}
body: |
- Rolling development build of **openshell-vm** — the MicroVM runtime for OpenShell.
+ Build of the OpenShell VM runtime artifacts used by `openshell-driver-vm`.
- > **NOTE**: This is a development build, not a tagged release, and may be unstable.
- > The VM implementation itself is also experimental and may change or break without
- > notice.
+ > **NOTE**: This is a development build.
### Kernel Runtime Artifacts
Pre-built kernel runtime (libkrunfw + libkrun + gvproxy) for embedding into
- the openshell-vm binary. These are rebuilt when the kernel config or pinned
- dependency versions change.
+ the `openshell-driver-vm` binary. These are rebuilt on demand when the kernel
+ config or pinned dependency versions change.
| Platform | Artifact |
|----------|----------|
@@ -243,27 +247,14 @@ jobs:
| Linux x86_64 | `vm-runtime-linux-x86_64.tar.zst` |
| macOS ARM64 | `vm-runtime-darwin-aarch64.tar.zst` |
- ### VM Binaries
-
- Self-extracting openshell-vm binaries with embedded kernel runtime and base
- rootfs. These are rebuilt on every push to main.
+ ### Verify
- | Platform | Artifact |
- |----------|----------|
- | Linux ARM64 | `openshell-vm-aarch64-unknown-linux-gnu.tar.gz` |
- | Linux x86_64 | `openshell-vm-x86_64-unknown-linux-gnu.tar.gz` |
- | macOS ARM64 | `openshell-vm-aarch64-apple-darwin.tar.gz` |
-
- ### Quick install
-
- ```
- curl -fsSL https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install-vm.sh | sh
+ ```bash
+ gh release download vm-runtime -R NVIDIA/OpenShell -p vm-runtime-linux-x86_64.tar.zst
+ gh attestation verify vm-runtime-linux-x86_64.tar.zst -R NVIDIA/OpenShell
```
- Auto-detects your platform, verifies checksums, and codesigns on macOS.
-
files: |
release/vm-runtime-linux-aarch64.tar.zst
release/vm-runtime-linux-x86_64.tar.zst
release/vm-runtime-darwin-aarch64.tar.zst
- release/vm-runtime-checksums-sha256.txt
diff --git a/.github/workflows/rpm-package.yml b/.github/workflows/rpm-package.yml
new file mode 100644
index 0000000000..f6003d6669
--- /dev/null
+++ b/.github/workflows/rpm-package.yml
@@ -0,0 +1,80 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+name: RPM Package
+
+on:
+ workflow_call:
+ inputs:
+ checkout-ref:
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ build-rpm-linux:
+ name: Build RPM Package (Linux ${{ matrix.arch }})
+ strategy:
+ matrix:
+ include:
+ - arch: x86_64
+ runner: linux-amd64-cpu8
+ - arch: aarch64
+ runner: linux-arm64-cpu8
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 60
+ container:
+ image: fedora:latest
+ steps:
+ - name: Install build dependencies
+ run: |
+ dnf install -y \
+ packit rpm-build \
+ rust cargo gcc gcc-c++ make cmake pkg-config \
+ clang-devel z3-devel systemd-rpm-macros \
+ pandoc python3-devel git-core \
+ cargo-rpm-macros
+
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.checkout-ref }}
+ fetch-depth: 0
+
+ - name: Mark workspace safe for git
+ run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
+
+ - name: Fetch tags
+ run: git fetch --tags --force
+
+ - name: Build RPMs via Packit
+ run: packit build locally
+
+ - name: Collect RPM artifacts
+ run: |
+ set -euo pipefail
+ mkdir -p artifacts
+ for rpm_dir in "$HOME/rpmbuild/RPMS" "$PWD/${{ matrix.arch }}"; do
+ if [ -d "$rpm_dir" ]; then
+ find "$rpm_dir" -type f -name '*.rpm' -exec cp {} artifacts/ \;
+ fi
+ done
+ if ! compgen -G 'artifacts/*.rpm' > /dev/null; then
+ echo "::error::No RPM artifacts found"
+ find "$PWD" -maxdepth 3 -type f -name '*.rpm' -print
+ exit 1
+ fi
+ echo "=== Built RPMs ==="
+ ls -lah artifacts/
+
+ - name: Upload RPM artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: rpm-linux-${{ matrix.arch }}
+ path: artifacts/*.rpm
+ retention-days: 5
diff --git a/.github/workflows/shadow-docker-build.yml b/.github/workflows/shadow-docker-build.yml
new file mode 100644
index 0000000000..62e6878675
--- /dev/null
+++ b/.github/workflows/shadow-docker-build.yml
@@ -0,0 +1,43 @@
+name: Shadow Docker Build
+
+# OS-128 Phase 4: manual non-publishing exercise of the production Docker
+# image workflow. This stays off main's push surface because the image path is
+# not a required signal while the prebuilt-binary rollout is being measured.
+
+on:
+ workflow_dispatch:
+ inputs:
+ platform:
+ description: "Target platform(s)"
+ required: false
+ type: string
+ default: "linux/amd64,linux/arm64"
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+ gateway:
+ uses: ./.github/workflows/docker-build.yml
+ with:
+ component: gateway
+ platform: ${{ inputs.platform }}
+ push: false
+ secrets: inherit
+
+ supervisor:
+ uses: ./.github/workflows/docker-build.yml
+ with:
+ component: supervisor
+ platform: ${{ inputs.platform }}
+ push: false
+ secrets: inherit
+
+ cluster:
+ uses: ./.github/workflows/docker-build.yml
+ with:
+ component: cluster
+ platform: ${{ inputs.platform }}
+ push: false
+ secrets: inherit
diff --git a/.github/workflows/shadow-rust-native-build.yml b/.github/workflows/shadow-rust-native-build.yml
new file mode 100644
index 0000000000..80df1056f7
--- /dev/null
+++ b/.github/workflows/shadow-rust-native-build.yml
@@ -0,0 +1,255 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+name: Rust Native Build (openshell-gateway / openshell-sandbox)
+
+# OS-128 Phase 4: build Rust binaries natively per Linux architecture before
+# the Docker image build consumes them as prebuilt artifacts.
+
+on:
+ workflow_call:
+ inputs:
+ component:
+ description: "Binary component to build (gateway or sandbox)"
+ required: true
+ type: string
+ arch:
+ description: "Linux architecture to build (amd64 or arm64)"
+ required: true
+ type: string
+ cargo-version:
+ description: "Pre-computed cargo version (skips internal git-based computation)"
+ required: false
+ type: string
+ default: ""
+ features:
+ description: "Cargo features to enable"
+ required: false
+ type: string
+ default: "openshell-core/dev-settings"
+ retention-days:
+ description: "Artifact retention period"
+ required: false
+ type: number
+ default: 5
+ artifact-name:
+ description: "Artifact name override"
+ required: false
+ type: string
+ default: ""
+ workflow_dispatch:
+ inputs:
+ component:
+ description: "Binary component to build"
+ required: true
+ type: choice
+ default: gateway
+ options:
+ - gateway
+ - sandbox
+ arch:
+ description: "Linux architecture to build"
+ required: true
+ type: choice
+ default: amd64
+ options:
+ - amd64
+ - arm64
+ cargo-version:
+ description: "Cargo version override"
+ required: false
+ type: string
+ default: ""
+ features:
+ description: "Cargo features to enable"
+ required: false
+ type: string
+ default: "openshell-core/dev-settings"
+ retention-days:
+ description: "Artifact retention period"
+ required: false
+ type: number
+ default: 5
+ artifact-name:
+ description: "Artifact name override"
+ required: false
+ type: string
+ default: ""
+
+permissions:
+ contents: read
+ packages: read
+
+env:
+ CARGO_TERM_COLOR: always
+ CARGO_INCREMENTAL: "0"
+ MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # Route sccache (already RUSTC_WRAPPER in mise.toml) to the GHA cache
+ # backend instead of the EKS memcached used by ARC.
+ SCCACHE_GHA_ENABLED: "true"
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ rust-native-build:
+ name: ${{ inputs.component }} (${{ inputs.arch }})
+ runs-on: ${{ inputs.arch == 'arm64' && 'linux-arm64-cpu8' || 'linux-amd64-cpu8' }}
+ timeout-minutes: 60
+ env:
+ COMPONENT: ${{ inputs.component }}
+ ARCH: ${{ inputs.arch }}
+ FEATURES: ${{ inputs.features }}
+ # Partition the GHA sccache cache per (component, arch). Without this,
+ # concurrent jobs collide on the same cache key and later-starting
+ # writers hit 409 Conflict (PR #961 fix for shadow-shared-cpu-spike).
+ SCCACHE_GHA_VERSION: ${{ inputs.component }}-${{ inputs.arch }}
+ container:
+ image: ghcr.io/nvidia/openshell/ci:latest
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Mark workspace safe for git
+ run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
+
+ - name: Fetch tags
+ run: git fetch --tags --force
+
+ - name: Install tools
+ run: mise install --locked
+
+ - name: Resolve build target
+ id: target
+ run: |
+ set -euo pipefail
+
+ case "$COMPONENT" in
+ gateway)
+ crate=openshell-server
+ binary=openshell-gateway
+ ;;
+ sandbox)
+ crate=openshell-sandbox
+ binary=openshell-sandbox
+ ;;
+ *)
+ echo "unsupported component: $COMPONENT" >&2
+ exit 1
+ ;;
+ esac
+
+ case "$ARCH" in
+ amd64)
+ target=x86_64-unknown-linux-gnu
+ ;;
+ arm64)
+ target=aarch64-unknown-linux-gnu
+ ;;
+ *)
+ echo "unsupported arch: $ARCH" >&2
+ exit 1
+ ;;
+ esac
+
+ {
+ echo "crate=$crate"
+ echo "binary=$binary"
+ echo "target=$target"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: Configure GHA sccache backend
+ # Exposes ACTIONS_CACHE_URL / ACTIONS_RUNTIME_TOKEN so sccache (wrapped
+ # around rustc via mise's RUSTC_WRAPPER) can initialize the GHA cache.
+ uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
+
+ - name: Cache Rust target and registry
+ uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
+ with:
+ shared-key: rust-native-${{ inputs.component }}-${{ inputs.arch }}
+ cache-directories: .cache/sccache
+ cache-targets: "true"
+
+ - name: Compute cargo version
+ id: version
+ run: |
+ set -euo pipefail
+ if [[ -n "${{ inputs['cargo-version'] }}" ]]; then
+ echo "cargo_version=${{ inputs['cargo-version'] }}" >> "$GITHUB_OUTPUT"
+ else
+ echo "cargo_version=$(uv run python tasks/scripts/release.py get-version --cargo)" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Patch workspace version
+ if: steps.version.outputs.cargo_version != ''
+ run: |
+ set -euo pipefail
+ sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${{ steps.version.outputs.cargo_version }}"'"/}' Cargo.toml
+
+ - name: Build ${{ steps.target.outputs.binary }} (${{ steps.target.outputs.target }})
+ env:
+ # Preserve the release-codegen setting used by the old Dockerfile
+ # Rust build path so image artifacts keep the same release profile.
+ CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "1"
+ run: |
+ set -euo pipefail
+ args=(
+ --release
+ --target "${{ steps.target.outputs.target }}"
+ -p "${{ steps.target.outputs.crate }}"
+ --bin "${{ steps.target.outputs.binary }}"
+ )
+ if [[ -n "$FEATURES" ]]; then
+ args+=(--features "$FEATURES")
+ fi
+ if [[ -n "${{ steps.version.outputs.cargo_version }}" ]]; then
+ export GIT_DIR=/nonexistent
+ fi
+ mise x -- cargo build "${args[@]}"
+
+ - name: Verify packaged binary
+ run: |
+ set -euo pipefail
+ BIN="target/${{ steps.target.outputs.target }}/release/${{ steps.target.outputs.binary }}"
+ OUTPUT="$("$BIN" --version)"
+ echo "$OUTPUT"
+ grep -q "^${{ steps.target.outputs.binary }} " <<<"$OUTPUT"
+ # Record glibc linkage so drift from the Ubuntu noble runtime base
+ # image is visible in logs.
+ ldd --version
+ ldd "$BIN" || true
+
+ - name: Stage binary for prebuilt layout
+ run: |
+ set -euo pipefail
+ STAGE="prebuilt-binaries/$ARCH"
+ mkdir -p "$STAGE"
+ install -m 0755 \
+ "target/${{ steps.target.outputs.target }}/release/${{ steps.target.outputs.binary }}" \
+ "$STAGE/${{ steps.target.outputs.binary }}"
+ ls -lh "$STAGE/"
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ inputs['artifact-name'] != '' && inputs['artifact-name'] || format('rust-binary-{0}-linux-{1}', inputs.component, inputs.arch) }}
+ path: prebuilt-binaries/${{ inputs.arch }}/${{ steps.target.outputs.binary }}
+ retention-days: ${{ inputs['retention-days'] }}
+ if-no-files-found: error
+
+ - name: sccache stats
+ if: always()
+ run: |
+ set +e
+ stats_bin="${SCCACHE_PATH:-sccache}"
+ "$stats_bin" --show-stats
+ status=$?
+ if [[ $status -ne 0 ]]; then
+ echo "::warning::sccache stats unavailable (exit $status)"
+ fi
+ exit 0
diff --git a/.github/workflows/shadow-shared-cpu-spike.yml b/.github/workflows/shadow-shared-cpu-spike.yml
new file mode 100644
index 0000000000..ca7b2ef09b
--- /dev/null
+++ b/.github/workflows/shadow-shared-cpu-spike.yml
@@ -0,0 +1,73 @@
+name: Shadow — Shared CPU Spike
+
+# OS-49 Phase 2 / PR 1 — non-blocking spike. Runs `cargo check` on the
+# nv-gha-runners shared CPU pool (`linux-{amd64,arm64}-cpu8`) with a
+# GHA-backed sccache.
+#
+# Plan, decision thresholds, and results live in the Linear doc attached
+# to OS-126 ("OS-126 — Shared CPU spike plan & results"). Dispatch this
+# workflow 4–5 times after merge and record numbers there.
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ packages: read
+
+env:
+ CARGO_TERM_COLOR: always
+ CARGO_INCREMENTAL: "0"
+ MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # Wire sccache (already RUSTC_WRAPPER in mise.toml) to the GHA cache
+ # backend instead of the EKS memcached used by ARC.
+ SCCACHE_GHA_ENABLED: "true"
+
+jobs:
+ rust-check:
+ name: cargo check (${{ matrix.runner }})
+ strategy:
+ fail-fast: false
+ matrix:
+ runner: [linux-amd64-cpu8, linux-arm64-cpu8]
+ runs-on: ${{ matrix.runner }}
+ env:
+ # Partition the GHA sccache cache per-arch. Without this, matrix jobs
+ # collide on the same cache key, and the later-starting job's writes
+ # fail with 409 Conflict while the earlier one's writes land — leaving
+ # subsequent runs with asymmetric and mostly-empty caches.
+ # (Run 1 on 2026-04-24 showed amd64 with 0/1062 writes succeeding
+ # while arm64 got 575/1064.)
+ SCCACHE_GHA_VERSION: ${{ matrix.runner }}
+ container:
+ image: ghcr.io/nvidia/openshell/ci:latest
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ timeout-minutes: 60
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Install tools
+ run: mise install
+
+ - name: Configure GHA sccache backend
+ # Exposes ACTIONS_CACHE_URL / ACTIONS_RUNTIME_TOKEN to subsequent steps
+ # so sccache (wrapped around rustc via RUSTC_WRAPPER in mise.toml) can
+ # initialize the GHA cache. Without this, sccache fails at startup with
+ # "cache url for ghac not found". The action also installs its own
+ # sccache binary; harmless since mise's sccache remains on PATH.
+ uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
+
+ - name: Cache Rust target and registry
+ uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
+ with:
+ shared-key: shadow-shared-cpu-spike-${{ matrix.runner }}
+ cache-directories: .cache/sccache
+
+ - name: cargo check
+ run: mise x -- cargo check --workspace --all-targets
+
+ - name: sccache stats
+ if: always()
+ run: mise x -- sccache --show-stats
diff --git a/.github/workflows/test-gpu.yml b/.github/workflows/test-gpu.yml
index df953b5d31..4721c97508 100644
--- a/.github/workflows/test-gpu.yml
+++ b/.github/workflows/test-gpu.yml
@@ -7,57 +7,30 @@ on:
workflow_dispatch: {}
# Add `schedule:` here when we want nightly coverage from the same workflow.
-permissions:
- contents: read
- pull-requests: read
- packages: write
+permissions: {}
jobs:
pr_metadata:
name: Resolve PR metadata
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
outputs:
should_run: ${{ steps.gate.outputs.should_run }}
steps:
- - id: get_pr_info
- if: github.event_name == 'push'
- continue-on-error: true
- uses: nv-gha-runners/get-pr-info@main
-
+ - uses: actions/checkout@v6
- id: gate
- shell: bash
- env:
- EVENT_NAME: ${{ github.event_name }}
- GITHUB_SHA_VALUE: ${{ github.sha }}
- GET_PR_INFO_OUTCOME: ${{ steps.get_pr_info.outcome }}
- PR_INFO: ${{ steps.get_pr_info.outputs.pr-info }}
- run: |
- if [ "$EVENT_NAME" != "push" ]; then
- echo "should_run=true" >> "$GITHUB_OUTPUT"
- exit 0
- fi
-
- if [ "$GET_PR_INFO_OUTCOME" != "success" ]; then
- echo "should_run=false" >> "$GITHUB_OUTPUT"
- exit 0
- fi
-
- head_sha="$(jq -r '.head.sha' <<< "$PR_INFO")"
- has_gpu_label="$(jq -r '[.labels[].name] | index("test:e2e-gpu") != null' <<< "$PR_INFO")"
-
- # Only trust copied pull-request/* pushes that still match the PR head SHA
- # and are explicitly labeled for GPU coverage.
- if [ "$head_sha" = "$GITHUB_SHA_VALUE" ] && [ "$has_gpu_label" = "true" ]; then
- should_run=true
- else
- should_run=false
- fi
-
- echo "should_run=$should_run" >> "$GITHUB_OUTPUT"
+ uses: ./.github/actions/pr-gate
+ with:
+ required_label: test:e2e-gpu
build-gateway:
needs: [pr_metadata]
if: needs.pr_metadata.outputs.should_run == 'true'
+ permissions:
+ contents: read
+ packages: write
uses: ./.github/workflows/docker-build.yml
with:
component: gateway
@@ -65,6 +38,9 @@ jobs:
build-cluster:
needs: [pr_metadata]
if: needs.pr_metadata.outputs.should_run == 'true'
+ permissions:
+ contents: read
+ packages: write
uses: ./.github/workflows/docker-build.yml
with:
component: cluster
@@ -72,6 +48,9 @@ jobs:
e2e-gpu:
needs: [pr_metadata, build-gateway, build-cluster]
if: needs.pr_metadata.outputs.should_run == 'true'
+ permissions:
+ contents: read
+ packages: read
uses: ./.github/workflows/e2e-gpu-test.yaml
with:
image-tag: ${{ github.sha }}
diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml
index 416adef666..06b1e007fb 100644
--- a/.github/workflows/test-install.yml
+++ b/.github/workflows/test-install.yml
@@ -41,7 +41,7 @@ jobs:
install: fish
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Install ${{ matrix.shell }}
if: matrix.install
diff --git a/.gitignore b/.gitignore
index d6b4fa3564..1b37bfd498 100644
--- a/.gitignore
+++ b/.gitignore
@@ -190,6 +190,9 @@ deploy/docker/.build/
# SBOM generated output (JSON, CSV) — release artifacts, not committed
deploy/sbom/output/
+# Debian package build output (default OPENSHELL_OUTPUT_DIR for tasks/scripts/package-deb.sh)
+artifacts/
+
# Local mise settings
mise.local.toml
@@ -197,8 +200,17 @@ mise.local.toml
architecture/plans
# Claude
-.claude/settings.local.json.claude/worktrees/
+.claude/settings.local.json
.claude/worktrees/
rfc.md
.worktrees
.z3-trace
+
+# RPM build artifacts
+*.src.rpm
+*.tar.gz
+*.tar.xz
+*.tar.bz2
+
+# Markdown/mermaid lint tooling deps
+scripts/lint-mermaid/node_modules/
diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc
new file mode 100644
index 0000000000..4c7f68e5a0
--- /dev/null
+++ b/.markdownlint-cli2.jsonc
@@ -0,0 +1,37 @@
+{
+ "globs": [
+ "**/*.md",
+ "**/*.mdx"
+ ],
+ "gitignore": true,
+ "ignores": [
+ ".agents/**",
+ ".claude/**",
+ ".opencode/**",
+ ".github/**",
+ "THIRD-PARTY-NOTICES/**",
+ "CLAUDE.md",
+ // Man page sources use pandoc markdown with multiple H1 sections
+ // (NAME, SYNOPSIS, DESCRIPTION, etc.) which is standard for man
+ // pages but violates MD025.
+ "deploy/man/**"
+ ],
+ "config": {
+ "default": true,
+ // Allow long lines — prose paragraphs are single-line per project style.
+ "MD013": false,
+ // Allow GitHub-rendered HTML commonly used in READMEs (centered logos,
+ // collapsible sections, keyboard hints). Regular prose HTML still flagged.
+ "MD033": { "allowed_elements": ["p", "img", "br", "a", "div", "details", "summary", "kbd", "sub", "sup"] },
+ // Allow duplicate headings in different sections.
+ "MD024": { "siblings_only": true },
+ // Bare URLs are fine in changelogs and tables.
+ "MD034": false,
+ // Internal docs commonly use bare fences for diagrams and terminal sketches.
+ "MD040": false,
+ // First line does not need to be a heading.
+ "MD002": false,
+ // Repo uses padded table pipes (`| foo | bar |`); rule default is "compact".
+ "MD060": { "style": "padded" }
+ }
+}
diff --git a/.packit.yaml b/.packit.yaml
new file mode 100644
index 0000000000..2598ae6381
--- /dev/null
+++ b/.packit.yaml
@@ -0,0 +1,80 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+# Packit configuration for OpenShell RPM builds via Fedora COPR.
+# See https://packit.dev/docs/configuration for full reference.
+
+upstream_tag_template: "v{version}"
+upstream_package_name: openshell
+downstream_package_name: openshell
+specfile_path: openshell.spec
+
+# Packages needed in the SRPM build environment to create vendor tarball
+srpm_build_deps:
+ - rust
+ - cargo
+ - git-core
+
+actions:
+ get-current-version:
+ # Derive version from the latest SemVer upstream tag on the current branch.
+ # Avoid operational tags such as vm-dev; Packit normalizes that to m.dev,
+ # which is not a valid Cargo package version.
+ - 'bash -c "git describe --tags --match ''v[0-9]*.[0-9]*.[0-9]*'' --abbrev=0 HEAD | sed ''s/^v//''"'
+
+ create-archive:
+ # Step 1: Create source tarball from git working tree.
+ # Uses git ls-files + tar instead of git archive so the tarball
+ # reflects any patching that Packit may have done (e.g. version bumps).
+ - 'bash -c "VERSION=${PACKIT_PROJECT_VERSION} && TMPDIR=$(mktemp -d) && DIR=openshell-${VERSION} && mkdir -p ${TMPDIR}/${DIR} && git ls-files -z | xargs -0 tar cf - | tar xf - -C ${TMPDIR}/${DIR}/ && tar -czf openshell-${VERSION}.tar.gz -C ${TMPDIR} ${DIR} && rm -rf ${TMPDIR}"'
+ # Step 2: Create vendored Cargo dependencies tarball for offline RPM build.
+ - 'bash -c "VERSION=${PACKIT_PROJECT_VERSION} && CARGO_HTTP_TIMEOUT=600 CARGO_NET_RETRY=5 cargo vendor --locked --quiet && tar -cJf openshell-${VERSION}-vendor.tar.xz vendor/ && rm -rf vendor/"'
+ # Step 3: Return the primary archive name. Packit expects create-archive
+ # to print one path for Source0; Source1 is patched explicitly below.
+ - 'bash -c "echo openshell-${PACKIT_PROJECT_VERSION}.tar.gz"'
+
+ fix-spec-file:
+ # Update Source0 to the generated tarball name
+ - 'bash -c "sed -i \"s|^Source0:.*|Source0: openshell-${PACKIT_PROJECT_VERSION}.tar.gz|\" openshell.spec"'
+ # Update Source1 to the generated vendor tarball name
+ - 'bash -c "sed -i \"s|^Source1:.*|Source1: openshell-${PACKIT_PROJECT_VERSION}-vendor.tar.xz|\" openshell.spec"'
+ # Update Version
+ - 'bash -c "sed -i -r \"s/^Version:(\\s*)\\S+/Version:\\1${PACKIT_RPMSPEC_VERSION}/\" openshell.spec"'
+ # Update Release
+ - 'bash -c "sed -i -r \"s/^Release:(\\s*)\\S+/Release:\\1${PACKIT_RPMSPEC_RELEASE}%{?dist}/\" openshell.spec"'
+
+jobs:
+ # Build on every pull request targeting main for CI validation
+ - job: copr_build
+ trigger: pull_request
+ branch: main
+ identifier: main-pr
+ targets:
+ - fedora-all
+ - epel-10
+
+ # Build into maxamillion/openshell on every commit to main
+ # for continuous development and testing builds.
+ - job: copr_build
+ trigger: commit
+ branch: main
+ owner: "maxamillion"
+ project: "openshell"
+ identifier: main-commit
+ targets:
+ - fedora-all
+ - epel-10
+ preserve_project: true
+ list_on_homepage: true
+
+ # Build on GitHub releases for publishable RPMs.
+ # See: https://packit.dev/docs/configuration/upstream/copr_build#using-a-custom-copr-project
+ - job: copr_build
+ trigger: release
+ owner: "maxamillion"
+ project: "openshell"
+ targets:
+ - fedora-all
+ - epel-10
+ preserve_project: true
+ list_on_homepage: true
diff --git a/AGENTS.md b/AGENTS.md
index a6cbd29ce8..93062fd5fb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -34,12 +34,15 @@ These pipelines connect skills into end-to-end workflows. Individual skill files
| `crates/openshell-sandbox/` | Sandbox runtime | Container supervision, policy-enforced egress routing |
| `crates/openshell-policy/` | Policy engine | Filesystem, network, process, and inference constraints |
| `crates/openshell-router/` | Privacy router | Privacy-aware LLM routing |
-| `crates/openshell-bootstrap/` | Cluster bootstrap | K3s cluster setup, image loading, mTLS PKI |
+| `crates/openshell-bootstrap/` | Gateway metadata | Gateway registration metadata, mTLS bundle storage, legacy bootstrap helpers |
| `crates/openshell-ocsf/` | OCSF logging | OCSF v1.7.0 event types, builders, shorthand/JSONL formatters, tracing layers |
| `crates/openshell-core/` | Shared core | Common types, configuration, error handling |
| `crates/openshell-providers/` | Provider management | Credential provider backends |
| `crates/openshell-tui/` | Terminal UI | Ratatui-based dashboard for monitoring |
| `crates/openshell-vm/` | MicroVM runtime | Experimental, work-in-progress libkrun-based VM execution |
+| `crates/openshell-driver-kubernetes/` | Kubernetes compute driver | In-process `ComputeDriver` backend for K8s sandbox pods |
+| `crates/openshell-driver-docker/` | Docker compute driver | In-process `ComputeDriver` backend for local Docker sandbox containers |
+| `crates/openshell-driver-vm/` | VM compute driver | Standalone libkrun-backed `ComputeDriver` subprocess (embeds its own rootfs + runtime) |
| `python/openshell/` | Python SDK | Python bindings and CLI packaging |
| `proto/` | Protobuf definitions | gRPC service contracts |
| `deploy/` | Docker, Helm, K8s | Dockerfiles, Helm chart, manifests |
@@ -151,7 +154,7 @@ ocsf_emit!(event);
## Sandbox Infra Changes
-- If you change sandbox infrastructure, ensure `mise run sandbox` succeeds.
+- If you change sandbox infrastructure, ensure the relevant sandbox e2e path succeeds.
## Commits
@@ -169,7 +172,7 @@ ocsf_emit!(event);
- `mise run pre-commit` — Lint, format, license headers. Run before every commit.
- `mise run test` — Unit test suite. Run after code changes.
-- `mise run e2e` — End-to-end tests against a running cluster. Run for infrastructure, sandbox, or policy changes.
+- `mise run e2e` — End-to-end tests against a running gateway. Run for infrastructure, sandbox, or policy changes.
- `mise run ci` — Full local CI (lint + compile/type checks + tests). Run before opening a PR.
## Python
@@ -182,7 +185,7 @@ ocsf_emit!(event);
## Cluster Infrastructure Changes
-- If you change cluster bootstrap infrastructure (e.g., `openshell-bootstrap` crate, `deploy/docker/Dockerfile.images`, `cluster-entrypoint.sh`, `cluster-healthcheck.sh`, deploy logic in `openshell-cli`), update the `debug-openshell-cluster` skill in `.agents/skills/debug-openshell-cluster/SKILL.md` to reflect those changes.
+- If you change gateway deployment infrastructure (e.g., Helm values/templates, gateway image packaging, or deploy logic in `openshell-cli`), update the `debug-openshell-cluster` skill in `.agents/skills/debug-openshell-cluster/SKILL.md` to reflect those changes.
## Documentation
diff --git a/CI.md b/CI.md
new file mode 100644
index 0000000000..57e6627ed9
--- /dev/null
+++ b/CI.md
@@ -0,0 +1,113 @@
+# CI
+
+This document describes how OpenShell's continuous integration works for pull requests, with a focus on what contributors need to do to get their PR tested.
+
+For local test commands see [TESTING.md](TESTING.md). For PR conventions see [CONTRIBUTING.md](CONTRIBUTING.md).
+
+## Overview
+
+PR CI that runs on NVIDIA self-hosted runners uses NVIDIA's copy-pr-bot. The bot mirrors trusted PR commits to internal `pull-request/` branches in this repository. The gated workflows trigger on pushes to those branches, not on the original PR.
+
+`Branch Checks` run automatically after copy-pr-bot mirrors the PR. E2E suites are opt-in because they are more expensive and publish temporary images.
+
+Two opt-in labels enable the suites:
+
+- `test:e2e` runs `Branch E2E Checks` (non-GPU E2E)
+- `test:e2e-gpu` runs `GPU Test`
+
+Both are required to merge once the corresponding `E2E Gate` checks are marked required in branch protection.
+
+## Commit signing
+
+copy-pr-bot decides whether to mirror a PR automatically based on whether the author is trusted. For org members and collaborators, "trusted" means **all commits in the PR are cryptographically signed**. Unsigned commits, even from an org member, force the bot to wait for a maintainer's `/ok to test `.
+
+DCO sign-off (`-s` / `Signed-off-by`) is a separate requirement and does not count as commit signing. Dependabot-authored dependency update PRs are allowlisted in DCO Assistant because the bot cannot sign commits.
+
+### One-time setup with an SSH key
+
+If you already use an SSH key for `git push`, you can reuse it as a signing key. (You can also generate a separate one - GitHub allows the same SSH key as both auth and signing.)
+
+1. Generate a key (skip if reusing your existing SSH key):
+
+ ```shell
+ ssh-keygen -t ed25519 -C "you@example.com" -f ~/.ssh/id_ed25519_signing
+ ```
+
+2. Add the **public** key at using **New SSH key**, and set **Key type: Signing Key** (not Authentication). Signing keys are managed separately from authentication keys, even when they reuse the same key material - you have to add the entry once for each role.
+
+3. Configure git globally:
+
+ ```shell
+ git config --global gpg.format ssh
+ git config --global user.signingkey ~/.ssh/id_ed25519_signing.pub
+ git config --global commit.gpgsign true
+ git config --global tag.gpgsign true
+ ```
+
+4. Verify on a test commit:
+
+ ```shell
+ git commit --allow-empty -s -m "test: signing"
+ ```
+
+ Push the branch and confirm GitHub shows the commit as **Verified**.
+
+## Pull request flows
+
+### Internal contributor PR
+
+Prerequisites:
+
+- Org member or collaborator on the repo.
+- All commits cryptographically signed (see [Commit signing](#commit-signing)).
+- All commits include a DCO sign-off (`git commit -s`).
+
+Flow:
+
+1. Open the PR. copy-pr-bot mirrors it to `pull-request/` automatically.
+2. The mirror push runs `Branch Checks` automatically. The first `Branch E2E Checks` / `GPU Test` run only resolves metadata and skips expensive jobs unless the matching label is already set.
+3. A maintainer applies `test:e2e` and/or `test:e2e-gpu`. `E2E Label Help` posts a comment with a link to the existing gated workflow run.
+4. The maintainer opens that link and clicks **Re-run all jobs**. This time `pr_metadata` sees the label and the build/E2E jobs run.
+5. When the run finishes, the `E2E Gate` check on the PR flips to green automatically.
+6. New commits push to the mirror automatically and re-trigger `Branch Checks` plus any labeled E2E/GPU workflows.
+
+### Forked PR
+
+Prerequisites:
+
+- DCO sign-off (`git commit -s`) on every commit. Commit signing is not required for forks - copy-pr-bot trusts forks based on maintainer review, not signing.
+- A maintainer must vouch you. See the [Vouch System](AGENTS.md#vouch-system).
+
+Flow:
+
+1. Open the PR. The vouch check confirms you are vouched (otherwise the PR is auto-closed).
+2. copy-pr-bot does not mirror forks automatically. A maintainer reviews the diff and comments `/ok to test ` with your latest commit SHA.
+3. After `/ok to test`, copy-pr-bot mirrors to `pull-request/`. From here the flow is identical to internal PRs: maintainer applies the label, follows the comment from `E2E Label Help`, and re-runs the workflow.
+
+Important: every new commit you push requires another `/ok to test ` from a maintainer before E2E will run on it. If a label is applied while the mirror is stale, `E2E Label Help` will post a comment explaining what's needed.
+
+## copy-pr-bot
+
+[copy-pr-bot](https://github.com/apps/copy-pr-bot) is a GitHub App maintained by NVIDIA that solves a specific GitHub Actions security problem: by default, `pull_request`-triggered workflows on a self-hosted runner can run an arbitrary contributor's code on hardware the project owns. For projects that need self-hosted runners (GPU access, ARM hardware, on-prem secrets), GitHub's recommended pattern is to never trigger workflows directly from external `pull_request` events.
+
+copy-pr-bot enforces that pattern. When a PR is opened against this repository, the bot evaluates whether the change is trusted - by default, only commits authored by org members and signed with a verified key are trusted, and forks always need an explicit per-SHA approval. Once a change passes that check, the bot mirrors the PR head into a branch named `pull-request/` inside this repository. Our self-hosted workflows then trigger on `push` to those mirror branches, never on the original `pull_request` event.
+
+The user-visible consequences inside this repo:
+
+- A PR cannot run E2E until copy-pr-bot has mirrored it. For trusted authors this happens within seconds of opening the PR; for forked PRs it requires a maintainer to comment `/ok to test `.
+- New commits to a fork need a fresh `/ok to test ` before the mirror updates.
+- The `pull-request/` branches are not for humans to push to - they are managed by the bot.
+
+The bot's full administrator documentation is internal to NVIDIA. The only command contributors may see in PR comments is `/ok to test `, used by maintainers to approve a specific commit on a forked PR for testing.
+
+## Workflow files
+
+| File | Role |
+|---|---|
+| `.github/workflows/branch-checks.yml` | Required non-E2E PR checks. Triggers on `push: pull-request/[0-9]+`. |
+| `.github/workflows/branch-e2e.yml` | Non-GPU E2E. Triggers on `push: pull-request/[0-9]+`. |
+| `.github/workflows/test-gpu.yml` | GPU E2E. Triggers on `push: pull-request/[0-9]+`. |
+| `.github/actions/pr-gate/action.yml` | Composite action that resolves PR metadata and verifies the required label is set. |
+| `.github/workflows/e2e-gate.yml` | Posts the required `E2E Gate` check on the PR. Re-evaluates after the gated workflow completes. |
+| `.github/workflows/e2e-gate-check.yml` | Reusable gate logic shared by E2E and GPU E2E. |
+| `.github/workflows/e2e-label-help.yml` | When a `test:e2e*` label is applied, posts a PR comment telling the maintainer the next manual step (re-run an existing workflow run, or `/ok to test ` to refresh the mirror). |
diff --git a/CLAUDE.md b/CLAUDE.md
index eef4bd20cf..43c994c2d3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1 +1 @@
-@AGENTS.md
\ No newline at end of file
+@AGENTS.md
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index dcb3f303fe..5c091c6c41 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -41,7 +41,7 @@ All open issues are actionable — if it's in the issue tracker, it's ready to b
This project ships with [agent skills](#agent-skills-for-contributors) that can diagnose problems, explore the codebase, generate policies, and walk you through common workflows. Before filing an issue:
1. Clone the repo and point your coding agent at it.
-2. Load the relevant skill - `debug-openshell-cluster` for cluster problems, `debug-inference` for inference setup problems, `openshell-cli` for usage questions, `generate-sandbox-policy` for policy help.
+2. Load the relevant skill - `debug-openshell-cluster` for gateway or deployment problems, `debug-inference` for inference setup problems, `openshell-cli` for usage questions, `generate-sandbox-policy` for policy help.
3. Have your agent investigate. Let it run diagnostics, read the architecture docs, and attempt a fix.
4. If the agent cannot resolve it, open an issue **with the agent's diagnostic output attached**. The issue template requires this.
@@ -49,7 +49,7 @@ This project ships with [agent skills](#agent-skills-for-contributors) that can
- A real bug that your agent confirmed and could not fix.
- A feature proposal with a design — not a "please build this" request.
-- An infrastructure problem that the `debug-openshell-cluster` skill could not resolve.
+- An infrastructure problem that the gateway deployment troubleshooting skill could not resolve.
- An inference setup problem that the `debug-inference` skill could not resolve.
- Security vulnerabilities must follow [SECURITY.md](SECURITY.md) — **not** GitHub issues.
@@ -66,7 +66,7 @@ Skills live in `.agents/skills/`. Your agent's harness can discover and load the
| Category | Skill | Purpose |
| --------------- | ------------------------- | --------------------------------------------------------------------------------------------------- |
| Getting Started | `openshell-cli` | CLI usage, sandbox lifecycle, provider management, BYOC workflows |
-| Getting Started | `debug-openshell-cluster` | Diagnose cluster startup failures and health issues |
+| Getting Started | `debug-openshell-cluster` | Diagnose gateway deployment and health issues |
| Getting Started | `debug-inference` | Diagnose `inference.local`, host-backed local inference, and direct external inference setup issues |
| Contributing | `create-spike` | Investigate a problem, produce a structured GitHub issue |
| Contributing | `build-from-issue` | Plan and implement work from a GitHub issue (maintainer workflow) |
@@ -92,6 +92,7 @@ Skills connect into pipelines. Individual skill files don't describe these relat
- **Policy iteration:** `openshell-cli` → `generate-sandbox-policy`
Workflow state labels use the `state:*` prefix, and security work uses `topic:security`. GitHub issue templates assign built-in issue types where applicable, and agent-created issues should use issue types or manual follow-up rather than type labels.
+New issues opened by users without `write`, `maintain`, or `admin` repository permission are automatically labeled `state:triage-needed` by the issue triage workflow.
## Prerequisites
@@ -107,6 +108,9 @@ After installing `mise`, activate it with `mise activate` or [add it to your she
Shell setup examples:
```bash
+# Bash
+echo 'eval "$(~/.local/bin/mise activate bash)"' >> ~/.bashrc
+
# Fish
echo '~/.local/bin/mise activate fish | source' >> ~/.config/fish/config.fish
@@ -148,8 +152,8 @@ cargo build -p openshell-prover --features bundled-z3
# One-time trust
mise trust
-# Launch a sandbox (deploys a cluster if one isn't running)
-mise run sandbox
+# Run a standalone gateway for local development
+mise run gateway
```
## Building the `openshell` CLI
@@ -166,32 +170,14 @@ openshell --help
openshell sandbox create -- codex
```
-### Cluster debugging helpers
-
-Two additional scripts in `scripts/bin/` provide gateway-aware wrappers for cluster debugging:
-
-| Script | What it does |
-| --------- | ------------------------------------------------------------------------------------ |
-| `kubectl` | Runs `kubectl` inside the active gateway's k3s container via `openshell doctor exec` |
-| `k9s` | Runs `k9s` inside the active gateway's k3s container via `openshell doctor exec` |
-
-These work for both local and remote gateways (SSH is handled automatically). Examples:
-
-```bash
-kubectl get pods -A
-kubectl logs -n openshell statefulset/openshell
-k9s
-k9s -n openshell
-```
-
## Main Tasks
These are the primary `mise` tasks for day-to-day development:
| Task | Purpose |
| ------------------ | ------------------------------------------------------- |
-| `mise run cluster` | Bootstrap or incremental deploy |
-| `mise run sandbox` | Create a sandbox on the running cluster |
+| `mise run gateway` | Run a standalone gateway for local development |
+| `mise run sandbox` | Create or reconnect to the dev sandbox |
| `mise run test` | Default test suite |
| `mise run e2e` | Default end-to-end test lane |
| `mise run ci` | Full local CI checks (lint, compile/type checks, tests) |
@@ -255,7 +241,7 @@ See [docs/CONTRIBUTING.mdx](docs/CONTRIBUTING.mdx) for the current docs authorin
This project uses [Conventional Commits](https://www.conventionalcommits.org/). All commit messages must follow the format:
-```
+```text
():
[optional body]
@@ -276,7 +262,7 @@ This project uses [Conventional Commits](https://www.conventionalcommits.org/).
**Examples:**
-```
+```text
feat(cli): add --verbose flag to openshell run
fix(sandbox): handle timeout errors gracefully
docs: update installation instructions
@@ -285,8 +271,14 @@ chore(deps): bump tokio to 1.40
### DCO
-All contributions must include a `Signed-off-by` line in each commit message. This certifies you have the right to submit the work under the project license. See the [Developer Certificate of Origin](https://developercertificate.org/).
+All human contributions must include a `Signed-off-by` line in each commit message. This certifies you have the right to submit the work under the project license. See the [Developer Certificate of Origin](https://developercertificate.org/). Dependabot-authored dependency update PRs are allowlisted because the bot cannot sign commits.
```bash
git commit -s -m "feat(sandbox): add new capability"
```
+
+DCO sign-off is separate from cryptographic commit signing. CI requires signing for org members so that copy-pr-bot can mirror your PR automatically; see [CI.md](CI.md#commit-signing) for setup.
+
+## CI
+
+How E2E runs in CI, the `test:e2e` / `test:e2e-gpu` labels, copy-pr-bot, and commit-signing setup are documented in [CI.md](CI.md).
diff --git a/Cargo.lock b/Cargo.lock
index e4057f75cc..c02158d44d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -145,6 +145,17 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+[[package]]
+name = "apollo-parser"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "947e21ff51879f8a40d7519dfe619268de2afba4042a8a43878276de3cb910f0"
+dependencies = [
+ "memchr",
+ "rowan",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "argon2"
version = "0.5.3"
@@ -232,9 +243,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
-version = "1.16.2"
+version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
+checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -243,9 +254,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
-version = "0.39.1"
+version = "0.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
+checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
dependencies = [
"cc",
"cmake",
@@ -282,9 +293,9 @@ dependencies = [
[[package]]
name = "axum"
-version = "0.8.8"
+version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core 0.5.6",
"base64 0.22.1",
@@ -309,7 +320,7 @@ dependencies = [
"sha1 0.10.6",
"sync_wrapper",
"tokio",
- "tokio-tungstenite 0.28.0",
+ "tokio-tungstenite 0.29.0",
"tower 0.5.3",
"tower-layer",
"tower-service",
@@ -363,7 +374,7 @@ checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1"
dependencies = [
"getrandom 0.2.17",
"instant",
- "rand 0.8.5",
+ "rand 0.8.6",
]
[[package]]
@@ -444,16 +455,16 @@ dependencies = [
"proc-macro2",
"quote",
"regex",
- "rustc-hash",
+ "rustc-hash 2.1.2",
"shlex",
"syn 2.0.117",
]
[[package]]
name = "bitflags"
-version = "2.11.0"
+version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
dependencies = [
"serde_core",
]
@@ -611,9 +622,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.2.59"
+version = "1.2.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
+checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -621,6 +632,12 @@ dependencies = [
"shlex",
]
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
[[package]]
name = "cexpr"
version = "0.6.0"
@@ -690,9 +707,9 @@ dependencies = [
[[package]]
name = "clap"
-version = "4.6.0"
+version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
@@ -712,9 +729,9 @@ dependencies = [
[[package]]
name = "clap_complete"
-version = "4.6.0"
+version = "4.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb"
+checksum = "3ff7a1dccbdd8b078c2bdebff47e404615151534d5043da397ec50286816f9cb"
dependencies = [
"clap",
"clap_lex",
@@ -724,9 +741,9 @@ dependencies = [
[[package]]
name = "clap_derive"
-version = "4.6.0"
+version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
@@ -761,6 +778,16 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
[[package]]
name = "compact_str"
version = "0.7.1"
@@ -808,6 +835,27 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+[[package]]
+name = "const_format"
+version = "0.2.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e"
+dependencies = [
+ "const_format_proc_macros",
+ "konst",
+]
+
+[[package]]
+name = "const_format_proc_macros"
+version = "0.2.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-xid",
+]
+
[[package]]
name = "constant_time_eq"
version = "0.4.2"
@@ -838,9 +886,15 @@ checksum = "0940496e5c83c54f3b753d5317daec82e8edac71c33aaa1f666d76f518de2444"
dependencies = [
"hax-lib",
"pastey",
- "rand 0.9.2",
+ "rand 0.9.4",
]
+[[package]]
+name = "countme"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
+
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -892,6 +946,15 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
@@ -1166,6 +1229,37 @@ dependencies = [
"syn 1.0.109",
]
+[[package]]
+name = "derive_builder"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
+dependencies = [
+ "derive_builder_macro",
+]
+
+[[package]]
+name = "derive_builder_core"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "derive_builder_macro"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
+dependencies = [
+ "derive_builder_core",
+ "syn 2.0.117",
+]
+
[[package]]
name = "dialoguer"
version = "0.11.0"
@@ -1455,6 +1549,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -1633,6 +1733,18 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "getset"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912"
+dependencies = [
+ "proc-macro-error2",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "ghash"
version = "0.5.1"
@@ -1721,7 +1833,16 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
- "foldhash",
+ "foldhash 0.1.5",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "foldhash 0.2.0",
]
[[package]]
@@ -1837,6 +1958,15 @@ dependencies = [
"itoa",
]
+[[package]]
+name = "http-auth"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "150fa4a9462ef926824cf4519c84ed652ca8f4fbae34cb8af045b5cbcaf98822"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "http-body"
version = "1.0.1"
@@ -1920,9 +2050,9 @@ dependencies = [
[[package]]
name = "hyper-rustls"
-version = "0.27.7"
+version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
@@ -1930,11 +2060,10 @@ dependencies = [
"log",
"rustls",
"rustls-native-certs",
- "rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
- "webpki-roots 1.0.6",
+ "webpki-roots 1.0.7",
]
[[package]]
@@ -2301,6 +2430,50 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+[[package]]
+name = "jni"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
+dependencies = [
+ "cesu8",
+ "cfg-if",
+ "combine",
+ "jni-sys 0.3.1",
+ "log",
+ "thiserror 1.0.69",
+ "walkdir",
+ "windows-sys 0.45.0",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "jobserver"
version = "0.1.34"
@@ -2313,9 +2486,9 @@ dependencies = [
[[package]]
name = "js-sys"
-version = "0.3.94"
+version = "0.3.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
+checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
dependencies = [
"cfg-if",
"futures-util",
@@ -2349,6 +2522,35 @@ dependencies = [
"thiserror 1.0.69",
]
+[[package]]
+name = "jsonwebtoken"
+version = "9.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
+dependencies = [
+ "base64 0.22.1",
+ "js-sys",
+ "pem",
+ "ring",
+ "serde",
+ "serde_json",
+ "simple_asn1",
+]
+
+[[package]]
+name = "jsonwebtoken"
+version = "10.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1"
+dependencies = [
+ "base64 0.22.1",
+ "getrandom 0.2.17",
+ "js-sys",
+ "serde",
+ "serde_json",
+ "signature 2.2.0",
+]
+
[[package]]
name = "k8s-openapi"
version = "0.21.1"
@@ -2362,6 +2564,21 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "konst"
+version = "0.2.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb"
+dependencies = [
+ "konst_macro_rules",
+]
+
+[[package]]
+name = "konst_macro_rules"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37"
+
[[package]]
name = "kube"
version = "0.90.0"
@@ -2496,15 +2713,15 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libbz2-rs-sys"
-version = "0.2.2"
+version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7"
+checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f"
[[package]]
name = "libc"
-version = "0.2.184"
+version = "0.2.185"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
+checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
[[package]]
name = "libcrux-intrinsics"
@@ -2528,7 +2745,7 @@ dependencies = [
"libcrux-secrets",
"libcrux-sha3",
"libcrux-traits",
- "rand 0.9.2",
+ "rand 0.9.4",
"tls_codec",
]
@@ -2569,7 +2786,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9adfd58e79d860f6b9e40e35127bfae9e5bd3ade33201d1347459011a2add034"
dependencies = [
"libcrux-secrets",
- "rand 0.9.2",
+ "rand 0.9.4",
]
[[package]]
@@ -2721,6 +2938,52 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+[[package]]
+name = "metrics"
+version = "0.24.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8"
+dependencies = [
+ "ahash",
+ "portable-atomic",
+]
+
+[[package]]
+name = "metrics-exporter-prometheus"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3589659543c04c7dc5526ec858591015b87cd8746583b51b48ef4353f99dbcda"
+dependencies = [
+ "base64 0.22.1",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "indexmap 2.14.0",
+ "ipnet",
+ "metrics",
+ "metrics-util",
+ "quanta",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "metrics-util"
+version = "0.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdfb1365fea27e6dd9dc1dbc19f570198bc86914533ad639dae939635f096be4"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+ "hashbrown 0.16.1",
+ "metrics",
+ "quanta",
+ "rand 0.9.4",
+ "rand_xoshiro",
+ "sketches-ddsketch",
+]
+
[[package]]
name = "miette"
version = "7.6.0"
@@ -2865,7 +3128,7 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
- "rand 0.8.5",
+ "rand 0.8.6",
]
[[package]]
@@ -2879,7 +3142,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
- "rand 0.8.5",
+ "rand 0.8.6",
"serde",
"smallvec",
"zeroize",
@@ -2957,6 +3220,26 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
+[[package]]
+name = "oauth2"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
+dependencies = [
+ "base64 0.22.1",
+ "chrono",
+ "getrandom 0.2.17",
+ "http",
+ "rand 0.8.6",
+ "reqwest 0.12.28",
+ "serde",
+ "serde_json",
+ "serde_path_to_error",
+ "sha2 0.10.9",
+ "thiserror 1.0.69",
+ "url",
+]
+
[[package]]
name = "object"
version = "0.37.3"
@@ -2966,6 +3249,60 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "oci-client"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b7f8deaffcd3b0e3baf93dddcab3d18b91d46dc37d38a8b170089b234de5bb3"
+dependencies = [
+ "bytes",
+ "chrono",
+ "futures-util",
+ "http",
+ "http-auth",
+ "jsonwebtoken 10.3.0",
+ "lazy_static",
+ "oci-spec",
+ "olpc-cjson",
+ "regex",
+ "reqwest 0.13.2",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "unicase",
+]
+
+[[package]]
+name = "oci-spec"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8445a2631507cec628a15fdd6154b54a3ab3f20ed4fe9d73a3b8b7a4e1ba03a"
+dependencies = [
+ "const_format",
+ "derive_builder",
+ "getset",
+ "regex",
+ "serde",
+ "serde_json",
+ "strum 0.27.2",
+ "strum_macros 0.27.2",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "olpc-cjson"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "696183c9b5fe81a7715d074fd632e8bd46f4ccc0231a3ed7fc580a80de5f7083"
+dependencies = [
+ "serde",
+ "serde_json",
+ "unicode-normalization",
+]
+
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -3009,6 +3346,7 @@ name = "openshell-cli"
version = "0.0.0"
dependencies = [
"anyhow",
+ "base64 0.22.1",
"bytes",
"clap",
"clap_complete",
@@ -3022,6 +3360,7 @@ dependencies = [
"indicatif",
"miette",
"nix",
+ "oauth2",
"openshell-bootstrap",
"openshell-core",
"openshell-policy",
@@ -3031,7 +3370,7 @@ dependencies = [
"owo-colors",
"prost-types",
"rcgen",
- "reqwest",
+ "reqwest 0.12.28",
"rustls",
"rustls-pemfile",
"serde",
@@ -3045,6 +3384,7 @@ dependencies = [
"tokio-stream",
"tokio-tungstenite 0.26.2",
"tonic",
+ "tower 0.5.3",
"tracing",
"tracing-subscriber",
"url",
@@ -3068,6 +3408,23 @@ dependencies = [
"url",
]
+[[package]]
+name = "openshell-driver-docker"
+version = "0.0.0"
+dependencies = [
+ "bollard",
+ "bytes",
+ "futures",
+ "openshell-core",
+ "tar",
+ "tempfile",
+ "tokio",
+ "tokio-stream",
+ "tonic",
+ "tracing",
+ "url",
+]
+
[[package]]
name = "openshell-driver-kubernetes"
version = "0.0.0"
@@ -3090,6 +3447,59 @@ dependencies = [
"tracing-subscriber",
]
+[[package]]
+name = "openshell-driver-podman"
+version = "0.0.0"
+dependencies = [
+ "clap",
+ "futures",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "miette",
+ "nix",
+ "openshell-core",
+ "serde",
+ "serde_json",
+ "temp-env",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-stream",
+ "tonic",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "openshell-driver-vm"
+version = "0.0.0"
+dependencies = [
+ "bollard",
+ "clap",
+ "flate2",
+ "futures",
+ "libc",
+ "libloading",
+ "miette",
+ "nix",
+ "oci-client",
+ "openshell-core",
+ "openshell-vfio",
+ "polling",
+ "prost-types",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "tar",
+ "tokio",
+ "tokio-stream",
+ "tonic",
+ "tracing",
+ "tracing-subscriber",
+ "url",
+ "zstd",
+]
+
[[package]]
name = "openshell-ocsf"
version = "0.0.0"
@@ -3130,6 +3540,8 @@ name = "openshell-providers"
version = "0.0.0"
dependencies = [
"openshell-core",
+ "serde",
+ "serde_yml",
"thiserror 2.0.18",
]
@@ -3139,7 +3551,7 @@ version = "0.0.0"
dependencies = [
"bytes",
"openshell-core",
- "reqwest",
+ "reqwest 0.12.28",
"serde",
"serde_json",
"serde_yml",
@@ -3156,10 +3568,12 @@ name = "openshell-sandbox"
version = "0.0.0"
dependencies = [
"anyhow",
+ "apollo-parser",
"base64 0.22.1",
"bytes",
"clap",
"futures",
+ "glob",
"hex",
"hmac",
"ipnet",
@@ -3178,6 +3592,7 @@ dependencies = [
"rustls",
"rustls-pemfile",
"seccompiler",
+ "serde",
"serde_json",
"serde_yml",
"sha2 0.10.9",
@@ -3193,7 +3608,7 @@ dependencies = [
"tracing-appender",
"tracing-subscriber",
"uuid",
- "webpki-roots 1.0.6",
+ "webpki-roots 1.0.7",
]
[[package]]
@@ -3201,7 +3616,7 @@ name = "openshell-server"
version = "0.0.0"
dependencies = [
"anyhow",
- "axum 0.8.8",
+ "axum 0.8.9",
"bytes",
"clap",
"futures",
@@ -3215,18 +3630,25 @@ dependencies = [
"hyper-rustls",
"hyper-util",
"ipnet",
+ "jsonwebtoken 9.3.1",
+ "metrics",
+ "metrics-exporter-prometheus",
"miette",
"openshell-core",
+ "openshell-driver-docker",
"openshell-driver-kubernetes",
+ "openshell-driver-podman",
+ "openshell-ocsf",
"openshell-policy",
+ "openshell-providers",
"openshell-router",
"petname",
"pin-project-lite",
"prost",
"prost-types",
- "rand 0.9.2",
+ "rand 0.9.4",
"rcgen",
- "reqwest",
+ "reqwest 0.12.28",
"russh",
"rustls",
"rustls-pemfile",
@@ -3264,37 +3686,21 @@ dependencies = [
"ratatui",
"serde",
"terminal-colorsaurus",
- "tokio",
- "tonic",
- "tracing",
- "url",
-]
-
-[[package]]
-name = "openshell-vm"
-version = "0.0.0"
-dependencies = [
- "base64 0.22.1",
- "clap",
- "indicatif",
- "libc",
- "libloading",
- "miette",
- "nix",
- "openshell-bootstrap",
- "openshell-core",
- "rustls",
- "rustls-pemfile",
+ "tokio",
+ "tonic",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "openshell-vfio"
+version = "0.0.0"
+dependencies = [
"serde",
"serde_json",
- "tar",
+ "tempfile",
"thiserror 2.0.18",
- "tokio",
- "tokio-rustls",
- "tonic",
"tracing",
- "tracing-subscriber",
- "zstd",
]
[[package]]
@@ -3381,7 +3787,7 @@ dependencies = [
"delegate",
"futures",
"log",
- "rand 0.8.5",
+ "rand 0.8.6",
"sha2 0.10.9",
"thiserror 1.0.69",
"tokio",
@@ -3549,7 +3955,7 @@ dependencies = [
"itertools 0.14.0",
"proc-macro2",
"quote",
- "rand 0.8.5",
+ "rand 0.8.6",
]
[[package]]
@@ -3638,9 +4044,9 @@ dependencies = [
[[package]]
name = "pkg-config"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plain"
@@ -3648,6 +4054,20 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix 1.1.4",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "poly1305"
version = "0.8.0"
@@ -3818,6 +4238,21 @@ dependencies = [
"autotools",
]
+[[package]]
+name = "quanta"
+version = "0.12.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
+dependencies = [
+ "crossbeam-utils",
+ "libc",
+ "once_cell",
+ "raw-cpuid",
+ "wasi",
+ "web-sys",
+ "winapi",
+]
+
[[package]]
name = "quinn"
version = "0.11.9"
@@ -3829,7 +4264,7 @@ dependencies = [
"pin-project-lite",
"quinn-proto",
"quinn-udp",
- "rustc-hash",
+ "rustc-hash 2.1.2",
"rustls",
"socket2 0.6.3",
"thiserror 2.0.18",
@@ -3844,12 +4279,13 @@ version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
+ "aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"lru-slab",
- "rand 0.9.2",
+ "rand 0.9.4",
"ring",
- "rustc-hash",
+ "rustc-hash 2.1.2",
"rustls",
"rustls-pki-types",
"slab",
@@ -3896,9 +4332,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
-version = "0.8.5"
+version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
@@ -3907,9 +4343,9 @@ dependencies = [
[[package]]
name = "rand"
-version = "0.9.2"
+version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
@@ -3959,6 +4395,15 @@ version = "0.10.0-rc-3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f66ee92bc15280519ef199a274fe0cafff4245d31bc39aaa31c011ad56cb1f05"
+[[package]]
+name = "rand_xoshiro"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
+dependencies = [
+ "rand_core 0.9.5",
+]
+
[[package]]
name = "ratatui"
version = "0.26.3"
@@ -3973,12 +4418,21 @@ dependencies = [
"lru",
"paste",
"stability",
- "strum",
+ "strum 0.26.3",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.1.14",
]
+[[package]]
+name = "raw-cpuid"
+version = "11.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
+dependencies = [
+ "bitflags",
+]
+
[[package]]
name = "rcgen"
version = "0.13.2"
@@ -4051,7 +4505,7 @@ dependencies = [
"msvc_spectre_libs",
"num-bigint",
"num-traits",
- "rand 0.9.2",
+ "rand 0.9.4",
"serde",
"serde_json",
"serde_yaml",
@@ -4096,7 +4550,48 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
- "webpki-roots 1.0.6",
+ "webpki-roots 1.0.7",
+]
+
+[[package]]
+name = "reqwest"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "rustls-platform-verifier",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tokio-util",
+ "tower 0.5.3",
+ "tower-http 0.6.8",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
]
[[package]]
@@ -4123,6 +4618,18 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "rowan"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21"
+dependencies = [
+ "countme",
+ "hashbrown 0.14.5",
+ "rustc-hash 1.1.0",
+ "text-size",
+]
+
[[package]]
name = "rsa"
version = "0.9.10"
@@ -4206,7 +4713,7 @@ dependencies = [
"pkcs1 0.8.0-rc.4",
"pkcs5",
"pkcs8 0.10.2",
- "rand 0.9.2",
+ "rand 0.9.4",
"rand_core 0.10.0-rc-3",
"rsa 0.10.0-rc.12",
"russh-cryptovec",
@@ -4255,6 +4762,12 @@ version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
+[[package]]
+name = "rustc-hash"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -4298,10 +4811,11 @@ dependencies = [
[[package]]
name = "rustls"
-version = "0.23.37"
+version = "0.23.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
+checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
dependencies = [
+ "aws-lc-rs",
"log",
"once_cell",
"ring",
@@ -4342,12 +4856,40 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "rustls-platform-verifier"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
+dependencies = [
+ "core-foundation",
+ "core-foundation-sys",
+ "jni",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-platform-verifier-android",
+ "rustls-webpki",
+ "security-framework",
+ "security-framework-sys",
+ "webpki-root-certs",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls-platform-verifier-android"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
+
[[package]]
name = "rustls-webpki"
-version = "0.103.10"
+version = "0.103.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
+checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
dependencies = [
+ "aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted 0.9.0",
@@ -4374,6 +4916,15 @@ dependencies = [
"cipher",
]
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
[[package]]
name = "schannel"
version = "0.1.29"
@@ -4751,6 +5302,24 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+[[package]]
+name = "simple_asn1"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
+dependencies = [
+ "num-bigint",
+ "num-traits",
+ "thiserror 2.0.18",
+ "time",
+]
+
+[[package]]
+name = "sketches-ddsketch"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
+
[[package]]
name = "slab"
version = "0.4.12"
@@ -4931,7 +5500,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
- "rand 0.8.5",
+ "rand 0.8.6",
"rsa 0.9.10",
"serde",
"sha1 0.10.6",
@@ -4969,7 +5538,7 @@ dependencies = [
"md-5",
"memchr",
"once_cell",
- "rand 0.8.5",
+ "rand 0.8.6",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -5079,9 +5648,15 @@ version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
- "strum_macros",
+ "strum_macros 0.26.4",
]
+[[package]]
+name = "strum"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
+
[[package]]
name = "strum_macros"
version = "0.26.4"
@@ -5095,6 +5670,18 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "strum_macros"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "subtle"
version = "2.6.1"
@@ -5122,6 +5709,12 @@ version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2"
+[[package]]
+name = "symlink"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
+
[[package]]
name = "syn"
version = "1.0.109"
@@ -5233,6 +5826,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "text-size"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233"
+
[[package]]
name = "textwrap"
version = "0.16.2"
@@ -5372,9 +5971,9 @@ dependencies = [
[[package]]
name = "tokio"
-version = "1.51.1"
+version = "1.52.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
+checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
dependencies = [
"bytes",
"libc",
@@ -5437,14 +6036,14 @@ dependencies = [
[[package]]
name = "tokio-tungstenite"
-version = "0.28.0"
+version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
+checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"tokio",
- "tungstenite 0.28.0",
+ "tungstenite 0.29.0",
]
[[package]]
@@ -5519,7 +6118,7 @@ dependencies = [
"indexmap 1.9.3",
"pin-project",
"pin-project-lite",
- "rand 0.8.5",
+ "rand 0.8.6",
"slab",
"tokio",
"tokio-util",
@@ -5580,6 +6179,7 @@ dependencies = [
"tower-layer",
"tower-service",
"tracing",
+ "uuid",
]
[[package]]
@@ -5608,11 +6208,12 @@ dependencies = [
[[package]]
name = "tracing-appender"
-version = "0.2.4"
+version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf"
+checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
+ "symlink",
"thiserror 2.0.18",
"time",
"tracing-subscriber",
@@ -5698,7 +6299,7 @@ dependencies = [
"http",
"httparse",
"log",
- "rand 0.9.2",
+ "rand 0.9.4",
"rustls",
"rustls-pki-types",
"sha1 0.10.6",
@@ -5708,19 +6309,18 @@ dependencies = [
[[package]]
name = "tungstenite"
-version = "0.28.0"
+version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
+checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
- "rand 0.9.2",
+ "rand 0.9.4",
"sha1 0.10.6",
"thiserror 2.0.18",
- "utf-8",
]
[[package]]
@@ -5741,6 +6341,12 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -5847,6 +6453,7 @@ dependencies = [
"idna",
"percent-encoding",
"serde",
+ "serde_derive",
]
[[package]]
@@ -5869,9 +6476,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
-version = "1.23.0"
+version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
+checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -5896,6 +6503,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
[[package]]
name = "want"
version = "0.3.1"
@@ -5913,11 +6530,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
-version = "1.0.2+wasi-0.2.9"
+version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
- "wit-bindgen",
+ "wit-bindgen 0.57.1",
]
[[package]]
@@ -5926,7 +6543,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
- "wit-bindgen",
+ "wit-bindgen 0.51.0",
]
[[package]]
@@ -5937,9 +6554,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
-version = "0.2.117"
+version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
+checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
dependencies = [
"cfg-if",
"once_cell",
@@ -5950,9 +6567,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
-version = "0.4.67"
+version = "0.4.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e"
+checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -5960,9 +6577,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.117"
+version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
+checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -5970,9 +6587,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.117"
+version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
+checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -5983,9 +6600,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.117"
+version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
+checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
dependencies = [
"unicode-ident",
]
@@ -6012,6 +6629,19 @@ dependencies = [
"wasmparser",
]
+[[package]]
+name = "wasm-streams"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "wasmparser"
version = "0.244.0"
@@ -6026,9 +6656,9 @@ dependencies = [
[[package]]
name = "web-sys"
-version = "0.3.94"
+version = "0.3.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
+checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -6044,20 +6674,29 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "webpki-root-certs"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
- "webpki-roots 1.0.6",
+ "webpki-roots 1.0.7",
]
[[package]]
name = "webpki-roots"
-version = "1.0.6"
+version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
+checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [
"rustls-pki-types",
]
@@ -6088,6 +6727,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
@@ -6195,6 +6843,15 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets 0.42.2",
+]
+
[[package]]
name = "windows-sys"
version = "0.48.0"
@@ -6240,6 +6897,21 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-targets"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm 0.42.2",
+ "windows_aarch64_msvc 0.42.2",
+ "windows_i686_gnu 0.42.2",
+ "windows_i686_msvc 0.42.2",
+ "windows_x86_64_gnu 0.42.2",
+ "windows_x86_64_gnullvm 0.42.2",
+ "windows_x86_64_msvc 0.42.2",
+]
+
[[package]]
name = "windows-targets"
version = "0.48.5"
@@ -6297,6 +6969,12 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@@ -6315,6 +6993,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
@@ -6333,6 +7017,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+[[package]]
+name = "windows_i686_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
@@ -6363,6 +7053,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+[[package]]
+name = "windows_i686_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
@@ -6381,6 +7077,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
@@ -6399,6 +7101,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
@@ -6417,6 +7125,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
@@ -6467,6 +7181,12 @@ dependencies = [
"wit-bindgen-rust-macro",
]
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
@@ -6620,7 +7340,7 @@ dependencies = [
"bindgen",
"cmake",
"pkg-config",
- "reqwest",
+ "reqwest 0.12.28",
"serde_json",
"zip",
]
diff --git a/Cargo.toml b/Cargo.toml
index b51aee3c25..c9bfe6c91c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,6 +4,7 @@
[workspace]
resolver = "2"
members = ["crates/*"]
+exclude = ["crates/openshell-vm"]
[workspace.package]
version = "0.0.0"
@@ -25,7 +26,7 @@ prost-types = "0.13"
# HTTP server
axum = { version = "0.8", features = ["ws"] }
tower = "0.5"
-tower-http = { version = "0.6", features = ["cors", "trace"] }
+tower-http = { version = "0.6", features = ["cors", "trace", "request-id"] }
hyper = { version = "1.6", features = ["full"] }
hyper-util = { version = "0.1", features = ["tokio", "server-auto"] }
http = "1.2"
@@ -58,6 +59,10 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-appender = "0.2"
+# Metrics
+metrics = "0.24"
+metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] }
+
# Unix/Process
nix = { version = "0.29", features = ["signal", "process", "user", "fs", "term"] }
@@ -65,6 +70,7 @@ nix = { version = "0.29", features = ["signal", "process", "user", "fs", "term"]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yml = "0.0.12"
+apollo-parser = "0.8.5"
# HTTP client
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
@@ -75,6 +81,12 @@ tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots"] }
# Clipboard (OSC 52)
base64 = "0.22"
+# Crypto / Auth
+sha2 = "0.10"
+rand = "0.9"
+jsonwebtoken = "9"
+getrandom = "0.3"
+
# Filesystem embedding
include_dir = "0.7"
diff --git a/README.md b/README.md
index aaa851452f..b773853281 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
OpenShell is the safe, private runtime for autonomous AI agents. It provides sandboxed execution environments that protect your data, credentials, and infrastructure — governed by declarative YAML policies that prevent unauthorized file access, data exfiltration, and uncontrolled network activity.
-OpenShell is built agent-first. The project ships with agent skills for everything from cluster debugging to policy generation, and we expect contributors to use them.
+OpenShell is built agent-first. The project ships with agent skills for everything from gateway troubleshooting to policy generation, and we expect contributors to use them.
> **Alpha software — single-player mode.** OpenShell is proof-of-life: one developer, one environment, one gateway. We are building toward multi-tenant enterprise deployments, but the starting point is getting your own environment up and running. Expect rough edges. Bring your agent.
@@ -16,7 +16,8 @@ OpenShell is built agent-first. The project ships with agent skills for everythi
### Prerequisites
-- **Docker** — Docker Desktop (or a Docker daemon) must be running.
+- **A supported host** — macOS, Windows with WSL 2, or Linux.
+- **A local runtime** — Docker, Podman, or host virtualization enabled for MicroVM-backed sandboxes.
### Install
@@ -40,8 +41,6 @@ Both methods install the latest stable release by default. To install a specific
openshell sandbox create -- claude # or opencode, codex, copilot
```
-A gateway is created automatically on first use. To deploy on a remote host instead, pass `--remote user@host` to the create command.
-
The sandbox container includes the following tools by default:
| Category | Tools |
@@ -99,7 +98,7 @@ OpenShell isolates each sandbox in its own container with policy-enforced egress
| **Policy Engine** | Enforces filesystem, network, and process constraints from application layer down to kernel. |
| **Privacy Router** | Privacy-aware LLM routing that keeps sensitive context on sandbox compute. |
-Under the hood, all these components run as a [K3s](https://k3s.io/) Kubernetes cluster inside a single Docker container — no separate K8s install required. The `openshell gateway` commands take care of provisioning the container and cluster.
+OpenShell runs a gateway control plane that manages sandbox lifecycle through a configured compute driver. Supported compute platforms include Docker, Podman, MicroVM, and Kubernetes.
## Protection Layers
@@ -128,7 +127,7 @@ OpenShell can pass host GPUs into sandboxes for local inference, fine-tuning, or
openshell sandbox create --gpu --from [gpu-enabled-sandbox] -- claude
```
-The CLI auto-bootstraps a GPU-enabled gateway on first use, auto-selecting CDI when available and otherwise falling back to Docker's NVIDIA GPU request path (`--gpus all`). GPU intent is also inferred automatically for community images with `gpu` in the name.
+Docker-backed GPU sandboxes auto-select CDI when available and otherwise fall back to Docker's NVIDIA GPU request path (`--gpus all`). GPU intent is also inferred automatically for community images with `gpu` in the name.
**Requirements:** NVIDIA drivers and the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) must be installed on the host. The sandbox image itself must include the appropriate GPU drivers and libraries for your workload — the default `base` image does not. See the [BYOC example](https://github.com/NVIDIA/OpenShell/tree/main/examples/bring-your-own-container) for building a custom sandbox image with GPU support.
@@ -171,7 +170,7 @@ openshell term
-The TUI gives you a live, keyboard-driven view of your cluster. Navigate with `Tab` to switch panels, `j`/`k` to move through lists, `Enter` to select, and `:` for command mode. Cluster health and sandbox status auto-refresh every two seconds.
+The TUI gives you a live, keyboard-driven view of your gateway and sandboxes. Navigate with `Tab` to switch panels, `j`/`k` to move through lists, `Enter` to select, and `:` for command mode. Gateway health and sandbox status auto-refresh every two seconds.
## Community Sandboxes and BYOC
@@ -195,7 +194,7 @@ cd OpenShell
# Point your agent here — it will discover the skills in .agents/skills/ automatically
```
-Your agent can load skills for CLI usage (`openshell-cli`), cluster troubleshooting (`debug-openshell-cluster`), inference troubleshooting (`debug-inference`), policy generation (`generate-sandbox-policy`), and more. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full skills table.
+Your agent can load skills for CLI usage (`openshell-cli`), gateway troubleshooting (`debug-openshell-cluster`), inference troubleshooting (`debug-inference`), policy generation (`generate-sandbox-policy`), and more. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full skills table.
## Built With Agents
diff --git a/SECURITY.md b/SECURITY.md
index 728cdb4a1d..9000efe988 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,4 +1,4 @@
-## Security
+# Security
NVIDIA is dedicated to the security and trust of our software products and services, including all source code repositories managed through our organization.
diff --git a/TESTING.md b/TESTING.md
index eba31967eb..c356b4a621 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -4,13 +4,13 @@
```bash
mise run test # Rust + Python unit tests
-mise run e2e # End-to-end tests (requires a running cluster)
+mise run e2e # End-to-end tests (starts a Docker-backed gateway)
mise run ci # Everything: lint, compile checks, and tests
```
## Test Layout
-```
+```text
crates/*/src/ # Inline #[cfg(test)] modules
crates/*/tests/ # Rust integration tests
python/openshell/ # Python unit tests (*_test.py suffix)
@@ -72,8 +72,17 @@ mise run test:python # uv run pytest python/
## E2E Tests
-E2E tests run against a live cluster. `mise run e2e` deploys changed components
-before running the suite.
+E2E tests run against a live gateway. By default, `mise run e2e` starts an
+ephemeral standalone gateway with the Docker compute driver, runs the suite,
+and cleans it up afterward. To run the suite against an existing plaintext
+gateway, set `OPENSHELL_GATEWAY_ENDPOINT`:
+
+```bash
+OPENSHELL_GATEWAY_ENDPOINT=http://127.0.0.1:18080 mise run e2e
+```
+
+Raw endpoint mode is HTTP-only. Use a named gateway config when a gateway
+requires mTLS.
### Python E2E (`e2e/python/`)
@@ -125,7 +134,7 @@ def test_multiply(sandbox):
| Fixture | Scope | Purpose |
|---|---|---|
-| `sandbox_client` | session | gRPC client connected to the active cluster |
+| `sandbox_client` | session | gRPC client connected to the active gateway |
| `sandbox` | function | Factory returning a `Sandbox` context manager |
| `inference_client` | session | Client for managing inference routes |
| `mock_inference_route` | session | Creates a mock OpenAI-protocol route for tests |
@@ -168,3 +177,4 @@ The harness (`e2e/rust/src/harness/`) provides:
| Variable | Purpose |
|---|---|
| `OPENSHELL_GATEWAY` | Override active gateway name for E2E tests |
+| `OPENSHELL_GATEWAY_ENDPOINT` | Run E2E tests against an existing plaintext HTTP gateway endpoint |
diff --git a/architecture/README.md b/architecture/README.md
index 570fce660c..9eb109b04d 100644
--- a/architecture/README.md
+++ b/architecture/README.md
@@ -6,11 +6,11 @@ This project is a platform for securely running AI agents in isolated sandbox en
This platform solves that problem by creating sandboxed execution environments where agents run with exactly the permissions they need and nothing more. Every sandbox is governed by a policy that defines which files the agent can access, which network hosts it can reach, and which system operations it can perform. All outbound network traffic is forced through a controlled proxy that inspects and enforces access rules in real time.
-The platform packages the entire infrastructure -- orchestration gateway, sandbox runtime, networking, and Kubernetes cluster -- into a single deployable unit. A user can go from zero to a running, secured sandbox in two commands. The system handles cluster provisioning, credential management, policy enforcement, and secure remote access without requiring the user to configure Kubernetes, networking, or security policies manually.
+The platform runs a gateway control plane and uses a configured compute driver to run agents in isolated sandbox environments. Supported compute platforms include Docker, Podman, Kubernetes, and the experimental MicroVM runtime. The system handles credential management, policy enforcement, and secure remote access while leaving runtime and cluster provisioning to the operator.
## How the Subsystems Fit Together
-The following diagram shows how the major subsystems interact at a high level. Users interact through the CLI, which communicates with a central gateway. The gateway manages sandbox lifecycle in Kubernetes, and each sandbox enforces its own policy locally. Inference API calls to `inference.local` are routed locally within the sandbox by an embedded inference router, without traversing the gateway at request time.
+The following diagram shows how the major subsystems interact at a high level. Users interact through the CLI, which communicates with a central gateway. The gateway manages sandbox lifecycle through a compute driver, and each sandbox enforces its own policy locally. Inference API calls to `inference.local` are routed locally within the sandbox by an embedded inference router, without traversing the gateway at request time.
```mermaid
flowchart TB
@@ -18,11 +18,12 @@ flowchart TB
CLI["Command-Line Interface"]
end
- subgraph CLUSTER["Kubernetes Cluster (single Docker container)"]
+ subgraph PLATFORM["Compute Platform"]
SERVER["Gateway / Control Plane"]
DB["Database (SQLite or Postgres)"]
+ DRIVER["Compute Driver (Docker, Podman, Kubernetes, VM)"]
- subgraph SBX["Sandbox Pod"]
+ subgraph SBX["Sandbox Workload"]
SUPERVISOR["Sandbox Supervisor"]
PROXY["Network Proxy"]
ROUTER["Inference Router"]
@@ -40,7 +41,8 @@ flowchart TB
CLI -- "gRPC / HTTPS" --> SERVER
CLI -- "SSH over HTTP CONNECT" --> SERVER
SERVER -- "CRUD + Watch" --> DB
- SERVER -- "Create / Delete Pods" --> SBX
+ SERVER -- "Create / Delete / Watch" --> DRIVER
+ DRIVER -- "Manage sandbox workload" --> SBX
SUPERVISOR -- "Fetch Policy + Credentials + Inference Bundle" --> SERVER
SUPERVISOR -- "Spawn + Restrict" --> CHILD
CHILD -- "All network traffic" --> PROXY
@@ -92,42 +94,41 @@ For more detail, see [Sandbox Architecture](sandbox.md) (Proxy Routing section).
### Gateway / Control Plane
-The gateway is the central orchestration service. It provides the API that the CLI talks to and manages the lifecycle of sandboxes in Kubernetes.
+The gateway is the central orchestration service. It provides the API that the CLI talks to and manages sandbox lifecycle through the selected compute driver.
Key responsibilities:
-- **Sandbox lifecycle management**: Creating, deleting, and monitoring sandboxes. When a user creates a sandbox, the gateway provisions a Kubernetes pod with the correct container image, policy, and environment configuration.
+- **Sandbox lifecycle management**: Creating, deleting, and monitoring sandboxes. When a user creates a sandbox, the gateway asks the selected compute driver to provision a workload with the correct image, policy, and environment configuration.
- **gRPC and HTTP APIs**: The gateway exposes a gRPC API for structured operations (sandbox CRUD, provider management, SSH session creation) and HTTP endpoints for health checks. Both protocols share a single network port through protocol multiplexing.
- **Data persistence**: Sandbox records, provider credentials, SSH sessions, and inference routes are stored in a database (SQLite by default, Postgres as an option).
- **TLS termination**: The gateway supports TLS with automatic protocol negotiation, so gRPC and HTTP clients can connect securely on the same port.
- **SSH tunnel gateway**: The gateway provides the entry point for SSH connections into sandboxes (see Sandbox Connect below).
- **Real-time updates**: The gateway streams sandbox status changes to the CLI, so users see live progress when a sandbox is starting up.
-- **Inference bundle resolution**: The gateway stores cluster-level inference configuration (provider name + model ID) and resolves it into bundles containing endpoint URLs, API keys, supported protocols, provider type, and auth metadata. Sandboxes fetch these bundles at startup and refresh them periodically. The gateway does not proxy inference traffic at request time -- it only provides configuration.
+- **Inference bundle resolution**: The gateway stores gateway-level inference configuration (provider name + model ID) and resolves it into bundles containing endpoint URLs, API keys, supported protocols, provider type, and auth metadata. Sandboxes fetch these bundles at startup and refresh them periodically. The gateway does not proxy inference traffic at request time -- it only provides configuration.
For more detail, see [Gateway Architecture](gateway.md).
-### Cluster Bootstrap and Infrastructure
+### Gateway Deployment Infrastructure
-The entire platform -- Kubernetes, the gateway, networking, and pre-loaded container images -- is packaged into a single Docker container. This means the only dependency a user needs is Docker.
+The gateway can run as a standalone process, a container, or a Kubernetes workload installed by the Helm chart in `deploy/helm/openshell`. Operators supply the compute platform and configure the driver that the gateway should use for sandboxes.
-The bootstrap system handles:
+The deployment layer handles:
-- **Provisioning**: Creating the Docker container with an embedded Kubernetes (k3s) cluster, pre-loaded with all required images and Helm charts.
-- **Local and remote deployment**: The same bootstrap flow works for local development (Docker on the user's machine) and remote deployment (Docker on a remote host, accessed via SSH).
-- **Health monitoring**: After starting the cluster, the system polls for readiness -- waiting for Kubernetes to start, for components to deploy, and for health checks to pass.
-- **Credential management**: If TLS is enabled, the bootstrap process automatically extracts client certificates and stores them locally for the CLI to use.
-- **Idempotent operation**: Running the deploy command again is safe. It reuses existing infrastructure or recreates only what changed.
+- **Gateway startup**: Running the gateway process or installing the Kubernetes Helm release.
+- **Runtime configuration**: Supplying image references, service exposure, sandbox runtime configuration, callback endpoints, and TLS material.
+- **Credential distribution**: Providing TLS and SSH relay material to the gateway and sandbox workloads.
+- **Compute driver configuration**: Selecting Docker, Podman, Kubernetes, or VM-backed sandbox execution.
-The target onboarding experience is two commands:
+The target onboarding experience is:
```bash
-pip install
-openshell sandbox create --remote user@host -- claude
+mise run gateway:docker
+openshell gateway add
```
-The first command installs the CLI. The second command bootstraps the cluster on the remote host (if needed) and launches a sandbox running the specified agent.
+The first command is one local development example. Kubernetes operators use `helm upgrade --install openshell ./deploy/helm/openshell --namespace openshell` instead. The second command registers the reachable endpoint with the CLI.
-For more detail, see [Cluster Bootstrap Architecture](cluster-single-node.md).
+For more detail, see [Gateway Deployment and Compute Platforms](gateway-single-node.md).
### Sandbox Connect (SSH Tunneling)
@@ -142,6 +143,7 @@ The connection flow works as follows:
5. The CLI and sandbox exchange SSH traffic bidirectionally through the tunnel.
This design provides several benefits:
+
- Sandbox pods are never directly accessible from outside the cluster.
- All access is authenticated and auditable through the gateway.
- Session tokens can be revoked to immediately cut off access.
@@ -170,7 +172,7 @@ The inference routing system transparently intercepts AI inference API calls fro
**How it works end-to-end:**
-1. An operator configures cluster-level inference via `openshell cluster inference set --provider --model `. This stores a reference to the named provider and model on the gateway.
+1. An operator configures gateway-level inference via `openshell inference set --provider --model `. This stores a reference to the named provider and model on the gateway.
2. When a sandbox starts, the supervisor fetches an inference bundle from the gateway via the `GetInferenceBundle` RPC. The gateway resolves the stored provider reference into a complete route: endpoint URL, API key, supported protocols, provider type, and auth metadata. The sandbox refreshes this bundle eagerly in the background every 5 seconds by default (override with `OPENSHELL_ROUTE_REFRESH_INTERVAL_SECS`).
3. The agent sends requests to `https://inference.local` using standard OpenAI or Anthropic SDK calls.
4. The sandbox proxy intercepts the HTTPS CONNECT to `inference.local` (bypassing OPA policy evaluation), TLS-terminates the connection using the sandbox's ephemeral CA, and parses the HTTP request.
@@ -183,9 +185,9 @@ The inference routing system transparently intercepts AI inference API calls fro
- The sandbox never sees the real API key for the backend -- credential isolation is maintained through the gateway's bundle resolution.
- Routing is explicit via `inference.local`; OPA network policy is not involved in inference routing.
- Provider-specific behavior (auth header style, default headers, supported protocols) is centralized in `InferenceProviderProfile` definitions in `openshell-core`. Supported inference provider types are openai, anthropic, and nvidia.
-- Cluster inference is managed via CLI (`openshell cluster inference set/get`).
+- Gateway inference is managed via CLI (`openshell inference set/get`).
-**Inference routes** are stored on the gateway as protobuf objects (`InferenceRoute` in `proto/inference.proto`). Cluster inference uses a managed singleton route entry keyed by `inference.local` and configured from provider + model settings. Endpoint, credentials, and protocols are resolved from the referenced provider record at bundle fetch time, so rotating a provider's API key takes effect on the next bundle refresh without reconfiguring the route.
+**Inference routes** are stored on the gateway as protobuf objects (`InferenceRoute` in `proto/inference.proto`). Gateway inference uses a managed singleton route entry keyed by `inference.local` and configured from provider + model settings. Endpoint, credentials, and protocols are resolved from the referenced provider record at bundle fetch time, so rotating a provider's API key takes effect on the next bundle refresh without reconfiguring the route.
**Components involved:**
@@ -195,21 +197,19 @@ The inference routing system transparently intercepts AI inference API calls fro
| Inference pattern detection | `crates/openshell-sandbox/src/l7/inference.rs` | Matches HTTP method + path against known inference API patterns |
| Local inference router | `crates/openshell-router/src/lib.rs` | Selects a compatible route by protocol and proxies to the backend |
| Provider profiles | `crates/openshell-core/src/inference.rs` | Centralized auth, headers, protocols, and endpoint defaults per provider type |
-| Gateway inference service | `crates/openshell-server/src/inference.rs` | Stores cluster inference config, resolves bundles with credentials from provider records |
+| Gateway inference service | `crates/openshell-server/src/inference.rs` | Stores gateway inference config, resolves bundles with credentials from provider records |
| Proto definitions | `proto/inference.proto` | `ClusterInferenceConfig`, `ResolvedRoute`, bundle RPCs |
-
### Container and Build System
-The platform produces three container images:
+The platform publishes the gateway image and relies on community-maintained sandbox images:
| Image | Purpose |
|---|---|
-| **Sandbox** | Runs inside each sandbox pod. Contains the sandbox supervisor binary, Python runtime, and agent tooling. Uses a multi-user setup (privileged supervisor, restricted agent user). |
| **Gateway** | Runs the control plane. Contains the gateway binary, database migrations, and an embedded SSH client for sandbox management. |
-| **Cluster** | An airgapped Kubernetes image with k3s, pre-loaded sandbox and gateway images, Helm charts, and an API gateway. This is the single container that users deploy. |
+| **Sandbox** | Runs each sandbox workload. Maintained in the OpenShell Community repository or supplied by the user. |
-Builds use multi-stage Dockerfiles with caching to keep rebuild times fast. A Helm chart handles Kubernetes-level configuration (service ports, health checks, security contexts, resource limits). Build automation is managed through mise tasks.
+Builds use multi-stage Dockerfiles with caching to keep rebuild times fast. The Helm chart handles Kubernetes-level configuration such as service ports, health checks, security contexts, resource limits, storage, and TLS secret mounts. Docker, Podman, and VM-backed deployments configure equivalent runtime concerns through their driver-specific gateway configuration.
For more detail, see [Container Management](build-containers.md).
@@ -222,7 +222,7 @@ Sandbox behavior is governed by policies written in YAML and evaluated by an emb
- **Process privileges**: What user/group the agent runs as.
- **L7 inspection rules**: Protocol-level constraints on HTTP API calls for specific endpoints.
-Inference routing to `inference.local` is configured separately at the cluster level and does not require network policy entries. The OPA engine evaluates only explicit network policies; `inference.local` connections bypass OPA entirely and are handled by the proxy's dedicated inference interception path.
+Inference routing to `inference.local` is configured separately at the gateway level and does not require network policy entries. The OPA engine evaluates only explicit network policies; `inference.local` connections bypass OPA entirely and are handled by the proxy's dedicated inference interception path.
Policies are not intended to be hand-edited by end users in normal operation. They are associated with sandboxes at creation time and fetched by the sandbox supervisor at startup via gRPC. For development and testing, policies can also be loaded from local files. A gateway-global policy can override all sandbox policies via `openshell policy set --global`.
@@ -234,17 +234,17 @@ For more detail on the policy language, see [Policy Language](security-policy.md
The CLI is the primary way users interact with the platform. It provides commands organized into four groups:
-- **Gateway management** (`openshell gateway`): Deploy, stop, destroy, and inspect clusters. Supports both local and remote (SSH) targets.
+- **Gateway management** (`openshell gateway`): Register, select, and inspect gateway endpoints.
- **Sandbox management** (`openshell sandbox`): Create sandboxes (with optional file upload and provider auto-discovery), connect to sandboxes via SSH, and delete sandboxes.
-- **Top-level commands**: `openshell status` (cluster health), `openshell logs` (sandbox logs), `openshell forward` (port forwarding), `openshell policy` (sandbox policy management), `openshell settings` (effective sandbox settings and global/sandbox key updates).
+- **Top-level commands**: `openshell status` (gateway health), `openshell logs` (sandbox logs), `openshell forward` (port forwarding), `openshell policy` (sandbox policy management), `openshell settings` (effective sandbox settings and global/sandbox key updates).
- **Provider management** (`openshell provider`): Create, update, list, and delete external service credentials.
-- **Inference management** (`openshell cluster inference`): Configure cluster-level inference by specifying a provider and model. The gateway resolves endpoint and credential details from the named provider record.
+- **Inference management** (`openshell inference`): Configure gateway-level inference by specifying a provider and model. The gateway resolves endpoint and credential details from the named provider record.
The CLI resolves which gateway to operate on through a priority chain: explicit `--gateway` flag, then the `OPENSHELL_GATEWAY` environment variable, then the active gateway set by `openshell gateway select`. Gateway names are exposed to shell completion from local metadata, and `openshell gateway select` opens an interactive chooser on a TTY while falling back to a printed list in non-interactive use. The CLI supports TLS client certificates for mutual authentication with the gateway.
## How Users Get Started
-The onboarding flow is designed to require minimal setup. Docker is the only prerequisite.
+The onboarding flow starts from a reachable gateway endpoint.
**Step 1: Install the CLI.**
@@ -258,22 +258,7 @@ pip install
openshell sandbox create -- claude
```
-If no cluster exists, the CLI automatically bootstraps one. It provisions a local Kubernetes cluster inside a Docker container, waits for it to become healthy, discovers the user's AI provider credentials from local configuration files, uploads them to the gateway, and launches a sandbox running the specified agent -- all from a single command.
-
-For remote deployment (running the sandbox on a different machine):
-
-```bash
-openshell sandbox create --remote user@hostname -- claude
-```
-
-This performs the same bootstrap flow on the remote host via SSH.
-
-For development and testing against the current checkout, use
-`scripts/remote-deploy.sh` instead. That helper syncs the local repository to
-an SSH-reachable machine, builds the CLI and Docker images on the remote host,
-and then runs `openshell gateway start` there. It defaults to secure gateway
-startup and only enables `--plaintext`, `--disable-gateway-auth`, or
-`--recreate` when explicitly requested.
+Before creating a sandbox, start or deploy the gateway on the selected compute platform and register the reachable endpoint with the CLI.
**Step 3: Connect to a running sandbox.**
@@ -287,10 +272,10 @@ This opens an interactive SSH session into the sandbox, with all provider creden
| Document | Description |
|---|---|
-| [Cluster Bootstrap](cluster-single-node.md) | How the platform bootstraps a Kubernetes cluster from a single Docker container, for local and remote targets. |
+| [Gateway Deployment and Compute Platforms](gateway-single-node.md) | How the gateway runs across Docker, Podman, Kubernetes with Helm, and the experimental VM driver. |
| [Gateway Architecture](gateway.md) | The control plane gateway: API multiplexing, gRPC services, persistence, TLS, and sandbox orchestration. |
| [Gateway Communication](gateway-deploy-connect.md) | How the CLI resolves a gateway and communicates with it over mTLS, plaintext HTTP/2, or an edge-authenticated WebSocket tunnel. |
-| [Gateway Security](gateway-security.md) | mTLS enforcement, PKI bootstrap, certificate hierarchy, and the gateway trust model. |
+| [Gateway Security](gateway-security.md) | mTLS enforcement, PKI provisioning, certificate hierarchy, and the gateway trust model. |
| [Sandbox Architecture](sandbox.md) | The sandbox execution environment: policy enforcement, Landlock, seccomp, network namespaces, and the network proxy. |
| [Container Management](build-containers.md) | Container images, Dockerfiles, Helm charts, build tasks, and CI/CD. |
| [Sandbox Connect](sandbox-connect.md) | SSH tunneling into sandboxes through the gateway. |
@@ -299,6 +284,7 @@ This opens an interactive SSH session into the sandbox, with all provider creden
| [Docs Site Architecture](docs-site.md) | Documentation source layout, navigation structure, local validation and preview workflow, and publish pipeline. |
| [Policy Language](security-policy.md) | The YAML/Rego policy system that governs sandbox behavior. |
| [Inference Routing](inference-routing.md) | Transparent interception and sandbox-local routing of AI inference API calls to configured backends. |
+| [Docker Driver](docker-driver.md) | Docker compute driver implementation, host networking, loopback gateway connectivity. |
| [System Architecture](system-architecture.md) | Top-level system architecture diagram with all deployable components and communication flows. |
| [Gateway Settings Channel](gateway-settings.md) | Runtime settings channel: two-tier key-value configuration, global policy override, settings registry, CLI/TUI commands. |
| [TUI](tui.md) | Terminal user interface for sandbox interaction. |
diff --git a/architecture/build-containers.md b/architecture/build-containers.md
index 493e7207a8..61cfe9d892 100644
--- a/architecture/build-containers.md
+++ b/architecture/build-containers.md
@@ -1,25 +1,60 @@
-# Container Images
+# Container Images and Deployment Packaging
-OpenShell produces two container images, both published for `linux/amd64` and `linux/arm64`.
+OpenShell publishes the gateway image and keeps Kubernetes Helm packaging in this repository. Sandbox images are maintained in the separate OpenShell Community repository.
-## Gateway (`openshell/gateway`)
+## Gateway Image
-The gateway runs the control plane API server. It is deployed as a StatefulSet inside the cluster container via a bundled Helm chart.
+The gateway image runs the control plane API server. Kubernetes deployments use it through the Helm chart. Standalone container deployments can use the same image with driver-specific runtime configuration.
- **Docker target**: `gateway` in `deploy/docker/Dockerfile.images`
- **Registry**: `ghcr.io/nvidia/openshell/gateway:latest`
-- **Pulled when**: Cluster startup (the Helm chart triggers the pull)
-- **Entrypoint**: `openshell-server --port 8080` (gRPC + HTTP, mTLS)
+- **Pulled when**: Helm install or upgrade, or standalone container deployment
+- **Entrypoint**: `openshell-gateway --port 8080`
-## Cluster (`openshell/cluster`)
+The image contains the gateway binary and database migrations. Runtime configuration is supplied by Helm values and Kubernetes secrets for Kubernetes, or by driver-specific configuration for standalone gateway deployments.
-The cluster image is a single-container Kubernetes distribution that bundles the Helm charts, Kubernetes manifests, and the `openshell-sandbox` supervisor binary needed to bootstrap the control plane.
+## Helm Chart
-- **Docker target**: `cluster` in `deploy/docker/Dockerfile.images`
-- **Registry**: `ghcr.io/nvidia/openshell/cluster:latest`
-- **Pulled when**: `openshell gateway start`
+The Helm chart at `deploy/helm/openshell` owns Kubernetes deployment concerns:
-The supervisor binary (`openshell-sandbox`) is built by the shared `supervisor-builder` stage in `deploy/docker/Dockerfile.images` and placed at `/opt/openshell/bin/openshell-sandbox`. It is exposed to sandbox pods at runtime via a read-only `hostPath` volume mount — it is not baked into sandbox images.
+- Gateway StatefulSet and persistent volume claim.
+- Service account, RBAC, and service.
+- Gateway service exposure.
+- TLS secret mounts and environment variables.
+- Sandbox namespace, default sandbox image, and callback endpoint configuration.
+- NetworkPolicy restricting sandbox SSH ingress to the gateway.
+
+The chart remains the supported deployment artifact for Kubernetes.
+
+## Image Build Pipeline
+
+`deploy/docker/Dockerfile.images` no longer compiles Rust. CI calls `.github/workflows/shadow-rust-native-build.yml` through `workflow_call` to build `openshell-gateway` or `openshell-sandbox` natively on the target architecture. `.github/workflows/docker-build.yml` downloads the resulting artifact, stages it at `deploy/docker/.build/prebuilt-binaries//`, builds the per-arch image with the local Buildx driver, and merges multi-arch pushes with `docker buildx imagetools create`. Callers normally publish the GitHub SHA tag, but can pass `image-tag` to publish isolated temporary tags for validation.
+
+Local image builds use `tasks/scripts/stage-prebuilt-binaries.sh` through `tasks/scripts/docker-build-image.sh` before invoking Docker, so clean checkouts do not need to create the staging directory manually.
+
+## Supervisor Delivery
+
+The `openshell-sandbox` supervisor is delivered by the selected compute driver:
+
+| Driver | Supervisor delivery |
+|---|---|
+| Kubernetes | Sandbox pod image or Kubernetes driver pod template configuration. |
+| Docker | Local supervisor binary or supervisor image extraction configured by the gateway. |
+| Podman | Read-only OCI image volume from the `supervisor-output` image. |
+| VM | Embedded in the VM runtime rootfs. |
+
+Each compute driver owns supervisor delivery for its runtime.
+
+## Standalone Gateway Binary
+
+OpenShell also publishes a standalone `openshell-gateway` binary as a GitHub release asset.
+
+- **Source crate**: `crates/openshell-server`
+- **Artifact name**: `openshell-gateway-.tar.gz`
+- **Targets**: `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `aarch64-apple-darwin`
+- **Release workflows**: `.github/workflows/release-dev.yml`, `.github/workflows/release-tag.yml`
+
+Both the standalone artifact and the deployed container image use the `openshell-gateway` binary.
## Python Wheels
@@ -29,10 +64,11 @@ OpenShell also publishes Python wheels for `linux/amd64`, `linux/arm64`, and mac
- There is no local Linux multiarch wheel build task. Release workflows own the per-arch Linux wheel production.
- The macOS ARM64 wheel is cross-compiled with `deploy/docker/Dockerfile.python-wheels-macos` via `build:python:wheel:macos`.
- Release workflows mirror the CLI layout: a Linux matrix job for amd64/arm64, a separate macOS job, and release jobs that download the per-platform wheel artifacts directly before publishing.
+- Release CPU jobs run on `linux-amd64-cpu8` and `linux-arm64-cpu8`; the macOS wheel is still cross-compiled in Docker from the amd64 Linux runner.
## Sandbox Images
-Sandbox images are **not built in this repository**. They are maintained in the [openshell-community](https://github.com/nvidia/openshell-community) repository and pulled from `ghcr.io/nvidia/openshell-community/sandboxes/` at runtime.
+Sandbox images are not built in this repository. They are maintained in the [openshell-community](https://github.com/nvidia/openshell-community) repository and pulled from `ghcr.io/nvidia/openshell-community/sandboxes/` at runtime.
The default sandbox image is `ghcr.io/nvidia/openshell-community/sandboxes/base:latest`. To use a named community sandbox:
@@ -44,38 +80,14 @@ This pulls `ghcr.io/nvidia/openshell-community/sandboxes/:latest`.
## Local Development
-`mise run cluster` is the primary development command. It bootstraps a cluster if one doesn't exist, then performs incremental deploys for subsequent runs.
+Use the workflow that matches the driver you are changing:
-The incremental deploy (`cluster-deploy-fast.sh`) fingerprints local Git changes and only rebuilds components whose files have changed:
-
-| Changed files | Rebuild triggered |
+| Area | Typical local command |
|---|---|
-| Cargo manifests, proto definitions, cross-build script | Gateway + supervisor |
-| `crates/openshell-server/*`, `deploy/docker/Dockerfile.images` | Gateway |
-| `crates/openshell-sandbox/*`, `crates/openshell-policy/*` | Supervisor |
-| `deploy/helm/openshell/*` | Helm upgrade |
-
-When no local changes are detected, the command is a no-op.
-
-**Gateway updates** are pushed to a local registry and the StatefulSet is restarted. **Supervisor updates** are copied directly into the running cluster container via `docker cp` — new sandbox pods pick up the updated binary immediately through the hostPath mount, with no image rebuild or cluster restart required.
-
-Fingerprints are stored in `.cache/cluster-deploy-fast.state`. You can also target specific components explicitly:
-
-```bash
-mise run cluster -- gateway # rebuild gateway only
-mise run cluster -- supervisor # rebuild supervisor only
-mise run cluster -- chart # helm upgrade only
-mise run cluster -- all # rebuild everything
-```
-
-To validate incremental routing and BuildKit cache reuse locally, run:
-
-```bash
-mise run cluster:test:fast-deploy-cache
-```
-
-The harness runs isolated scenarios in temporary git worktrees, keeps its own state and cache under `.cache/cluster-deploy-fast-test/`, and writes a Markdown summary with:
+| Gateway image or chart | `mise run helm:lint` and `mise run docker:build:gateway` |
+| Docker driver | `mise run gateway:docker` or `mise run e2e:docker` |
+| Podman driver | `mise run e2e:podman` |
+| VM driver | `mise run e2e:vm` |
+| Published docs | `mise run docs` |
-- auto-detection checks for gateway-only, supervisor-only, shared, Helm-only, unrelated, and explicit-target changes
-- cold vs warm rebuild comparisons for gateway and supervisor code changes
-- container-ID invalidation coverage to verify gateway + Helm are retriggered when the cluster container changes
+Kubernetes chart changes should be validated with `helm lint deploy/helm/openshell` and, when possible, by installing the chart into a disposable Kubernetes namespace.
diff --git a/architecture/ci-e2e.md b/architecture/ci-e2e.md
new file mode 100644
index 0000000000..4fc007fca4
--- /dev/null
+++ b/architecture/ci-e2e.md
@@ -0,0 +1,198 @@
+# E2E CI Architecture
+
+This document describes the architecture of the E2E CI flow: every workflow involved, the trigger each one listens on, why those triggers were chosen, and how the pieces fit together. For the contributor-facing how-to (labels, signing, fork flow), see [CI.md](../CI.md).
+
+## Goals and constraints
+
+Three independent goals shape the design:
+
+1. **Self-hosted runner safety.** Required PR checks, E2E, and GPU tests run on NVIDIA self-hosted runners. GitHub's [security hardening guide](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#hardening-for-self-hosted-runners) states bluntly: "Self-hosted runners should almost never be used for public repositories on GitHub, because any user can open pull requests against the repository and compromise the environment." Our workaround is the same one used elsewhere in NVIDIA's GHA infrastructure: copy-pr-bot mirrors trusted PRs into `pull-request/` branches inside this repository, and the self-hosted workflows trigger on `push` to those mirror branches rather than on `pull_request`.
+2. **Label as a hard merge gate.** When a PR carries `test:e2e` (or `test:e2e-gpu`), the corresponding suite *must* have actually executed and passed for the PR head SHA. The label has to be enforcing, not advisory: it blocks merge unless the suite ran with the label set.
+3. **Per-job least privilege on the GitHub token.** Each workflow declares `permissions: {}` at the top, and each job declares only what it needs. This follows the hardening pattern described at .
+
+These three goals do not compose cleanly: the safety goal forces `push: pull-request/` triggers (which the PR author can't influence), but `push` triggers don't fire on label changes, so the label gate has to come from a separate workflow on a different trigger. That is the heart of the architecture.
+
+## Pieces at a glance
+
+| File | Trigger | Role |
+|---|---|---|
+| `.github/copy-pr-bot.yaml` | (config) | Tells copy-pr-bot to mirror trusted PRs into `pull-request/` branches. Pre-existed. |
+| `.github/workflows/branch-checks.yml` | `push: pull-request/[0-9]+` + `workflow_dispatch` | Runs required branch checks on `linux-amd64-cpu8` and `linux-arm64-cpu8`. |
+| `.github/workflows/branch-e2e.yml` | `push: pull-request/[0-9]+` + `workflow_dispatch` | Runs non-GPU E2E on `linux-arm64-cpu8`. |
+| `.github/workflows/test-gpu.yml` | `push: pull-request/[0-9]+` + `workflow_dispatch` | Runs GPU E2E on self-hosted GPU runners. |
+| `.github/actions/pr-gate/action.yml` | (composite) | Resolves PR metadata for a `pull-request/` push and decides whether the run should proceed. Label enforcement is optional so ordinary branch checks can validate mirror metadata without introducing another PR label. |
+| `.github/workflows/e2e-gate.yml` | `pull_request` + `workflow_run` | Posts the required `E2E Gate` check on the PR. Re-evaluates after the gated workflow completes. |
+| `.github/workflows/e2e-gate-check.yml` | `workflow_call` | Reusable gate logic shared by E2E and GPU E2E. |
+| `.github/workflows/e2e-label-help.yml` | `pull_request_target: [labeled]` | Posts a PR comment when a `test:e2e*` label is applied, telling the maintainer the next manual step (re-run an existing run, or `/ok to test ` to refresh the mirror). Does *not* dispatch the workflow itself - see "Why we don't auto-dispatch" below. |
+| `.github/workflows/e2e-test.yml`, `e2e-gpu-test.yaml`, `docker-build.yml` | `workflow_call` | Reusable worker workflows. Unchanged by this design - called from the gated workflows and from release workflows. |
+
+## OS-49 runner migration
+
+OS-49 Phase 5 added non-required shadow workflows for the non-release workflows being prepared for shared-runner cutover. Phase 6 promoted the validated shared-runner path into the real non-release workflows and removed the obsolete PR-triggered shadow workflows to avoid duplicate PR checks.
+
+`branch-checks.yml` uses `pr-gate` without a required label. That still verifies the mirror SHA matches the source PR head SHA, but does not require a new GitHub label for ordinary required checks. `branch-e2e.yml` keeps the existing `test:e2e` gate because it publishes temporary images and runs the expensive E2E suite. `ci-image.yml` now builds amd64 and arm64 CI images natively on shared CPU runners and merges the multi-arch manifest after both per-arch images are pushed.
+
+The `mise-lockfile` job regenerates `mise.lock` with the CI image's pinned mise version and requires the checked-in file to match exactly. This intentionally includes generated metadata so contributors catch toolchain-version drift instead of letting different mise versions churn the lockfile.
+
+OS-49 Phase 7 moves the release-facing CPU jobs in `release-canary.yml`, `release-dev.yml`, and `release-tag.yml` to the same shared CPU labels. The release workflows also call `driver-vm-linux.yml`, `driver-vm-macos.yml`, and `deb-package.yml`, so those reusable workers use the same labels to avoid retaining a hidden ARC dependency in the release path. `release-vm-kernel.yml` uses the shared CPU labels for its Linux runtime and release jobs; the macOS runtime job stays on `macos-latest-xlarge` because it builds native macOS dylibs.
+
+## Trigger taxonomy
+
+Five GitHub Actions trigger types appear in this flow. Each one was chosen for a specific reason - they are not interchangeable.
+
+| Trigger | Workflow context | Token scope | Why we use it here |
+|---|---|---|---|
+| `push: pull-request/[0-9]+` | The pushed commit (mirror branch) | Repo-default | Only fires for branches copy-pr-bot created. Decouples test execution from PR author actions: the author cannot create a `pull-request/` branch themselves. |
+| `pull_request` | The PR head SHA, but actions checkout the *base* branch's workflow files | Read-only for forks | Lets us post a status check on the PR's head SHA (so branch protection sees it). Used by the `E2E Gate` evaluation jobs. |
+| `pull_request_target` | Base branch | Write-capable, even for forks | Needed for `e2e-label-help.yml` to post a comment on a forked PR. The workflow never checks out PR code, so the standard `pull_request_target` foot-gun does not apply. |
+| `workflow_run` | Default branch | Repo-default | Fires when the gated workflow finishes. Lets us run a gate re-evaluation step in a trusted (default-branch) context. |
+| `workflow_dispatch` | Caller's ref | Repo-default | Maintainer-only manual re-run (clicking "Re-run all jobs" in the Actions UI). We deliberately do not call this from another workflow - see "Why we don't auto-dispatch" below. |
+
+The non-obvious move here is that the same logical "did E2E pass for this PR" check has to be posted from two of these trigger contexts: a `pull_request`-triggered run (which can attach a check to the PR head SHA) and a `workflow_run`-triggered run (which knows the gated workflow finished but can only attach checks to `main`). The flow stitches them together by re-running the original `pull_request`-triggered run after the gated workflow completes.
+
+## Happy-path flow (trusted PR, label applied after mirror)
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Author as PR Author (org member)
+ participant GH as GitHub
+ participant Bot as copy-pr-bot
+ participant BranchE2E as Branch E2E Checks (self-hosted)
+ participant Gate as E2E Gate (github-hosted)
+ participant Help as E2E Label Help (github-hosted)
+ participant Maintainer
+
+ Author->>GH: Open PR (signed commits)
+ GH->>Bot: PR opened
+ Bot->>GH: push pull-request/N (mirror)
+ GH->>BranchE2E: push event on pull-request/N
+ BranchE2E->>BranchE2E: pr_metadata: should_run = false (no label yet)
+ BranchE2E-->>GH: workflow concludes success (only metadata job ran)
+
+ GH->>Gate: pull_request opened
+ Gate->>Gate: no label, gate passes (no-op)
+
+ Maintainer->>GH: apply test:e2e label
+ GH->>Gate: pull_request labeled
+ Gate->>Gate: label set, upstream only ran metadata → FAIL (red)
+ GH->>Help: pull_request_target labeled
+ Help->>GH: comment on PR with link to existing Branch E2E Checks run
+ Maintainer->>GH: open the linked run, click "Re-run all jobs"
+ GH->>BranchE2E: re-run (push event replayed)
+ BranchE2E->>BranchE2E: pr_metadata: should_run = true (label set, SHA matches)
+ BranchE2E->>BranchE2E: build + e2e jobs run
+
+ BranchE2E-->>GH: workflow concludes success
+ GH->>Gate: workflow_run completed
+ Gate->>GH: rerun original pull_request gate run
+ GH->>Gate: pull_request rerun (replays event)
+ Gate->>Gate: label set, upstream success + non-gate jobs ran → PASS (green)
+```
+
+The label-help workflow is intentionally a comment-only nudge: it never dispatches the workflow itself, so the maintainer's re-run goes through the same `push`-event run-id that originally fired on the mirror. This preserves in-progress visibility on the PR's Checks tab.
+
+## Forked PR flow
+
+The shape is identical but with two extra round trips: the maintainer has to vet each commit before copy-pr-bot will mirror it.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Author as PR Author (fork)
+ participant GH as GitHub
+ participant Bot as copy-pr-bot
+ participant Maintainer
+
+ Author->>GH: Open PR from fork
+ GH->>Bot: PR opened
+ Bot->>Bot: not trusted, wait
+ Maintainer->>GH: comment "/ok to test "
+ Bot->>GH: push pull-request/N
+ Note over Bot,GH: From here, identical to the trusted flow: label → help comment → maintainer re-runs → gate flips green
+ Author->>GH: push new commit
+ Bot->>Bot: still untrusted, wait again
+ Maintainer->>GH: comment "/ok to test "
+```
+
+## Why each design choice exists
+
+### Why `push` on `pull-request/` instead of `pull_request`
+
+`pull_request` workflows execute the workflow file from the PR's own branch. On a self-hosted runner, that means an attacker can rewrite our workflow YAML and run anything. `push: pull-request/` only fires for branches that copy-pr-bot creates, so the workflow file source is always one that the bot vetted (signed commit + trusted author, or `/ok to test`).
+
+### Why the gate has to verify a non-gate job actually ran
+
+The gated workflows always start with a `pr_metadata` job. When the label is missing, `pr_metadata` reports `should_run=false` and the build/E2E jobs are skipped. From GitHub's perspective the workflow concluded `success`. If the gate only checked top-level conclusion, an unlabeled run from earlier would satisfy the gate forever - the label could be added without ever causing E2E to actually execute. The gate's "at least one non-gate job succeeded" check (`e2e-gate-check.yml:106-110`) is what forces a re-run after labeling.
+
+### Why `workflow_run` is needed for the gate flip
+
+Once the gated workflow runs and finishes, the `pull_request`-triggered gate check posted earlier still says "fail". `workflow_run` is the only event that fires when an arbitrary other workflow completes, and it's how we know to re-evaluate the gate. But `workflow_run` runs in the *default branch context*, so a check posted from there lands on `main` instead of the PR. Workaround: instead of posting a new check, look up the most recent `pull_request`-triggered gate run for the same head SHA and call `POST /actions/runs//rerun`. The re-run replays the original `pull_request` event, so the new check posts against the PR's head SHA and branch protection picks it up.
+
+### Why `pull_request_target` for the label-help workflow
+
+A `pull_request` workflow on a forked PR receives a read-only `GITHUB_TOKEN`. That's intentional: it prevents PR-supplied workflow code from escalating. But the help workflow doesn't *run* PR code - it never checks out the PR head, only the workflow file from `main`. It needs `pull-requests: write` to post a comment. `pull_request_target` provides a write-capable token while still loading the workflow definition from `main`. The standard `pull_request_target` warning ("don't check out PR code with this token") doesn't apply because we don't check out anything.
+
+### Why we don't auto-dispatch the gated workflow
+
+An earlier iteration of this design auto-dispatched the gated workflow via `gh workflow run --ref pull-request/` from a `pull_request_target: [labeled]` workflow. It worked, but produced a worse UX: `workflow_dispatch`-triggered runs do not appear in the PR's Checks tab. The check-runs are technically attached to the PR head SHA (visible via `gh api commits//check-runs`), but the PR UI filters them out because the run isn't associated with a PR-context event. The maintainer would see "Dispatched" comment, then no progress on the PR until the gate eventually flipped from red to green many minutes later.
+
+We considered alternatives:
+
+- **Push an empty marker commit to `pull-request/` to fire a fresh `push` event.** Changes the SHA, breaks the gate's head-SHA equivalence, and writes to a branch copy-pr-bot owns. Architecturally bad.
+- **Re-trigger copy-pr-bot programmatically.** copy-pr-bot only listens for `pull_request.*` and `issue_comment.created` events ([source](https://github.com/NVIDIA/gha-runners-apps/blob/main/packages/copy-pr-bot/src/app.ts)). Even commenting `/ok to test ` is a no-op when the mirror is already at that SHA - the bot calls `git.updateRef` with the same SHA and GitHub fires no new push event. There is no way to make copy-pr-bot re-fire a push without an actual SHA change.
+- **Have the dispatcher post mirror Check Runs against the PR head SHA via the Checks API.** Possible, but adds a polling/webhook loop to keep the mirror checks in sync with the actual run. Not worth the complexity for a flow a maintainer goes through manually anyway.
+
+The current design takes the pragmatic path: when a label is applied, the help workflow posts a comment with a deep link to the existing `Branch E2E Checks` run on the mirror. The maintainer clicks **Re-run all jobs**. That re-run replays the original `push` event, so its check-runs surface on the PR's Checks tab in real time. The cost is one human click per label application, in exchange for live progress visibility.
+
+### Why labels and not comment commands
+
+Labels persist as PR metadata and survive re-runs and force-pushes. Comment-based commands like `/ok to test` don't survive the same way: a comment from yesterday doesn't enable today's commit. Branch protection rules can require a check be present; they cannot require a comment. The label is the merge gate's primary signal because it is the only thing GitHub's branch protection knows how to look at.
+
+## Permission posture
+
+The gated E2E workflows declare `permissions: {}` at the top. Branch checks and CI image publishing use the minimum workflow/job grants needed for checkout, package pulls, and package pushes.
+
+| Workflow | Job | Grants |
+|---|---|---|
+| `branch-checks.yml` | workflow default | `contents: read`, `packages: read` |
+| | `pr_metadata` | `contents: read`, `pull-requests: read` |
+| `ci-image.yml` | workflow default | `contents: read`, `packages: write` |
+| `branch-e2e.yml`, `test-gpu.yml` | `pr_metadata` | `contents: read`, `pull-requests: read` |
+| | `build-*` | `contents: read`, `packages: write` |
+| | `e2e*` | `contents: read`, `packages: read` |
+| `e2e-gate.yml` | `e2e`, `gpu` (`workflow_call`) | inherits via the called workflow |
+| | `rerun-on-completion` | `actions: write` |
+| `e2e-gate-check.yml` | `check` | `contents: read`, `pull-requests: read`, `actions: read` |
+| `e2e-label-help.yml` | `hint` | `pull-requests: write`, `actions: read`, `contents: read` |
+
+The reusable worker workflows (`e2e-test.yml`, `e2e-gpu-test.yaml`, `docker-build.yml`) declare their own internal permissions; the calling job grants are an upper bound for them.
+
+Only one workflow holds an "interesting" token: `rerun-on-completion` in `e2e-gate.yml` has `actions: write`. It calls one specific endpoint - `POST /actions/runs//rerun` for an `e2e-gate.yml` run on the same head SHA - and never executes PR code. The label-help workflow holds only `pull-requests: write` for posting the comment, also without checking out PR code.
+
+## Release flow
+
+`release-tag.yml` and `release-dev.yml` call `e2e-test.yml` directly on `main` / tag pushes. Tags and `main` are inherently trusted refs, so they bypass copy-pr-bot. E2E still blocks the release jobs (`tag-ghcr-release: needs: [..., e2e]`).
+
+The release CPU jobs run on `linux-amd64-cpu8` and `linux-arm64-cpu8`. GitHub-hosted docs publishing and the external wheel-publish bridge keep their existing runners. VM development release workflows are tracked separately because the managed platform capability decision is still open.
+
+Permissions on the release workflows are not yet scoped per-job. Tracked separately.
+
+## Edge cases
+
+| Case | What happens |
+|---|---|
+| Label applied before copy-pr-bot mirrors the PR | Help workflow detects no `pull-request/` branch and posts a comment telling the maintainer to wait or run `/ok to test `. |
+| Label applied while mirror is stale (new commit pending `/ok to test`) | Help workflow detects mirror SHA != PR head SHA and posts the corresponding comment with the SHA the maintainer needs to vet. |
+| Label removed | No reaction. The next PR event (push, label, etc.) re-evaluates the gate, which now sees no label and passes as a no-op. |
+| Author force-pushes after label set | copy-pr-bot re-mirrors the new SHA → gated workflow fires on `push` → because the label is still on the PR, `pr_metadata` runs the build/E2E jobs without manual re-run → `workflow_run` fires the gate re-run → new green check on the new SHA. |
+| Maintainer re-runs the gated workflow manually from the Actions UI | Same as above without the force-push. This is the path the help workflow points the maintainer at. |
+| Gate's first evaluation fails (label set, upstream not yet started) | Email-on-failure noise. The check eventually flips to success once upstream finishes and `workflow_run` re-runs the gate. Tracked as a known rough edge; possible fix is posting `neutral` until the upstream completes. |
+
+## References
+
+- copy-pr-bot:
+- Astral hardening guidance:
+- GitHub Actions security pattern for self-hosted runners:
+- `pull_request_target` foot-gun:
+- Contributor-facing flow doc: [../CI.md](../CI.md)
diff --git a/architecture/custom-vm-runtime.md b/architecture/custom-vm-runtime.md
index ce4d0bf393..9f723d8d76 100644
--- a/architecture/custom-vm-runtime.md
+++ b/architecture/custom-vm-runtime.md
@@ -1,123 +1,210 @@
# Custom libkrunfw VM Runtime
-> Status: Experimental and work in progress (WIP). VM support is under active development and may change.
+> Status: Experimental and work in progress (WIP). The VM compute driver is
+> under active development and may change.
## Overview
-The OpenShell gateway VM uses [libkrun](https://github.com/containers/libkrun) to boot a
-lightweight microVM with Apple Hypervisor.framework (macOS) or KVM (Linux). The kernel
-is embedded inside `libkrunfw`, a companion library that packages a pre-built Linux kernel.
-
-The stock `libkrunfw` from Homebrew ships a minimal kernel without bridge, netfilter, or
-conntrack support. This is insufficient for Kubernetes pod networking.
-
-The custom libkrunfw runtime adds bridge CNI, iptables/nftables, and conntrack support to
-the VM kernel, enabling standard Kubernetes networking.
+The OpenShell gateway uses [libkrun](https://github.com/containers/libkrun) via the
+`openshell-driver-vm` compute driver to boot a lightweight microVM per sandbox.
+Each VM runs on Apple Hypervisor.framework (macOS) or KVM (Linux), with the guest
+kernel embedded inside `libkrunfw`.
+
+The stock `libkrunfw` from Homebrew ships a minimal kernel without bridge,
+netfilter, or conntrack support. That is insufficient for the sandbox supervisor's
+per-sandbox network namespace primitives (veth pair + iptables, see
+`crates/openshell-sandbox/src/sandbox/linux/netns.rs`). The custom libkrunfw
+runtime adds bridge, iptables/nftables, and conntrack support to the guest
+kernel.
+
+The driver is spawned by `openshell-gateway` as a subprocess, talks to it over a
+Unix domain socket (`compute-driver.sock`) with the
+`openshell.compute.v1.ComputeDriver` gRPC surface, and manages per-sandbox
+microVMs. The runtime (libkrun + libkrunfw + gvproxy) and the sandbox
+supervisor are embedded directly in the driver binary; each sandbox guest
+rootfs is derived from a container image at create time.
## Architecture
```mermaid
graph TD
subgraph Host["Host (macOS / Linux)"]
- BIN[openshell-vm binary]
- EMB["Embedded runtime (zstd-compressed)\nlibkrun · libkrunfw · gvproxy"]
- CACHE["~/.local/share/openshell/vm-runtime/{version}/"]
- PROV[Runtime provenance logging]
- GVP[gvproxy networking proxy]
-
- BIN --> EMB
- BIN -->|extracts to| CACHE
- BIN --> PROV
- BIN -->|spawns| GVP
+ GATEWAY["openshell-gateway (compute::vm::spawn)"]
+ DRIVER["openshell-driver-vm (compute-driver.sock)"]
+ EMB["Embedded runtime (zstd) libkrun · libkrunfw · gvproxy + openshell-sandbox.zst"]
+ GVP["gvproxy (per sandbox) virtio-net · DHCP · DNS"]
+
+ GATEWAY <-->|gRPC over UDS| DRIVER
+ DRIVER --> EMB
+ DRIVER -->|spawns one per sandbox| GVP
end
- subgraph Guest["Guest VM"]
- INIT["openshell-vm-init.sh (PID 1)"]
- VAL[Validates kernel capabilities]
- CNI[Configures bridge CNI]
- EXECA["Starts exec agent\nvsock port 10777"]
- PKI[Generates mTLS PKI]
- K3S[Execs k3s server]
- EXECPY["openshell-vm-exec-agent.py"]
- CHK["check-vm-capabilities.sh"]
-
- INIT --> VAL --> CNI --> EXECA --> PKI --> K3S
+ subgraph Guest["Per-sandbox microVM"]
+ SBXINIT["/srv/openshell-vm-sandbox-init.sh"]
+ SBX["/opt/openshell/bin/openshell-sandbox (PID 1, supervisor)"]
+ SBXINIT --> SBX
end
- BIN -- "fork + krun_start_enter" --> INIT
- GVP -- "virtio-net" --> Guest
+ DRIVER -- "fork + krun_start_enter" --> SBXINIT
+ GVP -- "virtio-net eth0" --> Guest
+ SBX -.->|"outbound ConnectSupervisor gRPC stream"| GATEWAY
+ CLIENT["openshell-cli"] -->|SSH over supervisor relay| GATEWAY
```
+The driver spawns **one microVM per sandbox**. Each VM boots directly into
+`openshell-sandbox` as PID 1. All gateway ingress — SSH, exec, connect — rides
+the supervisor-initiated `ConnectSupervisor` gRPC stream opened from inside the
+guest back out to the gateway, so gvproxy is configured with `-ssh-port -1` and
+never binds a host-side TCP listener.
+
## Embedded Runtime
-The openshell-vm binary is fully self-contained, embedding both the VM runtime libraries
-and a minimal rootfs as zstd-compressed byte arrays. On first use, the binary extracts
-these to XDG cache directories with progress bars:
+`openshell-driver-vm` embeds the VM runtime libraries and the sandbox
+supervisor as zstd-compressed byte arrays, extracting on demand:
-```
-~/.local/share/openshell/vm-runtime/{version}/
+```text
+~/.local/share/openshell/vm-runtime// # libkrun / libkrunfw / gvproxy
├── libkrun.{dylib,so}
├── libkrunfw.{5.dylib,so.5}
└── gvproxy
-~/.local/share/openshell/openshell-vm/{version}/instances//rootfs/
-├── usr/local/bin/k3s
-├── opt/openshell/bin/openshell-sandbox
-├── opt/openshell/manifests/
-└── ...
-```
-
-This eliminates the need for separate bundles or downloads - a single ~120MB binary
-provides everything needed to run the VM. Old cache versions are automatically
-cleaned up when a new version is extracted.
-
-### Hybrid Approach
-
-The embedded rootfs uses a "minimal" configuration:
-- Includes: Base Ubuntu, k3s binary, supervisor binary, helm charts, manifests
-- Excludes: Pre-loaded container images (~1GB savings)
-
-Container images are pulled on demand when sandboxes are created. First boot takes
-~30-60s as k3s initializes; subsequent boots use cached state for ~3-5s startup.
-
-For fully air-gapped environments requiring pre-loaded images, build with:
-```bash
-mise run vm:rootfs # Full rootfs (~2GB, includes images)
-mise run vm:build # Rebuild binary with full rootfs
+/sandboxes//rootfs/ # per-sandbox rootfs
```
-## Network Profile
-
-The VM uses the bridge CNI profile, which requires a custom libkrunfw with bridge and
-netfilter kernel support. The init script validates these capabilities at boot and fails
-fast with an actionable error if they are missing.
-
-### Bridge Profile
-
-- CNI: bridge plugin with `cni0` interface
-- IP masquerade: enabled (iptables-legacy via CNI bridge plugin)
-- kube-proxy: enabled (nftables mode)
-- Service VIPs: functional (ClusterIP, NodePort)
-- hostNetwork workarounds: not required
+Old runtime cache versions are cleaned up when a new version is extracted.
+
+### Sandbox rootfs preparation
+
+Each VM sandbox starts from either a registry image fetched directly over OCI or
+a local Docker image reference produced by Dockerfile-based `--from` sources.
+For local Dockerfile sources, the CLI builds the image on the local Docker
+daemon and passes the ordinary image tag through `template.image`. The VM driver
+first checks the local Docker daemon for that tag; when present, it exports the
+image filesystem and **rewrites that filesystem into a supervisor-only sandbox
+guest** before caching it:
+
+- `/srv/openshell-vm-sandbox-init.sh` is installed as the guest entrypoint
+- the bundled `openshell-sandbox` binary is copied into
+ `/opt/openshell/bin/openshell-sandbox`
+- Kubernetes state and manifests are stripped out if the image contains them
+- the guest boots directly into `openshell-sandbox` -- no Kubernetes control
+ plane, no kube-proxy, no CNI plugins
+
+See `crates/openshell-driver-vm/src/rootfs.rs` for the rewrite logic and
+`crates/openshell-driver-vm/scripts/openshell-vm-sandbox-init.sh` for the init
+script that gets installed.
+
+### `--internal-run-vm` helper
+
+The driver binary has two modes: the default mode is the gRPC server; when
+launched with `--internal-run-vm` it becomes a per-sandbox launcher. The driver
+spawns one launcher per sandbox as a subprocess, which in turn starts `gvproxy`
+and calls `krun_start_enter` to boot the guest. Keeping the launcher in the
+same binary means the driver ships a single artifact for both roles.
+
+When a sandbox sets `template.image` through `openshell sandbox create --from ...`,
+the VM driver treats that image as the base guest rootfs source for that
+sandbox. When `template.image` is omitted, the gateway fills it from the VM
+driver's advertised `default_image`, which matches the gateway's configured
+sandbox image. The driver:
+
+- resolves the image on the gateway host without Docker for registry and
+ community image refs
+- for local Dockerfile sources, the CLI builds through the host Docker socket
+ and passes the resulting ordinary Docker tag through `template.image`
+- unpacks the image filesystem, injects the VM sandbox init/supervisor files,
+ and validates required guest tools such as `bash`, `mount`, `ip`, and `sed`
+- caches the prepared guest rootfs under
+ `/images//rootfs.tar`
+- extracts a private runtime copy under
+ `/sandboxes//rootfs`
+
+The cache key uses an immutable image identity: repo digest for registry images
+and the local Docker image ID for images resolved from the local daemon.
+Different VM sandboxes can use different base images concurrently because the
+shared cache is per image, not global for the driver. Cached prepared rootfs
+entries remain on disk until the operator removes them from the VM driver state
+directory.
+
+Docker is therefore no longer required for VM sandboxes created from registry or
+community image refs. It is only required on the local CLI/gateway host when the
+source is a local Dockerfile or build context.
+
+Local Dockerfile sources are treated as trusted local-development inputs for VM
+gateways. Remote VM gateways still reject local Dockerfile sources until a
+gateway-side artifact validation and transfer boundary is designed.
+
+There is no embedded guest rootfs fallback anymore. VM sandboxes therefore
+require either `template.image` or a configured default sandbox image. This is
+still replace-the-rootfs semantics, so VM images must remain base-compatible
+with the sandbox guest init path. Distroless or `scratch` images are not
+expected to work.
+
+The legacy `openshell-vm` crate remains in the repository for later
+deprecation, but it is excluded from the normal workspace and release paths.
+`openshell-driver-vm` owns active VM runtime build inputs.
+
+## Network Plane
+
+The driver launches a **dedicated `gvproxy` instance per sandbox** to provide the
+guest's networking plane:
+
+- virtio-net backend over a Unix SOCK_STREAM (Linux) or SOCK_DGRAM (macOS vfkit)
+ socket, which surfaces as `eth0` inside the guest
+- DHCP server + default router (192.168.127.1 / 192.168.127.2) for the guest's
+ udhcpc client
+- DNS for host aliases: the guest init script seeds `/etc/hosts` with
+ `host.openshell.internal` → 192.168.127.1, while leaving gvproxy's legacy
+ `host.containers.internal` / `host.docker.internal` resolution intact
+
+The `-listen` API socket and the `-ssh-port` forwarder are both intentionally
+omitted. After the supervisor-initiated relay migration the driver does not
+enqueue any host-side port forwards, and the guest's SSH listener lives on a
+Unix socket at `/run/openshell/ssh.sock` inside the VM that is reached over the
+outbound `ConnectSupervisor` gRPC stream. Binding a host listener would race
+concurrent sandboxes for port 2222 and surface a misleading "sshd is reachable"
+endpoint.
+
+The sandbox supervisor's per-sandbox netns (veth pair + iptables) branches off
+of this plane. libkrun's built-in TSI socket impersonation would not satisfy
+those kernel-level primitives, which is why we need the custom libkrunfw.
+
+## Process Lifecycle Cleanup
+
+`openshell-driver-vm` installs a cross-platform "die when my parent dies"
+primitive (`procguard`) in every link of the spawn chain so that killing
+`openshell-gateway` (SIGTERM, SIGKILL, or crash) reaps the driver, per-sandbox
+launcher, gvproxy, and the libkrun worker:
+
+- Linux: `nix::sys::prctl::set_pdeathsig(SIGKILL)`
+- macOS / BSDs: `smol-rs/polling` with `ProcessOps::Exit` on a helper thread
+- gvproxy (the one non-Rust child) gets `PR_SET_PDEATHSIG` via `pre_exec` on
+ Linux, and is SIGTERM'd from the launcher's procguard cleanup callback on
+ macOS
+
+See `crates/openshell-driver-vm/src/procguard.rs` for the implementation and
+`tasks/scripts/vm/smoke-orphan-cleanup.sh` (exposed as
+`mise run vm:smoke:orphan-cleanup`) for the regression test that covers both
+SIGTERM and SIGKILL paths.
## Runtime Provenance
-At boot, the openshell-vm binary logs provenance metadata about the loaded runtime bundle:
+At driver startup the loaded runtime bundle is logged with:
- Library paths and SHA-256 hashes
- Whether the runtime is custom-built or stock
- For custom runtimes: libkrunfw commit, kernel version, build timestamp
-This information is sourced from `provenance.json` (generated by the build script)
-and makes it straightforward to correlate VM behavior with a specific runtime artifact.
+This information is sourced from `provenance.json` (generated by the build
+script) and makes it straightforward to correlate sandbox VM behavior with a
+specific runtime artifact.
## Build Pipeline
```mermaid
graph LR
- subgraph Source["crates/openshell-vm/runtime/"]
- KCONF["kernel/openshell.kconfig\nKernel config fragment"]
- README["README.md\nOperator documentation"]
+ subgraph Source["crates/openshell-driver-vm/runtime/"]
+ KCONF["kernel/openshell.kconfig Kernel config fragment"]
end
subgraph Linux["Linux CI (build-libkrun.sh)"]
@@ -128,145 +215,128 @@ graph LR
BUILD_M["Build libkrunfw.dylib + libkrun.dylib"]
end
- subgraph Output["target/libkrun-build/"]
- LIB_SO["libkrunfw.so + libkrun.so\n(Linux)"]
- LIB_DY["libkrunfw.dylib + libkrun.dylib\n(macOS)"]
+ subgraph Output["vm-runtime-<platform>.tar.zst"]
+ LIB_SO["libkrunfw.so + libkrun.so + gvproxy (Linux)"]
+ LIB_DY["libkrunfw.dylib + libkrun.dylib + gvproxy (macOS)"]
end
- KCONF --> BUILD_L
- BUILD_L --> LIB_SO
- KCONF --> BUILD_M
- BUILD_M --> LIB_DY
+ KCONF --> BUILD_L --> LIB_SO
+ KCONF --> BUILD_M --> LIB_DY
```
+The `vm-runtime-.tar.zst` artifact is consumed by
+`openshell-driver-vm`'s `build.rs`, which embeds the library set into the
+binary via `include_bytes!()`. Setting `OPENSHELL_VM_RUNTIME_COMPRESSED_DIR`
+at build time (wired up by `tasks/scripts/gateway-vm.sh`, registered as
+`mise run gateway:vm`) points the build at the staged artifacts.
+
## Kernel Config Fragment
-The `openshell.kconfig` fragment enables these kernel features on top of the stock
-libkrunfw kernel:
+The `openshell.kconfig` fragment enables these kernel features on top of the
+stock libkrunfw kernel:
| Feature | Key Configs | Purpose |
|---------|-------------|---------|
-| Network namespaces | `CONFIG_NET_NS`, `CONFIG_NAMESPACES` | Pod isolation |
-| veth | `CONFIG_VETH` | Pod network namespace pairs |
-| Bridge device | `CONFIG_BRIDGE`, `CONFIG_BRIDGE_NETFILTER` | cni0 bridge for pod networking, kube-proxy bridge traffic visibility |
+| Network namespaces | `CONFIG_NET_NS`, `CONFIG_NAMESPACES` | Sandbox netns isolation |
+| veth | `CONFIG_VETH` | Sandbox network namespace pairs |
+| Bridge device | `CONFIG_BRIDGE`, `CONFIG_BRIDGE_NETFILTER` | Bridge support + iptables visibility into bridge traffic |
| Netfilter framework | `CONFIG_NETFILTER`, `CONFIG_NETFILTER_ADVANCED`, `CONFIG_NETFILTER_XTABLES` | iptables/nftables framework |
-| xtables match modules | `CONFIG_NETFILTER_XT_MATCH_CONNTRACK`, `_COMMENT`, `_MULTIPORT`, `_MARK`, `_STATISTIC`, `_ADDRTYPE`, `_RECENT`, `_LIMIT` | kube-proxy and kubelet iptables rules |
+| xtables match modules | `CONFIG_NETFILTER_XT_MATCH_CONNTRACK`, `_COMMENT`, `_MULTIPORT`, `_MARK`, `_STATISTIC`, `_ADDRTYPE`, `_RECENT`, `_LIMIT` | Sandbox supervisor iptables rules |
| Connection tracking | `CONFIG_NF_CONNTRACK`, `CONFIG_NF_CT_NETLINK` | NAT state tracking |
-| NAT | `CONFIG_NF_NAT` | Service VIP DNAT/SNAT |
-| iptables | `CONFIG_IP_NF_IPTABLES`, `CONFIG_IP_NF_FILTER`, `CONFIG_IP_NF_NAT`, `CONFIG_IP_NF_MANGLE` | CNI bridge masquerade and compat |
-| nftables | `CONFIG_NF_TABLES`, `CONFIG_NFT_CT`, `CONFIG_NFT_NAT`, `CONFIG_NFT_MASQ`, `CONFIG_NFT_NUMGEN`, `CONFIG_NFT_FIB_IPV4` | kube-proxy nftables mode (primary) |
-| IP forwarding | `CONFIG_IP_ADVANCED_ROUTER`, `CONFIG_IP_MULTIPLE_TABLES` | Pod-to-pod routing |
-| IPVS | `CONFIG_IP_VS`, `CONFIG_IP_VS_RR`, `CONFIG_IP_VS_NFCT` | kube-proxy IPVS mode (optional) |
-| Traffic control | `CONFIG_NET_SCH_HTB`, `CONFIG_NET_CLS_CGROUP` | Kubernetes QoS |
-| Cgroups | `CONFIG_CGROUPS`, `CONFIG_CGROUP_DEVICE`, `CONFIG_MEMCG`, `CONFIG_CGROUP_PIDS` | Container resource limits |
-| TUN/TAP | `CONFIG_TUN` | CNI plugin support |
+| NAT | `CONFIG_NF_NAT` | Sandbox egress DNAT/SNAT |
+| iptables | `CONFIG_IP_NF_IPTABLES`, `CONFIG_IP_NF_FILTER`, `CONFIG_IP_NF_NAT`, `CONFIG_IP_NF_MANGLE` | Masquerade and compat |
+| nftables | `CONFIG_NF_TABLES`, `CONFIG_NFT_CT`, `CONFIG_NFT_NAT`, `CONFIG_NFT_MASQ`, `CONFIG_NFT_NUMGEN`, `CONFIG_NFT_FIB_IPV4` | nftables path |
+| IP forwarding | `CONFIG_IP_ADVANCED_ROUTER`, `CONFIG_IP_MULTIPLE_TABLES` | Sandbox-to-host routing |
+| Traffic control | `CONFIG_NET_SCH_HTB`, `CONFIG_NET_CLS_CGROUP` | QoS |
+| Cgroups | `CONFIG_CGROUPS`, `CONFIG_CGROUP_DEVICE`, `CONFIG_MEMCG`, `CONFIG_CGROUP_PIDS` | Sandbox resource limits |
+| TUN/TAP | `CONFIG_TUN` | CNI plugin compatibility; inherited from the shared kconfig, not exercised by the driver. |
| Dummy interface | `CONFIG_DUMMY` | Fallback networking |
-| Landlock | `CONFIG_SECURITY_LANDLOCK` | Filesystem sandboxing support |
-| Seccomp filter | `CONFIG_SECCOMP_FILTER` | Syscall filtering support |
+| Landlock | `CONFIG_SECURITY_LANDLOCK` | Sandbox supervisor filesystem sandboxing |
+| Seccomp filter | `CONFIG_SECCOMP_FILTER` | Sandbox supervisor syscall filtering |
-See `crates/openshell-vm/runtime/kernel/openshell.kconfig` for the full fragment with
-inline comments explaining why each option is needed.
+See `crates/openshell-driver-vm/runtime/kernel/openshell.kconfig` for the full
+fragment with inline comments explaining why each option is needed.
## Verification
-One verification tool is provided:
-
-1. **Capability checker** (`check-vm-capabilities.sh`): Runs inside the VM to verify
- kernel capabilities. Produces pass/fail results for each required feature.
-
-## Running Commands In A Live VM
-
-The standalone `openshell-vm` binary supports `openshell-vm exec -- ` for a running VM.
-
-- Each VM instance stores local runtime state next to its instance rootfs
-- libkrun maps a per-instance host Unix socket into the guest on vsock port `10777`
-- `openshell-vm-init.sh` starts `openshell-vm-exec-agent.py` during boot
-- `openshell-vm exec` connects to the host socket, which libkrun forwards into the guest exec agent
-- The guest exec agent spawns the command, then streams stdout, stderr, and exit status back
-- The host-side bootstrap also uses the exec agent to read PKI cert files from the guest
- (via `cat /opt/openshell/pki/`) instead of requiring a separate vsock server
-
-`openshell-vm exec` also injects `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` by default so kubectl-style
-commands work the same way they would inside the VM shell.
+- **Capability checker** (`check-vm-capabilities.sh`): runs inside a sandbox VM
+ to verify kernel capabilities. Produces pass/fail results for each required
+ feature.
+- **Orphan-cleanup smoke test**: `mise run vm:smoke:orphan-cleanup` asserts
+ that killing the gateway leaves zero driver, launcher, gvproxy, or libkrun
+ survivors.
## Build Commands
-```bash
+```shell
# One-time setup: download pre-built runtime (~30s)
mise run vm:setup
-# Build and run
-mise run vm
-
-# Build embedded binary with base rootfs (~120MB, recommended)
-mise run vm:rootfs -- --base # Build base rootfs tarball
-mise run vm:build # Build binary with embedded rootfs
-
-# Build with full rootfs (air-gapped, ~2GB+)
-mise run vm:rootfs # Build full rootfs tarball
-mise run vm:build # Rebuild binary
+# Start openshell-gateway with the VM compute driver
+mise run gateway:vm
# With custom kernel (optional, adds ~20 min)
-FROM_SOURCE=1 mise run vm:setup # Build runtime from source
-mise run vm:build # Then build embedded binary
+FROM_SOURCE=1 mise run vm:setup
-# Wipe everything and start over
-mise run vm:clean
+# Remove the staged compressed runtime when you need a clean rebuild
+rm -rf target/vm-runtime-compressed
```
+See `crates/openshell-driver-vm/README.md` for the full driver workflow,
+including multi-gateway development, CLI registration, and sandbox creation
+examples.
+
## CI/CD
-The openshell-vm build is split into two GitHub Actions workflows that publish to a
-rolling `vm-dev` GitHub Release:
+The driver release path is split between on-demand runtime builds and normal
+OpenShell releases:
### Kernel Runtime (`release-vm-kernel.yml`)
-Builds the custom libkrunfw (kernel firmware), libkrun (VMM), and gvproxy for all
-supported platforms. Runs on-demand or when the kernel config / pinned versions change.
+Builds the custom libkrunfw (kernel firmware), libkrun (VMM), and gvproxy for
+all supported platforms. Run it on demand when the kernel config or pinned
+versions change.
| Platform | Runner | Build Method |
|----------|--------|-------------|
-| Linux ARM64 | `build-arm64` (self-hosted) | Native `build-libkrun.sh` |
-| Linux x86_64 | `build-amd64` (self-hosted) | Native `build-libkrun.sh` |
+| Linux ARM64 | `linux-arm64-cpu8` | Native `build-libkrun.sh` |
+| Linux x86_64 | `linux-amd64-cpu8` | Native `build-libkrun.sh` |
| macOS ARM64 | `macos-latest-xlarge` (GitHub-hosted) | `build-libkrun-macos.sh` |
-Artifacts: `vm-runtime-{platform}.tar.zst` containing libkrun, libkrunfw, gvproxy, and
-provenance metadata.
+Artifacts: `vm-runtime-{platform}.tar.zst` containing libkrun, libkrunfw,
+gvproxy, and provenance metadata. Each platform builds its own libkrunfw and
+libkrun natively; the kernel inside libkrunfw is always Linux regardless of
+host platform. The workflow publishes GitHub artifact attestations for each
+runtime tarball instead of a separate runtime checksum file.
-Each platform builds its own libkrunfw and libkrun natively. The kernel inside
-libkrunfw is always Linux regardless of host platform.
+### Driver Binary (`release-dev.yml` / `release-tag.yml`)
-### VM Binary (`release-vm-dev.yml`)
+Builds the self-contained `openshell-driver-vm` binary for every platform,
+with the kernel runtime + bundled sandbox supervisor embedded. Development
+driver binaries are published to the rolling `dev` release; tagged driver
+binaries are published to the corresponding `v*` release.
-Builds the self-extracting openshell-vm binary for all platforms. Runs on every push
-to `main` that touches VM-related crates.
+The reusable driver workflows pull the current `vm-runtime-.tar.zst`
+from the `vm-runtime` release; their build jobs set
+`OPENSHELL_VM_RUNTIME_COMPRESSED_DIR=$PWD/target/vm-runtime-compressed` and
+run `cargo build --release -p openshell-driver-vm`. The macOS driver is
+cross-compiled via osxcross (no macOS runner needed for the binary build —
+only for the kernel build).
-```mermaid
-graph TD
- CV[compute-versions] --> DL[download-kernel-runtime\nfrom vm-dev release]
- DL --> RFS_ARM[build-rootfs arm64]
- DL --> RFS_AMD[build-rootfs amd64]
- RFS_ARM --> VM_ARM[build-vm linux-arm64]
- RFS_AMD --> VM_AMD[build-vm linux-amd64]
- RFS_ARM --> VM_MAC["build-vm-macos\n(osxcross, reuses arm64 rootfs)"]
- VM_ARM --> REL[release-vm-dev\nupload to rolling release]
- VM_AMD --> REL
- VM_MAC --> REL
-```
-
-The macOS binary is cross-compiled via osxcross (no macOS runner needed for the binary
-build — only for the kernel build). The macOS VM guest is always Linux ARM64, so it
-reuses the arm64 rootfs.
-
-macOS binaries produced via osxcross are not codesigned. Users must self-sign:
-```bash
-codesign --entitlements crates/openshell-vm/entitlements.plist --force -s - ./openshell-vm
-```
+macOS driver binaries produced via osxcross are not codesigned. Local
+development builds are signed automatically by `tasks/scripts/gateway-vm.sh`
+(registered as `mise run gateway:vm`). Release tarball users on macOS must
+ad-hoc sign `openshell-driver-vm` before running VM sandboxes.
## Rollout Strategy
-1. Custom runtime is embedded by default when building with `mise run vm:build`.
-2. The init script validates kernel capabilities at boot and fails fast if missing.
-3. For development, override with `OPENSHELL_VM_RUNTIME_DIR` to use a local directory.
-4. In CI, kernel runtime is pre-built and cached in the `vm-dev` release. The binary
- build downloads it via `download-kernel-runtime.sh`.
+1. Custom runtime is embedded by default when building `openshell-driver-vm`
+ with `OPENSHELL_VM_RUNTIME_COMPRESSED_DIR` set (wired up by
+ `tasks/scripts/gateway-vm.sh`).
+2. The sandbox init script validates kernel capabilities at boot and fails
+ fast if missing.
+3. For development, override with `OPENSHELL_VM_RUNTIME_DIR` to use a local
+ directory instead of the extracted cache.
+4. In CI, the kernel runtime is pre-built and cached in the `vm-runtime` release.
+ Dev and tagged release builds download that runtime, embed it into
+ `openshell-driver-vm`, and publish the driver next to `openshell-gateway`.
diff --git a/architecture/docker-driver.md b/architecture/docker-driver.md
new file mode 100644
index 0000000000..25d48f740a
--- /dev/null
+++ b/architecture/docker-driver.md
@@ -0,0 +1,129 @@
+# Docker Driver
+
+The Docker compute driver manages sandbox containers through the local Docker
+daemon using the `bollard` client. It targets local developer environments
+where running a full Kubernetes cluster is unnecessary but Docker is already
+available.
+
+The gateway remains a host process. Each sandbox container bind-mounts a Linux
+`openshell-sandbox` supervisor binary and uses Docker host networking so the
+supervisor can connect to a gateway that is listening on host loopback without
+requiring an additional bridge-reachable listener on Linux.
+
+## Source Map
+
+| Path | Purpose |
+|---|---|
+| `crates/openshell-driver-docker/src/lib.rs` | Docker compute driver implementation |
+| `crates/openshell-driver-docker/src/tests.rs` | Unit tests for container spec, env, TLS paths, GPU, resource limits, and cache helpers |
+| `crates/openshell-server/src/cli.rs` | Gateway CLI flags for Docker driver configuration |
+| `crates/openshell-server/src/lib.rs` | In-process Docker compute runtime wiring |
+
+## Runtime Model
+
+```mermaid
+flowchart LR
+ CLI["OpenShell CLI host"] -->|gRPC/HTTP 127.0.0.1:8080| GW["Gateway host process"]
+ GW -->|Docker API| DA["Docker daemon"]
+ DA --> C["Sandbox container network_mode=host"]
+ C --> SV["openshell-sandbox supervisor"]
+ SV -->|ConnectSupervisor OPENSHELL_ENDPOINT| GW
+ SV --> NS["Nested sandbox netns workload + policy proxy"]
+```
+
+The Docker container itself uses `network_mode = "host"`. This is intentional
+for now: it makes a gateway bound to `127.0.0.1` reachable from the supervisor
+as `127.0.0.1`, matching the host process' endpoint without a bridge listener,
+NAT rule, or userland proxy.
+
+The container also gets a Docker-managed `/etc/hosts` entry for
+`host.openshell.internal` that resolves to `127.0.0.1`. This gives callers a
+stable OpenShell-owned hostname for host services without requiring changes to
+the host machine's hosts file.
+
+The supervisor still creates a nested network namespace for the actual workload
+and routes workload traffic through its policy proxy. Agent network requests are
+enforced by the supervisor in that nested namespace.
+
+## Container Spec
+
+`build_container_create_body()` constructs the Docker container:
+
+| Field | Value | Reason |
+|---|---|---|
+| `image` | Sandbox template image | User-selected runtime image |
+| `user` | `"0"` | Supervisor needs root inside the container for namespace and mount setup |
+| `entrypoint` | `/opt/openshell/bin/openshell-sandbox` | Bind-mounted supervisor binary |
+| `cmd` | Empty vector | Prevents image CMD args from being appended to the supervisor entrypoint |
+| `network_mode` | `"host"` | Lets supervisor connect to host loopback gateway endpoints |
+| `extra_hosts` | `host.openshell.internal:127.0.0.1` | Stable container-local alias for host loopback services |
+| `cap_add` | `SYS_ADMIN`, `NET_ADMIN`, `SYS_PTRACE`, `SYSLOG` | Required for supervisor isolation setup and process inspection |
+| `security_opt` | `apparmor=unconfined` | Docker's default AppArmor profile blocks mount operations required by network namespace setup |
+| `restart_policy` | `unless-stopped` | Resume managed sandboxes after Docker or gateway restarts |
+| `device_requests` | CDI all-GPU request when `spec.gpu` is true | Enables Docker CDI GPU sandboxes when daemon support is detected |
+
+## Gateway Callback
+
+The Docker driver injects `OPENSHELL_ENDPOINT` into each sandbox container from
+`Config::grpc_endpoint` without rewriting it. This is the key difference from a
+bridge-network design.
+
+Examples:
+
+```shell
+OPENSHELL_GRPC_ENDPOINT=http://127.0.0.1:8080
+```
+
+and:
+
+```shell
+OPENSHELL_GRPC_ENDPOINT=https://127.0.0.1:8080
+```
+
+are passed into the supervisor as-is. Because the container shares the host
+network namespace, `127.0.0.1` resolves to the host loopback interface and the
+gateway is reachable when it binds loopback.
+
+The endpoint can also use the stable alias:
+
+```shell
+OPENSHELL_GRPC_ENDPOINT=http://host.openshell.internal:8080
+```
+
+In host network mode this name resolves to `127.0.0.1` inside the container.
+
+For TLS endpoints, the gateway certificate must include the exact endpoint host
+as a subject alternative name. For `https://127.0.0.1:8080`, the certificate
+needs an IP SAN for `127.0.0.1`. For `https://localhost:8080`, it needs a DNS
+SAN for `localhost`. For `https://host.openshell.internal:8080`, it needs a DNS
+SAN for `host.openshell.internal`. Docker sandboxes also require client TLS
+material:
+
+| Env / flag | Purpose |
+|---|---|
+| `OPENSHELL_DOCKER_TLS_CA` / `--docker-tls-ca` | CA certificate mounted at `/etc/openshell/tls/client/ca.crt` |
+| `OPENSHELL_DOCKER_TLS_CERT` / `--docker-tls-cert` | Client certificate mounted at `/etc/openshell/tls/client/tls.crt` |
+| `OPENSHELL_DOCKER_TLS_KEY` / `--docker-tls-key` | Client private key mounted at `/etc/openshell/tls/client/tls.key` |
+
+When `OPENSHELL_GRPC_ENDPOINT` uses `http://`, these TLS mounts are not
+required and providing them is rejected. When it uses `https://`, all three are
+required.
+
+## Environment
+
+`build_environment()` merges template environment, spec environment, and
+driver-controlled keys. Driver-controlled keys win:
+
+| Variable | Value |
+|---|---|
+| `OPENSHELL_ENDPOINT` | Exact configured gateway endpoint |
+| `OPENSHELL_SANDBOX_ID` | Sandbox id |
+| `OPENSHELL_SANDBOX` | Sandbox name |
+| `OPENSHELL_SSH_SOCKET_PATH` | Unix socket path used by the supervisor's embedded SSH daemon |
+| `OPENSHELL_SANDBOX_COMMAND` | `sleep infinity` |
+| `OPENSHELL_TLS_CA` | Mounted CA path when HTTPS is enabled |
+| `OPENSHELL_TLS_CERT` | Mounted client cert path when HTTPS is enabled |
+| `OPENSHELL_TLS_KEY` | Mounted client key path when HTTPS is enabled |
+
+The Docker driver does not inject `OPENSHELL_SSH_HANDSHAKE_SECRET`; the
+supervisor-to-gateway path relies on mTLS for the Docker callback.
diff --git a/architecture/gateway-deploy-connect.md b/architecture/gateway-deploy-connect.md
index ec8153302c..14bb3e90fb 100644
--- a/architecture/gateway-deploy-connect.md
+++ b/architecture/gateway-deploy-connect.md
@@ -99,7 +99,7 @@ This stores `auth_mode = "plaintext"`, skips mTLS certificate extraction, and by
All connection artifacts are stored under `$XDG_CONFIG_HOME/openshell/` (default `~/.config/openshell/`):
-```
+```text
openshell/
active_gateway # plain text: active gateway name
gateways/
diff --git a/architecture/gateway-security.md b/architecture/gateway-security.md
index 4989f69b6f..b8c00571d8 100644
--- a/architecture/gateway-security.md
+++ b/architecture/gateway-security.md
@@ -2,17 +2,17 @@
## Overview
-By default, communication with the OpenShell gateway is secured by mutual TLS (mTLS). The CLI, SDK, and sandbox pods present certificates signed by the cluster CA before they reach any application handler. The PKI is bootstrapped automatically during cluster deployment, and certificates are distributed to Kubernetes secrets and the local filesystem without manual configuration.
+By default, communication with the OpenShell gateway is secured by mutual TLS (mTLS). The CLI, SDK, and sandbox workloads present certificates signed by the deployment CA before they reach any application handler. In Helm deployments, operators provide the certificate bundle as Kubernetes secrets and place the CLI client bundle in the local gateway credential directory. Non-Kubernetes deployments provide equivalent certificate files to the gateway and sandbox runtime.
The gateway also supports Cloudflare-fronted deployments where the edge, not the gateway, is the first authentication boundary. In that mode the gateway either keeps TLS enabled but allows no-certificate client handshakes (`allow_unauthenticated=true`) and relies on application-layer Cloudflare JWTs, or disables gateway TLS entirely and serves plaintext behind a trusted reverse proxy or tunnel.
-This document covers the certificate hierarchy, the bootstrap process, how gateway transport security modes are enforced, how sandboxes and the CLI consume their certificates, and the broader security model of the gateway.
+This document covers the certificate hierarchy, how gateway transport security modes are enforced, how sandboxes and the CLI consume their certificates, and the broader security model of the gateway.
## Architecture Diagram
```mermaid
graph TD
- subgraph PKI["PKI (generated at bootstrap)"]
+ subgraph PKI["PKI (operator provided)"]
CA["openshell-ca (self-signed root)"]
SERVER_CERT["openshell-server cert (signed by CA)"]
CLIENT_CERT["openshell-client cert (signed by CA, shared)"]
@@ -20,17 +20,17 @@ graph TD
CA --> CLIENT_CERT
end
- subgraph CLUSTER["Kubernetes Cluster"]
+ subgraph CLUSTER["Kubernetes Helm Deployment"]
S1["openshell-server-tls Secret (server cert+key)"]
S2["openshell-server-client-ca Secret (CA cert)"]
S3["openshell-client-tls Secret (client cert+key+CA)"]
GW["Gateway Process (tokio-rustls)"]
- SBX["Sandbox Pod"]
+ SBX["Sandbox Workload"]
end
subgraph HOST["User's Machine"]
CLI["CLI"]
- MTLS_DIR["~/.config/openshell/ clusters/<name>/mtls/"]
+ MTLS_DIR["~/.config/openshell/ gateways/<name>/mtls/"]
end
SERVER_CERT --> S1
@@ -49,9 +49,9 @@ graph TD
## Certificate Hierarchy
-The PKI is a single-tier CA hierarchy generated by the `openshell-bootstrap` crate using `rcgen`. All certificates are created in a single pass at cluster bootstrap time.
+The default PKI shape is a single-tier CA hierarchy. Operators can generate this bundle with their internal PKI tooling, cert-manager, or a local development CA.
-```
+```text
openshell-ca (Self-signed Root CA, O=openshell, CN=openshell-ca)
├── openshell-server (Leaf cert, CN=openshell-server)
│ SANs: openshell, openshell.openshell.svc,
@@ -60,28 +60,26 @@ openshell-ca (Self-signed Root CA, O=openshell, CN=openshell-ca)
│ + extra SANs for remote deployments
│
└── openshell-client (Leaf cert, CN=openshell-client)
- Shared by the CLI and all sandbox pods.
+ Shared by the CLI and all sandbox workloads.
```
Key design decisions:
-- **Single client certificate**: One client cert is shared by the CLI and every sandbox pod. This simplifies secret management. Individual sandbox identity is not expressed at the TLS layer; post-authentication identification uses the `x-sandbox-id` gRPC header.
-- **Long-lived certificates**: Certificates use `rcgen` defaults (validity ~1975--4096), which effectively never expire. This is appropriate for an internal dev-cluster PKI where certificates are ephemeral to the cluster's lifetime.
-- **CA key not persisted**: The CA private key is used only during generation and is not stored in any Kubernetes secret. Re-signing requires regenerating the entire PKI.
-
-See `crates/openshell-bootstrap/src/pki.rs:35` for the `generate_pki()` implementation and `crates/openshell-bootstrap/src/pki.rs:18` for the default SAN list.
+- **Single client certificate**: One client cert is shared by the CLI and every sandbox workload. This simplifies secret management. Individual sandbox identity is not expressed at the TLS layer; post-authentication identification uses the `x-sandbox-id` gRPC header.
+- **Certificate lifetime**: Certificate validity is owned by the operator's PKI policy.
+- **CA key not stored in OpenShell**: The chart consumes certificates and CA bundles, but it does not need the CA private key.
## Kubernetes Secret Distribution
-The PKI bundle is distributed as three Kubernetes secrets in the `openshell` namespace:
+In Helm deployments, the PKI bundle is distributed as three Kubernetes secrets in the `openshell` namespace:
| Secret Name | Type | Contents | Consumed By |
|---|---|---|---|
| `openshell-server-tls` | `kubernetes.io/tls` | `tls.crt` (server cert), `tls.key` (server key) | Gateway StatefulSet |
| `openshell-server-client-ca` | `Opaque` | `ca.crt` (CA cert) | Gateway StatefulSet (client verification) |
-| `openshell-client-tls` | `Opaque` | `tls.crt` (client cert), `tls.key` (client key), `ca.crt` (CA cert) | Sandbox pods, CLI (via local filesystem) |
+| `openshell-client-tls` | `Opaque` | `tls.crt` (client cert), `tls.key` (client key), `ca.crt` (CA cert) | Sandbox workloads, CLI (via local filesystem) |
-Secret names are defined as constants in `crates/openshell-bootstrap/src/constants.rs:6-10`.
+Secret names are chart values under `server.tls.*` in `deploy/helm/openshell/values.yaml`.
### Gateway Mounts
@@ -94,21 +92,21 @@ The Helm StatefulSet (`deploy/helm/openshell/templates/statefulset.yaml`) mounts
Environment variables point the gateway binary to these paths:
-```
+```text
OPENSHELL_TLS_CERT=/etc/openshell-tls/server/tls.crt
OPENSHELL_TLS_KEY=/etc/openshell-tls/server/tls.key
OPENSHELL_TLS_CLIENT_CA=/etc/openshell-tls/client-ca/ca.crt
```
-### Sandbox Pod Mounts
+### Sandbox Workload TLS Material
-When the gateway creates a sandbox pod (`crates/openshell-server/src/sandbox/mod.rs:681`), it injects:
+When the Kubernetes driver creates a sandbox pod, it injects:
- A volume backed by the `openshell-client-tls` secret.
- A read-only mount at `/etc/openshell-tls/client/` on the agent container.
- Environment variables for the sandbox gRPC client:
-```
+```text
OPENSHELL_TLS_CA=/etc/openshell-tls/client/ca.crt
OPENSHELL_TLS_CERT=/etc/openshell-tls/client/tls.crt
OPENSHELL_TLS_KEY=/etc/openshell-tls/client/tls.key
@@ -119,7 +117,7 @@ OPENSHELL_ENDPOINT=https://openshell.openshell.svc.cluster.local:8080
The CLI's copy of the client certificate bundle is written to:
-```
+```text
$XDG_CONFIG_HOME/openshell/gateways//mtls/
├── ca.crt
├── tls.crt
@@ -128,41 +126,31 @@ $XDG_CONFIG_HOME/openshell/gateways//mtls/
Files are written atomically using a temp-dir -> validate -> rename strategy with backup and rollback on failure. See `crates/openshell-bootstrap/src/mtls.rs:10`.
-## PKI Bootstrap Sequence
+## PKI Provisioning Sequence
+
+PKI provisioning is operator-driven:
-PKI provisioning occurs during `deploy_cluster_with_logs()` (`crates/openshell-bootstrap/src/lib.rs:284`). The full sequence:
+1. Generate or obtain a server certificate, server key, client certificate, client key, and CA certificate.
+2. Provide the server certificate and client CA to the gateway process.
+3. Provide the client certificate bundle to sandbox workloads through the selected compute driver.
+4. Store the same client bundle under `~/.config/openshell/gateways//mtls/` so the CLI can authenticate to the gateway.
-1. **Cluster container launched** -- a k3s container is created via Docker with a persistent volume.
-2. **k3s readiness** -- the bootstrap waits for k3s to become ready inside the container.
-3. **Extra SANs computed** -- for remote deployments, the SSH destination hostname and its resolved IP are added to the server certificate's SANs. For local deployments, the detected gateway host (if any) is added.
-4. **`reconcile_pki()` called** (`crates/openshell-bootstrap/src/lib.rs:515`):
- 1. Wait for the `openshell` namespace to exist (created by the Helm controller).
- 2. Attempt to load existing PKI from the three K8s secrets via `kubectl get secret` exec'd inside the container. Each field is base64-decoded and validated for PEM markers.
- 3. **If secrets exist and are valid**: reuse them and return `rotated=false`.
- 4. **If secrets are missing, incomplete, or malformed**: generate fresh PKI via `generate_pki()`, apply all three secrets via `kubectl apply`, and return `rotated=true`.
-5. **Workload restart on rotation** -- if `rotated=true` and the openshell StatefulSet already exists, the bootstrap performs `kubectl rollout restart` and waits for completion. This ensures the server picks up new TLS secrets before the CLI writes its local copy.
-6. **CLI-side credential storage** -- `store_pki_bundle()` writes `ca.crt`, `tls.crt`, `tls.key` to the local filesystem.
+For Helm deployments, steps 2 and 3 use the `openshell-server-tls`, `openshell-server-client-ca`, and `openshell-client-tls` Kubernetes secrets before installing or upgrading the chart.
```mermaid
sequenceDiagram
- participant CLI as nav deploy
- participant Docker as Cluster Container
- participant K8s as k3s / K8s API
-
- CLI->>Docker: Create container, wait for k3s
- CLI->>K8s: Wait for openshell namespace
- CLI->>K8s: Read existing TLS secrets
- alt Secrets valid
- CLI->>CLI: Reuse existing PKI
- else Secrets missing/invalid
- CLI->>CLI: generate_pki(extra_sans)
- CLI->>K8s: kubectl apply (3 secrets)
- alt Workload exists
- CLI->>K8s: kubectl rollout restart
- CLI->>K8s: Wait for rollout complete
- end
- end
- CLI->>CLI: store_pki_bundle() to local filesystem
+ participant O as Operator
+ participant K8s as Kubernetes API
+ participant Helm as Helm
+ participant GW as Gateway Pod
+ participant CLI as CLI
+
+ O->>K8s: Create TLS and SSH handshake secrets
+ O->>Helm: helm upgrade --install
+ Helm->>K8s: Apply StatefulSet and mounts
+ K8s->>GW: Start gateway with mounted certs
+ O->>CLI: Store client cert bundle locally
+ CLI->>GW: Connect with mTLS
```
## Gateway TLS Enforcement
@@ -183,7 +171,7 @@ The gateway supports three transport modes:
### Connection Flow
-```
+```text
TCP accept
→ TLS handshake (mandatory client cert in mTLS mode, optional in dual-auth mode)
→ hyper auto-negotiates HTTP/1.1 or HTTP/2 via ALPN
@@ -216,7 +204,7 @@ The e2e test suite (`e2e/python/test_security_tls.py`) validates four scenarios:
## Sandbox-to-Gateway mTLS
-Sandbox pods connect back to the gateway at startup to fetch their policy and provider credentials. The gRPC client (`crates/openshell-sandbox/src/grpc_client.rs:18`) reads three environment variables to configure mTLS:
+Sandbox workloads connect back to the gateway at startup to fetch their policy and provider credentials. The gRPC client (`crates/openshell-sandbox/src/grpc_client.rs:18`) reads three environment variables to configure mTLS:
| Env Var | Value |
|---|---|
@@ -225,10 +213,12 @@ Sandbox pods connect back to the gateway at startup to fetch their policy and pr
| `OPENSHELL_TLS_KEY` | `/etc/openshell-tls/client/tls.key` |
These are used to build a `tonic::transport::ClientTlsConfig` with:
-- `ca_certificate()` -- verifies the server's certificate against the cluster CA.
+
+- `ca_certificate()` -- verifies the server's certificate against the deployment CA.
- `identity()` -- presents the shared client certificate for mTLS.
The sandbox calls two RPCs over this authenticated channel:
+
- `GetSandboxSettings` -- fetches the YAML policy that governs the sandbox's behavior.
- `GetSandboxProviderEnvironment` -- fetches provider credentials as environment variables.
@@ -269,34 +259,41 @@ The gateway enforces two concurrent connection limits to bound the impact of cre
These limits are tracked in-memory and decremented when tunnels close. Exceeding either limit returns HTTP 429 (Too Many Requests).
-### NSSH1 Handshake
+### Supervisor-Initiated Relay Model
-After the gateway connects to the sandbox pod's SSH port, it performs a cryptographic handshake:
+The gateway never dials the sandbox. Instead, the sandbox supervisor opens an outbound `ConnectSupervisor` bidirectional gRPC stream to the gateway on startup and keeps it alive for the sandbox lifetime. SSH traffic for `/connect/ssh` (and exec traffic for `ExecSandbox`) rides this same TCP+TLS+HTTP/2 connection as separate multiplexed HTTP/2 streams. The gateway-side registry and `RelayStream` handler live in `crates/openshell-server/src/supervisor_session.rs`; the supervisor-side bridge lives in `crates/openshell-sandbox/src/supervisor_session.rs`.
-```
-NSSH1 \n
-```
+Per-connection flow:
+
+1. CLI presents `x-sandbox-id` + `x-sandbox-token` at `/connect/ssh` and passes gateway token validation.
+2. Gateway calls `SupervisorSessionRegistry::open_relay(sandbox_id, ...)`, which allocates a `channel_id` (UUID) and sends a `RelayOpen` message to the supervisor over the already-established `ConnectSupervisor` stream. If no session is registered yet, it polls with exponential backoff up to a bounded timeout (30 s for `/connect/ssh`, 15 s for `ExecSandbox`).
+3. The supervisor opens a new `RelayStream` RPC on the same `Channel` — a new HTTP/2 stream, no new TCP connection and no new TLS handshake. The first `RelayFrame` is a `RelayInit { channel_id }` that claims the pending slot on the gateway.
+4. `claim_relay` pairs the gateway-side waiter with the supervisor-side RPC via a `tokio::io::duplex(64 KiB)` pair. Subsequent `RelayFrame::data` frames carry raw SSH bytes in both directions. The supervisor is a dumb byte bridge: it has no protocol awareness of the SSH bytes flowing through.
+5. Inside the sandbox workload, the supervisor connects the relay to sshd over a Unix domain socket at `/run/openshell/ssh.sock`.
+
+Security properties of this model:
-- **HMAC**: `HMAC-SHA256(secret, "{token}|{timestamp}|{nonce}")`, hex-encoded.
-- **Secret**: shared via `OPENSHELL_SSH_HANDSHAKE_SECRET` env var, set on both the gateway and sandbox.
-- **Clock skew tolerance**: configurable via `OPENSHELL_SSH_HANDSHAKE_SKEW_SECS` (default 300 seconds).
-- **Expected response**: `OK\n` from the sandbox.
+- **One auth boundary.** mTLS on the `ConnectSupervisor` stream is the only identity check between gateway and sandbox. Every relay rides that same authenticated HTTP/2 connection.
+- **No inbound network path into the sandbox.** The sandbox exposes no TCP port for gateway ingress; all relays are supervisor-initiated. The workload only needs egress to the gateway.
+- **In-workload access control is filesystem permissions on the Unix socket.** sshd listens on `/run/openshell/ssh.sock` with the parent directory at `0700` and the socket itself at `0600`, both owned by the supervisor (root). The sandbox entrypoint runs as an unprivileged user and cannot open either. Any process in the supervisor's filesystem view that can open the socket can reach sshd; this is the same trust model as any local Unix socket with `0600` permissions. See `crates/openshell-sandbox/src/ssh.rs:55-83`.
+- **Supersede race is closed.** A supervisor reconnect registers a new `session_id` for the same sandbox id. Cleanup on the old session's task uses `remove_if_current(sandbox_id, session_id)` so a late-finishing old task cannot evict the new registration or serve relays meant for the new instance. See `SupervisorSessionRegistry::remove_if_current` in `crates/openshell-server/src/supervisor_session.rs`.
+- **Pending-relay reaper.** A background task sweeps `pending_relays` entries older than 10 s (`RELAY_PENDING_TIMEOUT`). If the supervisor acknowledges `RelayOpen` but never initiates `RelayStream` — crash, deadlock, or adversarial stall — the gateway-side slot does not pin indefinitely.
+- **Client-side keepalives.** The CLI's `ssh` invocation sets `ServerAliveInterval=15` / `ServerAliveCountMax=3` (`crates/openshell-cli/src/ssh.rs:150`), so a silently-dropped relay (gateway restart, supervisor restart, or adversarial TCP drop) surfaces to the user within roughly 45 s rather than hanging.
-This handshake prevents direct connections to sandbox SSH ports from within the cluster, even from pods that share the network.
+Observability (sandbox side, OCSF): `session_established`, `session_closed`, `session_failed`, `relay_open`, `relay_closed`, `relay_failed`, `relay_close_from_gateway` — all emitted as `NetworkActivity` events. Gateway-side OCSF emission for the same lifecycle is a tracked follow-up.
## Port Configuration
-Traffic flows through several layers from the host to the gateway process:
+Traffic flows through the configured gateway exposure path to the gateway process. Kubernetes deployments use the Helm-managed service; standalone deployments bind the gateway port directly or place it behind an operator-managed proxy.
| Layer | Port | Configurable Via |
|---|---|---|
-| Host (Docker) | `8080` (default) | `--port` flag on `nav deploy` |
-| Container | `30051` | Hardcoded in `crates/openshell-bootstrap/src/docker.rs` |
-| k3s NodePort | `30051` | `deploy/helm/openshell/values.yaml` (`service.nodePort`) |
-| k3s Service | `8080` | `deploy/helm/openshell/values.yaml` (`service.port`) |
+| External ingress / port-forward / load balancer / reverse proxy | Operator choice | Platform-specific service or proxy configuration |
+| Kubernetes Service | `8080` by default | `deploy/helm/openshell/values.yaml` (`service.port`) |
+| NodePort, when enabled | `30051` by default | `deploy/helm/openshell/values.yaml` (`service.nodePort`) |
| Server bind | `8080` | `--port` flag / `OPENSHELL_SERVER_PORT` env var |
-Docker maps `host_port → 30051/tcp`. Inside k3s, the NodePort service maps `30051 → 8080 (pod port)`. The server binds `0.0.0.0:8080`.
+The server binds `0.0.0.0:8080` by default. The chart maps the service port to the gateway workload's `grpc` port for Kubernetes deployments.
## Security Model Summary
@@ -314,19 +311,19 @@ graph LR
API["gRPC + HTTP API"]
end
- subgraph KUBE["Kubernetes"]
- SBX["Sandbox Pod"]
+ subgraph PLATFORM["Compute Platform"]
+ SBX["Sandbox Workload"]
end
subgraph INET["Internet"]
HOSTS["Allowed Hosts"]
end
- CLI -- "mTLS (cluster CA)" --> TLS
- SDK -- "mTLS (cluster CA)" --> TLS
+ CLI -- "mTLS (deployment CA)" --> TLS
+ SDK -- "mTLS (deployment CA)" --> TLS
TLS --> API
- SBX -- "mTLS (cluster CA)" --> TLS
- API -- "SSH + NSSH1 handshake" --> SBX
+ SBX -- "mTLS + ConnectSupervisor (supervisor-initiated)" --> TLS
+ API -- "RelayStream (HTTP/2 on same mTLS conn)" --> SBX
SBX -- "OPA policy + process identity" --> HOSTS
```
@@ -334,9 +331,10 @@ graph LR
| Boundary | Mechanism |
|---|---|
-| External → Gateway | mTLS with cluster CA by default, or trusted reverse-proxy/Cloudflare boundary in edge mode |
-| Sandbox → Gateway | mTLS with shared client cert |
-| Gateway → Sandbox (SSH) | Session token + HMAC-SHA256 handshake (NSSH1) |
+| External → Gateway | mTLS with deployment CA by default, or trusted reverse-proxy/Cloudflare boundary in edge mode |
+| Sandbox → Gateway | mTLS with shared client cert (supervisor-initiated `ConnectSupervisor` stream) |
+| Gateway → Sandbox (SSH/exec) | Rides the supervisor's mTLS `ConnectSupervisor` HTTP/2 connection as a `RelayStream`; no separate gateway-to-sandbox network connection |
+| Supervisor → workload sshd | Unix-socket filesystem permissions (`/run/openshell/ssh.sock`, 0700 parent / 0600 socket) |
| Sandbox → External (network) | OPA policy + process identity binding via `/proc` |
### What Is Not Authenticated (by Design)
@@ -346,7 +344,7 @@ graph LR
### Gateway Security Context
-The gateway container runs with a hardened security context (`deploy/helm/openshell/values.yaml:25`):
+The gateway workload runs with a hardened security context (`deploy/helm/openshell/values.yaml:25`):
```yaml
securityContext:
@@ -377,18 +375,21 @@ This section defines the primary attacker profiles, what the current design prot
|---|---|
| Network attacker | Can observe/modify traffic between clients and gateway |
| Unauthorized external client | Can reach gateway port but has no valid client cert |
-| Compromised sandbox workload | Has code execution inside one sandbox pod |
-| Malicious in-cluster pod | Can attempt direct pod-to-pod connections |
+| Compromised sandbox workload | Has code execution inside one sandbox workload |
+| Malicious platform peer | Can attempt direct workload-to-workload connections |
| Stolen CLI credentials | Has copied `ca.crt`/`tls.crt`/`tls.key` from a developer machine |
### Primary Defenses
| Threat | Existing Defense | Notes |
|---|---|---|
-| MITM or passive interception of gateway traffic | Mandatory mTLS with cluster CA, or trusted reverse-proxy boundary in Cloudflare mode | Default mode is direct mTLS; reverse-proxy mode shifts the outer trust boundary upstream |
+| MITM or passive interception of gateway traffic | Mandatory mTLS with deployment CA, or trusted reverse-proxy boundary in Cloudflare mode | Default mode is direct mTLS; reverse-proxy mode shifts the outer trust boundary upstream |
| Unauthenticated API/health access | mTLS by default, or Cloudflare/reverse-proxy auth in edge mode | `/health*` are direct-mTLS only in the default deployment mode |
-| Forged SSH tunnel connection to sandbox | Session token validation + NSSH1 HMAC handshake | Requires token and shared handshake secret |
-| Direct access to sandbox SSH port from cluster peers | NSSH1 challenge-response | Connection denied without valid signature |
+| Forged SSH tunnel connection to sandbox | Session token validation at the gateway; only the supervisor's authenticated mTLS `ConnectSupervisor` stream can carry a `RelayStream` to its sandbox | Forging a relay requires stealing a valid mTLS client identity |
+| Direct access to sandbox sshd from platform peers | sshd listens on a Unix socket (`0700` parent / `0600` socket) inside the workload | No network path exists to sshd from platform peers |
+| Stale or reconnecting supervisor serves relays for a new instance | `session_id`-scoped `remove_if_current` on the registry | Old session cleanup cannot evict a newer registration |
+| Supervisor acknowledges `RelayOpen` but never initiates `RelayStream` | Gateway-side pending-relay reaper (10 s timeout) | Prevents indefinite resource pinning by a buggy or malicious supervisor |
+| Silent TCP drop of an in-flight relay | CLI `ServerAliveInterval=15` / `ServerAliveCountMax=3` | Client detects a dead relay within ~45 s instead of hanging |
| Unauthorized outbound internet access from sandbox | OPA policy + process identity checks | Applies to sandbox egress policy layer |
### Residual Risks and Current Tradeoffs
@@ -404,35 +405,35 @@ This section defines the primary attacker profiles, what the current design prot
### Out of Scope / Not Defended By This Layer
-- A fully compromised Kubernetes control plane or cluster-admin account.
-- A malicious actor with direct access to Kubernetes secrets in the `openshell` namespace.
+- A fully compromised compute platform, such as a Kubernetes control plane, container host, or VM host.
+- A malicious actor with direct access to deployment secrets for the gateway or sandbox runtime.
- Host-level compromise of the developer workstation running the CLI.
- Application-layer authorization bugs after mTLS authentication succeeds.
### Trust Assumptions
-- The cluster CA is generated and distributed without interception during bootstrap.
-- Kubernetes secret access is restricted to intended workloads and operators.
+- The deployment CA is generated and distributed without interception during provisioning.
+- Secret access is restricted to intended workloads and operators.
- Gateway and sandbox container images are trusted and not tampered with.
-- System clocks are reasonably synchronized for timestamp-based SSH handshake checks.
+- The sandbox workload's filesystem is trusted: only the supervisor process (root) can open `/run/openshell/ssh.sock`, which is enforced by the `0700` parent directory and `0600` socket permissions set at sshd start.
## Sandbox Outbound TLS (L7 Inspection)
-Separate from the cluster mTLS infrastructure, each sandbox has an independent TLS capability for inspecting outbound HTTPS traffic. This is documented here for completeness because it involves a distinct, per-sandbox PKI.
+Separate from the gateway mTLS infrastructure, each sandbox has an independent TLS capability for inspecting outbound HTTPS traffic. This is documented here for completeness because it involves a distinct, per-sandbox PKI.
The sandbox proxy automatically detects and terminates TLS on outbound HTTPS connections by peeking the first bytes of each tunnel. This enables credential injection and L7 inspection without requiring explicit policy configuration. The proxy performs TLS man-in-the-middle inspection:
-1. **Ephemeral sandbox CA**: a per-sandbox CA (`CN=OpenShell Sandbox CA, O=OpenShell`) is generated at sandbox startup. This CA is completely independent of the cluster mTLS CA.
+1. **Ephemeral sandbox CA**: a per-sandbox CA (`CN=OpenShell Sandbox CA, O=OpenShell`) is generated at sandbox startup. This CA is completely independent of the gateway mTLS CA.
2. **Trust injection**: the sandbox CA is written to the sandbox filesystem and injected via `NODE_EXTRA_CA_CERTS` and `SSL_CERT_FILE` so processes inside the sandbox trust it.
3. **Dynamic leaf certs**: for each target hostname, the proxy generates and caches a leaf certificate signed by the sandbox CA (up to 256 entries).
-4. **Upstream verification**: the proxy verifies upstream server certificates against Mozilla root CAs (`webpki-roots`), not against the cluster CA.
+4. **Upstream verification**: the proxy verifies upstream server certificates against Mozilla root CAs (`webpki-roots`) and system CA certificates from the container's trust store, not against the gateway mTLS CA. Custom sandbox images can add corporate/internal CAs via `update-ca-certificates`.
This capability is orthogonal to gateway mTLS -- it operates only on sandbox-to-internet traffic and uses entirely separate key material. See [Policy Language](security-policy.md) for configuration details.
## Cross-References
- [Gateway Architecture](gateway.md) -- protocol multiplexing, gRPC services, persistence, and SSH tunneling
-- [Cluster Bootstrap](cluster-single-node.md) -- cluster provisioning, Docker container lifecycle, and credential management
+- [Gateway Deployment and Compute Platforms](gateway-single-node.md) -- gateway deployment modes, compute platform inputs, and removed k3s responsibilities
- [Sandbox Architecture](sandbox.md) -- sandbox-side isolation, proxy, and policy enforcement
- [Sandbox Connect](sandbox-connect.md) -- client-side SSH connection flow through the gateway
- [Policy Language](security-policy.md) -- YAML/Rego policy system including L7 TLS inspection configuration
diff --git a/architecture/gateway-settings.md b/architecture/gateway-settings.md
index ef9538f5b0..0b6da9bdd8 100644
--- a/architecture/gateway-settings.md
+++ b/architecture/gateway-settings.md
@@ -30,9 +30,8 @@ The `REGISTERED_SETTINGS` static array defines the allowed setting keys and thei
```rust
pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[
- RegisteredSetting { key: "log_level", kind: SettingValueKind::String },
- RegisteredSetting { key: "dummy_int", kind: SettingValueKind::Int },
- RegisteredSetting { key: "dummy_bool", kind: SettingValueKind::Bool },
+ RegisteredSetting { key: "providers_v2_enabled", kind: SettingValueKind::Bool },
+ RegisteredSetting { key: "ocsf_json_enabled", kind: SettingValueKind::Bool },
];
```
@@ -45,6 +44,7 @@ pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[
The reserved key `policy` is excluded from the registry. It is handled by dedicated policy commands and stored as a hex-encoded protobuf `SandboxPolicy` in the global settings' `Bytes` variant. Attempts to set or delete the `policy` key through settings commands are rejected.
Helper functions:
+
- `setting_for_key(key)` -- look up a `RegisteredSetting` by name, returns `None` for unknown keys
- `registered_keys_csv()` -- comma-separated list of valid keys for error messages
- `parse_bool_like(raw)` -- flexible bool parsing from CLI string input
@@ -83,6 +83,7 @@ The `UpdateSettings` RPC multiplexes policy and setting mutations through a sing
| `global` | `bool` | Target gateway-global scope instead of sandbox scope |
Validation rules:
+
- `policy` and `setting_key` cannot both be present
- At least one of `policy` or `setting_key` must be present
- `delete_setting` cannot be combined with a `policy` payload
@@ -266,7 +267,7 @@ This prevents conflicting values at different scopes. An operator must delete a
When a global policy is set, sandbox-scoped policy updates via `UpdateSettings` are rejected with `FailedPrecondition`:
-```
+```text
policy is managed globally; delete global policy before sandbox policy update
```
@@ -371,15 +372,14 @@ Set a single setting key at sandbox or global scope.
```bash
# Sandbox-scoped
-openshell settings set my-sandbox --key log_level --value debug
+openshell settings set my-sandbox --key ocsf_json_enabled --value true
# Global (requires confirmation)
-openshell settings set --global --key log_level --value warn
-openshell settings set --global --key dummy_bool --value yes
-openshell settings set --global --key dummy_int --value 42
+openshell settings set --global --key providers_v2_enabled --value true
+openshell settings set --global --key ocsf_json_enabled --value true
# Skip confirmation
-openshell settings set --global --key log_level --value info --yes
+openshell settings set --global --key providers_v2_enabled --value true --yes
```
Value parsing is type-aware: bool keys accept `true/false/yes/no/1/0/on/off` via `parse_bool_like()`. Int keys parse as base-10 `i64`. String keys accept any value.
@@ -390,7 +390,7 @@ Delete a setting key from the specified scope.
```bash
# Global delete (unlocks sandbox control)
-openshell settings delete --global --key log_level --yes
+openshell settings delete --global --key providers_v2_enabled --yes
```
### `policy set --global --policy FILE [--yes]`
@@ -442,6 +442,7 @@ openshell policy get --global --full
All `--global` mutations require human-in-the-loop confirmation via an interactive prompt. The `--yes` flag bypasses the prompt for scripted/CI usage. In non-interactive mode (no TTY), `--yes` is required -- otherwise the command fails with an error.
The confirmation message varies:
+
- **Global setting set**: warns that this will override sandbox-level values for the key
- **Global setting delete**: warns that this re-enables sandbox-level management
- **Global policy set**: warns that this overrides all sandbox policies
@@ -499,26 +500,26 @@ Settings are refreshed on each 2-second polling tick alongside the sandbox list
## Data Flow: Setting a Global Key
-End-to-end trace for `openshell settings set --global --key log_level --value debug --yes`:
+End-to-end trace for `openshell settings set --global --key providers_v2_enabled --value true --yes`:
1. **CLI** (`crates/openshell-cli/src/run.rs` -- `gateway_setting_set()`):
- - `parse_cli_setting_value("log_level", "debug")` -- looks up `SettingValueKind::String` in the registry, wraps as `SettingValue { string_value: "debug" }`
+ - `parse_cli_setting_value("providers_v2_enabled", "true")` -- looks up `SettingValueKind::Bool` in the registry, wraps as `SettingValue { bool_value: true }`
- `confirm_global_setting_takeover()` -- skipped because `--yes`
- - Sends `UpdateSettingsRequest { setting_key: "log_level", setting_value: Some(...), global: true }`
+ - Sends `UpdateSettingsRequest { setting_key: "providers_v2_enabled", setting_value: Some(...), global: true }`
2. **Gateway** (`crates/openshell-server/src/grpc.rs` -- `update_settings()`):
- Acquires `settings_mutex` for the duration of the operation
- Detects `global=true`, `has_setting=true`
- - `validate_registered_setting_key("log_level")` -- passes (key is in registry)
+ - `validate_registered_setting_key("providers_v2_enabled")` -- passes (key is in registry)
- `load_global_settings()` -- reads `gateway_settings` record from store
- - `proto_setting_to_stored()` -- converts proto value to `StoredSettingValue::String("debug")`
+ - `proto_setting_to_stored()` -- converts proto value to `StoredSettingValue::Bool(true)`
- `upsert_setting_value()` -- inserts into `BTreeMap`, returns `true` (changed)
- Increments `revision`, calls `save_global_settings()`
- Returns `UpdateSettingsResponse { settings_revision: N }`
3. **Sandbox** (next poll tick in `run_policy_poll_loop()`):
- `poll_settings(sandbox_id)` returns new `config_revision`
- - `log_setting_changes()` logs: `Setting changed key="log_level" old="" new="debug"`
+ - `log_setting_changes()` logs: `Setting changed key="providers_v2_enabled" old="" new="true"`
- `policy_hash` unchanged -- no OPA reload
- Updates tracked `current_config_revision` and `current_settings`
diff --git a/architecture/gateway-single-node.md b/architecture/gateway-single-node.md
index 6389c728e1..4b5a6aa6d1 100644
--- a/architecture/gateway-single-node.md
+++ b/architecture/gateway-single-node.md
@@ -1,469 +1,129 @@
-# Gateway Bootstrap Architecture
+# Gateway Deployment and Compute Platforms
-This document describes how OpenShell bootstraps a single-node k3s gateway inside a Docker container, for both local and remote (SSH) targets.
+This document describes the OpenShell gateway deployment model. Operators run a gateway endpoint and configure the compute driver that should create sandboxes.
+
+The Helm chart remains in this repository as the supported Kubernetes deployment artifact. Docker, Podman, and the experimental MicroVM runtime remain first-class compute platforms for local and specialized deployments.
## Goals and Scope
-- Provide a single bootstrap flow through `openshell-bootstrap` for local and remote gateway lifecycle.
-- Keep Docker as the only runtime dependency for provisioning and lifecycle operations.
-- Package the OpenShell gateway as one container image, transferred to the target host via registry pull.
-- Support idempotent `deploy` behavior (safe to re-run).
-- Persist gateway access artifacts (metadata, mTLS certs) in the local XDG config directory.
-- Track the active gateway so most CLI commands resolve their target automatically.
+- Keep the gateway deployable as a standard process, container, or Kubernetes Helm release.
+- Keep the Helm chart for Kubernetes deployments.
+- Keep the gateway image independent from the compute runtime.
+- Make compute-platform dependencies explicit.
+- Preserve CLI gateway registration and selection as the way users target an already-running gateway.
Out of scope:
-- Multi-node orchestration.
+- Provisioning Kubernetes, Docker, Podman, or VM host infrastructure.
+- Defining a new one-command mTLS import flow for every deployment type.
## Components
-- `crates/openshell-cli/src/main.rs`: CLI entry point; `clap`-based command parsing.
-- `crates/openshell-cli/src/run.rs`: CLI command implementations (`gateway_start`, `gateway_stop`, `gateway_destroy`, `gateway_info`, `doctor_logs`).
-- `crates/openshell-cli/src/bootstrap.rs`: Auto-bootstrap helpers for `sandbox create` (offers to deploy a gateway when one is unreachable).
-- `crates/openshell-bootstrap/src/lib.rs`: Gateway lifecycle orchestration (`deploy_gateway`, `deploy_gateway_with_logs`, `gateway_handle`, `check_existing_deployment`).
-- `crates/openshell-bootstrap/src/docker.rs`: Docker API wrappers (per-gateway network, volume, container, image operations).
-- `crates/openshell-bootstrap/src/image.rs`: Remote image registry pull with XOR-obfuscated distribution credentials.
-- `crates/openshell-bootstrap/src/runtime.rs`: In-container operations via `docker exec` (health polling, stale node cleanup, deployment restart).
-- `crates/openshell-bootstrap/src/metadata.rs`: Gateway metadata creation, storage, and active gateway tracking.
-- `crates/openshell-bootstrap/src/mtls.rs`: Gateway TLS detection and CLI mTLS bundle extraction.
-- `crates/openshell-bootstrap/src/push.rs`: Local development image push into k3s containerd.
-- `crates/openshell-bootstrap/src/paths.rs`: XDG path resolution.
-- `crates/openshell-bootstrap/src/constants.rs`: Shared constants (image name, container/volume/network naming).
-- `deploy/docker/Dockerfile.images` (target `cluster`): Container image definition (k3s base + Helm charts + manifests + entrypoint).
-- `deploy/docker/cluster-entrypoint.sh`: Container entrypoint (DNS proxy, registry config, manifest injection).
-- `deploy/docker/cluster-healthcheck.sh`: Docker HEALTHCHECK script.
-- Docker daemon(s):
- - Local daemon for local deploys.
- - Remote daemon over SSH for remote deploy container operations.
-
-## CLI Commands
-
-All gateway lifecycle commands live under `openshell gateway`:
-
-| Command | Description |
-|---|---|
-| `openshell gateway start [--name NAME] [--remote user@host] [--ssh-key PATH]` | Provision or update a gateway |
-| `openshell gateway stop [--name NAME] [--remote user@host]` | Stop the container (preserves state) |
-| `openshell gateway destroy [--name NAME] [--remote user@host]` | Destroy container, attached volumes, per-gateway network, and metadata |
-| `openshell gateway info [--name NAME]` | Show deployment details (endpoint, SSH host) |
-| `openshell status` | Show gateway health via gRPC/HTTP |
-| `openshell doctor logs [--name NAME] [--remote user@host] [--tail N]` | Fetch gateway container logs |
-| `openshell doctor exec [--name NAME] [--remote user@host] -- ` | Run a command inside the gateway container |
-| `openshell gateway select ` | Set the active gateway |
-| `openshell gateway select` | Open an interactive chooser on a TTY, or list all gateways in non-interactive mode |
-
-The `--name` flag defaults to `"openshell"`. When omitted on commands that accept it, the CLI resolves the active gateway via: `--gateway` flag, then `OPENSHELL_GATEWAY` env, then `~/.config/openshell/active_gateway` file.
-
-For remote dev/test deploys from a local checkout, `scripts/remote-deploy.sh`
-wraps a different workflow: it rsyncs the repository to a remote host, builds
-the release CLI plus cluster/server/sandbox images on that machine, and then
-invokes `openshell gateway start` with explicit flags such as `--recreate`,
-`--plaintext`, or `--disable-gateway-auth` only when requested.
+- `crates/openshell-server`: Gateway API server, persistence, inference route management, SSH relay, and compute-driver integration.
+- `crates/openshell-driver-kubernetes`: Kubernetes compute driver for sandbox pods and Kubernetes resources.
+- `crates/openshell-driver-docker`: Docker compute driver for local sandbox containers.
+- `crates/openshell-driver-vm`: VM compute driver for libkrun-backed sandboxes.
+- Podman driver path: rootless container execution compatible with the Podman runtime model.
+- `deploy/helm/openshell`: Helm chart for deploying the gateway and Kubernetes driver configuration.
+- `deploy/docker/Dockerfile.images` target `gateway`: Builds the published gateway image.
+- `crates/openshell-cli`: CLI commands that register, select, and talk to gateways.
-## Local Task Flows (`mise`)
-
-Development task entrypoints split bootstrap behavior:
-
-| Task | Behavior |
-|---|---|
-| `mise run cluster` | Bootstrap or incremental deploy: creates gateway if needed (fast recreate), then detects changed files and rebuilds/pushes only impacted components |
-
-For `mise run cluster`, `.env` acts as local source-of-truth for `GATEWAY_NAME`, `GATEWAY_PORT`, and `OPENSHELL_GATEWAY`. Missing keys are appended; existing values are preserved. If `GATEWAY_PORT` is missing, the task selects a free local port and persists it.
-Fast mode ensures a local registry (`127.0.0.1:5000`) is running and configures k3s to mirror pulls via `host.docker.internal:5000`, so the cluster task can push/pull local component images consistently.
-
-## Bootstrap Sequence Diagram
+## Deployment Flow
```mermaid
sequenceDiagram
- participant U as User
- participant C as openshell-cli
- participant B as openshell-bootstrap
- participant L as Local Docker daemon
- participant R as Remote Docker daemon (SSH)
-
- U->>C: openshell gateway start --remote user@host
- C->>B: deploy_gateway(DeployOptions)
-
- B->>B: create_ssh_docker_client (ssh://, 600s timeout)
- B->>R: pull_remote_image (registry auth, platform-aware)
- B->>R: tag image to local ref
-
- Note over B,R: Docker socket APIs only, no extra host dependencies
-
- B->>B: resolve SSH host for extra TLS SANs
- B->>R: ensure_network (per-gateway bridge, attachable)
- B->>R: ensure_volume
- B->>R: ensure_container (privileged, k3s server)
- B->>R: start_container
- B->>R: clean_stale_nodes (kubectl delete node)
- B->>R: wait_for_gateway_ready (180 attempts, 2s apart)
- B->>R: poll for secret openshell-cli-client (90 attempts, 2s apart)
- R-->>B: ca.crt, tls.crt, tls.key
- B->>B: atomically store mTLS bundle
- B->>B: create and persist gateway metadata JSON
- B->>B: save_active_gateway
- B-->>C: GatewayHandle (metadata, docker client)
-```
-
-## End-State Connectivity Diagram
-
-```mermaid
-flowchart LR
- subgraph WS[User workstation]
- NAV[openshell-cli]
- MTLS[mTLS bundle ca.crt, tls.crt, tls.key]
- end
-
- subgraph HOST[Target host]
- DOCKER[Docker daemon]
- K3S[openshell-cluster-NAME single k3s container]
- G8080[Gateway :port -> :30051 mTLS default 8080]
- SBX[Sandbox runtime]
- end
-
- NAV --> G8080
- MTLS -. client cert auth .-> G8080
-
- DOCKER --> K3S
- K3S --> G8080
- K3S --> G80
- K3S --> G443
+ participant O as Operator
+ participant G as Gateway
+ participant D as Compute Driver
+ participant P as Compute Platform
+ participant C as openshell CLI
+
+ O->>G: Start gateway with driver configuration
+ G->>D: Initialize selected compute driver
+ D->>P: Verify runtime or cluster access
+ O->>C: Register reachable gateway endpoint
+ C->>G: gRPC / HTTP requests
+ G->>D: Create / delete / watch sandboxes
+ D->>P: Create sandbox workload
```
-## Deploy Flow
-
-### 1) Entry and client selection
-
-`deploy_gateway(DeployOptions)` in `crates/openshell-bootstrap/src/lib.rs` chooses execution mode:
-
-- `DeployOptions` fields: `name: String`, `image_ref: Option`, `remote: Option`, `port: u16` (default 8080).
-- `RemoteOptions` fields: `destination: String`, `ssh_key: Option`.
-- **Local deploy**: Create one Docker client with `Docker::connect_with_local_defaults()`.
-- **Remote deploy**: Create SSH Docker client via `Docker::connect_with_ssh()` with a 600-second timeout (for large image transfers). The destination is prefixed with `ssh://` if not already present.
-
-The `deploy_gateway_with_logs` variant accepts an `FnMut(String)` callback for progress reporting. The CLI wraps this in a `GatewayDeployLogPanel` for interactive terminals.
+## Supported Compute Platforms
-**Pre-deploy check** (CLI layer in `gateway_start`): In interactive terminals, `check_existing_deployment` inspects whether a container or volume already exists. If found, the user is prompted to destroy and recreate or reuse the existing gateway.
+| Platform | Gateway shape | Sandbox workload | Primary dependencies |
+|---|---|---|---|
+| Docker | Standalone gateway process or container on a host with Docker access. | Local containers. | Docker daemon, image pull/build access, local networking. |
+| Podman | Standalone gateway process with Podman socket access. | Rootless or user-scoped containers. | Podman socket, rootless networking, image pull/build access. |
+| Kubernetes | Gateway StatefulSet installed by Helm. | Sandbox pods. | Kubernetes API, namespace, service account, RBAC, storage, secrets. |
+| MicroVM | Gateway process with VM driver access. | VM-backed sandboxes. | VM runtime rootfs, libkrun-based driver, host virtualization support. |
-### 2) Image readiness
+## Kubernetes Helm Deployment
-Image ref resolution in `default_gateway_image_ref()`:
-
-1. If `OPENSHELL_CLUSTER_IMAGE` is set and non-empty, use it verbatim.
-2. Otherwise, use the published distribution image base (`/openshell/cluster`) with its default tag behavior.
-
-- **Local deploy**: `ensure_image()` inspects the image on the local daemon and pulls from the configured registry if missing (using built-in distribution credentials when pulling from the default distribution host).
-- **Remote deploy**: `pull_remote_image()` queries the remote daemon's architecture via `Docker::version()`, pulls the matching platform variant from the distribution registry (with XOR-decoded credentials), and tags the pulled image to the expected local ref (for example `openshell/cluster:dev` when an explicit local tag is requested).
-
-### 3) Runtime infrastructure
-
-For the target daemon (local or remote):
-
-1. **Ensure bridge network** `openshell-cluster-{name}` (attachable, bridge driver) via `ensure_network()`. Each gateway gets its own isolated Docker network.
-2. **Ensure volume** `openshell-cluster-{name}` via `ensure_volume()`.
-3. **Compute extra TLS SANs**:
- - For **local deploys**: Check `DOCKER_HOST` for a non-loopback `tcp://` endpoint (e.g., `tcp://docker:2375` in CI). If found, extract the host as an extra SAN. The function `local_gateway_host_from_docker_host()` skips `localhost`, `127.0.0.1`, and `::1`.
- - For **remote deploys**: Extract the host from the SSH destination (handles `user@host`, `ssh://user@host`), resolve via `ssh -G` to get the canonical hostname/IP. Include both the resolved host and original SSH host (if different) as extra SANs.
-4. **Ensure container** `openshell-cluster-{name}` via `ensure_container()`:
- - k3s server command: `server --disable=traefik --tls-san=127.0.0.1 --tls-san=localhost --tls-san=host.docker.internal` plus computed extra SANs.
- - Privileged mode.
- - Volume bind mount: `openshell-cluster-{name}:/var/lib/rancher/k3s`.
- - Network: `openshell-cluster-{name}` (per-gateway bridge network).
- - Extra host: `host.docker.internal:host-gateway`.
- - The cluster entrypoint prefers the resolved IPv4 for `host.docker.internal` when populating sandbox pod `hostAliases`, then falls back to the container default gateway. This keeps sandbox host aliases working on Docker Desktop, where the host-reachable IP differs from the bridge gateway.
- - Port mappings:
-
- | Container Port | Host Port | Purpose |
- |---|---|---|
- | 30051/tcp | configurable (default 8080) | OpenShell service NodePort (mTLS) |
-
- - Container environment variables (see [Container Environment Variables](#container-environment-variables) below).
- - If the container exists with a different image ID (compared by inspecting the content-addressable ID), it is stopped, force-removed, and recreated. If the image matches, the existing container is reused.
-5. **Start container** via `start_container()`. Tolerates already-running 409 conflict.
-
-### 4) Readiness and artifact extraction
-
-After the container starts:
-
-1. **Clean stale nodes**: `clean_stale_nodes()` finds nodes whose name does not match the deterministic k3s `--node-name` and deletes them. That node name is derived from the gateway name but normalized to a Kubernetes-safe lowercase form so existing gateway names that contain `_`, `.`, or uppercase characters still produce a valid node identity. This cleanup is needed when a container is recreated but reuses the persistent volume -- old node entries can persist in etcd. Non-fatal on error; returns the count of removed nodes.
-2. **Push local images** (optional, local deploy only): If `OPENSHELL_PUSH_IMAGES` is set, the comma-separated image refs are exported from the local Docker daemon as a single tar, uploaded into the container via `docker put_archive`, and imported into containerd via `ctr images import` in the `k8s.io` namespace. After import, `kubectl rollout restart deployment/openshell openshell` is run, followed by `kubectl rollout status --timeout=180s` to wait for completion. See `crates/openshell-bootstrap/src/push.rs`.
-3. **Wait for gateway health**: `wait_for_gateway_ready()` polls the Docker HEALTHCHECK status up to 180 times, 2 seconds apart (6 min total). A background task streams container logs during this wait. Failure modes:
- - Container exits during polling: error includes recent log lines.
- - Container has no HEALTHCHECK instruction: fails immediately.
- - HEALTHCHECK reports unhealthy on final attempt: error includes recent logs.
-
-The gateway StatefulSet also uses a Kubernetes `startupProbe` on the gRPC port before steady-state liveness and readiness checks begin. This gives single-node k3s boots extra time to absorb early networking and flannel initialization delay without restarting the gateway pod too aggressively.
-
-### 5) mTLS bundle capture
-
-TLS is always required. `fetch_and_store_cli_mtls()` polls for Kubernetes secret `openshell-cli-client` in namespace `openshell` (90 attempts, 2 seconds apart, 3 min total). Each attempt checks the container is still running. The secret's base64-encoded `ca.crt`, `tls.crt`, and `tls.key` fields are decoded and stored.
-
-Storage location: `~/.config/openshell/gateways/{name}/mtls/`
-
-Write is atomic: write to `.tmp` directory, validate all three files are non-empty, rename existing directory to `.bak`, rename `.tmp` to final path, then remove `.bak`.
-
-### 6) Metadata persistence
-
-`create_gateway_metadata()` produces a `GatewayMetadata` struct:
-
-- **Local**: endpoint `https://127.0.0.1:{port}` by default, or `https://{docker_host}:{port}` when `DOCKER_HOST` is a non-loopback `tcp://` endpoint. `is_remote=false`.
-- **Remote**: endpoint `https://{resolved_host}:{port}`, `is_remote=true`, plus SSH destination and resolved host.
-
-Metadata fields:
-
-| Field | Type | Description |
-|---|---|---|
-| `name` | `String` | Gateway name |
-| `gateway_endpoint` | `String` | HTTPS endpoint with port (e.g., `https://127.0.0.1:8080`) |
-| `is_remote` | `bool` | Whether gateway is remote |
-| `gateway_port` | `u16` | Host port mapped to the gateway NodePort |
-| `remote_host` | `Option` | SSH destination (e.g., `user@host`) |
-| `resolved_host` | `Option` | Resolved hostname/IP from `ssh -G` |
-
-Metadata location: `~/.config/openshell/gateways/{name}_metadata.json`
-
-Note: metadata is stored at the `gateways/` level (not nested inside `{name}/` like mTLS).
-
-After deploy, the CLI calls `save_active_gateway(name)`, writing the gateway name to `~/.config/openshell/active_gateway`. Subsequent commands that don't specify `--gateway` or `OPENSHELL_GATEWAY` resolve to this active gateway.
-
-## Container Image
-
-The cluster image is defined by target `cluster` in `deploy/docker/Dockerfile.images`:
-
-```
-Base: rancher/k3s:v1.35.2-k3s1
-```
+The Helm chart at `deploy/helm/openshell` owns Kubernetes deployment concerns:
-Layers added:
+- Gateway StatefulSet and persistent volume claim.
+- Service account, RBAC, and service.
+- Gateway service exposure.
+- TLS secret mounts and environment variables.
+- Sandbox namespace, default sandbox image, and callback endpoint configuration.
+- NetworkPolicy restricting sandbox SSH ingress to the gateway.
-1. Custom entrypoint: `deploy/docker/cluster-entrypoint.sh` -> `/usr/local/bin/cluster-entrypoint.sh`
-2. Healthcheck script: `deploy/docker/cluster-healthcheck.sh` -> `/usr/local/bin/cluster-healthcheck.sh`
-3. Packaged Helm charts: `deploy/docker/.build/charts/*.tgz` -> `/var/lib/rancher/k3s/server/static/charts/`
-4. Kubernetes manifests: `deploy/kube/manifests/*.yaml` -> `/opt/openshell/manifests/`
+The chart expects these operator-provided inputs:
-Bundled manifests include:
-- `openshell-helmchart.yaml` (OpenShell Helm chart auto-deploy)
-- `envoy-gateway-helmchart.yaml` (Envoy Gateway for Gateway API)
-- `agent-sandbox.yaml`
-
-The HEALTHCHECK is configured as: `--interval=5s --timeout=5s --start-period=20s --retries=60`.
-
-## Entrypoint Script
-
-`deploy/docker/cluster-entrypoint.sh` runs before k3s starts. It performs:
-
-### DNS proxy setup
-
-On Docker custom networks, `/etc/resolv.conf` contains `127.0.0.11` (Docker's internal DNS). k3s detects this loopback and falls back to `8.8.8.8`, which does not work on Docker Desktop. The entrypoint solves this by:
-
-1. Discovering Docker's real DNS listener ports from the `DOCKER_OUTPUT` iptables chain.
-2. Getting the container's `eth0` IP as a routable address.
-3. Adding DNAT rules in PREROUTING to forward DNS from pod namespaces through to Docker's DNS.
-4. Writing a custom resolv.conf pointing to the container IP.
-5. Passing `--kubelet-arg=resolv-conf=/etc/rancher/k3s/resolv.conf` to k3s.
-
-Falls back to `8.8.8.8` / `8.8.4.4` if iptables detection fails.
-
-### Registry configuration
-
-Writes `/etc/rancher/k3s/registries.yaml` from `REGISTRY_HOST`, `REGISTRY_ENDPOINT`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and `REGISTRY_INSECURE` environment variables so that k3s/containerd can authenticate when pulling component images at runtime. When no explicit credentials are provided (the default for public GHCR repos), the auth block is omitted and images are pulled anonymously.
-
-### Manifest injection
-
-Copies bundled manifests from `/opt/openshell/manifests/` to `/var/lib/rancher/k3s/server/manifests/`. This is needed because the volume mount on `/var/lib/rancher/k3s` overwrites any files baked into that path at image build time.
-
-### Image configuration overrides
-
-When environment variables are set, the entrypoint modifies the HelmChart manifest at `/var/lib/rancher/k3s/server/manifests/openshell-helmchart.yaml`:
-
-- `IMAGE_REPO_BASE`: Rewrites `repository:`, `sandboxImage:`, and `jobImage:` in the HelmChart.
-- `PUSH_IMAGE_REFS`: In push mode, parses comma-separated image refs and rewrites the exact gateway, sandbox, and pki-job image references (matching on path component `/gateway:`, `/sandbox:`, `/pki-job:`).
-- `IMAGE_TAG`: Replaces `:latest` tags with the specified tag on gateway, sandbox, and pki-job images. Handles both quoted and unquoted `tag: latest` formats.
-- `IMAGE_PULL_POLICY`: Replaces `pullPolicy: Always` with the specified policy (e.g., `IfNotPresent`).
-- `SSH_GATEWAY_HOST` / `SSH_GATEWAY_PORT`: Replaces `__SSH_GATEWAY_HOST__` and `__SSH_GATEWAY_PORT__` placeholders.
-- `EXTRA_SANS`: Builds a YAML flow-style list from the comma-separated SANs and replaces `extraSANs: []`.
-
-## Healthcheck Script
-
-`deploy/docker/cluster-healthcheck.sh` validates cluster readiness through a series of checks:
-
-1. **Kubernetes API**: `kubectl get --raw='/readyz'`
-2. **OpenShell StatefulSet**: Checks that `statefulset/openshell` in namespace `openshell` exists and has 1 ready replica.
-3. **Gateway**: Checks that `gateway/openshell-gateway` in namespace `openshell` has the `Programmed` condition.
-4. **mTLS secret** (conditional): If `NAV_GATEWAY_TLS_ENABLED` is true (or inferred from the HelmChart manifest using the same two-path detection logic as the bootstrap code), checks that secret `openshell-cli-client` exists with non-empty `ca.crt`, `tls.crt`, and `tls.key` data.
-
-## GPU Enablement
-
-GPU support is part of the single-node gateway bootstrap path rather than a separate architecture.
-
-- `openshell gateway start --gpu` threads GPU device options through `crates/openshell-cli`, `crates/openshell-bootstrap`, and `crates/openshell-bootstrap/src/docker.rs`.
-- When enabled, the cluster container is created with Docker `DeviceRequests`. The injection mechanism is selected based on whether CDI is enabled on the daemon (`SystemInfo.CDISpecDirs` via `GET /info`):
- - **CDI enabled** (daemon reports non-empty `CDISpecDirs`): CDI device injection — `driver="cdi"` with `nvidia.com/gpu=all`. Specs are expected to be pre-generated on the host (e.g. automatically by the `nvidia-cdi-refresh.service` or manually via `nvidia-ctk generate`).
- - **CDI not enabled**: `--gpus all` device request — `driver="nvidia"`, `count=-1`, which relies on the NVIDIA Container Runtime hook.
-- `deploy/docker/Dockerfile.images` installs NVIDIA Container Toolkit packages in a dedicated Ubuntu stage and copies the runtime binaries, config, and `libnvidia-container` shared libraries into the final Ubuntu-based cluster image.
-- `deploy/docker/cluster-entrypoint.sh` checks `GPU_ENABLED=true` and copies GPU-only manifests from `/opt/openshell/gpu-manifests/` into k3s's manifests directory.
-- `deploy/kube/gpu-manifests/nvidia-device-plugin-helmchart.yaml` installs the NVIDIA device plugin chart, currently pinned to `0.18.2`. NFD and GFD are disabled; the device plugin's default `nodeAffinity` (which requires `feature.node.kubernetes.io/pci-10de.present=true` or `nvidia.com/gpu.present=true` from NFD/GFD) is overridden to empty so the DaemonSet schedules on the single-node cluster without requiring those labels. The chart is configured with `deviceListStrategy: cdi-cri` so the device plugin injects devices via direct CDI device requests in the CRI.
-- k3s auto-detects `nvidia-container-runtime` on `PATH`, registers the `nvidia` containerd runtime, and creates the `nvidia` `RuntimeClass` automatically.
-- The OpenShell Helm chart grants the gateway service account cluster-scoped read access to `node.k8s.io/runtimeclasses` and core `nodes` so GPU sandbox admission can verify both the `nvidia` `RuntimeClass` and allocatable GPU capacity before creating a sandbox.
-
-The runtime chain is:
-
-```text
-Host GPU drivers & NVIDIA Container Toolkit
- └─ Docker: DeviceRequests (CDI when enabled, --gpus all otherwise)
- └─ k3s/containerd: nvidia-container-runtime on PATH -> auto-detected
- └─ k8s: nvidia-device-plugin DaemonSet advertises nvidia.com/gpu
- └─ Pods: request nvidia.com/gpu in resource limits (CDI injection — no runtimeClassName needed)
-```
-
-### `--gpu` flag
-
-The `--gpu` flag on `gateway start` enables GPU passthrough. OpenShell auto-selects CDI when enabled on the daemon and falls back to Docker's NVIDIA GPU request path (`--gpus all`) otherwise.
+| Input | Purpose |
+|---|---|
+| Namespace | Release namespace and default sandbox namespace. |
+| `openshell-ssh-handshake` Secret | HMAC key used by the SSH relay handshake. |
+| `openshell-server-tls` Secret | Server certificate and key when TLS is enabled. |
+| `openshell-server-client-ca` Secret | CA bundle used by the gateway to verify client certificates. |
+| `openshell-client-tls` Secret | Client certificate bundle mounted into sandbox pods. |
+| StorageClass / PVC support | Persistent gateway SQLite data when using the default `server.dbUrl`. |
+| Service exposure | Port-forward, ingress, load balancer, or NodePort for CLI access. |
-Device injection uses CDI (`deviceListStrategy: cdi-cri`): the device plugin injects devices via direct CDI device requests in the CRI. Sandbox pods only need `nvidia.com/gpu: 1` in their resource limits, and GPU pods do not set `runtimeClassName`.
+For local Kubernetes evaluation, TLS may be disabled with `server.disableTls=true` and the service can be reached through `kubectl port-forward`. Production deployments should keep TLS enabled or place the gateway behind a trusted TLS-terminating access proxy.
-The expected smoke test is a plain pod requesting `nvidia.com/gpu: 1` without `runtimeClassName` and running `nvidia-smi`.
+Key Helm values:
-## Remote Image Transfer
+| Value | Effect |
+|---|---|
+| `image.repository`, `image.tag` | Select the gateway image. |
+| `service.type`, `service.port`, `service.nodePort` | Expose the gateway service. |
+| `server.dbUrl` | Select SQLite or Postgres persistence. |
+| `server.sandboxNamespace` | Namespace for sandbox resources. |
+| `server.sandboxImage` | Default sandbox image. |
+| `server.grpcEndpoint` | Endpoint sandbox supervisors use to call back to the gateway. |
+| `server.sshGatewayHost`, `server.sshGatewayPort` | Host and port returned to CLI clients for SSH proxy connections. |
+| `server.disableTls`, `server.disableGatewayAuth` | Transport/authentication mode. |
+| `server.tls.*` | Names of TLS secrets mounted into the gateway and sandboxes. |
+
+## Runtime Shape
```mermaid
flowchart LR
- B[bootstrap] -->|query| RP[Remote platform: Docker version API]
- RP --> Auth[Authenticate with distribution registry]
- Auth --> Pull[Pull platform-specific image layers]
- Pull --> Tag[Tag to local image ref]
- Tag --> OK[Image available as openshell/cluster:TAG]
+ CLI[openshell CLI] -->|gRPC / HTTP| GW[Gateway]
+ GW --> DB[(SQLite or Postgres)]
+ GW --> DRIVER[Compute Driver]
+ DRIVER --> DOCKER[Docker]
+ DRIVER --> PODMAN[Podman]
+ DRIVER --> K8S[Kubernetes API]
+ DRIVER --> VM[MicroVM Driver]
+ DOCKER --> SBX1[Sandbox Container]
+ PODMAN --> SBX2[Sandbox Container]
+ K8S --> SBX3[Sandbox Pod]
+ VM --> SBX4[Sandbox VM]
+ SBX1 --> GW
+ SBX2 --> GW
+ SBX3 --> GW
+ SBX4 --> GW
```
-- Remote platform is queried via `Docker::version()` and normalized (e.g., `x86_64` -> `amd64`, `aarch64` -> `arm64`).
-- Distribution registry credentials are XOR-encoded in the binary (lightweight obfuscation, not a security boundary).
-- If the image ref looks local (no `/` in repository), the `latest` tag is used from the distribution registry regardless of the local `IMAGE_TAG`.
-
-## Access Model
-
-### Gateway endpoint exposure
-
-- Local: `https://127.0.0.1:{port}` (or `https://{docker_host}:{port}` when `DOCKER_HOST` is a non-loopback TCP endpoint). Default port is 8080.
-- Remote: `https://:{port}`.
-- The host port (configurable via `--port`, default 8080) maps to container port 30051 (OpenShell service NodePort).
-
-## Lifecycle Operations
-
-### stop
-
-`GatewayHandle::stop()` calls `stop_container()`, which tolerates 404 (not found) and 409 (already stopped).
-
-### destroy
-
-**Bootstrap layer** (`GatewayHandle::destroy()` -> `destroy_gateway_resources()`):
-
-1. Stop the container.
-2. Remove the container (`force=true`). Tolerates 404.
-3. Remove the volume (`force=true`). Tolerates 404.
-4. Force-remove the per-gateway network via `force_remove_network()`, disconnecting any stale endpoints first.
-
-**CLI layer** (`gateway_destroy()` in `run.rs` additionally):
-
-6. Remove the metadata JSON file via `remove_gateway_metadata()`.
-7. Clear the active gateway reference if it matches the destroyed gateway.
-
-## Idempotency and Error Behavior
-
-- Re-running deploy is safe:
- - Network is recreated on each deploy to guarantee a clean state; volume is reused (inspect before create).
- - If a container exists with the same image ID, it is reused; if the image changed, the container is recreated.
- - `start_container` tolerates already-running state (409).
-- In interactive terminals, the CLI prompts the user to optionally destroy and recreate an existing gateway before redeploying.
-- Error handling surfaces:
- - Docker API failures from inspect/create/start/remove.
- - SSH connection failures when creating the remote Docker client.
- - Health check timeout (6 min) with recent container logs.
- - Container exit during any polling phase (health, mTLS) with diagnostic information (exit code, OOM status, recent logs).
- - mTLS secret polling timeout (3 min).
- - Local image ref without registry prefix: clear error with build instructions rather than a failed Docker Hub pull.
-
-## Auto-Bootstrap from `sandbox create`
-
-When `openshell sandbox create` cannot connect to a gateway (connection refused, DNS error, missing default TLS certs), the CLI offers to bootstrap one automatically:
-
-1. `should_attempt_bootstrap()` in `crates/openshell-cli/src/bootstrap.rs` checks the error type. It returns `true` for connectivity errors and missing default TLS materials, but `false` for TLS handshake/auth errors.
-2. If running in a terminal, the user is prompted to confirm.
-3. `run_bootstrap()` deploys a gateway named `"openshell"`, sets it as active, and returns fresh `TlsOptions` pointing to the newly-written mTLS certs.
-4. When `sandbox create` requests GPU explicitly (`--gpu`) or infers it from an image whose final name component contains `gpu` (such as `nvidia-gpu`), the bootstrap path enables gateway GPU support before retrying sandbox creation, using the same CDI-or-fallback selection as `gateway start --gpu`.
-
-## Container Environment Variables
-
-Variables set on the container by `ensure_container()` in `docker.rs`:
-
-| Variable | Value | When Set |
-|---|---|---|
-| `REGISTRY_MODE` | `"external"` | Always |
-| `REGISTRY_HOST` | Distribution registry host (or `OPENSHELL_REGISTRY_HOST` override) | Always |
-| `REGISTRY_INSECURE` | `"true"` or `"false"` | Always |
-| `IMAGE_REPO_BASE` | `{registry_host}/{namespace}` (or `IMAGE_REPO_BASE`/`OPENSHELL_IMAGE_REPO_BASE` override) | Always |
-| `REGISTRY_ENDPOINT` | Custom endpoint URL | When `OPENSHELL_REGISTRY_ENDPOINT` is set |
-| `REGISTRY_USERNAME` | Registry auth username | When explicit credentials provided via `--registry-username`/`--registry-token` or env vars |
-| `REGISTRY_PASSWORD` | Registry auth password | When explicit credentials provided via `--registry-username`/`--registry-token` or env vars |
-| `EXTRA_SANS` | Comma-separated extra TLS SANs | When extra SANs computed |
-| `SSH_GATEWAY_HOST` | Resolved remote hostname/IP | Remote deploys only |
-| `SSH_GATEWAY_PORT` | Configured host port (default `8080`) | Remote deploys only |
-| `IMAGE_TAG` | Image tag (e.g., `"dev"`) | When `IMAGE_TAG` env is set or push mode |
-| `IMAGE_PULL_POLICY` | `"IfNotPresent"` | Push mode only |
-| `PUSH_IMAGE_REFS` | Comma-separated image refs | Push mode only |
-
-## Host-Side Environment Variables
-
-Environment variables that affect bootstrap behavior when set on the host:
-
-| Variable | Effect |
-|---|---|
-| `OPENSHELL_CLUSTER_IMAGE` | Overrides entire image ref if set and non-empty |
-| `IMAGE_TAG` | Sets image tag (default: `"dev"`) when `OPENSHELL_CLUSTER_IMAGE` is not set |
-| `NAV_GATEWAY_TLS_ENABLED` | Overrides HelmChart manifest for TLS enabled check (`true`/`1`/`yes`/`false`/`0`/`no`) |
-| `XDG_CONFIG_HOME` | Base config directory (default: `$HOME/.config`) |
-| `DOCKER_HOST` | When `tcp://` and non-loopback, the host is added as a TLS SAN and used as the gateway endpoint |
-| `OPENSHELL_PUSH_IMAGES` | Comma-separated image refs to push into the gateway's containerd (local deploy only) |
-| `OPENSHELL_REGISTRY_HOST` | Override the distribution registry host |
-| `OPENSHELL_REGISTRY_NAMESPACE` | Override the registry namespace (default: `"openshell"`) |
-| `IMAGE_REPO_BASE` / `OPENSHELL_IMAGE_REPO_BASE` | Override the image repository base path |
-| `OPENSHELL_REGISTRY_INSECURE` | Use HTTP instead of HTTPS for registry mirror |
-| `OPENSHELL_REGISTRY_ENDPOINT` | Custom registry mirror endpoint |
-| `OPENSHELL_REGISTRY_USERNAME` | Override registry auth username |
-| `OPENSHELL_REGISTRY_PASSWORD` | Override registry auth password |
-| `OPENSHELL_GATEWAY` | Set the active gateway name for CLI commands |
-
-## File System Layout
-
-Artifacts stored under `$XDG_CONFIG_HOME/openshell/` (default `~/.config/openshell/`):
-
-```
-openshell/
- active_gateway # plain text: active gateway name
- gateways/
- {name}_metadata.json # GatewayMetadata JSON
- {name}/
- mtls/ # mTLS bundle (when TLS enabled)
- ca.crt
- tls.crt
- tls.key
-```
+The gateway process manages all OpenShell control-plane APIs. It persists records in SQLite or Postgres, watches sandbox state through the selected compute driver, and brokers SSH access through supervisor-initiated relay streams.
-## Implementation References
+## Operational Notes
-- `crates/openshell-bootstrap/src/lib.rs` -- public API, deploy orchestration
-- `crates/openshell-bootstrap/src/docker.rs` -- Docker API wrappers
-- `crates/openshell-bootstrap/src/image.rs` -- registry pull, XOR credentials
-- `crates/openshell-bootstrap/src/runtime.rs` -- exec, health polling, stale node cleanup
-- `crates/openshell-bootstrap/src/metadata.rs` -- metadata CRUD, active gateway, SSH resolution
-- `crates/openshell-bootstrap/src/mtls.rs` -- TLS detection, secret extraction, atomic write
-- `crates/openshell-bootstrap/src/push.rs` -- local image push into k3s containerd
-- `crates/openshell-bootstrap/src/constants.rs` -- naming conventions
-- `crates/openshell-bootstrap/src/paths.rs` -- XDG path helpers
-- `crates/openshell-cli/src/main.rs` -- CLI command definitions
-- `crates/openshell-cli/src/run.rs` -- CLI command implementations
-- `crates/openshell-cli/src/bootstrap.rs` -- auto-bootstrap from sandbox create
-- `deploy/docker/Dockerfile.images` -- shared image build definition (cluster target)
-- `deploy/docker/cluster-entrypoint.sh` -- container entrypoint script
-- `deploy/docker/cluster-healthcheck.sh` -- Docker HEALTHCHECK script
-- `deploy/kube/manifests/openshell-helmchart.yaml` -- OpenShell Helm chart manifest
-- `deploy/kube/manifests/envoy-gateway-helmchart.yaml` -- Envoy Gateway manifest
+- Gateway endpoint registration should use `openshell gateway add ` regardless of compute platform.
+- Kubernetes chart changes should be validated with `helm lint deploy/helm/openshell` and an install into a disposable namespace when possible.
+- Docker driver changes should be validated with `mise run gateway:docker` or `mise run e2e:docker`.
+- Podman driver changes should be validated with `mise run e2e:podman`.
+- VM driver changes should be validated with `mise run e2e:vm`.
+- Gateway image changes should be validated by building `deploy/docker/Dockerfile.images` target `gateway`.
+- Published docs should describe gateway deployment and endpoint registration.
diff --git a/architecture/gateway.md b/architecture/gateway.md
index 9677dfc47e..c85f77ef61 100644
--- a/architecture/gateway.md
+++ b/architecture/gateway.md
@@ -2,7 +2,9 @@
## Overview
-`openshell-server` is the gateway -- the central control plane for a cluster. It exposes two gRPC services (OpenShell and Inference) and HTTP endpoints on a single multiplexed port, manages sandbox lifecycle through Kubernetes CRDs, persists state in SQLite or Postgres, and provides SSH tunneling into sandbox pods. The gateway coordinates all interactions between clients, the Kubernetes cluster, and the persistence layer.
+`openshell-server` is the gateway -- the central control plane for a cluster. It exposes two gRPC services (OpenShell and Inference) and HTTP endpoints on a single multiplexed port, manages sandbox lifecycle through a pluggable compute driver, persists state in SQLite or Postgres, and brokers SSH access into sandboxes through supervisor-initiated relay streams. The gateway coordinates all interactions between clients, the compute backend, and the persistence layer.
+
+Each sandbox supervisor opens a persistent inbound gRPC session (`ConnectSupervisor`); the gateway multiplexes per-invocation `RelayStream` RPCs onto the same HTTP/2 connection to move bytes between clients and the in-sandbox SSH Unix socket. The gateway does not need to know, resolve, or reach the sandbox's network address.
## Architecture Diagram
@@ -11,25 +13,27 @@ The following diagram shows the major components inside the gateway process and
```mermaid
graph TD
Client["gRPC / HTTP Client"]
+ Supervisor["Sandbox Supervisor (inbound gRPC)"]
TCP["TCP Listener"]
TLS["TLS Acceptor (optional)"]
- MUX["MultiplexedService"]
+ MUX["MultiplexedService (HTTP/2 adaptive window)"]
GRPC_ROUTER["GrpcRouter"]
NAV["OpenShellServer (OpenShell service)"]
INF["InferenceServer (Inference service)"]
HTTP["HTTP Router (Axum)"]
HEALTH["Health Endpoints"]
SSH_TUNNEL["SSH Tunnel (/connect/ssh)"]
+ SUP_REG["SupervisorSessionRegistry"]
STORE["Store (SQLite / Postgres)"]
- K8S["Kubernetes API"]
- WATCHER["Sandbox Watcher"]
- EVENT_TAILER["Kube Event Tailer"]
+ COMPUTE["ComputeRuntime"]
+ DRIVER["ComputeDriver (kubernetes / vm)"]
WATCH_BUS["SandboxWatchBus"]
LOG_BUS["TracingLogBus"]
PLAT_BUS["PlatformEventBus"]
INDEX["SandboxIndex"]
Client --> TCP
+ Supervisor --> TCP
TCP --> TLS
TLS --> MUX
MUX -->|"content-type: application/grpc"| GRPC_ROUTER
@@ -39,17 +43,16 @@ graph TD
HTTP --> HEALTH
HTTP --> SSH_TUNNEL
NAV --> STORE
- NAV --> K8S
- INF --> STORE
+ NAV --> COMPUTE
+ NAV --> SUP_REG
SSH_TUNNEL --> STORE
- SSH_TUNNEL --> K8S
- WATCHER --> K8S
- WATCHER --> STORE
- WATCHER --> WATCH_BUS
- WATCHER --> INDEX
- EVENT_TAILER --> K8S
- EVENT_TAILER --> PLAT_BUS
- EVENT_TAILER --> INDEX
+ SSH_TUNNEL --> SUP_REG
+ INF --> STORE
+ COMPUTE --> DRIVER
+ COMPUTE --> STORE
+ COMPUTE --> WATCH_BUS
+ COMPUTE --> INDEX
+ COMPUTE --> PLAT_BUS
LOG_BUS --> PLAT_BUS
```
@@ -57,21 +60,24 @@ graph TD
| Module | File | Purpose |
|--------|------|---------|
-| Entry point | `crates/openshell-server/src/main.rs` | CLI argument parsing, config assembly, tracing setup, calls `run_server` |
+| Entry point | `crates/openshell-server/src/main.rs` | Thin binary wrapper that calls `cli::run_cli` |
+| CLI | `crates/openshell-server/src/cli.rs` | `Args` parser, config assembly, tracing setup, calls `run_server` |
| Gateway runtime | `crates/openshell-server/src/lib.rs` | `ServerState` struct, `run_server()` accept loop |
-| Protocol mux | `crates/openshell-server/src/multiplex.rs` | `MultiplexService`, `MultiplexedService`, `GrpcRouter`, `BoxBody` |
-| gRPC: OpenShell | `crates/openshell-server/src/grpc.rs` | `OpenShellService` -- sandbox CRUD, provider CRUD, watch, exec, SSH sessions, policy delivery |
-| gRPC: Inference | `crates/openshell-server/src/inference.rs` | `InferenceService` -- cluster inference config (set/get) and sandbox inference bundle delivery |
+| Protocol mux | `crates/openshell-server/src/multiplex.rs` | `MultiplexService`, `MultiplexedService`, `GrpcRouter`, `BoxBody`, HTTP/2 adaptive-window tuning |
+| gRPC: OpenShell | `crates/openshell-server/src/grpc/mod.rs` | `OpenShellService` trait impl -- dispatches to per-concern handlers |
+| gRPC: Sandbox/Exec | `crates/openshell-server/src/grpc/sandbox.rs` | Sandbox CRUD, `ExecSandbox`, SSH session handlers, relay-backed exec proxy |
+| gRPC: Inference | `crates/openshell-server/src/inference.rs` | `InferenceService` -- gateway inference config and sandbox bundle delivery |
+| Supervisor sessions | `crates/openshell-server/src/supervisor_session.rs` | `SupervisorSessionRegistry`, `handle_connect_supervisor`, `handle_relay_stream`, reaper |
| HTTP | `crates/openshell-server/src/http.rs` | Health endpoints, merged with SSH tunnel router |
| Browser auth | `crates/openshell-server/src/auth.rs` | Cloudflare browser login relay at `/auth/connect` |
-| SSH tunnel | `crates/openshell-server/src/ssh_tunnel.rs` | HTTP CONNECT handler at `/connect/ssh` |
+| SSH tunnel | `crates/openshell-server/src/ssh_tunnel.rs` | HTTP CONNECT handler at `/connect/ssh` backed by `open_relay` |
| WS tunnel | `crates/openshell-server/src/ws_tunnel.rs` | WebSocket tunnel handler at `/_ws_tunnel` for Cloudflare-fronted clients |
| TLS | `crates/openshell-server/src/tls.rs` | `TlsAcceptor` wrapping rustls with ALPN |
| Persistence | `crates/openshell-server/src/persistence/mod.rs` | `Store` enum (SQLite/Postgres), generic object CRUD, protobuf codec |
-| Persistence: SQLite | `crates/openshell-server/src/persistence/sqlite.rs` | `SqliteStore` with sqlx |
-| Persistence: Postgres | `crates/openshell-server/src/persistence/postgres.rs` | `PostgresStore` with sqlx |
| Compute runtime | `crates/openshell-server/src/compute/mod.rs` | `ComputeRuntime`, gateway-owned sandbox lifecycle orchestration over a compute backend |
-| Compute driver: Kubernetes | `crates/openshell-driver-kubernetes/src/driver.rs` | Kubernetes CRD create/delete, endpoint resolution, watch stream, pod template translation |
+| Compute driver: Kubernetes | `crates/openshell-driver-kubernetes/src/driver.rs` | Kubernetes CRD create/delete/watch, pod template translation |
+| Compute driver: Docker | `crates/openshell-driver-docker/src/lib.rs` | Local Docker container create/stop/delete/watch |
+| Compute driver: VM | `crates/openshell-driver-vm/src/driver.rs` | Per-sandbox microVM create/delete/watch, supervisor-only guest boot |
| Sandbox index | `crates/openshell-server/src/sandbox_index.rs` | `SandboxIndex` -- in-memory name/pod-to-id correlation |
| Watch bus | `crates/openshell-server/src/sandbox_watch.rs` | `SandboxWatchBus` -- in-memory broadcast for persisted sandbox updates |
| Tracing bus | `crates/openshell-server/src/tracing_bus.rs` | `TracingLogBus` -- captures tracing events keyed by `sandbox_id` |
@@ -80,38 +86,46 @@ Proto definitions consumed by the gateway:
| Proto file | Package | Defines |
|------------|---------|---------|
-| `proto/openshell.proto` | `openshell.v1` | `OpenShell` service, public sandbox resource model, provider/SSH/watch messages |
-| `proto/compute_driver.proto` | `openshell.compute.v1` | Internal `ComputeDriver` service, driver-native sandbox observations, endpoint resolution, compute watch stream envelopes |
+| `proto/openshell.proto` | `openshell.v1` | `OpenShell` service, public sandbox resource model, provider/SSH/watch/policy messages, supervisor session messages (`ConnectSupervisor`, `RelayStream`, `RelayFrame`) |
+| `proto/compute_driver.proto` | `openshell.compute.v1` | Internal `ComputeDriver` service, driver-native sandbox observations, compute watch stream envelopes |
| `proto/inference.proto` | `openshell.inference.v1` | `Inference` service: `SetClusterInference`, `GetClusterInference`, `GetInferenceBundle` |
| `proto/datamodel.proto` | `openshell.datamodel.v1` | `Provider` |
| `proto/sandbox.proto` | `openshell.sandbox.v1` | Sandbox supervisor policy, settings, and config messages |
## Startup Sequence
-The gateway boots in `main()` (`crates/openshell-server/src/main.rs`) and proceeds through these steps:
+The gateway boots in `cli::run_cli` (`crates/openshell-server/src/cli.rs`) and proceeds through these steps:
-1. **Install rustls crypto provider** -- `aws_lc_rs::default_provider().install_default()`.
+1. **Install rustls crypto provider** -- `rustls::crypto::ring::default_provider().install_default()`.
2. **Parse CLI arguments** -- `Args::parse()` via `clap`. Every flag has a corresponding environment variable (see [Configuration](#configuration)).
3. **Initialize tracing** -- Creates a `TracingLogBus` and installs a tracing subscriber that writes to stdout and publishes log events keyed by `sandbox_id` into the bus.
-4. **Build `Config`** -- Assembles a `openshell_core::Config` from the parsed arguments.
+4. **Build `Config`** -- Assembles an `openshell_core::Config` from the parsed arguments.
5. **Call `run_server()`** (`crates/openshell-server/src/lib.rs`):
1. Connect to the persistence store (`Store::connect`), which auto-detects SQLite vs Postgres from the URL prefix and runs migrations.
- 2. Create `ComputeRuntime` with an in-process `ComputeDriverService` backed by `KubernetesComputeDriver`, so the gateway calls the `openshell.compute.v1.ComputeDriver` RPC surface even without transport.
- 3. Build `ServerState` (shared via `Arc` across all handlers).
- 4. **Spawn background tasks**:
- - `ComputeRuntime::spawn_watchers` -- consumes the compute-driver watch stream, republishes platform events, and runs a periodic `ListSandboxes` snapshot reconcile so the store-backed public sandbox reads stay aligned with the compute driver.
- 5. Create `MultiplexService`.
- 6. Bind `TcpListener` on `config.bind_address`.
- 7. Optionally create `TlsAcceptor` from cert/key files.
- 8. Enter the accept loop: for each connection, spawn a tokio task that optionally performs a TLS handshake, then calls `MultiplexService::serve()`.
+ 2. Create `ComputeRuntime` with a `ComputeDriver` implementation selected by `OPENSHELL_DRIVERS`:
+ - `kubernetes` wraps `KubernetesComputeDriver` in `ComputeDriverService`, so the gateway uses the `openshell.compute.v1.ComputeDriver` RPC surface even without transport.
+ - `docker` constructs `openshell-driver-docker` in-process and manages local containers labeled with the configured sandbox namespace.
+ - `vm` spawns the standalone `openshell-driver-vm` binary as a local compute-driver process, resolves it from `--driver-dir`, conventional libexec install paths, or a sibling of the gateway binary, connects to it over a Unix domain socket, and keeps the libkrun/rootfs runtime out of the gateway binary.
+ 3. Build `ServerState` (shared via `Arc` across all handlers), including a fresh `SupervisorSessionRegistry`.
+ 4. Resume persisted sandboxes that were stopped during the previous gateway shutdown.
+ 5. **Spawn background tasks**:
+ - `ComputeRuntime::spawn_watchers` -- consumes the compute-driver watch stream, republishes platform events, and runs a periodic `ListSandboxes` snapshot reconcile.
+ - `ssh_tunnel::spawn_session_reaper` -- sweeps expired or revoked SSH session tokens from the store hourly.
+ - `supervisor_session::spawn_relay_reaper` -- sweeps orphaned pending relay channels every 30 seconds.
+ 6. Create `MultiplexService`.
+ 7. Bind the primary gateway listener and any compute-driver requested listeners. Docker requests the Docker bridge gateway address with the normal gateway port, so sandbox containers can call back over the bridge without joining the host network.
+ 8. Bind optional health and metrics listeners.
+ 9. Optionally create `TlsAcceptor` from cert/key files.
+ 10. Spawn a task per gateway listener. Each accepted connection optionally performs a TLS handshake, then calls `MultiplexService::serve()`.
## Configuration
-All configuration is via CLI flags with environment variable fallbacks. The `--db-url` flag is the only required argument.
+All configuration is via CLI flags with environment variable fallbacks. The `--db-url` flag is required.
| Flag | Env Var | Default | Description |
|------|---------|---------|-------------|
-| `--port` | `OPENSHELL_SERVER_PORT` | `8080` | TCP listen port (binds `0.0.0.0`) |
+| `--bind-address` | `OPENSHELL_BIND_ADDRESS` | `127.0.0.1` | IP address for gateway, health, and metrics listeners. Container deployments pass `0.0.0.0` explicitly. |
+| `--port` | `OPENSHELL_SERVER_PORT` | `8080` | TCP listen port |
| `--log-level` | `OPENSHELL_LOG_LEVEL` | `info` | Tracing log level filter |
| `--tls-cert` | `OPENSHELL_TLS_CERT` | None | Path to PEM certificate file |
| `--tls-key` | `OPENSHELL_TLS_KEY` | None | Path to PEM private key file |
@@ -122,13 +136,22 @@ All configuration is via CLI flags with environment variable fallbacks. The `--d
| `--db-url` | `OPENSHELL_DB_URL` | *required* | Database URL (`sqlite:...` or `postgres://...`). The Helm chart defaults to `sqlite:/var/openshell/openshell.db` (persistent volume). In-memory SQLite (`sqlite::memory:?cache=shared`) works for ephemeral/test environments but data is lost on restart. |
| `--sandbox-namespace` | `OPENSHELL_SANDBOX_NAMESPACE` | `default` | Kubernetes namespace for sandbox CRDs |
| `--sandbox-image` | `OPENSHELL_SANDBOX_IMAGE` | None | Default container image for sandbox pods |
-| `--grpc-endpoint` | `OPENSHELL_GRPC_ENDPOINT` | None | gRPC endpoint reachable from within the cluster (for sandbox callbacks) |
+| `--grpc-endpoint` | `OPENSHELL_GRPC_ENDPOINT` | None | gRPC endpoint reachable from sandbox workloads for supervisor callbacks |
+| `--drivers` | `OPENSHELL_DRIVERS` | `kubernetes` | Compute backend to use. Current options are `kubernetes`, `docker`, and `vm`. |
+| `--docker-network-name` | `OPENSHELL_DOCKER_NETWORK_NAME` | `openshell-docker` | Docker bridge network that local Docker sandboxes join |
+| `--driver-dir` | `OPENSHELL_DRIVER_DIR` | unset | Override directory for `openshell-driver-vm`. When unset, the gateway searches `~/.local/libexec/openshell`, `/usr/libexec/openshell`, `/usr/local/libexec/openshell`, `/usr/local/libexec`, then a sibling binary. |
+| `--vm-driver-state-dir` | `OPENSHELL_VM_DRIVER_STATE_DIR` | `target/openshell-vm-driver` | Host directory for VM sandbox rootfs, console logs, runtime state, and shared image-rootfs cache |
+| `--vm-krun-log-level` | `OPENSHELL_VM_KRUN_LOG_LEVEL` | `1` | libkrun log level for VM helper processes |
+| `--vm-driver-vcpus` | `OPENSHELL_VM_DRIVER_VCPUS` | `2` | Default vCPU count for VM sandboxes |
+| `--vm-driver-mem-mib` | `OPENSHELL_VM_DRIVER_MEM_MIB` | `2048` | Default memory allocation for VM sandboxes in MiB |
+| `--vm-tls-ca` | `OPENSHELL_VM_TLS_CA` | None | CA cert copied into VM guests for gateway mTLS |
+| `--vm-tls-cert` | `OPENSHELL_VM_TLS_CERT` | None | Client cert copied into VM guests for gateway mTLS |
+| `--vm-tls-key` | `OPENSHELL_VM_TLS_KEY` | None | Client private key copied into VM guests for gateway mTLS |
| `--ssh-gateway-host` | `OPENSHELL_SSH_GATEWAY_HOST` | `127.0.0.1` | Public hostname returned in SSH session responses |
| `--ssh-gateway-port` | `OPENSHELL_SSH_GATEWAY_PORT` | `8080` | Public port returned in SSH session responses |
| `--ssh-connect-path` | `OPENSHELL_SSH_CONNECT_PATH` | `/connect/ssh` | HTTP path for SSH CONNECT/upgrade |
-| `--sandbox-ssh-port` | `OPENSHELL_SANDBOX_SSH_PORT` | `2222` | SSH listen port inside sandbox pods |
-| `--ssh-handshake-secret` | `OPENSHELL_SSH_HANDSHAKE_SECRET` | None | Shared HMAC-SHA256 secret for gateway-to-sandbox handshake |
-| `--ssh-handshake-skew-secs` | `OPENSHELL_SSH_HANDSHAKE_SKEW_SECS` | `300` | Allowed clock skew (seconds) for SSH handshake timestamps |
+
+The sandbox-side SSH listener is a Unix domain socket inside the sandbox. The path defaults to `/run/openshell/ssh.sock` and is configured on the compute driver (e.g. `openshell-driver-kubernetes --sandbox-ssh-socket-path`). The gateway never dials this socket itself; the supervisor bridges it onto a `RelayStream` when asked.
## Shared State
@@ -145,15 +168,17 @@ pub struct ServerState {
pub ssh_connections_by_token: Mutex>,
pub ssh_connections_by_sandbox: Mutex>,
pub settings_mutex: tokio::sync::Mutex<()>,
+ pub supervisor_sessions: SupervisorSessionRegistry,
}
```
- **`store`** -- persistence backend (SQLite or Postgres) for all object types.
-- **`compute`** -- gateway-owned compute orchestration. Persists sandbox lifecycle transitions, validates create requests through the compute backend, resolves exec/SSH endpoints, consumes the backend watch stream, and periodically reconciles the store against `ComputeDriver/ListSandboxes` snapshots.
+- **`compute`** -- gateway-owned compute orchestration. Persists sandbox lifecycle transitions, validates create requests through the compute backend, consumes the backend watch stream, and periodically reconciles the store against `ComputeDriver/ListSandboxes` snapshots.
- **`sandbox_index`** -- in-memory bidirectional index mapping sandbox names and agent pod names to sandbox IDs. Updated from compute-driver sandbox snapshots.
- **`sandbox_watch_bus`** -- `broadcast`-based notification bus keyed by sandbox ID. Producers call `notify(&id)` when the persisted sandbox record changes; consumers in `WatchSandbox` streams receive `()` signals and re-read the record.
- **`tracing_log_bus`** -- captures `tracing` events that include a `sandbox_id` field and republishes them as `SandboxLogLine` messages. Maintains a per-sandbox tail buffer (default 200 entries). Also contains a nested `PlatformEventBus` for compute-driver platform events.
-- **`settings_mutex`** -- serializes settings mutations (global and sandbox) to prevent read-modify-write races. Held for the duration of any setting set/delete or global policy set/delete operation. See [Gateway Settings Channel](gateway-settings.md#global-policy-lifecycle).
+- **`supervisor_sessions`** -- tracks the live `ConnectSupervisor` session per sandbox and the set of pending relay channels awaiting the supervisor's `RelayStream` dial-back. See [Supervisor Sessions](#supervisor-sessions).
+- **`settings_mutex`** -- serializes settings mutations (global and sandbox) to prevent read-modify-write races. See [Gateway Settings Channel](gateway-settings.md#global-policy-lifecycle).
## Protocol Multiplexing
@@ -164,8 +189,9 @@ All traffic (gRPC and HTTP) shares a single TCP port. Multiplexing happens at th
`MultiplexService::serve()` (`crates/openshell-server/src/multiplex.rs`) creates per-connection service instances:
1. Each accepted TCP stream (optionally TLS-wrapped) is passed to `hyper_util::server::conn::auto::Builder`, which auto-negotiates HTTP/1.1 or HTTP/2.
-2. The builder calls `serve_connection_with_upgrades()`, which supports HTTP upgrades (needed for the SSH tunnel's CONNECT method).
-3. For each request, `MultiplexedService` inspects the `content-type` header:
+2. The HTTP/2 side is built with `adaptive_window(true)`. Hyper/h2 auto-sizes the per-stream flow-control window based on measured bandwidth-delay product, so bulk byte transfers on `RelayStream` (and `ExecSandbox` / `PushSandboxLogs`) are not throttled by the default 64 KiB window. Idle streams stay cheap; active streams grow as needed.
+3. The builder calls `serve_connection_with_upgrades()`, which supports HTTP upgrades (needed for the SSH tunnel's CONNECT method).
+4. For each request, `MultiplexedService` inspects the `content-type` header:
- **Starts with `application/grpc`** -- routes to `GrpcRouter`.
- **Anything else** -- routes to the Axum HTTP router.
@@ -189,33 +215,148 @@ When TLS is enabled (`crates/openshell-server/src/tls.rs`):
- `--disable-tls` removes gateway-side TLS entirely and serves plaintext HTTP behind a trusted reverse proxy or tunnel.
- Supports PKCS#1, PKCS#8, and SEC1 private key formats.
- The TLS handshake happens before the stream reaches Hyper's auto builder, so ALPN negotiation and HTTP version detection work together transparently.
-- Certificates are generated at cluster bootstrap time by the `openshell-bootstrap` crate using `rcgen`, not by a Helm Job. The bootstrap reconciles three K8s secrets: `openshell-server-tls` (server cert+key), `openshell-server-client-ca` (CA cert), and `openshell-client-tls` (client cert+key+CA, shared by CLI and sandbox pods).
-- **Certificate lifetime**: Certificates use `rcgen` defaults (effectively never expire), which is appropriate for an internal dev-cluster PKI where certs are ephemeral to the cluster's lifetime.
-- **Redeploy behavior**: On redeploy, existing cluster TLS secrets are loaded and reused if they are complete and valid PEM. If secrets are missing, incomplete, or malformed, fresh PKI is generated. If rotation occurs and the openshell workload is already running, the bootstrap performs a rollout restart and waits for completion before persisting CLI-side credentials.
+- Certificates are operator-provided in the target deployment model. Helm deployments consume three K8s secrets: `openshell-server-tls` (server cert+key), `openshell-server-client-ca` (CA cert), and `openshell-client-tls` (client cert+key+CA, shared by CLI and sandbox workloads).
+- Sandbox supervisors reuse the shared client cert to authenticate their `ConnectSupervisor` and `RelayStream` calls over the same mTLS channel.
+
+## Supervisor Sessions
+
+The gateway brokers all byte-level access into a sandbox through a two-plane design on a single HTTP/2 connection initiated by the supervisor:
+
+1. **Control plane** -- `ConnectSupervisor(stream SupervisorMessage) returns (stream GatewayMessage)`. Long-lived, one per sandbox. Carries `SupervisorHello`, `SessionAccepted`/`SessionRejected`, heartbeats, and `RelayOpen`/`RelayClose` control messages.
+2. **Data plane** -- `RelayStream(stream RelayFrame) returns (stream RelayFrame)`. One short-lived call per SSH or exec invocation. The first inbound frame is a `RelayInit { channel_id }`; subsequent frames carry raw bytes in `RelayFrame.data` in either direction.
+
+Both RPCs are defined in `proto/openshell.proto` and ride the same TCP + TLS + HTTP/2 connection from the supervisor. No new TLS handshake, no reverse HTTP CONNECT, no direct gateway-to-pod dial.
+
+### `SupervisorSessionRegistry`
+
+`crates/openshell-server/src/supervisor_session.rs` defines `SupervisorSessionRegistry`, a single instance of which lives on `ServerState.supervisor_sessions`. It holds two maps guarded by `std::sync::Mutex`:
+
+- `sessions: HashMap` -- one entry per connected supervisor. Each `LiveSession` carries a unique `session_id`, the `mpsc::Sender` for the outbound stream, and a connection timestamp.
+- `pending_relays: HashMap` -- one entry per in-flight `open_relay` call awaiting the supervisor's `RelayStream` dial-back. Each `PendingRelay` wraps a `oneshot::Sender` and a creation timestamp.
+
+Core operations:
+
+| Method | Purpose |
+|--------|---------|
+| `register(sandbox_id, session_id, tx)` | Insert a live session; returns the previous session's sender (if any) so the caller can close it. Used by `handle_connect_supervisor` when a supervisor reconnects. |
+| `remove_if_current(sandbox_id, session_id)` | Remove the session only if its `session_id` still matches. Guards against the supersede race where an old session's cleanup task fires after a newer session already registered. |
+| `open_relay(sandbox_id, session_wait_timeout)` | Wait up to `session_wait_timeout` for a live session, allocate a fresh `channel_id` (UUID v4), insert the pending slot, send `RelayOpen { channel_id }` to the supervisor, and return `(channel_id, oneshot::Receiver)`. The receiver resolves once the supervisor's `RelayStream` arrives and `claim_relay` pairs them up. |
+| `claim_relay(channel_id)` | Consume the pending slot, construct a `tokio::io::duplex(64 KiB)` pair, hand the gateway-side half to the waiter via the oneshot, and return the supervisor-side half to `handle_relay_stream`. |
+| `reap_expired_relays()` | Drop pending relays older than 10 s. Called by `spawn_relay_reaper` on a 30 s cadence. |
+
+Session wait uses exponential backoff from 100 ms to 2 s while polling the sessions map. Pending-relay expiry is fixed at `RELAY_PENDING_TIMEOUT = 10 s`.
+
+### `handle_connect_supervisor`
+
+Lifecycle of a supervisor session:
+
+1. Read the first `SupervisorMessage`; require `payload = Hello { sandbox_id, instance_id }` and a non-empty `sandbox_id`.
+2. Allocate a fresh `session_id` (UUID v4) and create an `mpsc::channel::(64)` for the outbound stream.
+3. Call `registry.register(...)`. If it returns a previous sender, log that the previous session was superseded (dropping the previous `tx` closes the old outbound stream).
+4. Send `SessionAccepted { session_id, heartbeat_interval_secs: 15 }`. If the send fails, call `remove_if_current` (so a concurrent reconnect isn't evicted) and return `Internal`.
+5. Spawn a session loop that `select!`s between inbound messages and a 15 s heartbeat timer. Inbound heartbeats are silent; `RelayOpenResult` is logged; `RelayClose` is logged; unknown payloads are logged as warnings.
+6. When the loop exits (inbound EOF, inbound error, or outbound channel closed), `remove_if_current` drops the registration -- unless a newer session has already replaced it.
+
+### `handle_relay_stream`
+
+Lifecycle of one relay call:
+
+1. Read the first inbound `RelayFrame`; require `payload = Init { channel_id }` with a non-empty `channel_id`. Reject anything else with `InvalidArgument`.
+2. Call `registry.claim_relay(channel_id)`. Returns `NotFound` if the channel is unknown or already expired, `DeadlineExceeded` if older than 10 s, or `Internal` if the waiter has dropped the oneshot receiver.
+3. Split the supervisor-side `DuplexStream` into read and write halves and spawn two tasks:
+ - **Supervisor → gateway**: pull `RelayFrame`s from the inbound stream, accept `Data(bytes)`, write to the duplex write-half. On non-data frames, warn and break. Best-effort `shutdown()` on exit so the reader sees EOF.
+ - **Gateway → supervisor**: read up to `RELAY_STREAM_CHUNK_SIZE = 16 KiB` at a time from the duplex read-half and emit `RelayFrame { Data }` messages on an outbound `mpsc::channel(16)`.
+4. Return the outbound receiver as the RPC response stream.
+
+### Connect Flow (SSH Tunnel)
+
+```mermaid
+sequenceDiagram
+ participant Client as SSH client
+ participant GW as Gateway (/connect/ssh)
+ participant Reg as SupervisorSessionRegistry
+ participant Sup as Sandbox Supervisor
+ participant Daemon as In-sandbox sshd (Unix socket)
+
+ Client->>GW: CONNECT /connect/ssh x-sandbox-id, x-sandbox-token
+ GW->>GW: validate session + sandbox Ready
+ GW->>Reg: open_relay(sandbox_id, 30s)
+ Reg->>Sup: GatewayMessage::RelayOpen { channel_id }
+ Note over Reg: waits for RelayStream on channel_id
+ Sup->>Daemon: connect to Unix socket
+ Sup->>GW: RelayStream(RelayFrame::Init { channel_id })
+ GW->>Reg: claim_relay(channel_id)
+ Reg-->>Sup: supervisor-side DuplexStream
+ Reg-->>GW: gateway-side DuplexStream
+ GW-->>Client: 200 OK + HTTP upgrade
+ Client<<->>GW: copy_bidirectional(upgraded, duplex)
+ GW<<->>Sup: RelayFrame::Data in both directions
+ Sup<<->>Daemon: raw SSH bytes
+```
+
+Timeouts on the tunnel path:
+
+- `open_relay` session wait: **30 s**. A first `sandbox connect` immediately after `sandbox create` must cover the supervisor's initial TLS + gRPC handshake on a cold pod.
+- `relay_rx` delivery timeout: 10 s. Covers the round-trip from the `RelayOpen` message to the supervisor's `RelayStream` dial-back.
+
+Per-token and per-sandbox concurrent-tunnel limits (3 and 20 respectively) are still enforced before the upgrade.
+
+### Exec Flow
+
+`ExecSandbox` reuses the same machinery from `grpc/sandbox.rs`:
+
+1. Validate the request (`sandbox_id`, `command`, env-key format, other field rules), fetch the sandbox, require `Ready` phase.
+2. `state.supervisor_sessions.open_relay(&sandbox.id, 15s)` -- shorter timeout than SSH connect, because exec is typically called mid-lifetime after the supervisor session is already established.
+3. Wait up to 10 s for the relay `DuplexStream`.
+4. `stream_exec_over_relay`: bind an ephemeral localhost TCP listener, bridge that single-use TCP socket to the relay duplex, and drive a `russh` client through the local port. The `russh` session opens a channel, executes the shell-escaped command, and streams `ExecSandboxStdout`/`ExecSandboxStderr` chunks to the caller. On completion, send `ExecSandboxExit { exit_code }`.
+5. On timeout (if `timeout_seconds > 0`), emit exit code 124 (matching `timeout(1)`).
+
+The supervisor-side SSH daemon is an SSH server bound to a Unix domain socket inside the sandbox's filesystem. Filesystem permissions on that socket are the only access-control boundary between the supervisor bridge and the daemon; all higher-level authorization is enforced at `CreateSshSession` / `ExecSandbox` in the gateway.
+
+### Regression Coverage
+
+`crates/openshell-server/tests/supervisor_relay_integration.rs` is the regression guard for the `RelayStream` wire protocol. It stands up an in-process tonic server that mounts the real `handle_relay_stream` behind `MultiplexedService`, connects a mock supervisor client over a real tonic `Channel`, and exercises the registry's `open_relay` → `claim_relay` pairing end to end with `tokio::io::duplex` bridging. The five test cases cover:
+
+- Round-trip bytes from gateway to supervisor and back (echo loop).
+- Clean close when the gateway drops the relay.
+- EOF propagation when the supervisor closes its outbound sender.
+- `Unavailable` when `open_relay` is called without a registered session.
+- Concurrent `RelayStream` calls multiplexed independently on the same connection.
+
+These complement the unit tests inside `supervisor_session.rs` (registry-only behavior) and the live cluster tests (full CLI → gateway → sandbox path).
## gRPC Services
### OpenShell Service
-Defined in `proto/openshell.proto`, implemented in `crates/openshell-server/src/grpc.rs` as `OpenShellService`.
+Defined in `proto/openshell.proto`, implemented in `crates/openshell-server/src/grpc/mod.rs` as `OpenShellService`. Per-concern handlers live in `crates/openshell-server/src/grpc/` submodules.
#### Sandbox Management
| RPC | Description | Key behavior |
|-----|-------------|--------------|
| `Health` | Returns service status and version | Always returns `HEALTHY` with `CARGO_PKG_VERSION` |
-| `CreateSandbox` | Create a new sandbox | Validates spec and policy, validates provider names exist (fail-fast), persists to store, creates Kubernetes CRD. On K8s 409 conflict or error, rolls back the store record and index entry. |
+| `CreateSandbox` | Create a new sandbox | Validates spec and policy, validates provider names exist (fail-fast), persists to store, creates the compute-driver sandbox. On driver failure, rolls back the store record and index entry. |
| `GetSandbox` | Fetch sandbox by name | Looks up by name via `store.get_message_by_name()` |
| `ListSandboxes` | List sandboxes | Paginated (default limit 100), decodes protobuf payloads from store records |
-| `DeleteSandbox` | Delete sandbox by name | Sets phase to `Deleting`, persists, notifies watch bus, then deletes the Kubernetes CRD. Cleans up store if the CRD was already gone. |
+| `DeleteSandbox` | Delete sandbox by name | Sets phase to `Deleting`, persists, notifies watch bus, then deletes via the compute driver. Cleans up store if the sandbox was already gone. |
| `WatchSandbox` | Stream sandbox updates | Server-streaming RPC. See [Watch Sandbox Stream](#watch-sandbox-stream) below. |
-| `ExecSandbox` | Execute command in sandbox | Server-streaming RPC. See [Remote Exec via SSH](#remote-exec-via-ssh) below. |
+| `ExecSandbox` | Execute command in sandbox | Server-streaming RPC; data plane runs through `SupervisorSessionRegistry::open_relay`. See [Exec Flow](#exec-flow). |
+
+#### Supervisor Session
+
+| RPC | Description |
+|-----|-------------|
+| `ConnectSupervisor` | Persistent bidi stream from the sandbox supervisor. Carries hello/accept/heartbeat/`RelayOpen`/`RelayClose`. One session per sandbox; reconnects supersede. |
+| `RelayStream` | Per-invocation bidi byte bridge. Supervisor initiates after receiving `RelayOpen`; first frame is `RelayInit { channel_id }`; subsequent frames carry raw bytes. |
+
+Neither RPC is called by end users. They are the private control/data plane between the gateway and each sandbox supervisor.
#### SSH Session Management
| RPC | Description |
|-----|-------------|
-| `CreateSshSession` | Creates a session token for a `Ready` sandbox. Persists an `SshSession` record and returns gateway connection details (host, port, scheme, connect path). |
+| `CreateSshSession` | Creates a session token for a `Ready` sandbox. Persists an `SshSession` record and returns gateway connection details (host, port, scheme, connect path). The resulting token is presented on the `/connect/ssh` HTTP CONNECT request. |
| `RevokeSshSession` | Marks a session as revoked by setting `session.revoked = true` in the store. |
#### Provider Management
@@ -232,17 +373,17 @@ Full CRUD for `Provider` objects, which store typed credentials (e.g., API keys
#### Policy, Settings, and Provider Environment Delivery
-These RPCs are called by sandbox pods at startup and during runtime polling.
+These RPCs are called by sandbox supervisors at startup and during runtime polling.
| RPC | Description |
|-----|-------------|
-| `GetSandboxSettings` | Returns effective sandbox config looked up by sandbox ID: policy payload, policy metadata (version, hash, source, `global_policy_version`), merged effective settings, and a `config_revision` fingerprint for change detection. Two-tier resolution: registered keys start unset, sandbox values overlay, global values override. The reserved `policy` key in global settings can override the sandbox's own policy. When a global policy is active, `policy_source` is `GLOBAL` and `global_policy_version` carries the active revision number. See [Gateway Settings Channel](gateway-settings.md). |
-| `GetGatewaySettings` | Returns gateway-global settings only (excluding the reserved `policy` key). Returns registered keys with empty values when unconfigured, and a monotonic `settings_revision`. |
+| `GetSandboxConfig` | Returns effective sandbox config looked up by sandbox ID: policy payload, policy metadata (version, hash, source, `global_policy_version`), merged effective settings, and a `config_revision` fingerprint for change detection. Two-tier resolution: registered keys start unset, sandbox values overlay, global values override. The reserved `policy` key in global settings can override the sandbox's own policy. When a global policy is active, `policy_source` is `GLOBAL` and `global_policy_version` carries the active revision number. See [Gateway Settings Channel](gateway-settings.md). |
+| `GetGatewayConfig` | Returns gateway-global settings only (excluding the reserved `policy` key). Returns registered keys with empty values when unconfigured, and a monotonic `settings_revision`. |
| `GetSandboxProviderEnvironment` | Resolves provider credentials into environment variables for a sandbox. Iterates the sandbox's `spec.providers` list, fetches each `Provider`, and collects credential key-value pairs. First provider wins on duplicate keys. Skips credential keys that do not match `^[A-Za-z_][A-Za-z0-9_]*$`. |
#### Policy Recommendation (Network Rules)
-These RPCs support the sandbox-initiated policy recommendation pipeline. The sandbox generates proposals via its mechanistic mapper and submits them; the gateway validates, persists, and manages the approval workflow. See [architecture/policy-advisor.md](policy-advisor.md) for the full pipeline design.
+These RPCs support the sandbox-initiated policy recommendation pipeline. The sandbox generates proposals via its mechanistic mapper and submits them; the gateway validates, persists, and manages the approval workflow. See [policy-advisor.md](policy-advisor.md) for the full pipeline design.
| RPC | Description |
|-----|-------------|
@@ -258,27 +399,27 @@ These RPCs support the sandbox-initiated policy recommendation pipeline. The san
Defined in `proto/inference.proto`, implemented in `crates/openshell-server/src/inference.rs` as `InferenceService`.
-The gateway acts as the control plane for inference configuration. It stores a single managed cluster inference route (named `inference.local`) and delivers resolved route bundles to sandbox pods. The gateway does not execute inference requests -- sandboxes connect directly to inference backends using the credentials and endpoints provided in the bundle.
+The gateway acts as the control plane for inference configuration. It stores a single managed gateway inference route (named `inference.local`) and delivers resolved route bundles to sandbox pods. The gateway does not execute inference requests -- sandboxes connect directly to inference backends using the credentials and endpoints provided in the bundle.
-#### Cluster Inference Configuration
+#### Gateway Inference Configuration
-The gateway manages a single cluster-wide inference route that maps to a provider record. When set, the route stores only a `provider_name` and `model_id` reference. At bundle resolution time, the gateway looks up the referenced provider and derives the endpoint URL, API key, protocols, and provider type from it. This late-binding design means provider credential rotations are automatically reflected in the next bundle fetch without updating the route itself.
+The gateway manages a single gateway-wide inference route that maps to a provider record. When set, the route stores only a `provider_name` and `model_id` reference. At bundle resolution time, the gateway looks up the referenced provider and derives the endpoint URL, API key, protocols, and provider type from it. This late-binding design means provider credential rotations are automatically reflected in the next bundle fetch without updating the route itself.
| RPC | Description |
|-----|-------------|
-| `SetClusterInference` | Configures the cluster inference route. Validates `provider_name` and `model_id` are non-empty, verifies the named provider exists and has a supported type for inference (openai, anthropic, nvidia), validates the provider has a usable API key, then upserts the `inference.local` route record. Increments a monotonic `version` on each update. Returns the configured `provider_name`, `model_id`, and `version`. |
-| `GetClusterInference` | Returns the current cluster inference configuration (`provider_name`, `model_id`, `version`). Returns `NotFound` if no cluster inference is configured, or `FailedPrecondition` if the stored route has empty provider/model metadata. |
+| `SetClusterInference` | Configures the gateway inference route. Validates `provider_name` and `model_id` are non-empty, verifies the named provider exists and has a supported type for inference (openai, anthropic, nvidia), validates the provider has a usable API key, then upserts the `inference.local` route record. Increments a monotonic `version` on each update. Returns the configured `provider_name`, `model_id`, and `version`. |
+| `GetClusterInference` | Returns the current gateway inference configuration (`provider_name`, `model_id`, `version`). Returns `NotFound` if no gateway inference is configured, or `FailedPrecondition` if the stored route has empty provider/model metadata. |
| `GetInferenceBundle` | Returns the resolved inference route bundle for sandbox consumption. See [Route Bundle Delivery](#route-bundle-delivery) below. |
#### Route Bundle Delivery
-The `GetInferenceBundle` RPC resolves the managed cluster route into a `GetInferenceBundleResponse` containing fully materialized route data that sandboxes can use directly.
+The `GetInferenceBundle` RPC resolves the managed gateway route into a `GetInferenceBundleResponse` containing fully materialized route data that sandboxes can use directly.
The trait method delegates to `resolve_inference_bundle(store)` (`crates/openshell-server/src/inference.rs`), which takes `&Store` instead of `&self`. This extraction decouples bundle resolution from `ServerState`, enabling direct unit testing against an in-memory SQLite store without constructing a full server.
The `GetInferenceBundleResponse` includes:
-- **`routes`** -- a list of `ResolvedRoute` messages containing base URL, model ID, API key, protocols, and provider type. Currently contains zero or one routes (the managed cluster route).
+- **`routes`** -- a list of `ResolvedRoute` messages containing base URL, model ID, API key, protocols, and provider type. Currently contains zero or one routes (the managed gateway route).
- **`revision`** -- a hex-encoded hash computed from route contents. Sandboxes compare this value to detect when their route set has changed.
- **`generated_at_ms`** -- epoch milliseconds when the bundle was assembled.
@@ -315,9 +456,9 @@ The HTTP router (`crates/openshell-server/src/http.rs`) merges two sub-routers:
| Path | Method | Response |
|------|--------|----------|
-| `/connect/ssh` | CONNECT | Upgrades the connection to a bidirectional TCP tunnel to a sandbox pod's SSH port |
+| `/connect/ssh` | CONNECT | Upgrades the connection to a bidirectional byte bridge tunneled through `SupervisorSessionRegistry::open_relay` |
-See [SSH Tunnel Gateway](#ssh-tunnel-gateway) for details.
+See [Connect Flow (SSH Tunnel)](#connect-flow-ssh-tunnel) for details.
### Cloudflare Endpoints
@@ -328,7 +469,7 @@ See [SSH Tunnel Gateway](#ssh-tunnel-gateway) for details.
## Watch Sandbox Stream
-The `WatchSandbox` RPC (`crates/openshell-server/src/grpc.rs`) provides a multiplexed server-streaming response that can include sandbox status snapshots, gateway log lines, and platform events.
+The `WatchSandbox` RPC (`crates/openshell-server/src/grpc/`) provides a multiplexed server-streaming response that can include sandbox status snapshots, gateway log lines, and platform events.
### Request Options
@@ -336,7 +477,7 @@ The `WatchSandboxRequest` controls what the stream includes:
- `follow_status` -- subscribe to `SandboxWatchBus` notifications and re-read the sandbox record on each change.
- `follow_logs` -- subscribe to `TracingLogBus` for gateway log lines correlated by `sandbox_id`.
-- `follow_events` -- subscribe to `PlatformEventBus` for Kubernetes events correlated to the sandbox.
+- `follow_events` -- subscribe to `PlatformEventBus` for compute-driver platform events correlated to the sandbox.
- `log_tail_lines` -- replay the last N log lines before following (default 200).
- `stop_on_terminal` -- end the stream when the sandbox reaches the `Ready` phase. Note: `Error` phase does not stop the stream because it may be transient (e.g., `ReconcilerError`).
@@ -355,8 +496,8 @@ The `WatchSandboxRequest` controls what the stream includes:
```mermaid
graph LR
- SW["spawn_sandbox_watcher"]
- ET["spawn_kube_event_tailer"]
+ CW["ComputeRuntime watcher"]
+ PE["Platform events (driver watch)"]
TL["SandboxLogLayer (tracing layer)"]
WB["SandboxWatchBus (broadcast per ID)"]
@@ -365,9 +506,9 @@ graph LR
WS["WatchSandbox stream"]
- SW -->|"notify(id)"| WB
+ CW -->|"notify(id)"| WB
TL -->|"publish(id, log_event)"| LB
- ET -->|"publish(id, platform_event)"| PB
+ PE -->|"publish(id, platform_event)"| PB
WB -->|"subscribe(id)"| WS
LB -->|"subscribe(id)"| WS
@@ -375,6 +516,7 @@ graph LR
```
All buses use `tokio::sync::broadcast` channels keyed by sandbox ID. Buffer sizes:
+
- `SandboxWatchBus`: 128 (signals only, no payload -- just `()`)
- `TracingLogBus`: 1024 (full `SandboxStreamEvent` payloads)
- `PlatformEventBus`: 1024 (full `SandboxStreamEvent` payloads)
@@ -385,52 +527,6 @@ Broadcast lag is translated to `Status::resource_exhausted` via `broadcast_to_st
**Validation:** `WatchSandbox` validates that the sandbox exists before subscribing to any bus, preventing entries from being created for non-existent IDs. `PushSandboxLogs` validates sandbox existence once on the first batch of the stream.
-## Remote Exec via SSH
-
-The `ExecSandbox` RPC (`crates/openshell-server/src/grpc.rs`) executes a command inside a sandbox pod over SSH and streams stdout/stderr/exit back to the client.
-
-### Execution Flow
-
-1. Validate request: `sandbox_id`, `command`, and environment key format (`^[A-Za-z_][A-Za-z0-9_]*$`).
-2. Verify sandbox exists and is in `Ready` phase.
-3. Resolve target: prefer agent pod IP, fall back to Kubernetes service DNS (`..svc.cluster.local`). If the sandbox is not connectable yet (for example the pod exists but has no IP), the gateway returns `FAILED_PRECONDITION` instead of surfacing the condition as an internal server fault.
-4. Build the remote command string: sort environment variables, shell-escape all values, prepend `cd &&` if `workdir` is set.
-5. **Start a single-use SSH proxy**: binds an ephemeral local TCP port, accepts one connection, performs the NSSH1 handshake with the sandbox, and bidirectionally copies data.
-6. **Connect via `russh`**: establishes an SSH connection through the local proxy, authenticates with `none` auth as user `sandbox`, opens a session channel, and executes the command.
-7. Stream `ExecSandboxStdout`, `ExecSandboxStderr` chunks as they arrive, then send `ExecSandboxExit` with the exit code.
-8. On timeout (if `timeout_seconds > 0`), send exit code 124 (matching the `timeout(1)` convention).
-
-### NSSH1 Handshake Protocol
-
-The single-use SSH proxy and the SSH tunnel endpoint both use the same handshake:
-
-```
-NSSH1 \n
-```
-
-- `token` -- session token or a one-time UUID.
-- `timestamp` -- Unix epoch seconds.
-- `nonce` -- UUID v4.
-- `hmac_signature` -- `HMAC-SHA256(secret, "{token}|{timestamp}|{nonce}")`, hex-encoded.
-- Expected response: `OK\n` from the sandbox.
-
-The `ssh_handshake_skew_secs` configuration controls how much clock skew is tolerated.
-
-## SSH Tunnel Gateway
-
-The SSH tunnel endpoint (`crates/openshell-server/src/ssh_tunnel.rs`) allows external SSH clients to reach sandbox pods through the gateway using HTTP CONNECT upgrades.
-
-### Request Flow
-
-1. Client sends `CONNECT /connect/ssh` with headers `x-sandbox-id` and `x-sandbox-token`.
-2. Handler validates the method is CONNECT, extracts headers.
-3. Fetches the `SshSession` from the store by token; rejects if revoked or if `sandbox_id` does not match.
-4. Fetches the `Sandbox`; rejects if not in `Ready` phase.
-5. Resolves the connect target: agent pod IP if available, otherwise Kubernetes service DNS.
-6. Returns `200 OK`, then upgrades the connection via `hyper::upgrade::on()`.
-7. In a spawned task: connects to the sandbox's SSH port, performs the NSSH1 handshake, then bidirectionally copies bytes between the upgraded HTTP connection and the sandbox TCP stream.
-8. On completion, gracefully shuts down the write-half of the upgraded connection for clean EOF handling.
-
## Persistence Layer
### Store Architecture
@@ -486,7 +582,7 @@ The `generate_name()` function produces random 6-character lowercase alphabetic
### Deployment Storage
-The gateway runs as a Kubernetes **StatefulSet** with a `volumeClaimTemplate` that provisions a 1Gi `ReadWriteOnce` PersistentVolumeClaim mounted at `/var/openshell`. On k3s clusters this uses the built-in `local-path-provisioner` StorageClass (the cluster default). The SQLite database file at `/var/openshell/openshell.db` survives pod restarts and rescheduling.
+The gateway runs as a Kubernetes **StatefulSet** with a `volumeClaimTemplate` that provisions a 1Gi `ReadWriteOnce` PersistentVolumeClaim mounted at `/var/openshell`. The cluster's default StorageClass supplies the volume unless an operator customizes the chart. The SQLite database file at `/var/openshell/openshell.db` survives pod restarts and rescheduling.
The Helm chart template is at `deploy/helm/openshell/templates/statefulset.yaml`.
@@ -496,28 +592,48 @@ The Helm chart template is at `deploy/helm/openshell/templates/statefulset.yaml`
- **Get / Delete**: Operate by primary key (`id`), filtered by `object_type`.
- **List**: Pages by `limit` + `offset` with deterministic ordering: `ORDER BY created_at_ms ASC, name ASC`. The secondary sort on `name` prevents unstable ordering when rows share the same millisecond timestamp.
-## Kubernetes Integration
+## Compute Driver Integration
-### Sandbox CRD Management
+### Kubernetes Driver
`KubernetesComputeDriver` (`crates/openshell-driver-kubernetes/src/driver.rs`) manages `agents.x-k8s.io/v1alpha1/Sandbox` CRDs behind the gateway's compute interface. The gateway binds to that driver through `ComputeDriverService` (`crates/openshell-driver-kubernetes/src/grpc.rs`) in-process, so the same `openshell.compute.v1.ComputeDriver` request and response types are exercised whether the driver is invoked locally or served over gRPC.
- **Get**: `GetSandbox` looks up a sandbox CRD by name and returns a driver-native platform observation (`openshell.compute.v1.DriverSandbox`) with raw status and condition data from the object.
- **List**: `ListSandboxes` enumerates sandbox CRDs and returns driver-native platform observations for each, sorted by name for stable results.
-- **Create**: Translates an internal `openshell.compute.v1.DriverSandbox` message into a Kubernetes `DynamicObject` with labels (`openshell.ai/sandbox-id`, `openshell.ai/managed-by: openshell`) and a spec that includes the pod template, environment variables, and gateway-required env vars (`OPENSHELL_SANDBOX_ID`, `OPENSHELL_ENDPOINT`, `OPENSHELL_SSH_LISTEN_ADDR`, etc.). When callers do not provide custom `volumeClaimTemplates`, the driver injects a default `workspace` PVC and mounts it at `/sandbox` so the default sandbox home/workdir survives pod rescheduling.
+- **Create**: Translates an internal `openshell.compute.v1.DriverSandbox` message into a Kubernetes `DynamicObject` with labels (`openshell.ai/sandbox-id`, `openshell.ai/managed-by: openshell`) and a spec that includes the pod template, environment variables, and gateway-required env vars (`OPENSHELL_SANDBOX_ID`, `OPENSHELL_ENDPOINT`, `OPENSHELL_SSH_SOCKET_PATH`, etc.). `OPENSHELL_SSH_SOCKET_PATH` is set from the driver's `--sandbox-ssh-socket-path` flag (default `/run/openshell/ssh.sock`) so the in-sandbox SSH daemon binds a Unix socket rather than a TCP port. When callers do not provide custom `volumeClaimTemplates`, the driver injects a default `workspace` PVC and mounts it at `/sandbox` so the default sandbox home/workdir survives pod rescheduling.
- **Delete**: Calls the Kubernetes API to delete the CRD by name. Returns `false` if already gone (404).
-- **Stop**: `proto/compute_driver.proto` now reserves `StopSandbox` for a non-destructive lifecycle transition. Resume is intentionally not a dedicated compute-driver RPC; the gateway is expected to auto-resume a stopped sandbox when a client connects or executes into it.
-- **Pod IP resolution**: `agent_pod_ip()` fetches the agent pod and reads `status.podIP`.
+- **Stop**: `proto/compute_driver.proto` reserves `StopSandbox` for a non-destructive lifecycle transition. Resume is intentionally not a dedicated compute-driver RPC; the gateway auto-resumes a stopped sandbox when a client connects or executes into it.
-### Sandbox Watcher
+The gateway reaches the sandbox exclusively through the supervisor-initiated `ConnectSupervisor` session, so the driver never returns sandbox network endpoints.
-The Kubernetes driver emits `WatchSandboxes` events through `proto/compute_driver.proto`. `ComputeRuntime` consumes that stream, translates the driver-native snapshots into public `openshell.v1.Sandbox` resources, derives the public phase, and applies the results to the store.
+### Docker Driver
-- **Applied**: Extracts the sandbox ID from labels (or falls back to name prefix stripping), reads the CRD status, emits a driver-native snapshot, and lets the gateway translate that into the stored public sandbox record. Notifies the watch bus.
-- **Deleted**: Removes the sandbox record from the store and the index. Notifies the watch bus.
-- **Restarted**: Re-processes all objects (full resync).
+The Docker driver (`crates/openshell-driver-docker/src/lib.rs`) is an in-process compute backend for local standalone gateways. It creates one Docker container per sandbox, labels each container with `openshell.ai/managed-by=openshell`, `openshell.ai/sandbox-id`, `openshell.ai/sandbox-name`, and `openshell.ai/sandbox-namespace`, and bind-mounts a Linux `openshell-sandbox` supervisor binary into the container.
-In addition to the watch stream, `ComputeRuntime` periodically calls `ComputeDriver/ListSandboxes` through the in-process `ComputeDriverService` and reconciles the store to that full driver snapshot. Public `GetSandbox` and `ListSandboxes` handlers remain store-backed, but the store is refreshed from the driver on a timer so the gateway still exercises the compute-driver RPC surface for reconciliation.
+- **Create**: Pulls or validates the sandbox image according to `sandbox_image_pull_policy`, creates a labeled container, mounts the supervisor binary and optional TLS material, and starts the container with the supervisor as entrypoint.
+- **Bridge networking**: Ensures a local Docker bridge network exists (`openshell-docker` by default) and starts every sandbox container on that network instead of using `network_mode=host`.
+- **Gateway callback routing**: On native Linux Docker, injects `host.openshell.internal` with the bridge gateway IP and reports that bridge gateway IP plus the normal gateway port to `run_server()` as an extra listener. If the primary listener already binds the wildcard address for that port, the extra address is covered and is not bound a second time. On Docker Desktop, the bridge gateway IP belongs to Docker Desktop's VM rather than the macOS/Windows host, so the driver maps `host.openshell.internal` to Docker's `host-gateway` alias and does not request an extra listener. `OPENSHELL_ENDPOINT` inside Docker sandboxes uses the configured scheme and points at `host.openshell.internal:` in both cases.
+- **Environment ownership**: Merges template and spec environment first, then overwrites driver-owned supervisor variables, including `PATH`, `OPENSHELL_ENDPOINT`, `OPENSHELL_SANDBOX_ID`, `OPENSHELL_SSH_SOCKET_PATH`, and `OPENSHELL_SANDBOX_COMMAND`. This keeps privileged supervisor setup from resolving helper binaries through a user-controlled search path.
+- **List/Get/Watch**: Reads labeled containers in the configured sandbox namespace and derives driver-native sandbox status from Docker state plus supervisor relay readiness.
+- **Stop**: Stops the matching labeled container without deleting it.
+- **Delete**: Force-removes the matching labeled container.
+- **Gateway shutdown**: On SIGINT or SIGTERM, `run_server()` leaves the accept loop and calls the Docker shutdown cleanup hook. The hook stops all running, restarting, or paused OpenShell-managed containers in the configured sandbox namespace so local sandboxes do not keep running after the gateway exits.
+- **Gateway startup resume**: Before the watch and reconcile loops spawn, `ComputeRuntime::resume_persisted_sandboxes()` walks every sandbox record in the store. For each sandbox whose phase is `Provisioning`, `Ready`, or `Unknown`, it asks the Docker driver to start the labeled container if it is in the `exited` or `created` state (`StartupResume::resume_sandbox`). Containers in `running` or `restarting` are left alone; `paused`, `dead`, and `removing` are skipped. If the matching container has disappeared, the sandbox is moved to phase `Error` with reason `BackendResourceMissing`; if the start call fails, the sandbox moves to `Error` with reason `ResumeFailed`. This is what makes sandboxes survive a graceful gateway restart end-to-end: shutdown stops them, the next startup resumes them, and the store remains the source of truth across the cycle. Drivers that do not need this hook (Kubernetes, Podman, VM) leave `startup_resume = None`, which makes the resume sweep a no-op.
+- **Handshake secret**: The Docker driver does not inject `OPENSHELL_SSH_HANDSHAKE_SECRET` or `OPENSHELL_SSH_HANDSHAKE_SKEW_SECS` into containers. Supervisor relay auth relies on the gateway connection rather than a Docker-visible container env secret.
+
+### VM Driver
+
+`VmDriver` (`crates/openshell-driver-vm/src/driver.rs`) is served by the standalone `openshell-driver-vm` process. The gateway spawns that binary on demand and talks to it over the internal `openshell.compute.v1.ComputeDriver` gRPC contract via a Unix domain socket.
+
+- **Create**: The VM driver process exports the selected sandbox image from the local Docker daemon, rewrites it into a sandbox-specific guest rootfs, injects an explicitly configured guest mTLS bundle when the gateway callback endpoint is `https://`, then re-execs itself in a hidden helper mode that loads libkrun directly and boots the supervisor.
+- **Networking**: The helper starts an embedded `gvproxy`, wires it into libkrun as virtio-net, and gives the guest outbound connectivity. No inbound TCP listener is needed — the supervisor reaches the gateway over its outbound `ConnectSupervisor` stream.
+- **Gateway callback**: The guest init script configures `eth0` for gvproxy networking, seeds `/etc/hosts` so `host.openshell.internal` resolves to the gvproxy gateway IP (`192.168.127.1`), preserves gvproxy's legacy `host.containers.internal` / `host.docker.internal` DNS answers, prefers the configured `OPENSHELL_GRPC_ENDPOINT`, and falls back to those aliases or the raw gateway IP when local hostname resolution is unavailable on macOS.
+- **Guest boot**: The sandbox guest runs a minimal init script that starts `openshell-sandbox` directly as PID 1 inside the VM.
+- **Watch stream**: Emits provisioning, ready, error, deleting, deleted, and platform-event updates so the gateway store remains the durable source of truth.
+
+### Compute Runtime
+
+`ComputeRuntime` consumes the driver-native watch stream from `WatchSandboxes`, translates the snapshots into public `openshell.v1.Sandbox` resources, derives the public phase, and applies the results to the store. In parallel, it periodically calls `ListSandboxes` and reconciles the store to the full driver snapshot; public `GetSandbox` and `ListSandboxes` handlers remain store-backed but are refreshed from the driver on a timer.
### Gateway Phase Derivation
@@ -534,7 +650,7 @@ In addition to the watch stream, `ComputeRuntime` periodically calls `ComputeDri
**Transient reasons** (will retry, stay in `Provisioning`): `ReconcilerError`, `DependenciesNotReady`.
All other `Ready=False` reasons are treated as terminal failures (`Error` phase).
-### Kubernetes Event Tailer
+### Kubernetes Event Correlation
The Kubernetes driver also watches namespace-scoped Kubernetes `Event` objects and correlates them to sandbox IDs before emitting them as compute-driver platform events:
@@ -551,29 +667,35 @@ Matched events are published to the `PlatformEventBus` as `SandboxStreamEvent::E
- `sandbox_name_to_id: HashMap`
- `agent_pod_to_id: HashMap`
-Updated by the sandbox watcher on every Applied event and by gRPC handlers during sandbox creation. Used by the event tailer to map Kubernetes event objects back to sandbox IDs.
+Updated by the compute watcher on every driver observation and by gRPC handlers during sandbox creation. Used by the compute-driver event correlator to map platform events back to sandbox IDs.
+
+## Observability
+
+Supervisor session telemetry is currently emitted as plain `tracing` events from `supervisor_session.rs` (accepted, superseded, ended, relay opened/claimed). OCSF structured logging on the gateway side is a tracked follow-up -- the `openshell-ocsf` crate needs a `GatewayContext` equivalent to the sandbox's `SandboxContext` before events like `network.activity` or `app.lifecycle` can be emitted here. Sandbox-side OCSF already covers SSH authentication, network decisions, and supervisor lifecycle.
## Error Handling
- **gRPC errors**: All gRPC handlers return `tonic::Status` with appropriate codes:
- - `InvalidArgument` for missing/malformed fields
- - `NotFound` for nonexistent objects
- - `AlreadyExists` for duplicate creation
- - `FailedPrecondition` for state violations (e.g., exec on non-Ready sandbox, missing provider)
- - `Internal` for store/decode/Kubernetes failures
- - `PermissionDenied` for policy violations
- - `ResourceExhausted` for broadcast lag (missed messages)
- - `Cancelled` for closed broadcast channels
-
-- **HTTP errors**: The SSH tunnel handler returns HTTP status codes directly (`401`, `404`, `405`, `412`, `500`, `502`).
+ - `InvalidArgument` for missing/malformed fields, including a non-`Init` first frame on `RelayStream`.
+ - `NotFound` for nonexistent objects, including unknown or expired relay channels on `claim_relay`.
+ - `AlreadyExists` for duplicate creation.
+ - `FailedPrecondition` for state violations (e.g., exec on non-Ready sandbox, missing provider).
+ - `Unavailable` when the supervisor session for a sandbox is not connected within `open_relay`'s wait window, or when the supervisor's outbound channel has closed between lookup and send.
+ - `DeadlineExceeded` when a pending relay slot is claimed past `RELAY_PENDING_TIMEOUT`, or when `relay_rx` fails to deliver in time.
+ - `Internal` for store/decode/driver failures and for the `claim_relay` case where the waiter has dropped the oneshot receiver.
+ - `PermissionDenied` for policy violations.
+ - `ResourceExhausted` for broadcast lag (missed messages).
+ - `Cancelled` for closed broadcast channels.
+
+- **HTTP errors**: The SSH tunnel handler returns HTTP status codes directly (`401`, `404`, `405`, `412`, `429`, `500`, `502`). `502` indicates the supervisor relay could not be opened; `429` indicates a per-token or per-sandbox concurrent-tunnel limit.
- **Connection errors**: Logged at `error` level but do not crash the gateway. TLS handshake failures and individual connection errors are caught and logged per-connection.
-- **Background task errors**: The sandbox watcher and event tailer log warnings for individual processing failures but continue running. If the watcher stream ends, it logs a warning and the task exits (no automatic restart).
+- **Background task errors**: The compute watcher and relay reaper log warnings for individual processing failures but continue running. If the watcher stream ends, it logs a warning and the task exits (no automatic restart).
## Cross-References
-- [Sandbox Architecture](sandbox.md) -- sandbox-side policy enforcement, proxy, and isolation details
+- [Sandbox Architecture](sandbox.md) -- sandbox-side policy enforcement, supervisor, and the local SSH daemon on the other end of the relay
- [Gateway Settings Channel](gateway-settings.md) -- runtime settings channel, two-tier resolution, CLI/TUI commands
- [Inference Routing](inference-routing.md) -- end-to-end inference interception flow, sandbox-side proxy logic, and route resolution
- [Container Management](build-containers.md) -- how sandbox container images are built and configured
diff --git a/architecture/inference-routing.md b/architecture/inference-routing.md
index e2fd671782..01b6a853a0 100644
--- a/architecture/inference-routing.md
+++ b/architecture/inference-routing.md
@@ -62,7 +62,7 @@ File: `crates/openshell-server/src/inference.rs`
The gateway implements the `Inference` gRPC service defined in `proto/inference.proto`.
-### Cluster inference set/get
+### Gateway inference set/get
`SetClusterInference` takes a `provider_name` and `model_id`. It:
@@ -73,7 +73,7 @@ The gateway implements the `Inference` gRPC service defined in `proto/inference.
5. Builds a managed route spec that stores only `provider_name` and `model_id`. The spec intentionally leaves `base_url`, `api_key`, and `protocols` empty -- these are resolved dynamically at bundle time from the provider record.
6. Upserts the route with name `inference.local`. Version starts at 1 and increments monotonically on each update.
-`GetClusterInference` returns `provider_name`, `model_id`, and `version` for the managed route. Returns `NOT_FOUND` if cluster inference is not configured.
+`GetClusterInference` returns `provider_name`, `model_id`, and `version` for the managed route. Returns `NOT_FOUND` if gateway inference is not configured.
### Bundle delivery
@@ -87,7 +87,7 @@ The gateway implements the `Inference` gRPC service defined in `proto/inference.
Because resolution happens at request time, credential rotation and endpoint changes on the provider record take effect on the next bundle fetch without re-running `SetClusterInference`.
-An empty route list is valid and indicates cluster inference is not yet configured.
+An empty route list is valid and indicates gateway inference is not yet configured.
### Proto definitions
@@ -109,7 +109,7 @@ Files:
- `crates/openshell-sandbox/src/lib.rs` -- inference context initialization, route refresh
- `crates/openshell-sandbox/src/grpc_client.rs` -- `fetch_inference_bundle()`
-In cluster mode, the sandbox starts a background refresh loop as soon as the inference context is created. The loop polls the gateway every 5 seconds by default (`OPENSHELL_ROUTE_REFRESH_INTERVAL_SECS` override) and uses the bundle revision hash to skip no-op cache writes. The revision hash covers all route fields including `timeout_secs`, so any configuration change (provider, model, or timeout) triggers a cache update on the next poll.
+In gateway bundle mode, the sandbox starts a background refresh loop as soon as the inference context is created. The loop polls the gateway every 5 seconds by default (`OPENSHELL_ROUTE_REFRESH_INTERVAL_SECS` override) and uses the bundle revision hash to skip no-op cache writes. The revision hash covers all route fields including `timeout_secs`, so any configuration change (provider, model, or timeout) triggers a cache update on the next poll.
### Interception flow
@@ -146,9 +146,9 @@ If no pattern matches, the proxy returns `403 Forbidden` with `{"error": "connec
### Route cache
- `InferenceContext` holds a `Router`, the pattern list, and an `Arc>>` route cache.
-- In cluster mode, `spawn_route_refresh()` polls `GetInferenceBundle` every 5 seconds (`OPENSHELL_ROUTE_REFRESH_INTERVAL_SECS`). On failure, stale routes are kept.
+- In gateway bundle mode, `spawn_route_refresh()` polls `GetInferenceBundle` every 5 seconds (`OPENSHELL_ROUTE_REFRESH_INTERVAL_SECS`). On failure, stale routes are kept.
- In file mode (`--inference-routes`), routes load once at startup from YAML. No refresh task is spawned.
-- In cluster mode, an empty initial bundle still enables the inference context so the refresh task can pick up later configuration.
+- In gateway bundle mode, an empty initial bundle still enables the inference context so the refresh task can pick up later configuration.
### Bundle-to-route conversion
@@ -171,16 +171,17 @@ Files:
`prepare_backend_request()` (shared by both buffered and streaming paths) rewrites outgoing requests:
1. **Auth injection**: Uses the route's `AuthHeader` -- either `Authorization: Bearer ` or a custom header (e.g. `x-api-key: ` for Anthropic).
-2. **Header stripping**: Removes `authorization`, `x-api-key`, `host`, and any header names that will be set from route defaults.
-3. **Default headers**: Applies route-level default headers (e.g. `anthropic-version: 2023-06-01`) unless the client already sent them.
-4. **Model rewrite**: Parses the request body as JSON and replaces the `model` field with the route's configured model. Non-JSON bodies are forwarded unchanged.
-5. **URL construction**: `build_backend_url()` appends the request path to the route endpoint. If the endpoint already ends with `/v1` and the request path starts with `/v1/`, the duplicate prefix is deduplicated.
+2. **Header allowlist**: Keeps only explicitly approved request headers: common inference headers (`content-type`, `accept`, `accept-encoding`, `user-agent`), route-specific passthrough headers (for example `openai-organization`, `x-model-id`, `anthropic-version`, `anthropic-beta`), and any route default header names.
+3. **Header stripping**: Removes `authorization`, `x-api-key`, `host`, `content-length`, hop-by-hop headers, and any non-allowlisted request headers.
+4. **Default headers**: Applies route-level default headers (e.g. `anthropic-version: 2023-06-01`) unless the client already sent them.
+5. **Model rewrite**: Parses the request body as JSON and replaces the `model` field with the route's configured model. Non-JSON bodies are forwarded unchanged.
+6. **URL construction**: `build_backend_url()` appends the request path to the route endpoint. If the endpoint already ends with `/v1` and the request path starts with `/v1/`, the duplicate prefix is deduplicated.
### Header sanitization
-Before forwarding inference requests, the proxy strips sensitive and hop-by-hop headers from both requests and responses:
+Before forwarding inference requests, the router enforces a route-aware request allowlist and strips sensitive/framing headers. Response sanitization remains framing-only:
-- **Request**: `authorization`, `x-api-key`, `host`, `content-length`, and hop-by-hop headers (`connection`, `keep-alive`, `proxy-authenticate`, `proxy-authorization`, `proxy-connection`, `te`, `trailer`, `transfer-encoding`, `upgrade`).
+- **Request**: forwards only common inference headers plus route-specific passthrough headers and route default header names. Always strips `authorization`, `x-api-key`, `host`, `content-length`, unknown headers such as `cookie`, and hop-by-hop headers (`connection`, `keep-alive`, `proxy-authenticate`, `proxy-authorization`, `proxy-connection`, `te`, `trailer`, `transfer-encoding`, `upgrade`).
- **Response**: `content-length` and hop-by-hop headers.
### Response streaming
@@ -213,7 +214,7 @@ This eliminates full-body buffering for streaming responses (SSE). Time-to-first
When the proxy truncates a streaming response, it injects an SSE error event via `format_sse_error()` (in `crates/openshell-sandbox/src/l7/inference.rs`) before sending the HTTP chunked terminator:
-```
+```text
data: {"error":{"message":"","type":"proxy_stream_error"}}
```
@@ -279,7 +280,7 @@ Validation at load time requires either `api_key` or `api_key_env` to resolve, a
| Status | Condition |
|--------|-----------|
| `403` | Request on `inference.local` does not match a recognized inference API pattern |
-| `503` | Pattern matched but route cache is empty (cluster inference not configured) |
+| `503` | Pattern matched but route cache is empty (gateway inference not configured) |
| `400` | No compatible route for the detected source protocol |
| `401` | Upstream returned unauthorized |
| `502` | Upstream protocol error or internal router error |
@@ -327,15 +328,15 @@ The system route is stored as a separate `InferenceRoute` record in the gateway
## CLI Surface
-Cluster inference commands:
+Gateway inference commands:
-- `openshell inference set --provider --model [--timeout ]` -- configures user-facing cluster inference
+- `openshell inference set --provider --model [--timeout ]` -- configures user-facing gateway inference
- `openshell inference set --system --provider --model [--timeout ]` -- configures system inference
- `openshell inference update [--provider ] [--model ] [--timeout ]` -- updates individual fields without resetting others
- `openshell inference get` -- displays both user and system inference configuration
- `openshell inference get --system` -- displays only the system inference configuration
-The `--provider` flag references a provider record name (not a provider type). The provider must already exist in the cluster and have a supported inference type (`openai`, `anthropic`, or `nvidia`).
+The `--provider` flag references a provider record name (not a provider type). The provider must already exist on the gateway and have a supported inference type (`openai`, `anthropic`, or `nvidia`).
The `--timeout` flag sets the per-request timeout in seconds for upstream inference calls. When omitted or set to `0`, the default of 60 seconds applies. Timeout changes propagate to running sandboxes within the route refresh interval (5 seconds by default).
diff --git a/architecture/object-metadata.md b/architecture/object-metadata.md
new file mode 100644
index 0000000000..961576e164
--- /dev/null
+++ b/architecture/object-metadata.md
@@ -0,0 +1,426 @@
+# Object Metadata Convention
+
+## Overview
+
+OpenShell adopts a Kubernetes-style object metadata convention for all top-level domain objects. This standardizes how resources are identified, labeled, and queried across the platform. All resources that users interact with directly (Sandbox, Provider, SshSession, InferenceRoute) follow this convention.
+
+## Core Principles
+
+### 1. Uniform Metadata Structure
+
+All top-level objects embed a common `ObjectMeta` message containing:
+
+- **Stable ID**: Server-generated UUID that never changes
+- **Human-readable name**: User-friendly identifier (unique per object type)
+- **Creation timestamp**: Milliseconds since Unix epoch
+- **Labels**: Key-value pairs for filtering and organization
+
+### 2. Trait-Based Access
+
+Rather than accessing metadata fields directly (e.g., `sandbox.metadata.as_ref().unwrap().id`), code uses trait methods from `openshell_core::metadata`:
+
+```rust
+use openshell_core::{ObjectId, ObjectName, ObjectLabels};
+
+let id = sandbox.object_id(); // Returns &str
+let name = sandbox.object_name(); // Returns &str
+let labels = sandbox.object_labels(); // Returns Option>
+```
+
+This provides:
+
+- **Uniform API** across all object types
+- **Graceful fallback** (returns empty string if metadata is None)
+- **Reduced boilerplate** in code that works with multiple object types
+
+### 3. Labels for Organization and Filtering
+
+Labels are key-value metadata attached to objects for:
+
+- **Grouping** related resources (e.g., all dev environment sandboxes)
+- **Filtering** in list operations (e.g., show only sandboxes with `team=backend`)
+- **Automation** and selection in scripts
+
+## Implementation Pattern
+
+### Protobuf Definition
+
+Define `ObjectMeta` once in `proto/datamodel.proto`:
+
+```protobuf
+message ObjectMeta {
+ string id = 1;
+ string name = 2;
+ int64 created_at_ms = 3;
+ map labels = 4;
+}
+```
+
+Embed it in top-level objects:
+
+```protobuf
+message Sandbox {
+ ObjectMeta metadata = 1;
+ SandboxSpec spec = 2;
+ SandboxStatus status = 3;
+ int32 phase = 4;
+ int32 current_policy_version = 5;
+}
+```
+
+**Migration**: When adding metadata to an existing object, shift field numbers to make room for `metadata = 1`. This maintains backward compatibility if done before release.
+
+### Trait Implementation
+
+Implement the three traits for each object in `crates/openshell-core/src/metadata.rs`:
+
+```rust
+impl ObjectId for Sandbox {
+ fn object_id(&self) -> &str {
+ self.metadata.as_ref().map(|m| m.id.as_str()).unwrap_or("")
+ }
+}
+
+impl ObjectName for Sandbox {
+ fn object_name(&self) -> &str {
+ self.metadata.as_ref().map(|m| m.name.as_str()).unwrap_or("")
+ }
+}
+
+impl ObjectLabels for Sandbox {
+ fn object_labels(&self) -> Option> {
+ self.metadata.as_ref().map(|m| m.labels.clone())
+ }
+}
+```
+
+**Pattern**: Always return empty string for missing metadata rather than panicking. This makes code resilient to malformed data.
+
+### Persistence Layer
+
+The `Store` trait in `crates/openshell-server/src/persistence/mod.rs` provides three methods for working with objects:
+
+```rust
+// Store/retrieve by stable ID
+async fn put_message(
+ &self,
+ message: &T,
+) -> Result<(), String>;
+
+async fn get(&self, object_type: &str, id: &str)
+ -> Result