on main help sign in
25 files changed+3084−839
since splitsnapshots
unifiedsplit
.github/workflows/ci.yml+29−1 Cargo.lock+93−1 Cargo.toml+10−1 README.md+98−37 README.zh.md+209 docs/tasks.md+14−9 scripts/commit-check.sh+6 src/bin/vibebox-supervisor.rs+8−7 src/bin/vibebox.rs+55−91 src/config.rs+467−77 src/error_report.sh+28 src/explain.rs+32−47 src/instance.rs+295−136 src/lib.rs+1 src/provision.sh+49−5 src/session_manager.rs+56−93 src/ssh.sh+18−10 src/tui.rs+31−31 src/utils.rs+18 src/vm.rs+279−167 src/vm_manager.rs+384−126 tests/e2e_cli.rs+58 tests/e2e_vm.rs+278 tests/e2e_vm_mock.rs+455 tests/explain_integration.rs+113
.github/workflows/ci.yml+29−1
@@ -10,7 +10,6 @@ on:
1010 - "README.zh.md"
1111 - "docs/**"
1212 - "install"
13− pull_request:
1413
1514concurrency:
1615 group: ci-${{ github.ref }}
@@ -20,6 +19,8 @@ jobs:
2019 fmt:
2120 name: Format
2221 runs-on: macos-latest
22+ env:
23+ RUST_BACKTRACE: "1"
2324 steps:
2425 - uses: actions/checkout@v6
2526 - name: Install Rust
@@ -34,6 +35,8 @@ jobs:
3435 clippy:
3536 name: Clippy
3637 runs-on: macos-latest
38+ env:
39+ RUST_BACKTRACE: "1"
3740 steps:
3841 - uses: actions/checkout@v6
3942 - name: Install Rust
@@ -48,6 +51,8 @@ jobs:
4851 build:
4952 name: Build
5053 runs-on: macos-latest
54+ env:
55+ RUST_BACKTRACE: "1"
5156 steps:
5257 - uses: actions/checkout@v6
5358 - name: Install Rust
@@ -60,6 +65,8 @@ jobs:
6065 test:
6166 name: Test
6267 runs-on: macos-latest
68+ env:
69+ RUST_BACKTRACE: "full"
6370 steps:
6471 - uses: actions/checkout@v6
6572 - name: Install Rust
@@ -68,3 +75,24 @@ jobs:
6875 uses: Swatinem/rust-cache@v2
6976 - name: cargo test
7077 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
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.2.1"
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+10−1
@@ -1,6 +1,6 @@
11[package]
22name = "vibebox"
3−version = "0.2.1"
3+version = "0.3.2"
44edition = "2024"
55authors = ["Finn Sheng"]
66description = "Ultrafast CLI on Apple Silicon macOS for fast, sandboxed development and LLM agents."
@@ -42,3 +42,12 @@ ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] }
4242tracing = "0.1"
4343tracing-subscriber = { version = "0.3", features = ["env-filter"] }
4444dialoguer = "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.md+98−37
@@ -5,56 +5,120 @@
55 </picture>
66 </a>
77</p>
8−<p align="center">Your ultrafast open source AI sandbox.</p>
8+<p align="center">an ultrafast, open-source sandbox for running coding agents safely.</p>
99
10−[![Crates.io][crates-badge]][crates-url]
11−[![MIT licensed][mit-badge]][mit-url]
12−[![Build Status][actions-badge]][actions-url]
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>
1326
14−[crates-badge]: https://img.shields.io/crates/v/vibebox.svg
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.
1529
16−[crates-url]: https://crates.io/crates/vibebox
30+**Who it’s for:** macOS users running coding agents who want real isolation without giving up a fast daily workflow.
1731
18−[mit-badge]: https://img.shields.io/badge/license-MIT-blue.svg
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).
1934
20−[mit-url]: https://github.com/robcholz/vibebox/blob/main/LICENSE
35+**Security model:** Linux guest VM with explicit mount allowlists from `vibebox.toml` (repo-first, everything else
36+opt-in).
2137
22−[actions-badge]: https://github.com/robcholz/vibebox/workflows/CI/badge.svg
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)
2341
24−[actions-url]: https://github.com/robcholz/vibebox/actions?query=workflow%3ACI+branch%3Amain
42+### Quick Demo
2543
26−<p align="center">
27− <a href="README.md">English</a> |
28− <a href="README.zh.md">简体中文</a> |
29−</p>
44+```bash
45+# from any repo
46+cd my-project
47+vibebox
48+```
49+
50+What you should see (roughly):
3051
31−[![OpenCode Terminal UI](docs/screenshot.png)](https://vibebox.robcholz.com)
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)
3259
3360---
3461
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+
35102### Installation
36103
37104```bash
38−# YOLO
105+# install script
39106curl -fsSL https://raw.githubusercontent.com/robcholz/vibebox/main/install | bash
40107
41−# Package managers
108+# package managers
42109cargo install vibebox
43110
44−# Or manually (bad)
111+# manual install
45112curl -LO https://github.com/robcholz/vibebox/releases/download/latest/vibebox-macos-arm64.zip
46113unzip vibebox-macos-arm64.zip
47114mkdir -p ~/.local/bin
48−mv vibe ~/.local/bin
115+mv vibebox ~/.local/bin
49116export PATH="$HOME/.local/bin:$PATH"
50117```
51118
52−> [!TIP]
53−> We truly recommend you to use `YOLO` to install.
54−
55119**Requirements**
56120
57−- macOS on Apple Silicon (Vibebox uses Apple's virtualization APIs).
121+- macOS on Apple Silicon (VibeBox uses Apple's virtualization APIs).
58122
59123**First Run**
60124
@@ -70,7 +134,7 @@ cd /path/to/your/project
70134vibebox
71135```
72136
73−On first run, Vibebox creates `vibebox.toml` in your project (if missing) and a `.vibebox/` directory for instance data.
137+On first run, VibeBox creates `vibebox.toml` in your project (if missing) and a `.vibebox/` directory for instance data.
74138
75139**Configuration (`vibebox.toml`)**
76140
@@ -98,7 +162,7 @@ recreate the disk.
98162
99163**Mounts**
100164
101−- Your project is mounted read-write at `/usr/local/vibebox-mounts/<project-name>`, and the shell starts there.
165+- Your project is mounted read-write at `~/<project-name>`, and the shell starts there.
102166- If a `.git` directory exists, it is masked with a tmpfs mount inside the VM to discourage accidental edits from the
103167 guest.
104168- Extra mounts come from `box.mounts` with the format `host:guest[:read-only|read-write]`.
@@ -121,7 +185,7 @@ vibebox explain # show mounts and network info
121185- Default SSH user: `vibecoder`
122186- Hostname: `vibebox`
123187- Base image provisioning installs: build tools, `git`, `curl`, `ripgrep`, `openssh-server`, and `sudo`.
124−- On first login, Vibebox installs `mise` and configures tools like `uv`, `node`, `@openai/codex`, and
188+- On first login, VibeBox installs `mise` and configures tools like `uv`, `node`, `@openai/codex`, and
125189 `@anthropic-ai/claude-code` (best-effort).
126190- Shell aliases: `:help` and `:exit`.
127191
@@ -136,27 +200,24 @@ vibebox explain # show mounts and network info
136200If you're interested in contributing to VibeBox, please read our [contributing docs](CONTRIBUTING.md) before
137201submitting a pull request.
138202
139−### Using VibeBox
140−
141−Feel free to use, but remember to promote VibeBox as well!
142−
143203### FAQ
144204
145−#### How is this different from other Sandboxes?
205+#### How is this different from other sandboxes?
146206
147−Vibebox is built for fast, repeatable local sandboxes with minimal ceremony. What’s different here:
207+VibeBox is built for fast, repeatable local sandboxes with minimal ceremony. What’s different here:
148208
149−- Warm startup is typically under **6 seconds** on my M3, so you can jump back in quickly.
209+- Warm re-entry is typically **<5s** on my M3 (varies by machine/cache), so you can jump back in quickly.
150210- One simple command — `vibebox` — drops you into the sandbox from your project.
151211- Configuration lives in `vibebox.toml`, where you can set CPU, RAM, disk size, and mounts.
212+- Sessions are first-class: reuse, multiple terminals, cleanup.
152213
153−### Special Thank
214+### Special thanks
154215
155216[vibe](https://github.com/lynaghk/vibe) by lynaghk.
156217
157−And amazing Rust community, without your rich crates and fantastic toolchain like [crates.io](https://crates.io), this
158−wouldn't be possible!
218+And the amazing Rust community — without the ecosystem and toolchain like [crates.io](https://crates.io), this wouldn't
219+be possible!
159220
160221---
161222
162−**Follow me on X** [X.com](https://x.com/robcholz)
223+**Follow me on X** [x.com/robcholz](https://x.com/robcholz)
README.zh.md+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/tasks.md+14−9
@@ -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
@@ -49,18 +49,23 @@
4949 4. [x] set up the cli.
50509. [x] fix ui overlap, and consistency issue.
515110. [x] `purge-cache` to clear the cache.
52−11. [ ] intensive integration test.
52+11. [x] intensive integration test.
5353
5454## Publish
5555
56−1. [ ] write the docs.
57−2. [ ] setup quick install link.
58−3. [ ] setup website.
56+1. [x] write the docs.
57+2. [x] setup quick install link.
58+3. [x] setup website.
5959
6060## Stage 2
6161
62621. [ ] retouch the cli ux.
63−2. [ ] refactor the code.
64−3. [ ] Redirect vm output to log.
65−4. [ ] Redirect vm output to vibebox starting it.
66−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
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-supervisor.rs+8−7
@@ -21,15 +21,16 @@ fn main() -> Result<()> {
2121 color_eyre::install()?;
2222
2323 tracing::info!("starting vm supervisor");
24− let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
25− let config = config::load_config(&cwd);
26− 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)
2726 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
28− 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);
2930 let args = vm::VmArg {
3031 cpu_count: config.box_cfg.cpu_count,
31− ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
32− disk_bytes: config.box_cfg.disk_gb.saturating_mul(1024 * 1024 * 1024),
32+ ram_bytes: config.box_cfg.ram_size.as_u64(),
33+ disk_bytes: config.box_cfg.disk_size.as_u64(),
3334 no_default_mounts: false,
3435 mounts: config.box_cfg.mounts.clone(),
3536 };
@@ -37,7 +38,7 @@ fn main() -> Result<()> {
3738 tracing::info!(auto_shutdown_ms, "vm supervisor config");
3839
3940 let result = vm_manager::run_manager(args, auto_shutdown_ms);
40− let _ = instance::touch_last_active(&instance_dir);
41+ let _ = instance::touch_last_active(&project_dir);
4142 if let Err(err) = result {
4243 tracing::error!(error = %err, "vm supervisor exited");
4344 return Err(color_eyre::eyre::eyre!(err.to_string()));
src/bin/vibebox.rs+55−91
@@ -1,3 +1,7 @@
1+use bytesize::ByteSize;
2+use clap::Parser;
3+use color_eyre::Result;
4+use dialoguer::Confirm;
15use std::{
26 env,
37 ffi::OsString,
@@ -6,10 +10,6 @@ use std::{
610 path::{Path, PathBuf},
711 sync::{Arc, Mutex},
812};
9−
10−use clap::Parser;
11−use color_eyre::Result;
12−use dialoguer::Confirm;
1313use time::OffsetDateTime;
1414use time::format_description::well_known::Rfc3339;
1515use tracing_subscriber::filter::LevelFilter;
@@ -17,8 +17,9 @@ use tracing_subscriber::registry::Registry;
1717use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload};
1818
1919use vibebox::tui::{AppState, VmInfo};
20+use vibebox::utils::relative_to_home;
2021use vibebox::{
21− SessionManager, commands, config, explain, instance, session_manager, tui, vm, vm_manager,
22+ SessionManager, commands, config, explain, instance, session_manager, tui, vm_manager,
2223};
2324
2425#[derive(Debug, Parser)]
@@ -56,53 +57,26 @@ fn main() -> Result<()> {
5657
5758 let config_override = cli.config.clone();
5859 let raw_args: Vec<OsString> = env::args_os().collect();
59− let config = config::load_config_with_path(&cwd, config_override.as_deref());
60+ let config = config::load_config_with_path(&cwd, config_override.as_deref())
61+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
6062
61− if env::var("VIBEBOX_VM_MANAGER").as_deref() == Ok("1") {
62− tracing::info!("starting vm manager mode");
63− let args = vm::VmArg {
64− cpu_count: config.box_cfg.cpu_count,
65− ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
66− disk_bytes: config.box_cfg.disk_gb.saturating_mul(1024 * 1024 * 1024),
67− no_default_mounts: false,
68− mounts: config.box_cfg.mounts.clone(),
69− };
70− let auto_shutdown_ms = config.supervisor.auto_shutdown_ms;
71− tracing::info!(auto_shutdown_ms, "vm manager config");
72− if let Err(err) = vm_manager::run_manager(args, auto_shutdown_ms) {
73− tracing::error!(error = %err, "vm manager exited");
74− return Err(color_eyre::eyre::eyre!(err.to_string()));
75− }
76− return Ok(());
77− }
78−
79− vm::ensure_signed();
80−
81− let vm_args = vm::VmArg {
82− cpu_count: config.box_cfg.cpu_count,
83− ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
84− disk_bytes: config.box_cfg.disk_gb.saturating_mul(1024 * 1024 * 1024),
85− no_default_mounts: false,
86− mounts: config.box_cfg.mounts.clone(),
87− };
88− let auto_shutdown_ms = config.supervisor.auto_shutdown_ms;
8963 let vm_info = VmInfo {
90− max_memory_mb: vm_args.ram_bytes / (1024 * 1024),
91− cpu_cores: vm_args.cpu_count,
92− max_disk_gb: (vm_args.disk_bytes as f32) / 1024.0 / 1024.0 / 1024.0,
64+ max_memory: config.box_cfg.ram_size,
65+ cpu_cores: config.box_cfg.cpu_count,
66+ max_disk: config.box_cfg.disk_size,
9367 system_name: "Debian".to_string(), // TODO: read system name from the VM.
94− auto_shutdown_ms,
68+ auto_shutdown_ms: config.supervisor.auto_shutdown_ms,
9569 };
9670 if let Ok(manager) = SessionManager::new() {
9771 if let Err(err) = manager.update_global_sessions(&cwd) {
9872 tracing::warn!(error = %err, "failed to update a global session list");
9973 }
10074 } else {
101− tracing::warn!("failed to initialize session manager");
75+ tracing::error!("failed to initialize session manager");
76+ std::process::exit(1);
10277 }
10378 let commands = commands::build_commands();
10479 let app = Arc::new(Mutex::new(AppState::new(cwd.clone(), vm_info, commands)));
105−
10680 {
10781 let mut locked = app.lock().expect("app state poisoned");
10882 tui::render_tui_once(&mut locked)?;
@@ -112,17 +86,39 @@ fn main() -> Result<()> {
11286 writeln!(stdout)?;
11387 stdout.flush()?;
11488 }
115− warn_disk_size_mismatch(&cwd, vm_args.disk_bytes);
89+ warn_disk_size_mismatch(&cwd, config.box_cfg.disk_size);
11690 if let Some(handle) = stderr_handle {
11791 let _ = handle.modify(|filter| *filter = LevelFilter::INFO);
11892 }
11993
120− tracing::debug!(auto_shutdown_ms, "auto shutdown config");
121− let manager_conn =
122− vm_manager::ensure_manager(&raw_args, auto_shutdown_ms, config_override.as_deref())
123− .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
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+ })?;
124104
125− instance::run_with_ssh(manager_conn).map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
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+ }
126122
127123 tracing::info!("See you again — keep vibecoding (no SEVs, only vibes) 😈");
128124
@@ -205,13 +201,14 @@ fn handle_command(command: Command, cwd: &Path, config_override: Option<&Path>)
205201 "Purged {} file{} totaling {} from {}",
206202 file_count,
207203 if file_count == 1 { "" } else { "s" },
208− format_bytes(total_bytes),
204+ ByteSize(total_bytes),
209205 cache_dir.display()
210206 );
211207 Ok(())
212208 }
213209 Command::Explain => {
214− let config = config::load_config_with_path(cwd, config_override);
210+ let config = config::load_config_with_path(cwd, config_override)
211+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
215212 let mounts = explain::build_mount_rows(cwd, &config)
216213 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
217214 let networks = explain::build_network_rows(cwd)
@@ -234,20 +231,6 @@ fn project_name(directory: &Path) -> String {
234231 .to_string()
235232}
236233
237−fn relative_to_home(directory: &Path) -> String {
238− let Ok(home) = env::var("HOME") else {
239− return directory.display().to_string();
240− };
241− let home_path = PathBuf::from(home);
242− if let Ok(stripped) = directory.strip_prefix(&home_path) {
243− if stripped.components().next().is_none() {
244− return "~".to_string();
245− }
246− return format!("~/{}", stripped.display());
247− }
248− directory.display().to_string()
249−}
250−
251234fn cache_dir() -> Result<PathBuf> {
252235 let home = env::var("HOME").map(PathBuf::from)?;
253236 let cache_home = env::var("XDG_CACHE_HOME")
@@ -296,23 +279,6 @@ fn measure_dir(path: &Path) -> Result<(u64, u64)> {
296279 Ok((file_count, total_bytes))
297280}
298281
299−fn format_bytes(bytes: u64) -> String {
300− const KB: f64 = 1024.0;
301− const MB: f64 = KB * 1024.0;
302− const GB: f64 = MB * 1024.0;
303−
304− let b = bytes as f64;
305− if b >= GB {
306− format!("{:.2} GB", b / GB)
307− } else if b >= MB {
308− format!("{:.1} MB", b / MB)
309− } else if b >= KB {
310− format!("{:.1} KB", b / KB)
311− } else {
312− format!("{} B", bytes)
313− }
314−}
315−
316282fn format_last_active(value: Option<&str>) -> String {
317283 let Some(raw) = value else {
318284 return "-".to_string();
@@ -354,24 +320,22 @@ fn format_last_active(value: Option<&str>) -> String {
354320 format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
355321}
356322
357−fn warn_disk_size_mismatch(cwd: &Path, configured_bytes: u64) {
323+fn warn_disk_size_mismatch(cwd: &Path, configured_size: ByteSize) {
358324 let instance_raw = cwd
359325 .join(session_manager::INSTANCE_DIR_NAME)
360326 .join("instance.raw");
361327 let Ok(meta) = fs::metadata(&instance_raw) else {
362328 return;
363329 };
364− let current_bytes = meta.len();
365− if current_bytes == configured_bytes {
330+ let current_size = ByteSize::b(meta.len());
331+ if current_size == configured_size {
366332 return;
367333 }
368− let current_gb = current_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
369− let target_gb = configured_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
370334 tracing::warn!(
371− "instance disk size does not match config (current {:.2} GB, config {:.2} GB). \
335+ "instance disk size does not match config (current {}, config {}). \
372336disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using the existing disk.",
373− current_gb,
374− target_gb
337+ current_size,
338+ configured_size,
375339 );
376340}
377341
@@ -380,13 +344,13 @@ type StderrHandle = reload::Handle<LevelFilter, Registry>;
380344fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
381345 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
382346 let file_filter = filter.clone();
383− let stderr_is_tty = std::io::stderr().is_terminal();
347+ let stderr_is_tty = io::stderr().is_terminal();
384348 let ansi = stderr_is_tty && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
385349 let file = instance::ensure_instance_dir(cwd)
386350 .ok()
387351 .and_then(|instance_dir| {
388352 let log_path = instance_dir.join("cli.log");
389− std::fs::OpenOptions::new()
353+ fs::OpenOptions::new()
390354 .create(true)
391355 .write(true)
392356 .truncate(true)
@@ -400,7 +364,7 @@ fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
400364 .with_target(false)
401365 .with_ansi(ansi)
402366 .without_time()
403− .with_writer(std::io::stderr)
367+ .with_writer(io::stderr)
404368 .with_filter(stderr_filter);
405369 let subscriber = tracing_subscriber::registry().with(stderr_layer);
406370 if let Some(file) = file {
@@ -418,7 +382,7 @@ fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
418382 let stderr_layer = fmt::layer()
419383 .with_target(false)
420384 .with_ansi(ansi)
421− .with_writer(std::io::stderr)
385+ .with_writer(io::stderr)
422386 .with_filter(filter);
423387 let subscriber = tracing_subscriber::registry().with(stderr_layer);
424388 if let Some(file) = file {
src/config.rs+467−77
@@ -1,13 +1,14 @@
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;
@@ -22,20 +23,83 @@ pub struct Config {
2223 pub supervisor: SupervisorConfig,
2324}
2425
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+ ));
42+ }
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))
84+ }
85+}
86+
2587#[derive(Debug, Clone, Serialize, Deserialize)]
2688pub struct BoxConfig {
2789 pub cpu_count: usize,
28− pub ram_mb: u64,
29− pub disk_gb: 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,
3094 pub mounts: Vec<String>,
3195}
3296
3397impl Default for BoxConfig {
3498 fn default() -> Self {
3599 Self {
36− cpu_count: default_cpu_count(),
37− ram_mb: default_ram_mb(),
38− disk_gb: default_disk_gb(),
100+ cpu_count: DEFAULT_CPU_COUNT,
101+ ram_size: ByteSize::mib(DEFAULT_RAM_MB),
102+ disk_size: ByteSize::gib(DEFAULT_DISK_GB),
39103 mounts: default_mounts(),
40104 }
41105 }
@@ -49,23 +113,11 @@ pub struct SupervisorConfig {
49113impl Default for SupervisorConfig {
50114 fn default() -> Self {
51115 Self {
52− auto_shutdown_ms: default_auto_shutdown_ms(),
116+ auto_shutdown_ms: DEFAULT_AUTO_SHUTDOWN_MS,
53117 }
54118 }
55119}
56120
57−fn default_cpu_count() -> usize {
58− DEFAULT_CPU_COUNT
59−}
60−
61−fn default_ram_mb() -> u64 {
62− DEFAULT_RAM_MB
63−}
64−
65−fn default_auto_shutdown_ms() -> u64 {
66− DEFAULT_AUTO_SHUTDOWN_MS
67−}
68−
69121fn default_mounts() -> Vec<String> {
70122 vec![
71123 "~/.codex:~/.codex:read-write".into(),
@@ -73,19 +125,12 @@ fn default_mounts() -> Vec<String> {
73125 ]
74126}
75127
76−fn default_disk_gb() -> u64 {
77− DEFAULT_DISK_GB
78−}
79−
80128pub fn config_path(project_root: &Path) -> PathBuf {
81129 project_root.join(CONFIG_FILENAME)
82130}
83131
84−pub fn ensure_config_file(
85− project_root: &Path,
86− override_path: Option<&Path>,
87−) -> Result<PathBuf, io::Error> {
88− 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)?;
89134 if !path.exists() {
90135 let default_config = Config::default();
91136 let contents = toml::to_string_pretty(&default_config).unwrap_or_default();
@@ -95,32 +140,24 @@ pub fn ensure_config_file(
95140 Ok(path)
96141}
97142
98−pub fn load_config(project_root: &Path) -> Config {
143+pub fn load_config(project_root: &Path) -> Result<Config> {
99144 load_config_with_path(project_root, None)
100145}
101146
102−pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) -> Config {
103− let path = match ensure_config_file(project_root, override_path) {
104− Ok(path) => path,
105− Err(err) => die(&format!("failed to create config: {err}")),
106− };
107− let raw = match fs::read_to_string(&path) {
108− Ok(raw) => raw,
109− Err(err) => die(&format!("failed to read config: {err}")),
110− };
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")?;
111151 let trimmed = raw.trim();
112152 tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config");
113153 if trimmed.is_empty() {
114− die(&format!(
154+ bail!(format!(
115155 "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)",
116156 path.display()
117157 ));
118158 }
119159
120− let value: toml::Value = match toml::from_str(trimmed) {
121− Ok(value) => value,
122− Err(err) => die(&format!("invalid config: {err}")),
123− };
160+ let value: toml::Value = toml::from_str(trimmed).context("invalid config")?;
124161 let schema_errors = validate_schema(&value);
125162 if !schema_errors.is_empty() {
126163 let message = format!(
@@ -128,27 +165,28 @@ pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>)
128165 path.display(),
129166 schema_errors.join("\n- ")
130167 );
131− die(&message);
168+ bail!(message);
132169 }
133170
134− let config: Config = match toml::from_str(trimmed) {
135− Ok(config) => config,
136− Err(err) => die(&format!("invalid config: {err}")),
137− };
138− validate_or_exit(&config);
139− config
171+ let config: Config = toml::from_str(trimmed).context("invalid config")?;
172+ validate_config(&config).map_err(Error::msg)?;
173+ Ok(config)
140174}
141175
142−fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> PathBuf {
143− let root = match fs::canonicalize(project_root) {
144− Ok(root) => root,
145− Err(err) => die(&format!("failed to resolve project root: {err}")),
146− };
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+}
147180
148− let override_path = override_path
149− .map(PathBuf::from)
150− .or_else(|| env::var_os(CONFIG_PATH_ENV).map(PathBuf::from));
151− let raw_path = if let Some(path) = override_path {
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")?;
187+
188+ let selected_path = override_path.map(PathBuf::from).or(env_override);
189+ let raw_path = if let Some(path) = selected_path {
152190 if path.is_absolute() {
153191 path
154192 } else {
@@ -159,14 +197,50 @@ fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> Pat
159197 };
160198
161199 let normalized = normalize_path(&raw_path);
162− if !normalized.starts_with(&root) {
163− 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!(
164204 "config path must be within {}: {}",
165205 root.display(),
166− normalized.display()
167− ));
206+ resolved.display()
207+ );
168208 }
169− 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))
170244}
171245
172246fn normalize_path(path: &Path) -> PathBuf {
@@ -263,27 +337,343 @@ fn validate_string_array(
263337 }
264338}
265339
266−fn validate_or_exit(config: &Config) {
340+fn validate_config(config: &Config) -> Result<(), String> {
267341 if config.box_cfg.cpu_count == 0 {
268− die("box.cpu_count must be >= 1");
342+ return Err("box.cpu_count must be >= 1".to_string());
269343 }
270− if config.box_cfg.ram_mb == 0 {
271− 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());
272346 }
273− if config.box_cfg.disk_gb == 0 {
274− die("box.disk_gb must be >= 1");
347+ if config.box_cfg.disk_size.as_gib() == 0.0 {
348+ return Err("box.disk_gb must be >= 1".to_string());
275349 }
276350 if config.supervisor.auto_shutdown_ms == 0 {
277− die("supervisor.auto_shutdown_ms must be >= 1");
351+ return Err("supervisor.auto_shutdown_ms must be >= 1".to_string());
278352 }
279353 for spec in &config.box_cfg.mounts {
280354 if let Err(err) = DirectoryShare::from_mount_spec(spec) {
281− die(&format!("invalid mount spec '{spec}': {err}"));
355+ return Err(format!("invalid mount spec '{spec}': {err}"));
282356 }
283357 }
358+ Ok(())
284359}
285360
286−fn die(message: &str) -> ! {
287− tracing::error!("{message}");
288− 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+ }
289679}
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.rs+32−47
@@ -1,31 +1,31 @@
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};
15use std::{
26 env,
3− error::Error,
47 path::{Path, PathBuf},
58};
69
7−use crate::{config, instance, session_manager, tui};
8−
9−pub fn build_mount_rows(
10− cwd: &Path,
11− config: &config::Config,
12−) -> Result<Vec<tui::MountListRow>, Box<dyn Error + Send + Sync>> {
10+pub fn build_mount_rows(project: &Path, config: &config::Config) -> Result<Vec<tui::MountListRow>> {
1311 let mut rows = Vec::new();
14− rows.extend(default_mounts(cwd)?);
15− let guest_home = resolve_guest_home(cwd)?;
12+ rows.extend(default_mounts(project)?);
13+ let guest_home = resolve_guest_home(project);
1614 for spec in &config.box_cfg.mounts {
17− rows.push(parse_mount_spec(cwd, spec, false, &guest_home)?);
15+ rows.push(parse_mount_spec(project, spec, false, &guest_home)?);
1816 }
1917 Ok(rows)
2018}
2119
22−pub fn build_network_rows(
23− cwd: &Path,
24−) -> Result<Vec<tui::NetworkListRow>, Box<dyn Error + Send + Sync>> {
25− let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME);
20+pub fn build_network_rows(project_dir: &Path) -> Result<Vec<tui::NetworkListRow>> {
2621 let mut vm_ip = "-".to_string();
27− if let Ok(Some(ip)) = instance::read_instance_vm_ip(&instance_dir) {
28− vm_ip = ip;
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+ }
2929 }
3030 let host_to_vm = if vm_ip == "-" {
3131 "ssh: <pending>:22".to_string()
@@ -41,13 +41,13 @@ pub fn build_network_rows(
4141 Ok(vec![row])
4242}
4343
44−fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>, Box<dyn Error + Send + Sync>> {
44+fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>> {
4545 let project_name = cwd
4646 .file_name()
4747 .and_then(|name| name.to_str())
48− .unwrap_or("project");
48+ .with_context(|| "failed to get project name")?;
4949 let project_guest = format!("~/{project_name}");
50− let project_host = display_path(cwd);
50+ let project_host = relative_to_home(cwd);
5151 let mut rows = vec![tui::MountListRow {
5252 host: project_host,
5353 guest: project_guest,
@@ -57,14 +57,14 @@ fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>, Box<dyn Error +
5757
5858 let home = env::var("HOME")
5959 .map(PathBuf::from)
60− .unwrap_or_else(|_| PathBuf::from("/"));
60+ .with_context(|| "failed to get home directory")?;
6161 let cache_home = env::var("XDG_CACHE_HOME")
6262 .map(PathBuf::from)
6363 .unwrap_or_else(|_| home.join(".cache"));
6464 let cache_dir = cache_home.join(session_manager::GLOBAL_CACHE_DIR_NAME);
6565 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
6666 rows.push(tui::MountListRow {
67− host: display_path(&guest_mise_cache),
67+ host: relative_to_home(&guest_mise_cache),
6868 guest: "/root/.local/share/mise".to_string(),
6969 mode: "read-write".to_string(),
7070 default_mount: "yes".to_string(),
@@ -77,10 +77,10 @@ fn parse_mount_spec(
7777 spec: &str,
7878 default_mount: bool,
7979 guest_home: &str,
80−) -> Result<tui::MountListRow, Box<dyn Error + Send + Sync>> {
80+) -> Result<tui::MountListRow> {
8181 let parts: Vec<&str> = spec.split(':').collect();
8282 if parts.len() < 2 || parts.len() > 3 {
83− return Err(format!("invalid mount spec: {spec}").into());
83+ bail!["invalid mount spec: {spec}"];
8484 }
8585 let host_part = parts[0];
8686 let guest_part = parts[1];
@@ -89,11 +89,10 @@ fn parse_mount_spec(
8989 "read-only" => "read-only",
9090 "read-write" => "read-write",
9191 other => {
92− return Err(format!(
92+ bail![format!(
9393 "invalid mount mode '{}'; expected read-only or read-write",
9494 other
95− )
96− .into());
95+ )];
9796 }
9897 }
9998 } else {
@@ -116,22 +115,22 @@ fn display_host_spec(cwd: &Path, host: &str) -> String {
116115 }
117116 let host_path = PathBuf::from(host);
118117 if host_path.is_absolute() {
119− return display_path(&host_path);
118+ return relative_to_home(&host_path);
120119 }
121120 let candidate = cwd.join(&host_path);
122121 if candidate.is_absolute() {
123− display_path(&candidate)
122+ relative_to_home(&candidate)
124123 } else {
125124 host.to_string()
126125 }
127126}
128127
129−fn resolve_guest_home(cwd: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
130− let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME);
131− if let Ok(Some(user)) = instance::read_instance_ssh_user(&instance_dir) {
132− return Ok(format!("/home/{user}"));
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),
133133 }
134− Ok(format!("/home/{}", instance::DEFAULT_SSH_USER))
135134}
136135
137136fn resolve_guest_display(guest: &str, guest_home: &str) -> String {
@@ -155,17 +154,3 @@ fn resolve_guest_display(guest: &str, guest_home: &str) -> String {
155154 format!("/root/{guest}")
156155 }
157156}
158−
159−fn display_path(path: &Path) -> String {
160− let Ok(home) = env::var("HOME") else {
161− return path.display().to_string();
162− };
163− let home_path = PathBuf::from(home);
164− if let Ok(stripped) = path.strip_prefix(&home_path) {
165− if stripped.components().next().is_none() {
166− return "~".to_string();
167− }
168− return format!("~/{}", stripped.display());
169− }
170− path.display().to_string()
171−}
src/instance.rs+295−136
@@ -1,81 +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},
6+ io::{self, IsTerminal, Read},
47 net::{SocketAddr, TcpStream},
58 os::unix::{fs::PermissionsExt, net::UnixStream},
69 path::{Path, PathBuf},
7− process::{Command, Stdio},
8− sync::{Arc, Mutex},
10+ process::{Child, Command, Stdio},
11+ sync::{
12+ Arc, Mutex,
13+ atomic::{AtomicBool, Ordering},
14+ mpsc,
15+ },
916 thread,
1017 time::{Duration, Instant},
1118};
12−
13−use serde::{Deserialize, Serialize};
1419use time::{OffsetDateTime, format_description::well_known::Rfc3339};
1520use uuid::Uuid;
1621
1722use crate::{
1823 commands,
19− session_manager::{INSTANCE_DIR_NAME, INSTANCE_FILENAME},
24+ session_manager::INSTANCE_DIR_NAME,
25+ session_manager::{VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME},
2026 vm::{self, LoginAction},
2127};
2228
29+pub const STATUS_VM_ERROR_PREFIX: &str = "error:";
30+
2331const SSH_KEY_NAME: &str = "ssh_key";
24−pub(crate) const VM_ROOT_LOG_NAME: &str = "vm_root.log";
25−pub(crate) const STATUS_FILE_NAME: &str = "status.txt";
26−pub(crate) const DEFAULT_SSH_USER: &str = "vibecoder";
27−const SSH_CONNECT_RETRIES: usize = 30;
32+const INSTANCE_FILENAME: &str = "instance.toml";
33+const DEFAULT_SSH_USER: &str = "vibecoder";
34+const SSH_CONNECT_RETRIES: usize = 10;
2835const SSH_CONNECT_DELAY_MS: u64 = 500;
2936const 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+}
3092
3193#[derive(Debug, Clone, Serialize, Deserialize)]
32−pub(crate) struct InstanceConfig {
94+pub struct InstanceConfig {
3395 #[serde(default)]
34− id: String,
96+ pub id: String,
3597 #[serde(default = "default_ssh_user")]
36− ssh_user: String,
98+ pub ssh_user: String,
3799 #[serde(default)]
38100 sudo_password: String,
39101 #[serde(default)]
40− last_active: Option<String>,
102+ pub last_active: Option<String>,
41103 #[serde(default)]
42− pub(crate) vm_ipv4: Option<String>,
104+ pub vm_ipv4: Option<String>,
43105}
44106
45−impl InstanceConfig {
46− pub(crate) fn ssh_user_display(&self) -> String {
47− if self.ssh_user.trim().is_empty() {
48− DEFAULT_SSH_USER.to_string()
49− } else {
50− self.ssh_user.clone()
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,
51115 }
52116 }
53117}
54118
55−fn default_ssh_user() -> String {
56− DEFAULT_SSH_USER.to_string()
57−}
58−
59−pub fn run_with_ssh(manager_conn: UnixStream) -> Result<(), Box<dyn std::error::Error>> {
119+pub fn run_with_ssh(manager_conn: UnixStream) -> Result<()> {
60120 let project_root = env::current_dir()?;
61121 tracing::info!(root = %project_root.display(), "starting ssh session");
62122 let instance_dir = ensure_instance_dir(&project_root)?;
63123 tracing::debug!(instance_dir = %instance_dir.display(), "instance dir ready");
64124 let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?;
65125
66− let config = load_or_create_instance_config(&instance_dir)?;
126+ let config = load_or_create_instance_config(&project_root)?;
67127 let ssh_user = config.ssh_user.clone();
68128 tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
69129
70− let _manager_conn = manager_conn;
71− wait_for_vm_ipv4(&instance_dir, Duration::from_secs(120))?;
130+ wait_for_vm_ipv4(&project_root, Duration::from_secs(480), &manager_conn)?;
72131
73− let ip = load_or_create_instance_config(&instance_dir)?
132+ let ip = load_or_create_instance_config(&project_root)?
74133 .vm_ipv4
75− .ok_or("VM IPv4 not available")?;
134+ .with_context(|| "failed to load instance IP address")?;
76135 tracing::info!(ip = %ip, "vm ipv4 ready");
77136
78− run_ssh_session(ssh_key, ssh_user, ip)
137+ run_ssh_session(ssh_key, ssh_user, ip, manager_conn, project_root)
79138}
80139
81140pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
@@ -84,9 +143,7 @@ pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
84143 Ok(instance_dir)
85144}
86145
87−pub(crate) fn ensure_ssh_keypair(
88− instance_dir: &Path,
89−) -> Result<(PathBuf, PathBuf), Box<dyn std::error::Error>> {
146+pub fn ensure_ssh_keypair(instance_dir: &Path) -> Result<(PathBuf, PathBuf)> {
90147 let private_key = instance_dir.join(SSH_KEY_NAME);
91148 let public_key = instance_dir.join(format!("{SSH_KEY_NAME}.pub"));
92149
@@ -108,7 +165,9 @@ pub(crate) fn ensure_ssh_keypair(
108165 "-N",
109166 "",
110167 "-f",
111− 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")?,
112171 "-C",
113172 "vibebox",
114173 ])
@@ -118,7 +177,7 @@ pub(crate) fn ensure_ssh_keypair(
118177 .status()?;
119178
120179 if !status.success() {
121− return Err("ssh-keygen failed".into());
180+ bail!("ssh-keygen failed");
122181 }
123182
124183 fs::set_permissions(&private_key, fs::Permissions::from_mode(0o600))?;
@@ -127,97 +186,66 @@ pub(crate) fn ensure_ssh_keypair(
127186 Ok((private_key, public_key))
128187}
129188
130−pub(crate) fn load_or_create_instance_config(
131− instance_dir: &Path,
132−) -> Result<InstanceConfig, Box<dyn std::error::Error>> {
133− let config_path = instance_dir.join(INSTANCE_FILENAME);
134− let mut config = if config_path.exists() {
135− let raw = fs::read_to_string(&config_path)?;
136− toml::from_str::<InstanceConfig>(&raw)?
137− } else {
138− InstanceConfig {
139− id: String::new(),
140− ssh_user: default_ssh_user(),
141− sudo_password: String::new(),
142− last_active: None,
143− vm_ipv4: None,
144− }
145− };
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+ });
146195
147196 let mut changed = false;
148197 if config.ssh_user.trim().is_empty() {
149− config.ssh_user = default_ssh_user();
198+ config.ssh_user = InstanceConfig::default().ssh_user;
150199 changed = true;
151200 }
152201
153202 if config.id.trim().is_empty() {
154− config.id = Uuid::now_v7().to_string();
203+ config.id = InstanceConfig::default().id;
155204 changed = true;
156205 }
157206
158207 if config.sudo_password.trim().is_empty() {
159− config.sudo_password = generate_password();
208+ config.sudo_password = InstanceConfig::default().sudo_password;
160209 changed = true;
161210 }
162211
163− if !config_path.exists() || changed {
164− write_instance_config(&config_path, &config)?;
212+ if !exist || changed {
213+ write_instance_config(project_dir, &config)?;
165214 }
166215
167216 Ok(config)
168217}
169218
170−fn read_instance_config(
171− instance_dir: &Path,
172−) -> Result<Option<InstanceConfig>, Box<dyn std::error::Error>> {
173− 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);
174222 if !config_path.exists() {
175− return Ok(None);
223+ bail!("instance config file does not exist");
176224 }
177225 let raw = fs::read_to_string(&config_path)?;
178226 let config = toml::from_str::<InstanceConfig>(&raw)?;
179− Ok(Some(config))
180−}
181−
182−pub fn read_instance_vm_ip(
183− instance_dir: &Path,
184−) -> Result<Option<String>, Box<dyn std::error::Error>> {
185− let config = read_instance_config(instance_dir)?;
186− Ok(config.and_then(|cfg| cfg.vm_ipv4))
187−}
188−
189−pub fn read_instance_ssh_user(
190− instance_dir: &Path,
191−) -> Result<Option<String>, Box<dyn std::error::Error>> {
192− let config = read_instance_config(instance_dir)?;
193− Ok(config
194− .map(|cfg| cfg.ssh_user)
195− .filter(|user| !user.trim().is_empty()))
227+ Ok(config)
196228}
197229
198−pub fn touch_last_active(instance_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
199− 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)?;
200232 let now = OffsetDateTime::now_utc().format(&Rfc3339)?;
201233 config.last_active = Some(now);
202− write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
234+ write_instance_config(project_dir, &config)?;
203235 Ok(())
204236}
205237
206−pub(crate) fn write_instance_config(
207− path: &Path,
208− config: &InstanceConfig,
209−) -> 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);
210240 let data = toml::to_string_pretty(config)?;
211− fs::write(path, data)?;
212− 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))?;
213244 Ok(())
214245}
215246
216−fn generate_password() -> String {
217− Uuid::now_v7().simple().to_string()
218−}
219−
220−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> {
221249 let mut current = String::new();
222250 let mut best: Option<String> = None;
223251
@@ -236,25 +264,69 @@ pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
236264 best
237265}
238266
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+
239285fn wait_for_vm_ipv4(
240− instance_dir: &Path,
286+ project_dir: &Path,
241287 timeout: Duration,
242−) -> Result<(), Box<dyn std::error::Error>> {
288+ manager_conn: &UnixStream,
289+) -> Result<()> {
243290 let start = Instant::now();
244291 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();
245296 tracing::info!("waiting for vm ipv4");
246− let status_path = instance_dir.join(STATUS_FILE_NAME);
247297 let mut last_status: Option<String> = None;
248298 let mut once_hint = false;
249299 loop {
250− 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)?;
251321 if config.vm_ipv4.is_some() {
252322 return Ok(());
253323 }
254324 if start.elapsed() > timeout {
255− return Err("Timed out waiting for VM IPv4".into());
325+ bail!("timed out waiting for VM IPv4");
256326 }
257− if Instant::now() >= next_log_at {
327+
328+ let now = Instant::now();
329+ if now >= next_log_at {
258330 let waited = start.elapsed();
259331 if waited.as_secs() > 15 && !once_hint {
260332 tracing::info!(
@@ -262,18 +334,9 @@ fn wait_for_vm_ipv4(
262334 );
263335 once_hint = true;
264336 }
265− if let Ok(status) = fs::read_to_string(&status_path) {
266− let status = status.trim().to_string();
267− if !status.is_empty() && last_status.as_deref() != Some(status.as_str()) {
268− tracing::info!("[background]: {}", status);
269− last_status = Some(status);
270− }
271− } else {
272− tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
273− }
274− next_log_at += Duration::from_secs(10);
337+ tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
338+ next_log_at += Duration::from_secs(20);
275339 }
276− thread::sleep(Duration::from_millis(200));
277340 }
278341}
279342
@@ -281,9 +344,14 @@ fn run_ssh_session(
281344 ssh_key: PathBuf,
282345 ssh_user: String,
283346 ip: String,
284−) -> Result<(), Box<dyn std::error::Error>> {
347+ manager_conn: UnixStream,
348+ project_root: PathBuf,
349+) -> Result<()> {
285350 let mut attempts = 0usize;
286351 loop {
352+ if matches!(vm_liveness(&project_root)?, VmLiveness::NotRunningOrMissing) {
353+ return Err(InstanceError::UnexpectedDisconnection.into());
354+ }
287355 attempts += 1;
288356 if !ssh_port_open(&ip) {
289357 tracing::debug!(attempts, "ssh port doesn't open yet");
@@ -295,9 +363,7 @@ fn run_ssh_session(
295363 SSH_CONNECT_RETRIES
296364 );
297365 if attempts >= SSH_CONNECT_RETRIES {
298− return Err(
299− format!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts").into(),
300− );
366+ bail!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts");
301367 }
302368 thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS));
303369 continue;
@@ -311,10 +377,10 @@ fn run_ssh_session(
311377 attempts,
312378 SSH_CONNECT_RETRIES
313379 );
314− let status = Command::new("ssh")
380+ let child = Command::new("ssh")
315381 .args([
316382 "-i",
317− ssh_key.to_str().unwrap_or(".vibebox/ssh_key"),
383+ ssh_key.to_str().with_context(|| "invalid path")?,
318384 "-o",
319385 "IdentitiesOnly=yes",
320386 "-o",
@@ -339,27 +405,67 @@ fn run_ssh_session(
339405 .stdin(Stdio::inherit())
340406 .stdout(Stdio::inherit())
341407 .stderr(Stdio::inherit())
342− .status();
343−
344− match status {
345− Ok(status) if status.success() => {
346− tracing::info!(status = %status, "ssh exited");
347− break;
348− }
349− Ok(status) if status.code() == Some(255) => {
350− tracing::warn!(status = %status, "ssh connection failed");
351− if attempts >= SSH_CONNECT_RETRIES {
352− return Err(format!("ssh failed after {SSH_CONNECT_RETRIES} attempts").into());
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+ };
450+
451+ if status.success() {
452+ tracing::info!(status = %status, "ssh exited");
453+ break;
454+ }
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;
353462 }
354− thread::sleep(Duration::from_millis(500));
355− }
356− Ok(status) => {
357463 tracing::info!(status = %status, "ssh exited");
358464 break;
359465 }
360466 Err(err) => {
361467 tracing::error!(error = %err, "failed to start ssh");
362− return Err(format!("failed to start ssh: {err}").into());
468+ bail!("failed to start ssh: {err}");
363469 }
364470 }
365471 }
@@ -367,6 +473,31 @@ fn run_ssh_session(
367473 Ok(())
368474}
369475
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))]
370501fn is_ipv4_candidate(candidate: &str) -> bool {
371502 let parts: Vec<&str> = candidate.split('.').collect();
372503 if parts.len() != 4 {
@@ -388,10 +519,10 @@ fn ssh_port_open(ip: &str) -> bool {
388519 Ok(addr) => addr,
389520 Err(_) => return false,
390521 };
391− TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).is_ok()
522+ TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok()
392523}
393524
394−pub(crate) fn build_ssh_login_actions(
525+pub fn build_ssh_login_actions(
395526 config: &Arc<Mutex<InstanceConfig>>,
396527 project_name: &str,
397528 project_guest_dir: &str,
@@ -406,7 +537,7 @@ pub(crate) fn build_ssh_login_actions(
406537
407538 let key_path = format!("{guest_dir}/{key_name}.pub");
408539
409− let setup_script = SSH_SETUP_SCRIPT
540+ let ssh_script = SSH_SETUP_SCRIPT
410541 .replace("__SSH_USER__", &ssh_user)
411542 .replace("__SUDO_PASSWORD__", &sudo_password)
412543 .replace("__PROJECT_NAME__", project_name)
@@ -414,8 +545,36 @@ pub(crate) fn build_ssh_login_actions(
414545 .replace("__KEY_PATH__", &key_path)
415546 .replace("__VIBEBOX_SHELL_SCRIPT__", &commands::render_shell_script())
416547 .replace("__VIBEBOX_HOME_LINKS__", home_links_script);
417− let setup = vm::script_command_from_content("ssh_setup", &setup_script)
548+ let setup = vm::script_command_from_content("ssh.sh", &ssh_script)
418549 .expect("ssh setup script contained invalid marker");
419550
420551 vec![LoginAction::Send(setup)]
421552}
553+
554+#[cfg(test)]
555+mod tests {
556+ use super::*;
557+
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+ }
565+
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+ }
572+
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+ }
580+}
src/lib.rs+1
@@ -8,3 +8,4 @@ pub mod vm_manager;
88
99pub use session_manager::{SessionError, SessionManager, SessionRecord};
1010pub mod config;
11+pub mod utils;
src/provision.sh+49−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 \
@@ -55,4 +98,5 @@ sleep 100 # sleep here so that we don't see the login screen flash up before the
5598EOF
5699
57100# Done provisioning, power off the VM
101+printf "%s%s\n" VIBEBOX_PROVISION_ OK
58102systemctl poweroff
src/session_manager.rs+56−93
@@ -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,7 +101,6 @@ impl SessionManager {
110101 } else {
111102 tracing::warn!(
112103 directory = %directory.display(),
113− file = INSTANCE_FILENAME,
114104 "missing session id in instance file"
115105 );
116106 }
@@ -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 }
@@ -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− && id.trim().is_empty()
332− {
333− meta.id = None;
334− }
335− if let Some(last_active) = &meta.last_active
336− && last_active.trim().is_empty()
337− {
338− meta.last_active = None;
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+18−10
@@ -2,6 +2,7 @@
22set -eu
33
44SSH_USER="__SSH_USER__"
5+SUDO_PASSWORD="__SUDO_PASSWORD__"
56PROJECT_NAME="__PROJECT_NAME__"
67PROJECT_GUEST_DIR="__PROJECT_GUEST_DIR__"
78KEY_PATH="__KEY_PATH__"
@@ -61,6 +62,10 @@ if ! id -u "$SSH_USER" >/dev/null 2>&1; then
6162 usermod -aG sudo "$SSH_USER" || true
6263fi
6364
65+if [ -n "$SUDO_PASSWORD" ]; then
66+ echo "${SSH_USER}:${SUDO_PASSWORD}" | chpasswd
67+fi
68+
6469install -d -m 700 -o "$SSH_USER" -g "$SSH_USER" "/home/${SSH_USER}/.ssh"
6570install -m 600 -o "$SSH_USER" -g "$SSH_USER" "$KEY_PATH" "/home/${SSH_USER}/.ssh/authorized_keys"
6671
@@ -109,8 +114,8 @@ mise_ok() { command -v mise >/dev/null 2>&1 || [ -x "$MISE_BIN" ]; }
109114mise_install() {
110115 if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
111116 if ! curl https://mise.run | HOME="$USER_HOME" sh; then
112− mise_warn "mise install script failed (continuing)"
113− return 0
117+ mise_warn "mise install script failed"
118+ return 1
114119 fi
115120 fi
116121 echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
@@ -136,18 +141,21 @@ MISE
136141 touch "${USER_HOME}/.config/mise/mise.lock"
137142 if [ -x "$MISE_BIN" ]; then
138143 if ! HOME="$USER_HOME" "$MISE_BIN" install; then
139− mise_warn "mise install failed (continuing)"
140− return 0
144+ mise_warn "mise install failed"
145+ return 1
141146 fi
142147 else
143148 if ! HOME="$USER_HOME" mise install; then
144− mise_warn "mise install failed (continuing)"
145− return 0
149+ mise_warn "mise install failed"
150+ return 1
146151 fi
147152 fi
148153}
149154
150−mise_install || true
155+if ! mise_install; then
156+ diag "mise installation failed"
157+ vibebox_fail "mise installation failed" 1
158+fi
151159
152160# 3) start ssh (don't swallow failures)
153161# If ssh is already active, don't force start/restart.
@@ -155,7 +163,7 @@ if ! systemctl is-active --quiet ssh; then
155163 if ! systemctl start ssh; then
156164 diag "systemctl start ssh failed"
157165 dump_diag
158− exit 1
166+ vibebox_fail "failed to start ssh service" 1
159167 fi
160168fi
161169
@@ -193,7 +201,7 @@ done
193201if [ -z "$dev" ] || [ -z "$ip" ]; then
194202 diag "no stable IPv4 on default route interface"
195203 dump_diag
196− exit 1
204+ vibebox_fail "no stable ipv4 route on default interface" 1
197205fi
198206
199207# 5) strong verify: ssh must listen externally (0.0.0.0:22 or $ip:22 or [::]:22)
@@ -212,7 +220,7 @@ done
212220if ! listens_ok; then
213221 diag "sshd not listening on 0.0.0.0:22 / ${ip}:22"
214222 dump_diag
215− exit 1
223+ vibebox_fail "sshd is not listening on the expected address" 1
216224fi
217225
218226ip a
src/tui.rs+31−31
@@ -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,6 +16,12 @@ 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
@@ -39,9 +39,9 @@ const INFO_LINE_COUNT: u16 = 5;
3939
4040#[derive(Debug, Clone)]
4141pub struct VmInfo {
42− pub max_memory_mb: u64,
42+ pub max_memory: ByteSize,
4343 pub cpu_cores: usize,
44− pub max_disk_gb: f32,
44+ pub max_disk: ByteSize,
4545 pub system_name: String,
4646 pub auto_shutdown_ms: u64,
4747}
@@ -474,27 +474,27 @@ fn write_buffer_with_style(buffer: &Buffer, out: &mut impl Write) -> io::Result<
474474 Ok(())
475475}
476476
477−fn map_color(color: ratatui::style::Color) -> CrosstermColor {
477+fn map_color(color: Color) -> CrosstermColor {
478478 match color {
479− ratatui::style::Color::Reset => CrosstermColor::Reset,
480− ratatui::style::Color::Black => CrosstermColor::Black,
481− ratatui::style::Color::Red => CrosstermColor::DarkRed,
482− ratatui::style::Color::Green => CrosstermColor::DarkGreen,
483− ratatui::style::Color::Yellow => CrosstermColor::DarkYellow,
484− ratatui::style::Color::Blue => CrosstermColor::DarkBlue,
485− ratatui::style::Color::Magenta => CrosstermColor::DarkMagenta,
486− ratatui::style::Color::Cyan => CrosstermColor::DarkCyan,
487− ratatui::style::Color::Gray => CrosstermColor::Grey,
488− ratatui::style::Color::DarkGray => CrosstermColor::DarkGrey,
489− ratatui::style::Color::LightRed => CrosstermColor::Red,
490− ratatui::style::Color::LightGreen => CrosstermColor::Green,
491− ratatui::style::Color::LightYellow => CrosstermColor::Yellow,
492− ratatui::style::Color::LightBlue => CrosstermColor::Blue,
493− ratatui::style::Color::LightMagenta => CrosstermColor::Magenta,
494− ratatui::style::Color::LightCyan => CrosstermColor::Cyan,
495− ratatui::style::Color::White => CrosstermColor::White,
496− ratatui::style::Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
497− 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),
498498 }
499499}
500500
@@ -574,8 +574,8 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
574574 Span::raw("CPU / Memory / Disk: "),
575575 Span::styled(
576576 format!(
577− "{} cores / {} MB / {} GB",
578− app.vm_info.cpu_cores, app.vm_info.max_memory_mb, app.vm_info.max_disk_gb
577+ "{} cores / {} / {}",
578+ app.vm_info.cpu_cores, app.vm_info.max_memory, app.vm_info.max_disk
579579 ),
580580 Style::default().fg(Color::Green),
581581 ),
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+279−167
@@ -1,5 +1,5 @@
1−use crate::instance::STATUS_FILE_NAME;
21use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
2+use anyhow::{Context, Error, Result, bail};
33use std::{
44 env, fs,
55 io::{self, Write},
@@ -23,6 +23,7 @@ use std::{
2323};
2424
2525use block2::RcBlock;
26+use bytesize::ByteSize;
2627use dispatch2::DispatchQueue;
2728use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
2829use objc2_foundation::*;
@@ -34,74 +35,44 @@ const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
3435const SHARED_DIRECTORIES_TAG: &str = "shared";
3536pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts";
3637
37−const BYTES_PER_MB: u64 = 1024 * 1024;
38−const DEFAULT_CPU_COUNT: usize = 2;
39−const DEFAULT_RAM_MB: u64 = 2048;
40−const DEFAULT_RAM_BYTES: u64 = DEFAULT_RAM_MB * BYTES_PER_MB;
4138const START_TIMEOUT: Duration = Duration::from_secs(60);
4239const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
40+const PROVISION_EXPECT_TIMEOUT: Duration = Duration::from_secs(900);
4341
44−struct StatusFile {
45− path: PathBuf,
46− cleared: AtomicBool,
47−}
48−
49−impl StatusFile {
50− fn new(path: PathBuf) -> Self {
51− Self {
52− path,
53− cleared: AtomicBool::new(false),
54− }
55− }
56−
57− fn update(&self, message: &str) {
58− let _ = fs::write(&self.path, message);
59− }
60−
61− fn clear(&self) {
62− if !self.cleared.swap(true, Ordering::SeqCst) {
63− let _ = fs::remove_file(&self.path);
64− }
65− }
66−}
67−
68−impl Drop for StatusFile {
69− fn drop(&mut self) {
70− if !self.cleared.load(Ordering::SeqCst) {
71− let _ = fs::remove_file(&self.path);
72− self.cleared.store(true, Ordering::SeqCst);
73− }
74− }
75−}
42+const ERROR_REPORT_SCRIPT: &str = include_str!("error_report.sh");
7643const PROVISION_SCRIPT: &str = include_str!("provision.sh");
77−const PROVISION_SCRIPT_NAME: &str = "provision.sh";
7844const RESIZE_DISK_SCRIPT: &str = include_str!("resize_disk.sh");
7945const DEFAULT_RAW_NAME: &str = "default.raw";
8046const INSTANCE_RAW_NAME: &str = "instance.raw";
8147const BASE_DISK_RAW_NAME: &str = "disk.raw";
8248
8349#[derive(Clone)]
84−pub(crate) enum LoginAction {
85− 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+ },
8660 Send(String),
8761}
62+use crate::config::BoxConfig;
8863use LoginAction::*;
8964
9065#[derive(Clone)]
91−pub(crate) struct DirectoryShare {
66+pub struct DirectoryShare {
9267 host: PathBuf,
9368 guest: PathBuf,
9469 read_only: bool,
9570}
9671
9772impl DirectoryShare {
98− pub(crate) fn new(
99− host: PathBuf,
100− mut guest: PathBuf,
101− read_only: bool,
102− ) -> Result<Self, Box<dyn std::error::Error>> {
73+ pub fn new(host: PathBuf, mut guest: PathBuf, read_only: bool) -> Result<Self> {
10374 if !host.exists() {
104− return Err(format!("Host path does not exist: {}", host.display()).into());
75+ bail!(format!("host path does not exist: {}", host.display()));
10576 }
10677 if !guest.is_absolute() {
10778 guest = PathBuf::from("/root").join(guest);
@@ -113,10 +84,10 @@ impl DirectoryShare {
11384 })
11485 }
11586
116− 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> {
11788 let parts: Vec<&str> = spec.split(':').collect();
11889 if parts.len() < 2 || parts.len() > 3 {
119− return Err(format!("Invalid mount spec: {spec}").into());
90+ bail!(format!("invalid mount spec: {spec}"));
12091 }
12192 let host = expand_tilde_path(parts[0]);
12293 let guest = PathBuf::from(parts[1]);
@@ -125,11 +96,10 @@ impl DirectoryShare {
12596 "read-only" => true,
12697 "read-write" => false,
12798 _ => {
128− return Err(format!(
99+ bail!(format!(
129100 "Invalid mount mode '{}'; expected read-only or read-write",
130101 parts[2]
131− )
132− .into());
102+ ));
133103 }
134104 }
135105 } else {
@@ -173,19 +143,28 @@ pub struct VmArg {
173143 pub mounts: Vec<String>,
174144}
175145
176−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<()>
177155where
178156 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
179157{
180− 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)
181159}
182160
183−pub(crate) fn run_with_args_and_extras<F>(
161+pub fn run_with_args_and_extras<F>(
184162 args: VmArg,
185163 io_handler: F,
186164 extra_login_actions: Vec<LoginAction>,
187165 extra_directory_shares: Vec<DirectoryShare>,
188−) -> Result<(), Box<dyn std::error::Error>>
166+ status: Option<&StatusEmitter<'_>>,
167+) -> Result<()>
189168where
190169 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
191170{
@@ -194,7 +173,7 @@ where
194173 let project_root = env::current_dir()?;
195174 let project_name = project_root
196175 .file_name()
197− .ok_or("Project directory has no name")?
176+ .with_context(|| "Project directory has no name")?
198177 .to_string_lossy()
199178 .into_owned();
200179
@@ -206,8 +185,10 @@ where
206185 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
207186
208187 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
209− let status_file = StatusFile::new(instance_dir.join(STATUS_FILE_NAME));
210− status_file.update("preparing VM image...");
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");
211192
212193 let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
213194 let base_compressed = cache_dir.join(basename_compressed);
@@ -231,13 +212,14 @@ where
231212 &base_compressed,
232213 &default_raw,
233214 std::slice::from_ref(&mise_directory_share),
234− Some(&status_file),
215+ Some(&provision_log),
216+ status,
235217 )?;
236218 let _ = ensure_instance_disk(
237219 &instance_raw,
238220 &default_raw,
239− args.disk_bytes,
240− Some(&status_file),
221+ ByteSize(args.disk_bytes),
222+ status,
241223 )?;
242224 let base_size = fs::metadata(&default_raw)?.len();
243225 let instance_size = fs::metadata(&instance_raw)?.len();
@@ -267,7 +249,7 @@ where
267249 }
268250
269251 if needs_resize {
270− let resize_cmd = script_command_from_content("resize_disk", RESIZE_DISK_SCRIPT)?;
252+ let resize_cmd = script_command_from_content("resize_disk.sh", RESIZE_DISK_SCRIPT)?;
271253 login_actions.push(Send(resize_cmd));
272254 }
273255
@@ -283,25 +265,29 @@ where
283265 &directory_shares[..],
284266 args.cpu_count,
285267 args.ram_bytes,
286− Some(&status_file),
268+ status,
287269 io_handler,
288270 )
289271}
290272
291−pub(crate) fn script_command_from_content(
292− label: &str,
293− script: &str,
294−) -> Result<String, Box<dyn std::error::Error>> {
273+pub fn script_command_from_content(label: &str, script: &str) -> Result<String> {
295274 let marker = "VIBE_SCRIPT_EOF";
296275 let guest_dir = "/tmp/vibe-scripts";
297276 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);
298284 let command = format!(
299− "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}"
300286 );
301287 if script.contains(marker) {
302− return Err(
303− format!("Script '{label}' contains marker '{marker}', cannot safely upload").into(),
304− );
288+ bail!(format!(
289+ "Script '{label}' contains marker '{marker}', cannot safely upload"
290+ ));
305291 }
306292 Ok(command)
307293}
@@ -365,6 +351,12 @@ enum WaitResult {
365351 Found,
366352}
367353
354+#[derive(PartialEq, Eq)]
355+enum WaitAnyResult {
356+ Timeout,
357+ Found(usize),
358+}
359+
368360pub enum VmInput {
369361 Bytes(Vec<u8>),
370362 Shutdown,
@@ -372,6 +364,7 @@ pub enum VmInput {
372364
373365enum VmOutput {
374366 LoginActionTimeout { action: String, timeout: Duration },
367+ LoginActionFailed { action: String, reason: String },
375368}
376369
377370#[derive(Default)]
@@ -408,6 +401,41 @@ impl OutputMonitor {
408401 WaitResult::Found
409402 }
410403 }
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
411439}
412440
413441#[derive(Debug)]
@@ -456,18 +484,16 @@ impl IoControl {
456484fn ensure_base_image(
457485 base_raw: &Path,
458486 base_compressed: &Path,
459− status: Option<&StatusFile>,
460−) -> Result<(), Box<dyn std::error::Error>> {
487+ status: Option<&StatusEmitter<'_>>,
488+) -> Result<()> {
461489 if base_raw.exists() {
462490 return Ok(());
463491 }
464492
465493 if !base_compressed.exists()
466− || 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
467495 {
468− if let Some(status) = status {
469− status.update("downloading base image...");
470− }
496+ emit_status(status, "downloading base image...");
471497 tracing::info!("downloading base image");
472498 let status = Command::new("curl")
473499 .args([
@@ -482,15 +508,14 @@ fn ensure_base_image(
482508 ])
483509 .status()?;
484510 if !status.success() {
485− return Err("Failed to download base image".into());
511+ bail!("failed to download base image");
486512 }
487513 }
488514
489515 // Check SHA
490516 {
491− if let Some(status) = status {
492− status.update("verifying base image...");
493− }
517+ emit_status(status, "verifying base image...");
518+ tracing::info!("verifying base image...");
494519 let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
495520
496521 let mut child = Command::new("/usr/bin/shasum")
@@ -508,25 +533,25 @@ fn ensure_base_image(
508533
509534 let status = child.wait().expect("failed to wait on child");
510535 if !status.success() {
511− 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+ ));
512539 }
513540 }
514541
515− if let Some(status) = status {
516− status.update("decompressing base image...");
517− }
518− tracing::info!("decompressing base image");
542+ emit_status(status, "decompressing base image...");
543+ tracing::info!("decompressing base image...");
519544 let status = Command::new("tar")
520545 .args([
521546 "-xOf",
522547 &base_compressed.to_string_lossy(),
523548 BASE_DISK_RAW_NAME,
524549 ])
525− .stdout(std::fs::File::create(base_raw)?)
550+ .stdout(fs::File::create(base_raw)?)
526551 .status()?;
527552
528553 if !status.success() {
529− return Err("Failed to decompress base image".into());
554+ bail!("Failed to decompress base image");
530555 }
531556
532557 Ok(())
@@ -537,29 +562,56 @@ fn ensure_default_image(
537562 base_compressed: &Path,
538563 default_raw: &Path,
539564 directory_shares: &[DirectoryShare],
540− status: Option<&StatusFile>,
541−) -> Result<(), Box<dyn std::error::Error>> {
565+ provision_log: Option<&Path>,
566+ status: Option<&StatusEmitter<'_>>,
567+) -> Result<()> {
542568 if default_raw.exists() {
543569 return Ok(());
544570 }
545571
546572 ensure_base_image(base_raw, base_compressed, status)?;
547573
548− if let Some(status) = status {
549− status.update("configuring base image...");
550− }
551− tracing::info!("configuring base image");
574+ emit_status(status, "configuring base image...");
575+ tracing::info!("configuring base image...");
552576 fs::copy(base_raw, default_raw)?;
553577
554− let provision_command = script_command_from_content(PROVISION_SCRIPT_NAME, PROVISION_SCRIPT)?;
555− run_vm(
556− default_raw,
557− &[Send(provision_command)],
558− directory_shares,
559− DEFAULT_CPU_COUNT,
560− DEFAULT_RAM_BYTES,
561− None,
562− )?;
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+ }
563615
564616 Ok(())
565617}
@@ -567,18 +619,16 @@ fn ensure_default_image(
567619fn ensure_instance_disk(
568620 instance_raw: &Path,
569621 template_raw: &Path,
570− target_bytes: u64,
571− status: Option<&StatusFile>,
572−) -> Result<bool, Box<dyn std::error::Error>> {
622+ target_bytes: ByteSize,
623+ status: Option<&StatusEmitter<'_>>,
624+) -> Result<bool> {
573625 if instance_raw.exists() {
574− let current_size = fs::metadata(instance_raw)?.len();
626+ let current_size = ByteSize(fs::metadata(instance_raw)?.len());
575627 if current_size != target_bytes {
576− let current_gb = current_size as f64 / (1024.0 * 1024.0 * 1024.0);
577− let target_gb = target_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
628+ let current_gb = current_size;
629+ let target_gb = target_bytes;
578630 tracing::warn!(
579− current_bytes = current_size,
580− target_bytes,
581− "instance disk size does not match config (current {:.2} GB, config {:.2} GB); disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using existing disk.",
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.",
582632 current_gb,
583633 target_gb
584634 );
@@ -586,30 +636,28 @@ fn ensure_instance_disk(
586636 return Ok(false);
587637 }
588638
589− let template_size = fs::metadata(template_raw)?.len();
639+ let template_size = ByteSize(fs::metadata(template_raw)?.len());
590640 if target_bytes < template_size {
591− return Err(format!(
592− "Requested disk size {} bytes is smaller than base image size {} bytes",
641+ bail!(format!(
642+ "Requested disk size {} is smaller than base image size {}",
593643 target_bytes, template_size
594− )
595− .into());
644+ ));
596645 }
597646 let target_size = target_bytes;
598647 let needs_resize = target_size > template_size;
599648
600− if let Some(status) = status {
601− status.update("creating instance disk...");
602− }
649+ emit_status(status, "creating instance disk...");
650+ tracing::info!("creating instance disk...");
603651 tracing::info!(path = %template_raw.display(), "creating instance disk");
604− std::fs::create_dir_all(instance_raw.parent().unwrap())?;
652+ fs::create_dir_all(instance_raw.parent().unwrap())?;
605653 if target_size == template_size {
606654 fs::copy(template_raw, instance_raw)?;
607655 return Ok(needs_resize);
608656 }
609657
610− let mut dst = std::fs::File::create(instance_raw)?;
611− dst.set_len(target_size)?;
612− let mut src = std::fs::File::open(template_raw)?;
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)?;
613661 std::io::copy(&mut src, &mut dst)?;
614662 Ok(needs_resize)
615663}
@@ -627,18 +675,17 @@ pub fn create_pipe() -> (OwnedFd, OwnedFd) {
627675 (read_stream.into(), write_stream.into())
628676}
629677
630−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+>(
631682 output_monitor: Arc<OutputMonitor>,
632683 vm_output_fd: OwnedFd,
633684 vm_input_fd: OwnedFd,
634685 io_control: Arc<IoControl>,
635686 mut on_line: F,
636687 mut on_output: G,
637−) -> IoContext
638−where
639− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
640− G: FnMut(&[u8]) + ::std::marker::Send + 'static,
641−{
688+) -> IoContext {
642689 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
643690
644691 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
@@ -786,14 +833,14 @@ where
786833 PollResult::Spurious => continue,
787834 PollResult::Ready(bytes) => {
788835 if io_control.forward_output() {
789− // enable raw mode, if we haven't already
836+ // enable raw mode if we haven't already
790837 if raw_guard.lock().unwrap().is_none()
791838 && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
792839 {
793840 *raw_guard.lock().unwrap() = Some(guard);
794841 }
795842
796− let mut stdout = std::io::stdout().lock();
843+ let mut stdout = io::stdout().lock();
797844 if stdout.write_all(bytes).is_err() {
798845 break;
799846 }
@@ -809,7 +856,7 @@ where
809856
810857 // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
811858 let mux_thread = thread::spawn(move || {
812− let mut vm_writer = std::fs::File::from(vm_input_fd);
859+ let mut vm_writer = fs::File::from(vm_input_fd);
813860 loop {
814861 match input_rx.recv() {
815862 Ok(VmInput::Bytes(data)) => {
@@ -839,7 +886,7 @@ pub fn spawn_vm_io_with_line_handler<F>(
839886 on_line: F,
840887) -> IoContext
841888where
842− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
889+ F: FnMut(&str) -> bool + std::marker::Send + 'static,
843890{
844891 spawn_vm_io_with_hooks(
845892 output_monitor,
@@ -859,6 +906,36 @@ pub fn spawn_vm_io(
859906 spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
860907}
861908
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+
862939impl IoContext {
863940 pub fn shutdown(self) {
864941 let _ = self.input_tx.send(VmInput::Shutdown);
@@ -876,7 +953,7 @@ fn create_vm_configuration(
876953 vm_writes_to_fd: OwnedFd,
877954 cpu_count: usize,
878955 ram_bytes: u64,
879−) -> Result<Retained<VZVirtualMachineConfiguration>, Box<dyn std::error::Error>> {
956+) -> Result<Retained<VZVirtualMachineConfiguration>> {
880957 unsafe {
881958 let platform =
882959 VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
@@ -910,7 +987,7 @@ fn create_vm_configuration(
910987 false,
911988 VZDiskImageCachingMode::Automatic,
912989 VZDiskImageSynchronizationMode::Full,
913− ).unwrap();
990+ )?;
914991
915992 let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
916993 VZVirtioBlockDeviceConfiguration::alloc(),
@@ -997,7 +1074,7 @@ fn create_vm_configuration(
9971074 // Validate
9981075 config.validateWithError().map_err(|e| {
9991076 io::Error::other(format!(
1000− "Invalid VM configuration: {:?}",
1077+ "invalid VM configuration: {:?}",
10011078 e.localizedDescription()
10021079 ))
10031080 })?;
@@ -1006,9 +1083,9 @@ fn create_vm_configuration(
10061083 }
10071084}
10081085
1009−fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std::error::Error>> {
1086+fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>> {
10101087 unsafe {
1011− let temp_dir = std::env::temp_dir();
1088+ let temp_dir = env::temp_dir();
10121089 let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
10131090 let url = nsurl_from_path(&temp_path)?;
10141091 let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
@@ -1024,8 +1101,8 @@ fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std
10241101fn spawn_login_actions_thread(
10251102 login_actions: Vec<LoginAction>,
10261103 output_monitor: Arc<OutputMonitor>,
1027− input_tx: mpsc::Sender<VmInput>,
1028− vm_output_tx: mpsc::Sender<VmOutput>,
1104+ input_tx: Sender<VmInput>,
1105+ vm_output_tx: Sender<VmOutput>,
10291106) -> thread::JoinHandle<()> {
10301107 thread::spawn(move || {
10311108 for a in login_actions {
@@ -1039,6 +1116,27 @@ fn spawn_login_actions_thread(
10391116 return;
10401117 }
10411118 }
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+ },
10421140 Send(mut text) => {
10431141 text.push('\n'); // Type the newline so the command is actually submitted.
10441142 input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
@@ -1054,9 +1152,9 @@ fn run_vm_with_io<F>(
10541152 directory_shares: &[DirectoryShare],
10551153 cpu_count: usize,
10561154 ram_bytes: u64,
1057− status: Option<&StatusFile>,
1155+ status: Option<&StatusEmitter<'_>>,
10581156 io_handler: F,
1059−) -> Result<(), Box<dyn std::error::Error>>
1157+) -> Result<()>
10601158where
10611159 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
10621160{
@@ -1103,24 +1201,22 @@ where
11031201
11041202 match rx.try_recv() {
11051203 Ok(result) => {
1106− result.map_err(|e| format!("Failed to start VM: {}", e))?;
1204+ result.map_err(|e| Error::msg(format!("Failed to start VM: {}", e)))?;
11071205 break;
11081206 }
11091207 Err(mpsc::TryRecvError::Empty) => continue,
11101208 Err(mpsc::TryRecvError::Disconnected) => {
1111− return Err("VM start channel disconnected".into());
1209+ bail!("VM start channel disconnected");
11121210 }
11131211 }
11141212 }
11151213
11161214 if Instant::now() >= start_deadline {
1117− return Err("Timed out waiting for VM to start".into());
1215+ bail!("Timed out waiting for VM to start");
11181216 }
11191217
1120− if let Some(status) = status {
1121− status.update("vm booting... go vibecoder!");
1122− status.clear();
1123− }
1218+ emit_status(status, "vm booting... go vibecoder!");
1219+ tracing::info!("vm booting... go vibecoder!");
11241220 tracing::info!("vm booting");
11251221
11261222 let output_monitor = Arc::new(OutputMonitor::default());
@@ -1166,7 +1262,7 @@ where
11661262 );
11671263
11681264 let mut last_state = None;
1169− let mut exit_result = Ok(());
1265+ let mut exit_result: Result<(), String> = Ok(());
11701266 loop {
11711267 unsafe {
11721268 NSRunLoop::mainRunLoop().runMode_beforeDate(
@@ -1185,8 +1281,24 @@ where
11851281 exit_result = Err(format!(
11861282 "Login action ({}) timed out after {:?}; shutting down.",
11871283 action, timeout
1188− )
1189− .into());
1284+ ));
1285+ unsafe {
1286+ if vm.canRequestStop() {
1287+ if let Err(err) = vm.requestStopWithError() {
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+ ));
11901302 unsafe {
11911303 if vm.canRequestStop() {
11921304 if let Err(err) = vm.requestStopWithError() {
@@ -1202,7 +1314,7 @@ where
12021314 Err(mpsc::TryRecvError::Empty) => {}
12031315 Err(mpsc::TryRecvError::Disconnected) => {}
12041316 }
1205− if state != objc2_virtualization::VZVirtualMachineState::Running {
1317+ if state != VZVirtualMachineState::Running {
12061318 //eprintln!("VM stopped with state: {:?}", state);
12071319 break;
12081320 }
@@ -1212,7 +1324,7 @@ where
12121324
12131325 io_ctx.shutdown();
12141326
1215− exit_result
1327+ exit_result.map_err(Error::msg)
12161328}
12171329
12181330fn run_vm(
@@ -1221,8 +1333,8 @@ fn run_vm(
12211333 directory_shares: &[DirectoryShare],
12221334 cpu_count: usize,
12231335 ram_bytes: u64,
1224− status: Option<&StatusFile>,
1225−) -> Result<(), Box<dyn std::error::Error>> {
1336+ status: Option<&StatusEmitter<'_>>,
1337+) -> Result<()> {
12261338 run_vm_with_io(
12271339 disk_path,
12281340 login_actions,
@@ -1234,7 +1346,7 @@ fn run_vm(
12341346 )
12351347}
12361348
1237−fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
1349+fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>> {
12381350 let abs_path = if path.is_absolute() {
12391351 path.to_path_buf()
12401352 } else {
@@ -1243,7 +1355,7 @@ fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::E
12431355 let ns_path = NSString::from_str(
12441356 abs_path
12451357 .to_str()
1246− .ok_or("Non-UTF8 path encountered while building NSURL")?,
1358+ .with_context(|| "non-UTF8 path encountered while building NSURL")?,
12471359 );
12481360 Ok(NSURL::fileURLWithPath(&ns_path))
12491361}
@@ -1258,7 +1370,7 @@ fn enable_raw_mode(fd: i32) -> io::Result<RawModeGuard> {
12581370 let original = attributes;
12591371
12601372 // Disable translation of carriage return to newline on input
1261− attributes.c_iflag &= !(libc::ICRNL);
1373+ attributes.c_iflag &= !libc::ICRNL;
12621374 // Disable canonical mode (line buffering), echo, and signal generation
12631375 attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
12641376 attributes.c_cc[libc::VMIN] = 0;
@@ -1286,10 +1398,10 @@ impl Drop for RawModeGuard {
12861398
12871399// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
12881400pub fn ensure_signed() {
1289− if std::env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
1401+ if env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
12901402 return;
12911403 }
1292− 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");
12931405 let exe_str = exe.to_str().expect("exe path not valid utf-8");
12941406
12951407 let has_required_entitlements = {
@@ -1311,8 +1423,8 @@ pub fn ensure_signed() {
13111423 }
13121424
13131425 const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1314− let entitlements_path = std::env::temp_dir().join("entitlements.plist");
1315− 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");
13161428
13171429 let output = Command::new("codesign")
13181430 .args([
@@ -1325,7 +1437,7 @@ pub fn ensure_signed() {
13251437 ])
13261438 .output();
13271439
1328− let _ = std::fs::remove_file(&entitlements_path);
1440+ let _ = fs::remove_file(&entitlements_path);
13291441
13301442 match output {
13311443 Ok(o) if o.status.success() => {
@@ -1333,7 +1445,7 @@ pub fn ensure_signed() {
13331445 if !stderr.trim().is_empty() {
13341446 tracing::debug!(codesign_stderr = %stderr.trim(), "codesign output");
13351447 }
1336− let err = Command::new(&exe).args(std::env::args_os().skip(1)).exec();
1448+ let err = Command::new(&exe).args(env::args_os().skip(1)).exec();
13371449 tracing::error!(error = %err, "failed to re-exec after signing");
13381450 std::process::exit(1);
13391451 }
src/vm_manager.rs+384−126
@@ -1,12 +1,22 @@
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},
416 os::unix::{
5− fs::FileTypeExt,
617 fs::PermissionsExt,
718 io::AsRawFd,
819 net::{UnixListener, UnixStream},
9− process::CommandExt,
1020 },
1121 path::{Path, PathBuf},
1222 process::{Command, Stdio},
@@ -15,36 +25,44 @@ use std::{
1525 time::{Duration, Instant},
1626};
1727
18−use crate::{
19− config::CONFIG_PATH_ENV,
20− instance::VM_ROOT_LOG_NAME,
21− instance::{
22− DEFAULT_SSH_USER, InstanceConfig, build_ssh_login_actions, ensure_instance_dir,
23− ensure_ssh_keypair, extract_ipv4, load_or_create_instance_config, write_instance_config,
24− },
25− session_manager::{
26− GLOBAL_DIR_NAME, INSTANCE_FILENAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME,
27− },
28− vm::{self, DirectoryShare, LoginAction, PROJECT_GUEST_BASE, VmInput},
29−};
30−
28+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
29+const VM_ROOT_LOG_NAME: &str = "vm_root.log";
3130const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
3231const 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+}
3351
3452pub fn ensure_manager(
3553 raw_args: &[std::ffi::OsString],
3654 auto_shutdown_ms: u64,
3755 config_path: Option<&Path>,
38−) -> Result<UnixStream, Box<dyn std::error::Error>> {
56+) -> Result<UnixStream> {
3957 let project_root = env::current_dir()?;
4058 tracing::debug!(root = %project_root.display(), "ensure vm manager");
4159 let instance_dir = ensure_instance_dir(&project_root)?;
42− cleanup_stale_manager(&instance_dir);
60+ cleanup_stale_manager(&project_root)?;
4361 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
4462
4563 if let Ok(stream) = UnixStream::connect(&socket_path) {
4664 send_client_pid(&stream);
47− tracing::info!(path = %socket_path.display(), "connected to existing vm manager");
65+ tracing::info!(path = %socket_path.display(), "connected to an existing vm manager");
4866 return Ok(stream);
4967 }
5068
@@ -82,12 +100,11 @@ pub fn ensure_manager(
82100 drop(lock_file.take());
83101 let _ = fs::remove_file(&lock_path);
84102 }
85− return Err(format!(
86− "Timed out waiting for vm manager socket: {} ({})",
103+ bail!(format!(
104+ "timed out waiting for vm manager socket: {} ({})",
87105 socket_path.display(),
88106 err
89− )
90− .into());
107+ ));
91108 }
92109 thread::sleep(Duration::from_millis(100));
93110 }
@@ -95,24 +112,49 @@ pub fn ensure_manager(
95112 }
96113}
97114
98−pub fn run_manager(
99− args: vm::VmArg,
100− auto_shutdown_ms: u64,
101−) -> Result<(), Box<dyn std::error::Error>> {
115+pub fn run_manager(args: vm::VmArg, auto_shutdown_ms: u64) -> Result<()> {
102116 let project_root = env::current_dir()?;
103117 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+ }
104128 let _pid_guard = ensure_pid_file(&project_root)?;
105− run_manager_with(
106− &project_root,
107− args,
108− auto_shutdown_ms,
109− &RealVmExecutor,
110− ManagerOptions {
111− ensure_signed: true,
112− detach: true,
113− prepare_vm: true,
114− },
115− )
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)
116158}
117159
118160fn spawn_manager_process(
@@ -120,25 +162,22 @@ fn spawn_manager_process(
120162 auto_shutdown_ms: u64,
121163 instance_dir: &Path,
122164 config_path: Option<&Path>,
123−) -> Result<(), Box<dyn std::error::Error>> {
165+) -> Result<()> {
124166 let exe = env::current_exe()?;
125167 let mut supervisor_exe = exe.clone();
126168 supervisor_exe.set_file_name("vibebox-supervisor");
127− let use_supervisor = supervisor_exe.exists();
128− let mut cmd = if use_supervisor {
129− Command::new(supervisor_exe)
130− } else {
131− let mut cmd = Command::new(exe);
132− cmd.arg0("vibebox-supervisor");
133− cmd
134− };
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);
135177 if raw_args.len() > 1 {
136178 cmd.args(&raw_args[1..]);
137179 }
138180 cmd.env("VIBEBOX_INTERNAL", "1");
139− if !use_supervisor {
140− cmd.env("VIBEBOX_VM_MANAGER", "1");
141− }
142181 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
143182 cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
144183 if let Some(path) = config_path {
@@ -166,22 +205,22 @@ fn spawn_manager_process(
166205 Ok(())
167206}
168207
169−fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard, Box<dyn std::error::Error>> {
208+fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard> {
170209 let instance_dir = ensure_instance_dir(project_root)?;
171210 let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
172211 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
173− if let Ok(content) = fs::read_to_string(&pid_path)
174− && let Ok(pid) = content.trim().parse::<u32>()
175− && pid_is_alive(pid)
176− {
177− if is_socket_path(&socket_path) {
178− return Err(format!("vm manager already running (pid {pid})").into());
212+ match vm_liveness(project_root)? {
213+ VmLiveness::RunningWithSocket { pid } => {
214+ bail!("vm manager already running (pid {pid})");
179215 }
180− tracing::warn!(
181− pid,
182− path = %socket_path.display(),
183− "stale pid file detected with missing socket"
184− );
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 => {}
185224 }
186225 let _ = fs::remove_file(&pid_path);
187226 fs::write(&pid_path, format!("{}\n", std::process::id()))?;
@@ -189,15 +228,18 @@ fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard, Box<dyn std::err
189228 Ok(PidFileGuard { path: pid_path })
190229}
191230
192−fn cleanup_stale_manager(instance_dir: &Path) {
193− let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
194− if let Ok(content) = fs::read_to_string(&pid_path)
195− && let Ok(pid) = content.trim().parse::<u32>()
196− && pid_is_alive(pid)
197− {
198− return;
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(());
199240 }
200241 let _ = fs::remove_file(&pid_path);
242+ Ok(())
201243}
202244
203245fn inject_project_mount(
@@ -224,12 +266,6 @@ fn inject_project_mount(
224266 mounts.insert(0, format!("{host}:{guest_tilde}:read-write"));
225267}
226268
227−fn is_socket_path(path: &Path) -> bool {
228− fs::metadata(path)
229− .map(|meta| meta.file_type().is_socket())
230− .unwrap_or(false)
231−}
232−
233269fn prepare_mounts_and_links(mut args: vm::VmArg, ssh_user: &str) -> (vm::VmArg, String) {
234270 let mut links = Vec::new();
235271 let mut mounts = Vec::with_capacity(args.mounts.len());
@@ -373,6 +409,30 @@ fn wait_for_disconnect(mut stream: UnixStream) {
373409 }
374410}
375411
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+
376436fn send_client_pid(stream: &UnixStream) {
377437 let pid = std::process::id();
378438 let payload = format!("pid={pid}\n");
@@ -382,7 +442,7 @@ fn send_client_pid(stream: &UnixStream) {
382442 }
383443}
384444
385−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>> {
386446 match fs::OpenOptions::new()
387447 .write(true)
388448 .create_new(true)
@@ -416,6 +476,12 @@ fn is_lock_stale(lock_path: &Path) -> bool {
416476 }
417477}
418478
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+
419485fn pid_is_alive(pid: u32) -> bool {
420486 let pid = pid as libc::pid_t;
421487 let result = unsafe { libc::kill(pid, 0) };
@@ -429,12 +495,6 @@ fn pid_is_alive(pid: u32) -> bool {
429495 }
430496}
431497
432−fn read_lock_pid(lock_path: &Path) -> Option<u32> {
433− let content = fs::read_to_string(lock_path).ok()?;
434− let line = content.lines().next()?;
435− line.strip_prefix("pid=")?.trim().parse::<u32>().ok()
436−}
437−
438498fn read_client_pid(stream: &UnixStream) -> Option<u32> {
439499 let mut stream = stream.try_clone().ok()?;
440500 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
@@ -467,14 +527,17 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
467527 }
468528}
469529
530+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
470531fn spawn_manager_io(
471532 config: Arc<Mutex<InstanceConfig>>,
472− instance_dir: PathBuf,
533+ project_dir: PathBuf,
534+ clients: ClientStreams,
535+ latest_status: SharedStatus,
473536 output_monitor: Arc<vm::OutputMonitor>,
474537 vm_output_fd: std::os::unix::io::OwnedFd,
475538 vm_input_fd: std::os::unix::io::OwnedFd,
476539) -> vm::IoContext {
477− let log_path = instance_dir.join(VM_ROOT_LOG_NAME);
540+ let log_path = project_dir.join(INSTANCE_DIR_NAME).join(VM_ROOT_LOG_NAME);
478541 let log_file = fs::OpenOptions::new()
479542 .create(true)
480543 .write(true)
@@ -483,7 +546,6 @@ fn spawn_manager_io(
483546 .ok()
484547 .map(|file| Arc::new(Mutex::new(file)));
485548
486− let instance_path = instance_dir.join(INSTANCE_FILENAME);
487549 let config_for_output = config.clone();
488550 let log_for_output = log_file.clone();
489551 let mut line_buf = String::new();
@@ -506,6 +568,17 @@ fn spawn_manager_io(
506568 line_buf.drain(..=pos);
507569
508570 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+ }
509582 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
510583 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
511584 let ip = extract_ipv4(ip_raw).unwrap_or_default();
@@ -514,7 +587,7 @@ fn spawn_manager_io(
514587 && cfg.vm_ipv4.as_deref() != Some(ip.as_str())
515588 {
516589 cfg.vm_ipv4 = Some(ip.clone());
517− let _ = write_instance_config(&instance_path, &cfg);
590+ let _ = write_instance_config(&project_dir, &cfg);
518591 }
519592 }
520593 }
@@ -537,12 +610,12 @@ enum ManagerEvent {
537610}
538611
539612struct ManagerOptions {
540− ensure_signed: bool,
541613 detach: bool,
542614 prepare_vm: bool,
543615}
544616
545617trait VmExecutor {
618+ #[allow(clippy::too_many_arguments)]
546619 fn run_vm(
547620 &self,
548621 args: vm::VmArg,
@@ -550,10 +623,13 @@ trait VmExecutor {
550623 extra_shares: Vec<DirectoryShare>,
551624 config: Arc<Mutex<InstanceConfig>>,
552625 instance_dir: PathBuf,
626+ clients: ClientStreams,
627+ latest_status: SharedStatus,
553628 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
554− ) -> Result<(), Box<dyn std::error::Error>>;
629+ ) -> Result<()>;
555630}
556631
632+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
557633struct RealVmExecutor;
558634
559635impl VmExecutor for RealVmExecutor {
@@ -563,15 +639,22 @@ impl VmExecutor for RealVmExecutor {
563639 extra_login_actions: Vec<LoginAction>,
564640 extra_shares: Vec<DirectoryShare>,
565641 config: Arc<Mutex<InstanceConfig>>,
566− instance_dir: PathBuf,
642+ project_dir: PathBuf,
643+ clients: ClientStreams,
644+ latest_status: SharedStatus,
567645 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
568− ) -> Result<(), Box<dyn std::error::Error>> {
646+ ) -> Result<()> {
647+ let status_callback = |status: &str| {
648+ broadcast_status(&clients, &latest_status, status);
649+ };
569650 vm::run_with_args_and_extras(
570651 args,
571652 |output_monitor, vm_output_fd, vm_input_fd| {
572653 let io_ctx = spawn_manager_io(
573654 config.clone(),
574− instance_dir.clone(),
655+ project_dir.clone(),
656+ clients.clone(),
657+ latest_status.clone(),
575658 output_monitor,
576659 vm_output_fd,
577660 vm_input_fd,
@@ -581,34 +664,60 @@ impl VmExecutor for RealVmExecutor {
581664 },
582665 extra_login_actions,
583666 extra_shares,
667+ Some(&status_callback),
584668 )
585669 }
586670}
587671
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(())
704+ }
705+}
706+
588707fn run_manager_with(
589708 project_root: &Path,
590709 mut args: vm::VmArg,
591710 auto_shutdown_ms: u64,
592711 executor: &dyn VmExecutor,
593712 options: ManagerOptions,
594−) -> Result<(), Box<dyn std::error::Error>> {
595− if options.ensure_signed {
596− let _had_skip = env::var("VIBEBOX_SKIP_CODESIGN").ok();
597− unsafe {
598− env::remove_var("VIBEBOX_SKIP_CODESIGN");
599− }
600− vm::ensure_signed();
601− unsafe {
602− env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
603− }
604− }
713+) -> Result<()> {
605714 if options.detach {
606715 detach_from_terminal();
607716 }
608717
609718 let project_name = project_root
610719 .file_name()
611− .ok_or("Project directory has no name")?
720+ .ok_or_else(|| anyhow!("Project directory has no name"))?
612721 .to_string_lossy()
613722 .into_owned();
614723 let instance_dir = ensure_instance_dir(project_root)?;
@@ -616,16 +725,16 @@ fn run_manager_with(
616725 let _ = ensure_ssh_keypair(&instance_dir)?;
617726 }
618727
619− let mut config = load_or_create_instance_config(&instance_dir)?;
728+ let mut config = load_or_create_instance_config(project_root)?;
620729 if config.vm_ipv4.is_some() {
621730 config.vm_ipv4 = None;
622− write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
731+ write_instance_config(project_root, &config)?;
623732 }
624733 let config = Arc::new(Mutex::new(config));
625734 let ssh_user = config
626735 .lock()
627− .map(|cfg| cfg.ssh_user_display())
628− .unwrap_or_else(|_| DEFAULT_SSH_USER.to_string());
736+ .map(|cfg| cfg.ssh_user.clone())
737+ .map_err(|_| anyhow!("failed to acquire ssh user display"))?;
629738 if !args.no_default_mounts {
630739 inject_project_mount(&mut args.mounts, project_root, &ssh_user, &project_name);
631740 }
@@ -633,11 +742,10 @@ fn run_manager_with(
633742
634743 let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}");
635744 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
636− let extra_shares = vec![DirectoryShare::new(
637− instance_dir.clone(),
638− ssh_guest_dir.clone().into(),
639− true,
640− )?];
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+ ];
641749 let extra_login_actions = build_ssh_login_actions(
642750 &config,
643751 &project_name,
@@ -660,17 +768,43 @@ fn run_manager_with(
660768 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
661769 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
662770
771+ let clients: ClientStreams = Arc::new(Mutex::new(Vec::new()));
772+ let latest_status: SharedStatus = Arc::new(Mutex::new(String::new()));
663773 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
664774 let event_tx_accept = event_tx.clone();
775+ let clients_accept = clients.clone();
776+ let latest_status_accept = latest_status.clone();
665777 thread::spawn(move || {
666778 for stream in listener.incoming() {
667779 match stream {
668780 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+ }
669799 let event_tx_conn = event_tx_accept.clone();
800+ let clients_conn = clients_accept.clone();
670801 thread::spawn(move || {
671802 let pid = read_client_pid(&stream);
672803 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
673804 wait_for_disconnect(stream);
805+ if let Some(fd) = client_fd {
806+ remove_client(&clients_conn, fd);
807+ }
674808 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
675809 });
676810 }
@@ -690,43 +824,59 @@ fn run_manager_with(
690824 extra_login_actions,
691825 extra_shares,
692826 config.clone(),
693− instance_dir.clone(),
827+ project_root.to_path_buf(),
828+ clients.clone(),
829+ latest_status.clone(),
694830 vm_input_tx.clone(),
695831 );
696832 tracing::info!("vm manager vm run completed");
697833 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+ }
698841 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
699− let event_loop_result: Result<(), String> = event_loop_handle
842+ let event_loop_result = event_loop_handle
700843 .join()
701− .unwrap_or_else(|_| Err("vm manager event loop panicked".into()))
844+ .unwrap_or_else(|_| Err(Error::msg("vm manager event loop panicked")))
702845 .map_err(|err| err.to_string());
703846 let _ = fs::remove_file(&socket_path);
704847 if let Err(err) = &event_loop_result {
705848 tracing::error!(error = %err, "vm manager exiting due to event loop error");
706− return Err(err.to_string().into());
849+ bail!(err.to_string());
707850 }
708851 if let Some(err) = vm_err {
709852 tracing::error!(error = %err, "vm manager exiting due to vm error");
710− return Err(err.into());
853+ bail!(err);
711854 }
712855 tracing::info!("vm manager exiting");
713− Ok(event_loop_result?)
856+ event_loop_result.map_err(Error::msg)
714857}
715858
716859fn manager_event_loop(
717860 event_rx: mpsc::Receiver<ManagerEvent>,
718861 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
719862 auto_shutdown_ms: u64,
720−) -> Result<(), String> {
863+) -> Result<()> {
721864 let mut ref_count: usize = 0;
722865 let mut shutdown_deadline: Option<Instant> = None;
723866 let mut shutdown_sent = false;
867+ let mut hard_deadline: Option<Instant> = None;
724868 let grace = Duration::from_millis(auto_shutdown_ms.max(1));
869+ let hard_timeout = Duration::from_millis(HARD_SHUTDOWN_TIMEOUT_MS);
725870
726871 loop {
727− let timeout = match shutdown_deadline {
728− Some(deadline) => deadline.saturating_duration_since(Instant::now()),
729− 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),
730880 };
731881
732882 match event_rx.recv_timeout(timeout) {
@@ -740,6 +890,7 @@ fn manager_event_loop(
740890 );
741891 shutdown_deadline = None;
742892 shutdown_sent = false;
893+ hard_deadline = None;
743894 }
744895 Ok(ManagerEvent::Dec(pid)) => {
745896 ref_count = ref_count.saturating_sub(1);
@@ -765,12 +916,48 @@ fn manager_event_loop(
765916 && Instant::now() >= deadline
766917 && !shutdown_sent
767918 {
919+ if hard_deadline.is_none() {
920+ hard_deadline = Some(Instant::now() + hard_timeout);
921+ }
922+ let mut sent = false;
768923 if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
769− let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
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");
931+ }
932+ } else {
933+ tracing::warn!("shutdown command deferred; vm input not ready");
934+ }
935+ if sent {
936+ tracing::info!("shutdown command sent");
937+ shutdown_sent = true;
938+ 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+ );
770959 }
771− tracing::info!("shutdown command sent");
772− shutdown_sent = true;
773− shutdown_deadline = None;
960+ force_exit("vm manager forced exit");
774961 }
775962 }
776963 Err(mpsc::RecvTimeoutError::Disconnected) => break,
@@ -783,7 +970,7 @@ fn manager_event_loop(
783970#[cfg(test)]
784971mod tests {
785972 use super::*;
786− use std::{sync::mpsc, time::Duration};
973+ use std::{fs, sync::mpsc, thread, time::Duration};
787974
788975 #[test]
789976 fn manager_powers_off_after_grace_when_no_refs() {
@@ -816,4 +1003,75 @@ mod tests {
8161003 let _ = event_tx.send(ManagerEvent::VmExited(None));
8171004 let _ = manager_thread.join();
8181005 }
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+ }
8191077}
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+}