on main help sign in
feat: first commit
commit07ea478716f7d98ae29468b505f4bb7c16b09407 authorrobcholz <84130577+robcholz@users.noreply.github.com> dateThu 5 Feb 2026, 21:02 (8 months ago)
12 files changed+1819
.gitignore+3 Cargo.lock+120 Cargo.toml+23 LICENSE+7 build.rs+12 docs/implementations.md+91 docs/new_features.md+22 docs/old_features.md+47 readme.md+208 src/entitlements.plist+8 src/main.rs+1216 src/provision.sh+62
.gitignorenew file+3
@@ -0,0 +1,3 @@
1+/target
2+.vibebox
3+.idea
Cargo.locknew file+120
@@ -0,0 +1,120 @@
1+# This file is automatically @generated by Cargo.
2+# It is not intended for manual editing.
3+version = 4
4+
5+[[package]]
6+name = "bitflags"
7+version = "2.10.0"
8+source = "registry+https://github.com/rust-lang/crates.io-index"
9+checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
10+
11+[[package]]
12+name = "block2"
13+version = "0.6.2"
14+source = "registry+https://github.com/rust-lang/crates.io-index"
15+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
16+dependencies = [
17+ "objc2",
18+]
19+
20+[[package]]
21+name = "dispatch2"
22+version = "0.3.0"
23+source = "registry+https://github.com/rust-lang/crates.io-index"
24+checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
25+dependencies = [
26+ "bitflags",
27+ "block2",
28+ "libc",
29+ "objc2",
30+]
31+
32+[[package]]
33+name = "lexopt"
34+version = "0.3.1"
35+source = "registry+https://github.com/rust-lang/crates.io-index"
36+checksum = "9fa0e2a1fcbe2f6be6c42e342259976206b383122fc152e872795338b5a3f3a7"
37+
38+[[package]]
39+name = "libc"
40+version = "0.2.180"
41+source = "registry+https://github.com/rust-lang/crates.io-index"
42+checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
43+
44+[[package]]
45+name = "objc2"
46+version = "0.6.3"
47+source = "registry+https://github.com/rust-lang/crates.io-index"
48+checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05"
49+dependencies = [
50+ "objc2-encode",
51+]
52+
53+[[package]]
54+name = "objc2-app-kit"
55+version = "0.3.2"
56+source = "registry+https://github.com/rust-lang/crates.io-index"
57+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
58+dependencies = [
59+ "bitflags",
60+ "objc2",
61+ "objc2-foundation",
62+]
63+
64+[[package]]
65+name = "objc2-core-foundation"
66+version = "0.3.2"
67+source = "registry+https://github.com/rust-lang/crates.io-index"
68+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
69+dependencies = [
70+ "bitflags",
71+ "dispatch2",
72+ "objc2",
73+]
74+
75+[[package]]
76+name = "objc2-encode"
77+version = "4.1.0"
78+source = "registry+https://github.com/rust-lang/crates.io-index"
79+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
80+
81+[[package]]
82+name = "objc2-foundation"
83+version = "0.3.2"
84+source = "registry+https://github.com/rust-lang/crates.io-index"
85+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
86+dependencies = [
87+ "bitflags",
88+ "block2",
89+ "libc",
90+ "objc2",
91+ "objc2-core-foundation",
92+]
93+
94+[[package]]
95+name = "objc2-virtualization"
96+version = "0.3.2"
97+source = "registry+https://github.com/rust-lang/crates.io-index"
98+checksum = "8edb98625ad1f7dea2e82ab54d5bdf3f477e6645b550f4a7cced1818be9ff59d"
99+dependencies = [
100+ "bitflags",
101+ "block2",
102+ "dispatch2",
103+ "objc2",
104+ "objc2-app-kit",
105+ "objc2-core-foundation",
106+ "objc2-foundation",
107+]
108+
109+[[package]]
110+name = "vibebox"
111+version = "0.1.0"
112+dependencies = [
113+ "block2",
114+ "dispatch2",
115+ "lexopt",
116+ "libc",
117+ "objc2",
118+ "objc2-foundation",
119+ "objc2-virtualization",
120+]
Cargo.tomlnew file+23
@@ -0,0 +1,23 @@
1+[package]
2+name = "vibebox"
3+version = "0.1.0"
4+edition = "2024"
5+
6+[dependencies]
7+objc2 = "*"
8+objc2-foundation = { version = "*", features = [
9+ "NSArray",
10+ "NSString",
11+ "NSURL",
12+ "NSError",
13+ "NSFileHandle",
14+ "NSData",
15+ "NSDate",
16+ "NSRunLoop",
17+ "NSObject",
18+] }
19+objc2-virtualization = "*"
20+block2 = "*"
21+dispatch2 = "*"
22+libc = "*"
23+lexopt = "0.3"
LICENSEnew file+7
@@ -0,0 +1,7 @@
1+Copyright 2026 Kevin Lynagh
2+
3+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4+
5+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6+
7+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
build.rsnew file+12
@@ -0,0 +1,12 @@
1+use std::process::Command;
2+
3+fn main() {
4+ let sha = Command::new("git")
5+ .args(["rev-parse", "--short", "HEAD"])
6+ .output()
7+ .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
8+ .unwrap_or_else(|_| "unknown".into());
9+
10+ println!("cargo:rustc-env=GIT_SHA={sha}");
11+ println!("cargo:rerun-if-changed=.git/HEAD");
12+}
docs/implementations.mdnew file+91
@@ -0,0 +1,91 @@
1+# Implementations
2+
3+## Definitions
4+
5+### Vibebox Command
6+
7+Commands for vibebox, the format is `:<command>`
8+
9+### Box Command
10+
11+Commands for general vm spawned by the vibebox, a typical linux command,
12+the format is as same as the linux, `<command>`
13+
14+### Data Storage
15+
16+Global session index is stored in `~/.vibebox/sessions.toml`.
17+
18+Global images are stored in `~/.vibebox/images`.
19+
20+Project config is stored in `[project_dir]/vibebox.toml`, this should be committed to VCS.
21+
22+Project cache should be stored in `[project_dir]/.vibebox/`.
23+
24+### Session management
25+
26+Each session represents an underlying VM instance, users can quit, enter, spawn a session.
27+
28+A session is represented by a UUID(RFC 4122 variant),and it is UUIDv7.
29+
30+Example:
31+
32+`019bf290-cccc-7c23-ba1d-dce7e6d40693`
33+
34+Each session has the following members:
35+
36+- `id`: session id
37+- `directory`: project directory (absolute)
38+- `last_active`: utc time indicating the last active time for the session
39+
40+Sessions info are stored in `~/.vibebox/sessions.toml`
41+
42+A session can be shut down, resumed, deleted, created.
43+
44+A session links to a directory. Currently, a directory can only have a single session; a session can be connected to
45+multiple vibeboxes.
46+
47+Instance data are stored in `project_dir/.vibebox`.
48+
49+Deleting `[project_dir]/.vibebox/` permanently deletes the session. The global index entry (if any) will be removed by
50+any command that uses the global index but failed to locate.
51+
52+There is a reference count for each session, and this represents the number of `vibebox` using that session.
53+
54+A session (VM) will only shut down after `AUTO_SHUTDOWN_GRACE_MS` milliseconds if the reference count is 0. Shutting a
55+session down can save some resources, but the first startup time will be larger than resuming an active session.
56+
57+When a session shuts down, the VM stops, but the instance disk in project_dir/.vibebox/ is preserved for faster
58+later boots.
59+
60+#### Behavior
61+
62+In host cli:
63+
64+- use `vibebox` without config to connect to an exising session, or create a new one if not existed.
65+- use `vibebox delete <session_id>` to delete an existed session, delete <session_id> removes
66+ `[session.directory]/.vibebox/` and deletes the global index entry.
67+- use `vibebox list` to list a list of sessions
68+
69+In vibebox:
70+
71+- use `:new` to prompt user to delete and create a session.
72+- use `:exit` to exit vibebox.
73+
74+### (Shows all the mounts/network/visibility)
75+
76+Each session has mounts, meaning it has a file system mapping from host to the inner VM, this has a default value, and
77+users can add new mounts to it.
78+
79+Each session also has its own network mapping, users can choose to use blocklist or allowlist mode to control the
80+traffic by hostname/domain.
81+
82+The command can display the mapping and network strategy.
83+
84+They are also stored per project, in `vibebox.toml`
85+
86+#### Behaviors
87+
88+- use `:explain` to display:
89+ - mounts: host_path → guest_path, ro/rw
90+ - network: mode (allowlist/blocklist) and entries
91+ - storage: paths to vibebox.toml and .vibebox/ (relative from the project_dir)
docs/new_features.mdnew file+22
@@ -0,0 +1,22 @@
1+# New Features
2+
3+## Priority 1
4+
5+### Session management
6+
7+1. each session is a folder
8+2. by default, there should be a reference count for each vm, if count is 0, shutdown in DEFAULT_EXPECT_TIMEOUT.
9+3. You can also use command inside a session to never allow it to auto shutdown
10+4. you can use commands to resume a session
11+
12+### explain (Shows all the mounts/network/visibility)
13+
14+1. you can display all the mounts & network activity.
15+2. you can set disable to disable a network connection.
16+3. use a config file in .vibebox/config.toml to config it
17+
18+## Priority 2
19+
20+### Port Forwarding
21+
22+1. Port will be auto forwarded to the host
docs/old_features.mdnew file+47
@@ -0,0 +1,47 @@
1+# Existing Features (Legacy Snapshot)
2+
3+This document summarizes the currently implemented features in the repo at the time of review.
4+
5+## Core Purpose
6+- Single-binary CLI to spin up a Linux VM on Apple Silicon macOS for sandboxing LLM agents.
7+- Uses Apple Virtualization framework with a lightweight Debian nocloud image.
8+
9+## VM Lifecycle
10+- Downloads a Debian base image on first run and verifies SHA-512 before use.
11+- Decompresses the base image and provisions it once, saving as a default template.
12+- Creates a per-project instance disk at `.vibe/instance.raw` on first run.
13+- Instance disks persist across runs until manually deleted.
14+
15+## Default Sharing and Mounts
16+- Automatically shares the current project directory into the VM.
17+- Shares common cache/config directories when present:
18+- `~/.m2`, `~/.cargo/registry`, `~/.codex`, `~/.claude`.
19+- Maintains a dedicated guest mise cache at `~/.cache/vibe/.guest-mise-cache` on the host.
20+- Supports disabling default mounts with `--no-default-mounts`.
21+- Supports explicit mounts via `--mount host:guest[:read-only|:read-write]`.
22+
23+## CLI Options and Automation
24+- `--cpus` and `--ram` to configure virtual CPU count and memory size.
25+- `--script` to upload and run a shell script inside the VM.
26+- `--send` to type a command into the VM console.
27+- `--expect` to wait for console output before continuing with script or send actions.
28+- Optional disk image argument to boot an existing raw disk directly.
29+
30+## Console and Login Experience
31+- Auto-login as root by waiting for login prompt.
32+- Mounts shared directories using virtiofs, then bind-mounts to target paths.
33+- Prints a startup MOTD showing host and guest mount mappings.
34+- Streams VM console to the terminal with raw mode for interactive use.
35+
36+## Security and Signing
37+- Checks for `com.apple.security.virtualization` entitlement on startup.
38+- Self-signs the binary with the required entitlement if missing, then re-execs.
39+
40+## Networking and Devices
41+- NAT networking enabled by default.
42+- Virtio block device for storage and virtio entropy device for randomness.
43+- Serial console plumbing between host and guest for I/O.
44+
45+## Platform Assumptions
46+- ARM-based macOS (Ventura or newer) is required.
47+- First run requires network access to fetch the base image.
readme.mdnew file+208
@@ -0,0 +1,208 @@
1+Vibe is a quick, zero-configuration way to spin up a Linux virtual machine on Mac to sandbox LLM agents:
2+
3+```
4+$ cd my-project
5+$ vibe
6+
7+░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░
8+░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
9+ ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
10+ ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓███████▓▒░░▒▓██████▓▒░
11+ ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
12+ ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
13+ ░▒▓██▓▒░ ░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░
14+
15+Host Guest Mode
16+---------------------------------------- ----------------------- ----------
17+/Users/dev/work/my-project /root/my-project read-write
18+/Users/dev/.cache/vibe/.guest-mise-cache /root/.local/share/mise read-write
19+/Users/dev/.m2 /root/.m2 read-write
20+/Users/dev/.cargo/registry /root/.cargo/registry read-write
21+/Users/dev/.codex /root/.codex read-write
22+/Users/dev/.claude /root/.claude read-write
23+
24+root@vibe:~/my-project#
25+```
26+
27+On my M1 MacBook Air it takes ~10 seconds to boot.
28+
29+
30+Dependencies:
31+
32+- An ARM-based Mac running MacOS 13 (Ventura) or higher.
33+- A network connection is required on the first run to download and configure the Debian Linux base image.
34+- That's it!
35+
36+
37+## Why use Vibe?
38+
39+- LLM agents are more fun to use with `--yolo`, since they're not always interrupting you to approve their commands.
40+- Sandboxing the agent in a VM lets it install/remove whatever tools its lil' transformer heart desires, *without* wrecking your actual machine.
41+- You control what the agent (and thus the upstream LLM provider) can actually see, by controlling exactly what's shared into the VM sandbox.
42+ (This project was inspired by me running `codex` *without* `--yolo` and seeing it reading files outside of the directory I started it in --- not cool, bro.)
43+
44+I'm using virtual machines rather than containers because:
45+
46+- Virtualization is more secure against malicious escapes than containers or the MacOS sandbox framework.
47+- Containers on MacOS require spinning up a virtual machine anyway.
48+
49+Finally, as a matter of taste and style:
50+
51+- The binary is < 1 MB.
52+- I wrote the entire README myself, 100% with my human brain.
53+- The entire implementation is in one ~1200 line Rust file.
54+- The only Rust dependencies are the [Objc2](https://github.com/madsmtm/objc2) interop crates and the [lexopt](https://github.com/blyxxyz/lexopt) argument parser.
55+- There are no emoji anywhere in this repository.
56+
57+
58+## Install
59+
60+Vibe is a single binary built with Rust.
61+
62+Download [the latest binary built by GitHub actions](https://github.com/lynaghk/vibe/releases/tag/latest) and put it somewhere on your `$PATH`:
63+
64+ curl -LO https://github.com/lynaghk/vibe/releases/download/latest/vibe-macos-arm64.zip
65+ unzip vibe-macos-arm64.zip
66+ mkdir -p ~/.local/bin
67+ mv vibe ~/.local/bin
68+ export PATH="$HOME/.local/bin:$PATH"
69+
70+If you use [mise-en-place](https://mise.jdx.dev/):
71+
72+ mise use github:lynaghk/vibe@latest
73+
74+I'm not making formal releases or keeping a change log.
75+I recommend reading the commit history and pinning to a specific version.
76+
77+You can also install via `cargo`:
78+
79+ cargo install --locked --git https://github.com/lynaghk/vibe.git
80+
81+If you don't have `cargo`, you need to install Rust:
82+
83+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
84+
85+
86+## Using Vibe
87+
88+
89+```
90+vibe [OPTIONS] [disk-image.raw]
91+
92+Options
93+
94+ --help Print this help message.
95+ --version Print the version (commit SHA).
96+ --no-default-mounts Disable all default mounts.
97+ --mount host-path:guest-path[:read-only | :read-write] Mount `host-path` inside VM at `guest-path`.
98+ Defaults to read-write.
99+ Errors if host-path does not exist.
100+ --cpus <count> Number of virtual CPUs (default 2).
101+ --ram <megabytes> RAM size in megabytes (default 2048).
102+ --script <path/to/script.sh> Run script in VM.
103+ --send <some-command> Type `some-command` followed by newline into the VM.
104+ --expect <string> [timeout-seconds] Wait for `string` to appear in console output before executing next `--script` or `--send`.
105+ If `string` does not appear within timeout (default 30 seconds), shutdown VM with error.
106+```
107+
108+Invoking vibe without a disk image:
109+
110+- shares the current directory with the VM
111+- shares package manager cache directories with the VM, so that packages are not re-downloaded
112+- shares the `~/.codex` directory with the VM, so you can use OpenAI's [codex](https://openai.com/codex/)
113+- shares the `~/.claude` directory with the VM, so you can use Anthropic's [claude](https://claude.com/product/claude-code)
114+
115+The first time you run `vibe`, a Debian Linux image is downloaded to `~/.cache/vibe/`, configured with basic tools like gcc, [mise-en-place](https://mise.jdx.dev/), ripgrep, rust, etc., and saved as `default.raw`.
116+(See [provision.sh](/src/provision.sh) for details.)
117+
118+Then when you run `vibe` in a project directory, it copies this default image to `.vibe/instance.raw`, boots it up, and attaches your terminal to this VM.
119+
120+When you `exit` this shell, the VM is shutdown.
121+The disk state persists until you delete it.
122+There is no centralized registry of VMs --- if you want to delete a VM, just delete its disk image file.
123+
124+## Other notes
125+
126+- Apple Filesystem is copy-on-write, so instance images only use disk space when they diverge from the default image.
127+ You can use `du -h` to see how much space is actually consumed:
128+
129+ $ /bin/ls -lah .vibe/instance.raw
130+ -rw-r--r-- 1 dev staff 10G Jan 25 20:41 .vibe/instance.raw
131+
132+ $ du -h .vibe/instance.raw
133+ 2.3G .vibe/instance.raw
134+
135+- MacOS only lets binaries signed with the `com.apple.security.virtualization` entitlement run virtual machines, so `vibe` checks itself on startup and, if necessary, signs itself using `codesign`. SeCuRiTy!
136+
137+- Debian "nocloud" is used as a base image because it boots directly to a root prompt.
138+ The other images use [cloudinit](https://cloudinit.readthedocs.io/en/latest/), which I found much more complex:
139+ - Network requests are made during the boot process, and if you're offline they take several *minutes* to timeout before the login prompt is reached (thanks, `systemd-networkd-wait-online.service`).
140+ - Subsequent boots are much slower (at least, I couldn't easily figure out how to remove the associated cloud init machinery).
141+
142+
143+## Alternatives
144+
145+Here's what I tried before writing this solution:
146+
147+- [Sandboxtron](https://github.com/lynaghk/sandboxtron/) - My own little wrapper around Mac's `sandbox-exec`.
148+Turns out both Claude Code and Codex rely on this as well, and MacOS doesn't allow creating a sandbox from within a sandbox.
149+I considered writing my own sandboxing rules and running the agents `--yolo`, but didn't like the risk of configuration typos and/or Mac sandbox escapes (there are a lot --- I'm not an expert, but from [this HN discussion](https://news.ycombinator.com/item?id=42084588) I figured virtualization would be safer).
150+
151+- [Lima](https://github.com/lima-vm/lima/), quick Linux VMs on Mac. I wanted to like this, ran into too many issues in first 30 minutes to trust it:
152+ - The recommended Debian image took 8 seconds to get to a login prompt, even after the VM was already running.
153+ - The CLI flags *mutate hidden state*. E.g., If you `limactl start --mount foo` and then later `limactl start --mount bar`, both `foo` and `bar` will be mounted.
154+ - Some capabilities are only available via yaml. E.g., the `--mount` CLI flag always mounts at the same path in the guest. If you want to mount at a different path, you have to do that via YAML.
155+ - There are many layers of inheritance/defaults, so even if you do write YAML, you can't see the full configuration.
156+
157+- [Vagrant](https://developer.hashicorp.com/vagrant/) - I fondly remember using this back in the early 2010's, but based on this [2025 Reddit discussion](https://www.reddit.com/r/devops/comments/1axws75/vagrant_doesnt_support_mac_m1/) it seemed like running it on an ARM-based Mac was A Project and so I figured it'd be easier to roll my own thing.
158+
159+- [Tart](https://tart.run/) - I found this via some positive HN comments, but unfortunately wasn't able to run the release binary from their GitHub because it's not signed.
160+They apparently hack around that when installing with homebrew, but I don't use homebrew either.
161+I tried cloning the repo and compiling myself, but the build failed with lots of language syntax errors despite the repo SHA is the same as one of their releases.
162+I assume this is a Swift problem and not Tart's, since this sort of mess happens most times when I try to build Swift. `¯\_(ツ)_/¯`
163+
164+- [OrbStack](https://orbstack.dev/) - This looked nice, but seems mostly geared towards container stuff.
165+It runs a single VM, and I couldn't figure out how to have this VM run *without* my entire disk mounted inside of it.
166+I didn't want to run agents via containers, since containers aren't security boundaries.
167+
168+- [Apple Container Framework](https://github.com/apple/container) - This looks technically promising, as it runs every container within a lightweight VM.
169+Unfortunately it requires MacOS 26 Tahoe, which wrecks [window resizing](https://news.ycombinator.com/item?id=46579864), adds [useless icons everywhere](https://news.ycombinator.com/item?id=46497712), and otherwise seems to be a mess.
170+Sorry excellent Apple programmers and hardware designers, I hope your management can reign in the haute couture folks before we all have to switch to Linux for professional computing.
171+
172+- [QEMU](https://wiki.qemu.org/) - The first prototype of this app was [a single bash script](https://github.com/lynaghk/vibe/blob/1c82fd3b9fabf93abba2680fc856458e97a105cd/qemu.sh) wrapping `qemu`. This worked swimmingly, except for host/guest directory sharing, which ended up being a show-stopper. This is because QEMU doesn't support [virtiofs](https://virtio-fs.gitlab.io/) on Mac hosts, it only supports "9p", which is way slower --- e.g., `mise use node@latest` takes > 10 minutes on 9p and 5 seconds on virtiofs.
173+
174+
175+## Roadmap / Collaboration
176+
177+I wrote this software for myself, and I'm open to pull requests and otherwise collaborating on features that I'd personally use:
178+
179+- resizing disk images
180+- forwarding ports from the host to a guest
181+- running `vibe` against a disk image that's already running should connect to the already-running VM
182+ - the VM shouldn't shutdown until all host terminals have logged out
183+- if not the above, at least a check and throw a nice error message when you try to start a VM that's already running
184+- a way to make faster-booting even more minimal Linux virtual machines
185+ - this should be bootstrappable on Mac; i.e., if the only way to make a small Linux image is with Linux-only tools, the entire process should still be runnable on MacOS via intermediate VMs
186+- propagate an exit code from within VM to the `vibe` command
187+- don't propagate user typing until all provided `--expect` and `--send` actions have completed
188+- CPU core / memory / networking configuration, possibly via flags or via extended attributes on the disk image file
189+- a `--plan` flag which pretty-prints a CLI invocation with all of the default arguments shown
190+ - to keep ourselves honest, we should use the same codepath for the actual execution (maybe we can `exec` into the generated command?)
191+ - Being fully "explicit" is tricky due to flag interactions.
192+ E.g., the friendly `--mount` would need to be decomposed into two flags: One that exposes the host directory in the guest's staging area at `/mnt/shared/` and another flag `--send 'mount --bind ...'`to bind this to the desired guest location.
193+
194+I'm not sure about (but open to discussing proposals via GitHub issues):
195+
196+- running VMs in the background
197+- supporting Linux hosts
198+- supporting guests beyond Debian Linux
199+- using SSH as a login mechanism; this would eliminate the current stdin/stdout-to-console plumbing (yay!) but require additional setup/configuration (boo!)
200+- making Claude Code work seamlessly
201+ - I tried the native installer but it sometimes fails during setup (see 6352d13), so I switched back to NPM via mise which is more reliable.
202+ - The `~/.claude` folder is shared in the VM by default, by apparently the `~/.claude.json` file is also required for auth credentials and session history.
203+ I'm not sure the best way to share a file between host and VM (virtioFS only works with folders).
204+ Also: Wild to me that Anthropic puts both a file and a folder in your home directory --- how rude!
205+
206+I'm not interested in:
207+
208+- anything related to Docker / containers / Kubernetes / distributed systems
src/entitlements.plistnew file+8
@@ -0,0 +1,8 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+<plist version="1.0">
4+<dict>
5+ <key>com.apple.security.virtualization</key>
6+ <true/>
7+</dict>
8+</plist>
src/main.rsnew file+1216
@@ -0,0 +1,1216 @@
1+use std::{
2+ env,
3+ ffi::OsString,
4+ fs,
5+ io::{self, Write},
6+ os::{
7+ fd::RawFd,
8+ unix::{
9+ io::{AsRawFd, IntoRawFd, OwnedFd},
10+ net::UnixStream,
11+ process::CommandExt,
12+ },
13+ },
14+ path::{Path, PathBuf},
15+ process::{Command, Stdio},
16+ sync::{
17+ Arc, Condvar, Mutex,
18+ mpsc::{self, Receiver, Sender},
19+ },
20+ thread,
21+ time::{Duration, Instant},
22+};
23+
24+use block2::RcBlock;
25+use dispatch2::DispatchQueue;
26+use lexopt::prelude::*;
27+use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
28+use objc2_foundation::*;
29+use objc2_virtualization::*;
30+
31+const DEBIAN_COMPRESSED_DISK_URL: &str = "https://cloud.debian.org/images/cloud/trixie/20260112-2355/debian-13-nocloud-arm64-20260112-2355.tar.xz";
32+const DEBIAN_COMPRESSED_SHA: &str = "6ab9be9e6834adc975268367f2f0235251671184345c34ee13031749fdfbf66fe4c3aafd949a2d98550426090e9ac645e79009c51eb0eefc984c15786570bb38";
33+const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
34+const SHARED_DIRECTORIES_TAG: &str = "shared";
35+
36+const BYTES_PER_MB: u64 = 1024 * 1024;
37+const DEFAULT_CPU_COUNT: usize = 2;
38+const DEFAULT_RAM_MB: u64 = 2048;
39+const DEFAULT_RAM_BYTES: u64 = DEFAULT_RAM_MB * BYTES_PER_MB;
40+const START_TIMEOUT: Duration = Duration::from_secs(60);
41+const DEFAULT_EXPECT_TIMEOUT: Duration = Duration::from_secs(30);
42+const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
43+const PROVISION_SCRIPT: &str = include_str!("provision.sh");
44+
45+#[derive(Clone)]
46+enum LoginAction {
47+ Expect { text: String, timeout: Duration },
48+ Send(String),
49+ Script { path: PathBuf, index: usize },
50+}
51+use LoginAction::*;
52+
53+#[derive(Clone)]
54+struct DirectoryShare {
55+ host: PathBuf,
56+ guest: PathBuf,
57+ read_only: bool,
58+}
59+
60+impl DirectoryShare {
61+ fn new(
62+ host: PathBuf,
63+ mut guest: PathBuf,
64+ read_only: bool,
65+ ) -> Result<Self, Box<dyn std::error::Error>> {
66+ if !host.exists() {
67+ return Err(format!("Host path does not exist: {}", host.display()).into());
68+ }
69+ if !guest.is_absolute() {
70+ guest = PathBuf::from("/root").join(guest);
71+ }
72+ Ok(Self {
73+ host,
74+ guest,
75+ read_only,
76+ })
77+ }
78+
79+ fn from_mount_spec(spec: &str) -> Result<Self, Box<dyn std::error::Error>> {
80+ let parts: Vec<&str> = spec.split(':').collect();
81+ if parts.len() < 2 || parts.len() > 3 {
82+ return Err(format!("Invalid mount spec: {spec}").into());
83+ }
84+ let host = PathBuf::from(parts[0]);
85+ let guest = PathBuf::from(parts[1]);
86+ let read_only = if parts.len() == 3 {
87+ match parts[2] {
88+ "read-only" => true,
89+ "read-write" => false,
90+ _ => {
91+ return Err(format!(
92+ "Invalid mount mode '{}'; expected read-only or read-write",
93+ parts[2]
94+ )
95+ .into());
96+ }
97+ }
98+ } else {
99+ false
100+ };
101+ DirectoryShare::new(host, guest, read_only)
102+ }
103+
104+ fn tag(&self) -> String {
105+ let path_str = self.host.to_string_lossy();
106+ let hash = path_str
107+ .bytes()
108+ .fold(5381u64, |h, b| h.wrapping_mul(33).wrapping_add(b as u64));
109+ let base_name = self
110+ .host
111+ .file_name()
112+ .map(|s| s.to_string_lossy())
113+ .unwrap_or("share".into());
114+ format!("{}_{:016x}", base_name, hash)
115+ }
116+}
117+
118+fn main() -> Result<(), Box<dyn std::error::Error>> {
119+ let args = parse_cli()?;
120+
121+ if args.version {
122+ println!("Vibe");
123+ println!("https://github.com/lynaghk/vibe/");
124+ println!("Git SHA: {}", env!("GIT_SHA"));
125+ std::process::exit(0);
126+ }
127+
128+ if args.help {
129+ println!(
130+ "Vibe is a quick way to spin up a Linux virtual machine on Mac to sandbox LLM agents.
131+
132+vibe [OPTIONS] [disk-image.raw]
133+
134+Options
135+
136+ --help Print this help message.
137+ --version Print the version (commit SHA).
138+ --no-default-mounts Disable all default mounts.
139+ --mount host-path:guest-path[:read-only | :read-write] Mount `host-path` inside VM at `guest-path`.
140+ Defaults to read-write.
141+ Errors if host-path does not exist.
142+ --cpus <count> Number of virtual CPUs (default {DEFAULT_CPU_COUNT}).
143+ --ram <megabytes> RAM size in megabytes (default {DEFAULT_RAM_MB}).
144+ --script <path/to/script.sh> Run script in VM.
145+ --send <some-command> Type `some-command` followed by newline into the VM.
146+ --expect <string> [timeout-seconds] Wait for `string` to appear in console output before executing next `--script` or `--send`.
147+ If `string` does not appear within timeout (default 30 seconds), shutdown VM with error.
148+"
149+ );
150+ std::process::exit(0);
151+ }
152+
153+ ensure_signed();
154+
155+ let project_root = env::current_dir()?;
156+ let project_name = project_root
157+ .file_name()
158+ .ok_or("Project directory has no name")?
159+ .to_string_lossy()
160+ .into_owned();
161+
162+ let home = env::var("HOME").map(PathBuf::from)?;
163+ let cache_home = env::var("XDG_CACHE_HOME")
164+ .map(PathBuf::from)
165+ .unwrap_or_else(|_| home.join(".cache"));
166+ let cache_dir = cache_home.join("vibe");
167+ let guest_mise_cache = cache_dir.join(".guest-mise-cache");
168+
169+ let instance_dir = project_root.join(".vibe");
170+
171+ let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
172+ let base_compressed = cache_dir.join(basename_compressed);
173+ let base_raw = cache_dir.join(format!(
174+ "{}.raw",
175+ basename_compressed.trim_end_matches(".tar.xz")
176+ ));
177+
178+ let default_raw = cache_dir.join("default.raw");
179+ let instance_raw = instance_dir.join("instance.raw");
180+
181+ // Prepare system-wide directories
182+ fs::create_dir_all(&cache_dir)?;
183+ fs::create_dir_all(&guest_mise_cache)?;
184+
185+ let mise_directory_share =
186+ DirectoryShare::new(guest_mise_cache, "/root/.local/share/mise".into(), false)?;
187+
188+ let disk_path = if let Some(path) = args.disk {
189+ if !path.exists() {
190+ return Err(format!("Disk image does not exist: {}", path.display()).into());
191+ }
192+ path
193+ } else {
194+ ensure_default_image(
195+ &base_raw,
196+ &base_compressed,
197+ &default_raw,
198+ std::slice::from_ref(&mise_directory_share),
199+ )?;
200+ ensure_instance_disk(&instance_raw, &default_raw)?;
201+
202+ instance_raw
203+ };
204+
205+ let mut login_actions = Vec::new();
206+ let mut directory_shares = Vec::new();
207+
208+ if !args.no_default_mounts {
209+ login_actions.push(Send(format!("cd {project_name}")));
210+
211+ // discourage read/write of .git folder from within the VM. note that this isn't secure, since the VM runs as root and could unmount this.
212+ // I couldn't find an alternative way to do this --- the MacOS sandbox doesn't apply to the Apple Virtualization system
213+ if project_root.join(".git").exists() {
214+ login_actions.push(Send(r"mount -t tmpfs tmpfs .git/".into()));
215+ }
216+
217+ directory_shares.push(
218+ DirectoryShare::new(
219+ project_root,
220+ PathBuf::from("/root/").join(project_name),
221+ false,
222+ )
223+ .expect("Project directory must exist"),
224+ );
225+
226+ directory_shares.push(mise_directory_share);
227+
228+ // Add default shares, if they exist
229+ for share in [
230+ DirectoryShare::new(home.join(".m2"), "/root/.m2".into(), false),
231+ DirectoryShare::new(
232+ home.join(".cargo/registry"),
233+ "/root/.cargo/registry".into(),
234+ false,
235+ ),
236+ DirectoryShare::new(home.join(".codex"), "/root/.codex".into(), false),
237+ DirectoryShare::new(home.join(".claude"), "/root/.claude".into(), false),
238+ ]
239+ .into_iter()
240+ .flatten()
241+ {
242+ directory_shares.push(share)
243+ }
244+ }
245+
246+ for spec in &args.mounts {
247+ directory_shares.push(DirectoryShare::from_mount_spec(spec)?);
248+ }
249+
250+ if let Some(motd_action) = motd_login_action(&directory_shares) {
251+ login_actions.push(motd_action);
252+ }
253+
254+ // Any user-provided login actions must come after our system ones
255+ login_actions.extend(args.login_actions);
256+
257+ run_vm(
258+ &disk_path,
259+ &login_actions,
260+ &directory_shares[..],
261+ args.cpu_count,
262+ args.ram_bytes,
263+ )
264+}
265+
266+struct CliArgs {
267+ disk: Option<PathBuf>,
268+ version: bool,
269+ help: bool,
270+ no_default_mounts: bool,
271+ mounts: Vec<String>,
272+ login_actions: Vec<LoginAction>,
273+ cpu_count: usize,
274+ ram_bytes: u64,
275+}
276+
277+fn parse_cli() -> Result<CliArgs, Box<dyn std::error::Error>> {
278+ fn os_to_string(value: OsString, flag: &str) -> Result<String, Box<dyn std::error::Error>> {
279+ value
280+ .into_string()
281+ .map_err(|_| format!("{flag} expects valid UTF-8").into())
282+ }
283+
284+ let mut parser = lexopt::Parser::from_env();
285+ let mut disk = None;
286+ let mut version = false;
287+ let mut help = false;
288+ let mut no_default_mounts = false;
289+ let mut mounts = Vec::new();
290+ let mut login_actions = Vec::new();
291+ let mut script_index = 0;
292+ let mut cpu_count = DEFAULT_CPU_COUNT;
293+ let mut ram_bytes = DEFAULT_RAM_BYTES;
294+
295+ while let Some(arg) = parser.next()? {
296+ match arg {
297+ Long("version") => version = true,
298+ Long("help") | Short('h') => help = true,
299+ Long("no-default-mounts") => no_default_mounts = true,
300+ Long("cpus") => {
301+ let value = os_to_string(parser.value()?, "--cpus")?.parse()?;
302+ if value == 0 {
303+ return Err("--cpus must be >= 1".into());
304+ }
305+ cpu_count = value;
306+ }
307+ Long("ram") => {
308+ let value: u64 = os_to_string(parser.value()?, "--ram")?.parse()?;
309+ if value == 0 {
310+ return Err("--ram must be >= 1".into());
311+ }
312+ ram_bytes = value * BYTES_PER_MB;
313+ }
314+ Long("mount") => {
315+ mounts.push(os_to_string(parser.value()?, "--mount")?);
316+ }
317+ Long("script") => {
318+ login_actions.push(Script {
319+ path: os_to_string(parser.value()?, "--script")?.into(),
320+ index: script_index,
321+ });
322+ script_index += 1;
323+ }
324+ Long("send") => {
325+ login_actions.push(Send(os_to_string(parser.value()?, "--send")?));
326+ }
327+ Long("expect") => {
328+ let text = os_to_string(parser.value()?, "--expect")?;
329+ let timeout = match parser.optional_value() {
330+ Some(value) => Duration::from_secs(os_to_string(value, "--expect")?.parse()?),
331+ None => DEFAULT_EXPECT_TIMEOUT,
332+ };
333+ login_actions.push(Expect { text, timeout });
334+ }
335+ Value(value) => {
336+ if disk.is_some() {
337+ return Err("Only one disk path may be provided".into());
338+ }
339+ disk = Some(PathBuf::from(value));
340+ }
341+ _ => return Err(arg.unexpected().into()),
342+ }
343+ }
344+
345+ Ok(CliArgs {
346+ disk,
347+ version,
348+ help,
349+ no_default_mounts,
350+ mounts,
351+ login_actions,
352+ cpu_count,
353+ ram_bytes,
354+ })
355+}
356+
357+fn script_command_from_path(
358+ path: &Path,
359+ index: usize,
360+) -> Result<String, Box<dyn std::error::Error>> {
361+ let script = fs::read_to_string(path)
362+ .map_err(|err| format!("Failed to read script {}: {err}", path.display()))?;
363+ let label = format!("{}_{}", index, path.file_name().unwrap().display());
364+ script_command_from_content(&label, &script)
365+}
366+
367+fn script_command_from_content(
368+ label: &str,
369+ script: &str,
370+) -> Result<String, Box<dyn std::error::Error>> {
371+ let marker = "VIBE_SCRIPT_EOF";
372+ let guest_dir = "/tmp/vibe-scripts";
373+ let guest_path = format!("{guest_dir}/{label}.sh");
374+ let command = format!(
375+ "mkdir -p {guest_dir}\ncat >{guest_path} <<'{marker}'\n{script}\n{marker}\nchmod +x {guest_path}\n{guest_path}"
376+ );
377+ if script.contains(marker) {
378+ return Err(
379+ format!("Script '{label}' contains marker '{marker}', cannot safely upload").into(),
380+ );
381+ }
382+ Ok(command)
383+}
384+
385+fn motd_login_action(directory_shares: &[DirectoryShare]) -> Option<LoginAction> {
386+ if directory_shares.is_empty() {
387+ return Some(Send("clear".into()));
388+ }
389+
390+ let host_header = "Host";
391+ let guest_header = "Guest";
392+ let mode_header = "Mode";
393+ let mut host_width = host_header.len();
394+ let mut guest_width = guest_header.len();
395+ let mut mode_width = mode_header.len();
396+ let mut rows = Vec::with_capacity(directory_shares.len());
397+
398+ for share in directory_shares {
399+ let host = share.host.to_string_lossy().into_owned();
400+ let guest = share.guest.to_string_lossy().into_owned();
401+ let mode = if share.read_only {
402+ "read-only"
403+ } else {
404+ "read-write"
405+ }
406+ .to_string();
407+ host_width = host_width.max(host.len());
408+ guest_width = guest_width.max(guest.len());
409+ mode_width = mode_width.max(mode.len());
410+ rows.push((host, guest, mode));
411+ }
412+
413+ let mut output = String::new();
414+ output.push_str(
415+ "
416+░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░
417+░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
418+ ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
419+ ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓███████▓▒░░▒▓██████▓▒░
420+ ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
421+ ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
422+ ░▒▓██▓▒░ ░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░
423+
424+",
425+ );
426+ output.push_str(&format!(
427+ "{host_header:<host_width$} {guest_header:<guest_width$} {mode_header}\n",
428+ host_width = host_width
429+ ));
430+ output.push_str(&format!(
431+ "{:-<host_width$} {:-<guest_width$} {:-<mode_width$}\n",
432+ "",
433+ "",
434+ "",
435+ host_width = host_width,
436+ guest_width = guest_width,
437+ mode_width = mode_width
438+ ));
439+
440+ for (host, guest, mode) in rows {
441+ output.push_str(&format!(
442+ "{host:<host_width$} {guest:<guest_width$} {mode}\n"
443+ ));
444+ }
445+
446+ let command = format!("clear && cat <<'VIBE_MOTD'\n{output}\nVIBE_MOTD");
447+ Some(Send(command))
448+}
449+
450+#[derive(PartialEq, Eq)]
451+enum WaitResult {
452+ Timeout,
453+ Found,
454+}
455+
456+pub enum VmInput {
457+ Bytes(Vec<u8>),
458+ Shutdown,
459+}
460+
461+enum VmOutput {
462+ LoginActionTimeout { action: String, timeout: Duration },
463+}
464+
465+#[derive(Default)]
466+pub struct OutputMonitor {
467+ buffer: Mutex<String>,
468+ condvar: Condvar,
469+}
470+
471+impl OutputMonitor {
472+ fn push(&self, bytes: &[u8]) {
473+ self.buffer
474+ .lock()
475+ .unwrap()
476+ .push_str(&String::from_utf8_lossy(bytes));
477+ self.condvar.notify_all();
478+ }
479+
480+ fn wait_for(&self, needle: &str, timeout: Duration) -> WaitResult {
481+ let (_unused, timeout_result) = self
482+ .condvar
483+ .wait_timeout_while(self.buffer.lock().unwrap(), timeout, |buf| {
484+ if let Some((_, remaining)) = buf.split_once(needle) {
485+ *buf = remaining.to_string();
486+ false
487+ } else {
488+ true
489+ }
490+ })
491+ .unwrap();
492+
493+ if timeout_result.timed_out() {
494+ WaitResult::Timeout
495+ } else {
496+ WaitResult::Found
497+ }
498+ }
499+}
500+
501+fn ensure_base_image(
502+ base_raw: &Path,
503+ base_compressed: &Path,
504+) -> Result<(), Box<dyn std::error::Error>> {
505+ if base_raw.exists() {
506+ return Ok(());
507+ }
508+
509+ if !base_compressed.exists()
510+ || std::fs::metadata(base_compressed).map(|m| m.len())? < DEBIAN_COMPRESSED_SIZE_BYTES
511+ {
512+ println!("Downloading base image...");
513+ let status = Command::new("curl")
514+ .args([
515+ "--continue-at",
516+ "-",
517+ "--compressed",
518+ "--location",
519+ "--fail",
520+ "-o",
521+ &base_compressed.to_string_lossy(),
522+ DEBIAN_COMPRESSED_DISK_URL,
523+ ])
524+ .status()?;
525+ if !status.success() {
526+ return Err("Failed to download base image".into());
527+ }
528+ }
529+
530+ // Check SHA
531+ {
532+ let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
533+
534+ let mut child = Command::new("/usr/bin/shasum")
535+ .args(["--algorithm", "512", "--check"])
536+ .stdin(Stdio::piped())
537+ .spawn()
538+ .expect("failed to spawn shasum");
539+
540+ child
541+ .stdin
542+ .take()
543+ .expect("failed to open stdin")
544+ .write_all(input.as_bytes())
545+ .expect("failed to write to stdin");
546+
547+ let status = child.wait().expect("failed to wait on child");
548+ if !status.success() {
549+ return Err(format!("SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}").into());
550+ }
551+ }
552+
553+ println!("Decompressing base image...");
554+ let status = Command::new("tar")
555+ .args(["-xOf", &base_compressed.to_string_lossy(), "disk.raw"])
556+ .stdout(std::fs::File::create(base_raw)?)
557+ .status()?;
558+
559+ if !status.success() {
560+ return Err("Failed to decompress base image".into());
561+ }
562+
563+ Ok(())
564+}
565+
566+fn ensure_default_image(
567+ base_raw: &Path,
568+ base_compressed: &Path,
569+ default_raw: &Path,
570+ directory_shares: &[DirectoryShare],
571+) -> Result<(), Box<dyn std::error::Error>> {
572+ if default_raw.exists() {
573+ return Ok(());
574+ }
575+
576+ ensure_base_image(base_raw, base_compressed)?;
577+
578+ println!("Configuring base image...");
579+ fs::copy(base_raw, default_raw)?;
580+
581+ let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?;
582+ run_vm(
583+ default_raw,
584+ &[Send(provision_command)],
585+ directory_shares,
586+ DEFAULT_CPU_COUNT,
587+ DEFAULT_RAM_BYTES,
588+ )?;
589+
590+ Ok(())
591+}
592+
593+fn ensure_instance_disk(
594+ instance_raw: &Path,
595+ template_raw: &Path,
596+) -> Result<(), Box<dyn std::error::Error>> {
597+ if instance_raw.exists() {
598+ return Ok(());
599+ }
600+
601+ println!("Creating instance disk from {}...", template_raw.display());
602+ std::fs::create_dir_all(instance_raw.parent().unwrap())?;
603+ fs::copy(template_raw, instance_raw)?;
604+ Ok(())
605+}
606+
607+pub struct IoContext {
608+ pub input_tx: Sender<VmInput>,
609+ wakeup_write: OwnedFd,
610+ stdin_thread: thread::JoinHandle<()>,
611+ mux_thread: thread::JoinHandle<()>,
612+ stdout_thread: thread::JoinHandle<()>,
613+}
614+
615+pub fn create_pipe() -> (OwnedFd, OwnedFd) {
616+ let (read_stream, write_stream) = UnixStream::pair().expect("Failed to create socket pair");
617+ (read_stream.into(), write_stream.into())
618+}
619+
620+pub fn spawn_vm_io(
621+ output_monitor: Arc<OutputMonitor>,
622+ vm_output_fd: OwnedFd,
623+ vm_input_fd: OwnedFd,
624+) -> IoContext {
625+ let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
626+
627+ // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
628+ let raw_guard = Arc::new(Mutex::new(None));
629+
630+ let (wakeup_read, wakeup_write) = create_pipe();
631+
632+ enum PollResult<'a> {
633+ Ready(&'a [u8]),
634+ Spurious,
635+ Shutdown,
636+ Error,
637+ }
638+
639+ fn poll_with_wakeup<'a>(main_fd: RawFd, wakeup_fd: RawFd, buf: &'a mut [u8]) -> PollResult<'a> {
640+ let mut fds = [
641+ libc::pollfd {
642+ fd: main_fd,
643+ events: libc::POLLIN,
644+ revents: 0,
645+ },
646+ libc::pollfd {
647+ fd: wakeup_fd,
648+ events: libc::POLLIN,
649+ revents: 0,
650+ },
651+ ];
652+
653+ let ret = unsafe { libc::poll(fds.as_mut_ptr(), 2, -1) };
654+ if ret <= 0 || fds[1].revents & libc::POLLIN != 0 {
655+ PollResult::Shutdown
656+ } else if fds[0].revents & libc::POLLIN != 0 {
657+ let n = unsafe { libc::read(main_fd, buf.as_mut_ptr() as *mut _, buf.len()) };
658+ if n < 0 {
659+ PollResult::Error
660+ } else if n == 0 {
661+ PollResult::Shutdown
662+ } else {
663+ PollResult::Ready(&buf[..(n as usize)])
664+ }
665+ } else {
666+ PollResult::Spurious
667+ }
668+ }
669+
670+ // Copies from stdin to the VM; also polls wakeup_read to exit the thread when it's time to shutdown.
671+ let stdin_thread = thread::spawn({
672+ let input_tx = input_tx.clone();
673+ let raw_guard = raw_guard.clone();
674+ let wakeup_read = wakeup_read.try_clone().unwrap();
675+
676+ move || {
677+ let mut buf = [0u8; 1024];
678+ loop {
679+ match poll_with_wakeup(libc::STDIN_FILENO, wakeup_read.as_raw_fd(), &mut buf) {
680+ PollResult::Shutdown | PollResult::Error => break,
681+ PollResult::Spurious => continue,
682+ PollResult::Ready(bytes) => {
683+ if raw_guard.lock().unwrap().is_none() {
684+ continue;
685+ }
686+ if input_tx.send(VmInput::Bytes(bytes.to_vec())).is_err() {
687+ break;
688+ }
689+ }
690+ }
691+ }
692+ }
693+ });
694+
695+ // Copies VM output to stdout; also polls wakeup_read to exit the thread when it's time to shutdown.
696+ let stdout_thread = thread::spawn({
697+ let raw_guard = raw_guard.clone();
698+ let wakeup_read = wakeup_read.try_clone().unwrap();
699+
700+ move || {
701+ let mut stdout = std::io::stdout().lock();
702+ let mut buf = [0u8; 1024];
703+ loop {
704+ match poll_with_wakeup(vm_output_fd.as_raw_fd(), wakeup_read.as_raw_fd(), &mut buf)
705+ {
706+ PollResult::Shutdown | PollResult::Error => break,
707+ PollResult::Spurious => continue,
708+ PollResult::Ready(bytes) => {
709+ // enable raw mode, if we haven't already
710+ if raw_guard.lock().unwrap().is_none()
711+ && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
712+ {
713+ *raw_guard.lock().unwrap() = Some(guard);
714+ }
715+
716+ if stdout.write_all(bytes).is_err() {
717+ break;
718+ }
719+ let _ = stdout.flush();
720+ output_monitor.push(bytes);
721+ }
722+ }
723+ }
724+ }
725+ });
726+
727+ // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
728+ let mux_thread = thread::spawn(move || {
729+ let mut vm_writer = std::fs::File::from(vm_input_fd);
730+ loop {
731+ match input_rx.recv() {
732+ Ok(VmInput::Bytes(data)) => {
733+ if vm_writer.write_all(&data).is_err() {
734+ break;
735+ }
736+ }
737+ Ok(VmInput::Shutdown) => break,
738+ Err(_) => break,
739+ }
740+ }
741+ });
742+
743+ IoContext {
744+ input_tx,
745+ wakeup_write,
746+ stdin_thread,
747+ mux_thread,
748+ stdout_thread,
749+ }
750+}
751+
752+impl IoContext {
753+ pub fn shutdown(self) {
754+ let _ = self.input_tx.send(VmInput::Shutdown);
755+ unsafe { libc::write(self.wakeup_write.as_raw_fd(), b"x".as_ptr() as *const _, 1) };
756+ let _ = self.stdin_thread.join();
757+ let _ = self.stdout_thread.join();
758+ let _ = self.mux_thread.join();
759+ }
760+}
761+
762+fn create_vm_configuration(
763+ disk_path: &Path,
764+ directory_shares: &[DirectoryShare],
765+ vm_reads_from_fd: OwnedFd,
766+ vm_writes_to_fd: OwnedFd,
767+ cpu_count: usize,
768+ ram_bytes: u64,
769+) -> Result<Retained<VZVirtualMachineConfiguration>, Box<dyn std::error::Error>> {
770+ unsafe {
771+ let platform =
772+ VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
773+
774+ let boot_loader = VZEFIBootLoader::init(VZEFIBootLoader::alloc());
775+ let variable_store = load_efi_variable_store()?;
776+ boot_loader.setVariableStore(Some(&variable_store));
777+
778+ let config = VZVirtualMachineConfiguration::new();
779+ config.setPlatform(&platform);
780+ config.setBootLoader(Some(&boot_loader));
781+ config.setCPUCount(cpu_count as NSUInteger);
782+ config.setMemorySize(ram_bytes);
783+
784+ config.setNetworkDevices(&NSArray::from_retained_slice(&[{
785+ let network_device = VZVirtioNetworkDeviceConfiguration::new();
786+ network_device.setAttachment(Some(&VZNATNetworkDeviceAttachment::new()));
787+ Retained::into_super(network_device)
788+ }]));
789+
790+ config.setEntropyDevices(&NSArray::from_retained_slice(&[Retained::into_super(
791+ VZVirtioEntropyDeviceConfiguration::new(),
792+ )]));
793+
794+ ////////////////////////////
795+ // Disks
796+ {
797+ let disk_attachment = VZDiskImageStorageDeviceAttachment::initWithURL_readOnly_cachingMode_synchronizationMode_error(
798+ VZDiskImageStorageDeviceAttachment::alloc(),
799+ &nsurl_from_path(disk_path).unwrap(),
800+ false,
801+ VZDiskImageCachingMode::Automatic,
802+ VZDiskImageSynchronizationMode::Full,
803+ ).unwrap();
804+
805+ let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
806+ VZVirtioBlockDeviceConfiguration::alloc(),
807+ &disk_attachment,
808+ );
809+
810+ let storage_devices: Retained<NSArray<_>> =
811+ NSArray::from_retained_slice(&[Retained::into_super(disk_device)]);
812+
813+ config.setStorageDevices(&storage_devices);
814+ };
815+
816+ ////////////////////////////
817+ // Directory shares
818+
819+ if !directory_shares.is_empty() {
820+ let directories: Retained<NSMutableDictionary<NSString, VZSharedDirectory>> =
821+ NSMutableDictionary::new();
822+
823+ for share in directory_shares.iter() {
824+ assert!(
825+ share.host.is_dir(),
826+ "path does not exist or is not a directory: {:?}",
827+ share.host
828+ );
829+
830+ let url = nsurl_from_path(&share.host)?;
831+ let shared_directory = VZSharedDirectory::initWithURL_readOnly(
832+ VZSharedDirectory::alloc(),
833+ &url,
834+ share.read_only,
835+ );
836+
837+ let key = NSString::from_str(&share.tag());
838+ directories.setObject_forKey(&*shared_directory, ProtocolObject::from_ref(&*key));
839+ }
840+
841+ let multi_share = VZMultipleDirectoryShare::initWithDirectories(
842+ VZMultipleDirectoryShare::alloc(),
843+ &directories,
844+ );
845+
846+ let device = VZVirtioFileSystemDeviceConfiguration::initWithTag(
847+ VZVirtioFileSystemDeviceConfiguration::alloc(),
848+ &NSString::from_str(SHARED_DIRECTORIES_TAG),
849+ );
850+ device.setShare(Some(&multi_share));
851+
852+ let share_devices = NSArray::from_retained_slice(&[device.into_super()]);
853+ config.setDirectorySharingDevices(&share_devices);
854+ }
855+
856+ ////////////////////////////
857+ // Serial port
858+ {
859+ let ns_read_handle = NSFileHandle::initWithFileDescriptor_closeOnDealloc(
860+ NSFileHandle::alloc(),
861+ vm_reads_from_fd.into_raw_fd(),
862+ true,
863+ );
864+
865+ let ns_write_handle = NSFileHandle::initWithFileDescriptor_closeOnDealloc(
866+ NSFileHandle::alloc(),
867+ vm_writes_to_fd.into_raw_fd(),
868+ true,
869+ );
870+
871+ let serial_attach =
872+ VZFileHandleSerialPortAttachment::initWithFileHandleForReading_fileHandleForWriting(
873+ VZFileHandleSerialPortAttachment::alloc(),
874+ Some(&ns_read_handle),
875+ Some(&ns_write_handle),
876+ );
877+ let serial_port = VZVirtioConsoleDeviceSerialPortConfiguration::new();
878+ serial_port.setAttachment(Some(&serial_attach));
879+
880+ let serial_ports: Retained<NSArray<_>> =
881+ NSArray::from_retained_slice(&[Retained::into_super(serial_port)]);
882+
883+ config.setSerialPorts(&serial_ports);
884+ }
885+
886+ ////////////////////////////
887+ // Validate
888+ config.validateWithError().map_err(|e| {
889+ io::Error::other(format!(
890+ "Invalid VM configuration: {:?}",
891+ e.localizedDescription()
892+ ))
893+ })?;
894+
895+ Ok(config)
896+ }
897+}
898+
899+fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std::error::Error>> {
900+ unsafe {
901+ let temp_dir = std::env::temp_dir();
902+ let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
903+ let url = nsurl_from_path(&temp_path)?;
904+ let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
905+ let store = VZEFIVariableStore::initCreatingVariableStoreAtURL_options_error(
906+ VZEFIVariableStore::alloc(),
907+ &url,
908+ options,
909+ )?;
910+ Ok(store)
911+ }
912+}
913+
914+fn spawn_login_actions_thread(
915+ login_actions: Vec<LoginAction>,
916+ output_monitor: Arc<OutputMonitor>,
917+ input_tx: mpsc::Sender<VmInput>,
918+ vm_output_tx: mpsc::Sender<VmOutput>,
919+) -> thread::JoinHandle<()> {
920+ thread::spawn(move || {
921+ for a in login_actions {
922+ match a {
923+ Expect { text, timeout } => {
924+ if WaitResult::Timeout == output_monitor.wait_for(&text, timeout) {
925+ let _ = vm_output_tx.send(VmOutput::LoginActionTimeout {
926+ action: format!("expect '{}'", text),
927+ timeout,
928+ });
929+ return;
930+ }
931+ }
932+ Send(mut text) => {
933+ text.push('\n'); // Type the newline so the command is actually submitted.
934+ input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
935+ }
936+ Script { path, index } => {
937+ let command = match script_command_from_path(&path, index) {
938+ Ok(command) => command,
939+ Err(err) => {
940+ eprintln!("{err}");
941+ return;
942+ }
943+ };
944+ let mut text = command;
945+ text.push('\n');
946+ input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
947+ }
948+ }
949+ }
950+ })
951+}
952+
953+fn run_vm(
954+ disk_path: &Path,
955+ login_actions: &[LoginAction],
956+ directory_shares: &[DirectoryShare],
957+ cpu_count: usize,
958+ ram_bytes: u64,
959+) -> Result<(), Box<dyn std::error::Error>> {
960+ let (vm_reads_from, we_write_to) = create_pipe();
961+ let (we_read_from, vm_writes_to) = create_pipe();
962+
963+ let config = create_vm_configuration(
964+ disk_path,
965+ directory_shares,
966+ vm_reads_from,
967+ vm_writes_to,
968+ cpu_count,
969+ ram_bytes,
970+ )?;
971+
972+ let queue = DispatchQueue::main();
973+
974+ let vm = unsafe {
975+ VZVirtualMachine::initWithConfiguration_queue(VZVirtualMachine::alloc(), &config, queue)
976+ };
977+
978+ let (tx, rx) = mpsc::channel::<Result<(), String>>();
979+ let completion_handler = RcBlock::new(move |error: *mut NSError| {
980+ if error.is_null() {
981+ let _ = tx.send(Ok(()));
982+ } else {
983+ let err = unsafe { &*error };
984+ let _ = tx.send(Err(format!("{:?}", err.localizedDescription())));
985+ }
986+ });
987+
988+ unsafe {
989+ vm.startWithCompletionHandler(&completion_handler);
990+ }
991+
992+ let start_deadline = Instant::now() + START_TIMEOUT;
993+ while Instant::now() < start_deadline {
994+ unsafe {
995+ NSRunLoop::mainRunLoop().runMode_beforeDate(
996+ NSDefaultRunLoopMode,
997+ &NSDate::dateWithTimeIntervalSinceNow(0.1),
998+ )
999+ };
1000+
1001+ match rx.try_recv() {
1002+ Ok(result) => {
1003+ result.map_err(|e| format!("Failed to start VM: {}", e))?;
1004+ break;
1005+ }
1006+ Err(mpsc::TryRecvError::Empty) => continue,
1007+ Err(mpsc::TryRecvError::Disconnected) => {
1008+ return Err("VM start channel disconnected".into());
1009+ }
1010+ }
1011+ }
1012+
1013+ if Instant::now() >= start_deadline {
1014+ return Err("Timed out waiting for VM to start".into());
1015+ }
1016+
1017+ println!("VM booting...");
1018+
1019+ let output_monitor = Arc::new(OutputMonitor::default());
1020+ let io_ctx = spawn_vm_io(output_monitor.clone(), we_read_from, we_write_to);
1021+
1022+ let mut all_login_actions = vec![
1023+ Expect {
1024+ text: "login: ".to_string(),
1025+ timeout: LOGIN_EXPECT_TIMEOUT,
1026+ },
1027+ Send("root".to_string()),
1028+ Expect {
1029+ text: "~#".to_string(),
1030+ timeout: LOGIN_EXPECT_TIMEOUT,
1031+ },
1032+ ];
1033+
1034+ if !directory_shares.is_empty() {
1035+ all_login_actions.push(Send("mkdir -p /mnt/shared".into()));
1036+ all_login_actions.push(Send(format!(
1037+ "mount -t virtiofs {} /mnt/shared",
1038+ SHARED_DIRECTORIES_TAG
1039+ )));
1040+
1041+ for share in directory_shares {
1042+ let staging = format!("/mnt/shared/{}", share.tag());
1043+ let guest = share.guest.to_string_lossy();
1044+ all_login_actions.push(Send(format!("mkdir -p {}", guest)));
1045+ all_login_actions.push(Send(format!("mount --bind {} {}", staging, guest)));
1046+ }
1047+ }
1048+
1049+ for a in login_actions {
1050+ all_login_actions.push(a.clone())
1051+ }
1052+
1053+ let (vm_output_tx, vm_output_rx) = mpsc::channel::<VmOutput>();
1054+ let login_actions_thread = spawn_login_actions_thread(
1055+ all_login_actions,
1056+ output_monitor.clone(),
1057+ io_ctx.input_tx.clone(),
1058+ vm_output_tx,
1059+ );
1060+
1061+ let mut last_state = None;
1062+ let mut exit_result = Ok(());
1063+ loop {
1064+ unsafe {
1065+ NSRunLoop::mainRunLoop().runMode_beforeDate(
1066+ NSDefaultRunLoopMode,
1067+ &NSDate::dateWithTimeIntervalSinceNow(0.2),
1068+ )
1069+ };
1070+
1071+ let state = unsafe { vm.state() };
1072+ if last_state != Some(state) {
1073+ //eprintln!("[state] {:?}", state);
1074+ last_state = Some(state);
1075+ }
1076+ match vm_output_rx.try_recv() {
1077+ Ok(VmOutput::LoginActionTimeout { action, timeout }) => {
1078+ exit_result = Err(format!(
1079+ "Login action ({}) timed out after {:?}; shutting down.",
1080+ action, timeout
1081+ )
1082+ .into());
1083+ unsafe {
1084+ if vm.canRequestStop() {
1085+ if let Err(err) = vm.requestStopWithError() {
1086+ eprintln!("Failed to request VM stop: {:?}", err);
1087+ }
1088+ } else if vm.canStop() {
1089+ let handler = RcBlock::new(|_error: *mut NSError| {});
1090+ vm.stopWithCompletionHandler(&handler);
1091+ }
1092+ }
1093+ break;
1094+ }
1095+ Err(mpsc::TryRecvError::Empty) => {}
1096+ Err(mpsc::TryRecvError::Disconnected) => {}
1097+ }
1098+ if state != objc2_virtualization::VZVirtualMachineState::Running {
1099+ //eprintln!("VM stopped with state: {:?}", state);
1100+ break;
1101+ }
1102+ }
1103+
1104+ let _ = login_actions_thread.join();
1105+
1106+ io_ctx.shutdown();
1107+
1108+ exit_result
1109+}
1110+
1111+fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
1112+ let abs_path = if path.is_absolute() {
1113+ path.to_path_buf()
1114+ } else {
1115+ env::current_dir()?.join(path)
1116+ };
1117+ let ns_path = NSString::from_str(
1118+ abs_path
1119+ .to_str()
1120+ .ok_or("Non-UTF8 path encountered while building NSURL")?,
1121+ );
1122+ Ok(NSURL::fileURLWithPath(&ns_path))
1123+}
1124+
1125+fn enable_raw_mode(fd: i32) -> io::Result<RawModeGuard> {
1126+ let mut attributes: libc::termios = unsafe { std::mem::zeroed() };
1127+
1128+ if unsafe { libc::tcgetattr(fd, &mut attributes) } != 0 {
1129+ return Err(io::Error::last_os_error());
1130+ }
1131+
1132+ let original = attributes;
1133+
1134+ // Disable translation of carriage return to newline on input
1135+ attributes.c_iflag &= !(libc::ICRNL);
1136+ // Disable canonical mode (line buffering), echo, and signal generation
1137+ attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
1138+ attributes.c_cc[libc::VMIN] = 0;
1139+ attributes.c_cc[libc::VTIME] = 1;
1140+
1141+ if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &attributes) } != 0 {
1142+ return Err(io::Error::last_os_error());
1143+ }
1144+
1145+ Ok(RawModeGuard { fd, original })
1146+}
1147+
1148+struct RawModeGuard {
1149+ fd: i32,
1150+ original: libc::termios,
1151+}
1152+
1153+impl Drop for RawModeGuard {
1154+ fn drop(&mut self) {
1155+ unsafe {
1156+ libc::tcsetattr(self.fd, libc::TCSANOW, &self.original);
1157+ }
1158+ }
1159+}
1160+
1161+// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
1162+pub fn ensure_signed() {
1163+ let exe = std::env::current_exe().expect("failed to get current exe path");
1164+ let exe_str = exe.to_str().expect("exe path not valid utf-8");
1165+
1166+ let has_required_entitlements = {
1167+ let output = Command::new("codesign")
1168+ .args(["-d", "--entitlements", "-", "--xml", exe.to_str().unwrap()])
1169+ .output();
1170+
1171+ match output {
1172+ Ok(o) if o.status.success() => {
1173+ let stdout = String::from_utf8_lossy(&o.stdout);
1174+ stdout.contains("com.apple.security.virtualization")
1175+ }
1176+ _ => false,
1177+ }
1178+ };
1179+
1180+ if has_required_entitlements {
1181+ return;
1182+ }
1183+
1184+ const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1185+ let entitlements_path = std::env::temp_dir().join("entitlements.plist");
1186+ std::fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
1187+
1188+ let status = Command::new("codesign")
1189+ .args([
1190+ "--sign",
1191+ "-",
1192+ "--force",
1193+ "--entitlements",
1194+ entitlements_path.to_str().unwrap(),
1195+ exe_str,
1196+ ])
1197+ .status();
1198+
1199+ let _ = std::fs::remove_file(&entitlements_path);
1200+
1201+ match status {
1202+ Ok(s) if s.success() => {
1203+ let err = Command::new(&exe).args(std::env::args_os().skip(1)).exec();
1204+ eprintln!("failed to re-exec after signing: {err}");
1205+ std::process::exit(1);
1206+ }
1207+ Ok(s) => {
1208+ eprintln!("codesign failed with status: {s}");
1209+ std::process::exit(1);
1210+ }
1211+ Err(e) => {
1212+ eprintln!("failed to run codesign: {e}");
1213+ std::process::exit(1);
1214+ }
1215+ }
1216+}
src/provision.shnew file+62
@@ -0,0 +1,62 @@
1+#!/bin/bash
2+set -eux
3+
4+# Don't wait too long for slow mirrors.
5+echo 'Acquire::http::Timeout "2";' | tee /etc/apt/apt.conf.d/99timeout
6+echo 'Acquire::https::Timeout "2";' | tee -a /etc/apt/apt.conf.d/99timeout
7+echo 'Acquire::Retries "2";' | tee -a /etc/apt/apt.conf.d/99timeout
8+
9+apt-get update
10+apt-get install -y --no-install-recommends \
11+ build-essential \
12+ pkg-config \
13+ libssl-dev \
14+ curl \
15+ git \
16+ ripgrep
17+
18+# Set hostname to "vibe" so it's clear that you're inside the VM.
19+hostnamectl set-hostname vibe
20+
21+# Set this env var so claude doesn't complain about running as root.'
22+echo "export IS_SANDBOX=1" >> .bashrc
23+
24+# Shutdown the VM when you logout
25+cat > .bash_logout <<EOF
26+systemctl poweroff
27+sleep 100 # sleep here so that we don't see the login screen flash up before the shutdown.
28+EOF
29+
30+
31+# Install Rust
32+curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --component "rustfmt,clippy"
33+
34+
35+# Install Mise
36+curl https://mise.run | sh
37+echo 'eval "$(~/.local/bin/mise activate bash)"' >> .bashrc
38+
39+export PATH="$HOME/.local/bin:$PATH"
40+eval "$(mise activate bash)"
41+
42+mkdir -p .config/mise/
43+
44+cat > .config/mise/config.toml <<MISE
45+ [settings]
46+ # Always use the venv created by uv, if available in directory
47+ python.uv_venv_auto = true
48+ experimental = true
49+ idiomatic_version_file_enable_tools = ["rust"]
50+
51+ [tools]
52+ uv = "0.9.25"
53+ node = "24.13.0"
54+ "npm:@openai/codex" = "latest"
55+ "npm:@anthropic-ai/claude-code" = "latest"
56+MISE
57+
58+touch .config/mise/mise.lock
59+mise install
60+
61+# Done provisioning, power off the VM
62+systemctl poweroff