on main help sign in
19 files changed+1774−853
since splitsnapshots
unifiedsplit
Cargo.lock+18−1 Cargo.toml+3−1 README.md+63−34 README.zh.md+51−17 docs/tasks.md+14−9 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+289−149 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−160 src/vm_manager.rs+294−121
Cargo.lock+18−1
@@ -82,6 +82,12 @@ 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+
8591[[package]]
8692name = "assert_cmd"
8793version = "2.1.2"
@@ -144,6 +150,15 @@ version = "3.19.1"
144150source = "registry+https://github.com/rust-lang/crates.io-index"
145151checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
146152
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+
147162[[package]]
148163name = "cassowary"
149164version = "0.3.0"
@@ -1303,10 +1318,12 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
13031318
13041319[[package]]
13051320name = "vibebox"
1306−version = "0.2.3"
1321+version = "0.3.2"
13071322dependencies = [
1323+ "anyhow",
13081324 "assert_cmd",
13091325 "block2",
1326+ "bytesize",
13101327 "clap",
13111328 "color-eyre",
13121329 "crossterm",
Cargo.toml+3−1
@@ -1,6 +1,6 @@
11[package]
22name = "vibebox"
3−version = "0.2.3"
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,6 +42,8 @@ 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"
4547
4648[dev-dependencies]
4749assert_cmd = "2"
README.md+63−34
@@ -5,7 +5,7 @@
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
1010<p align="center">
1111 <a href="https://crates.io/crates/vibebox">
@@ -24,45 +24,77 @@
2424 <a href="README.zh.md">简体中文</a>
2525</p>
2626
27−**VibeBox is a per-project micro-VM sandbox for coding agents on macOS (Apple Virtualization Framework), fast to enter,
28−configurable, and built around sessions.**
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.
2929
30−- **Enter in seconds:** `vibebox` drops you into a reusable sandbox for the current repo
31−- **Project-scoped:** explicit mounts + repo-contained changes
32−- **Sessioned:** multi-instance + session management (reuse, multiple terminals, cleanup)
30+**Who it’s for:** macOS users running coding agents who want real isolation without giving up a fast daily workflow.
31+
32+**Quick facts:** warm re-entry is typically **<5s** on my M3 (varies by machine/cache); first run downloads and
33+provisions a Debian base image (network dependent).
34+
35+**Security model:** Linux guest VM with explicit mount allowlists from `vibebox.toml` (repo-first, everything else
36+opt-in).
37+
38+- **enter/attach in seconds:** `vibebox` drops you into a reusable sandbox for the current repo
39+- **project-scoped by default:** explicit mounts + repo-contained changes (repo-first, everything else is allowlisted)
40+- **sessioned:** multi-instance + session management (reuse, multiple terminals, cleanup)
3341
3442### Quick Demo
3543
3644```bash
37−# 1) from any repo
45+# from any repo
3846cd my-project
3947vibebox
4048```
4149
50+What you should see (roughly):
51+
52+```text
53+vibebox: starting (session: my-project)
54+vibebox: attaching...
55+vibecoder@vibebox:~/my-project$
56+```
57+
4258[![VibeBox Terminal UI](docs/screenshot.png)](https://vibebox.robcholz.com)
4359
4460---
4561
4662### Why I built VibeBox
4763
48−I use agents like Codex and CC a lot, but I always felt uneasy running them directly on my host machine. If I lock
49−things
50−down, I get interrupted by constant “are you sure?” prompts. If I loosen it up, I worry the agent might touch the
51−wrong files or run something I didn’t intend.
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.
5267
53−I wanted something that feels as frictionless as giving an agent a real shell, but with a hard isolation boundary. So I
54−built VibeBox: a per-project micro-VM sandbox that starts fast, keeps changes contained to the repo, and lets me iterate
55−without babysitting permissions.
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.
5686
5787### Comparison
5888
5989Here’s why I didn’t just use existing options:
6090
61−- **vibe**: super convenient, but it’s too minimal for what I need. It lacks basic configuration, and it doesn’t give me
62− the multi-instance + session management my workflow wants.
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.
6393- **QEMU**: powerful, but the configuration surface area is huge. For day-to-day sandboxing it’s not “open a repo and
6494 go” — it’s a project on its own.
65−- **Docker / devcontainers**: great ecosystem, but for daily use it feels heavy.
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.
6698
6799That’s what pushed me to build **VibeBox**: I wanted a per-project sandbox that’s fast to enter (just `vibebox`),
68100supports real configuration + sessions, and keeps a hard isolation boundary.
@@ -70,13 +102,13 @@ supports real configuration + sessions, and keeps a hard isolation boundary.
70102### Installation
71103
72104```bash
73−# YOLO
105+# install script
74106curl -fsSL https://raw.githubusercontent.com/robcholz/vibebox/main/install | bash
75107
76−# Package managers
108+# package managers
77109cargo install vibebox
78110
79−# Or manually
111+# manual install
80112curl -LO https://github.com/robcholz/vibebox/releases/download/latest/vibebox-macos-arm64.zip
81113unzip vibebox-macos-arm64.zip
82114mkdir -p ~/.local/bin
@@ -86,7 +118,7 @@ export PATH="$HOME/.local/bin:$PATH"
86118
87119**Requirements**
88120
89−- macOS on Apple Silicon (Vibebox uses Apple's virtualization APIs).
121+- macOS on Apple Silicon (VibeBox uses Apple's virtualization APIs).
90122
91123**First Run**
92124
@@ -102,7 +134,7 @@ cd /path/to/your/project
102134vibebox
103135```
104136
105−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.
106138
107139**Configuration (`vibebox.toml`)**
108140
@@ -153,7 +185,7 @@ vibebox explain # show mounts and network info
153185- Default SSH user: `vibecoder`
154186- Hostname: `vibebox`
155187- Base image provisioning installs: build tools, `git`, `curl`, `ripgrep`, `openssh-server`, and `sudo`.
156−- 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
157189 `@anthropic-ai/claude-code` (best-effort).
158190- Shell aliases: `:help` and `:exit`.
159191
@@ -168,27 +200,24 @@ vibebox explain # show mounts and network info
168200If you're interested in contributing to VibeBox, please read our [contributing docs](CONTRIBUTING.md) before
169201submitting a pull request.
170202
171−### Using VibeBox
172−
173−Feel free to use
174−
175203### FAQ
176204
177−#### How is this different from other Sandboxes?
205+#### How is this different from other sandboxes?
178206
179−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:
180208
181−- 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.
182210- One simple command — `vibebox` — drops you into the sandbox from your project.
183211- Configuration lives in `vibebox.toml`, where you can set CPU, RAM, disk size, and mounts.
212+- Sessions are first-class: reuse, multiple terminals, cleanup.
184213
185−### Special Thank
214+### Special thanks
186215
187216[vibe](https://github.com/lynaghk/vibe) by lynaghk.
188217
189−And amazing Rust community, without your rich crates and fantastic toolchain like [crates.io](https://crates.io), this
190−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!
191220
192221---
193222
194−**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+51−17
@@ -5,7 +5,7 @@
55 </picture>
66 </a>
77</p>
8−<p align="center">超高速、开源的 AI 沙盒。</p>
8+<p align="center">用于安全运行 coding agents 的超高速开源沙盒。</p>
99
1010<p align="center">
1111 <a href="https://crates.io/crates/vibebox">
@@ -24,8 +24,34 @@
2424 <a href="README.md">English</a>
2525</p>
2626
27−VibeBox 是一个轻量、启动极快的沙盒环境,让 AI Agent 可以安全地直接跑命令、改文件、执行代码,不会不停弹“要不要允许”的提示。它基于
28−Apple 的 Virtualization Framework 做到彻底隔离,所以无论 Agent 在里面怎么折腾,你的真实系统都不会被影响;同时做到了低内存和磁盘占用。
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+```
2955
3056[![VibeBox Terminal UI](docs/screenshot.png)](https://vibebox.robcholz.com)
3157
@@ -33,22 +59,33 @@ Apple 的 Virtualization Framework 做到彻底隔离,所以无论 Agent 在
3359
3460### 我为什么做 VibeBox
3561
36−我平时经常用像 Codex 和 CC 这样的
37−agent,但一直不太敢让它们直接在我的宿主机上跑。把权限收紧一点,就会被各种“你确定吗?”的确认弹窗不停打断;放松一点,又担心它哪天误操作,碰到不该碰的文件,或者跑了我没打算执行的命令。
62+我每天都在用 coding agents,也希望它们有一个真实的 shell,但不想把宿主机直接交出去。
63+权限收紧会被不停的确认打断;权限放开又担心误删文件、触及密钥,或者跑出仓库边界。
64+
65+VibeBox 是中间方案:按项目隔离、硬 VM 边界、快速回到工作状态、显式挂载。它适合把 agent 当成日常工具,而不是把安全变成负担。
66+
67+### 为什么是 micro-VM(而不是容器)?
3868
39−我想要的是一种体验:像给 agent 一个真实 shell 一样顺滑,但同时又有一道硬隔离的安全边界。于是我做了 VibeBox:一个按项目划分的
40−micro-VM 沙箱,启动很快,改动被限制在仓库范围内,让我可以更高频地迭代,而不用一直盯着权限确认。
69+容器很好用。VibeBox 并不是用来替代 Docker/devcontainers 去构建服务。
70+
71+我更想要的是在 macOS 上适合 agent 的 VM 默认形态:
72+
73+- **默认是 guest-kernel 隔离边界:** 让 agent 跑任意命令时,“安全模式”是 Linux 来宾而不是宿主机。
74+- **会话是第一等公民:** 按项目附加/复用,多终端进入同一沙盒,可靠清理,避免孤儿环境。
75+- **显式挂载白名单作为主要 UX:** 默认仅项目目录,其它都需显式允许。
76+- **最少的每项目配置:** 你可以用 compose/devcontainers 实现一部分,但我想要的是一个命令,在不同仓库间直接工作,不必维护容器配置来获得基础“安全
77+ shell”体验。
4178
4279### 对比
4380
4481下面是我为什么没有直接用现成方案的原因:
4582
46−- **vibe**:非常方便,但对我来说太“极简”了。它缺少一些基础配置能力,也没法提供我工作流里需要的多开和 session 管理。
83+- **vibe**:非常方便,“零配置、直接用”做得很好。但 VibeBox 走的是另一条路:按项目配置 + 会话 + 多实例生命周期。
4784- **QEMU**:很强大,但配置面太大了。日常当沙箱用,它不像是“进到 repo 就能用”,更像是你得先把它当成一个项目来折腾。
48−- **Docker / devcontainers**:生态很成熟,但日常使用对我来说偏重。
85+- **Docker / devcontainers / devpods**:生态很成熟。我的痛点不在启动时间,而是日常保持
86+ safe-by-default(挂载白名单、密钥暴露、附加/复用、清理)的开销,不想为基础 workflow 在每个项目维护容器配置。
4987
50−这些就促使我做了 **VibeBox**:我想要一个按项目隔离的沙箱,进入速度快(直接用 `vibebox`),支持真正可用的配置和
51−sessions,同时还保留明确的硬隔离边界。
88+这就是我做 **VibeBox** 的原因:我想要一个按项目隔离的沙箱,进入快(直接 `vibebox`),支持真实配置 + 会话,同时保持硬隔离边界。
5289
5390### 安装
5491
@@ -149,19 +186,16 @@ vibebox explain # 显示挂载与网络信息
149186
150187如果你想参与贡献 VibeBox,请先阅读 [贡献指南](CONTRIBUTING.md),再提交 Pull Request。
151188
152−### 使用 VibeBox
153−
154−欢迎使用
155−
156189### FAQ
157190
158191#### 它和其它 Sandboxes 有什么不同?
159192
160−Vibebox 追求的是:本地、可复现、启动快、流程简单。主要差异点:
193+VibeBox 追求的是:本地、可复现、启动快、流程简单。主要差异点:
161194
162−- 在我的 M3 上,热启动通常 **6 秒以内**,可以非常快地回到工作状态。
195+- 在我的 M3 上,热启动通常 **<5s**,可以非常快地回到工作状态。
163196- 一个命令——`vibebox`——直接把你带进沙盒(从你的项目目录启动)。
164197- 配置集中在 `vibebox.toml`,CPU / 内存 / 磁盘大小 / 挂载都能一眼看懂、随手改。
198+- 会话是第一等公民:复用、多终端、清理。
165199
166200### 特别鸣谢
167201
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
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+289−149
@@ -1,82 +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−#[cfg_attr(feature = "mock-vm", allow(dead_code))]
25−pub(crate) const VM_ROOT_LOG_NAME: &str = "vm_root.log";
26−pub(crate) const STATUS_FILE_NAME: &str = "status.txt";
27−pub(crate) const DEFAULT_SSH_USER: &str = "vibecoder";
28−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;
2935const SSH_CONNECT_DELAY_MS: u64 = 500;
3036const 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+}
3192
3293#[derive(Debug, Clone, Serialize, Deserialize)]
33−pub(crate) struct InstanceConfig {
94+pub struct InstanceConfig {
3495 #[serde(default)]
35− id: String,
96+ pub id: String,
3697 #[serde(default = "default_ssh_user")]
37− ssh_user: String,
98+ pub ssh_user: String,
3899 #[serde(default)]
39100 sudo_password: String,
40101 #[serde(default)]
41− last_active: Option<String>,
102+ pub last_active: Option<String>,
42103 #[serde(default)]
43− pub(crate) vm_ipv4: Option<String>,
104+ pub vm_ipv4: Option<String>,
44105}
45106
46−impl InstanceConfig {
47− pub(crate) fn ssh_user_display(&self) -> String {
48− if self.ssh_user.trim().is_empty() {
49− DEFAULT_SSH_USER.to_string()
50− } else {
51− 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,
52115 }
53116 }
54117}
55118
56−fn default_ssh_user() -> String {
57− DEFAULT_SSH_USER.to_string()
58−}
59−
60−pub fn run_with_ssh(manager_conn: UnixStream) -> Result<(), Box<dyn std::error::Error>> {
119+pub fn run_with_ssh(manager_conn: UnixStream) -> Result<()> {
61120 let project_root = env::current_dir()?;
62121 tracing::info!(root = %project_root.display(), "starting ssh session");
63122 let instance_dir = ensure_instance_dir(&project_root)?;
64123 tracing::debug!(instance_dir = %instance_dir.display(), "instance dir ready");
65124 let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?;
66125
67− let config = load_or_create_instance_config(&instance_dir)?;
126+ let config = load_or_create_instance_config(&project_root)?;
68127 let ssh_user = config.ssh_user.clone();
69128 tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
70129
71− let _manager_conn = manager_conn;
72− wait_for_vm_ipv4(&instance_dir, Duration::from_secs(480))?;
130+ wait_for_vm_ipv4(&project_root, Duration::from_secs(480), &manager_conn)?;
73131
74− let ip = load_or_create_instance_config(&instance_dir)?
132+ let ip = load_or_create_instance_config(&project_root)?
75133 .vm_ipv4
76− .ok_or("VM IPv4 not available")?;
134+ .with_context(|| "failed to load instance IP address")?;
77135 tracing::info!(ip = %ip, "vm ipv4 ready");
78136
79− run_ssh_session(ssh_key, ssh_user, ip)
137+ run_ssh_session(ssh_key, ssh_user, ip, manager_conn, project_root)
80138}
81139
82140pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
@@ -85,9 +143,7 @@ pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
85143 Ok(instance_dir)
86144}
87145
88−pub(crate) fn ensure_ssh_keypair(
89− instance_dir: &Path,
90−) -> Result<(PathBuf, PathBuf), Box<dyn std::error::Error>> {
146+pub fn ensure_ssh_keypair(instance_dir: &Path) -> Result<(PathBuf, PathBuf)> {
91147 let private_key = instance_dir.join(SSH_KEY_NAME);
92148 let public_key = instance_dir.join(format!("{SSH_KEY_NAME}.pub"));
93149
@@ -109,7 +165,9 @@ pub(crate) fn ensure_ssh_keypair(
109165 "-N",
110166 "",
111167 "-f",
112− 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")?,
113171 "-C",
114172 "vibebox",
115173 ])
@@ -119,7 +177,7 @@ pub(crate) fn ensure_ssh_keypair(
119177 .status()?;
120178
121179 if !status.success() {
122− return Err("ssh-keygen failed".into());
180+ bail!("ssh-keygen failed");
123181 }
124182
125183 fs::set_permissions(&private_key, fs::Permissions::from_mode(0o600))?;
@@ -128,98 +186,66 @@ pub(crate) fn ensure_ssh_keypair(
128186 Ok((private_key, public_key))
129187}
130188
131−pub(crate) fn load_or_create_instance_config(
132− instance_dir: &Path,
133−) -> Result<InstanceConfig, Box<dyn std::error::Error>> {
134− let config_path = instance_dir.join(INSTANCE_FILENAME);
135− let mut config = if config_path.exists() {
136− let raw = fs::read_to_string(&config_path)?;
137− toml::from_str::<InstanceConfig>(&raw)?
138− } else {
139− InstanceConfig {
140− id: String::new(),
141− ssh_user: default_ssh_user(),
142− sudo_password: String::new(),
143− last_active: None,
144− vm_ipv4: None,
145− }
146− };
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+ });
147195
148196 let mut changed = false;
149197 if config.ssh_user.trim().is_empty() {
150− config.ssh_user = default_ssh_user();
198+ config.ssh_user = InstanceConfig::default().ssh_user;
151199 changed = true;
152200 }
153201
154202 if config.id.trim().is_empty() {
155− config.id = Uuid::now_v7().to_string();
203+ config.id = InstanceConfig::default().id;
156204 changed = true;
157205 }
158206
159207 if config.sudo_password.trim().is_empty() {
160− config.sudo_password = generate_password();
208+ config.sudo_password = InstanceConfig::default().sudo_password;
161209 changed = true;
162210 }
163211
164− if !config_path.exists() || changed {
165− write_instance_config(&config_path, &config)?;
212+ if !exist || changed {
213+ write_instance_config(project_dir, &config)?;
166214 }
167215
168216 Ok(config)
169217}
170218
171−fn read_instance_config(
172− instance_dir: &Path,
173−) -> Result<Option<InstanceConfig>, Box<dyn std::error::Error>> {
174− 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);
175222 if !config_path.exists() {
176− return Ok(None);
223+ bail!("instance config file does not exist");
177224 }
178225 let raw = fs::read_to_string(&config_path)?;
179226 let config = toml::from_str::<InstanceConfig>(&raw)?;
180− Ok(Some(config))
181−}
182−
183−pub fn read_instance_vm_ip(
184− instance_dir: &Path,
185−) -> Result<Option<String>, Box<dyn std::error::Error>> {
186− let config = read_instance_config(instance_dir)?;
187− Ok(config.and_then(|cfg| cfg.vm_ipv4))
188−}
189−
190−pub fn read_instance_ssh_user(
191− instance_dir: &Path,
192−) -> Result<Option<String>, Box<dyn std::error::Error>> {
193− let config = read_instance_config(instance_dir)?;
194− Ok(config
195− .map(|cfg| cfg.ssh_user)
196− .filter(|user| !user.trim().is_empty()))
227+ Ok(config)
197228}
198229
199−pub fn touch_last_active(instance_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
200− 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)?;
201232 let now = OffsetDateTime::now_utc().format(&Rfc3339)?;
202233 config.last_active = Some(now);
203− write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
234+ write_instance_config(project_dir, &config)?;
204235 Ok(())
205236}
206237
207−pub(crate) fn write_instance_config(
208− path: &Path,
209− config: &InstanceConfig,
210−) -> 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);
211240 let data = toml::to_string_pretty(config)?;
212− fs::write(path, data)?;
213− 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))?;
214244 Ok(())
215245}
216246
217−fn generate_password() -> String {
218− Uuid::now_v7().simple().to_string()
219−}
220−
221247#[cfg_attr(feature = "mock-vm", allow(dead_code))]
222−pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
248+pub fn extract_ipv4(line: &str) -> Option<String> {
223249 let mut current = String::new();
224250 let mut best: Option<String> = None;
225251
@@ -238,46 +264,68 @@ pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
238264 best
239265}
240266
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+
241285fn wait_for_vm_ipv4(
242− instance_dir: &Path,
286+ project_dir: &Path,
243287 timeout: Duration,
244−) -> Result<(), Box<dyn std::error::Error>> {
288+ manager_conn: &UnixStream,
289+) -> Result<()> {
245290 let start = Instant::now();
246291 let mut next_log_at = start + Duration::from_secs(10);
247− let mut next_status_check = start;
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();
248296 tracing::info!("waiting for vm ipv4");
249− let status_path = instance_dir.join(STATUS_FILE_NAME);
250297 let mut last_status: Option<String> = None;
251− let mut status_missing = true;
252298 let mut once_hint = false;
253299 loop {
254− 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)?;
255321 if config.vm_ipv4.is_some() {
256− let _ = fs::remove_file(&status_path);
257322 return Ok(());
258323 }
259324 if start.elapsed() > timeout {
260− let _ = fs::remove_file(&status_path);
261− return Err("Timed out waiting for VM IPv4".into());
325+ bail!("timed out waiting for VM IPv4");
262326 }
327+
263328 let now = Instant::now();
264− if now >= next_status_check {
265− match fs::read_to_string(&status_path) {
266− Ok(status) => {
267− status_missing = false;
268− let status = status.trim().to_string();
269− if !status.is_empty() && last_status.as_deref() != Some(status.as_str()) {
270− tracing::info!("[background]: {}", status);
271− last_status = Some(status);
272− next_log_at = now + Duration::from_secs(20);
273− }
274− }
275− Err(_) => {
276− status_missing = true;
277− }
278− }
279− next_status_check = now + Duration::from_millis(500);
280− }
281329 if now >= next_log_at {
282330 let waited = start.elapsed();
283331 if waited.as_secs() > 15 && !once_hint {
@@ -286,12 +334,9 @@ fn wait_for_vm_ipv4(
286334 );
287335 once_hint = true;
288336 }
289− if status_missing {
290− tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
291− }
337+ tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
292338 next_log_at += Duration::from_secs(20);
293339 }
294− thread::sleep(Duration::from_millis(200));
295340 }
296341}
297342
@@ -299,9 +344,14 @@ fn run_ssh_session(
299344 ssh_key: PathBuf,
300345 ssh_user: String,
301346 ip: String,
302−) -> Result<(), Box<dyn std::error::Error>> {
347+ manager_conn: UnixStream,
348+ project_root: PathBuf,
349+) -> Result<()> {
303350 let mut attempts = 0usize;
304351 loop {
352+ if matches!(vm_liveness(&project_root)?, VmLiveness::NotRunningOrMissing) {
353+ return Err(InstanceError::UnexpectedDisconnection.into());
354+ }
305355 attempts += 1;
306356 if !ssh_port_open(&ip) {
307357 tracing::debug!(attempts, "ssh port doesn't open yet");
@@ -313,9 +363,7 @@ fn run_ssh_session(
313363 SSH_CONNECT_RETRIES
314364 );
315365 if attempts >= SSH_CONNECT_RETRIES {
316− return Err(
317− format!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts").into(),
318− );
366+ bail!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts");
319367 }
320368 thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS));
321369 continue;
@@ -329,10 +377,10 @@ fn run_ssh_session(
329377 attempts,
330378 SSH_CONNECT_RETRIES
331379 );
332− let status = Command::new("ssh")
380+ let child = Command::new("ssh")
333381 .args([
334382 "-i",
335− ssh_key.to_str().unwrap_or(".vibebox/ssh_key"),
383+ ssh_key.to_str().with_context(|| "invalid path")?,
336384 "-o",
337385 "IdentitiesOnly=yes",
338386 "-o",
@@ -357,27 +405,67 @@ fn run_ssh_session(
357405 .stdin(Stdio::inherit())
358406 .stdout(Stdio::inherit())
359407 .stderr(Stdio::inherit())
360− .status();
408+ .spawn();
409+
410+ match child {
411+ Ok(mut child) => {
412+ let done = Arc::new(AtomicBool::new(false));
413+ let done_for_monitor = done.clone();
414+ let (disconnect_tx, disconnect_rx) = mpsc::channel::<()>();
415+ let mut manager_stream = manager_conn.try_clone()?;
416+ let _ = manager_stream.set_read_timeout(Some(Duration::from_millis(250)));
417+ thread::spawn(move || {
418+ let mut buf = [0u8; 1];
419+ while !done_for_monitor.load(Ordering::Relaxed) {
420+ match manager_stream.read(&mut buf) {
421+ Ok(0) => {
422+ let _ = disconnect_tx.send(());
423+ return;
424+ }
425+ Ok(_) => {}
426+ Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
427+ Err(err) if err.kind() == io::ErrorKind::TimedOut => {}
428+ Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
429+ Err(_) => {
430+ let _ = disconnect_tx.send(());
431+ return;
432+ }
433+ }
434+ }
435+ });
436+
437+ let status = loop {
438+ if disconnect_rx.try_recv().is_ok() {
439+ done.store(true, Ordering::Relaxed);
440+ terminate_ssh_child(&mut child);
441+ restore_terminal_after_disconnect();
442+ return Err(InstanceError::UnexpectedDisconnection.into());
443+ }
444+ if let Some(status) = child.try_wait()? {
445+ done.store(true, Ordering::Relaxed);
446+ break status;
447+ }
448+ thread::sleep(Duration::from_millis(100));
449+ };
361450
362− match status {
363− Ok(status) if status.success() => {
364− tracing::info!(status = %status, "ssh exited");
365− break;
366− }
367− Ok(status) if status.code() == Some(255) => {
368− tracing::warn!(status = %status, "ssh connection failed");
369− if attempts >= SSH_CONNECT_RETRIES {
370− return Err(format!("ssh failed after {SSH_CONNECT_RETRIES} attempts").into());
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;
371462 }
372− thread::sleep(Duration::from_millis(500));
373− }
374− Ok(status) => {
375463 tracing::info!(status = %status, "ssh exited");
376464 break;
377465 }
378466 Err(err) => {
379467 tracing::error!(error = %err, "failed to start ssh");
380− return Err(format!("failed to start ssh: {err}").into());
468+ bail!("failed to start ssh: {err}");
381469 }
382470 }
383471 }
@@ -385,6 +473,30 @@ fn run_ssh_session(
385473 Ok(())
386474}
387475
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+
388500#[cfg_attr(feature = "mock-vm", allow(dead_code))]
389501fn is_ipv4_candidate(candidate: &str) -> bool {
390502 let parts: Vec<&str> = candidate.split('.').collect();
@@ -407,10 +519,10 @@ fn ssh_port_open(ip: &str) -> bool {
407519 Ok(addr) => addr,
408520 Err(_) => return false,
409521 };
410− TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).is_ok()
522+ TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok()
411523}
412524
413−pub(crate) fn build_ssh_login_actions(
525+pub fn build_ssh_login_actions(
414526 config: &Arc<Mutex<InstanceConfig>>,
415527 project_name: &str,
416528 project_guest_dir: &str,
@@ -425,7 +537,7 @@ pub(crate) fn build_ssh_login_actions(
425537
426538 let key_path = format!("{guest_dir}/{key_name}.pub");
427539
428− let setup_script = SSH_SETUP_SCRIPT
540+ let ssh_script = SSH_SETUP_SCRIPT
429541 .replace("__SSH_USER__", &ssh_user)
430542 .replace("__SUDO_PASSWORD__", &sudo_password)
431543 .replace("__PROJECT_NAME__", project_name)
@@ -433,8 +545,36 @@ pub(crate) fn build_ssh_login_actions(
433545 .replace("__KEY_PATH__", &key_path)
434546 .replace("__VIBEBOX_SHELL_SCRIPT__", &commands::render_shell_script())
435547 .replace("__VIBEBOX_HOME_LINKS__", home_links_script);
436− let setup = vm::script_command_from_content("ssh_setup", &setup_script)
548+ let setup = vm::script_command_from_content("ssh.sh", &ssh_script)
437549 .expect("ssh setup script contained invalid marker");
438550
439551 vec![LoginAction::Send(setup)]
440552}
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−160
@@ -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,68 +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−
62−impl Drop for StatusFile {
63− fn drop(&mut self) {
64− if !self.cleared.load(Ordering::SeqCst) {
65− let _ = fs::remove_file(&self.path);
66− self.cleared.store(true, Ordering::SeqCst);
67− }
68− }
69−}
42+const ERROR_REPORT_SCRIPT: &str = include_str!("error_report.sh");
7043const PROVISION_SCRIPT: &str = include_str!("provision.sh");
71−const PROVISION_SCRIPT_NAME: &str = "provision.sh";
7244const RESIZE_DISK_SCRIPT: &str = include_str!("resize_disk.sh");
7345const DEFAULT_RAW_NAME: &str = "default.raw";
7446const INSTANCE_RAW_NAME: &str = "instance.raw";
7547const BASE_DISK_RAW_NAME: &str = "disk.raw";
7648
7749#[derive(Clone)]
78−pub(crate) enum LoginAction {
79− 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+ },
8060 Send(String),
8161}
62+use crate::config::BoxConfig;
8263use LoginAction::*;
8364
8465#[derive(Clone)]
85−pub(crate) struct DirectoryShare {
66+pub struct DirectoryShare {
8667 host: PathBuf,
8768 guest: PathBuf,
8869 read_only: bool,
8970}
9071
9172impl DirectoryShare {
92− pub(crate) fn new(
93− host: PathBuf,
94− mut guest: PathBuf,
95− read_only: bool,
96− ) -> Result<Self, Box<dyn std::error::Error>> {
73+ pub fn new(host: PathBuf, mut guest: PathBuf, read_only: bool) -> Result<Self> {
9774 if !host.exists() {
98− return Err(format!("Host path does not exist: {}", host.display()).into());
75+ bail!(format!("host path does not exist: {}", host.display()));
9976 }
10077 if !guest.is_absolute() {
10178 guest = PathBuf::from("/root").join(guest);
@@ -107,10 +84,10 @@ impl DirectoryShare {
10784 })
10885 }
10986
110− 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> {
11188 let parts: Vec<&str> = spec.split(':').collect();
11289 if parts.len() < 2 || parts.len() > 3 {
113− return Err(format!("Invalid mount spec: {spec}").into());
90+ bail!(format!("invalid mount spec: {spec}"));
11491 }
11592 let host = expand_tilde_path(parts[0]);
11693 let guest = PathBuf::from(parts[1]);
@@ -119,11 +96,10 @@ impl DirectoryShare {
11996 "read-only" => true,
12097 "read-write" => false,
12198 _ => {
122− return Err(format!(
99+ bail!(format!(
123100 "Invalid mount mode '{}'; expected read-only or read-write",
124101 parts[2]
125− )
126− .into());
102+ ));
127103 }
128104 }
129105 } else {
@@ -167,19 +143,28 @@ pub struct VmArg {
167143 pub mounts: Vec<String>,
168144}
169145
170−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<()>
171155where
172156 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
173157{
174− 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)
175159}
176160
177−pub(crate) fn run_with_args_and_extras<F>(
161+pub fn run_with_args_and_extras<F>(
178162 args: VmArg,
179163 io_handler: F,
180164 extra_login_actions: Vec<LoginAction>,
181165 extra_directory_shares: Vec<DirectoryShare>,
182−) -> Result<(), Box<dyn std::error::Error>>
166+ status: Option<&StatusEmitter<'_>>,
167+) -> Result<()>
183168where
184169 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
185170{
@@ -188,7 +173,7 @@ where
188173 let project_root = env::current_dir()?;
189174 let project_name = project_root
190175 .file_name()
191− .ok_or("Project directory has no name")?
176+ .with_context(|| "Project directory has no name")?
192177 .to_string_lossy()
193178 .into_owned();
194179
@@ -200,8 +185,10 @@ where
200185 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
201186
202187 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
203− let status_file = StatusFile::new(instance_dir.join(STATUS_FILE_NAME));
204− 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");
205192
206193 let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
207194 let base_compressed = cache_dir.join(basename_compressed);
@@ -225,13 +212,14 @@ where
225212 &base_compressed,
226213 &default_raw,
227214 std::slice::from_ref(&mise_directory_share),
228− Some(&status_file),
215+ Some(&provision_log),
216+ status,
229217 )?;
230218 let _ = ensure_instance_disk(
231219 &instance_raw,
232220 &default_raw,
233− args.disk_bytes,
234− Some(&status_file),
221+ ByteSize(args.disk_bytes),
222+ status,
235223 )?;
236224 let base_size = fs::metadata(&default_raw)?.len();
237225 let instance_size = fs::metadata(&instance_raw)?.len();
@@ -261,7 +249,7 @@ where
261249 }
262250
263251 if needs_resize {
264− 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)?;
265253 login_actions.push(Send(resize_cmd));
266254 }
267255
@@ -277,25 +265,29 @@ where
277265 &directory_shares[..],
278266 args.cpu_count,
279267 args.ram_bytes,
280− Some(&status_file),
268+ status,
281269 io_handler,
282270 )
283271}
284272
285−pub(crate) fn script_command_from_content(
286− label: &str,
287− script: &str,
288−) -> Result<String, Box<dyn std::error::Error>> {
273+pub fn script_command_from_content(label: &str, script: &str) -> Result<String> {
289274 let marker = "VIBE_SCRIPT_EOF";
290275 let guest_dir = "/tmp/vibe-scripts";
291276 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);
292284 let command = format!(
293− "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}"
294286 );
295287 if script.contains(marker) {
296− return Err(
297− format!("Script '{label}' contains marker '{marker}', cannot safely upload").into(),
298− );
288+ bail!(format!(
289+ "Script '{label}' contains marker '{marker}', cannot safely upload"
290+ ));
299291 }
300292 Ok(command)
301293}
@@ -359,6 +351,12 @@ enum WaitResult {
359351 Found,
360352}
361353
354+#[derive(PartialEq, Eq)]
355+enum WaitAnyResult {
356+ Timeout,
357+ Found(usize),
358+}
359+
362360pub enum VmInput {
363361 Bytes(Vec<u8>),
364362 Shutdown,
@@ -366,6 +364,7 @@ pub enum VmInput {
366364
367365enum VmOutput {
368366 LoginActionTimeout { action: String, timeout: Duration },
367+ LoginActionFailed { action: String, reason: String },
369368}
370369
371370#[derive(Default)]
@@ -402,6 +401,41 @@ impl OutputMonitor {
402401 WaitResult::Found
403402 }
404403 }
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
405439}
406440
407441#[derive(Debug)]
@@ -450,18 +484,16 @@ impl IoControl {
450484fn ensure_base_image(
451485 base_raw: &Path,
452486 base_compressed: &Path,
453− status: Option<&StatusFile>,
454−) -> Result<(), Box<dyn std::error::Error>> {
487+ status: Option<&StatusEmitter<'_>>,
488+) -> Result<()> {
455489 if base_raw.exists() {
456490 return Ok(());
457491 }
458492
459493 if !base_compressed.exists()
460− || 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
461495 {
462− if let Some(status) = status {
463− status.update("downloading base image...");
464− }
496+ emit_status(status, "downloading base image...");
465497 tracing::info!("downloading base image");
466498 let status = Command::new("curl")
467499 .args([
@@ -476,15 +508,14 @@ fn ensure_base_image(
476508 ])
477509 .status()?;
478510 if !status.success() {
479− return Err("Failed to download base image".into());
511+ bail!("failed to download base image");
480512 }
481513 }
482514
483515 // Check SHA
484516 {
485− if let Some(status) = status {
486− status.update("verifying base image...");
487− }
517+ emit_status(status, "verifying base image...");
518+ tracing::info!("verifying base image...");
488519 let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
489520
490521 let mut child = Command::new("/usr/bin/shasum")
@@ -502,25 +533,25 @@ fn ensure_base_image(
502533
503534 let status = child.wait().expect("failed to wait on child");
504535 if !status.success() {
505− 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+ ));
506539 }
507540 }
508541
509− if let Some(status) = status {
510− status.update("decompressing base image...");
511− }
512− tracing::info!("decompressing base image");
542+ emit_status(status, "decompressing base image...");
543+ tracing::info!("decompressing base image...");
513544 let status = Command::new("tar")
514545 .args([
515546 "-xOf",
516547 &base_compressed.to_string_lossy(),
517548 BASE_DISK_RAW_NAME,
518549 ])
519− .stdout(std::fs::File::create(base_raw)?)
550+ .stdout(fs::File::create(base_raw)?)
520551 .status()?;
521552
522553 if !status.success() {
523− return Err("Failed to decompress base image".into());
554+ bail!("Failed to decompress base image");
524555 }
525556
526557 Ok(())
@@ -531,29 +562,56 @@ fn ensure_default_image(
531562 base_compressed: &Path,
532563 default_raw: &Path,
533564 directory_shares: &[DirectoryShare],
534− status: Option<&StatusFile>,
535−) -> Result<(), Box<dyn std::error::Error>> {
565+ provision_log: Option<&Path>,
566+ status: Option<&StatusEmitter<'_>>,
567+) -> Result<()> {
536568 if default_raw.exists() {
537569 return Ok(());
538570 }
539571
540572 ensure_base_image(base_raw, base_compressed, status)?;
541573
542− if let Some(status) = status {
543− status.update("configuring base image...");
544− }
545− tracing::info!("configuring base image");
574+ emit_status(status, "configuring base image...");
575+ tracing::info!("configuring base image...");
546576 fs::copy(base_raw, default_raw)?;
547577
548− let provision_command = script_command_from_content(PROVISION_SCRIPT_NAME, PROVISION_SCRIPT)?;
549− run_vm(
550− default_raw,
551− &[Send(provision_command)],
552− directory_shares,
553− DEFAULT_CPU_COUNT,
554− DEFAULT_RAM_BYTES,
555− None,
556− )?;
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+ }
557615
558616 Ok(())
559617}
@@ -561,18 +619,16 @@ fn ensure_default_image(
561619fn ensure_instance_disk(
562620 instance_raw: &Path,
563621 template_raw: &Path,
564− target_bytes: u64,
565− status: Option<&StatusFile>,
566−) -> Result<bool, Box<dyn std::error::Error>> {
622+ target_bytes: ByteSize,
623+ status: Option<&StatusEmitter<'_>>,
624+) -> Result<bool> {
567625 if instance_raw.exists() {
568− let current_size = fs::metadata(instance_raw)?.len();
626+ let current_size = ByteSize(fs::metadata(instance_raw)?.len());
569627 if current_size != target_bytes {
570− let current_gb = current_size as f64 / (1024.0 * 1024.0 * 1024.0);
571− 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;
572630 tracing::warn!(
573− current_bytes = current_size,
574− target_bytes,
575− "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.",
576632 current_gb,
577633 target_gb
578634 );
@@ -580,30 +636,28 @@ fn ensure_instance_disk(
580636 return Ok(false);
581637 }
582638
583− let template_size = fs::metadata(template_raw)?.len();
639+ let template_size = ByteSize(fs::metadata(template_raw)?.len());
584640 if target_bytes < template_size {
585− return Err(format!(
586− "Requested disk size {} bytes is smaller than base image size {} bytes",
641+ bail!(format!(
642+ "Requested disk size {} is smaller than base image size {}",
587643 target_bytes, template_size
588− )
589− .into());
644+ ));
590645 }
591646 let target_size = target_bytes;
592647 let needs_resize = target_size > template_size;
593648
594− if let Some(status) = status {
595− status.update("creating instance disk...");
596− }
649+ emit_status(status, "creating instance disk...");
650+ tracing::info!("creating instance disk...");
597651 tracing::info!(path = %template_raw.display(), "creating instance disk");
598− std::fs::create_dir_all(instance_raw.parent().unwrap())?;
652+ fs::create_dir_all(instance_raw.parent().unwrap())?;
599653 if target_size == template_size {
600654 fs::copy(template_raw, instance_raw)?;
601655 return Ok(needs_resize);
602656 }
603657
604− let mut dst = std::fs::File::create(instance_raw)?;
605− dst.set_len(target_size)?;
606− 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)?;
607661 std::io::copy(&mut src, &mut dst)?;
608662 Ok(needs_resize)
609663}
@@ -621,18 +675,17 @@ pub fn create_pipe() -> (OwnedFd, OwnedFd) {
621675 (read_stream.into(), write_stream.into())
622676}
623677
624−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+>(
625682 output_monitor: Arc<OutputMonitor>,
626683 vm_output_fd: OwnedFd,
627684 vm_input_fd: OwnedFd,
628685 io_control: Arc<IoControl>,
629686 mut on_line: F,
630687 mut on_output: G,
631−) -> IoContext
632−where
633− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
634− G: FnMut(&[u8]) + ::std::marker::Send + 'static,
635−{
688+) -> IoContext {
636689 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
637690
638691 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
@@ -780,14 +833,14 @@ where
780833 PollResult::Spurious => continue,
781834 PollResult::Ready(bytes) => {
782835 if io_control.forward_output() {
783− // enable raw mode, if we haven't already
836+ // enable raw mode if we haven't already
784837 if raw_guard.lock().unwrap().is_none()
785838 && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
786839 {
787840 *raw_guard.lock().unwrap() = Some(guard);
788841 }
789842
790− let mut stdout = std::io::stdout().lock();
843+ let mut stdout = io::stdout().lock();
791844 if stdout.write_all(bytes).is_err() {
792845 break;
793846 }
@@ -803,7 +856,7 @@ where
803856
804857 // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
805858 let mux_thread = thread::spawn(move || {
806− let mut vm_writer = std::fs::File::from(vm_input_fd);
859+ let mut vm_writer = fs::File::from(vm_input_fd);
807860 loop {
808861 match input_rx.recv() {
809862 Ok(VmInput::Bytes(data)) => {
@@ -833,7 +886,7 @@ pub fn spawn_vm_io_with_line_handler<F>(
833886 on_line: F,
834887) -> IoContext
835888where
836− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
889+ F: FnMut(&str) -> bool + std::marker::Send + 'static,
837890{
838891 spawn_vm_io_with_hooks(
839892 output_monitor,
@@ -853,6 +906,36 @@ pub fn spawn_vm_io(
853906 spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
854907}
855908
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+
856939impl IoContext {
857940 pub fn shutdown(self) {
858941 let _ = self.input_tx.send(VmInput::Shutdown);
@@ -870,7 +953,7 @@ fn create_vm_configuration(
870953 vm_writes_to_fd: OwnedFd,
871954 cpu_count: usize,
872955 ram_bytes: u64,
873−) -> Result<Retained<VZVirtualMachineConfiguration>, Box<dyn std::error::Error>> {
956+) -> Result<Retained<VZVirtualMachineConfiguration>> {
874957 unsafe {
875958 let platform =
876959 VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
@@ -904,7 +987,7 @@ fn create_vm_configuration(
904987 false,
905988 VZDiskImageCachingMode::Automatic,
906989 VZDiskImageSynchronizationMode::Full,
907− ).unwrap();
990+ )?;
908991
909992 let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
910993 VZVirtioBlockDeviceConfiguration::alloc(),
@@ -991,7 +1074,7 @@ fn create_vm_configuration(
9911074 // Validate
9921075 config.validateWithError().map_err(|e| {
9931076 io::Error::other(format!(
994− "Invalid VM configuration: {:?}",
1077+ "invalid VM configuration: {:?}",
9951078 e.localizedDescription()
9961079 ))
9971080 })?;
@@ -1000,9 +1083,9 @@ fn create_vm_configuration(
10001083 }
10011084}
10021085
1003−fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std::error::Error>> {
1086+fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>> {
10041087 unsafe {
1005− let temp_dir = std::env::temp_dir();
1088+ let temp_dir = env::temp_dir();
10061089 let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
10071090 let url = nsurl_from_path(&temp_path)?;
10081091 let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
@@ -1018,8 +1101,8 @@ fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std
10181101fn spawn_login_actions_thread(
10191102 login_actions: Vec<LoginAction>,
10201103 output_monitor: Arc<OutputMonitor>,
1021− input_tx: mpsc::Sender<VmInput>,
1022− vm_output_tx: mpsc::Sender<VmOutput>,
1104+ input_tx: Sender<VmInput>,
1105+ vm_output_tx: Sender<VmOutput>,
10231106) -> thread::JoinHandle<()> {
10241107 thread::spawn(move || {
10251108 for a in login_actions {
@@ -1033,6 +1116,27 @@ fn spawn_login_actions_thread(
10331116 return;
10341117 }
10351118 }
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+ },
10361140 Send(mut text) => {
10371141 text.push('\n'); // Type the newline so the command is actually submitted.
10381142 input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
@@ -1048,9 +1152,9 @@ fn run_vm_with_io<F>(
10481152 directory_shares: &[DirectoryShare],
10491153 cpu_count: usize,
10501154 ram_bytes: u64,
1051− status: Option<&StatusFile>,
1155+ status: Option<&StatusEmitter<'_>>,
10521156 io_handler: F,
1053−) -> Result<(), Box<dyn std::error::Error>>
1157+) -> Result<()>
10541158where
10551159 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
10561160{
@@ -1097,23 +1201,22 @@ where
10971201
10981202 match rx.try_recv() {
10991203 Ok(result) => {
1100− result.map_err(|e| format!("Failed to start VM: {}", e))?;
1204+ result.map_err(|e| Error::msg(format!("Failed to start VM: {}", e)))?;
11011205 break;
11021206 }
11031207 Err(mpsc::TryRecvError::Empty) => continue,
11041208 Err(mpsc::TryRecvError::Disconnected) => {
1105− return Err("VM start channel disconnected".into());
1209+ bail!("VM start channel disconnected");
11061210 }
11071211 }
11081212 }
11091213
11101214 if Instant::now() >= start_deadline {
1111− return Err("Timed out waiting for VM to start".into());
1215+ bail!("Timed out waiting for VM to start");
11121216 }
11131217
1114− if let Some(status) = status {
1115− status.update("vm booting... go vibecoder!");
1116− }
1218+ emit_status(status, "vm booting... go vibecoder!");
1219+ tracing::info!("vm booting... go vibecoder!");
11171220 tracing::info!("vm booting");
11181221
11191222 let output_monitor = Arc::new(OutputMonitor::default());
@@ -1159,7 +1262,7 @@ where
11591262 );
11601263
11611264 let mut last_state = None;
1162− let mut exit_result = Ok(());
1265+ let mut exit_result: Result<(), String> = Ok(());
11631266 loop {
11641267 unsafe {
11651268 NSRunLoop::mainRunLoop().runMode_beforeDate(
@@ -1178,8 +1281,24 @@ where
11781281 exit_result = Err(format!(
11791282 "Login action ({}) timed out after {:?}; shutting down.",
11801283 action, timeout
1181− )
1182− .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+ ));
11831302 unsafe {
11841303 if vm.canRequestStop() {
11851304 if let Err(err) = vm.requestStopWithError() {
@@ -1195,7 +1314,7 @@ where
11951314 Err(mpsc::TryRecvError::Empty) => {}
11961315 Err(mpsc::TryRecvError::Disconnected) => {}
11971316 }
1198− if state != objc2_virtualization::VZVirtualMachineState::Running {
1317+ if state != VZVirtualMachineState::Running {
11991318 //eprintln!("VM stopped with state: {:?}", state);
12001319 break;
12011320 }
@@ -1205,7 +1324,7 @@ where
12051324
12061325 io_ctx.shutdown();
12071326
1208− exit_result
1327+ exit_result.map_err(Error::msg)
12091328}
12101329
12111330fn run_vm(
@@ -1214,8 +1333,8 @@ fn run_vm(
12141333 directory_shares: &[DirectoryShare],
12151334 cpu_count: usize,
12161335 ram_bytes: u64,
1217− status: Option<&StatusFile>,
1218−) -> Result<(), Box<dyn std::error::Error>> {
1336+ status: Option<&StatusEmitter<'_>>,
1337+) -> Result<()> {
12191338 run_vm_with_io(
12201339 disk_path,
12211340 login_actions,
@@ -1227,7 +1346,7 @@ fn run_vm(
12271346 )
12281347}
12291348
1230−fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
1349+fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>> {
12311350 let abs_path = if path.is_absolute() {
12321351 path.to_path_buf()
12331352 } else {
@@ -1236,7 +1355,7 @@ fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::E
12361355 let ns_path = NSString::from_str(
12371356 abs_path
12381357 .to_str()
1239− .ok_or("Non-UTF8 path encountered while building NSURL")?,
1358+ .with_context(|| "non-UTF8 path encountered while building NSURL")?,
12401359 );
12411360 Ok(NSURL::fileURLWithPath(&ns_path))
12421361}
@@ -1251,7 +1370,7 @@ fn enable_raw_mode(fd: i32) -> io::Result<RawModeGuard> {
12511370 let original = attributes;
12521371
12531372 // Disable translation of carriage return to newline on input
1254− attributes.c_iflag &= !(libc::ICRNL);
1373+ attributes.c_iflag &= !libc::ICRNL;
12551374 // Disable canonical mode (line buffering), echo, and signal generation
12561375 attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
12571376 attributes.c_cc[libc::VMIN] = 0;
@@ -1279,10 +1398,10 @@ impl Drop for RawModeGuard {
12791398
12801399// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
12811400pub fn ensure_signed() {
1282− if std::env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
1401+ if env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
12831402 return;
12841403 }
1285− 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");
12861405 let exe_str = exe.to_str().expect("exe path not valid utf-8");
12871406
12881407 let has_required_entitlements = {
@@ -1304,8 +1423,8 @@ pub fn ensure_signed() {
13041423 }
13051424
13061425 const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1307− let entitlements_path = std::env::temp_dir().join("entitlements.plist");
1308− 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");
13091428
13101429 let output = Command::new("codesign")
13111430 .args([
@@ -1318,7 +1437,7 @@ pub fn ensure_signed() {
13181437 ])
13191438 .output();
13201439
1321− let _ = std::fs::remove_file(&entitlements_path);
1440+ let _ = fs::remove_file(&entitlements_path);
13221441
13231442 match output {
13241443 Ok(o) if o.status.success() => {
@@ -1326,7 +1445,7 @@ pub fn ensure_signed() {
13261445 if !stderr.trim().is_empty() {
13271446 tracing::debug!(codesign_stderr = %stderr.trim(), "codesign output");
13281447 }
1329− 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();
13301449 tracing::error!(error = %err, "failed to re-exec after signing");
13311450 std::process::exit(1);
13321451 }
src/vm_manager.rs+294−121
@@ -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,37 +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))]
3336const 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+}
3451
3552pub fn ensure_manager(
3653 raw_args: &[std::ffi::OsString],
3754 auto_shutdown_ms: u64,
3855 config_path: Option<&Path>,
39−) -> Result<UnixStream, Box<dyn std::error::Error>> {
56+) -> Result<UnixStream> {
4057 let project_root = env::current_dir()?;
4158 tracing::debug!(root = %project_root.display(), "ensure vm manager");
4259 let instance_dir = ensure_instance_dir(&project_root)?;
43− cleanup_stale_manager(&instance_dir);
60+ cleanup_stale_manager(&project_root)?;
4461 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
4562
4663 if let Ok(stream) = UnixStream::connect(&socket_path) {
4764 send_client_pid(&stream);
48− tracing::info!(path = %socket_path.display(), "connected to existing vm manager");
65+ tracing::info!(path = %socket_path.display(), "connected to an existing vm manager");
4966 return Ok(stream);
5067 }
5168
@@ -83,12 +100,11 @@ pub fn ensure_manager(
83100 drop(lock_file.take());
84101 let _ = fs::remove_file(&lock_path);
85102 }
86− return Err(format!(
87− "Timed out waiting for vm manager socket: {} ({})",
103+ bail!(format!(
104+ "timed out waiting for vm manager socket: {} ({})",
88105 socket_path.display(),
89106 err
90− )
91− .into());
107+ ));
92108 }
93109 thread::sleep(Duration::from_millis(100));
94110 }
@@ -96,12 +112,19 @@ pub fn ensure_manager(
96112 }
97113}
98114
99−pub fn run_manager(
100− args: vm::VmArg,
101− auto_shutdown_ms: u64,
102−) -> Result<(), Box<dyn std::error::Error>> {
115+pub fn run_manager(args: vm::VmArg, auto_shutdown_ms: u64) -> Result<()> {
103116 let project_root = env::current_dir()?;
104117 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+ }
105128 let _pid_guard = ensure_pid_file(&project_root)?;
106129 #[cfg(feature = "mock-vm")]
107130 tracing::info!("vm manager using mock executor");
@@ -119,7 +142,6 @@ pub fn run_manager(
119142 #[cfg(feature = "mock-vm")]
120143 {
121144 ManagerOptions {
122− ensure_signed: false,
123145 detach: true,
124146 prepare_vm: false,
125147 }
@@ -127,7 +149,6 @@ pub fn run_manager(
127149 #[cfg(not(feature = "mock-vm"))]
128150 {
129151 ManagerOptions {
130− ensure_signed: true,
131152 detach: true,
132153 prepare_vm: true,
133154 }
@@ -141,25 +162,22 @@ fn spawn_manager_process(
141162 auto_shutdown_ms: u64,
142163 instance_dir: &Path,
143164 config_path: Option<&Path>,
144−) -> Result<(), Box<dyn std::error::Error>> {
165+) -> Result<()> {
145166 let exe = env::current_exe()?;
146167 let mut supervisor_exe = exe.clone();
147168 supervisor_exe.set_file_name("vibebox-supervisor");
148− let use_supervisor = supervisor_exe.exists();
149− let mut cmd = if use_supervisor {
150− Command::new(supervisor_exe)
151− } else {
152− let mut cmd = Command::new(exe);
153− cmd.arg0("vibebox-supervisor");
154− cmd
155− };
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);
156177 if raw_args.len() > 1 {
157178 cmd.args(&raw_args[1..]);
158179 }
159180 cmd.env("VIBEBOX_INTERNAL", "1");
160− if !use_supervisor {
161− cmd.env("VIBEBOX_VM_MANAGER", "1");
162− }
163181 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
164182 cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
165183 if let Some(path) = config_path {
@@ -187,22 +205,22 @@ fn spawn_manager_process(
187205 Ok(())
188206}
189207
190−fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard, Box<dyn std::error::Error>> {
208+fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard> {
191209 let instance_dir = ensure_instance_dir(project_root)?;
192210 let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
193211 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_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− if is_socket_path(&socket_path) {
199− 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})");
200215 }
201− tracing::warn!(
202− pid,
203− path = %socket_path.display(),
204− "stale pid file detected with missing socket"
205− );
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 => {}
206224 }
207225 let _ = fs::remove_file(&pid_path);
208226 fs::write(&pid_path, format!("{}\n", std::process::id()))?;
@@ -210,15 +228,18 @@ fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard, Box<dyn std::err
210228 Ok(PidFileGuard { path: pid_path })
211229}
212230
213−fn cleanup_stale_manager(instance_dir: &Path) {
214− let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
215− if let Ok(content) = fs::read_to_string(&pid_path)
216− && let Ok(pid) = content.trim().parse::<u32>()
217− && pid_is_alive(pid)
218− {
219− 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(());
220240 }
221241 let _ = fs::remove_file(&pid_path);
242+ Ok(())
222243}
223244
224245fn inject_project_mount(
@@ -245,12 +266,6 @@ fn inject_project_mount(
245266 mounts.insert(0, format!("{host}:{guest_tilde}:read-write"));
246267}
247268
248−fn is_socket_path(path: &Path) -> bool {
249− fs::metadata(path)
250− .map(|meta| meta.file_type().is_socket())
251− .unwrap_or(false)
252−}
253−
254269fn prepare_mounts_and_links(mut args: vm::VmArg, ssh_user: &str) -> (vm::VmArg, String) {
255270 let mut links = Vec::new();
256271 let mut mounts = Vec::with_capacity(args.mounts.len());
@@ -394,6 +409,30 @@ fn wait_for_disconnect(mut stream: UnixStream) {
394409 }
395410}
396411
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+
397436fn send_client_pid(stream: &UnixStream) {
398437 let pid = std::process::id();
399438 let payload = format!("pid={pid}\n");
@@ -403,7 +442,7 @@ fn send_client_pid(stream: &UnixStream) {
403442 }
404443}
405444
406−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>> {
407446 match fs::OpenOptions::new()
408447 .write(true)
409448 .create_new(true)
@@ -437,6 +476,12 @@ fn is_lock_stale(lock_path: &Path) -> bool {
437476 }
438477}
439478
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+
440485fn pid_is_alive(pid: u32) -> bool {
441486 let pid = pid as libc::pid_t;
442487 let result = unsafe { libc::kill(pid, 0) };
@@ -450,12 +495,6 @@ fn pid_is_alive(pid: u32) -> bool {
450495 }
451496}
452497
453−fn read_lock_pid(lock_path: &Path) -> Option<u32> {
454− let content = fs::read_to_string(lock_path).ok()?;
455− let line = content.lines().next()?;
456− line.strip_prefix("pid=")?.trim().parse::<u32>().ok()
457−}
458−
459498fn read_client_pid(stream: &UnixStream) -> Option<u32> {
460499 let mut stream = stream.try_clone().ok()?;
461500 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
@@ -491,12 +530,14 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
491530#[cfg_attr(feature = "mock-vm", allow(dead_code))]
492531fn spawn_manager_io(
493532 config: Arc<Mutex<InstanceConfig>>,
494− instance_dir: PathBuf,
533+ project_dir: PathBuf,
534+ clients: ClientStreams,
535+ latest_status: SharedStatus,
495536 output_monitor: Arc<vm::OutputMonitor>,
496537 vm_output_fd: std::os::unix::io::OwnedFd,
497538 vm_input_fd: std::os::unix::io::OwnedFd,
498539) -> vm::IoContext {
499− 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);
500541 let log_file = fs::OpenOptions::new()
501542 .create(true)
502543 .write(true)
@@ -505,7 +546,6 @@ fn spawn_manager_io(
505546 .ok()
506547 .map(|file| Arc::new(Mutex::new(file)));
507548
508− let instance_path = instance_dir.join(INSTANCE_FILENAME);
509549 let config_for_output = config.clone();
510550 let log_for_output = log_file.clone();
511551 let mut line_buf = String::new();
@@ -528,6 +568,17 @@ fn spawn_manager_io(
528568 line_buf.drain(..=pos);
529569
530570 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+ }
531582 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
532583 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
533584 let ip = extract_ipv4(ip_raw).unwrap_or_default();
@@ -536,7 +587,7 @@ fn spawn_manager_io(
536587 && cfg.vm_ipv4.as_deref() != Some(ip.as_str())
537588 {
538589 cfg.vm_ipv4 = Some(ip.clone());
539− let _ = write_instance_config(&instance_path, &cfg);
590+ let _ = write_instance_config(&project_dir, &cfg);
540591 }
541592 }
542593 }
@@ -559,12 +610,12 @@ enum ManagerEvent {
559610}
560611
561612struct ManagerOptions {
562− ensure_signed: bool,
563613 detach: bool,
564614 prepare_vm: bool,
565615}
566616
567617trait VmExecutor {
618+ #[allow(clippy::too_many_arguments)]
568619 fn run_vm(
569620 &self,
570621 args: vm::VmArg,
@@ -572,8 +623,10 @@ trait VmExecutor {
572623 extra_shares: Vec<DirectoryShare>,
573624 config: Arc<Mutex<InstanceConfig>>,
574625 instance_dir: PathBuf,
626+ clients: ClientStreams,
627+ latest_status: SharedStatus,
575628 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
576− ) -> Result<(), Box<dyn std::error::Error>>;
629+ ) -> Result<()>;
577630}
578631
579632#[cfg_attr(feature = "mock-vm", allow(dead_code))]
@@ -586,15 +639,22 @@ impl VmExecutor for RealVmExecutor {
586639 extra_login_actions: Vec<LoginAction>,
587640 extra_shares: Vec<DirectoryShare>,
588641 config: Arc<Mutex<InstanceConfig>>,
589− instance_dir: PathBuf,
642+ project_dir: PathBuf,
643+ clients: ClientStreams,
644+ latest_status: SharedStatus,
590645 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
591− ) -> Result<(), Box<dyn std::error::Error>> {
646+ ) -> Result<()> {
647+ let status_callback = |status: &str| {
648+ broadcast_status(&clients, &latest_status, status);
649+ };
592650 vm::run_with_args_and_extras(
593651 args,
594652 |output_monitor, vm_output_fd, vm_input_fd| {
595653 let io_ctx = spawn_manager_io(
596654 config.clone(),
597− instance_dir.clone(),
655+ project_dir.clone(),
656+ clients.clone(),
657+ latest_status.clone(),
598658 output_monitor,
599659 vm_output_fd,
600660 vm_input_fd,
@@ -604,6 +664,7 @@ impl VmExecutor for RealVmExecutor {
604664 },
605665 extra_login_actions,
606666 extra_shares,
667+ Some(&status_callback),
607668 )
608669 }
609670}
@@ -620,8 +681,10 @@ impl VmExecutor for MockVmExecutor {
620681 _extra_shares: Vec<DirectoryShare>,
621682 _config: Arc<Mutex<InstanceConfig>>,
622683 _instance_dir: PathBuf,
684+ _clients: ClientStreams,
685+ _latest_status: SharedStatus,
623686 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
624− ) -> Result<(), Box<dyn std::error::Error>> {
687+ ) -> Result<()> {
625688 let (tx, rx) = mpsc::channel::<VmInput>();
626689 *vm_input_tx.lock().unwrap() = Some(tx);
627690 tracing::info!("mock vm executor running");
@@ -647,24 +710,14 @@ fn run_manager_with(
647710 auto_shutdown_ms: u64,
648711 executor: &dyn VmExecutor,
649712 options: ManagerOptions,
650−) -> Result<(), Box<dyn std::error::Error>> {
651− if options.ensure_signed {
652− let _had_skip = env::var("VIBEBOX_SKIP_CODESIGN").ok();
653− unsafe {
654− env::remove_var("VIBEBOX_SKIP_CODESIGN");
655− }
656− vm::ensure_signed();
657− unsafe {
658− env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
659− }
660− }
713+) -> Result<()> {
661714 if options.detach {
662715 detach_from_terminal();
663716 }
664717
665718 let project_name = project_root
666719 .file_name()
667− .ok_or("Project directory has no name")?
720+ .ok_or_else(|| anyhow!("Project directory has no name"))?
668721 .to_string_lossy()
669722 .into_owned();
670723 let instance_dir = ensure_instance_dir(project_root)?;
@@ -672,16 +725,16 @@ fn run_manager_with(
672725 let _ = ensure_ssh_keypair(&instance_dir)?;
673726 }
674727
675− let mut config = load_or_create_instance_config(&instance_dir)?;
728+ let mut config = load_or_create_instance_config(project_root)?;
676729 if config.vm_ipv4.is_some() {
677730 config.vm_ipv4 = None;
678− write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
731+ write_instance_config(project_root, &config)?;
679732 }
680733 let config = Arc::new(Mutex::new(config));
681734 let ssh_user = config
682735 .lock()
683− .map(|cfg| cfg.ssh_user_display())
684− .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"))?;
685738 if !args.no_default_mounts {
686739 inject_project_mount(&mut args.mounts, project_root, &ssh_user, &project_name);
687740 }
@@ -689,11 +742,10 @@ fn run_manager_with(
689742
690743 let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}");
691744 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
692− let extra_shares = vec![DirectoryShare::new(
693− instance_dir.clone(),
694− ssh_guest_dir.clone().into(),
695− true,
696− )?];
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+ ];
697749 let extra_login_actions = build_ssh_login_actions(
698750 &config,
699751 &project_name,
@@ -716,17 +768,43 @@ fn run_manager_with(
716768 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
717769 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
718770
771+ let clients: ClientStreams = Arc::new(Mutex::new(Vec::new()));
772+ let latest_status: SharedStatus = Arc::new(Mutex::new(String::new()));
719773 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
720774 let event_tx_accept = event_tx.clone();
775+ let clients_accept = clients.clone();
776+ let latest_status_accept = latest_status.clone();
721777 thread::spawn(move || {
722778 for stream in listener.incoming() {
723779 match stream {
724780 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+ }
725799 let event_tx_conn = event_tx_accept.clone();
800+ let clients_conn = clients_accept.clone();
726801 thread::spawn(move || {
727802 let pid = read_client_pid(&stream);
728803 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
729804 wait_for_disconnect(stream);
805+ if let Some(fd) = client_fd {
806+ remove_client(&clients_conn, fd);
807+ }
730808 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
731809 });
732810 }
@@ -746,34 +824,43 @@ fn run_manager_with(
746824 extra_login_actions,
747825 extra_shares,
748826 config.clone(),
749− instance_dir.clone(),
827+ project_root.to_path_buf(),
828+ clients.clone(),
829+ latest_status.clone(),
750830 vm_input_tx.clone(),
751831 );
752832 tracing::info!("vm manager vm run completed");
753833 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+ }
754841 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
755− let event_loop_result: Result<(), String> = event_loop_handle
842+ let event_loop_result = event_loop_handle
756843 .join()
757− .unwrap_or_else(|_| Err("vm manager event loop panicked".into()))
844+ .unwrap_or_else(|_| Err(Error::msg("vm manager event loop panicked")))
758845 .map_err(|err| err.to_string());
759846 let _ = fs::remove_file(&socket_path);
760847 if let Err(err) = &event_loop_result {
761848 tracing::error!(error = %err, "vm manager exiting due to event loop error");
762− return Err(err.to_string().into());
849+ bail!(err.to_string());
763850 }
764851 if let Some(err) = vm_err {
765852 tracing::error!(error = %err, "vm manager exiting due to vm error");
766− return Err(err.into());
853+ bail!(err);
767854 }
768855 tracing::info!("vm manager exiting");
769− Ok(event_loop_result?)
856+ event_loop_result.map_err(Error::msg)
770857}
771858
772859fn manager_event_loop(
773860 event_rx: mpsc::Receiver<ManagerEvent>,
774861 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
775862 auto_shutdown_ms: u64,
776−) -> Result<(), String> {
863+) -> Result<()> {
777864 let mut ref_count: usize = 0;
778865 let mut shutdown_deadline: Option<Instant> = None;
779866 let mut shutdown_sent = false;
@@ -782,9 +869,14 @@ fn manager_event_loop(
782869 let hard_timeout = Duration::from_millis(HARD_SHUTDOWN_TIMEOUT_MS);
783870
784871 loop {
785− let timeout = match shutdown_deadline {
786− Some(deadline) => deadline.saturating_duration_since(Instant::now()),
787− 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),
788880 };
789881
790882 match event_rx.recv_timeout(timeout) {
@@ -824,6 +916,9 @@ fn manager_event_loop(
824916 && Instant::now() >= deadline
825917 && !shutdown_sent
826918 {
919+ if hard_deadline.is_none() {
920+ hard_deadline = Some(Instant::now() + hard_timeout);
921+ }
827922 let mut sent = false;
828923 if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
829924 if tx
@@ -843,19 +938,26 @@ fn manager_event_loop(
843938 shutdown_deadline = None;
844939 hard_deadline = Some(Instant::now() + hard_timeout);
845940 } else {
846− shutdown_deadline = Some(Instant::now() + Duration::from_millis(500));
941+ shutdown_deadline =
942+ Some(Instant::now() + Duration::from_millis(SHUTDOWN_RETRY_MS));
847943 }
848944 }
849945 if ref_count == 0
850− && shutdown_sent
851946 && let Some(deadline) = hard_deadline
852947 && Instant::now() >= deadline
853948 {
854− tracing::warn!(
855− timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
856− "force exiting: VM did not stop after shutdown timeout"
857− );
858− std::process::exit(1);
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+ );
959+ }
960+ force_exit("vm manager forced exit");
859961 }
860962 }
861963 Err(mpsc::RecvTimeoutError::Disconnected) => break,
@@ -868,7 +970,7 @@ fn manager_event_loop(
868970#[cfg(test)]
869971mod tests {
870972 use super::*;
871− use std::{sync::mpsc, time::Duration};
973+ use std::{fs, sync::mpsc, thread, time::Duration};
872974
873975 #[test]
874976 fn manager_powers_off_after_grace_when_no_refs() {
@@ -901,4 +1003,75 @@ mod tests {
9011003 let _ = event_tx.send(ManagerEvent::VmExited(None));
9021004 let _ = manager_thread.join();
9031005 }
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+ }
9041077}