diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 805c5f5b..9bdb9df3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,10 +1,5 @@
name: CI
-
-on:
- push:
- branches: [main]
- pull_request:
- branches: [main]
+on: [workflow_call]
concurrency:
group: ci-${{ github.ref }}
@@ -15,13 +10,11 @@ jobs:
name: Lint & Typecheck
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- - uses: pnpm/action-setup@v2
- with:
- version: 8
+ - uses: pnpm/action-setup@v5
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'pnpm'
@@ -54,13 +47,11 @@ jobs:
artifact: gondolin-guest-arm64
zig-target: aarch64-linux-musl
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- - uses: pnpm/action-setup@v2
- with:
- version: 8
+ - uses: pnpm/action-setup@v5
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'pnpm'
@@ -108,13 +99,11 @@ jobs:
needs: [check]
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- - uses: pnpm/action-setup@v2
- with:
- version: 8
+ - uses: pnpm/action-setup@v5
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'pnpm'
@@ -179,7 +168,7 @@ jobs:
needs: [check]
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
@@ -209,9 +198,9 @@ jobs:
needs: [check]
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v6
with:
node-version: '24'
@@ -253,19 +242,17 @@ jobs:
needs: [check, build-guest, krun-linux-build, krun-runner-package-smoke]
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- - uses: pnpm/action-setup@v2
- with:
- version: 8
+ - uses: pnpm/action-setup@v5
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'pnpm'
- name: Download guest assets
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: gondolin-guest-x64
path: .
@@ -322,19 +309,17 @@ jobs:
needs: [check, build-guest]
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- - uses: pnpm/action-setup@v2
- with:
- version: 8
+ - uses: pnpm/action-setup@v5
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'pnpm'
- name: Download guest assets
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: gondolin-guest-x64
path: .
@@ -373,13 +358,137 @@ jobs:
run: |
NODE_BIN="$(node -p 'process.execPath')"
make -C guest test NODE="$NODE_BIN"
- make -C host test NODE="$NODE_BIN"
+ pnpm --dir host test
env:
GONDOLIN_GUEST_DIR: ${{ github.workspace }}/guest/image/out
WS_TIMEOUT: "120000"
# Enable VM/QEMU logs to help diagnose boot/hang issues in CI.
GONDOLIN_DEBUG: "net"
+ windows-host:
+ name: Windows host (QEMU)
+ needs: [check, build-guest]
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ - uses: pnpm/action-setup@v5
+
+ - uses: actions/setup-node@v6
+ with:
+ node-version: '24'
+ cache: 'pnpm'
+
+ - name: Install QEMU (Windows)
+ shell: pwsh
+ run: |
+ $url = 'https://qemu.weilnetz.de/w64/2026/qemu-w64-setup-20260415.exe'
+ $expected = '736e125e044149611c47510d5696bc877b8df741655f229a3e9348f31bd49bf2b0105c78717888f5259aac3708e51fdc9d2c45d919becec5ec9398a08c8d435a'
+ $installer = Join-Path $env:RUNNER_TEMP 'qemu-w64-setup-20260415.exe'
+ $installDir = Join-Path $env:ProgramFiles 'qemu'
+ $sevenZip = Get-Command 7z.exe -ErrorAction SilentlyContinue
+ if (-not $sevenZip) {
+ $sevenZip = Get-Command 7z -ErrorAction SilentlyContinue
+ }
+ if (-not $sevenZip) {
+ throw '7z is required to extract the QEMU installer on windows-latest'
+ }
+
+ Invoke-WebRequest -Uri $url -OutFile $installer
+ $actual = (Get-FileHash $installer -Algorithm SHA512).Hash.ToLowerInvariant()
+ if ($actual -ne $expected) {
+ throw "QEMU installer SHA512 mismatch: expected $expected got $actual"
+ }
+
+ if (Test-Path $installDir) {
+ Remove-Item -Recurse -Force $installDir
+ }
+ New-Item -ItemType Directory -Force $installDir | Out-Null
+ & $sevenZip.Source x '-y' "-o$installDir" $installer | Out-Host
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Failed to extract the QEMU installer with 7z'
+ }
+
+ $qemuExe = Join-Path $installDir 'qemu-system-x86_64.exe'
+ $qemuGuiExe = Join-Path $installDir 'qemu-system-x86_64w.exe'
+ if (-not (Test-Path $qemuExe) -and -not (Test-Path $qemuGuiExe)) {
+ throw 'Extracted QEMU payload did not contain qemu-system-x86_64(.exe/.w.exe)'
+ }
+
+ Write-Host "Extracted QEMU into $installDir"
+
+ - name: Resolve QEMU path and accelerators
+ shell: pwsh
+ run: |
+ $candidates = @(
+ (Join-Path $env:ProgramFiles 'qemu\qemu-system-x86_64.exe'),
+ (Join-Path $env:ProgramFiles 'qemu\qemu-system-x86_64w.exe')
+ )
+ $qemu = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1
+ if (-not $qemu) {
+ throw 'Failed to resolve qemu-system-x86_64(.exe/.w.exe) after extraction'
+ }
+
+ "WINDOWS_QEMU=$qemu" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
+ & $qemu -accel help
+
+ - name: Download guest assets
+ uses: actions/download-artifact@v8
+ with:
+ name: gondolin-guest-x64
+ path: .
+
+ - name: Extract guest assets
+ shell: pwsh
+ run: |
+ New-Item -ItemType Directory -Force guest/image/out | Out-Null
+ tar -xzf gondolin-guest-x64.tar.gz -C guest/image/out
+
+ - name: Install dependencies
+ run: pnpm install
+
+ - name: Build
+ run: pnpm run build
+
+ - name: Run host tests
+ timeout-minutes: 25
+ run: pnpm --dir host test
+ env:
+ GONDOLIN_GUEST_DIR: ${{ github.workspace }}\guest\image\out
+ WS_TIMEOUT: "120000"
+ GONDOLIN_DEBUG: "net"
+
+ - name: CLI smoke test (Windows QEMU)
+ timeout-minutes: 10
+ shell: pwsh
+ env:
+ GONDOLIN_GUEST_DIR: ${{ github.workspace }}\guest\image\out
+ run: |
+ $accelHelp = (& $env:WINDOWS_QEMU -accel help 2>&1 | Out-String)
+ $hasWhpx = $accelHelp -match '(^|\r?\n)\s*whpx\s*(\r?\n|$)'
+
+ $args = @('host/dist/bin/gondolin.js', 'exec', '--', '/bin/sh', '-lc', 'echo WINDOWS_CLI_SMOKE_OK')
+ $output = & node @args 2>&1
+ $text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
+ Write-Host $text
+
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Windows CLI smoke test failed'
+ }
+ if ($text -notmatch 'WINDOWS_CLI_SMOKE_OK') {
+ throw 'Windows CLI smoke test did not produce the success marker'
+ }
+
+ if ($hasWhpx) {
+ if ($text -match 'using QEMU TCG software emulation') {
+ throw 'Unexpected TCG warning while WHPX is available'
+ }
+ } else {
+ if ($text -notmatch 'using QEMU TCG software emulation') {
+ throw 'Expected the Windows CLI to warn when falling back to TCG'
+ }
+ }
+
# Optional: Publish preview package for PRs (uses trusted publishing)
# Uncomment to enable publishing test versions from PRs
# publish-preview:
@@ -391,13 +500,11 @@ jobs:
# contents: read
# id-token: write
# steps:
- # - uses: actions/checkout@v4
+ # - uses: actions/checkout@v6
#
- # - uses: pnpm/action-setup@v2
- # with:
- # version: 8
+ # - uses: pnpm/action-setup@v5
#
- # - uses: actions/setup-node@v4
+ # - uses: actions/setup-node@v6
# with:
# node-version: '24'
# registry-url: 'https://registry.npmjs.org'
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
new file mode 100644
index 00000000..1c29e712
--- /dev/null
+++ b/.github/workflows/pr.yml
@@ -0,0 +1,6 @@
+on: pull_request
+
+jobs:
+ ci:
+ if: github.event.pull_request.head.repo.full_name != github.repository
+ uses: ./.github/workflows/ci.yml
\ No newline at end of file
diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml
new file mode 100644
index 00000000..62a40eb0
--- /dev/null
+++ b/.github/workflows/push.yml
@@ -0,0 +1,5 @@
+on: push
+
+jobs:
+ ci:
+ uses: ./.github/workflows/ci.yml
\ No newline at end of file
diff --git a/README.md b/README.md
index cf2b89b0..d4b8fbcc 100644
--- a/README.md
+++ b/README.md
@@ -74,9 +74,9 @@ Gondolin uses `GONDOLIN_DEFAULT_IMAGE` (default: `alpine-base:latest`).
Requirements:
-| macOS | Linux (Debian/Ubuntu) |
-| ------------------------ | --------------------------------------------- |
-| `brew install qemu node` | `sudo apt install qemu-system-arm nodejs npm` |
+| macOS | Linux (Debian/Ubuntu) | Windows x64 |
+| ------------------------ | --------------------------------------------- | ----------- |
+| `brew install qemu node` | `sudo apt install qemu-system-arm nodejs npm` | Install Node.js 23.6+ and a QEMU build with WHPX support, then put `qemu-system-x86_64` or `qemu-system-x86_64w` on `PATH` |
Optional experimental libkrun backend setup:
@@ -85,7 +85,8 @@ make krun-runner
```
Published installs of `@earendil-works/gondolin` also include platform-specific
-optional runner packages for supported targets.
+optional runner packages for supported targets. `krun` is currently unsupported
+on Windows hosts; use the default `qemu` backend there.
This stages `libkrun` under `.cache/` (no global install) and builds the local
runner helper at `host/krun-runner/zig-out/bin/gondolin-krun-runner`.
@@ -111,7 +112,8 @@ When `vmm=krun` is selected, Gondolin requires krun boot assets from the selecte
image manifest (`assets.krunKernel` and optional `assets.krunInitrd`).
For custom kernels/initrds, provide an explicit `sandbox.imagePath` asset object.
-> Linux and macOS are supported. ARM64 is the most tested runtime path today.
+> The QEMU backend is supported on macOS, Linux, and Windows x64.
+> `krun` remains supported on macOS/Linux only. ARM64 is the most tested runtime path today.
> Linux x86_64 `make krun-runner` is covered by CI smoke builds.
## Feature Highlights
@@ -138,6 +140,7 @@ For custom kernels/initrds, provide an explicit `sandbox.imagePath` asset object
- [SSH](https://earendil-works.github.io/gondolin/ssh/)
- [Custom Images](https://earendil-works.github.io/gondolin/custom-images/)
- [Architecture Overview](https://earendil-works.github.io/gondolin/architecture/)
+- [Windows QEMU showcase](docs/windows-showcase.md)
- [VM Backends (QEMU vs krun)](docs/backends.md)
- [Security Design](https://earendil-works.github.io/gondolin/security/)
- [Limitations](https://earendil-works.github.io/gondolin/limitations/)
diff --git a/docs/backends.md b/docs/backends.md
index b645db5e..4c4cd4bb 100644
--- a/docs/backends.md
+++ b/docs/backends.md
@@ -38,6 +38,7 @@ This page is the authoritative backend-parity reference for SDK/CLI behavior.
### krun
- Guest architecture must match the **host** architecture
+- Windows hosts are not supported; use `qemu` there
- Requires a **libkrunfw-compatible kernel**
- Gondolin requires image manifest krun boot assets:
- `assets.krunKernel`
diff --git a/docs/index.md b/docs/index.md
index 8778ab9c..7ee49017 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -8,10 +8,10 @@ want to be able to tightly control the file system, for convenience of the agent
and to control persistence.
Gondolin gives you that. Lightweight micro-VMs (QEMU by default, optional
-libkrun backend) boot in under a second on your Mac or Linux machine. The
-network stack and virtual filesystem are implemented entirely in JavaScript,
-giving you complete programmatic control over what the sandbox can access and
-what secrets it can use.
+libkrun backend on macOS/Linux) boot in under a second on your Mac, Linux, or
+Windows machine. The network stack and virtual filesystem are implemented
+entirely in JavaScript, giving you complete programmatic control over what the
+sandbox can access and what secrets it can use.
This documentation helps you get started with it. We also welcome your feedback
as this is an early project and we are eager to learn more about how you want
@@ -65,6 +65,7 @@ await vm.close();
- [Workloads](./workloads.md): typical workloads and lifecycles
- [CLI](./cli.md): run shells/commands, list sessions, and attach to running VMs
+- [Windows QEMU showcase](./windows-showcase.md): end-to-end Git Bash walkthrough for Windows x64
- [Secrets Handling](./secrets.md): placeholder-based secret injection and host allowlists
- [Ingress](./ingress.md): expose guest HTTP servers on the host
- [SSH](./ssh.md): enable SSH access to the guest with safe defaults
diff --git a/docs/limitations.md b/docs/limitations.md
index 4ebdf40c..348440ec 100644
--- a/docs/limitations.md
+++ b/docs/limitations.md
@@ -63,6 +63,11 @@ Notable gaps today:
See [VM Backends (QEMU vs krun)](./backends.md) for the maintained matrix.
-## No Windows support
+## Windows support notes
-The host side of Gondolin is currently supported on macOS and Linux.
+The QEMU backend is supported on Windows x64, where Gondolin uses loopback TCP
+endpoints instead of Unix sockets and prefers WHPX when the installed
+`qemu-system-x86_64` build advertises it.
+
+The experimental `krun` backend remains unsupported on Windows; use
+`vmm=qemu` there.
diff --git a/docs/windows-showcase.md b/docs/windows-showcase.md
new file mode 100644
index 00000000..8957f1cc
--- /dev/null
+++ b/docs/windows-showcase.md
@@ -0,0 +1,360 @@
+# Windows QEMU showcase (Git Bash)
+
+This walkthrough demonstrates the end-user Windows x64 path for Gondolin's
+QEMU backend from a local checkout.
+
+It is written for **Git Bash on Windows** and exercises the most relevant
+features in one flow:
+
+- QEMU + WHPX host support
+- host directory mounts (`--mount-hostfs`)
+- outbound HTTP allowlists (`--allow-host`)
+- secret injection without guest exposure (`--host-secret`)
+- host ingress (`--listen`)
+- host → guest SSH access (`--ssh`)
+- session discovery (`gondolin list`)
+- session attach (`gondolin attach`)
+- optional snapshot/resume
+
+> `krun` is not part of this walkthrough. On Windows, use the default
+> `qemu` backend.
+
+## Prerequisites
+
+- Windows x64
+- Node.js `>= 23.6`
+- A QEMU build with `whpx` support
+- This repo checked out locally
+
+Useful Windows references:
+
+- QEMU WHPX documentation: https://www.qemu.org/docs/master/system/whpx.html
+- Example Windows installer used during validation: https://qemu.weilnetz.de/w64/2026/qemu-w64-setup-20260415.exe
+- Example installer SHA512: `736e125e044149611c47510d5696bc877b8df741655f229a3e9348f31bd49bf2b0105c78717888f5259aac3708e51fdc9d2c45d919becec5ec9398a08c8d435a`
+- Enable the Hypervisor Platform feature if WHPX is unavailable:
+
+```powershell
+DISM /online /Enable-Feature /FeatureName:HypervisorPlatform /All
+```
+
+Gondolin auto-detects the usual Windows QEMU binary names when `sandbox.qemuPath`
+is not set, including `qemu-system-x86_64`, `qemu-system-x86_64w`, and the
+standard `C:\Program Files\qemu\...` install paths.
+
+From the repo root:
+
+```bash
+pnpm install
+pnpm build
+qemu-system-x86_64 -accel help
+```
+
+Expected QEMU accelerator output includes:
+
+```text
+Accelerators supported in QEMU binary:
+tcg
+whpx
+```
+
+## 1. Prepare a demo workspace
+
+From the repo root in Git Bash:
+
+```bash
+mkdir -p demo
+
+cat > demo/index.html <<'EOF'
+
+
+
+ Hello from Gondolin
+ If you can read this on localhost, ingress works.
+
+
+EOF
+
+export demo="$PWD/demo"
+export SHOWCASE_TOKEN=demo-secret-123
+```
+
+## 2. Launch Gondolin with mounts, network policy, ingress, and SSH
+
+```bash
+pnpm gondolin bash \
+ --mount-hostfs "$demo:/workspace" \
+ --allow-host httpbin.org \
+ --host-secret SHOWCASE_TOKEN@httpbin.org \
+ --listen 127.0.0.1:3000 \
+ --ssh --ssh-port 2222
+```
+
+Expected startup output looks like:
+
+```text
+SSH enabled: ssh -p 2222 -i C:\Users\Admin\AppData\Local\Temp\gondolin-ssh-...\id_ed25519 -o ForwardAgent=no -o ClearAllForwardings=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@127.0.0.1
+Ingress enabled: http://127.0.0.1:3000
+Configure routes by editing /etc/gondolin/listeners inside the VM.
+(none):/#
+```
+
+Keep this shell open. It is the primary guest shell for the rest of the demo.
+
+## 3. Inside the guest: prove the mount, ingress config, and secret injection
+
+Run these commands inside the VM shell:
+
+```sh
+pwd
+set -eux
+
+echo "written by guest" > /workspace/from-guest.txt
+echo "snapshot-ok" > /etc/checkpoint.txt
+printf '/ :8000\n' > /etc/gondolin/listeners
+
+cd /workspace
+python3 -m http.server 8000 >/tmp/showcase-http.log 2>&1 &
+
+echo "$SHOWCASE_TOKEN"
+
+curl -sS \
+ -H "Authorization: Bearer $SHOWCASE_TOKEN" \
+ https://httpbin.org/anything | sed -n '1,40p'
+```
+
+Observed output from a successful run:
+
+```text
+(none):/# pwd
+/
+(none):/# set -eux
+(none):/# echo "written by guest" > /workspace/from-guest.txt
++ echo 'written by guest'
+(none):/# echo "snapshot-ok" > /etc/checkpoint.txt
++ echo snapshot-ok
+(none):/# printf '/ :8000\n' > /etc/gondolin/listeners
++ printf '/ :8000\n'
+(none):/# cd /workspace
++ cd /workspace
+(none):/workspace# python3 -m http.server 8000 >/tmp/showcase-http.log 2>&1 &
+[1] 738
++ python3 -m http.server 8000
+(none):/workspace# echo $SHOWCASE_TOKEN
++ echo GONDOLIN_SECRET_07f1d89d439a12d9ce54747d936720cfc366a55945a6d368
+GONDOLIN_SECRET_07f1d89d439a12d9ce54747d936720cfc366a55945a6d368
+(none):/workspace# curl -sS \
+> -H "Authorization: Bearer $SHOWCASE_TOKEN" \
+> https://httpbin.org/anything | sed -n '1,40p'
++ sed -n 1,40p
++ curl -sS -H 'Authorization: Bearer GONDOLIN_SECRET_07f1d89d439a12d9ce54747d936720cfc366a55945a6d368' https://httpbin.org/anything
+{
+ "args": {},
+ "data": "",
+ "files": {},
+ "form": {},
+ "headers": {
+ "Accept": "*/*",
+ "Accept-Encoding": "br, gzip, deflate",
+ "Accept-Language": "*",
+ "Authorization": "Bearer demo-secret-123",
+ "Host": "httpbin.org",
+ "Sec-Fetch-Mode": "cors",
+ "User-Agent": "curl/8.17.0",
+ "X-Amzn-Trace-Id": "Root=1-69e016c0-7478b3de6855bd6d0958338c"
+ },
+ "json": null,
+ "method": "GET",
+ "origin": "91.90.174.9",
+ "url": "https://httpbin.org/anything"
+}
+```
+
+What this proves:
+
+- `/workspace` is a live host mount
+- the guest can write files back to the host
+- `/etc/checkpoint.txt` is written to the root disk, so it can survive disk-only checkpoint resume
+- `/etc/gondolin/listeners` configures host ingress routing
+- the guest only sees a `GONDOLIN_SECRET_...` placeholder
+- the host replaces that placeholder with the real secret for the allowed host
+
+## 4. From another terminal: prove the host can see the guest changes
+
+Open a second Git Bash window in the repo root.
+
+### 4.1 Verify the file written by the guest
+
+```bash
+cat demo/from-guest.txt
+```
+
+Observed output:
+
+```text
+written by guest
+```
+
+### 4.2 Verify ingress from host → guest
+
+```bash
+curl -sS http://127.0.0.1:3000/
+```
+
+Observed output:
+
+```html
+
+
+
+ Hello from Gondolin
+ If you can read this on localhost, ingress works.
+
+
+```
+
+## 5. Discover the live session
+
+```bash
+pnpm gondolin list
+```
+
+Observed output:
+
+```text
+ID PID AGE ALIVE LABEL
+cb977f76-d32d-42a9-89fd-9a937cefe4fe 15368 4m yes C:\Program Files\nodejs\node.exe C:\CodeBlocks\gondolin\host\bin\gondolin.ts bash --mount-hostfs C:\\CodeBlocks\\gondolin\\demo;C:\\Program Files\\Git\\workspace --allow-host httpbin.org --host-secret SHOWCASE_TOKEN@httpbin.org --listen 127.0.0.1:3000 --ssh --ssh-port 2222
+```
+
+Copy the session ID for the next step.
+
+## 6. Attach a second command to the running VM
+
+In Git Bash, prefix the command with `MSYS2_ARG_CONV_EXCL='*'` so `/bin/sh`
+reaches the guest unchanged.
+
+```bash
+MSYS2_ARG_CONV_EXCL='*' pnpm gondolin attach -- /bin/sh -lc \
+ 'echo ATTACH_OK && ls -la /workspace && cat /workspace/from-guest.txt'
+```
+
+Observed output:
+
+```text
+ATTACH_OK
+total 1
+-rw-rw-rw- 1 root root 17 Apr 15 22:51 from-guest.txt
+-rw-rw-rw- 1 root root 153 Apr 15 22:37 index.html
+written by guest
+```
+
+What this proves:
+
+- session registry works on Windows
+- attach IPC works on Windows
+- the attached process sees the same mounted workspace and guest state
+
+## 7. SSH into the guest from the host
+
+The easiest path is to copy the exact `ssh ...` command printed by Gondolin when
+it started. Example:
+
+```bash
+ssh -p 2222 -i /c/Users/Admin/AppData/Local/Temp/gondolin-ssh-2YQoxX/id_ed25519 \
+ -o ForwardAgent=no \
+ -o ClearAllForwardings=yes \
+ -o IdentitiesOnly=yes \
+ -o StrictHostKeyChecking=no \
+ -o UserKnownHostsFile=/dev/null \
+ root@127.0.0.1 'uname -a && cat /workspace/from-guest.txt'
+```
+
+Observed output:
+
+```text
+Warning: Permanently added '[127.0.0.1]:2222' (ED25519) to the list of known hosts.
+Linux (none) 6.18.13-0-virt #1-Alpine SMP PREEMPT_DYNAMIC 2026-02-20 15:23:57 x86_64 Linux
+written by guest
+```
+
+What this proves:
+
+- host → guest SSH forwarding works on Windows
+- the guest is a real Linux VM reachable via the advertised SSH endpoint
+
+## 8. Optional: snapshot and resume
+
+This step stops the live session and writes a qcow2 checkpoint.
+
+```bash
+pnpm gondolin snapshot --output ./showcase.qcow2
+```
+
+Resume it with the **same host-side arguments again** so the resumed VM gets the
+same mount, allowlist, secret injection, ingress, and SSH setup as the original
+showcase session:
+
+```bash
+pnpm gondolin bash \
+ --resume ./showcase.qcow2 \
+ --mount-hostfs "$demo:/workspace" \
+ --allow-host httpbin.org \
+ --host-secret SHOWCASE_TOKEN@httpbin.org \
+ --listen 127.0.0.1:3000 \
+ --ssh --ssh-port 2222
+```
+
+In Git Bash, prefer `./showcase.qcow2`, `host/showcase.qcow2`, or a `/c/...`
+path. Avoid an unquoted `C:\...` argument, because bash treats backslashes as
+escape characters before Gondolin sees the path.
+
+Inside the resumed VM shell, verify both the checkpointed root-disk state and
+that `/workspace` has been mounted again:
+
+```sh
+cat /etc/checkpoint.txt && echo RESUME_OK
+ls -la /workspace
+cat /workspace/from-guest.txt
+```
+
+Expected output:
+
+```text
+snapshot-ok
+RESUME_OK
+total 1
+-rw-rw-rw- 1 root root 17 ... from-guest.txt
+-rw-rw-rw- 1 root root 153 ... index.html
+written by guest
+```
+
+Notes:
+
+- snapshots are **disk-only**
+- tmpfs-backed paths like `/tmp` are **not** preserved
+- host mounts and listeners are re-created from the CLI arguments you pass on resume
+
+What this proves:
+
+- disk checkpoints work on Windows with the QEMU backend
+- resumed VMs can recover root-disk state written before the snapshot
+- the same host filesystem mount can be reattached after resume
+- the resumed VM can be brought back up with the same end-user CLI workflow shape
+
+## Success criteria summary
+
+A successful Windows showcase demonstrates all of the following:
+
+- `qemu-system-x86_64 -accel help` advertises `whpx`
+- Gondolin boots a guest and opens an interactive shell
+- `--mount-hostfs` works from Git Bash with `/c/...` host paths
+- guest writes appear on the host under `demo/`
+- `--allow-host` permits outbound HTTP only to the configured host
+- `--host-secret` injects a placeholder into the guest and substitutes the real secret on the host side
+- `--listen` exposes a guest web server on `http://127.0.0.1:3000/`
+- `gondolin list` sees the live session
+- `gondolin attach` can run an extra command in the same VM
+- `--ssh` enables host SSH access to the guest
+- optional: snapshot/resume preserves root-disk guest state, and the showcase host-side settings can be re-applied on resume
+
+If all of those pass, Gondolin's Windows x64 QEMU path is working end-to-end in
+an end-user workflow.
diff --git a/host/README.md b/host/README.md
index 6eda9aad..87aef6ec 100644
--- a/host/README.md
+++ b/host/README.md
@@ -2,22 +2,24 @@
**Local Linux micro-VMs with a fully programmable network stack and filesystem.**
-Gondolin runs lightweight micro-VMs on your Mac or Linux machine (QEMU by
-default, optional `krun` backend). The network stack and virtual filesystem are
-implemented in TypeScript, giving you complete programmatic control over what
-the sandbox can access and what secrets it can use.
+Gondolin runs lightweight micro-VMs on your Mac, Linux, or Windows machine
+(QEMU by default, optional `krun` backend on macOS/Linux). The network stack
+and virtual filesystem are implemented in TypeScript, giving you complete
+programmatic control over what the sandbox can access and what secrets it can
+use.
## Requirements
You need QEMU installed to run the micro-VMs (default backend):
-| macOS | Linux (Debian/Ubuntu) |
-| ------------------- | ---------------------------------- |
-| `brew install qemu` | `sudo apt install qemu-system-arm` |
+| macOS | Linux (Debian/Ubuntu) | Windows x64 |
+| ------------------- | ---------------------------------- | ----------- |
+| `brew install qemu` | `sudo apt install qemu-system-arm` | Install a QEMU build with WHPX support and put `qemu-system-x86_64` or `qemu-system-x86_64w` on `PATH` |
Optional experimental backend:
- `libkrun` + `host/krun-runner` (`sandbox.vmm = "krun"`)
+- `krun` is currently unsupported on Windows; use the default `qemu` backend there
- `make krun-runner` from repo root stages dependencies locally and builds the runner
- on macOS, it also ad-hoc signs the runner with `com.apple.security.hypervisor`
- Gondolin auto-detects this local runner for `--vmm krun`
@@ -26,6 +28,7 @@ Optional experimental backend:
- `gondolin bash --vmm krun` selects the backend per-command
- `GONDOLIN_VMM=krun` still works as a global default
- backend parity matrix: [docs/backends.md](../docs/backends.md)
+- Windows walkthrough: [docs/windows-showcase.md](../docs/windows-showcase.md)
Linux prerequisites for `make krun-runner` (Ubuntu/Debian):
diff --git a/host/bin/gondolin.ts b/host/bin/gondolin.ts
index 9b3d6ae2..70c30893 100644
--- a/host/bin/gondolin.ts
+++ b/host/bin/gondolin.ts
@@ -8,6 +8,12 @@ import readline from "node:readline/promises";
import { PassThrough } from "stream";
import { fileURLToPath } from "url";
+import {
+ normalizeCliHostPath,
+ parseMountSpec,
+ type MountSpec,
+} from "../src/cli/mount-spec.ts";
+
import { VmCheckpoint } from "../src/checkpoint.ts";
import { gondolinCacheDir } from "../src/cache.ts";
import { parseDiskSizeToBytes } from "../src/qemu/img.ts";
@@ -96,7 +102,7 @@ function sanitizeCheckpointName(name: string): string {
function resolveSnapshotPath(args: { output?: string; name?: string }): string {
if (args.output) {
- return path.resolve(args.output);
+ return path.resolve(normalizeCliHostPath(args.output));
}
const checkpointDir = checkpointBaseDir();
@@ -136,18 +142,23 @@ async function waitForCheckpointReady(
}
function resolveResumeCheckpoint(resume: string): string {
- const value = resume.trim();
- if (!value) {
+ const rawValue = resume.trim();
+ if (!rawValue) {
throw new Error("--resume requires a non-empty checkpoint id or path");
}
+ const value = normalizeCliHostPath(rawValue);
const resolvedPath = path.resolve(value);
if (fs.existsSync(resolvedPath)) {
return resolvedPath;
}
- if (value.includes(path.sep) || value.includes("/") || value.includes("\\")) {
- throw new Error(`checkpoint not found: ${value}`);
+ if (
+ value.includes(path.sep) ||
+ value.includes("/") ||
+ value.includes("\\")
+ ) {
+ throw new Error(`checkpoint not found: ${rawValue}`);
}
const dir = checkpointBaseDir();
@@ -188,7 +199,23 @@ function renderCliError(err: unknown) {
if (binary.includes("qemu")) {
console.error(`Error: QEMU binary '${binary}' not found.`);
console.error("Please install QEMU to run the sandbox.");
- if (process.platform === "darwin") {
+ if (process.platform === "win32") {
+ console.error(
+ " Install a Windows QEMU build with WHPX support, for example:",
+ );
+ console.error(
+ " https://qemu.weilnetz.de/w64/2026/qemu-w64-setup-20260415.exe",
+ );
+ console.error(
+ " Then ensure qemu-system-x86_64.exe or qemu-system-x86_64w.exe is on PATH, or set sandbox.qemuPath.",
+ );
+ console.error(
+ " WHPX docs: https://www.qemu.org/docs/master/system/whpx.html",
+ );
+ console.error(
+ " Enable Hypervisor Platform: DISM /online /Enable-Feature /FeatureName:HypervisorPlatform /All",
+ );
+ } else if (process.platform === "darwin") {
console.error(" brew install qemu");
} else {
console.error(
@@ -211,6 +238,29 @@ function renderCliError(err: unknown) {
console.error(message);
}
+function renderCliBackendWarnings(vm: VM) {
+ if (process.platform !== "win32") return;
+
+ const backend = vm.getBackendInfo();
+ const accelName = backend.accel?.split(",", 1)[0]?.trim().toLowerCase();
+ if (backend.vmm !== "qemu" || accelName !== "tcg") {
+ return;
+ }
+
+ console.error(
+ `[gondolin] warning: using QEMU TCG software emulation on Windows (${backend.qemuPath}).`,
+ );
+ console.error(
+ "[gondolin] WHPX acceleration was not selected; startup and runtime performance will be much slower.",
+ );
+ console.error(
+ "[gondolin] WHPX docs: https://www.qemu.org/docs/master/system/whpx.html",
+ );
+ console.error(
+ "[gondolin] Enable Hypervisor Platform: DISM /online /Enable-Feature /FeatureName:HypervisorPlatform /All",
+ );
+}
+
function usage() {
console.log("Usage: gondolin [options]");
console.log("Commands:");
@@ -464,12 +514,6 @@ function execUsage() {
);
}
-type MountSpec = {
- hostPath: string;
- guestPath: string;
- readonly: boolean;
-};
-
type SecretSpec = {
name: string;
value: string;
@@ -537,47 +581,7 @@ type CommonOptions = {
};
function parseMount(spec: string): MountSpec {
- const parts = spec.split(":");
- if (parts.length < 2) {
- throw new Error(`Invalid mount format: ${spec} (expected HOST:GUEST[:ro])`);
- }
-
- // Handle Windows paths like C:\path by checking if the second part looks like a path
- let hostPath: string;
- let rest: string[];
-
- // Check if this looks like a Windows drive letter (single letter followed by nothing before the colon)
- if (
- parts[0].length === 1 &&
- /^[a-zA-Z]$/.test(parts[0]) &&
- parts.length >= 3
- ) {
- hostPath = `${parts[0]}:${parts[1]}`;
- rest = parts.slice(2);
- } else {
- hostPath = parts[0];
- rest = parts.slice(1);
- }
-
- if (rest.length === 0) {
- throw new Error(`Invalid mount format: ${spec} (missing guest path)`);
- }
-
- // Similar check for guest path (though unlikely to be Windows in a VM)
- let guestPath: string;
- let options: string[];
-
- if (rest[0].length === 1 && /^[a-zA-Z]$/.test(rest[0]) && rest.length >= 2) {
- guestPath = `${rest[0]}:${rest[1]}`;
- options = rest.slice(2);
- } else {
- guestPath = rest[0];
- options = rest.slice(1);
- }
-
- const readonly = options.includes("ro");
-
- return { hostPath, guestPath, readonly };
+ return parseMountSpec(spec);
}
function parseHostSecret(spec: string): SecretSpec {
@@ -1039,7 +1043,9 @@ function buildVmOptions(common: CommonOptions) {
common.sshCredentials.length > 0
? Object.fromEntries(
common.sshCredentials.map((credential) => {
- const resolvedPath = path.resolve(credential.keyPath);
+ const resolvedPath = path.resolve(
+ normalizeCliHostPath(credential.keyPath),
+ );
if (!fs.existsSync(resolvedPath)) {
throw new Error(
`SSH key file does not exist: ${credential.keyPath}`,
@@ -1080,7 +1086,9 @@ function buildVmOptions(common: CommonOptions) {
agent: common.sshAgent,
knownHostsFile:
common.sshKnownHostsFiles.length > 0
- ? common.sshKnownHostsFiles
+ ? common.sshKnownHostsFiles.map((file) =>
+ path.resolve(normalizeCliHostPath(file)),
+ )
: undefined,
}
: undefined,
@@ -1096,7 +1104,9 @@ function buildVmOptions(common: CommonOptions) {
if (common.image || common.vmm) {
vmOptions.sandbox = {
...(vmOptions.sandbox ?? {}),
- ...(common.image ? { imagePath: common.image } : {}),
+ ...(common.image
+ ? { imagePath: normalizeCliHostPath(common.image) }
+ : {}),
...(common.vmm ? { vmm: common.vmm } : {}),
};
}
@@ -1409,6 +1419,7 @@ async function runExecVm(args: ExecArgs) {
vm = await VM.create({
...vmOptions,
});
+ renderCliBackendWarnings(vm);
for (const command of args.commands) {
const result = await vm.exec([command.cmd, ...command.argv], {
@@ -1880,6 +1891,7 @@ async function runBash(argv: string[]) {
...vmOptions,
});
}
+ renderCliBackendWarnings(vm);
if (args.ssh) {
const access = await vm.enableSsh({
@@ -2200,7 +2212,7 @@ async function runAttach(argv: string[]) {
},
);
- const client = connectToSession(session.socketPath, {
+ const client = connectToSession(session.endpoint, {
onJson(message: ServerMessage) {
if (message.type === "status") {
return;
@@ -2434,7 +2446,7 @@ async function runSnapshot(argv: string[]) {
},
);
- const client = connectToSession(session.socketPath, {
+ const client = connectToSession(session.endpoint, {
onJson(message: ServerMessage) {
if (message.type === "status") {
return;
@@ -2660,7 +2672,7 @@ async function runBuild(argv: string[]) {
// Handle --verify
if (args.verify) {
- const assetDir = path.resolve(args.verify);
+ const assetDir = path.resolve(normalizeCliHostPath(args.verify));
const manifest = loadAssetManifest(assetDir);
if (!manifest) {
@@ -2686,7 +2698,7 @@ async function runBuild(argv: string[]) {
let config: BuildConfig;
let configDir: string | undefined;
if (args.configFile) {
- const configPath = path.resolve(args.configFile);
+ const configPath = path.resolve(normalizeCliHostPath(args.configFile));
configDir = path.dirname(configPath);
if (!fs.existsSync(configPath)) {
console.error(`Config file not found: ${configPath}`);
@@ -2711,7 +2723,9 @@ async function runBuild(argv: string[]) {
const cleanupOutputDir = args.outputDir === undefined;
const outputDir = path.resolve(
- args.outputDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-build-")),
+ args.outputDir
+ ? normalizeCliHostPath(args.outputDir)
+ : fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-build-")),
);
// Run the build
@@ -2961,7 +2975,9 @@ async function runImage(argv: string[]) {
throw new Error("image import requires ");
}
- const imported = importImageFromDirectory(assetDir);
+ const imported = importImageFromDirectory(
+ normalizeCliHostPath(assetDir),
+ );
console.log(`Imported buildId: ${imported.buildId}`);
console.log(` arch: ${imported.arch}`);
console.log(` object: ${imported.assetDir}`);
@@ -3012,7 +3028,7 @@ async function runImage(argv: string[]) {
throw new Error("image tag requires and ");
}
- const updated = tagImage(source, target, arch);
+ const updated = tagImage(normalizeCliHostPath(source), target, arch);
console.log(`Updated ${updated.reference}`);
if (updated.targets.aarch64) {
console.log(` aarch64: ${updated.targets.aarch64}`);
@@ -3054,7 +3070,10 @@ async function runImage(argv: string[]) {
throw new Error("image inspect requires ");
}
- const resolved = await ensureImageSelector(selector, arch);
+ const resolved = await ensureImageSelector(
+ normalizeCliHostPath(selector),
+ arch,
+ );
const manifest = loadAssetManifest(resolved.assetDir);
console.log(`selector: ${resolved.selector}`);
@@ -3105,7 +3124,10 @@ async function runImage(argv: string[]) {
throw new Error("image pull requires ");
}
- const resolved = await ensureImageSelector(selector, arch);
+ const resolved = await ensureImageSelector(
+ normalizeCliHostPath(selector),
+ arch,
+ );
console.log(`Pulled ${resolved.selector}`);
console.log(` assetDir: ${resolved.assetDir}`);
if (resolved.buildId) {
diff --git a/host/package.json b/host/package.json
index 73a6b910..92ab8421 100644
--- a/host/package.json
+++ b/host/package.json
@@ -20,7 +20,7 @@
"dist/"
],
"scripts": {
- "build": "rm -rf dist && tsc -p tsconfig.build.json && node ./scripts/postbuild.mjs",
+ "build": "node ./scripts/clean-dist.mjs && tsc -p tsconfig.build.json && node ./scripts/postbuild.mjs",
"start": "node dist/bin/gondolin.js exec",
"dev": "node bin/gondolin.ts exec",
"test": "node --test test/*.test.ts",
diff --git a/host/scripts/clean-dist.mjs b/host/scripts/clean-dist.mjs
new file mode 100644
index 00000000..9cee52a9
--- /dev/null
+++ b/host/scripts/clean-dist.mjs
@@ -0,0 +1,7 @@
+import fs from "node:fs";
+import path from "node:path";
+
+const pkgRoot = path.resolve(import.meta.dirname, "..");
+const distDir = path.join(pkgRoot, "dist");
+
+fs.rmSync(distDir, { recursive: true, force: true });
diff --git a/host/src/alpine/tar.ts b/host/src/alpine/tar.ts
index 5fdec61d..c40794ca 100644
--- a/host/src/alpine/tar.ts
+++ b/host/src/alpine/tar.ts
@@ -1,11 +1,11 @@
import fs from "fs";
import path from "path";
-import { createGunzip } from "zlib";
import { Writable } from "stream";
import { pipeline } from "stream/promises";
+import { createGunzip } from "zlib";
-import type { TarEntry } from "./types.ts";
import { hasSymlinkComponent } from "./rootfs.ts";
+import type { TarEntry } from "./types.ts";
const TAR_TYPE_FILE = 0x30;
const TAR_TYPE_DIRECTORY = 0x35;
@@ -16,112 +16,239 @@ const TAR_TYPE_PAX_GLOBAL = 0x67;
const TAR_TYPE_GNU_LONGNAME = 0x4c;
const TAR_TYPE_GNU_LONGLINK = 0x4b;
+type TarHeader = {
+ /** resolved tar entry name from header/prefix */
+ name: string;
+ /** raw tar mode bits */
+ mode: number;
+ /** entry payload size in `bytes` */
+ size: number;
+ /** tar type flag byte */
+ typeFlag: number;
+ /** raw tar link target from header */
+ linkName: string;
+};
+
+type TarMetadataState = {
+ /** global PAX headers that apply to subsequent entries */
+ globalPaxHeaders: Record;
+ /** one-shot PAX headers for the next non-meta entry */
+ nextPaxHeaders: Record | null;
+ /** one-shot GNU long path for the next non-meta entry */
+ nextLongName: string | null;
+ /** one-shot GNU long link target for the next non-meta entry */
+ nextLongLink: string | null;
+};
+
+type TarMetaEntryKind =
+ | "pax-local"
+ | "pax-global"
+ | "gnu-longname"
+ | "gnu-longlink";
+
+type StreamingTarEntry =
+ | {
+ /** buffered metadata record kind */
+ kind: "meta";
+ /** metadata interpretation for the buffered payload */
+ metaType: TarMetaEntryKind;
+ /** content bytes left to read */
+ remaining: number;
+ /** tar padding bytes left to skip */
+ padding: number;
+ /** buffered metadata chunks */
+ chunks: Buffer[];
+ }
+ | {
+ /** regular file being streamed to disk */
+ kind: "file";
+ /** content bytes left to read */
+ remaining: number;
+ /** tar padding bytes left to skip */
+ padding: number;
+ /** open file descriptor for the destination file */
+ fd: number | null;
+ /** destination path for post-write chmod */
+ targetPath: string | null;
+ /** file mode bits from the tar header */
+ mode: number;
+ }
+ | {
+ /** skipped entry payload */
+ kind: "skip";
+ /** content bytes left to skip */
+ remaining: number;
+ /** tar padding bytes left to skip */
+ padding: number;
+ };
+
/** Parse a raw tar archive buffer into entries */
export function parseTar(buf: Buffer): TarEntry[] {
const entries: TarEntry[] = [];
let offset = 0;
- let globalPaxHeaders: Record = {};
- let nextPaxHeaders: Record | null = null;
- let nextLongName: string | null = null;
- let nextLongLink: string | null = null;
+ const metadata = createTarMetadataState();
while (offset + 512 <= buf.length) {
- const header = buf.subarray(offset, offset + 512);
-
- // Check for end-of-archive (two zero blocks)
- if (header.every((b) => b === 0)) {
+ const header = parseTarHeader(buf.subarray(offset, offset + 512));
+ if (!header) {
break;
}
-
- const name = readTarString(header, 0, 100);
- const mode = parseInt(readTarString(header, 100, 8), 8) || 0;
- const size = parseInt(readTarString(header, 124, 12), 8) || 0;
- const typeFlag = header[156];
- const headerLinkName = readTarString(header, 157, 100);
-
- // Handle UStar prefix
- const magic = readTarString(header, 257, 6);
- let fullName = name;
- if (magic === "ustar" || magic === "ustar\0") {
- const prefix = readTarString(header, 345, 155);
- if (prefix) {
- fullName = `${prefix}/${name}`;
- }
- }
-
offset += 512;
let content: Buffer | null = null;
- if (size > 0) {
- content = Buffer.from(buf.subarray(offset, offset + size));
- offset += Math.ceil(size / 512) * 512;
- }
-
- // PAX extended headers (type 'x' or 'g')
- if (typeFlag === TAR_TYPE_PAX_LOCAL || typeFlag === TAR_TYPE_PAX_GLOBAL) {
- const paxHeaders = parsePaxHeaders(content);
- if (typeFlag === TAR_TYPE_PAX_GLOBAL) {
- globalPaxHeaders = {
- ...globalPaxHeaders,
- ...paxHeaders,
- };
- } else {
- nextPaxHeaders = paxHeaders;
- }
- continue;
+ if (header.size > 0) {
+ content = Buffer.from(buf.subarray(offset, offset + header.size));
+ offset += header.size;
}
- // GNU long path/link name records apply to the next non-meta entry
- if (typeFlag === TAR_TYPE_GNU_LONGNAME) {
- nextLongName = readLongTarString(content);
+ const padding = tarPaddingBytes(header.size);
+ offset += padding;
+
+ const metaType = tarMetaEntryKind(header.typeFlag);
+ if (metaType) {
+ applyTarMetaEntry(metaType, content, metadata);
continue;
}
- if (typeFlag === TAR_TYPE_GNU_LONGLINK) {
- nextLongLink = readLongTarString(content);
- continue;
+
+ const entry = materializeTarEntry(header, metadata);
+ if (entry.type === 0 && content === null) {
+ entry.content = Buffer.alloc(0);
+ } else {
+ entry.content = content;
}
+ entries.push(entry);
+ clearPendingTarEntryMetadata(metadata);
+ }
- const effectivePaxHeaders = nextPaxHeaders
- ? { ...globalPaxHeaders, ...nextPaxHeaders }
- : globalPaxHeaders;
+ return entries;
+}
- if (nextLongName) {
- fullName = nextLongName;
- } else if (effectivePaxHeaders.path) {
- fullName = effectivePaxHeaders.path;
- }
+function createTarMetadataState(): TarMetadataState {
+ return {
+ globalPaxHeaders: {},
+ nextPaxHeaders: null,
+ nextLongName: null,
+ nextLongLink: null,
+ };
+}
- let linkName = headerLinkName;
- if (nextLongLink) {
- linkName = nextLongLink;
- } else if (effectivePaxHeaders.linkpath) {
- linkName = effectivePaxHeaders.linkpath;
- }
+function tarPaddingBytes(size: number): number {
+ return (512 - (size % 512)) % 512;
+}
- const type =
- typeFlag === 0 || typeFlag === TAR_TYPE_FILE
- ? 0 // regular file
- : typeFlag === TAR_TYPE_DIRECTORY
- ? 5 // directory
- : typeFlag === TAR_TYPE_SYMLINK
- ? 2 // symlink
- : typeFlag === TAR_TYPE_HARDLINK
- ? 1 // hardlink
- : typeFlag;
+function parseTarHeader(header: Buffer): TarHeader | null {
+ if (header.length !== 512) {
+ throw new Error(`invalid tar header length: ${header.length}`);
+ }
+
+ if (header.every((byte) => byte === 0)) {
+ return null;
+ }
- if (type === 0 && content === null) {
- content = Buffer.alloc(0);
+ const name = readTarString(header, 0, 100);
+ const mode = parseInt(readTarString(header, 100, 8), 8) || 0;
+ const size = parseInt(readTarString(header, 124, 12), 8) || 0;
+ const typeFlag = header[156];
+ const headerLinkName = readTarString(header, 157, 100);
+
+ const magic = readTarString(header, 257, 6);
+ let fullName = name;
+ if (magic === "ustar" || magic === "ustar\0") {
+ const prefix = readTarString(header, 345, 155);
+ if (prefix) {
+ fullName = `${prefix}/${name}`;
}
+ }
+
+ return {
+ name: fullName,
+ mode,
+ size,
+ typeFlag,
+ linkName: headerLinkName,
+ };
+}
+
+function materializeTarEntry(
+ header: TarHeader,
+ metadata: TarMetadataState,
+): TarEntry {
+ const effectivePaxHeaders = metadata.nextPaxHeaders
+ ? { ...metadata.globalPaxHeaders, ...metadata.nextPaxHeaders }
+ : metadata.globalPaxHeaders;
+
+ const name = metadata.nextLongName ?? effectivePaxHeaders.path ?? header.name;
+ const linkName =
+ metadata.nextLongLink ?? effectivePaxHeaders.linkpath ?? header.linkName;
+
+ const type =
+ header.typeFlag === 0 || header.typeFlag === TAR_TYPE_FILE
+ ? 0
+ : header.typeFlag === TAR_TYPE_DIRECTORY
+ ? 5
+ : header.typeFlag === TAR_TYPE_SYMLINK
+ ? 2
+ : header.typeFlag === TAR_TYPE_HARDLINK
+ ? 1
+ : header.typeFlag;
+
+ return {
+ name,
+ type,
+ mode: header.mode,
+ size: header.size,
+ linkName,
+ content: null,
+ };
+}
- entries.push({ name: fullName, type, mode, size, linkName, content });
+function clearPendingTarEntryMetadata(metadata: TarMetadataState): void {
+ metadata.nextPaxHeaders = null;
+ metadata.nextLongName = null;
+ metadata.nextLongLink = null;
+}
- nextPaxHeaders = null;
- nextLongName = null;
- nextLongLink = null;
+function tarMetaEntryKind(typeFlag: number): TarMetaEntryKind | null {
+ if (typeFlag === TAR_TYPE_PAX_LOCAL) {
+ return "pax-local";
+ }
+ if (typeFlag === TAR_TYPE_PAX_GLOBAL) {
+ return "pax-global";
+ }
+ if (typeFlag === TAR_TYPE_GNU_LONGNAME) {
+ return "gnu-longname";
}
+ if (typeFlag === TAR_TYPE_GNU_LONGLINK) {
+ return "gnu-longlink";
+ }
+ return null;
+}
- return entries;
+function applyTarMetaEntry(
+ metaType: TarMetaEntryKind,
+ content: Buffer | null,
+ metadata: TarMetadataState,
+): void {
+ if (metaType === "pax-global") {
+ metadata.globalPaxHeaders = {
+ ...metadata.globalPaxHeaders,
+ ...parsePaxHeaders(content),
+ };
+ return;
+ }
+
+ if (metaType === "pax-local") {
+ metadata.nextPaxHeaders = parsePaxHeaders(content);
+ return;
+ }
+
+ if (metaType === "gnu-longname") {
+ metadata.nextLongName = readLongTarString(content);
+ return;
+ }
+
+ metadata.nextLongLink = readLongTarString(content);
}
function parsePaxHeaders(content: Buffer | null): Record {
@@ -205,9 +332,10 @@ export async function extractTarGz(
tarGzPath: string,
destDir: string,
): Promise {
- const raw = await decompressTarGz(tarGzPath);
- const entries = parseTar(raw);
- extractEntries(entries, destDir);
+ const input = fs.createReadStream(tarGzPath);
+ const gunzip = createGunzip();
+ const extractor = new StreamingTarExtractor(destDir);
+ await pipeline(input, gunzip, extractor);
}
/** Extract tar entries into a directory with symlink-safety checks */
@@ -215,65 +343,403 @@ export function extractEntries(entries: TarEntry[], destDir: string): void {
const absRoot = path.resolve(destDir);
for (const entry of entries) {
- // Skip APK metadata files
- if (entry.name.startsWith(".") && !entry.name.startsWith("./")) {
- continue;
+ extractPreparedTarEntry(entry, absRoot, destDir);
+ }
+}
+
+function extractPreparedTarEntry(
+ entry: TarEntry,
+ absRoot: string,
+ destDir: string,
+): void {
+ const target = resolveSafeTarTarget(absRoot, destDir, entry.name);
+ if (!target) {
+ return;
+ }
+
+ if (entry.type === 5) {
+ prepareTarget(target, true);
+ fs.mkdirSync(target, { recursive: true });
+ return;
+ }
+
+ if (entry.type === 2) {
+ prepareTarget(target, false);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ try {
+ fs.symlinkSync(entry.linkName, target);
+ } catch (err: any) {
+ if (err.code !== "EEXIST") throw err;
}
+ return;
+ }
- const target = path.resolve(destDir, entry.name);
+ if (entry.type === 1) {
+ const linkTarget = resolveSafeTarLinkTarget(absRoot, destDir, entry.linkName);
+ if (!linkTarget || !fs.existsSync(linkTarget)) {
+ return;
+ }
- // Guard: target must be inside destDir
- if (!target.startsWith(absRoot + path.sep) && target !== absRoot) {
- continue;
+ prepareTarget(target, false);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ try {
+ fs.linkSync(linkTarget, target);
+ } catch {
+ if (fs.existsSync(linkTarget)) {
+ fs.copyFileSync(linkTarget, target);
+ }
}
+ return;
+ }
- // Guard: no symlink in any intermediate path component
- if (hasSymlinkComponent(target, absRoot)) {
- process.stderr.write(`skipping symlinked path ${entry.name}\n`);
- continue;
+ if (entry.type === 0 && entry.content) {
+ writeTarFile(target, entry.content, entry.mode);
+ }
+}
+
+function resolveSafeTarTarget(
+ absRoot: string,
+ destDir: string,
+ entryName: string,
+): string | null {
+ if (entryName.startsWith(".") && !entryName.startsWith("./")) {
+ return null;
+ }
+
+ const target = path.resolve(destDir, entryName);
+ if (!isPathInsideRoot(target, absRoot)) {
+ return null;
+ }
+
+ if (hasSymlinkComponent(target, absRoot)) {
+ process.stderr.write(`skipping symlinked path ${entryName}\n`);
+ return null;
+ }
+
+ return target;
+}
+
+function resolveSafeTarLinkTarget(
+ absRoot: string,
+ destDir: string,
+ linkName: string,
+): string | null {
+ const target = path.resolve(destDir, linkName);
+ return isPathInsideRoot(target, absRoot) ? target : null;
+}
+
+function isPathInsideRoot(target: string, root: string): boolean {
+ return target === root || target.startsWith(root + path.sep);
+}
+
+function writeTarFile(target: string, content: Buffer, mode: number): void {
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ prepareTarget(target, false);
+ fs.writeFileSync(target, content);
+ applyTarMode(target, mode);
+}
+
+function openTarFile(target: string): number {
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ prepareTarget(target, false);
+ return fs.openSync(target, "w");
+}
+
+function applyTarMode(target: string, mode: number): void {
+ try {
+ fs.chmodSync(target, mode & 0o7777);
+ } catch {
+ // chmod may fail on some platforms; ignore
+ }
+}
+
+class StreamingTarExtractor extends Writable {
+ private buffer = Buffer.alloc(0);
+ private current: StreamingTarEntry | null = null;
+ private readonly absRoot: string;
+ private readonly destDir: string;
+ private readonly metadata = createTarMetadataState();
+ private ended = false;
+
+ constructor(destDir: string) {
+ super();
+ this.destDir = destDir;
+ this.absRoot = path.resolve(destDir);
+ }
+
+ override _write(
+ chunk: Buffer,
+ _encoding: BufferEncoding,
+ callback: (error?: Error | null) => void,
+ ): void {
+ try {
+ if (!this.ended) {
+ this.buffer =
+ this.buffer.length === 0
+ ? Buffer.from(chunk)
+ : Buffer.concat([this.buffer, chunk]);
+ this.processBuffer();
+ }
+ callback();
+ } catch (error) {
+ callback(error as Error);
+ }
+ }
+
+ override _final(callback: (error?: Error | null) => void): void {
+ try {
+ this.processBuffer();
+ if (!this.ended || this.current) {
+ throw new Error("truncated tar archive");
+ }
+ callback();
+ } catch (error) {
+ callback(error as Error);
+ }
+ }
+
+ override _destroy(
+ error: Error | null,
+ callback: (error?: Error | null) => void,
+ ): void {
+ this.closeCurrentFile();
+ callback(error);
+ }
+
+ private processBuffer(): void {
+ while (true) {
+ if (this.current) {
+ if (!this.processCurrentEntry()) {
+ return;
+ }
+ continue;
+ }
+
+ if (this.ended) {
+ this.buffer = Buffer.alloc(0);
+ return;
+ }
+
+ if (this.buffer.length < 512) {
+ return;
+ }
+
+ const header = parseTarHeader(this.consume(512));
+ if (!header) {
+ this.ended = true;
+ this.buffer = Buffer.alloc(0);
+ return;
+ }
+
+ this.startEntry(header);
+ }
+ }
+
+ private processCurrentEntry(): boolean {
+ const current = this.current;
+ if (!current) {
+ return true;
+ }
+
+ if (current.remaining > 0) {
+ if (this.buffer.length === 0) {
+ return false;
+ }
+
+ const take = Math.min(this.buffer.length, current.remaining);
+ const chunk = this.consume(take);
+ current.remaining -= take;
+
+ if (current.kind === "meta") {
+ current.chunks.push(Buffer.from(chunk));
+ } else if (current.kind === "file" && current.fd !== null) {
+ fs.writeSync(current.fd, chunk);
+ }
+ }
+
+ if (current.remaining > 0) {
+ return false;
+ }
+
+ if (current.padding > 0) {
+ if (this.buffer.length === 0) {
+ return false;
+ }
+ const skip = Math.min(this.buffer.length, current.padding);
+ this.consume(skip);
+ current.padding -= skip;
+ if (current.padding > 0) {
+ return false;
+ }
}
- // Prepare for extraction — remove existing entry if needed
- prepareTarget(target, entry.type === 5);
+ this.finishCurrentEntry(current);
+ this.current = null;
+ return true;
+ }
+
+ private startEntry(header: TarHeader): void {
+ const padding = tarPaddingBytes(header.size);
+ const metaType = tarMetaEntryKind(header.typeFlag);
+ if (metaType) {
+ if (header.size === 0) {
+ applyTarMetaEntry(metaType, null, this.metadata);
+ return;
+ }
+
+ this.current = {
+ kind: "meta",
+ metaType,
+ remaining: header.size,
+ padding,
+ chunks: [],
+ };
+ return;
+ }
+
+ const entry = materializeTarEntry(header, this.metadata);
+ clearPendingTarEntryMetadata(this.metadata);
if (entry.type === 5) {
- // Directory
- fs.mkdirSync(target, { recursive: true });
- } else if (entry.type === 2) {
- // Symlink
- fs.mkdirSync(path.dirname(target), { recursive: true });
- try {
- fs.symlinkSync(entry.linkName, target);
- } catch (err: any) {
- if (err.code !== "EEXIST") throw err;
+ const target = resolveSafeTarTarget(this.absRoot, this.destDir, entry.name);
+ if (target) {
+ prepareTarget(target, true);
+ fs.mkdirSync(target, { recursive: true });
+ }
+ if (header.size > 0 || padding > 0) {
+ this.current = {
+ kind: "skip",
+ remaining: header.size,
+ padding,
+ };
+ }
+ return;
+ }
+
+ if (entry.type === 2) {
+ const target = resolveSafeTarTarget(this.absRoot, this.destDir, entry.name);
+ if (target) {
+ prepareTarget(target, false);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ try {
+ fs.symlinkSync(entry.linkName, target);
+ } catch (err: any) {
+ if (err.code !== "EEXIST") {
+ throw err;
+ }
+ }
}
- } else if (entry.type === 1) {
- // Hardlink
- const linkTarget = path.resolve(destDir, entry.linkName);
- if (
- linkTarget.startsWith(absRoot + path.sep) &&
- fs.existsSync(linkTarget)
- ) {
+ if (header.size > 0 || padding > 0) {
+ this.current = {
+ kind: "skip",
+ remaining: header.size,
+ padding,
+ };
+ }
+ return;
+ }
+
+ if (entry.type === 1) {
+ const target = resolveSafeTarTarget(this.absRoot, this.destDir, entry.name);
+ const linkTarget = resolveSafeTarLinkTarget(
+ this.absRoot,
+ this.destDir,
+ entry.linkName,
+ );
+ if (target && linkTarget && fs.existsSync(linkTarget)) {
+ prepareTarget(target, false);
fs.mkdirSync(path.dirname(target), { recursive: true });
try {
fs.linkSync(linkTarget, target);
} catch {
- // Fall back to copy
if (fs.existsSync(linkTarget)) {
fs.copyFileSync(linkTarget, target);
}
}
}
- } else if (entry.type === 0 && entry.content) {
- // Regular file
- fs.mkdirSync(path.dirname(target), { recursive: true });
- fs.writeFileSync(target, entry.content);
- try {
- fs.chmodSync(target, entry.mode & 0o7777);
- } catch {
- // chmod may fail on some platforms; ignore
+ if (header.size > 0 || padding > 0) {
+ this.current = {
+ kind: "skip",
+ remaining: header.size,
+ padding,
+ };
}
+ return;
}
+
+ if (entry.type !== 0) {
+ this.current = {
+ kind: "skip",
+ remaining: header.size,
+ padding,
+ };
+ return;
+ }
+
+ const target = resolveSafeTarTarget(this.absRoot, this.destDir, entry.name);
+ if (header.size === 0) {
+ if (target) {
+ writeTarFile(target, Buffer.alloc(0), entry.mode);
+ }
+ if (padding > 0) {
+ this.current = {
+ kind: "skip",
+ remaining: 0,
+ padding,
+ };
+ }
+ return;
+ }
+
+ if (!target) {
+ this.current = {
+ kind: "skip",
+ remaining: header.size,
+ padding,
+ };
+ return;
+ }
+
+ this.current = {
+ kind: "file",
+ remaining: header.size,
+ padding,
+ fd: openTarFile(target),
+ targetPath: target,
+ mode: entry.mode,
+ };
+ }
+
+ private finishCurrentEntry(current: StreamingTarEntry): void {
+ if (current.kind === "meta") {
+ const content =
+ current.chunks.length === 0 ? null : Buffer.concat(current.chunks);
+ applyTarMetaEntry(current.metaType, content, this.metadata);
+ return;
+ }
+
+ if (current.kind === "file") {
+ this.closeCurrentFile(current);
+ }
+ }
+
+ private closeCurrentFile(current = this.current): void {
+ if (!current || current.kind !== "file" || current.fd === null) {
+ return;
+ }
+
+ const fd = current.fd;
+ current.fd = null;
+ fs.closeSync(fd);
+ if (current.targetPath) {
+ applyTarMode(current.targetPath, current.mode);
+ }
+ }
+
+ private consume(length: number): Buffer {
+ const chunk = this.buffer.subarray(0, length);
+ this.buffer = this.buffer.subarray(length);
+ return chunk;
}
}
@@ -283,10 +749,10 @@ function prepareTarget(target: string, isDir: boolean): void {
try {
stat = fs.lstatSync(target);
} catch {
- return; // Doesn't exist, nothing to do
+ return;
}
- if (isDir && stat.isDirectory()) return; // Already a directory, fine
+ if (isDir && stat.isDirectory()) return;
try {
if (stat.isDirectory()) {
@@ -295,7 +761,6 @@ function prepareTarget(target: string, isDir: boolean): void {
fs.unlinkSync(target);
}
} catch {
- // Try harder: fix permissions then remove
try {
fs.chmodSync(target, 0o700);
} catch {
diff --git a/host/src/build/shared.ts b/host/src/build/shared.ts
index 936d6e47..857911c8 100644
--- a/host/src/build/shared.ts
+++ b/host/src/build/shared.ts
@@ -103,6 +103,111 @@ export function detectContainerRuntime(
);
}
+type WindowsSpawnDeps = {
+ platform?: NodeJS.Platform;
+ existsSync?: (candidate: string) => boolean;
+};
+
+function getEnvValue(
+ env: NodeJS.ProcessEnv | undefined,
+ name: string,
+ platform: NodeJS.Platform,
+): string | undefined {
+ const source = env ?? process.env;
+ const direct = source[name];
+ if (direct !== undefined || platform !== "win32") return direct;
+ const lower = name.toLowerCase();
+ const key = Object.keys(source).find(
+ (entry) => entry.toLowerCase() === lower,
+ );
+ return key ? source[key] : undefined;
+}
+
+function quoteCmdArg(value: string): string {
+ if (value.includes('"')) {
+ // cmd.exe's batch-argument tokenizer has no reliable escape sequence for
+ // a literal double quote inside a quoted argument (verified empirically:
+ // both caret- and backslash-escaping corrupt the command line instead of
+ // producing a literal quote), so refuse rather than silently mis-invoking
+ // the command.
+ throw new Error(
+ `cannot safely pass an argument containing a double quote to a Windows .bat/.cmd command: ${JSON.stringify(value)}`,
+ );
+ }
+ // Quotes protect whitespace but not cmd.exe's own line-level metacharacters
+ // (&, |, <, >), which stay "live" even inside a quoted argument, so those
+ // must be caret-escaped individually.
+ return `"${value.replace(/(["^&|<>])/g, (char) => `^${char}`)}"`;
+}
+
+function resolveWindowsCommandPath(
+ command: string,
+ env: NodeJS.ProcessEnv | undefined,
+ deps: WindowsSpawnDeps = {},
+): string {
+ if (/[\\/]/.test(command)) return command;
+
+ const platform = deps.platform ?? process.platform;
+ const existsSync = deps.existsSync ?? fs.existsSync;
+ const pathEnv = getEnvValue(env, "PATH", platform) ?? "";
+ const pathExt = getEnvValue(env, "PATHEXT", platform) ?? ".COM;.EXE;.BAT;.CMD";
+ // Always resolve using Windows path semantics (`;`-delimited PATH,
+ // `\`-or-`/` separators), regardless of the host OS actually running this
+ // code - deps.platform lets callers (and tests) simulate Windows path
+ // resolution from a non-Windows host, which the native `path` module
+ // can't do since it's fixed to the real host platform's conventions.
+ const extensions = path.win32.extname(command)
+ ? [""]
+ : pathExt
+ .split(";")
+ .map((ext) => ext.trim())
+ .filter(Boolean);
+
+ for (const dir of pathEnv.split(";")) {
+ if (!dir) continue;
+ for (const ext of extensions) {
+ const candidate = path.win32.join(dir, `${command}${ext.toLowerCase()}`);
+ if (existsSync(candidate)) return candidate;
+ const upperCandidate = path.win32.join(
+ dir,
+ `${command}${ext.toUpperCase()}`,
+ );
+ if (existsSync(upperCandidate)) return upperCandidate;
+ }
+ }
+
+ return command;
+}
+
+function resolveSpawnCommand(
+ command: string,
+ args: string[],
+ options: SpawnOptions,
+ deps: WindowsSpawnDeps = {},
+): { command: string; args: string[]; windowsVerbatimArguments?: boolean } {
+ const platform = deps.platform ?? process.platform;
+ if (platform !== "win32") return { command, args };
+
+ const resolved = resolveWindowsCommandPath(command, options.env, deps);
+ if (!/\.(bat|cmd)$/i.test(resolved)) {
+ return { command: resolved, args };
+ }
+
+ // cmd.exe can execute a .bat/.cmd file directly (no `call` needed - `call`
+ // is only required to preserve control flow *within* an already-running
+ // batch script, and its own argument re-parsing pass does not respect
+ // caret-escaped metacharacters the way a plain command line does).
+ // Wrapping the whole command line in one extra pair of quotes and passing
+ // `/s` makes cmd.exe strip only that outer pair before parsing, which is
+ // what makes the per-argument caret-escaping below actually take effect.
+ const inner = [resolved, ...args].map(quoteCmdArg).join(" ");
+ return {
+ command: process.env.ComSpec ?? "cmd.exe",
+ args: ["/d", "/s", "/c", `"${inner}"`],
+ windowsVerbatimArguments: true,
+ };
+}
+
/** Run a command and stream output */
export async function runCommand(
command: string,
@@ -113,8 +218,11 @@ export async function runCommand(
return new Promise((resolve, reject) => {
log(`Running: ${command} ${args.join(" ")}`);
- const child = spawn(command, args, {
+ const resolved = resolveSpawnCommand(command, args, options);
+ const child = spawn(resolved.command, resolved.args, {
...options,
+ windowsVerbatimArguments:
+ resolved.windowsVerbatimArguments ?? options.windowsVerbatimArguments,
stdio: ["inherit", "pipe", "pipe"],
});
@@ -555,3 +663,9 @@ export function writeAssetManifest(
return { manifestPath, manifest };
}
+
+export const __test = {
+ quoteCmdArg,
+ resolveWindowsCommandPath,
+ resolveSpawnCommand,
+};
diff --git a/host/src/checkpoint.ts b/host/src/checkpoint.ts
index 07798063..a72bc553 100644
--- a/host/src/checkpoint.ts
+++ b/host/src/checkpoint.ts
@@ -266,9 +266,10 @@ function resolveAssetDirByBuildId(buildId: string): {
function ensureCheckpointBackedByRootfs(
checkpointDiskPath: string,
rootfsPath: string,
+ qemuPathHint?: string,
): void {
const checkpointAbs = path.resolve(checkpointDiskPath);
- const backingAbs = resolveQcow2BackingPath(checkpointDiskPath);
+ const backingAbs = resolveQcow2BackingPath(checkpointDiskPath, qemuPathHint);
if (!backingAbs) return;
if (backingAbs === checkpointAbs) {
@@ -285,6 +286,8 @@ function ensureCheckpointBackedByRootfs(
checkpointDiskPath,
desired,
inferDiskFormatFromPath(desired),
+ "unsafe",
+ qemuPathHint,
);
}
@@ -447,7 +450,8 @@ export class VmCheckpoint {
);
}
- ensureQemuImgAvailable();
+ const qemuImgPathHint = mergedForResume.sandbox?.qemuPath;
+ ensureQemuImgAvailable(qemuImgPathHint);
const checkpointDisk = this.diskPath;
if (!fs.existsSync(checkpointDisk)) {
@@ -460,9 +464,17 @@ export class VmCheckpoint {
);
// Fix qcow2 backing filename portability by rebasing in-place on resume.
- ensureCheckpointBackedByRootfs(checkpointDisk, resolved.assets.rootfsPath);
+ ensureCheckpointBackedByRootfs(
+ checkpointDisk,
+ resolved.assets.rootfsPath,
+ qemuImgPathHint,
+ );
- const overlayPath = createTempQcow2Overlay(checkpointDisk, "qcow2");
+ const overlayPath = createTempQcow2Overlay(
+ checkpointDisk,
+ "qcow2",
+ qemuImgPathHint,
+ );
const merged: VMOptions = {
...mergedForResume,
diff --git a/host/src/cli/mount-spec.ts b/host/src/cli/mount-spec.ts
new file mode 100644
index 00000000..7ff3addf
--- /dev/null
+++ b/host/src/cli/mount-spec.ts
@@ -0,0 +1,134 @@
+import { execFileSync } from "node:child_process";
+
+export type MountSpec = {
+ hostPath: string;
+ guestPath: string;
+ readonly: boolean;
+};
+
+export type CliHostPathDeps = {
+ platform?: NodeJS.Platform;
+ env?: NodeJS.ProcessEnv;
+ runCygpath?: (args: string[]) => string;
+};
+
+type ParseMountSpecDeps = CliHostPathDeps;
+
+function defaultRunCygpath(args: string[]): string {
+ return execFileSync("cygpath", args, {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ windowsHide: true,
+ }).trim();
+}
+
+function isGitBashEnvironment(
+ platform: NodeJS.Platform,
+ env: NodeJS.ProcessEnv,
+): boolean {
+ return platform === "win32" && typeof env.MSYSTEM === "string";
+}
+
+function splitMountSpec(spec: string): MountSpec {
+ const parts = spec.split(":");
+ if (parts.length < 2) {
+ throw new Error(`Invalid mount format: ${spec} (expected HOST:GUEST[:ro])`);
+ }
+
+ let hostPath: string;
+ let rest: string[];
+
+ if (
+ parts[0].length === 1 &&
+ /^[a-zA-Z]$/.test(parts[0]) &&
+ parts.length >= 3
+ ) {
+ hostPath = `${parts[0]}:${parts[1]}`;
+ rest = parts.slice(2);
+ } else {
+ hostPath = parts[0];
+ rest = parts.slice(1);
+ }
+
+ if (rest.length === 0) {
+ throw new Error(`Invalid mount format: ${spec} (missing guest path)`);
+ }
+
+ let guestPath: string;
+ let options: string[];
+
+ if (rest[0].length === 1 && /^[a-zA-Z]$/.test(rest[0]) && rest.length >= 2) {
+ guestPath = `${rest[0]}:${rest[1]}`;
+ options = rest.slice(2);
+ } else {
+ guestPath = rest[0];
+ options = rest.slice(1);
+ }
+
+ return {
+ hostPath,
+ guestPath,
+ readonly: options.includes("ro"),
+ };
+}
+
+function recoverGitBashPathListSpec(
+ spec: string,
+ deps: ParseMountSpecDeps,
+): string {
+ const platform = deps.platform ?? process.platform;
+ const env = deps.env ?? process.env;
+ if (!isGitBashEnvironment(platform, env) || !spec.includes(";")) {
+ return spec;
+ }
+
+ const runCygpath = deps.runCygpath ?? defaultRunCygpath;
+ try {
+ return runCygpath(["-u", "-p", spec]);
+ } catch {
+ return spec;
+ }
+}
+
+function convertMsysDrivePathToWindows(value: string): string | null {
+ const match = /^\/([a-zA-Z])(?:(\/.*)|$)/.exec(value);
+ if (!match) return null;
+ const drive = match[1]!.toUpperCase();
+ const suffix = match[2];
+ return `${drive}:${suffix && suffix.length > 0 ? suffix : "/"}`;
+}
+
+export function normalizeCliHostPath(
+ hostPath: string,
+ deps: CliHostPathDeps = {},
+): string {
+ const platform = deps.platform ?? process.platform;
+ const env = deps.env ?? process.env;
+ if (platform !== "win32") return hostPath;
+
+ const drivePath = convertMsysDrivePathToWindows(hostPath);
+ if (drivePath) {
+ return drivePath;
+ }
+
+ if (!isGitBashEnvironment(platform, env) || !hostPath.startsWith("/")) {
+ return hostPath;
+ }
+
+ const runCygpath = deps.runCygpath ?? defaultRunCygpath;
+ try {
+ return runCygpath(["-m", hostPath]);
+ } catch {
+ return hostPath;
+ }
+}
+
+export function parseMountSpec(
+ spec: string,
+ deps: ParseMountSpecDeps = {},
+): MountSpec {
+ const normalizedSpec = recoverGitBashPathListSpec(spec, deps);
+ const parsed = splitMountSpec(normalizedSpec);
+ parsed.hostPath = normalizeCliHostPath(parsed.hostPath, deps);
+ return parsed;
+}
diff --git a/host/src/images.ts b/host/src/images.ts
index f5ffd578..f3215385 100644
--- a/host/src/images.ts
+++ b/host/src/images.ts
@@ -1,9 +1,9 @@
-import child_process from "child_process";
import { randomUUID, createHash } from "crypto";
import fs from "fs";
import os from "os";
import path from "path";
+import { extractTarGz } from "./alpine/tar.ts";
import { loadAssetManifest, loadGuestAssets } from "./assets.ts";
import { gondolinCacheDir } from "./cache.ts";
import type { Architecture } from "./build/config.ts";
@@ -1295,9 +1295,7 @@ async function importImageFromSource(
await downloadArchive(source, archivePath, progressLabel);
fs.mkdirSync(extractDir, { recursive: true });
- child_process.execFileSync("tar", ["-xzf", archivePath, "-C", extractDir], {
- stdio: "pipe",
- });
+ await extractTarGz(archivePath, extractDir);
const imported = importImageFromDirectory(extractDir);
diff --git a/host/src/index.ts b/host/src/index.ts
index 48c9137e..d1abaa05 100644
--- a/host/src/index.ts
+++ b/host/src/index.ts
@@ -152,6 +152,11 @@ export {
type IngressHookResponsePatch,
} from "./ingress.ts";
+export {
+ type LocalEndpoint,
+ type LocalEndpointInput,
+} from "./local-endpoint.ts";
+
// Session registry
export {
registerSession,
@@ -160,6 +165,7 @@ export {
findSession,
gcSessions,
SessionIpcServer,
+ createSessionEndpoint,
connectToSession,
type SessionInfo,
type SessionEntry,
diff --git a/host/src/local-endpoint.ts b/host/src/local-endpoint.ts
new file mode 100644
index 00000000..9e2cb2eb
--- /dev/null
+++ b/host/src/local-endpoint.ts
@@ -0,0 +1,199 @@
+import crypto from "crypto";
+import fs from "fs";
+import net from "net";
+import os from "os";
+import path from "path";
+
+/** local IPC endpoint description */
+export type LocalEndpoint =
+ | {
+ /** local unix domain socket transport */
+ transport: "unix";
+ /** absolute unix socket path */
+ path: string;
+ }
+ | {
+ /** loopback tcp transport */
+ transport: "tcp";
+ /** loopback bind/connect host */
+ host: string;
+ /** tcp port or `0` for ephemeral listen */
+ port: number;
+ };
+
+/** caller-provided local IPC endpoint */
+export type LocalEndpointInput = string | LocalEndpoint;
+
+type DefaultLocalEndpointDeps = {
+ platform?: NodeJS.Platform;
+ tmpDir?: string;
+ randomId?: () => string;
+};
+
+type NormalizeLocalEndpointDeps = DefaultLocalEndpointDeps;
+
+function isLoopbackTcpHost(value: string): boolean {
+ const trimmed = value.trim();
+ const lower = trimmed.toLowerCase();
+ if (lower === "localhost" || trimmed === "::1") {
+ return true;
+ }
+
+ if (net.isIP(trimmed) !== 4) {
+ return false;
+ }
+
+ const firstOctet = Number.parseInt(trimmed.split(".", 1)[0]!, 10);
+ return firstOctet === 127;
+}
+
+export function createDefaultLocalEndpoint(
+ name: string,
+ deps: DefaultLocalEndpointDeps = {},
+): LocalEndpoint {
+ const platform = deps.platform ?? process.platform;
+ if (platform === "win32") {
+ return {
+ transport: "tcp",
+ host: "127.0.0.1",
+ port: 0,
+ };
+ }
+
+ const tmpDir =
+ deps.tmpDir ?? (platform === "darwin" ? "/tmp" : os.tmpdir());
+ const randomId = deps.randomId ?? (() => crypto.randomUUID().slice(0, 8));
+
+ return {
+ transport: "unix",
+ path: path.resolve(tmpDir, `${name}-${randomId()}.sock`),
+ };
+}
+
+export function normalizeLocalEndpoint(
+ value: LocalEndpointInput,
+ fieldName: string,
+ deps: NormalizeLocalEndpointDeps = {},
+): LocalEndpoint {
+ const platform = deps.platform ?? process.platform;
+
+ if (typeof value === "string") {
+ if (platform === "win32") {
+ throw new Error(
+ `${fieldName} must use an explicit { transport: \"tcp\", host, port } endpoint on Windows`,
+ );
+ }
+
+ return {
+ transport: "unix",
+ path: path.resolve(value),
+ };
+ }
+
+ if (!value || typeof value !== "object") {
+ throw new Error(`${fieldName} must be a local endpoint object`);
+ }
+
+ if (value.transport === "unix") {
+ if (platform === "win32") {
+ throw new Error(`${fieldName} must use transport \"tcp\" on Windows`);
+ }
+ if (typeof value.path !== "string" || value.path.length === 0) {
+ throw new Error(`${fieldName}.path must be a non-empty string`);
+ }
+ value.path = path.resolve(value.path);
+ return value;
+ }
+
+ if (value.transport === "tcp") {
+ if (typeof value.host !== "string" || value.host.length === 0) {
+ throw new Error(`${fieldName}.host must be a non-empty string`);
+ }
+
+ const normalizedHost = value.host.trim();
+ if (!isLoopbackTcpHost(normalizedHost)) {
+ throw new Error(
+ `${fieldName}.host must be a loopback host (127.0.0.0/8, ::1, or localhost)`,
+ );
+ }
+
+ if (
+ !Number.isInteger(value.port) ||
+ value.port < 0 ||
+ value.port > 65535
+ ) {
+ throw new Error(`${fieldName}.port must be a valid tcp port`);
+ }
+
+ value.host = normalizedHost;
+ return value;
+ }
+
+ throw new Error(`${fieldName}.transport must be \"unix\" or \"tcp\"`);
+}
+
+export function describeLocalEndpoint(endpoint: LocalEndpoint): string {
+ if (endpoint.transport === "unix") {
+ return endpoint.path;
+ }
+
+ return endpoint.host.includes(":")
+ ? `[${endpoint.host}]:${endpoint.port}`
+ : `${endpoint.host}:${endpoint.port}`;
+}
+
+export function createNetConnectOptions(
+ endpoint: LocalEndpoint,
+): net.NetConnectOpts {
+ return endpoint.transport === "unix"
+ ? { path: endpoint.path }
+ : { host: endpoint.host, port: endpoint.port };
+}
+
+export async function listenOnLocalEndpoint(
+ server: net.Server,
+ endpoint: LocalEndpoint,
+): Promise {
+ if (endpoint.transport === "unix") {
+ if (!fs.existsSync(path.dirname(endpoint.path))) {
+ fs.mkdirSync(path.dirname(endpoint.path), { recursive: true });
+ }
+ fs.rmSync(endpoint.path, { force: true });
+ }
+
+ await new Promise((resolve, reject) => {
+ const onError = (err: Error) => {
+ cleanup();
+ reject(err);
+ };
+ const onListening = () => {
+ cleanup();
+ syncEndpointWithServerAddress(endpoint, server);
+ resolve();
+ };
+ const cleanup = () => {
+ server.off("error", onError);
+ server.off("listening", onListening);
+ };
+
+ server.once("error", onError);
+ server.once("listening", onListening);
+
+ if (endpoint.transport === "unix") {
+ server.listen(endpoint.path);
+ } else {
+ server.listen(endpoint.port, endpoint.host);
+ }
+ });
+}
+
+export function syncEndpointWithServerAddress(
+ endpoint: LocalEndpoint,
+ server: net.Server,
+): void {
+ if (endpoint.transport !== "tcp") return;
+ const address = server.address();
+ if (address && typeof address === "object") {
+ endpoint.port = address.port;
+ }
+}
diff --git a/host/src/qemu/img.ts b/host/src/qemu/img.ts
index 6a7fab3c..b031f80e 100644
--- a/host/src/qemu/img.ts
+++ b/host/src/qemu/img.ts
@@ -4,6 +4,12 @@ import os from "os";
import path from "path";
import { randomUUID } from "crypto";
+import {
+ buildQemuFamilyCandidates,
+ resolveFromQemuFamilyCandidates,
+ type ResolveQemuFamilyBinaryDeps,
+} from "./locate-binary.ts";
+
type Qcow2CreateOptions = {
/** overlay file path */
path: string;
@@ -13,15 +19,60 @@ type Qcow2CreateOptions = {
backingFormat: "raw" | "qcow2";
};
+type ResolveQemuImgPathDeps = ResolveQemuFamilyBinaryDeps & {
+ qemuPath?: string;
+ /** @deprecated use `probeBinary` */
+ probeQemuImg?: (candidatePath: string) => boolean;
+};
+
function tmpDir(): string {
// macOS has tighter unix socket path limits in the default temp dir and we
// already standardize on /tmp elsewhere.
return process.platform === "darwin" ? "/tmp" : os.tmpdir();
}
+function qemuImgSiblingCandidate(
+ qemuPath: string | undefined,
+ platform: NodeJS.Platform,
+): string | null {
+ if (!qemuPath || !/[\\/]/.test(qemuPath)) {
+ return null;
+ }
+
+ const pathModule = platform === "win32" ? path.win32 : path.posix;
+ return pathModule.join(
+ pathModule.dirname(qemuPath),
+ platform === "win32" ? "qemu-img.exe" : "qemu-img",
+ );
+}
+
+function buildDefaultQemuImgCandidates(
+ deps: ResolveQemuImgPathDeps = {},
+): string[] {
+ const platform = deps.platform ?? process.platform;
+ const siblingCandidate = qemuImgSiblingCandidate(deps.qemuPath, platform);
+ const candidates = [
+ ...(siblingCandidate ? [siblingCandidate] : []),
+ ...buildQemuFamilyCandidates(["qemu-img"], deps),
+ ];
+
+ return Array.from(new Set(candidates));
+}
+
+function resolveQemuImgPath(deps: ResolveQemuImgPathDeps = {}): string {
+ const candidates = buildDefaultQemuImgCandidates(deps);
+ return resolveFromQemuFamilyCandidates(candidates, {
+ ...deps,
+ probeBinary: deps.probeBinary ?? deps.probeQemuImg,
+ });
+}
+
/** Ensure `qemu-img` can be invoked. */
-export function ensureQemuImgAvailable(): void {
- execFileSync("qemu-img", ["--version"], { stdio: "ignore" });
+export function ensureQemuImgAvailable(qemuPath?: string): void {
+ execFileSync(resolveQemuImgPath({ qemuPath }), ["--version"], {
+ stdio: "ignore",
+ windowsHide: true,
+ });
}
export function inferDiskFormatFromPath(diskPath: string): "raw" | "qcow2" {
@@ -94,7 +145,7 @@ export function parseDiskSizeToBytes(value: string | number): number {
return Number(bytes);
}
-function createQcow2Overlay(opts: Qcow2CreateOptions): void {
+function createQcow2Overlay(opts: Qcow2CreateOptions, qemuPath?: string): void {
const dir = path.dirname(opts.path);
fs.mkdirSync(dir, { recursive: true });
@@ -102,7 +153,7 @@ function createQcow2Overlay(opts: Qcow2CreateOptions): void {
fs.rmSync(opts.path, { force: true });
execFileSync(
- "qemu-img",
+ resolveQemuImgPath({ qemuPath }),
[
"create",
"-f",
@@ -113,20 +164,24 @@ function createQcow2Overlay(opts: Qcow2CreateOptions): void {
opts.backingPath,
opts.path,
],
- { stdio: "ignore" },
+ { stdio: "ignore", windowsHide: true },
);
}
export function createTempQcow2Overlay(
backingPath: string,
backingFormat: "raw" | "qcow2",
+ qemuPath?: string,
): string {
const overlayPath = path.join(
tmpDir(),
`gondolin-disk-${randomUUID().slice(0, 8)}.qcow2`,
);
try {
- createQcow2Overlay({ path: overlayPath, backingPath, backingFormat });
+ createQcow2Overlay(
+ { path: overlayPath, backingPath, backingFormat },
+ qemuPath,
+ );
return overlayPath;
} catch (err) {
fs.rmSync(overlayPath, { force: true });
@@ -153,11 +208,16 @@ export function moveFile(src: string, dst: string): void {
type QemuImgInfo = Record;
-function qemuImgInfoJson(imagePath: string): QemuImgInfo {
- const raw = execFileSync("qemu-img", ["info", "--output=json", imagePath], {
- encoding: "utf8",
- stdio: ["ignore", "pipe", "pipe"],
- });
+function qemuImgInfoJson(imagePath: string, qemuPath?: string): QemuImgInfo {
+ const raw = execFileSync(
+ resolveQemuImgPath({ qemuPath }),
+ ["info", "--output=json", imagePath],
+ {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ windowsHide: true,
+ },
+ );
return JSON.parse(raw) as QemuImgInfo;
}
@@ -179,14 +239,20 @@ function extractBackingFilename(info: any): string | null {
*
* Note: this is the string stored in the qcow2 metadata and may be relative.
*/
-export function getQcow2BackingFilename(imagePath: string): string | null {
- const info = qemuImgInfoJson(imagePath);
+export function getQcow2BackingFilename(
+ imagePath: string,
+ qemuPath?: string,
+): string | null {
+ const info = qemuImgInfoJson(imagePath, qemuPath);
return extractBackingFilename(info);
}
/** Return the image virtual size in `bytes`. */
-export function getImageVirtualSizeBytes(imagePath: string): number {
- const info = qemuImgInfoJson(imagePath);
+export function getImageVirtualSizeBytes(
+ imagePath: string,
+ qemuPath?: string,
+): number {
+ const info = qemuImgInfoJson(imagePath, qemuPath);
const value = info["virtual-size"];
if (!Number.isSafeInteger(value) || (value as number) < 0) {
throw new Error(
@@ -200,19 +266,28 @@ export function getImageVirtualSizeBytes(imagePath: string): number {
export function ensureDiskImageMinimumSize(
imagePath: string,
sizeBytes: number,
+ qemuPath?: string,
): void {
if (!Number.isSafeInteger(sizeBytes) || sizeBytes <= 0) {
throw new Error(`invalid disk resize target: ${String(sizeBytes)}`);
}
- if (getImageVirtualSizeBytes(imagePath) >= sizeBytes) return;
- execFileSync("qemu-img", ["resize", imagePath, String(sizeBytes)], {
- stdio: "ignore",
- });
+ if (getImageVirtualSizeBytes(imagePath, qemuPath) >= sizeBytes) return;
+ execFileSync(
+ resolveQemuImgPath({ qemuPath }),
+ ["resize", imagePath, String(sizeBytes)],
+ {
+ stdio: "ignore",
+ windowsHide: true,
+ },
+ );
}
/** Resolve qcow2 backing metadata into an absolute path when present. */
-export function resolveQcow2BackingPath(imagePath: string): string | null {
- const backing = getQcow2BackingFilename(imagePath);
+export function resolveQcow2BackingPath(
+ imagePath: string,
+ qemuPath?: string,
+): string | null {
+ const backing = getQcow2BackingFilename(imagePath, qemuPath);
if (!backing) return null;
return path.isAbsolute(backing)
? path.resolve(backing)
@@ -227,11 +302,20 @@ export function rebaseQcow2InPlace(
backingPath: string,
backingFormat: "raw" | "qcow2",
mode: "safe" | "unsafe" = "unsafe",
+ qemuPath?: string,
): void {
const args = ["rebase"];
if (mode === "unsafe") {
args.push("-u");
}
args.push("-F", backingFormat, "-b", backingPath, imagePath);
- execFileSync("qemu-img", args, { stdio: "ignore" });
+ execFileSync(resolveQemuImgPath({ qemuPath }), args, {
+ stdio: "ignore",
+ windowsHide: true,
+ });
}
+
+export const __test = {
+ buildDefaultQemuImgCandidates,
+ resolveQemuImgPath,
+};
diff --git a/host/src/qemu/locate-binary.ts b/host/src/qemu/locate-binary.ts
new file mode 100644
index 00000000..2dc133d3
--- /dev/null
+++ b/host/src/qemu/locate-binary.ts
@@ -0,0 +1,82 @@
+import { execFileSync } from "child_process";
+import fs from "fs";
+import path from "path";
+
+export type ResolveQemuFamilyBinaryDeps = {
+ platform?: NodeJS.Platform;
+ env?: NodeJS.ProcessEnv;
+ existsSync?: typeof fs.existsSync;
+ probeBinary?: (candidatePath: string) => boolean;
+};
+
+/** Check whether a qemu-family binary (qemu-system-*, qemu-img, ...) runs. */
+export function probeQemuFamilyBinary(candidatePath: string): boolean {
+ try {
+ execFileSync(candidatePath, ["--version"], {
+ stdio: "ignore",
+ windowsHide: true,
+ });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Build the list of candidate paths for a qemu-family binary known by one or
+ * more bare basenames (e.g. `["qemu-img"]` or
+ * `["qemu-system-x86_64", "qemu-system-x86_64w"]`). Off Windows this is just
+ * the first basename; on Windows it also searches `.exe` variants under the
+ * `ProgramW6432`/`ProgramFiles` install roots, since qemu is commonly
+ * installed outside PATH there.
+ */
+export function buildQemuFamilyCandidates(
+ names: string[],
+ deps: ResolveQemuFamilyBinaryDeps = {},
+): string[] {
+ const platform = deps.platform ?? process.platform;
+ if (platform !== "win32") {
+ return [names[0]!];
+ }
+
+ const env = deps.env ?? process.env;
+ const candidates: string[] = [];
+ for (const name of names) {
+ candidates.push(name, `${name}.exe`);
+ }
+
+ const installRoots = [env.ProgramW6432, env.ProgramFiles].filter(
+ (value): value is string => typeof value === "string" && value.length > 0,
+ );
+ for (const root of installRoots) {
+ for (const name of names) {
+ candidates.push(path.win32.join(root, "qemu", `${name}.exe`));
+ }
+ }
+
+ return Array.from(new Set(candidates));
+}
+
+/**
+ * Pick the first candidate that exists (when given as an explicit path) and
+ * responds to a `--version` probe, falling back to the first candidate.
+ */
+export function resolveFromQemuFamilyCandidates(
+ candidates: string[],
+ deps: ResolveQemuFamilyBinaryDeps = {},
+): string {
+ const existsSync = deps.existsSync ?? fs.existsSync;
+ const probeBinary = deps.probeBinary ?? probeQemuFamilyBinary;
+
+ for (const candidate of candidates) {
+ const isExplicitPath = /[\\/]/.test(candidate);
+ if (isExplicitPath && !existsSync(candidate)) {
+ continue;
+ }
+ if (probeBinary(candidate)) {
+ return candidate;
+ }
+ }
+
+ return candidates[0]!;
+}
diff --git a/host/src/qemu/net.ts b/host/src/qemu/net.ts
index 1dccd23f..ab32e73e 100644
--- a/host/src/qemu/net.ts
+++ b/host/src/qemu/net.ts
@@ -1,7 +1,6 @@
import { EventEmitter } from "events";
import { stripTrailingNewline } from "../debug.ts";
import net from "net";
-import fs from "fs";
import fsp from "fs/promises";
import path from "path";
import dgram from "dgram";
@@ -12,6 +11,12 @@ import { Duplex } from "stream";
import { monitorEventLoopDelay, performance } from "perf_hooks";
import forge from "node-forge";
+import {
+ createNetConnectOptions,
+ listenOnLocalEndpoint,
+ normalizeLocalEndpoint,
+ type LocalEndpointInput,
+} from "../local-endpoint.ts";
import {
generatePositiveSerialNumber,
getCertificateSubjectKeyIdentifierBytes,
@@ -246,8 +251,8 @@ export type {
export type { TcpOptions } from "./tcp.ts";
export type QemuNetworkOptions = {
- /** unix socket path for the qemu net backend */
- socketPath: string;
+ /** local endpoint for the qemu net backend */
+ socketPath: LocalEndpointInput;
/** gateway ipv4 address */
gatewayIP?: string;
/** guest ipv4 address */
@@ -489,17 +494,26 @@ export class QemuNetworkBackend extends EventEmitter {
this.emit("guest-activity-change", active);
}
- start() {
+ async start() {
if (this.server) return;
- if (!fs.existsSync(path.dirname(this.options.socketPath))) {
- fs.mkdirSync(path.dirname(this.options.socketPath), { recursive: true });
- }
- fs.rmSync(this.options.socketPath, { force: true });
-
this.server = net.createServer((socket) => this.attachSocket(socket));
this.server.on("error", (err) => this.emit("error", err));
- this.server.listen(this.options.socketPath);
+ try {
+ await listenOnLocalEndpoint(
+ this.server,
+ normalizeLocalEndpoint(this.options.socketPath, "qemu net socketPath"),
+ );
+ } catch (err) {
+ const server = this.server;
+ this.server = null;
+ try {
+ server?.close();
+ } catch {
+ // ignore
+ }
+ throw err;
+ }
}
async close(): Promise {
diff --git a/host/src/sandbox/controller.ts b/host/src/sandbox/controller.ts
index 9edb4f58..53df0825 100644
--- a/host/src/sandbox/controller.ts
+++ b/host/src/sandbox/controller.ts
@@ -3,10 +3,20 @@ import child_process from "child_process";
import type { ChildProcess } from "child_process";
import fs from "fs";
import net from "net";
+import os from "os";
import path from "path";
import { randomUUID } from "crypto";
+import {
+ normalizeLocalEndpoint,
+ createNetConnectOptions,
+ type LocalEndpoint,
+ type LocalEndpointInput,
+} from "../local-endpoint.ts";
+
const activeChildren = new Set();
+const accelSupportCache = new Map>();
+const accelRuntimeProbeCache = new Map();
let exitHookRegistered = false;
function killActiveChildren() {
@@ -94,7 +104,7 @@ function formatQmpError(command: QmpCommand, message: QmpMessage) {
}
function executeQmpCommand(
- socketPath: string,
+ endpoint: LocalEndpoint,
command: QmpCommand,
): Promise {
return new Promise((resolve, reject) => {
@@ -103,7 +113,7 @@ function executeQmpCommand(
let commandSent = false;
let stage: "greeting" | "capabilities" | "command" = "greeting";
- const socket = net.createConnection(socketPath);
+ const socket = net.createConnection(createNetConnectOptions(endpoint));
socket.setEncoding("utf8");
let timer: NodeJS.Timeout;
@@ -215,11 +225,53 @@ function executeQmpCommand(
});
}
-function defaultQmpSocketPath(config: SandboxConfig) {
- return path.join(
- path.resolve(path.dirname(config.virtioSocketPath)),
- `gondolin-qmp-${randomUUID().slice(0, 8)}.sock`,
+function defaultUnixQmpEndpoint(config: SandboxConfig): LocalEndpoint {
+ const endpoint = normalizeLocalEndpoint(
+ config.virtioSocketPath,
+ "sandbox.virtioSocketPath",
);
+ const dir =
+ endpoint.transport === "unix" ? path.dirname(endpoint.path) : os.tmpdir();
+ return {
+ transport: "unix",
+ path: path.join(
+ path.resolve(dir),
+ `gondolin-qmp-${randomUUID().slice(0, 8)}.sock`,
+ ),
+ };
+}
+
+/**
+ * Reserve a loopback TCP port by binding to port 0 and immediately closing.
+ * There's an inherent (small, accepted) TOCTOU race between the close here
+ * and qemu's own bind, same tradeoff every "find a free port" helper makes.
+ */
+function reserveEphemeralTcpEndpoint(host: string): Promise {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer();
+ server.once("error", reject);
+ server.listen(0, host, () => {
+ const address = server.address();
+ const port = address && typeof address === "object" ? address.port : 0;
+ server.close(() => resolve({ transport: "tcp", host, port }));
+ });
+ });
+}
+
+/**
+ * Resolve the default QMP monitor endpoint. Off Windows this is a unix
+ * socket next to the virtio control socket (unchanged from before). On
+ * Windows there's no unix socket support, so a loopback TCP port is
+ * reserved instead - qemu's `-qmp` chardev supports `tcp:host:port` just as
+ * well as `unix:path`.
+ */
+async function resolveDefaultQmpEndpoint(
+ config: SandboxConfig,
+): Promise {
+ if (process.platform !== "win32") {
+ return defaultUnixQmpEndpoint(config);
+ }
+ return reserveEphemeralTcpEndpoint("127.0.0.1");
}
export type SandboxConfig = {
@@ -247,15 +299,15 @@ export type SandboxConfig = {
memory: string;
/** vm cpu count */
cpus: number;
- /** virtio-serial control socket path */
- virtioSocketPath: string;
- /** virtiofs/vfs socket path */
- virtioFsSocketPath: string;
- /** virtio-serial ssh socket path */
- virtioSshSocketPath: string;
-
- /** virtio-serial ingress socket path */
- virtioIngressSocketPath: string;
+ /** virtio-serial control endpoint */
+ virtioSocketPath: LocalEndpointInput;
+ /** virtiofs/vfs endpoint */
+ virtioFsSocketPath: LocalEndpointInput;
+ /** virtio-serial ssh endpoint */
+ virtioSshSocketPath: LocalEndpointInput;
+
+ /** virtio-serial ingress endpoint */
+ virtioIngressSocketPath: LocalEndpointInput;
/** kernel cmdline append string */
append: string;
/** qemu machine type */
@@ -266,12 +318,12 @@ export type SandboxConfig = {
cpu?: string;
/** guest console mode */
console?: "stdio" | "none";
- /** qemu net socket path */
- netSocketPath?: string;
+ /** qemu net backend endpoint */
+ netSocketPath?: LocalEndpointInput;
/** guest mac address */
netMac?: string;
- /** qemu monitor socket path */
- qmpSocketPath?: string;
+ /** qemu monitor endpoint */
+ qmpSocketPath?: LocalEndpointInput;
/** qemu idle pause timeout in `ms` */
qemuIdlePauseMs?: number;
/** whether to restart the vm automatically on exit */
@@ -284,6 +336,7 @@ export type SandboxLogStream = "stdout" | "stderr";
export class SandboxController extends EventEmitter {
private child: ChildProcess | null = null;
+ private starting = false;
private state: SandboxState = "stopped";
private restartTimer: NodeJS.Timeout | null = null;
private idleTimer: NodeJS.Timeout | null = null;
@@ -294,7 +347,7 @@ export class SandboxController extends EventEmitter {
private qmpChain: Promise = Promise.resolve();
private qmpGeneration = 0;
private readonly config: SandboxConfig;
- private readonly qmpSocketPath: string | null;
+ private qmpEndpoint: LocalEndpoint | null = null;
private readonly idlePauseMs: number | null;
constructor(config: SandboxConfig) {
@@ -312,9 +365,26 @@ export class SandboxController extends EventEmitter {
const idlePauseMs = Math.trunc(config.qemuIdlePauseMs ?? 0);
this.idlePauseMs = idlePauseMs > 0 ? idlePauseMs : null;
- this.qmpSocketPath = this.idlePauseMs
- ? (config.qmpSocketPath ?? defaultQmpSocketPath(config))
- : null;
+ }
+
+ /**
+ * Resolve (and cache) the QMP endpoint. Reserving a Windows loopback TCP
+ * port requires an async bind/close round trip, so this can't happen
+ * synchronously in the constructor the way the unix-socket path used to;
+ * `start()` awaits it before spawning qemu instead. The resolved endpoint
+ * is cached so restarts reuse the same one, matching prior behavior.
+ */
+ private async ensureQmpEndpoint(): Promise {
+ if (!this.idlePauseMs) return null;
+ if (this.qmpEndpoint) return this.qmpEndpoint;
+
+ this.qmpEndpoint = this.config.qmpSocketPath
+ ? normalizeLocalEndpoint(
+ this.config.qmpSocketPath,
+ "sandbox.qmpSocketPath",
+ )
+ : await resolveDefaultQmpEndpoint(this.config);
+ return this.qmpEndpoint;
}
setAppend(append: string) {
@@ -330,68 +400,90 @@ export class SandboxController extends EventEmitter {
}
async start() {
- if (this.child) return;
-
- this.cancelIdlePause();
- this.qmpGeneration += 1;
- this.paused = false;
- this.pauseInProgress = false;
- this.qmpIdleDisabled = false;
- this.qmpChain = Promise.resolve();
- this.manualStop = false;
- this.setState("starting");
-
- this.cleanupQmpSocket();
-
- const args = buildQemuArgs({
- ...this.config,
- qmpSocketPath: this.qmpSocketPath ?? undefined,
- });
- this.child = child_process.spawn(this.config.qemuPath, args, {
- stdio: ["ignore", "pipe", "pipe"],
- });
- trackChild(this.child);
-
- this.child.stdout?.on("data", (chunk) => {
- this.emit("log", chunk.toString(), "stdout" satisfies SandboxLogStream);
- });
-
- this.child.stderr?.on("data", (chunk) => {
- this.emit("log", chunk.toString(), "stderr" satisfies SandboxLogStream);
- });
-
- this.child.on("spawn", () => {
- this.setState("running");
- });
+ if (this.child || this.starting) return;
+ this.starting = true;
- this.child.on("error", (err) => {
+ try {
this.cancelIdlePause();
this.qmpGeneration += 1;
- this.cleanupQmpSocket();
this.paused = false;
this.pauseInProgress = false;
- this.child = null;
- this.setState("stopped");
- this.emit("exit", { code: null, signal: null, error: err });
- });
+ this.qmpIdleDisabled = false;
+ this.qmpChain = Promise.resolve();
+ this.manualStop = false;
+ this.setState("starting");
- this.child.on("exit", (code, signal) => {
- this.cancelIdlePause();
- this.qmpGeneration += 1;
this.cleanupQmpSocket();
- this.paused = false;
- this.pauseInProgress = false;
- this.child = null;
- this.setState("stopped");
- this.emit("exit", { code, signal });
- if (this.manualStop) {
- this.manualStop = false;
- return;
- }
- if (this.config.autoRestart) {
- this.scheduleRestart();
- }
- });
+
+ // The only await point in this method - resolving the QMP endpoint
+ // may need an async port reservation on Windows. Guarded by
+ // `this.starting` above so a concurrent start() call can't slip past
+ // the `this.child` check while this is in flight. Short-circuit
+ // (skip calling the async function entirely) when idle-pause isn't
+ // configured, so the common case stays fully synchronous like before.
+ const qmpEndpoint = this.idlePauseMs
+ ? await this.ensureQmpEndpoint()
+ : null;
+ const args = buildQemuArgs({
+ ...this.config,
+ qmpSocketPath: qmpEndpoint ?? undefined,
+ });
+ this.child = child_process.spawn(this.config.qemuPath, args, {
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ trackChild(this.child);
+
+ this.child.stdout?.on("data", (chunk) => {
+ this.emit(
+ "log",
+ chunk.toString(),
+ "stdout" satisfies SandboxLogStream,
+ );
+ });
+
+ this.child.stderr?.on("data", (chunk) => {
+ this.emit(
+ "log",
+ chunk.toString(),
+ "stderr" satisfies SandboxLogStream,
+ );
+ });
+
+ this.child.on("spawn", () => {
+ this.setState("running");
+ });
+
+ this.child.on("error", (err) => {
+ this.cancelIdlePause();
+ this.qmpGeneration += 1;
+ this.cleanupQmpSocket();
+ this.paused = false;
+ this.pauseInProgress = false;
+ this.child = null;
+ this.setState("stopped");
+ this.emit("exit", { code: null, signal: null, error: err });
+ });
+
+ this.child.on("exit", (code, signal) => {
+ this.cancelIdlePause();
+ this.qmpGeneration += 1;
+ this.cleanupQmpSocket();
+ this.paused = false;
+ this.pauseInProgress = false;
+ this.child = null;
+ this.setState("stopped");
+ this.emit("exit", { code, signal });
+ if (this.manualStop) {
+ this.manualStop = false;
+ return;
+ }
+ if (this.config.autoRestart) {
+ this.scheduleRestart();
+ }
+ });
+ } finally {
+ this.starting = false;
+ }
}
async close() {
@@ -525,7 +617,7 @@ export class SandboxController extends EventEmitter {
}
resumeForActivity(): Promise | void {
- if (!this.qmpSocketPath) return;
+ if (!this.idlePauseMs) return;
this.cancelIdlePause();
if (!this.paused && !this.pauseInProgress) return;
return this.resumeForActivityAsync(this.qmpGeneration, this.child);
@@ -551,7 +643,7 @@ export class SandboxController extends EventEmitter {
scheduleIdlePause() {
if (
- !this.qmpSocketPath ||
+ !this.idlePauseMs ||
this.qmpIdleDisabled ||
!this.child ||
this.state !== "running" ||
@@ -577,7 +669,7 @@ export class SandboxController extends EventEmitter {
private async pauseForIdle() {
if (
- !this.qmpSocketPath ||
+ !this.idlePauseMs ||
this.qmpIdleDisabled ||
!this.child ||
this.state !== "running" ||
@@ -651,13 +743,14 @@ export class SandboxController extends EventEmitter {
}
private async runQmpCommand(command: QmpCommand): Promise {
- if (!this.qmpSocketPath) return;
+ if (!this.idlePauseMs || !this.qmpEndpoint) return;
+ const endpoint = this.qmpEndpoint;
const run = this.qmpChain
.catch(() => {
// keep the command chain alive after a failed command
})
- .then(() => executeQmpCommand(this.qmpSocketPath!, command));
+ .then(() => executeQmpCommand(endpoint, command));
this.qmpChain = run.then(
() => {
// keep the command chain alive after a completed command
@@ -670,9 +763,9 @@ export class SandboxController extends EventEmitter {
}
private cleanupQmpSocket() {
- if (!this.qmpSocketPath) return;
+ if (!this.qmpEndpoint || this.qmpEndpoint.transport !== "unix") return;
try {
- fs.rmSync(this.qmpSocketPath, { force: true });
+ fs.rmSync(this.qmpEndpoint.path, { force: true });
} catch {
// ignore
}
@@ -699,6 +792,26 @@ export class SandboxController extends EventEmitter {
}
function buildQemuArgs(config: SandboxConfig) {
+ const virtioEndpoint = normalizeLocalEndpoint(
+ config.virtioSocketPath,
+ "sandbox.virtioSocketPath",
+ );
+ const virtioFsEndpoint = normalizeLocalEndpoint(
+ config.virtioFsSocketPath,
+ "sandbox.virtioFsSocketPath",
+ );
+ const virtioSshEndpoint = normalizeLocalEndpoint(
+ config.virtioSshSocketPath,
+ "sandbox.virtioSshSocketPath",
+ );
+ const virtioIngressEndpoint = normalizeLocalEndpoint(
+ config.virtioIngressSocketPath,
+ "sandbox.virtioIngressSocketPath",
+ );
+ const netEndpoint = config.netSocketPath
+ ? normalizeLocalEndpoint(config.netSocketPath, "sandbox.netSocketPath")
+ : null;
+
const args: string[] = [
"-nodefaults",
"-no-reboot",
@@ -716,7 +829,7 @@ function buildQemuArgs(config: SandboxConfig) {
];
const targetArch = detectTargetArch(config);
- const accel = config.accel ?? selectAccel(targetArch);
+ const accel = config.accel ?? selectAccel(targetArch, config.qemuPath);
const machineType =
config.machineType ?? selectMachineType(targetArch, accel);
@@ -769,23 +882,26 @@ function buildQemuArgs(config: SandboxConfig) {
const serialDev = useMmio ? "virtio-serial-device" : "virtio-serial-pci";
const netDev = useMmio ? "virtio-net-device" : "virtio-net-pci";
- args.push("-object", "rng-random,filename=/dev/urandom,id=rng0");
- args.push("-device", `${rngDev},rng=rng0`);
+ const rngObject = selectRngObject();
+ if (rngObject) {
+ args.push("-object", rngObject);
+ args.push("-device", `${rngDev},rng=rng0`);
+ }
args.push(
"-chardev",
- `socket,id=virtiocon0,path=${config.virtioSocketPath},server=off`,
+ buildQemuSocketChardevArg("virtiocon0", virtioEndpoint),
);
args.push(
"-chardev",
- `socket,id=virtiofs0,path=${config.virtioFsSocketPath},server=off`,
+ buildQemuSocketChardevArg("virtiofs0", virtioFsEndpoint),
);
args.push(
"-chardev",
- `socket,id=virtiossh0,path=${config.virtioSshSocketPath},server=off`,
+ buildQemuSocketChardevArg("virtiossh0", virtioSshEndpoint),
);
args.push(
"-chardev",
- `socket,id=virtioingress0,path=${config.virtioIngressSocketPath},server=off`,
+ buildQemuSocketChardevArg("virtioingress0", virtioIngressEndpoint),
);
args.push("-device", `${serialDev},id=virtio-serial0`);
@@ -806,17 +922,23 @@ function buildQemuArgs(config: SandboxConfig) {
"virtserialport,chardev=virtioingress0,name=virtio-ingress,bus=virtio-serial0.0",
);
- if (config.netSocketPath) {
- args.push(
- "-netdev",
- `stream,id=net0,server=off,addr.type=unix,addr.path=${config.netSocketPath}`,
- );
+ if (netEndpoint) {
+ args.push("-netdev", buildQemuStreamNetdevArg("net0", netEndpoint));
const mac = config.netMac ?? "02:00:00:00:00:01";
args.push("-device", `${netDev},netdev=net0,mac=${mac}`);
}
if (config.qmpSocketPath) {
- args.push("-qmp", `unix:${config.qmpSocketPath},server=on,wait=off`);
+ const qmpEndpoint = normalizeLocalEndpoint(
+ config.qmpSocketPath,
+ "sandbox.qmpSocketPath",
+ );
+ args.push(
+ "-qmp",
+ qmpEndpoint.transport === "unix"
+ ? `unix:${qmpEndpoint.path},server=on,wait=off`
+ : `tcp:${qmpEndpoint.host}:${qmpEndpoint.port},server=on,wait=off`,
+ );
}
return args;
@@ -846,11 +968,175 @@ function selectMachineType(targetArch: string, accel?: string) {
return "q35";
}
+function buildQemuSocketChardevArg(id: string, endpoint: LocalEndpoint) {
+ return endpoint.transport === "unix"
+ ? `socket,id=${id},path=${endpoint.path},server=off`
+ : `socket,id=${id},host=${endpoint.host},port=${endpoint.port},server=off`;
+}
+
+function buildQemuStreamNetdevArg(id: string, endpoint: LocalEndpoint) {
+ return endpoint.transport === "unix"
+ ? `stream,id=${id},server=off,addr.type=unix,addr.path=${endpoint.path}`
+ : `stream,id=${id},server=off,addr.type=inet,addr.host=${endpoint.host},addr.port=${endpoint.port}`;
+}
+
+function selectRngObject() {
+ if (process.platform === "win32") {
+ // No /dev/urandom-equivalent device path to hand QEMU on Windows; use
+ // QEMU's cross-platform builtin RNG backend instead (host getrandom()/
+ // BCryptGenRandom, no device file required, available since QEMU 5.0)
+ // rather than leaving the guest with no virtio-rng device at all.
+ return "rng-builtin,id=rng0";
+ }
+ return "rng-random,filename=/dev/urandom,id=rng0";
+}
+
function getHostArch(): "arm64" | "x64" {
return process.arch === "arm64" ? "arm64" : "x64";
}
-function selectAccel(targetArch: string) {
+function readSupportedAccels(qemuPath: string): Set | null {
+ const cached = accelSupportCache.get(qemuPath);
+ if (cached) {
+ return cached;
+ }
+
+ try {
+ const result = child_process.spawnSync(qemuPath, ["-accel", "help"], {
+ encoding: "utf8",
+ windowsHide: true,
+ });
+ if (result.status !== 0) {
+ return null;
+ }
+
+ const supported = new Set(
+ `${result.stdout ?? ""}`
+ .split(/\r?\n/)
+ .map((line) => line.trim().toLowerCase())
+ .filter(
+ (line) => line.length > 0 && !line.includes("accelerators supported"),
+ ),
+ );
+ accelSupportCache.set(qemuPath, supported);
+ return supported;
+ } catch {
+ return null;
+ }
+}
+
+function qemuSupportsAccel(qemuPath: string, accel: string) {
+ const supported = readSupportedAccels(qemuPath);
+ return supported?.has(accel.toLowerCase()) ?? false;
+}
+
+function qemuCanInitializeAccel(qemuPath: string, accel: string) {
+ const cacheKey = `${qemuPath}\0${accel.toLowerCase()}`;
+ const cached = accelRuntimeProbeCache.get(cacheKey);
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ try {
+ const result = child_process.spawnSync(
+ qemuPath,
+ [
+ "-accel",
+ accel,
+ "-machine",
+ "none",
+ "-nodefaults",
+ "-display",
+ "none",
+ "-S",
+ ],
+ {
+ timeout: 1500,
+ windowsHide: true,
+ encoding: "utf8",
+ },
+ );
+ const available =
+ (result.error as NodeJS.ErrnoException | undefined)?.code ===
+ "ETIMEDOUT" || result.status === 0;
+ accelRuntimeProbeCache.set(cacheKey, available);
+ return available;
+ } catch {
+ accelRuntimeProbeCache.set(cacheKey, false);
+ return false;
+ }
+}
+
+/**
+ * Async, non-blocking equivalent of `qemuCanInitializeAccel`, populating the
+ * same cache. `selectAccel` still uses the synchronous `spawnSync` probe
+ * (required to support the synchronous `SandboxServer` constructor), which
+ * blocks the event loop for up to 1.5s on an unwarmed cache entry. Callers on
+ * an async path (`resolveSandboxServerOptionsAsync`, `SandboxServer.create`)
+ * should await this first so that by the time the synchronous accel
+ * selection runs, the cache is already warm and returns instantly.
+ */
+export async function primeAccelProbeCache(
+ qemuPath: string,
+ accel: string,
+): Promise {
+ const cacheKey = `${qemuPath}\0${accel.toLowerCase()}`;
+ if (accelRuntimeProbeCache.has(cacheKey)) {
+ return;
+ }
+
+ const available = await new Promise((resolve) => {
+ let settled = false;
+ let child: ChildProcess;
+ try {
+ child = child_process.spawn(
+ qemuPath,
+ [
+ "-accel",
+ accel,
+ "-machine",
+ "none",
+ "-nodefaults",
+ "-display",
+ "none",
+ "-S",
+ ],
+ { windowsHide: true, stdio: "ignore" },
+ );
+ } catch {
+ resolve(false);
+ return;
+ }
+
+ const timer = setTimeout(() => {
+ if (settled) return;
+ settled = true;
+ child.kill();
+ // Matches the sync probe: a still-running QEMU after the timeout means
+ // the accelerator initialized and is idling at the `-S` stop, not that
+ // it failed.
+ resolve(true);
+ }, 1500);
+
+ child.on("error", () => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ resolve(false);
+ });
+
+ child.on("exit", (code) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ resolve(code === 0);
+ });
+ });
+
+ accelRuntimeProbeCache.set(cacheKey, available);
+}
+
+export function selectAccel(targetArch: string, qemuPath?: string) {
const hostArch = getHostArch();
// Cross-arch emulation cannot use hardware acceleration.
@@ -869,6 +1155,14 @@ function selectAccel(targetArch: string) {
}
if (process.platform === "darwin") return "hvf";
+ if (process.platform === "win32") {
+ if (targetArch !== "x64") return "tcg";
+ if (!qemuPath) return "whpx";
+ if (!qemuSupportsAccel(qemuPath, "whpx")) {
+ return "tcg";
+ }
+ return qemuCanInitializeAccel(qemuPath, "whpx") ? "whpx" : "tcg";
+ }
return "tcg";
}
@@ -891,6 +1185,15 @@ function selectCpu(targetArch: string, accel?: string) {
return accelName === "hvf" ? "host" : "max";
}
+ if (process.platform === "win32") {
+ if (targetArch === "x64" && accelName === "whpx") {
+ // WHPX is more stable with a conservative named CPU model than with
+ // broad synthetic models like `max` on current QEMU/Windows builds.
+ return "qemu64";
+ }
+ return "max";
+ }
+
return "max";
}
@@ -898,10 +1201,20 @@ function selectCpu(targetArch: string, accel?: string) {
// Expose internal helpers for unit tests. Not part of the public API.
export const __test = {
buildQemuArgs,
+ buildQemuSocketChardevArg,
+ buildQemuStreamNetdevArg,
detectTargetArch,
selectMachineType,
selectAccel,
selectCpu,
+ selectRngObject,
+ qemuSupportsAccel,
+ qemuCanInitializeAccel,
+ primeAccelProbeCache,
+ executeQmpCommand,
+ defaultUnixQmpEndpoint,
+ reserveEphemeralTcpEndpoint,
+ resolveDefaultQmpEndpoint,
killActiveChildren,
getActiveChildrenCount: () => activeChildren.size,
};
diff --git a/host/src/sandbox/krun-controller.ts b/host/src/sandbox/krun-controller.ts
index b09d472e..fc20640a 100644
--- a/host/src/sandbox/krun-controller.ts
+++ b/host/src/sandbox/krun-controller.ts
@@ -6,6 +6,10 @@ import os from "os";
import path from "path";
import { randomUUID } from "crypto";
+import {
+ normalizeLocalEndpoint,
+ type LocalEndpointInput,
+} from "../local-endpoint.ts";
import type { SandboxLogStream, SandboxState } from "./controller.ts";
const activeChildren = new Set();
@@ -104,21 +108,21 @@ export type KrunConfig = {
memory: string;
/** vm cpu count */
cpus: number;
- /** virtio-serial control socket path */
- virtioSocketPath: string;
- /** virtiofs/vfs socket path */
- virtioFsSocketPath: string;
- /** virtio-serial ssh socket path */
- virtioSshSocketPath: string;
- /** virtio-serial ingress socket path */
- virtioIngressSocketPath: string;
+ /** virtio-serial control endpoint */
+ virtioSocketPath: LocalEndpointInput;
+ /** virtiofs/vfs endpoint */
+ virtioFsSocketPath: LocalEndpointInput;
+ /** virtio-serial ssh endpoint */
+ virtioSshSocketPath: LocalEndpointInput;
+ /** virtio-serial ingress endpoint */
+ virtioIngressSocketPath: LocalEndpointInput;
/** kernel cmdline append string */
append: string;
/** guest console mode */
console?: "stdio" | "none";
- /** qemu net socket path */
- netSocketPath?: string;
+ /** qemu net backend endpoint */
+ netSocketPath?: LocalEndpointInput;
/** guest mac address */
netMac?: string;
/** whether to restart the vm automatically on exit */
@@ -395,6 +399,14 @@ function parseMemoryToMiB(value: string): number {
return mib;
}
+function getUnixEndpointPath(value: LocalEndpointInput, fieldName: string): string {
+ const endpoint = normalizeLocalEndpoint(value, fieldName);
+ if (endpoint.transport !== "unix") {
+ throw new Error(`${fieldName} must use a unix socket for vmm=krun`);
+ }
+ return endpoint.path;
+}
+
function buildRunnerConfig(config: KrunConfig): KrunRunnerConfig {
if (config.cpus < 1 || config.cpus > 255 || !Number.isInteger(config.cpus)) {
throw new Error(`invalid vm cpu count for krun backend: ${config.cpus}`);
@@ -408,13 +420,27 @@ function buildRunnerConfig(config: KrunConfig): KrunRunnerConfig {
rootDiskReadOnly: config.rootDiskReadOnly ?? false,
memoryMiB: parseMemoryToMiB(config.memory),
cpus: config.cpus,
- virtioSocketPath: config.virtioSocketPath,
- virtioFsSocketPath: config.virtioFsSocketPath,
- virtioSshSocketPath: config.virtioSshSocketPath,
- virtioIngressSocketPath: config.virtioIngressSocketPath,
+ virtioSocketPath: getUnixEndpointPath(
+ config.virtioSocketPath,
+ "sandbox.virtioSocketPath",
+ ),
+ virtioFsSocketPath: getUnixEndpointPath(
+ config.virtioFsSocketPath,
+ "sandbox.virtioFsSocketPath",
+ ),
+ virtioSshSocketPath: getUnixEndpointPath(
+ config.virtioSshSocketPath,
+ "sandbox.virtioSshSocketPath",
+ ),
+ virtioIngressSocketPath: getUnixEndpointPath(
+ config.virtioIngressSocketPath,
+ "sandbox.virtioIngressSocketPath",
+ ),
append: config.append,
console: config.console ?? "none",
- netSocketPath: config.netSocketPath,
+ netSocketPath: config.netSocketPath
+ ? getUnixEndpointPath(config.netSocketPath, "sandbox.netSocketPath")
+ : undefined,
netMac: config.netMac,
};
}
diff --git a/host/src/sandbox/server-ops.ts b/host/src/sandbox/server-ops.ts
index 2b650774..7074d42e 100644
--- a/host/src/sandbox/server-ops.ts
+++ b/host/src/sandbox/server-ops.ts
@@ -617,15 +617,41 @@ export class SandboxServerOps {
return this.closeSingleflight.run(() => this.closeInternal());
}
+ private async cleanupFailedStart(): Promise {
+ const results = await Promise.allSettled([
+ this.network?.close(),
+ this.bridge.disconnect({ permanent: false }),
+ this.fsBridge.disconnect({ permanent: false }),
+ this.sshBridge.disconnect({ permanent: false }),
+ this.ingressBridge.disconnect({ permanent: false }),
+ ]);
+
+ for (const result of results) {
+ if (result.status === "rejected") {
+ const error =
+ result.reason instanceof Error
+ ? result.reason
+ : new Error(String(result.reason));
+ this.emit("error", error);
+ }
+ }
+ }
+
async startInternal(): Promise {
if (this.started) return;
this.started = true;
- this.network?.start();
- this.bridge.connect();
- this.fsBridge.connect();
- this.sshBridge.connect();
- this.ingressBridge.connect();
+ try {
+ await this.network?.start();
+ await this.bridge.connect();
+ await this.fsBridge.connect();
+ await this.sshBridge.connect();
+ await this.ingressBridge.connect();
+ } catch (err) {
+ this.started = false;
+ await this.cleanupFailedStart();
+ throw err;
+ }
}
async closeInternal() {
diff --git a/host/src/sandbox/server-options.ts b/host/src/sandbox/server-options.ts
index 63339ce9..aa7e1001 100644
--- a/host/src/sandbox/server-options.ts
+++ b/host/src/sandbox/server-options.ts
@@ -1,11 +1,21 @@
import fs from "fs";
import os from "os";
import path from "path";
-import { randomUUID } from "crypto";
import { execFileSync } from "child_process";
import { createRequire } from "module";
import { getHostNodeArchCached } from "../host/arch.ts";
+import {
+ createDefaultLocalEndpoint,
+ normalizeLocalEndpoint,
+ type LocalEndpoint,
+ type LocalEndpointInput,
+} from "../local-endpoint.ts";
+import {
+ buildQemuFamilyCandidates,
+ resolveFromQemuFamilyCandidates,
+ type ResolveQemuFamilyBinaryDeps,
+} from "../qemu/locate-binary.ts";
import {
debugFlagsToArray,
parseDebugEnv,
@@ -31,6 +41,7 @@ import {
import type { SshOptions } from "../qemu/ssh.ts";
import type { TcpOptions } from "../qemu/tcp.ts";
import type { VirtualProvider } from "../vfs/node/index.ts";
+import { selectAccel, primeAccelProbeCache } from "./controller.ts";
const require = createRequire(import.meta.url);
@@ -73,17 +84,17 @@ export type SandboxServerOptions = {
memory?: string;
/** vm cpu count */
cpus?: number;
- /** virtio-serial control socket path */
- virtioSocketPath?: string;
- /** virtiofs/vfs socket path */
- virtioFsSocketPath?: string;
- /** virtio-serial ssh socket path */
- virtioSshSocketPath?: string;
-
- /** virtio-serial ingress socket path */
- virtioIngressSocketPath?: string;
- /** qemu net socket path */
- netSocketPath?: string;
+ /** virtio-serial control endpoint */
+ virtioSocketPath?: LocalEndpointInput;
+ /** virtiofs/vfs endpoint */
+ virtioFsSocketPath?: LocalEndpointInput;
+ /** virtio-serial ssh endpoint */
+ virtioSshSocketPath?: LocalEndpointInput;
+
+ /** virtio-serial ingress endpoint */
+ virtioIngressSocketPath?: LocalEndpointInput;
+ /** qemu net backend endpoint */
+ netSocketPath?: LocalEndpointInput;
/** guest mac address */
netMac?: string;
/** whether to enable networking */
@@ -194,17 +205,17 @@ export type ResolvedSandboxServerOptions = {
memory: string;
/** vm cpu count */
cpus: number;
- /** virtio-serial control socket path */
- virtioSocketPath: string;
- /** virtiofs/vfs socket path */
- virtioFsSocketPath: string;
- /** virtio-serial ssh socket path */
- virtioSshSocketPath: string;
-
- /** virtio-serial ingress socket path */
- virtioIngressSocketPath: string;
- /** qemu net socket path */
- netSocketPath: string;
+ /** virtio-serial control endpoint */
+ virtioSocketPath: LocalEndpoint;
+ /** virtiofs/vfs endpoint */
+ virtioFsSocketPath: LocalEndpoint;
+ /** virtio-serial ssh endpoint */
+ virtioSshSocketPath: LocalEndpoint;
+
+ /** virtio-serial ingress endpoint */
+ virtioIngressSocketPath: LocalEndpoint;
+ /** qemu net backend endpoint */
+ netSocketPath: LocalEndpoint;
/** guest mac address */
netMac: string;
/** whether networking is enabled */
@@ -361,6 +372,7 @@ function normalizeQemuIdlePauseMs(value: number): number | undefined {
function resolveQemuIdlePauseMs(
options: SandboxServerOptions,
vmm: SandboxVmm,
+ accel?: string,
): number | undefined {
if (options.qemuIdlePauseMs !== undefined) {
return normalizeQemuIdlePauseMs(options.qemuIdlePauseMs);
@@ -370,7 +382,7 @@ function resolveQemuIdlePauseMs(
return undefined;
}
- const accelName = (options.accel ?? "")
+ const accelName = (accel ?? options.accel ?? "")
.split(",", 1)[0]!
.trim()
.toLowerCase();
@@ -381,6 +393,34 @@ function resolveQemuIdlePauseMs(
return DEFAULT_DARWIN_HVF_IDLE_PAUSE_MS;
}
+type ResolveDefaultQemuPathDeps = ResolveQemuFamilyBinaryDeps & {
+ /** @deprecated use `probeBinary` */
+ probeQemuBinary?: (candidatePath: string) => boolean;
+};
+
+function buildDefaultQemuCandidates(
+ targetArch: "arm64" | "x64",
+ deps: ResolveDefaultQemuPathDeps = {},
+): string[] {
+ const platform = deps.platform ?? process.platform;
+ const archName = targetArch === "arm64" ? "aarch64" : "x86_64";
+ const baseName = `qemu-system-${archName}`;
+ const names = platform === "win32" ? [baseName, `${baseName}w`] : [baseName];
+
+ return buildQemuFamilyCandidates(names, deps);
+}
+
+function resolveDefaultQemuPath(
+ targetArch: "arm64" | "x64",
+ deps: ResolveDefaultQemuPathDeps = {},
+): string {
+ const candidates = buildDefaultQemuCandidates(targetArch, deps);
+ return resolveFromQemuFamilyCandidates(candidates, {
+ ...deps,
+ probeBinary: deps.probeBinary ?? deps.probeQemuBinary,
+ });
+}
+
function resolveLocalKrunRunnerPath(): string | null {
const directCandidates = [
path.resolve(
@@ -802,6 +842,8 @@ function detectGuestArchFromManifest(assets: Partial): {
* @param assets Optional pre-resolved guest assets (from ensureGuestAssets)
*/
type ResolveSandboxServerOptionsDeps = {
+ /** test-only override for default qemu binary resolution */
+ resolveDefaultQemuPath?: (targetArch: "arm64" | "x64") => string;
/** test-only override for default krun runner resolution */
resolveDefaultKrunRunnerPath?: () => string;
};
@@ -841,36 +883,22 @@ export function resolveSandboxServerOptions(
const baseInitrdPath = resolvedAssets.initrdPath;
const rootfsPath = resolvedAssets.rootfsPath;
- // we are running into length limits on macos on the default temp dir
- const tmpDir = process.platform === "darwin" ? "/tmp" : os.tmpdir();
- const defaultVirtio = path.resolve(
- tmpDir,
- `gondolin-virtio-${randomUUID().slice(0, 8)}.sock`,
- );
- const defaultVirtioFs = path.resolve(
- tmpDir,
- `gondolin-virtio-fs-${randomUUID().slice(0, 8)}.sock`,
- );
- const defaultVirtioSsh = path.resolve(
- tmpDir,
- `gondolin-virtio-ssh-${randomUUID().slice(0, 8)}.sock`,
- );
- const defaultVirtioIngress = path.resolve(
- tmpDir,
- `gondolin-virtio-ingress-${randomUUID().slice(0, 8)}.sock`,
- );
- const defaultNetSock = path.resolve(
- tmpDir,
- `gondolin-net-${randomUUID().slice(0, 8)}.sock`,
+ const defaultVirtio = createDefaultLocalEndpoint("gondolin-virtio");
+ const defaultVirtioFs = createDefaultLocalEndpoint("gondolin-virtio-fs");
+ const defaultVirtioSsh = createDefaultLocalEndpoint("gondolin-virtio-ssh");
+ const defaultVirtioIngress = createDefaultLocalEndpoint(
+ "gondolin-virtio-ingress",
);
+ const defaultNetSock = createDefaultLocalEndpoint("gondolin-net");
const defaultNetMac = "02:00:00:00:00:01";
const hostArch = getHostNodeArchCached();
const hostArchNormalized = normalizeArch(hostArch);
- const defaultQemuForHostArch =
- hostArchNormalized === "arm64"
- ? "qemu-system-aarch64"
- : "qemu-system-x86_64";
+ const resolveDefaultQemuPathFn =
+ deps.resolveDefaultQemuPath ?? resolveDefaultQemuPath;
+ const defaultQemuForHostArch = resolveDefaultQemuPathFn(
+ hostArchNormalized === "arm64" ? "arm64" : "x64",
+ );
const defaultMemory = "1G";
const envDebugFlags = parseDebugEnv();
const resolvedDebugFlags = resolveDebugFlags(options.debug, envDebugFlags);
@@ -895,6 +923,12 @@ export function resolveSandboxServerOptions(
? resolveDefaultKrunRunnerPathFn()
: "gondolin-krun-runner");
+ if (vmm === "krun" && process.platform === "win32") {
+ throw new Error(
+ "vmm=krun is not supported on Windows hosts; use vmm=qemu instead.",
+ );
+ }
+
if (vmm === "krun") {
const unsupported: string[] = [];
if (options.qemuPath !== undefined) unsupported.push("sandbox.qemuPath");
@@ -950,10 +984,7 @@ export function resolveSandboxServerOptions(
options.qemuPath === undefined &&
guestFromManifest !== null
) {
- qemuPath =
- guestFromManifest.arch === "arm64"
- ? "qemu-system-aarch64"
- : "qemu-system-x86_64";
+ qemuPath = resolveDefaultQemuPathFn(guestFromManifest.arch);
}
if (vmm === "qemu") {
@@ -1004,6 +1035,13 @@ export function resolveSandboxServerOptions(
maxQueuedStdinBytes,
);
+ const resolvedQemuTargetArch =
+ guestFromManifest?.arch ?? detectQemuArch(qemuPath) ?? hostArchNormalized;
+ const resolvedAccel =
+ vmm === "qemu" && resolvedQemuTargetArch
+ ? (options.accel ?? selectAccel(resolvedQemuTargetArch, qemuPath))
+ : options.accel;
+
return {
vmm,
qemuPath,
@@ -1016,22 +1054,36 @@ export function resolveSandboxServerOptions(
rootDiskReadOnly,
memory: options.memory ?? defaultMemory,
cpus: options.cpus ?? 2,
- virtioSocketPath: options.virtioSocketPath ?? defaultVirtio,
- virtioFsSocketPath: options.virtioFsSocketPath ?? defaultVirtioFs,
- virtioSshSocketPath: options.virtioSshSocketPath ?? defaultVirtioSsh,
- virtioIngressSocketPath:
+ virtioSocketPath: normalizeLocalEndpoint(
+ options.virtioSocketPath ?? defaultVirtio,
+ "sandbox.virtioSocketPath",
+ ),
+ virtioFsSocketPath: normalizeLocalEndpoint(
+ options.virtioFsSocketPath ?? defaultVirtioFs,
+ "sandbox.virtioFsSocketPath",
+ ),
+ virtioSshSocketPath: normalizeLocalEndpoint(
+ options.virtioSshSocketPath ?? defaultVirtioSsh,
+ "sandbox.virtioSshSocketPath",
+ ),
+ virtioIngressSocketPath: normalizeLocalEndpoint(
options.virtioIngressSocketPath ?? defaultVirtioIngress,
- netSocketPath: options.netSocketPath ?? defaultNetSock,
+ "sandbox.virtioIngressSocketPath",
+ ),
+ netSocketPath: normalizeLocalEndpoint(
+ options.netSocketPath ?? defaultNetSock,
+ "sandbox.netSocketPath",
+ ),
netMac: options.netMac ?? defaultNetMac,
netEnabled: options.netEnabled ?? true,
allowWebSockets: options.allowWebSockets ?? true,
debug,
machineType: options.machineType,
- accel: options.accel,
+ accel: resolvedAccel,
cpu,
console: options.console,
autoRestart: options.autoRestart ?? false,
- qemuIdlePauseMs: resolveQemuIdlePauseMs(options, vmm),
+ qemuIdlePauseMs: resolveQemuIdlePauseMs(options, vmm, resolvedAccel),
append: options.append,
maxStdinBytes,
maxQueuedStdinBytes,
@@ -1058,6 +1110,18 @@ export function resolveSandboxServerOptions(
export async function resolveSandboxServerOptionsAsync(
options: SandboxServerOptions = {},
): Promise {
+ // WHPX is the only accelerator `selectAccel` probes at runtime, and only
+ // for x64 QEMU. Warm its probe cache asynchronously so the synchronous
+ // accel selection below (which must stay sync to support the sync
+ // `SandboxServer` constructor) doesn't block the event loop for up to 1.5s
+ // on the common case where this hint matches the eventually-resolved path.
+ // A mismatched guess is harmless: the cache entry simply goes unused and
+ // behavior falls back to today's synchronous probe.
+ if (process.platform === "win32" && (options.vmm ?? "qemu") === "qemu") {
+ const qemuPathHint = options.qemuPath ?? resolveDefaultQemuPath("x64");
+ await primeAccelProbeCache(qemuPathHint, "whpx");
+ }
+
// Explicit object imagePath is already fully resolved.
if (options.imagePath && typeof options.imagePath === "object") {
return resolveSandboxServerOptions(options);
@@ -1077,6 +1141,7 @@ export async function resolveSandboxServerOptionsAsync(
}
export const __test = {
+ resolveDefaultQemuPath,
probeKrunRunnerCandidate,
resolvePackagedKrunRunnerPath,
resolveDefaultKrunRunnerPath,
diff --git a/host/src/sandbox/server-transport.ts b/host/src/sandbox/server-transport.ts
index 7e66d75e..a9cfa25e 100644
--- a/host/src/sandbox/server-transport.ts
+++ b/host/src/sandbox/server-transport.ts
@@ -1,8 +1,12 @@
-import fs from "fs";
import net from "net";
-import path from "path";
import { Duplex } from "stream";
+import {
+ listenOnLocalEndpoint,
+ normalizeLocalEndpoint,
+ type LocalEndpointInput,
+} from "../local-endpoint.ts";
+
import {
FrameReader,
type IncomingMessage,
@@ -22,22 +26,21 @@ export class VirtioBridge {
private waitingDrain = false;
private allowReconnect = true;
private closed = false;
- private readonly socketPath: string;
+ private readonly endpoint: LocalEndpointInput;
private readonly maxPendingBytes: number;
- constructor(socketPath: string, maxPendingBytes: number = 8 * 1024 * 1024) {
- this.socketPath = socketPath;
+ constructor(
+ endpoint: LocalEndpointInput,
+ maxPendingBytes: number = 8 * 1024 * 1024,
+ ) {
+ this.endpoint = endpoint;
this.maxPendingBytes = maxPendingBytes;
}
- connect() {
+ async connect() {
if (this.closed) return;
if (this.server) return;
this.allowReconnect = true;
- if (!fs.existsSync(path.dirname(this.socketPath))) {
- fs.mkdirSync(path.dirname(this.socketPath), { recursive: true });
- }
- fs.rmSync(this.socketPath, { force: true });
const server = net.createServer((socket) => {
this.attachSocket(socket);
@@ -54,11 +57,27 @@ export class VirtioBridge {
this.scheduleReconnect();
});
- server.listen(this.socketPath);
+ try {
+ await listenOnLocalEndpoint(
+ server,
+ normalizeLocalEndpoint(this.endpoint, "virtio endpoint"),
+ );
+ } catch (err) {
+ this.server = null;
+ try {
+ server.close();
+ } catch {
+ // ignore
+ }
+ throw err;
+ }
}
- async disconnect(): Promise {
- this.closed = true;
+ async disconnect(options: { permanent?: boolean } = {}): Promise {
+ const permanent = options.permanent ?? true;
+ if (permanent) {
+ this.closed = true;
+ }
this.allowReconnect = false;
if (this.reconnectTimer) {
@@ -92,7 +111,8 @@ export class VirtioBridge {
this.waitingDrain = false;
- // Drop any queued frames; after disconnect the bridge is permanently closed.
+ // Drop any queued frames; temporary startup cleanup should not replay
+ // stale messages into a future guest connection.
this.pending = [];
this.pendingBytes = 0;
}
@@ -102,7 +122,7 @@ export class VirtioBridge {
return false;
}
if (!this.socket) {
- this.connect();
+ this.connectInBackground();
}
const frame = encodeFrame(message);
if (this.pending.length === 0 && !this.waitingDrain) {
@@ -210,12 +230,18 @@ export class VirtioBridge {
this.waitingDrain = false;
}
+ private connectInBackground() {
+ void this.connect().catch(() => {
+ this.scheduleReconnect();
+ });
+ }
+
private scheduleReconnect() {
if (!this.allowReconnect || this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (this.allowReconnect) {
- this.connect();
+ this.connectInBackground();
}
}, 500);
}
diff --git a/host/src/sandbox/server.ts b/host/src/sandbox/server.ts
index 76fdb9ee..ae7e6c7c 100644
--- a/host/src/sandbox/server.ts
+++ b/host/src/sandbox/server.ts
@@ -525,12 +525,18 @@ export class SandboxServer extends EventEmitter {
});
}
+ const reconnectBridge = (promise: Promise) => {
+ void promise.catch((err) => {
+ this.emit("error", err);
+ });
+ };
+
this.controller.on("state", (state) => {
if (state === "running") {
- this.bridge.connect();
- this.fsBridge.connect();
- this.sshBridge.connect();
- this.ingressBridge.connect();
+ reconnectBridge(this.bridge.connect());
+ reconnectBridge(this.fsBridge.connect());
+ reconnectBridge(this.sshBridge.connect());
+ reconnectBridge(this.ingressBridge.connect());
}
if (state === "stopped") {
// The controller emits state="stopped" before emitting "exit".
diff --git a/host/src/secret-host-suggestions.ts b/host/src/secret-host-suggestions.ts
index c9b5f172..5e7ee278 100644
--- a/host/src/secret-host-suggestions.ts
+++ b/host/src/secret-host-suggestions.ts
@@ -1,4 +1,5 @@
import fs from "fs";
+import os from "os";
import path from "path";
import { spawn } from "node:child_process";
@@ -75,7 +76,9 @@ async function runTrufflehogSecretDetection(
secretValue: string,
): Promise {
const binaryPath = await ensureTrufflehogBinary();
- const tmpRoot = fs.mkdtempSync(path.join(process.env.TMPDIR ?? "/tmp", "gondolin-secret-detect-"));
+ const tmpRoot = fs.mkdtempSync(
+ path.join(process.env.TMPDIR ?? os.tmpdir(), "gondolin-secret-detect-"),
+ );
const filePath = path.join(tmpRoot, "secret.txt");
try {
diff --git a/host/src/session-registry.ts b/host/src/session-registry.ts
index c62096d7..0b2dda25 100644
--- a/host/src/session-registry.ts
+++ b/host/src/session-registry.ts
@@ -3,6 +3,13 @@ import net from "net";
import path from "path";
import { gondolinCacheDir } from "./cache.ts";
+import {
+ createNetConnectOptions,
+ listenOnLocalEndpoint,
+ normalizeLocalEndpoint,
+ type LocalEndpoint,
+ type LocalEndpointInput,
+} from "./local-endpoint.ts";
import type { SandboxConnection } from "./sandbox/client.ts";
import {
decodeOutputFrame,
@@ -31,8 +38,10 @@ export type SessionInfo = {
id: string;
/** host process pid */
pid: number;
- /** unix socket path for IPC */
- socketPath: string;
+ /** local IPC endpoint */
+ endpoint: LocalEndpoint;
+ /** legacy unix socket path for IPC */
+ socketPath?: string;
/** iso 8601 creation timestamp */
createdAt: string;
/** human-readable label */
@@ -41,7 +50,7 @@ export type SessionInfo = {
/** discovered session entry */
export type SessionEntry = SessionInfo & {
- /** whether the session socket is connectable */
+ /** whether the session endpoint is connectable */
alive: boolean;
};
@@ -58,6 +67,21 @@ function socketPath(id: string): string {
return path.join(SESSIONS_DIR, `${id}.sock`);
}
+export function createSessionEndpoint(id: string): LocalEndpoint {
+ if (process.platform === "win32") {
+ return {
+ transport: "tcp",
+ host: "127.0.0.1",
+ port: 0,
+ };
+ }
+
+ return {
+ transport: "unix",
+ path: socketPath(id),
+ };
+}
+
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
@@ -67,14 +91,77 @@ function isPidAlive(pid: number): boolean {
}
}
-function isSocketAlive(sockPath: string, timeoutMs = 500): Promise {
+function parseSessionInfo(raw: unknown): SessionInfo | null {
+ if (!raw || typeof raw !== "object") return null;
+
+ const value = raw as {
+ id?: unknown;
+ pid?: unknown;
+ endpoint?: unknown;
+ socketPath?: unknown;
+ createdAt?: unknown;
+ label?: unknown;
+ };
+
+ const pid = value.pid;
+ if (
+ typeof value.id !== "string" ||
+ typeof pid !== "number" ||
+ !Number.isInteger(pid)
+ ) {
+ return null;
+ }
+
+ let endpoint: LocalEndpoint;
+ try {
+ if (value.endpoint !== undefined) {
+ endpoint = normalizeLocalEndpoint(
+ value.endpoint as LocalEndpointInput,
+ "session.endpoint",
+ );
+ } else if (
+ typeof value.socketPath === "string" &&
+ value.socketPath.length > 0
+ ) {
+ endpoint = normalizeLocalEndpoint(
+ value.socketPath,
+ "session.socketPath",
+ {
+ platform: "linux",
+ },
+ );
+ } else {
+ return null;
+ }
+ } catch {
+ return null;
+ }
+
+ return {
+ id: value.id,
+ pid,
+ endpoint,
+ socketPath:
+ typeof value.socketPath === "string" ? value.socketPath : undefined,
+ createdAt:
+ typeof value.createdAt === "string"
+ ? value.createdAt
+ : new Date(0).toISOString(),
+ label: typeof value.label === "string" ? value.label : undefined,
+ };
+}
+
+function isEndpointAlive(
+ endpoint: LocalEndpoint,
+ timeoutMs = 500,
+): Promise {
return new Promise((resolve) => {
- if (!fs.existsSync(sockPath)) {
+ if (endpoint.transport === "unix" && !fs.existsSync(endpoint.path)) {
resolve(false);
return;
}
- const socket = net.createConnection({ path: sockPath });
+ const socket = net.createConnection(createNetConnectOptions(endpoint));
const timer = setTimeout(() => {
socket.destroy();
resolve(false);
@@ -94,37 +181,68 @@ function isSocketAlive(sockPath: string, timeoutMs = 500): Promise {
}
/** register a live session */
-export function registerSession(options: { id: string; label?: string }): {
- socketPath: string;
+export function registerSession(options: {
+ id: string;
+ endpoint?: LocalEndpointInput;
+ label?: string;
+}): {
+ endpoint: LocalEndpoint;
+ socketPath?: string;
metadataPath: string;
} {
ensureSessionsDir();
- const sockPath = socketPath(options.id);
+ const endpoint = normalizeLocalEndpoint(
+ options.endpoint ?? createSessionEndpoint(options.id),
+ "session.endpoint",
+ );
const metaPath = metadataPath(options.id);
const info: SessionInfo = {
id: options.id,
pid: process.pid,
- socketPath: sockPath,
+ endpoint,
+ socketPath: endpoint.transport === "unix" ? endpoint.path : undefined,
createdAt: new Date().toISOString(),
label: options.label,
};
fs.writeFileSync(metaPath, JSON.stringify(info, null, 2) + "\n");
- return { socketPath: sockPath, metadataPath: metaPath };
+ return {
+ endpoint,
+ socketPath: endpoint.transport === "unix" ? endpoint.path : undefined,
+ metadataPath: metaPath,
+ };
}
/** unregister a session */
export function unregisterSession(id: string): void {
+ let endpoint: LocalEndpoint | null = null;
+
+ try {
+ const parsed = parseSessionInfo(
+ JSON.parse(fs.readFileSync(metadataPath(id), "utf8")),
+ );
+ endpoint = parsed?.endpoint ?? null;
+ } catch {
+ // ignore
+ }
+
try {
fs.rmSync(metadataPath(id), { force: true });
} catch {
// ignore
}
+ const defaultEndpoint = createSessionEndpoint(id);
+ const unixPath =
+ endpoint?.transport === "unix"
+ ? endpoint.path
+ : defaultEndpoint.transport === "unix"
+ ? defaultEndpoint.path
+ : socketPath(id);
try {
- fs.rmSync(socketPath(id), { force: true });
+ fs.rmSync(unixPath, { force: true });
} catch {
// ignore
}
@@ -149,13 +267,15 @@ export async function listSessions(): Promise {
const filePath = path.join(dir, file);
try {
- const info = JSON.parse(fs.readFileSync(filePath, "utf8")) as SessionInfo;
- if (!info.id || !Number.isInteger(info.pid) || !info.socketPath) {
+ const info = parseSessionInfo(
+ JSON.parse(fs.readFileSync(filePath, "utf8")),
+ );
+ if (!info) {
continue;
}
const pidAlive = isPidAlive(info.pid);
- const sockAlive = pidAlive ? await isSocketAlive(info.socketPath) : false;
+ const sockAlive = pidAlive ? await isEndpointAlive(info.endpoint) : false;
entries.push({ ...info, alive: pidAlive && sockAlive });
} catch {
@@ -190,8 +310,10 @@ export async function gcSessions(): Promise {
const filePath = path.join(dir, file);
try {
- const info = JSON.parse(fs.readFileSync(filePath, "utf8")) as SessionInfo;
- if (!info.id || !Number.isInteger(info.pid) || !info.socketPath) {
+ const info = parseSessionInfo(
+ JSON.parse(fs.readFileSync(filePath, "utf8")),
+ );
+ if (!info) {
staleIds.add(file.replace(/\.json$/, ""));
continue;
}
@@ -203,7 +325,7 @@ export async function gcSessions(): Promise {
continue;
}
- const alive = await isSocketAlive(info.socketPath);
+ const alive = await isEndpointAlive(info.endpoint);
if (!alive) {
staleIds.add(info.id);
}
@@ -315,7 +437,7 @@ export class SessionIpcServer {
private clients = new Set();
private allocatedInternalIds = new Set();
private nextInternalId = 0xffffffff;
- private readonly sockPath: string;
+ private readonly endpoint: LocalEndpointInput;
private readonly connectToSandbox: (
onMessage: (data: Buffer | string, isBinary: boolean) => void,
onClose?: () => void,
@@ -323,27 +445,21 @@ export class SessionIpcServer {
private readonly handlers: SessionIpcServerHandlers;
constructor(
- sockPath: string,
+ endpoint: LocalEndpointInput,
connectToSandbox: (
onMessage: (data: Buffer | string, isBinary: boolean) => void,
onClose?: () => void,
) => SandboxConnection,
handlers: SessionIpcServerHandlers = {},
) {
- this.sockPath = sockPath;
+ this.endpoint = endpoint;
this.connectToSandbox = connectToSandbox;
this.handlers = handlers;
}
- start(): void {
+ async start(): Promise {
if (this.server) return;
- try {
- fs.rmSync(this.sockPath, { force: true });
- } catch {
- // ignore
- }
-
const server = net.createServer((socket) => {
this.handleConnection(socket);
});
@@ -352,8 +468,21 @@ export class SessionIpcServer {
// ignore
});
- server.listen(this.sockPath);
this.server = server;
+ try {
+ await listenOnLocalEndpoint(
+ server,
+ normalizeLocalEndpoint(this.endpoint, "session ipc endpoint"),
+ );
+ } catch (err) {
+ this.server = null;
+ try {
+ server.close();
+ } catch {
+ // ignore
+ }
+ throw err;
+ }
}
async close(): Promise {
@@ -372,10 +501,16 @@ export class SessionIpcServer {
await new Promise((resolve) => server.close(() => resolve()));
}
- try {
- fs.rmSync(this.sockPath, { force: true });
- } catch {
- // ignore
+ const endpoint = normalizeLocalEndpoint(
+ this.endpoint,
+ "session ipc endpoint",
+ );
+ if (endpoint.transport === "unix") {
+ try {
+ fs.rmSync(endpoint.path, { force: true });
+ } catch {
+ // ignore
+ }
}
}
@@ -732,15 +867,19 @@ export type IpcClientCallbacks = {
onClose: (error?: Error) => void;
};
-/** connect to an external session IPC socket */
+/** connect to an external session IPC endpoint */
export function connectToSession(
- sockPath: string,
+ endpoint: LocalEndpointInput,
callbacks: IpcClientCallbacks,
): {
send: (message: ClientMessage) => void;
close: () => void;
} {
- const socket = net.createConnection({ path: sockPath });
+ const socket = net.createConnection(
+ createNetConnectOptions(
+ normalizeLocalEndpoint(endpoint, "session endpoint"),
+ ),
+ );
socket.setNoDelay(true);
let closed = false;
diff --git a/host/src/vm/core.ts b/host/src/vm/core.ts
index 3ad1aa82..f6f1574c 100644
--- a/host/src/vm/core.ts
+++ b/host/src/vm/core.ts
@@ -46,6 +46,7 @@ import type { SandboxConnection } from "../sandbox/client.ts";
import type { SandboxState } from "../sandbox/controller.ts";
import {
SessionIpcServer,
+ createSessionEndpoint,
gcSessions,
registerSession,
unregisterSession,
@@ -500,6 +501,7 @@ export class VM {
const manifestRootfsMode = resolveManifestRootfsMode(resolved);
const rootfsMode = options.rootfs?.mode ?? manifestRootfsMode ?? "cow";
const supportsSnapshotRootDisk = (resolved.vmm ?? "qemu") === "qemu";
+ const qemuImgPathHint = resolved.qemuPath;
try {
// Prepare root disk:
@@ -519,9 +521,9 @@ export class VM {
readOnly: false,
snapshot: true,
})
- : prepareOverlayRootDisk(resolved);
+ : prepareOverlayRootDisk(resolved, qemuImgPathHint);
} else if (rootfsMode === "cow") {
- this.rootDisk = prepareOverlayRootDisk(resolved);
+ this.rootDisk = prepareOverlayRootDisk(resolved, qemuImgPathHint);
} else {
throw new Error(`unsupported rootfs mode: ${String(rootfsMode)}`);
}
@@ -531,6 +533,7 @@ export class VM {
this.rootDisk,
resolved.rootfsPath,
rootfsSizeBytes,
+ qemuImgPathHint,
);
this.rootfsGuestResizePending = true;
}
@@ -579,6 +582,19 @@ export class VM {
}
}
+ /**
+ * Return the resolved backend runtime settings for this VM
+ */
+ getBackendInfo() {
+ return {
+ vmm: this.resolvedSandboxOptions.vmm,
+ qemuPath: this.resolvedSandboxOptions.qemuPath,
+ accel: this.resolvedSandboxOptions.accel,
+ cpu: this.resolvedSandboxOptions.cpu,
+ machineType: this.resolvedSandboxOptions.machineType,
+ };
+ }
+
/**
* Start the VM.
*
@@ -1292,15 +1308,12 @@ fi
this.ensureStartupGeneration(startupGeneration);
}
- const { socketPath } = registerSession({
- id: this.id,
- label: this.sessionLabel,
- });
+ const sessionEndpoint = createSessionEndpoint(this.id);
let sessionIpc: SessionIpcServer | null = null;
try {
sessionIpc = new SessionIpcServer(
- socketPath,
+ sessionEndpoint,
(onMessage, onClose) => {
const server = this.server;
if (!server) {
@@ -1329,7 +1342,13 @@ fi
this.ensureStartupGeneration(startupGeneration);
}
- sessionIpc.start();
+ await sessionIpc.start();
+
+ registerSession({
+ id: this.id,
+ endpoint: sessionEndpoint,
+ label: this.sessionLabel,
+ });
if (startupGeneration !== undefined) {
this.ensureStartupGeneration(startupGeneration);
@@ -1966,19 +1985,24 @@ fi
const resolvedCheckpointPath = path.resolve(checkpointPath);
const rootfsPath = path.resolve(this.resolvedSandboxOptions.rootfsPath);
- const backingPath = resolveQcow2BackingPath(rootDisk.path);
+ const qemuImgPathHint = this.resolvedSandboxOptions.qemuPath;
+ const backingPath = resolveQcow2BackingPath(rootDisk.path, qemuImgPathHint);
if (backingPath && backingPath !== rootfsPath) {
// Collapse resume-generated checkpoint ancestry before we publish this
// overlay as the new checkpoint file.
- ensureQemuImgAvailable();
+ ensureQemuImgAvailable(qemuImgPathHint);
rebaseQcow2InPlace(
rootDisk.path,
rootfsPath,
inferDiskFormatFromPath(rootfsPath),
"safe",
+ qemuImgPathHint,
);
- const rebasedBackingPath = resolveQcow2BackingPath(rootDisk.path);
+ const rebasedBackingPath = resolveQcow2BackingPath(
+ rootDisk.path,
+ qemuImgPathHint,
+ );
if (rebasedBackingPath === resolvedCheckpointPath) {
throw new Error(
`cannot checkpoint: rebased overlay still points to destination checkpoint path (${resolvedCheckpointPath})`,
@@ -2114,12 +2138,14 @@ function prepareBaseRootDisk(
function prepareOverlayRootDisk(
resolved: ResolvedSandboxServerOptions,
+ qemuImgPathHint = resolved.qemuPath,
): RootDiskState {
- ensureQemuImgAvailable();
+ ensureQemuImgAvailable(qemuImgPathHint);
return installRootDisk(resolved, {
path: createTempQcow2Overlay(
resolved.rootfsPath,
inferDiskFormatFromPath(resolved.rootfsPath),
+ qemuImgPathHint,
),
format: "qcow2",
snapshot: false,
@@ -2146,6 +2172,7 @@ function prepareRootDiskResize(
rootDisk: RootDiskState | null,
rootfsPath: string,
sizeBytes: number,
+ qemuImgPathHint?: string,
): void {
if (!rootDisk) {
throw new Error("rootfs.size requires a root disk");
@@ -2161,8 +2188,8 @@ function prepareRootDiskResize(
);
}
- ensureQemuImgAvailable();
- ensureDiskImageMinimumSize(rootDisk.path, sizeBytes);
+ ensureQemuImgAvailable(qemuImgPathHint);
+ ensureDiskImageMinimumSize(rootDisk.path, sizeBytes, qemuImgPathHint);
}
function resolveManifestRootfsMode(
diff --git a/host/test/alpine-tar.test.ts b/host/test/alpine-tar.test.ts
index eb347d81..743dc202 100644
--- a/host/test/alpine-tar.test.ts
+++ b/host/test/alpine-tar.test.ts
@@ -3,8 +3,9 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
+import { gzipSync } from "node:zlib";
-import { extractEntries, parseTar } from "../src/alpine/tar.ts";
+import { extractEntries, extractTarGz, parseTar } from "../src/alpine/tar.ts";
interface TarBuildEntry {
/** entry name stored in the tar header */
@@ -99,6 +100,46 @@ test("extractEntries preserves zero-byte regular files", () => {
}
});
+test("extractTarGz extracts long-path gzip archives", async () => {
+ const longPath =
+ "usr/lib/node_modules/npm/node_modules/@sigstore/protobuf-specs/dist/__generated__/google/api/field_behavior.js";
+ const body = Buffer.alloc(1024 * 1024 + 137, 0x61);
+ const tar = buildTar([
+ {
+ name: "././@LongLink",
+ type: "L",
+ body: Buffer.from(`${longPath}\0`, "utf8"),
+ },
+ {
+ name: "field_behavior.js",
+ type: "0",
+ body,
+ },
+ ]);
+
+ const archivePath = path.join(
+ os.tmpdir(),
+ `gondolin-tar-${process.pid}-${Date.now()}.tar.gz`,
+ );
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-tar-"));
+
+ try {
+ fs.writeFileSync(archivePath, gzipSync(tar));
+ await extractTarGz(archivePath, tmpDir);
+
+ const extractedPath = path.join(tmpDir, longPath);
+ const stat = fs.statSync(extractedPath);
+ assert.equal(stat.size, body.length);
+
+ const extracted = fs.readFileSync(extractedPath);
+ assert.equal(extracted[0], 0x61);
+ assert.equal(extracted[extracted.length - 1], 0x61);
+ } finally {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ fs.rmSync(archivePath, { force: true });
+ }
+});
+
function buildPaxRecord(key: string, value: string): string {
const body = `${key}=${value}\n`;
let length = Buffer.byteLength(body, "utf8") + 3;
diff --git a/host/test/backend-parity.test.ts b/host/test/backend-parity.test.ts
index c5c4e814..f97c1491 100644
--- a/host/test/backend-parity.test.ts
+++ b/host/test/backend-parity.test.ts
@@ -100,6 +100,11 @@ async function skipIfBackendUnavailable(
t: test.TestContext,
backend: BackendName,
): Promise {
+ if (process.platform === "win32" && backend === "krun") {
+ t.skip("krun is not supported on Windows hosts");
+ return true;
+ }
+
if (shouldSkipVmTests()) {
t.skip("hardware virtualization unavailable");
return true;
diff --git a/host/test/build-alpine-postbuild.test.ts b/host/test/build-alpine-postbuild.test.ts
index e6057420..4a76b8b7 100644
--- a/host/test/build-alpine-postbuild.test.ts
+++ b/host/test/build-alpine-postbuild.test.ts
@@ -8,6 +8,11 @@ import test from "node:test";
import { runPostBuildCommands } from "../src/alpine/packages.ts";
import type { Architecture } from "../src/build/config.ts";
+const skipWindowsPostBuildTests =
+ process.platform === "win32"
+ ? "postBuild procfs/chroot tests require Linux mount semantics"
+ : false;
+
function runtimeArch(): Architecture {
if (process.arch === "arm64") return "aarch64";
return "x86_64";
@@ -20,194 +25,218 @@ function writeStubCommand(binDir: string, name: string, body: string): void {
});
}
-test("postBuild: mounts procfs before running chroot commands", () => {
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-postbuild-"));
- const rootfsDir = path.join(tmp, "rootfs");
- const binDir = path.join(tmp, "bin");
- const callLog = path.join(tmp, "calls.log");
-
- fs.mkdirSync(path.join(rootfsDir, "bin"), { recursive: true });
- fs.mkdirSync(binDir, { recursive: true });
- fs.writeFileSync(path.join(rootfsDir, "bin", "sh"), "#!/bin/sh\n", {
- mode: 0o755,
- });
-
- writeStubCommand(binDir, "mount", 'printf "mount %s\\n" "$*" >> "$CALL_LOG"');
- writeStubCommand(
- binDir,
- "chroot",
- 'printf "chroot %s\\n" "$*" >> "$CALL_LOG"',
- );
- writeStubCommand(
- binDir,
- "umount",
- 'printf "umount %s\\n" "$*" >> "$CALL_LOG"',
- );
-
- const oldPath = process.env.PATH;
- const oldCallLog = process.env.CALL_LOG;
- const oldGetuid = process.getuid;
- const oldPlatform = Object.getOwnPropertyDescriptor(process, "platform");
-
- try {
- process.env.PATH = `${binDir}:${oldPath ?? ""}`;
- process.env.CALL_LOG = callLog;
- process.getuid = () => 0;
- Object.defineProperty(process, "platform", { value: "linux" });
-
- runPostBuildCommands(rootfsDir, ["echo hello"], runtimeArch(), () => {});
-
- const lines = fs
- .readFileSync(callLog, "utf8")
- .trim()
- .split("\n")
- .filter(Boolean);
-
- assert.equal(lines.length, 3);
- assert.equal(
- lines[0],
- `mount -t proc proc ${path.join(rootfsDir, "proc")}`,
+test(
+ "postBuild: mounts procfs before running chroot commands",
+ { skip: skipWindowsPostBuildTests },
+ () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-postbuild-"));
+ const rootfsDir = path.join(tmp, "rootfs");
+ const binDir = path.join(tmp, "bin");
+ const callLog = path.join(tmp, "calls.log");
+
+ fs.mkdirSync(path.join(rootfsDir, "bin"), { recursive: true });
+ fs.mkdirSync(binDir, { recursive: true });
+ fs.writeFileSync(path.join(rootfsDir, "bin", "sh"), "#!/bin/sh\n", {
+ mode: 0o755,
+ });
+
+ writeStubCommand(
+ binDir,
+ "mount",
+ 'printf "mount %s\\n" "$*" >> "$CALL_LOG"',
);
- assert.equal(
- lines[1],
- `chroot ${path.resolve(rootfsDir)} /bin/sh -lc echo hello`,
+ writeStubCommand(
+ binDir,
+ "chroot",
+ 'printf "chroot %s\\n" "$*" >> "$CALL_LOG"',
);
- assert.equal(lines[2], `umount ${path.join(rootfsDir, "proc")}`);
- } finally {
- process.env.PATH = oldPath;
- process.env.CALL_LOG = oldCallLog;
- process.getuid = oldGetuid;
- if (oldPlatform) {
- Object.defineProperty(process, "platform", oldPlatform);
- }
- fs.rmSync(tmp, { recursive: true, force: true });
- }
-});
-
-test("postBuild: unmounts procfs even when a command fails", () => {
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-postbuild-"));
- const rootfsDir = path.join(tmp, "rootfs");
- const binDir = path.join(tmp, "bin");
- const callLog = path.join(tmp, "calls.log");
-
- fs.mkdirSync(path.join(rootfsDir, "bin"), { recursive: true });
- fs.mkdirSync(binDir, { recursive: true });
- fs.writeFileSync(path.join(rootfsDir, "bin", "sh"), "#!/bin/sh\n", {
- mode: 0o755,
- });
-
- writeStubCommand(binDir, "mount", 'printf "mount %s\\n" "$*" >> "$CALL_LOG"');
- writeStubCommand(
- binDir,
- "chroot",
- 'printf "chroot %s\\n" "$*" >> "$CALL_LOG"; printf "boom\\n" >&2; exit 42',
- );
- writeStubCommand(
- binDir,
- "umount",
- 'printf "umount %s\\n" "$*" >> "$CALL_LOG"',
- );
-
- const oldPath = process.env.PATH;
- const oldCallLog = process.env.CALL_LOG;
- const oldGetuid = process.getuid;
- const oldPlatform = Object.getOwnPropertyDescriptor(process, "platform");
-
- try {
- process.env.PATH = `${binDir}:${oldPath ?? ""}`;
- process.env.CALL_LOG = callLog;
- process.getuid = () => 0;
- Object.defineProperty(process, "platform", { value: "linux" });
-
- assert.throws(
- () =>
- runPostBuildCommands(
- rootfsDir,
- ["echo broken"],
- runtimeArch(),
- () => {},
- ),
- /postBuild command failed \(1\/1\): echo broken[\s\S]*exit: 42/,
+ writeStubCommand(
+ binDir,
+ "umount",
+ 'printf "umount %s\\n" "$*" >> "$CALL_LOG"',
);
- const lines = fs
- .readFileSync(callLog, "utf8")
- .trim()
- .split("\n")
- .filter(Boolean);
-
- assert.equal(
- lines[0],
- `mount -t proc proc ${path.join(rootfsDir, "proc")}`,
+ const oldPath = process.env.PATH;
+ const oldCallLog = process.env.CALL_LOG;
+ const oldGetuid = process.getuid;
+ const oldPlatform = Object.getOwnPropertyDescriptor(process, "platform");
+
+ try {
+ process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`;
+ process.env.CALL_LOG = callLog;
+ process.getuid = () => 0;
+ Object.defineProperty(process, "platform", { value: "linux" });
+
+ runPostBuildCommands(rootfsDir, ["echo hello"], runtimeArch(), () => {});
+
+ const lines = fs
+ .readFileSync(callLog, "utf8")
+ .trim()
+ .split("\n")
+ .filter(Boolean);
+
+ assert.equal(lines.length, 3);
+ assert.equal(
+ lines[0],
+ `mount -t proc proc ${path.join(rootfsDir, "proc")}`,
+ );
+ assert.equal(
+ lines[1],
+ `chroot ${path.resolve(rootfsDir)} /bin/sh -lc echo hello`,
+ );
+ assert.equal(lines[2], `umount ${path.join(rootfsDir, "proc")}`);
+ } finally {
+ process.env.PATH = oldPath;
+ process.env.CALL_LOG = oldCallLog;
+ process.getuid = oldGetuid;
+ if (oldPlatform) {
+ Object.defineProperty(process, "platform", oldPlatform);
+ }
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ },
+);
+
+test(
+ "postBuild: unmounts procfs even when a command fails",
+ { skip: skipWindowsPostBuildTests },
+ () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-postbuild-"));
+ const rootfsDir = path.join(tmp, "rootfs");
+ const binDir = path.join(tmp, "bin");
+ const callLog = path.join(tmp, "calls.log");
+
+ fs.mkdirSync(path.join(rootfsDir, "bin"), { recursive: true });
+ fs.mkdirSync(binDir, { recursive: true });
+ fs.writeFileSync(path.join(rootfsDir, "bin", "sh"), "#!/bin/sh\n", {
+ mode: 0o755,
+ });
+
+ writeStubCommand(
+ binDir,
+ "mount",
+ 'printf "mount %s\\n" "$*" >> "$CALL_LOG"',
+ );
+ writeStubCommand(
+ binDir,
+ "chroot",
+ 'printf "chroot %s\\n" "$*" >> "$CALL_LOG"; printf "boom\\n" >&2; exit 42',
+ );
+ writeStubCommand(
+ binDir,
+ "umount",
+ 'printf "umount %s\\n" "$*" >> "$CALL_LOG"',
);
- assert.equal(lines[2], `umount ${path.join(rootfsDir, "proc")}`);
- } finally {
- process.env.PATH = oldPath;
- process.env.CALL_LOG = oldCallLog;
- process.getuid = oldGetuid;
- if (oldPlatform) {
- Object.defineProperty(process, "platform", oldPlatform);
+
+ const oldPath = process.env.PATH;
+ const oldCallLog = process.env.CALL_LOG;
+ const oldGetuid = process.getuid;
+ const oldPlatform = Object.getOwnPropertyDescriptor(process, "platform");
+
+ try {
+ process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`;
+ process.env.CALL_LOG = callLog;
+ process.getuid = () => 0;
+ Object.defineProperty(process, "platform", { value: "linux" });
+
+ assert.throws(
+ () =>
+ runPostBuildCommands(
+ rootfsDir,
+ ["echo broken"],
+ runtimeArch(),
+ () => {},
+ ),
+ /postBuild command failed \(1\/1\): echo broken[\s\S]*exit: 42/,
+ );
+
+ const lines = fs
+ .readFileSync(callLog, "utf8")
+ .trim()
+ .split("\n")
+ .filter(Boolean);
+
+ assert.equal(
+ lines[0],
+ `mount -t proc proc ${path.join(rootfsDir, "proc")}`,
+ );
+ assert.equal(lines[2], `umount ${path.join(rootfsDir, "proc")}`);
+ } finally {
+ process.env.PATH = oldPath;
+ process.env.CALL_LOG = oldCallLog;
+ process.getuid = oldGetuid;
+ if (oldPlatform) {
+ Object.defineProperty(process, "platform", oldPlatform);
+ }
+ fs.rmSync(tmp, { recursive: true, force: true });
}
- fs.rmSync(tmp, { recursive: true, force: true });
- }
-});
-
-test("postBuild: accepts /bin/sh absolute symlinks inside rootfs", () => {
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-postbuild-"));
- const rootfsDir = path.join(tmp, "rootfs");
- const binDir = path.join(tmp, "bin");
- const callLog = path.join(tmp, "calls.log");
-
- fs.mkdirSync(path.join(rootfsDir, "bin"), { recursive: true });
- fs.mkdirSync(binDir, { recursive: true });
- fs.writeFileSync(path.join(rootfsDir, "bin", "busybox"), "busybox", {
- mode: 0o755,
- });
- fs.symlinkSync("/bin/busybox", path.join(rootfsDir, "bin", "sh"));
-
- writeStubCommand(binDir, "mount", 'printf "mount %s\\n" "$*" >> "$CALL_LOG"');
- writeStubCommand(
- binDir,
- "chroot",
- 'printf "chroot %s\\n" "$*" >> "$CALL_LOG"',
- );
- writeStubCommand(
- binDir,
- "umount",
- 'printf "umount %s\\n" "$*" >> "$CALL_LOG"',
- );
-
- const oldPath = process.env.PATH;
- const oldCallLog = process.env.CALL_LOG;
- const oldGetuid = process.getuid;
- const oldPlatform = Object.getOwnPropertyDescriptor(process, "platform");
-
- try {
- process.env.PATH = `${binDir}:${oldPath ?? ""}`;
- process.env.CALL_LOG = callLog;
- process.getuid = () => 0;
- Object.defineProperty(process, "platform", { value: "linux" });
-
- runPostBuildCommands(rootfsDir, ["echo shell"], runtimeArch(), () => {});
-
- const lines = fs
- .readFileSync(callLog, "utf8")
- .trim()
- .split("\n")
- .filter(Boolean);
-
- assert.equal(lines.length, 3);
- assert.equal(
- lines[1],
- `chroot ${path.resolve(rootfsDir)} /bin/sh -lc echo shell`,
+ },
+);
+
+test(
+ "postBuild: accepts /bin/sh absolute symlinks inside rootfs",
+ { skip: skipWindowsPostBuildTests },
+ () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-postbuild-"));
+ const rootfsDir = path.join(tmp, "rootfs");
+ const binDir = path.join(tmp, "bin");
+ const callLog = path.join(tmp, "calls.log");
+
+ fs.mkdirSync(path.join(rootfsDir, "bin"), { recursive: true });
+ fs.mkdirSync(binDir, { recursive: true });
+ fs.writeFileSync(path.join(rootfsDir, "bin", "busybox"), "busybox", {
+ mode: 0o755,
+ });
+ fs.symlinkSync("/bin/busybox", path.join(rootfsDir, "bin", "sh"));
+
+ writeStubCommand(
+ binDir,
+ "mount",
+ 'printf "mount %s\\n" "$*" >> "$CALL_LOG"',
+ );
+ writeStubCommand(
+ binDir,
+ "chroot",
+ 'printf "chroot %s\\n" "$*" >> "$CALL_LOG"',
);
- } finally {
- process.env.PATH = oldPath;
- process.env.CALL_LOG = oldCallLog;
- process.getuid = oldGetuid;
- if (oldPlatform) {
- Object.defineProperty(process, "platform", oldPlatform);
+ writeStubCommand(
+ binDir,
+ "umount",
+ 'printf "umount %s\\n" "$*" >> "$CALL_LOG"',
+ );
+
+ const oldPath = process.env.PATH;
+ const oldCallLog = process.env.CALL_LOG;
+ const oldGetuid = process.getuid;
+ const oldPlatform = Object.getOwnPropertyDescriptor(process, "platform");
+
+ try {
+ process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`;
+ process.env.CALL_LOG = callLog;
+ process.getuid = () => 0;
+ Object.defineProperty(process, "platform", { value: "linux" });
+
+ runPostBuildCommands(rootfsDir, ["echo shell"], runtimeArch(), () => {});
+
+ const lines = fs
+ .readFileSync(callLog, "utf8")
+ .trim()
+ .split("\n")
+ .filter(Boolean);
+
+ assert.equal(lines.length, 3);
+ assert.equal(
+ lines[1],
+ `chroot ${path.resolve(rootfsDir)} /bin/sh -lc echo shell`,
+ );
+ } finally {
+ process.env.PATH = oldPath;
+ process.env.CALL_LOG = oldCallLog;
+ process.getuid = oldGetuid;
+ if (oldPlatform) {
+ Object.defineProperty(process, "platform", oldPlatform);
+ }
+ fs.rmSync(tmp, { recursive: true, force: true });
}
- fs.rmSync(tmp, { recursive: true, force: true });
- }
-});
+ },
+);
diff --git a/host/test/build-shared-windows-spawn.test.ts b/host/test/build-shared-windows-spawn.test.ts
new file mode 100644
index 00000000..2862c62c
--- /dev/null
+++ b/host/test/build-shared-windows-spawn.test.ts
@@ -0,0 +1,127 @@
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import { __test as sharedTest } from "../src/build/shared.ts";
+
+const { quoteCmdArg, resolveWindowsCommandPath, resolveSpawnCommand } =
+ sharedTest;
+
+test("quoteCmdArg wraps values in quotes and caret-escapes cmd.exe metacharacters", () => {
+ assert.equal(quoteCmdArg("simple"), '"simple"');
+ assert.equal(quoteCmdArg("has space"), '"has space"');
+ assert.equal(quoteCmdArg("amp&ersand"), '"amp^&ersand"');
+ assert.equal(quoteCmdArg("pipe|char"), '"pipe^|char"');
+ assert.equal(quoteCmdArg("lessthan"), '"greater^>than"');
+});
+
+test("quoteCmdArg leaves backslashes and percent signs untouched", () => {
+ // cmd.exe's batch-argument tokenizer (unlike CommandLineToArgvW) does not
+ // treat backslashes specially, and percent-doubling is a batch-file-body
+ // concept that does not apply to arguments delivered via the command line.
+ assert.equal(quoteCmdArg("trailing\\backslash\\"), '"trailing\\backslash\\"');
+ assert.equal(quoteCmdArg("percent%VAR%percent"), '"percent%VAR%percent"');
+});
+
+test("quoteCmdArg refuses arguments containing a literal double quote", () => {
+ assert.throws(() => quoteCmdArg('has"quote'), /double quote/);
+});
+
+test("resolveWindowsCommandPath returns the command unchanged when it already looks like a path", () => {
+ const resolved = resolveWindowsCommandPath("C:\\tools\\docker.exe", undefined, {
+ platform: "win32",
+ });
+ assert.equal(resolved, "C:\\tools\\docker.exe");
+});
+
+test("resolveWindowsCommandPath falls back to the bare command when nothing on PATH matches", () => {
+ const resolved = resolveWindowsCommandPath(
+ "docker",
+ { PATH: "C:\\a;C:\\tools", PATHEXT: ".EXE;.CMD" } as NodeJS.ProcessEnv,
+ { platform: "win32", existsSync: () => false },
+ );
+ assert.equal(resolved, "docker");
+});
+
+test("resolveWindowsCommandPath finds a .cmd shim on PATH", () => {
+ const resolved = resolveWindowsCommandPath(
+ "docker",
+ { PATH: "C:\\a;C:\\tools", PATHEXT: ".EXE;.CMD" } as NodeJS.ProcessEnv,
+ {
+ platform: "win32",
+ existsSync: (candidate) => candidate === "C:\\tools\\docker.cmd",
+ },
+ );
+ assert.equal(resolved, "C:\\tools\\docker.cmd");
+});
+
+test("resolveSpawnCommand is a no-op off Windows", () => {
+ const resolved = resolveSpawnCommand("docker", ["run"], {}, { platform: "linux" });
+ assert.deepEqual(resolved, { command: "docker", args: ["run"] });
+});
+
+test("resolveSpawnCommand passes non-.bat/.cmd commands through unmodified on Windows", () => {
+ const resolved = resolveSpawnCommand(
+ "C:\\tools\\docker.exe",
+ ["run", "--rm"],
+ {},
+ { platform: "win32" },
+ );
+ assert.deepEqual(resolved, { command: "C:\\tools\\docker.exe", args: ["run", "--rm"] });
+});
+
+test("resolveSpawnCommand reroutes .cmd files through cmd.exe without `call`, wrapped for /s", () => {
+ const resolved = resolveSpawnCommand(
+ "C:\\tools\\docker.cmd",
+ ["run", "arg with space", "amp&ersand"],
+ {},
+ { platform: "win32", existsSync: () => false },
+ );
+ assert.equal(resolved.command, process.env.ComSpec ?? "cmd.exe");
+ assert.equal(resolved.windowsVerbatimArguments, true);
+ assert.deepEqual(resolved.args.slice(0, 3), ["/d", "/s", "/c"]);
+ const inner = resolved.args[3];
+ assert.match(inner, /^".*"$/); // wrapped in exactly one extra outer quote pair
+ assert.doesNotMatch(inner, /\bcall\b/);
+ assert.match(inner, /"arg with space"/);
+ assert.match(inner, /amp\^&ersand/);
+});
+
+test.describe("resolveSpawnCommand end-to-end via real cmd.exe", { skip: process.platform !== "win32" }, () => {
+ test("delivers arguments with spaces and metacharacters to a real .cmd script intact", async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-cmd-spawn-test-"));
+ const scriptPath = path.join(dir, "echoargs.cmd");
+ fs.writeFileSync(
+ scriptPath,
+ [
+ "@echo off",
+ ":loop",
+ 'if "%~1"=="" goto :eof',
+ "echo ARG=[%~1]",
+ "shift",
+ "goto loop",
+ ].join("\r\n"),
+ );
+
+ const cases = ["simple", "has space", "amp&ersand", "pipe|char", "trailing\\backslash\\"];
+ const resolved = resolveSpawnCommand(scriptPath, cases, {});
+
+ const output = await new Promise((resolve, reject) => {
+ const child = spawn(resolved.command, resolved.args, {
+ windowsVerbatimArguments: resolved.windowsVerbatimArguments,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ let out = "";
+ child.stdout?.on("data", (d) => (out += d.toString()));
+ child.on("error", reject);
+ child.on("close", () => resolve(out));
+ });
+
+ const expected = cases.map((c) => `ARG=[${c}]`).join("\r\n") + "\r\n";
+ assert.equal(output, expected);
+ });
+});
diff --git a/host/test/cli-qemu-missing.test.ts b/host/test/cli-qemu-missing.test.ts
index 4bc4b1d0..9ca991e3 100644
--- a/host/test/cli-qemu-missing.test.ts
+++ b/host/test/cli-qemu-missing.test.ts
@@ -28,6 +28,8 @@ test("cli: gondolin bash renders a friendly error if qemu is missing from PATH",
...process.env,
GONDOLIN_GUEST_DIR: guestDir,
PATH: emptyPathDir,
+ ProgramFiles: "",
+ ProgramW6432: "",
},
encoding: "utf8",
timeout: 15000,
@@ -42,7 +44,11 @@ test("cli: gondolin bash renders a friendly error if qemu is missing from PATH",
const stderr = result.stderr ?? "";
assert.match(stderr, /QEMU binary '.*qemu.*' not found/i);
- if (process.platform === "darwin") {
+ if (process.platform === "win32") {
+ assert.match(stderr, /qemu-w64-setup/i);
+ assert.match(stderr, /qemu-system-x86_64w/i);
+ assert.match(stderr, /HypervisorPlatform/i);
+ } else if (process.platform === "darwin") {
assert.match(stderr, /brew install qemu/);
} else {
assert.match(stderr, /apt install qemu/i);
diff --git a/host/test/container-build.test.ts b/host/test/container-build.test.ts
index f88b7ba7..a32e1775 100644
--- a/host/test/container-build.test.ts
+++ b/host/test/container-build.test.ts
@@ -15,6 +15,11 @@ const SANDBOX_HELPER_NAMES = [
"sandboxingress",
] as const;
+const skipWindowsContainerBuildTests =
+ process.platform === "win32"
+ ? "container build stub tests require POSIX shell/docker semantics"
+ : false;
+
function createSandboxHelpersDir(root: string): string {
const helpersDir = path.join(root, "helpers");
const binDir = path.join(helpersDir, "bin");
@@ -37,18 +42,21 @@ function setEnv(name: string, value: string | undefined): void {
}
}
-test("builder: container build stages helpers and does not install Zig", async () => {
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-docker-stub-"));
- const stubDir = path.join(tmp, "bin");
- fs.mkdirSync(stubDir, { recursive: true });
-
- const dockerStubPath = path.join(stubDir, "docker");
-
- // A tiny docker stub that:
- // - responds to `docker --version`
- // - intercepts `docker run ...` and validates the generated build script
- // - writes fake assets + manifest to the mounted output dir
- const dockerStub = `#!${process.execPath}
+test(
+ "builder: container build stages helpers and does not install Zig",
+ { skip: skipWindowsContainerBuildTests },
+ async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-docker-stub-"));
+ const stubDir = path.join(tmp, "bin");
+ fs.mkdirSync(stubDir, { recursive: true });
+
+ const dockerStubPath = path.join(stubDir, "docker");
+
+ // A tiny docker stub that:
+ // - responds to `docker --version`
+ // - intercepts `docker run ...` and validates the generated build script
+ // - writes fake assets + manifest to the mounted output dir
+ const dockerStub = `#!${process.execPath}
"use strict";
const fs = require("fs");
@@ -166,69 +174,73 @@ if (args[0] === "run") {
process.exit(0);
`;
- fs.writeFileSync(dockerStubPath, dockerStub, { mode: 0o755 });
-
- const helpersDir = createSandboxHelpersDir(tmp);
- const outputDir = fs.mkdtempSync(
- path.join(os.tmpdir(), "gondolin-assets-out-"),
- );
+ fs.writeFileSync(dockerStubPath, dockerStub, { mode: 0o755 });
- const config: BuildConfig = {
- arch: "x86_64",
- distro: "alpine",
- alpine: {
- version: "3.23.0",
- },
- container: {
- force: true,
- runtime: "docker",
- image: "alpine:3.23",
- },
- };
+ const helpersDir = createSandboxHelpersDir(tmp);
+ const outputDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), "gondolin-assets-out-"),
+ );
- const oldPath = process.env.PATH;
- const oldHelpersDir = process.env.GONDOLIN_SANDBOX_HELPERS_DIR;
- try {
- process.env.PATH = `${stubDir}:${oldPath ?? ""}`;
- process.env.GONDOLIN_SANDBOX_HELPERS_DIR = helpersDir;
-
- // Sanity check: the docker stub is discoverable and executable
- const { execFileSync } = await import("node:child_process");
- const versionOut = execFileSync("docker", ["--version"], {
- encoding: "utf8",
- stdio: ["ignore", "pipe", "pipe"],
- });
- assert.match(versionOut, /0\.0\.0-stub/);
-
- const result = await buildAssets(config, {
- outputDir,
- verbose: false,
- });
-
- assert.equal(result.outputDir, outputDir);
- assert.ok(fs.existsSync(path.join(outputDir, "manifest.json")));
- assert.ok(fs.existsSync(path.join(outputDir, "vmlinuz-virt")));
-
- // Make sure buildAssets returned the manifest generated by the container build
- assert.equal(result.manifest.config.arch, "x86_64");
- assert.equal(result.manifest.config.sandboxdPath, "/work/sandboxd");
- assert.equal(result.manifest.version, 1);
- } finally {
- setEnv("PATH", oldPath);
- setEnv("GONDOLIN_SANDBOX_HELPERS_DIR", oldHelpersDir);
- fs.rmSync(tmp, { recursive: true, force: true });
- fs.rmSync(outputDir, { recursive: true, force: true });
- }
-});
+ const config: BuildConfig = {
+ arch: "x86_64",
+ distro: "alpine",
+ alpine: {
+ version: "3.23.0",
+ },
+ container: {
+ force: true,
+ runtime: "docker",
+ image: "alpine:3.23",
+ },
+ };
+
+ const oldPath = process.env.PATH;
+ const oldHelpersDir = process.env.GONDOLIN_SANDBOX_HELPERS_DIR;
+ try {
+ process.env.PATH = `${stubDir}:${oldPath ?? ""}`;
+ process.env.GONDOLIN_SANDBOX_HELPERS_DIR = helpersDir;
+
+ // Sanity check: the docker stub is discoverable and executable
+ const { execFileSync } = await import("node:child_process");
+ const versionOut = execFileSync("docker", ["--version"], {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ assert.match(versionOut, /0\.0\.0-stub/);
+
+ const result = await buildAssets(config, {
+ outputDir,
+ verbose: false,
+ });
+
+ assert.equal(result.outputDir, outputDir);
+ assert.ok(fs.existsSync(path.join(outputDir, "manifest.json")));
+ assert.ok(fs.existsSync(path.join(outputDir, "vmlinuz-virt")));
+
+ // Make sure buildAssets returned the manifest generated by the container build
+ assert.equal(result.manifest.config.arch, "x86_64");
+ assert.equal(result.manifest.config.sandboxdPath, "/work/sandboxd");
+ assert.equal(result.manifest.version, 1);
+ } finally {
+ setEnv("PATH", oldPath);
+ setEnv("GONDOLIN_SANDBOX_HELPERS_DIR", oldHelpersDir);
+ fs.rmSync(tmp, { recursive: true, force: true });
+ fs.rmSync(outputDir, { recursive: true, force: true });
+ }
+ },
+);
-test("builder: container build uses --privileged when postBuild commands are configured", async () => {
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-docker-stub-"));
- const stubDir = path.join(tmp, "bin");
- fs.mkdirSync(stubDir, { recursive: true });
+test(
+ "builder: container build uses --privileged when postBuild commands are configured",
+ { skip: skipWindowsContainerBuildTests },
+ async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-docker-stub-"));
+ const stubDir = path.join(tmp, "bin");
+ fs.mkdirSync(stubDir, { recursive: true });
- const dockerStubPath = path.join(stubDir, "docker");
+ const dockerStubPath = path.join(stubDir, "docker");
- const dockerStub = `#!${process.execPath}
+ const dockerStub = `#!${process.execPath}
"use strict";
const fs = require("fs");
@@ -308,56 +320,60 @@ if (args[0] === "run") {
process.exit(0);
`;
- fs.writeFileSync(dockerStubPath, dockerStub, { mode: 0o755 });
-
- const helpersDir = createSandboxHelpersDir(tmp);
- const outputDir = fs.mkdtempSync(
- path.join(os.tmpdir(), "gondolin-assets-out-"),
- );
-
- const config: BuildConfig = {
- arch: "x86_64",
- distro: "alpine",
- alpine: {
- version: "3.23.0",
- },
- postBuild: {
- commands: ["echo hello"],
- },
- container: {
- force: true,
- runtime: "docker",
- image: "alpine:3.23",
- },
- };
+ fs.writeFileSync(dockerStubPath, dockerStub, { mode: 0o755 });
- const oldPath = process.env.PATH;
- const oldHelpersDir = process.env.GONDOLIN_SANDBOX_HELPERS_DIR;
- try {
- process.env.PATH = `${stubDir}:${oldPath ?? ""}`;
- process.env.GONDOLIN_SANDBOX_HELPERS_DIR = helpersDir;
+ const helpersDir = createSandboxHelpersDir(tmp);
+ const outputDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), "gondolin-assets-out-"),
+ );
- await buildAssets(config, {
- outputDir,
- verbose: false,
- skipBinaries: true,
- });
- } finally {
- setEnv("PATH", oldPath);
- setEnv("GONDOLIN_SANDBOX_HELPERS_DIR", oldHelpersDir);
- fs.rmSync(tmp, { recursive: true, force: true });
- fs.rmSync(outputDir, { recursive: true, force: true });
- }
-});
+ const config: BuildConfig = {
+ arch: "x86_64",
+ distro: "alpine",
+ alpine: {
+ version: "3.23.0",
+ },
+ postBuild: {
+ commands: ["echo hello"],
+ },
+ container: {
+ force: true,
+ runtime: "docker",
+ image: "alpine:3.23",
+ },
+ };
+
+ const oldPath = process.env.PATH;
+ const oldHelpersDir = process.env.GONDOLIN_SANDBOX_HELPERS_DIR;
+ try {
+ process.env.PATH = `${stubDir}:${oldPath ?? ""}`;
+ process.env.GONDOLIN_SANDBOX_HELPERS_DIR = helpersDir;
+
+ await buildAssets(config, {
+ outputDir,
+ verbose: false,
+ skipBinaries: true,
+ });
+ } finally {
+ setEnv("PATH", oldPath);
+ setEnv("GONDOLIN_SANDBOX_HELPERS_DIR", oldHelpersDir);
+ fs.rmSync(tmp, { recursive: true, force: true });
+ fs.rmSync(outputDir, { recursive: true, force: true });
+ }
+ },
+);
-test("builder: container build stages postBuild.copy sources under /work", async () => {
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-docker-stub-"));
- const stubDir = path.join(tmp, "bin");
- fs.mkdirSync(stubDir, { recursive: true });
+test(
+ "builder: container build stages postBuild.copy sources under /work",
+ { skip: skipWindowsContainerBuildTests },
+ async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-docker-stub-"));
+ const stubDir = path.join(tmp, "bin");
+ fs.mkdirSync(stubDir, { recursive: true });
- const dockerStubPath = path.join(stubDir, "docker");
+ const dockerStubPath = path.join(stubDir, "docker");
- const dockerStub = `#!${process.execPath}
+ const dockerStub = `#!${process.execPath}
"use strict";
const fs = require("fs");
@@ -448,64 +464,68 @@ if (args[0] === "run") {
process.exit(0);
`;
- fs.writeFileSync(dockerStubPath, dockerStub, { mode: 0o755 });
+ fs.writeFileSync(dockerStubPath, dockerStub, { mode: 0o755 });
- const sourcePath = path.join(tmp, "tool.tar.gz");
- fs.writeFileSync(sourcePath, "archive");
+ const sourcePath = path.join(tmp, "tool.tar.gz");
+ fs.writeFileSync(sourcePath, "archive");
- const helpersDir = createSandboxHelpersDir(tmp);
- const outputDir = fs.mkdtempSync(
- path.join(os.tmpdir(), "gondolin-assets-out-"),
- );
-
- const config: BuildConfig = {
- arch: "x86_64",
- distro: "alpine",
- alpine: {
- version: "3.23.0",
- },
- postBuild: {
- copy: [
- {
- src: sourcePath,
- dest: "/tmp/tool.tar.gz",
- },
- ],
- },
- container: {
- force: true,
- runtime: "docker",
- image: "alpine:3.23",
- },
- };
+ const helpersDir = createSandboxHelpersDir(tmp);
+ const outputDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), "gondolin-assets-out-"),
+ );
- const oldPath = process.env.PATH;
- const oldHelpersDir = process.env.GONDOLIN_SANDBOX_HELPERS_DIR;
- try {
- process.env.PATH = `${stubDir}:${oldPath ?? ""}`;
- process.env.GONDOLIN_SANDBOX_HELPERS_DIR = helpersDir;
-
- await buildAssets(config, {
- outputDir,
- verbose: false,
- skipBinaries: true,
- });
- } finally {
- setEnv("PATH", oldPath);
- setEnv("GONDOLIN_SANDBOX_HELPERS_DIR", oldHelpersDir);
- fs.rmSync(tmp, { recursive: true, force: true });
- fs.rmSync(outputDir, { recursive: true, force: true });
- }
-});
+ const config: BuildConfig = {
+ arch: "x86_64",
+ distro: "alpine",
+ alpine: {
+ version: "3.23.0",
+ },
+ postBuild: {
+ copy: [
+ {
+ src: sourcePath,
+ dest: "/tmp/tool.tar.gz",
+ },
+ ],
+ },
+ container: {
+ force: true,
+ runtime: "docker",
+ image: "alpine:3.23",
+ },
+ };
+
+ const oldPath = process.env.PATH;
+ const oldHelpersDir = process.env.GONDOLIN_SANDBOX_HELPERS_DIR;
+ try {
+ process.env.PATH = `${stubDir}:${oldPath ?? ""}`;
+ process.env.GONDOLIN_SANDBOX_HELPERS_DIR = helpersDir;
+
+ await buildAssets(config, {
+ outputDir,
+ verbose: false,
+ skipBinaries: true,
+ });
+ } finally {
+ setEnv("PATH", oldPath);
+ setEnv("GONDOLIN_SANDBOX_HELPERS_DIR", oldHelpersDir);
+ fs.rmSync(tmp, { recursive: true, force: true });
+ fs.rmSync(outputDir, { recursive: true, force: true });
+ }
+ },
+);
-test("builder: container build preserves postBuild.copy symlinks", async () => {
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-docker-stub-"));
- const stubDir = path.join(tmp, "bin");
- fs.mkdirSync(stubDir, { recursive: true });
+test(
+ "builder: container build preserves postBuild.copy symlinks",
+ { skip: skipWindowsContainerBuildTests },
+ async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-docker-stub-"));
+ const stubDir = path.join(tmp, "bin");
+ fs.mkdirSync(stubDir, { recursive: true });
- const dockerStubPath = path.join(stubDir, "docker");
+ const dockerStubPath = path.join(stubDir, "docker");
- const dockerStub = `#!${process.execPath}
+ const dockerStub = `#!${process.execPath}
"use strict";
const fs = require("fs");
@@ -592,54 +612,55 @@ if (args[0] === "run") {
process.exit(0);
`;
- fs.writeFileSync(dockerStubPath, dockerStub, { mode: 0o755 });
+ fs.writeFileSync(dockerStubPath, dockerStub, { mode: 0o755 });
- const sourceTargetPath = path.join(tmp, "tool.tar.gz");
- const sourceLinkPath = path.join(tmp, "tool-link");
- fs.writeFileSync(sourceTargetPath, "archive");
- fs.symlinkSync("tool.tar.gz", sourceLinkPath);
+ const sourceTargetPath = path.join(tmp, "tool.tar.gz");
+ const sourceLinkPath = path.join(tmp, "tool-link");
+ fs.writeFileSync(sourceTargetPath, "archive");
+ fs.symlinkSync("tool.tar.gz", sourceLinkPath);
- const helpersDir = createSandboxHelpersDir(tmp);
- const outputDir = fs.mkdtempSync(
- path.join(os.tmpdir(), "gondolin-assets-out-"),
- );
-
- const config: BuildConfig = {
- arch: "x86_64",
- distro: "alpine",
- alpine: {
- version: "3.23.0",
- },
- postBuild: {
- copy: [
- {
- src: sourceLinkPath,
- dest: "/tmp/",
- },
- ],
- },
- container: {
- force: true,
- runtime: "docker",
- image: "alpine:3.23",
- },
- };
+ const helpersDir = createSandboxHelpersDir(tmp);
+ const outputDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), "gondolin-assets-out-"),
+ );
- const oldPath = process.env.PATH;
- const oldHelpersDir = process.env.GONDOLIN_SANDBOX_HELPERS_DIR;
- try {
- process.env.PATH = `${stubDir}:${oldPath ?? ""}`;
- process.env.GONDOLIN_SANDBOX_HELPERS_DIR = helpersDir;
-
- await buildAssets(config, {
- outputDir,
- verbose: false,
- skipBinaries: true,
- });
- } finally {
- setEnv("PATH", oldPath);
- setEnv("GONDOLIN_SANDBOX_HELPERS_DIR", oldHelpersDir);
- fs.rmSync(tmp, { recursive: true, force: true });
- fs.rmSync(outputDir, { recursive: true, force: true });
- }
-});
+ const config: BuildConfig = {
+ arch: "x86_64",
+ distro: "alpine",
+ alpine: {
+ version: "3.23.0",
+ },
+ postBuild: {
+ copy: [
+ {
+ src: sourceLinkPath,
+ dest: "/tmp/",
+ },
+ ],
+ },
+ container: {
+ force: true,
+ runtime: "docker",
+ image: "alpine:3.23",
+ },
+ };
+
+ const oldPath = process.env.PATH;
+ const oldHelpersDir = process.env.GONDOLIN_SANDBOX_HELPERS_DIR;
+ try {
+ process.env.PATH = `${stubDir}:${oldPath ?? ""}`;
+ process.env.GONDOLIN_SANDBOX_HELPERS_DIR = helpersDir;
+
+ await buildAssets(config, {
+ outputDir,
+ verbose: false,
+ skipBinaries: true,
+ });
+ } finally {
+ setEnv("PATH", oldPath);
+ setEnv("GONDOLIN_SANDBOX_HELPERS_DIR", oldHelpersDir);
+ fs.rmSync(tmp, { recursive: true, force: true });
+ fs.rmSync(outputDir, { recursive: true, force: true });
+ }
+ },
+);
diff --git a/host/test/helpers/vm-fixture.ts b/host/test/helpers/vm-fixture.ts
index fa0ef139..79f85c93 100644
--- a/host/test/helpers/vm-fixture.ts
+++ b/host/test/helpers/vm-fixture.ts
@@ -1,12 +1,74 @@
import fs from "fs";
import path from "path";
-import { execFileSync } from "child_process";
+import { execFileSync, spawnSync } from "child_process";
import { VM, type VMOptions } from "../../src/vm/core.ts";
+import { buildQemuFamilyCandidates } from "../../src/qemu/locate-binary.ts";
+import type { LocalEndpoint } from "../../src/local-endpoint.ts";
+
+/**
+ * Build a LocalEndpoint for test fixtures: a unix socket at `posixPath` off
+ * Windows, or a reserved-by-the-OS ephemeral loopback TCP endpoint on
+ * Windows (where a bare unix path isn't valid - see local-endpoint.ts). This
+ * is the one place tests should branch on win32 vs unix transport for a
+ * throwaway test endpoint; call sites that need the socket at a specific
+ * path (e.g. inside a temp dir they clean up themselves) pass that path in.
+ */
+export function makeTestEndpoint(posixPath: string): LocalEndpoint {
+ return process.platform === "win32"
+ ? { transport: "tcp", host: "127.0.0.1", port: 0 }
+ : { transport: "unix", path: posixPath };
+}
+
+function hasWindowsWhpx(): boolean {
+ if (process.arch !== "x64") {
+ return false;
+ }
+
+ const candidates = buildQemuFamilyCandidates([
+ "qemu-system-x86_64",
+ "qemu-system-x86_64w",
+ ]);
+
+ for (const candidate of candidates) {
+ try {
+ const output = execFileSync(candidate, ["-accel", "help"], {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ windowsHide: true,
+ });
+ const supported = output
+ .split(/\r?\n/)
+ .map((line) => line.trim().toLowerCase());
+ if (!supported.includes("whpx")) {
+ continue;
+ }
+
+ const probe = spawnSync(
+ candidate,
+ ["-accel", "whpx", "-machine", "none", "-nodefaults", "-display", "none", "-S"],
+ {
+ timeout: 1500,
+ windowsHide: true,
+ encoding: "utf8",
+ },
+ );
+ const timedOut = probe.error?.code === "ETIMEDOUT";
+ if (timedOut || probe.status === 0) {
+ return true;
+ }
+ } catch {
+ // Try next candidate.
+ }
+ }
+
+ return false;
+}
/**
* Check if hardware virtualization is available.
* On Linux, this checks for KVM. On macOS, HVF is always available.
+ * On Windows x64, this checks whether an installed QEMU can initialize WHPX.
* Returns false for other platforms or when acceleration is unavailable.
*/
export function hasHardwareAccel(): boolean {
@@ -21,6 +83,9 @@ export function hasHardwareAccel(): boolean {
return false;
}
}
+ if (process.platform === "win32") {
+ return hasWindowsWhpx();
+ }
return false;
}
diff --git a/host/test/host-pid-stats.test.ts b/host/test/host-pid-stats.test.ts
index 77a9dd25..9deb1609 100644
--- a/host/test/host-pid-stats.test.ts
+++ b/host/test/host-pid-stats.test.ts
@@ -12,7 +12,19 @@ const startTimeoutMs = Math.max(
Number(process.env.GONDOLIN_HOST_PID_START_TIMEOUT_MS ?? 30000),
);
-function readPsStats(pid: number): string {
+function readHostPidStats(pid: number): string {
+ if (process.platform === "win32") {
+ return execFileSync(
+ "powershell",
+ [
+ "-NoProfile",
+ "-Command",
+ `Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" | Select-Object ProcessId,ParentProcessId,WorkingSetSize,VirtualSize,CommandLine | Format-List`,
+ ],
+ { encoding: "utf8" },
+ );
+ }
+
return execFileSync(
"ps",
["-o", "pid,ppid,rss,vsz,pcpu,pmem,etime,command", "-p", String(pid)],
@@ -20,12 +32,26 @@ function readPsStats(pid: number): string {
);
}
+function assertHostPidStats(stats: string, pid: number): void {
+ if (process.platform === "win32") {
+ assert.match(stats, new RegExp(`ProcessId\\s+:\\s+${pid}\\b`));
+ assert.match(stats, /CommandLine\s+:/);
+ return;
+ }
+
+ assert.match(
+ stats,
+ /^\s*PID\s+PPID\s+RSS\s+VSZ\s+%CPU\s+%MEM\s+ELAPSED\s+COMMAND/m,
+ );
+ assert.match(stats, new RegExp(`\\b${pid}\\b`));
+}
+
test.after(() => {
scheduleForceExit();
});
test(
- "VM.getHostPid exposes a pid that can be sampled with ps",
+ "VM.getHostPid exposes a pid that can be sampled by host process tools",
{ skip: skipVmTests, timeout: timeoutMs },
async () => {
const vm = await VM.create({
@@ -40,11 +66,9 @@ test(
assert.equal(typeof pid, "number");
assert.ok(pid > 0, "expected a positive host pid");
- const stats = readPsStats(pid);
- console.log(`ps stats for VM host pid ${pid}:\n${stats}`);
-
- assert.match(stats, /^\s*PID\s+PPID\s+RSS\s+VSZ\s+%CPU\s+%MEM\s+ELAPSED\s+COMMAND/m);
- assert.match(stats, new RegExp(`\\b${pid}\\b`));
+ const stats = readHostPidStats(pid);
+ console.log(`host process stats for VM host pid ${pid}:\n${stats}`);
+ assertHostPidStats(stats, pid);
} finally {
await vm.close();
}
diff --git a/host/test/images.test.ts b/host/test/images.test.ts
index 5eecc386..ef093fb0 100644
--- a/host/test/images.test.ts
+++ b/host/test/images.test.ts
@@ -14,6 +14,12 @@ import {
setImageRef,
tagImage,
} from "../src/images.ts";
+
+function systemTarPath(): string {
+ return process.platform === "win32"
+ ? "C:\\Windows\\System32\\tar.exe"
+ : "tar";
+}
import { resolveSandboxServerOptions } from "../src/sandbox/server-options.ts";
const prevImageStore = process.env.GONDOLIN_IMAGE_STORE;
@@ -533,7 +539,7 @@ test("images: ensureImageSelector pulls refs from builtin registry", async () =>
try {
child_process.execFileSync(
- "tar",
+ systemTarPath(),
[
"-czf",
archivePath,
diff --git a/host/test/local-endpoint.test.ts b/host/test/local-endpoint.test.ts
new file mode 100644
index 00000000..6e235deb
--- /dev/null
+++ b/host/test/local-endpoint.test.ts
@@ -0,0 +1,86 @@
+import assert from "node:assert/strict";
+import net from "node:net";
+import test from "node:test";
+
+import {
+ createDefaultLocalEndpoint,
+ createNetConnectOptions,
+ listenOnLocalEndpoint,
+ normalizeLocalEndpoint,
+} from "../src/local-endpoint.ts";
+
+test("createDefaultLocalEndpoint uses loopback tcp on Windows", () => {
+ const endpoint = createDefaultLocalEndpoint("gondolin-test", {
+ platform: "win32",
+ });
+
+ assert.deepEqual(endpoint, {
+ transport: "tcp",
+ host: "127.0.0.1",
+ port: 0,
+ });
+});
+
+test("normalizeLocalEndpoint rejects legacy string paths on Windows", () => {
+ assert.throws(
+ () =>
+ normalizeLocalEndpoint("C:/tmp/gondolin.sock", "sandbox.netSocketPath", {
+ platform: "win32",
+ }),
+ /explicit \{ transport: "tcp", host, port \} endpoint on Windows/,
+ );
+});
+
+test("normalizeLocalEndpoint rejects unix endpoints on Windows", () => {
+ assert.throws(
+ () =>
+ normalizeLocalEndpoint(
+ { transport: "unix", path: "C:/tmp/gondolin.sock" },
+ "sandbox.netSocketPath",
+ { platform: "win32" },
+ ),
+ /must use transport "tcp" on Windows/,
+ );
+});
+
+test("normalizeLocalEndpoint rejects non-loopback tcp hosts", () => {
+ assert.throws(
+ () =>
+ normalizeLocalEndpoint(
+ { transport: "tcp", host: "0.0.0.0", port: 9000 },
+ "sandbox.netSocketPath",
+ { platform: "win32" },
+ ),
+ /must be a loopback host/,
+ );
+});
+
+test("listenOnLocalEndpoint binds ephemeral tcp ports and updates the endpoint", async () => {
+ const endpoint = {
+ transport: "tcp" as const,
+ host: "127.0.0.1",
+ port: 0,
+ };
+
+ const server = net.createServer((socket) => {
+ socket.end("ok");
+ });
+
+ await listenOnLocalEndpoint(server, endpoint);
+ assert.ok(endpoint.port > 0);
+
+ const payload = await new Promise((resolve, reject) => {
+ const socket = net.createConnection(createNetConnectOptions(endpoint));
+ let data = "";
+
+ socket.on("data", (chunk) => {
+ data += chunk.toString();
+ });
+ socket.on("end", () => resolve(data));
+ socket.on("error", reject);
+ });
+
+ assert.equal(payload, "ok");
+
+ await new Promise((resolve) => server.close(() => resolve()));
+});
diff --git a/host/test/mount-spec.test.ts b/host/test/mount-spec.test.ts
new file mode 100644
index 00000000..1e8ee40d
--- /dev/null
+++ b/host/test/mount-spec.test.ts
@@ -0,0 +1,142 @@
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import test from "node:test";
+
+import {
+ normalizeCliHostPath,
+ parseMountSpec,
+} from "../src/cli/mount-spec.ts";
+
+let cygpathAvailable: boolean | null = null;
+
+/** Whether a real `cygpath` binary can actually be invoked in this environment. */
+function hasRealCygpath(): boolean {
+ if (cygpathAvailable !== null) return cygpathAvailable;
+ try {
+ execFileSync("cygpath", ["--version"], {
+ stdio: "ignore",
+ windowsHide: true,
+ });
+ cygpathAvailable = true;
+ } catch {
+ cygpathAvailable = false;
+ }
+ return cygpathAvailable;
+}
+
+test("parseMountSpec converts raw Git Bash /c host paths on Windows", () => {
+ const parsed = parseMountSpec("/c/CodeBlocks/gondolin/demo:/workspace", {
+ platform: "win32",
+ env: { MSYSTEM: "MINGW64" } as NodeJS.ProcessEnv,
+ });
+
+ assert.deepEqual(parsed, {
+ hostPath: "C:/CodeBlocks/gondolin/demo",
+ guestPath: "/workspace",
+ readonly: false,
+ });
+});
+
+test("parseMountSpec recovers Git Bash path-list rewritten mount specs", () => {
+ // The mocked return value below is not an arbitrary guess: it's the
+ // verified real output of `cygpath -u -p` for this exact input (checked
+ // against a real cygpath.exe via execFileSync). An earlier version of this
+ // fixture used a path nested under a typical Git-for-Windows install root
+ // (C:\Program Files\Git\workspace), which coincidentally collapses to just
+ // `/workspace` on machines where that's cygpath's own POSIX root mount -
+ // a misleading, environment-dependent "realistic-looking" value. This one
+ // (a path under a user's home directory) doesn't have that collision.
+ const calls: string[][] = [];
+ const parsed = parseMountSpec(
+ "C:\\CodeBlocks\\gondolin\\demo;C:\\Users\\Test User\\my workspace;ro",
+ {
+ platform: "win32",
+ env: { MSYSTEM: "MINGW64" } as NodeJS.ProcessEnv,
+ runCygpath(args) {
+ calls.push(args);
+ assert.deepEqual(args, [
+ "-u",
+ "-p",
+ "C:\\CodeBlocks\\gondolin\\demo;C:\\Users\\Test User\\my workspace;ro",
+ ]);
+ return "/c/CodeBlocks/gondolin/demo:/c/Users/Test User/my workspace:ro";
+ },
+ },
+ );
+
+ assert.deepEqual(parsed, {
+ hostPath: "C:/CodeBlocks/gondolin/demo",
+ guestPath: "/c/Users/Test User/my workspace",
+ readonly: true,
+ });
+ assert.equal(calls.length, 1);
+});
+
+test(
+ "parseMountSpec recovers Git Bash path-list specs via a real cygpath binary",
+ { skip: !hasRealCygpath() },
+ () => {
+ // Same scenario as above, but exercising defaultRunCygpath's real
+ // `execFileSync("cygpath", ...)` call end-to-end (no runCygpath mock),
+ // so a real change in cygpath's output format would actually be caught.
+ const parsed = parseMountSpec(
+ "C:\\CodeBlocks\\gondolin\\demo;C:\\Users\\Test User\\my workspace;ro",
+ {
+ platform: "win32",
+ env: { MSYSTEM: "MINGW64" } as NodeJS.ProcessEnv,
+ },
+ );
+
+ assert.equal(parsed.readonly, true);
+ // cygpath's own POSIX root mount can vary by installation, so assert on
+ // structure rather than the exact string: both paths were recovered
+ // (not left semicolon-joined), stayed absolute, and kept the embedded
+ // space intact.
+ assert.ok(!parsed.hostPath.includes(";"));
+ assert.ok(!parsed.guestPath.includes(";"));
+ assert.match(parsed.guestPath, /Test User.my workspace$/);
+ },
+);
+
+test("parseMountSpec preserves standard Windows mount specs", () => {
+ const parsed = parseMountSpec("C:/CodeBlocks/gondolin/demo:/workspace:ro", {
+ platform: "win32",
+ env: {} as NodeJS.ProcessEnv,
+ });
+
+ assert.deepEqual(parsed, {
+ hostPath: "C:/CodeBlocks/gondolin/demo",
+ guestPath: "/workspace",
+ readonly: true,
+ });
+});
+
+test("normalizeCliHostPath converts Git Bash /c paths on Windows", () => {
+ assert.equal(
+ normalizeCliHostPath("/c/CodeBlocks/gondolin/host/showcase.qcow2", {
+ platform: "win32",
+ env: { MSYSTEM: "MINGW64" } as NodeJS.ProcessEnv,
+ }),
+ "C:/CodeBlocks/gondolin/host/showcase.qcow2",
+ );
+});
+
+test("normalizeCliHostPath preserves Windows drive-root semantics for /c", () => {
+ assert.equal(
+ normalizeCliHostPath("/c", {
+ platform: "win32",
+ env: { MSYSTEM: "MINGW64" } as NodeJS.ProcessEnv,
+ }),
+ "C:/",
+ );
+});
+
+test("normalizeCliHostPath preserves non-Windows paths", () => {
+ assert.equal(
+ normalizeCliHostPath("/tmp/showcase.qcow2", {
+ platform: "linux",
+ env: {} as NodeJS.ProcessEnv,
+ }),
+ "/tmp/showcase.qcow2",
+ );
+});
diff --git a/host/test/oci-pull-policy.test.ts b/host/test/oci-pull-policy.test.ts
index 2fec4ea6..21684225 100644
--- a/host/test/oci-pull-policy.test.ts
+++ b/host/test/oci-pull-policy.test.ts
@@ -7,6 +7,11 @@ import test from "node:test";
import { exportOciRootfs } from "../src/alpine/oci.ts";
+const skipWindowsOciPullPolicyTests =
+ process.platform === "win32"
+ ? "OCI docker runtime tests require POSIX shell/tar semantics"
+ : false;
+
function writeFakeDockerRuntime(binDir: string): void {
const runtimePath = path.join(binDir, "docker");
fs.writeFileSync(
@@ -127,7 +132,10 @@ function restoreEnv(name: string, value: string | undefined): void {
process.env[name] = value;
}
-test("oci pullPolicy never: fails when requested platform is not local", () => {
+test(
+ "oci pullPolicy never: fails when requested platform is not local",
+ { skip: skipWindowsOciPullPolicyTests },
+ () => {
const tmp = fs.mkdtempSync(
path.join(os.tmpdir(), "gondolin-oci-pull-never-"),
);
@@ -181,9 +189,13 @@ test("oci pullPolicy never: fails when requested platform is not local", () => {
restoreEnv("LOCAL_PLATFORM", oldLocalPlatform);
fs.rmSync(tmp, { recursive: true, force: true });
}
-});
+},
+);
-test("oci pullPolicy if-not-present: pulls when requested platform is not local", () => {
+test(
+ "oci pullPolicy if-not-present: pulls when requested platform is not local",
+ { skip: skipWindowsOciPullPolicyTests },
+ () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-oci-pull-auto-"));
const binDir = path.join(tmp, "bin");
const rootfsDir = path.join(tmp, "rootfs");
@@ -240,9 +252,13 @@ test("oci pullPolicy if-not-present: pulls when requested platform is not local"
restoreEnv("FAKE_REPO_DIGEST", oldRepoDigest);
fs.rmSync(tmp, { recursive: true, force: true });
}
-});
+},
+);
-test("oci pullPolicy never: export create uses --pull=never", () => {
+test(
+ "oci pullPolicy never: export create uses --pull=never",
+ { skip: skipWindowsOciPullPolicyTests },
+ () => {
const tmp = fs.mkdtempSync(
path.join(os.tmpdir(), "gondolin-oci-pull-never-"),
);
@@ -298,9 +314,13 @@ test("oci pullPolicy never: export create uses --pull=never", () => {
restoreEnv("FAKE_REPO_DIGEST", oldRepoDigest);
fs.rmSync(tmp, { recursive: true, force: true });
}
-});
+},
+);
-test("oci export: returns resolved image digest metadata", () => {
+test(
+ "oci export: returns resolved image digest metadata",
+ { skip: skipWindowsOciPullPolicyTests },
+ () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-oci-digest-"));
const binDir = path.join(tmp, "bin");
const rootfsDir = path.join(tmp, "rootfs");
@@ -341,9 +361,13 @@ test("oci export: returns resolved image digest metadata", () => {
restoreEnv("FAKE_REPO_DIGEST", oldRepoDigest);
fs.rmSync(tmp, { recursive: true, force: true });
}
-});
+},
+);
-test("oci export: fails when runtime does not report RepoDigests", () => {
+test(
+ "oci export: fails when runtime does not report RepoDigests",
+ { skip: skipWindowsOciPullPolicyTests },
+ () => {
const tmp = fs.mkdtempSync(
path.join(os.tmpdir(), "gondolin-oci-digest-missing-"),
);
@@ -383,4 +407,5 @@ test("oci export: fails when runtime does not report RepoDigests", () => {
restoreEnv("FAKE_REPO_DIGEST", oldRepoDigest);
fs.rmSync(tmp, { recursive: true, force: true });
}
-});
+},
+);
diff --git a/host/test/oci-rootfs-safety.test.ts b/host/test/oci-rootfs-safety.test.ts
index 39bba76e..9e55df9b 100644
--- a/host/test/oci-rootfs-safety.test.ts
+++ b/host/test/oci-rootfs-safety.test.ts
@@ -17,6 +17,16 @@ import {
} from "../src/alpine/rootfs.ts";
import { syncKernelModules } from "../src/alpine/kernel-modules.ts";
+const skipWindowsOciRuntimeTests =
+ process.platform === "win32"
+ ? "OCI docker runtime tests require POSIX shell/tar semantics"
+ : false;
+
+const skipWindowsAbsoluteSymlinkRewriteTest =
+ process.platform === "win32"
+ ? "Windows host symlink APIs rewrite absolute targets differently"
+ : false;
+
interface TarFixtureEntry {
/** tar entry path */
name: string;
@@ -253,7 +263,10 @@ test("oci rootfs: tar ownership parser preserves uid/gid metadata", () => {
}
});
-test("oci rootfs: pullPolicy always tolerates large pull output", () => {
+test(
+ "oci rootfs: pullPolicy always tolerates large pull output",
+ { skip: skipWindowsOciRuntimeTests },
+ () => {
const tmp = fs.mkdtempSync(
path.join(os.tmpdir(), "gondolin-oci-large-pull-"),
);
@@ -293,43 +306,48 @@ test("oci rootfs: pullPolicy always tolerates large pull output", () => {
}
fs.rmSync(tmp, { recursive: true, force: true });
}
-});
-
-test("oci rootfs: pullPolicy never propagates non-missing runtime errors", () => {
- const tmp = fs.mkdtempSync(
- path.join(os.tmpdir(), "gondolin-oci-runtime-fail-"),
- );
- const binDir = path.join(tmp, "bin");
- const rootfsDir = path.join(tmp, "rootfs");
-
- fs.mkdirSync(binDir, { recursive: true });
- fs.mkdirSync(rootfsDir, { recursive: true });
- writeCreateFailRuntime(binDir);
-
- const oldPath = process.env.PATH;
-
- try {
- process.env.PATH = `${binDir}:${oldPath ?? ""}`;
-
- assert.throws(
- () =>
- exportOciRootfs({
- arch: "x86_64",
- image: "docker.io/library/debian:bookworm-slim",
- runtime: "docker",
- platform: "linux/amd64",
- pullPolicy: "never",
- workDir: tmp,
- targetDir: rootfsDir,
- log: () => {},
- }),
- /daemon unavailable/,
+},
+);
+
+test(
+ "oci rootfs: pullPolicy never propagates non-missing runtime errors",
+ { skip: skipWindowsOciRuntimeTests },
+ () => {
+ const tmp = fs.mkdtempSync(
+ path.join(os.tmpdir(), "gondolin-oci-runtime-fail-"),
);
- } finally {
- process.env.PATH = oldPath;
- fs.rmSync(tmp, { recursive: true, force: true });
- }
-});
+ const binDir = path.join(tmp, "bin");
+ const rootfsDir = path.join(tmp, "rootfs");
+
+ fs.mkdirSync(binDir, { recursive: true });
+ fs.mkdirSync(rootfsDir, { recursive: true });
+ writeCreateFailRuntime(binDir);
+
+ const oldPath = process.env.PATH;
+
+ try {
+ process.env.PATH = `${binDir}:${oldPath ?? ""}`;
+
+ assert.throws(
+ () =>
+ exportOciRootfs({
+ arch: "x86_64",
+ image: "docker.io/library/debian:bookworm-slim",
+ runtime: "docker",
+ platform: "linux/amd64",
+ pullPolicy: "never",
+ workDir: tmp,
+ targetDir: rootfsDir,
+ log: () => {},
+ }),
+ /daemon unavailable/,
+ );
+ } finally {
+ process.env.PATH = oldPath;
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ },
+);
test("oci rootfs: hardenExtractedRootfs rejects escaping relative symlinks", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-oci-symlink-"));
@@ -348,29 +366,35 @@ test("oci rootfs: hardenExtractedRootfs rejects escaping relative symlinks", ()
}
});
-test("oci rootfs: hardenExtractedRootfs rewrites absolute symlinks", () => {
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-oci-symlink-"));
- const rootfsDir = path.join(tmp, "rootfs");
-
- fs.mkdirSync(path.join(rootfsDir, "tmp", "hostdir"), { recursive: true });
- fs.symlinkSync("/tmp/hostdir", path.join(rootfsDir, "usr"));
-
- try {
- hardenExtractedRootfs(rootfsDir);
-
- assert.equal(fs.readlinkSync(path.join(rootfsDir, "usr")), "tmp/hostdir");
- assert.throws(
- () =>
- assertSafeWritePath(
- path.join(rootfsDir, "usr", "bin", "sandboxd"),
- rootfsDir,
- ),
- /symlinked path/,
+test(
+ "oci rootfs: hardenExtractedRootfs rewrites absolute symlinks",
+ { skip: skipWindowsAbsoluteSymlinkRewriteTest },
+ () => {
+ const tmp = fs.mkdtempSync(
+ path.join(os.tmpdir(), "gondolin-oci-symlink-"),
);
- } finally {
- fs.rmSync(tmp, { recursive: true, force: true });
- }
-});
+ const rootfsDir = path.join(tmp, "rootfs");
+
+ fs.mkdirSync(path.join(rootfsDir, "tmp", "hostdir"), { recursive: true });
+ fs.symlinkSync("/tmp/hostdir", path.join(rootfsDir, "usr"));
+
+ try {
+ hardenExtractedRootfs(rootfsDir);
+
+ assert.equal(fs.readlinkSync(path.join(rootfsDir, "usr")), "tmp/hostdir");
+ assert.throws(
+ () =>
+ assertSafeWritePath(
+ path.join(rootfsDir, "usr", "bin", "sandboxd"),
+ rootfsDir,
+ ),
+ /symlinked path/,
+ );
+ } finally {
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ },
+);
test("oci rootfs: syncKernelModules handles /lib -> usr/lib symlink", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-oci-modules-"));
diff --git a/host/test/prepare-krun-runner-package.test.ts b/host/test/prepare-krun-runner-package.test.ts
index e09e65b1..39a14001 100644
--- a/host/test/prepare-krun-runner-package.test.ts
+++ b/host/test/prepare-krun-runner-package.test.ts
@@ -12,7 +12,15 @@ const scriptPath = path.join(
"prepare-krun-runner-package.mjs",
);
-test("prepare-krun-runner-package materializes SONAME aliases for npm pack", () => {
+const skipWindowsKrunPackageTest =
+ process.platform === "win32"
+ ? "krun runner packaging is only relevant on Linux/macOS hosts"
+ : false;
+
+test(
+ "prepare-krun-runner-package materializes SONAME aliases for npm pack",
+ { skip: skipWindowsKrunPackageTest },
+ () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-package-test-"));
try {
@@ -88,4 +96,5 @@ test("prepare-krun-runner-package materializes SONAME aliases for npm pack", ()
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
-});
+},
+);
diff --git a/host/test/qemu-arch-mismatch.test.ts b/host/test/qemu-arch-mismatch.test.ts
index b5717f2f..a7ac7648 100644
--- a/host/test/qemu-arch-mismatch.test.ts
+++ b/host/test/qemu-arch-mismatch.test.ts
@@ -4,7 +4,18 @@ import os from "node:os";
import path from "node:path";
import test from "node:test";
-import { resolveSandboxServerOptions } from "../src/sandbox/server-options.ts";
+import {
+ __test as serverOptionsTest,
+ resolveSandboxServerOptions,
+} from "../src/sandbox/server-options.ts";
+
+function skipWindowsKrun(t: { skip: (message?: string) => void }): boolean {
+ if (process.platform === "win32") {
+ t.skip("krun is not supported on Windows hosts");
+ return true;
+ }
+ return false;
+}
function makeTempAssetsDir(
arch: "aarch64" | "x86_64",
@@ -99,19 +110,49 @@ test("resolveSandboxServerOptions auto-selects qemu binary from guest image arch
const dir = makeTempAssetsDir(guestArch);
try {
- const resolved = resolveSandboxServerOptions({
- imagePath: dir,
- });
+ const resolved = resolveSandboxServerOptions(
+ {
+ imagePath: dir,
+ },
+ undefined,
+ {
+ resolveDefaultQemuPath: (targetArch) =>
+ targetArch === "arm64" ? "chosen-aarch64" : "chosen-x86_64",
+ },
+ );
assert.equal(
resolved.qemuPath,
- guestArch === "aarch64" ? "qemu-system-aarch64" : "qemu-system-x86_64",
+ guestArch === "aarch64" ? "chosen-aarch64" : "chosen-x86_64",
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+test("resolveDefaultQemuPath prefers Windows w-suffixed PATH binary when available", () => {
+ const resolved = serverOptionsTest.resolveDefaultQemuPath("x64", {
+ platform: "win32",
+ env: { ProgramFiles: "C:\\Program Files" } as NodeJS.ProcessEnv,
+ existsSync: () => false,
+ probeQemuBinary: (candidate: string) => candidate === "qemu-system-x86_64w",
+ });
+
+ assert.equal(resolved, "qemu-system-x86_64w");
+});
+
+test("resolveDefaultQemuPath falls back to standard Program Files install on Windows", () => {
+ const qemuPath = "C:\\Program Files\\qemu\\qemu-system-x86_64.exe";
+ const resolved = serverOptionsTest.resolveDefaultQemuPath("x64", {
+ platform: "win32",
+ env: { ProgramFiles: "C:\\Program Files" } as NodeJS.ProcessEnv,
+ existsSync: (candidate: string) => candidate === qemuPath,
+ probeQemuBinary: (candidate: string) => candidate === qemuPath,
+ });
+
+ assert.equal(resolved, qemuPath);
+});
+
test("resolveSandboxServerOptions allows matching guest/qemu arch", () => {
const dir = makeTempAssetsDir("aarch64");
try {
@@ -149,7 +190,8 @@ test("resolveSandboxServerOptions applies GONDOLIN_CPU with explicit override pr
}
});
-test("resolveSandboxServerOptions fails fast on guest/krun host arch mismatch", () => {
+test("resolveSandboxServerOptions fails fast on guest/krun host arch mismatch", (t) => {
+ if (skipWindowsKrun(t)) return;
const hostArch = process.arch === "arm64" ? "aarch64" : "x86_64";
const otherArch = hostArch === "aarch64" ? "x86_64" : "aarch64";
const dir = makeTempAssetsDir(otherArch);
@@ -201,7 +243,8 @@ test("resolveSandboxServerOptions rejects removed sandbox.rootDiskSnapshot", ()
}
});
-test("resolveSandboxServerOptions requires manifest krunKernel for vmm=krun", () => {
+test("resolveSandboxServerOptions requires manifest krunKernel for vmm=krun", (t) => {
+ if (skipWindowsKrun(t)) return;
const hostArch = process.arch === "arm64" ? "aarch64" : "x86_64";
const dir = makeTempAssetsDir(hostArch, { includeKrunAssets: false });
@@ -219,7 +262,8 @@ test("resolveSandboxServerOptions requires manifest krunKernel for vmm=krun", ()
}
});
-test("resolveSandboxServerOptions rejects qemu-only options for krun", () => {
+test("resolveSandboxServerOptions rejects qemu-only options for krun", (t) => {
+ if (skipWindowsKrun(t)) return;
const hostArch = process.arch === "arm64" ? "aarch64" : "x86_64";
const dir = makeTempAssetsDir(hostArch);
try {
@@ -240,7 +284,8 @@ test("resolveSandboxServerOptions rejects qemu-only options for krun", () => {
}
});
-test("resolveSandboxServerOptions rejects single qemu-only option for krun", () => {
+test("resolveSandboxServerOptions rejects single qemu-only option for krun", (t) => {
+ if (skipWindowsKrun(t)) return;
const hostArch = process.arch === "arm64" ? "aarch64" : "x86_64";
const dir = makeTempAssetsDir(hostArch);
try {
@@ -258,7 +303,8 @@ test("resolveSandboxServerOptions rejects single qemu-only option for krun", ()
}
});
-test("resolveSandboxServerOptions uses manifest krunKernel/krunInitrd when vmm=krun", () => {
+test("resolveSandboxServerOptions uses manifest krunKernel/krunInitrd when vmm=krun", (t) => {
+ if (skipWindowsKrun(t)) return;
const hostArch = process.arch === "arm64" ? "aarch64" : "x86_64";
const dir = makeTempAssetsDir(hostArch);
@@ -286,7 +332,8 @@ test("resolveSandboxServerOptions uses manifest krunKernel/krunInitrd when vmm=k
}
});
-test("resolveSandboxServerOptions supports split manifest asset directories for krun", () => {
+test("resolveSandboxServerOptions supports split manifest asset directories for krun", (t) => {
+ if (skipWindowsKrun(t)) return;
const hostArch = process.arch === "arm64" ? "aarch64" : "x86_64";
const dir = makeTempAssetsDir(hostArch, { splitAssetDirs: true });
@@ -304,7 +351,8 @@ test("resolveSandboxServerOptions supports split manifest asset directories for
}
});
-test("resolveSandboxServerOptions keeps explicit asset object for krun", () => {
+test("resolveSandboxServerOptions keeps explicit asset object for krun", (t) => {
+ if (skipWindowsKrun(t)) return;
const hostArch = process.arch === "arm64" ? "aarch64" : "x86_64";
const dir = makeTempAssetsDir(hostArch);
@@ -327,7 +375,8 @@ test("resolveSandboxServerOptions keeps explicit asset object for krun", () => {
}
});
-test("resolveSandboxServerOptions auto-detects local krun runner path", () => {
+test("resolveSandboxServerOptions auto-detects local krun runner path", (t) => {
+ if (skipWindowsKrun(t)) return;
const hostArch = process.arch === "arm64" ? "aarch64" : "x86_64";
const dir = makeTempAssetsDir(hostArch);
const tempRoot = fs.mkdtempSync(
diff --git a/host/test/qemu-img-path.test.ts b/host/test/qemu-img-path.test.ts
new file mode 100644
index 00000000..d2da4253
--- /dev/null
+++ b/host/test/qemu-img-path.test.ts
@@ -0,0 +1,29 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { __test as qemuImgTest } from "../src/qemu/img.ts";
+
+test("resolveQemuImgPath prefers the configured qemu binary directory", () => {
+ const qemuImgPath = "D:\\Portable\\qemu\\qemu-img.exe";
+ const resolved = qemuImgTest.resolveQemuImgPath({
+ platform: "win32",
+ qemuPath: "D:\\Portable\\qemu\\qemu-system-x86_64w.exe",
+ env: { ProgramFiles: "C:\\Program Files" } as NodeJS.ProcessEnv,
+ existsSync: (candidate: string) => candidate === qemuImgPath,
+ probeQemuImg: (candidate: string) => candidate === qemuImgPath,
+ });
+
+ assert.equal(resolved, qemuImgPath);
+});
+
+test("resolveQemuImgPath falls back to Program Files install on Windows", () => {
+ const qemuImgPath = "C:\\Program Files\\qemu\\qemu-img.exe";
+ const resolved = qemuImgTest.resolveQemuImgPath({
+ platform: "win32",
+ env: { ProgramFiles: "C:\\Program Files" } as NodeJS.ProcessEnv,
+ existsSync: (candidate: string) => candidate === qemuImgPath,
+ probeQemuImg: (candidate: string) => candidate === qemuImgPath,
+ });
+
+ assert.equal(resolved, qemuImgPath);
+});
diff --git a/host/test/qemu-locate-binary.test.ts b/host/test/qemu-locate-binary.test.ts
new file mode 100644
index 00000000..42b29f4f
--- /dev/null
+++ b/host/test/qemu-locate-binary.test.ts
@@ -0,0 +1,51 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ buildQemuFamilyCandidates,
+ resolveFromQemuFamilyCandidates,
+} from "../src/qemu/locate-binary.ts";
+
+test("buildQemuFamilyCandidates returns only the primary name off Windows", () => {
+ const candidates = buildQemuFamilyCandidates(["qemu-img"], {
+ platform: "linux",
+ });
+ assert.deepEqual(candidates, ["qemu-img"]);
+});
+
+test("buildQemuFamilyCandidates expands install roots on Windows for every name", () => {
+ const candidates = buildQemuFamilyCandidates(
+ ["qemu-system-x86_64", "qemu-system-x86_64w"],
+ {
+ platform: "win32",
+ env: { ProgramFiles: "C:\\Program Files" } as NodeJS.ProcessEnv,
+ },
+ );
+
+ assert.deepEqual(candidates, [
+ "qemu-system-x86_64",
+ "qemu-system-x86_64.exe",
+ "qemu-system-x86_64w",
+ "qemu-system-x86_64w.exe",
+ "C:\\Program Files\\qemu\\qemu-system-x86_64.exe",
+ "C:\\Program Files\\qemu\\qemu-system-x86_64w.exe",
+ ]);
+});
+
+test("resolveFromQemuFamilyCandidates skips explicit paths that don't exist and picks the first probe-passing candidate", () => {
+ const resolved = resolveFromQemuFamilyCandidates(
+ ["C:\\missing\\qemu-img.exe", "qemu-img", "qemu-img.exe"],
+ {
+ existsSync: (candidate) => candidate !== "C:\\missing\\qemu-img.exe",
+ probeBinary: (candidate) => candidate === "qemu-img.exe",
+ },
+ );
+ assert.equal(resolved, "qemu-img.exe");
+});
+
+test("resolveFromQemuFamilyCandidates falls back to the first candidate when nothing probes successfully", () => {
+ const resolved = resolveFromQemuFamilyCandidates(["qemu-img", "qemu-img.exe"], {
+ probeBinary: () => false,
+ });
+ assert.equal(resolved, "qemu-img");
+});
diff --git a/host/test/qemu-net.test.ts b/host/test/qemu-net.test.ts
index 1f1078ca..3779c8fd 100644
--- a/host/test/qemu-net.test.ts
+++ b/host/test/qemu-net.test.ts
@@ -32,15 +32,20 @@ import * as qemuWs from "../src/qemu/ws.ts";
import { createHttpHooks } from "../src/http/hooks.ts";
import { mitmLeafHasRequiredKeyIdentifiers } from "../src/mitm.ts";
import { EventEmitter } from "node:events";
+import { makeTestEndpoint } from "./helpers/vm-fixture.ts";
function makeBackend(
options?: Partial[0]>,
) {
- return new QemuNetworkBackend({
- socketPath: path.join(
+ const socketPath = makeTestEndpoint(
+ path.join(
os.tmpdir(),
`gondolin-net-test-${process.pid}-${crypto.randomUUID()}.sock`,
),
+ );
+
+ return new QemuNetworkBackend({
+ socketPath,
...options,
});
}
diff --git a/host/test/rootfs-ownership.test.ts b/host/test/rootfs-ownership.test.ts
index 21b71ca2..73277a8c 100644
--- a/host/test/rootfs-ownership.test.ts
+++ b/host/test/rootfs-ownership.test.ts
@@ -8,6 +8,11 @@ import test from "node:test";
import { createRootfsImage } from "../src/alpine/utils.ts";
import type { RootfsOwnershipEntry } from "../src/alpine/types.ts";
+const skipWindowsRootfsOwnershipTest =
+ process.platform === "win32"
+ ? "rootfs ownership tests require POSIX shell/ext4 tool semantics"
+ : false;
+
function writeStubCommand(binDir: string, name: string, body: string): string {
const commandPath = path.join(binDir, name);
fs.writeFileSync(commandPath, `#!/bin/sh\nset -eu\n${body}\n`, {
@@ -51,205 +56,219 @@ function captureDebugfsCommandFileScript(): string {
].join("\n");
}
-test("rootfs image: applies OCI ownership metadata with debugfs for non-root builds", () => {
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-rootfs-owners-"));
- const binDir = path.join(tmp, "bin");
- const rootfsDir = path.join(tmp, "rootfs");
- const imagePath = path.join(tmp, "rootfs.ext4");
- const debugfsLog = path.join(tmp, "debugfs-commands.txt");
- const mkfsLog = path.join(tmp, "mkfs.log");
-
- fs.mkdirSync(binDir, { recursive: true });
- fs.mkdirSync(path.join(rootfsDir, "etc"), { recursive: true });
- fs.writeFileSync(path.join(rootfsDir, "etc", "test"), "test\n");
- fs.writeFileSync(path.join(rootfsDir, "etc", "test space"), "test\n");
- fs.writeFileSync(path.join(rootfsDir, "etc", "same-owner"), "test\n");
-
- const mke2fsPath = writeStubCommand(
- binDir,
- "mke2fs",
- ['printf "%s\\n" "$*" > "$MKFS_LOG"', writeMke2fsStubBody()].join("\n"),
- );
-
- writeStubCommand(
- binDir,
- "debugfs",
- [
- 'if [ "${1:-}" = "-V" ]; then',
- ' printf "debugfs fake 1.0\\n"',
- " exit 0",
- "fi",
- captureDebugfsCommandFileScript(),
- ].join("\n"),
- );
-
- const st = fs.lstatSync(path.join(rootfsDir, "etc", "same-owner"));
-
- const ownershipEntries: RootfsOwnershipEntry[] = [
- { path: "etc/test", uid: 0, gid: 0 },
- { path: "etc/test space", uid: 0, gid: 0 },
- { path: "etc/same-owner", uid: st.uid, gid: st.gid },
- { path: "etc/does-not-exist", uid: 0, gid: 0 },
- ];
-
- const oldGetuid = process.getuid;
- const oldDebugfsLog = process.env.DEBUGFS_LOG;
- const oldMkfsLog = process.env.MKFS_LOG;
-
- try {
- process.getuid = () => 12345;
- process.env.DEBUGFS_LOG = debugfsLog;
- process.env.MKFS_LOG = mkfsLog;
-
- createRootfsImage(
- mke2fsPath,
- imagePath,
- rootfsDir,
- "gondolin-root",
- 16,
- ownershipEntries,
+test(
+ "rootfs image: applies OCI ownership metadata with debugfs for non-root builds",
+ { skip: skipWindowsRootfsOwnershipTest },
+ () => {
+ const tmp = fs.mkdtempSync(
+ path.join(os.tmpdir(), "gondolin-rootfs-owners-"),
+ );
+ const binDir = path.join(tmp, "bin");
+ const rootfsDir = path.join(tmp, "rootfs");
+ const imagePath = path.join(tmp, "rootfs.ext4");
+ const debugfsLog = path.join(tmp, "debugfs-commands.txt");
+ const mkfsLog = path.join(tmp, "mkfs.log");
+
+ fs.mkdirSync(binDir, { recursive: true });
+ fs.mkdirSync(path.join(rootfsDir, "etc"), { recursive: true });
+ fs.writeFileSync(path.join(rootfsDir, "etc", "test"), "test\n");
+ fs.writeFileSync(path.join(rootfsDir, "etc", "test space"), "test\n");
+ fs.writeFileSync(path.join(rootfsDir, "etc", "same-owner"), "test\n");
+
+ const mke2fsPath = writeStubCommand(
+ binDir,
+ "mke2fs",
+ ['printf "%s\\n" "$*" > "$MKFS_LOG"', writeMke2fsStubBody()].join("\n"),
);
- assert.equal(fs.existsSync(imagePath), true);
- assert.equal(fs.existsSync(mkfsLog), true);
- assert.equal(fs.existsSync(debugfsLog), true);
-
- const debugfsCommands = fs.readFileSync(debugfsLog, "utf8");
- assert.match(debugfsCommands, /sif "\/etc\/test" uid 0/);
- assert.match(debugfsCommands, /sif "\/etc\/test" gid 0/);
- assert.match(debugfsCommands, /sif "\/etc\/test space" uid 0/);
- assert.match(debugfsCommands, /sif "\/etc\/test space" gid 0/);
- assert.equal(debugfsCommands.includes("same-owner"), false);
- assert.equal(debugfsCommands.includes("does-not-exist"), false);
- } finally {
- process.getuid = oldGetuid;
- if (oldDebugfsLog === undefined) {
- delete process.env.DEBUGFS_LOG;
- } else {
- process.env.DEBUGFS_LOG = oldDebugfsLog;
- }
- if (oldMkfsLog === undefined) {
- delete process.env.MKFS_LOG;
- } else {
- process.env.MKFS_LOG = oldMkfsLog;
+ writeStubCommand(
+ binDir,
+ "debugfs",
+ [
+ 'if [ "${1:-}" = "-V" ]; then',
+ ' printf "debugfs fake 1.0\\n"',
+ " exit 0",
+ "fi",
+ captureDebugfsCommandFileScript(),
+ ].join("\n"),
+ );
+
+ const st = fs.lstatSync(path.join(rootfsDir, "etc", "same-owner"));
+
+ const ownershipEntries: RootfsOwnershipEntry[] = [
+ { path: "etc/test", uid: 0, gid: 0 },
+ { path: "etc/test space", uid: 0, gid: 0 },
+ { path: "etc/same-owner", uid: st.uid, gid: st.gid },
+ { path: "etc/does-not-exist", uid: 0, gid: 0 },
+ ];
+
+ const oldGetuid = process.getuid;
+ const oldDebugfsLog = process.env.DEBUGFS_LOG;
+ const oldMkfsLog = process.env.MKFS_LOG;
+
+ try {
+ process.getuid = () => 12345;
+ process.env.DEBUGFS_LOG = debugfsLog;
+ process.env.MKFS_LOG = mkfsLog;
+
+ createRootfsImage(
+ mke2fsPath,
+ imagePath,
+ rootfsDir,
+ "gondolin-root",
+ 16,
+ ownershipEntries,
+ );
+
+ assert.equal(fs.existsSync(imagePath), true);
+ assert.equal(fs.existsSync(mkfsLog), true);
+ assert.equal(fs.existsSync(debugfsLog), true);
+
+ const debugfsCommands = fs.readFileSync(debugfsLog, "utf8");
+ assert.match(debugfsCommands, /sif "\/etc\/test" uid 0/);
+ assert.match(debugfsCommands, /sif "\/etc\/test" gid 0/);
+ assert.match(debugfsCommands, /sif "\/etc\/test space" uid 0/);
+ assert.match(debugfsCommands, /sif "\/etc\/test space" gid 0/);
+ assert.equal(debugfsCommands.includes("same-owner"), false);
+ assert.equal(debugfsCommands.includes("does-not-exist"), false);
+ } finally {
+ process.getuid = oldGetuid;
+ if (oldDebugfsLog === undefined) {
+ delete process.env.DEBUGFS_LOG;
+ } else {
+ process.env.DEBUGFS_LOG = oldDebugfsLog;
+ }
+ if (oldMkfsLog === undefined) {
+ delete process.env.MKFS_LOG;
+ } else {
+ process.env.MKFS_LOG = oldMkfsLog;
+ }
+ fs.rmSync(tmp, { recursive: true, force: true });
}
- fs.rmSync(tmp, { recursive: true, force: true });
- }
-});
-
-test("rootfs image: ignores large debugfs stdout while applying OCI ownership metadata", () => {
- const tmp = fs.mkdtempSync(
- path.join(os.tmpdir(), "gondolin-debugfs-stdout-"),
- );
- const binDir = path.join(tmp, "bin");
- const rootfsDir = path.join(tmp, "rootfs");
- const imagePath = path.join(tmp, "rootfs.ext4");
- const debugfsLog = path.join(tmp, "debugfs-commands.txt");
-
- fs.mkdirSync(binDir, { recursive: true });
- fs.mkdirSync(path.join(rootfsDir, "etc"), { recursive: true });
- fs.writeFileSync(path.join(rootfsDir, "etc", "test"), "test\n");
-
- const mke2fsPath = writeMke2fsStub(binDir);
-
- writeStubCommand(
- binDir,
- "debugfs",
- [
- 'if [ "${1:-}" = "-V" ]; then',
- ' printf "debugfs fake 1.0\\n"',
- " exit 0",
- "fi",
- captureDebugfsCommandFileScript(),
- `${JSON.stringify(process.execPath)} -e 'process.stdout.write("x".repeat(70 * 1024 * 1024))'`,
- ].join("\n"),
- );
-
- const ownershipEntries: RootfsOwnershipEntry[] = [
- { path: "etc/test", uid: 0, gid: 0 },
- ];
-
- const oldGetuid = process.getuid;
- const oldDebugfsLog = process.env.DEBUGFS_LOG;
-
- try {
- process.getuid = () => 12345;
- process.env.DEBUGFS_LOG = debugfsLog;
-
- createRootfsImage(
- mke2fsPath,
- imagePath,
- rootfsDir,
- "gondolin-root",
- 16,
- ownershipEntries,
+ },
+);
+
+test(
+ "rootfs image: ignores large debugfs stdout while applying OCI ownership metadata",
+ { skip: skipWindowsRootfsOwnershipTest },
+ () => {
+ const tmp = fs.mkdtempSync(
+ path.join(os.tmpdir(), "gondolin-debugfs-stdout-"),
+ );
+ const binDir = path.join(tmp, "bin");
+ const rootfsDir = path.join(tmp, "rootfs");
+ const imagePath = path.join(tmp, "rootfs.ext4");
+ const debugfsLog = path.join(tmp, "debugfs-commands.txt");
+
+ fs.mkdirSync(binDir, { recursive: true });
+ fs.mkdirSync(path.join(rootfsDir, "etc"), { recursive: true });
+ fs.writeFileSync(path.join(rootfsDir, "etc", "test"), "test\n");
+
+ const mke2fsPath = writeMke2fsStub(binDir);
+
+ writeStubCommand(
+ binDir,
+ "debugfs",
+ [
+ 'if [ "${1:-}" = "-V" ]; then',
+ ' printf "debugfs fake 1.0\\n"',
+ " exit 0",
+ "fi",
+ captureDebugfsCommandFileScript(),
+ `${JSON.stringify(process.execPath)} -e 'process.stdout.write("x".repeat(70 * 1024 * 1024))'`,
+ ].join("\n"),
);
- assert.equal(fs.existsSync(imagePath), true);
- const debugfsCommands = fs.readFileSync(debugfsLog, "utf8");
- assert.match(debugfsCommands, /sif "\/etc\/test" uid 0/);
- assert.match(debugfsCommands, /sif "\/etc\/test" gid 0/);
- } finally {
- process.getuid = oldGetuid;
- if (oldDebugfsLog === undefined) {
- delete process.env.DEBUGFS_LOG;
- } else {
- process.env.DEBUGFS_LOG = oldDebugfsLog;
+ const ownershipEntries: RootfsOwnershipEntry[] = [
+ { path: "etc/test", uid: 0, gid: 0 },
+ ];
+
+ const oldGetuid = process.getuid;
+ const oldDebugfsLog = process.env.DEBUGFS_LOG;
+
+ try {
+ process.getuid = () => 12345;
+ process.env.DEBUGFS_LOG = debugfsLog;
+
+ createRootfsImage(
+ mke2fsPath,
+ imagePath,
+ rootfsDir,
+ "gondolin-root",
+ 16,
+ ownershipEntries,
+ );
+
+ assert.equal(fs.existsSync(imagePath), true);
+ const debugfsCommands = fs.readFileSync(debugfsLog, "utf8");
+ assert.match(debugfsCommands, /sif "\/etc\/test" uid 0/);
+ assert.match(debugfsCommands, /sif "\/etc\/test" gid 0/);
+ } finally {
+ process.getuid = oldGetuid;
+ if (oldDebugfsLog === undefined) {
+ delete process.env.DEBUGFS_LOG;
+ } else {
+ process.env.DEBUGFS_LOG = oldDebugfsLog;
+ }
+ fs.rmSync(tmp, { recursive: true, force: true });
}
- fs.rmSync(tmp, { recursive: true, force: true });
- }
-});
-
-test("rootfs image: includes debugfs stderr when ownership metadata fails", () => {
- const tmp = fs.mkdtempSync(
- path.join(os.tmpdir(), "gondolin-debugfs-stderr-"),
- );
- const binDir = path.join(tmp, "bin");
- const rootfsDir = path.join(tmp, "rootfs");
- const imagePath = path.join(tmp, "rootfs.ext4");
-
- fs.mkdirSync(binDir, { recursive: true });
- fs.mkdirSync(path.join(rootfsDir, "etc"), { recursive: true });
- fs.writeFileSync(path.join(rootfsDir, "etc", "test"), "test\n");
-
- const mke2fsPath = writeMke2fsStub(binDir);
-
- writeStubCommand(
- binDir,
- "debugfs",
- [
- 'if [ "${1:-}" = "-V" ]; then',
- ' printf "debugfs fake 1.0\\n"',
- " exit 0",
- "fi",
- 'printf "debugfs ownership write failed\\n" >&2',
- "exit 7",
- ].join("\n"),
- );
-
- const ownershipEntries: RootfsOwnershipEntry[] = [
- { path: "etc/test", uid: 0, gid: 0 },
- ];
-
- const oldGetuid = process.getuid;
-
- try {
- process.getuid = () => 12345;
-
- assert.throws(
- () =>
- createRootfsImage(
- mke2fsPath,
- imagePath,
- rootfsDir,
- "gondolin-root",
- 16,
- ownershipEntries,
- ),
- /debugfs ownership write failed/,
+ },
+);
+
+test(
+ "rootfs image: includes debugfs stderr when ownership metadata fails",
+ { skip: skipWindowsRootfsOwnershipTest },
+ () => {
+ const tmp = fs.mkdtempSync(
+ path.join(os.tmpdir(), "gondolin-debugfs-stderr-"),
+ );
+ const binDir = path.join(tmp, "bin");
+ const rootfsDir = path.join(tmp, "rootfs");
+ const imagePath = path.join(tmp, "rootfs.ext4");
+
+ fs.mkdirSync(binDir, { recursive: true });
+ fs.mkdirSync(path.join(rootfsDir, "etc"), { recursive: true });
+ fs.writeFileSync(path.join(rootfsDir, "etc", "test"), "test\n");
+
+ const mke2fsPath = writeMke2fsStub(binDir);
+
+ writeStubCommand(
+ binDir,
+ "debugfs",
+ [
+ 'if [ "${1:-}" = "-V" ]; then',
+ ' printf "debugfs fake 1.0\\n"',
+ " exit 0",
+ "fi",
+ 'printf "debugfs ownership write failed\\n" >&2',
+ "exit 7",
+ ].join("\n"),
);
- } finally {
- process.getuid = oldGetuid;
- fs.rmSync(tmp, { recursive: true, force: true });
- }
-});
+
+ const ownershipEntries: RootfsOwnershipEntry[] = [
+ { path: "etc/test", uid: 0, gid: 0 },
+ ];
+
+ const oldGetuid = process.getuid;
+
+ try {
+ process.getuid = () => 12345;
+
+ assert.throws(
+ () =>
+ createRootfsImage(
+ mke2fsPath,
+ imagePath,
+ rootfsDir,
+ "gondolin-root",
+ 16,
+ ownershipEntries,
+ ),
+ /debugfs ownership write failed/,
+ );
+ } finally {
+ process.getuid = oldGetuid;
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ },
+);
diff --git a/host/test/sandbox-controller-accel-prime.test.ts b/host/test/sandbox-controller-accel-prime.test.ts
new file mode 100644
index 00000000..19128e6b
--- /dev/null
+++ b/host/test/sandbox-controller-accel-prime.test.ts
@@ -0,0 +1,137 @@
+import assert from "node:assert/strict";
+import test, { afterEach, mock } from "node:test";
+import { PassThrough } from "node:stream";
+import { EventEmitter } from "node:events";
+import * as child_process from "child_process";
+
+import { __test as controllerTest } from "../src/sandbox/controller.ts";
+
+// In ESM, built-in modules expose live bindings via getters which cannot be
+// replaced with node:test mocks. The actual mutable exports object is on
+// `default`. (Matches the pattern used in sandbox-controller.test.ts.)
+const cp: any = (child_process as any).default ?? (child_process as any);
+
+const { primeAccelProbeCache, qemuCanInitializeAccel } = controllerTest as any;
+
+class FakeChildProcess extends EventEmitter {
+ stdout = new PassThrough();
+ stderr = new PassThrough();
+ killed = false;
+
+ kill() {
+ this.killed = true;
+ return true;
+ }
+}
+
+afterEach(() => {
+ mock.restoreAll();
+});
+
+test("primeAccelProbeCache resolves true and warms the cache on exit code 0", async () => {
+ let spawnCalls = 0;
+ let child: FakeChildProcess;
+ mock.method(cp, "spawn", () => {
+ spawnCalls++;
+ child = new FakeChildProcess();
+ setImmediate(() => child.emit("exit", 0));
+ return child as any;
+ });
+
+ await primeAccelProbeCache("qemu-prime-test-ok", "whpx");
+ assert.equal(spawnCalls, 1);
+
+ // A warm cache must short-circuit qemuCanInitializeAccel's own spawnSync
+ // probe entirely -- assert spawnSync is never called for this lookup.
+ let spawnSyncCalls = 0;
+ mock.method(cp, "spawnSync", () => {
+ spawnSyncCalls++;
+ return { status: 1 };
+ });
+
+ assert.equal(qemuCanInitializeAccel("qemu-prime-test-ok", "whpx"), true);
+ assert.equal(spawnSyncCalls, 0);
+});
+
+test("primeAccelProbeCache resolves false and warms the cache on non-zero exit", async () => {
+ mock.method(cp, "spawn", () => {
+ const child = new FakeChildProcess();
+ setImmediate(() => child.emit("exit", 1));
+ return child as any;
+ });
+
+ await primeAccelProbeCache("qemu-prime-test-fail", "whpx");
+
+ let spawnSyncCalls = 0;
+ mock.method(cp, "spawnSync", () => {
+ spawnSyncCalls++;
+ return { status: 0 };
+ });
+
+ assert.equal(qemuCanInitializeAccel("qemu-prime-test-fail", "whpx"), false);
+ assert.equal(spawnSyncCalls, 0);
+});
+
+test("primeAccelProbeCache resolves false when spawn errors (e.g. missing binary)", async () => {
+ mock.method(cp, "spawn", () => {
+ const child = new FakeChildProcess();
+ setImmediate(() =>
+ child.emit("error", Object.assign(new Error("ENOENT"), { code: "ENOENT" })),
+ );
+ return child as any;
+ });
+
+ await primeAccelProbeCache("qemu-prime-test-missing", "whpx");
+
+ let spawnSyncCalls = 0;
+ mock.method(cp, "spawnSync", () => {
+ spawnSyncCalls++;
+ return { status: 0 };
+ });
+
+ assert.equal(qemuCanInitializeAccel("qemu-prime-test-missing", "whpx"), false);
+ assert.equal(spawnSyncCalls, 0);
+});
+
+test("primeAccelProbeCache treats a still-running probe past the timeout as available", async () => {
+ mock.timers.enable({ apis: ["setTimeout"] });
+ let killed = false;
+ mock.method(cp, "spawn", () => {
+ const child = new FakeChildProcess();
+ child.kill = () => {
+ killed = true;
+ return true;
+ };
+ // Never emits exit/error on its own -- simulates a running QEMU idling
+ // at `-S`, which is what a successful WHPX init looks like.
+ return child as any;
+ });
+
+ const primePromise = primeAccelProbeCache("qemu-prime-test-timeout", "whpx");
+ mock.timers.tick(1500);
+ await primePromise;
+
+ assert.equal(killed, true);
+
+ let spawnSyncCalls = 0;
+ mock.method(cp, "spawnSync", () => {
+ spawnSyncCalls++;
+ return { status: 1 };
+ });
+ assert.equal(qemuCanInitializeAccel("qemu-prime-test-timeout", "whpx"), true);
+ assert.equal(spawnSyncCalls, 0);
+});
+
+test("primeAccelProbeCache does not re-probe when the cache is already warm", async () => {
+ let spawnCalls = 0;
+ mock.method(cp, "spawn", () => {
+ spawnCalls++;
+ const child = new FakeChildProcess();
+ setImmediate(() => child.emit("exit", 0));
+ return child as any;
+ });
+
+ await primeAccelProbeCache("qemu-prime-test-idempotent", "whpx");
+ await primeAccelProbeCache("qemu-prime-test-idempotent", "whpx");
+ assert.equal(spawnCalls, 1);
+});
diff --git a/host/test/sandbox-controller-qmp-endpoint.test.ts b/host/test/sandbox-controller-qmp-endpoint.test.ts
new file mode 100644
index 00000000..4370b3ff
--- /dev/null
+++ b/host/test/sandbox-controller-qmp-endpoint.test.ts
@@ -0,0 +1,90 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import { __test as controllerTest } from "../src/sandbox/controller.ts";
+import type { SandboxConfig } from "../src/sandbox/controller.ts";
+
+const { defaultUnixQmpEndpoint, reserveEphemeralTcpEndpoint, resolveDefaultQmpEndpoint } =
+ controllerTest as any;
+
+function baseConfig(overrides: Partial = {}): SandboxConfig {
+ return {
+ qemuPath: "qemu-system-x86_64",
+ kernelPath: "/tmp/vmlinuz",
+ initrdPath: "/tmp/initrd",
+ memory: "256M",
+ cpus: 1,
+ virtioSocketPath: "/tmp/virtio.sock",
+ virtioFsSocketPath: "/tmp/virtiofs.sock",
+ virtioSshSocketPath: "/tmp/virtio-ssh.sock",
+ virtioIngressSocketPath: "/tmp/virtio-ingress.sock",
+ append: "console=ttyS0",
+ machineType: "q35",
+ autoRestart: false,
+ ...overrides,
+ };
+}
+
+test(
+ "defaultUnixQmpEndpoint places the qmp socket next to the virtio socket",
+ { skip: process.platform === "win32" },
+ () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-qmp-endpoint-test-"));
+ const endpoint = defaultUnixQmpEndpoint(
+ baseConfig({ virtioSocketPath: path.join(dir, "virtio.sock") }),
+ );
+ assert.equal(endpoint.transport, "unix");
+ assert.equal(path.dirname(endpoint.path), dir);
+ assert.match(path.basename(endpoint.path), /^gondolin-qmp-[0-9a-f]{8}\.sock$/);
+ },
+);
+
+test("reserveEphemeralTcpEndpoint returns a real, currently-free loopback port", async () => {
+ const endpoint = await reserveEphemeralTcpEndpoint("127.0.0.1");
+ assert.equal(endpoint.transport, "tcp");
+ assert.equal(endpoint.host, "127.0.0.1");
+ assert.ok(endpoint.port > 0 && endpoint.port < 65536);
+
+ // The port was released after reservation, so a fresh listener should be
+ // able to bind it immediately (accepting the same small TOCTOU window the
+ // production code accepts).
+ const net = await import("node:net");
+ await new Promise((resolve, reject) => {
+ const server = net.createServer();
+ server.once("error", reject);
+ server.listen(endpoint.port, endpoint.host, () => {
+ server.close(() => resolve());
+ });
+ });
+});
+
+test(
+ "resolveDefaultQmpEndpoint returns a unix endpoint off Windows",
+ { skip: process.platform === "win32" },
+ async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-qmp-endpoint-test-"));
+ const endpoint = await resolveDefaultQmpEndpoint(
+ baseConfig({ virtioSocketPath: path.join(dir, "virtio.sock") }),
+ );
+ assert.equal(endpoint.transport, "unix");
+ assert.equal(path.dirname(endpoint.path), dir);
+ },
+);
+
+test(
+ "resolveDefaultQmpEndpoint returns a reserved loopback tcp endpoint on Windows",
+ { skip: process.platform !== "win32" },
+ async () => {
+ const endpoint = await resolveDefaultQmpEndpoint(
+ baseConfig({
+ virtioSocketPath: { transport: "tcp", host: "127.0.0.1", port: 0 },
+ }),
+ );
+ assert.equal(endpoint.transport, "tcp");
+ assert.equal(endpoint.host, "127.0.0.1");
+ assert.ok(endpoint.port > 0);
+ },
+);
diff --git a/host/test/sandbox-controller.test.ts b/host/test/sandbox-controller.test.ts
index cb4d755d..06f712c2 100644
--- a/host/test/sandbox-controller.test.ts
+++ b/host/test/sandbox-controller.test.ts
@@ -14,6 +14,8 @@ import {
type SandboxState,
__test,
} from "../src/sandbox/controller.ts";
+import type { LocalEndpointInput } from "../src/local-endpoint.ts";
+import { makeTestEndpoint } from "./helpers/vm-fixture.ts";
// In ESM, built-in modules expose live bindings via getters which cannot be
// replaced with node:test mocks. The actual mutable exports object is on
@@ -32,6 +34,10 @@ class FakeChildProcess extends EventEmitter {
}
}
+function makeEndpoint(name: string): LocalEndpointInput {
+ return makeTestEndpoint(`/tmp/${name}.sock`);
+}
+
function makeConfig(overrides?: Partial): SandboxConfig {
return {
qemuPath: "qemu-system-aarch64",
@@ -39,10 +45,10 @@ function makeConfig(overrides?: Partial): SandboxConfig {
initrdPath: "/tmp/initrd",
memory: "256M",
cpus: 1,
- virtioSocketPath: "/tmp/virtio.sock",
- virtioFsSocketPath: "/tmp/virtiofs.sock",
- virtioSshSocketPath: "/tmp/virtio-ssh.sock",
- virtioIngressSocketPath: "/tmp/virtio-ingress.sock",
+ virtioSocketPath: makeEndpoint("virtio"),
+ virtioFsSocketPath: makeEndpoint("virtiofs"),
+ virtioSshSocketPath: makeEndpoint("virtio-ssh"),
+ virtioIngressSocketPath: makeEndpoint("virtio-ingress"),
append: "console=ttyS0",
machineType: "virt",
accel: "tcg",
@@ -160,10 +166,17 @@ test("SandboxController: idle pause uses a short QMP socket", async () => {
return child as any;
});
+ // On Windows a bare unix-socket path isn't a valid virtioSocketPath, and
+ // the default QMP endpoint is a reserved loopback TCP port instead of a
+ // unix socket next to it (see resolveDefaultQmpEndpoint in controller.ts).
+ const virtioSocketPath: LocalEndpointInput = makeTestEndpoint(
+ path.join(tmpDir, "virtio.sock"),
+ );
+
const controller = new SandboxController(
makeConfig({
qemuIdlePauseMs: 1,
- virtioSocketPath: path.join(tmpDir, "virtio.sock"),
+ virtioSocketPath,
}),
);
@@ -195,15 +208,27 @@ test("SandboxController: idle pause uses a short QMP socket", async () => {
const qmpIndex = spawnedArgs.indexOf("-qmp");
assert.notEqual(qmpIndex, -1);
- const match = /^unix:(.*),server=on,wait=off$/.exec(
- spawnedArgs[qmpIndex + 1]!,
- );
- assert.ok(match);
- const qmpSocketPath = match[1]!;
- assert.equal(path.dirname(qmpSocketPath), tmpDir);
+ const qmpArg = spawnedArgs[qmpIndex + 1]!;
await new Promise((resolve, reject) => {
server.once("error", reject);
+
+ if (process.platform === "win32") {
+ const match = /^tcp:([^:]+):(\d+),server=on,wait=off$/.exec(qmpArg);
+ assert.ok(match, `expected a tcp qmp endpoint, got ${qmpArg}`);
+ const qmpHost = match[1]!;
+ const qmpPort = Number(match[2]);
+ server.listen(qmpPort, qmpHost, () => {
+ server.off("error", reject);
+ resolve();
+ });
+ return;
+ }
+
+ const match = /^unix:(.*),server=on,wait=off$/.exec(qmpArg);
+ assert.ok(match, `expected a unix qmp endpoint, got ${qmpArg}`);
+ const qmpSocketPath = match[1]!;
+ assert.equal(path.dirname(qmpSocketPath), tmpDir);
server.listen(qmpSocketPath, () => {
server.off("error", reject);
resolve();
@@ -234,6 +259,41 @@ test("SandboxController: idle pause uses a short QMP socket", async () => {
}
});
+test("buildQemuArgs supports tcp-backed chardev and netdev endpoints", () => {
+ const args = __test.buildQemuArgs(
+ makeConfig({
+ qemuPath: "qemu-system-x86_64",
+ machineType: "q35",
+ virtioSocketPath: { transport: "tcp", host: "127.0.0.1", port: 4101 },
+ virtioFsSocketPath: {
+ transport: "tcp",
+ host: "127.0.0.1",
+ port: 4102,
+ },
+ virtioSshSocketPath: {
+ transport: "tcp",
+ host: "127.0.0.1",
+ port: 4103,
+ },
+ virtioIngressSocketPath: {
+ transport: "tcp",
+ host: "127.0.0.1",
+ port: 4104,
+ },
+ netSocketPath: { transport: "tcp", host: "127.0.0.1", port: 4105 },
+ }),
+ );
+
+ assert.ok(
+ args.includes("socket,id=virtiocon0,host=127.0.0.1,port=4101,server=off"),
+ );
+ assert.ok(
+ args.includes(
+ "stream,id=net0,server=off,addr.type=inet,addr.host=127.0.0.1,addr.port=4105",
+ ),
+ );
+});
+
test("SandboxController: start is idempotent while running", async () => {
let spawnCalls = 0;
const child = new FakeChildProcess();
@@ -365,9 +425,10 @@ test("sandbox-controller: buildQemuArgs does not select -cpu host when using tcg
initrdPath: "/tmp/initrd",
memory: "256M",
cpus: 1,
- virtioSocketPath: "/tmp/virtio.sock",
- virtioFsSocketPath: "/tmp/virtiofs.sock",
- virtioSshSocketPath: "/tmp/virtiossh.sock",
+ virtioSocketPath: makeEndpoint("virtio"),
+ virtioFsSocketPath: makeEndpoint("virtiofs"),
+ virtioSshSocketPath: makeEndpoint("virtiossh"),
+ virtioIngressSocketPath: makeEndpoint("virtioingress"),
append: "console=ttyS0",
machineType: "q35",
accel: "tcg",
@@ -390,6 +451,9 @@ test("sandbox-controller: selectCpu only uses host with matching hw accel", () =
assert.equal((__test as any).selectCpu(hostArch, "kvm"), "host");
} else if (process.platform === "darwin") {
assert.equal((__test as any).selectCpu(hostArch, "hvf"), "host");
+ } else if (process.platform === "win32") {
+ assert.equal((__test as any).selectCpu(hostArch, "whpx"), "qemu64");
+ assert.equal((__test as any).selectCpu(hostArch, "tcg"), "max");
} else {
assert.equal((__test as any).selectCpu(hostArch, "kvm"), "max");
}
@@ -398,6 +462,109 @@ test("sandbox-controller: selectCpu only uses host with matching hw accel", () =
assert.equal((__test as any).selectCpu(otherArch, "kvm"), "max");
});
+test("sandbox-controller: qemuSupportsAccel parses '-accel help' output", () => {
+ mock.method(cp, "spawnSync", () => ({
+ status: 0,
+ stdout: "Accelerators supported in QEMU binary:\r\ntcg\r\nwhpx\r\n",
+ }));
+
+ assert.equal(
+ (__test as any).qemuSupportsAccel("qemu-system-x86_64", "whpx"),
+ true,
+ );
+ assert.equal(
+ (__test as any).qemuSupportsAccel("qemu-system-x86_64", "hvf"),
+ false,
+ );
+});
+
+test("sandbox-controller: selectAccel falls back to tcg when WHPX cannot initialize", (t) => {
+ if (process.platform !== "win32") {
+ t.skip("WHPX probing is Windows-specific");
+ return;
+ }
+
+ mock.method(cp, "spawnSync", (_bin: string, args: string[]) => {
+ if (args[1] === "help") {
+ return {
+ status: 0,
+ stdout: "Accelerators supported in QEMU binary:\r\ntcg\r\nwhpx\r\n",
+ };
+ }
+
+ return {
+ status: 1,
+ stdout: "",
+ };
+ });
+
+ assert.equal(
+ (__test as any).selectAccel("x64", "qemu-system-x86_64-runtime-fail"),
+ "tcg",
+ );
+});
+
+test("sandbox-controller: selectAccel keeps WHPX when runtime probe succeeds", (t) => {
+ if (process.platform !== "win32") {
+ t.skip("WHPX probing is Windows-specific");
+ return;
+ }
+
+ mock.method(cp, "spawnSync", (_bin: string, args: string[]) => {
+ if (args[1] === "help") {
+ return {
+ status: 0,
+ stdout: "Accelerators supported in QEMU binary:\r\ntcg\r\nwhpx\r\n",
+ };
+ }
+
+ return {
+ status: null,
+ stdout: "",
+ error: { code: "ETIMEDOUT" },
+ };
+ });
+
+ assert.equal(
+ (__test as any).selectAccel("x64", "qemu-system-x86_64-runtime-ok"),
+ "whpx",
+ );
+});
+
+test("sandbox-controller: selectRngObject uses the cross-platform builtin RNG on Windows", (t) => {
+ if (process.platform !== "win32") {
+ t.skip("this asserts the Windows-specific branch");
+ return;
+ }
+
+ assert.equal((__test as any).selectRngObject(), "rng-builtin,id=rng0");
+});
+
+test("sandbox-controller: selectRngObject uses /dev/urandom off Windows", (t) => {
+ if (process.platform === "win32") {
+ t.skip("this asserts the non-Windows branch");
+ return;
+ }
+
+ assert.equal(
+ (__test as any).selectRngObject(),
+ "rng-random,filename=/dev/urandom,id=rng0",
+ );
+});
+
+test("sandbox-controller: buildQemuArgs always emits a virtio-rng device (never silently drops RNG)", () => {
+ const args = __test.buildQemuArgs(makeConfig());
+
+ const objectIndex = args.indexOf("-object");
+ assert.notEqual(objectIndex, -1);
+ const rngObject = args[objectIndex + 1]!;
+ assert.match(rngObject, /^rng-(random|builtin),.*id=rng0/);
+
+ const deviceIndex = args.indexOf("-device");
+ assert.notEqual(deviceIndex, -1);
+ assert.match(args[deviceIndex + 1]!, /^virtio-rng-(pci|device),rng=rng0/);
+});
+
test("sandbox-controller: selectMachineType avoids microvm for x64 tcg", () => {
const selectMachineType = (__test as any).selectMachineType as (
targetArch: string,
diff --git a/host/test/sandbox-helpers.test.ts b/host/test/sandbox-helpers.test.ts
index 72929e02..f6bc2e7a 100644
--- a/host/test/sandbox-helpers.test.ts
+++ b/host/test/sandbox-helpers.test.ts
@@ -69,23 +69,28 @@ function createHelperBundle(
};
}
-function createHelperArchive(bundleDir: string, tmpDir: string): {
+function createHelperArchive(
+ bundleDir: string,
+ tmpDir: string,
+): {
archivePath: string;
data: Buffer;
sha256: string;
} {
- const archivePath = path.join(tmpDir, "helpers.tar.gz");
+ const archiveName = "helpers.tar.gz";
+ const archivePath = path.join(tmpDir, archiveName);
child_process.execFileSync(
"tar",
- ["-czf", archivePath, "manifest.json", "bin"],
- { cwd: bundleDir, stdio: "pipe" },
+ ["-czf", archiveName, "-C", bundleDir, "manifest.json", "bin"],
+ { cwd: tmpDir, stdio: "pipe" },
);
const data = fs.readFileSync(archivePath);
return { archivePath, data, sha256: sha256(data) };
}
function restoreFetch(prevFetch: typeof globalThis.fetch): void {
- (globalThis as unknown as { fetch: typeof globalThis.fetch }).fetch = prevFetch;
+ (globalThis as unknown as { fetch: typeof globalThis.fetch }).fetch =
+ prevFetch;
}
function setEnv(name: string, value: string | undefined): void {
@@ -96,6 +101,28 @@ function setEnv(name: string, value: string | undefined): void {
}
}
+function writeNodeCommand(binDir: string, name: string, script: string): void {
+ fs.mkdirSync(binDir, { recursive: true });
+
+ if (process.platform === "win32") {
+ const scriptPath = path.join(binDir, `${name}.js`);
+ fs.writeFileSync(scriptPath, script);
+ fs.writeFileSync(
+ path.join(binDir, `${name}.cmd`),
+ `@echo off\r\n"${process.execPath}" "%~dp0${name}.js" %*\r\n`,
+ );
+ return;
+ }
+
+ fs.writeFileSync(
+ path.join(binDir, name),
+ `#!${process.execPath}\n${script}`,
+ {
+ mode: 0o755,
+ },
+ );
+}
+
function hostPackageVersion(): string {
const pkgPath = path.join(import.meta.dirname, "..", "package.json");
const parsed = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as {
@@ -242,10 +269,11 @@ test("sandbox helpers: explicit helper directory bypasses registry fetch", async
const prevFetch = globalThis.fetch;
let fetchCalls = 0;
- (globalThis as unknown as { fetch: typeof globalThis.fetch }).fetch = async () => {
- fetchCalls += 1;
- return new Response("not found", { status: 404 });
- };
+ (globalThis as unknown as { fetch: typeof globalThis.fetch }).fetch =
+ async () => {
+ fetchCalls += 1;
+ return new Response("not found", { status: 404 });
+ };
try {
const resolved = await ensureSandboxHelperBinaries({
@@ -255,7 +283,10 @@ test("sandbox helpers: explicit helper directory bypasses registry fetch", async
});
assert.equal(resolved.source, "directory");
assert.equal(resolved.buildId, buildId);
- assert.equal(resolved.paths.sandboxingressPath, path.join(bundleDir, "bin", "sandboxingress"));
+ assert.equal(
+ resolved.paths.sandboxingressPath,
+ path.join(bundleDir, "bin", "sandboxingress"),
+ );
assert.equal(fetchCalls, 0);
} finally {
restoreFetch(prevFetch);
@@ -362,7 +393,8 @@ test("resolveSandboxBinaryPaths: uses registry helpers by default without zig",
const prevRegistryUrl = process.env.GONDOLIN_SANDBOX_HELPER_REGISTRY_URL;
const prevStore = process.env.GONDOLIN_SANDBOX_HELPER_STORE;
const prevHelpersDir = process.env.GONDOLIN_SANDBOX_HELPERS_DIR;
- const prevSourceBuild = process.env.GONDOLIN_BUILD_SANDBOX_HELPERS_FROM_SOURCE;
+ const prevSourceBuild =
+ process.env.GONDOLIN_BUILD_SANDBOX_HELPERS_FROM_SOURCE;
let archiveFetches = 0;
(globalThis as unknown as { fetch: typeof globalThis.fetch }).fetch = async (
@@ -448,10 +480,11 @@ test("resolveSandboxBinaryPaths: all custom helper paths bypass registry", async
const prevFetch = globalThis.fetch;
let fetchCalls = 0;
- (globalThis as unknown as { fetch: typeof globalThis.fetch }).fetch = async () => {
- fetchCalls += 1;
- return new Response("not found", { status: 404 });
- };
+ (globalThis as unknown as { fetch: typeof globalThis.fetch }).fetch =
+ async () => {
+ fetchCalls += 1;
+ return new Response("not found", { status: 404 });
+ };
try {
const paths = await resolveSandboxBinaryPaths(
@@ -480,11 +513,10 @@ test("resolveSandboxBinaryPaths: registry failures do not source-build by defaul
fs.mkdirSync(guestDir, { recursive: true });
fs.mkdirSync(stubDir, { recursive: true });
fs.writeFileSync(path.join(guestDir, "build.zig"), "// test\n");
- fs.writeFileSync(
- path.join(stubDir, "zig"),
- `#!${process.execPath}\n` +
- `require("node:fs").writeFileSync(${JSON.stringify(markerPath)}, "called");\n`,
- { mode: 0o755 },
+ writeNodeCommand(
+ stubDir,
+ "zig",
+ `require("node:fs").writeFileSync(${JSON.stringify(markerPath)}, "called");\n`,
);
const registryUrl =
@@ -495,13 +527,15 @@ test("resolveSandboxBinaryPaths: registry failures do not source-build by defaul
const prevStore = process.env.GONDOLIN_SANDBOX_HELPER_STORE;
const prevHelpersDir = process.env.GONDOLIN_SANDBOX_HELPERS_DIR;
const prevGuestSrc = process.env.GONDOLIN_GUEST_SRC;
- const prevSourceBuild = process.env.GONDOLIN_BUILD_SANDBOX_HELPERS_FROM_SOURCE;
+ const prevSourceBuild =
+ process.env.GONDOLIN_BUILD_SANDBOX_HELPERS_FROM_SOURCE;
- (globalThis as unknown as { fetch: typeof globalThis.fetch }).fetch = async () =>
- new Response("not found", { status: 404, statusText: "Not Found" });
+ (globalThis as unknown as { fetch: typeof globalThis.fetch }).fetch =
+ async () =>
+ new Response("not found", { status: 404, statusText: "Not Found" });
try {
- process.env.PATH = `${stubDir}:${prevPath ?? ""}`;
+ process.env.PATH = `${stubDir}${path.delimiter}${prevPath ?? ""}`;
process.env.GONDOLIN_SANDBOX_HELPER_REGISTRY_URL = registryUrl;
process.env.GONDOLIN_SANDBOX_HELPER_STORE = storeDir;
process.env.GONDOLIN_GUEST_SRC = guestDir;
@@ -539,11 +573,10 @@ test("resolveSandboxBinaryPaths: source builds require explicit env opt-in", asy
fs.mkdirSync(stubDir, { recursive: true });
fs.writeFileSync(path.join(guestDir, "build.zig"), "// test\n");
- const zigStubPath = path.join(stubDir, "zig");
- fs.writeFileSync(
- zigStubPath,
- `#!${process.execPath}\n` +
- `const fs = require("node:fs");\n` +
+ writeNodeCommand(
+ stubDir,
+ "zig",
+ `const fs = require("node:fs");\n` +
`const path = require("node:path");\n` +
`fs.writeFileSync(path.join(process.cwd(), "zig-args.json"), JSON.stringify(process.argv.slice(2)));\n` +
`const binDir = path.join(process.cwd(), "zig-out", "bin");\n` +
@@ -552,7 +585,6 @@ test("resolveSandboxBinaryPaths: source builds require explicit env opt-in", asy
` const filePath = path.join(binDir, name);\n` +
` fs.writeFileSync(filePath, "#!/bin/sh\\necho source-" + name + "\\n", { mode: 0o755 });\n` +
`}\n`,
- { mode: 0o755 },
);
const registryUrl =
@@ -563,16 +595,21 @@ test("resolveSandboxBinaryPaths: source builds require explicit env opt-in", asy
const prevStore = process.env.GONDOLIN_SANDBOX_HELPER_STORE;
const prevHelpersDir = process.env.GONDOLIN_SANDBOX_HELPERS_DIR;
const prevGuestSrc = process.env.GONDOLIN_GUEST_SRC;
- const prevSourceBuild = process.env.GONDOLIN_BUILD_SANDBOX_HELPERS_FROM_SOURCE;
+ const prevSourceBuild =
+ process.env.GONDOLIN_BUILD_SANDBOX_HELPERS_FROM_SOURCE;
let fetchCalls = 0;
- (globalThis as unknown as { fetch: typeof globalThis.fetch }).fetch = async () => {
- fetchCalls += 1;
- return new Response("not found", { status: 404, statusText: "Not Found" });
- };
+ (globalThis as unknown as { fetch: typeof globalThis.fetch }).fetch =
+ async () => {
+ fetchCalls += 1;
+ return new Response("not found", {
+ status: 404,
+ statusText: "Not Found",
+ });
+ };
try {
- process.env.PATH = `${stubDir}:${prevPath ?? ""}`;
+ process.env.PATH = `${stubDir}${path.delimiter}${prevPath ?? ""}`;
process.env.GONDOLIN_SANDBOX_HELPER_REGISTRY_URL = registryUrl;
process.env.GONDOLIN_SANDBOX_HELPER_STORE = storeDir;
process.env.GONDOLIN_GUEST_SRC = guestDir;
diff --git a/host/test/sandbox-server-exit-diagnostics.test.ts b/host/test/sandbox-server-exit-diagnostics.test.ts
index fabb6e1a..53a326c0 100644
--- a/host/test/sandbox-server-exit-diagnostics.test.ts
+++ b/host/test/sandbox-server-exit-diagnostics.test.ts
@@ -3,6 +3,12 @@ import test from "node:test";
import { SandboxServer } from "../src/sandbox/server.ts";
import type { ResolvedSandboxServerOptions } from "../src/sandbox/server-options.ts";
+import type { LocalEndpoint } from "../src/local-endpoint.ts";
+import { makeTestEndpoint } from "./helpers/vm-fixture.ts";
+
+function makeEndpoint(name: string): LocalEndpoint {
+ return makeTestEndpoint(`/tmp/${name}.sock`);
+}
function makeResolvedOptions(
overrides: Partial = {},
@@ -21,10 +27,11 @@ function makeResolvedOptions(
memory: "256M",
cpus: 1,
- virtioSocketPath: "/tmp/gondolin-test-virtio.sock",
- virtioFsSocketPath: "/tmp/gondolin-test-virtiofs.sock",
- virtioSshSocketPath: "/tmp/gondolin-test-virtiossh.sock",
- netSocketPath: "/tmp/gondolin-test-net.sock",
+ virtioSocketPath: makeEndpoint("gondolin-test-virtio"),
+ virtioFsSocketPath: makeEndpoint("gondolin-test-virtiofs"),
+ virtioSshSocketPath: makeEndpoint("gondolin-test-virtiossh"),
+ virtioIngressSocketPath: makeEndpoint("gondolin-test-virtioingress"),
+ netSocketPath: makeEndpoint("gondolin-test-net"),
netMac: "02:00:00:00:00:01",
netEnabled: false,
allowWebSockets: true,
diff --git a/host/test/sandbox-server-queueing.test.ts b/host/test/sandbox-server-queueing.test.ts
index 1ec013ff..bbce0fca 100644
--- a/host/test/sandbox-server-queueing.test.ts
+++ b/host/test/sandbox-server-queueing.test.ts
@@ -3,6 +3,12 @@ import test from "node:test";
import { SandboxServer } from "../src/sandbox/server.ts";
import type { ResolvedSandboxServerOptions } from "../src/sandbox/server-options.ts";
+import type { LocalEndpoint } from "../src/local-endpoint.ts";
+import { makeTestEndpoint } from "./helpers/vm-fixture.ts";
+
+function makeEndpoint(name: string): LocalEndpoint {
+ return makeTestEndpoint(`/tmp/${name}.sock`);
+}
function makeResolvedOptions(
overrides: Partial = {},
@@ -19,10 +25,11 @@ function makeResolvedOptions(
memory: "256M",
cpus: 1,
- virtioSocketPath: "/tmp/gondolin-test-virtio.sock",
- virtioFsSocketPath: "/tmp/gondolin-test-virtiofs.sock",
- virtioSshSocketPath: "/tmp/gondolin-test-virtiossh.sock",
- netSocketPath: "/tmp/gondolin-test-net.sock",
+ virtioSocketPath: makeEndpoint("gondolin-test-virtio"),
+ virtioFsSocketPath: makeEndpoint("gondolin-test-virtiofs"),
+ virtioSshSocketPath: makeEndpoint("gondolin-test-virtiossh"),
+ virtioIngressSocketPath: makeEndpoint("gondolin-test-virtioingress"),
+ netSocketPath: makeEndpoint("gondolin-test-net"),
netMac: "02:00:00:00:00:01",
netEnabled: false,
allowWebSockets: true,
@@ -127,6 +134,67 @@ function tcpSession(extra: Record = {}) {
};
}
+test("SandboxServer: failed host-side startup tears down partial listeners", async () => {
+ const server = new SandboxServer(makeResolvedOptions({ netEnabled: true }));
+ const steps: string[] = [];
+
+ const network = (server as any).network;
+ const bridge = (server as any).bridge;
+ const fsBridge = (server as any).fsBridge;
+ const sshBridge = (server as any).sshBridge;
+ const ingressBridge = (server as any).ingressBridge;
+
+ network.start = async () => {
+ steps.push("network.start");
+ };
+ network.close = async () => {
+ steps.push("network.close");
+ };
+
+ bridge.connect = async () => {
+ steps.push("bridge.connect");
+ };
+ bridge.disconnect = async (options: { permanent?: boolean } = {}) => {
+ steps.push(`bridge.disconnect:${String(options.permanent ?? true)}`);
+ };
+
+ fsBridge.connect = async () => {
+ steps.push("fsBridge.connect");
+ throw new Error("bind failed");
+ };
+ fsBridge.disconnect = async (options: { permanent?: boolean } = {}) => {
+ steps.push(`fsBridge.disconnect:${String(options.permanent ?? true)}`);
+ };
+
+ sshBridge.connect = async () => {
+ steps.push("sshBridge.connect");
+ };
+ sshBridge.disconnect = async (options: { permanent?: boolean } = {}) => {
+ steps.push(`sshBridge.disconnect:${String(options.permanent ?? true)}`);
+ };
+
+ ingressBridge.connect = async () => {
+ steps.push("ingressBridge.connect");
+ };
+ ingressBridge.disconnect = async (options: { permanent?: boolean } = {}) => {
+ steps.push(`ingressBridge.disconnect:${String(options.permanent ?? true)}`);
+ };
+
+ await assert.rejects(server.start(), /bind failed/);
+
+ assert.equal((server as any).started, false);
+ assert.deepEqual(steps, [
+ "network.start",
+ "bridge.connect",
+ "fsBridge.connect",
+ "network.close",
+ "bridge.disconnect:false",
+ "fsBridge.disconnect:false",
+ "sshBridge.disconnect:false",
+ "ingressBridge.disconnect:false",
+ ]);
+});
+
test("exec requests are started concurrently when no file operation is active", () => {
const server = new SandboxServer(makeResolvedOptions());
const sent: any[] = [];
@@ -270,10 +338,10 @@ test("idle pause is armed when sandbox reaches running with no active work", ()
const server = new SandboxServer(makeResolvedOptions());
let scheduleCalls = 0;
- (server as any).bridge.connect = () => {};
- (server as any).fsBridge.connect = () => {};
- (server as any).sshBridge.connect = () => {};
- (server as any).ingressBridge.connect = () => {};
+ (server as any).bridge.connect = async () => {};
+ (server as any).fsBridge.connect = async () => {};
+ (server as any).sshBridge.connect = async () => {};
+ (server as any).ingressBridge.connect = async () => {};
(server as any).controller.scheduleIdlePause = () => {
scheduleCalls += 1;
};
diff --git a/host/test/server-transport.test.ts b/host/test/server-transport.test.ts
new file mode 100644
index 00000000..79030a5b
--- /dev/null
+++ b/host/test/server-transport.test.ts
@@ -0,0 +1,64 @@
+import assert from "node:assert/strict";
+import test, { afterEach, mock } from "node:test";
+
+import { VirtioBridge } from "../src/sandbox/server-transport.ts";
+
+afterEach(() => {
+ mock.restoreAll();
+ mock.timers.reset();
+});
+
+test("VirtioBridge retries reconnect attempts after async connect failures", async () => {
+ mock.timers.enable();
+
+ const bridge = new VirtioBridge({
+ transport: "tcp",
+ host: "127.0.0.1",
+ port: 0,
+ });
+
+ let attempts = 0;
+ (bridge as any).connect = async () => {
+ attempts += 1;
+ if (attempts === 1) {
+ throw new Error("bind failed");
+ }
+ };
+
+ (bridge as any).scheduleReconnect();
+
+ mock.timers.tick(500);
+ await Promise.resolve();
+
+ assert.equal(attempts, 1);
+ assert.ok((bridge as any).reconnectTimer);
+
+ mock.timers.tick(500);
+ await Promise.resolve();
+
+ assert.equal(attempts, 2);
+
+ await bridge.disconnect();
+});
+
+test("VirtioBridge temporary disconnect keeps the bridge reusable", async () => {
+ const bridge = new VirtioBridge({
+ transport: "tcp",
+ host: "127.0.0.1",
+ port: 0,
+ });
+
+ let attempts = 0;
+ (bridge as any).connect = async () => {
+ attempts += 1;
+ };
+
+ await bridge.disconnect({ permanent: false });
+
+ assert.equal(bridge.send({ v: 1, t: "test" }), true);
+ await Promise.resolve();
+
+ assert.equal(attempts, 1);
+
+ await bridge.disconnect();
+});
diff --git a/host/test/session-registry.test.ts b/host/test/session-registry.test.ts
new file mode 100644
index 00000000..5fd81770
--- /dev/null
+++ b/host/test/session-registry.test.ts
@@ -0,0 +1,61 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import type { ClientMessage } from "../src/sandbox/control-protocol.ts";
+import {
+ SessionIpcServer,
+ connectToSession,
+} from "../src/session-registry.ts";
+
+test("SessionIpcServer accepts tcp endpoints for attach IPC", async () => {
+ const endpoint = {
+ transport: "tcp" as const,
+ host: "127.0.0.1",
+ port: 0,
+ };
+
+ const server = new SessionIpcServer(endpoint, (onMessage) => ({
+ send(message: ClientMessage) {
+ if (message.type !== "exec") return;
+ onMessage(
+ JSON.stringify({
+ type: "exec_response",
+ id: message.id,
+ exit_code: 0,
+ }),
+ false,
+ );
+ },
+ close() {},
+ }));
+
+ await server.start();
+ assert.ok(endpoint.port > 0);
+
+ const response = await new Promise((resolve, reject) => {
+ const client = connectToSession(endpoint, {
+ onJson(message) {
+ resolve(message);
+ client.close();
+ },
+ onBinary() {
+ reject(new Error("unexpected binary frame"));
+ },
+ onClose(error) {
+ if (error) reject(error);
+ },
+ });
+
+ client.send({
+ type: "exec",
+ id: 7,
+ cmd: "/bin/true",
+ });
+ });
+
+ assert.equal(response.type, "exec_response");
+ assert.equal(response.id, 7);
+ assert.equal(response.exit_code, 0);
+
+ await server.close();
+});
diff --git a/host/test/ssh.test.ts b/host/test/ssh.test.ts
index ec81fdc8..97411837 100644
--- a/host/test/ssh.test.ts
+++ b/host/test/ssh.test.ts
@@ -12,6 +12,9 @@ import {
const skipVmTests = shouldSkipVmTests();
const timeoutMs = Number(process.env.WS_TIMEOUT ?? 120000);
const sshVmKey = "ssh-default";
+const sshConnectTimeoutSeconds = process.platform === "win32" ? 10 : 5;
+const sshExecTimeoutMs = process.platform === "win32" ? 15000 : 10000;
+const sshRetryWindowMs = process.platform === "win32" ? 45000 : 15000;
function hasSshClient(): boolean {
try {
@@ -24,6 +27,36 @@ function hasSshClient(): boolean {
const skipIfNoSsh = !hasSshClient();
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function runSshCommand(args: string[]): Promise {
+ const deadline = Date.now() + sshRetryWindowMs;
+ let lastError: unknown;
+
+ while (Date.now() < deadline) {
+ try {
+ return await new Promise((resolve, reject) => {
+ execFile(
+ "ssh",
+ args,
+ { timeout: sshExecTimeoutMs },
+ (err, stdout) => {
+ if (err) reject(err);
+ else resolve(stdout);
+ },
+ );
+ });
+ } catch (err) {
+ lastError = err;
+ await sleep(500);
+ }
+ }
+
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
+}
+
test.after(async () => {
await closeVm(sshVmKey);
scheduleForceExit();
@@ -55,41 +88,31 @@ test(
const access = await vm.enableSsh();
- const stdout = await new Promise((resolve, reject) => {
- execFile(
- "ssh",
- [
- "-p",
- String(access.port),
- "-i",
- access.identityFile,
- "-o",
- "StrictHostKeyChecking=no",
- "-o",
- "UserKnownHostsFile=/dev/null",
- "-o",
- "BatchMode=yes",
- "-o",
- "ConnectTimeout=5",
- "-o",
- "IdentitiesOnly=yes",
- "-o",
- "ForwardAgent=no",
- "-o",
- "ClearAllForwardings=yes",
- "-o",
- "LogLevel=ERROR",
- `${access.user}@${access.host}`,
- "echo",
- "ssh-ok",
- ],
- { timeout: 20000 },
- (err, stdout) => {
- if (err) reject(err);
- else resolve(stdout);
- },
- );
- });
+ const stdout = await runSshCommand([
+ "-p",
+ String(access.port),
+ "-i",
+ access.identityFile,
+ "-o",
+ "StrictHostKeyChecking=no",
+ "-o",
+ "UserKnownHostsFile=/dev/null",
+ "-o",
+ "BatchMode=yes",
+ "-o",
+ `ConnectTimeout=${sshConnectTimeoutSeconds}`,
+ "-o",
+ "IdentitiesOnly=yes",
+ "-o",
+ "ForwardAgent=no",
+ "-o",
+ "ClearAllForwardings=yes",
+ "-o",
+ "LogLevel=ERROR",
+ `${access.user}@${access.host}`,
+ "echo",
+ "ssh-ok",
+ ]);
assert.equal(stdout.trim(), "ssh-ok");
diff --git a/host/test/streaming-race.test.ts b/host/test/streaming-race.test.ts
index 8bafb4ca..166fa50e 100644
--- a/host/test/streaming-race.test.ts
+++ b/host/test/streaming-race.test.ts
@@ -6,15 +6,20 @@ import test from "node:test";
import * as qemuHttp from "../src/qemu/http.ts";
import { QemuNetworkBackend } from "../src/qemu/net.ts";
+import { makeTestEndpoint } from "./helpers/vm-fixture.ts";
function makeBackend(
options?: Partial[0]>,
) {
- return new QemuNetworkBackend({
- socketPath: path.join(
+ const socketPath = makeTestEndpoint(
+ path.join(
os.tmpdir(),
`gondolin-net-test-${process.pid}-${crypto.randomUUID()}.sock`,
),
+ );
+
+ return new QemuNetworkBackend({
+ socketPath,
...options,
});
}
diff --git a/host/test/vfs-realfs-provider.test.ts b/host/test/vfs-realfs-provider.test.ts
index c6570fdd..b91d8185 100644
--- a/host/test/vfs-realfs-provider.test.ts
+++ b/host/test/vfs-realfs-provider.test.ts
@@ -21,6 +21,39 @@ function makeTempDir(t: TestContext, prefix = "gondolin-vfs-") {
return dir;
}
+let symlinkSupport: boolean | null = null;
+
+/**
+ * Probe (once, cached) whether this process can actually create symlinks,
+ * rather than assuming Windows can't: Developer Mode or an elevated/admin
+ * process can create them fine on Windows too, and skipping unconditionally
+ * by platform would leave RealFSProvider's symlink-escape security guards
+ * completely untested wherever it's actually capable of running them.
+ */
+function canCreateSymlinks(): boolean {
+ if (symlinkSupport !== null) return symlinkSupport;
+ if (process.platform !== "win32") {
+ symlinkSupport = true;
+ return symlinkSupport;
+ }
+
+ const probeDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), "gondolin-symlink-probe-"),
+ );
+ try {
+ const target = path.join(probeDir, "target.txt");
+ const link = path.join(probeDir, "link.txt");
+ fs.writeFileSync(target, "probe");
+ fs.symlinkSync(target, link, "file");
+ symlinkSupport = true;
+ } catch {
+ symlinkSupport = false;
+ } finally {
+ fs.rmSync(probeDir, { recursive: true, force: true });
+ }
+ return symlinkSupport;
+}
+
test("RealFSProvider proxies filesystem operations (sync + async)", async (t) => {
const root = makeTempDir(t);
const provider = new RealFSProvider(root);
@@ -100,8 +133,11 @@ test("RealFSProvider blocks path traversal outside root", (t) => {
});
test("RealFSProvider symlink, readlink, lstat, realpath", (t) => {
- if (process.platform === "win32") {
- t.skip("symlink semantics require elevated permissions on Windows");
+ if (!canCreateSymlinks()) {
+ t.skip(
+ "symlink creation is not permitted in this environment (Windows without Developer Mode/admin)",
+ );
+ return;
}
const root = makeTempDir(t);
@@ -125,8 +161,11 @@ test("RealFSProvider symlink, readlink, lstat, realpath", (t) => {
});
test("RealFSProvider blocks read via pre-existing escaping symlink", async (t) => {
- if (process.platform === "win32") {
- t.skip("symlink semantics require elevated permissions on Windows");
+ if (!canCreateSymlinks()) {
+ t.skip(
+ "symlink creation is not permitted in this environment (Windows without Developer Mode/admin)",
+ );
+ return;
}
const root = makeTempDir(t);
@@ -144,8 +183,11 @@ test("RealFSProvider blocks read via pre-existing escaping symlink", async (t) =
});
test("RealFSProvider blocks create under symlinked parent escaping root", async (t) => {
- if (process.platform === "win32") {
- t.skip("symlink semantics require elevated permissions on Windows");
+ if (!canCreateSymlinks()) {
+ t.skip(
+ "symlink creation is not permitted in this environment (Windows without Developer Mode/admin)",
+ );
+ return;
}
const root = makeTempDir(t);
@@ -166,8 +208,11 @@ test("RealFSProvider blocks create under symlinked parent escaping root", async
});
test("RealFSProvider allows lstat/readlink/unlink on escaping symlink inside root", (t) => {
- if (process.platform === "win32") {
- t.skip("symlink semantics require elevated permissions on Windows");
+ if (!canCreateSymlinks()) {
+ t.skip(
+ "symlink creation is not permitted in this environment (Windows without Developer Mode/admin)",
+ );
+ return;
}
const root = makeTempDir(t);
@@ -191,8 +236,11 @@ test("RealFSProvider allows lstat/readlink/unlink on escaping symlink inside roo
});
test("RealFSProvider blocks unlink through escaping intermediate symlink", async (t) => {
- if (process.platform === "win32") {
- t.skip("symlink semantics require elevated permissions on Windows");
+ if (!canCreateSymlinks()) {
+ t.skip(
+ "symlink creation is not permitted in this environment (Windows without Developer Mode/admin)",
+ );
+ return;
}
const root = makeTempDir(t);
@@ -208,8 +256,11 @@ test("RealFSProvider blocks unlink through escaping intermediate symlink", async
});
test("RealFSProvider allows in-root relative symlink", (t) => {
- if (process.platform === "win32") {
- t.skip("symlink semantics require elevated permissions on Windows");
+ if (!canCreateSymlinks()) {
+ t.skip(
+ "symlink creation is not permitted in this environment (Windows without Developer Mode/admin)",
+ );
+ return;
}
const root = makeTempDir(t);
@@ -229,8 +280,11 @@ test("RealFSProvider allows in-root relative symlink", (t) => {
});
test("RealFSProvider allows in-root absolute symlink", (t) => {
- if (process.platform === "win32") {
- t.skip("symlink semantics require elevated permissions on Windows");
+ if (!canCreateSymlinks()) {
+ t.skip(
+ "symlink creation is not permitted in this environment (Windows without Developer Mode/admin)",
+ );
+ return;
}
const root = makeTempDir(t);
@@ -252,8 +306,11 @@ test("RealFSProvider allows in-root absolute symlink", (t) => {
});
test("RealFSProvider blocks dangling escaping symlink on write", (t) => {
- if (process.platform === "win32") {
- t.skip("symlink semantics require elevated permissions on Windows");
+ if (!canCreateSymlinks()) {
+ t.skip(
+ "symlink creation is not permitted in this environment (Windows without Developer Mode/admin)",
+ );
+ return;
}
const root = makeTempDir(t);
@@ -272,8 +329,11 @@ test("RealFSProvider blocks dangling escaping symlink on write", (t) => {
});
test("RealFSProvider blocks write via dangling symlink inside root", (t) => {
- if (process.platform === "win32") {
- t.skip("symlink semantics require elevated permissions on Windows");
+ if (!canCreateSymlinks()) {
+ t.skip(
+ "symlink creation is not permitted in this environment (Windows without Developer Mode/admin)",
+ );
+ return;
}
const root = makeTempDir(t);
@@ -285,8 +345,11 @@ test("RealFSProvider blocks write via dangling symlink inside root", (t) => {
});
test("RealFSProvider blocks hard-link to escaping symlink target", (t) => {
- if (process.platform === "win32") {
- t.skip("symlink semantics require elevated permissions on Windows");
+ if (!canCreateSymlinks()) {
+ t.skip(
+ "symlink creation is not permitted in this environment (Windows without Developer Mode/admin)",
+ );
+ return;
}
const root = makeTempDir(t);
@@ -300,8 +363,11 @@ test("RealFSProvider blocks hard-link to escaping symlink target", (t) => {
});
test("RealFSProvider blocks hard-link destination through escaping intermediate symlink", async (t) => {
- if (process.platform === "win32") {
- t.skip("symlink semantics require elevated permissions on Windows");
+ if (!canCreateSymlinks()) {
+ t.skip(
+ "symlink creation is not permitted in this environment (Windows without Developer Mode/admin)",
+ );
+ return;
}
const root = makeTempDir(t);
@@ -322,8 +388,11 @@ test("RealFSProvider blocks hard-link destination through escaping intermediate
});
test("RealFSProvider blocks chained dangling symlink escape", (t) => {
- if (process.platform === "win32") {
- t.skip("symlink semantics require elevated permissions on Windows");
+ if (!canCreateSymlinks()) {
+ t.skip(
+ "symlink creation is not permitted in this environment (Windows without Developer Mode/admin)",
+ );
+ return;
}
const root = makeTempDir(t);
@@ -341,8 +410,11 @@ test("RealFSProvider blocks chained dangling symlink escape", (t) => {
});
test("RealFSProvider blocks mkdir through escaping intermediate symlink", (t) => {
- if (process.platform === "win32") {
- t.skip("symlink semantics require elevated permissions on Windows");
+ if (!canCreateSymlinks()) {
+ t.skip(
+ "symlink creation is not permitted in this environment (Windows without Developer Mode/admin)",
+ );
+ return;
}
const root = makeTempDir(t);
@@ -355,8 +427,11 @@ test("RealFSProvider blocks mkdir through escaping intermediate symlink", (t) =>
});
test("RealFSProvider blocks rename through escaping intermediate symlink", (t) => {
- if (process.platform === "win32") {
- t.skip("symlink semantics require elevated permissions on Windows");
+ if (!canCreateSymlinks()) {
+ t.skip(
+ "symlink creation is not permitted in this environment (Windows without Developer Mode/admin)",
+ );
+ return;
}
const root = makeTempDir(t);
@@ -374,28 +449,43 @@ test("RealFSProvider blocks rename through escaping intermediate symlink", (t) =
});
test("RealFSProvider rmdir does not follow final symlink", (t) => {
- if (process.platform === "win32") {
- t.skip("symlink semantics require elevated permissions on Windows");
+ if (!canCreateSymlinks()) {
+ t.skip(
+ "symlink creation is not permitted in this environment (Windows without Developer Mode/admin)",
+ );
+ return;
}
const root = makeTempDir(t);
const provider = new RealFSProvider(root);
fs.mkdirSync(path.join(root, "real-dir"));
- fs.symlinkSync("real-dir", path.join(root, "dir-link"));
+ fs.symlinkSync("real-dir", path.join(root, "dir-link"), "dir");
- assert.throws(() => provider.rmdirSync("/dir-link"), {
- code: "ENOTDIR",
- });
+ if (process.platform === "win32") {
+ // Windows' RemoveDirectory (unlike POSIX rmdir) doesn't refuse to
+ // operate on a directory symlink/junction - it removes the reparse
+ // point itself without following it, which is equally safe (the real
+ // target is never touched) but doesn't throw ENOTDIR the way POSIX does.
+ provider.rmdirSync("/dir-link");
+ assert.equal(fs.existsSync(path.join(root, "dir-link")), false);
+ } else {
+ assert.throws(() => provider.rmdirSync("/dir-link"), {
+ code: "ENOTDIR",
+ });
+ assert.equal(
+ fs.lstatSync(path.join(root, "dir-link")).isSymbolicLink(),
+ true,
+ );
+ }
assert.equal(fs.existsSync(path.join(root, "real-dir")), true);
- assert.equal(
- fs.lstatSync(path.join(root, "dir-link")).isSymbolicLink(),
- true,
- );
});
test("RealFSProvider rename does not follow final symlink components", (t) => {
- if (process.platform === "win32") {
- t.skip("symlink semantics require elevated permissions on Windows");
+ if (!canCreateSymlinks()) {
+ t.skip(
+ "symlink creation is not permitted in this environment (Windows without Developer Mode/admin)",
+ );
+ return;
}
const root = makeTempDir(t);
diff --git a/host/test/vm-internals.test.ts b/host/test/vm-internals.test.ts
index be897619..05efe618 100644
--- a/host/test/vm-internals.test.ts
+++ b/host/test/vm-internals.test.ts
@@ -11,6 +11,7 @@ import { VM, __test, type VMOptions } from "../src/vm/core.ts";
import { resolveEnvNumber } from "../src/utils/env.ts";
import { getImageVirtualSizeBytes } from "../src/qemu/img.ts";
import type { RootfsMode } from "../src/build/config.ts";
+import { makeTestEndpoint as makeEndpoint } from "./helpers/vm-fixture.ts";
function makeTempResolvedServerOptions() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-vm-test-"));
@@ -35,11 +36,13 @@ function makeTempResolvedServerOptions() {
rootDiskReadOnly: false,
memory: "256M",
cpus: 1,
- virtioSocketPath: path.join(dir, "virtio.sock"),
- virtioFsSocketPath: path.join(dir, "virtiofs.sock"),
- virtioSshSocketPath: path.join(dir, "virtio-ssh.sock"),
- virtioIngressSocketPath: path.join(dir, "virtio-ingress.sock"),
- netSocketPath: path.join(dir, "net.sock"),
+ virtioSocketPath: makeEndpoint(path.join(dir, "virtio.sock")),
+ virtioFsSocketPath: makeEndpoint(path.join(dir, "virtiofs.sock")),
+ virtioSshSocketPath: makeEndpoint(path.join(dir, "virtio-ssh.sock")),
+ virtioIngressSocketPath: makeEndpoint(
+ path.join(dir, "virtio-ingress.sock"),
+ ),
+ netSocketPath: makeEndpoint(path.join(dir, "net.sock")),
netMac: "02:00:00:00:00:01",
netEnabled: false,
debug: [],
diff --git a/package.json b/package.json
index 67ebd765..754fe11b 100644
--- a/package.json
+++ b/package.json
@@ -3,13 +3,10 @@
"scripts": {
"build": "pnpm -r build",
"test": "pnpm -r test",
- "gondolin": "pnpm --filter @earendil-works/gondolin gondolin",
- "bash": "pnpm --filter @earendil-works/gondolin bash",
+ "gondolin": "pnpm --dir host gondolin",
+ "bash": "pnpm --dir host bash",
"prepare:krun-runner-package": "node ./scripts/prepare-krun-runner-package.mjs"
},
- "dependencies": {
- "@earendil-works/gondolin": "workspace:*"
- },
"devDependencies": {
"prettier": "^3.8.1",
"typescript": "^5.7.3"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bc6cc643..e9fc68a6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7,10 +7,6 @@ settings:
importers:
.:
- dependencies:
- '@earendil-works/gondolin':
- specifier: workspace:*
- version: link:host
devDependencies:
prettier:
specifier: ^3.8.1