on main help sign in
33 files changed+4420−1379
since splitsnapshots
unifiedsplit
.github/workflows/ci.yml+98 .github/workflows/release.yml+93 CONTRIBUTING.md+44 Cargo.lock+93−1 Cargo.toml+25−7 README.md+223 README.zh.md+209 docs/banner.png docs/screenshot.png docs/tasks.md+16−10 install.sh → install+54−61 scripts/commit-check.sh+6 src/bin/vibebox-cli.rs−370 src/bin/vibebox-supervisor.rs+35−14 src/bin/vibebox.rs+400 src/config.rs+472−91 src/error_report.sh+28 src/explain.rs+156 src/instance.rs+322−341 src/lib.rs+2 src/provision.sh+55−5 src/resize_disk.sh+55 src/session_manager.rs+64−101 src/ssh.sh+64−25 src/tui.rs+54−37 src/utils.rs+18 src/vm.rs+339−141 src/vm_manager.rs+587−172 tests/e2e_cli.rs+58 tests/e2e_vm.rs+278 tests/e2e_vm_mock.rs+455 tests/explain_integration.rs+113 vibebox.toml+4−3
.github/workflows/ci.ymlnew file+98
@@ -0,0 +1,98 @@
1+name: CI
2+
3+on:
4+ push:
5+ branches:
6+ - "**"
7+ paths-ignore:
8+ - "README.md"
9+ - "CONTRIBUTING.md"
10+ - "README.zh.md"
11+ - "docs/**"
12+ - "install"
13+
14+concurrency:
15+ group: ci-${{ github.ref }}
16+ cancel-in-progress: true
17+
18+jobs:
19+ fmt:
20+ name: Format
21+ runs-on: macos-latest
22+ env:
23+ RUST_BACKTRACE: "1"
24+ steps:
25+ - uses: actions/checkout@v6
26+ - name: Install Rust
27+ uses: dtolnay/rust-toolchain@stable
28+ with:
29+ components: rustfmt
30+ - name: Cache Rust
31+ uses: Swatinem/rust-cache@v2
32+ - name: cargo fmt
33+ run: cargo fmt --all -- --check
34+
35+ clippy:
36+ name: Clippy
37+ runs-on: macos-latest
38+ env:
39+ RUST_BACKTRACE: "1"
40+ steps:
41+ - uses: actions/checkout@v6
42+ - name: Install Rust
43+ uses: dtolnay/rust-toolchain@stable
44+ with:
45+ components: clippy
46+ - name: Cache Rust
47+ uses: Swatinem/rust-cache@v2
48+ - name: cargo clippy
49+ run: cargo clippy --all-targets --all-features -- -D warnings
50+
51+ build:
52+ name: Build
53+ runs-on: macos-latest
54+ env:
55+ RUST_BACKTRACE: "1"
56+ steps:
57+ - uses: actions/checkout@v6
58+ - name: Install Rust
59+ uses: dtolnay/rust-toolchain@stable
60+ - name: Cache Rust
61+ uses: Swatinem/rust-cache@v2
62+ - name: cargo build
63+ run: cargo build --locked
64+
65+ test:
66+ name: Test
67+ runs-on: macos-latest
68+ env:
69+ RUST_BACKTRACE: "full"
70+ steps:
71+ - uses: actions/checkout@v6
72+ - name: Install Rust
73+ uses: dtolnay/rust-toolchain@stable
74+ - name: Cache Rust
75+ uses: Swatinem/rust-cache@v2
76+ - name: cargo test
77+ run: cargo test --locked
78+
79+ coverage:
80+ name: Coverage
81+ runs-on: macos-latest
82+ needs: [fmt, clippy, build, test]
83+ env:
84+ RUST_BACKTRACE: "full"
85+ steps:
86+ - uses: actions/checkout@v6
87+ - name: Install Rust
88+ uses: dtolnay/rust-toolchain@stable
89+ with:
90+ components: llvm-tools-preview
91+ - name: Cache Rust
92+ uses: Swatinem/rust-cache@v2
93+ - name: Install cargo-llvm-cov
94+ uses: taiki-e/install-action@v2
95+ with:
96+ tool: cargo-llvm-cov
97+ - name: cargo llvm-cov
98+ run: cargo llvm-cov --locked --features mock-vm --tests -- --nocapture
.github/workflows/release.ymlnew file+93
@@ -0,0 +1,93 @@
1+name: Build and Release
2+
3+permissions:
4+ contents: write
5+
6+on:
7+ push:
8+ tags:
9+ - "v*.*.*"
10+
11+concurrency:
12+ group: release-${{ github.ref }}
13+ cancel-in-progress: true
14+
15+jobs:
16+ build:
17+ runs-on: macos-latest
18+
19+ steps:
20+ - uses: actions/checkout@v6
21+ with:
22+ fetch-depth: 0
23+
24+ - name: Validate release tag format
25+ run: |
26+ TAG="${GITHUB_REF_NAME}"
27+ if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z]+)?$ ]]; then
28+ echo "Tag '$TAG' does not match required format vX.X.X or vX.X.X-word"
29+ exit 1
30+ fi
31+
32+ - name: Install Rust
33+ uses: dtolnay/rust-toolchain@stable
34+
35+ - name: Cache Rust
36+ uses: Swatinem/rust-cache@v2
37+
38+ - name: Resolve target commit
39+ run: |
40+ TARGET_SHA="$(git rev-list -n 1 "${GITHUB_REF_NAME}")"
41+ echo "TARGET_SHA=$TARGET_SHA" >> "$GITHUB_ENV"
42+
43+ - name: Verify CI passed
44+ env:
45+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46+ run: |
47+ COUNT="$(gh run list --workflow ci.yml --commit "$TARGET_SHA" --json conclusion,createdAt --jq 'length')"
48+ if [ "$COUNT" -eq 0 ]; then
49+ echo "No CI runs found for commit $TARGET_SHA"
50+ exit 1
51+ fi
52+ CONCLUSION="$(gh run list --workflow ci.yml --commit "$TARGET_SHA" --json conclusion,createdAt --jq '.[0].conclusion')"
53+ if [ "$CONCLUSION" != "success" ]; then
54+ echo "Latest CI run for commit $TARGET_SHA is $CONCLUSION"
55+ exit 1
56+ fi
57+
58+ - name: Build release binary
59+ run: cargo build --release --locked
60+
61+ - name: Publish crate
62+ env:
63+ CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
64+ run: cargo publish --locked
65+
66+ - name: Package binary
67+ run: |
68+ cd target/release
69+ zip vibebox-macos-arm64.zip vibebox
70+
71+ - name: Create tag release
72+ run: |
73+ gh release create "${{ github.ref_name }}" \
74+ --title "${{ github.ref_name }}" \
75+ --notes "Built from commit $TARGET_SHA" \
76+ target/release/vibebox-macos-arm64.zip
77+ env:
78+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
79+
80+ - name: Delete existing latest release
81+ run: gh release delete latest --cleanup-tag --yes || true
82+ env:
83+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
84+
85+ - name: Create latest release
86+ run: |
87+ gh release create latest \
88+ --title "Latest" \
89+ --notes "Built from commit $TARGET_SHA" \
90+ --prerelease \
91+ target/release/vibebox-macos-arm64.zip
92+ env:
93+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CONTRIBUTING.mdnew file+44
@@ -0,0 +1,44 @@
1+# Contributing to Vibebox
2+
3+Thanks for your interest in contributing! This guide keeps PRs small, reviewable, and consistent with the project’s
4+development workflow.
5+
6+**Prerequisites**
7+
8+- macOS on Apple Silicon (required for the virtualization backend)
9+- Rust `1.91.1` or newer (see `Cargo.toml`)
10+
11+**Getting Started**
12+
13+1. Fork the repo and create a feature branch.
14+2. Build once to validate your toolchain:
15+
16+```bash
17+cargo build --locked
18+```
19+
20+**Development Commands**
21+
22+- Format: `cargo fmt --all -- --check`
23+- Lint: `cargo clippy --all-targets --all-features -- -D warnings`
24+- Test: `cargo test --locked`
25+- Build: `cargo build --locked`
26+
27+**Submitting Changes**
28+
29+- Keep changes focused and scoped to one problem.
30+- Update or add tests when behavior changes.
31+- If you change user-facing behavior, update docs or help text.
32+- Avoid adding heavy dependencies without a clear reason.
33+
34+**Reporting Issues**
35+Please include:
36+
37+- macOS version and hardware (Apple Silicon model)
38+- Vibebox version (`vibebox --version`)
39+- Steps to reproduce
40+- Logs from `.vibebox/cli.log`, `.vibebox/vm_root.log` and `.vibebox/vm_manager.log`
41+
42+**Security**
43+If you believe you’ve found a security issue, please avoid public disclosure. Open a private report via GitHub Security
44+Advisories instead.
Cargo.lock+93−1
@@ -82,6 +82,27 @@ dependencies = [
8282 "windows-sys 0.61.2",
8383]
8484
85+[[package]]
86+name = "anyhow"
87+version = "1.0.101"
88+source = "registry+https://github.com/rust-lang/crates.io-index"
89+checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
90+
91+[[package]]
92+name = "assert_cmd"
93+version = "2.1.2"
94+source = "registry+https://github.com/rust-lang/crates.io-index"
95+checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514"
96+dependencies = [
97+ "anstyle",
98+ "bstr",
99+ "libc",
100+ "predicates",
101+ "predicates-core",
102+ "predicates-tree",
103+ "wait-timeout",
104+]
105+
85106[[package]]
86107name = "backtrace"
87108version = "0.3.76"
@@ -112,12 +133,32 @@ dependencies = [
112133 "objc2",
113134]
114135
136+[[package]]
137+name = "bstr"
138+version = "1.12.1"
139+source = "registry+https://github.com/rust-lang/crates.io-index"
140+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
141+dependencies = [
142+ "memchr",
143+ "regex-automata",
144+ "serde",
145+]
146+
115147[[package]]
116148name = "bumpalo"
117149version = "3.19.1"
118150source = "registry+https://github.com/rust-lang/crates.io-index"
119151checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
120152
153+[[package]]
154+name = "bytesize"
155+version = "2.3.1"
156+source = "registry+https://github.com/rust-lang/crates.io-index"
157+checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3"
158+dependencies = [
159+ "serde_core",
160+]
161+
121162[[package]]
122163name = "cassowary"
123164version = "0.3.0"
@@ -321,6 +362,12 @@ dependencies = [
321362 "zeroize",
322363]
323364
365+[[package]]
366+name = "difflib"
367+version = "0.4.0"
368+source = "registry+https://github.com/rust-lang/crates.io-index"
369+checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
370+
324371[[package]]
325372name = "dispatch2"
326373version = "0.3.0"
@@ -737,6 +784,33 @@ version = "0.2.0"
737784source = "registry+https://github.com/rust-lang/crates.io-index"
738785checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
739786
787+[[package]]
788+name = "predicates"
789+version = "3.1.3"
790+source = "registry+https://github.com/rust-lang/crates.io-index"
791+checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
792+dependencies = [
793+ "anstyle",
794+ "difflib",
795+ "predicates-core",
796+]
797+
798+[[package]]
799+name = "predicates-core"
800+version = "1.0.9"
801+source = "registry+https://github.com/rust-lang/crates.io-index"
802+checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
803+
804+[[package]]
805+name = "predicates-tree"
806+version = "1.0.12"
807+source = "registry+https://github.com/rust-lang/crates.io-index"
808+checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
809+dependencies = [
810+ "predicates-core",
811+ "termtree",
812+]
813+
740814[[package]]
741815name = "proc-macro2"
742816version = "1.0.106"
@@ -1007,6 +1081,12 @@ dependencies = [
10071081 "windows-sys 0.61.2",
10081082]
10091083
1084+[[package]]
1085+name = "termtree"
1086+version = "0.5.1"
1087+source = "registry+https://github.com/rust-lang/crates.io-index"
1088+checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
1089+
10101090[[package]]
10111091name = "thiserror"
10121092version = "2.0.18"
@@ -1238,9 +1318,12 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
12381318
12391319[[package]]
12401320name = "vibebox"
1241−version = "0.1.0"
1321+version = "0.3.2"
12421322dependencies = [
1323+ "anyhow",
1324+ "assert_cmd",
12431325 "block2",
1326+ "bytesize",
12441327 "clap",
12451328 "color-eyre",
12461329 "crossterm",
@@ -1261,6 +1344,15 @@ dependencies = [
12611344 "uuid",
12621345]
12631346
1347+[[package]]
1348+name = "wait-timeout"
1349+version = "0.2.1"
1350+source = "registry+https://github.com/rust-lang/crates.io-index"
1351+checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
1352+dependencies = [
1353+ "libc",
1354+]
1355+
12641356[[package]]
12651357name = "wasi"
12661358version = "0.11.1+wasi-snapshot-preview1"
Cargo.toml+25−7
@@ -1,11 +1,20 @@
11[package]
22name = "vibebox"
3−version = "0.1.0"
3+version = "0.3.2"
44edition = "2024"
5+authors = ["Finn Sheng"]
6+description = "Ultrafast CLI on Apple Silicon macOS for fast, sandboxed development and LLM agents."
7+homepage = "https://github.com/robcholz/vibebox"
8+repository = "https://github.com/robcholz/vibebox"
9+readme = "README.md"
10+license = "MIT"
11+categories = ["virtualization","command-line-utilities"]
12+keywords = ["virtualization", "ai", "macos", "agent", "sandbox"]
13+rust-version = "1.91.1"
514
615[dependencies]
7−objc2 = "*"
8−objc2-foundation = { version = "*", features = [
16+objc2 = "0.6.3"
17+objc2-foundation = { version = "0.3.2", features = [
918 "NSArray",
1019 "NSString",
1120 "NSURL",
@@ -16,10 +25,10 @@ objc2-foundation = { version = "*", features = [
1625 "NSRunLoop",
1726 "NSObject",
1827] }
19−objc2-virtualization = "*"
20−block2 = "*"
21−dispatch2 = "*"
22−libc = "*"
28+objc2-virtualization = "0.3.2"
29+block2 = "0.6.2"
30+dispatch2 = "0.3.0"
31+libc = "0.2.180"
2332clap = { version = "4", features = ["derive"] }
2433serde = { version = "1", features = ["derive"] }
2534tempfile = "3"
@@ -33,3 +42,12 @@ ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] }
3342tracing = "0.1"
3443tracing-subscriber = { version = "0.3", features = ["env-filter"] }
3544dialoguer = "0.12.0"
45+bytesize = {version = "2.3.1",features = ["serde"]}
46+anyhow = "1.0.101"
47+
48+[dev-dependencies]
49+assert_cmd = "2"
50+tempfile = "3"
51+
52+[features]
53+mock-vm = []
README.mdnew file+223
@@ -0,0 +1,223 @@
1+<p align="center">
2+ <a href="https://vibebox.robcholz.com">
3+ <picture>
4+ <img src="docs/banner.png" alt="VibeBox logo">
5+ </picture>
6+ </a>
7+</p>
8+<p align="center">an ultrafast, open-source sandbox for running coding agents safely.</p>
9+
10+<p align="center">
11+ <a href="https://crates.io/crates/vibebox">
12+ <img alt="Crates.io" src="https://img.shields.io/crates/v/vibebox.svg">
13+ </a>
14+ <a href="https://github.com/robcholz/vibebox/blob/main/LICENSE">
15+ <img alt="MIT licensed" src="https://img.shields.io/badge/license-MIT-blue.svg">
16+ </a>
17+ <a href="https://github.com/robcholz/vibebox/actions?query=workflow%3ACI+branch%3Amain">
18+ <img alt="Build Status" src="https://github.com/robcholz/vibebox/workflows/CI/badge.svg">
19+ </a>
20+</p>
21+
22+<p align="center">
23+ <a href="README.md">English</a> |
24+ <a href="README.zh.md">简体中文</a>
25+</p>
26+
27+**VibeBox is a per-project micro-VM sandbox for running coding agents on macOS (Apple Virtualization Framework).**
28+It’s optimized for a *daily-driver* workflow: fast warm re-entry, explicit mounts, and reusable sessions.
29+
30+**Who it’s for:** macOS users running coding agents who want real isolation without giving up a fast daily workflow.
31+
32+**Quick facts:** warm re-entry is typically **<5s** on my M3 (varies by machine/cache); first run downloads and
33+provisions a Debian base image (network dependent).
34+
35+**Security model:** Linux guest VM with explicit mount allowlists from `vibebox.toml` (repo-first, everything else
36+opt-in).
37+
38+- **enter/attach in seconds:** `vibebox` drops you into a reusable sandbox for the current repo
39+- **project-scoped by default:** explicit mounts + repo-contained changes (repo-first, everything else is allowlisted)
40+- **sessioned:** multi-instance + session management (reuse, multiple terminals, cleanup)
41+
42+### Quick Demo
43+
44+```bash
45+# from any repo
46+cd my-project
47+vibebox
48+```
49+
50+What you should see (roughly):
51+
52+```text
53+vibebox: starting (session: my-project)
54+vibebox: attaching...
55+vibecoder@vibebox:~/my-project$
56+```
57+
58+[![VibeBox Terminal UI](docs/screenshot.png)](https://vibebox.robcholz.com)
59+
60+---
61+
62+### Why I built VibeBox
63+
64+I use coding agents daily, and I wanted to give them a real shell without handing them my host machine.
65+Lock things down and you get nonstop confirmations; loosen it up and you worry about deleting files, touching secrets,
66+or wandering outside the repo.
67+
68+VibeBox is the middle ground: a per-repo sandbox with a hard VM boundary, fast re-entry, and explicit mounts. It’s built
69+to be “always on” for agent work without turning safety into a chore.
70+
71+### Why a micro-VM (vs containers)?
72+
73+Containers are great. VibeBox isn’t trying to replace Docker/devcontainers for building services.
74+
75+I specifically wanted a VM-shaped default for agent workflows on macOS:
76+
77+- **guest-kernel isolation boundary by default:** when I’m letting an agent run arbitrary commands, I want “safe mode”
78+ to
79+ be a Linux guest, not my host.
80+- **sessions as a first-class workflow:** attach/reuse per repo, multiple terminals into the same sandbox, reliable
81+ cleanup to avoid orphan environments.
82+- **explicit mount allowlists as the primary UX:** repo-scoped by default; anything else is an explicit decision.
83+- **minimal per-repo setup:** you *can* reproduce parts of this with compose/devcontainers, but I wanted a single
84+ command
85+ that works repo-to-repo without maintaining container configs for the basic “safe shell” workflow.
86+
87+### Comparison
88+
89+Here’s why I didn’t just use existing options:
90+
91+- **vibe**: super convenient and nails “zero-config, just go”. VibeBox is intentionally on a different axis: per-repo
92+ config + sessions + multi-instance lifecycle.
93+- **QEMU**: powerful, but the configuration surface area is huge. For day-to-day sandboxing it’s not “open a repo and
94+ go” — it’s a project on its own.
95+- **Docker / devcontainers / devpods**: great ecosystem. My friction wasn’t raw startup time, it was the day-to-day
96+ overhead of keeping per-repo agent sandboxes *safe-by-default* (mount allowlists, secrets exposure, attach/reuse,
97+ cleanup) without maintaining container configs per repo for the basic workflow.
98+
99+That’s what pushed me to build **VibeBox**: I wanted a per-project sandbox that’s fast to enter (just `vibebox`),
100+supports real configuration + sessions, and keeps a hard isolation boundary.
101+
102+### Installation
103+
104+```bash
105+# install script
106+curl -fsSL https://raw.githubusercontent.com/robcholz/vibebox/main/install | bash
107+
108+# package managers
109+cargo install vibebox
110+
111+# manual install
112+curl -LO https://github.com/robcholz/vibebox/releases/download/latest/vibebox-macos-arm64.zip
113+unzip vibebox-macos-arm64.zip
114+mkdir -p ~/.local/bin
115+mv vibebox ~/.local/bin
116+export PATH="$HOME/.local/bin:$PATH"
117+```
118+
119+**Requirements**
120+
121+- macOS on Apple Silicon (VibeBox uses Apple's virtualization APIs).
122+
123+**First Run**
124+
125+The first `vibebox` run downloads a Debian base image and provisions it. After that, per-project instances reuse the
126+cached base image for much faster startups.
127+
128+### Documentation
129+
130+**Quick Start**
131+
132+```bash
133+cd /path/to/your/project
134+vibebox
135+```
136+
137+On first run, VibeBox creates `vibebox.toml` in your project (if missing) and a `.vibebox/` directory for instance data.
138+
139+**Configuration (`vibebox.toml`)**
140+
141+`vibebox.toml` lives in your project root by default. You can override it with `vibebox -c path/to/vibebox.toml` or the
142+`VIBEBOX_CONFIG_PATH` env var, but the path must stay inside the project directory.
143+
144+Default config (auto-created when missing):
145+
146+```toml
147+[box]
148+cpu_count = 2
149+ram_mb = 2048
150+disk_gb = 5
151+mounts = [
152+ "~/.codex:~/.codex:read-write",
153+ "~/.claude:~/.claude:read-write",
154+]
155+
156+[supervisor]
157+auto_shutdown_ms = 20000
158+```
159+
160+`disk_gb` is only applied when the instance disk is first created. If you change it later, run `vibebox reset` to
161+recreate the disk.
162+
163+**Mounts**
164+
165+- Your project is mounted read-write at `~/<project-name>`, and the shell starts there.
166+- If a `.git` directory exists, it is masked with a tmpfs mount inside the VM to discourage accidental edits from the
167+ guest.
168+- Extra mounts come from `box.mounts` with the format `host:guest[:read-only|read-write]`.
169+- Host paths support `~` expansion. Relative guest paths are treated as `/root/<path>`.
170+- Guest paths that use `~` are linked into `/home/<ssh-user>` for convenience. Run `vibebox explain` to see the resolved
171+ host/guest mappings.
172+
173+**CLI Commands**
174+
175+```bash
176+vibebox # start or attach to the current project VM
177+vibebox list # list known project sessions
178+vibebox reset # delete .vibebox for this project and recreate on next run
179+vibebox purge-cache # delete the global cache (~/.cache/vibebox)
180+vibebox explain # show mounts and network info
181+```
182+
183+**Inside the VM**
184+
185+- Default SSH user: `vibecoder`
186+- Hostname: `vibebox`
187+- Base image provisioning installs: build tools, `git`, `curl`, `ripgrep`, `openssh-server`, and `sudo`.
188+- On first login, VibeBox installs `mise` and configures tools like `uv`, `node`, `@openai/codex`, and
189+ `@anthropic-ai/claude-code` (best-effort).
190+- Shell aliases: `:help` and `:exit`.
191+
192+**State & Cache**
193+
194+- Project state lives in `.vibebox/` (instance disk, SSH keys, logs, manager socket/pid). `vibebox reset` removes it.
195+- Global cache lives in `~/.cache/vibebox` (base image + shared guest cache). `vibebox purge-cache` clears it.
196+- Session index lives in `~/.vibebox/sessions` and is shown by `vibebox list`.
197+
198+### Contributing
199+
200+If you're interested in contributing to VibeBox, please read our [contributing docs](CONTRIBUTING.md) before
201+submitting a pull request.
202+
203+### FAQ
204+
205+#### How is this different from other sandboxes?
206+
207+VibeBox is built for fast, repeatable local sandboxes with minimal ceremony. What’s different here:
208+
209+- Warm re-entry is typically **<5s** on my M3 (varies by machine/cache), so you can jump back in quickly.
210+- One simple command — `vibebox` — drops you into the sandbox from your project.
211+- Configuration lives in `vibebox.toml`, where you can set CPU, RAM, disk size, and mounts.
212+- Sessions are first-class: reuse, multiple terminals, cleanup.
213+
214+### Special thanks
215+
216+[vibe](https://github.com/lynaghk/vibe) by lynaghk.
217+
218+And the amazing Rust community — without the ecosystem and toolchain like [crates.io](https://crates.io), this wouldn't
219+be possible!
220+
221+---
222+
223+**Follow me on X** [x.com/robcholz](https://x.com/robcholz)
README.zh.mdnew file+209
@@ -0,0 +1,209 @@
1+<p align="center">
2+ <a href="https://vibebox.robcholz.com">
3+ <picture>
4+ <img src="docs/banner.png" alt="VibeBox logo">
5+ </picture>
6+ </a>
7+</p>
8+<p align="center">用于安全运行 coding agents 的超高速开源沙盒。</p>
9+
10+<p align="center">
11+ <a href="https://crates.io/crates/vibebox">
12+ <img alt="Crates.io" src="https://img.shields.io/crates/v/vibebox.svg">
13+ </a>
14+ <a href="https://github.com/robcholz/vibebox/blob/main/LICENSE">
15+ <img alt="MIT licensed" src="https://img.shields.io/badge/license-MIT-blue.svg">
16+ </a>
17+ <a href="https://github.com/robcholz/vibebox/actions?query=workflow%3ACI+branch%3Amain">
18+ <img alt="Build Status" src="https://github.com/robcholz/vibebox/workflows/CI/badge.svg">
19+ </a>
20+</p>
21+
22+<p align="center">
23+ <a href="README.zh.md">简体中文</a> |
24+ <a href="README.md">English</a>
25+</p>
26+
27+**VibeBox 是一个按项目划分的 micro-VM 沙盒,用于在 macOS 上运行 coding agents(基于 Apple Virtualization Framework)。**
28+它面向 *日常使用* 工作流优化:快速热启动、显式挂载、可复用会话。
29+
30+**适合谁:** 在 macOS 上使用 coding agents,并且既想要真实隔离又不想牺牲日常效率的人。
31+
32+**快速事实:** 在我的 M3 上,热启动通常 **<5s**(因机器/缓存而异);首次运行会下载并初始化 Debian 基础镜像(受网络影响)。
33+
34+**安全模型:** Linux 来宾 VM + `vibebox.toml` 显式挂载白名单(默认仅项目目录,其它均需显式允许)。
35+
36+- **几秒进入/附加:** `vibebox` 直接进入当前仓库的可复用沙盒
37+- **默认按项目范围:** 显式挂载 + 改动限制在仓库内(repo 优先,其他需 allowlist)
38+- **会话化:** 多实例 + 会话管理(复用、多终端、清理)
39+
40+### 快速演示
41+
42+```bash
43+# 在任意仓库内
44+cd my-project
45+vibebox
46+```
47+
48+你大致会看到:
49+
50+```text
51+vibebox: starting (session: my-project)
52+vibebox: attaching...
53+vibecoder@vibebox:~/my-project$
54+```
55+
56+[![VibeBox Terminal UI](docs/screenshot.png)](https://vibebox.robcholz.com)
57+
58+---
59+
60+### 我为什么做 VibeBox
61+
62+我每天都在用 coding agents,也希望它们有一个真实的 shell,但不想把宿主机直接交出去。
63+权限收紧会被不停的确认打断;权限放开又担心误删文件、触及密钥,或者跑出仓库边界。
64+
65+VibeBox 是中间方案:按项目隔离、硬 VM 边界、快速回到工作状态、显式挂载。它适合把 agent 当成日常工具,而不是把安全变成负担。
66+
67+### 为什么是 micro-VM(而不是容器)?
68+
69+容器很好用。VibeBox 并不是用来替代 Docker/devcontainers 去构建服务。
70+
71+我更想要的是在 macOS 上适合 agent 的 VM 默认形态:
72+
73+- **默认是 guest-kernel 隔离边界:** 让 agent 跑任意命令时,“安全模式”是 Linux 来宾而不是宿主机。
74+- **会话是第一等公民:** 按项目附加/复用,多终端进入同一沙盒,可靠清理,避免孤儿环境。
75+- **显式挂载白名单作为主要 UX:** 默认仅项目目录,其它都需显式允许。
76+- **最少的每项目配置:** 你可以用 compose/devcontainers 实现一部分,但我想要的是一个命令,在不同仓库间直接工作,不必维护容器配置来获得基础“安全
77+ shell”体验。
78+
79+### 对比
80+
81+下面是我为什么没有直接用现成方案的原因:
82+
83+- **vibe**:非常方便,“零配置、直接用”做得很好。但 VibeBox 走的是另一条路:按项目配置 + 会话 + 多实例生命周期。
84+- **QEMU**:很强大,但配置面太大了。日常当沙箱用,它不像是“进到 repo 就能用”,更像是你得先把它当成一个项目来折腾。
85+- **Docker / devcontainers / devpods**:生态很成熟。我的痛点不在启动时间,而是日常保持
86+ safe-by-default(挂载白名单、密钥暴露、附加/复用、清理)的开销,不想为基础 workflow 在每个项目维护容器配置。
87+
88+这就是我做 **VibeBox** 的原因:我想要一个按项目隔离的沙箱,进入快(直接 `vibebox`),支持真实配置 + 会话,同时保持硬隔离边界。
89+
90+### 安装
91+
92+```bash
93+# YOLO:一键安装
94+curl -fsSL https://raw.githubusercontent.com/robcholz/vibebox/main/install | bash
95+
96+# Cargo
97+cargo install vibebox
98+
99+# 或者手动安装
100+curl -LO https://github.com/robcholz/vibebox/releases/download/latest/vibebox-macos-arm64.zip
101+unzip vibebox-macos-arm64.zip
102+mkdir -p ~/.local/bin
103+mv vibebox ~/.local/bin
104+export PATH="$HOME/.local/bin:$PATH"
105+```
106+
107+**系统要求**
108+
109+- Apple Silicon 的 macOS(VibeBox 使用了 Apple 的虚拟化 API)。
110+
111+**首次运行**
112+
113+第一次执行 `vibebox` 会下载 Debian 基础镜像并完成初始化。之后每个项目的实例会复用缓存的基础镜像,
114+启动会快很多。
115+
116+### 文档
117+
118+**快速开始**
119+
120+```bash
121+cd /path/to/your/project
122+vibebox
123+```
124+
125+第一次运行时,如果项目目录里缺少配置,VibeBox 会自动创建 `vibebox.toml`(放在项目根目录),并创建
126+`.vibebox/` 用来保存实例数据。
127+
128+**配置(`vibebox.toml`)**
129+
130+默认情况下,`vibebox.toml` 位于项目根目录。你可以用 `vibebox -c path/to/vibebox.toml` 或设置
131+`VIBEBOX_CONFIG_PATH` 环境变量来覆盖路径,但配置文件必须仍然位于项目目录内部。
132+
133+默认配置(缺失时会自动生成):
134+
135+```toml
136+[box]
137+cpu_count = 2
138+ram_mb = 2048
139+disk_gb = 5
140+mounts = [
141+ "~/.codex:~/.codex:read-write",
142+ "~/.claude:~/.claude:read-write",
143+]
144+
145+[supervisor]
146+auto_shutdown_ms = 20000
147+```
148+
149+注意:`disk_gb` 只在「首次创建实例磁盘」时生效。之后如果你改了它,需要运行 `vibebox reset` 重新创建磁盘。
150+
151+**挂载(Mounts)**
152+
153+- 你的项目会以读写方式挂载到 `~/<project-name>`,并且 shell 会默认从那里启动。
154+- 如果项目里存在 `.git` 目录,VM 内会用 tmpfs 把它遮住,避免你在 guest 里误操作改到 Git 元数据。
155+- 额外挂载通过 `box.mounts` 配置,格式为 `host:guest[:read-only|read-write]`。
156+- Host 路径支持 `~` 展开;guest 的相对路径会被视为 `/root/<path>`。
157+- guest 路径如果用了 `~`,会为了方便被链接到 `/home/<ssh-user>` 下。你可以运行 `vibebox explain`
158+ 查看最终解析后的 host/guest 映射关系。
159+
160+**CLI 命令**
161+
162+```bash
163+vibebox # 启动或连接当前项目的 VM
164+vibebox list # 列出已知的项目会话
165+vibebox reset # 删除当前项目的 .vibebox,下一次运行会重新创建
166+vibebox purge-cache # 删除全局缓存(~/.cache/vibebox)
167+vibebox explain # 显示挂载与网络信息
168+```
169+
170+**在 VM 内部**
171+
172+- 默认 SSH 用户:`vibecoder`
173+- 主机名:`vibebox`
174+- 基础镜像初始化会安装:构建工具、`git`、`curl`、`ripgrep`、`openssh-server`、`sudo`
175+- 首次登录时,VibeBox 会安装 `mise`,并尽力配置 `uv`、`node`、`@openai/codex`、
176+ `@anthropic-ai/claude-code` 等工具(best-effort,视网络和环境而定)
177+- Shell 里有两个别名:`:help` 和 `:exit`
178+
179+**状态与缓存**
180+
181+- 项目级状态在 `.vibebox/`(实例磁盘、SSH key、日志、manager socket/pid)。`vibebox reset` 会移除它。
182+- 全局缓存在 `~/.cache/vibebox`(基础镜像 + 共享 guest 缓存)。`vibebox purge-cache` 会清空它。
183+- 会话索引在 `~/.vibebox/sessions`,可以通过 `vibebox list` 查看。
184+
185+### 参与贡献
186+
187+如果你想参与贡献 VibeBox,请先阅读 [贡献指南](CONTRIBUTING.md),再提交 Pull Request。
188+
189+### FAQ
190+
191+#### 它和其它 Sandboxes 有什么不同?
192+
193+VibeBox 追求的是:本地、可复现、启动快、流程简单。主要差异点:
194+
195+- 在我的 M3 上,热启动通常 **<5s**,可以非常快地回到工作状态。
196+- 一个命令——`vibebox`——直接把你带进沙盒(从你的项目目录启动)。
197+- 配置集中在 `vibebox.toml`,CPU / 内存 / 磁盘大小 / 挂载都能一眼看懂、随手改。
198+- 会话是第一等公民:复用、多终端、清理。
199+
200+### 特别鸣谢
201+
202+[vibe](https://github.com/lynaghk/vibe) by lynaghk。
203+
204+以及 Rust 社区。没有你们丰富的 crates 生态和优秀的工具链(比如 [crates.io](https://crates.io)),
205+这个项目不可能这么顺利!Rust教。
206+
207+---
208+
209+**在 X 上关注我** [X.com](https://x.com/robcholz)
docs/banner.pngnew file

Binary file not shown.

docs/screenshot.pngnew file

Binary file not shown.

docs/tasks.md+16−10
@@ -25,7 +25,7 @@
25255. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar.
26266. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio.
27277. [x] Add a standalone TUI CLI binary (no main.rs wiring) with placeholder VM info and TODOs for VM integration.
28−8. [ ] Run tests and validate coverage for the new module.
28+8. [x] Run tests and validate coverage for the new module.
2929
3030## TUI
3131
@@ -47,19 +47,25 @@
4747 2. [x] Remove old cli.
4848 3. [x] add an actual config file.
4949 4. [x] set up the cli.
50−9. [ ] fix ui overlap, and consistency issue.
51−10. [ ] intensive integration test.
50+9. [x] fix ui overlap, and consistency issue.
51+10. [x] `purge-cache` to clear the cache.
52+11. [x] intensive integration test.
5253
5354## Publish
5455
55−1. [ ] write the docs.
56−2. [ ] setup quick install link.
57−3. [ ] setup website.
56+1. [x] write the docs.
57+2. [x] setup quick install link.
58+3. [x] setup website.
5859
5960## Stage 2
6061
61621. [ ] retouch the cli ux.
62−2. [ ] refactor the code.
63−3. [ ] Redirect vm output to log.
64−4. [ ] Redirect vm output to vibebox starting it.
65−5. [ ] use anyhow to sync api.
63+2. [x] refactor the code.
64+3. [ ] refactor the mount system.
65+4. [x] refactor the vm process lifetime.
66+5. [x] Redirect vm output to log.
67+6. [x] Redirect vm output to vibebox starting it.
68+7. [x] use anyhow to sync api.
69+8. [ ] add support for ipv6.
70+9. [x] use UnixStream instead of status file
71+10. [x] liveness check should also happen when waiting for ssh port
install.sh → installrenamed+54−61
@@ -2,7 +2,7 @@
22set -euo pipefail
33
44APP=vibebox
5−REPO_URL="https://github.com/opencode-ai/vibebox"
5+REPO_URL="https://github.com/robcholz/vibebox"
66REQUESTED_VERSION=${VERSION:-}
77
88RED='\033[0;31m'
@@ -38,9 +38,58 @@ require_cmd() {
3838 fi
3939}
4040
41−require_cmd cargo "Install Rust (cargo) from https://rustup.rs and retry."
4241require_cmd git "Install git and retry."
4342
43+ensure_cargo() {
44+ if command -v cargo >/dev/null 2>&1; then
45+ return 0
46+ fi
47+
48+ print_message warning "Rust (cargo) is required but not found."
49+ print_message info "You should review and approve the Rust installer before proceeding."
50+ reply=""
51+ if [[ -t 0 ]]; then
52+ read -r -p "Install Rust using rustup now? (y/N) " reply
53+ elif [[ -r /dev/tty ]]; then
54+ read -r -p "Install Rust using rustup now? (y/N) " reply </dev/tty
55+ else
56+ print_message error "Non-interactive shell: cannot prompt to install Rust."
57+ print_message info "Install Rust manually from https://rustup.rs and retry."
58+ exit 1
59+ fi
60+
61+ case "${reply}" in
62+ y|Y)
63+ if command -v curl >/dev/null 2>&1; then
64+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
65+ elif command -v wget >/dev/null 2>&1; then
66+ wget -qO- https://sh.rustup.rs | sh -s -- -y
67+ else
68+ print_message error "Missing required command: curl or wget"
69+ print_message info "Install Rust manually from https://rustup.rs and retry."
70+ exit 1
71+ fi
72+ # shellcheck source=/dev/null
73+ if [[ -f "$HOME/.cargo/env" ]]; then
74+ # shellcheck disable=SC1090
75+ source "$HOME/.cargo/env"
76+ fi
77+ ;;
78+ *)
79+ print_message info "Install Rust manually from https://rustup.rs and retry."
80+ exit 1
81+ ;;
82+ esac
83+
84+ if ! command -v cargo >/dev/null 2>&1; then
85+ print_message error "Cargo still not available after installation."
86+ print_message info "Open a new shell or run: source \"$HOME/.cargo/env\""
87+ exit 1
88+ fi
89+}
90+
91+ensure_cargo
92+
4493CARGO_HOME=${CARGO_HOME:-$HOME/.cargo}
4594INSTALL_DIR="$CARGO_HOME/bin"
4695
@@ -68,66 +117,10 @@ print_message info "Using cargo install from ${ORANGE}${REPO_URL}${GREEN}..."
68117
69118cargo "${install_args[@]}"
70119
71−add_to_path() {
72− local config_file=$1
73− local command=$2
74−
75− if [[ -w $config_file ]]; then
76− echo -e "\n# vibebox" >> "$config_file"
77− echo "$command" >> "$config_file"
78− print_message info "Added ${ORANGE}${APP}${GREEN} to \$PATH in $config_file"
79− else
80− print_message warning "Manually add the directory to $config_file (or similar):"
81− print_message info " $command"
82− fi
83−}
84−
85−XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config}
86−current_shell=$(basename "$SHELL")
87−
88−case $current_shell in
89− fish)
90− config_files="$HOME/.config/fish/config.fish"
91− ;;
92− zsh)
93− config_files="$HOME/.zshrc $HOME/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv"
94− ;;
95− bash)
96− config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"
97− ;;
98− ash)
99− config_files="$HOME/.ashrc $HOME/.profile /etc/profile"
100− ;;
101− sh)
102− config_files="$HOME/.ashrc $HOME/.profile /etc/profile"
103− ;;
104− *)
105− config_files="$HOME/.bashrc $HOME/.bash_profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"
106− ;;
107−esac
108−
109−config_file=""
110−for file in $config_files; do
111− if [[ -f $file ]]; then
112− config_file=$file
113− break
114− fi
115−done
116−
117−if [[ -z $config_file ]]; then
118− print_message error "No config file found for $current_shell. Checked files: ${config_files[@]}"
119− exit 1
120−fi
121−
122120if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
123− case $current_shell in
124− fish)
125− add_to_path "$config_file" "fish_add_path $INSTALL_DIR"
126− ;;
127− *)
128− add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
129− ;;
130− esac
121+ print_message warning "Cargo bin directory is not on PATH."
122+ print_message info "Add this to your shell profile if you can't run ${APP}:"
123+ print_message info " export PATH=$INSTALL_DIR:\$PATH"
131124fi
132125
133126if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
scripts/commit-check.shnew file+6
@@ -0,0 +1,6 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+cargo clippy --all-targets --all-features -- -D warnings
5+cargo fmt --all
6+cargo build --all-targets
src/bin/vibebox-cli.rsdeleted−370
@@ -1,370 +0,0 @@
1−use std::{
2− env,
3− ffi::OsString,
4− io::{self, IsTerminal, Write},
5− path::{Path, PathBuf},
6− sync::{Arc, Mutex},
7−};
8−
9−use clap::Parser;
10−use color_eyre::Result;
11−use dialoguer::Confirm;
12−use time::OffsetDateTime;
13−use time::format_description::well_known::Rfc3339;
14−use tracing_subscriber::EnvFilter;
15−
16−use vibebox::tui::{AppState, VmInfo};
17−use vibebox::{SessionManager, commands, config, instance, session_manager, tui, vm, vm_manager};
18−
19−#[derive(Debug, Parser)]
20−#[command(name = "vibebox", version, about = "Vibebox CLI")]
21−struct Cli {
22− /// Path to vibebox.toml (relative to the current directory)
23− #[arg(short = 'c', long = "config", value_name = "PATH", global = true)]
24− config: Option<PathBuf>,
25− #[command(subcommand)]
26− command: Option<Command>,
27−}
28−
29−#[derive(Debug, clap::Subcommand)]
30−enum Command {
31− /// List all sessions
32− List,
33− /// Delete the current project's .vibebox directory
34− Clean,
35− /// Explain mounts and mappings
36− Explain,
37−}
38−
39−fn main() -> Result<()> {
40− init_tracing();
41− color_eyre::install()?;
42−
43− let cli = Cli::parse();
44− let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
45− tracing::info!(cwd = %cwd.display(), "starting vibebox cli");
46− if let Some(command) = cli.command {
47− return handle_command(command, &cwd, cli.config.as_deref());
48− }
49−
50− let config_override = cli.config.clone();
51− let raw_args: Vec<OsString> = env::args_os().collect();
52− let config = config::load_config_with_path(&cwd, config_override.as_deref());
53−
54− if env::var("VIBEBOX_VM_MANAGER").as_deref() == Ok("1") {
55− tracing::info!("starting vm manager mode");
56− let args = vm::VmArg {
57− cpu_count: config.box_cfg.cpu_count,
58− ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
59− no_default_mounts: false,
60− mounts: config.box_cfg.mounts.clone(),
61− };
62− let auto_shutdown_ms = config.supervisor.auto_shutdown_ms;
63− tracing::info!(auto_shutdown_ms, "vm manager config");
64− if let Err(err) = vm_manager::run_manager(args, auto_shutdown_ms) {
65− tracing::error!(error = %err, "vm manager exited");
66− return Err(color_eyre::eyre::eyre!(err.to_string()));
67− }
68− return Ok(());
69− }
70−
71− vm::ensure_signed();
72−
73− let vm_args = vm::VmArg {
74− cpu_count: config.box_cfg.cpu_count,
75− ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
76− no_default_mounts: false,
77− mounts: config.box_cfg.mounts.clone(),
78− };
79−
80− let vm_info = VmInfo {
81− version: env!("CARGO_PKG_VERSION").to_string(),
82− max_memory_mb: vm_args.ram_bytes / (1024 * 1024),
83− cpu_cores: vm_args.cpu_count,
84− };
85− let auto_shutdown_ms = config.supervisor.auto_shutdown_ms;
86− if let Ok(manager) = SessionManager::new() {
87− if let Err(err) = manager.update_global_sessions(&cwd) {
88− tracing::warn!(error = %err, "failed to update a global session list");
89− }
90− } else {
91− tracing::warn!("failed to initialize session manager");
92− }
93− let commands = commands::build_commands();
94− let app = Arc::new(Mutex::new(AppState::new(cwd.clone(), vm_info, commands)));
95−
96− {
97− let mut locked = app.lock().expect("app state poisoned");
98− tui::render_tui_once(&mut locked)?;
99− }
100− {
101− let mut stdout = io::stdout().lock();
102− writeln!(stdout)?;
103− stdout.flush()?;
104− }
105−
106− tracing::info!(auto_shutdown_ms, "auto shutdown config");
107− let manager_conn =
108− vm_manager::ensure_manager(&raw_args, auto_shutdown_ms, config_override.as_deref())
109− .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
110−
111− instance::run_with_ssh(manager_conn).map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
112−
113− Ok(())
114−}
115−
116−fn handle_command(command: Command, cwd: &PathBuf, config_override: Option<&Path>) -> Result<()> {
117− match command {
118− Command::List => {
119− let manager = SessionManager::new()?;
120− let sessions = manager.list_sessions()?;
121− if sessions.is_empty() {
122− println!("No sessions were found.");
123− return Ok(());
124− }
125− let rows: Vec<tui::SessionListRow> = sessions
126− .into_iter()
127− .map(|session| tui::SessionListRow {
128− name: project_name(&session.directory),
129− id: session.id,
130− directory: relative_to_home(&session.directory),
131− last_active: format_last_active(session.last_active.as_deref()),
132− active: if session.active {
133− "yes".to_string()
134− } else {
135− "no".to_string()
136− },
137− })
138− .collect();
139− tui::render_sessions_table(&rows)?;
140− Ok(())
141− }
142− Command::Clean => {
143− let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME);
144− if !instance_dir.exists() {
145− println!("No .vibebox directory found at {}", instance_dir.display());
146− return Ok(());
147− }
148− let confirmed = Confirm::new()
149− .with_prompt(format!(
150− "Delete {} and all its contents?",
151− instance_dir.display()
152− ))
153− .default(false)
154− .interact()?;
155− if !confirmed {
156− println!("Cancelled.");
157− return Ok(());
158− }
159− let manager = SessionManager::new()?;
160− let summary = manager.clean_project(cwd)?;
161− println!(
162− "Deleted {} (removed={}, session_records_removed={})",
163− summary.instance_dir.display(),
164− summary.removed_instance_dir,
165− summary.removed_sessions
166− );
167− Ok(())
168− }
169− Command::Explain => {
170− let config = config::load_config_with_path(cwd, config_override);
171− let mounts = build_mount_rows(cwd, &config)?;
172− let networks = build_network_rows(cwd)?;
173− if mounts.is_empty() && networks.is_empty() {
174− println!("No mounts or network info available.");
175− return Ok(());
176− }
177− tui::render_explain_tables(&mounts, &networks)?;
178− Ok(())
179− }
180− }
181−}
182−
183−fn project_name(directory: &PathBuf) -> String {
184− directory
185− .file_name()
186− .and_then(|name| name.to_str())
187− .unwrap_or("-")
188− .to_string()
189−}
190−
191−fn relative_to_home(directory: &PathBuf) -> String {
192− let Ok(home) = env::var("HOME") else {
193− return directory.display().to_string();
194− };
195− let home_path = PathBuf::from(home);
196− if let Ok(stripped) = directory.strip_prefix(&home_path) {
197− if stripped.components().next().is_none() {
198− return "~".to_string();
199− }
200− return format!("~/{}", stripped.display());
201− }
202− directory.display().to_string()
203−}
204−
205−fn format_last_active(value: Option<&str>) -> String {
206− let Some(raw) = value else {
207− return "-".to_string();
208− };
209− let parsed = OffsetDateTime::parse(raw, &Rfc3339);
210− let Ok(timestamp) = parsed else {
211− return raw.to_string();
212− };
213− let now = OffsetDateTime::now_utc();
214− let mut seconds = (now - timestamp).whole_seconds();
215− if seconds < 0 {
216− seconds = 0;
217− }
218− let seconds = seconds as i64;
219− let week_seconds = 7 * 24 * 60 * 60;
220− if seconds >= week_seconds {
221− let formatted =
222− match time::format_description::parse("[year]-[month]-[day] [hour]:[minute]Z") {
223− Ok(format) => timestamp
224− .format(&format)
225− .unwrap_or_else(|_| raw.to_string()),
226− Err(_) => timestamp
227− .format(&Rfc3339)
228− .unwrap_or_else(|_| raw.to_string()),
229− };
230− return formatted;
231− }
232− if seconds < 60 {
233− return "just now".to_string();
234− }
235− if seconds < 60 * 60 {
236− let mins = seconds / 60;
237− return format!("{} min{} ago", mins, if mins == 1 { "" } else { "s" });
238− }
239− if seconds < 60 * 60 * 24 {
240− let hours = seconds / (60 * 60);
241− return format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" });
242− }
243− let days = seconds / (60 * 60 * 24);
244− format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
245−}
246−
247−fn build_mount_rows(cwd: &Path, config: &config::Config) -> Result<Vec<tui::MountListRow>> {
248− let mut rows = Vec::new();
249− rows.extend(default_mounts(cwd)?);
250− for spec in &config.box_cfg.mounts {
251− rows.push(parse_mount_spec(cwd, spec, false)?);
252− }
253− Ok(rows)
254−}
255−
256−fn build_network_rows(cwd: &Path) -> Result<Vec<tui::NetworkListRow>> {
257− let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME);
258− let mut vm_ip = "-".to_string();
259− if let Ok(Some(ip)) = instance::read_instance_vm_ip(&instance_dir) {
260− vm_ip = ip;
261− }
262− let host_to_vm = if vm_ip == "-" {
263− "ssh: <pending>:22".to_string()
264− } else {
265− format!("ssh: {vm_ip}:22")
266− };
267− let row = tui::NetworkListRow {
268− network_type: "NAT".to_string(),
269− vm_ip: vm_ip.clone(),
270− host_to_vm,
271− vm_to_host: "none".to_string(),
272− };
273− Ok(vec![row])
274−}
275−
276−fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>> {
277− let project_name = cwd
278− .file_name()
279− .and_then(|name| name.to_str())
280− .unwrap_or("project");
281− let project_guest = format!("/root/{project_name}");
282− let project_host = relative_to_home(&cwd.to_path_buf());
283− let mut rows = vec![tui::MountListRow {
284− host: project_host,
285− guest: project_guest,
286− mode: "read-write".to_string(),
287− default_mount: "yes".to_string(),
288− }];
289−
290− let home = env::var("HOME")
291− .map(PathBuf::from)
292− .unwrap_or_else(|_| PathBuf::from("/"));
293− let cache_home = env::var("XDG_CACHE_HOME")
294− .map(PathBuf::from)
295− .unwrap_or_else(|_| home.join(".cache"));
296− let cache_dir = cache_home.join(session_manager::GLOBAL_CACHE_DIR_NAME);
297− let guest_mise_cache = cache_dir.join(".guest-mise-cache");
298− rows.push(tui::MountListRow {
299− host: relative_to_home(&guest_mise_cache),
300− guest: "/root/.local/share/mise".to_string(),
301− mode: "read-write".to_string(),
302− default_mount: "yes".to_string(),
303− });
304− Ok(rows)
305−}
306−
307−fn parse_mount_spec(cwd: &Path, spec: &str, default_mount: bool) -> Result<tui::MountListRow> {
308− let parts: Vec<&str> = spec.split(':').collect();
309− if parts.len() < 2 || parts.len() > 3 {
310− return Err(color_eyre::eyre::eyre!("invalid mount spec: {spec}"));
311− }
312− let host_part = parts[0];
313− let guest_part = parts[1];
314− let mode = if parts.len() == 3 {
315− match parts[2] {
316− "read-only" => "read-only",
317− "read-write" => "read-write",
318− other => {
319− return Err(color_eyre::eyre::eyre!(
320− "invalid mount mode '{}'; expected read-only or read-write",
321− other
322− ));
323− }
324− }
325− } else {
326− "read-write"
327− };
328− let host_path = resolve_host_path(cwd, host_part);
329− let host_display = relative_to_home(&host_path);
330− let guest_display = if Path::new(guest_part).is_absolute() {
331− guest_part.to_string()
332− } else {
333− format!("/root/{guest_part}")
334− };
335− Ok(tui::MountListRow {
336− host: host_display,
337− guest: guest_display,
338− mode: mode.to_string(),
339− default_mount: if default_mount { "yes" } else { "no" }.to_string(),
340− })
341−}
342−
343−fn resolve_host_path(cwd: &Path, host: &str) -> PathBuf {
344− if let Some(stripped) = host.strip_prefix("~/") {
345− if let Ok(home) = env::var("HOME") {
346− return PathBuf::from(home).join(stripped);
347− }
348− } else if host == "~" {
349− if let Ok(home) = env::var("HOME") {
350− return PathBuf::from(home);
351− }
352− }
353− let host_path = PathBuf::from(host);
354− if host_path.is_absolute() {
355− host_path
356− } else {
357− cwd.join(host_path)
358− }
359−}
360−
361−fn init_tracing() {
362− let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
363− let ansi = std::io::stderr().is_terminal() && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
364− let _ = tracing_subscriber::fmt()
365− .with_env_filter(filter)
366− .with_target(false)
367− .with_ansi(ansi)
368− .with_writer(std::io::stderr)
369− .try_init();
370−}
src/bin/vibebox-supervisor.rs+35−14
@@ -5,22 +5,32 @@ use std::{
55
66use color_eyre::Result;
77use tracing_subscriber::EnvFilter;
8+use tracing_subscriber::filter::LevelFilter;
9+use tracing_subscriber::fmt;
10+use tracing_subscriber::prelude::*;
811
912use vibebox::{config, instance, vm, vm_manager};
1013
1114fn main() -> Result<()> {
15+ if env::var("VIBEBOX_INTERNAL").as_deref() != Ok("1") {
16+ eprintln!("vibebox-supervisor is internal. Use `vibebox` instead.");
17+ std::process::exit(2);
18+ }
19+
1220 init_tracing();
1321 color_eyre::install()?;
1422
1523 tracing::info!("starting vm supervisor");
16− let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
17− let config = config::load_config(&cwd);
18− let instance_dir = instance::ensure_instance_dir(&cwd)
24+ let project_dir = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
25+ let config = config::load_config(&project_dir)
1926 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
20− let _ = instance::touch_last_active(&instance_dir);
27+ let _ = instance::ensure_instance_dir(&project_dir)
28+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
29+ let _ = instance::touch_last_active(&project_dir);
2130 let args = vm::VmArg {
2231 cpu_count: config.box_cfg.cpu_count,
23− ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
32+ ram_bytes: config.box_cfg.ram_size.as_u64(),
33+ disk_bytes: config.box_cfg.disk_size.as_u64(),
2434 no_default_mounts: false,
2535 mounts: config.box_cfg.mounts.clone(),
2636 };
@@ -28,7 +38,7 @@ fn main() -> Result<()> {
2838 tracing::info!(auto_shutdown_ms, "vm supervisor config");
2939
3040 let result = vm_manager::run_manager(args, auto_shutdown_ms);
31− let _ = instance::touch_last_active(&instance_dir);
41+ let _ = instance::touch_last_active(&project_dir);
3242 if let Err(err) = result {
3343 tracing::error!(error = %err, "vm supervisor exited");
3444 return Err(color_eyre::eyre::eyre!(err.to_string()));
@@ -39,12 +49,23 @@ fn main() -> Result<()> {
3949}
4050
4151fn init_tracing() {
42− let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
43− let ansi = std::io::stderr().is_terminal() && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
44− let _ = tracing_subscriber::fmt()
45− .with_env_filter(filter)
46− .with_target(false)
47− .with_ansi(ansi)
48− .with_writer(io::stderr)
49− .try_init();
52+ let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
53+ let stderr_is_tty = std::io::stderr().is_terminal();
54+ let ansi = stderr_is_tty && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
55+ if stderr_is_tty {
56+ let stderr_layer = fmt::layer()
57+ .with_target(false)
58+ .with_ansi(ansi)
59+ .without_time()
60+ .with_writer(io::stderr)
61+ .with_filter(LevelFilter::INFO);
62+ let _ = tracing_subscriber::registry().with(stderr_layer).try_init();
63+ } else {
64+ let stderr_layer = fmt::layer()
65+ .with_target(false)
66+ .with_ansi(ansi)
67+ .with_writer(io::stderr)
68+ .with_filter(filter);
69+ let _ = tracing_subscriber::registry().with(stderr_layer).try_init();
70+ }
5071}
src/bin/vibebox.rsnew file+400
@@ -0,0 +1,400 @@
1+use bytesize::ByteSize;
2+use clap::Parser;
3+use color_eyre::Result;
4+use dialoguer::Confirm;
5+use std::{
6+ env,
7+ ffi::OsString,
8+ fs,
9+ io::{self, IsTerminal, Write},
10+ path::{Path, PathBuf},
11+ sync::{Arc, Mutex},
12+};
13+use time::OffsetDateTime;
14+use time::format_description::well_known::Rfc3339;
15+use tracing_subscriber::filter::LevelFilter;
16+use tracing_subscriber::registry::Registry;
17+use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload};
18+
19+use vibebox::tui::{AppState, VmInfo};
20+use vibebox::utils::relative_to_home;
21+use vibebox::{
22+ SessionManager, commands, config, explain, instance, session_manager, tui, vm_manager,
23+};
24+
25+#[derive(Debug, Parser)]
26+#[command(name = "vibebox", version, about = "Vibebox CLI")]
27+struct Cli {
28+ /// Path to vibebox.toml (relative to the current directory)
29+ #[arg(short = 'c', long = "config", value_name = "PATH", global = true)]
30+ config: Option<PathBuf>,
31+ #[command(subcommand)]
32+ command: Option<Command>,
33+}
34+
35+#[derive(Debug, clap::Subcommand)]
36+enum Command {
37+ /// List all sessions
38+ List,
39+ /// Reset the current project's .vibebox directory
40+ Reset,
41+ /// Purge the global cache directory
42+ PurgeCache,
43+ /// Explain mounts and mappings
44+ Explain,
45+}
46+
47+fn main() -> Result<()> {
48+ color_eyre::install()?;
49+ let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
50+ let stderr_handle = init_tracing(&cwd);
51+
52+ let cli = Cli::parse();
53+ tracing::debug!(cwd = %cwd.display(), "starting vibebox cli");
54+ if let Some(command) = cli.command {
55+ return handle_command(command, &cwd, cli.config.as_deref());
56+ }
57+
58+ let config_override = cli.config.clone();
59+ let raw_args: Vec<OsString> = env::args_os().collect();
60+ let config = config::load_config_with_path(&cwd, config_override.as_deref())
61+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
62+
63+ let vm_info = VmInfo {
64+ max_memory: config.box_cfg.ram_size,
65+ cpu_cores: config.box_cfg.cpu_count,
66+ max_disk: config.box_cfg.disk_size,
67+ system_name: "Debian".to_string(), // TODO: read system name from the VM.
68+ auto_shutdown_ms: config.supervisor.auto_shutdown_ms,
69+ };
70+ if let Ok(manager) = SessionManager::new() {
71+ if let Err(err) = manager.update_global_sessions(&cwd) {
72+ tracing::warn!(error = %err, "failed to update a global session list");
73+ }
74+ } else {
75+ tracing::error!("failed to initialize session manager");
76+ std::process::exit(1);
77+ }
78+ let commands = commands::build_commands();
79+ let app = Arc::new(Mutex::new(AppState::new(cwd.clone(), vm_info, commands)));
80+ {
81+ let mut locked = app.lock().expect("app state poisoned");
82+ tui::render_tui_once(&mut locked)?;
83+ }
84+ {
85+ let mut stdout = io::stdout().lock();
86+ writeln!(stdout)?;
87+ stdout.flush()?;
88+ }
89+ warn_disk_size_mismatch(&cwd, config.box_cfg.disk_size);
90+ if let Some(handle) = stderr_handle {
91+ let _ = handle.modify(|filter| *filter = LevelFilter::INFO);
92+ }
93+
94+ tracing::debug!(config.supervisor.auto_shutdown_ms, "auto shutdown config");
95+ let manager_conn = vm_manager::ensure_manager(
96+ &raw_args,
97+ config.supervisor.auto_shutdown_ms,
98+ config_override.as_deref(),
99+ )
100+ .map_err(|err| {
101+ tracing::error!(error = %err, "failed to ensure vm manager");
102+ color_eyre::eyre::eyre!(err.to_string())
103+ })?;
104+
105+ if let Err(err) = instance::run_with_ssh(manager_conn) {
106+ if let Some(instance::InstanceError::UnexpectedDisconnection) =
107+ err.downcast_ref::<instance::InstanceError>()
108+ {
109+ tracing::warn!("vm manager disconnected; exiting vibebox");
110+ } else if let Some(instance::InstanceError::VMError(vm_error)) =
111+ err.downcast_ref::<instance::InstanceError>()
112+ {
113+ tracing::error!("[vm]: {vm_error}");
114+ tracing::info!("vibecoding paused: the VM says today is a rest day 😴");
115+ std::process::exit(1);
116+ } else {
117+ let message = err.to_string();
118+ tracing::error!(error = %message, "vibebox exited: uncaught error");
119+ return Err(color_eyre::eyre::eyre!(message));
120+ }
121+ }
122+
123+ tracing::info!("See you again — keep vibecoding (no SEVs, only vibes) 😈");
124+
125+ Ok(())
126+}
127+
128+fn handle_command(command: Command, cwd: &Path, config_override: Option<&Path>) -> Result<()> {
129+ match command {
130+ Command::List => {
131+ let manager = SessionManager::new()?;
132+ let sessions = manager.list_sessions()?;
133+ if sessions.is_empty() {
134+ println!("No sessions were found.");
135+ return Ok(());
136+ }
137+ let rows: Vec<tui::SessionListRow> = sessions
138+ .into_iter()
139+ .map(|session| tui::SessionListRow {
140+ name: project_name(&session.directory),
141+ id: session.id,
142+ directory: relative_to_home(&session.directory),
143+ last_active: format_last_active(session.last_active.as_deref()),
144+ active: if session.active {
145+ "yes".to_string()
146+ } else {
147+ "no".to_string()
148+ },
149+ })
150+ .collect();
151+ tui::render_sessions_table(&rows)?;
152+ Ok(())
153+ }
154+ Command::Reset => {
155+ let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME);
156+ if !instance_dir.exists() {
157+ println!("No .vibebox directory found at {}", instance_dir.display());
158+ return Ok(());
159+ }
160+ let confirmed = Confirm::new()
161+ .with_prompt(format!(
162+ "Delete {} and all its contents?",
163+ instance_dir.display()
164+ ))
165+ .default(false)
166+ .interact()?;
167+ if !confirmed {
168+ println!("Cancelled.");
169+ return Ok(());
170+ }
171+ let manager = SessionManager::new()?;
172+ let summary = manager.clean_project(cwd)?;
173+ println!(
174+ "Deleted {} (removed={}, session_records_removed={})",
175+ summary.instance_dir.display(),
176+ summary.removed_instance_dir,
177+ summary.removed_sessions
178+ );
179+ Ok(())
180+ }
181+ Command::PurgeCache => {
182+ let cache_dir = cache_dir()?;
183+ if !cache_dir.exists() {
184+ println!("No cache directory found at {}", cache_dir.display());
185+ return Ok(());
186+ }
187+ let (file_count, total_bytes) = measure_dir(&cache_dir)?;
188+ let confirmed = Confirm::new()
189+ .with_prompt(format!(
190+ "Delete cache directory {} and all its contents?",
191+ cache_dir.display()
192+ ))
193+ .default(false)
194+ .interact()?;
195+ if !confirmed {
196+ println!("Cancelled.");
197+ return Ok(());
198+ }
199+ fs::remove_dir_all(&cache_dir)?;
200+ println!(
201+ "Purged {} file{} totaling {} from {}",
202+ file_count,
203+ if file_count == 1 { "" } else { "s" },
204+ ByteSize(total_bytes),
205+ cache_dir.display()
206+ );
207+ Ok(())
208+ }
209+ Command::Explain => {
210+ let config = config::load_config_with_path(cwd, config_override)
211+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
212+ let mounts = explain::build_mount_rows(cwd, &config)
213+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
214+ let networks = explain::build_network_rows(cwd)
215+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
216+ if mounts.is_empty() && networks.is_empty() {
217+ println!("No mounts or network info available.");
218+ return Ok(());
219+ }
220+ tui::render_explain_tables(&mounts, &networks)?;
221+ Ok(())
222+ }
223+ }
224+}
225+
226+fn project_name(directory: &Path) -> String {
227+ directory
228+ .file_name()
229+ .and_then(|name| name.to_str())
230+ .unwrap_or("-")
231+ .to_string()
232+}
233+
234+fn cache_dir() -> Result<PathBuf> {
235+ let home = env::var("HOME").map(PathBuf::from)?;
236+ let cache_home = env::var("XDG_CACHE_HOME")
237+ .map(PathBuf::from)
238+ .unwrap_or_else(|_| home.join(".cache"));
239+ Ok(cache_home.join(session_manager::GLOBAL_CACHE_DIR_NAME))
240+}
241+
242+fn measure_dir(path: &Path) -> Result<(u64, u64)> {
243+ let mut total_bytes = 0u64;
244+ let mut file_count = 0u64;
245+ let mut stack = vec![path.to_path_buf()];
246+ while let Some(current) = stack.pop() {
247+ let entries = match fs::read_dir(&current) {
248+ Ok(entries) => entries,
249+ Err(err) => {
250+ tracing::warn!(path = %current.display(), error = %err, "failed to read directory");
251+ continue;
252+ }
253+ };
254+ for entry in entries {
255+ let entry = match entry {
256+ Ok(entry) => entry,
257+ Err(err) => {
258+ tracing::warn!(path = %current.display(), error = %err, "failed to read directory entry");
259+ continue;
260+ }
261+ };
262+ let path = entry.path();
263+ let metadata = match fs::symlink_metadata(&path) {
264+ Ok(metadata) => metadata,
265+ Err(err) => {
266+ tracing::warn!(path = %path.display(), error = %err, "failed to stat path");
267+ continue;
268+ }
269+ };
270+ let file_type = metadata.file_type();
271+ if file_type.is_dir() {
272+ stack.push(path);
273+ } else {
274+ file_count += 1;
275+ total_bytes = total_bytes.saturating_add(metadata.len());
276+ }
277+ }
278+ }
279+ Ok((file_count, total_bytes))
280+}
281+
282+fn format_last_active(value: Option<&str>) -> String {
283+ let Some(raw) = value else {
284+ return "-".to_string();
285+ };
286+ let parsed = OffsetDateTime::parse(raw, &Rfc3339);
287+ let Ok(timestamp) = parsed else {
288+ return raw.to_string();
289+ };
290+ let now = OffsetDateTime::now_utc();
291+ let mut seconds = (now - timestamp).whole_seconds();
292+ if seconds < 0 {
293+ seconds = 0;
294+ }
295+ let week_seconds = 7 * 24 * 60 * 60;
296+ if seconds >= week_seconds {
297+ let formatted =
298+ match time::format_description::parse("[year]-[month]-[day] [hour]:[minute]Z") {
299+ Ok(format) => timestamp
300+ .format(&format)
301+ .unwrap_or_else(|_| raw.to_string()),
302+ Err(_) => timestamp
303+ .format(&Rfc3339)
304+ .unwrap_or_else(|_| raw.to_string()),
305+ };
306+ return formatted;
307+ }
308+ if seconds < 60 {
309+ return "just now".to_string();
310+ }
311+ if seconds < 60 * 60 {
312+ let mins = seconds / 60;
313+ return format!("{} min{} ago", mins, if mins == 1 { "" } else { "s" });
314+ }
315+ if seconds < 60 * 60 * 24 {
316+ let hours = seconds / (60 * 60);
317+ return format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" });
318+ }
319+ let days = seconds / (60 * 60 * 24);
320+ format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
321+}
322+
323+fn warn_disk_size_mismatch(cwd: &Path, configured_size: ByteSize) {
324+ let instance_raw = cwd
325+ .join(session_manager::INSTANCE_DIR_NAME)
326+ .join("instance.raw");
327+ let Ok(meta) = fs::metadata(&instance_raw) else {
328+ return;
329+ };
330+ let current_size = ByteSize::b(meta.len());
331+ if current_size == configured_size {
332+ return;
333+ }
334+ tracing::warn!(
335+ "instance disk size does not match config (current {}, config {}). \
336+disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using the existing disk.",
337+ current_size,
338+ configured_size,
339+ );
340+}
341+
342+type StderrHandle = reload::Handle<LevelFilter, Registry>;
343+
344+fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
345+ let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
346+ let file_filter = filter.clone();
347+ let stderr_is_tty = io::stderr().is_terminal();
348+ let ansi = stderr_is_tty && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
349+ let file = instance::ensure_instance_dir(cwd)
350+ .ok()
351+ .and_then(|instance_dir| {
352+ let log_path = instance_dir.join("cli.log");
353+ fs::OpenOptions::new()
354+ .create(true)
355+ .write(true)
356+ .truncate(true)
357+ .open(log_path)
358+ .ok()
359+ });
360+
361+ if stderr_is_tty {
362+ let (stderr_filter, handle) = reload::Layer::new(LevelFilter::INFO);
363+ let stderr_layer = fmt::layer()
364+ .with_target(false)
365+ .with_ansi(ansi)
366+ .without_time()
367+ .with_writer(io::stderr)
368+ .with_filter(stderr_filter);
369+ let subscriber = tracing_subscriber::registry().with(stderr_layer);
370+ if let Some(file) = file {
371+ let file_layer = fmt::layer()
372+ .with_target(false)
373+ .with_ansi(false)
374+ .with_writer(file)
375+ .with_filter(file_filter);
376+ let _ = subscriber.with(file_layer).try_init();
377+ } else {
378+ let _ = subscriber.try_init();
379+ }
380+ Some(handle)
381+ } else {
382+ let stderr_layer = fmt::layer()
383+ .with_target(false)
384+ .with_ansi(ansi)
385+ .with_writer(io::stderr)
386+ .with_filter(filter);
387+ let subscriber = tracing_subscriber::registry().with(stderr_layer);
388+ if let Some(file) = file {
389+ let file_layer = fmt::layer()
390+ .with_target(false)
391+ .with_ansi(false)
392+ .with_writer(file)
393+ .with_filter(file_filter);
394+ let _ = subscriber.with(file_layer).try_init();
395+ } else {
396+ let _ = subscriber.try_init();
397+ }
398+ None
399+ }
400+}
src/config.rs+472−91
@@ -1,47 +1,105 @@
1+use anyhow::{Context, Error, Result, bail};
2+use bytesize::ByteSize;
3+use serde::{Deserialize, Serialize};
14use std::{
25 env, fs, io,
36 path::{Path, PathBuf},
47};
58
6−use serde::{Deserialize, Serialize};
7−
89use crate::vm::DirectoryShare;
910
10−pub const CONFIG_FILENAME: &str = "vibebox.toml";
11+const CONFIG_FILENAME: &str = "vibebox.toml";
1112pub const CONFIG_PATH_ENV: &str = "VIBEBOX_CONFIG_PATH";
1213
1314const DEFAULT_CPU_COUNT: usize = 2;
1415const DEFAULT_RAM_MB: u64 = 2048;
1516const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 20000;
17+const DEFAULT_DISK_GB: u64 = 5;
1618
17−#[derive(Debug, Clone, Serialize, Deserialize)]
19+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1820pub struct Config {
1921 #[serde(rename = "box")]
2022 pub box_cfg: BoxConfig,
2123 pub supervisor: SupervisorConfig,
2224}
2325
24−impl Default for Config {
25− fn default() -> Self {
26− Self {
27− box_cfg: BoxConfig::default(),
28− supervisor: SupervisorConfig::default(),
26+const MI_B: u64 = 1024 * 1024;
27+const GI_B: u64 = 1024 * 1024 * 1024;
28+
29+mod serde_mb {
30+ use super::{ByteSize, MI_B};
31+ use serde::{Deserialize, Deserializer, Serializer};
32+
33+ pub fn serialize<S>(v: &ByteSize, s: S) -> Result<S::Ok, S::Error>
34+ where
35+ S: Serializer,
36+ {
37+ let bytes = v.0;
38+ if !bytes.is_multiple_of(MI_B) {
39+ return Err(serde::ser::Error::custom(
40+ "ram_mb must be an integer number of MB",
41+ ));
2942 }
43+ s.serialize_u64(bytes / MI_B)
44+ }
45+
46+ pub fn deserialize<'de, D>(d: D) -> Result<ByteSize, D::Error>
47+ where
48+ D: Deserializer<'de>,
49+ {
50+ let mb = u64::deserialize(d)?;
51+ let bytes = mb
52+ .checked_mul(MI_B)
53+ .ok_or_else(|| serde::de::Error::custom("ram_mb overflow"))?;
54+ Ok(ByteSize(bytes))
55+ }
56+}
57+
58+mod serde_gb {
59+ use super::{ByteSize, GI_B};
60+ use serde::{Deserialize, Deserializer, Serializer};
61+
62+ pub fn serialize<S>(v: &ByteSize, s: S) -> Result<S::Ok, S::Error>
63+ where
64+ S: Serializer,
65+ {
66+ let bytes = v.0;
67+ if !bytes.is_multiple_of(GI_B) {
68+ return Err(serde::ser::Error::custom(
69+ "disk_gb must be an integer number of GB",
70+ ));
71+ }
72+ s.serialize_u64(bytes / GI_B)
73+ }
74+
75+ pub fn deserialize<'de, D>(d: D) -> Result<ByteSize, D::Error>
76+ where
77+ D: Deserializer<'de>,
78+ {
79+ let gb = u64::deserialize(d)?;
80+ let bytes = gb
81+ .checked_mul(GI_B)
82+ .ok_or_else(|| serde::de::Error::custom("disk_gb overflow"))?;
83+ Ok(ByteSize(bytes))
3084 }
3185}
3286
3387#[derive(Debug, Clone, Serialize, Deserialize)]
3488pub struct BoxConfig {
3589 pub cpu_count: usize,
36− pub ram_mb: u64,
90+ #[serde(rename = "ram_mb", with = "serde_mb")]
91+ pub ram_size: ByteSize,
92+ #[serde(rename = "disk_gb", with = "serde_gb")]
93+ pub disk_size: ByteSize,
3794 pub mounts: Vec<String>,
3895}
3996
4097impl Default for BoxConfig {
4198 fn default() -> Self {
4299 Self {
43− cpu_count: default_cpu_count(),
44− ram_mb: default_ram_mb(),
100+ cpu_count: DEFAULT_CPU_COUNT,
101+ ram_size: ByteSize::mib(DEFAULT_RAM_MB),
102+ disk_size: ByteSize::gib(DEFAULT_DISK_GB),
45103 mounts: default_mounts(),
46104 }
47105 }
@@ -55,50 +113,24 @@ pub struct SupervisorConfig {
55113impl Default for SupervisorConfig {
56114 fn default() -> Self {
57115 Self {
58− auto_shutdown_ms: default_auto_shutdown_ms(),
116+ auto_shutdown_ms: DEFAULT_AUTO_SHUTDOWN_MS,
59117 }
60118 }
61119}
62120
63−fn default_cpu_count() -> usize {
64− DEFAULT_CPU_COUNT
65−}
66−
67−fn default_ram_mb() -> u64 {
68− DEFAULT_RAM_MB
69−}
70−
71−fn default_auto_shutdown_ms() -> u64 {
72− DEFAULT_AUTO_SHUTDOWN_MS
73−}
74−
75121fn default_mounts() -> Vec<String> {
76− let home = match std::env::var("HOME") {
77− Ok(home) => PathBuf::from(home),
78− Err(_) => return Vec::new(),
79− };
80−
81− let mut mounts = Vec::new();
82− let codex_host = home.join(".codex");
83− if codex_host.exists() {
84− mounts.push("~/.codex:/usr/local/codex:read-write".to_string());
85− }
86− let claude_host = home.join(".claude");
87− if claude_host.exists() {
88− mounts.push("~/.claude:/usr/local/claude:read-write".to_string());
89− }
90− mounts
122+ vec![
123+ "~/.codex:~/.codex:read-write".into(),
124+ "~/.claude:~/.claude:read-write".into(),
125+ ]
91126}
92127
93128pub fn config_path(project_root: &Path) -> PathBuf {
94129 project_root.join(CONFIG_FILENAME)
95130}
96131
97−pub fn ensure_config_file(
98− project_root: &Path,
99− override_path: Option<&Path>,
100−) -> Result<PathBuf, io::Error> {
101− let path = resolve_config_path(project_root, override_path);
132+pub fn ensure_config_file(project_root: &Path, override_path: Option<&Path>) -> Result<PathBuf> {
133+ let path = resolve_config_path(project_root, override_path)?;
102134 if !path.exists() {
103135 let default_config = Config::default();
104136 let contents = toml::to_string_pretty(&default_config).unwrap_or_default();
@@ -108,32 +140,24 @@ pub fn ensure_config_file(
108140 Ok(path)
109141}
110142
111−pub fn load_config(project_root: &Path) -> Config {
143+pub fn load_config(project_root: &Path) -> Result<Config> {
112144 load_config_with_path(project_root, None)
113145}
114146
115−pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) -> Config {
116− let path = match ensure_config_file(project_root, override_path) {
117− Ok(path) => path,
118− Err(err) => die(&format!("failed to create config: {err}")),
119− };
120− let raw = match fs::read_to_string(&path) {
121− Ok(raw) => raw,
122− Err(err) => die(&format!("failed to read config: {err}")),
123− };
147+pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) -> Result<Config> {
148+ let path =
149+ ensure_config_file(project_root, override_path).context("failed to create config")?;
150+ let raw = fs::read_to_string(&path).context("failed to read config")?;
124151 let trimmed = raw.trim();
125152 tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config");
126153 if trimmed.is_empty() {
127− die(&format!(
128− "config file ({}) is empty. Required fields: [box].cpu_count (integer), [box].ram_mb (integer), [box].mounts (array of strings), [supervisor].auto_shutdown_ms (integer)",
154+ bail!(format!(
155+ "config file ({}) is empty. Required fields: [box].cpu_count (integer), [box].ram_mb (integer), [box].disk_gb (integer), [box].mounts (array of strings), [supervisor].auto_shutdown_ms (integer)",
129156 path.display()
130157 ));
131158 }
132159
133− let value: toml::Value = match toml::from_str(trimmed) {
134− Ok(value) => value,
135− Err(err) => die(&format!("invalid config: {err}")),
136− };
160+ let value: toml::Value = toml::from_str(trimmed).context("invalid config")?;
137161 let schema_errors = validate_schema(&value);
138162 if !schema_errors.is_empty() {
139163 let message = format!(
@@ -141,27 +165,28 @@ pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>)
141165 path.display(),
142166 schema_errors.join("\n- ")
143167 );
144− die(&message);
168+ bail!(message);
145169 }
146170
147− let config: Config = match toml::from_str(trimmed) {
148− Ok(config) => config,
149− Err(err) => die(&format!("invalid config: {err}")),
150− };
151− validate_or_exit(&config);
152− config
171+ let config: Config = toml::from_str(trimmed).context("invalid config")?;
172+ validate_config(&config).map_err(Error::msg)?;
173+ Ok(config)
153174}
154175
155−fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> PathBuf {
156− let root = match fs::canonicalize(project_root) {
157− Ok(root) => root,
158− Err(err) => die(&format!("failed to resolve project root: {err}")),
159− };
176+fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> Result<PathBuf> {
177+ let env_override = env::var_os(CONFIG_PATH_ENV).map(PathBuf::from);
178+ resolve_config_path_inner(project_root, override_path, env_override)
179+}
180+
181+fn resolve_config_path_inner(
182+ project_root: &Path,
183+ override_path: Option<&Path>,
184+ env_override: Option<PathBuf>,
185+) -> Result<PathBuf> {
186+ let root = fs::canonicalize(project_root).context("failed to resolve project root")?;
160187
161− let override_path = override_path
162− .map(PathBuf::from)
163− .or_else(|| env::var_os(CONFIG_PATH_ENV).map(PathBuf::from));
164− let raw_path = if let Some(path) = override_path {
188+ let selected_path = override_path.map(PathBuf::from).or(env_override);
189+ let raw_path = if let Some(path) = selected_path {
165190 if path.is_absolute() {
166191 path
167192 } else {
@@ -172,14 +197,50 @@ fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> Pat
172197 };
173198
174199 let normalized = normalize_path(&raw_path);
175− if !normalized.starts_with(&root) {
176− die(&format!(
200+ let resolved =
201+ resolve_path_for_boundary_check(&normalized).context("failed to resolve config path")?;
202+ if !resolved.starts_with(&root) {
203+ bail!(
177204 "config path must be within {}: {}",
178205 root.display(),
179− normalized.display()
180− ));
206+ resolved.display()
207+ );
181208 }
182− normalized
209+ Ok(normalized)
210+}
211+
212+fn resolve_path_for_boundary_check(path: &Path) -> Result<PathBuf, io::Error> {
213+ if path.exists() {
214+ return fs::canonicalize(path);
215+ }
216+ let (ancestor, missing) = nearest_existing_ancestor(path)?;
217+ let mut resolved = fs::canonicalize(ancestor)?;
218+ for part in missing {
219+ resolved.push(part);
220+ }
221+ Ok(resolved)
222+}
223+
224+fn nearest_existing_ancestor(path: &Path) -> Result<(&Path, Vec<std::ffi::OsString>), io::Error> {
225+ let mut current = path;
226+ let mut missing = Vec::new();
227+ while !current.exists() {
228+ let name = current.file_name().ok_or_else(|| {
229+ io::Error::new(
230+ io::ErrorKind::InvalidInput,
231+ format!("path has no existing ancestor: {}", path.display()),
232+ )
233+ })?;
234+ missing.push(name.to_os_string());
235+ current = current.parent().ok_or_else(|| {
236+ io::Error::new(
237+ io::ErrorKind::InvalidInput,
238+ format!("path has no parent: {}", path.display()),
239+ )
240+ })?;
241+ }
242+ missing.reverse();
243+ Ok((current, missing))
183244}
184245
185246fn normalize_path(path: &Path) -> PathBuf {
@@ -216,6 +277,7 @@ fn validate_schema(value: &toml::Value) -> Vec<String> {
216277 Some(table) => {
217278 validate_int(table, "cpu_count", "[box].cpu_count (integer)", &mut errors);
218279 validate_int(table, "ram_mb", "[box].ram_mb (integer)", &mut errors);
280+ validate_int(table, "disk_gb", "[box].disk_gb (integer)", &mut errors);
219281 validate_string_array(
220282 table,
221283 "mounts",
@@ -275,24 +337,343 @@ fn validate_string_array(
275337 }
276338}
277339
278−fn validate_or_exit(config: &Config) {
340+fn validate_config(config: &Config) -> Result<(), String> {
279341 if config.box_cfg.cpu_count == 0 {
280− die("box.cpu_count must be >= 1");
342+ return Err("box.cpu_count must be >= 1".to_string());
281343 }
282− if config.box_cfg.ram_mb == 0 {
283− die("box.ram_mb must be >= 1");
344+ if config.box_cfg.ram_size.as_mib() == 0.0 {
345+ return Err("box.ram_mb must be >= 1".to_string());
346+ }
347+ if config.box_cfg.disk_size.as_gib() == 0.0 {
348+ return Err("box.disk_gb must be >= 1".to_string());
284349 }
285350 if config.supervisor.auto_shutdown_ms == 0 {
286− die("supervisor.auto_shutdown_ms must be >= 1");
351+ return Err("supervisor.auto_shutdown_ms must be >= 1".to_string());
287352 }
288353 for spec in &config.box_cfg.mounts {
289354 if let Err(err) = DirectoryShare::from_mount_spec(spec) {
290− die(&format!("invalid mount spec '{spec}': {err}"));
355+ return Err(format!("invalid mount spec '{spec}': {err}"));
291356 }
292357 }
358+ Ok(())
293359}
294360
295−fn die(message: &str) -> ! {
296− eprintln!("[vibebox] {message}");
297− std::process::exit(1);
361+#[cfg(test)]
362+mod tests {
363+ use super::*;
364+ use std::ffi::OsString;
365+ use std::sync::Mutex;
366+ use tempfile::TempDir;
367+
368+ static ENV_MUTEX: Mutex<()> = Mutex::new(());
369+
370+ struct EnvGuard {
371+ key: &'static str,
372+ previous: Option<OsString>,
373+ }
374+
375+ impl EnvGuard {
376+ fn set(key: &'static str, value: &Path) -> Self {
377+ let previous = env::var_os(key);
378+ unsafe {
379+ env::set_var(key, value);
380+ }
381+ Self { key, previous }
382+ }
383+ }
384+
385+ impl Drop for EnvGuard {
386+ fn drop(&mut self) {
387+ match &self.previous {
388+ Some(value) => unsafe {
389+ env::set_var(self.key, value);
390+ },
391+ None => unsafe {
392+ env::remove_var(self.key);
393+ },
394+ }
395+ }
396+ }
397+
398+ #[test]
399+ fn default_config_serializes_with_legacy_keys() {
400+ let cfg = Config::default();
401+ let serialized = toml::to_string(&cfg).expect("default config should serialize");
402+
403+ assert!(serialized.contains("ram_mb = 2048"));
404+ assert!(serialized.contains("disk_gb = 5"));
405+ assert!(!serialized.contains("ram_size"));
406+ assert!(!serialized.contains("disk_size"));
407+ }
408+
409+ #[test]
410+ fn config_deserializes_sizes_from_mb_and_gb() {
411+ let raw = r#"
412+[box]
413+cpu_count = 4
414+ram_mb = 3072
415+disk_gb = 12
416+mounts = ["~/src:~/src:read-write"]
417+
418+[supervisor]
419+auto_shutdown_ms = 15000
420+"#;
421+ let cfg: Config = toml::from_str(raw).expect("config should deserialize");
422+
423+ assert_eq!(cfg.box_cfg.cpu_count, 4);
424+ assert_eq!(cfg.box_cfg.ram_size.as_u64(), ByteSize::mib(3072).as_u64());
425+ assert_eq!(cfg.box_cfg.disk_size.as_u64(), ByteSize::gib(12).as_u64());
426+ assert_eq!(cfg.supervisor.auto_shutdown_ms, 15000);
427+ }
428+
429+ #[test]
430+ fn serialize_rejects_non_integral_mb_or_gb() {
431+ let cfg = Config {
432+ box_cfg: BoxConfig {
433+ cpu_count: 2,
434+ ram_size: ByteSize::b((2 * MI_B) + 1),
435+ disk_size: ByteSize::gib(5),
436+ mounts: default_mounts(),
437+ },
438+ supervisor: SupervisorConfig::default(),
439+ };
440+
441+ let err = toml::to_string(&cfg).expect_err("serialization should reject invalid MB");
442+ assert!(
443+ err.to_string()
444+ .contains("ram_mb must be an integer number of MB")
445+ );
446+
447+ let cfg = Config {
448+ box_cfg: BoxConfig {
449+ cpu_count: 2,
450+ ram_size: ByteSize::mib(2048),
451+ disk_size: ByteSize::b((5 * GI_B) + 1),
452+ mounts: default_mounts(),
453+ },
454+ supervisor: SupervisorConfig::default(),
455+ };
456+
457+ let err = toml::to_string(&cfg).expect_err("serialization should reject invalid GB");
458+ assert!(
459+ err.to_string()
460+ .contains("disk_gb must be an integer number of GB")
461+ );
462+ }
463+
464+ #[test]
465+ fn normalize_path_removes_dot_and_parent_components() {
466+ let normalized = normalize_path(Path::new("/tmp/project/./nested/../config.toml"));
467+ assert_eq!(normalized, PathBuf::from("/tmp/project/config.toml"));
468+ }
469+
470+ #[test]
471+ fn validate_schema_returns_errors_for_missing_required_fields() {
472+ let value: toml::Value = toml::from_str(
473+ r#"
474+[box]
475+cpu_count = 2
476+"#,
477+ )
478+ .expect("toml should parse");
479+
480+ let errors = validate_schema(&value);
481+
482+ assert!(errors.iter().any(|e| e == "missing [supervisor] table"));
483+ assert!(errors.iter().any(|e| e == "missing [box].ram_mb (integer)"));
484+ assert!(
485+ errors
486+ .iter()
487+ .any(|e| e == "missing [box].disk_gb (integer)")
488+ );
489+ assert!(
490+ errors
491+ .iter()
492+ .any(|e| e == "missing [box].mounts (array of strings)")
493+ );
494+ }
495+
496+ #[test]
497+ fn validate_schema_errors_when_supervisor_is_not_table() {
498+ let value: toml::Value = toml::from_str(
499+ r#"
500+supervisor = 123
501+
502+[box]
503+cpu_count = 2
504+ram_mb = 2048
505+disk_gb = 5
506+mounts = []
507+"#,
508+ )
509+ .expect("toml should parse");
510+
511+ let errors = validate_schema(&value);
512+ assert!(errors.iter().any(|e| e == "[supervisor] must be a table"));
513+ }
514+
515+ #[test]
516+ fn ensure_config_file_creates_default_config_if_absent() {
517+ let temp = TempDir::new().expect("temp dir should be created");
518+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
519+
520+ let path = ensure_config_file(&root, None).expect("config should be created");
521+ let raw = fs::read_to_string(&path).expect("created config should be readable");
522+ let parsed: Config = toml::from_str(&raw).expect("created config should be valid");
523+
524+ assert_eq!(path, root.join("vibebox.toml"));
525+ assert_eq!(parsed.box_cfg.cpu_count, DEFAULT_CPU_COUNT);
526+ assert_eq!(
527+ parsed.box_cfg.ram_size.as_u64(),
528+ ByteSize::mib(DEFAULT_RAM_MB).as_u64()
529+ );
530+ assert_eq!(
531+ parsed.box_cfg.disk_size.as_u64(),
532+ ByteSize::gib(DEFAULT_DISK_GB).as_u64()
533+ );
534+ }
535+
536+ #[test]
537+ fn load_config_creates_and_loads_default_config() {
538+ let _lock = ENV_MUTEX.lock().expect("env lock should be acquired");
539+ let temp = TempDir::new().expect("temp dir should be created");
540+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
541+ let home = root.join("home");
542+ fs::create_dir_all(home.join(".codex")).expect("home .codex should be created");
543+ fs::create_dir_all(home.join(".claude")).expect("home .claude should be created");
544+ let _home_guard = EnvGuard::set("HOME", &home);
545+
546+ let cfg = load_config(&root).expect("load_config should succeed");
547+
548+ assert_eq!(cfg.box_cfg.cpu_count, DEFAULT_CPU_COUNT);
549+ assert_eq!(
550+ cfg.box_cfg.ram_size.as_u64(),
551+ ByteSize::mib(DEFAULT_RAM_MB).as_u64()
552+ );
553+ assert_eq!(
554+ cfg.box_cfg.disk_size.as_u64(),
555+ ByteSize::gib(DEFAULT_DISK_GB).as_u64()
556+ );
557+ assert!(root.join("vibebox.toml").exists());
558+ }
559+
560+ #[test]
561+ fn load_config_with_path_uses_override_path() {
562+ let _lock = ENV_MUTEX.lock().expect("env lock should be acquired");
563+ let temp = TempDir::new().expect("temp dir should be created");
564+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
565+ let home = root.join("home");
566+ fs::create_dir_all(home.join(".codex")).expect("home .codex should be created");
567+ fs::create_dir_all(home.join(".claude")).expect("home .claude should be created");
568+ let _home_guard = EnvGuard::set("HOME", &home);
569+ let override_path = root.join("custom.toml");
570+
571+ fs::write(
572+ &override_path,
573+ r#"
574+[box]
575+cpu_count = 6
576+ram_mb = 4096
577+disk_gb = 9
578+mounts = ["~/.codex:~/.codex:read-write", "~/.claude:~/.claude:read-write"]
579+
580+[supervisor]
581+auto_shutdown_ms = 12345
582+"#,
583+ )
584+ .expect("override config should be written");
585+
586+ let cfg = load_config_with_path(&root, Some(Path::new("custom.toml")))
587+ .expect("load_config_with_path should succeed");
588+
589+ assert_eq!(cfg.box_cfg.cpu_count, 6);
590+ assert_eq!(cfg.box_cfg.ram_size.as_u64(), ByteSize::mib(4096).as_u64());
591+ assert_eq!(cfg.box_cfg.disk_size.as_u64(), ByteSize::gib(9).as_u64());
592+ assert_eq!(cfg.supervisor.auto_shutdown_ms, 12345);
593+ assert!(!root.join("vibebox.toml").exists());
594+ }
595+
596+ #[test]
597+ fn resolve_config_path_uses_env_override_when_cli_override_missing() {
598+ let temp = TempDir::new().expect("temp dir should be created");
599+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
600+
601+ let resolved = resolve_config_path_inner(&root, None, Some(PathBuf::from("custom.toml")))
602+ .expect("env override path should resolve");
603+
604+ assert_eq!(resolved, root.join("custom.toml"));
605+ }
606+
607+ #[test]
608+ fn resolve_config_path_rejects_env_override_outside_project() {
609+ let temp = TempDir::new().expect("temp dir should be created");
610+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
611+
612+ let err = resolve_config_path_inner(&root, None, Some(PathBuf::from("../escape.toml")))
613+ .expect_err("outside-project path should be rejected");
614+
615+ assert!(
616+ err.to_string().contains("config path must be within"),
617+ "expected bounds-check error, got: {err}"
618+ );
619+ }
620+
621+ #[test]
622+ fn validate_config_rejects_invalid_values() {
623+ let cfg = Config {
624+ box_cfg: BoxConfig {
625+ cpu_count: 0,
626+ ram_size: ByteSize::mib(2048),
627+ disk_size: ByteSize::gib(5),
628+ mounts: vec![],
629+ },
630+ supervisor: SupervisorConfig::default(),
631+ };
632+ let err = validate_config(&cfg).expect_err("cpu_count=0 should fail");
633+ assert_eq!(err, "box.cpu_count must be >= 1");
634+
635+ let cfg = Config {
636+ box_cfg: BoxConfig {
637+ cpu_count: 2,
638+ ram_size: ByteSize::mib(2048),
639+ disk_size: ByteSize::gib(5),
640+ mounts: vec!["/definitely/missing:/tmp/missing:read-write".to_string()],
641+ },
642+ supervisor: SupervisorConfig::default(),
643+ };
644+ let err = validate_config(&cfg).expect_err("invalid mount should fail");
645+ assert!(err.starts_with("invalid mount spec"));
646+ }
647+
648+ #[test]
649+ fn resolve_config_path_accepts_symlinked_project_root() {
650+ let temp = TempDir::new().expect("temp dir should be created");
651+ let actual_root = temp.path().join("actual");
652+ let link_root = temp.path().join("linked");
653+ fs::create_dir_all(&actual_root).expect("actual root should exist");
654+ std::os::unix::fs::symlink(&actual_root, &link_root).expect("symlink should be created");
655+
656+ let resolved = resolve_config_path_inner(&link_root, Some(Path::new("vibebox.toml")), None)
657+ .expect("symlinked project root should resolve");
658+
659+ assert_eq!(resolved, link_root.join("vibebox.toml"));
660+ }
661+
662+ #[test]
663+ fn resolve_config_path_rejects_symlink_escape() {
664+ let temp = TempDir::new().expect("temp dir should be created");
665+ let project_root = temp.path().join("project");
666+ let outside_root = temp.path().join("outside");
667+ fs::create_dir_all(&project_root).expect("project root should exist");
668+ fs::create_dir_all(&outside_root).expect("outside root should exist");
669+ std::os::unix::fs::symlink(&outside_root, project_root.join("link"))
670+ .expect("escape symlink should be created");
671+
672+ let err = resolve_config_path_inner(&project_root, Some(Path::new("link/cfg.toml")), None)
673+ .expect_err("symlink escape should be rejected");
674+ assert!(
675+ err.to_string().contains("config path must be within"),
676+ "expected bounds-check error, got: {err}"
677+ );
678+ }
298679}
src/error_report.shnew file+28
@@ -0,0 +1,28 @@
1+#!/usr/bin/env bash
2+set -Eeuo pipefail
3+__vibebox_err_reported=0
4+__vibebox_report_error() {
5+ local rc="$1"
6+ local line="$2"
7+ local msg="${3:-}"
8+ if [ "$__vibebox_err_reported" -eq 0 ]; then
9+ msg="${msg//$'\n'/ }"
10+ msg="${msg//$'\r'/ }"
11+ if [ -n "$msg" ]; then
12+ echo "VIBEBOX_SCRIPT_ERROR:__LABEL__:${line}:${rc} ${msg}"
13+ else
14+ echo "VIBEBOX_SCRIPT_ERROR:__LABEL__:${line}:${rc}"
15+ fi
16+ __vibebox_err_reported=1
17+ fi
18+}
19+vibebox_fail() {
20+ local msg="${1:-script failed}"
21+ local rc="${2:-1}"
22+ __vibebox_report_error "$rc" "${LINENO}" "$msg"
23+ exit "$rc"
24+}
25+trap 'rc="$?"; __vibebox_report_error "$rc" "${LINENO}" "command failed: ${BASH_COMMAND:-unknown}"' ERR
26+trap 'rc="$?"; if [ "$rc" -ne 0 ]; then __vibebox_report_error "$rc" "${LINENO}" "script exited with code ${rc}"; fi' EXIT
27+
28+__SCRIPT_BODY__
src/explain.rsnew file+156
@@ -0,0 +1,156 @@
1+use crate::instance::InstanceConfig;
2+use crate::utils::relative_to_home;
3+use crate::{config, instance, session_manager, tui};
4+use anyhow::{Context, Result, bail};
5+use std::{
6+ env,
7+ path::{Path, PathBuf},
8+};
9+
10+pub fn build_mount_rows(project: &Path, config: &config::Config) -> Result<Vec<tui::MountListRow>> {
11+ let mut rows = Vec::new();
12+ rows.extend(default_mounts(project)?);
13+ let guest_home = resolve_guest_home(project);
14+ for spec in &config.box_cfg.mounts {
15+ rows.push(parse_mount_spec(project, spec, false, &guest_home)?);
16+ }
17+ Ok(rows)
18+}
19+
20+pub fn build_network_rows(project_dir: &Path) -> Result<Vec<tui::NetworkListRow>> {
21+ let mut vm_ip = "-".to_string();
22+ if let Ok(config) = instance::read_instance_config(project_dir) {
23+ match config.vm_ipv4 {
24+ None => {}
25+ Some(ip) => {
26+ vm_ip = ip;
27+ }
28+ }
29+ }
30+ let host_to_vm = if vm_ip == "-" {
31+ "ssh: <pending>:22".to_string()
32+ } else {
33+ format!("ssh: {vm_ip}:22")
34+ };
35+ let row = tui::NetworkListRow {
36+ network_type: "NAT".to_string(),
37+ vm_ip: vm_ip.clone(),
38+ host_to_vm,
39+ vm_to_host: "none".to_string(),
40+ };
41+ Ok(vec![row])
42+}
43+
44+fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>> {
45+ let project_name = cwd
46+ .file_name()
47+ .and_then(|name| name.to_str())
48+ .with_context(|| "failed to get project name")?;
49+ let project_guest = format!("~/{project_name}");
50+ let project_host = relative_to_home(cwd);
51+ let mut rows = vec![tui::MountListRow {
52+ host: project_host,
53+ guest: project_guest,
54+ mode: "read-write".to_string(),
55+ default_mount: "yes".to_string(),
56+ }];
57+
58+ let home = env::var("HOME")
59+ .map(PathBuf::from)
60+ .with_context(|| "failed to get home directory")?;
61+ let cache_home = env::var("XDG_CACHE_HOME")
62+ .map(PathBuf::from)
63+ .unwrap_or_else(|_| home.join(".cache"));
64+ let cache_dir = cache_home.join(session_manager::GLOBAL_CACHE_DIR_NAME);
65+ let guest_mise_cache = cache_dir.join(".guest-mise-cache");
66+ rows.push(tui::MountListRow {
67+ host: relative_to_home(&guest_mise_cache),
68+ guest: "/root/.local/share/mise".to_string(),
69+ mode: "read-write".to_string(),
70+ default_mount: "yes".to_string(),
71+ });
72+ Ok(rows)
73+}
74+
75+fn parse_mount_spec(
76+ cwd: &Path,
77+ spec: &str,
78+ default_mount: bool,
79+ guest_home: &str,
80+) -> Result<tui::MountListRow> {
81+ let parts: Vec<&str> = spec.split(':').collect();
82+ if parts.len() < 2 || parts.len() > 3 {
83+ bail!["invalid mount spec: {spec}"];
84+ }
85+ let host_part = parts[0];
86+ let guest_part = parts[1];
87+ let mode = if parts.len() == 3 {
88+ match parts[2] {
89+ "read-only" => "read-only",
90+ "read-write" => "read-write",
91+ other => {
92+ bail![format!(
93+ "invalid mount mode '{}'; expected read-only or read-write",
94+ other
95+ )];
96+ }
97+ }
98+ } else {
99+ "read-write"
100+ };
101+
102+ let host_display = display_host_spec(cwd, host_part);
103+ let guest_display = resolve_guest_display(guest_part, guest_home);
104+ Ok(tui::MountListRow {
105+ host: host_display,
106+ guest: guest_display,
107+ mode: mode.to_string(),
108+ default_mount: if default_mount { "yes" } else { "no" }.to_string(),
109+ })
110+}
111+
112+fn display_host_spec(cwd: &Path, host: &str) -> String {
113+ if host == "~" || host.starts_with("~/") {
114+ return host.to_string();
115+ }
116+ let host_path = PathBuf::from(host);
117+ if host_path.is_absolute() {
118+ return relative_to_home(&host_path);
119+ }
120+ let candidate = cwd.join(&host_path);
121+ if candidate.is_absolute() {
122+ relative_to_home(&candidate)
123+ } else {
124+ host.to_string()
125+ }
126+}
127+
128+fn resolve_guest_home(project_dir: &Path) -> String {
129+ let config = instance::read_instance_config(project_dir);
130+ match config {
131+ Ok(config) => format!("/home/{}", config.ssh_user),
132+ Err(_) => format!("/home/{}", InstanceConfig::default().ssh_user),
133+ }
134+}
135+
136+fn resolve_guest_display(guest: &str, guest_home: &str) -> String {
137+ if guest == "~" {
138+ return "~".to_string();
139+ }
140+ if let Some(stripped) = guest.strip_prefix("~/") {
141+ return format!("~/{stripped}");
142+ }
143+ if Path::new(guest).is_absolute() {
144+ if let Ok(stripped) = Path::new(guest).strip_prefix(guest_home) {
145+ if stripped.components().next().is_none() {
146+ "~".to_string()
147+ } else {
148+ format!("~/{}", stripped.display())
149+ }
150+ } else {
151+ guest.to_string()
152+ }
153+ } else {
154+ format!("/root/{guest}")
155+ }
156+}
src/instance.rs+322−341
@@ -1,76 +1,140 @@
1+use anyhow::{Context, Result, bail};
2+use serde::{Deserialize, Serialize};
3+use std::os::unix::fs::FileTypeExt;
14use std::{
25 env, fs,
3− io::{self, Write},
6+ io::{self, IsTerminal, Read},
47 net::{SocketAddr, TcpStream},
5− os::unix::{fs::PermissionsExt, io::OwnedFd, net::UnixStream},
8+ os::unix::{fs::PermissionsExt, net::UnixStream},
69 path::{Path, PathBuf},
7− process::{Command, Stdio},
10+ process::{Child, Command, Stdio},
811 sync::{
912 Arc, Mutex,
1013 atomic::{AtomicBool, Ordering},
14+ mpsc,
1115 },
1216 thread,
1317 time::{Duration, Instant},
1418};
15−
16−use serde::{Deserialize, Serialize};
17−use std::sync::mpsc::Sender;
1819use time::{OffsetDateTime, format_description::well_known::Rfc3339};
1920use uuid::Uuid;
2021
2122use crate::{
2223 commands,
23− session_manager::{INSTANCE_DIR_NAME, INSTANCE_FILENAME},
24− tui::AppState,
25− vm::{self, LoginAction, VmInput},
24+ session_manager::INSTANCE_DIR_NAME,
25+ session_manager::{VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME},
26+ vm::{self, LoginAction},
2627};
2728
29+pub const STATUS_VM_ERROR_PREFIX: &str = "error:";
30+
2831const SSH_KEY_NAME: &str = "ssh_key";
29−pub(crate) const SERIAL_LOG_NAME: &str = "serial.log";
32+const INSTANCE_FILENAME: &str = "instance.toml";
3033const DEFAULT_SSH_USER: &str = "vibecoder";
31−const SSH_CONNECT_RETRIES: usize = 30;
34+const SSH_CONNECT_RETRIES: usize = 10;
3235const SSH_CONNECT_DELAY_MS: u64 = 500;
3336const SSH_SETUP_SCRIPT: &str = include_str!("ssh.sh");
37+const STATUS_PREFIX: &str = "status:";
38+
39+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40+pub enum VmLiveness {
41+ RunningWithSocket { pid: u32 },
42+ RunningWithoutSocket { pid: u32 },
43+ NotRunningOrMissing,
44+}
45+
46+pub fn vm_liveness(project_root: &Path) -> Result<VmLiveness> {
47+ let instance_dir = ensure_instance_dir(project_root)?;
48+ let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
49+ let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
50+ fn pid_is_alive(pid: u32) -> bool {
51+ let pid = pid as libc::pid_t;
52+ let result = unsafe { libc::kill(pid, 0) };
53+ if result == 0 {
54+ return true;
55+ }
56+ match io::Error::last_os_error().raw_os_error() {
57+ Some(code) if code == libc::EPERM => true,
58+ Some(code) if code == libc::ESRCH => false,
59+ _ => false,
60+ }
61+ }
62+ let Ok(content) = fs::read_to_string(pid_path) else {
63+ return Ok(VmLiveness::NotRunningOrMissing);
64+ };
65+ let Ok(pid) = content.trim().parse::<u32>() else {
66+ return Ok(VmLiveness::NotRunningOrMissing);
67+ };
68+ if !pid_is_alive(pid) {
69+ return Ok(VmLiveness::NotRunningOrMissing);
70+ }
71+ let has_socket = fs::metadata(socket_path)
72+ .map(|meta| meta.file_type().is_socket())
73+ .unwrap_or(false);
74+ if has_socket {
75+ Ok(VmLiveness::RunningWithSocket { pid })
76+ } else {
77+ Ok(VmLiveness::RunningWithoutSocket { pid })
78+ }
79+}
80+
81+#[derive(Debug, thiserror::Error)]
82+pub enum InstanceError {
83+ #[error("unexpected disconnection from vm manager")]
84+ UnexpectedDisconnection,
85+ #[error("{0}")]
86+ VMError(String),
87+}
88+
89+fn default_ssh_user() -> String {
90+ DEFAULT_SSH_USER.to_string()
91+}
3492
3593#[derive(Debug, Clone, Serialize, Deserialize)]
36−pub(crate) struct InstanceConfig {
94+pub struct InstanceConfig {
3795 #[serde(default)]
38− id: String,
96+ pub id: String,
3997 #[serde(default = "default_ssh_user")]
40− ssh_user: String,
98+ pub ssh_user: String,
4199 #[serde(default)]
42100 sudo_password: String,
43101 #[serde(default)]
44− last_active: Option<String>,
102+ pub last_active: Option<String>,
45103 #[serde(default)]
46− pub(crate) vm_ipv4: Option<String>,
104+ pub vm_ipv4: Option<String>,
47105}
48106
49−fn default_ssh_user() -> String {
50− DEFAULT_SSH_USER.to_string()
107+impl Default for InstanceConfig {
108+ fn default() -> Self {
109+ Self {
110+ id: Uuid::now_v7().to_string(),
111+ ssh_user: DEFAULT_SSH_USER.to_string(),
112+ sudo_password: Uuid::now_v7().simple().to_string(),
113+ last_active: None,
114+ vm_ipv4: None,
115+ }
116+ }
51117}
52118
53−pub fn run_with_ssh(manager_conn: UnixStream) -> Result<(), Box<dyn std::error::Error>> {
119+pub fn run_with_ssh(manager_conn: UnixStream) -> Result<()> {
54120 let project_root = env::current_dir()?;
55121 tracing::info!(root = %project_root.display(), "starting ssh session");
56122 let instance_dir = ensure_instance_dir(&project_root)?;
57123 tracing::debug!(instance_dir = %instance_dir.display(), "instance dir ready");
58124 let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?;
59125
60− let config = load_or_create_instance_config(&instance_dir)?;
126+ let config = load_or_create_instance_config(&project_root)?;
61127 let ssh_user = config.ssh_user.clone();
62128 tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
63129
64− let _manager_conn = manager_conn;
65− tracing::debug!("waiting for vm ipv4");
66− wait_for_vm_ipv4(&instance_dir, Duration::from_secs(120))?;
130+ wait_for_vm_ipv4(&project_root, Duration::from_secs(480), &manager_conn)?;
67131
68− let ip = load_or_create_instance_config(&instance_dir)?
132+ let ip = load_or_create_instance_config(&project_root)?
69133 .vm_ipv4
70− .ok_or("VM IPv4 not available")?;
134+ .with_context(|| "failed to load instance IP address")?;
71135 tracing::info!(ip = %ip, "vm ipv4 ready");
72136
73− run_ssh_session(ssh_key, ssh_user, ip)
137+ run_ssh_session(ssh_key, ssh_user, ip, manager_conn, project_root)
74138}
75139
76140pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
@@ -79,9 +143,7 @@ pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
79143 Ok(instance_dir)
80144}
81145
82−pub(crate) fn ensure_ssh_keypair(
83− instance_dir: &Path,
84−) -> Result<(PathBuf, PathBuf), Box<dyn std::error::Error>> {
146+pub fn ensure_ssh_keypair(instance_dir: &Path) -> Result<(PathBuf, PathBuf)> {
85147 let private_key = instance_dir.join(SSH_KEY_NAME);
86148 let public_key = instance_dir.join(format!("{SSH_KEY_NAME}.pub"));
87149
@@ -103,17 +165,19 @@ pub(crate) fn ensure_ssh_keypair(
103165 "-N",
104166 "",
105167 "-f",
106− private_key.to_str().ok_or("ssh key path not utf-8")?,
168+ private_key
169+ .to_str()
170+ .with_context(|| "ssh key path not utf-8")?,
107171 "-C",
108172 "vibebox",
109173 ])
110174 .stdin(Stdio::null())
111− .stdout(Stdio::null())
175+ .stdout(Stdio::inherit())
112176 .stderr(Stdio::inherit())
113177 .status()?;
114178
115179 if !status.success() {
116− return Err("ssh-keygen failed".into());
180+ bail!("ssh-keygen failed");
117181 }
118182
119183 fs::set_permissions(&private_key, fs::Permissions::from_mode(0o600))?;
@@ -122,88 +186,66 @@ pub(crate) fn ensure_ssh_keypair(
122186 Ok((private_key, public_key))
123187}
124188
125−pub(crate) fn load_or_create_instance_config(
126− instance_dir: &Path,
127−) -> Result<InstanceConfig, Box<dyn std::error::Error>> {
128− let config_path = instance_dir.join(INSTANCE_FILENAME);
129− let mut config = if config_path.exists() {
130− let raw = fs::read_to_string(&config_path)?;
131− toml::from_str::<InstanceConfig>(&raw)?
132− } else {
133− InstanceConfig {
134− id: String::new(),
135− ssh_user: default_ssh_user(),
136− sudo_password: String::new(),
137− last_active: None,
138− vm_ipv4: None,
139− }
140− };
189+pub fn load_or_create_instance_config(project_dir: &Path) -> Result<InstanceConfig> {
190+ let mut exist = true;
191+ let mut config = read_instance_config(project_dir).unwrap_or_else(|_| {
192+ exist = false;
193+ InstanceConfig::default()
194+ });
141195
142196 let mut changed = false;
143197 if config.ssh_user.trim().is_empty() {
144− config.ssh_user = default_ssh_user();
198+ config.ssh_user = InstanceConfig::default().ssh_user;
145199 changed = true;
146200 }
147201
148202 if config.id.trim().is_empty() {
149− config.id = Uuid::now_v7().to_string();
203+ config.id = InstanceConfig::default().id;
150204 changed = true;
151205 }
152206
153207 if config.sudo_password.trim().is_empty() {
154− config.sudo_password = generate_password();
208+ config.sudo_password = InstanceConfig::default().sudo_password;
155209 changed = true;
156210 }
157211
158− if !config_path.exists() || changed {
159− write_instance_config(&config_path, &config)?;
212+ if !exist || changed {
213+ write_instance_config(project_dir, &config)?;
160214 }
161215
162216 Ok(config)
163217}
164218
165−fn read_instance_config(
166− instance_dir: &Path,
167−) -> Result<Option<InstanceConfig>, Box<dyn std::error::Error>> {
168− let config_path = instance_dir.join(INSTANCE_FILENAME);
219+pub fn read_instance_config(project_dir: &Path) -> Result<InstanceConfig> {
220+ // todo maybe verify schema?
221+ let config_path = project_dir.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
169222 if !config_path.exists() {
170− return Ok(None);
223+ bail!("instance config file does not exist");
171224 }
172225 let raw = fs::read_to_string(&config_path)?;
173226 let config = toml::from_str::<InstanceConfig>(&raw)?;
174− Ok(Some(config))
175−}
176−
177−pub fn read_instance_vm_ip(
178− instance_dir: &Path,
179−) -> Result<Option<String>, Box<dyn std::error::Error>> {
180− let config = read_instance_config(instance_dir)?;
181− Ok(config.and_then(|cfg| cfg.vm_ipv4))
227+ Ok(config)
182228}
183229
184−pub fn touch_last_active(instance_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
185− let mut config = load_or_create_instance_config(instance_dir)?;
230+pub fn touch_last_active(project_dir: &Path) -> Result<()> {
231+ let mut config = load_or_create_instance_config(project_dir)?;
186232 let now = OffsetDateTime::now_utc().format(&Rfc3339)?;
187233 config.last_active = Some(now);
188− write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
234+ write_instance_config(project_dir, &config)?;
189235 Ok(())
190236}
191237
192−pub(crate) fn write_instance_config(
193− path: &Path,
194− config: &InstanceConfig,
195−) -> Result<(), Box<dyn std::error::Error>> {
238+pub fn write_instance_config(project_dir: &Path, config: &InstanceConfig) -> Result<()> {
239+ let path = project_dir.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
196240 let data = toml::to_string_pretty(config)?;
197− fs::write(path, data)?;
198− fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
241+ fs::create_dir_all(project_dir.join(INSTANCE_DIR_NAME))?;
242+ fs::write(&path, data)?;
243+ fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
199244 Ok(())
200245}
201246
202−fn generate_password() -> String {
203− Uuid::now_v7().simple().to_string()
204−}
205−
206−pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
247+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
248+pub fn extract_ipv4(line: &str) -> Option<String> {
207249 let mut current = String::new();
208250 let mut best: Option<String> = None;
209251
@@ -222,23 +264,79 @@ pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
222264 best
223265}
224266
267+fn handle_manager_line(line: &str, last_status: &mut Option<String>) -> Result<()> {
268+ if let Some(status) = line.strip_prefix(STATUS_PREFIX) {
269+ let status = status.trim();
270+ if let Some(message) = status.strip_prefix(STATUS_VM_ERROR_PREFIX) {
271+ let message = message.trim();
272+ if message.is_empty() {
273+ bail!("vm manager reported startup failure");
274+ }
275+ return Err(InstanceError::VMError(message.to_string()).into());
276+ }
277+ if !status.is_empty() && last_status.as_deref() != Some(status) {
278+ tracing::info!("[background]: {}", status);
279+ *last_status = Some(status.to_string());
280+ }
281+ }
282+ Ok(())
283+}
284+
225285fn wait_for_vm_ipv4(
226− instance_dir: &Path,
286+ project_dir: &Path,
227287 timeout: Duration,
228−) -> Result<(), Box<dyn std::error::Error>> {
288+ manager_conn: &UnixStream,
289+) -> Result<()> {
229290 let start = Instant::now();
291+ let mut next_log_at = start + Duration::from_secs(10);
292+ let mut stream = manager_conn.try_clone()?;
293+ let _ = stream.set_read_timeout(Some(Duration::from_millis(250)));
294+ let mut read_buf = [0u8; 1024];
295+ let mut pending = String::new();
296+ tracing::info!("waiting for vm ipv4");
297+ let mut last_status: Option<String> = None;
298+ let mut once_hint = false;
230299 loop {
231− let config = load_or_create_instance_config(instance_dir)?;
300+ match stream.read(&mut read_buf) {
301+ Ok(0) => {
302+ bail!("vm manager disconnected before VM became ready");
303+ }
304+ Ok(n) => {
305+ pending.push_str(&String::from_utf8_lossy(&read_buf[..n]));
306+ while let Some(pos) = pending.find('\n') {
307+ let line = pending[..pos].trim().to_string();
308+ pending.drain(..=pos);
309+ handle_manager_line(&line, &mut last_status)?;
310+ }
311+ }
312+ Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
313+ Err(err) if err.kind() == io::ErrorKind::TimedOut => {}
314+ Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
315+ Err(err) => {
316+ tracing::warn!(error = %err, "failed to read vm manager status stream");
317+ }
318+ }
319+
320+ let config = load_or_create_instance_config(project_dir)?;
232321 if config.vm_ipv4.is_some() {
233322 return Ok(());
234323 }
235324 if start.elapsed() > timeout {
236− return Err("Timed out waiting for VM IPv4".into());
325+ bail!("timed out waiting for VM IPv4");
237326 }
238− if start.elapsed().as_secs() % 5 == 0 {
239− tracing::debug!("still waiting for vm ipv4");
327+
328+ let now = Instant::now();
329+ if now >= next_log_at {
330+ let waited = start.elapsed();
331+ if waited.as_secs() > 15 && !once_hint {
332+ tracing::info!(
333+ "if vibebox is just initialized in this directory, it might take up to 1 minute depending on your machine, and then you can enjoy secure & speed vibecoding! go pack!"
334+ );
335+ once_hint = true;
336+ }
337+ tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
338+ next_log_at += Duration::from_secs(20);
240339 }
241− thread::sleep(Duration::from_millis(200));
242340 }
243341}
244342
@@ -246,32 +344,43 @@ fn run_ssh_session(
246344 ssh_key: PathBuf,
247345 ssh_user: String,
248346 ip: String,
249−) -> Result<(), Box<dyn std::error::Error>> {
347+ manager_conn: UnixStream,
348+ project_root: PathBuf,
349+) -> Result<()> {
250350 let mut attempts = 0usize;
251351 loop {
352+ if matches!(vm_liveness(&project_root)?, VmLiveness::NotRunningOrMissing) {
353+ return Err(InstanceError::UnexpectedDisconnection.into());
354+ }
252355 attempts += 1;
253356 if !ssh_port_open(&ip) {
254− tracing::debug!(attempts, "ssh port not open yet");
255− eprintln!(
256− "[vibebox] waiting for ssh port on {ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
357+ tracing::debug!(attempts, "ssh port doesn't open yet");
358+ tracing::info!(
359+ attempts,
360+ ip = %ip,
361+ "waiting for ssh port ({}/{})",
362+ attempts,
363+ SSH_CONNECT_RETRIES
257364 );
258365 if attempts >= SSH_CONNECT_RETRIES {
259− return Err(
260− format!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts").into(),
261− );
366+ bail!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts");
262367 }
263368 thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS));
264369 continue;
265370 }
266371
267− eprintln!(
268− "[vibebox] starting ssh to {ssh_user}@{ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
372+ tracing::info!(
373+ attempts,
374+ user = %ssh_user,
375+ ip = %ip,
376+ "starting ssh ({}/{})",
377+ attempts,
378+ SSH_CONNECT_RETRIES
269379 );
270− tracing::info!(attempts, user = %ssh_user, ip = %ip, "starting ssh");
271− let status = Command::new("ssh")
380+ let child = Command::new("ssh")
272381 .args([
273382 "-i",
274− ssh_key.to_str().unwrap_or(".vibebox/ssh_key"),
383+ ssh_key.to_str().with_context(|| "invalid path")?,
275384 "-o",
276385 "IdentitiesOnly=yes",
277386 "-o",
@@ -296,25 +405,67 @@ fn run_ssh_session(
296405 .stdin(Stdio::inherit())
297406 .stdout(Stdio::inherit())
298407 .stderr(Stdio::inherit())
299− .status();
408+ .spawn();
409+
410+ match child {
411+ Ok(mut child) => {
412+ let done = Arc::new(AtomicBool::new(false));
413+ let done_for_monitor = done.clone();
414+ let (disconnect_tx, disconnect_rx) = mpsc::channel::<()>();
415+ let mut manager_stream = manager_conn.try_clone()?;
416+ let _ = manager_stream.set_read_timeout(Some(Duration::from_millis(250)));
417+ thread::spawn(move || {
418+ let mut buf = [0u8; 1];
419+ while !done_for_monitor.load(Ordering::Relaxed) {
420+ match manager_stream.read(&mut buf) {
421+ Ok(0) => {
422+ let _ = disconnect_tx.send(());
423+ return;
424+ }
425+ Ok(_) => {}
426+ Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
427+ Err(err) if err.kind() == io::ErrorKind::TimedOut => {}
428+ Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
429+ Err(_) => {
430+ let _ = disconnect_tx.send(());
431+ return;
432+ }
433+ }
434+ }
435+ });
436+
437+ let status = loop {
438+ if disconnect_rx.try_recv().is_ok() {
439+ done.store(true, Ordering::Relaxed);
440+ terminate_ssh_child(&mut child);
441+ restore_terminal_after_disconnect();
442+ return Err(InstanceError::UnexpectedDisconnection.into());
443+ }
444+ if let Some(status) = child.try_wait()? {
445+ done.store(true, Ordering::Relaxed);
446+ break status;
447+ }
448+ thread::sleep(Duration::from_millis(100));
449+ };
300450
301− match status {
302− Ok(status) if status.success() => {
303− eprintln!("[vibebox] ssh exited with status: {status}");
304− break;
305− }
306− Ok(status) if status.code() == Some(255) => {
307− eprintln!("[vibebox] ssh connection failed: {status}");
308− if attempts >= SSH_CONNECT_RETRIES {
309− return Err(format!("ssh failed after {SSH_CONNECT_RETRIES} attempts").into());
451+ if status.success() {
452+ tracing::info!(status = %status, "ssh exited");
453+ break;
310454 }
311− thread::sleep(Duration::from_millis(500));
312− }
313− Ok(status) => {
314− return Err(format!("ssh exited with status: {status}").into());
455+ if status.code() == Some(255) {
456+ tracing::warn!(status = %status, "ssh connection failed");
457+ if attempts >= SSH_CONNECT_RETRIES {
458+ bail!("ssh failed after {SSH_CONNECT_RETRIES} attempts");
459+ }
460+ thread::sleep(Duration::from_millis(500));
461+ continue;
462+ }
463+ tracing::info!(status = %status, "ssh exited");
464+ break;
315465 }
316466 Err(err) => {
317− return Err(format!("failed to start ssh: {err}").into());
467+ tracing::error!(error = %err, "failed to start ssh");
468+ bail!("failed to start ssh: {err}");
318469 }
319470 }
320471 }
@@ -322,6 +473,31 @@ fn run_ssh_session(
322473 Ok(())
323474}
324475
476+fn terminate_ssh_child(child: &mut Child) {
477+ let pid = child.id() as i32;
478+ unsafe {
479+ libc::kill(pid, libc::SIGTERM);
480+ }
481+ let deadline = Instant::now() + Duration::from_millis(700);
482+ while Instant::now() < deadline {
483+ match child.try_wait() {
484+ Ok(Some(_)) => return,
485+ Ok(None) => thread::sleep(Duration::from_millis(50)),
486+ Err(_) => break,
487+ }
488+ }
489+ let _ = child.kill();
490+ let _ = child.wait();
491+}
492+
493+fn restore_terminal_after_disconnect() {
494+ if io::stdin().is_terminal() {
495+ let _ = Command::new("stty").arg("sane").status();
496+ }
497+ eprintln!();
498+}
499+
500+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
325501fn is_ipv4_candidate(candidate: &str) -> bool {
326502 let parts: Vec<&str> = candidate.split('.').collect();
327503 if parts.len() != 4 {
@@ -343,14 +519,16 @@ fn ssh_port_open(ip: &str) -> bool {
343519 Ok(addr) => addr,
344520 Err(_) => return false,
345521 };
346− TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).is_ok()
522+ TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok()
347523}
348524
349−pub(crate) fn build_ssh_login_actions(
525+pub fn build_ssh_login_actions(
350526 config: &Arc<Mutex<InstanceConfig>>,
351527 project_name: &str,
528+ project_guest_dir: &str,
352529 guest_dir: &str,
353530 key_name: &str,
531+ home_links_script: &str,
354532) -> Vec<LoginAction> {
355533 let config_guard = config.lock().expect("config mutex poisoned");
356534 let ssh_user = config_guard.ssh_user.clone();
@@ -359,241 +537,44 @@ pub(crate) fn build_ssh_login_actions(
359537
360538 let key_path = format!("{guest_dir}/{key_name}.pub");
361539
362− let setup_script = SSH_SETUP_SCRIPT
540+ let ssh_script = SSH_SETUP_SCRIPT
363541 .replace("__SSH_USER__", &ssh_user)
364542 .replace("__SUDO_PASSWORD__", &sudo_password)
365543 .replace("__PROJECT_NAME__", project_name)
544+ .replace("__PROJECT_GUEST_DIR__", project_guest_dir)
366545 .replace("__KEY_PATH__", &key_path)
367− .replace("__VIBEBOX_SHELL_SCRIPT__", &commands::render_shell_script());
368− let setup = vm::script_command_from_content("ssh_setup", &setup_script)
546+ .replace("__VIBEBOX_SHELL_SCRIPT__", &commands::render_shell_script())
547+ .replace("__VIBEBOX_HOME_LINKS__", home_links_script);
548+ let setup = vm::script_command_from_content("ssh.sh", &ssh_script)
369549 .expect("ssh setup script contained invalid marker");
370550
371551 vec![LoginAction::Send(setup)]
372552}
373553
374−fn spawn_ssh_io(
375− app: Arc<Mutex<AppState>>,
376− config: Arc<Mutex<InstanceConfig>>,
377− instance_dir: PathBuf,
378− ssh_key: PathBuf,
379− output_monitor: Arc<vm::OutputMonitor>,
380− vm_output_fd: OwnedFd,
381− vm_input_fd: OwnedFd,
382−) -> vm::IoContext {
383− let io_control = vm::IoControl::new();
384−
385− let log_path = instance_dir.join(SERIAL_LOG_NAME);
386− let log_file = fs::OpenOptions::new()
387− .create(true)
388− .write(true)
389− .truncate(true)
390− .open(&log_path)
391− .ok()
392− .map(|file| Arc::new(Mutex::new(file)));
393−
394− let ssh_connected = Arc::new(AtomicBool::new(false));
395− let ssh_started = Arc::new(AtomicBool::new(false));
396− let ssh_ready = Arc::new(AtomicBool::new(false));
397− let input_tx_holder: Arc<Mutex<Option<Sender<VmInput>>>> = Arc::new(Mutex::new(None));
398−
399− let instance_path = instance_dir.join(INSTANCE_FILENAME);
400− let config_for_output = config.clone();
401− let log_for_output = log_file.clone();
402− let ssh_connected_for_output = ssh_connected.clone();
403− let ssh_ready_for_output = ssh_ready.clone();
404−
405− let mut line_buf = String::new();
406−
407− let handlers = commands::build_handlers(app.clone(), io_control.clone());
408− let on_line = move |line: &str| handlers.handle(line);
409−
410− let on_output = move |bytes: &[u8]| {
411− if ssh_connected_for_output.load(Ordering::SeqCst) {
412− if let Some(log) = &log_for_output {
413− if let Ok(mut file) = log.lock() {
414− let _ = file.write_all(bytes);
415− }
416− }
417− }
418−
419− let text = String::from_utf8_lossy(bytes);
420− line_buf.push_str(&text);
421−
422− while let Some(pos) = line_buf.find('\n') {
423− let mut line = line_buf[..pos].to_string();
424− if line.ends_with('\r') {
425− line.pop();
426− }
427− line_buf.drain(..=pos);
428−
429− let cleaned = line.trim_start_matches(|c: char| c == '\r' || c == ' ');
430−
431− if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
432− let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
433− let ip = extract_ipv4(ip_raw).unwrap_or_default();
434− if !ip.is_empty() {
435− if let Ok(mut cfg) = config_for_output.lock() {
436− if cfg.vm_ipv4.as_deref() != Some(ip.as_str()) {
437− cfg.vm_ipv4 = Some(ip.clone());
438− let _ = write_instance_config(&instance_path, &cfg);
439− eprintln!("[vibebox] detected vm IPv4: {ip}");
440− }
441− }
442− }
443− }
444−
445− if cleaned.contains("VIBEBOX_SSH_READY") {
446− ssh_ready_for_output.store(true, Ordering::SeqCst);
447− eprintln!("[vibebox] sshd ready");
448− }
449− }
450− };
451−
452− let io_ctx = vm::spawn_vm_io_with_hooks(
453− output_monitor,
454− vm_output_fd,
455− vm_input_fd,
456− io_control.clone(),
457− on_line,
458− on_output,
459− );
460−
461− *input_tx_holder.lock().unwrap() = Some(io_ctx.input_tx.clone());
462−
463− let ssh_ready_for_thread = ssh_ready.clone();
464− let ssh_started_for_thread = ssh_started.clone();
465− let ssh_connected_for_thread = ssh_connected.clone();
466− let io_control_for_thread = io_control.clone();
467− let ssh_key_for_thread = ssh_key.clone();
468− let config_for_thread = config.clone();
469− let input_tx_holder_for_thread = input_tx_holder.clone();
470−
471− thread::spawn(move || {
472− loop {
473− if ssh_started_for_thread.load(Ordering::SeqCst) {
474− break;
475− }
476− if !ssh_ready_for_thread.load(Ordering::SeqCst) {
477− thread::sleep(std::time::Duration::from_millis(200));
478− continue;
479− }
554+#[cfg(test)]
555+mod tests {
556+ use super::*;
480557
481− let ip = config_for_thread
482− .lock()
483− .ok()
484− .and_then(|cfg| cfg.vm_ipv4.clone());
485− let ssh_user = config_for_thread
486− .lock()
487− .map(|cfg| cfg.ssh_user.clone())
488− .unwrap_or_else(|_| DEFAULT_SSH_USER.to_string());
489−
490− if let Some(ip) = ip {
491− if ssh_started_for_thread
492− .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
493− .is_err()
494− {
495− break;
496− }
497−
498− eprintln!("[vibebox] starting ssh to {ssh_user}@{ip}");
499− io_control_for_thread.request_terminal_restore();
500− io_control_for_thread.set_forward_output(false);
501− io_control_for_thread.set_forward_input(false);
502− ssh_connected_for_thread.store(true, Ordering::SeqCst);
503−
504− let mut attempts = 0usize;
505− loop {
506− attempts += 1;
507− if !ssh_port_open(&ip) {
508− eprintln!(
509− "[vibebox] waiting for ssh port on {ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
510− );
511− if attempts >= SSH_CONNECT_RETRIES {
512− ssh_connected_for_thread.store(false, Ordering::SeqCst);
513− eprintln!(
514− "[vibebox] ssh port not ready after {SSH_CONNECT_RETRIES} attempts"
515− );
516− break;
517− }
518− thread::sleep(std::time::Duration::from_millis(SSH_CONNECT_DELAY_MS));
519− continue;
520− }
521−
522− eprintln!(
523− "[vibebox] starting ssh to {ssh_user}@{ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
524− );
525− let status = Command::new("ssh")
526− .args([
527− "-i",
528− ssh_key_for_thread.to_str().unwrap_or(".vibebox/ssh_key"),
529− "-o",
530− "IdentitiesOnly=yes",
531− "-o",
532− "StrictHostKeyChecking=no",
533− "-o",
534− "UserKnownHostsFile=/dev/null",
535− "-o",
536− "GlobalKnownHostsFile=/dev/null",
537− "-o",
538− "PasswordAuthentication=no",
539− "-o",
540− "BatchMode=yes",
541− "-o",
542− "LogLevel=ERROR",
543− "-o",
544− "ConnectTimeout=5",
545− ])
546− .env_remove("LC_CTYPE")
547− .env_remove("LC_ALL")
548− // .env_remove("LANG")
549− .arg(format!("{ssh_user}@{ip}"))
550− .stdin(Stdio::inherit())
551− .stdout(Stdio::inherit())
552− .stderr(Stdio::inherit())
553− .status();
554−
555− match status {
556− Ok(status) if status.success() => {
557− ssh_connected_for_thread.store(false, Ordering::SeqCst);
558− eprintln!("[vibebox] ssh exited with status: {status}");
559− if let Some(tx) = input_tx_holder_for_thread.lock().unwrap().clone() {
560− let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
561− }
562− break;
563− }
564− Ok(status) if status.code() == Some(255) => {
565− eprintln!("[vibebox] ssh connection failed: {status}");
566− if attempts >= SSH_CONNECT_RETRIES {
567− ssh_connected_for_thread.store(false, Ordering::SeqCst);
568− eprintln!(
569− "[vibebox] ssh failed after {SSH_CONNECT_RETRIES} attempts"
570− );
571− break;
572− }
573− thread::sleep(std::time::Duration::from_millis(500));
574− continue;
575− }
576− Ok(status) => {
577− ssh_connected_for_thread.store(false, Ordering::SeqCst);
578− eprintln!("[vibebox] ssh exited with status: {status}");
579− if let Some(tx) = input_tx_holder_for_thread.lock().unwrap().clone() {
580− let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
581− }
582− break;
583− }
584− Err(err) => {
585− ssh_connected_for_thread.store(false, Ordering::SeqCst);
586− eprintln!("[vibebox] failed to start ssh: {err}");
587− break;
588− }
589− }
590− }
591− break;
592− }
558+ #[test]
559+ fn handle_manager_line_updates_status() {
560+ let mut last_status = None;
561+ handle_manager_line("status: preparing VM image...", &mut last_status)
562+ .expect("status should be accepted");
563+ assert_eq!(last_status.as_deref(), Some("preparing VM image..."));
564+ }
593565
594− thread::sleep(std::time::Duration::from_millis(200));
595− }
596− });
566+ #[test]
567+ fn handle_manager_line_ignores_non_status_lines() {
568+ let mut last_status = None;
569+ handle_manager_line("pid=123", &mut last_status).expect("non-status lines are ignored");
570+ assert!(last_status.is_none());
571+ }
597572
598− io_ctx
573+ #[test]
574+ fn handle_manager_line_surfaces_error_status() {
575+ let mut last_status = None;
576+ let err = handle_manager_line("status: error: vm failed to boot", &mut last_status)
577+ .expect_err("error status should fail");
578+ assert_eq!(err.to_string(), "vm failed to boot");
579+ }
599580}
src/lib.rs+2
@@ -1,4 +1,5 @@
11pub mod commands;
2+pub mod explain;
23pub mod instance;
34pub mod session_manager;
45pub mod tui;
@@ -7,3 +8,4 @@ pub mod vm_manager;
78
89pub use session_manager::{SessionError, SessionManager, SessionRecord};
910pub mod config;
11+pub mod utils;
src/provision.sh+55−5
@@ -1,12 +1,55 @@
11#!/bin/bash
2−set -eux
2+set -eEux
3+
4+trap 'rc=$?; echo "[vibebox][error] provisioning failed at: ${BASH_COMMAND} (exit ${rc})"; printf "%s%s\n" VIBEBOX_PROVISION_ FAILED; systemctl poweroff || true; exit 1' ERR
5+
6+# Wait for network + DNS before apt-get to avoid early boot flakiness.
7+wait_for_network() {
8+ echo "[vibebox] waiting for network/DNS readiness"
9+ local deadline=$((SECONDS + 180))
10+ while [ "$SECONDS" -lt "$deadline" ]; do
11+ local has_route=0
12+ if ip -4 route show default >/dev/null 2>&1; then
13+ has_route=1
14+ elif ip -6 route show default >/dev/null 2>&1; then
15+ has_route=1
16+ fi
17+ if [ "$has_route" -eq 1 ]; then
18+ if getent hosts deb.debian.org >/dev/null 2>&1; then
19+ return 0
20+ fi
21+ fi
22+ sleep 1
23+ done
24+ echo "[vibebox][warn] network/DNS still not ready after 180s; continuing" >&2
25+ echo "[vibebox][warn] /etc/resolv.conf:" >&2
26+ cat /etc/resolv.conf >&2 || true
27+ ip -br addr >&2 || true
28+ ip route >&2 || true
29+ ip -6 route >&2 || true
30+ return 0
31+}
32+
33+apt_update_with_retries() {
34+ local attempt=1
35+ while [ "$attempt" -le 5 ]; do
36+ if apt-get update; then
37+ return 0
38+ fi
39+ echo "[vibebox][warn] apt-get update failed (attempt ${attempt}/5); retrying..." >&2
40+ attempt=$((attempt + 1))
41+ sleep 2
42+ done
43+ return 1
44+}
345
446# 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
47+echo 'Acquire::http::Timeout "10";' | tee /etc/apt/apt.conf.d/99timeout
48+echo 'Acquire::https::Timeout "10";' | tee -a /etc/apt/apt.conf.d/99timeout
49+echo 'Acquire::Retries "5";' | tee -a /etc/apt/apt.conf.d/99timeout
850
9−apt-get update
51+wait_for_network
52+apt_update_with_retries
1053apt-get install -y --no-install-recommends \
1154 build-essential \
1255 pkg-config \
@@ -14,6 +57,7 @@ apt-get install -y --no-install-recommends \
1457 curl \
1558 git \
1659 ripgrep \
60+ cloud-guest-utils \
1761 openssh-server \
1862 sudo
1963
@@ -42,6 +86,11 @@ systemctl restart ssh
4286# Set this env var so claude doesn't complain about running as root.'
4387echo "export IS_SANDBOX=1" >> .bashrc
4488
89+# Ensure cloned instances generate unique machine-id on first boot.
90+truncate -s 0 /etc/machine-id
91+rm -f /var/lib/dbus/machine-id
92+ln -sf /etc/machine-id /var/lib/dbus/machine-id
93+
4594# Shutdown the VM when you logout
4695cat > .bash_logout <<EOF
4796systemctl poweroff
@@ -49,4 +98,5 @@ sleep 100 # sleep here so that we don't see the login screen flash up before the
4998EOF
5099
51100# Done provisioning, power off the VM
101+printf "%s%s\n" VIBEBOX_PROVISION_ OK
52102systemctl poweroff
src/resize_disk.shnew file+55
@@ -0,0 +1,55 @@
1+#!/bin/bash
2+set -euo pipefail
3+
4+ROOT_DEV="$(findmnt / -n -o SOURCE || true)"
5+ROOT_FSTYPE="$(findmnt / -n -o FSTYPE || true)"
6+
7+if [ -z "$ROOT_DEV" ]; then
8+ exit 0
9+fi
10+
11+DISK_DEV=""
12+PART_NUM=""
13+
14+if command -v lsblk >/dev/null 2>&1; then
15+ DISK_DEV="$(lsblk -no pkname "$ROOT_DEV" 2>/dev/null | head -n1 || true)"
16+ PART_NUM="$(lsblk -no PARTNUM "$ROOT_DEV" 2>/dev/null | head -n1 || true)"
17+fi
18+
19+if [ -z "$DISK_DEV" ] || [ -z "$PART_NUM" ]; then
20+ ROOT_BASENAME="$(basename "$ROOT_DEV")"
21+ if echo "$ROOT_BASENAME" | grep -Eq '^nvme.+p[0-9]+$'; then
22+ DISK_DEV="/dev/${ROOT_BASENAME%p[0-9]*}"
23+ PART_NUM="${ROOT_BASENAME##*p}"
24+ elif echo "$ROOT_BASENAME" | grep -Eq '^[a-z]+[0-9]+$'; then
25+ DISK_DEV="/dev/${ROOT_BASENAME%%[0-9]*}"
26+ PART_NUM="${ROOT_BASENAME##*[a-z]}"
27+ fi
28+fi
29+
30+if [ -n "$DISK_DEV" ] && [ -n "$PART_NUM" ]; then
31+ if command -v growpart >/dev/null 2>&1; then
32+ growpart "$DISK_DEV" "$PART_NUM" || true
33+ elif command -v sfdisk >/dev/null 2>&1; then
34+ sfdisk -N "$PART_NUM" --force "$DISK_DEV" <<'EOF' || true
35+,,
36+EOF
37+ fi
38+fi
39+
40+if command -v partprobe >/dev/null 2>&1; then
41+ partprobe "$DISK_DEV" || true
42+fi
43+
44+case "$ROOT_FSTYPE" in
45+ ext4|ext3|ext2)
46+ if command -v resize2fs >/dev/null 2>&1; then
47+ resize2fs "$ROOT_DEV" || true
48+ fi
49+ ;;
50+ xfs)
51+ if command -v xfs_growfs >/dev/null 2>&1; then
52+ xfs_growfs / || true
53+ fi
54+ ;;
55+esac
src/session_manager.rs+64−101
@@ -1,19 +1,18 @@
1+use anyhow::Result;
12use std::{
23 env, fs,
34 io::{self, Write},
4− os::unix::fs::FileTypeExt,
55 path::{Path, PathBuf},
66};
77
8+use crate::config::config_path;
9+use crate::instance::{VmLiveness, read_instance_config, vm_liveness};
810use serde::{Deserialize, Serialize};
911
10−use crate::config::CONFIG_FILENAME;
11−
1212pub const INSTANCE_DIR_NAME: &str = ".vibebox";
1313pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
1414pub const GLOBAL_DIR_NAME: &str = ".vibebox";
15−pub const INSTANCE_FILENAME: &str = "instance.toml";
16−pub const SESSION_TOML_SUFFIX: &str = ".toml";
15+const SESSION_TOML_SUFFIX: &str = ".toml";
1716pub const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
1817pub const VM_MANAGER_PID_NAME: &str = "vm.pid";
1918const SESSIONS_DIR_NAME: &str = "sessions";
@@ -32,14 +31,6 @@ struct SessionEntry {
3231 pub id: String,
3332}
3433
35−#[derive(Debug, Default, Deserialize)]
36−struct InstanceMetadata {
37− #[serde(default)]
38− id: Option<String>,
39− #[serde(default)]
40− last_active: Option<String>,
41−}
42−
4334#[derive(Debug)]
4435pub struct SessionManager {
4536 sessions_dir: PathBuf,
@@ -61,7 +52,7 @@ pub enum SessionError {
6152 #[error("Session directory does not exist: {0}")]
6253 MissingDirectory(PathBuf),
6354 #[error(transparent)]
64− Io(#[from] std::io::Error),
55+ Io(#[from] io::Error),
6556 #[error(transparent)]
6657 TomlDe(#[from] toml::de::Error),
6758 #[error(transparent)]
@@ -94,8 +85,8 @@ impl SessionManager {
9485 let mut added = false;
9586
9687 if has_config {
97− let meta = read_instance_metadata(&directory)?;
98− if let Some(id) = meta.id {
88+ let id = read_instance_config(&directory).map_or(None, |config| Some(config.id));
89+ if let Some(id) = id {
9990 let record = SessionEntry {
10091 directory: directory.clone(),
10192 id: id.clone(),
@@ -110,14 +101,13 @@ impl SessionManager {
110101 } else {
111102 tracing::warn!(
112103 directory = %directory.display(),
113− file = INSTANCE_FILENAME,
114104 "missing session id in instance file"
115105 );
116106 }
117107 }
118108
119109 if removed > 0 || added {
120− tracing::info!(
110+ tracing::debug!(
121111 path = %self.sessions_dir.display(),
122112 removed,
123113 added,
@@ -148,12 +138,13 @@ impl SessionManager {
148138 }
149139 let mut records = Vec::with_capacity(sessions.len());
150140 for session in sessions {
151− let meta = read_instance_metadata(&session.directory)?;
141+ let last_active =
142+ read_instance_config(&session.directory).map_or(None, |option| option.last_active);
152143 let active = is_session_active(&session.directory);
153144 records.push(SessionRecord {
154145 directory: session.directory,
155146 id: session.id,
156− last_active: meta.last_active,
147+ last_active,
157148 active,
158149 });
159150 }
@@ -196,7 +187,7 @@ impl SessionManager {
196187 let path = self.session_path_for(&record.id);
197188 let content = toml::to_string_pretty(record)?;
198189 atomic_write(&path, content.as_bytes())?;
199− tracing::info!(
190+ tracing::debug!(
200191 path = %path.display(),
201192 "wrote session record"
202193 );
@@ -223,12 +214,12 @@ impl SessionManager {
223214 removed += 1;
224215 continue;
225216 }
226− if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
227− if stem != record.id {
228− let _ = fs::remove_file(&path);
229− removed += 1;
230− continue;
231− }
217+ if let Some(stem) = path.file_stem().and_then(|s| s.to_str())
218+ && stem != record.id
219+ {
220+ let _ = fs::remove_file(&path);
221+ removed += 1;
222+ continue;
232223 }
233224 sessions.push(record);
234225 }
@@ -271,43 +262,22 @@ fn is_vibebox_dir(directory: &Path) -> bool {
271262 if !directory.is_absolute() {
272263 return false;
273264 }
274− directory.join(CONFIG_FILENAME).is_file()
265+ config_path(directory).is_file()
275266}
276267
277268fn is_session_active(directory: &Path) -> bool {
278269 let instance_dir = directory.join(INSTANCE_DIR_NAME);
279270 let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
280− let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
281−
282− let pid = read_pid(&pid_path);
283− let is_alive = pid.map(pid_is_alive).unwrap_or(false);
284− if !is_alive {
285− let _ = fs::remove_file(&pid_path);
286− return false;
287− }
288−
289− if let Ok(metadata) = fs::metadata(&socket_path) {
290− return metadata.file_type().is_socket();
291− }
292−
293− true
294−}
295−
296−fn read_pid(path: &Path) -> Option<u32> {
297− let content = fs::read_to_string(path).ok()?;
298− content.trim().parse::<u32>().ok()
299−}
300−
301−fn pid_is_alive(pid: u32) -> bool {
302− let pid = pid as libc::pid_t;
303− let result = unsafe { libc::kill(pid, 0) };
304− if result == 0 {
305− return true;
306− }
307− match std::io::Error::last_os_error().raw_os_error() {
308− Some(code) if code == libc::EPERM => true,
309− Some(code) if code == libc::ESRCH => false,
310− _ => false,
271+ match vm_liveness(directory) {
272+ Ok(liveness) => match liveness {
273+ VmLiveness::RunningWithSocket { .. } => true,
274+ VmLiveness::RunningWithoutSocket { .. } => true,
275+ VmLiveness::NotRunningOrMissing => {
276+ let _ = fs::remove_file(&pid_path);
277+ false
278+ }
279+ },
280+ Err(_) => false,
311281 }
312282}
313283
@@ -315,31 +285,11 @@ fn read_session_file(path: &Path) -> Result<SessionEntry, SessionError> {
315285 let raw = fs::read_to_string(path)?;
316286 let record: SessionEntry = toml::from_str(&raw)?;
317287 if record.id.trim().is_empty() {
318− return Err(std::io::Error::new(io::ErrorKind::InvalidData, "session id missing").into());
288+ return Err(io::Error::new(io::ErrorKind::InvalidData, "session id missing").into());
319289 }
320290 Ok(record)
321291}
322292
323−fn read_instance_metadata(directory: &Path) -> Result<InstanceMetadata, SessionError> {
324− let instance_path = directory.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
325− if !instance_path.exists() {
326− return Ok(InstanceMetadata::default());
327− }
328− let raw = fs::read_to_string(&instance_path)?;
329− let mut meta: InstanceMetadata = toml::from_str(&raw)?;
330− if let Some(id) = &meta.id {
331− if id.trim().is_empty() {
332− meta.id = None;
333− }
334− }
335− if let Some(last_active) = &meta.last_active {
336− if last_active.trim().is_empty() {
337− meta.last_active = None;
338− }
339− }
340− Ok(meta)
341−}
342−
343293fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
344294 let Some(parent) = path.parent() else {
345295 return Err(io::Error::new(
@@ -362,6 +312,7 @@ fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
362312#[cfg(test)]
363313mod tests {
364314 use super::*;
315+ use crate::instance::{InstanceConfig, write_instance_config};
365316 use std::fs;
366317 use tempfile::TempDir;
367318
@@ -375,11 +326,14 @@ mod tests {
375326 dir
376327 }
377328
378− fn write_instance(project_dir: &Path, id: &str, last_active: &str) {
379− let instance_dir = project_dir.join(INSTANCE_DIR_NAME);
380− fs::create_dir_all(&instance_dir).unwrap();
381− let content = format!("id = \"{id}\"\nlast_active = \"{last_active}\"\n");
382− fs::write(instance_dir.join(INSTANCE_FILENAME), content).unwrap();
329+ fn write_instance(project_dir: &Path, id: &str, last_active: &str) -> Result<()> {
330+ fs::create_dir_all(project_dir)?;
331+
332+ let mut config = InstanceConfig::default();
333+ config.id = id.to_string();
334+ config.last_active = Some(last_active.to_string());
335+
336+ write_instance_config(project_dir, &config)
383337 }
384338
385339 #[test]
@@ -387,11 +341,14 @@ mod tests {
387341 let temp = TempDir::new().unwrap();
388342 let mgr = manager(&temp);
389343 let project_dir = create_project_dir(&temp);
390− fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
391− write_instance(
392− &project_dir,
393− "019bf290-cccc-7c23-ba1d-dce7e6d40693",
394− "2026-02-07T05:00:00Z",
344+ fs::write(config_path(project_dir.as_path()), "").unwrap();
345+ assert!(
346+ write_instance(
347+ &project_dir,
348+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
349+ "2026-02-07T05:00:00Z",
350+ )
351+ .is_ok()
395352 );
396353
397354 let dirs = mgr.update_global_sessions(&project_dir).unwrap();
@@ -412,15 +369,18 @@ mod tests {
412369 let temp = TempDir::new().unwrap();
413370 let mgr = manager(&temp);
414371 let project_dir = create_project_dir(&temp);
415− fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
416− write_instance(
417− &project_dir,
418− "019bf290-cccc-7c23-ba1d-dce7e6d40693",
419− "2026-02-07T05:00:00Z",
372+ fs::write(config_path(project_dir.as_path()), "").unwrap();
373+ assert!(
374+ write_instance(
375+ &project_dir,
376+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
377+ "2026-02-07T05:00:00Z",
378+ )
379+ .is_ok()
420380 );
421381 let _ = mgr.update_global_sessions(&project_dir).unwrap();
422382
423− fs::remove_file(project_dir.join(CONFIG_FILENAME)).unwrap();
383+ fs::remove_file(config_path(project_dir.as_path())).unwrap();
424384 let sessions = mgr.list_sessions().unwrap();
425385 assert!(sessions.is_empty());
426386
@@ -451,11 +411,14 @@ mod tests {
451411 let temp = TempDir::new().unwrap();
452412 let mgr = manager(&temp);
453413 let project_dir = create_project_dir(&temp);
454− fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
455− write_instance(
456− &project_dir,
457− "019bf290-cccc-7c23-ba1d-dce7e6d40693",
458− "2026-02-07T05:00:00Z",
414+ fs::write(config_path(project_dir.as_path()), "").unwrap();
415+ assert!(
416+ write_instance(
417+ &project_dir,
418+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
419+ "2026-02-07T05:00:00Z",
420+ )
421+ .is_ok()
459422 );
460423 let _ = mgr.update_global_sessions(&project_dir).unwrap();
461424
src/ssh.sh+64−25
@@ -2,7 +2,9 @@
22set -eu
33
44SSH_USER="__SSH_USER__"
5+SUDO_PASSWORD="__SUDO_PASSWORD__"
56PROJECT_NAME="__PROJECT_NAME__"
7+PROJECT_GUEST_DIR="__PROJECT_GUEST_DIR__"
68KEY_PATH="__KEY_PATH__"
79
810diag() { echo "[vibebox][diag] $*" >&2; }
@@ -49,7 +51,7 @@ dump_diag() {
4951}
5052
5153# 1) tmpfs mount
52−TARGET="/root/${PROJECT_NAME}/.vibebox"
54+TARGET="${PROJECT_GUEST_DIR}/.vibebox"
5355if [ -d "$TARGET" ] && ! mountpoint -q "$TARGET"; then
5456 mount -t tmpfs tmpfs "$TARGET"
5557fi
@@ -60,22 +62,20 @@ if ! id -u "$SSH_USER" >/dev/null 2>&1; then
6062 usermod -aG sudo "$SSH_USER" || true
6163fi
6264
65+if [ -n "$SUDO_PASSWORD" ]; then
66+ echo "${SSH_USER}:${SUDO_PASSWORD}" | chpasswd
67+fi
68+
6369install -d -m 700 -o "$SSH_USER" -g "$SSH_USER" "/home/${SSH_USER}/.ssh"
6470install -m 600 -o "$SSH_USER" -g "$SSH_USER" "$KEY_PATH" "/home/${SSH_USER}/.ssh/authorized_keys"
6571
66−# Ensure codex/claude are visible in the user's HOME
6772USER_HOME="$(getent passwd "$SSH_USER" | cut -d: -f6 2>/dev/null || true)"
6873if [ -z "$USER_HOME" ]; then
6974 USER_HOME="/home/${SSH_USER}"
7075fi
71−install -d -m 755 /usr/local/codex /usr/local/claude
72−if [ ! -e "${USER_HOME}/.codex" ]; then
73− ln -s /usr/local/codex "${USER_HOME}/.codex"
74−fi
75−if [ ! -e "${USER_HOME}/.claude" ]; then
76− ln -s /usr/local/claude "${USER_HOME}/.claude"
77−fi
78−chown -h "${SSH_USER}:${SSH_USER}" "${USER_HOME}/.codex" "${USER_HOME}/.claude" 2>/dev/null || true
76+
77+# Home mount links (config-driven)
78+__VIBEBOX_HOME_LINKS__
7979
8080# Vibebox shell commands
8181install -d -m 755 /etc/profile.d
@@ -84,6 +84,21 @@ __VIBEBOX_SHELL_SCRIPT__
8484VIBEBOX_SHELL_EOF
8585chmod 644 /etc/profile.d/vibebox.sh
8686
87+# Auto-cd into project for interactive shells
88+cat > /etc/profile.d/vibebox-project.sh <<'VIBEBOX_PROJECT_EOF'
89+case "$-" in
90+ *i*)
91+ project_home="${HOME}/__PROJECT_NAME__"
92+ if [ "$USER" = "__SSH_USER__" ] && [ -d "$project_home" ]; then
93+ cd "$project_home"
94+ elif [ "$USER" = "__SSH_USER__" ] && [ -d "__PROJECT_GUEST_DIR__" ]; then
95+ cd "__PROJECT_GUEST_DIR__"
96+ fi
97+ ;;
98+esac
99+VIBEBOX_PROJECT_EOF
100+chmod 644 /etc/profile.d/vibebox-project.sh
101+
87102if ! grep -q "vibebox-aliases" "${USER_HOME}/.bashrc" 2>/dev/null; then
88103 {
89104 echo ""
@@ -94,16 +109,22 @@ fi
94109
95110# Install Mise
96111MISE_BIN="${USER_HOME}/.local/bin/mise"
97−if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
98− curl https://mise.run | HOME="$USER_HOME" sh
99−fi
100−echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
112+mise_warn() { echo "[mise] $*" >&2; }
113+mise_ok() { command -v mise >/dev/null 2>&1 || [ -x "$MISE_BIN" ]; }
114+mise_install() {
115+ if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
116+ if ! curl https://mise.run | HOME="$USER_HOME" sh; then
117+ mise_warn "mise install script failed"
118+ return 1
119+ fi
120+ fi
121+ echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
101122
102−export PATH="${USER_HOME}/.local/bin:/usr/local/bin:$PATH"
123+ export PATH="${USER_HOME}/.local/bin:/usr/local/bin:$PATH"
103124
104−mkdir -p "${USER_HOME}/.config/mise"
125+ mkdir -p "${USER_HOME}/.config/mise"
105126
106−cat > "${USER_HOME}/.config/mise/config.toml" <<MISE
127+ cat > "${USER_HOME}/.config/mise/config.toml" <<MISE
107128 [settings]
108129 # Always use the venv created by uv, if available in directory
109130 python.uv_venv_auto = true
@@ -117,11 +138,23 @@ cat > "${USER_HOME}/.config/mise/config.toml" <<MISE
117138 "npm:@anthropic-ai/claude-code" = "latest"
118139MISE
119140
120−touch "${USER_HOME}/.config/mise/mise.lock"
121−if [ -x "$MISE_BIN" ]; then
122− HOME="$USER_HOME" "$MISE_BIN" install
123−else
124− HOME="$USER_HOME" mise install
141+ touch "${USER_HOME}/.config/mise/mise.lock"
142+ if [ -x "$MISE_BIN" ]; then
143+ if ! HOME="$USER_HOME" "$MISE_BIN" install; then
144+ mise_warn "mise install failed"
145+ return 1
146+ fi
147+ else
148+ if ! HOME="$USER_HOME" mise install; then
149+ mise_warn "mise install failed"
150+ return 1
151+ fi
152+ fi
153+}
154+
155+if ! mise_install; then
156+ diag "mise installation failed"
157+ vibebox_fail "mise installation failed" 1
125158fi
126159
127160# 3) start ssh (don't swallow failures)
@@ -130,7 +163,7 @@ if ! systemctl is-active --quiet ssh; then
130163 if ! systemctl start ssh; then
131164 diag "systemctl start ssh failed"
132165 dump_diag
133− exit 1
166+ vibebox_fail "failed to start ssh service" 1
134167 fi
135168fi
136169
@@ -168,7 +201,7 @@ done
168201if [ -z "$dev" ] || [ -z "$ip" ]; then
169202 diag "no stable IPv4 on default route interface"
170203 dump_diag
171− exit 1
204+ vibebox_fail "no stable ipv4 route on default interface" 1
172205fi
173206
174207# 5) strong verify: ssh must listen externally (0.0.0.0:22 or $ip:22 or [::]:22)
@@ -187,8 +220,14 @@ done
187220if ! listens_ok; then
188221 diag "sshd not listening on 0.0.0.0:22 / ${ip}:22"
189222 dump_diag
190− exit 1
223+ vibebox_fail "sshd is not listening on the expected address" 1
191224fi
192225
226+ip a
227+ip link
228+curl -s https://api.ipify.org ; echo
229+
230+cat /etc/machine-id
231+
193232echo VIBEBOX_SSH_READY
194233echo "VIBEBOX_IPV4=$ip"
src/tui.rs+54−37
@@ -1,10 +1,4 @@
1−use std::{
2− io::{self, Write},
3− os::unix::io::OwnedFd,
4− path::PathBuf,
5− sync::{Arc, Mutex},
6−};
7−
1+use bytesize::ByteSize;
82use color_eyre::Result;
93use crossterm::{
104 cursor::{MoveTo, Show},
@@ -22,9 +16,16 @@ use ratatui::{
2216 text::{Line, Span, Text},
2317 widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table, Widget},
2418};
19+use std::{
20+ io::{self, Write},
21+ os::unix::io::OwnedFd,
22+ path::PathBuf,
23+ sync::{Arc, Mutex},
24+};
2525
2626use crate::vm;
2727
28+// https://patorjk.com/software/taag/#p=display&f=ANSI+Shadow&t=VIBEBOX&x=none&v=4&h=4&w=80&we=false
2829const ASCII_BANNER: [&str; 7] = [
2930 "██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
3031 "██║ ██║██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗╚██╗██╔╝",
@@ -34,12 +35,15 @@ const ASCII_BANNER: [&str; 7] = [
3435 " ╚═══╝ ╚═╝╚═════╝ ╚══════╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝",
3536 "",
3637];
38+const INFO_LINE_COUNT: u16 = 5;
3739
3840#[derive(Debug, Clone)]
3941pub struct VmInfo {
40− pub version: String,
41− pub max_memory_mb: u64,
42+ pub max_memory: ByteSize,
4243 pub cpu_cores: usize,
44+ pub max_disk: ByteSize,
45+ pub system_name: String,
46+ pub auto_shutdown_ms: u64,
4347}
4448
4549#[derive(Debug)]
@@ -155,10 +159,14 @@ fn compute_page_layout(app: &AppState, width: u16) -> PageLayout {
155159fn header_height() -> u16 {
156160 let banner_height = ASCII_BANNER.len() as u16;
157161 let welcome_height = 1;
158− let info_height = 4;
162+ let info_height = info_block_height();
159163 welcome_height + banner_height + info_height
160164}
161165
166+fn info_block_height() -> u16 {
167+ INFO_LINE_COUNT.saturating_add(1)
168+}
169+
162170pub fn render_tui_once(app: &mut AppState) -> Result<()> {
163171 let (width, _) = crossterm::terminal::size()?;
164172 if width == 0 {
@@ -466,27 +474,27 @@ fn write_buffer_with_style(buffer: &Buffer, out: &mut impl Write) -> io::Result<
466474 Ok(())
467475}
468476
469−fn map_color(color: ratatui::style::Color) -> CrosstermColor {
477+fn map_color(color: Color) -> CrosstermColor {
470478 match color {
471− ratatui::style::Color::Reset => CrosstermColor::Reset,
472− ratatui::style::Color::Black => CrosstermColor::Black,
473− ratatui::style::Color::Red => CrosstermColor::DarkRed,
474− ratatui::style::Color::Green => CrosstermColor::DarkGreen,
475− ratatui::style::Color::Yellow => CrosstermColor::DarkYellow,
476− ratatui::style::Color::Blue => CrosstermColor::DarkBlue,
477− ratatui::style::Color::Magenta => CrosstermColor::DarkMagenta,
478− ratatui::style::Color::Cyan => CrosstermColor::DarkCyan,
479− ratatui::style::Color::Gray => CrosstermColor::Grey,
480− ratatui::style::Color::DarkGray => CrosstermColor::DarkGrey,
481− ratatui::style::Color::LightRed => CrosstermColor::Red,
482− ratatui::style::Color::LightGreen => CrosstermColor::Green,
483− ratatui::style::Color::LightYellow => CrosstermColor::Yellow,
484− ratatui::style::Color::LightBlue => CrosstermColor::Blue,
485− ratatui::style::Color::LightMagenta => CrosstermColor::Magenta,
486− ratatui::style::Color::LightCyan => CrosstermColor::Cyan,
487− ratatui::style::Color::White => CrosstermColor::White,
488− ratatui::style::Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
489− ratatui::style::Color::Indexed(i) => CrosstermColor::AnsiValue(i),
479+ Color::Reset => CrosstermColor::Reset,
480+ Color::Black => CrosstermColor::Black,
481+ Color::Red => CrosstermColor::DarkRed,
482+ Color::Green => CrosstermColor::DarkGreen,
483+ Color::Yellow => CrosstermColor::DarkYellow,
484+ Color::Blue => CrosstermColor::DarkBlue,
485+ Color::Magenta => CrosstermColor::DarkMagenta,
486+ Color::Cyan => CrosstermColor::DarkCyan,
487+ Color::Gray => CrosstermColor::Grey,
488+ Color::DarkGray => CrosstermColor::DarkGrey,
489+ Color::LightRed => CrosstermColor::Red,
490+ Color::LightGreen => CrosstermColor::Green,
491+ Color::LightYellow => CrosstermColor::Yellow,
492+ Color::LightBlue => CrosstermColor::Blue,
493+ Color::LightMagenta => CrosstermColor::Magenta,
494+ Color::LightCyan => CrosstermColor::Cyan,
495+ Color::White => CrosstermColor::White,
496+ Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
497+ Color::Indexed(i) => CrosstermColor::AnsiValue(i),
490498 }
491499}
492500
@@ -532,13 +540,15 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
532540 .constraints([
533541 Constraint::Length(1),
534542 Constraint::Length(ASCII_BANNER.len() as u16),
535− Constraint::Length(4),
543+ Constraint::Length(info_block_height()),
536544 ])
537545 .split(area);
538546
547+ let version = env!("CARGO_PKG_VERSION");
548+
539549 let welcome = Line::from(vec![
540550 Span::raw("Welcome to Vibebox v"),
541− Span::styled(&app.vm_info.version, Style::default().fg(Color::Yellow)),
551+ Span::styled(version, Style::default().fg(Color::Yellow)),
542552 ]);
543553
544554 Paragraph::new(welcome).render(header_chunks[0], buffer);
@@ -557,19 +567,26 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
557567 Span::styled(app.cwd.to_string_lossy(), Style::default().fg(Color::Cyan)),
558568 ]),
559569 Line::from(vec![
560− Span::raw("VM Version: "),
561− Span::styled(&app.vm_info.version, Style::default().fg(Color::Green)),
570+ Span::raw("System: "),
571+ Span::styled(&app.vm_info.system_name, Style::default().fg(Color::Green)),
562572 ]),
563573 Line::from(vec![
564− Span::raw("CPU / Memory: "),
574+ Span::raw("CPU / Memory / Disk: "),
565575 Span::styled(
566576 format!(
567− "{} cores / {} MB",
568− app.vm_info.cpu_cores, app.vm_info.max_memory_mb
577+ "{} cores / {} / {}",
578+ app.vm_info.cpu_cores, app.vm_info.max_memory, app.vm_info.max_disk
569579 ),
570580 Style::default().fg(Color::Green),
571581 ),
572582 ]),
583+ Line::from(vec![
584+ Span::raw("Auto Shutdown: "),
585+ Span::styled(
586+ format!("{} ms", app.vm_info.auto_shutdown_ms),
587+ Style::default().fg(Color::Green),
588+ ),
589+ ]),
573590 ];
574591
575592 Paragraph::new(info_lines)
src/utils.rsnew file+18
@@ -0,0 +1,18 @@
1+use std::{
2+ env,
3+ path::{Path, PathBuf},
4+};
5+
6+pub fn relative_to_home(directory: &Path) -> String {
7+ let Ok(home) = env::var("HOME") else {
8+ return directory.display().to_string();
9+ };
10+ let home_path = PathBuf::from(home);
11+ if let Ok(stripped) = directory.strip_prefix(&home_path) {
12+ if stripped.components().next().is_none() {
13+ return "~".to_string();
14+ }
15+ return format!("~/{}", stripped.display());
16+ }
17+ directory.display().to_string()
18+}
src/vm.rs+339−141
@@ -1,4 +1,5 @@
11use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
2+use anyhow::{Context, Error, Result, bail};
23use std::{
34 env, fs,
45 io::{self, Write},
@@ -22,6 +23,7 @@ use std::{
2223};
2324
2425use block2::RcBlock;
26+use bytesize::ByteSize;
2527use dispatch2::DispatchQueue;
2628use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
2729use objc2_foundation::*;
@@ -31,42 +33,46 @@ const DEBIAN_COMPRESSED_DISK_URL: &str = "https://cloud.debian.org/images/cloud/
3133const DEBIAN_COMPRESSED_SHA: &str = "6ab9be9e6834adc975268367f2f0235251671184345c34ee13031749fdfbf66fe4c3aafd949a2d98550426090e9ac645e79009c51eb0eefc984c15786570bb38";
3234const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
3335const SHARED_DIRECTORIES_TAG: &str = "shared";
36+pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts";
3437
35−const BYTES_PER_MB: u64 = 1024 * 1024;
36−const DEFAULT_CPU_COUNT: usize = 2;
37−const DEFAULT_RAM_MB: u64 = 2048;
38−const DEFAULT_RAM_BYTES: u64 = DEFAULT_RAM_MB * BYTES_PER_MB;
3938const START_TIMEOUT: Duration = Duration::from_secs(60);
4039const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
40+const PROVISION_EXPECT_TIMEOUT: Duration = Duration::from_secs(900);
41+
42+const ERROR_REPORT_SCRIPT: &str = include_str!("error_report.sh");
4143const PROVISION_SCRIPT: &str = include_str!("provision.sh");
42−const PROVISION_SCRIPT_NAME: &str = "provision.sh";
44+const RESIZE_DISK_SCRIPT: &str = include_str!("resize_disk.sh");
4345const DEFAULT_RAW_NAME: &str = "default.raw";
4446const INSTANCE_RAW_NAME: &str = "instance.raw";
4547const BASE_DISK_RAW_NAME: &str = "disk.raw";
4648
4749#[derive(Clone)]
48−pub(crate) enum LoginAction {
49− Expect { text: String, timeout: Duration },
50+pub enum LoginAction {
51+ Expect {
52+ text: String,
53+ timeout: Duration,
54+ },
55+ ExpectEither {
56+ success: String,
57+ failure: String,
58+ timeout: Duration,
59+ },
5060 Send(String),
51− Script { path: PathBuf, index: usize },
5261}
62+use crate::config::BoxConfig;
5363use LoginAction::*;
5464
5565#[derive(Clone)]
56−pub(crate) struct DirectoryShare {
66+pub struct DirectoryShare {
5767 host: PathBuf,
5868 guest: PathBuf,
5969 read_only: bool,
6070}
6171
6272impl DirectoryShare {
63− pub(crate) fn new(
64− host: PathBuf,
65− mut guest: PathBuf,
66− read_only: bool,
67− ) -> Result<Self, Box<dyn std::error::Error>> {
73+ pub fn new(host: PathBuf, mut guest: PathBuf, read_only: bool) -> Result<Self> {
6874 if !host.exists() {
69− return Err(format!("Host path does not exist: {}", host.display()).into());
75+ bail!(format!("host path does not exist: {}", host.display()));
7076 }
7177 if !guest.is_absolute() {
7278 guest = PathBuf::from("/root").join(guest);
@@ -78,10 +84,10 @@ impl DirectoryShare {
7884 })
7985 }
8086
81− pub(crate) fn from_mount_spec(spec: &str) -> Result<Self, Box<dyn std::error::Error>> {
87+ pub fn from_mount_spec(spec: &str) -> Result<Self> {
8288 let parts: Vec<&str> = spec.split(':').collect();
8389 if parts.len() < 2 || parts.len() > 3 {
84− return Err(format!("Invalid mount spec: {spec}").into());
90+ bail!(format!("invalid mount spec: {spec}"));
8591 }
8692 let host = expand_tilde_path(parts[0]);
8793 let guest = PathBuf::from(parts[1]);
@@ -90,11 +96,10 @@ impl DirectoryShare {
9096 "read-only" => true,
9197 "read-write" => false,
9298 _ => {
93− return Err(format!(
99+ bail!(format!(
94100 "Invalid mount mode '{}'; expected read-only or read-write",
95101 parts[2]
96− )
97− .into());
102+ ));
98103 }
99104 }
100105 } else {
@@ -122,10 +127,10 @@ fn expand_tilde_path(value: &str) -> PathBuf {
122127 if let Ok(home) = env::var("HOME") {
123128 return PathBuf::from(home).join(stripped);
124129 }
125− } else if value == "~" {
126− if let Ok(home) = env::var("HOME") {
127− return PathBuf::from(home);
128− }
130+ } else if value == "~"
131+ && let Ok(home) = env::var("HOME")
132+ {
133+ return PathBuf::from(home);
129134 }
130135 PathBuf::from(value)
131136}
@@ -133,23 +138,33 @@ fn expand_tilde_path(value: &str) -> PathBuf {
133138pub struct VmArg {
134139 pub cpu_count: usize,
135140 pub ram_bytes: u64,
141+ pub disk_bytes: u64,
136142 pub no_default_mounts: bool,
137143 pub mounts: Vec<String>,
138144}
139145
140−pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<(), Box<dyn std::error::Error>>
146+type StatusEmitter<'a> = dyn Fn(&str) + std::marker::Send + Sync + 'a;
147+
148+fn emit_status(status: Option<&StatusEmitter<'_>>, message: &str) {
149+ if let Some(status) = status {
150+ status(message);
151+ }
152+}
153+
154+pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<()>
141155where
142156 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
143157{
144− run_with_args_and_extras(args, io_handler, Vec::new(), Vec::new())
158+ run_with_args_and_extras(args, io_handler, Vec::new(), Vec::new(), None)
145159}
146160
147−pub(crate) fn run_with_args_and_extras<F>(
161+pub fn run_with_args_and_extras<F>(
148162 args: VmArg,
149163 io_handler: F,
150164 extra_login_actions: Vec<LoginAction>,
151165 extra_directory_shares: Vec<DirectoryShare>,
152−) -> Result<(), Box<dyn std::error::Error>>
166+ status: Option<&StatusEmitter<'_>>,
167+) -> Result<()>
153168where
154169 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
155170{
@@ -158,7 +173,7 @@ where
158173 let project_root = env::current_dir()?;
159174 let project_name = project_root
160175 .file_name()
161− .ok_or("Project directory has no name")?
176+ .with_context(|| "Project directory has no name")?
162177 .to_string_lossy()
163178 .into_owned();
164179
@@ -170,6 +185,10 @@ where
170185 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
171186
172187 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
188+ fs::create_dir_all(&instance_dir)?;
189+ emit_status(status, "preparing VM image...");
190+ tracing::info!("preparing VM image...");
191+ let provision_log = instance_dir.join("provision.log");
173192
174193 let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
175194 let base_compressed = cache_dir.join(basename_compressed);
@@ -193,15 +212,26 @@ where
193212 &base_compressed,
194213 &default_raw,
195214 std::slice::from_ref(&mise_directory_share),
215+ Some(&provision_log),
216+ status,
196217 )?;
197− ensure_instance_disk(&instance_raw, &default_raw)?;
218+ let _ = ensure_instance_disk(
219+ &instance_raw,
220+ &default_raw,
221+ ByteSize(args.disk_bytes),
222+ status,
223+ )?;
224+ let base_size = fs::metadata(&default_raw)?.len();
225+ let instance_size = fs::metadata(&instance_raw)?.len();
226+ let needs_resize = instance_size > base_size;
198227 let disk_path = instance_raw;
199228
200229 let mut login_actions = Vec::new();
201230 let mut directory_shares = Vec::new();
202231
203232 if !args.no_default_mounts {
204− login_actions.push(Send(format!("cd {project_name}")));
233+ let project_guest_dir = PathBuf::from(PROJECT_GUEST_BASE).join(project_name);
234+ login_actions.push(Send(format!("cd {}", project_guest_dir.display())));
205235
206236 // 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.
207237 // I couldn't find an alternative way to do this --- the MacOS sandbox doesn't apply to the Apple Virtualization system
@@ -209,15 +239,6 @@ where
209239 login_actions.push(Send(r"mount -t tmpfs tmpfs .git/".into()));
210240 }
211241
212− directory_shares.push(
213− DirectoryShare::new(
214− project_root,
215− PathBuf::from("/root/").join(project_name),
216− false,
217− )
218− .expect("Project directory must exist"),
219− );
220−
221242 directory_shares.push(mise_directory_share);
222243 }
223244
@@ -227,6 +248,11 @@ where
227248 directory_shares.push(DirectoryShare::from_mount_spec(spec)?);
228249 }
229250
251+ if needs_resize {
252+ let resize_cmd = script_command_from_content("resize_disk.sh", RESIZE_DISK_SCRIPT)?;
253+ login_actions.push(Send(resize_cmd));
254+ }
255+
230256 if let Some(motd_action) = motd_login_action(&directory_shares) {
231257 login_actions.push(motd_action);
232258 }
@@ -239,34 +265,29 @@ where
239265 &directory_shares[..],
240266 args.cpu_count,
241267 args.ram_bytes,
268+ status,
242269 io_handler,
243270 )
244271}
245272
246−fn script_command_from_path(
247− path: &Path,
248− index: usize,
249−) -> Result<String, Box<dyn std::error::Error>> {
250− let script = fs::read_to_string(path)
251− .map_err(|err| format!("Failed to read script {}: {err}", path.display()))?;
252− let label = format!("{}_{}", index, path.file_name().unwrap().display());
253− script_command_from_content(&label, &script)
254−}
255−
256−pub(crate) fn script_command_from_content(
257− label: &str,
258− script: &str,
259−) -> Result<String, Box<dyn std::error::Error>> {
273+pub fn script_command_from_content(label: &str, script: &str) -> Result<String> {
260274 let marker = "VIBE_SCRIPT_EOF";
261275 let guest_dir = "/tmp/vibe-scripts";
262276 let guest_path = format!("{guest_dir}/{label}.sh");
277+ let script_body = match script.split_once('\n') {
278+ Some((first, rest)) if first.starts_with("#!") => rest,
279+ _ => script,
280+ };
281+ let wrapped_script = ERROR_REPORT_SCRIPT
282+ .replace("__LABEL__", label)
283+ .replace("__SCRIPT_BODY__", script_body);
263284 let command = format!(
264− "mkdir -p {guest_dir}\ncat >{guest_path} <<'{marker}'\n{script}\n{marker}\nchmod +x {guest_path}\n{guest_path}"
285+ "mkdir -p {guest_dir}\ncat >{guest_path} <<'{marker}'\n{wrapped_script}\n{marker}\nchmod +x {guest_path}\n{guest_path}"
265286 );
266287 if script.contains(marker) {
267− return Err(
268− format!("Script '{label}' contains marker '{marker}', cannot safely upload").into(),
269− );
288+ bail!(format!(
289+ "Script '{label}' contains marker '{marker}', cannot safely upload"
290+ ));
270291 }
271292 Ok(command)
272293}
@@ -330,6 +351,12 @@ enum WaitResult {
330351 Found,
331352}
332353
354+#[derive(PartialEq, Eq)]
355+enum WaitAnyResult {
356+ Timeout,
357+ Found(usize),
358+}
359+
333360pub enum VmInput {
334361 Bytes(Vec<u8>),
335362 Shutdown,
@@ -337,6 +364,7 @@ pub enum VmInput {
337364
338365enum VmOutput {
339366 LoginActionTimeout { action: String, timeout: Duration },
367+ LoginActionFailed { action: String, reason: String },
340368}
341369
342370#[derive(Default)]
@@ -373,6 +401,41 @@ impl OutputMonitor {
373401 WaitResult::Found
374402 }
375403 }
404+
405+ fn wait_for_any(&self, needles: &[&str], timeout: Duration) -> WaitAnyResult {
406+ let mut found: Option<usize> = None;
407+ let (_unused, timeout_result) = self
408+ .condvar
409+ .wait_timeout_while(self.buffer.lock().unwrap(), timeout, |buf| {
410+ if let Some((pos, idx, len)) = find_any(buf, needles) {
411+ *buf = buf[(pos + len)..].to_string();
412+ found = Some(idx);
413+ false
414+ } else {
415+ true
416+ }
417+ })
418+ .unwrap();
419+
420+ if timeout_result.timed_out() {
421+ WaitAnyResult::Timeout
422+ } else {
423+ WaitAnyResult::Found(found.unwrap_or(0))
424+ }
425+ }
426+}
427+
428+fn find_any(buf: &str, needles: &[&str]) -> Option<(usize, usize, usize)> {
429+ let mut best: Option<(usize, usize, usize)> = None; // (pos, idx, len)
430+ for (idx, needle) in needles.iter().enumerate() {
431+ if let Some(pos) = buf.find(needle) {
432+ let candidate = (pos, idx, needle.len());
433+ if best.is_none_or(|b| candidate.0 < b.0) {
434+ best = Some(candidate);
435+ }
436+ }
437+ }
438+ best
376439}
377440
378441#[derive(Debug)]
@@ -421,15 +484,17 @@ impl IoControl {
421484fn ensure_base_image(
422485 base_raw: &Path,
423486 base_compressed: &Path,
424−) -> Result<(), Box<dyn std::error::Error>> {
487+ status: Option<&StatusEmitter<'_>>,
488+) -> Result<()> {
425489 if base_raw.exists() {
426490 return Ok(());
427491 }
428492
429493 if !base_compressed.exists()
430− || std::fs::metadata(base_compressed).map(|m| m.len())? < DEBIAN_COMPRESSED_SIZE_BYTES
494+ || fs::metadata(base_compressed).map(|m| m.len())? < DEBIAN_COMPRESSED_SIZE_BYTES
431495 {
432− println!("Downloading base image...");
496+ emit_status(status, "downloading base image...");
497+ tracing::info!("downloading base image");
433498 let status = Command::new("curl")
434499 .args([
435500 "--continue-at",
@@ -443,12 +508,14 @@ fn ensure_base_image(
443508 ])
444509 .status()?;
445510 if !status.success() {
446− return Err("Failed to download base image".into());
511+ bail!("failed to download base image");
447512 }
448513 }
449514
450515 // Check SHA
451516 {
517+ emit_status(status, "verifying base image...");
518+ tracing::info!("verifying base image...");
452519 let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
453520
454521 let mut child = Command::new("/usr/bin/shasum")
@@ -466,22 +533,25 @@ fn ensure_base_image(
466533
467534 let status = child.wait().expect("failed to wait on child");
468535 if !status.success() {
469− return Err(format!("SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}").into());
536+ bail!(format!(
537+ "SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}"
538+ ));
470539 }
471540 }
472541
473− println!("Decompressing base image...");
542+ emit_status(status, "decompressing base image...");
543+ tracing::info!("decompressing base image...");
474544 let status = Command::new("tar")
475545 .args([
476546 "-xOf",
477547 &base_compressed.to_string_lossy(),
478548 BASE_DISK_RAW_NAME,
479549 ])
480− .stdout(std::fs::File::create(base_raw)?)
550+ .stdout(fs::File::create(base_raw)?)
481551 .status()?;
482552
483553 if !status.success() {
484− return Err("Failed to decompress base image".into());
554+ bail!("Failed to decompress base image");
485555 }
486556
487557 Ok(())
@@ -492,24 +562,56 @@ fn ensure_default_image(
492562 base_compressed: &Path,
493563 default_raw: &Path,
494564 directory_shares: &[DirectoryShare],
495−) -> Result<(), Box<dyn std::error::Error>> {
565+ provision_log: Option<&Path>,
566+ status: Option<&StatusEmitter<'_>>,
567+) -> Result<()> {
496568 if default_raw.exists() {
497569 return Ok(());
498570 }
499571
500− ensure_base_image(base_raw, base_compressed)?;
572+ ensure_base_image(base_raw, base_compressed, status)?;
501573
502− println!("Configuring base image...");
574+ emit_status(status, "configuring base image...");
575+ tracing::info!("configuring base image...");
503576 fs::copy(base_raw, default_raw)?;
504577
505− let provision_command = script_command_from_content(PROVISION_SCRIPT_NAME, PROVISION_SCRIPT)?;
506− run_vm(
507− default_raw,
508− &[Send(provision_command)],
509− directory_shares,
510− DEFAULT_CPU_COUNT,
511− DEFAULT_RAM_BYTES,
512− )?;
578+ let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?;
579+ let provision_actions = [
580+ Send(provision_command),
581+ ExpectEither {
582+ success: "VIBEBOX_PROVISION_OK".to_string(),
583+ failure: "VIBEBOX_PROVISION_FAILED".to_string(),
584+ timeout: PROVISION_EXPECT_TIMEOUT,
585+ },
586+ ];
587+ let provision_result = if let Some(log_path) = provision_log {
588+ let log_path = log_path.to_path_buf();
589+ run_vm_with_io(
590+ default_raw,
591+ &provision_actions,
592+ directory_shares,
593+ BoxConfig::default().cpu_count,
594+ BoxConfig::default().ram_size.as_u64(),
595+ status,
596+ move |output_monitor, vm_output_fd, vm_input_fd| {
597+ spawn_vm_io_with_log(output_monitor, vm_output_fd, vm_input_fd, log_path)
598+ },
599+ )
600+ } else {
601+ run_vm(
602+ default_raw,
603+ &provision_actions,
604+ directory_shares,
605+ BoxConfig::default().cpu_count,
606+ BoxConfig::default().ram_size.as_u64(),
607+ status,
608+ )
609+ };
610+
611+ if let Err(err) = provision_result {
612+ let _ = fs::remove_file(default_raw);
613+ return Err(err);
614+ }
513615
514616 Ok(())
515617}
@@ -517,15 +619,47 @@ fn ensure_default_image(
517619fn ensure_instance_disk(
518620 instance_raw: &Path,
519621 template_raw: &Path,
520−) -> Result<(), Box<dyn std::error::Error>> {
622+ target_bytes: ByteSize,
623+ status: Option<&StatusEmitter<'_>>,
624+) -> Result<bool> {
521625 if instance_raw.exists() {
522− return Ok(());
626+ let current_size = ByteSize(fs::metadata(instance_raw)?.len());
627+ if current_size != target_bytes {
628+ let current_gb = current_size;
629+ let target_gb = target_bytes;
630+ tracing::warn!(
631+ "instance disk size does not match config (current {}, config {}); disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using existing disk.",
632+ current_gb,
633+ target_gb
634+ );
635+ }
636+ return Ok(false);
523637 }
524638
525− println!("Creating instance disk from {}...", template_raw.display());
526− std::fs::create_dir_all(instance_raw.parent().unwrap())?;
527− fs::copy(template_raw, instance_raw)?;
528− Ok(())
639+ let template_size = ByteSize(fs::metadata(template_raw)?.len());
640+ if target_bytes < template_size {
641+ bail!(format!(
642+ "Requested disk size {} is smaller than base image size {}",
643+ target_bytes, template_size
644+ ));
645+ }
646+ let target_size = target_bytes;
647+ let needs_resize = target_size > template_size;
648+
649+ emit_status(status, "creating instance disk...");
650+ tracing::info!("creating instance disk...");
651+ tracing::info!(path = %template_raw.display(), "creating instance disk");
652+ fs::create_dir_all(instance_raw.parent().unwrap())?;
653+ if target_size == template_size {
654+ fs::copy(template_raw, instance_raw)?;
655+ return Ok(needs_resize);
656+ }
657+
658+ let mut dst = fs::File::create(instance_raw)?;
659+ dst.set_len(target_size.as_u64())?;
660+ let mut src = fs::File::open(template_raw)?;
661+ std::io::copy(&mut src, &mut dst)?;
662+ Ok(needs_resize)
529663}
530664
531665pub struct IoContext {
@@ -541,18 +675,17 @@ pub fn create_pipe() -> (OwnedFd, OwnedFd) {
541675 (read_stream.into(), write_stream.into())
542676}
543677
544−pub fn spawn_vm_io_with_hooks<F, G>(
678+pub fn spawn_vm_io_with_hooks<
679+ F: FnMut(&str) -> bool + std::marker::Send + 'static,
680+ G: FnMut(&[u8]) + std::marker::Send + 'static,
681+>(
545682 output_monitor: Arc<OutputMonitor>,
546683 vm_output_fd: OwnedFd,
547684 vm_input_fd: OwnedFd,
548685 io_control: Arc<IoControl>,
549686 mut on_line: F,
550687 mut on_output: G,
551−) -> IoContext
552−where
553− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
554− G: FnMut(&[u8]) + ::std::marker::Send + 'static,
555−{
688+) -> IoContext {
556689 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
557690
558691 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
@@ -700,14 +833,14 @@ where
700833 PollResult::Spurious => continue,
701834 PollResult::Ready(bytes) => {
702835 if io_control.forward_output() {
703− // enable raw mode, if we haven't already
836+ // enable raw mode if we haven't already
704837 if raw_guard.lock().unwrap().is_none()
705838 && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
706839 {
707840 *raw_guard.lock().unwrap() = Some(guard);
708841 }
709842
710− let mut stdout = std::io::stdout().lock();
843+ let mut stdout = io::stdout().lock();
711844 if stdout.write_all(bytes).is_err() {
712845 break;
713846 }
@@ -723,7 +856,7 @@ where
723856
724857 // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
725858 let mux_thread = thread::spawn(move || {
726− let mut vm_writer = std::fs::File::from(vm_input_fd);
859+ let mut vm_writer = fs::File::from(vm_input_fd);
727860 loop {
728861 match input_rx.recv() {
729862 Ok(VmInput::Bytes(data)) => {
@@ -753,7 +886,7 @@ pub fn spawn_vm_io_with_line_handler<F>(
753886 on_line: F,
754887) -> IoContext
755888where
756− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
889+ F: FnMut(&str) -> bool + std::marker::Send + 'static,
757890{
758891 spawn_vm_io_with_hooks(
759892 output_monitor,
@@ -773,6 +906,36 @@ pub fn spawn_vm_io(
773906 spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
774907}
775908
909+fn spawn_vm_io_with_log(
910+ output_monitor: Arc<OutputMonitor>,
911+ vm_output_fd: OwnedFd,
912+ vm_input_fd: OwnedFd,
913+ log_path: PathBuf,
914+) -> IoContext {
915+ let log_file = fs::OpenOptions::new()
916+ .create(true)
917+ .write(true)
918+ .truncate(true)
919+ .open(&log_path)
920+ .ok()
921+ .map(|file| Arc::new(Mutex::new(file)));
922+
923+ spawn_vm_io_with_hooks(
924+ output_monitor,
925+ vm_output_fd,
926+ vm_input_fd,
927+ IoControl::new(),
928+ |_| false,
929+ move |bytes| {
930+ if let Some(log) = &log_file
931+ && let Ok(mut file) = log.lock()
932+ {
933+ let _ = file.write_all(bytes);
934+ }
935+ },
936+ )
937+}
938+
776939impl IoContext {
777940 pub fn shutdown(self) {
778941 let _ = self.input_tx.send(VmInput::Shutdown);
@@ -790,7 +953,7 @@ fn create_vm_configuration(
790953 vm_writes_to_fd: OwnedFd,
791954 cpu_count: usize,
792955 ram_bytes: u64,
793−) -> Result<Retained<VZVirtualMachineConfiguration>, Box<dyn std::error::Error>> {
956+) -> Result<Retained<VZVirtualMachineConfiguration>> {
794957 unsafe {
795958 let platform =
796959 VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
@@ -824,7 +987,7 @@ fn create_vm_configuration(
824987 false,
825988 VZDiskImageCachingMode::Automatic,
826989 VZDiskImageSynchronizationMode::Full,
827− ).unwrap();
990+ )?;
828991
829992 let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
830993 VZVirtioBlockDeviceConfiguration::alloc(),
@@ -911,7 +1074,7 @@ fn create_vm_configuration(
9111074 // Validate
9121075 config.validateWithError().map_err(|e| {
9131076 io::Error::other(format!(
914− "Invalid VM configuration: {:?}",
1077+ "invalid VM configuration: {:?}",
9151078 e.localizedDescription()
9161079 ))
9171080 })?;
@@ -920,9 +1083,9 @@ fn create_vm_configuration(
9201083 }
9211084}
9221085
923−fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std::error::Error>> {
1086+fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>> {
9241087 unsafe {
925− let temp_dir = std::env::temp_dir();
1088+ let temp_dir = env::temp_dir();
9261089 let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
9271090 let url = nsurl_from_path(&temp_path)?;
9281091 let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
@@ -938,8 +1101,8 @@ fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std
9381101fn spawn_login_actions_thread(
9391102 login_actions: Vec<LoginAction>,
9401103 output_monitor: Arc<OutputMonitor>,
941− input_tx: mpsc::Sender<VmInput>,
942− vm_output_tx: mpsc::Sender<VmOutput>,
1104+ input_tx: Sender<VmInput>,
1105+ vm_output_tx: Sender<VmOutput>,
9431106) -> thread::JoinHandle<()> {
9441107 thread::spawn(move || {
9451108 for a in login_actions {
@@ -953,22 +1116,31 @@ fn spawn_login_actions_thread(
9531116 return;
9541117 }
9551118 }
1119+ ExpectEither {
1120+ success,
1121+ failure,
1122+ timeout,
1123+ } => match output_monitor.wait_for_any(&[&success, &failure], timeout) {
1124+ WaitAnyResult::Found(0) => {}
1125+ WaitAnyResult::Found(_) => {
1126+ let _ = vm_output_tx.send(VmOutput::LoginActionFailed {
1127+ action: format!("expect '{}'", success),
1128+ reason: format!("saw failure marker '{}'", failure),
1129+ });
1130+ return;
1131+ }
1132+ WaitAnyResult::Timeout => {
1133+ let _ = vm_output_tx.send(VmOutput::LoginActionTimeout {
1134+ action: format!("expect '{}'", success),
1135+ timeout,
1136+ });
1137+ return;
1138+ }
1139+ },
9561140 Send(mut text) => {
9571141 text.push('\n'); // Type the newline so the command is actually submitted.
9581142 input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
9591143 }
960− Script { path, index } => {
961− let command = match script_command_from_path(&path, index) {
962− Ok(command) => command,
963− Err(err) => {
964− eprintln!("{err}");
965− return;
966− }
967− };
968− let mut text = command;
969− text.push('\n');
970− input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
971− }
9721144 }
9731145 }
9741146 })
@@ -980,8 +1152,9 @@ fn run_vm_with_io<F>(
9801152 directory_shares: &[DirectoryShare],
9811153 cpu_count: usize,
9821154 ram_bytes: u64,
1155+ status: Option<&StatusEmitter<'_>>,
9831156 io_handler: F,
984−) -> Result<(), Box<dyn std::error::Error>>
1157+) -> Result<()>
9851158where
9861159 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
9871160{
@@ -1028,21 +1201,23 @@ where
10281201
10291202 match rx.try_recv() {
10301203 Ok(result) => {
1031− result.map_err(|e| format!("Failed to start VM: {}", e))?;
1204+ result.map_err(|e| Error::msg(format!("Failed to start VM: {}", e)))?;
10321205 break;
10331206 }
10341207 Err(mpsc::TryRecvError::Empty) => continue,
10351208 Err(mpsc::TryRecvError::Disconnected) => {
1036− return Err("VM start channel disconnected".into());
1209+ bail!("VM start channel disconnected");
10371210 }
10381211 }
10391212 }
10401213
10411214 if Instant::now() >= start_deadline {
1042− return Err("Timed out waiting for VM to start".into());
1215+ bail!("Timed out waiting for VM to start");
10431216 }
10441217
1045− println!("VM booting...");
1218+ emit_status(status, "vm booting... go vibecoder!");
1219+ tracing::info!("vm booting... go vibecoder!");
1220+ tracing::info!("vm booting");
10461221
10471222 let output_monitor = Arc::new(OutputMonitor::default());
10481223 let io_ctx = io_handler(output_monitor.clone(), we_read_from, we_write_to);
@@ -1087,7 +1262,7 @@ where
10871262 );
10881263
10891264 let mut last_state = None;
1090− let mut exit_result = Ok(());
1265+ let mut exit_result: Result<(), String> = Ok(());
10911266 loop {
10921267 unsafe {
10931268 NSRunLoop::mainRunLoop().runMode_beforeDate(
@@ -1106,12 +1281,28 @@ where
11061281 exit_result = Err(format!(
11071282 "Login action ({}) timed out after {:?}; shutting down.",
11081283 action, timeout
1109− )
1110− .into());
1284+ ));
11111285 unsafe {
11121286 if vm.canRequestStop() {
11131287 if let Err(err) = vm.requestStopWithError() {
1114− eprintln!("Failed to request VM stop: {:?}", err);
1288+ tracing::error!(error = ?err, "failed to request VM stop");
1289+ }
1290+ } else if vm.canStop() {
1291+ let handler = RcBlock::new(|_error: *mut NSError| {});
1292+ vm.stopWithCompletionHandler(&handler);
1293+ }
1294+ }
1295+ break;
1296+ }
1297+ Ok(VmOutput::LoginActionFailed { action, reason }) => {
1298+ exit_result = Err(format!(
1299+ "Login action ({}) failed: {}; shutting down.",
1300+ action, reason
1301+ ));
1302+ unsafe {
1303+ if vm.canRequestStop() {
1304+ if let Err(err) = vm.requestStopWithError() {
1305+ tracing::error!(error = ?err, "failed to request VM stop");
11151306 }
11161307 } else if vm.canStop() {
11171308 let handler = RcBlock::new(|_error: *mut NSError| {});
@@ -1123,7 +1314,7 @@ where
11231314 Err(mpsc::TryRecvError::Empty) => {}
11241315 Err(mpsc::TryRecvError::Disconnected) => {}
11251316 }
1126− if state != objc2_virtualization::VZVirtualMachineState::Running {
1317+ if state != VZVirtualMachineState::Running {
11271318 //eprintln!("VM stopped with state: {:?}", state);
11281319 break;
11291320 }
@@ -1133,7 +1324,7 @@ where
11331324
11341325 io_ctx.shutdown();
11351326
1136− exit_result
1327+ exit_result.map_err(Error::msg)
11371328}
11381329
11391330fn run_vm(
@@ -1142,18 +1333,20 @@ fn run_vm(
11421333 directory_shares: &[DirectoryShare],
11431334 cpu_count: usize,
11441335 ram_bytes: u64,
1145−) -> Result<(), Box<dyn std::error::Error>> {
1336+ status: Option<&StatusEmitter<'_>>,
1337+) -> Result<()> {
11461338 run_vm_with_io(
11471339 disk_path,
11481340 login_actions,
11491341 directory_shares,
11501342 cpu_count,
11511343 ram_bytes,
1344+ status,
11521345 spawn_vm_io,
11531346 )
11541347}
11551348
1156−fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
1349+fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>> {
11571350 let abs_path = if path.is_absolute() {
11581351 path.to_path_buf()
11591352 } else {
@@ -1162,7 +1355,7 @@ fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::E
11621355 let ns_path = NSString::from_str(
11631356 abs_path
11641357 .to_str()
1165− .ok_or("Non-UTF8 path encountered while building NSURL")?,
1358+ .with_context(|| "non-UTF8 path encountered while building NSURL")?,
11661359 );
11671360 Ok(NSURL::fileURLWithPath(&ns_path))
11681361}
@@ -1177,7 +1370,7 @@ fn enable_raw_mode(fd: i32) -> io::Result<RawModeGuard> {
11771370 let original = attributes;
11781371
11791372 // Disable translation of carriage return to newline on input
1180− attributes.c_iflag &= !(libc::ICRNL);
1373+ attributes.c_iflag &= !libc::ICRNL;
11811374 // Disable canonical mode (line buffering), echo, and signal generation
11821375 attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
11831376 attributes.c_cc[libc::VMIN] = 0;
@@ -1205,10 +1398,10 @@ impl Drop for RawModeGuard {
12051398
12061399// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
12071400pub fn ensure_signed() {
1208− if std::env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
1401+ if env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
12091402 return;
12101403 }
1211− let exe = std::env::current_exe().expect("failed to get current exe path");
1404+ let exe = env::current_exe().expect("failed to get current exe path");
12121405 let exe_str = exe.to_str().expect("exe path not valid utf-8");
12131406
12141407 let has_required_entitlements = {
@@ -1230,10 +1423,10 @@ pub fn ensure_signed() {
12301423 }
12311424
12321425 const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1233− let entitlements_path = std::env::temp_dir().join("entitlements.plist");
1234− std::fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
1426+ let entitlements_path = env::temp_dir().join("entitlements.plist");
1427+ fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
12351428
1236− let status = Command::new("codesign")
1429+ let output = Command::new("codesign")
12371430 .args([
12381431 "--sign",
12391432 "-",
@@ -1242,22 +1435,27 @@ pub fn ensure_signed() {
12421435 entitlements_path.to_str().unwrap(),
12431436 exe_str,
12441437 ])
1245− .status();
1438+ .output();
12461439
1247− let _ = std::fs::remove_file(&entitlements_path);
1440+ let _ = fs::remove_file(&entitlements_path);
12481441
1249− match status {
1250− Ok(s) if s.success() => {
1251− let err = Command::new(&exe).args(std::env::args_os().skip(1)).exec();
1252− eprintln!("failed to re-exec after signing: {err}");
1442+ match output {
1443+ Ok(o) if o.status.success() => {
1444+ let stderr = String::from_utf8_lossy(&o.stderr);
1445+ if !stderr.trim().is_empty() {
1446+ tracing::debug!(codesign_stderr = %stderr.trim(), "codesign output");
1447+ }
1448+ let err = Command::new(&exe).args(env::args_os().skip(1)).exec();
1449+ tracing::error!(error = %err, "failed to re-exec after signing");
12531450 std::process::exit(1);
12541451 }
1255− Ok(s) => {
1256− eprintln!("codesign failed with status: {s}");
1452+ Ok(o) => {
1453+ let stderr = String::from_utf8_lossy(&o.stderr);
1454+ tracing::error!(status = %o.status, codesign_stderr = %stderr.trim(), "codesign failed");
12571455 std::process::exit(1);
12581456 }
12591457 Err(e) => {
1260− eprintln!("failed to run codesign: {e}");
1458+ tracing::error!(error = %e, "failed to run codesign");
12611459 std::process::exit(1);
12621460 }
12631461 }
src/vm_manager.rs+587−172
@@ -1,3 +1,15 @@
1+use crate::instance::{VmLiveness, vm_liveness};
2+use crate::session_manager::INSTANCE_DIR_NAME;
3+use crate::{
4+ config::CONFIG_PATH_ENV,
5+ instance::{
6+ InstanceConfig, STATUS_VM_ERROR_PREFIX, build_ssh_login_actions, ensure_instance_dir,
7+ ensure_ssh_keypair, extract_ipv4, load_or_create_instance_config, write_instance_config,
8+ },
9+ session_manager::{GLOBAL_DIR_NAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME},
10+ vm::{self, DirectoryShare, LoginAction, PROJECT_GUEST_BASE, VmInput},
11+};
12+use anyhow::{Error, Result, anyhow, bail};
113use std::{
214 env, fs,
315 io::{Read, Write},
@@ -5,7 +17,6 @@ use std::{
517 fs::PermissionsExt,
618 io::AsRawFd,
719 net::{UnixListener, UnixStream},
8− process::CommandExt,
920 },
1021 path::{Path, PathBuf},
1122 process::{Command, Stdio},
@@ -14,36 +25,44 @@ use std::{
1425 time::{Duration, Instant},
1526};
1627
17−use crate::{
18− config::CONFIG_PATH_ENV,
19− instance::SERIAL_LOG_NAME,
20− instance::{
21− InstanceConfig, build_ssh_login_actions, ensure_instance_dir, ensure_ssh_keypair,
22− extract_ipv4, load_or_create_instance_config, write_instance_config,
23− },
24− session_manager::{
25− GLOBAL_DIR_NAME, INSTANCE_FILENAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME,
26− },
27− vm::{self, DirectoryShare, LoginAction, VmInput},
28−};
29−
28+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
29+const VM_ROOT_LOG_NAME: &str = "vm_root.log";
3030const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
3131const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
32+const SHUTDOWN_RETRY_MS: u64 = 500;
33+#[cfg(test)]
34+const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 1_000;
35+#[cfg(not(test))]
36+const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 12_000;
37+const STATUS_PREFIX: &str = "status:";
38+
39+type ClientStreams = Arc<Mutex<Vec<UnixStream>>>;
40+type SharedStatus = Arc<Mutex<String>>;
41+
42+#[cfg(not(test))]
43+fn force_exit(_reason: &str) -> ! {
44+ std::process::exit(1);
45+}
46+
47+#[cfg(test)]
48+fn force_exit(reason: &str) -> ! {
49+ panic!("{reason}");
50+}
3251
3352pub fn ensure_manager(
3453 raw_args: &[std::ffi::OsString],
3554 auto_shutdown_ms: u64,
3655 config_path: Option<&Path>,
37−) -> Result<UnixStream, Box<dyn std::error::Error>> {
56+) -> Result<UnixStream> {
3857 let project_root = env::current_dir()?;
3958 tracing::debug!(root = %project_root.display(), "ensure vm manager");
4059 let instance_dir = ensure_instance_dir(&project_root)?;
41− cleanup_stale_manager(&instance_dir);
60+ cleanup_stale_manager(&project_root)?;
4261 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
4362
4463 if let Ok(stream) = UnixStream::connect(&socket_path) {
4564 send_client_pid(&stream);
46− tracing::info!(path = %socket_path.display(), "connected to existing vm manager");
65+ tracing::info!(path = %socket_path.display(), "connected to an existing vm manager");
4766 return Ok(stream);
4867 }
4968
@@ -81,12 +100,11 @@ pub fn ensure_manager(
81100 drop(lock_file.take());
82101 let _ = fs::remove_file(&lock_path);
83102 }
84− return Err(format!(
85− "Timed out waiting for vm manager socket: {} ({})",
103+ bail!(format!(
104+ "timed out waiting for vm manager socket: {} ({})",
86105 socket_path.display(),
87106 err
88− )
89− .into());
107+ ));
90108 }
91109 thread::sleep(Duration::from_millis(100));
92110 }
@@ -94,24 +112,49 @@ pub fn ensure_manager(
94112 }
95113}
96114
97−pub fn run_manager(
98− args: vm::VmArg,
99− auto_shutdown_ms: u64,
100−) -> Result<(), Box<dyn std::error::Error>> {
115+pub fn run_manager(args: vm::VmArg, auto_shutdown_ms: u64) -> Result<()> {
101116 let project_root = env::current_dir()?;
102117 tracing::info!(root = %project_root.display(), "vm manager starting");
118+ #[cfg(not(feature = "mock-vm"))]
119+ {
120+ unsafe {
121+ env::remove_var("VIBEBOX_SKIP_CODESIGN");
122+ }
123+ vm::ensure_signed();
124+ unsafe {
125+ env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
126+ }
127+ }
103128 let _pid_guard = ensure_pid_file(&project_root)?;
104− run_manager_with(
105− &project_root,
106− args,
107− auto_shutdown_ms,
108− &RealVmExecutor,
109− ManagerOptions {
110− ensure_signed: true,
111− detach: true,
112− prepare_vm: true,
113− },
114− )
129+ #[cfg(feature = "mock-vm")]
130+ tracing::info!("vm manager using mock executor");
131+ let executor: &dyn VmExecutor = {
132+ #[cfg(feature = "mock-vm")]
133+ {
134+ &MockVmExecutor
135+ }
136+ #[cfg(not(feature = "mock-vm"))]
137+ {
138+ &RealVmExecutor
139+ }
140+ };
141+ let options = {
142+ #[cfg(feature = "mock-vm")]
143+ {
144+ ManagerOptions {
145+ detach: true,
146+ prepare_vm: false,
147+ }
148+ }
149+ #[cfg(not(feature = "mock-vm"))]
150+ {
151+ ManagerOptions {
152+ detach: true,
153+ prepare_vm: true,
154+ }
155+ }
156+ };
157+ run_manager_with(&project_root, args, auto_shutdown_ms, executor, options)
115158}
116159
117160fn spawn_manager_process(
@@ -119,30 +162,28 @@ fn spawn_manager_process(
119162 auto_shutdown_ms: u64,
120163 instance_dir: &Path,
121164 config_path: Option<&Path>,
122−) -> Result<(), Box<dyn std::error::Error>> {
165+) -> Result<()> {
123166 let exe = env::current_exe()?;
124167 let mut supervisor_exe = exe.clone();
125168 supervisor_exe.set_file_name("vibebox-supervisor");
126− let use_supervisor = supervisor_exe.exists();
127− let mut cmd = if use_supervisor {
128− Command::new(supervisor_exe)
129− } else {
130− let mut cmd = Command::new(exe);
131− cmd.arg0("vibebox-supervisor");
132− cmd
133− };
169+ // intentional
170+ if !supervisor_exe.exists() {
171+ bail!(format!(
172+ "vibebox-supervisor not found at {}",
173+ supervisor_exe.display()
174+ ));
175+ }
176+ let mut cmd = Command::new(supervisor_exe);
134177 if raw_args.len() > 1 {
135178 cmd.args(&raw_args[1..]);
136179 }
137− if !use_supervisor {
138− cmd.env("VIBEBOX_VM_MANAGER", "1");
139− }
180+ cmd.env("VIBEBOX_INTERNAL", "1");
140181 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
141182 cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
142183 if let Some(path) = config_path {
143184 cmd.env(CONFIG_PATH_ENV, path);
144185 }
145− tracing::info!(auto_shutdown_ms, "vm manager process spawn requested");
186+ tracing::debug!(auto_shutdown_ms, "vm manager process spawn requested");
146187 let log_path = instance_dir.join(VM_MANAGER_LOG_NAME);
147188 let log_file = fs::OpenOptions::new()
148189 .create(true)
@@ -164,32 +205,172 @@ fn spawn_manager_process(
164205 Ok(())
165206}
166207
167−fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard, Box<dyn std::error::Error>> {
208+fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard> {
168209 let instance_dir = ensure_instance_dir(project_root)?;
169210 let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
170− if let Ok(content) = fs::read_to_string(&pid_path) {
171− if let Ok(pid) = content.trim().parse::<u32>() {
172− if pid_is_alive(pid) {
173− return Err(format!("vm manager already running (pid {pid})").into());
174− }
211+ let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
212+ match vm_liveness(project_root)? {
213+ VmLiveness::RunningWithSocket { pid } => {
214+ bail!("vm manager already running (pid {pid})");
175215 }
176− let _ = fs::remove_file(&pid_path);
216+ VmLiveness::RunningWithoutSocket { pid } => {
217+ tracing::warn!(
218+ pid,
219+ path = %socket_path.display(),
220+ "stale pid file detected with missing socket"
221+ );
222+ }
223+ VmLiveness::NotRunningOrMissing => {}
177224 }
225+ let _ = fs::remove_file(&pid_path);
178226 fs::write(&pid_path, format!("{}\n", std::process::id()))?;
179227 let _ = fs::set_permissions(&pid_path, fs::Permissions::from_mode(0o600));
180228 Ok(PidFileGuard { path: pid_path })
181229}
182230
183−fn cleanup_stale_manager(instance_dir: &Path) {
184− let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
185− if let Ok(content) = fs::read_to_string(&pid_path) {
186− if let Ok(pid) = content.trim().parse::<u32>() {
187− if pid_is_alive(pid) {
188− return;
189− }
190− }
231+fn cleanup_stale_manager(project_root: &Path) -> Result<()> {
232+ let pid_path = project_root
233+ .join(INSTANCE_DIR_NAME)
234+ .join(VM_MANAGER_PID_NAME);
235+ if matches!(
236+ vm_liveness(project_root)?,
237+ VmLiveness::RunningWithSocket { .. } | VmLiveness::RunningWithoutSocket { .. }
238+ ) {
239+ return Ok(());
191240 }
192241 let _ = fs::remove_file(&pid_path);
242+ Ok(())
243+}
244+
245+fn inject_project_mount(
246+ mounts: &mut Vec<String>,
247+ project_root: &Path,
248+ ssh_user: &str,
249+ project_name: &str,
250+) {
251+ let guest_tilde = format!("~/{project_name}");
252+ let guest_home = format!("/home/{ssh_user}/{project_name}");
253+ let guest_base = format!("{PROJECT_GUEST_BASE}/{project_name}");
254+ let already_mapped = mounts.iter().any(|spec| {
255+ let parts: Vec<&str> = spec.split(':').collect();
256+ if parts.len() < 2 {
257+ return false;
258+ }
259+ let guest = parts[1];
260+ guest == guest_tilde || guest == guest_home || guest == guest_base
261+ });
262+ if already_mapped {
263+ return;
264+ }
265+ let host = project_root.display();
266+ mounts.insert(0, format!("{host}:{guest_tilde}:read-write"));
267+}
268+
269+fn prepare_mounts_and_links(mut args: vm::VmArg, ssh_user: &str) -> (vm::VmArg, String) {
270+ let mut links = Vec::new();
271+ let mut mounts = Vec::with_capacity(args.mounts.len());
272+ for spec in args.mounts {
273+ let (rewritten, link) = rewrite_mount_spec(&spec, ssh_user);
274+ if let Some(link) = link {
275+ links.push(link);
276+ }
277+ mounts.push(rewritten);
278+ }
279+ args.mounts = mounts;
280+ let script = render_home_links_script(&links, ssh_user);
281+ (args, script)
282+}
283+
284+struct HomeLink {
285+ source: String,
286+ target: String,
287+}
288+
289+fn rewrite_mount_spec(spec: &str, ssh_user: &str) -> (String, Option<HomeLink>) {
290+ let parts: Vec<&str> = spec.split(':').collect();
291+ if parts.len() < 2 || parts.len() > 3 {
292+ return (spec.to_string(), None);
293+ }
294+ let host = parts[0];
295+ let guest = parts[1];
296+ let mode = parts.get(2).copied();
297+
298+ let home_prefix = format!("/home/{ssh_user}");
299+ let (rel, is_home) = if guest == "~" {
300+ (String::new(), true)
301+ } else if let Some(stripped) = guest.strip_prefix("~/") {
302+ (stripped.to_string(), true)
303+ } else if guest == home_prefix {
304+ (String::new(), true)
305+ } else if let Some(stripped) = guest.strip_prefix(&(home_prefix.clone() + "/")) {
306+ (stripped.to_string(), true)
307+ } else {
308+ (String::new(), false)
309+ };
310+
311+ if !is_home {
312+ return (spec.to_string(), None);
313+ }
314+
315+ let root_base = PROJECT_GUEST_BASE;
316+ let root_path = if rel.is_empty() {
317+ root_base.to_string()
318+ } else {
319+ format!("{root_base}/{rel}")
320+ };
321+ let target = if rel.is_empty() {
322+ home_prefix
323+ } else {
324+ format!("{home_prefix}/{rel}")
325+ };
326+
327+ let rewritten = match mode {
328+ Some(mode) => format!("{host}:{root_path}:{mode}"),
329+ None => format!("{host}:{root_path}"),
330+ };
331+
332+ (
333+ rewritten,
334+ Some(HomeLink {
335+ source: root_path,
336+ target,
337+ }),
338+ )
339+}
340+
341+fn render_home_links_script(links: &[HomeLink], ssh_user: &str) -> String {
342+ if links.is_empty() {
343+ return String::new();
344+ }
345+ let mut lines = Vec::new();
346+ lines.push("link_home() {".to_string());
347+ lines.push(" src=\"$1\"".to_string());
348+ lines.push(" dest=\"$2\"".to_string());
349+ lines.push(" if [ -L \"$dest\" ]; then".to_string());
350+ lines.push(" current=\"$(readlink \"$dest\" || true)\"".to_string());
351+ lines.push(" if [ \"$current\" != \"$src\" ]; then".to_string());
352+ lines.push(" rm -f \"$dest\"".to_string());
353+ lines.push(" fi".to_string());
354+ lines.push(" fi".to_string());
355+ lines.push(" if [ ! -e \"$dest\" ]; then".to_string());
356+ lines.push(" mkdir -p \"$(dirname \"$dest\")\"".to_string());
357+ lines.push(" ln -s \"$src\" \"$dest\"".to_string());
358+ lines.push(" fi".to_string());
359+ lines.push(format!(
360+ " chown -h \"{ssh_user}:{ssh_user}\" \"$dest\" 2>/dev/null || true"
361+ ));
362+ lines.push("}".to_string());
363+ for link in links {
364+ let src = shell_escape(&link.source);
365+ let dest = shell_escape(&link.target);
366+ lines.push(format!("link_home {src} {dest}"));
367+ }
368+ lines.join("\n")
369+}
370+
371+fn shell_escape(value: &str) -> String {
372+ let escaped = value.replace('\'', "'\"'\"'");
373+ format!("'{escaped}'")
193374}
194375
195376struct PidFileGuard {
@@ -228,6 +409,30 @@ fn wait_for_disconnect(mut stream: UnixStream) {
228409 }
229410}
230411
412+fn remove_client(streams: &ClientStreams, fd: std::os::fd::RawFd) {
413+ if let Ok(mut clients) = streams.lock() {
414+ clients.retain(|stream| stream.as_raw_fd() != fd);
415+ }
416+}
417+
418+fn send_status_line(stream: &mut UnixStream, status: &str) -> bool {
419+ let mut payload = String::with_capacity(STATUS_PREFIX.len() + status.len() + 1);
420+ payload.push_str(STATUS_PREFIX);
421+ payload.push_str(status);
422+ payload.push('\n');
423+ stream.write_all(payload.as_bytes()).is_ok()
424+}
425+
426+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
427+fn broadcast_status(streams: &ClientStreams, latest_status: &SharedStatus, status: &str) {
428+ if let Ok(mut current) = latest_status.lock() {
429+ *current = status.to_string();
430+ }
431+ if let Ok(mut clients) = streams.lock() {
432+ clients.retain_mut(|stream| send_status_line(stream, status));
433+ }
434+}
435+
231436fn send_client_pid(stream: &UnixStream) {
232437 let pid = std::process::id();
233438 let payload = format!("pid={pid}\n");
@@ -237,7 +442,7 @@ fn send_client_pid(stream: &UnixStream) {
237442 }
238443}
239444
240−fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>, Box<dyn std::error::Error>> {
445+fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>> {
241446 match fs::OpenOptions::new()
242447 .write(true)
243448 .create_new(true)
@@ -271,6 +476,12 @@ fn is_lock_stale(lock_path: &Path) -> bool {
271476 }
272477}
273478
479+fn read_lock_pid(lock_path: &Path) -> Option<u32> {
480+ let content = fs::read_to_string(lock_path).ok()?;
481+ let line = content.lines().next()?;
482+ line.strip_prefix("pid=")?.trim().parse::<u32>().ok()
483+}
484+
274485fn pid_is_alive(pid: u32) -> bool {
275486 let pid = pid as libc::pid_t;
276487 let result = unsafe { libc::kill(pid, 0) };
@@ -284,12 +495,6 @@ fn pid_is_alive(pid: u32) -> bool {
284495 }
285496}
286497
287−fn read_lock_pid(lock_path: &Path) -> Option<u32> {
288− let content = fs::read_to_string(lock_path).ok()?;
289− let line = content.lines().next()?;
290− line.strip_prefix("pid=")?.trim().parse::<u32>().ok()
291−}
292−
293498fn read_client_pid(stream: &UnixStream) -> Option<u32> {
294499 let mut stream = stream.try_clone().ok()?;
295500 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
@@ -300,7 +505,7 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
300505 Ok(0) => break,
301506 Ok(n) => {
302507 len += n;
303− if buf[..len].iter().any(|b| *b == b'\n') || len == buf.len() {
508+ if buf[..len].contains(&b'\n') || len == buf.len() {
304509 break;
305510 }
306511 }
@@ -322,14 +527,17 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
322527 }
323528}
324529
530+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
325531fn spawn_manager_io(
326532 config: Arc<Mutex<InstanceConfig>>,
327− instance_dir: PathBuf,
533+ project_dir: PathBuf,
534+ clients: ClientStreams,
535+ latest_status: SharedStatus,
328536 output_monitor: Arc<vm::OutputMonitor>,
329537 vm_output_fd: std::os::unix::io::OwnedFd,
330538 vm_input_fd: std::os::unix::io::OwnedFd,
331539) -> vm::IoContext {
332− let log_path = instance_dir.join(SERIAL_LOG_NAME);
540+ let log_path = project_dir.join(INSTANCE_DIR_NAME).join(VM_ROOT_LOG_NAME);
333541 let log_file = fs::OpenOptions::new()
334542 .create(true)
335543 .write(true)
@@ -338,16 +546,15 @@ fn spawn_manager_io(
338546 .ok()
339547 .map(|file| Arc::new(Mutex::new(file)));
340548
341− let instance_path = instance_dir.join(INSTANCE_FILENAME);
342549 let config_for_output = config.clone();
343550 let log_for_output = log_file.clone();
344551 let mut line_buf = String::new();
345552
346553 let on_output = move |bytes: &[u8]| {
347− if let Some(log) = &log_for_output {
348− if let Ok(mut file) = log.lock() {
349− let _ = file.write_all(bytes);
350− }
554+ if let Some(log) = &log_for_output
555+ && let Ok(mut file) = log.lock()
556+ {
557+ let _ = file.write_all(bytes);
351558 }
352559
353560 let text = String::from_utf8_lossy(bytes);
@@ -360,17 +567,27 @@ fn spawn_manager_io(
360567 }
361568 line_buf.drain(..=pos);
362569
363− let cleaned = line.trim_start_matches(|c: char| c == '\r' || c == ' ');
570+ let cleaned = line.trim_start_matches(['\r', ' ']);
571+ if let Some(script_failure) = cleaned.strip_prefix("VIBEBOX_SCRIPT_ERROR:") {
572+ let failure = script_failure.trim();
573+ if !failure.is_empty() {
574+ tracing::error!(script_failure = %failure, "[vm] script reported failure");
575+ broadcast_status(
576+ &clients,
577+ &latest_status,
578+ &format!("{STATUS_VM_ERROR_PREFIX} {failure}"),
579+ );
580+ }
581+ }
364582 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
365583 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
366584 let ip = extract_ipv4(ip_raw).unwrap_or_default();
367− if !ip.is_empty() {
368− if let Ok(mut cfg) = config_for_output.lock() {
369− if cfg.vm_ipv4.as_deref() != Some(ip.as_str()) {
370− cfg.vm_ipv4 = Some(ip.clone());
371− let _ = write_instance_config(&instance_path, &cfg);
372− }
373− }
585+ if !ip.is_empty()
586+ && let Ok(mut cfg) = config_for_output.lock()
587+ && cfg.vm_ipv4.as_deref() != Some(ip.as_str())
588+ {
589+ cfg.vm_ipv4 = Some(ip.clone());
590+ let _ = write_instance_config(&project_dir, &cfg);
374591 }
375592 }
376593 }
@@ -392,51 +609,13 @@ enum ManagerEvent {
392609 VmExited(Option<String>),
393610}
394611
395−#[cfg(test)]
396−mod tests {
397− use super::*;
398− use std::{sync::mpsc, time::Duration};
399−
400− #[test]
401− fn manager_powers_off_after_grace_when_no_refs() {
402− let _temp = tempfile::Builder::new()
403− .prefix("vb")
404− .tempdir_in("/tmp")
405− .expect("tempdir");
406−
407− let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
408− let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
409− let vm_input_tx = Arc::new(Mutex::new(Some(vm_tx)));
410−
411− let manager_thread = thread::spawn(move || {
412− manager_event_loop(event_rx, vm_input_tx, 50).expect("event loop");
413− });
414−
415− event_tx.send(ManagerEvent::Inc(None)).unwrap();
416− assert!(vm_rx.recv_timeout(Duration::from_millis(100)).is_err());
417−
418− event_tx.send(ManagerEvent::Dec(None)).unwrap();
419− let msg = vm_rx
420− .recv_timeout(Duration::from_secs(2))
421− .expect("poweroff");
422− match msg {
423− VmInput::Bytes(data) => {
424− assert_eq!(data, b"systemctl poweroff\n");
425− }
426− _ => panic!("unexpected vm input"),
427− }
428− let _ = event_tx.send(ManagerEvent::VmExited(None));
429− let _ = manager_thread.join();
430− }
431−}
432−
433612struct ManagerOptions {
434− ensure_signed: bool,
435613 detach: bool,
436614 prepare_vm: bool,
437615}
438616
439617trait VmExecutor {
618+ #[allow(clippy::too_many_arguments)]
440619 fn run_vm(
441620 &self,
442621 args: vm::VmArg,
@@ -444,10 +623,13 @@ trait VmExecutor {
444623 extra_shares: Vec<DirectoryShare>,
445624 config: Arc<Mutex<InstanceConfig>>,
446625 instance_dir: PathBuf,
626+ clients: ClientStreams,
627+ latest_status: SharedStatus,
447628 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
448− ) -> Result<(), Box<dyn std::error::Error>>;
629+ ) -> Result<()>;
449630}
450631
632+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
451633struct RealVmExecutor;
452634
453635impl VmExecutor for RealVmExecutor {
@@ -457,15 +639,22 @@ impl VmExecutor for RealVmExecutor {
457639 extra_login_actions: Vec<LoginAction>,
458640 extra_shares: Vec<DirectoryShare>,
459641 config: Arc<Mutex<InstanceConfig>>,
460− instance_dir: PathBuf,
642+ project_dir: PathBuf,
643+ clients: ClientStreams,
644+ latest_status: SharedStatus,
461645 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
462− ) -> Result<(), Box<dyn std::error::Error>> {
463− let result = vm::run_with_args_and_extras(
646+ ) -> Result<()> {
647+ let status_callback = |status: &str| {
648+ broadcast_status(&clients, &latest_status, status);
649+ };
650+ vm::run_with_args_and_extras(
464651 args,
465652 |output_monitor, vm_output_fd, vm_input_fd| {
466653 let io_ctx = spawn_manager_io(
467654 config.clone(),
468− instance_dir.clone(),
655+ project_dir.clone(),
656+ clients.clone(),
657+ latest_status.clone(),
469658 output_monitor,
470659 vm_output_fd,
471660 vm_input_fd,
@@ -475,35 +664,60 @@ impl VmExecutor for RealVmExecutor {
475664 },
476665 extra_login_actions,
477666 extra_shares,
478− );
479− result
667+ Some(&status_callback),
668+ )
669+ }
670+}
671+
672+#[cfg(feature = "mock-vm")]
673+struct MockVmExecutor;
674+
675+#[cfg(feature = "mock-vm")]
676+impl VmExecutor for MockVmExecutor {
677+ fn run_vm(
678+ &self,
679+ _args: vm::VmArg,
680+ _extra_login_actions: Vec<LoginAction>,
681+ _extra_shares: Vec<DirectoryShare>,
682+ _config: Arc<Mutex<InstanceConfig>>,
683+ _instance_dir: PathBuf,
684+ _clients: ClientStreams,
685+ _latest_status: SharedStatus,
686+ vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
687+ ) -> Result<()> {
688+ let (tx, rx) = mpsc::channel::<VmInput>();
689+ *vm_input_tx.lock().unwrap() = Some(tx);
690+ tracing::info!("mock vm executor running");
691+ while let Ok(input) = rx.recv() {
692+ match input {
693+ VmInput::Shutdown => break,
694+ VmInput::Bytes(bytes) => {
695+ let text = String::from_utf8_lossy(&bytes);
696+ if text.contains("systemctl poweroff") {
697+ break;
698+ }
699+ }
700+ }
701+ }
702+ tracing::info!("mock vm executor exiting");
703+ Ok(())
480704 }
481705}
482706
483707fn run_manager_with(
484708 project_root: &Path,
485− args: vm::VmArg,
709+ mut args: vm::VmArg,
486710 auto_shutdown_ms: u64,
487711 executor: &dyn VmExecutor,
488712 options: ManagerOptions,
489−) -> Result<(), Box<dyn std::error::Error>> {
490− if options.ensure_signed {
491− let _had_skip = env::var("VIBEBOX_SKIP_CODESIGN").ok();
492− unsafe {
493− env::remove_var("VIBEBOX_SKIP_CODESIGN");
494− }
495− vm::ensure_signed();
496− unsafe {
497− env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
498− }
499− }
713+) -> Result<()> {
500714 if options.detach {
501715 detach_from_terminal();
502716 }
503717
504718 let project_name = project_root
505719 .file_name()
506− .ok_or("Project directory has no name")?
720+ .ok_or_else(|| anyhow!("Project directory has no name"))?
507721 .to_string_lossy()
508722 .into_owned();
509723 let instance_dir = ensure_instance_dir(project_root)?;
@@ -511,21 +725,35 @@ fn run_manager_with(
511725 let _ = ensure_ssh_keypair(&instance_dir)?;
512726 }
513727
514− let mut config = load_or_create_instance_config(&instance_dir)?;
728+ let mut config = load_or_create_instance_config(project_root)?;
515729 if config.vm_ipv4.is_some() {
516730 config.vm_ipv4 = None;
517− write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
731+ write_instance_config(project_root, &config)?;
518732 }
519733 let config = Arc::new(Mutex::new(config));
734+ let ssh_user = config
735+ .lock()
736+ .map(|cfg| cfg.ssh_user.clone())
737+ .map_err(|_| anyhow!("failed to acquire ssh user display"))?;
738+ if !args.no_default_mounts {
739+ inject_project_mount(&mut args.mounts, project_root, &ssh_user, &project_name);
740+ }
741+ let (args, home_links_script) = prepare_mounts_and_links(args, &ssh_user);
520742
743+ let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}");
521744 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
522− let extra_shares = vec![DirectoryShare::new(
523− instance_dir.clone(),
524− ssh_guest_dir.clone().into(),
525− true,
526− )?];
527− let extra_login_actions =
528− build_ssh_login_actions(&config, &project_name, ssh_guest_dir.as_str(), "ssh_key");
745+ let extra_shares = vec![
746+ DirectoryShare::new(instance_dir.clone(), ssh_guest_dir.clone().into(), true)
747+ .map_err(|err| anyhow!(err.to_string()))?,
748+ ];
749+ let extra_login_actions = build_ssh_login_actions(
750+ &config,
751+ &project_name,
752+ &project_guest_dir,
753+ ssh_guest_dir.as_str(),
754+ "ssh_key",
755+ &home_links_script,
756+ );
529757
530758 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
531759 if let Ok(stream) = UnixStream::connect(&socket_path) {
@@ -540,17 +768,43 @@ fn run_manager_with(
540768 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
541769 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
542770
771+ let clients: ClientStreams = Arc::new(Mutex::new(Vec::new()));
772+ let latest_status: SharedStatus = Arc::new(Mutex::new(String::new()));
543773 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
544774 let event_tx_accept = event_tx.clone();
775+ let clients_accept = clients.clone();
776+ let latest_status_accept = latest_status.clone();
545777 thread::spawn(move || {
546778 for stream in listener.incoming() {
547779 match stream {
548780 Ok(stream) => {
781+ let mut client_fd: Option<std::os::fd::RawFd> = None;
782+ let latest_status_snapshot = latest_status_accept
783+ .lock()
784+ .ok()
785+ .map(|status| status.clone());
786+ if let Ok(writer) = stream.try_clone() {
787+ let writer_fd = writer.as_raw_fd();
788+ if let Ok(mut connected) = clients_accept.lock() {
789+ connected.push(writer);
790+ client_fd = Some(writer_fd);
791+ if let Some(last) = connected.last_mut()
792+ && let Some(status) = latest_status_snapshot.as_deref()
793+ && !status.is_empty()
794+ {
795+ let _ = send_status_line(last, status);
796+ }
797+ }
798+ }
549799 let event_tx_conn = event_tx_accept.clone();
800+ let clients_conn = clients_accept.clone();
550801 thread::spawn(move || {
551802 let pid = read_client_pid(&stream);
552803 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
553804 wait_for_disconnect(stream);
805+ if let Some(fd) = client_fd {
806+ remove_client(&clients_conn, fd);
807+ }
554808 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
555809 });
556810 }
@@ -570,43 +824,59 @@ fn run_manager_with(
570824 extra_login_actions,
571825 extra_shares,
572826 config.clone(),
573− instance_dir.clone(),
827+ project_root.to_path_buf(),
828+ clients.clone(),
829+ latest_status.clone(),
574830 vm_input_tx.clone(),
575831 );
576832 tracing::info!("vm manager vm run completed");
577833 let vm_err = vm_result.err().map(|e| e.to_string());
834+ if let Some(err) = &vm_err {
835+ broadcast_status(
836+ &clients,
837+ &latest_status,
838+ &format!("{STATUS_VM_ERROR_PREFIX} {err}"),
839+ );
840+ }
578841 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
579− let event_loop_result: Result<(), String> = event_loop_handle
842+ let event_loop_result = event_loop_handle
580843 .join()
581− .unwrap_or_else(|_| Err("vm manager event loop panicked".into()))
844+ .unwrap_or_else(|_| Err(Error::msg("vm manager event loop panicked")))
582845 .map_err(|err| err.to_string());
583846 let _ = fs::remove_file(&socket_path);
584847 if let Err(err) = &event_loop_result {
585848 tracing::error!(error = %err, "vm manager exiting due to event loop error");
586− return Err(err.to_string().into());
849+ bail!(err.to_string());
587850 }
588851 if let Some(err) = vm_err {
589852 tracing::error!(error = %err, "vm manager exiting due to vm error");
590− return Err(err.into());
853+ bail!(err);
591854 }
592855 tracing::info!("vm manager exiting");
593− Ok(event_loop_result?)
856+ event_loop_result.map_err(Error::msg)
594857}
595858
596859fn manager_event_loop(
597860 event_rx: mpsc::Receiver<ManagerEvent>,
598861 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
599862 auto_shutdown_ms: u64,
600−) -> Result<(), String> {
863+) -> Result<()> {
601864 let mut ref_count: usize = 0;
602865 let mut shutdown_deadline: Option<Instant> = None;
603866 let mut shutdown_sent = false;
867+ let mut hard_deadline: Option<Instant> = None;
604868 let grace = Duration::from_millis(auto_shutdown_ms.max(1));
869+ let hard_timeout = Duration::from_millis(HARD_SHUTDOWN_TIMEOUT_MS);
605870
606871 loop {
607− let timeout = match shutdown_deadline {
608− Some(deadline) => deadline.saturating_duration_since(Instant::now()),
609− None => Duration::from_secs(1),
872+ let timeout = match (shutdown_deadline, hard_deadline) {
873+ (Some(shutdown), Some(hard)) => {
874+ let next = if shutdown <= hard { shutdown } else { hard };
875+ next.saturating_duration_since(Instant::now())
876+ }
877+ (Some(shutdown), None) => shutdown.saturating_duration_since(Instant::now()),
878+ (None, Some(hard)) => hard.saturating_duration_since(Instant::now()),
879+ (None, None) => Duration::from_secs(1),
610880 };
611881
612882 match event_rx.recv_timeout(timeout) {
@@ -620,11 +890,10 @@ fn manager_event_loop(
620890 );
621891 shutdown_deadline = None;
622892 shutdown_sent = false;
893+ hard_deadline = None;
623894 }
624895 Ok(ManagerEvent::Dec(pid)) => {
625− if ref_count > 0 {
626− ref_count -= 1;
627− }
896+ ref_count = ref_count.saturating_sub(1);
628897 tracing::info!(
629898 ref_count,
630899 pid = pid.unwrap_or(0),
@@ -643,15 +912,52 @@ fn manager_event_loop(
643912 break;
644913 }
645914 Err(mpsc::RecvTimeoutError::Timeout) => {
646− if let Some(deadline) = shutdown_deadline {
647− if Instant::now() >= deadline && !shutdown_sent {
648− if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
649− let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
915+ if let Some(deadline) = shutdown_deadline
916+ && Instant::now() >= deadline
917+ && !shutdown_sent
918+ {
919+ if hard_deadline.is_none() {
920+ hard_deadline = Some(Instant::now() + hard_timeout);
921+ }
922+ let mut sent = false;
923+ if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
924+ if tx
925+ .send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()))
926+ .is_ok()
927+ {
928+ sent = true;
929+ } else {
930+ tracing::warn!("shutdown command failed to send");
650931 }
932+ } else {
933+ tracing::warn!("shutdown command deferred; vm input not ready");
934+ }
935+ if sent {
651936 tracing::info!("shutdown command sent");
652937 shutdown_sent = true;
653938 shutdown_deadline = None;
939+ hard_deadline = Some(Instant::now() + hard_timeout);
940+ } else {
941+ shutdown_deadline =
942+ Some(Instant::now() + Duration::from_millis(SHUTDOWN_RETRY_MS));
943+ }
944+ }
945+ if ref_count == 0
946+ && let Some(deadline) = hard_deadline
947+ && Instant::now() >= deadline
948+ {
949+ if shutdown_sent {
950+ tracing::warn!(
951+ timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
952+ "force exiting: VM did not stop after shutdown timeout"
953+ );
954+ } else {
955+ tracing::warn!(
956+ timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
957+ "force exiting: VM input not ready after shutdown timeout"
958+ );
654959 }
960+ force_exit("vm manager forced exit");
655961 }
656962 }
657963 Err(mpsc::RecvTimeoutError::Disconnected) => break,
@@ -660,3 +966,112 @@ fn manager_event_loop(
660966
661967 Ok(())
662968}
969+
970+#[cfg(test)]
971+mod tests {
972+ use super::*;
973+ use std::{fs, sync::mpsc, thread, time::Duration};
974+
975+ #[test]
976+ fn manager_powers_off_after_grace_when_no_refs() {
977+ let _temp = tempfile::Builder::new()
978+ .prefix("vb")
979+ .tempdir_in("/tmp")
980+ .expect("tempdir");
981+
982+ let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
983+ let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
984+ let vm_input_tx = Arc::new(Mutex::new(Some(vm_tx)));
985+
986+ let manager_thread = thread::spawn(move || {
987+ manager_event_loop(event_rx, vm_input_tx, 50).expect("event loop");
988+ });
989+
990+ event_tx.send(ManagerEvent::Inc(None)).unwrap();
991+ assert!(vm_rx.recv_timeout(Duration::from_millis(100)).is_err());
992+
993+ event_tx.send(ManagerEvent::Dec(None)).unwrap();
994+ let msg = vm_rx
995+ .recv_timeout(Duration::from_secs(2))
996+ .expect("poweroff");
997+ match msg {
998+ VmInput::Bytes(data) => {
999+ assert_eq!(data, b"systemctl poweroff\n");
1000+ }
1001+ _ => panic!("unexpected vm input"),
1002+ }
1003+ let _ = event_tx.send(ManagerEvent::VmExited(None));
1004+ let _ = manager_thread.join();
1005+ }
1006+
1007+ #[test]
1008+ fn manager_force_exits_when_vm_input_never_ready() {
1009+ let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
1010+ let vm_input_tx = Arc::new(Mutex::new(None));
1011+
1012+ let manager_thread = thread::spawn(move || {
1013+ let _ = manager_event_loop(event_rx, vm_input_tx, 10);
1014+ });
1015+
1016+ event_tx.send(ManagerEvent::Inc(None)).unwrap();
1017+ event_tx.send(ManagerEvent::Dec(None)).unwrap();
1018+
1019+ let join_result = manager_thread.join();
1020+ assert!(
1021+ join_result.is_err(),
1022+ "expected manager to force-exit when vm input never becomes ready"
1023+ );
1024+ }
1025+
1026+ #[test]
1027+ fn manager_sends_shutdown_after_vm_input_becomes_ready() {
1028+ let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
1029+ let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
1030+ let vm_input_tx = Arc::new(Mutex::new(None));
1031+ let vm_input_for_thread = vm_input_tx.clone();
1032+
1033+ let manager_thread = thread::spawn(move || {
1034+ manager_event_loop(event_rx, vm_input_for_thread, 10).expect("event loop");
1035+ });
1036+
1037+ event_tx.send(ManagerEvent::Inc(None)).unwrap();
1038+ event_tx.send(ManagerEvent::Dec(None)).unwrap();
1039+
1040+ thread::sleep(Duration::from_millis(100));
1041+ *vm_input_tx.lock().unwrap() = Some(vm_tx);
1042+
1043+ let msg = vm_rx
1044+ .recv_timeout(Duration::from_secs(2))
1045+ .expect("poweroff");
1046+ match msg {
1047+ VmInput::Bytes(data) => {
1048+ assert_eq!(data, b"systemctl poweroff\n");
1049+ }
1050+ _ => panic!("unexpected vm input"),
1051+ }
1052+ let _ = event_tx.send(ManagerEvent::VmExited(None));
1053+ let _ = manager_thread.join();
1054+ }
1055+
1056+ #[test]
1057+ fn lock_is_not_stale_when_owner_pid_is_alive() {
1058+ let temp = tempfile::Builder::new()
1059+ .prefix("vb")
1060+ .tempdir_in("/tmp")
1061+ .expect("tempdir");
1062+ let lock_path = temp.path().join("vm.lock");
1063+ fs::write(&lock_path, format!("pid={}\n", std::process::id())).expect("write lock");
1064+ assert!(!is_lock_stale(&lock_path));
1065+ }
1066+
1067+ #[test]
1068+ fn lock_is_stale_when_owner_pid_is_missing() {
1069+ let temp = tempfile::Builder::new()
1070+ .prefix("vb")
1071+ .tempdir_in("/tmp")
1072+ .expect("tempdir");
1073+ let lock_path = temp.path().join("vm.lock");
1074+ fs::write(&lock_path, "pid=999999\n").expect("write lock");
1075+ assert!(is_lock_stale(&lock_path));
1076+ }
1077+}
tests/e2e_cli.rsnew file+58
@@ -0,0 +1,58 @@
1+use assert_cmd::cargo::cargo_bin_cmd;
2+use tempfile::TempDir;
3+
4+#[test]
5+fn cli_version_shows_binary_name() {
6+ let output = cargo_bin_cmd!("vibebox").arg("--version").output().unwrap();
7+ print_output("e2e_cli", &output);
8+ assert!(
9+ output.status.success(),
10+ "expected success, got status: {}",
11+ output.status
12+ );
13+ let stdout = String::from_utf8_lossy(&output.stdout);
14+ assert!(
15+ stdout.contains("vibebox"),
16+ "expected --version output to contain 'vibebox', got: {}",
17+ stdout
18+ );
19+}
20+
21+#[test]
22+fn list_reports_no_sessions_when_empty() {
23+ let temp = TempDir::new().unwrap();
24+ let home = temp.path().join("home");
25+ let project = temp.path().join("project");
26+ std::fs::create_dir_all(&home).unwrap();
27+ std::fs::create_dir_all(&project).unwrap();
28+
29+ let output = cargo_bin_cmd!("vibebox")
30+ .current_dir(&project)
31+ .env("HOME", &home)
32+ .arg("list")
33+ .output()
34+ .unwrap();
35+ print_output("e2e_cli", &output);
36+ assert!(
37+ output.status.success(),
38+ "expected success, got status: {}",
39+ output.status
40+ );
41+ let stdout = String::from_utf8_lossy(&output.stdout);
42+ assert!(
43+ stdout.contains("No sessions were found."),
44+ "expected empty sessions message, got: {}",
45+ stdout
46+ );
47+}
48+
49+fn print_output(prefix: &str, output: &std::process::Output) {
50+ let stdout = String::from_utf8_lossy(&output.stdout);
51+ for line in stdout.lines() {
52+ println!("[{}] {}", prefix, line);
53+ }
54+ let stderr = String::from_utf8_lossy(&output.stderr);
55+ for line in stderr.lines() {
56+ eprintln!("[{}] {}", prefix, line);
57+ }
58+}
tests/e2e_vm.rsnew file+278
@@ -0,0 +1,278 @@
1+use std::{
2+ fs,
3+ io::{BufRead, BufReader, Read},
4+ path::{Path, PathBuf},
5+ process::{Child, Command, Stdio},
6+ thread,
7+ time::{Duration, Instant},
8+};
9+
10+use tempfile::TempDir;
11+
12+#[cfg(target_os = "macos")]
13+#[test]
14+#[ignore]
15+fn vm_boots_and_runs_command() {
16+ if std::env::var("VIBEBOX_E2E_VM").as_deref() != Ok("1") {
17+ eprintln!("skipping: set VIBEBOX_E2E_VM=1 to run this test");
18+ return;
19+ }
20+ if !virtualization_available() {
21+ eprintln!("[e2e_vm] skipping: virtualization not available on this hardware");
22+ return;
23+ }
24+
25+ let temp = TempDir::new().unwrap();
26+ let home = temp.path().join("home");
27+ let cache_home = temp.path().join("cache");
28+ let project = temp.path().join("project");
29+ fs::create_dir_all(&home).unwrap();
30+ fs::create_dir_all(&cache_home).unwrap();
31+ fs::create_dir_all(&project).unwrap();
32+
33+ write_config(&project);
34+
35+ let mut child = Command::new(assert_cmd::cargo_bin!("vibebox-supervisor"))
36+ .current_dir(&project)
37+ .env("HOME", &home)
38+ .env("XDG_CACHE_HOME", &cache_home)
39+ .env("VIBEBOX_INTERNAL", "1")
40+ .stdout(Stdio::piped())
41+ .stderr(Stdio::piped())
42+ .spawn()
43+ .unwrap();
44+ if let Some(stdout) = child.stdout.take() {
45+ spawn_prefix_reader("e2e_vm", "stdout", stdout);
46+ }
47+ if let Some(stderr) = child.stderr.take() {
48+ spawn_prefix_reader("e2e_vm", "stderr", stderr);
49+ }
50+ let _child_guard = ChildGuard::new(child);
51+
52+ log_line("e2e_vm", "waiting for vm manager socket");
53+ let socket_path = project.join(".vibebox").join("vm.sock");
54+ let _socket_guard = wait_for_socket(&socket_path, Duration::from_secs(30));
55+ log_line("e2e_vm", "vm manager socket ready");
56+
57+ log_line("e2e_vm", "waiting for vm ipv4");
58+ let instance_path = project.join(".vibebox").join("instance.toml");
59+ let (ip, user) = wait_for_vm_ip(&instance_path, Duration::from_secs(180));
60+ log_line("e2e_vm", &format!("vm ipv4={ip} user={user}"));
61+
62+ let ssh_key = project.join(".vibebox").join("ssh_key");
63+ wait_for_file(&ssh_key, Duration::from_secs(30));
64+ log_line("e2e_vm", "ssh key ready");
65+
66+ let output = wait_for_ssh_command(&ssh_key, &user, &ip, Duration::from_secs(90));
67+ print_output("e2e_vm", &output);
68+ let stdout = String::from_utf8_lossy(&output.stdout);
69+ assert!(
70+ stdout.contains("Linux"),
71+ "expected ssh command output to contain 'Linux', got: {}",
72+ stdout
73+ );
74+}
75+
76+#[cfg(not(target_os = "macos"))]
77+#[test]
78+#[ignore]
79+fn vm_boots_and_runs_command() {
80+ eprintln!("skipping: vm e2e test requires macOS virtualization");
81+}
82+
83+struct ChildGuard {
84+ child: Option<Child>,
85+}
86+
87+impl ChildGuard {
88+ fn new(child: Child) -> Self {
89+ Self { child: Some(child) }
90+ }
91+}
92+
93+impl Drop for ChildGuard {
94+ fn drop(&mut self) {
95+ if let Some(child) = &mut self.child {
96+ let _ = child.kill();
97+ let _ = child.wait();
98+ }
99+ }
100+}
101+
102+struct SocketGuard {
103+ _path: PathBuf,
104+ _stream: std::os::unix::net::UnixStream,
105+}
106+
107+fn write_config(project: &Path) {
108+ let config = r#"[box]
109+cpu_count = 2
110+ram_mb = 2048
111+disk_gb = 5
112+mounts = []
113+
114+[supervisor]
115+auto_shutdown_ms = 120000
116+"#;
117+ fs::write(project.join("vibebox.toml"), config).unwrap();
118+}
119+
120+fn wait_for_socket(path: &Path, timeout: Duration) -> SocketGuard {
121+ let start = Instant::now();
122+ loop {
123+ if let Ok(stream) = std::os::unix::net::UnixStream::connect(path) {
124+ return SocketGuard {
125+ _path: path.to_path_buf(),
126+ _stream: stream,
127+ };
128+ }
129+ if start.elapsed() > timeout {
130+ panic!(
131+ "timed out waiting for vm manager socket at {}",
132+ path.display()
133+ );
134+ }
135+ thread::sleep(Duration::from_millis(200));
136+ }
137+}
138+
139+fn wait_for_vm_ip(instance_path: &Path, timeout: Duration) -> (String, String) {
140+ let start = Instant::now();
141+ loop {
142+ if let Ok(raw) = fs::read_to_string(instance_path)
143+ && let Ok(value) = toml::from_str::<toml::Value>(&raw)
144+ {
145+ let ip = value
146+ .get("vm_ipv4")
147+ .and_then(|v| v.as_str())
148+ .map(|s| s.trim().to_string())
149+ .filter(|s| !s.is_empty());
150+ if let Some(ip) = ip {
151+ let user = value
152+ .get("ssh_user")
153+ .and_then(|v| v.as_str())
154+ .map(|s| s.trim().to_string())
155+ .filter(|s| !s.is_empty())
156+ .unwrap_or_else(|| "vibecoder".to_string());
157+ return (ip, user);
158+ }
159+ }
160+ if start.elapsed() > timeout {
161+ panic!(
162+ "timed out waiting for vm_ipv4 in {}",
163+ instance_path.display()
164+ );
165+ }
166+ thread::sleep(Duration::from_millis(500));
167+ }
168+}
169+
170+fn wait_for_file(path: &Path, timeout: Duration) {
171+ let start = Instant::now();
172+ loop {
173+ if path.exists() {
174+ return;
175+ }
176+ if start.elapsed() > timeout {
177+ panic!("timed out waiting for file {}", path.display());
178+ }
179+ thread::sleep(Duration::from_millis(200));
180+ }
181+}
182+
183+fn wait_for_ssh_command(
184+ ssh_key: &Path,
185+ user: &str,
186+ ip: &str,
187+ timeout: Duration,
188+) -> std::process::Output {
189+ let start = Instant::now();
190+ loop {
191+ let output = Command::new("ssh")
192+ .args([
193+ "-i",
194+ ssh_key.to_str().unwrap_or(".vibebox/ssh_key"),
195+ "-o",
196+ "IdentitiesOnly=yes",
197+ "-o",
198+ "StrictHostKeyChecking=no",
199+ "-o",
200+ "UserKnownHostsFile=/dev/null",
201+ "-o",
202+ "GlobalKnownHostsFile=/dev/null",
203+ "-o",
204+ "PasswordAuthentication=no",
205+ "-o",
206+ "BatchMode=yes",
207+ "-o",
208+ "LogLevel=ERROR",
209+ "-o",
210+ "ConnectTimeout=5",
211+ &format!("{user}@{ip}"),
212+ "uname -s",
213+ ])
214+ .output()
215+ .unwrap();
216+ if output.status.success() {
217+ return output;
218+ }
219+ if start.elapsed() > timeout {
220+ let stderr = String::from_utf8_lossy(&output.stderr);
221+ panic!("ssh command failed and timed out: {}", stderr);
222+ }
223+ thread::sleep(Duration::from_millis(1000));
224+ }
225+}
226+
227+fn spawn_prefix_reader(
228+ label: &'static str,
229+ stream: &'static str,
230+ reader: impl Read + Send + 'static,
231+) {
232+ thread::spawn(move || {
233+ let buf = BufReader::new(reader);
234+ for line in buf.lines() {
235+ match line {
236+ Ok(line) => {
237+ println!("[{}][{}] {}", label, stream, line);
238+ }
239+ Err(err) => {
240+ eprintln!("[{}][{}] read error: {}", label, stream, err);
241+ break;
242+ }
243+ }
244+ }
245+ });
246+}
247+
248+fn log_line(prefix: &str, message: &str) {
249+ println!("[{}] {}", prefix, message);
250+}
251+
252+fn print_output(prefix: &str, output: &std::process::Output) {
253+ let stdout = String::from_utf8_lossy(&output.stdout);
254+ for line in stdout.lines() {
255+ println!("[{}] {}", prefix, line);
256+ }
257+ let stderr = String::from_utf8_lossy(&output.stderr);
258+ for line in stderr.lines() {
259+ eprintln!("[{}] {}", prefix, line);
260+ }
261+}
262+
263+fn virtualization_available() -> bool {
264+ let output = Command::new("sysctl")
265+ .args(["-n", "kern.hv_support"])
266+ .output();
267+ match output {
268+ Ok(output) if output.status.success() => {
269+ let value = String::from_utf8_lossy(&output.stdout);
270+ match value.trim() {
271+ "1" => true,
272+ "0" => false,
273+ _ => true,
274+ }
275+ }
276+ _ => true,
277+ }
278+}
tests/e2e_vm_mock.rsnew file+455
@@ -0,0 +1,455 @@
1+#![cfg(all(feature = "mock-vm", target_os = "macos"))]
2+
3+use std::{
4+ fs,
5+ io::{BufRead, BufReader, Read},
6+ os::unix::net::UnixStream,
7+ path::{Path, PathBuf},
8+ process::{Child, Command, Stdio},
9+ sync::mpsc,
10+ thread,
11+ time::{Duration, Instant},
12+};
13+
14+use tempfile::TempDir;
15+
16+#[test]
17+fn mock_vm_allows_refcount_concurrency() {
18+ let temp = TempDir::new().unwrap();
19+ let mut supervisor = spawn_supervisor(&temp, 0, 1200, "e2e_vm_mock".to_string());
20+
21+ supervisor.clients = connect_clients(
22+ &supervisor.socket_path,
23+ 12,
24+ Duration::from_secs(2),
25+ true,
26+ "e2e_vm_mock",
27+ );
28+ log_line("e2e_vm_mock", "connected 12 clients");
29+
30+ assert_manager_alive_for(
31+ &mut supervisor.child,
32+ Duration::from_millis(900),
33+ "vm manager exited while clients active",
34+ );
35+
36+ let remaining = supervisor.clients.split_off(6);
37+ supervisor.clients = remaining;
38+ log_line("e2e_vm_mock", "dropped 6 clients");
39+ assert_manager_alive_for(
40+ &mut supervisor.child,
41+ Duration::from_millis(900),
42+ "vm manager exited while clients active",
43+ );
44+
45+ supervisor.clients.clear();
46+ log_line("e2e_vm_mock", "dropped remaining clients");
47+ wait_for_exit(&mut supervisor.child, Duration::from_secs(10));
48+ let status = supervisor.child.wait().unwrap();
49+ assert!(status.success(), "vm manager exited with {status}");
50+}
51+
52+#[test]
53+fn mock_vm_many_managers_many_clients() {
54+ let temp = TempDir::new().unwrap();
55+ let mut supervisors = Vec::new();
56+
57+ for idx in 0..3 {
58+ supervisors.push(spawn_supervisor(
59+ &temp,
60+ idx + 1,
61+ 1400,
62+ format!("e2e_vm_mock_{idx}"),
63+ ));
64+ }
65+
66+ for supervisor in &mut supervisors {
67+ supervisor.clients = connect_clients(
68+ &supervisor.socket_path,
69+ 8,
70+ Duration::from_secs(2),
71+ true,
72+ "e2e_vm_mock",
73+ );
74+ }
75+ log_line("e2e_vm_mock", "connected 8 clients per manager");
76+
77+ for supervisor in &mut supervisors {
78+ assert_manager_alive_for(
79+ &mut supervisor.child,
80+ Duration::from_millis(900),
81+ "manager exited while clients active",
82+ );
83+ }
84+
85+ supervisors[0].clients.clear();
86+ log_line("e2e_vm_mock", "dropped all clients for manager 0");
87+ wait_for_exit(&mut supervisors[0].child, Duration::from_secs(10));
88+ let status = supervisors[0].child.wait().unwrap();
89+ assert!(status.success(), "manager 0 exited with {status}");
90+
91+ for supervisor in supervisors.iter_mut().skip(1) {
92+ assert_manager_alive_for(
93+ &mut supervisor.child,
94+ Duration::from_millis(900),
95+ "another manager exited early",
96+ );
97+ }
98+
99+ for supervisor in supervisors.iter_mut().skip(1) {
100+ supervisor.clients.clear();
101+ }
102+ log_line("e2e_vm_mock", "dropped remaining clients");
103+ for supervisor in supervisors.iter_mut().skip(1) {
104+ wait_for_exit(&mut supervisor.child, Duration::from_secs(10));
105+ let status = supervisor.child.wait().unwrap();
106+ assert!(status.success(), "manager exited with {status}");
107+ }
108+}
109+
110+#[test]
111+fn mock_vm_monkey_processes() {
112+ let temp = TempDir::new().unwrap();
113+ let mut rng = Lcg::new(0x5eed_f00d_dead_beef);
114+ let mut supervisors = Vec::new();
115+ let mut next_id = 0usize;
116+ let max_supervisors = 4usize;
117+ let steps = 25usize;
118+
119+ supervisors.push(spawn_supervisor(
120+ &temp,
121+ next_id,
122+ 1200,
123+ format!("e2e_vm_monkey_{next_id}"),
124+ ));
125+ next_id += 1;
126+
127+ for step in 0..steps {
128+ prune_exited_supervisors(&mut supervisors, "e2e_vm_monkey");
129+ let roll = rng.gen_range(100);
130+ log_line(
131+ "e2e_vm_monkey",
132+ &format!("step {step} roll={roll} supervisors={}", supervisors.len()),
133+ );
134+ if roll < 20 && supervisors.len() < max_supervisors {
135+ supervisors.push(spawn_supervisor(
136+ &temp,
137+ next_id,
138+ 1200,
139+ format!("e2e_vm_monkey_{next_id}"),
140+ ));
141+ log_line("e2e_vm_monkey", &format!("spawned supervisor {next_id}"));
142+ next_id += 1;
143+ } else if roll < 45 && !supervisors.is_empty() {
144+ let idx = rng.gen_range(supervisors.len());
145+ let mut supervisor = supervisors.swap_remove(idx);
146+ log_line(
147+ "e2e_vm_monkey",
148+ &format!("killing supervisor {}", supervisor.label),
149+ );
150+ kill_supervisor(&mut supervisor, Duration::from_secs(5));
151+ } else if roll < 80 && !supervisors.is_empty() {
152+ let idx = rng.gen_range(supervisors.len());
153+ let burst = 1 + rng.gen_range(3);
154+ let new_clients = connect_clients(
155+ &supervisors[idx].socket_path,
156+ burst,
157+ Duration::from_secs(1),
158+ false,
159+ "e2e_vm_monkey",
160+ );
161+ supervisors[idx].clients.extend(new_clients);
162+ log_line(
163+ "e2e_vm_monkey",
164+ &format!("connected {burst} clients to {}", supervisors[idx].label),
165+ );
166+ } else if !supervisors.is_empty() {
167+ let idx = rng.gen_range(supervisors.len());
168+ if !supervisors[idx].clients.is_empty() {
169+ let len = supervisors[idx].clients.len();
170+ let drop_count = 1 + rng.gen_range(len);
171+ supervisors[idx].clients.drain(0..drop_count.min(len));
172+ log_line(
173+ "e2e_vm_monkey",
174+ &format!(
175+ "dropped {drop_count} clients from {}",
176+ supervisors[idx].label
177+ ),
178+ );
179+ }
180+ }
181+ thread::sleep(Duration::from_millis(200));
182+ }
183+
184+ log_line("e2e_vm_monkey", "final cleanup");
185+ for supervisor in supervisors.iter_mut() {
186+ shutdown_supervisor(supervisor, Duration::from_secs(10));
187+ }
188+}
189+
190+#[test]
191+fn mock_vm_exits_without_clients() {
192+ let temp = TempDir::new().unwrap();
193+ let mut supervisor = spawn_supervisor(&temp, 99, 300, "e2e_vm_no_clients".to_string());
194+ wait_for_exit(&mut supervisor.child, Duration::from_secs(5));
195+ let status = supervisor.child.wait().unwrap();
196+ assert!(status.success(), "vm manager exited with {status}");
197+}
198+
199+#[test]
200+fn mock_vm_reconnect_resets_shutdown() {
201+ let temp = TempDir::new().unwrap();
202+ let mut supervisor = spawn_supervisor(&temp, 100, 800, "e2e_vm_reconnect".to_string());
203+
204+ supervisor.clients = connect_clients(
205+ &supervisor.socket_path,
206+ 1,
207+ Duration::from_secs(2),
208+ true,
209+ "e2e_vm_reconnect",
210+ );
211+ supervisor.clients.clear();
212+ thread::sleep(Duration::from_millis(400));
213+
214+ supervisor.clients = connect_clients(
215+ &supervisor.socket_path,
216+ 1,
217+ Duration::from_secs(2),
218+ true,
219+ "e2e_vm_reconnect",
220+ );
221+ assert_manager_alive_for(
222+ &mut supervisor.child,
223+ Duration::from_millis(600),
224+ "vm manager exited despite reconnect",
225+ );
226+
227+ supervisor.clients.clear();
228+ wait_for_exit(&mut supervisor.child, Duration::from_secs(10));
229+ let status = supervisor.child.wait().unwrap();
230+ assert!(status.success(), "vm manager exited with {status}");
231+}
232+
233+struct Supervisor {
234+ child: Child,
235+ socket_path: PathBuf,
236+ clients: Vec<UnixStream>,
237+ label: String,
238+}
239+
240+fn write_config(project: &Path, auto_shutdown_ms: u64) {
241+ let config = format!(
242+ r#"[box]
243+cpu_count = 2
244+ram_mb = 2048
245+disk_gb = 5
246+mounts = []
247+
248+[supervisor]
249+auto_shutdown_ms = {auto_shutdown_ms}
250+"#
251+ );
252+ fs::write(project.join("vibebox.toml"), config).unwrap();
253+}
254+
255+fn spawn_supervisor(
256+ temp: &TempDir,
257+ idx: usize,
258+ auto_shutdown_ms: u64,
259+ label: String,
260+) -> Supervisor {
261+ let home = temp.path().join(format!("home-{idx}"));
262+ let cache_home = temp.path().join(format!("cache-{idx}"));
263+ let project = temp.path().join(format!("project-{idx}"));
264+ fs::create_dir_all(&home).unwrap();
265+ fs::create_dir_all(&cache_home).unwrap();
266+ fs::create_dir_all(&project).unwrap();
267+ write_config(&project, auto_shutdown_ms);
268+
269+ let mut child = Command::new(assert_cmd::cargo_bin!("vibebox-supervisor"))
270+ .current_dir(&project)
271+ .env("HOME", &home)
272+ .env("XDG_CACHE_HOME", &cache_home)
273+ .env("VIBEBOX_INTERNAL", "1")
274+ .stdout(Stdio::piped())
275+ .stderr(Stdio::piped())
276+ .spawn()
277+ .unwrap();
278+ if let Some(stdout) = child.stdout.take() {
279+ spawn_prefix_reader(label.clone(), "stdout", stdout);
280+ }
281+ if let Some(stderr) = child.stderr.take() {
282+ spawn_prefix_reader(label.clone(), "stderr", stderr);
283+ }
284+
285+ let socket_path = project.join(".vibebox").join("vm.sock");
286+ wait_for_socket(&socket_path, Duration::from_secs(10));
287+
288+ Supervisor {
289+ child,
290+ socket_path,
291+ clients: Vec::new(),
292+ label,
293+ }
294+}
295+
296+fn connect_clients(
297+ socket_path: &Path,
298+ count: usize,
299+ timeout: Duration,
300+ require_all: bool,
301+ label: &str,
302+) -> Vec<UnixStream> {
303+ let (tx, rx) = mpsc::channel();
304+ let mut handles = Vec::with_capacity(count);
305+ for _ in 0..count {
306+ let path = socket_path.to_path_buf();
307+ let tx = tx.clone();
308+ handles.push(thread::spawn(move || {
309+ let stream = connect_client_with_retry(&path, timeout);
310+ let _ = tx.send(stream);
311+ }));
312+ }
313+ drop(tx);
314+ let mut clients = Vec::with_capacity(count);
315+ for stream in rx.into_iter().flatten() {
316+ clients.push(stream);
317+ }
318+ for handle in handles {
319+ handle.join().unwrap();
320+ }
321+ if require_all && clients.len() != count {
322+ panic!(
323+ "client count mismatch: expected {count} got {}",
324+ clients.len()
325+ );
326+ }
327+ if !require_all && clients.len() != count {
328+ log_line(
329+ label,
330+ &format!("connected {} of {count} clients", clients.len()),
331+ );
332+ }
333+ clients
334+}
335+
336+fn connect_client_with_retry(path: &Path, timeout: Duration) -> Option<UnixStream> {
337+ let start = Instant::now();
338+ loop {
339+ match UnixStream::connect(path) {
340+ Ok(stream) => return Some(stream),
341+ Err(_) => {
342+ if start.elapsed() > timeout {
343+ return None;
344+ }
345+ thread::sleep(Duration::from_millis(50));
346+ }
347+ }
348+ }
349+}
350+
351+fn wait_for_socket(path: &Path, timeout: Duration) {
352+ let start = Instant::now();
353+ loop {
354+ if UnixStream::connect(path).is_ok() {
355+ return;
356+ }
357+ if start.elapsed() > timeout {
358+ panic!("timed out waiting for socket {}", path.display());
359+ }
360+ thread::sleep(Duration::from_millis(100));
361+ }
362+}
363+
364+fn assert_manager_alive(child: &mut Child, message: &str) {
365+ assert!(child.try_wait().unwrap().is_none(), "{message}");
366+}
367+
368+fn assert_manager_alive_for(child: &mut Child, duration: Duration, message: &str) {
369+ let start = Instant::now();
370+ while start.elapsed() < duration {
371+ assert_manager_alive(child, message);
372+ thread::sleep(Duration::from_millis(100));
373+ }
374+}
375+
376+fn kill_supervisor(supervisor: &mut Supervisor, timeout: Duration) {
377+ supervisor.clients.clear();
378+ let _ = supervisor.child.kill();
379+ wait_for_exit(&mut supervisor.child, timeout);
380+ let _ = supervisor.child.wait();
381+}
382+
383+fn prune_exited_supervisors(supervisors: &mut Vec<Supervisor>, label: &str) {
384+ supervisors.retain_mut(|supervisor| {
385+ if supervisor.child.try_wait().unwrap().is_some() {
386+ log_line(label, &format!("removed exited {}", supervisor.label));
387+ false
388+ } else {
389+ true
390+ }
391+ });
392+}
393+
394+fn shutdown_supervisor(supervisor: &mut Supervisor, timeout: Duration) {
395+ supervisor.clients.clear();
396+ wait_for_exit(&mut supervisor.child, timeout);
397+ if supervisor.child.try_wait().unwrap().is_none() {
398+ let _ = supervisor.child.kill();
399+ let _ = supervisor.child.wait();
400+ }
401+}
402+
403+fn wait_for_exit(child: &mut Child, timeout: Duration) {
404+ let start = Instant::now();
405+ loop {
406+ if child.try_wait().unwrap().is_some() {
407+ return;
408+ }
409+ if start.elapsed() > timeout {
410+ panic!("timed out waiting for mock vm supervisor exit");
411+ }
412+ thread::sleep(Duration::from_millis(200));
413+ }
414+}
415+
416+fn spawn_prefix_reader(label: String, stream: &'static str, reader: impl Read + Send + 'static) {
417+ thread::spawn(move || {
418+ let buf = BufReader::new(reader);
419+ for line in buf.lines() {
420+ match line {
421+ Ok(line) => println!("[{}][{}] {}", label, stream, line),
422+ Err(err) => {
423+ eprintln!("[{}][{}] read error: {}", label, stream, err);
424+ break;
425+ }
426+ }
427+ }
428+ });
429+}
430+
431+fn log_line(prefix: &str, message: &str) {
432+ println!("[{}] {}", prefix, message);
433+}
434+
435+struct Lcg {
436+ state: u64,
437+}
438+
439+impl Lcg {
440+ fn new(seed: u64) -> Self {
441+ Self { state: seed }
442+ }
443+
444+ fn next_u32(&mut self) -> u32 {
445+ self.state = self.state.wrapping_mul(6364136223846793005).wrapping_add(1);
446+ (self.state >> 32) as u32
447+ }
448+
449+ fn gen_range(&mut self, upper: usize) -> usize {
450+ if upper == 0 {
451+ return 0;
452+ }
453+ (self.next_u32() as usize) % upper
454+ }
455+}
tests/explain_integration.rsnew file+113
@@ -0,0 +1,113 @@
1+use std::{env, ffi::OsString, fs, path::Path, sync::Mutex};
2+
3+use tempfile::TempDir;
4+
5+use vibebox::session_manager::INSTANCE_DIR_NAME;
6+use vibebox::{config, explain};
7+
8+static ENV_MUTEX: Mutex<()> = Mutex::new(());
9+
10+struct EnvGuard {
11+ key: &'static str,
12+ previous: Option<OsString>,
13+}
14+
15+impl EnvGuard {
16+ fn set(key: &'static str, value: &Path) -> Self {
17+ let previous = env::var_os(key);
18+ unsafe {
19+ env::set_var(key, value);
20+ }
21+ Self { key, previous }
22+ }
23+}
24+
25+impl Drop for EnvGuard {
26+ fn drop(&mut self) {
27+ match &self.previous {
28+ Some(value) => unsafe {
29+ env::set_var(self.key, value);
30+ },
31+ None => unsafe {
32+ env::remove_var(self.key);
33+ },
34+ }
35+ }
36+}
37+
38+#[test]
39+fn build_mount_rows_includes_defaults_and_custom_mounts() {
40+ let _lock = ENV_MUTEX.lock().unwrap();
41+ let temp = TempDir::new().unwrap();
42+ let home = temp.path().join("home");
43+ let project = home.join("project");
44+ let cache_home = home.join("cache");
45+ fs::create_dir_all(&project).unwrap();
46+ fs::create_dir_all(&cache_home).unwrap();
47+
48+ let _home_guard = EnvGuard::set("HOME", &home);
49+ let _cache_guard = EnvGuard::set("XDG_CACHE_HOME", &cache_home);
50+
51+ let box_cfg = config::BoxConfig {
52+ mounts: vec!["data:~/data:read-only".to_string()],
53+ ..Default::default()
54+ };
55+ let cfg = config::Config {
56+ box_cfg,
57+ supervisor: config::SupervisorConfig::default(),
58+ };
59+
60+ let rows = explain::build_mount_rows(&project, &cfg).unwrap();
61+
62+ assert_eq!(rows.len(), 3);
63+ assert_eq!(rows[0].host, "~/project");
64+ assert_eq!(rows[0].guest, "~/project");
65+ assert_eq!(rows[0].mode, "read-write");
66+ assert_eq!(rows[0].default_mount, "yes");
67+
68+ assert_eq!(rows[1].host, "~/cache/vibebox/.guest-mise-cache");
69+ assert_eq!(rows[1].guest, "/root/.local/share/mise");
70+ assert_eq!(rows[1].mode, "read-write");
71+ assert_eq!(rows[1].default_mount, "yes");
72+
73+ assert_eq!(rows[2].host, "~/project/data");
74+ assert_eq!(rows[2].guest, "~/data");
75+ assert_eq!(rows[2].mode, "read-only");
76+ assert_eq!(rows[2].default_mount, "no");
77+}
78+
79+#[test]
80+fn build_network_rows_pending_without_instance_file() {
81+ let temp = TempDir::new().unwrap();
82+ let project = temp.path().join("project");
83+ fs::create_dir_all(&project).unwrap();
84+
85+ let rows = explain::build_network_rows(&project).unwrap();
86+
87+ assert_eq!(rows.len(), 1);
88+ assert_eq!(rows[0].network_type, "NAT");
89+ assert_eq!(rows[0].vm_ip, "-");
90+ assert_eq!(rows[0].host_to_vm, "ssh: <pending>:22");
91+ assert_eq!(rows[0].vm_to_host, "none");
92+}
93+
94+#[test]
95+fn build_network_rows_uses_instance_vm_ip() {
96+ let temp = TempDir::new().unwrap();
97+ let project = temp.path().join("project");
98+ let instance_dir = project.join(INSTANCE_DIR_NAME);
99+ fs::create_dir_all(&instance_dir).unwrap();
100+ fs::write(
101+ instance_dir.join("instance.toml"),
102+ "vm_ipv4 = \"10.1.2.3\"\n",
103+ )
104+ .unwrap();
105+
106+ let rows = explain::build_network_rows(&project).unwrap();
107+
108+ assert_eq!(rows.len(), 1);
109+ assert_eq!(rows[0].network_type, "NAT");
110+ assert_eq!(rows[0].vm_ip, "10.1.2.3");
111+ assert_eq!(rows[0].host_to_vm, "ssh: 10.1.2.3:22");
112+ assert_eq!(rows[0].vm_to_host, "none");
113+}
vibebox.toml+4−3
@@ -1,10 +1,11 @@
11[box]
22cpu_count = 2
33ram_mb = 2048
4+disk_gb = 5
45mounts = [
5− "~/.codex:/usr/local/codex:read-write",
6− "~/.claude:/usr/local/claude:read-write",
6+ "~/.codex:~/.codex:read-write",
7+ "~/.claude:~/.claude:read-write",
78]
89
910[supervisor]
10−auto_shutdown_ms = 20000
11+auto_shutdown_ms = 2000