D9010 VmApp specification, format 1
D9010 VmApp runs desktop apps from anywhere without trusting them:
- The app's core (its "main process": Node, .NET, Rust, any language) runs inside a small Linux VM.
- The app's UI (HTML/CSS/JS) renders on the host, in a sandboxed Chromium window that has no Node, no network and no file access.
- The two halves talk only through a bridge that the host controls.
- The user decides in a loader app what each app may reach: which folders, and how.
It's Electron's split, but with "main" moved into a VM. Apps ship only their own code; the Linux system and the language runtimes are shared and never rebuilt per app.
This document is binding for every component. If code and spec disagree, the spec wins until it's changed here.
1. Components#
| Folder | Component | Language |
|---|---|---|
guest/ |
vmapp-init: the VM's /init (static aarch64 musl) |
Rust |
host/ |
vmapp-vm: one VM per running app (Apple Virtualization.framework), plus libvmapp; and vmapp-vm-krun, the experimental second launcher for the Vulkan GPU (libkrun, §8.7) |
Rust + Objective-C shim |
runtimes/<name>/ |
Dockerfiles of the shared runtime images (native, node22, dotnet8) |
Dockerfile |
tools/mkruntime |
Builds a runtime image (EROFS) from runtimes/<name> |
bash |
sdk/node/vmapp.js |
The core-side SDK for Node apps (Electron-like API); baked into the node22 runtime |
JavaScript |
sdk/node/http-bridge.js, sdk/web/vmapp-fetch.js |
"HTTP over the bridge" with streamed bodies (docs/CONVERTING.md 5.3): the core half and the page half. Not part of the SDK contract and not baked into a runtime: an app that converts a web app vendors the two files into its core/ and ui/. They use only window.vmapp / ipcMain (§6, §9). |
JavaScript |
hostlib/ |
Host-side library: package reader/verifier, installer, VM session + bridge, window-manager interface | JavaScript (Node, no deps) |
tools/vmapp |
CLI: pack, inspect, verify, install, list, uninstall, run --headless |
JavaScript (Node) |
loader/ |
D9010 VmApp Launcher: the user's app manager and renderer host | Electron 44.4.5 |
gpu/ |
The GPU capability's host server (§8.6): pinned llama.cpp commit (pins.env), build.sh (Metal RPC server into out/gpu/), the sandbox profile rpc-server.sb |
bash + sandbox profile |
gpu/krun/ |
The experimental Vulkan backend (§8.7): pinned build of its host libraries (libkrun, virglrenderer with Venus, MoltenVK, libepoxy; pins.env, patches/, build-libs.sh, build.sh) and the VMM's sandbox profile vmm.sb |
bash + sandbox profile |
apps/ |
Sample apps: notes (Node), hasher (Rust), todo (.NET) |
various |
tests/ |
End-to-end tests | bash / Node |
Some pieces were adapted from earlier projects of the author: agent-vm (guest init, host launcher, the runtime and
cpio tools) and d9010-native (the kernel build in kernel/, and set_shares in the VZ shim). Each such file carries
a header comment naming its source. Nothing in this repository depends on those projects being present.
Kernel (milestone 1): built by kernel/build.sh (Linux 6.18.56-vmapp, arm64, pinned in kernel/pins.env) to
out/Image. tools/build.sh runs it when the kernel is missing or its inputs changed. Every launcher passes
--kernel explicitly. The gpu flavour (kernel/build.sh --flavour gpu -> out/Image-gpu, §8.7) adds virtio-mmio, the
virtio-gpu DRM driver and a blob-alignment patch for the experimental Vulkan backend; the default kernel does not change.
Dev data root: $VMAPP_HOME, default $VMAPP_WORK/vmapp-home; VMAPP_WORK is the scratch and cache folder and
defaults to out/work in the repository (git-ignored). A packaged loader uses
~/Library/Application Support/VMApp Loader. Layout:
$VMAPP_HOME/
runtimes/<name>.img installed runtime images
apps/<id>/<version>/ extracted, verified packages (vmapp.json, ui/, core.cpio or core.erofs)
apps/<id>/current text file: the active version
data/<id>/ the app's private data (shared to the VM as appdata → /data)
grants/<id>.json the user's saved permission choices (folders, host capabilities, network, devices)
gpu/servers/<pid>.json the GPU servers running now (§8.6), so a crashed loader's are stopped at the next start
2. Package format: .vmapp#
A .vmapp file is an uncompressed POSIX ustar tar archive:
- Entry order:
vmapp.json(first);ui/and everything below it, sorted by path;core.cpio, orcore.erofswhen the manifest'score.imageis"erofs"(exactly the one the manifest names);- optional
icon.png.
- Allowed entries: only regular files and directories, with relative paths (no leading
/, no..component, no empty components). - Ownership and times: uid/gid 0, mtime 0, mode 0644 for files and 0755 for directories.
- Path length: at most 255 bytes, using the ustar prefix field.
- No other entries: readers reject anything else (links, devices, pax headers, duplicate paths) and any entry
not listed in
files, exceptvmapp.jsonand directories. - Size limits: 2047 MiB per package (a package is read whole into memory, and Node reads at most 2 GiB in one go),
core.cpioat most 256 MiB (it is copied into the VM's memory at boot),core.erofsup to the package limit (it is mounted, not copied),ui/at most 1 GiB. (Until 2026-10-17: 512 MiB per package and 64 MiB ofui/; raised withcore.erofs, docs/design/DEV-APPS.md C7.) - Reproducible: packing the same input twice gives byte-identical output.
2.1 core.cpio and core.erofs#
This is the app's core files, which become /app in the VM. The manifest's core.image chooses how they are packed; the tree
rules are the same for both:
- Allowed entries: regular files, directories and relative symlinks that stay inside
/app. - Ownership and times: uid/gid 0, mtime 0, and modes as on disk masked to 0755/0644. The exec bit is kept.
core.cpio (core.image "cpio", the default) is an uncompressed newc cpio, sorted by path, ending with
TRAILER!!!. The guest copies it into a tmpfs, so the files cost the VM their size in memory.
core.erofs (core.image "erofs") is a read-only EROFS image (the Linux kernel's format, fs/erofs/erofs_fs.h), which
the guest mounts as it is: the files cost no memory but the page cache. Only this subset is valid:
- 4 KiB blocks, the superblock at byte 1024, no compression, no chunk-based files, no extended attributes or xattr prefixes, no
extra devices, no superblock extension slots; compatible feature bits at most
SB_CHKSUMandMTIME; - inodes "compact" (32 bytes) or "extended" (64 bytes), data "flat plain" or "flat inline" (the tail in the inode's block, not crossing it);
- directories with
.and.., entries sorted by name, link counts that match; a file has one link (no hard links); every inode is reached once from the root (no cycles); every block lies inside the image; - a symlink's own mode bits are ignored (as by Linux) but may not have the setuid, setgid or sticky bit.
hostlib/erofs.js writes such images deterministically (the same tree gives the same bytes: inodes in path order, the
root at nid 36, the UUID derived from the content) and its reader checks every rule above at pack, verify and install. An
uncompressed image of mkfs.erofs -T0 --all-root -x-1 from an export of the same tree also passes.
2.2 vmapp.json (the manifest)#
{
"format": 1,
"id": "com.example.notes",
"name": "Notes",
"version": "1.0.0",
"publisher": "Example Inc.",
"description": "Markdown notes in a folder you choose.",
"runtime": "node22",
"core": {
"command": ["node", "main.js"],
"workdir": "/app",
"env": { "NODE_ENV": "production" },
"memoryMiB": 256,
"cpus": 1
},
"ui": { "root": "ui" },
"permissions": {
"folders": [
{ "name": "notes", "access": "readwrite", "purpose": "The folder where your notes are kept" },
{ "name": "export", "access": "writeonly", "purpose": "Where exported HTML files are saved" }
],
"dialogs": true,
"external": true
},
"files": {
"core.cpio": "<sha256 hex>",
"ui/index.html": "<sha256 hex>"
}
}
Rules, all checked by hostlib on pack, verify and install:
| Field | Rule |
|---|---|
format |
1 |
id |
^[a-z0-9]+(\.[a-z0-9-]+)+$, 3–100 chars (reverse-DNS) |
name |
1–60 chars, no control chars |
version |
semver MAJOR.MINOR.PATCH (optional -pre) |
publisher, description |
strings, ≤100 and ≤500 chars |
runtime |
one of the installed runtime names: native, node22, node22-gpu (§8.7), dotnet8 |
core.command |
non-empty array of strings. command[0] is resolved by vmapp-init in PATH, or is an absolute path, or is ./x relative to workdir. |
core.workdir |
absolute path inside the VM; default /app |
core.env |
string→string map; names ^[A-Z_][A-Z0-9_]*$; must not set VMAPP_* |
core.memoryMiB |
64–32768, default 256. A recommendation: the memory the developer found the app needs. The user chooses the real size (§8.5). |
core.cpus |
1–16, default 1. A recommendation, like memoryMiB (§8.5). |
core.image |
"cpio" (default) or "erofs": how the core files are packed (§2.1), and so whether the package has core.cpio or core.erofs. Absent from the canonical form when "cpio", so existing package ids are unchanged. Proposed 2026-10-17, docs/design/DEV-APPS.md C7. |
ui.root |
a single folder name (default ui); the UI is stored under that name in the package. Where §2 says ui/, read <ui.root>/. |
ui.features |
optional array of unique strings from workers, wasm, self-fetch, frames, network: window features the app's windows may use (§7 "Window features"). Each adds exactly its own sources to the window's CSP; none reaches a file or a device, and only network reaches the network: the windows' data requests, through the app's own proxy and under the app's network grants (§8.3 "The app's windows"). network needs permissions.network. Absent means none (SPEC 7's CSP as it is). Proposed 2026-10-17, docs/design/DEV-APPS.md C1 (network: C3). |
permissions.folders[] |
name ^[a-z][a-z0-9-]{0,30}$, unique; access one of read, readwrite, writeonly; purpose ≤200 chars, optional; the loader shows "no reason given" when it's empty. At most 8 entries. |
permissions.host[] |
optional, at most 8 entries {cap, api?, purpose?}; cap is screen, input, gpu, tool:<id>, tool:*, mcp:<id> or mcp:* (ids ^[a-z0-9][a-z0-9-]{0,40}$), unique; purpose ≤200 chars. api exists for gpu only, and its only value is vulkan (the experimental Vulkan GPU, §8.7; the app's runtime must then be node22-gpu); a plain gpu is the llama.cpp RPC service. See §8.2; gpu is §8.6 and §8.7. |
permissions.network[] |
optional, at most 32 entries {host, ports?, purpose?}; host is a lowercase ASCII host name (api.example.com), a wildcard (*.example.com) or * (any public host), unique; ports is 1-8 distinct integers 1-65535 or "*" (every port), default [443]; purpose ≤200 chars. IP literals, one-label names, localhost and the .local, .internal, .localdomain, .home.arpa, .lan names are rejected. See §8.3. |
permissions.devices[] |
optional, at most 4 entries {device, purpose?}; device is microphone, clipboard or ports (camera is reserved and rejected), unique; purpose ≤200 chars. The microphone: the app's windows (not its core) may use it; the clipboard: its windows and its core may read and write it; ports: its core may open its servers on this Mac's localhost (§8.8). Each only with the user's grant. See §8.4. |
permissions.dialogs |
bool, default true: may the core show native open/save dialogs (each grant chosen by the user) |
permissions.external |
bool, default false: may the core ask to open https: URLs in the user's browser (the user confirms each one) |
permissions.links |
bool, default false (absent from the canonical form when false): the app receives vmapp://<its id>/… links opened on the Mac, as app.on('open-url') (§8.9) |
files |
every entry of the package except vmapp.json and directories, mapped to its SHA-256 (lowercase hex). Extra or missing entries mean the package is invalid. |
| unknown keys | rejected (strict) |
The package id of a build is sha256(vmapp.json bytes). It is shown in the loader and identifies exactly what
was reviewed.
Reserved for milestone 2: signature (publisher signature over the manifest) and the value camera of
permissions.devices (the manifest rejects it until a later spec change defines it). (permissions.network is defined in
§8.3 and permissions.devices with microphone in §8.4.)
Future (owner notes, 2026-10-15):
- Reset. Every launch already starts from a clean VM: the runtime and
/appare read-only, and/tmplives in memory. The only persistent state is/dataand the granted folders. "Reset app" therefore means emptyingdata/<id>/and forgettinggrants/<id>.json. A loader button does exactly this. - App groups (
permissions.groups, not in format 1). Several apps opt in, with the user's consent, to share one group data folder (mounted at/shared/<group>). This is for workflows where apps are meant to share context. Without it, apps never see each other's data.
3. Runtimes (shared, installed once)#
A runtime is a read-only EROFS image of a Linux root filesystem (arm64). It becomes the VM's root, and the app's
core (core.cpio extracted, or core.erofs mounted) is /app inside it. Apps never contain the OS or the language runtime.
| Runtime | Built from | Contents |
|---|---|---|
native |
FROM scratch + CA certificates |
nothing but the mount-point folders and /etc/ssl/certs. For static binaries (Rust musl, Go, Zig, C). |
node22 |
node:22-bookworm-slim (pinned digest) |
Node 22 + the SDK at /usr/lib/vmapp/node/node_modules/vmapp/index.js with NODE_PATH=/usr/lib/vmapp/node/node_modules, so require('vmapp') works |
dotnet8 |
mcr.microsoft.com/dotnet/runtime:8.0 (pinned digest) |
the shared .NET 8 runtime; apps ship framework-dependent dotnet publish output and run ["dotnet", "App.dll"] |
node22-gpu |
node:22-trixie-slim (pinned digest) |
experimental (§8.7): everything node22 has, on Debian trixie, plus Debian's Vulkan loader (libvulkan1) and Mesa 26.2.5's Venus driver (libvulkan_virtio.so and its ICD file, found by the loader without any variable), so a Vulkan program in the VM reaches the Mac's GPU when the app runs on the Vulkan backend. Without that backend it simply has no Vulkan device. |
Every runtime image must contain these empty directories: app data host proc sys dev tmp run.
tools/mkruntime runtimes/<name> out/runtimes/<name>.img builds an image:
docker build --platform linux/arm64;docker export;- adds the mount points;
mkfs.erofs -zlz4hc,12 -T0with a fixed UUID, in a pinned helper container.
The output is reproducible. Docker is a build-time tool for runtime maintainers only. App developers never need it for Node apps, and never at install or run time.
4. The VM (guest contract, vmapp-init)#
Boot:
- Kernel cmdline:
console=hvc0 rdinit=/init panic=-1 quiet vmapp.id=<instance id> - Initramfs: a single newc cpio with
/init=vmapp-initanddev proc sys tmp. - Disks, both read-only:
vda= the runtime image (identifierruntime);vdb=core.cpioorcore.erofsas a raw block device (identifiercore). A cpio is read sequentially; its trailer marks the end, and padding after it is ignored. The guest tells the two apart by the EROFS magic0xE0F5E1E2at byte 1024.
- Static virtiofs device, tag
vmapp, always present and never changed after boot. It holdsappdataand the launch-time folder shares (f-<name>); the directory names inside it are the share names in §5. - Grant slots: 8 more virtiofs devices, tags
vmapp-s0…vmapp-s7. At boot each shares an empty placeholder directory. A runtime grant (pick-N,save-N) replaces the share of one free slot only, so nothing else ever goes stale. (Format 1 shipped with one device for everything at first. Replacing its share set made existing mounts such as/datago stale; slots fix that.) At most 8 runtime grants exist at once; a revoke frees its slot. - The libkrun backend's variant (§8.7, experimental). libkrun exposes one folder per virtiofs device and cannot re-point
a share while the VM runs, so on that backend there are no slot devices and no runtime grants, and the static shares are
one device each: the kernel command line carries
vmapp.fs=appdata,f-notes,...(the share names) and the guest mounts a tmpfs at/host, then the device whose tag is the share name at/host/<name>. Everything after that is as above. The same command line carriesvirtio_gpu.blob_alignment=16384(the gpu kernel flavour's parameter) and, when the VM has a GPU,vmapp.gpu=1: the guest then waits (≤5 s) for the DRM render node/dev/dri/renderD128and gives it to group 1000 with mode 0660, so that the core (uid/gid 1000, no supplementary groups) can open it; the KMS nodecard0stays root-only. If the node does not appear the guest logs it and carries on (the core has no GPU).
Setup (PID 1):
Base mounts: devtmpfs on
/dev, proc on/proc, sysfs on/sys, tmpfs on/tmp. Bring the loopback interfaceloup.Root: wait (≤5 s) for
/dev/vdaand/dev/vdb. Mountvdaas erofs, read-only, at/rt.App files, from
/dev/vdb:core.cpio: mount tmpfs at/rt/app(mode 0755). Extract the cpio into it:- regular files, directories and relative symlinks only;
- reject absolute or
..paths and symlinks escaping/app; - files owned by root, read-only to the app.
Then remount
/rt/appread-only, withnosuidandnodev.core.erofs: mount/dev/vdbas erofs at/rt/app, read-only,nosuid,nodev. Then walk the mounted tree once with the same rules (regular files, directories and relative symlinks that stay inside/app; owner root; no setuid, setgid or sticky bit); any violation is fatal, as for the cpio. (The host's reader already checked the image, §2.1.)
Either way
hello'sappreports the regular files and their bytes.Host shares: mount virtiofs
vmappat/host(nosuid, nodev).Mounts inside
/rt:- proc at
/rt/proc, sysfs at/rt/sys; - a bind of
/devat/rt/dev, plus tmpfs at/rt/dev/shm; - pseudo-terminals: devpts at
/rt/dev/pts(newinstance,ptmxmode=0666,mode=0620,gid=G, nosuid, noexec;Gis the gid of the groupttyin the runtime's/etc/group, normally 5, else 0) and/rt/dev/ptmxas a symlink topts/ptmx, so thatopenpty(),forkpty()andnode-ptywork for the core. The instance belongs to the VM and adds no way out of it. If the mount fails, the guest logs it and carries on: the core just has no terminals; - tmpfs at
/rt/tmp(1777) and at/rt/run, with/rt/run/vmappowned by the app user (0700); - a bind of
/hostat/rt/host; - a bind of
/host/appdataat/rt/data.
Because
/rt/hostis a bind of the live virtiofs mount, folders the host adds later appear there too.- proc at
The app user: uid 1000, gid 1000. The core runs with no_new_privs, setgroups([]), and chroot /rt.
vsock ports (the guest listens, the host connects):
| Port | Use |
|---|---|
| 1022 | network control (§8.3): the host opens one persistent connection after boot, only when the app has network grants or a host service (§8.6); JSON lines |
| 1023 | control: one JSON object per line |
| 1024 | bridge: each connection is forwarded to a new connection to the core's Unix socket /rt/run/vmapp/core.sock, retrying the connect for up to 15 s. Bytes are copied both ways. |
| 1025 | network data (§8.3, §8.6): one host-opened connection per proxied TCP connection or service tunnel; first line N\n, then raw bytes |
| 1026 | port forwarding (§8.8): one host-opened connection per forwarded TCP connection; first line P\n (the VM port), then raw bytes. The guest connects to 127.0.0.1:P inside the VM, and to nothing else |
Control operations:
{"op":"hello"}returns{"ok":true,"name":"vmapp-init","version":"0.1.0","id":"…","kernel":"…","app":{"files":N,"bytes":B},"core":{"running":false}}. Once the core is started,coreis{"running":true,"pid":N}, or{"running":false,"exit":CODE}after it exits. Withvmapp.gpu=1on the command line (§8.7)hellohas one more member,"gpu":{"probe":false}or"gpu":{"probe":true,"stalledMs":N,"probes":M}: whether the guest's GPU liveness probe runs, and how long the probe in flight has been waiting for the host's GPU worker (0 when none is). The launcher's watchdog reads it.{"op":"start","command":[…],"workdir":"/app","env":["K=V",…]}starts the core once.Its environment is built in this precedence order:
- the given
env; - the runtime image's own environment (
/etc/vmapp/runtime.env, written bymkruntimefrom the image'sENV; for exampleNODE_PATHandDOTNET_*), with anyVMAPP_*lines ignored; - the defaults:
HOME=/data,TMPDIR=/tmp,PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin, and the network defaults of §8.3 (HTTPS_PROXY,HTTP_PROXY,ALL_PROXYand their lower-case forms,NO_PROXY=localhost,127.0.0.1,NODE_USE_ENV_PROXY=1, andSSL_CERT_FILEwhen the runtime has/etc/ssl/certs/ca-certificates.crt).
VMAPP_SOCKET=/run/vmapp/core.sock,VMAPP_ID=<instance id>andVMAPP_DATA=/dataare always set by the guest.mkruntimealso writes a minimal/etc/hosts(localhost).VMAPP_GPU_RPC=127.0.0.1:50052is in the givenenv(the host's, never the manifest's: §8.6) exactly when the app was granted the GPU.- the given
Resolving
command[0]: if it contains a/, use it as is (relative toworkdir). Otherwise searchPATHinside/rt.Returns
{"ok":true,"pid":N}, or{"ok":false,"error":"…"}. A secondstartreturns{"ok":false,"error":"already started"}.
{"op":"shutdown"}returns{"ok":true}. The guest then sends SIGTERM to the core, waits up to 2 s, sends SIGKILL, syncs and powers off.{"op":"mount","slot":K,"name":"pick-N"}mounts virtiofs tagvmapp-sKat/rt/host/<name>(nosuid, nodev; read-only if"ro":true). It creates the mount point on a small tmpfs overlay:/rt/hostis a tmpfs holding bind mounts of the static shares plus these slot mounts. Returns{"ok":true}. The name must match^(pick|save)-[0-9]{1,4}$.{"op":"umount","name":"pick-N"}lazily unmounts (MNT_DETACH) and removes the mount point. Returns{"ok":true}.{"op":"service","name":"gpu"}opens the guest's loopback listener of a host service (§8.6):127.0.0.1:50052forgpu. Returns{"ok":true}(also when it is already open) or{"ok":false,"error":"unknown service …"}. The host sends it beforestart, only for a service it was given.{"op":"service-stop","name":"gpu"}closes the listener again (connections that are open are closed by the host). Withoutservice, nothing listens on the service ports and a connection to one is refused by the kernel.
The network proxy (§8.3). vmapp-init always listens on 127.0.0.1:3128 (an HTTP proxy), and on vsock ports 1022,
1025 and 1026 (all before port 1023 answers, so a hello means they are ready). The VM still has no network device:
the proxy is the only way out, and every connection goes to the host to be allowed or refused.
The /rt/host layout (replacing the earlier "bind of /host"): /rt/host is a tmpfs. At boot, the guest
bind-mounts each top-level directory of the static /host (appdata, f-*) into it. Runtime grants are mounted
there by the mount op. The core sees one /host folder either way.
The core's stdout and stderr go to the console log, one line each.
When the core exits: log its status, sync, power off. The VM is the app.
5. Host launcher contract (vmapp-vm)#
vmapp-vm run --kernel IMAGE --initramfs CPIO --runtime RUNTIME.img --core CORE.cpio --appdata DIR
[--share NAME:ACCESS:DIR]... --start JSON --socket PATH
[--memory MiB] [--cpus N] [--console LOG] [--id HEX8] [--no-follow-parent]
[--net RULE]... [--service NAME=127.0.0.1:PORT]... [--dev-allow-loopback] [--dev-resolve NAME=IP]...
Size. --memory and --cpus carry the user's choice of the VM's size, resolved by hostlib (§8.5): by default one vCPU
and the manifest's recommended memory, never above the Mac's cores or half of its memory. The manifest's core.cpus and
core.memoryMiB are only recommendations.
Network rules. --net RULE (repeatable) allows the app to reach one host (§8.3). RULE is HOST[:PORT[,PORT]...]
or HOST:* with HOST an exact name, *.suffix or * (any public host name), and the ports default to 443; * as the
ports means every port. No --net means no network: the guest
proxy then answers every request with 403 network not granted, and the host opens no network channel at all.
--dev-allow-loopback and --dev-resolve exist for tests and developer tools only (§8.3, INV-N3); the loader never
passes them.
Host services. --service NAME=127.0.0.1:PORT (repeatable, once per name) gives the app one host service (§8.6): a
loopback endpoint that the loader started, which the guest reaches by name. NAME is one of the services this launcher
knows (gpu); the endpoint must be the IPv4 literal 127.0.0.1 and a port from 1 to 65535, otherwise the launcher exits 2
before any VM starts. The flag comes from the loader's command line and from nowhere else: not from a manifest, the guest or
the bridge (GOVERNANCE INV-H7). With a service the launcher sends the guest service (§4) after hello, before start, and
opens the network channel (port 1022) even when there are no --net rules. No --service, no listener in the guest and no
channel.
Shares. ACCESS is ro, rw or wo.
appdatais always shared read-write.Folder grants from the manifest are named
f-<name>.Grants made at run time are named
pick-<n>(open dialogs) andsave-<n>(save dialogs).A
woshare is a fresh staging directory, delivered live intoDIRwith exactly agent-vm's rules:- a file is delivered when it settles for two 500 ms polls in a row;
- names starting with
.or ending in.partialwait until stop; - a name clash becomes
name (1).ext; - the guest never sees
DIR.
One exception: a
save-<n>share delivers to its chosen file path and overwrites it, because the user already confirmed the overwrite in the save dialog.
--start is the JSON object for the control start op, minus "op".
Sequence:
- Boot.
- Wait for
hello(≤15 s). - Send
start. - On success, print the ready line.
stdout: JSON lines. Writes ignore errors; never println!.
{"ready":true,"socket":"…","hello":{…},"pid":N}{"exported":{"share":"f-export","files":["a.html"]}}for each live delivery batch.{"granted":{"name":"pick-0","access":"ro"}}or{"revoked":{"name":"pick-0"}}, in answer to stdin commands.{"net":{"id":N,"host":"…","port":443,"allowed":true,"bytesUp":0,"bytesDown":0,"durationMs":0,"reason":"…"}}for each proxied connection, printed when it closes or is refused (reasononly when there is one;"window":truefor a connection of the app's windows through the window endpoint). See §8.3.{"net-allowed":{"rule":"…"}}or{"net-revoked":{"rule":"…","closed":N}}, in answer to stdin commands.{"window-proxy":{"port":N}}, in answer to the stdin commandwindow-proxy(§8.3 "The app's windows"). The secret is never printed.{"forward":{"id":N,"port":P,"hostPort":H,"phase":"listen|conn|stop","bytesUp":0,"bytesDown":0,"durationMs":0,"reason":"…"}}for port forwarding (§8.8):listenwhen a forward starts,connwhen one forwarded connection ends (with its counts;bytesUpfrom the app's server,bytesDownto it),stopwhen the forward ends.{"port-forwarded":{"port":P,"hostPort":H}}and{"port-closed":{"port":P,"closed":N}}, in answer to stdin commands.{"service":{"id":N,"name":"gpu","phase":"open|traffic|close","allowed":true,"bytesUp":0,"bytesDown":0,"durationMs":0,"reason":"…"}}for each tunnel to a host service (§8.6):openwhen it is up,trafficabout every half second while bytes moved (the counters are cumulative, and a newertrafficreplaces one that was not printed yet),closewhen it ends or was refused (allowed:false, andreasonsays why:service not granted,too many connections …, …).bytesUpcounts bytes from the app,bytesDownbytes to the app. Names and counts only, never contents.{"service-revoked":{"name":"gpu","closed":N}}, in answer to the stdin commandservice-revoke.{"error":{"op":"grant","message":"…"}}{"stopped":true,"reason":"core-exited|shutdown|parent-exited|signal","exitCode":N|null,"exported":{"f-export":["…"]}}, printed last.vmapp-vm-krun(§8.7) adds two reasons,gpu-hang(its watchdog killed the VMM because the guest or its GPU stopped answering) andvmm-crashed(the VMM died by itself), and then a"detail":"…"member that says what it saw.
stdin: JSON lines, commands from the parent.
{"op":"grant","name":"pick-0","access":"ro|rw|wo","path":"/abs/dir"}adds a share to the running VM:- Take a free slot K.
set_shareson devicevmapp-sKonly. For awogrant, share its staging folder.- Send the guest control
{"op":"mount","slot":K,"name":…,"ro":…}. - Print
granted.
For
wowith a file path, it is a save grant. If all 8 slots are in use, it fails with anerrorline.{"op":"revoke","name":"pick-0"}: controlumount, then reset the slot's share to the placeholder and free the slot.{"op":"net-allow","rule":"api.example.com:443"}adds a network rule to the running VM (§8.3). A rule for the same host replaces the old one, and open connections the new rule no longer allows (fewer ports) are closed like a revoke. Answered withnet-allowed, or anerrorline.{"op":"net-revoke","rule":"api.example.com"}removes the rule for that host (any ports part is ignored), then closes every open connection that no remaining rule allows. Answered withnet-revoked.{"op":"window-proxy","secret":"…"}starts the window endpoint (§8.3 "The app's windows"): aCONNECTproxy on127.0.0.1for the app's windows, under the same rules as the guest, answering only clients that authenticate asvmappwith this secret (32 to 128 letters and digits; hostlib makes 64 hex characters per run). Once per run. Answered withwindow-proxy, or anerrorline. The secret comes on stdin only, never on the command line.{"op":"port-forward","port":P}forwards the VM's port P to this Mac (§8.8): listens on127.0.0.1:Pwhen it is free, else on127.0.0.1at a port of the system's choice; a port already forwarded gives its port again. At most 16 forwards. Answered withport-forwarded, or anerrorline. The parent checks the user's grant first.{"op":"port-close","port":P}stops it and closes its open connections. Answered withport-closed(after thestopevent).{"op":"service-revoke","name":"gpu"}takes a host service away (§8.6): no new tunnel opens, every open tunnel is closed at once (each reports aclosewith reasonrevoked), the guest is toldservice-stop(its port closes). Answered withservice-revoked(closed= the tunnels it closed; 0 for a name the app was not started with).{"op":"shutdown"}is the graceful stop: controlshutdown, wait 5 s, force stop, final delivery.EOF on stdin means the parent is gone, and leads to a graceful stop.
Lifecycle:
- Watch the parent (kqueue
NOTE_EXIT). If the parent dies, or SIGINT, SIGTERM or SIGHUP arrives, do a graceful stop. - If the guest powers off, do the final delivery and print the stopped line.
- The process exits 0 after printing the stopped line.
A Unix socket path must be shorter than 104 bytes. Launchers put it in /tmp/vmapp-XXXXXX/b.sock.
The second launcher, vmapp-vm-krun (EXPERIMENTAL, §8.7). It takes this command line and speaks this protocol unchanged,
for what its backend supports: the --kernel is the gpu flavour's Image-gpu; stdin grant and revoke are answered with
an error line, not supported on the gpu backend; exported, net, net-allowed/net-revoked, window-proxy,
service/service-revoked, ready and stopped are the same. Its extra flags: --gpu-window MiB (the guest's window of host GPU memory, 256 to 32768,
default 8192), --watchdog SECS (default 30, 0 = off), --krun-dir DIR (where lib/ and share/vmm.sb are; default the folder
above the executable), --no-gpu (boot without the virtio-gpu device; tests) and --dev-no-sandbox (no sandbox profile;
development only, like --dev-allow-loopback never passed by the loader, INV-H8).
6. The bridge protocol (host ↔ core)#
The host opens one connection to the bridge socket after the ready line and keeps it open for the app's
lifetime. Both directions send JSON lines: one object per \n, with "t" as the message type. The maximum line
is 16 MiB. Unknown t values are logged and ignored. Window ids (win) are integers assigned by the host. Request
ids (id) are integers unique per sender.
The Inspector's view of the bridge. Every message in both directions is an Inspector row with its type, channel or op,
size, timing and a preview: the first 200 characters of the message JSON, so a developer can watch their window talk to
their core. The Inspector appears on shared screens and in recordings, so a value that looks like a secret is shown as
"[hidden]": a string under a field whose name suggests one (apiKey, secret, password, token, authorization,
credential, cookie, …), a string shaped like a well-known key or token (sk-…, ghp_…, a JWT, Bearer …, a PEM private
key), and the whole payload (args, result, params, data) of a message whose channel or op name suggests one
(api-keys:set). This is a best effort: the payloads are the app's own and the platform cannot know which are secret, so
a secret under an unremarkable name is shown. The host also reads only a bounded part of a message for the preview (a fixed
number of values, long strings cut). host.* and clipboard.* payloads are not shown at all (§8.2, §8.4).
host → core#
t |
Fields | Meaning |
|---|---|---|
init |
app:{id,name,version}, platform:{os:"linux",arch:"arm64"} (the VM's platform), folders:{<name>: "/host/f-<name>" | null}, data:"/data" |
first message; null = the user didn't grant that folder |
invoke |
id, win, channel, args:[…] |
the renderer called vmapp.invoke(channel, …args); the core must answer with result |
send |
win, channel, args:[…] |
the renderer called vmapp.send(channel, …args); no answer |
window |
win, event:"closed"|"focus"|"blur"|"resize"|"menu", data:{…} |
window events; menu carries {id}: the user chose that item of the menus the core set (menu.set, §7 "Menus") while window win was focused |
reply |
id, result or error:{message} |
answer to a core request |
before-quit |
— | the host is stopping the app; the core has 3 s before the VM is stopped |
open-url |
url |
a vmapp://<this app's id>/… link was opened on the Mac (§8.9); only with permissions.links. The SDK emits Electron's app.on('open-url', (event, url)) |
core → host#
t |
Fields | Meaning |
|---|---|---|
ready |
— | the core finished starting; it must arrive within 30 s of init, or the host stops the app |
result |
id, result or error:{message} |
answer to an invoke |
emit |
win, channel, args:[…] |
deliver to the renderer's vmapp.on(channel) listeners in window win (webContents.send) |
request |
id, op, params |
ask the host for something; answered by reply |
log |
level:"debug"|"info"|"warn"|"error", message |
shown in the loader's app log |
Request ops (core → host)#
op |
params |
reply.result |
|---|---|---|
window.create |
{entry:"index.html", width, height, minWidth?, minHeight?, title?, resizable?, devTools?}. entry is relative to ui.root, with no ... devTools is a boolean, default false: only true lets the user open DevTools on this window (§7) |
{win} |
window.close |
{win} |
{} |
window.setTitle |
{win, title} |
{} |
window.focus |
{win} |
{} |
menu.set |
{template} (§7 "Menus"; null removes the menus) |
{items, dropped}: the app's menus in the Mac menu bar while one of its windows is focused (a window's own vmapp.menu.set wins for that window). An invalid template is refused with the path of the first problem; dropped lists the shortcuts the loader keeps. Clicks come back as the window event menu {id}. |
dialog.open |
{win?, title?, directory:bool, multiple:bool, access?:"read"|"readwrite"} |
{canceled:true} or {canceled:false, paths:["/host/pick-0", …]}. Needs permissions.dialogs. Directory picks share the folder with the requested access (default read); the dialog's prompt states it ("…will be able to change files in this folder"). File picks are always read-only and never expose sibling files. The host makes an instant APFS clone (COPYFILE_FICLONE, falling back to a copy) of each chosen file into a fresh $VMAPP_HOME/picks/<id>/pick-N/ and shares that folder, returning /host/pick-N/<file name>. |
dialog.save |
{win?, title?, defaultName} |
{canceled:true} or {canceled:false, path:"/host/save-N/<name>"}. Write the file there; the host delivers it to the chosen location when the write settles (write <name>.partial, then rename, for large files). Needs permissions.dialogs. |
dialog.message |
{win?, type:"info"|"warning"|"error"|"question", message, detail?, buttons?:[…]} |
{response: index} |
shell.openExternal |
{url} (https:, or http(s)://localhost / 127.0.0.1 on a port this app forwards now, §8.8) |
{opened:bool}. The host asks the user first. Needs permissions.external. |
port.forward |
{port} (1-65535, a port of the VM) |
{port, hostPort, url}: the app's server on port in the VM is reachable on this Mac at http://localhost:<hostPort>/ (the same port when it is free). Needs the device ports (manifest and the user's allow, §8.4, §8.8), checked at every call. |
port.close |
{port} |
{closed}: the forward ends and its open connections close. Needs no permission. |
port.list |
{} |
{ports:[{port, hostPort}]}: the ports forwarded now. |
app.quit |
{} |
{}, then the host stops the app |
clipboard.readText |
{} |
{text, truncated}: the Mac's clipboard as plain text, at most 1 Mi characters. Needs the device clipboard (manifest and the user's allow, §8.4), checked at every call. The Inspector shows the op, never the text. |
clipboard.writeText |
{text} (a string of at most 1 Mi characters) |
{}: the Mac's clipboard now holds text. Same permission as clipboard.readText. |
The host enforces every permission. A denied request gets reply.error = {message:"permission denied: …"}.
Notes for cores that implement this protocol themselves (without the Node SDK):
- Never block the reading loop. A core's own
requestis answered by areplyon the same connection, so aninvokehandler that waits on a request (for example a dialog) must run off the reader: on a thread or task, or through an async continuation. Otherwise the reply can never be read. - Empty results: a
reply.resultdocumented as{}may arrive asnull. Treat both the same. - Quitting: send
request app.quitbefore exiting, for example on the last window'sclosed. Then the stop reason is reliablycore-exitedrather than racing with the host's own shutdown.
7. The renderer (host side)#
Each app window is a
BrowserWindowin the loader, with:contextIsolation: true,sandbox: true,nodeIntegration: false,webSecurity: true;- its own session partition,
persist:vmapp-<id>(storage isolated per app); devTools= thedevToolsof that window'swindow.createrequest (§6), defaultfalse, in dev and packaged builds. This is the app's own choice for its own windows, made by its core, exactly like Electron'swebPreferences.devTools(the Node SDK sends it fornew BrowserWindow({ webPreferences: { devTools: true } })). It is not a manifest field, and the renderer can't change it.
DevTools: the loader's View menu has one "Developer Tools" item (⌥⌘I). It opens DevTools only for the focused app window that was created with
devTools: true; it does nothing for any other window. The loader's own windows (library, inspector, settings, badge, activity monitor) have DevTools in dev builds only. A packaged loader also closes DevTools the moment they open on a loader window or on an app window created withdevTools: false(belt and braces), and it still refuses--remote-debugging-*and the other debugging switches (loader/PACKAGING.md).UI files are served from the installed package with the custom protocol
vmapp-ui://<id>/<path>. Each file is checked againstfilesbefore it is served. A missing or modified file gives 404.Every response carries this CSP header:
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'. The renderer has no network of its own. The one way out is the window featurenetwork: the windows' data requests leave through the app's own proxy, under the app's network grants (§8.3 "The app's windows").Window features (proposed 2026-10-17, docs/design/DEV-APPS.md C1). An app whose manifest declares
ui.features(§2.2) gets the CSP above plus exactly these sources, per declared feature (a directive whose value is'none'is replaced, a missing one is added; the loader's table isUI_FEATURE_CSPinloader/src/protocols.js):Feature Adds So that the window may workersworker-src 'self' blob:run dedicated and shared workers, also from blob:scripts it buildswasmscript-src 'wasm-unsafe-eval'compile WebAssembly self-fetchconnect-src 'self'fetchits own package files (vmapp-ui://<id>/…), from the page and from workers; still no networkframesframe-src vmapp-frame:show isolated frames (below) networkconnect-src https: wss:; img-src https:; media-src https:; font-src https:; style-src https:load data from the hosts the user allowed the app to reach: fetch, WebSockets, images, media, fonts and style sheets, through the app's proxy (§8.3 "The app's windows"); needspermissions.networkNo feature adds
'unsafe-inline'or'unsafe-eval'to the page. Onlynetworkadds network sources, and only these: never toscript-src,worker-src,frame-src,object-srcordefault-src, so no code and no document ever comes from the network.Isolated frames (
frames).vmapp-frame://<label>/<path>, in the app's own partition, serves the same files asvmapp-ui://<id>/<path>(listed infiles, SHA-256 checked, INV-B4), and only for an app that declaresframes(404 otherwise).<label>is 1-253 characters[a-z0-9.-]starting with a letter or digit; it carries no meaning but the origin: every label is its own origin, so a frame cannot read the app's page or another frame (they talk withpostMessageonly). Frames get nowindow.vmapp(the loader's preload runs in the main frame only;nodeIntegrationInSubFramesisfalse), no permission (the microphone gate needs the main frame), and no network of their own (the request filter below).vmapp-frameis the only scheme where service workers may run. Everyvmapp-frameresponse carries this CSP:default-src 'self' vmapp-frame: blob: data: https:; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob: data: https:; style-src 'self' 'unsafe-inline' blob: data: https:; img-src 'self' blob: data: https:; media-src 'self' blob: data: https:; font-src 'self' blob: data: https:; connect-src 'self' blob: data: https:; worker-src 'self' blob:; frame-src 'self' blob: data:; object-src 'none'; base-uri 'self' https:; form-action 'none'Thehttps:sources let web content name https URLs that the frame's own service worker answers (VS Code's webviews refer to their resources ashttps://*.vscode-resource.vscode-cdn.net/…, and set<base href>to such a URL); they are not network. For frames the network boundary is the app partition's request filter, as it always was for every app window: it cancels every request whose scheme is notvmapp-ui:,vmapp-frame:,data:,blob:ordevtools:, so anhttp(s)/ws(s)request that leaves a frame fails, and only what the frame's service worker answers itself arrives. The filter's one exception, for an app that declaresnetwork(§8.3 "The app's windows"), holds for frames as for the page:httpsdata requests (images, media, fonts, style sheets,fetch) leave through the app's proxy; a script, a frame or a worker from the network never does. A subframe of such an app may navigate tovmapp-frame://,about:blank,about:srcdoc,blob:vmapp-frame://…anddata:URLs; the main frame stays onvmapp-ui://<id>/.Navigation outside
vmapp-ui://<id>/is blocked andwindow.openis denied. Every permission request is denied (camera, notifications, geolocation, clipboard-read, screen sharing, …) with exactly one exception, defined in §8.4:getUserMedia({audio})for the microphone, only when the app's manifest asks for it and the user allowed it for that app. A request that includes video is denied as a whole. The loader's own windows get no permission at all.The loader's preload (never the app's own; apps can't ship a preload) exposes exactly:
window.vmapp = { invoke(channel, ...args) → Promise, // → core ipcMain.handle send(channel, ...args), // → core ipcMain.on on(channel, listener) → unsubscribe(), // ← core webContents.send menu: { set(template) → Promise, onClick(listener) → unsubscribe() }, // the window's menus ("Menus" below) app: { id, name, version }, }; window.vmapp.ipcRenderer = { invoke, send, on } // alias that eases porting Electron renderersArguments and results must be JSON-serialisable.
The window title is
<window title> — <app name>, set by the host. The loader marks app windows as sandboxed in its own UI (the app list); the app can't draw over that.Leave this page? (proposed 2026-10-17, docs/design/DEV-APPS.md §11 step 18). When a page vetoes its own unload (a
beforeunloadhandler that callspreventDefaultor setsreturnValue, on a reload, a navigation or the user closing the window), the loader asks with a native sheet on that window, in its own words: "Leave this page?", “<app name>” asks you to confirm: changes you made may not be saved, Leave / Stay (Stay is the default and the answer to Escape). The page's own text is never shown. Leave unloads; Stay keeps the page. A veto of the same window within 1 s after Stay stays without asking, so a page cannot pile up questions. A window the app's core or the loader closes (the opwindow.closeof §6, Stop, quit) is never asked about.
Menus (proposed 2026-10-17, docs/design/DEV-APPS.md C9)#
An app may put its own menus in the Mac menu bar, with their keyboard shortcuts, from its page
(vmapp.menu.set(template), clicks to vmapp.menu.onClick(listener) with the item's id) or from its core (the op
menu.set, §6; the Node SDK's Electron-compatible Menu.buildFromTemplate / Menu.setApplicationMenu, whose items' click
run in the core). A page's menus win for its own window; the core's apply to every other window of the app.
- A template is data: an array of at most 16 menus
{label, submenu:[item…]}; an item is{id, label, accelerator?, enabled?, visible?, checked?, type?: normal|separator|checkbox|radio|submenu, role?, submenu?}, at most 4000 items, 5 levels, labels of 120 characters, ids of 200 (unique).roleis one ofundo redo cut copy paste pasteAndMatchStyle delete selectAll minimize zoom togglefullscreen: Electron runs those itself on the focused window. Any other key, role or type is refused, and so is a shortcut that is not Electron's accelerator grammar (hostlib/menus.js). - The loader keeps ⌘Q, ⌘H and ⌥⌘H whatever an app asks (such a shortcut is dropped from the item, which stays). It keeps its
first menu (About, Hide, Quit) before the app's menus and its Window menu after them (its windows, without their ⌘1-⌘4
shortcuts while an app with menus is focused, and Developer Tools). When the app's menus have no
copyorpasterole, the loader's Edit menu is added so that keyboard copy and paste keep working. - A click only sends the item's id to the app (the page's listener, or the core's window event
menu); it never runs loader code. While a loader window, or an app window without menus, is focused, the menu bar is the loader's own. - Threats: an app's labels appear in native UI. They cannot imitate a loader decision: every loader question is a sheet or a native dialog of its own (INV-B5), the loader's own menus stay first and last, and choosing an app item only tells that app.
8. Lifecycle#
Install (
vmapp install file.vmapp, or Install from file… in the loader):- read and verify the package (§2), checking the runtime is installed and the version is newer than or equal to
the installed one (downgrade only with
--force); - extract it to
apps/<id>/<version>/, keep the installed.vmappitself asapps/<id>/pkg/<version>.vmapp(so Publish… sends exactly what was installed), and writecurrent.
App data and grants are kept across updates.
Developer build (the loader's Run project folder… and Rebuild & run): the loader packs a project folder with the same
packasvmapp pack, installs the result with the downgrade rule lifted (the developer's folder wins) and records the folder inapps/<id>/dev.json({"path":"<absolute project folder>"}). The card then says "Developer build from" and offers Rebuild & run. A developer build never asks a registry for updates; any other install of the id removes dev.json. Everything else (verification, permission sheet) is as for any install.Load examples (the loader's empty library, and File → Load examples…): the sample apps that came with this loader (
<Resources>/vmapp/examples/*.vmappwhen packaged,out/examples/or$VMAPP_EXAMPLES_DIRin development) are listed from the packages themselves (name, description, version, size, and badges for the network hosts, the GPU, control of the computer and the microphone they ask for) together with a "What to allow" hint (the folder'sREADME.mdrow for the package, else derived from the manifest). The user chooses which to install; each goes through exactly the install above (full verification, then extraction), one after the other. A package that fails verification is reported with the precise message and does not stop the others; one whose version is already installed, or older than the installed one, is skipped with a note. Installing grants nothing and launches nothing: the permission sheet comes at each app's first launch (INV-B5, INV-D3). A packaged loader without examples hides the entry.- read and verify the package (§2), checking the runtime is installed and the version is newer than or equal to
the installed one (downgrade only with
Launch:
- Permission sheet: shown on first launch, after an update that adds folder requests, or whenever the user
chose "ask every time". It lists the manifest's requests with their purpose. The user picks a folder for each,
or skips it, and can grant less than requested (
readwritecan be downgraded toread). Choices are saved ingrants/<id>.json, which holds folder paths and accesses, host-capability grants (§8.2), network grants (§8.3), device grants (§8.4) and the user's choice of the VM's size (§8.5), never file contents. A device entry (the microphone) starts on Don't allow. Every network entry is shown with its purpose and starts on Don't allow. The sheet also has a Resources row (§8.5): the VM's vCPUs and memory, which the manifest only recommends. An app that asks for network access, host capabilities or devices gets the sheet at its first launch even if it asks for no folder. - Spawn
vmapp-vmwith the runtime, the core (core.cpioorcore.erofs, as--core), appdata, the granted shares and thestartcommand. - Wait for the ready line.
- Connect the bridge and send
init. - The core sends
readyand creates its windows withwindow.create.
- Permission sheet: shown on first launch, after an update that adds folder requests, or whenever the user
chose "ask every time". It lists the manifest's requests with their purpose. The user picks a folder for each,
or skips it, and can grant less than requested (
Run: the host routes
invoke,send,emitandrequesttraffic, enforces permissions and logs.Stop, in any of these ways:
- The core quits (
app.quitor a process exit): the VM powers off, the host closes the app's windows. - The user stops it in the loader: the host sends
before-quit, waits up to 3 s, sends{"op":"shutdown"}on stdin, then closes the windows. - The loader quits: it does the same for every running app.
- The loader crashes: every
vmapp-vmsees its parent die and stops its VM.
Write-only outputs are delivered on every path.
- The core quits (
Window closing: when the last window of an app closes, the host sends
window closed. Following Electron, the core decides what happens: the Node SDK default isapp.quit()when all windows are closed.Uninstall: removes
apps/<id>/. App data is removed only on request (--purge).
8.1 Versions and update choices (format 1, loader and registry)#
What the store keeps:
apps/<id>/<version>/for each installed version. The store keeps the current version plus the 2 previous ones; older ones are removed after a successful switch.apps/<id>/current: the active version.apps/<id>/source.json, optional, written when the app was installed from a registry:{"registry":"https://…","id":"<id>","installedFrom":"<version>"}.apps/<id>/policy.json:{"update":"manual"|"compatible"|"latest"}. The default ismanual.apps/<id>/pkg/<version>.vmapp: the package file as installed, removed with its version;dev.json: see §8 (developer build).publish-tokens.jsonin the data root (mode 0600):{"<registry url>": {"<app id>": "<owner token>"}}, written byvmapp publishand by the loader's Publish….
Update choices:
| Value | Shown as | Picks |
|---|---|---|
manual |
Stay on this version | nothing, until the user chooses a version |
compatible |
Latest compatible | the highest non-withdrawn version with the same major as the current one (for 0.x, the same minor). It never crosses a breaking change. |
latest |
Always latest | the highest non-withdrawn version |
When the loader contacts a registry: only for apps with a source.json, and only:
- at launch, if the choice isn't
manual; or - when the user clicks Check for updates.
Separately, and for any installed app, the loader's Publish… sheet makes one upload of the installed package when the
user presses Publish (the owner token is sent only if the user has one). That is a GET /api/info, then either one
POST /api/publish or the presigned form (init, a PUT to the storage address the registry named, complete).
The loader's Browse registry… sheet makes one GET /api/apps to the registry the user typed or configured each time the
user opens it with a registry set, searches, changes the sort or presses Show more. The request carries only the search text,
the sort and the page (q, sort, limit, offset): never the installed apps. Its answer is display data only;
Install on a result goes through the same download and verification as any registry install.
This is user-chosen network use (INV-G3). It has a 5 s timeout; if the registry is offline, the app launches the current version.
Applying an update:
- Download the package. A registry that keeps packages in object storage answers
…/downloadwith a302to the package on its public storage address (carrying the headers below); the client follows that one redirect (never from https to http, never a second hop) and takes the checksum and package id from the registry's response, not from the storage's. - Verify the
.vmappfile's sha256 (against the registry'sX-Vmapp-Sha256), the package (SPEC §2), the package id (against the registry'sX-Vmapp-Package-Id), and that the id and version are the ones asked for. The storage is never trusted. - Compare permissions with the current version. If the new version adds a folder request, raises a folder's access
(
read→readwrite,read→writeonly, orwriteonly→readwrite; any change that gains write or read ability), adds a host capability (§8.2), adds a network host or a port the old version could not already reach (§8.3), asks for a device it did not ask for before (the microphone, §8.4), or turns ondialogsorexternal, the update always stops and asks, whatever the choice. The loader shows what changed, and the user approves or keeps the current version. - Install it, set it current, and launch.
Switching versions:
- The user can switch to any installed version at any time ("Use version X"), or install a specific version from the registry.
- Going to an older version is an explicit user action; it's never chosen automatically. It also sets the
app's update choice to
manual, so the next launch doesn't jump forward again; the confirm dialog says so. - "The 2 previous versions" means the 2 most recently current ones.
- App data (
/data) is shared across versions of the same id. An app that changes its data format must handle older data, or migrate it; downgrades are at the user's risk, and the loader says so.
Withdrawn versions:
- Publishing it: a registry publisher can mark a version as withdrawn, with a reason.
- Effect on choices: a withdrawn version is never picked by
compatibleorlatest. - Still available: it stays downloadable by exact version, and the loader shows the reason.
Registry endpoints for this:
GET /api/apps/:id/versions→[{version, packageId, sha256, size, publishedAt, withdrawn:{reason,at}|null}]GET /api/apps/:id/resolve?choice=compatible|latest&from=<version>→{version}|{version:null}.- Never lower: the result is never lower than
from. It may equalfrom, or benullwhen nothing fits, so resolution never downgrades. - Prereleases: they are skipped, unless
fromis itself a prerelease of the samex.y.z. from: required forcompatible; forlatest, it defaults to no lower bound.- Withdrawn versions: a withdrawn version downloaded by exact number carries
X-Vmapp-Withdrawn: <percent-encoded reason>. Clients read reasons from/versions.
- Never lower: the result is never lower than
POST /api/apps/:id/:version/withdraw {reason}and…/restore(owner token)GET /api/apps/:id/:version/download→ the package (200), or302to it. Both carryX-Vmapp-Sha256,X-Vmapp-Package-Id,X-Vmapp-Version(andX-Vmapp-Withdrawnfor a withdrawn version); a client verifies the bytes against these.GET /api/info→{publishModes:["direct"]|["direct","presigned"], downloads:"direct"|"redirect", maxPackageBytes}. Withpresigned, publishing may goPOST /api/publish/init {size, sha256}→{uploadId, method:"PUT", url, headers, expiresAt},PUTof exactlysizebytes tourl(valid 15 minutes),POST /api/publish/complete {uploadId}(same headers as a directPOST /api/publish; the registry fetches and verifies the package as for a direct upload). Clients use it when advertised and fall back toPOST /api/publishotherwise. The registry's own storage design is inregistry/README.md.
8.2 Host capabilities (accepted by the maintainer 2026-10-15; broker, helper and loader UI implemented)#
Host capabilities let an app act on the host beyond files: see the screen, control the mouse and keyboard, run host tools, and use host apps through MCP. They exist for computer-use agents and automation. Granting one deliberately lets that app act on your computer in that way. The rules below keep that explicit, visible and revocable. GOVERNANCE §H holds the invariants.
Who defines capabilities. Only the loader and the user, never an app.
- The catalog lives in the loader's data folder:
capabilities/tools.json: user-registered host tools;capabilities/mcp.json: user-registered MCP servers;- the built-ins
screenandinput.
- Where it can be edited: only in loader-owned settings UI, or with the CLI on the host. Nothing reached through the bridge can read or change it, except asking for what is granted.
Manifest: an optional permissions.host, an array of at most 8 entries:
"host": [
{ "cap": "screen", "purpose": "Reads the screen to help you fill forms" },
{ "cap": "input", "purpose": "Clicks and types for you when you ask" },
{ "cap": "tool:*", "purpose": "Runs the tools you allow, like screencapture" },
{ "cap": "mcp:*", "purpose": "Uses apps that offer MCP tools, like VS Code" }
]
capis one ofscreen,input,gpu,tool:<id>,tool:*,mcp:<id>ormcp:*.tool:*andmcp:*mean "the user picks which ones".gpuis not a bridge op: it gives the core a tunnel to a sandboxed GPU server on the host (§8.6). It has one mode,session("Allow while the app runs"), because a stream of requests cannot be asked about call by call. With"api":"vulkan"it is instead the experimental Vulkan GPU in the VM (§8.7); its grant records the API.- A manifest can't name a tool path or a command. The user maps requests onto their own catalog.
Grants: stored per app in grants/<id>.json under host. Each grant has a mode:
ask(the default): a loader dialog for each use, or for each session of input control;session: allowed until the app stops;always: never the default.
input and full-shell tools can't be granted always.
Bridge request ops (SPEC §6 additions). All are denied unless granted, and every use is logged to the Inspector:
op |
params |
result |
|---|---|---|
host.screen.windows |
{} |
[{windowId, app, title, bounds}]. Needs permissions.host (screen). |
host.screen.capture |
{target:"display"|"window"|"region", windowId?, region?:{x,y,w,h}, display?, maxWidth?}: a suggestion (below) |
{png: base64, width, height, target:{kind, display?, windowId?, region?, bounds?, by}}: target is what was actually captured. Needs permissions.host (screen). |
host.input.start |
{reason} |
{sessionId}. Shows the control badge. Needs permissions.host (input); the user may be asked. |
host.input.mouse |
{sessionId, action:"move"|"down"|"up"|"click"|"scroll", x?, y?, button?, dx?, dy?, clicks?} |
{}. Needs permissions.host (input) and a running input session. |
host.input.keys |
{sessionId, text?} or {sessionId, key, modifiers?} |
{}. Needs permissions.host (input) and a running input session. |
host.input.stop |
{sessionId} |
{}. Needs permissions.host (input). |
host.tool.list |
{} |
the tools granted to this app: [{id, name, kind, description?, variadic?, args}] (never the host path). Needs permissions.host (tool:* or tool:<id>). |
host.tool.run |
{id, args:[…], stdin?} |
{exitCode, stdout, stderr, truncated, timedOut, signal}. Needs permissions.host (tool:<id>); args checked. |
host.mcp.list |
{} |
the granted MCP servers and their tool lists: [{id, name, tools, error?}]. Needs permissions.host (mcp:* or mcp:<id>). |
host.mcp.call |
{server, method, params} |
the MCP JSON-RPC result. Needs permissions.host (mcp:<id>); method is one of tools/list, tools/call, resources/list, resources/read, prompts/list, prompts/get. |
Screenshots: the user chooses what is captured. The parameters of host.screen.capture are only a suggestion; the
user's choice wins, and the app receives only what was picked.
- The question (mode
ask). The loader shows a loader-owned modal window parented to the app's window (never app UI, INV-B5) with: each display (name, resolution, a small live thumbnail the loader captures itself; thumbnails stay in the loader and are never sent to the app); each window (app and title; titles need Screen Recording); and Select a region… per display, a full-screen dimmed selection window on that display where the user drags a rectangle (Esc cancels; shown only during the selection). Buttons: Deny (the default), Allow once, Allow while the app runs. The app's suggestion is preselected when it exists (else the main display), so one click confirms it. - What "Allow while the app runs" remembers. The permission only, never a target. Later captures in that run (and mode
session/always, which never ask) use the app's suggestion when it is valid (the display, window or area still exists), else the main display. Allowing screenshots for the run also allowshost.screen.windows; allowing only the window list does not allow screenshots. - Parameters.
targetisdisplay(default),window(needswindowId) orregion;displayis a display id and applies totarget:"display";regionis{x,y,w,h}in global desktop points (a display to the left of the main one has negativex) and the display holding most of it is used;maxWidth(1 to 16384) scales down, never up, and is the one parameter the app keeps full say over. A malformed request is an error and is never asked about. - The result's
targetsays what was really captured, which can differ from the suggestion:kindisdisplay,windoworregion;displayis the display id used;windowIdfor a window;regionfor a region, in global points and clipped to its display;boundsis the captured area{x,y,w,h}in global points, so a position in the picture maps to a position forhost.input.mouse(x = bounds.x + fx * bounds.w);byis"user"(picked in the question),"app"(the app's own suggestion, because the permission was already given) or"default"(the suggestion no longer exists, so the main display was captured). An app can passtargetback as its next suggestion. - The loader's own windows are never in a screenshot and are not listed by
host.screen.windows: the broker givesvmapp-hostctlexcludePids(the loader's process), which leaves the badge, the input overlay and the pickers out of display and region pictures (ScreenCaptureKitexcludingWindows; best effort on the legacy path) and refuses a window capture of one.
Details the table leaves out (the broker is hostlib/capabilities.js):
- Two checks, always: the manifest must request the capability (
tool:<id>is covered bytool:*,mcp:<id>bymcp:*) and the user's grant must exist; atool:*/mcp:*grant lists the tools / servers the user picked. Grants are read at every call, so revoking one applies at once. ask: the loader's confirm dialog answers yes, no, or "allow for this session" (remembered until the app stops).session: no further question while this run lasts; when the app stops, the grant goes back toask.alwaysis treated asaskforinputand for shell tools even if a file says otherwise.- Input: the loader is told when a session starts and ends (badge). A session ends after 10 minutes, when the app
stops, on panic, on the badge's Stop, or when its grant is removed; buttons held down are released. A new
host.input.startreplaces the running session. What is typed is not written to the Inspector, only its length. - Tools:
argsmust match the definition (below);stdinis at most 1 MiB; output stdout+stderr sharemaxOutputBytes; a tool that runs longer thantimeoutMsis killed with its children (timedOut: true). - MCP: the host sends
initializeandnotifications/initializeditself on first use. Other methods are refused. - Inspector: every call (allowed or not) produces a
{kind:"host", cap, op, detail, allowed, durationMs}event; never screenshot bytes; arguments are truncated. The generic bridge trace shows no payload forhost.*.
Tool definitions (tools.json, written by the user):
{ "id": "screencapture", "name": "Screenshot tool", "path": "/usr/sbin/screencapture",
"args": [ {"pattern": "^-x$"}, {"pattern": "^-R\\d+,\\d+,\\d+,\\d+$", "optional": true},
{"pattern": "^/host/(save|pick)-\\d+/[\\w .-]+\\.png$", "mapPath": true} ],
"timeoutMs": 30000, "maxOutputBytes": 1048576, "kind": "tool" }
kind:tool(constrained), orshell(full shell:pathis a shell, any args, red warning, neveralways).args: one spec per argument, in order. Each argument must match itspattern(an anchored regular expression, applied to the whole argument); a spec may beoptional; extra arguments are refused unless"variadic": true, where they repeat the last spec.mapPath: maps a VM path under a share that is currently granted to the app (/host/f-<name>,/host/pick-N,/host/save-N) to the real host path..., other shares and symlinks that lead out of the share are refused. A tool can only reach files the user already granted."read"/"write"instead oftruealso require the share to be readable / writable.shelltools: the agent passes the arguments, so the definition allows them, typically"args": [{"pattern": "^-c$"}, {"pattern": "^[\\s\\S]{1,10000}$"}]for/bin/sh.- How it runs: with no shell interpretation (
spawnof the path with an argv array), a clean environment (PATH,HOME), the user's home as cwd unless set, killed on timeout, output truncated atmaxOutputBytes.
MCP servers (mcp.json, written by the user):
- Format:
{ "id": "vscode", "name": "VS Code", "command": [...], "env": {...} }. The loader starts the stdio server on the host on first use, relays JSON-RPC, and stops it when the app stops. - Isolation: each app gets its own server process.
Always visible and stoppable:
- The badge: while
inputis active, or ashelltool runs, a loader-owned always-on-top badge shows "is controlling your computer — Stop". - The panic shortcut: ⌃⌥⌘. (Control-Option-Command-period) stops every input session and host tool immediately.
- Time limits: an input session ends after 10 minutes unless renewed through a new
host.input.start. - The virtual-input overlay: while an input session is active the loader draws, on every display, a coloured ring at
the virtual pointer (it moves with each
host.input.mouse), a ripple on a press or click, and a fading caption of what was pressed, near the bottom of the screen. Typed text is masked by default: the caption saystyping… (12 characters)(typing… (1 character)for one), so a password an app types is never readable on the screen. Characters typed within the 2.2 s the caption stays up are counted together, and a single printable key without ⌘, ⌃ or ⌥ (Shift only changes the letter) counts as typed text, so an app cannot get round the mask one key at a time. Key combinations and named keys are always shown (⌘S,↩,⌃⌥⌘.,←,F5). The loader setting Show typed text in the overlay (Host Capabilities window, off by default, kept in the loader'sloader-prefs.json, never an app's choice and never a manifest field) shows the text instead (Hello, wor…: cut at 28 characters, newline and tab as↩and⇥); it is read at every keystroke, so turning it off masks again at once. It is one loader-owned window per display: transparent, frameless, click-through (setIgnoreMouseEvents(true): it can never intercept a real click), not focusable, above full-screen apps on every Space, and excluded from captures (content protection, andexcludePidsabove). It exists only while an input session is active (gone on stop, expiry, panic and when the app stops). The overlay is shown to the user only: typed text never reaches the Inspector, with or without the setting.
The loader's UI (all loader-owned, GOVERNANCE INV-B5):
- Install review: says "This app requests control of your computer." for
input,tool:*andmcp:*(INV-H6), and lists every requested capability with its purpose. Installing allows nothing. - Permission sheet: a red "Control of your computer" section, shown at first launch, with every capability on
Don't allow; the user may choose
askorsession(neveralways) and, fortool:*andmcp:*, picks entries of their own catalog. Asessiongrant goes back toaskwhen the app stops. An update that adds a capability stops at the "asks for more" sheet and leaves the new capability off. - Host Capabilities window: the macOS permissions (with Ask macOS… only on a click), the user's tools and MCP servers (the only editor of the catalog besides the CLI; presets only fill the form), and Stop everything now.
- Confirmation: for each use on
ask, a native sheet on the app's window, worded by the loader (the app's name, its stated purpose and the exact arguments are quoted): Deny (the default), Allow once, Allow while the app runs. For a screenshot it is the picker described above (a loader window, not a native message box). - While active: the badge (above), a red strip in the library with the same Stop, a red chip on the app's card; the
Inspector shows every use as a
hostrow.
The native helper: on macOS, screen and input are performed by vmapp-hostctl, which the loader spawns. Its
JSON-lines protocol is in host/hostctl/README.md. Coordinates are global desktop points, with the origin at the
top left of the main display. Apps never talk to it; only the loader does, after its own permission checks.
macOS permissions: screen capture needs Screen Recording, and synthetic input needs Accessibility, both granted to the D9010 VmApp Launcher by the user in System Settings. The loader shows when they're missing. Apps can never trigger those prompts silently.
Not in this step: generic AppleScript automation (use a tool definition), Windows and Linux providers (the catalog format is OS-neutral; providers differ per OS).
8.3 Network (accepted by the maintainer 2026-10-15, milestone 2; launcher, guest proxy, hostlib, CLI, loader UI and registry page implemented)#
An app's core may reach chosen internet hosts. The VM still has no network device (INV-D1) and never gets one: the only way out is a host-enforced allowlist proxy. GOVERNANCE INV-N1 to INV-N4 hold the invariants.
core (HTTP client) ── 127.0.0.1:3128 ──► vmapp-init (guest proxy) ── vsock 1022 "may I open host:port?" ──► vmapp-vm
▲ │ rule check, DNS,
└─ vsock 1025 (one per connection, raw bytes) ◄───────┘ address check, connect
What a malicious app could do with it, and the answer.
| Attack | Answer |
|---|---|
| Send the user's files to its own server | Needs a granted host and a granted folder: the loader warns when both exist (riskSummary, INV-N4). Nothing is granted by default. A granted host can itself be an exfiltration route; the sheet says so. |
Reach a host that was not granted (also through a redirect or a changed Host header) |
Every new TCP connection is checked by the host against the current rules. Redirects open new connections. |
Reach the user's LAN, router or this Mac (localhost, 192.168.x.x, cloud metadata 169.254.169.254) |
No IP literals in rules. The host resolves the name itself and refuses if any answer is a non-public address (DNS rebinding included). It connects to the address it checked, not to a second lookup. |
Wildcard abuse (*.com, *.co.uk) |
A wildcard needs at least two labels after *.. There is no public-suffix list, so *.co.uk is accepted: the sheet marks every wildcard as covering many servers and the user decides. |
Ask for everything ("host": "*", "ports": "*") |
Allowed as a request, because some apps (a developer's editor, a terminal) genuinely reach arbitrary servers. It is its own entry, off until the user allows it, shown red as "any server on the internet" with the app's purpose, and the risk sentence says "send what it reads to any server on the internet". It still names hosts only: every answer goes through the address check, so the LAN and this Mac stay unreachable. (Proposed 2026-10-17, docs/design/DEV-APPS.md C2.) |
| Exhaust the host (many connections, slow DNS, stuck sockets) | 64 concurrent connections per app, 10 s connect and DNS timeout, 5 min idle timeout, bounded request lines and an event queue that drops its oldest entries. |
| Keep talking after the user revokes | net-revoke closes every open connection that no remaining rule allows, at once. Stopping the app closes all. |
| Make the host read its traffic | TLS is end-to-end through a CONNECT tunnel: the host copies bytes and sees only host, port, byte counts and timing. |
Manifest and grants#
permissions.network (§2.2):
"network": [
{ "host": "registry.npmjs.org", "purpose": "Looks up package versions" },
{ "host": "*.example.com", "ports": [443, 8443], "purpose": "Talks to the Example API" },
{ "host": "*", "ports": "*", "purpose": "Anything you reach from the terminal: git remotes, package managers" }
]
host: ASCII only (an international name is written in itsxn--form), lower case, at most 253 characters, labels of[a-z0-9-](1-63 characters, no leading or trailing-), at least two labels, the last one starting with a letter (so no spelling of an IPv4 address passes). An exact name,*.suffixwheresuffixhas at least two labels, or*alone: any public host name. Refused: IP literals (every form),*.com,localhost, and names ending in.localhost,.local,.internal,.localdomain,.home.arpaor.lan. (*alone was refused until 2026-10-17; DEV-APPS C2.)- A wildcard covers the subdomains of
suffixat any depth, neversuffixitself. List both to get both.*covers every name, and nothing else: the request host is a name, never an address, and the address check below applies to it as to any. ports: default[443], or"*": every port 1-65535. Port 80 means unencrypted HTTP; the loader marks it (also inside"*").
Grants are per app and per entry, in grants/<id>.json:
"network": { "registry.npmjs.org": { "allow": true }, "*.example.com": { "allow": true, "ports": [443] } }
- Keyed by the entry's
host. No grant means deny.allow: falserecords that the user declined. portsmay only be a subset of the requested ports (a wider one is ignored): a list within the list, a list (or"*") for an entry that asks for"*". Missing means all requested ports.- A grant for a host the manifest does not request is ignored. Only entries with
allow: truereach the VM, as--netrules. An update never keeps a grant that no longer matches an entry. - An update never widens a grant. An
allow: truewithoutportsmeans "all the ports the manifest requests now", so when a version becomes current (install, "Use version X") the store pins such a grant to the ports the previous version asked for (those the new version still asks for) before the new version uses it: a new port stays off until the user allows it in the sheet. If none of the old ports is still asked for, the grant is dropped. - Grants come only from the user (INV-D3, INV-N2): the permission sheet, the card's Network… and
vmapp grant-net.
How a connection flows#
- The core makes an HTTP(S) request. Its environment (below) points the client at the guest proxy.
- The guest proxy reads the first request:
CONNECT host:port(HTTPS and anything else tunnelled), or an absolute-formhttp://host[:port]/…request (plain HTTP). Anything else gets400;https://in absolute form gets400(useCONNECT). - With no control connection (no grants), it answers
403 network not granted. Otherwise it sends the host, on port 1022:{"op":"net-open","id":N,"host":"api.example.com","port":443}(one JSON line;idunique per VM). vmapp-vmchecks the request (below). When it allows it, it resolves and connects to the target, opens a new vsock connection to guest port 1025, writesN\n, and only then answers on port 1022{"id":N,"ok":true}.- A refusal is
{"id":N,"ok":false,"error":"…","status":403};statusis 403 (not allowed), 502 (resolve or connect failed), 503 (too many connections) or 504 (timed out). The proxy returns that status with a one-linetext/plainbodyD9010 VmApp network: <error>and closes. - On
okthe proxy answers aCONNECTwith200 Connection Established, or, for plain HTTP, sends the server the request head the way any forward proxy does (the target in origin form/path?query, the client's headers unchanged except thatProxy-ConnectionandProxy-Authorizationare dropped, aHostheader added if the client sent none, andConnection: closeset unless the request is an upgrade). Then it copies bytes both ways until either side closes. Bytes the core sent right behind its head go into the tunnel first. - The host closes the connection on EOF from either side, after 5 minutes without data in either direction, when the rule that allowed it is revoked, or when the VM stops.
The host's decision (vmapp-vm)#
In this order; the first failure refuses and is reported with allowed:false and its reason:
- The line is at most 4 KiB;
hostis a valid lower-case ASCII name (a trailing dot is removed; an IP literal is refused with a clear message) andportis 1 to 65535. - Allowlist. Some rule has the exact host (or
*.suffixcovers it, or it is*; §8.3 Manifest) and lists the port (or allows every port). - Caps. Fewer than 64 connections are open or being opened.
- Resolve on the host (10 s). If any address is non-public the request is refused (the app is told "resolves to a
private or local address", never which one; the Inspector event names it). Non-public means:
- IPv4:
0.0.0.0/8,10/8,100.64/10(CGNAT),127/8,169.254/16,172.16/12,192.0.0/24,192.0.2/24,192.88.99/24,192.168/16,198.18/15,198.51.100/24,203.0.113/24,224/4(multicast),240/4. - IPv6:
::,::1,fc00::/7(unique local),fe80::/10,fec0::/10,ff00::/8,100::/64,2001::/23,2001:db8::/32,64:ff9b:1::/48; and::ffff:0:0/96(IPv4-mapped),::/96,64:ff9b::/96and2002::/16are judged by the IPv4 address they carry.
- IPv4:
- Connect to one of the checked addresses, 10 s in total (DNS included). The address that was checked is the address that is used.
--dev-allow-loopback (tests and developer tools only) lets loopback addresses (127/8, ::1) through step 4, and
nothing else. --dev-resolve NAME=IP answers a lookup from a table instead of DNS; the result still goes through step 4.
Neither is reachable from the loader, a manifest or the bridge (INV-N3).
Limits#
| Limit | Value |
|---|---|
| Concurrent connections per app | 64 |
| DNS plus connect timeout | 10 s |
| Idle timeout (no bytes in either direction) | 5 min |
Entries in permissions.network |
32; at most 8 ports each |
| Held by the host for a target that is not reading yet | 1 MiB per connection (the guest's bytes are always read, so the virtio socket device is never left waiting; service tunnels hold up to 32 MiB, §8.6) |
| Request line on port 1022 | 4 KiB |
| Request head at the guest proxy | 16 KiB; 30 s to send it |
| Concurrent window connections per app ("The app's windows") | 64, apart from the core's |
| Request head at the window endpoint | 8 KiB; 10 s to send it; 32 clients still sending one |
Environment for the core#
The guest adds these to the core's environment as defaults (§4: an env of the manifest or the runtime image wins). The
same variables are set whether or not the app has any grant; without one every request is answered 403.
| Variable | Value |
|---|---|
HTTPS_PROXY, HTTP_PROXY, ALL_PROXY, and the lower-case forms |
http://127.0.0.1:3128 |
NO_PROXY, no_proxy |
localhost,127.0.0.1 |
NODE_USE_ENV_PROXY |
1 (Node 22.21 and later send fetch, http and https through the proxy) |
SSL_CERT_FILE |
/etc/ssl/certs/ca-certificates.crt, when the runtime image has it |
Anything that ignores these variables has no network at all. curl, Go, Python requests, Rust reqwest, .NET
HttpClient and Node 22.21+ honour them. There is no DNS and no route in the VM: a program must hand the name to the
proxy.
The app's windows (proposed 2026-10-17, docs/design/DEV-APPS.md C3)#
An app that declares the window feature network (ui.features, §2.2; it needs permissions.network) lets its windows
load data from the hosts the user allowed it to reach: what a web UI shows from the internet (extension icons, a README's
pictures, an API's JSON, a WebSocket). The windows get no network of their own: their requests go through the same proxy
and the same grants as the core's, so one grant covers both, a revoke cuts both, and without a grant everything is refused.
window (page, workers, frames) ── https/wss ──► request filter ──► partition proxy ── 127.0.0.1:P, Basic vmapp:<secret> ──► vmapp-vm
│ data only, names only window endpoint │ rule check,
└ everything else cancelled │ DNS, address
▼ check, connect
- The endpoint. When the VM is ready and before the core starts, hostlib makes a fresh secret (32 random bytes, 64 hex
characters) and sends
{"op":"window-proxy","secret":…}on the launcher's stdin (§5).vmapp-vmlistens on127.0.0.1at a port of the system's choice and answers{"window-proxy":{"port":P}}. The secret is never on a command line, in a file or in a log; the loader andvmapp-vmkeep it in memory for the run. If the endpoint cannot start, the windows have no network and the app runs anyway. - Who may use it. The endpoint answers every request without
Proxy-Authorization: Basic base64("vmapp:" + secret)(compared in constant time) with407andProxy-Authenticate: Basic realm="vmapp", and closes; no event, since it is not the app. The loader answers Electron'sloginevent with these credentials only for a proxy authentication of127.0.0.1:Pfrom that app's own partition (windowProxyCredentials); it cancels every other authentication. - What it does. It reads one request head (8 KiB, 10 s; nothing may follow it before the answer). Only
CONNECT host:portis served; anything else gets405. The host's decision is the guest's (above): the name (no IP literal), the rules, the address check, connect to the checked address, 10 s. Window connections have their own cap of 64, so a window cannot use up the core's connections or the reverse. It answers200 Connection Establishedand copies bytes both ways (TLS end to end, never read), or the refusal's status (403, 502, 503, 504) with a shorttext/plainbody (the window is never told which address a name resolved to). Revokes, the idle timeout and the stop close window tunnels as they close the guest's. - The partition. Before the run's first window loads, the loader sets the app partition's proxy to
http=127.0.0.1:P;https=127.0.0.1:Pwith<-loopback>(no implicit bypass: even a loopback request would go to the endpoint, which refuses it). The partition's request filter keeps refusing everything but its own schemes, with one exception,windowNetAllowed(loader/src/window-net.js): while the run's endpoint is set, anhttps:orwss:request of a data kind (fetch/XHR, WebSocket, image, media, font, style sheet), to a host name (no IP address in any spelling, no single-label or local name:localhost,.local,.internal,.localdomain,.lan,.home.arpa), without credentials in the URL. Neverhttp:orws:, never a script, a frame, an object, a worker or a document. When the run ends the filter refuses at once, then the proxy setting and the cached proxy credentials are cleared. - The CSP gets the
networkrow of §7's table:https:(andwss:for connections) on the data directives only.
What a malicious app could do with it, and the answer.
| Attack | Answer |
|---|---|
| Send what its window shows to a server | Only to a host the user allowed, exactly as its core could; every connection is an Inspector row. |
| Load code from the network into its page | Refused twice: no feature adds a network source to script-src, worker-src, frame-src or object-src, and the request filter cancels script, subFrame, mainFrame and object requests whatever the CSP says. |
| Reach the LAN or this Mac from the window | The filter refuses addresses and local names; the endpoint refuses them again and applies the address check to every answer. |
| Let another program on the Mac use the app's grants | The endpoint needs the run's secret; it is on loopback only, new every run, and never leaves the loader's and vmapp-vm's memory. |
| Use the endpoint after the app stopped | It stops with the VM; the loader clears the proxy setting and the credentials. A new run has a new port and a new secret. |
Traffic events#
Every connection produces one event when it closes, or at once when it is refused:
{"net":{"id","host","port","allowed","bytesUp","bytesDown","durationMs","reason"?,"window"?}} (§5). allowed is the host's
decision; a connection that was allowed but failed (connect failed) has allowed:true, zero bytes and a reason.
bytesUp counts bytes from the app, bytesDown bytes to the app. window: true marks a connection of the app's windows
(above). Hostlib turns it into a traffic event for the Inspector: {ts, dir:'from-core', t:'net', kind:'net', appId, host, port, allowed, bytesUp, bytesDown, durationMs, reason?, window?, bytes, preview}. Host names and byte counts are the only
things logged; contents never are.
hostlib and the CLI#
hostlib/network.js:validateNetwork(used bymanifest.js),matchRule,resolveNetwork(manifest, grants)(the rules for--netplus notes),checkNetGrant(manifest, host, {allow, ports})(what a UI calls before saving a grant: only listed hosts, never more ports than requested),netRequestSummary(manifest, grants)(what a sheet shows, likehostRequestSummary),netDiff(new hosts and ports, used bypermissionDiffasnetAdded),riskSummary(manifest, grants).tests/net/vectors.jsonholds the name and matching cases that both this file andhost/src/net.rsare tested with.Store:loadNetGrants,saveNetGrants,setNetGrant,pruneNetGrants. Saving folder grants keeps the network grants; a version that no longer lists a host (or a port) drops that grant when it becomes current, and one that asks for more ports does not widen it (above).AppSession: passes the granted rules as--net;netAllow(host, ports),netRevoke(host)andsyncNetwork()change a running VM; the VM'snetlines becometrafficevents.devNet: {allowLoopback, resolve}is for tests only. For an app that declaresnetworkit starts the window endpoint and keepswindowProxy({host, port, username, password}, null when the VM is not running), which the window manager reads ("The app's windows").- CLI:
vmapp grant-net <id> <host> [--ports 443,80] [--deny] [--revoke],vmapp grant-net <id> --list;inspectlists the network requests and the exfiltration warning;run --headlesshonours grants and prints thenetevents.
What the user sees (loader contract, GOVERNANCE INV-B5, INV-N4)#
- Install review lists every requested host with its purpose and marks wildcards, ports other than 443, and port 80 ("not encrypted"). Installing allows nothing.
- When an app requests
read/readwriteaccess to a folder (orscreen, §8.2) and a network host, the review and the permission sheet say so in words: "This app could readand send what it reads to ." The words come from riskSummary, never from the app. - Permission sheet: a Network section, every entry on Don't allow, each with its purpose. A wildcard is marked
and gets an explicit warning ("any address under example.com: it covers many servers"); port 80 is marked "not encrypted".
Granting less than requested (fewer ports, one checkbox per port) is allowed, but at least one port must stay checked.
The risk banner is live: it says "could" for what the app asks, and a solid red "Right now: this app can read the
folder ... and send what it reads to ..." as soon as the choices on screen allow a readable folder (or the screen) and
a host. The sentence comes from
riskSummarythrough the loader, never from the app. An app that asks for network access gets the sheet at its first launch even if it asks for no folder. - The card shows what was asked and what the user chose per host, an amber Can reach the internet chip while a host
is allowed, the same red warning when a readable folder and a host are both allowed, and Network…, which works while the
app runs. A change made there is saved and applied to the running app at once (
syncNetwork): a host taken away is cut off, open connections included. While the app is starting or stopping the button is refused. The monitor has aNetworkpill for the allowed hosts (SPEC 8.4). - Updates that add a host, a wider wildcard or a port always stop at the "asks for more" sheet (§8.1), which lists each
netAddedentry ("More network ports on api.example.com: port 8443", "A new address to reach: *.example.org (port 443)", wildcards marked) and the new version's risk sentence. The new access stays off until the user allows it. - Inspector: every connection is a
netrow (host, port, allowed or denied, bytes up and down, duration, reason), with a Network filter and a detail view; a window's connection readswindow <host>:<port>and its detail says it came from the app's window. - Registry: the app page and the listing show the hosts an app asks to reach (wildcards and port 80 marked) and, since INV-H6, control of the computer. The registry never allows anything; the user does, in the loader.
Not in this step#
UDP and anything that is not TCP through CONNECT or plain HTTP; plain http:/ws: from the windows; inbound connections; a user-visible allow-once dialog
for a denied request; per-host bandwidth limits; certificate inspection (the host does not look inside a tunnel, so an app
can send a different TLS server name or Host header to a granted server: a granted host that fronts many sites, such as
a CDN edge, can serve them all); DNS-over-anything inside the VM; Windows and Linux launchers (docs/PLATFORMS.md).
8.4 Devices (the microphone) and the live monitor (accepted by the maintainer 2026-10-15; implemented)#
The microphone#
An app's window (the UI, on the host) may listen to the user's microphone, with navigator.mediaDevices.getUserMedia({audio}).
What it hears reaches the core only if the UI sends it over the bridge (§6, JSON), so the same rules as for any other data apply:
the VM has no network unless §8.3 grants hosts. The core itself never touches a host device. GOVERNANCE INV-B3 holds the
invariant: the only permission an app window can ever be given is the microphone, and only with the user's grant.
Manifest (§2.2): "devices": [{ "device": "microphone", "purpose": "Records a voice note" }]. camera is reserved and
rejected; so is every other name. There is no other field: a manifest names the device and why, nothing more.
Grants (INV-D3): grants/<id>.json has "devices": { "microphone": { "mode": "allow" } }. The mode allow is the sheet's
Allow while the app runs: no further question, on every run, until the user changes it. A missing entry is Don't allow,
the default. Grants come only from the user: the permission sheet and vmapp grant-device <id> microphone [--revoke]. Saving
folder, host or network grants keeps the device grants; Reset forgets them with the rest of grants/<id>.json. A grant does
not outlive the request: when the version that becomes current no longer asks for the microphone, its grant is dropped, so a
later version that asks again starts on Don't allow.
The decision (loader/src/devices.js, read at every request, so a revoked grant applies at once). An app window's
getUserMedia is allowed only when all of these hold:
- the permission is
mediaandmediaTypesis exactly["audio"](video, or audio together with video, is refused as a whole); - the request comes from the main frame of a window of this app, at
vmapp-ui://<id>/; - the installed manifest asks for the microphone, and the user's grant allows it (a hand-written grant for an app that did not ask is ignored).
The permission check (navigator.permissions.query, device labels) uses the same decision for audio. Everything else stays
denied, except the clipboard below: video, screen sharing, notifications, geolocation, HID/USB/serial, and every permission of the
loader's own windows. Every request, allowed or refused, is a host row (cap microphone or camera, op media.audio or media.video)
in the Inspector. The loader never records audio itself.
What the user sees (loader-owned, INV-B5):
- Install review: a "Devices" part naming the microphone with the app's purpose. Installing allows nothing.
- Permission sheet: a "Devices" section with Don't allow (default) and Allow while the app runs; it says the app's window can hear the microphone, that the loader shows a red label while it listens, and that macOS asks too.
- Updates that newly ask for the microphone always stop at the "asks for more" sheet (§8.1); it stays off until allowed.
- Indicator: a red chip "Can use the microphone" on the app's card while the grant stands (always accurate: it comes from
the grant), and "Using the microphone now" plus a pulsing pill in the monitor while a window holds a live audio track. The
second signal is best effort: Chromium gives the embedder no event for capture, so the loader looks at the window from the
main process (DevTools
Runtime.queryObjectsfor liveaudioMediaStreamTracks, only while the window has been granted the microphone; the page gets no script, API or binding). A hostile page could hide a track from that view; it cannot hide the grant, the Inspector row, or macOS's own microphone indicator. - macOS: the packaged loader has
NSMicrophoneUsageDescriptionin Info.plist and thecom.apple.security.device.audio-inputentitlement on the app and its Electron helpers (the process that captures audio is a helper). macOS asks for consent the first time. The camera has no entitlement and no usage text of its own.
Threats: an app that asks for the microphone and is allowed can record the user's voice, and a core with network grants could
send it out (the review and the sheet say what the app asks for; INV-N4 already warns about data plus network). An app that was not
allowed cannot open the microphone by any route in the renderer: the gate is the only code that answers true to a permission, and
tools/check-invariants reads it. The dev tests use Chromium's fake microphone (--use-fake-device-for-media-stream); a packaged
loader refuses that switch and --use-fake-ui-for-media-stream, which would make Chromium accept requests without asking the gate.
The clipboard (proposed 2026-10-17, docs/design/DEV-APPS.md C5)#
Keyboard copy and paste (⌘C, ⌘X, ⌘V in an app window) always worked and still need nothing: they go through the loader's Edit
menu, and the page only sees what the user pastes. The device clipboard is for everything else: an app's window reading or
writing the clipboard from its own code (navigator.clipboard.readText/read, writeText/write: a Paste menu item, a terminal
pasting on ⌘V, a Copy button) and its core doing the same (clipboard.readText/clipboard.writeText, §6; the Node SDK's
clipboard).
- Manifest:
"devices": [{ "device": "clipboard", "purpose": "Paste from the Mac into the editor and its terminal" }]. - Grants:
"devices": { "clipboard": { "mode": "allow" } }, from the permission sheet orvmapp grant-device <id> clipboard, with the microphone's rules (Don't allow by default, dropped when a version no longer asks, kept across other grant changes). - The decision (window:
loader/src/devices.js; core:hostlib/session.js; both at every use, so a revoke applies at once): the permission isclipboard-readorclipboard-sanitized-write(Chromium's names), the request comes from the main frame of a window of this app atvmapp-ui://<id>/(window only), the installed manifest asks forclipboard, and the user allowed it. Plain text for the core, at most 1 Mi characters each way. - Visible: every window request is a
hostrow in the Inspector (capclipboard, opclipboard.readorclipboard.write); every core request is a request row whose payload is not shown (the clipboard may hold a password). - Threats: an allowed app can read whatever the user copied, at any time it runs, and with network grants send it out; it can replace the clipboard's content. That is why it is a device the user allows per app, named in the sheet ("can read and change what you copy"), and never part of keyboard copy and paste, which needs no grant.
The live monitor#
A menu-bar (Tray) icon owned by the loader opens a small popover (a loader page, INV-B5) that lists the running apps. Each app has pills for what it can reach right now, taken from the user's grants (never from the manifest alone): Folders (count; the names and access in the hover text; dialog picks of this run count too), Screen, Input, Microphone, Tools, MCP, Network (a granted host; hover text lists them). An app that can reach none of these says so.
A pill pulses while that thing is in use: an event within the last 2 seconds (a host row of that capability, a net traffic
row, a dialog grant or a delivered file), or continuously (an input session, a shell tool, a live microphone track). Events are
matched by kind only; the monitor never reads or keeps their contents. Reads and writes on a granted folder are not observable by
the host, so the Folders pill pulses on dialog grants and deliveries only.
Per app: Stop, Inspect (the Bridge Inspector for that app) and Show app data in Finder (the loader's own
data/<id>). The popover can name only an app the loader lists, and the loader checks again. The library has a compact Activity
section at the top with the same rows (Stop only). --monitor opens the popover pinned (developers, tests).
Not in this step: the camera, a per-use "ask" mode for the microphone, audio capture of the system output, and recording the microphone in the loader.
8.5 The VM's size: the manifest recommends, the user chooses (accepted by the maintainer 2026-10-15; implemented)#
How many vCPUs and how much memory an app's VM gets is a decision about the user's computer, so it is the user's (INV-D3).
The manifest's core.cpus (1-16) and core.memoryMiB (64-32768) are recommendations: what the developer found the app
needs to run well. They allow nothing.
The choice is stored per app in grants/<id>.json:
"resources": { "cpus": 2, "memoryMiB": 768 }
- Either key may be missing: it then means its default. The stored values are whole numbers (
cpusat least 1,memoryMiBat least 64); the Mac's limits are applied when a VM starts (below), so a grants file copied to a smaller Mac still runs. - Defaults, when the user has not chosen: one vCPU and the recommended memory. (A bigger default would let any manifest take more of the Mac than the user decided; the sheet offers Use recommended for the rest.)
- Caps: vCPUs at most the Mac's logical cores (
os.cpus().length) and memory at most half of its RAM, at least 64 MiB. A choice above either is refused when it is saved (sheet, card, CLI) and lowered, with a note in the app's log, when a VM starts. A recommendation above the cap is lowered the same way. - A value below the recommendation is allowed, with a warning ("less memory than the app recommends: it may run slowly or fail"; for vCPUs a lighter note).
- The choice survives updates (the user's decision outlives a version: a new, bigger recommendation does not change it, and
the sheet then warns); Reset and
uninstall --purgeforget it; uninstalling without--purgekeeps it, like every grant. - A VM cannot change size while it runs: the choice is locked while the app runs and applies at the next launch.
- A grants file that holds only the size (chosen on the card before the first launch) is not a decision about
permissions: the permission sheet still comes first (
Store.hasDecided).
Where it is chosen (loader UI, INV-B5; the app only recommends):
- the permission sheet: a Resources section with a vCPU stepper (1 to the Mac's cores), a memory menu (usual steps up to the cap, plus the recommendation and the current value), Use recommended (N vCPUs, M MB) (lowered to the Mac's limits) and the warnings. "MB" in the loader's words is MiB. An untouched row saves nothing (the defaults keep following the recommendation);
- the card: the same row, saved at once, disabled while the app runs;
- the CLI:
vmapp resources <id> [--cpus N] [--memory MiB] [--recommended] [--default];vmapp run --headlessand the loader start the VM with the user's choice (AppSessionpasses it as--cpusand--memory; its log says "VM size: 2 vCPUs, 768 MiB (your choice); the app recommends ..."). inspectand the install review say "recommends", never "uses".
hostlib: hostlib/resources.js (hostLimits, resolveResources, checkResources, resourceSummary, recommendedChoice);
Store.loadResources, saveResources, hasDecided; saveGrants keeps the choice (resources: null forgets it).
8.6 GPU: llama.cpp RPC remoting (accepted by the maintainer 2026-10-15, docs/VISION.md §4 phase B; implemented)#
An app that runs a language model (llama.cpp, Ollama) inside its VM can have the heavy maths run on the Mac's GPU. Linux has no
Metal, so nothing in the VM could call it; what works is remoting at the API the app already speaks: llama.cpp's RPC backend
(ggml-rpc). The loader starts llama.cpp's own RPC server, built with the Metal backend, in a macOS sandbox, one per app run,
and the VM reaches it through the same host-controlled tunnel as §8.3. The VM still has no network device (INV-D1) and no new
channel to the host (INV-H5); the GPU is a service the host offers by name.
llama.cpp client (in the VM) ── 127.0.0.1:50052 ──► vmapp-init (guest listener) ── vsock 1022 "service-open gpu" ──► vmapp-vm
$VMAPP_GPU_RPC ▲ │ the name maps to the
└─ vsock 1025 (one per connection, raw bytes) ◄───┘ endpoint it was started with
vmapp-vm ── TCP ──► 127.0.0.1:PORT ── sandbox-exec ──► rpc-server (Metal) ──► the GPU
The contract (fixed; apps rely on it):
- When the app has the
gpucapability granted: inside its VM a TCP endpoint is reachable at127.0.0.1:50052, the core's environment hasVMAPP_GPU_RPC=127.0.0.1:50052, and the endpoint speaks llama.cpp's ggml RPC protocol to a Metal-backedrpc-serveron the host. The server serves one device, the Mac's GPU (MTL0). - Without the grant:
VMAPP_GPU_RPCis not set and connections to127.0.0.1:50052are refused (nothing listens). - The version. The RPC protocol is versioned, and the server refuses a client whose major version differs. The platform's server
is built from the llama.cpp commit pinned in
gpu/pins.env(tag, commit andRPC_PROTO_VERSION; the build record isBUILD-INFO.txtnext to the binary). An app builds its llama.cpp client from that same commit (or at least the same protocol major version) and checksRPC_PROTO_VERSIONwhen it updates llama.cpp. - It is an opt-in, per-app capability: off by default, granted in the permission sheet or with the CLI, visible in the Inspector and the monitor, revocable at any moment (below).
Manifest and grants#
permissions.host (§8.2) with {"cap":"gpu","purpose":"…"}. gpu has no id and no wildcard. A grant in grants/<id>.json is
"host": {"gpu": {"mode": "session"}}:
sessionis the sheet's Allow while the app runs. It is the only mode: a stream of requests cannot be asked about call by call, so there is noask, and noalways(a hand-writtenaskoralwaysforgpuis refused by the store and ignored by the decision). Not allowing it is having no grant, which is the default.- Unlike the
sessiongrants of §8.2, it does not go back toaskwhen the app stops: it is a decision about this app that holds on every run until the user changes it, like the microphone's (§8.4). - A grant does not outlive the request: when the version that becomes current no longer asks for
gpu, the grant is dropped, so a later version that asks again starts on Don't allow (an update never grants anything, §8.1). An update that addsgpualways stops at the "asks for more" sheet (hostAdded, withcontrol:false). - Grants come only from the user (INV-D3): the permission sheet and
vmapp grant-host <id> gpu --mode session.
What a malicious app could do with it, and the answer#
| Attack | Answer |
|---|---|
| Use the GPU without being allowed | The grant is checked when the run starts: no grant, no server, no --service, no variable, no listener. A hand-written grant for an app that did not ask is ignored. |
| Reach anything on the Mac's loopback or LAN through the tunnel | The guest names a service, never an address. vmapp-vm maps the name to the one endpoint it was started with (127.0.0.1:PORT only; ServiceSpec::parse), looks nothing up and checks nothing the guest says. A network rule cannot name a service and net-open cannot reach one (service:gpu is not a host name). |
| Attack the RPC server itself (upstream says it is "proof-of-concept … fragile and insecure": it trusts the shapes and graphs a client sends) | It runs in a macOS sandbox (below) as a process of its own, per app; a compromise gets the sandbox, not the user's files, network or other programs. Other local processes can still connect to its loopback port while it runs (§10). The user is told in the sheet: only allow apps you trust with your GPU. |
| Keep the GPU after the user revokes | The running app is checked once a second: a revoked grant closes every tunnel at once (service-revoke), closes the guest's port and stops the server. |
| Exhaust the host | At most 8 tunnels per app, 32 MiB held per tunnel for a server that is not reading yet, 20 s to wait for a server that is still starting. There is no cap on GPU memory or GPU time: an allowed app can use as much of the Mac's GPU and unified memory as macOS allows until it is stopped or revoked. That is what allowing it means. |
| Persist a server after the app, or after the loader | The server stops with the app, on revoke, on panic and when the loader quits; a server a crashed loader left behind is stopped at the next start (pid files, checked against the command line). |
| Poison what other Metal programs use | The one writable place in the sandbox is the user's shader cache (com.apple.metal), so the compiled shaders are reused (start in a fraction of a second instead of 10 to 15 s). See §10 and docs/SECURITY.md. |
How it works#
- At launch (
hostlib/session.js,hostlib/gpu.js): if the manifest asks forgpuand the grant exists,GpuBroker.startpicks a free port and starts the server throughhostlib/hostproc.js(the only place that spawns it):/usr/bin/sandbox-exec -f <bundled profile> -D BIN=… -D BINDIR=… -D PORT=… [-D METAL_CACHE=…] <bundled rpc-server> --host 127.0.0.1 --port PORT --device MTL0. Both files are the loader's own (Contents/Resources/vmapp/gpu/packaged,out/gpu/in development); no path or argument comes from a manifest or an app. The server starts before the VM, so it can compile its shaders while the VM boots. vmapp-vmis started with--service gpu=127.0.0.1:PORT(§5) and the core's startenvgetsVMAPP_GPU_RPC=127.0.0.1:50052. The launcher asks the guest to open its listener (service, §4) and the network channel.- A connection from the core to
127.0.0.1:50052makes the guest send{"op":"service-open","id":N,"name":"gpu"}on vsockvmapp-vmanswers{"id":N,"ok":true}after it connected to the endpoint and opened a vsock connection to guest port 1025 whose first line isN\n; a refusal is{"id":N,"ok":false,"error":"…","status":403|502|503}. Then bytes are copied both ways. If nothing listens on the endpoint yet (the server is still compiling its shaders), the launcher retries for up to 20 s before it answers 502.
- No idle timeout (a model stays loaded while the user thinks); a write that makes no progress for 5 minutes ends the tunnel.
The guest's bytes always go through a queue (up to 32 MiB per tunnel) written to the server by another thread: the virtio
socket device behind the vsock serves every connection of the VM from one place, and a launcher that stops reading it while it
waits for a peer that is itself waiting leaves the VM's vsock, the bridge included, stuck (found with an echo server;
tests/e2e.shscenario 12 moves 64 MiB each way at once). - Stopping. The server stops when the app stops, when the grant is taken away (looked at once a second, so the CLI and the
sheet work on a running app), on panic (the shortcut and Stop everything now stop every server), when the loader quits,
and the launcher is told
service-revokewhenever the server ends for any reason (a crash included), so tunnels never outlive it. The app keeps running without a GPU. - If the server cannot start (not built, not Apple silicon, the sandbox refuses), the app starts without a GPU: no variable, no service; the log and the Inspector say why.
The sandbox (gpu/rpc-server.sb)#
(deny default), then exactly: the system's standard rules for a program (system.sb, system-graphics); executing the one
binary and reading its folder; one loopback TCP port (bind and accept localhost:PORT, nothing else, no outbound connection
at all); the GPU's IOKit user clients (the list Apple's own sandboxed GPU services use) and the shader compiler service
com.apple.MTLCompilerService; and, only when the loader names the user's cache folder, read-write access to its com.apple.metal
folder. No other file is readable or writable, no program can be started, no other service can be looked up. The rule set is
closed: tools/check-invariants (rule h7-gpu-service) fails on any other rule, so a change to what the server may do is a
maintainer-reviewed change of the rule (GOVERNANCE INV-H7). The binary is signed with the hardened runtime and no
entitlements (Metal needs none; checked on the signed binary by tests/gpu/).
Environment for the core#
| Variable | Value |
|---|---|
VMAPP_GPU_RPC |
127.0.0.1:50052, only while the GPU is granted and its server started |
Service events#
{"service":{...}} (§5) becomes a traffic event for the Inspector: {ts, dir:'from-core', t:'gpu', kind:'gpu', appId, id, phase:'connect'|'traffic'|'disconnect', allowed, bytesUp, bytesDown, durationMs, reason?, bytes, preview}, and the loader adds
{dir:'host', t:'gpu', kind:'gpu', phase:'server', op:'start'|'stop', port?, pid?, reason?, error?} rows when the server starts
(or cannot) and stops (reason: app-stopped, revoked, panic, loader-quit, crashed). Byte counts and names only; what
the app sends the GPU is never read.
What the user sees (loader contract, INV-B5)#
- Install review and permission sheet: the entry sits under Control of your computer with the app's purpose and the loader's own words: "Lets this app run work on your Mac's GPU (Metal) through a sandboxed helper. The helper processes the app's requests, so only allow apps you trust with your GPU." Two choices only: Don't allow (default) and Allow while the app runs. It is not labelled "requests control of your computer" (INV-H6): it is neither input nor a shell.
- The card: an amber chip Can use your GPU while the grant stands, and a GPU… button that works while the app runs (like Network…): taking the GPU away stops the app's helper and closes its tunnels at once; allowing it for a running app starts at the next launch.
- The monitor: a GPU pill for a running app that was allowed it; it pulses while a tunnel opens, moves bytes or closes.
- The Inspector: a GPU filter;
gpurows for the server and for every tunnel, with bytes up and down. - The registry: the app page and listing say the app asks to run work on the GPU through a sandboxed helper.
- Updates that add
gpustop at the "asks for more" sheet and leave it off.
hostlib and the CLI#
hostlib/gpu.js:gpuRequest,gpuAllowed(manifest, hostGrants),serviceArg,GpuBroker(start,stop,stopAll,active,reapStale, eventsstarted/stopped);hostlib/hostproc.js:GpuServer,metalCacheRoot.AppSessiontakes the broker (gpuoption), passes--serviceand the variable, turnsservicelines into rows, watches the grant,revokeGpu(reason).- CLI:
vmapp grant-host <id> gpu --mode session(the default mode forgpu),--revoke,--list;askandalwaysare refused;vmapp run --headlessstarts the server like the loader does (out/gpu/, orVMAPP_GPU_SERVER/VMAPP_GPU_PROFILE).
Building the server#
gpu/build.sh fetches the pinned commit (git clone --depth 1 --branch <tag>, then refuses a checkout whose commit is not the
pinned one or whose RPC protocol version is not RPC_PROTO_VERSION) into $VMAPP_WORK/cache/ and builds
ggml-rpc-server with -DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON -DGGML_RPC=ON -DGGML_RPC_RDMA=OFF -DBUILD_SHARED_LIBS=OFF for
arm64 into out/gpu/ (rpc-server, rpc-server.sb, BUILD-INFO.txt, LICENSE-llama.cpp.txt, MIT). It needs cmake and Xcode's
command-line tools, no Docker. tools/build.sh calls it (--no-gpu skips it); the packaged loader carries the files in
Contents/Resources/vmapp/gpu/.
Not in this step#
Vulkan inside the VM (docs/VISION.md §4 phase A; the experimental form of it is §8.7); Ollama's own conversion (an app that already uses llama.cpp's RPC client
needs only the address); choosing a device or limiting GPU memory and time; several GPUs; a server that survives the app (a model
that stays loaded between runs); the user's cache folder as a private one (needs a precompiled Metal library instead of runtime
compilation); Windows (Hyper-V GPU-PV, docs/PLATFORMS.md).
8.7 Experimental Vulkan backend: the GPU through Vulkan inside the VM (maintainer decision 2026-10-15, option 2 of gpu/venus-spike/REPORT.md §11; implemented, EXPERIMENTAL)#
Experimental, opt-in, compute-first, not a production driver. The research spike (
gpu/venus-spike/REPORT.md) showed that Vulkan from a Linux VM reaches the Apple GPU at 92 to 115 % of native speed, and also that the stack is not yet safe to trust (below: B2 to B7). So it ships behind three switches, all of which must be on, and the shipping GPU path stays §8.6.
An app can run its core in a VM that has a virtio-gpu (Venus) device: a standard Vulkan driver (libvulkan.so.1 with Mesa's Venus
ICD) inside the VM, whose commands the host executes with MoltenVK on Metal. Unmodified Vulkan programs (vulkaninfo, vkpeak, llama.cpp's
Vulkan backend) run. This needs a different VM implementation than Virtualization.framework, which has no GPU device: libkrun on
Hypervisor.framework, run by a second launcher, vmapp-vm-krun, with the same command line and protocol as vmapp-vm (§5).
Switches#
An app runs on this backend only when all three hold; otherwise it runs on the normal backend, without a GPU, and its log says which one was missing:
- The app asks:
permissions.hosthas{"cap":"gpu","api":"vulkan","purpose":"…"}(§2.2) and itsruntimeisnode22-gpu(§3). A plain{"cap":"gpu"}is the llama.cpp RPC service (§8.6) and never uses this backend. An app lists at most onegpuentry. - The user allowed it, per app: the permission sheet line (labelled Experimental), or
vmapp grant-host <id> gpu. Like §8.6, the only mode issession("Allow while the app runs", on every run until revoked). The grant records the API:"host":{"gpu":{"mode":"session","api":"vulkan"}}. A grant made for the plaingpudoes not allowvulkanand the other way round; an update that addsapi, or changes it, stops at the "asks for more" sheet (§8.1,permissionDiffreports{cap:"gpu", api:"vulkan"}inhostAdded) and the old grant is dropped when the version becomes current. - The loader setting is on: Host Capabilities window, Experimental GPU (Vulkan) (
experimentalGpuVulkaninloader-prefs.json, off by default;vmapp run --headless --experimental-gpufor the CLI). Also required:out/krun/and the gpu kernel (Image-gpu) are installed.
hostlib/gpu.js chooseBackend is the one place this is decided, and hostlib/session.js the one place that acts on it. A manifest, the
guest and the bridge cannot choose the backend.
What is supported and what is not#
| On the Vulkan backend | |
|---|---|
Kernel, runtime, /app, /data, folder grants made at launch (f-<name>, all three accesses), write-only delivery |
same as §4/§5 (the static shares are one virtiofs device each; ownership is simplified so the app user can use a share whatever uid the Mac user has) |
Bridge, windows, dialogs that need no folder (dialog.message, shell.openExternal), host capabilities (§8.2) |
same |
| Network allowlist proxy (§8.3), host services (§8.6) | same (vsock ports 1022 to 1025 are libkrun Unix sockets in a private folder; a tunnel is closed in both directions when its target is done, because libkrun cannot pass a host-side half-close on) |
Runtime folder grants (dialog.open directory or file, dialog.save; stdin grant) |
not supported (B8): libkrun cannot re-point a virtiofs share while the VM runs. The session answers the core not supported on the gpu backend before the user is asked, and the launcher answers a stdin grant with {"error":{"op":"grant","message":"not supported on the gpu backend"}} |
| Presenting frames in the app window | not supported: compute and offscreen rendering only; no swapchain to the host window |
| Memory balloon | free-page reporting only (B9) |
| The GPU | one virtual device, Vulkan profile vmapp-gpu-0.1 (below) |
Profile vmapp-gpu-0.1 (what the spike measured; results/vkinfo-venus-guest.json; the profile is bumped with each host-stack change): one
integrated UMA device Virtio-GPU Venus (<host GPU>), device API 1.3 (the host offers 1.4; the guest is capped by the old Venus protocol), instance
1.4.309; all 15 Vulkan 1.3 features, but no geometry shaders, wide lines, float64, logicOp, depth bounds, pipeline statistics, cull distance,
variable multisample rate or sparse; 88 device extensions (the host has 129; among the missing: KHR_maintenance5 to 9,
EXT_host_image_copy, EXT_memory_budget, KHR_portability_subset); limits equal to the host's except nine per-stage and per-set limits that
are about half; four queue families with graphics, compute and transfer; presentation advertised but unusable. Known undeclared deviations
(B5): samplerMipLodBias, pointPolygons, tessellationIsolines/PointMode and triangle fans behave as the host lacks them. An app that
needs conformance beyond this must not use the backend.
Known problems of the stack (spike REPORT §6), and what this backend does about them#
| Problem | What the backend does | |
|---|---|---|
| B2 | MoltenVK's Metal argument buffers and Venus lose the device ("Invalid Resource") during llama.cpp generation, and the loss is never reported to the guest | the VMM runs with MVK_CONFIG_USE_METAL_ARGUMENT_BUFFERS=0 (also faster) and MVK_CONFIG_LOG_LEVEL=1, set by the launcher |
| B3 | timeline semaphores crash or hang the VMM | not fixed. A crash is detected (vmm-crashed); a hang is the watchdog's (below) |
| B4 | a failed pipeline creation hangs Venus (the fork lacks upstream's fatal-wait check); some unimplemented virtio-gpu commands panic!() in libkrun |
upstream's "stop waiting when the context is fatal" commits are back-ported (gpu/krun/patches/0003, built and booted, not yet exercised against a failing pipeline); the libkrun panics remain; the watchdog |
| B5 | VK_KHR_portability_subset is not exposed to the guest |
not fixed (profile above) |
| B6 | virglrenderer is an old fork (0.10.4 plus eleven macOS commits) | pinned by commit, patched in gpu/krun/patches/; rebasing on upstream 1.x is the first condition of REPORT §11 |
| B7 | no GPU time limit: a 43 s dispatch ran unpre-empted | no limit on a dispatch; the watchdog kills a VM whose GPU worker stops answering for 30 s |
| B8 | shares are fixed at boot | see the table above |
| B9 | RAM plus the GPU window must fit the guest address space | --gpu-window MiB (256 to 32768, default 8192); the loader passes none |
The launcher vmapp-vm-krun, the VMM and the sandbox#
vmapp-vm-krun (host/src/krun.rs, host/src/bin/vmapp-vm-krun.rs; built into out/krun/bin/ by gpu/krun/build.sh with the pinned host
libraries in out/krun/lib/) is two processes of one executable:
- the launcher is what the loader starts. It owns everything §5 lists (ready line, bridge socket, shares and delivery, network proxy and service tunnels, stdin protocol, parent watch, signals) and never loads libkrun, MoltenVK or Metal;
- the VMM is its child (
vmapp-vm-krun __vmm <spec>; the spec is a file in the launcher's private run folder$TMPDIR/vmapp-<id>-k/, mode 0700). It loadsout/krun/lib/libkrun.1.dylibwithdlopen, enters a deny-by-default macOS sandbox, and runs the VM; libkrun ends the process when the guest powers off. The virtio devices, the Venus decoder, MoltenVK and Metal are all in this one process, so it is the process a hostile guest attacks, and it is the one that is sandboxed. It follows the launcher (kqueueNOTE_EXIT; it exits when the launcher does, INV-E1), has no core dumps and a raised descriptor limit.
The sandbox (gpu/krun/vmm.sb, applied with sandbox_init_with_parameters; GOVERNANCE INV-H8): (deny default), then the system's standard rules and
system-graphics, the folder of the executable and the existence of the home folder (Core Foundation and Metal's device discovery crash without them),
read access to the kernel, the initramfs, the two images and the read-only shares, read and write access to the run folder, the console log and the
read-write shares (appdata, the folders the user granted at launch, the staging folders of write-only shares), Unix sockets only in the run folder, the GPU's
IOKit user clients, the shader compiler service, and the user's Metal shader cache. No network of any kind, no program can be started, no
other file is readable or writable. Every path in it is a parameter built by the launcher from its own command line. --dev-no-sandbox (tests and
development) leaves the profile out; the loader never passes it.
Entitlement: vmapp-vm-krun is signed with com.apple.security.hypervisor (host/krun.entitlements) and nothing else (in particular no networking
entitlement; the VM has no network device, INV-D1: libkrun is built without its network code, and the launcher uses a plain vsock device without socket
hijacking). Ad-hoc signing is what the build does; a Developer ID release needs the hardened runtime and the four libraries signed with the same identity.
The guest#
- Kernel: the
gpuflavour (kernel/build.sh --flavour gpu->out/Image-gpu,kernel/flavours/gpu/): the default config plusVIRTIO_MMIO(libkrun's devices are MMIO),DRM,DRM_VIRTIO_GPU,DRM_VIRTIO_GPU_KMS,SYNC_FILE, and the blob-alignment patch (module parametervirtio_gpu.blob_alignment; Apple's hypervisor maps 16 KiB pages and the guest has 4 KiB, B1). The launcher passesvirtio_gpu.blob_alignment=16384. The forbidden list of the default kernel is split per flavour: this flavour relaxes exactly the DRM family and nothing else (no modules, namespaces, cgroups, eBPF, USB, sound or network device); the default kernel is byte-identical to before. - Runtime
node22-gpu(§3). Mesa is the unmodified upstream 26.2.5, which needs the kernel above (no userspace workaround). vmapp-init(§4): mounts one virtiofs device per share (vmapp.fs=), makes the render node openable by the core (vmapp.gpu=1), and runs the GPU liveness probe (below). The core finds the GPU through the standard Vulkan loader; no variable is set.- Console and exit code: the console log is written by the VMM to the same file
--consolenames; the core's exit status is read from it as on the normal backend.
The watchdog (gpu-hang)#
Every second the launcher asks the guest hello on the control port and tells the backend what came back. The backend kills the VMM (SIGKILL) and
stopped says "reason":"gpu-hang" with a detail when either holds for --watchdog seconds (default 30; 0 = off):
- the control port gave no answer (the VMM or the guest kernel is wedged), or
- the guest's GPU probe has been stuck:
vmapp-initstarts/usr/lib/vmapp/gpu-probefrom the runtime with the core's restrictions (uid/gid 1000, chroot, no privileges); every half second it allocates, maps and frees a block of host-visible GPU memory, a request the host's virtio-gpu worker must answer. A worker wedged by a Venus wait (REPORT B3/B4) leaves it stuck while every other process in the guest runs on, andhelloreports it asgpu.stalledMs. Without the probe program in the runtime, or when its Vulkan setup fails, the watchdog uses the control port alone.
A VMM that dies by itself is reported as "reason":"vmm-crashed" with the signal or status. A graceful stop (stdin shutdown, EOF, a signal, the core exiting)
is unchanged. The watchdog does not limit how long an honest dispatch may run; it only fires when the GPU stops answering. Treat the limit as: an app that
keeps the host's worker busy for longer than --watchdog seconds is stopped.
What a malicious app could do with it, and the answer#
| Attack | Answer |
|---|---|
| Use the Vulkan GPU without being allowed | The backend is chosen only when the manifest asks, the grant exists and records the API, and the loader setting is on; the launcher is started with the backend's own command line. A hand-written grant for the other API is ignored. |
| Attack the host through the virtio-gpu protocol, the Venus decoder, MoltenVK or the Metal driver (an app sends arbitrary commands) | The VMM is a separate process in the sandbox above; a compromise gets the sandbox: no network, no other files, no programs. The Venus decoder and the macOS sharing path are unaudited and unfuzzed (REPORT §8); this is why the backend is experimental and why the user is told so in the sheet. |
| Take the user's GPU or memory | Not limited: GPU memory (bounded by --gpu-window for what the guest can map) and time are not capped, only the hang watchdog above. An allowed app can slow the Mac. |
| Crash or hang the VMM | Only this app's VM is affected (two VMs ran at once in the spike; a crash killed one). The launcher reports vmm-crashed / gpu-hang; the loader shows it in the app's log and the Inspector. |
| Reach the network, the LAN or the Mac through the VM | The VM still has no network device (INV-D1); the only way out is §8.3 through the launcher. |
| Escape through a share | The shares are virtiofs from libkrun's passthrough code, in the sandbox: it can read and write only the folders it was given, so a path traversal bug in the passthrough code meets the sandbox. Read-only is enforced by libkrun's read-only flag. |
| Leave a VM running after the loader | The VMM follows the launcher, the launcher follows the loader (INV-E1). |
What the user sees (loader contract, INV-B5)#
- Settings: Host Capabilities window, a section Experimental GPU (Vulkan) with the switch and the limits in the loader's words.
- Install review and permission sheet: the entry reads "EXPERIMENTAL: Use your Mac's GPU through Vulkan, inside the app's virtual machine", with an amber experimental chip, the loader's note (different and less proven backend, no runtime folder grants, the drivers process what the app sends, only works while the setting is on), and the two choices Don't allow (default) and Allow while the app runs. Like §8.6 it is not "control of your computer".
- The card: the amber chip and GPU… button of §8.6 (they act on whichever GPU request the app has). Inspector: a
gpurow when the backend starts (backend:"krun") and when it stops with a verdict; the app's log says that it runs on the experimental backend, or why it does not.
Building and testing#
gpu/krun/build.sh (pins in gpu/krun/pins.env, patches in gpu/krun/patches/, notes in gpu/krun/PATCHES.md; about 10 minutes the first time, reproducible: two clean
builds give byte-identical libraries), kernel/build.sh --flavour gpu, tools/mkruntime runtimes/node22-gpu out/runtimes/node22-gpu.img, tests/gpu-vulkan/build-core.sh
(the test core: vulkaninfo, vkpeak, llama-bench, vk-spin, vk-offscreen-tri). Tests: tests/gpu-vulkan/run.sh (real VMs: Venus device, compute, llama.cpp, the watchdog),
tests/gpu-vulkan/krun-e2e.sh (the part of tests/e2e.sh that applies, against the real launcher), cargo test in host/, tests/unit/.
Not in this step#
Windowed presentation; runtime folder grants; the REPORT §11 conditions 1 to 4 and 6 (rebase on virglrenderer 1.x, a non-blocking control-queue worker with device-loss propagation, a conformance gate and the portability-subset mask, fuzzing and per-VM GPU/RAM budgets, KosmicKrisp on macOS 26); a Windows or Linux backend.
8.8 Ports: the app's servers on this Mac's localhost (proposed 2026-10-17, docs/design/DEV-APPS.md C4)#
An app's core may run a server in its VM (a dev server, a documentation preview, a notebook) and open it on this Mac, so that the
user's own browser reaches it at http://localhost:<port>. The VM still has no network device: the Mac's side is a loopback
listener of vmapp-vm, and each connection is carried over vsock to the VM's own loopback.
the user's browser ── 127.0.0.1:H ──► vmapp-vm (listener of the forward) ── vsock 1026, "P\n" ──► vmapp-init ── 127.0.0.1:P ──► the app's server
- Permission. The device
ports(permissions.devices, §2.2, §8.4): the manifest asks with a purpose; the user allows it per app in the permission sheet (default Don't allow) or withvmapp grant-device <id> ports. Checked at everyport.forward. When the user takes it away, every open forward closes at once (the loader callssyncPorts). - Ops (§6):
port.forward {port}→{port, hostPort, url},port.close {port},port.list. The Mac's port is the VM's port when that is free on the Mac, else one the system picks. The SDK:const { ports } = require('vmapp'),ports.forward(3000). - Links.
shell.openExternalacceptshttp(s)://localhost:<hostPort>/…andhttp(s)://127.0.0.1:<hostPort>/…while that port is forwarded by this app (the user still confirms, as for any link). - The launcher (§5
port-forward,port-close,forwardevents): listens on127.0.0.1only; at most 16 forwards and 64 forwarded connections per app; no idle timeout (a dev server keeps its live-reload socket open). Network rules and revokes never touch a forward;port-close, the grant taken away and the VM's stop end it. - The guest (§4 vsock 1026): reads the port, connects to
127.0.0.1:Pinside the VM (that address only, fixed in its code), copies bytes. If nothing listens there the connection ends at once.
What a malicious app could do with it, and the answer.
| Attack | Answer |
|---|---|
| Expose a server to the user's network | The listener is on 127.0.0.1 only: nothing from the LAN reaches it (GOVERNANCE INV-N1 rule for host listeners). |
| Reach the Mac or the LAN from the VM through a forward | A forward carries connections into the VM only; the guest connects to its own loopback, never anywhere else. |
| Serve something to other programs on this Mac | Any local process can connect to a forwarded port while it exists, as to any local dev server: the sheet says so ("other programs on this Mac can connect to them while they are open"). Forwards are shown on the card and in the Inspector, and close with the app. |
| Open many ports or connections | 16 forwards, 64 connections per app. |
| Trick the user with a localhost link | shell.openExternal still asks, and accepts only ports this app forwards now. |
Visible: every forward's start and stop and every forwarded connection (with bytes) is an Inspector row (port); the monitor shows
a Ports pill while the device is allowed.
Not in this step#
Forwarding into the app's windows (VS Code's Simple Browser showing a forwarded port inside the app); UDP; a forward that other machines may reach.
8.9 App links and sign-in callbacks (proposed 2026-10-17, docs/design/DEV-APPS.md C8)#
A link can lead back into an app: a sign-in page in the user's browser that redirects to the app, a link in a mail or a document.
vmapp://<app id>/<path>?<query>. The loader is the Mac's handler of thevmapp:scheme (packaged:Info.plistCFBundleURLTypesandapp.setAsDefaultProtocolClient). A link goes to the app named by its host part, only when that app is installed and its manifest haspermissions.links(§2.2); any other link is ignored (logged).- Running: the core gets
{"t":"open-url","url":…}(§6), in the SDK Electron'sapp.on('open-url', (event, url)). - Not running: the loader asks (its own words: “Open this link in “
”? … is not running. Start it to open the link.”, Start / Cancel). A link never starts an app by itself. Start launches it the usual way (the permission sheet first if needed) and the link is delivered once the app runs (within 2 minutes). - Visible, not readable: the Inspector row shows
vmapp://<id>/<path>and never the query or fragment (a sign-in code or token).
- Running: the core gets
- Loopback redirects (RFC 8252:
http://127.0.0.1:<port>/callback, what most command-line and desktop sign-ins use) need no scheme: the app listens in its VM, forwards that port (§8.8, the deviceports) and gives the forwarded port in its redirect URI; the browser's redirect reaches the app through the forward.
What a malicious party could do with it, and the answer.
| Attack | Answer |
|---|---|
| A web page sends crafted links at an app | Only apps that ask for links get them; the app must treat a link like any other untrusted input (as in Electron). The link cannot pick another app: the host part names the one app it reaches. |
| A link starts an app the user did not mean to run | Never without the loader's question; the permission sheet still comes first when it is needed. |
| A sign-in code leaks into logs | The Inspector and the loader's log show the link without its query and fragment. |
Not in this step#
Links with other schemes (myapp://); a link that opens a particular window of the app (the app decides from the path).
9. The Node SDK (require('vmapp'), inside the node22 runtime)#
It mirrors the Electron main-process API that real apps use, so porting is mostly
require('electron') → require('vmapp'):
const { app, BrowserWindow, ipcMain, dialog, shell, folders } = require('vmapp');
app.whenReady().then(() => {
const win = new BrowserWindow({ width: 900, height: 640, title: 'Notes' });
win.loadFile('index.html'); // relative to ui.root
});
ipcMain.handle('notes:list', async (event, dir) => …); // ← window.vmapp.invoke('notes:list', dir)
ipcMain.on('log', (event, msg) => …); // ← window.vmapp.send('log', msg)
win.webContents.send('notes:changed', names); // → window.vmapp.on('notes:changed', …)
app.on('window-all-closed', () => app.quit()); // the default when no listener is registered
| Export | Members |
|---|---|
app |
whenReady(), isReady(), on('ready' | 'window-all-closed' | 'before-quit'), quit(), getName(), getVersion(), getPath('userData' | 'temp' | 'home') (→ /data, /tmp, /data) |
BrowserWindow |
new BrowserWindow({width,height,minWidth,minHeight,title,resizable,webPreferences:{devTools}}) (devTools is a boolean, default false, sent as devTools in window.create (§6, §7); every other webPreferences key is ignored, with one warning in the core log per run); loadFile(entry) (creates the window on the host; returns a Promise); webContents.send(channel, …args); setTitle, focus, close; on('closed' | 'focus' | 'blur'); id; static getAllWindows(), fromId(id) |
ipcMain |
handle(channel, fn), handleOnce, removeHandler, on(channel, fn), off. event = {sender: win.webContents, senderWindow: win} |
dialog |
showOpenDialog([win], {title, properties:['openFile'|'openDirectory'|'multiSelections']}) → {canceled, filePaths}; showSaveDialog([win], {title, defaultPath}) → {canceled, filePath}; showMessageBox([win], {type, message, detail, buttons}) → {response} |
shell |
openExternal(url) → Promise |
folders |
{<name>: path | null} from the user's grants (filled before ready) |
host |
host capabilities (§8.2), each denied unless the manifest asks and the user grants: host.screen.windows(), host.screen.capture({target,windowId,region,display,maxWidth}) (a suggestion: the user picks) → {png: Buffer, width, height, target:{kind, display?, windowId?, region?, bounds?, by}}, host.input.start(reason) → session {id, mouse(params), keys(params | text), stop()}, host.tool.list(), host.tool.run(id, args, {stdin}) → {exitCode, stdout, stderr, truncated, ...}, host.mcp.list(), host.mcp.call(server, method, params) |
The SDK connects to $VMAPP_SOCKET. It is a server: it listens there, and the host's bridge connection arrives
through the guest's port 1024. One connection per app run. Errors thrown in handle reach the renderer as rejected
promises with the message only (no stack).
Network needs no SDK call. fetch(), http and https in the core go through the proxy of §8.3 by themselves
(NODE_USE_ENV_PROXY=1), for the hosts the user granted; every other host fails with a 403 from the proxy.
Rust and .NET apps speak §6 directly. The samples in apps/hasher and apps/todo show minimal implementations
(listen on $VMAPP_SOCKET, JSON lines).
10. Security model (format 1)#
| Threat | Mitigation |
|---|---|
| App code reading or changing the user's files | The core runs in a VM. Only granted folders exist, and their access is enforced by the host (Virtualization.framework). |
| App code using the network | The VM has no network device. The core's only way out is the allowlist proxy of §8.3: nothing is reachable until the user grants a host, the host checks every connection, refuses non-public addresses and can cut a connection at once. The renderer has connect-src 'none'. |
| App code sending the user's data out | The loader warns when an app can read folders or the screen and reach hosts (riskSummary). TLS is end-to-end, so the host can show where data goes (host, port, bytes) but not what. |
| App UI escaping into the host | Sandboxed Chromium renderer, no Node, a strict CSP, no navigation or popups, no device permissions except the microphone when the app asks for it and the user allowed it (§8.4). The only API is window.vmapp. |
| App using the Mac's GPU through Vulkan (experimental, §8.7) | Off unless the manifest asks for {cap:"gpu", api:"vulkan"}, the user allows exactly that per app and the loader setting "Experimental GPU (Vulkan)" is on. The app's VM then runs on libkrun, whose virtio-gpu device, Venus decoder, MoltenVK and Metal all live in one VMM process that sits in a deny-by-default sandbox (no network, no programs, only the VM's own files); a watchdog kills a VM whose GPU stops answering. The decoder is not audited or fuzzed: that is why it is experimental. |
| App using the Mac's GPU | Off unless the manifest asks and the user allows it per app (loader sheet; default Don't allow). The work runs in llama.cpp's RPC server, which upstream does not harden against hostile clients, so it runs in a deny-by-default macOS sandbox (one loopback port, the GPU, nothing writable but the shader cache), one per app run, stopped with the app, on revoke, on panic and at quit. The guest can name the service and nothing else (§8.6, INV-H7). Not limited: how much GPU memory and time an allowed app uses. |
| App listening to the user | The microphone is off unless the manifest asks and the user allows it, per app, in loader UI. Only audio, only the app's own main page; video and screen sharing stay refused. Every request is logged; the card, the activity monitor and macOS show when it listens (§8.4). |
| A fake system prompt drawn by the app | Real decisions are native dialogs or loader UI, never app UI. |
| A tampered package | SHA-256 of every file, checked at install and when UI files are served. Publisher signatures arrive in milestone 2. |
| App survives the loader | vmapp-vm follows its parent. A VM can't outlive the loader. |
Not in format 1:
- network beyond the allowlist proxy of §8.3 (no UDP, no inbound);
- the GPU beyond llama.cpp RPC remoting (§8.6) and the experimental, opt-in Vulkan backend (§8.7: sandboxed VMM, no runtime folder grants, no conformance guarantee): no Metal API in the VM, no limits on GPU memory or time;
- the camera (the microphone is §8.4);
- clipboard beyond the renderer's own;
- notifications;
- publisher signatures;
- per-app CPU and memory accounting beyond the size the user chose (§8.5);
- x86_64 hosts or guests;
- Windows and Linux hosts.