Files
OpenShell/crates/openshell-driver-vm
Jesse Jaggars 2c31ac7e60 feat(network): support additional destination CAs (#3292)
* feat(network): support additional destination CAs

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(network): harden additional CA propagation

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(network): narrow additional CA review scope

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(network): harden additional CA rotation

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* feat(network): complete additional CA delivery

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(docker): remove stale runtime fixture field

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(network): adapt additional CA series to upstream main

Resolve rebase fallout against current upstream main after the series was
rebased 214 commits forward:

- follow upstream #3110: supervisor→gateway TLS is CA-only, so
  configure_gateway_tls and its isolation tests drop the client identity
- follow upstream tls_secret_names reduction to [String; 1] in the Podman
  driver while keeping the network trust bundle delivery
- port [openshell.supervisor.network] rendering into the
  openshell.gatewayConfigToml serializer in _toml.tpl
- adapt tests to upstream struct/function changes (DriverStartupContext,
  SandboxRunConfig, IsolationSpecInput, supervisor_pod, VM bootstrap image)
- drop conformance-artifact-prefix plumbing removed upstream

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* refactor(network): adopt upstream additional CA bundle surface

Unify the additional destination CA series on the mechanism merged to
main in #3996: one operator pre-assembled PEM bundle, the
--additional-ca-bundle supervisor flag, and the
/etc/openshell/tls/additional/ca-bundle.pem staging path.

- Replace the gateway-level additional_ca_cert_paths list with a single
  additional_ca_bundle file validated and staged at gateway startup.
- Restore the upstream supervisor argument path and fold-into-roots
  behavior; drop the --network-additional-ca-bundle and
  --network-additional-ca-digest contracts.
- Keep gateway normalization bounds, content-addressed staging,
  digest-based driver reconciliation, and stop/start rotation; the
  freshness check now runs at the gateway/driver boundary instead of
  the supervisor boundary.
- Reject additional_ca_bundle in operator driver JSON config; standalone
  drivers self-source the bundle from --sandbox-additional-ca-bundle
  with the same validation.
- Extend the Kubernetes ConfigMap name digest segment to keep the full
  63-byte budget after the prefix rename.
- Box the persisted-sandbox reconciliation futures so callers stay
  under the clippy::large_futures threshold.

Updates Docker, Podman, Kubernetes, and VM delivery, Helm chart and
tests, e2e wiring, gateway configuration docs, and the cluster
debugging skill to the single mechanism.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(ci): make gateway startup tests wrap-tolerant and refresh example lockfile

The network trust startup tests asserted on raw substrings of gateway
diagnostics, but the CLI renderer wraps output at a fixed width with
continuation markers and hard-breaks long paths. Compare assertions on
whitespace- and continuation-squashed text so they hold regardless of
wrap position.

Format the e2e workspace (separate cargo workspace, not covered by the
root cargo fmt run) and refresh examples/isolation-backend-v8/Cargo.lock
for the libc dependency added to the network trust crate.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(podman): reuse TLS CA secret during stopped-pair trust replacement

The additional CA e2e never ran in CI before the first maintainer test
dispatch, and its local container inspection was broken in two ways: it
located the supervisor container through e2e network membership (the
supervisor is host-networked or runtime-bridged, so the filter matched
the CI job container instead), and it required the gateway mTLS CA as a
bind mount (Podman delivers it as a container secret when user
namespaces remap UIDs). Select the supervisor by managed labels and
accept the secret delivery form.

The lifecycle leg then exposed a driver bug: stopped-pair trust
replacement re-created the per-sandbox TLS CA secret, colliding with
the secret still owned by the renamed stopped pair. Mirror the token
and resolver secret handling: require and reuse the existing secret
during replacement instead of creating it.

Validated end-to-end with the Podman additional CA e2e lane.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(server): restore upstream recovery semantics for start-request failures

Recovery no longer marks sandboxes Error/InvalidPersistedSandbox when
driver_start_request fails during the startup or lifecycle sweeps.
Gateway configuration problems (for example a missing launch signer)
must not permanently fail unrelated sandbox records; the sweep logs a
warning and skips them, matching the behavior before the additional CA
work. Sandbox-record failures surface through the normal start path.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* refactor(podman): refresh stale supervisors without replacing workloads

Replace the stopped-pair trust reconciliation with supervisor-only
recreation. The destination-trust generation label moves from the
workload to the supervisor container, and start_sandbox recreates only
the supervisor when its generation is missing or stale. The workload
container, its volumes, its secrets, and its workspace data are never
replaced: the additional CA bundle is mounted only into the supervisor.

Running sandboxes are never gated on trust labels, and recovery without
fresh launch credentials keeps the existing supervisor. The intentional
-removal fence machinery in the watcher existed only for workload
replacement and is removed with it.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* refactor(docker): drop workload replacement for destination-trust changes

Delete the stopped-workload trust reconciliation path and all of its
machinery: the trust-generation label on workload containers, the
generation match check in the start fence, the stopped-sandbox
replacement, the bounded workspace archive copy with its 512 MiB cap
and regular-file-only validation, and the boundary-state rollback
helpers.

No replacement machinery is needed: the trusted supervisor container
is already re-created from the gateway's current configuration on
every start via ensure_control_process_for_container, so stop/start
picks up the current additional CA bundle while the workload
container, its volumes, and its workspace data are never replaced.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* docs(configuration): drop reconciliation wording from additional CA lifecycle

The stop/start lifecycle description already matches the
supervisor-only refresh model; remove the two remaining references to
restart reconciliation of workload containers.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(network): port additional CA test fixtures to the crypto interface

Route the gateway TLS isolation test PKI, the destination CA source
fixtures, and the gateway startup trust tests through
openshell_crypto::pki now that rcgen access is centralized there:
generate_keypair/self_signed/signed_by replace the direct rcgen calls
and CertifiedKey field access follows the wrapper's shape.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(e2e): repair additional CA lane for Docker and non-container builds

The first maintainer test dispatch revealed two latent issues in the
additional CA e2e that no local run had exercised: the Podman gateway
CA secret helper referenced ContainerEngine without the container
feature gate, breaking Kubernetes and VM builds of the test, and the
Docker branch asserted a client ca.crt bind mount that the Docker
driver never creates. Gate the helper like its callers, and assert
Docker's actual delivery path instead: the gateway mTLS CA staged as
tls/ca.pem inside the read-only supervisor state volume, selected
through the OPENSHELL_TLS_CA environment pointer.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(helm): restore alphabetical README ordering for supervisor keys

The helm:docs check regenerates the chart README with helm-docs, which
orders rows alphabetically; a conflict resolution had placed
supervisor.network.additionalCaConfigMapName after the
supervisor.podSecurityContext rows. Regenerate with mise run
helm:docs.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* chore: refresh testkit lockfile for openshell-core libc dependency

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(driver-podman): stage operator CA bundle as driver-owned artifact

Standalone Podman drivers accepting --sandbox-additional-ca-bundle
previously kept the operator's source path as the trust artifact path.
The immutable-artifact verification performed on every create/start
requires exactly mode 0444, which an operator-owned source path cannot
guarantee, so a readable-but-writable source failed reconciliation.

Read the operator bundle with the same bounded, no-symlink validation,
then stage the normalized material into a driver-owned artifact inside a
private tempdir held for the driver process lifetime. The artifact is
created 0600, synced, and only then restricted to 0444 so no observer
ever sees a writable artifact. The operator source and its permissions
are never referenced after startup.

Addresses GATOR-291f43b2-02.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(driver-podman): pin retained workload image during supervisor refresh

Supervisor-only trust reconciliation re-entered
create_sandbox_with_existing_token, which re-pulled and re-resolved the
workload image tag even though the workload container is retained. If
the tag moved to a different image between stop and start, identity
resolution and child-environment construction would reflect the new
image while the retained workload still runs the original one, leaving
mode-0600 bootstrap files the retained user cannot read.

During a refresh with a retained workload, inspect the stopped container
and pin to the immutable image ID it was created from (Config.Image);
never pull or resolve the request tag. The requested reference is still
used for child metadata so the retained workload's environment is
byte-identical to its original launch.

Addresses GATOR-291f43b2-01.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* test(e2e): verify host-only VM trust delivery and drop curl dependency

The additional-CA lane's VM assertions read the destination CA out of the
guest overlay and a guest-side supervisor-argv marker — artifacts from
the guest-supervisor design that no longer exist now that the VM driver
stages the CA host-only and runs the supervisor on the host. Inspect the
driver-staged host artifact (digest-compared against the source), assert
the guest overlay contains no CA material at the old mount paths, and
keep the serial-console no-disclosure check.

The workload validation scripts also ran curl, which exists only in the
container-engine fixture image; the Kubernetes and VM lanes use the
uv-based workload image and died instantly with "canonical main process
already finished". Rewrite the main-process, exec, and lifecycle
validation scripts in python3 (present in every lane's workload image).
The network-policy binary identity is the resolved /proc/<pid>/exe
target, so the versioned python3.12 binaries are allowlisted rather
than the unversioned symlinks. The hostname mismatch check keeps its
semantics: same reachable fixture, mismatched TLS SNI, and the failure
must specifically be hostname verification.

Addresses GATOR-291f43b2-04.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(kubernetes): allow admission-added labels on destination-trust ConfigMaps

The destination-trust ConfigMap validation compared the whole label map
for equality. A mutating admission webhook that attaches an
organizational label to the created ConfigMap made the create response
fail validation, and the retained ConfigMap then failed every subsequent
create/start through the existing-object path, blocking sandbox launches
despite the ownership labels, immutable flag, digest, and certificate
bytes remaining correct.

Validate the gateway-owned label pairs individually and allow unrelated
labels; the exact trust-material checks are unchanged. Covered by a
regression test with an admission-added label and a missing
gateway-owned label.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(podman): close supervisor-refresh rollback gaps

The supervisor-only trust refresh renamed the previous supervisor to a
random rollback name and then performed the token-secret lookup. A
transient lookup failure returned with the supervisor still renamed,
where a normal delete could never find it, and the rollback rename and
final rollback removal ignored their own failures, leaving orphans with
attached control and channel resources.

Complete every fallible preparation before the rename. On a failed
replacement, restore the canonical name; if the restore itself fails,
return an error naming the rollback container so the orphan stays
identifiable — a later retry still succeeds because the canonical name
is absent and the replacement path recreates a fresh supervisor. Treat
not-found during the final rollback removal as success and name the
orphan in both the log and the error on other removal failures; a retry
observes the fresh canonical supervisor and resumes the normal start
path.

Covered by failure-injection tests for each transaction boundary:
preparation failure before any rename, replacement failure restoring
the canonical name, and restore failure naming the orphaned rollback
container.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* docs: address destination-trust review corrections

The gateway-configuration RFC documented an additional_ca_cert_paths
list that the strict configuration parser never accepted; document the
additional_ca_bundle single-path setting the parser actually enforces.

The debug-openshell-cluster skill named the wrong generation annotation
(openshell.ai/network-supervisor-trust-generation) and described digest
argv and network-init canonicalization checks the implementation does
not perform. Correct it to openshell.ai/additional-ca-generation and
describe the actual delivery: the supervisor Pod mounts the immutable
ConfigMap read-only and receives --additional-ca-bundle for the mount
path, with the driver validating the digest annotation and exact bytes
per creation.

Classify the destination-trust generation hashes as deterministic
identity derivation rather than authenticity proofs, following the
openshell-crypto exclusions table: trust decisions come from the
gateway's normalization, immutable staging, exact-byte validation, and
TLS verification. Record the classification in the crypto README and at
each hashing site.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(network): reconcile additional CA tests with suspend/resume and auth changes

Upstream's suspend/resume lifecycle took StartSandboxRequest fields 7
and 8, so the durable-snapshot field moves from 7 to 9; update the
wire-format test to assert the field-9 key (0x4a) and initialize the
new resume controls. Repair the cli test module after the auth opt-in
test merged alongside the environment-guard helpers, and initialize the
gateway driver-startup context's network-trust fields in the shared
supervision test.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

* fix(server): box start-recovery futures for clippy large-futures

The durable-snapshot field grows StartSandboxRequest enough that the
start-recovery match and the recovery-expiry test exceed the
large-futures limit under -D warnings. Box::pin both futures, matching
the pattern the recovery path used before the suspend/resume rework.

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>

---------

Signed-off-by: Jesse Jaggars <jjaggars@redhat.com>
2026-10-27 18:31:11 +00:00
..

openshell-driver-vm

Status: Experimental. The VM compute driver is under active development.

Standalone libkrun-backed ComputeDriver for OpenShell. The gateway spawns this binary as a subprocess and talks to it over the openshell.compute.v1.ComputeDriver Unix-socket surface. openshell-supervisor runs as a native host process, while openshell-sandbox runs as capability-free PID 1 inside each microVM and applies guest-local isolation over virtio-vsock.

New VM generations initiate the control connection to host CID 2 over vsock. Libkrun forwards that guest connection to the supervisor's protected Unix socket; QEMU uses a host vsock listener on a per-guest port and checks the guest CID before TLS authentication. Existing protocol roles and authorization stay unchanged. Running legacy generations retain their original descriptors; newly booted generations require matching embedded runtime and supervisor binaries.

The driver embeds libkrun, libkrunfw, the guest OCI unpacker, the portable guest sandbox, and the custom kernel runtime. Each sandbox boots from a cached immutable bootstrap ext4 root disk plus a per-sandbox writable overlay disk. When the requested sandbox image differs from the bootstrap image, the driver prepares a read-only image ext4 disk inside a bootstrap VM and mounts that unpacked rootfs as the sandbox lowerdir.

How it fits together

Caller driver config is disabled by default, including upload workflows encoded in driver JSON. GPU devices and their trusted VFIO plumbing are temporarily exempt from approval labels; existing GPU selection validation still applies. Private rootfs staging does not authorize arbitrary host paths. The gateway passes its common policy to its managed VM subprocess. See resource admission configuration.

flowchart LR
    subgraph host["Host"]
        gateway["openshell-gateway<br/>(vm::spawn)"]
        driver["openshell-driver-vm<br/>libkrun"]
        supervisor["openshell-supervisor<br/>host policy supervisor"]
        gateway <-->|"gRPC over UDS<br/>compute-driver.sock"| driver
        supervisor <-->|"authenticated gRPC<br/>policy + relay"| gateway
    end

    subgraph guest["Per-sandbox microVM"]
        init["/srv/openshell-vm-<br/>sandbox-init.sh"]
        sandbox["openshell-sandbox<br/>capability-free guest PID 1"]
        workload["sandbox workload"]
        init --> sandbox --> workload
    end

    driver -->|"CreateSandbox<br/>boots via libkrun"| guest
    supervisor <-->|"mutual TLS RFC 0012<br/>over virtio-vsock"| sandbox

    client["openshell-cli"] -->|"connect / exec / forward"| gateway
    client -->|"CreateSandbox / Watch"| gateway

The supervisor owns gateway credentials, admitted policy, provider resolution, middleware, the network proxy, and relay registration. The sandbox receives no gateway JWT. Each VM generation receives distinct sandbox and supervisor channel keys; the guest consumes and unlinks its private bootstrap files before launching the workload.

VM-specific RFC 0012 code under src/isolation/ only chooses the vsock transport and binds immutable VM generation and image claims into the protected guest config and host descriptor. Lifecycle, authentication, process control, binary identity, forwarding, and streaming come from openshell-isolation-interface and openshell-sandbox.

mise run gateway:vm

First run takes a few minutes while mise run vm:setup stages libkrun/libkrunfw/umoci and mise run vm:supervisor builds the portable Linux guest sandbox plus its small static guest-init helper. The development task also builds the native host supervisor. Subsequent runs are cached.

By default mise run gateway:vm:

  • Listens on plaintext HTTP at 127.0.0.1:18081.
  • Uses nvcr.io/nvidia/base/ubuntu:24.04 as the sandbox and bootstrap image.
  • Configures the gateway installation name as vm-dev and registers the same name with the CLI by writing ~/.config/openshell/gateways/vm-dev/metadata.json. It does not modify the workspace .env.
  • Persists the gateway SQLite DB under .cache/gateway-vm/gateway.db.
  • Places the VM driver state (per-sandbox overlay.ext4, image cache, and run/compute-driver.sock) under /tmp/openshell-vm-driver-$USER-vm-dev/ so the AF_UNIX socket path stays under macOS SUN_LEN.
  • Writes .cache/gateway-vm/gateway.toml with [openshell.drivers.vm].driver_dir = "$PWD/target/debug" so the freshly built openshell-driver-vm is used instead of an older installed copy from ~/.local/libexec/openshell, /usr/libexec/openshell, or /usr/local/libexec.
  • Enables OTLP trace export to http://127.0.0.1:4317 only when a local collector is listening there. Otherwise, it omits the OTLP configuration to avoid repeated export failures.

For GPU passthrough (VFIO), pass -- --gpu and run with root privileges:

sudo -E env "PATH=$PATH" mise run gateway:vm -- --gpu

GPU passthrough uses VFIO and requires host support for IOMMU, root privileges for bind/unbind operations, and a compatible sandbox image. The public GPU overview lives in the repository README.md.

Point the CLI at the gateway with one of:

openshell --gateway vm-dev status
openshell gateway select vm-dev    # then plain `openshell <command>`

Override defaults via environment:

# custom port (fails fast if in use)
OPENSHELL_SERVER_PORT=18091 mise run gateway:vm

# custom gateway installation/CLI name + namespace
OPENSHELL_VM_GATEWAY_NAME=vm-feature-a \
OPENSHELL_SANDBOX_NAMESPACE=vm-feature-a \
mise run gateway:vm

# custom sandbox image
OPENSHELL_SANDBOX_IMAGE=ghcr.io/example/sandbox:latest mise run gateway:vm

# custom bootstrap image for the VM runtime used to prepare/boot target images
OPENSHELL_VM_BOOTSTRAP_IMAGE=ghcr.io/example/bootstrap:latest mise run gateway:vm

Teardown:

rm -rf /tmp/openshell-vm-driver-$USER-vm-dev .cache/gateway-vm
rm -rf "${XDG_CONFIG_HOME:-$HOME/.config}/openshell/gateways/vm-dev"

Manual equivalent

If you want to drive the launch yourself instead of using mise run gateway:vm (i.e. tasks/scripts/gateway-vm.sh):

# 1. Stage runtime artifacts + guest sandbox into target/vm-runtime-compressed/
mise run vm:setup
mise run vm:supervisor          # builds the Linux guest sandbox and static guest-init helper

# 2. Build gateway, native host supervisor, and driver
OPENSHELL_VM_RUNTIME_COMPRESSED_DIR=$PWD/target/vm-runtime-compressed \
  cargo build -p openshell-gateway -p openshell-supervisor -p openshell-driver-vm

# 3. macOS only: codesign the driver for Hypervisor.framework
codesign \
  --entitlements crates/openshell-driver-vm/entitlements.plist \
  --force -s - target/debug/openshell-driver-vm

# 4. Start the gateway with the VM driver
mkdir -p /tmp/openshell-vm-driver-$USER-vm-dev .cache/gateway-vm
cat > .cache/gateway-vm/gateway.toml <<EOF
[openshell]
version = 2

[openshell.gateway]
compute_driver = "vm"
disable_tls = true

[openshell.drivers.vm]
default_image = "<compatible-image>"
grpc_endpoint = "http://127.0.0.1:18081"
driver_dir = "$PWD/target/debug"
state_dir = "/tmp/openshell-vm-driver-$USER-vm-dev"
EOF

target/debug/openshell-gateway \
  --config .cache/gateway-vm/gateway.toml \
  --compute-driver vm \
  --disable-tls \
  --db-url "sqlite:.cache/gateway-vm/gateway.db?mode=rwc" \
  --port 18081

The gateway resolves openshell-driver-vm in this order: [openshell.drivers.vm].driver_dir, conventional install locations (~/.local/libexec/openshell, /usr/libexec/openshell, /usr/local/libexec/openshell, /usr/local/libexec), then a sibling of the gateway binary.

Gateway And Driver Configuration

Select the VM driver with --compute-driver vm, OPENSHELL_COMPUTE_DRIVER=vm, or compute_driver = "vm" in [openshell.gateway]. Configure VM-specific settings in [openshell.drivers.vm].

Configuration key Default Purpose
grpc_endpoint empty Required. URL the native host supervisor uses to reach the gateway. Host loopback such as http://127.0.0.1:<port> is valid. Legacy guest aliases are normalized to host loopback. This endpoint is never sent into the VM.
state_dir target/openshell-vm-driver Per-sandbox overlay disks, console logs, image cache, and private run/compute-driver.sock UDS. Relative paths are resolved to absolute paths at driver startup.
driver_dir unset Override the directory searched for openshell-driver-vm.
default_image OpenShell base image Sandbox image used when a create request omits one.
bootstrap_image unset VM runtime image used as the immutable bootstrap root disk. Falls back only to the operator-configured default_image; the driver refuses to start when both are empty.
vcpus 2 vCPUs per sandbox.
mem_mib 2048 Memory per sandbox, in MiB.
overlay_disk_mib 4096 Sparse writable overlay disk size per sandbox, in MiB.
krun_log_level 1 libkrun verbosity (0-5).
guest_tls_ca unset Historical key name for the host supervisor's gateway CA certificate. Required when grpc_endpoint uses https://; never copied into the guest.
https_proxy unset Corporate forward proxy (http://host:port or https://host:port) that host control chains policy-approved TLS CONNECT egress through. Host-loopback proxy URLs work because control runs on the gateway host.
no_proxy unset Comma-separated bypass list for the corporate proxy only. OpenShell policy evaluation still applies.
proxy_auth_file unset Gateway-host path to a validated user:pass credential file. Staged root-only into the per-sandbox overlay and removed with the sandbox; credentials never enter logs or process arguments.
proxy_auth_allow_insecure unset Required with proxy_auth_file against an http:// proxy: acknowledges that Basic auth is cleartext on the connection to the proxy.
proxy_connect_by_hostname unset Send hostnames rather than validated IPs in CONNECT. Last resort for proxies whose ACLs reject IP CONNECT targets.
proxy_ca_bundle unset Gateway-host PEM CA bundle trusted for the corporate proxy and TLS-intercepted server certificates. The driver validates it and stages it at a fixed non-secret guest path in the protected overlay. Requires https_proxy.
provider_spiffe_workload_api_tcp_endpoint unset Explicit guest-reachable tcp:IP:port SPIFFE Workload API listener for provider token exchange. It requires provider_spiffe_allow_guest_tcp = true; a host UNIX socket is never silently exposed to a VM guest.
enable_bind_mounts false Allow per-sandbox driver_config.vm.mounts host directory shares (libkrun virtiofs only). The driver writes a tag\ttarget\tmode manifest to /.openshell/mounts.manifest in the overlay, and guest init mounts each share after /sandbox ownership fixups. Rejected while resource admission is enabled.

The proxy settings are operator-owned and deployment-level: they are not accepted through template.driver_config.vm, and the driver passes them only to native host control. Every present-but-invalid value is fatal at gateway or sandbox startup rather than degrading to a direct dial.

See openshell-gateway --help for the gateway process flag surface.

Verifying the gateway

The gateway is auto-registered by mise run gateway:vm. In another terminal:

./scripts/bin/openshell status
./scripts/bin/openshell sandbox create --name demo --from <compatible-image>
./scripts/bin/openshell sandbox connect demo

First sandbox takes 10–30 seconds to boot (image fetch/prepare/cache + libkrun + guest init). If --from is omitted, the VM driver uses the gateway's configured default sandbox image. Without either --from or --sandbox-image, VM sandbox creation fails. Subsequent creates reuse the prepared image cache and create only a sparse per-sandbox overlay.ext4 before boot.

CreateSandbox accepts the sandbox quickly and continues VM provisioning in the background. The driver publishes platform events for image resolution, cache hits/misses, layer pulls, rootfs preparation, overlay creation, and VM launcher startup so the CLI can show progress through the existing sandbox watch stream.

The VM driver keeps two image caches. The bootstrap cache is a controlled rootfs.ext4 used to boot the guest init and OpenShell supervisor. The prepared image cache is used when the requested sandbox image differs from the bootstrap image: the host downloads registry layers into a valid OCI layout, attaches that payload to a temporary bootstrap VM, and guest init runs umoci raw unpack onto Linux-owned ext4 storage. The resulting disk is cached under <state-dir>/images/<cache-id>/rootfs.ext4 and attached read-only to later sandboxes. Local Docker images are still exported as rootfs tar archives and prepared inside the bootstrap VM. The driver checks that a prepared disk contains the unpacked rootfs before caching it; on failure it caches nothing and reports the image-prep console tail. Set OPENSHELL_VM_IMAGE_PULL_CONCURRENCY to tune registry layer download parallelism (default 4, maximum 16). Registry pulls in both caches use the resolved digest as an immutable reference. The driver verifies the downloaded manifest against that digest before downloading layers. For multi-platform images, it verifies the index and then the selected platform manifest. Digest-pinned requests retain the caller's digest. Cache keys hash source identities to keep cache and staging filenames bounded. Bootstrap cache layout v6 and prepared-image layout v4 rebuild older entries on first use; older cache files remain on disk but are not reused for registry images. Both caches are scoped by source image identity and OpenShell version, so an OpenShell upgrade builds a fresh guest rootfs instead of reusing one with an old embedded supervisor. Host-side bootstrap layer assembly preserves relative symlinks that remain inside the rootfs, but rejects absolute or escaping cross-layer symlink traversal before applying later files or whiteouts. The layer applier performs rootfs mutations relative to opened directory handles and creates destination files without following symlinks. The requested sandbox image is never selected as the bootstrap image. Operators must configure either bootstrap_image or default_image; when both are empty, the driver fails during startup.

Image and writable-overlay preparation run in owned worker processes. Stopping or deleting a sandbox cancels its worker and formatter processes, waits for them to exit, then removes the attempt's temporary files under <state-dir>/images/preparations/. New overlays, including retries after an interrupted first start, are built there and renamed into sandbox state only after completion. A file lock inherited by the worker's children keeps cleanup from deleting files that a process still owns. If cleanup cannot establish that the processes have stopped, the operation returns an error and retains both temporary files and sandbox state.

At startup, the driver reclaims inactive attempts in this directory. It preserves active attempts, committed image caches, and unmarked staging from older releases. Concurrent preparations serialize image-cache publication; an interrupted attempt cannot publish a partial disk over an existing cache entry. Gateway upload slots for rootfs_tar_path keep their existing, separate lifecycle.

Each sandbox gets its own sparse writable <state-dir>/sandboxes/<id>/overlay.ext4. Guest init mounts overlayfs as / with the prepared image rootfs as lowerdir when present, otherwise the bootstrap rootfs is used directly. Writes to /sandbox and other mutable paths land in the overlay while cached image disks remain unchanged. The overlay disk must be large enough to hold the compressed payload, unpacked rootfs, and sandbox writes during the first prepare.

The driver also writes the accepted DriverSandbox launch request to <state-dir>/sandboxes/<id>/sandbox.pb. If the gateway restarts, it starts a new VM driver process. During graceful shutdown, the gateway first sends the shared StopSandbox request for each persisted running-intent sandbox, which stops its launcher while retaining the launch request and overlay.ext4. After driver initialization, the gateway sends the idempotent StartSandbox request for that retained intent. Explicitly stopped sandboxes remain excluded.

Stop writes a marker in the sandbox state directory before terminating the launcher and releasing host GPU and network allocations. It retains sandbox.pb, overlay.ext4, and lifecycle-extension state. Startup registers marked sandboxes without launching compute. Start removes the marker and uses the normal persisted restore path with the existing overlay. Delete removes the entire sandbox state directory, including a stop marker and overlay.

The host control writes and syncs a terminal tombstone when the canonical main process exits, before it reports completion and while it retains the boundary for exec and forwarding. Driver startup reports that sandbox as terminal instead of relaunching the VM, even when the process exited successfully.

The driver embeds a platform-native host supervisor and extracts it into <state-dir>/host-runtime. It accepts a cached binary only when its SHA-256 content matches the embedded supervisor and it remains an executable regular file. Replacement is written and synced under a temporary name, then atomically renamed into place. OPENSHELL_VM_SUPERVISOR_BIN remains an explicit development override.

Logs and debugging

Raise log verbosity for both processes:

RUST_LOG=openshell_server=debug,openshell_driver_vm=debug \
  mise run gateway:vm

The VM guest's serial console is appended to <state-dir>/<sandbox-id>/console.log. Sandbox IDs must match [A-Za-z0-9._-]{1,128} before the driver uses them in host paths. The gateway-owned compute-driver socket lives at <state-dir>/run/compute-driver.sock; OpenShell creates run/ with owner-only permissions and removes same-owner stale sockets. On clean shutdown, the gateway sends the managed driver SIGTERM, waits up to five seconds for it to flush telemetry and exit, then force-kills it if necessary and removes the socket. UDS clients must match the driver UID and provide the expected gateway process PID by default. Standalone same-UID UDS mode requires the explicit --allow-same-uid-peer development flag. TCP mode is disabled by default because it is unauthenticated; use --allow-unauthenticated-tcp --bind-address 127.0.0.1:50061 only for local development. The VM serial console is appended to <state-dir>/sandboxes/<sandbox-id>/rootfs-console.log. Host-supervisor stdout and stderr are written beside it as supervisor.log and supervisor.err.log. Sandbox IDs must match [A-Za-z0-9._-]{1,128} before the driver uses them in host paths. The gateway-owned compute-driver socket lives at <state-dir>/run/compute-driver.sock; OpenShell creates run/ with owner-only permissions, removes same-owner stale sockets, and the gateway removes the socket on clean shutdown via ManagedDriverProcess::drop. UDS clients must match the driver UID and provide the expected gateway process PID by default. Standalone same-UID UDS mode requires the explicit --allow-same-uid-peer development flag. TCP mode is disabled by default because it is unauthenticated; use --allow-unauthenticated-tcp --bind-address 127.0.0.1:50061 only for local development.

Network isolation

VM sandboxes boot without a virtual NIC. The guest exposes only the protected vsock channel used by openshell-sandbox; openshell-supervisor performs DNS, policy evaluation, and external networking on the host. The driver does not create TAP devices or install nftables/iptables rules.

Prerequisites

  • macOS on Apple Silicon, or Linux on aarch64/x86_64 with KVM
  • Rust toolchain
  • e2fsprogs (mke2fs or mkfs.ext4, plus debugfs) for root and overlay disk image creation and QEMU environment injection
  • Guest-sandbox cross-compile toolchain (needed on macOS, and on Linux when host arch differs from the guest):
    • Matching static guest target: rustup target add aarch64-unknown-linux-musl (or x86_64-unknown-linux-musl for an amd64 guest)
    • cargo install --locked cargo-zigbuild and brew install zig (or distro equivalent). vm:supervisor cross-compiles the Linux guest openshell-sandbox and its matching openshell-supervisor.
  • mise task runner
  • Docker or Podman socket on the local CLI/gateway host when building an image before openshell sandbox create --from <image>; the VM driver exports the image via the local container engine. Docker is tried first; if unavailable, the driver falls back to the Podman socket. On Linux, enable the Podman API socket with systemctl --user start podman.socket
  • gh CLI (used by mise run vm:setup to download pre-built runtime artifacts)

Releases

openshell-driver-vm is published as a normal OpenShell release artifact:

  • development builds: the rolling dev release
  • tagged builds: the corresponding v* release
  • runtime tarballs: the rolling vm-runtime release, rebuilt on demand by release-vm-kernel.yml

On Debian-family Linux amd64 and arm64 systems, install.sh installs the Debian package from the selected OPENSHELL_VERSION release tag. That package includes openshell-gateway and openshell-driver-vm, but leaves OPENSHELL_COMPUTE_DRIVER unset so the gateway uses its normal runtime auto-detection. Set OPENSHELL_COMPUTE_DRIVER=vm to force the VM driver.

On RPM-family Linux x86_64 and aarch64 systems, install.sh installs the openshell and openshell-gateway RPM packages from the selected release tag. The RPM gateway package is configured for the Podman driver.

On Apple Silicon macOS, install.sh stages the generated openshell.rb formula from the selected release in the nvidia/openshell Homebrew tap. Homebrew installs openshell, openshell-gateway, and the self-contained openshell-driver-vm with its embedded native supervisor. It ad-hoc signs the driver with the Hypervisor entitlement in post_install and owns the brew services gateway lifecycle. The service also leaves OPENSHELL_DRIVERS unset so driver choice remains automatic unless the user explicitly overrides it.

TODOs

  • The gateway still configures the driver via CLI args; this will move to a gRPC bootstrap call so the driver interface is uniform across backends. See the TODO(driver-abstraction) note in crates/openshell-gateway/src/vm.rs.
  • macOS local builds are codesigned by tasks/scripts/gateway-vm.sh; the generated Homebrew formula signs the release tarball driver for local installs.