wip: gateway:vm refactor + driver-scoped ssh handshake check

This commit is contained in:
Drew Newberry
2026-04-29 15:11:05 -07:00
parent b25c143a2b
commit 2c4d8977c0
12 changed files with 441 additions and 407 deletions
+6 -5
View File
@@ -220,8 +220,8 @@ graph LR
The `vm-runtime-<platform>.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 `crates/openshell-driver-vm/start.sh`) points the
build at the staged artifacts.
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
@@ -315,14 +315,15 @@ cross-compiled via osxcross (no macOS runner needed for the binary build —
only for the kernel build).
macOS driver binaries produced via osxcross are not codesigned. Development
builds are signed automatically by `crates/openshell-driver-vm/start.sh`; a
packaged release needs signing in CI.
builds are signed automatically by `tasks/scripts/gateway-vm.sh`
(registered as `mise run gateway:vm`); a packaged release needs signing in
CI.
## Rollout Strategy
1. Custom runtime is embedded by default when building `openshell-driver-vm`
with `OPENSHELL_VM_RUNTIME_COMPRESSED_DIR` set (wired up by
`crates/openshell-driver-vm/start.sh`).
`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
+32 -35
View File
@@ -35,10 +35,15 @@ Sandbox guests execute `/opt/openshell/bin/openshell-sandbox` as PID 1 inside th
mise run gateway:vm
```
First run takes a few minutes while `mise run vm:setup` stages libkrun/libkrunfw/gvproxy and `mise run vm:supervisor` builds the bundled guest supervisor. Subsequent runs are cached. To keep the Unix socket path under macOS `SUN_LEN`, `mise run gateway:vm` and `start.sh` default the state dir to `/tmp/openshell-vm-driver-dev-$USER-<repo-name>/` (SQLite DB + per-sandbox rootfs + `compute-driver.sock`) unless `OPENSHELL_VM_DRIVER_STATE_DIR` is set.
By default the wrapper names the gateway after the repo directory, writes `OPENSHELL_GATEWAY=<repo-name>` into `.env`, and writes plaintext local gateway metadata under `~/.config/openshell/gateways/<repo-name>/metadata.json` so repo-local `scripts/bin/openshell status` and `sandbox create` resolve to the VM gateway without an extra `gateway select`. When running under `sudo`, the wrapper uses `sudo -u $SUDO_USER` for the registration so the config is written under the invoking user's home directory. Re-runs are idempotent.
If neither `OPENSHELL_SERVER_PORT` nor `GATEWAY_PORT` is set, the wrapper picks a random free local port once and appends `GATEWAY_PORT=<port>` to `.env`. Later runs reuse that port through `mise`'s env loading. If you set `OPENSHELL_SERVER_PORT` explicitly, the wrapper uses it for that run and still fails fast on conflicts.
It also exports `OPENSHELL_DRIVER_DIR=$PWD/target/debug` before starting the gateway so local dev runs use the freshly built `openshell-driver-vm` instead of an older installed copy from `~/.local/libexec/openshell` or `/usr/local/libexec`.
First run takes a few minutes while `mise run vm:setup` stages libkrun/libkrunfw/gvproxy and `mise run vm:supervisor` builds the bundled guest supervisor. Subsequent runs are cached.
By default `mise run gateway:vm`:
- Listens on plaintext HTTP at `127.0.0.1:18081`.
- Registers the CLI gateway `vm-dev` 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 rootfs + `compute-driver.sock`) under `/tmp/openshell-vm-driver-$USER-vm-dev/` so the AF_UNIX socket path stays under macOS `SUN_LEN`.
- Passes `--driver-dir $PWD/target/debug` so the freshly built `openshell-driver-vm` is used instead of an older installed copy from `~/.local/libexec/openshell` or `/usr/local/libexec`.
For GPU passthrough (VFIO), pass `-- --gpu` and run with root privileges:
@@ -48,45 +53,38 @@ sudo -E env "PATH=$PATH" mise run gateway:vm -- --gpu
See [`architecture/vm-gpu-sandbox-guide.md`](../../architecture/vm-gpu-sandbox-guide.md) for full GPU prerequisites and usage.
Override via environment:
Point the CLI at the gateway with one of:
```shell
OPENSHELL_SERVER_PORT=9090 \
OPENSHELL_SSH_HANDSHAKE_SECRET=$(openssl rand -hex 32) \
crates/openshell-driver-vm/start.sh
openshell --gateway vm-dev status
openshell gateway select vm-dev # then plain `openshell <command>`
```
If you want to pin the project port instead of using the `.env` default:
Override defaults via environment:
```shell
GATEWAY_PORT=28080 mise run gateway:vm
```
# custom port (fails fast if in use)
OPENSHELL_SERVER_PORT=18091 mise run gateway:vm
If you want a custom state-dir suffix instead of the repo-name default, set `OPENSHELL_VM_INSTANCE`:
```shell
GATEWAY_PORT=28081 \
OPENSHELL_VM_INSTANCE=feature-a \
mise run gateway:vm
```
If you want a custom CLI gateway name instead of the repo directory, set `OPENSHELL_VM_GATEWAY_NAME`:
```shell
GATEWAY_PORT=28082 \
# custom CLI gateway 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
```
Teardown:
```shell
rm -rf /tmp/openshell-vm-driver-dev-$USER-$(basename "$PWD" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
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 `start.sh`:
If you want to drive the launch yourself instead of using `mise run gateway:vm` (i.e. `tasks/scripts/gateway-vm.sh`):
```shell
# 1. Stage runtime artifacts + supervisor bundle into target/vm-runtime-compressed/
@@ -103,18 +101,17 @@ codesign \
--force -s - target/debug/openshell-driver-vm
# 4. Start the gateway with the VM driver
mkdir -p /tmp/openshell-vm-driver-dev-$USER-port-8080
mkdir -p /tmp/openshell-vm-driver-$USER-vm-dev .cache/gateway-vm
target/debug/openshell-gateway \
--drivers vm \
--disable-tls \
--database-url sqlite:/tmp/openshell-vm-driver-dev-$USER-port-8080/openshell.db \
--db-url "sqlite:.cache/gateway-vm/gateway.db?mode=rwc" \
--driver-dir $PWD/target/debug \
--sandbox-namespace vm-dev \
--sandbox-image <compatible-image> \
--grpc-endpoint http://host.containers.internal:8080 \
--ssh-handshake-secret dev-vm-driver-secret \
--ssh-gateway-host 127.0.0.1 \
--ssh-gateway-port 8080 \
--vm-driver-state-dir /tmp/openshell-vm-driver-dev-$USER-port-8080
--grpc-endpoint http://host.containers.internal:18081 \
--port 18081 \
--vm-driver-state-dir /tmp/openshell-vm-driver-$USER-vm-dev
```
The gateway resolves `openshell-driver-vm` in this order: `--driver-dir`, conventional install locations (`~/.local/libexec/openshell`, `/usr/local/libexec/openshell`, `/usr/local/libexec`), then a sibling of the gateway binary.
@@ -124,7 +121,7 @@ The gateway resolves `openshell-driver-vm` in this order: `--driver-dir`, conven
| Flag | Env var | Default | Purpose |
|---|---|---|---|
| `--drivers vm` | `OPENSHELL_DRIVERS` | `kubernetes` | Select the VM compute driver. |
| `--grpc-endpoint URL` | `OPENSHELL_GRPC_ENDPOINT` | — | Required. URL the sandbox guest calls back to. Use a host alias that resolves to the gateway's host from inside the VM (`host.containers.internal` comes from gvproxy DNS; the guest init script also seeds `host.openshell.internal` to `192.168.127.1`). |
| `--grpc-endpoint URL` | `OPENSHELL_GRPC_ENDPOINT` | — | Required. URL the sandbox guest dials to reach the gateway. Use `http://host.containers.internal:<port>` (or `host.docker.internal` / `host.openshell.internal`) so traffic flows through gvproxy's host-loopback NAT (HostIP `192.168.127.254` → host `127.0.0.1`). Loopback URLs like `http://127.0.0.1:<port>` are rewritten automatically by the driver. The bare gateway IP (`192.168.127.1`) only carries gvproxy's own services and will not reach host-bound ports. |
| `--vm-driver-state-dir DIR` | `OPENSHELL_VM_DRIVER_STATE_DIR` | `target/openshell-vm-driver` | Per-sandbox rootfs, console logs, and the `compute-driver.sock` UDS. |
| `--driver-dir DIR` | `OPENSHELL_DRIVER_DIR` | unset | Override the directory searched for `openshell-driver-vm`. |
| `--vm-driver-vcpus N` | `OPENSHELL_VM_DRIVER_VCPUS` | `2` | vCPUs per sandbox. |
@@ -154,7 +151,7 @@ Raise log verbosity for both processes:
```shell
RUST_LOG=openshell_server=debug,openshell_driver_vm=debug \
crates/openshell-driver-vm/start.sh
mise run gateway:vm
```
The VM guest's serial console is appended to `<state-dir>/<sandbox-id>/console.log`. The `compute-driver.sock` lives at `<state-dir>/compute-driver.sock`; the gateway removes it on clean shutdown via `ManagedDriverProcess::drop`.
@@ -177,4 +174,4 @@ The VM guest's serial console is appended to `<state-dir>/<sandbox-id>/console.l
## 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)` notes in `crates/openshell-server/src/lib.rs` and `crates/openshell-server/src/compute/vm.rs`.
- macOS codesigning is handled by `start.sh`; a packaged release would need signing in CI.
- macOS codesigning is handled by `tasks/scripts/gateway-vm.sh`; a packaged release would need signing in CI.
@@ -15,7 +15,20 @@ if [ -f /srv/openshell-env.sh ]; then
fi
BOOT_START=$(date +%s%3N 2>/dev/null || date +%s)
# gvisor-tap-vsock subnet layout:
# 192.168.127.1 — gateway: gvproxy's DNS / DHCP / HTTP API. Does NOT
# proxy arbitrary host ports.
# 192.168.127.254 — host-loopback: NAT-rewritten to host's 127.0.0.1 by
# gvproxy's TCP/UDP/ICMP forwarder. Use this address
# (or any of the host.* hostnames below) to reach a
# service the host is listening on.
# The host.containers.internal / host.docker.internal DNS records served
# by gvproxy's embedded resolver point at 192.168.127.254. We mirror that
# in /etc/hosts so the supervisor can reach the gateway even when
# gvproxy's DNS is not in resolv.conf (e.g. DHCP failed and we fell
# back to 8.8.8.8).
GVPROXY_GATEWAY_IP="192.168.127.1"
GVPROXY_HOST_LOOPBACK_IP="192.168.127.254"
GATEWAY_IP="$GVPROXY_GATEWAY_IP"
# Parse kernel cmdline for GPU and TAP networking parameters
@@ -96,11 +109,19 @@ tcp_probe() {
}
ensure_host_gateway_aliases() {
# Pin every host-gateway alias to the gvproxy gateway IP via /etc/hosts so the
# supervisor can reach the OpenShell server even when gvproxy's built-in DNS
# is not in resolv.conf (e.g. when DHCP fails and we fall back to 8.8.8.8).
# Seed /etc/hosts with the well-known gvproxy hostnames so the supervisor
# can reach the OpenShell server even when gvproxy's built-in DNS is not
# in resolv.conf (e.g. when DHCP fails and we fall back to 8.8.8.8).
#
# Critical distinction: host.* aliases point at the gvproxy *host-loopback*
# IP (192.168.127.254), not the gateway IP (192.168.127.1). Only the
# host-loopback IP carries NAT rewriting to the host's 127.0.0.1 — the
# gateway IP only listens on gvproxy's own service ports (DNS:53, DHCP,
# HTTP API:80). Pinning host.containers.internal to the gateway IP
# silently breaks guest→host port reachability for arbitrary ports.
local hosts_tmp="/tmp/openshell-hosts.$$"
local aliases="host.openshell.internal host.containers.internal host.docker.internal gateway.containers.internal"
local host_aliases="host.openshell.internal host.containers.internal host.docker.internal"
local gateway_aliases="gateway.containers.internal"
local filter='(^|[[:space:]])(host\.openshell\.internal|host\.containers\.internal|host\.docker\.internal|gateway\.containers\.internal)([[:space:]]|$)'
if [ -f /etc/hosts ]; then
@@ -109,7 +130,18 @@ ensure_host_gateway_aliases() {
: > "$hosts_tmp"
fi
printf '%s %s\n' "$GATEWAY_IP" "$aliases" >> "$hosts_tmp"
# In TAP/GPU mode, GATEWAY_IP is overridden to VM_NET_GW (the host-side
# of the TAP), and the gateway is reachable directly there. In gvproxy
# mode, host.openshell.internal etc. need GVPROXY_HOST_LOOPBACK_IP
# (192.168.127.254) which is gvproxy's host-NAT entry, while
# gateway.containers.internal points at the gvproxy gateway itself.
if [ "${GATEWAY_IP}" = "${GVPROXY_GATEWAY_IP}" ]; then
printf '%s %s\n' "$GVPROXY_HOST_LOOPBACK_IP" "$host_aliases" >> "$hosts_tmp"
printf '%s %s\n' "$GVPROXY_GATEWAY_IP" "$gateway_aliases" >> "$hosts_tmp"
else
# TAP networking: gateway and host are both reachable at GATEWAY_IP.
printf '%s %s %s\n' "$GATEWAY_IP" "$host_aliases" "$gateway_aliases" >> "$hosts_tmp"
fi
cat "$hosts_tmp" > /etc/hosts
rm -f "$hosts_tmp"
}
@@ -134,7 +166,15 @@ rewrite_openshell_endpoint_if_needed() {
return 0
fi
for candidate in host.openshell.internal host.containers.internal host.docker.internal "$GATEWAY_IP"; do
# Probe candidates in preference order. Hostnames first for informative
# log output, then a bare IP as a final safety net. In gvproxy mode the
# bare IP is the host-loopback (192.168.127.254). In TAP/GPU mode it's
# the TAP host gateway.
local fallback_ip="$GVPROXY_HOST_LOOPBACK_IP"
if [ "${GATEWAY_IP}" != "${GVPROXY_GATEWAY_IP}" ]; then
fallback_ip="$GATEWAY_IP"
fi
for candidate in host.openshell.internal host.containers.internal host.docker.internal "$fallback_ip"; do
if [ "$candidate" = "$host" ]; then
continue
fi
+57 -8
View File
@@ -52,8 +52,36 @@ const DRIVER_NAME: &str = "openshell-driver-vm";
const WATCH_BUFFER: usize = 256;
const DEFAULT_VCPUS: u8 = 2;
const DEFAULT_MEM_MIB: u32 = 2048;
/// gvproxy gateway IP — runs DNS, DHCP, and the gvproxy HTTP API. Does **not**
/// proxy arbitrary host ports.
const GVPROXY_GATEWAY_IP: &str = "192.168.127.1";
/// gvproxy host-loopback IP — gvproxy's TCP/UDP/ICMP forwarder NAT-rewrites
/// this destination to the host's `127.0.0.1` and dials out from the host
/// process. This is the only address that transparently reaches host-bound
/// services without explicit `expose` rules.
///
/// See gvisor-tap-vsock `cmd/gvproxy/config.go` (default NAT entry
/// `HostIP -> 127.0.0.1`) and `pkg/services/forwarder/tcp.go` (NAT lookup
/// before `net.Dial`).
///
/// Code paths route via `GVPROXY_HOST_LOOPBACK_ALIAS` (DNS / /etc/hosts)
/// instead so logs stay readable; this constant is kept for documentation
/// and parity with the guest init script.
#[allow(dead_code)]
const GVPROXY_HOST_LOOPBACK_IP: &str = "192.168.127.254";
const OPENSHELL_HOST_GATEWAY_ALIAS: &str = "host.openshell.internal";
/// Hostname gvproxy resolves (via its embedded DNS) to the host-loopback IP.
///
/// We rewrite loopback URLs to this hostname rather than the bare IP because:
/// * the guest init script seeds /etc/hosts with the same mapping, so it
/// resolves even when gvproxy's DNS is not in resolv.conf;
/// * keeping a recognisable hostname makes log messages clearer than a bare
/// 192.168.127.254 reference;
/// * `host.docker.internal` works the same way for Docker-flavoured tooling.
///
/// Both names ultimately route through the gvproxy NAT path on
/// `GVPROXY_HOST_LOOPBACK_IP` — they do **not** go through the gateway IP.
const GVPROXY_HOST_LOOPBACK_ALIAS: &str = "host.containers.internal";
const GUEST_SSH_SOCKET_PATH: &str = "/run/openshell/ssh.sock";
const GUEST_TLS_DIR: &str = "/opt/openshell/tls";
const GUEST_TLS_CA_PATH: &str = "/opt/openshell/tls/ca.crt";
@@ -1660,6 +1688,25 @@ fn merged_environment(sandbox: &Sandbox) -> HashMap<String, String> {
environment
}
/// Rewrites loopback host references in a gateway URL to a hostname the guest
/// can reach via gvproxy.
///
/// The driver receives the gateway endpoint from `--openshell-endpoint`, which
/// in local/dev/e2e setups is typically `http://127.0.0.1:<port>`. That URL is
/// useless inside the guest because the guest's loopback interface is its own,
/// not the host's. Inside the guest we need a name that gvproxy will translate
/// into the host's loopback address.
///
/// We rewrite to `host.containers.internal`, which gvproxy's embedded DNS resolves
/// to the host-loopback IP `192.168.127.254`. gvproxy installs a default NAT entry
/// rewriting that destination to the host's `127.0.0.1` and dialing out from the
/// host process, so any port the host is listening on becomes reachable. The
/// gateway IP `192.168.127.1` does **not** do this — it only listens on gvproxy's
/// own service ports (DNS, DHCP, HTTP API). The guest init script also seeds the
/// hostname in `/etc/hosts` so resolution works even if gvproxy's DNS isn't in
/// resolv.conf (e.g. when DHCP fails).
///
/// Non-loopback URLs are returned unchanged.
fn guest_visible_openshell_endpoint(endpoint: &str) -> String {
let Ok(mut url) = Url::parse(endpoint) else {
return endpoint.to_string();
@@ -1672,7 +1719,7 @@ fn guest_visible_openshell_endpoint(endpoint: &str) -> String {
None => false,
};
if should_rewrite && url.set_host(Some(GVPROXY_GATEWAY_IP)).is_ok() {
if should_rewrite && url.set_host(Some(GVPROXY_HOST_LOOPBACK_ALIAS)).is_ok() {
return url.to_string();
}
@@ -2181,7 +2228,7 @@ mod tests {
let env = build_guest_environment(&sandbox, &config, None);
assert!(env.contains(&"HOME=/root".to_string()));
assert!(env.contains(&format!(
"OPENSHELL_ENDPOINT=http://{GVPROXY_GATEWAY_IP}:8080/"
"OPENSHELL_ENDPOINT=http://{GVPROXY_HOST_LOOPBACK_ALIAS}:8080/"
)));
assert!(env.contains(&"OPENSHELL_SANDBOX_ID=sandbox-123".to_string()));
assert!(env.contains(&format!(
@@ -2223,18 +2270,18 @@ mod tests {
}
#[test]
fn guest_visible_openshell_endpoint_rewrites_loopback_hosts_to_gvproxy_gateway() {
fn guest_visible_openshell_endpoint_rewrites_loopback_hosts_to_gvproxy_host_alias() {
assert_eq!(
guest_visible_openshell_endpoint("http://127.0.0.1:8080"),
format!("http://{GVPROXY_GATEWAY_IP}:8080/")
format!("http://{GVPROXY_HOST_LOOPBACK_ALIAS}:8080/")
);
assert_eq!(
guest_visible_openshell_endpoint("http://localhost:8080"),
format!("http://{GVPROXY_GATEWAY_IP}:8080/")
format!("http://{GVPROXY_HOST_LOOPBACK_ALIAS}:8080/")
);
assert_eq!(
guest_visible_openshell_endpoint("https://[::1]:8443"),
format!("https://{GVPROXY_GATEWAY_IP}:8443/")
format!("https://{GVPROXY_HOST_LOOPBACK_ALIAS}:8443/")
);
}
@@ -2247,8 +2294,10 @@ mod tests {
format!("http://{OPENSHELL_HOST_GATEWAY_ALIAS}:8080")
);
assert_eq!(
guest_visible_openshell_endpoint("http://host.containers.internal:8080"),
"http://host.containers.internal:8080"
guest_visible_openshell_endpoint(&format!(
"http://{GVPROXY_HOST_LOOPBACK_ALIAS}:8080"
)),
format!("http://{GVPROXY_HOST_LOOPBACK_ALIAS}:8080")
);
assert_eq!(
guest_visible_openshell_endpoint(&format!("http://{GVPROXY_GATEWAY_IP}:8080")),
+9 -7
View File
@@ -705,13 +705,15 @@ fn run_libkrun_vm(config: &VmLaunchConfig) -> Result<(), String> {
// talks to on boot (IPs 192.168.127.1 / .2, defaults for
// gvisor-tap-vsock);
// * the host-facing gateway identity the guest uses for callbacks:
// the init script seeds `/etc/hosts` with
// `host.openshell.internal` pointing at 192.168.127.1 while
// leaving gvproxy's legacy `host.containers.internal` /
// `host.docker.internal` DNS answers intact, which is how the guest's
// `rewrite_openshell_endpoint_if_needed` probe reaches the host
// gateway when the bare loopback address doesn't resolve from
// inside the VM.
// gvproxy installs a default NAT entry rewriting `192.168.127.254`
// (the subnet's HostIP) to the host's `127.0.0.1`, and serves
// `host.containers.internal` / `host.docker.internal` /
// `host.openshell.internal` in its embedded DNS pointing at that
// same HostIP. The guest init script seeds /etc/hosts with the
// same mapping so the supervisor reaches the host gateway even
// when gvproxy's DNS isn't in resolv.conf. The gateway IP
// (192.168.127.1) is NOT a host-loopback proxy — it only listens
// on its own service ports (DNS:53, DHCP, HTTP API:80).
//
// That network plane is also what the sandbox supervisor's
// per-sandbox netns (veth pair + iptables, see
-305
View File
@@ -1,305 +0,0 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
set -euo pipefail
# Under sudo, PATH is reset and user-local tools (mise, cargo) disappear.
# Restore the invoking user's tool directories so mise and its shims work.
if [ -n "${SUDO_USER:-}" ]; then
_sudo_home=$(getent passwd "${SUDO_USER}" | cut -d: -f6)
for _p in "${_sudo_home}/.local/bin" "${_sudo_home}/.local/share/mise/shims" "${_sudo_home}/.cargo/bin"; do
[ -d "${_p}" ] && PATH="${_p}:${PATH}"
done
export PATH
fi
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "${ROOT}/crates/openshell-vm/pins.env" 2>/dev/null || true
CLI_BIN="${ROOT}/scripts/bin/openshell"
ENV_FILE="${ROOT}/.env"
COMPRESSED_DIR_DEFAULT="${ROOT}/target/vm-runtime-compressed"
COMPRESSED_DIR="${OPENSHELL_VM_RUNTIME_COMPRESSED_DIR:-${COMPRESSED_DIR_DEFAULT}}"
SERVER_PORT_REQUESTED="${OPENSHELL_SERVER_PORT:-${GATEWAY_PORT:-}}"
SERVER_PORT="${SERVER_PORT_REQUESTED:-}"
STATE_DIR_ROOT="${OPENSHELL_VM_DRIVER_STATE_ROOT:-/tmp}"
VM_HOST_GATEWAY_DEFAULT="${OPENSHELL_VM_HOST_GATEWAY:-host.containers.internal}"
DRIVER_DIR_DEFAULT="${ROOT}/target/debug"
DRIVER_DIR="${OPENSHELL_DRIVER_DIR:-${DRIVER_DIR_DEFAULT}}"
normalize_name() {
printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//'
}
for arg in "$@"; do
if [ "${arg}" = "--gpu" ]; then
export OPENSHELL_VM_GPU=true
break
fi
done
has_env_key() {
local key=$1
[ -f "${ENV_FILE}" ] || return 1
grep -Eq "^[[:space:]]*(export[[:space:]]+)?${key}=" "${ENV_FILE}"
}
append_env_if_missing() {
local key=$1
local value=$2
if has_env_key "${key}"; then
return
fi
if [ -f "${ENV_FILE}" ] && [ -s "${ENV_FILE}" ]; then
if [ "$(tail -c1 "${ENV_FILE}" | wc -l)" -eq 0 ]; then
printf "\n" >>"${ENV_FILE}"
fi
fi
printf "%s=%s\n" "${key}" "${value}" >>"${ENV_FILE}"
}
upsert_env_key() {
local key=$1
local value=$2
local tmp_file
tmp_file="$(mktemp "${ENV_FILE}.tmp.XXXXXX")"
if [ -f "${ENV_FILE}" ]; then
awk -v key="${key}" -v value="${value}" '
BEGIN { updated = 0 }
$0 ~ "^[[:space:]]*(export[[:space:]]+)?" key "=" {
if (!updated) {
print key "=" value
updated = 1
}
next
}
{ print }
END {
if (!updated) {
print key "=" value
}
}
' "${ENV_FILE}" >"${tmp_file}"
else
printf "%s=%s\n" "${key}" "${value}" >"${tmp_file}"
fi
mv "${tmp_file}" "${ENV_FILE}"
}
normalize_bool() {
case "${1,,}" in
1|true|yes|on) echo "true" ;;
0|false|no|off) echo "false" ;;
*)
echo "invalid boolean value '$1' (expected true/false, 1/0, yes/no, on/off)" >&2
exit 1
;;
esac
}
port_is_in_use() {
local port=$1
if command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1
return $?
fi
if command -v nc >/dev/null 2>&1; then
nc -z 127.0.0.1 "${port}" >/dev/null 2>&1
return $?
fi
(echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1
}
pick_random_port() {
local lower=20000
local upper=60999
local attempts=256
local port
for _ in $(seq 1 "${attempts}"); do
port=$((RANDOM % (upper - lower + 1) + lower))
if ! port_is_in_use "${port}"; then
echo "${port}"
return 0
fi
done
echo "ERROR: could not find a free port after ${attempts} attempts." >&2
return 1
}
check_supervisor_cross_toolchain() {
# The sandbox supervisor inside the guest is always Linux. On non-Linux
# hosts (macOS) and on Linux hosts with a different arch than the guest,
# we cross-compile via cargo-zigbuild and need the matching rustup target.
local host_os host_arch guest_arch rust_target
host_os="$(uname -s)"
host_arch="$(uname -m)"
guest_arch="${GUEST_ARCH:-${host_arch}}"
case "${guest_arch}" in
arm64|aarch64) rust_target="aarch64-unknown-linux-gnu" ;;
x86_64|amd64) rust_target="x86_64-unknown-linux-gnu" ;;
*) return 0 ;;
esac
if [ "${host_os}" = "Linux" ] && [ "${host_arch}" = "${guest_arch}" ]; then
return 0
fi
local missing=0
if ! command -v cargo-zigbuild >/dev/null 2>&1; then
echo "ERROR: cargo-zigbuild not found (required to cross-compile the guest supervisor)." >&2
echo " Install: cargo install --locked cargo-zigbuild && brew install zig" >&2
missing=1
fi
if ! rustup target list --installed 2>/dev/null | grep -qx "${rust_target}"; then
echo "ERROR: Rust target '${rust_target}' not installed." >&2
echo " Install: rustup target add ${rust_target}" >&2
missing=1
fi
if [ "${missing}" -ne 0 ]; then
exit 1
fi
}
build_gateway_and_driver() {
local rustc_wrapper_mode="${1:-default}"
local cargo_prefix=()
if [ "${rustc_wrapper_mode}" = "without-rustc-wrapper" ]; then
cargo_prefix=(env -u RUSTC_WRAPPER)
fi
"${cargo_prefix[@]}" cargo build -p openshell-server -p openshell-driver-vm
}
if [ -n "${SERVER_PORT_REQUESTED}" ]; then
if port_is_in_use "${SERVER_PORT}"; then
echo "ERROR: requested gateway port ${SERVER_PORT} is already in use." >&2
echo " Update .env GATEWAY_PORT or override it for one run:" >&2
echo " OPENSHELL_SERVER_PORT=<free-port> mise run gateway:vm" >&2
exit 1
fi
else
SERVER_PORT="$(pick_random_port)"
append_env_if_missing "GATEWAY_PORT" "${SERVER_PORT}"
fi
GATEWAY_NAME_DEFAULT="$(basename "${ROOT}")"
GATEWAY_NAME="${OPENSHELL_VM_GATEWAY_NAME:-${GATEWAY_NAME_DEFAULT}}"
if [ -z "${GATEWAY_NAME}" ]; then
GATEWAY_NAME="openshell"
fi
# Keep the driver socket path under AF_UNIX SUN_LEN on macOS.
STATE_LABEL_RAW="${OPENSHELL_VM_INSTANCE:-$(normalize_name "${GATEWAY_NAME}")}"
STATE_LABEL="$(printf '%s' "${STATE_LABEL_RAW}" | tr -cs '[:alnum:]._-' '-')"
if [ -z "${STATE_LABEL}" ]; then
STATE_LABEL="gateway"
fi
STATE_DIR_DEFAULT="${STATE_DIR_ROOT}/openshell-vm-driver-dev-${USER:-user}-${STATE_LABEL}"
STATE_DIR="${OPENSHELL_VM_DRIVER_STATE_DIR:-${STATE_DIR_DEFAULT}}"
DB_PATH_DEFAULT="${STATE_DIR}/openshell.db"
LOCAL_GATEWAY_ENDPOINT_DEFAULT="http://127.0.0.1:${SERVER_PORT}"
LOCAL_GATEWAY_ENDPOINT="${OPENSHELL_VM_LOCAL_GATEWAY_ENDPOINT:-${LOCAL_GATEWAY_ENDPOINT_DEFAULT}}"
export OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="${COMPRESSED_DIR}"
export OPENSHELL_GATEWAY="${GATEWAY_NAME}"
upsert_env_key "OPENSHELL_GATEWAY" "${GATEWAY_NAME}"
mkdir -p "${STATE_DIR}"
if [ ! -d "${COMPRESSED_DIR}" ] || ! find "${COMPRESSED_DIR}" -maxdepth 1 -name 'libkrun*.zst' | grep -q . || [ ! -f "${COMPRESSED_DIR}/gvproxy.zst" ]; then
echo "==> Preparing embedded VM runtime"
mise run vm:setup
fi
if [ ! -f "${COMPRESSED_DIR}/openshell-sandbox.zst" ]; then
check_supervisor_cross_toolchain
echo "==> Building bundled VM supervisor"
mise run vm:supervisor
fi
echo "==> Building gateway and VM compute driver"
if ! build_gateway_and_driver; then
if [ -n "${RUSTC_WRAPPER:-}" ]; then
echo "WARNING: gateway/driver build failed through RUSTC_WRAPPER=${RUSTC_WRAPPER}; retrying without RUSTC_WRAPPER." >&2
build_gateway_and_driver without-rustc-wrapper
else
exit 1
fi
fi
if [ "$(uname -s)" = "Darwin" ]; then
echo "==> Codesigning VM compute driver"
codesign \
--entitlements "${ROOT}/crates/openshell-driver-vm/entitlements.plist" \
--force \
-s - \
"${ROOT}/target/debug/openshell-driver-vm"
fi
export OPENSHELL_DISABLE_TLS="$(normalize_bool "${OPENSHELL_DISABLE_TLS:-true}")"
export OPENSHELL_DB_URL="${OPENSHELL_DB_URL:-sqlite:${DB_PATH_DEFAULT}}"
export OPENSHELL_DRIVERS="${OPENSHELL_DRIVERS:-vm}"
export OPENSHELL_DRIVER_DIR="${DRIVER_DIR}"
export OPENSHELL_SERVER_PORT="${SERVER_PORT}"
export OPENSHELL_GRPC_ENDPOINT="${OPENSHELL_GRPC_ENDPOINT:-http://${VM_HOST_GATEWAY_DEFAULT}:${SERVER_PORT}}"
export OPENSHELL_SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-${COMMUNITY_SANDBOX_IMAGE:-}}"
export OPENSHELL_SSH_GATEWAY_HOST="${OPENSHELL_SSH_GATEWAY_HOST:-127.0.0.1}"
export OPENSHELL_SSH_GATEWAY_PORT="${OPENSHELL_SSH_GATEWAY_PORT:-${SERVER_PORT}}"
export OPENSHELL_SSH_HANDSHAKE_SECRET="${OPENSHELL_SSH_HANDSHAKE_SECRET:-dev-vm-driver-secret}"
export OPENSHELL_VM_DRIVER_STATE_DIR="${STATE_DIR}"
# Resolve the VM runtime directory (contains vmlinux, virtiofsd, etc.)
# so the child --internal-run-vm process can find it under sudo.
if [ -z "${OPENSHELL_VM_RUNTIME_DIR:-}" ]; then
_candidate="${HOME}/.local/share/openshell/vm-runtime/0.0.0"
if [ -n "${SUDO_USER:-}" ]; then
_sudo_home=$(getent passwd "${SUDO_USER}" | cut -d: -f6)
_candidate="${_sudo_home}/.local/share/openshell/vm-runtime/0.0.0"
fi
if [ -f "${_candidate}/vmlinux" ]; then
export OPENSHELL_VM_RUNTIME_DIR="${_candidate}"
fi
fi
# Write gateway metadata to the invoking user's config dir, even under sudo.
# When running under sudo, $HOME points at /root and XDG_CONFIG_HOME may also
# be inherited from the root env; fall back to SUDO_USER's home directory so
# repo-local `scripts/bin/openshell` reads the same config.
if [ -n "${SUDO_USER:-}" ]; then
_sudo_home=$(getent passwd "${SUDO_USER}" | cut -d: -f6)
GATEWAY_CONFIG_BASE="${_sudo_home}/.config"
else
GATEWAY_CONFIG_BASE="${XDG_CONFIG_HOME:-${HOME}/.config}"
fi
GATEWAY_METADATA_DIR="${GATEWAY_CONFIG_BASE}/openshell/gateways/${GATEWAY_NAME}"
mkdir -p "${GATEWAY_METADATA_DIR}"
cat >"${GATEWAY_METADATA_DIR}/metadata.json" <<EOF
{
"name": "${GATEWAY_NAME}",
"gateway_endpoint": "${LOCAL_GATEWAY_ENDPOINT}",
"is_remote": false,
"gateway_port": ${SERVER_PORT},
"auth_mode": "plaintext"
}
EOF
if [ -n "${SUDO_USER:-}" ]; then
chown -R "${SUDO_USER}" "${GATEWAY_CONFIG_BASE}/openshell" 2>/dev/null || true
fi
echo "==> Gateway config"
echo " Name: ${GATEWAY_NAME}"
echo " Endpoint: ${LOCAL_GATEWAY_ENDPOINT}"
echo " .env: OPENSHELL_GATEWAY=${GATEWAY_NAME}"
echo " .env: GATEWAY_PORT=${SERVER_PORT}"
echo " Driver: ${OPENSHELL_DRIVER_DIR}/openshell-driver-vm"
echo " Image: ${OPENSHELL_SANDBOX_IMAGE}"
echo " Status: ${CLI_BIN} status"
echo " Create: ${CLI_BIN} sandbox create --name vm-test --from ubuntu:24.04"
echo "==> Starting OpenShell server with VM compute driver"
exec "${ROOT}/target/debug/openshell-gateway"
+8 -3
View File
@@ -310,9 +310,14 @@ pub async fn spawn(
if !vm_config.default_image.trim().is_empty() {
command.arg("--default-image").arg(&vm_config.default_image);
}
command
.arg("--ssh-handshake-secret")
.arg(&config.ssh_handshake_secret);
// Only forward the handshake secret when one is configured. The VM
// driver does not consume it, but accepts it for parity with the
// Kubernetes/Podman drivers; passing an empty value is noise.
if !config.ssh_handshake_secret.is_empty() {
command
.arg("--ssh-handshake-secret")
.arg(&config.ssh_handshake_secret);
}
command
.arg("--ssh-handshake-skew-secs")
.arg(config.ssh_handshake_skew_secs.to_string());
+43 -32
View File
@@ -148,12 +148,10 @@ pub async fn run_server(
if database_url.is_empty() {
return Err(Error::config("database_url is required"));
}
let driver = configured_compute_driver(&config)?;
if config.ssh_handshake_secret.is_empty() && driver != ComputeDriverKind::Docker {
return Err(Error::config(
"ssh_handshake_secret is required. Set --ssh-handshake-secret or OPENSHELL_SSH_HANDSHAKE_SECRET",
));
}
// The ssh_handshake_secret check is driver-scoped: the Kubernetes and
// Podman drivers inject the secret into sandbox env, while the VM and
// Docker drivers do not consume it. The check is enforced per-driver
// in `build_compute_runtime`.
let store = Arc::new(Store::connect(database_url).await?);
@@ -368,32 +366,35 @@ async fn build_compute_runtime(
info!(driver = %driver, "Using compute driver");
match driver {
ComputeDriverKind::Kubernetes => ComputeRuntime::new_kubernetes(
KubernetesComputeConfig {
namespace: config.sandbox_namespace.clone(),
default_image: config.sandbox_image.clone(),
image_pull_policy: config.sandbox_image_pull_policy.clone(),
grpc_endpoint: config.grpc_endpoint.clone(),
// Filesystem path to the supervisor's Unix-socket SSH daemon.
// The path lives in a root-only directory so only the
// supervisor can connect; the gateway reaches it through the
// RelayStream bridge, not directly. Override via
// `sandbox_ssh_socket_path` in the config for deployments
// where multiple supervisors share a filesystem.
ssh_socket_path: config.sandbox_ssh_socket_path.clone(),
ssh_handshake_secret: config.ssh_handshake_secret.clone(),
ssh_handshake_skew_secs: config.ssh_handshake_skew_secs,
client_tls_secret_name: config.client_tls_secret_name.clone(),
host_gateway_ip: config.host_gateway_ip.clone(),
},
store,
sandbox_index,
sandbox_watch_bus,
tracing_log_bus,
supervisor_sessions.clone(),
)
.await
.map_err(|e| Error::execution(format!("failed to create compute runtime: {e}"))),
ComputeDriverKind::Kubernetes => {
require_ssh_handshake_secret(config)?;
ComputeRuntime::new_kubernetes(
KubernetesComputeConfig {
namespace: config.sandbox_namespace.clone(),
default_image: config.sandbox_image.clone(),
image_pull_policy: config.sandbox_image_pull_policy.clone(),
grpc_endpoint: config.grpc_endpoint.clone(),
// Filesystem path to the supervisor's Unix-socket SSH daemon.
// The path lives in a root-only directory so only the
// supervisor can connect; the gateway reaches it through
// the RelayStream bridge, not directly. Override via
// `sandbox_ssh_socket_path` in the config for deployments
// where multiple supervisors share a filesystem.
ssh_socket_path: config.sandbox_ssh_socket_path.clone(),
ssh_handshake_secret: config.ssh_handshake_secret.clone(),
ssh_handshake_skew_secs: config.ssh_handshake_skew_secs,
client_tls_secret_name: config.client_tls_secret_name.clone(),
host_gateway_ip: config.host_gateway_ip.clone(),
},
store,
sandbox_index,
sandbox_watch_bus,
tracing_log_bus,
supervisor_sessions.clone(),
)
.await
.map_err(|e| Error::execution(format!("failed to create compute runtime: {e}")))
}
ComputeDriverKind::Docker => ComputeRuntime::new_docker(
config.clone(),
docker_config.clone(),
@@ -420,6 +421,7 @@ async fn build_compute_runtime(
.map_err(|e| Error::execution(format!("failed to create compute runtime: {e}")))
}
ComputeDriverKind::Podman => {
require_ssh_handshake_secret(config)?;
let socket_path = std::env::var("OPENSHELL_PODMAN_SOCKET")
.ok()
.filter(|s| !s.is_empty())
@@ -471,6 +473,15 @@ async fn build_compute_runtime(
}
}
fn require_ssh_handshake_secret(config: &Config) -> Result<()> {
if config.ssh_handshake_secret.is_empty() {
return Err(Error::config(
"ssh_handshake_secret is required for this driver. Set --ssh-handshake-secret or OPENSHELL_SSH_HANDSHAKE_SECRET",
));
}
Ok(())
}
fn configured_compute_driver(config: &Config) -> Result<ComputeDriverKind> {
match config.compute_drivers.as_slice() {
[] => Err(Error::config(
+8 -1
View File
@@ -167,13 +167,20 @@ echo "==> Starting openshell-gateway on 127.0.0.1:${HOST_PORT} (state: ${RUN_STA
# `~/.local/libexec/openshell/openshell-driver-vm` when present
# (install-vm.sh installs there), which silently shadows development
# builds — a subtle source of stale-binary bugs in e2e runs.
# --grpc-endpoint is the URL the VM driver passes into each guest as
# OPENSHELL_ENDPOINT. The supervisor inside the VM dials this address.
# Use `host.containers.internal` rather than `127.0.0.1` so gvproxy's
# host-loopback proxy carries the connection — gvproxy's bare gateway IP
# (192.168.127.1) does NOT forward arbitrary host ports. The driver also
# rewrites loopback URLs to this hostname as a safety net, so this matches
# what the guest will actually see and aligns with `tasks/scripts/gateway-vm.sh`.
"${GATEWAY_BIN}" \
--drivers vm \
--disable-tls \
--disable-gateway-auth \
--db-url 'sqlite::memory:' \
--port "${HOST_PORT}" \
--grpc-endpoint "http://127.0.0.1:${HOST_PORT}" \
--grpc-endpoint "http://host.containers.internal:${HOST_PORT}" \
--ssh-handshake-secret "${SSH_HANDSHAKE_SECRET}" \
--driver-dir "${ROOT}/target/debug" \
--vm-driver-state-dir "${RUN_STATE_DIR}" \
+4
View File
@@ -6,3 +6,7 @@
["gateway:docker"]
description = "Run a standalone gateway with the bundled Docker compute driver"
run = "bash tasks/scripts/gateway-docker.sh"
["gateway:vm"]
description = "Run a standalone gateway with the bundled VM compute driver"
run = "bash tasks/scripts/gateway-vm.sh"
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# Start a standalone openshell-gateway backed by the VM compute driver
# (openshell-driver-vm) for local manual testing.
#
# Defaults:
# - Plaintext HTTP on 127.0.0.1:18081
# - Dedicated CLI gateway "vm-dev"
# - Persistent gateway state (SQLite DB) under .cache/gateway-vm
# - Per-sandbox VM driver state (rootfs + compute-driver.sock) under
# /tmp/openshell-vm-driver-<user>-<gateway-name> so the AF_UNIX socket
# path stays under macOS SUN_LEN
#
# Common overrides:
# OPENSHELL_SERVER_PORT=18091 mise run gateway:vm
# OPENSHELL_VM_GATEWAY_NAME=my-vm-gateway mise run gateway:vm
# OPENSHELL_SANDBOX_NAMESPACE=my-ns mise run gateway:vm
# OPENSHELL_SANDBOX_IMAGE=ghcr.io/... mise run gateway:vm
#
# After the gateway is running, point the CLI at it with either:
# openshell --gateway vm-dev <command>
# openshell gateway select vm-dev # then plain `openshell <command>`
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
PORT="${OPENSHELL_SERVER_PORT:-18081}"
GATEWAY_NAME="${OPENSHELL_VM_GATEWAY_NAME:-vm-dev}"
STATE_DIR="${OPENSHELL_VM_GATEWAY_STATE_DIR:-${ROOT}/.cache/gateway-vm}"
SANDBOX_NAMESPACE="${OPENSHELL_SANDBOX_NAMESPACE:-vm-dev}"
SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-${COMMUNITY_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base:latest}}"
SANDBOX_IMAGE_PULL_POLICY="${OPENSHELL_SANDBOX_IMAGE_PULL_POLICY:-IfNotPresent}"
LOG_LEVEL="${OPENSHELL_LOG_LEVEL:-info}"
GATEWAY_BIN="${ROOT}/target/debug/openshell-gateway"
DRIVER_DIR_DEFAULT="${ROOT}/target/debug"
DRIVER_DIR="${OPENSHELL_DRIVER_DIR:-${DRIVER_DIR_DEFAULT}}"
COMPRESSED_DIR_DEFAULT="${ROOT}/target/vm-runtime-compressed"
COMPRESSED_DIR="${OPENSHELL_VM_RUNTIME_COMPRESSED_DIR:-${COMPRESSED_DIR_DEFAULT}}"
VM_HOST_GATEWAY_DEFAULT="${OPENSHELL_VM_HOST_GATEWAY:-host.containers.internal}"
GRPC_ENDPOINT="${OPENSHELL_GRPC_ENDPOINT:-http://${VM_HOST_GATEWAY_DEFAULT}:${PORT}}"
normalize_arch() {
case "$1" in
x86_64|amd64) echo "amd64" ;;
aarch64|arm64) echo "arm64" ;;
*) echo "$1" ;;
esac
}
normalize_bool() {
case "${1,,}" in
1|true|yes|on) echo "true" ;;
0|false|no|off) echo "false" ;;
*)
echo "ERROR: invalid boolean value '$1' (expected true/false, 1/0, yes/no, on/off)" >&2
exit 2
;;
esac
}
port_is_in_use() {
local port=$1
if command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1
return $?
fi
if command -v nc >/dev/null 2>&1; then
nc -z 127.0.0.1 "${port}" >/dev/null 2>&1
return $?
fi
(echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1
}
register_gateway_metadata() {
local name=$1
local endpoint=$2
local port=$3
local config_home gateway_dir
config_home="${XDG_CONFIG_HOME:-${HOME}/.config}"
gateway_dir="${config_home}/openshell/gateways/${name}"
mkdir -p "${gateway_dir}"
cat >"${gateway_dir}/metadata.json" <<EOF
{
"name": "${name}",
"gateway_endpoint": "${endpoint}",
"is_remote": false,
"gateway_port": ${port},
"auth_mode": "plaintext"
}
EOF
}
check_supervisor_cross_toolchain() {
# The sandbox supervisor inside the guest is always Linux. On non-Linux
# hosts (macOS) and on Linux hosts with a different arch than the guest,
# `mise run vm:supervisor` cross-compiles via cargo-zigbuild and needs
# the matching rustup target installed.
local host_os host_arch guest_arch rust_target
host_os="$(uname -s)"
host_arch="$(uname -m)"
guest_arch="${GUEST_ARCH:-${host_arch}}"
case "${guest_arch}" in
arm64|aarch64) rust_target="aarch64-unknown-linux-gnu" ;;
x86_64|amd64) rust_target="x86_64-unknown-linux-gnu" ;;
*) return 0 ;;
esac
if [ "${host_os}" = "Linux" ] && [ "${host_arch}" = "${guest_arch}" ]; then
return 0
fi
local missing=0
if ! command -v cargo-zigbuild >/dev/null 2>&1; then
echo "ERROR: cargo-zigbuild not found (required to cross-compile the guest supervisor)." >&2
echo " Install: cargo install --locked cargo-zigbuild && brew install zig" >&2
missing=1
fi
if ! rustup target list --installed 2>/dev/null | grep -qx "${rust_target}"; then
echo "ERROR: Rust target '${rust_target}' not installed." >&2
echo " Install: rustup target add ${rust_target}" >&2
missing=1
fi
if [ "${missing}" -ne 0 ]; then
exit 1
fi
}
if [[ ! "${GATEWAY_NAME}" =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "ERROR: OPENSHELL_VM_GATEWAY_NAME must contain only letters, numbers, dots, underscores, or dashes" >&2
exit 2
fi
if port_is_in_use "${PORT}"; then
echo "ERROR: port ${PORT} is already in use; free it or set OPENSHELL_SERVER_PORT" >&2
exit 2
fi
# AF_UNIX SUN_LEN on macOS is 104 bytes; the VM driver places
# `compute-driver.sock` directly under VM_DRIVER_STATE_DIR, so anchor it
# under /tmp instead of `${ROOT}/.cache` (which is typically too long on
# macOS dev boxes with worktree paths).
STATE_LABEL="$(printf '%s' "${GATEWAY_NAME}" | tr -cs '[:alnum:]._-' '-')"
if [ -z "${STATE_LABEL}" ]; then
STATE_LABEL="vm-dev"
fi
VM_DRIVER_STATE_DIR_DEFAULT="${OPENSHELL_VM_DRIVER_STATE_ROOT:-/tmp}/openshell-vm-driver-${USER:-user}-${STATE_LABEL}"
VM_DRIVER_STATE_DIR="${OPENSHELL_VM_DRIVER_STATE_DIR:-${VM_DRIVER_STATE_DIR_DEFAULT}}"
DISABLE_TLS="$(normalize_bool "${OPENSHELL_DISABLE_TLS:-true}")"
# Build prerequisites: VM runtime artifacts + bundled supervisor.
if [ ! -d "${COMPRESSED_DIR}" ] \
|| ! find "${COMPRESSED_DIR}" -maxdepth 1 -name 'libkrun*.zst' | grep -q . \
|| [ ! -f "${COMPRESSED_DIR}/gvproxy.zst" ]; then
echo "==> Preparing embedded VM runtime (mise run vm:setup)"
mise run vm:setup
fi
if [ ! -f "${COMPRESSED_DIR}/openshell-sandbox.zst" ]; then
check_supervisor_cross_toolchain
echo "==> Building bundled VM supervisor (mise run vm:supervisor)"
mise run vm:supervisor
fi
export OPENSHELL_VM_RUNTIME_COMPRESSED_DIR="${COMPRESSED_DIR}"
CARGO_BUILD_JOBS_ARG=()
if [[ -n "${CARGO_BUILD_JOBS:-}" ]]; then
CARGO_BUILD_JOBS_ARG=(-j "${CARGO_BUILD_JOBS}")
fi
echo "==> Building openshell-gateway and openshell-driver-vm"
cargo build ${CARGO_BUILD_JOBS_ARG[@]+"${CARGO_BUILD_JOBS_ARG[@]}"} \
-p openshell-server -p openshell-driver-vm
if [ "$(uname -s)" = "Darwin" ]; then
echo "==> Codesigning openshell-driver-vm (Hypervisor entitlement)"
codesign \
--entitlements "${ROOT}/crates/openshell-driver-vm/entitlements.plist" \
--force \
-s - \
"${DRIVER_DIR}/openshell-driver-vm"
fi
mkdir -p "${STATE_DIR}"
mkdir -p "${VM_DRIVER_STATE_DIR}"
GATEWAY_ENDPOINT="http://127.0.0.1:${PORT}"
register_gateway_metadata "${GATEWAY_NAME}" "${GATEWAY_ENDPOINT}" "${PORT}"
echo "Starting standalone VM gateway..."
echo " gateway: ${GATEWAY_NAME}"
echo " endpoint: ${GATEWAY_ENDPOINT}"
echo " namespace: ${SANDBOX_NAMESPACE}"
echo " state dir: ${STATE_DIR}"
echo " driver: ${DRIVER_DIR}/openshell-driver-vm"
echo " driver dir: ${VM_DRIVER_STATE_DIR}"
echo " image: ${SANDBOX_IMAGE}"
echo
echo "Point the CLI at this gateway with one of:"
echo " openshell --gateway ${GATEWAY_NAME} status"
echo " openshell gateway select ${GATEWAY_NAME}"
echo
GATEWAY_ARGS=(
--port "${PORT}"
--log-level "${LOG_LEVEL}"
--drivers vm
--db-url "sqlite:${STATE_DIR}/gateway.db?mode=rwc"
--sandbox-namespace "${SANDBOX_NAMESPACE}"
--sandbox-image "${SANDBOX_IMAGE}"
--sandbox-image-pull-policy "${SANDBOX_IMAGE_PULL_POLICY}"
--grpc-endpoint "${GRPC_ENDPOINT}"
--driver-dir "${DRIVER_DIR}"
--vm-driver-state-dir "${VM_DRIVER_STATE_DIR}"
)
if [ "${DISABLE_TLS}" = "true" ]; then
GATEWAY_ARGS+=(--disable-tls)
fi
exec "${GATEWAY_BIN}" "${GATEWAY_ARGS[@]}"
+3 -5
View File
@@ -7,20 +7,18 @@
# mise run vm:setup # one-time: download pre-built runtime (~30s)
# mise run vm:supervisor # build the bundled sandbox supervisor
# mise run gateway:vm # start openshell-gateway with the VM driver
# # (defined in tasks/gateway.toml)
# mise run vm # build + run the standalone openshell-vm microVM
# mise run vm:clean # wipe everything and start over
#
# See crates/openshell-driver-vm/README.md for the `gateway:vm` flow and
# See tasks/gateway.toml for `gateway:vm`,
# crates/openshell-driver-vm/README.md for the VM driver workflow, and
# crates/openshell-vm/README.md for the standalone microVM path.
# ═══════════════════════════════════════════════════════════════════════════
# Main Commands
# ═══════════════════════════════════════════════════════════════════════════
["gateway:vm"]
description = "Build openshell-gateway + openshell-driver-vm and start the gateway with the VM driver (pass -- --gpu for GPU support)"
run = "crates/openshell-driver-vm/start.sh"
[vm]
description = "Build and run the standalone openshell-vm microVM"
depends = ["build:docker:gateway"]