on main help sign in
Cargo.lock+18−1
since splitsnapshots
unifiedsplit
@@ -82,6 +82,12 @@ dependencies = [
82 "windows-sys 0.61.2",
82 "windows-sys 0.61.2",
83]
83]
84
84
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+
85[[package]]
91[[package]]
86name = "assert_cmd"
92name = "assert_cmd"
87version = "2.1.2"
93version = "2.1.2"
@@ -144,6 +150,15 @@ version = "3.19.1"
144source = "registry+https://github.com/rust-lang/crates.io-index"
150source = "registry+https://github.com/rust-lang/crates.io-index"
145checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
151checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
146
152
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+
147[[package]]
162[[package]]
148name = "cassowary"
163name = "cassowary"
149version = "0.3.0"
164version = "0.3.0"
@@ -1303,10 +1318,12 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
1303
1318
1304[[package]]
1319[[package]]
1305name = "vibebox"
1320name = "vibebox"
1306−version = "0.2.3"
1321+version = "0.3.2"
1307dependencies = [
1322dependencies = [
1323+ "anyhow",
1308 "assert_cmd",
1324 "assert_cmd",
1309 "block2",
1325 "block2",
1326+ "bytesize",
1310 "clap",
1327 "clap",
1311 "color-eyre",
1328 "color-eyre",
1312 "crossterm",
1329 "crossterm",
Cargo.toml+3−1
@@ -1,6 +1,6 @@
1[package]
1[package]
2name = "vibebox"
2name = "vibebox"
3−version = "0.2.3"
3+version = "0.3.2"
4edition = "2024"
4edition = "2024"
5authors = ["Finn Sheng"]
5authors = ["Finn Sheng"]
6description = "Ultrafast CLI on Apple Silicon macOS for fast, sandboxed development and LLM agents."
6description = "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"] }
42tracing = "0.1"
42tracing = "0.1"
43tracing-subscriber = { version = "0.3", features = ["env-filter"] }
43tracing-subscriber = { version = "0.3", features = ["env-filter"] }
44dialoguer = "0.12.0"
44dialoguer = "0.12.0"
45+bytesize = {version = "2.3.1",features = ["serde"]}
46+anyhow = "1.0.101"
45
47
46[dev-dependencies]
48[dev-dependencies]
47assert_cmd = "2"
49assert_cmd = "2"
README.md+63−34
@@ -5,7 +5,7 @@
5 </picture>
5 </picture>
6 </a>
6 </a>
7</p>
7</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>
9
9
10<p align="center">
10<p align="center">
11 <a href="https://crates.io/crates/vibebox">
11 <a href="https://crates.io/crates/vibebox">
@@ -24,45 +24,77 @@
24 <a href="README.zh.md">简体中文</a>
24 <a href="README.zh.md">简体中文</a>
25</p>
25</p>
26
26
27−**VibeBox is a per-project micro-VM sandbox for coding agents on macOS (Apple Virtualization Framework), fast to enter,
27+**VibeBox is a per-project micro-VM sandbox for running coding agents on macOS (Apple Virtualization Framework).**
28−configurable, and built around sessions.**
28+It’s optimized for a *daily-driver* workflow: fast warm re-entry, explicit mounts, and reusable sessions.
29
29
30+**Who it’s for:** macOS users running coding agents who want real isolation without giving up a fast daily workflow.
31+
32+**Quick facts:** warm re-entry is typically **<5s** on my M3 (varies by machine/cache); first run downloads and
33+provisions a Debian base image (network dependent).
34+
35+**Security model:** Linux guest VM with explicit mount allowlists from `vibebox.toml` (repo-first, everything else
36+opt-in).
37+
30−- **Enter in seconds:** `vibebox` drops you into a reusable sandbox for the current repo
38+- **enter/attach in seconds:** `vibebox` drops you into a reusable sandbox for the current repo
31−- **Project-scoped:** explicit mounts + repo-contained changes
39+- **project-scoped by default:** explicit mounts + repo-contained changes (repo-first, everything else is allowlisted)
32−- **Sessioned:** multi-instance + session management (reuse, multiple terminals, cleanup)
40+- **sessioned:** multi-instance + session management (reuse, multiple terminals, cleanup)
33
41
34### Quick Demo
42### Quick Demo
35
43
36```bash
44```bash
37−# 1) from any repo
45+# from any repo
38cd my-project
46cd my-project
39vibebox
47vibebox
40```
48```
41
49
50+What you should see (roughly):
51+
52+```text
53+vibebox: starting (session: my-project)
54+vibebox: attaching...
55+vibecoder@vibebox:~/my-project$
56+```
57+
42[![VibeBox Terminal UI](docs/screenshot.png)](https://vibebox.robcholz.com)
58[![VibeBox Terminal UI](docs/screenshot.png)](https://vibebox.robcholz.com)
43
59
44---
60---
45
61
46### Why I built VibeBox
62### Why I built VibeBox
47
63
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
64+I use coding agents daily, and I wanted to give them a real shell without handing them my host machine.
49−things
65+Lock things down and you get nonstop confirmations; loosen it up and you worry about deleting files, touching secrets,
50−down, I get interrupted by constant “are you sure?” prompts. If I loosen it up, I worry the agent might touch the
66+or wandering outside the repo.
51−wrong files or run something I didn’t intend.
52
67
53−I wanted something that feels as frictionless as giving an agent a real shell, but with a hard isolation boundary. So I
68+VibeBox is the middle ground: a per-repo sandbox with a hard VM boundary, fast re-entry, and explicit mounts. It’s built
54−built VibeBox: a per-project micro-VM sandbox that starts fast, keeps changes contained to the repo, and lets me iterate
69+to be “always on” for agent work without turning safety into a chore.
55−without babysitting permissions.
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.
56
86
57### Comparison
87### Comparison
58
88
59Here’s why I didn’t just use existing options:
89Here’s why I didn’t just use existing options:
60
90
61−- **vibe**: super convenient, but it’s too minimal for what I need. It lacks basic configuration, and it doesn’t give me
91+- **vibe**: super convenient and nails “zero-config, just go”. VibeBox is intentionally on a different axis: per-repo
62− the multi-instance + session management my workflow wants.
92+ config + sessions + multi-instance lifecycle.
63- **QEMU**: powerful, but the configuration surface area is huge. For day-to-day sandboxing it’s not “open a repo and
93- **QEMU**: powerful, but the configuration surface area is huge. For day-to-day sandboxing it’s not “open a repo and
64 go” — it’s a project on its own.
94 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.
66
98
67That’s what pushed me to build **VibeBox**: I wanted a per-project sandbox that’s fast to enter (just `vibebox`),
99That’s what pushed me to build **VibeBox**: I wanted a per-project sandbox that’s fast to enter (just `vibebox`),
68supports real configuration + sessions, and keeps a hard isolation boundary.
100supports real configuration + sessions, and keeps a hard isolation boundary.
@@ -70,13 +102,13 @@ supports real configuration + sessions, and keeps a hard isolation boundary.
70### Installation
102### Installation
71
103
72```bash
104```bash
73−# YOLO
105+# install script
74curl -fsSL https://raw.githubusercontent.com/robcholz/vibebox/main/install | bash
106curl -fsSL https://raw.githubusercontent.com/robcholz/vibebox/main/install | bash
75
107
76−# Package managers
108+# package managers
77cargo install vibebox
109cargo install vibebox
78
110
79−# Or manually
111+# manual install
80curl -LO https://github.com/robcholz/vibebox/releases/download/latest/vibebox-macos-arm64.zip
112curl -LO https://github.com/robcholz/vibebox/releases/download/latest/vibebox-macos-arm64.zip
81unzip vibebox-macos-arm64.zip
113unzip vibebox-macos-arm64.zip
82mkdir -p ~/.local/bin
114mkdir -p ~/.local/bin
@@ -86,7 +118,7 @@ export PATH="$HOME/.local/bin:$PATH"
86
118
87**Requirements**
119**Requirements**
88
120
89−- macOS on Apple Silicon (Vibebox uses Apple's virtualization APIs).
121+- macOS on Apple Silicon (VibeBox uses Apple's virtualization APIs).
90
122
91**First Run**
123**First Run**
92
124
@@ -102,7 +134,7 @@ cd /path/to/your/project
102vibebox
134vibebox
103```
135```
104
136
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.
106
138
107**Configuration (`vibebox.toml`)**
139**Configuration (`vibebox.toml`)**
108
140
@@ -153,7 +185,7 @@ vibebox explain # show mounts and network info
153- Default SSH user: `vibecoder`
185- Default SSH user: `vibecoder`
154- Hostname: `vibebox`
186- Hostname: `vibebox`
155- Base image provisioning installs: build tools, `git`, `curl`, `ripgrep`, `openssh-server`, and `sudo`.
187- 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
157 `@anthropic-ai/claude-code` (best-effort).
189 `@anthropic-ai/claude-code` (best-effort).
158- Shell aliases: `:help` and `:exit`.
190- Shell aliases: `:help` and `:exit`.
159
191
@@ -168,27 +200,24 @@ vibebox explain # show mounts and network info
168If you're interested in contributing to VibeBox, please read our [contributing docs](CONTRIBUTING.md) before
200If you're interested in contributing to VibeBox, please read our [contributing docs](CONTRIBUTING.md) before
169submitting a pull request.
201submitting a pull request.
170
202
171−### Using VibeBox
172−
173−Feel free to use
174−
175### FAQ
203### FAQ
176
204
177−#### How is this different from other Sandboxes?
205+#### How is this different from other sandboxes?
178
206
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:
180
208
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.
182- One simple command — `vibebox` — drops you into the sandbox from your project.
210- One simple command — `vibebox` — drops you into the sandbox from your project.
183- Configuration lives in `vibebox.toml`, where you can set CPU, RAM, disk size, and mounts.
211- Configuration lives in `vibebox.toml`, where you can set CPU, RAM, disk size, and mounts.
212+- Sessions are first-class: reuse, multiple terminals, cleanup.
184
213
185−### Special Thank
214+### Special thanks
186
215
187[vibe](https://github.com/lynaghk/vibe) by lynaghk.
216[vibe](https://github.com/lynaghk/vibe) by lynaghk.
188
217
189−And amazing Rust community, without your rich crates and fantastic toolchain like [crates.io](https://crates.io), this
218+And the amazing Rust community — without the ecosystem and toolchain like [crates.io](https://crates.io), this wouldn't
190−wouldn't be possible!
219+be possible!
191
220
192---
221---
193
222
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 @@
5 </picture>
5 </picture>
6 </a>
6 </a>
7</p>
7</p>
8−<p align="center">超高速、开源的 AI 沙盒。</p>
8+<p align="center">用于安全运行 coding agents 的超高速开源沙盒。</p>
9
9
10<p align="center">
10<p align="center">
11 <a href="https://crates.io/crates/vibebox">
11 <a href="https://crates.io/crates/vibebox">
@@ -24,8 +24,34 @@
24 <a href="README.md">English</a>
24 <a href="README.md">English</a>
25</p>
25</p>
26
26
27−VibeBox 是一个轻量、启动极快的沙盒环境,让 AI Agent 可以安全地直接跑命令、改文件、执行代码,不会不停弹“要不要允许”的提示。它基于
27+**VibeBox 是一个按项目划分的 micro-VM 沙盒,用于在 macOS 上运行 coding agents(基于 Apple Virtualization Framework)。**
28−Apple 的 Virtualization Framework 做到彻底隔离,所以无论 Agent 在里面怎么折腾,你的真实系统都不会被影响;同时做到了低内存和磁盘占用。
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+```
29
55
30[![VibeBox Terminal UI](docs/screenshot.png)](https://vibebox.robcholz.com)
56[![VibeBox Terminal UI](docs/screenshot.png)](https://vibebox.robcholz.com)
31
57
@@ -33,22 +59,33 @@ Apple 的 Virtualization Framework 做到彻底隔离,所以无论 Agent 在
33
59
34### 我为什么做 VibeBox
60### 我为什么做 VibeBox
35
61
36−我平时经常用像 Codex 和 CC 这样的
62+我每天都在用 coding agents,也希望它们有一个真实的 shell,但不想把宿主机直接交出去。
37−agent,但一直不太敢让它们直接在我的宿主机上跑。把权限收紧一点,就会被各种“你确定吗?”的确认弹窗不停打断;放松一点,又担心它哪天误操作,碰到不该碰的文件,或者跑了我没打算执行的命令。
63+权限收紧会被不停的确认打断;权限放开又担心误删文件、触及密钥,或者跑出仓库边界。
64+
65+VibeBox 是中间方案:按项目隔离、硬 VM 边界、快速回到工作状态、显式挂载。它适合把 agent 当成日常工具,而不是把安全变成负担。
66+
67+### 为什么是 micro-VM(而不是容器)?
38
68
39−我想要的是一种体验:像给 agent 一个真实 shell 一样顺滑,但同时又有一道硬隔离的安全边界。于是我做了 VibeBox:一个按项目划分的
69+容器很好用。VibeBox 并不是用来替代 Docker/devcontainers 去构建服务。
40−micro-VM 沙箱,启动很快,改动被限制在仓库范围内,让我可以更高频地迭代,而不用一直盯着权限确认。
70+
71+我更想要的是在 macOS 上适合 agent 的 VM 默认形态:
72+
73+- **默认是 guest-kernel 隔离边界:** 让 agent 跑任意命令时,“安全模式”是 Linux 来宾而不是宿主机。
74+- **会话是第一等公民:** 按项目附加/复用,多终端进入同一沙盒,可靠清理,避免孤儿环境。
75+- **显式挂载白名单作为主要 UX:** 默认仅项目目录,其它都需显式允许。
76+- **最少的每项目配置:** 你可以用 compose/devcontainers 实现一部分,但我想要的是一个命令,在不同仓库间直接工作,不必维护容器配置来获得基础“安全
77+ shell”体验。
41
78
42### 对比
79### 对比
43
80
44下面是我为什么没有直接用现成方案的原因:
81下面是我为什么没有直接用现成方案的原因:
45
82
46−- **vibe**:非常方便,但对我来说太“极简”了。它缺少一些基础配置能力,也没法提供我工作流里需要的多开和 session 管理。
83+- **vibe**:非常方便,“零配置、直接用”做得很好。但 VibeBox 走的是另一条路:按项目配置 + 会话 + 多实例生命周期。
47- **QEMU**:很强大,但配置面太大了。日常当沙箱用,它不像是“进到 repo 就能用”,更像是你得先把它当成一个项目来折腾。
84- **QEMU**:很强大,但配置面太大了。日常当沙箱用,它不像是“进到 repo 就能用”,更像是你得先把它当成一个项目来折腾。
48−- **Docker / devcontainers**:生态很成熟,但日常使用对我来说偏重。
85+- **Docker / devcontainers / devpods**:生态很成熟。我的痛点不在启动时间,而是日常保持
86+ safe-by-default(挂载白名单、密钥暴露、附加/复用、清理)的开销,不想为基础 workflow 在每个项目维护容器配置。
49
87
50−这些就促使我做了 **VibeBox**:我想要一个按项目隔离的沙箱,进入速度快(直接用 `vibebox`),支持真正可用的配置和
88+这就是我做 **VibeBox** 的原因:我想要一个按项目隔离的沙箱,进入快(直接 `vibebox`),支持真实配置 + 会话,同时保持硬隔离边界。
51−sessions,同时还保留明确的硬隔离边界。
52
89
53### 安装
90### 安装
54
91
@@ -149,19 +186,16 @@ vibebox explain # 显示挂载与网络信息
149
186
150如果你想参与贡献 VibeBox,请先阅读 [贡献指南](CONTRIBUTING.md),再提交 Pull Request。
187如果你想参与贡献 VibeBox,请先阅读 [贡献指南](CONTRIBUTING.md),再提交 Pull Request。
151
188
152−### 使用 VibeBox
153−
154−欢迎使用
155−
156### FAQ
189### FAQ
157
190
158#### 它和其它 Sandboxes 有什么不同?
191#### 它和其它 Sandboxes 有什么不同?
159
192
160−Vibebox 追求的是:本地、可复现、启动快、流程简单。主要差异点:
193+VibeBox 追求的是:本地、可复现、启动快、流程简单。主要差异点:
161
194
162−- 在我的 M3 上,热启动通常 **6 秒以内**,可以非常快地回到工作状态。
195+- 在我的 M3 上,热启动通常 **<5s**,可以非常快地回到工作状态。
163- 一个命令——`vibebox`——直接把你带进沙盒(从你的项目目录启动)。
196- 一个命令——`vibebox`——直接把你带进沙盒(从你的项目目录启动)。
164- 配置集中在 `vibebox.toml`,CPU / 内存 / 磁盘大小 / 挂载都能一眼看懂、随手改。
197- 配置集中在 `vibebox.toml`,CPU / 内存 / 磁盘大小 / 挂载都能一眼看懂、随手改。
198+- 会话是第一等公民:复用、多终端、清理。
165
199
166### 特别鸣谢
200### 特别鸣谢
167
201
docs/tasks.md+14−9
@@ -25,7 +25,7 @@
255. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar.
255. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar.
266. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio.
266. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio.
277. [x] Add a standalone TUI CLI binary (no main.rs wiring) with placeholder VM info and TODOs for VM integration.
277. [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.
29
29
30## TUI
30## TUI
31
31
@@ -49,18 +49,23 @@
49 4. [x] set up the cli.
49 4. [x] set up the cli.
509. [x] fix ui overlap, and consistency issue.
509. [x] fix ui overlap, and consistency issue.
5110. [x] `purge-cache` to clear the cache.
5110. [x] `purge-cache` to clear the cache.
52−11. [ ] intensive integration test.
52+11. [x] intensive integration test.
53
53
54## Publish
54## Publish
55
55
56−1. [ ] write the docs.
56+1. [x] write the docs.
57−2. [ ] setup quick install link.
57+2. [x] setup quick install link.
58−3. [ ] setup website.
58+3. [x] setup website.
59
59
60## Stage 2
60## Stage 2
61
61
621. [ ] retouch the cli ux.
621. [ ] retouch the cli ux.
63−2. [ ] refactor the code.
63+2. [x] refactor the code.
64−3. [ ] Redirect vm output to log.
64+3. [ ] refactor the mount system.
65+4. [x] refactor the vm process lifetime.
66+5. [x] Redirect vm output to log.
65−4. [ ] Redirect vm output to vibebox starting it.
67+6. [x] Redirect vm output to vibebox starting it.
66−5. [ ] use anyhow to sync api.
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<()> {
21 color_eyre::install()?;
21 color_eyre::install()?;
22
22
23 tracing::info!("starting vm supervisor");
23 tracing::info!("starting vm supervisor");
24− let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
24+ let project_dir = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
25− let config = config::load_config(&cwd);
25+ let config = config::load_config(&project_dir)
26− let instance_dir = instance::ensure_instance_dir(&cwd)
27 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
26 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
27+ let _ = instance::ensure_instance_dir(&project_dir)
28+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
28− let _ = instance::touch_last_active(&instance_dir);
29+ let _ = instance::touch_last_active(&project_dir);
29 let args = vm::VmArg {
30 let args = vm::VmArg {
30 cpu_count: config.box_cfg.cpu_count,
31 cpu_count: config.box_cfg.cpu_count,
31− ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
32+ ram_bytes: config.box_cfg.ram_size.as_u64(),
32− disk_bytes: config.box_cfg.disk_gb.saturating_mul(1024 * 1024 * 1024),
33+ disk_bytes: config.box_cfg.disk_size.as_u64(),
33 no_default_mounts: false,
34 no_default_mounts: false,
34 mounts: config.box_cfg.mounts.clone(),
35 mounts: config.box_cfg.mounts.clone(),
35 };
36 };
@@ -37,7 +38,7 @@ fn main() -> Result<()> {
37 tracing::info!(auto_shutdown_ms, "vm supervisor config");
38 tracing::info!(auto_shutdown_ms, "vm supervisor config");
38
39
39 let result = vm_manager::run_manager(args, auto_shutdown_ms);
40 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);
41 if let Err(err) = result {
42 if let Err(err) = result {
42 tracing::error!(error = %err, "vm supervisor exited");
43 tracing::error!(error = %err, "vm supervisor exited");
43 return Err(color_eyre::eyre::eyre!(err.to_string()));
44 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;
1use std::{
5use std::{
2 env,
6 env,
3 ffi::OsString,
7 ffi::OsString,
@@ -6,10 +10,6 @@ use std::{
6 path::{Path, PathBuf},
10 path::{Path, PathBuf},
7 sync::{Arc, Mutex},
11 sync::{Arc, Mutex},
8};
12};
9−
10−use clap::Parser;
11−use color_eyre::Result;
12−use dialoguer::Confirm;
13use time::OffsetDateTime;
13use time::OffsetDateTime;
14use time::format_description::well_known::Rfc3339;
14use time::format_description::well_known::Rfc3339;
15use tracing_subscriber::filter::LevelFilter;
15use tracing_subscriber::filter::LevelFilter;
@@ -17,8 +17,9 @@ use tracing_subscriber::registry::Registry;
17use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload};
17use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload};
18
18
19use vibebox::tui::{AppState, VmInfo};
19use vibebox::tui::{AppState, VmInfo};
20+use vibebox::utils::relative_to_home;
20use vibebox::{
21use vibebox::{
21− SessionManager, commands, config, explain, instance, session_manager, tui, vm, vm_manager,
22+ SessionManager, commands, config, explain, instance, session_manager, tui, vm_manager,
22};
23};
23
24
24#[derive(Debug, Parser)]
25#[derive(Debug, Parser)]
@@ -56,53 +57,26 @@ fn main() -> Result<()> {
56
57
57 let config_override = cli.config.clone();
58 let config_override = cli.config.clone();
58 let raw_args: Vec<OsString> = env::args_os().collect();
59 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()))?;
60
62
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;
89 let vm_info = VmInfo {
63 let vm_info = VmInfo {
90− max_memory_mb: vm_args.ram_bytes / (1024 * 1024),
64+ max_memory: config.box_cfg.ram_size,
91− cpu_cores: vm_args.cpu_count,
65+ cpu_cores: config.box_cfg.cpu_count,
92− max_disk_gb: (vm_args.disk_bytes as f32) / 1024.0 / 1024.0 / 1024.0,
66+ max_disk: config.box_cfg.disk_size,
93 system_name: "Debian".to_string(), // TODO: read system name from the VM.
67 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,
95 };
69 };
96 if let Ok(manager) = SessionManager::new() {
70 if let Ok(manager) = SessionManager::new() {
97 if let Err(err) = manager.update_global_sessions(&cwd) {
71 if let Err(err) = manager.update_global_sessions(&cwd) {
98 tracing::warn!(error = %err, "failed to update a global session list");
72 tracing::warn!(error = %err, "failed to update a global session list");
99 }
73 }
100 } else {
74 } else {
101− tracing::warn!("failed to initialize session manager");
75+ tracing::error!("failed to initialize session manager");
76+ std::process::exit(1);
102 }
77 }
103 let commands = commands::build_commands();
78 let commands = commands::build_commands();
104 let app = Arc::new(Mutex::new(AppState::new(cwd.clone(), vm_info, commands)));
79 let app = Arc::new(Mutex::new(AppState::new(cwd.clone(), vm_info, commands)));
105−
106 {
80 {
107 let mut locked = app.lock().expect("app state poisoned");
81 let mut locked = app.lock().expect("app state poisoned");
108 tui::render_tui_once(&mut locked)?;
82 tui::render_tui_once(&mut locked)?;
@@ -112,17 +86,39 @@ fn main() -> Result<()> {
112 writeln!(stdout)?;
86 writeln!(stdout)?;
113 stdout.flush()?;
87 stdout.flush()?;
114 }
88 }
115− warn_disk_size_mismatch(&cwd, vm_args.disk_bytes);
89+ warn_disk_size_mismatch(&cwd, config.box_cfg.disk_size);
116 if let Some(handle) = stderr_handle {
90 if let Some(handle) = stderr_handle {
117 let _ = handle.modify(|filter| *filter = LevelFilter::INFO);
91 let _ = handle.modify(|filter| *filter = LevelFilter::INFO);
118 }
92 }
119
93
120− tracing::debug!(auto_shutdown_ms, "auto shutdown config");
94+ tracing::debug!(config.supervisor.auto_shutdown_ms, "auto shutdown config");
121− let manager_conn =
95+ let manager_conn = vm_manager::ensure_manager(
96+ &raw_args,
97+ config.supervisor.auto_shutdown_ms,
122− vm_manager::ensure_manager(&raw_args, auto_shutdown_ms, config_override.as_deref())
98+ config_override.as_deref(),
99+ )
100+ .map_err(|err| {
101+ tracing::error!(error = %err, "failed to ensure vm manager");
123− .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
102+ color_eyre::eyre::eyre!(err.to_string())
103+ })?;
124
104
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+ }
126
122
127 tracing::info!("See you again — keep vibecoding (no SEVs, only vibes) 😈");
123 tracing::info!("See you again — keep vibecoding (no SEVs, only vibes) 😈");
128
124
@@ -205,13 +201,14 @@ fn handle_command(command: Command, cwd: &Path, config_override: Option<&Path>)
205 "Purged {} file{} totaling {} from {}",
201 "Purged {} file{} totaling {} from {}",
206 file_count,
202 file_count,
207 if file_count == 1 { "" } else { "s" },
203 if file_count == 1 { "" } else { "s" },
208− format_bytes(total_bytes),
204+ ByteSize(total_bytes),
209 cache_dir.display()
205 cache_dir.display()
210 );
206 );
211 Ok(())
207 Ok(())
212 }
208 }
213 Command::Explain => {
209 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()))?;
215 let mounts = explain::build_mount_rows(cwd, &config)
212 let mounts = explain::build_mount_rows(cwd, &config)
216 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
213 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
217 let networks = explain::build_network_rows(cwd)
214 let networks = explain::build_network_rows(cwd)
@@ -234,20 +231,6 @@ fn project_name(directory: &Path) -> String {
234 .to_string()
231 .to_string()
235}
232}
236
233
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−
251fn cache_dir() -> Result<PathBuf> {
234fn cache_dir() -> Result<PathBuf> {
252 let home = env::var("HOME").map(PathBuf::from)?;
235 let home = env::var("HOME").map(PathBuf::from)?;
253 let cache_home = env::var("XDG_CACHE_HOME")
236 let cache_home = env::var("XDG_CACHE_HOME")
@@ -296,23 +279,6 @@ fn measure_dir(path: &Path) -> Result<(u64, u64)> {
296 Ok((file_count, total_bytes))
279 Ok((file_count, total_bytes))
297}
280}
298
281
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−
316fn format_last_active(value: Option<&str>) -> String {
282fn format_last_active(value: Option<&str>) -> String {
317 let Some(raw) = value else {
283 let Some(raw) = value else {
318 return "-".to_string();
284 return "-".to_string();
@@ -354,24 +320,22 @@ fn format_last_active(value: Option<&str>) -> String {
354 format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
320 format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
355}
321}
356
322
357−fn warn_disk_size_mismatch(cwd: &Path, configured_bytes: u64) {
323+fn warn_disk_size_mismatch(cwd: &Path, configured_size: ByteSize) {
358 let instance_raw = cwd
324 let instance_raw = cwd
359 .join(session_manager::INSTANCE_DIR_NAME)
325 .join(session_manager::INSTANCE_DIR_NAME)
360 .join("instance.raw");
326 .join("instance.raw");
361 let Ok(meta) = fs::metadata(&instance_raw) else {
327 let Ok(meta) = fs::metadata(&instance_raw) else {
362 return;
328 return;
363 };
329 };
364− let current_bytes = meta.len();
330+ let current_size = ByteSize::b(meta.len());
365− if current_bytes == configured_bytes {
331+ if current_size == configured_size {
366 return;
332 return;
367 }
333 }
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);
370 tracing::warn!(
334 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 {}). \
372disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using the existing disk.",
336disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using the existing disk.",
373− current_gb,
337+ current_size,
374− target_gb
338+ configured_size,
375 );
339 );
376}
340}
377
341
@@ -380,13 +344,13 @@ type StderrHandle = reload::Handle<LevelFilter, Registry>;
380fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
344fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
381 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
345 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
382 let file_filter = filter.clone();
346 let file_filter = filter.clone();
383− let stderr_is_tty = std::io::stderr().is_terminal();
347+ let stderr_is_tty = io::stderr().is_terminal();
384 let ansi = stderr_is_tty && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
348 let ansi = stderr_is_tty && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
385 let file = instance::ensure_instance_dir(cwd)
349 let file = instance::ensure_instance_dir(cwd)
386 .ok()
350 .ok()
387 .and_then(|instance_dir| {
351 .and_then(|instance_dir| {
388 let log_path = instance_dir.join("cli.log");
352 let log_path = instance_dir.join("cli.log");
389− std::fs::OpenOptions::new()
353+ fs::OpenOptions::new()
390 .create(true)
354 .create(true)
391 .write(true)
355 .write(true)
392 .truncate(true)
356 .truncate(true)
@@ -400,7 +364,7 @@ fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
400 .with_target(false)
364 .with_target(false)
401 .with_ansi(ansi)
365 .with_ansi(ansi)
402 .without_time()
366 .without_time()
403− .with_writer(std::io::stderr)
367+ .with_writer(io::stderr)
404 .with_filter(stderr_filter);
368 .with_filter(stderr_filter);
405 let subscriber = tracing_subscriber::registry().with(stderr_layer);
369 let subscriber = tracing_subscriber::registry().with(stderr_layer);
406 if let Some(file) = file {
370 if let Some(file) = file {
@@ -418,7 +382,7 @@ fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
418 let stderr_layer = fmt::layer()
382 let stderr_layer = fmt::layer()
419 .with_target(false)
383 .with_target(false)
420 .with_ansi(ansi)
384 .with_ansi(ansi)
421− .with_writer(std::io::stderr)
385+ .with_writer(io::stderr)
422 .with_filter(filter);
386 .with_filter(filter);
423 let subscriber = tracing_subscriber::registry().with(stderr_layer);
387 let subscriber = tracing_subscriber::registry().with(stderr_layer);
424 if let Some(file) = file {
388 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};
1use std::{
4use std::{
2 env, fs, io,
5 env, fs, io,
3 path::{Path, PathBuf},
6 path::{Path, PathBuf},
4};
7};
5
8
6−use serde::{Deserialize, Serialize};
7−
8use crate::vm::DirectoryShare;
9use crate::vm::DirectoryShare;
9
10
10−pub const CONFIG_FILENAME: &str = "vibebox.toml";
11+const CONFIG_FILENAME: &str = "vibebox.toml";
11pub const CONFIG_PATH_ENV: &str = "VIBEBOX_CONFIG_PATH";
12pub const CONFIG_PATH_ENV: &str = "VIBEBOX_CONFIG_PATH";
12
13
13const DEFAULT_CPU_COUNT: usize = 2;
14const DEFAULT_CPU_COUNT: usize = 2;
@@ -22,20 +23,83 @@ pub struct Config {
22 pub supervisor: SupervisorConfig,
23 pub supervisor: SupervisorConfig,
23}
24}
24
25
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+
25#[derive(Debug, Clone, Serialize, Deserialize)]
87#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct BoxConfig {
88pub struct BoxConfig {
27 pub cpu_count: usize,
89 pub cpu_count: usize,
28− pub ram_mb: u64,
90+ #[serde(rename = "ram_mb", with = "serde_mb")]
29− pub disk_gb: u64,
91+ pub ram_size: ByteSize,
92+ #[serde(rename = "disk_gb", with = "serde_gb")]
93+ pub disk_size: ByteSize,
30 pub mounts: Vec<String>,
94 pub mounts: Vec<String>,
31}
95}
32
96
33impl Default for BoxConfig {
97impl Default for BoxConfig {
34 fn default() -> Self {
98 fn default() -> Self {
35 Self {
99 Self {
36− cpu_count: default_cpu_count(),
100+ cpu_count: DEFAULT_CPU_COUNT,
37− ram_mb: default_ram_mb(),
101+ ram_size: ByteSize::mib(DEFAULT_RAM_MB),
38− disk_gb: default_disk_gb(),
102+ disk_size: ByteSize::gib(DEFAULT_DISK_GB),
39 mounts: default_mounts(),
103 mounts: default_mounts(),
40 }
104 }
41 }
105 }
@@ -49,23 +113,11 @@ pub struct SupervisorConfig {
49impl Default for SupervisorConfig {
113impl Default for SupervisorConfig {
50 fn default() -> Self {
114 fn default() -> Self {
51 Self {
115 Self {
52− auto_shutdown_ms: default_auto_shutdown_ms(),
116+ auto_shutdown_ms: DEFAULT_AUTO_SHUTDOWN_MS,
53 }
117 }
54 }
118 }
55}
119}
56
120
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−
69fn default_mounts() -> Vec<String> {
121fn default_mounts() -> Vec<String> {
70 vec![
122 vec![
71 "~/.codex:~/.codex:read-write".into(),
123 "~/.codex:~/.codex:read-write".into(),
@@ -73,19 +125,12 @@ fn default_mounts() -> Vec<String> {
73 ]
125 ]
74}
126}
75
127
76−fn default_disk_gb() -> u64 {
77− DEFAULT_DISK_GB
78−}
79−
80pub fn config_path(project_root: &Path) -> PathBuf {
128pub fn config_path(project_root: &Path) -> PathBuf {
81 project_root.join(CONFIG_FILENAME)
129 project_root.join(CONFIG_FILENAME)
82}
130}
83
131
84−pub fn ensure_config_file(
132+pub fn ensure_config_file(project_root: &Path, override_path: Option<&Path>) -> Result<PathBuf> {
85− project_root: &Path,
86− override_path: Option<&Path>,
87−) -> Result<PathBuf, io::Error> {
88− let path = resolve_config_path(project_root, override_path);
133+ let path = resolve_config_path(project_root, override_path)?;
89 if !path.exists() {
134 if !path.exists() {
90 let default_config = Config::default();
135 let default_config = Config::default();
91 let contents = toml::to_string_pretty(&default_config).unwrap_or_default();
136 let contents = toml::to_string_pretty(&default_config).unwrap_or_default();
@@ -95,32 +140,24 @@ pub fn ensure_config_file(
95 Ok(path)
140 Ok(path)
96}
141}
97
142
98−pub fn load_config(project_root: &Path) -> Config {
143+pub fn load_config(project_root: &Path) -> Result<Config> {
99 load_config_with_path(project_root, None)
144 load_config_with_path(project_root, None)
100}
145}
101
146
102−pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) -> Config {
147+pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) -> Result<Config> {
148+ let path =
103− let path = match ensure_config_file(project_root, override_path) {
149+ ensure_config_file(project_root, override_path).context("failed to create config")?;
104− Ok(path) => path,
105− Err(err) => die(&format!("failed to create config: {err}")),
106− };
107− let raw = match fs::read_to_string(&path) {
150+ let raw = fs::read_to_string(&path).context("failed to read config")?;
108− Ok(raw) => raw,
109− Err(err) => die(&format!("failed to read config: {err}")),
110− };
111 let trimmed = raw.trim();
151 let trimmed = raw.trim();
112 tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config");
152 tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config");
113 if trimmed.is_empty() {
153 if trimmed.is_empty() {
114− die(&format!(
154+ bail!(format!(
115 "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)",
155 "config file ({}) is empty. Required fields: [box].cpu_count (integer), [box].ram_mb (integer), [box].disk_gb (integer), [box].mounts (array of strings), [supervisor].auto_shutdown_ms (integer)",
116 path.display()
156 path.display()
117 ));
157 ));
118 }
158 }
119
159
120− let value: toml::Value = match toml::from_str(trimmed) {
160+ let value: toml::Value = toml::from_str(trimmed).context("invalid config")?;
121− Ok(value) => value,
122− Err(err) => die(&format!("invalid config: {err}")),
123− };
124 let schema_errors = validate_schema(&value);
161 let schema_errors = validate_schema(&value);
125 if !schema_errors.is_empty() {
162 if !schema_errors.is_empty() {
126 let message = format!(
163 let message = format!(
@@ -128,27 +165,28 @@ pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>)
128 path.display(),
165 path.display(),
129 schema_errors.join("\n- ")
166 schema_errors.join("\n- ")
130 );
167 );
131− die(&message);
168+ bail!(message);
132 }
169 }
133
170
134− let config: Config = match toml::from_str(trimmed) {
171+ let config: Config = toml::from_str(trimmed).context("invalid config")?;
172+ validate_config(&config).map_err(Error::msg)?;
135− Ok(config) => config,
173+ Ok(config)
136− Err(err) => die(&format!("invalid config: {err}")),
137− };
138− validate_or_exit(&config);
139− config
140}
174}
141
175
142−fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> PathBuf {
176+fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> Result<PathBuf> {
143− let root = match fs::canonicalize(project_root) {
177+ let env_override = env::var_os(CONFIG_PATH_ENV).map(PathBuf::from);
144− Ok(root) => root,
178+ resolve_config_path_inner(project_root, override_path, env_override)
145− Err(err) => die(&format!("failed to resolve project root: {err}")),
179+}
146− };
147
180
181+fn resolve_config_path_inner(
182+ project_root: &Path,
148− let override_path = override_path
183+ override_path: Option<&Path>,
184+ env_override: Option<PathBuf>,
149− .map(PathBuf::from)
185+) -> Result<PathBuf> {
150− .or_else(|| env::var_os(CONFIG_PATH_ENV).map(PathBuf::from));
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);
151− let raw_path = if let Some(path) = override_path {
189+ let raw_path = if let Some(path) = selected_path {
152 if path.is_absolute() {
190 if path.is_absolute() {
153 path
191 path
154 } else {
192 } else {
@@ -159,14 +197,50 @@ fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> Pat
159 };
197 };
160
198
161 let normalized = normalize_path(&raw_path);
199 let normalized = normalize_path(&raw_path);
200+ let resolved =
201+ resolve_path_for_boundary_check(&normalized).context("failed to resolve config path")?;
162− if !normalized.starts_with(&root) {
202+ if !resolved.starts_with(&root) {
163− die(&format!(
203+ bail!(
164 "config path must be within {}: {}",
204 "config path must be within {}: {}",
165 root.display(),
205 root.display(),
166− normalized.display()
206+ resolved.display()
167− ));
207+ );
168 }
208 }
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))
170}
244}
171
245
172fn normalize_path(path: &Path) -> PathBuf {
246fn normalize_path(path: &Path) -> PathBuf {
@@ -263,27 +337,343 @@ fn validate_string_array(
263 }
337 }
264}
338}
265
339
266−fn validate_or_exit(config: &Config) {
340+fn validate_config(config: &Config) -> Result<(), String> {
267 if config.box_cfg.cpu_count == 0 {
341 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());
269 }
343 }
270− if config.box_cfg.ram_mb == 0 {
344+ if config.box_cfg.ram_size.as_mib() == 0.0 {
271− die("box.ram_mb must be >= 1");
345+ return Err("box.ram_mb must be >= 1".to_string());
272 }
346 }
273− if config.box_cfg.disk_gb == 0 {
347+ if config.box_cfg.disk_size.as_gib() == 0.0 {
274− die("box.disk_gb must be >= 1");
348+ return Err("box.disk_gb must be >= 1".to_string());
275 }
349 }
276 if config.supervisor.auto_shutdown_ms == 0 {
350 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());
278 }
352 }
279 for spec in &config.box_cfg.mounts {
353 for spec in &config.box_cfg.mounts {
280 if let Err(err) = DirectoryShare::from_mount_spec(spec) {
354 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}"));
282 }
356 }
283 }
357 }
358+ Ok(())
284}
359}
285
360
286−fn die(message: &str) -> ! {
361+#[cfg(test)]
287− tracing::error!("{message}");
362+mod tests {
288− std::process::exit(1);
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+ }
289}
679}
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};
1use std::{
5use std::{
2 env,
6 env,
3− error::Error,
4 path::{Path, PathBuf},
7 path::{Path, PathBuf},
5};
8};
6
9
7−use crate::{config, instance, session_manager, tui};
8−
9−pub fn build_mount_rows(
10+pub fn build_mount_rows(project: &Path, config: &config::Config) -> Result<Vec<tui::MountListRow>> {
10− cwd: &Path,
11− config: &config::Config,
12−) -> Result<Vec<tui::MountListRow>, Box<dyn Error + Send + Sync>> {
13 let mut rows = Vec::new();
11 let mut rows = Vec::new();
14− rows.extend(default_mounts(cwd)?);
12+ rows.extend(default_mounts(project)?);
15− let guest_home = resolve_guest_home(cwd)?;
13+ let guest_home = resolve_guest_home(project);
16 for spec in &config.box_cfg.mounts {
14 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)?);
18 }
16 }
19 Ok(rows)
17 Ok(rows)
20}
18}
21
19
22−pub fn build_network_rows(
20+pub fn build_network_rows(project_dir: &Path) -> Result<Vec<tui::NetworkListRow>> {
23− cwd: &Path,
24−) -> Result<Vec<tui::NetworkListRow>, Box<dyn Error + Send + Sync>> {
25− let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME);
26 let mut vm_ip = "-".to_string();
21 let mut vm_ip = "-".to_string();
27− if let Ok(Some(ip)) = instance::read_instance_vm_ip(&instance_dir) {
22+ if let Ok(config) = instance::read_instance_config(project_dir) {
23+ match config.vm_ipv4 {
24+ None => {}
25+ Some(ip) => {
28− vm_ip = ip;
26+ vm_ip = ip;
27+ }
28+ }
29 }
29 }
30 let host_to_vm = if vm_ip == "-" {
30 let host_to_vm = if vm_ip == "-" {
31 "ssh: <pending>:22".to_string()
31 "ssh: <pending>:22".to_string()
@@ -41,13 +41,13 @@ pub fn build_network_rows(
41 Ok(vec![row])
41 Ok(vec![row])
42}
42}
43
43
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>> {
45 let project_name = cwd
45 let project_name = cwd
46 .file_name()
46 .file_name()
47 .and_then(|name| name.to_str())
47 .and_then(|name| name.to_str())
48− .unwrap_or("project");
48+ .with_context(|| "failed to get project name")?;
49 let project_guest = format!("~/{project_name}");
49 let project_guest = format!("~/{project_name}");
50− let project_host = display_path(cwd);
50+ let project_host = relative_to_home(cwd);
51 let mut rows = vec![tui::MountListRow {
51 let mut rows = vec![tui::MountListRow {
52 host: project_host,
52 host: project_host,
53 guest: project_guest,
53 guest: project_guest,
@@ -57,14 +57,14 @@ fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>, Box<dyn Error +
57
57
58 let home = env::var("HOME")
58 let home = env::var("HOME")
59 .map(PathBuf::from)
59 .map(PathBuf::from)
60− .unwrap_or_else(|_| PathBuf::from("/"));
60+ .with_context(|| "failed to get home directory")?;
61 let cache_home = env::var("XDG_CACHE_HOME")
61 let cache_home = env::var("XDG_CACHE_HOME")
62 .map(PathBuf::from)
62 .map(PathBuf::from)
63 .unwrap_or_else(|_| home.join(".cache"));
63 .unwrap_or_else(|_| home.join(".cache"));
64 let cache_dir = cache_home.join(session_manager::GLOBAL_CACHE_DIR_NAME);
64 let cache_dir = cache_home.join(session_manager::GLOBAL_CACHE_DIR_NAME);
65 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
65 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
66 rows.push(tui::MountListRow {
66 rows.push(tui::MountListRow {
67− host: display_path(&guest_mise_cache),
67+ host: relative_to_home(&guest_mise_cache),
68 guest: "/root/.local/share/mise".to_string(),
68 guest: "/root/.local/share/mise".to_string(),
69 mode: "read-write".to_string(),
69 mode: "read-write".to_string(),
70 default_mount: "yes".to_string(),
70 default_mount: "yes".to_string(),
@@ -77,10 +77,10 @@ fn parse_mount_spec(
77 spec: &str,
77 spec: &str,
78 default_mount: bool,
78 default_mount: bool,
79 guest_home: &str,
79 guest_home: &str,
80−) -> Result<tui::MountListRow, Box<dyn Error + Send + Sync>> {
80+) -> Result<tui::MountListRow> {
81 let parts: Vec<&str> = spec.split(':').collect();
81 let parts: Vec<&str> = spec.split(':').collect();
82 if parts.len() < 2 || parts.len() > 3 {
82 if parts.len() < 2 || parts.len() > 3 {
83− return Err(format!("invalid mount spec: {spec}").into());
83+ bail!["invalid mount spec: {spec}"];
84 }
84 }
85 let host_part = parts[0];
85 let host_part = parts[0];
86 let guest_part = parts[1];
86 let guest_part = parts[1];
@@ -89,11 +89,10 @@ fn parse_mount_spec(
89 "read-only" => "read-only",
89 "read-only" => "read-only",
90 "read-write" => "read-write",
90 "read-write" => "read-write",
91 other => {
91 other => {
92− return Err(format!(
92+ bail![format!(
93 "invalid mount mode '{}'; expected read-only or read-write",
93 "invalid mount mode '{}'; expected read-only or read-write",
94 other
94 other
95− )
95+ )];
96− .into());
97 }
96 }
98 }
97 }
99 } else {
98 } else {
@@ -116,22 +115,22 @@ fn display_host_spec(cwd: &Path, host: &str) -> String {
116 }
115 }
117 let host_path = PathBuf::from(host);
116 let host_path = PathBuf::from(host);
118 if host_path.is_absolute() {
117 if host_path.is_absolute() {
119− return display_path(&host_path);
118+ return relative_to_home(&host_path);
120 }
119 }
121 let candidate = cwd.join(&host_path);
120 let candidate = cwd.join(&host_path);
122 if candidate.is_absolute() {
121 if candidate.is_absolute() {
123− display_path(&candidate)
122+ relative_to_home(&candidate)
124 } else {
123 } else {
125 host.to_string()
124 host.to_string()
126 }
125 }
127}
126}
128
127
129−fn resolve_guest_home(cwd: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
128+fn resolve_guest_home(project_dir: &Path) -> String {
130− let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME);
129+ let config = instance::read_instance_config(project_dir);
131− if let Ok(Some(user)) = instance::read_instance_ssh_user(&instance_dir) {
130+ match config {
132− return Ok(format!("/home/{user}"));
131+ Ok(config) => format!("/home/{}", config.ssh_user),
132+ Err(_) => format!("/home/{}", InstanceConfig::default().ssh_user),
133 }
133 }
134− Ok(format!("/home/{}", instance::DEFAULT_SSH_USER))
135}
134}
136
135
137fn resolve_guest_display(guest: &str, guest_home: &str) -> String {
136fn resolve_guest_display(guest: &str, guest_home: &str) -> String {
@@ -155,17 +154,3 @@ fn resolve_guest_display(guest: &str, guest_home: &str) -> String {
155 format!("/root/{guest}")
154 format!("/root/{guest}")
156 }
155 }
157}
156}
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;
1use std::{
4use std::{
2 env, fs,
5 env, fs,
3− io::{self},
6+ io::{self, IsTerminal, Read},
4 net::{SocketAddr, TcpStream},
7 net::{SocketAddr, TcpStream},
5 os::unix::{fs::PermissionsExt, net::UnixStream},
8 os::unix::{fs::PermissionsExt, net::UnixStream},
6 path::{Path, PathBuf},
9 path::{Path, PathBuf},
7− process::{Command, Stdio},
10+ process::{Child, Command, Stdio},
11+ sync::{
8− sync::{Arc, Mutex},
12+ Arc, Mutex,
13+ atomic::{AtomicBool, Ordering},
14+ mpsc,
15+ },
9 thread,
16 thread,
10 time::{Duration, Instant},
17 time::{Duration, Instant},
11};
18};
12−
13−use serde::{Deserialize, Serialize};
14use time::{OffsetDateTime, format_description::well_known::Rfc3339};
19use time::{OffsetDateTime, format_description::well_known::Rfc3339};
15use uuid::Uuid;
20use uuid::Uuid;
16
21
17use crate::{
22use crate::{
18 commands,
23 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},
20 vm::{self, LoginAction},
26 vm::{self, LoginAction},
21};
27};
22
28
29+pub const STATUS_VM_ERROR_PREFIX: &str = "error:";
30+
23const SSH_KEY_NAME: &str = "ssh_key";
31const SSH_KEY_NAME: &str = "ssh_key";
24−#[cfg_attr(feature = "mock-vm", allow(dead_code))]
32+const INSTANCE_FILENAME: &str = "instance.toml";
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";
33+const DEFAULT_SSH_USER: &str = "vibecoder";
28−const SSH_CONNECT_RETRIES: usize = 30;
34+const SSH_CONNECT_RETRIES: usize = 10;
29const SSH_CONNECT_DELAY_MS: u64 = 500;
35const SSH_CONNECT_DELAY_MS: u64 = 500;
30const SSH_SETUP_SCRIPT: &str = include_str!("ssh.sh");
36const 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+}
31
92
32#[derive(Debug, Clone, Serialize, Deserialize)]
93#[derive(Debug, Clone, Serialize, Deserialize)]
33−pub(crate) struct InstanceConfig {
94+pub struct InstanceConfig {
34 #[serde(default)]
95 #[serde(default)]
35− id: String,
96+ pub id: String,
36 #[serde(default = "default_ssh_user")]
97 #[serde(default = "default_ssh_user")]
37− ssh_user: String,
98+ pub ssh_user: String,
38 #[serde(default)]
99 #[serde(default)]
39 sudo_password: String,
100 sudo_password: String,
40 #[serde(default)]
101 #[serde(default)]
41− last_active: Option<String>,
102+ pub last_active: Option<String>,
42 #[serde(default)]
103 #[serde(default)]
43− pub(crate) vm_ipv4: Option<String>,
104+ pub vm_ipv4: Option<String>,
44}
105}
45
106
46−impl InstanceConfig {
107+impl Default for InstanceConfig {
47− pub(crate) fn ssh_user_display(&self) -> String {
108+ fn default() -> Self {
48− if self.ssh_user.trim().is_empty() {
109+ Self {
110+ id: Uuid::now_v7().to_string(),
49− DEFAULT_SSH_USER.to_string()
111+ ssh_user: DEFAULT_SSH_USER.to_string(),
50− } else {
112+ sudo_password: Uuid::now_v7().simple().to_string(),
51− self.ssh_user.clone()
113+ last_active: None,
114+ vm_ipv4: None,
52 }
115 }
53 }
116 }
54}
117}
55
118
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<()> {
61 let project_root = env::current_dir()?;
120 let project_root = env::current_dir()?;
62 tracing::info!(root = %project_root.display(), "starting ssh session");
121 tracing::info!(root = %project_root.display(), "starting ssh session");
63 let instance_dir = ensure_instance_dir(&project_root)?;
122 let instance_dir = ensure_instance_dir(&project_root)?;
64 tracing::debug!(instance_dir = %instance_dir.display(), "instance dir ready");
123 tracing::debug!(instance_dir = %instance_dir.display(), "instance dir ready");
65 let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?;
124 let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?;
66
125
67− let config = load_or_create_instance_config(&instance_dir)?;
126+ let config = load_or_create_instance_config(&project_root)?;
68 let ssh_user = config.ssh_user.clone();
127 let ssh_user = config.ssh_user.clone();
69 tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
128 tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
70
129
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)?;
73
131
74− let ip = load_or_create_instance_config(&instance_dir)?
132+ let ip = load_or_create_instance_config(&project_root)?
75 .vm_ipv4
133 .vm_ipv4
76− .ok_or("VM IPv4 not available")?;
134+ .with_context(|| "failed to load instance IP address")?;
77 tracing::info!(ip = %ip, "vm ipv4 ready");
135 tracing::info!(ip = %ip, "vm ipv4 ready");
78
136
79− run_ssh_session(ssh_key, ssh_user, ip)
137+ run_ssh_session(ssh_key, ssh_user, ip, manager_conn, project_root)
80}
138}
81
139
82pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
140pub 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> {
85 Ok(instance_dir)
143 Ok(instance_dir)
86}
144}
87
145
88−pub(crate) fn ensure_ssh_keypair(
146+pub fn ensure_ssh_keypair(instance_dir: &Path) -> Result<(PathBuf, PathBuf)> {
89− instance_dir: &Path,
90−) -> Result<(PathBuf, PathBuf), Box<dyn std::error::Error>> {
91 let private_key = instance_dir.join(SSH_KEY_NAME);
147 let private_key = instance_dir.join(SSH_KEY_NAME);
92 let public_key = instance_dir.join(format!("{SSH_KEY_NAME}.pub"));
148 let public_key = instance_dir.join(format!("{SSH_KEY_NAME}.pub"));
93
149
@@ -109,7 +165,9 @@ pub(crate) fn ensure_ssh_keypair(
109 "-N",
165 "-N",
110 "",
166 "",
111 "-f",
167 "-f",
168+ private_key
169+ .to_str()
112− private_key.to_str().ok_or("ssh key path not utf-8")?,
170+ .with_context(|| "ssh key path not utf-8")?,
113 "-C",
171 "-C",
114 "vibebox",
172 "vibebox",
115 ])
173 ])
@@ -119,7 +177,7 @@ pub(crate) fn ensure_ssh_keypair(
119 .status()?;
177 .status()?;
120
178
121 if !status.success() {
179 if !status.success() {
122− return Err("ssh-keygen failed".into());
180+ bail!("ssh-keygen failed");
123 }
181 }
124
182
125 fs::set_permissions(&private_key, fs::Permissions::from_mode(0o600))?;
183 fs::set_permissions(&private_key, fs::Permissions::from_mode(0o600))?;
@@ -128,98 +186,66 @@ pub(crate) fn ensure_ssh_keypair(
128 Ok((private_key, public_key))
186 Ok((private_key, public_key))
129}
187}
130
188
131−pub(crate) fn load_or_create_instance_config(
189+pub fn load_or_create_instance_config(project_dir: &Path) -> Result<InstanceConfig> {
132− instance_dir: &Path,
190+ let mut exist = true;
133−) -> Result<InstanceConfig, Box<dyn std::error::Error>> {
191+ let mut config = read_instance_config(project_dir).unwrap_or_else(|_| {
134− let config_path = instance_dir.join(INSTANCE_FILENAME);
192+ exist = false;
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 {
193+ InstanceConfig::default()
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− };
194+ });
147
195
148 let mut changed = false;
196 let mut changed = false;
149 if config.ssh_user.trim().is_empty() {
197 if config.ssh_user.trim().is_empty() {
150− config.ssh_user = default_ssh_user();
198+ config.ssh_user = InstanceConfig::default().ssh_user;
151 changed = true;
199 changed = true;
152 }
200 }
153
201
154 if config.id.trim().is_empty() {
202 if config.id.trim().is_empty() {
155− config.id = Uuid::now_v7().to_string();
203+ config.id = InstanceConfig::default().id;
156 changed = true;
204 changed = true;
157 }
205 }
158
206
159 if config.sudo_password.trim().is_empty() {
207 if config.sudo_password.trim().is_empty() {
160− config.sudo_password = generate_password();
208+ config.sudo_password = InstanceConfig::default().sudo_password;
161 changed = true;
209 changed = true;
162 }
210 }
163
211
164− if !config_path.exists() || changed {
212+ if !exist || changed {
165− write_instance_config(&config_path, &config)?;
213+ write_instance_config(project_dir, &config)?;
166 }
214 }
167
215
168 Ok(config)
216 Ok(config)
169}
217}
170
218
171−fn read_instance_config(
219+pub fn read_instance_config(project_dir: &Path) -> Result<InstanceConfig> {
172− instance_dir: &Path,
220+ // todo maybe verify schema?
173−) -> Result<Option<InstanceConfig>, Box<dyn std::error::Error>> {
174− let config_path = instance_dir.join(INSTANCE_FILENAME);
221+ let config_path = project_dir.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
175 if !config_path.exists() {
222 if !config_path.exists() {
176− return Ok(None);
223+ bail!("instance config file does not exist");
177 }
224 }
178 let raw = fs::read_to_string(&config_path)?;
225 let raw = fs::read_to_string(&config_path)?;
179 let config = toml::from_str::<InstanceConfig>(&raw)?;
226 let config = toml::from_str::<InstanceConfig>(&raw)?;
180− Ok(Some(config))
227+ Ok(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()))
197}
228}
198
229
199−pub fn touch_last_active(instance_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
230+pub fn touch_last_active(project_dir: &Path) -> Result<()> {
200− let mut config = load_or_create_instance_config(instance_dir)?;
231+ let mut config = load_or_create_instance_config(project_dir)?;
201 let now = OffsetDateTime::now_utc().format(&Rfc3339)?;
232 let now = OffsetDateTime::now_utc().format(&Rfc3339)?;
202 config.last_active = Some(now);
233 config.last_active = Some(now);
203− write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
234+ write_instance_config(project_dir, &config)?;
204 Ok(())
235 Ok(())
205}
236}
206
237
207−pub(crate) fn write_instance_config(
238+pub fn write_instance_config(project_dir: &Path, config: &InstanceConfig) -> Result<()> {
208− path: &Path,
239+ let path = project_dir.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
209− config: &InstanceConfig,
210−) -> Result<(), Box<dyn std::error::Error>> {
211 let data = toml::to_string_pretty(config)?;
240 let data = toml::to_string_pretty(config)?;
241+ fs::create_dir_all(project_dir.join(INSTANCE_DIR_NAME))?;
212− fs::write(path, data)?;
242+ fs::write(&path, data)?;
213− fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
243+ fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
214 Ok(())
244 Ok(())
215}
245}
216
246
217−fn generate_password() -> String {
218− Uuid::now_v7().simple().to_string()
219−}
220−
221#[cfg_attr(feature = "mock-vm", allow(dead_code))]
247#[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> {
223 let mut current = String::new();
249 let mut current = String::new();
224 let mut best: Option<String> = None;
250 let mut best: Option<String> = None;
225
251
@@ -238,46 +264,68 @@ pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
238 best
264 best
239}
265}
240
266
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+
241fn wait_for_vm_ipv4(
285fn wait_for_vm_ipv4(
242− instance_dir: &Path,
286+ project_dir: &Path,
243 timeout: Duration,
287 timeout: Duration,
244−) -> Result<(), Box<dyn std::error::Error>> {
288+ manager_conn: &UnixStream,
289+) -> Result<()> {
245 let start = Instant::now();
290 let start = Instant::now();
246 let mut next_log_at = start + Duration::from_secs(10);
291 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();
248 tracing::info!("waiting for vm ipv4");
296 tracing::info!("waiting for vm ipv4");
249− let status_path = instance_dir.join(STATUS_FILE_NAME);
250 let mut last_status: Option<String> = None;
297 let mut last_status: Option<String> = None;
251− let mut status_missing = true;
252 let mut once_hint = false;
298 let mut once_hint = false;
253 loop {
299 loop {
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+
254− let config = load_or_create_instance_config(instance_dir)?;
320+ let config = load_or_create_instance_config(project_dir)?;
255 if config.vm_ipv4.is_some() {
321 if config.vm_ipv4.is_some() {
256− let _ = fs::remove_file(&status_path);
257 return Ok(());
322 return Ok(());
258 }
323 }
259 if start.elapsed() > timeout {
324 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");
262 }
326 }
327+
263 let now = Instant::now();
328 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− }
281 if now >= next_log_at {
329 if now >= next_log_at {
282 let waited = start.elapsed();
330 let waited = start.elapsed();
283 if waited.as_secs() > 15 && !once_hint {
331 if waited.as_secs() > 15 && !once_hint {
@@ -286,12 +334,9 @@ fn wait_for_vm_ipv4(
286 );
334 );
287 once_hint = true;
335 once_hint = true;
288 }
336 }
289− if status_missing {
290− tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
337+ tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
291− }
292 next_log_at += Duration::from_secs(20);
338 next_log_at += Duration::from_secs(20);
293 }
339 }
294− thread::sleep(Duration::from_millis(200));
295 }
340 }
296}
341}
297
342
@@ -299,9 +344,14 @@ fn run_ssh_session(
299 ssh_key: PathBuf,
344 ssh_key: PathBuf,
300 ssh_user: String,
345 ssh_user: String,
301 ip: String,
346 ip: String,
302−) -> Result<(), Box<dyn std::error::Error>> {
347+ manager_conn: UnixStream,
348+ project_root: PathBuf,
349+) -> Result<()> {
303 let mut attempts = 0usize;
350 let mut attempts = 0usize;
304 loop {
351 loop {
352+ if matches!(vm_liveness(&project_root)?, VmLiveness::NotRunningOrMissing) {
353+ return Err(InstanceError::UnexpectedDisconnection.into());
354+ }
305 attempts += 1;
355 attempts += 1;
306 if !ssh_port_open(&ip) {
356 if !ssh_port_open(&ip) {
307 tracing::debug!(attempts, "ssh port doesn't open yet");
357 tracing::debug!(attempts, "ssh port doesn't open yet");
@@ -313,9 +363,7 @@ fn run_ssh_session(
313 SSH_CONNECT_RETRIES
363 SSH_CONNECT_RETRIES
314 );
364 );
315 if attempts >= SSH_CONNECT_RETRIES {
365 if attempts >= SSH_CONNECT_RETRIES {
316− return Err(
317− format!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts").into(),
366+ bail!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts");
318− );
319 }
367 }
320 thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS));
368 thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS));
321 continue;
369 continue;
@@ -329,10 +377,10 @@ fn run_ssh_session(
329 attempts,
377 attempts,
330 SSH_CONNECT_RETRIES
378 SSH_CONNECT_RETRIES
331 );
379 );
332− let status = Command::new("ssh")
380+ let child = Command::new("ssh")
333 .args([
381 .args([
334 "-i",
382 "-i",
335− ssh_key.to_str().unwrap_or(".vibebox/ssh_key"),
383+ ssh_key.to_str().with_context(|| "invalid path")?,
336 "-o",
384 "-o",
337 "IdentitiesOnly=yes",
385 "IdentitiesOnly=yes",
338 "-o",
386 "-o",
@@ -357,27 +405,67 @@ fn run_ssh_session(
357 .stdin(Stdio::inherit())
405 .stdin(Stdio::inherit())
358 .stdout(Stdio::inherit())
406 .stdout(Stdio::inherit())
359 .stderr(Stdio::inherit())
407 .stderr(Stdio::inherit())
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);
360− .status();
446+ break status;
447+ }
448+ thread::sleep(Duration::from_millis(100));
449+ };
361
450
362− match status {
363− Ok(status) if status.success() => {
451+ if status.success() {
364− tracing::info!(status = %status, "ssh exited");
452+ tracing::info!(status = %status, "ssh exited");
365− break;
453+ break;
366− }
454+ }
367− Ok(status) if status.code() == Some(255) => {
455+ if status.code() == Some(255) {
368− tracing::warn!(status = %status, "ssh connection failed");
456+ tracing::warn!(status = %status, "ssh connection failed");
369− if attempts >= SSH_CONNECT_RETRIES {
457+ if attempts >= SSH_CONNECT_RETRIES {
370− return Err(format!("ssh failed after {SSH_CONNECT_RETRIES} attempts").into());
458+ bail!("ssh failed after {SSH_CONNECT_RETRIES} attempts");
459+ }
460+ thread::sleep(Duration::from_millis(500));
461+ continue;
371 }
462 }
372− thread::sleep(Duration::from_millis(500));
373− }
374− Ok(status) => {
375 tracing::info!(status = %status, "ssh exited");
463 tracing::info!(status = %status, "ssh exited");
376 break;
464 break;
377 }
465 }
378 Err(err) => {
466 Err(err) => {
379 tracing::error!(error = %err, "failed to start ssh");
467 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}");
381 }
469 }
382 }
470 }
383 }
471 }
@@ -385,6 +473,30 @@ fn run_ssh_session(
385 Ok(())
473 Ok(())
386}
474}
387
475
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+
388#[cfg_attr(feature = "mock-vm", allow(dead_code))]
500#[cfg_attr(feature = "mock-vm", allow(dead_code))]
389fn is_ipv4_candidate(candidate: &str) -> bool {
501fn is_ipv4_candidate(candidate: &str) -> bool {
390 let parts: Vec<&str> = candidate.split('.').collect();
502 let parts: Vec<&str> = candidate.split('.').collect();
@@ -407,10 +519,10 @@ fn ssh_port_open(ip: &str) -> bool {
407 Ok(addr) => addr,
519 Ok(addr) => addr,
408 Err(_) => return false,
520 Err(_) => return false,
409 };
521 };
410− TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).is_ok()
522+ TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok()
411}
523}
412
524
413−pub(crate) fn build_ssh_login_actions(
525+pub fn build_ssh_login_actions(
414 config: &Arc<Mutex<InstanceConfig>>,
526 config: &Arc<Mutex<InstanceConfig>>,
415 project_name: &str,
527 project_name: &str,
416 project_guest_dir: &str,
528 project_guest_dir: &str,
@@ -425,7 +537,7 @@ pub(crate) fn build_ssh_login_actions(
425
537
426 let key_path = format!("{guest_dir}/{key_name}.pub");
538 let key_path = format!("{guest_dir}/{key_name}.pub");
427
539
428− let setup_script = SSH_SETUP_SCRIPT
540+ let ssh_script = SSH_SETUP_SCRIPT
429 .replace("__SSH_USER__", &ssh_user)
541 .replace("__SSH_USER__", &ssh_user)
430 .replace("__SUDO_PASSWORD__", &sudo_password)
542 .replace("__SUDO_PASSWORD__", &sudo_password)
431 .replace("__PROJECT_NAME__", project_name)
543 .replace("__PROJECT_NAME__", project_name)
@@ -433,8 +545,36 @@ pub(crate) fn build_ssh_login_actions(
433 .replace("__KEY_PATH__", &key_path)
545 .replace("__KEY_PATH__", &key_path)
434 .replace("__VIBEBOX_SHELL_SCRIPT__", &commands::render_shell_script())
546 .replace("__VIBEBOX_SHELL_SCRIPT__", &commands::render_shell_script())
435 .replace("__VIBEBOX_HOME_LINKS__", home_links_script);
547 .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)
437 .expect("ssh setup script contained invalid marker");
549 .expect("ssh setup script contained invalid marker");
438
550
439 vec![LoginAction::Send(setup)]
551 vec![LoginAction::Send(setup)]
440}
552}
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;
8
8
9pub use session_manager::{SessionError, SessionManager, SessionRecord};
9pub use session_manager::{SessionError, SessionManager, SessionRecord};
10pub mod config;
10pub mod config;
11+pub mod utils;
src/provision.sh+49−5
@@ -1,12 +1,55 @@
1#!/bin/bash
1#!/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+}
3
45
4# Don't wait too long for slow mirrors.
46# Don't wait too long for slow mirrors.
5−echo 'Acquire::http::Timeout "2";' | tee /etc/apt/apt.conf.d/99timeout
47+echo 'Acquire::http::Timeout "10";' | tee /etc/apt/apt.conf.d/99timeout
6−echo 'Acquire::https::Timeout "2";' | tee -a /etc/apt/apt.conf.d/99timeout
48+echo 'Acquire::https::Timeout "10";' | tee -a /etc/apt/apt.conf.d/99timeout
7−echo 'Acquire::Retries "2";' | tee -a /etc/apt/apt.conf.d/99timeout
49+echo 'Acquire::Retries "5";' | tee -a /etc/apt/apt.conf.d/99timeout
8
50
9−apt-get update
51+wait_for_network
52+apt_update_with_retries
10apt-get install -y --no-install-recommends \
53apt-get install -y --no-install-recommends \
11 build-essential \
54 build-essential \
12 pkg-config \
55 pkg-config \
@@ -55,4 +98,5 @@ sleep 100 # sleep here so that we don't see the login screen flash up before the
55EOF
98EOF
56
99
57# Done provisioning, power off the VM
100# Done provisioning, power off the VM
101+printf "%s%s\n" VIBEBOX_PROVISION_ OK
58systemctl poweroff
102systemctl poweroff
src/session_manager.rs+56−93
@@ -1,19 +1,18 @@
1+use anyhow::Result;
1use std::{
2use std::{
2 env, fs,
3 env, fs,
3 io::{self, Write},
4 io::{self, Write},
4− os::unix::fs::FileTypeExt,
5 path::{Path, PathBuf},
5 path::{Path, PathBuf},
6};
6};
7
7
8+use crate::config::config_path;
9+use crate::instance::{VmLiveness, read_instance_config, vm_liveness};
8use serde::{Deserialize, Serialize};
10use serde::{Deserialize, Serialize};
9
11
10−use crate::config::CONFIG_FILENAME;
11−
12pub const INSTANCE_DIR_NAME: &str = ".vibebox";
12pub const INSTANCE_DIR_NAME: &str = ".vibebox";
13pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
13pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
14pub const GLOBAL_DIR_NAME: &str = ".vibebox";
14pub 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";
17pub const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
16pub const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
18pub const VM_MANAGER_PID_NAME: &str = "vm.pid";
17pub const VM_MANAGER_PID_NAME: &str = "vm.pid";
19const SESSIONS_DIR_NAME: &str = "sessions";
18const SESSIONS_DIR_NAME: &str = "sessions";
@@ -32,14 +31,6 @@ struct SessionEntry {
32 pub id: String,
31 pub id: String,
33}
32}
34
33
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−
43#[derive(Debug)]
34#[derive(Debug)]
44pub struct SessionManager {
35pub struct SessionManager {
45 sessions_dir: PathBuf,
36 sessions_dir: PathBuf,
@@ -61,7 +52,7 @@ pub enum SessionError {
61 #[error("Session directory does not exist: {0}")]
52 #[error("Session directory does not exist: {0}")]
62 MissingDirectory(PathBuf),
53 MissingDirectory(PathBuf),
63 #[error(transparent)]
54 #[error(transparent)]
64− Io(#[from] std::io::Error),
55+ Io(#[from] io::Error),
65 #[error(transparent)]
56 #[error(transparent)]
66 TomlDe(#[from] toml::de::Error),
57 TomlDe(#[from] toml::de::Error),
67 #[error(transparent)]
58 #[error(transparent)]
@@ -94,8 +85,8 @@ impl SessionManager {
94 let mut added = false;
85 let mut added = false;
95
86
96 if has_config {
87 if has_config {
97− let meta = read_instance_metadata(&directory)?;
88+ let id = read_instance_config(&directory).map_or(None, |config| Some(config.id));
98− if let Some(id) = meta.id {
89+ if let Some(id) = id {
99 let record = SessionEntry {
90 let record = SessionEntry {
100 directory: directory.clone(),
91 directory: directory.clone(),
101 id: id.clone(),
92 id: id.clone(),
@@ -110,7 +101,6 @@ impl SessionManager {
110 } else {
101 } else {
111 tracing::warn!(
102 tracing::warn!(
112 directory = %directory.display(),
103 directory = %directory.display(),
113− file = INSTANCE_FILENAME,
114 "missing session id in instance file"
104 "missing session id in instance file"
115 );
105 );
116 }
106 }
@@ -148,12 +138,13 @@ impl SessionManager {
148 }
138 }
149 let mut records = Vec::with_capacity(sessions.len());
139 let mut records = Vec::with_capacity(sessions.len());
150 for session in sessions {
140 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);
152 let active = is_session_active(&session.directory);
143 let active = is_session_active(&session.directory);
153 records.push(SessionRecord {
144 records.push(SessionRecord {
154 directory: session.directory,
145 directory: session.directory,
155 id: session.id,
146 id: session.id,
156− last_active: meta.last_active,
147+ last_active,
157 active,
148 active,
158 });
149 });
159 }
150 }
@@ -271,43 +262,22 @@ fn is_vibebox_dir(directory: &Path) -> bool {
271 if !directory.is_absolute() {
262 if !directory.is_absolute() {
272 return false;
263 return false;
273 }
264 }
274− directory.join(CONFIG_FILENAME).is_file()
265+ config_path(directory).is_file()
275}
266}
276
267
277fn is_session_active(directory: &Path) -> bool {
268fn is_session_active(directory: &Path) -> bool {
278 let instance_dir = directory.join(INSTANCE_DIR_NAME);
269 let instance_dir = directory.join(INSTANCE_DIR_NAME);
279 let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
270 let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
280− let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
271+ match vm_liveness(directory) {
281−
272+ Ok(liveness) => match liveness {
282− let pid = read_pid(&pid_path);
273+ VmLiveness::RunningWithSocket { .. } => true,
283− let is_alive = pid.map(pid_is_alive).unwrap_or(false);
274+ VmLiveness::RunningWithoutSocket { .. } => true,
284− if !is_alive {
275+ VmLiveness::NotRunningOrMissing => {
285− let _ = fs::remove_file(&pid_path);
276+ let _ = fs::remove_file(&pid_path);
286− return false;
277+ false
287− }
278+ }
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− }
279+ },
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,
280+ Err(_) => false,
311 }
281 }
312}
282}
313
283
@@ -315,31 +285,11 @@ fn read_session_file(path: &Path) -> Result<SessionEntry, SessionError> {
315 let raw = fs::read_to_string(path)?;
285 let raw = fs::read_to_string(path)?;
316 let record: SessionEntry = toml::from_str(&raw)?;
286 let record: SessionEntry = toml::from_str(&raw)?;
317 if record.id.trim().is_empty() {
287 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());
319 }
289 }
320 Ok(record)
290 Ok(record)
321}
291}
322
292
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−
343fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
293fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
344 let Some(parent) = path.parent() else {
294 let Some(parent) = path.parent() else {
345 return Err(io::Error::new(
295 return Err(io::Error::new(
@@ -362,6 +312,7 @@ fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
362#[cfg(test)]
312#[cfg(test)]
363mod tests {
313mod tests {
364 use super::*;
314 use super::*;
315+ use crate::instance::{InstanceConfig, write_instance_config};
365 use std::fs;
316 use std::fs;
366 use tempfile::TempDir;
317 use tempfile::TempDir;
367
318
@@ -375,11 +326,14 @@ mod tests {
375 dir
326 dir
376 }
327 }
377
328
378− fn write_instance(project_dir: &Path, id: &str, last_active: &str) {
329+ fn write_instance(project_dir: &Path, id: &str, last_active: &str) -> Result<()> {
379− let instance_dir = project_dir.join(INSTANCE_DIR_NAME);
380− fs::create_dir_all(&instance_dir).unwrap();
330+ fs::create_dir_all(project_dir)?;
381− let content = format!("id = \"{id}\"\nlast_active = \"{last_active}\"\n");
331+
382− fs::write(instance_dir.join(INSTANCE_FILENAME), content).unwrap();
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)
383 }
337 }
384
338
385 #[test]
339 #[test]
@@ -387,11 +341,14 @@ mod tests {
387 let temp = TempDir::new().unwrap();
341 let temp = TempDir::new().unwrap();
388 let mgr = manager(&temp);
342 let mgr = manager(&temp);
389 let project_dir = create_project_dir(&temp);
343 let project_dir = create_project_dir(&temp);
390− fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
344+ fs::write(config_path(project_dir.as_path()), "").unwrap();
345+ assert!(
391− write_instance(
346+ write_instance(
392− &project_dir,
347+ &project_dir,
393− "019bf290-cccc-7c23-ba1d-dce7e6d40693",
348+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
394− "2026-02-07T05:00:00Z",
349+ "2026-02-07T05:00:00Z",
350+ )
351+ .is_ok()
395 );
352 );
396
353
397 let dirs = mgr.update_global_sessions(&project_dir).unwrap();
354 let dirs = mgr.update_global_sessions(&project_dir).unwrap();
@@ -412,15 +369,18 @@ mod tests {
412 let temp = TempDir::new().unwrap();
369 let temp = TempDir::new().unwrap();
413 let mgr = manager(&temp);
370 let mgr = manager(&temp);
414 let project_dir = create_project_dir(&temp);
371 let project_dir = create_project_dir(&temp);
415− fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
372+ fs::write(config_path(project_dir.as_path()), "").unwrap();
373+ assert!(
416− write_instance(
374+ write_instance(
417− &project_dir,
375+ &project_dir,
418− "019bf290-cccc-7c23-ba1d-dce7e6d40693",
376+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
419− "2026-02-07T05:00:00Z",
377+ "2026-02-07T05:00:00Z",
378+ )
379+ .is_ok()
420 );
380 );
421 let _ = mgr.update_global_sessions(&project_dir).unwrap();
381 let _ = mgr.update_global_sessions(&project_dir).unwrap();
422
382
423− fs::remove_file(project_dir.join(CONFIG_FILENAME)).unwrap();
383+ fs::remove_file(config_path(project_dir.as_path())).unwrap();
424 let sessions = mgr.list_sessions().unwrap();
384 let sessions = mgr.list_sessions().unwrap();
425 assert!(sessions.is_empty());
385 assert!(sessions.is_empty());
426
386
@@ -451,11 +411,14 @@ mod tests {
451 let temp = TempDir::new().unwrap();
411 let temp = TempDir::new().unwrap();
452 let mgr = manager(&temp);
412 let mgr = manager(&temp);
453 let project_dir = create_project_dir(&temp);
413 let project_dir = create_project_dir(&temp);
454− fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
414+ fs::write(config_path(project_dir.as_path()), "").unwrap();
415+ assert!(
455− write_instance(
416+ write_instance(
456− &project_dir,
417+ &project_dir,
457− "019bf290-cccc-7c23-ba1d-dce7e6d40693",
418+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
458− "2026-02-07T05:00:00Z",
419+ "2026-02-07T05:00:00Z",
420+ )
421+ .is_ok()
459 );
422 );
460 let _ = mgr.update_global_sessions(&project_dir).unwrap();
423 let _ = mgr.update_global_sessions(&project_dir).unwrap();
461
424
src/ssh.sh+18−10
@@ -2,6 +2,7 @@
2set -eu
2set -eu
3
3
4SSH_USER="__SSH_USER__"
4SSH_USER="__SSH_USER__"
5+SUDO_PASSWORD="__SUDO_PASSWORD__"
5PROJECT_NAME="__PROJECT_NAME__"
6PROJECT_NAME="__PROJECT_NAME__"
6PROJECT_GUEST_DIR="__PROJECT_GUEST_DIR__"
7PROJECT_GUEST_DIR="__PROJECT_GUEST_DIR__"
7KEY_PATH="__KEY_PATH__"
8KEY_PATH="__KEY_PATH__"
@@ -61,6 +62,10 @@ if ! id -u "$SSH_USER" >/dev/null 2>&1; then
61 usermod -aG sudo "$SSH_USER" || true
62 usermod -aG sudo "$SSH_USER" || true
62fi
63fi
63
64
65+if [ -n "$SUDO_PASSWORD" ]; then
66+ echo "${SSH_USER}:${SUDO_PASSWORD}" | chpasswd
67+fi
68+
64install -d -m 700 -o "$SSH_USER" -g "$SSH_USER" "/home/${SSH_USER}/.ssh"
69install -d -m 700 -o "$SSH_USER" -g "$SSH_USER" "/home/${SSH_USER}/.ssh"
65install -m 600 -o "$SSH_USER" -g "$SSH_USER" "$KEY_PATH" "/home/${SSH_USER}/.ssh/authorized_keys"
70install -m 600 -o "$SSH_USER" -g "$SSH_USER" "$KEY_PATH" "/home/${SSH_USER}/.ssh/authorized_keys"
66
71
@@ -109,8 +114,8 @@ mise_ok() { command -v mise >/dev/null 2>&1 || [ -x "$MISE_BIN" ]; }
109mise_install() {
114mise_install() {
110 if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
115 if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
111 if ! curl https://mise.run | HOME="$USER_HOME" sh; then
116 if ! curl https://mise.run | HOME="$USER_HOME" sh; then
112− mise_warn "mise install script failed (continuing)"
117+ mise_warn "mise install script failed"
113− return 0
118+ return 1
114 fi
119 fi
115 fi
120 fi
116 echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
121 echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
@@ -136,18 +141,21 @@ MISE
136 touch "${USER_HOME}/.config/mise/mise.lock"
141 touch "${USER_HOME}/.config/mise/mise.lock"
137 if [ -x "$MISE_BIN" ]; then
142 if [ -x "$MISE_BIN" ]; then
138 if ! HOME="$USER_HOME" "$MISE_BIN" install; then
143 if ! HOME="$USER_HOME" "$MISE_BIN" install; then
139− mise_warn "mise install failed (continuing)"
144+ mise_warn "mise install failed"
140− return 0
145+ return 1
141 fi
146 fi
142 else
147 else
143 if ! HOME="$USER_HOME" mise install; then
148 if ! HOME="$USER_HOME" mise install; then
144− mise_warn "mise install failed (continuing)"
149+ mise_warn "mise install failed"
145− return 0
150+ return 1
146 fi
151 fi
147 fi
152 fi
148}
153}
149
154
150−mise_install || true
155+if ! mise_install; then
156+ diag "mise installation failed"
157+ vibebox_fail "mise installation failed" 1
158+fi
151
159
152# 3) start ssh (don't swallow failures)
160# 3) start ssh (don't swallow failures)
153# If ssh is already active, don't force start/restart.
161# If ssh is already active, don't force start/restart.
@@ -155,7 +163,7 @@ if ! systemctl is-active --quiet ssh; then
155 if ! systemctl start ssh; then
163 if ! systemctl start ssh; then
156 diag "systemctl start ssh failed"
164 diag "systemctl start ssh failed"
157 dump_diag
165 dump_diag
158− exit 1
166+ vibebox_fail "failed to start ssh service" 1
159 fi
167 fi
160fi
168fi
161
169
@@ -193,7 +201,7 @@ done
193if [ -z "$dev" ] || [ -z "$ip" ]; then
201if [ -z "$dev" ] || [ -z "$ip" ]; then
194 diag "no stable IPv4 on default route interface"
202 diag "no stable IPv4 on default route interface"
195 dump_diag
203 dump_diag
196− exit 1
204+ vibebox_fail "no stable ipv4 route on default interface" 1
197fi
205fi
198
206
199# 5) strong verify: ssh must listen externally (0.0.0.0:22 or $ip:22 or [::]:22)
207# 5) strong verify: ssh must listen externally (0.0.0.0:22 or $ip:22 or [::]:22)
@@ -212,7 +220,7 @@ done
212if ! listens_ok; then
220if ! listens_ok; then
213 diag "sshd not listening on 0.0.0.0:22 / ${ip}:22"
221 diag "sshd not listening on 0.0.0.0:22 / ${ip}:22"
214 dump_diag
222 dump_diag
215− exit 1
223+ vibebox_fail "sshd is not listening on the expected address" 1
216fi
224fi
217
225
218ip a
226ip a
src/tui.rs+31−31
@@ -1,10 +1,4 @@
1−use std::{
1+use bytesize::ByteSize;
2− io::{self, Write},
3− os::unix::io::OwnedFd,
4− path::PathBuf,
5− sync::{Arc, Mutex},
6−};
7−
8use color_eyre::Result;
2use color_eyre::Result;
9use crossterm::{
3use crossterm::{
10 cursor::{MoveTo, Show},
4 cursor::{MoveTo, Show},
@@ -22,6 +16,12 @@ use ratatui::{
22 text::{Line, Span, Text},
16 text::{Line, Span, Text},
23 widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table, Widget},
17 widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table, Widget},
24};
18};
19+use std::{
20+ io::{self, Write},
21+ os::unix::io::OwnedFd,
22+ path::PathBuf,
23+ sync::{Arc, Mutex},
24+};
25
25
26use crate::vm;
26use crate::vm;
27
27
@@ -39,9 +39,9 @@ const INFO_LINE_COUNT: u16 = 5;
39
39
40#[derive(Debug, Clone)]
40#[derive(Debug, Clone)]
41pub struct VmInfo {
41pub struct VmInfo {
42− pub max_memory_mb: u64,
42+ pub max_memory: ByteSize,
43 pub cpu_cores: usize,
43 pub cpu_cores: usize,
44− pub max_disk_gb: f32,
44+ pub max_disk: ByteSize,
45 pub system_name: String,
45 pub system_name: String,
46 pub auto_shutdown_ms: u64,
46 pub auto_shutdown_ms: u64,
47}
47}
@@ -474,27 +474,27 @@ fn write_buffer_with_style(buffer: &Buffer, out: &mut impl Write) -> io::Result<
474 Ok(())
474 Ok(())
475}
475}
476
476
477−fn map_color(color: ratatui::style::Color) -> CrosstermColor {
477+fn map_color(color: Color) -> CrosstermColor {
478 match color {
478 match color {
479− ratatui::style::Color::Reset => CrosstermColor::Reset,
479+ Color::Reset => CrosstermColor::Reset,
480− ratatui::style::Color::Black => CrosstermColor::Black,
480+ Color::Black => CrosstermColor::Black,
481− ratatui::style::Color::Red => CrosstermColor::DarkRed,
481+ Color::Red => CrosstermColor::DarkRed,
482− ratatui::style::Color::Green => CrosstermColor::DarkGreen,
482+ Color::Green => CrosstermColor::DarkGreen,
483− ratatui::style::Color::Yellow => CrosstermColor::DarkYellow,
483+ Color::Yellow => CrosstermColor::DarkYellow,
484− ratatui::style::Color::Blue => CrosstermColor::DarkBlue,
484+ Color::Blue => CrosstermColor::DarkBlue,
485− ratatui::style::Color::Magenta => CrosstermColor::DarkMagenta,
485+ Color::Magenta => CrosstermColor::DarkMagenta,
486− ratatui::style::Color::Cyan => CrosstermColor::DarkCyan,
486+ Color::Cyan => CrosstermColor::DarkCyan,
487− ratatui::style::Color::Gray => CrosstermColor::Grey,
487+ Color::Gray => CrosstermColor::Grey,
488− ratatui::style::Color::DarkGray => CrosstermColor::DarkGrey,
488+ Color::DarkGray => CrosstermColor::DarkGrey,
489− ratatui::style::Color::LightRed => CrosstermColor::Red,
489+ Color::LightRed => CrosstermColor::Red,
490− ratatui::style::Color::LightGreen => CrosstermColor::Green,
490+ Color::LightGreen => CrosstermColor::Green,
491− ratatui::style::Color::LightYellow => CrosstermColor::Yellow,
491+ Color::LightYellow => CrosstermColor::Yellow,
492− ratatui::style::Color::LightBlue => CrosstermColor::Blue,
492+ Color::LightBlue => CrosstermColor::Blue,
493− ratatui::style::Color::LightMagenta => CrosstermColor::Magenta,
493+ Color::LightMagenta => CrosstermColor::Magenta,
494− ratatui::style::Color::LightCyan => CrosstermColor::Cyan,
494+ Color::LightCyan => CrosstermColor::Cyan,
495− ratatui::style::Color::White => CrosstermColor::White,
495+ Color::White => CrosstermColor::White,
496− ratatui::style::Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
496+ Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
497− ratatui::style::Color::Indexed(i) => CrosstermColor::AnsiValue(i),
497+ Color::Indexed(i) => CrosstermColor::AnsiValue(i),
498 }
498 }
499}
499}
500
500
@@ -574,8 +574,8 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
574 Span::raw("CPU / Memory / Disk: "),
574 Span::raw("CPU / Memory / Disk: "),
575 Span::styled(
575 Span::styled(
576 format!(
576 format!(
577− "{} cores / {} MB / {} GB",
577+ "{} cores / {} / {}",
578− app.vm_info.cpu_cores, app.vm_info.max_memory_mb, app.vm_info.max_disk_gb
578+ app.vm_info.cpu_cores, app.vm_info.max_memory, app.vm_info.max_disk
579 ),
579 ),
580 Style::default().fg(Color::Green),
580 Style::default().fg(Color::Green),
581 ),
581 ),
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;
2use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
1use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
2+use anyhow::{Context, Error, Result, bail};
3use std::{
3use std::{
4 env, fs,
4 env, fs,
5 io::{self, Write},
5 io::{self, Write},
@@ -23,6 +23,7 @@ use std::{
23};
23};
24
24
25use block2::RcBlock;
25use block2::RcBlock;
26+use bytesize::ByteSize;
26use dispatch2::DispatchQueue;
27use dispatch2::DispatchQueue;
27use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
28use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
28use objc2_foundation::*;
29use objc2_foundation::*;
@@ -34,68 +35,44 @@ const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
34const SHARED_DIRECTORIES_TAG: &str = "shared";
35const SHARED_DIRECTORIES_TAG: &str = "shared";
35pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts";
36pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts";
36
37
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;
41const START_TIMEOUT: Duration = Duration::from_secs(60);
38const START_TIMEOUT: Duration = Duration::from_secs(60);
42const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
39const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
40+const PROVISION_EXPECT_TIMEOUT: Duration = Duration::from_secs(900);
43
41
44−struct StatusFile {
42+const ERROR_REPORT_SCRIPT: &str = include_str!("error_report.sh");
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−}
70const PROVISION_SCRIPT: &str = include_str!("provision.sh");
43const PROVISION_SCRIPT: &str = include_str!("provision.sh");
71−const PROVISION_SCRIPT_NAME: &str = "provision.sh";
72const RESIZE_DISK_SCRIPT: &str = include_str!("resize_disk.sh");
44const RESIZE_DISK_SCRIPT: &str = include_str!("resize_disk.sh");
73const DEFAULT_RAW_NAME: &str = "default.raw";
45const DEFAULT_RAW_NAME: &str = "default.raw";
74const INSTANCE_RAW_NAME: &str = "instance.raw";
46const INSTANCE_RAW_NAME: &str = "instance.raw";
75const BASE_DISK_RAW_NAME: &str = "disk.raw";
47const BASE_DISK_RAW_NAME: &str = "disk.raw";
76
48
77#[derive(Clone)]
49#[derive(Clone)]
78−pub(crate) enum LoginAction {
50+pub enum LoginAction {
51+ Expect {
52+ text: String,
79− Expect { text: String, timeout: Duration },
53+ timeout: Duration,
54+ },
55+ ExpectEither {
56+ success: String,
57+ failure: String,
58+ timeout: Duration,
59+ },
80 Send(String),
60 Send(String),
81}
61}
62+use crate::config::BoxConfig;
82use LoginAction::*;
63use LoginAction::*;
83
64
84#[derive(Clone)]
65#[derive(Clone)]
85−pub(crate) struct DirectoryShare {
66+pub struct DirectoryShare {
86 host: PathBuf,
67 host: PathBuf,
87 guest: PathBuf,
68 guest: PathBuf,
88 read_only: bool,
69 read_only: bool,
89}
70}
90
71
91impl DirectoryShare {
72impl DirectoryShare {
92− pub(crate) fn new(
93− host: PathBuf,
94− mut guest: PathBuf,
73+ pub fn new(host: PathBuf, mut guest: PathBuf, read_only: bool) -> Result<Self> {
95− read_only: bool,
96− ) -> Result<Self, Box<dyn std::error::Error>> {
97 if !host.exists() {
74 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()));
99 }
76 }
100 if !guest.is_absolute() {
77 if !guest.is_absolute() {
101 guest = PathBuf::from("/root").join(guest);
78 guest = PathBuf::from("/root").join(guest);
@@ -107,10 +84,10 @@ impl DirectoryShare {
107 })
84 })
108 }
85 }
109
86
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> {
111 let parts: Vec<&str> = spec.split(':').collect();
88 let parts: Vec<&str> = spec.split(':').collect();
112 if parts.len() < 2 || parts.len() > 3 {
89 if parts.len() < 2 || parts.len() > 3 {
113− return Err(format!("Invalid mount spec: {spec}").into());
90+ bail!(format!("invalid mount spec: {spec}"));
114 }
91 }
115 let host = expand_tilde_path(parts[0]);
92 let host = expand_tilde_path(parts[0]);
116 let guest = PathBuf::from(parts[1]);
93 let guest = PathBuf::from(parts[1]);
@@ -119,11 +96,10 @@ impl DirectoryShare {
119 "read-only" => true,
96 "read-only" => true,
120 "read-write" => false,
97 "read-write" => false,
121 _ => {
98 _ => {
122− return Err(format!(
99+ bail!(format!(
123 "Invalid mount mode '{}'; expected read-only or read-write",
100 "Invalid mount mode '{}'; expected read-only or read-write",
124 parts[2]
101 parts[2]
125− )
102+ ));
126− .into());
127 }
103 }
128 }
104 }
129 } else {
105 } else {
@@ -167,19 +143,28 @@ pub struct VmArg {
167 pub mounts: Vec<String>,
143 pub mounts: Vec<String>,
168}
144}
169
145
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+
170−pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<(), Box<dyn std::error::Error>>
154+pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<()>
171where
155where
172 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
156 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
173{
157{
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)
175}
159}
176
160
177−pub(crate) fn run_with_args_and_extras<F>(
161+pub fn run_with_args_and_extras<F>(
178 args: VmArg,
162 args: VmArg,
179 io_handler: F,
163 io_handler: F,
180 extra_login_actions: Vec<LoginAction>,
164 extra_login_actions: Vec<LoginAction>,
181 extra_directory_shares: Vec<DirectoryShare>,
165 extra_directory_shares: Vec<DirectoryShare>,
182−) -> Result<(), Box<dyn std::error::Error>>
166+ status: Option<&StatusEmitter<'_>>,
167+) -> Result<()>
183where
168where
184 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
169 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
185{
170{
@@ -188,7 +173,7 @@ where
188 let project_root = env::current_dir()?;
173 let project_root = env::current_dir()?;
189 let project_name = project_root
174 let project_name = project_root
190 .file_name()
175 .file_name()
191− .ok_or("Project directory has no name")?
176+ .with_context(|| "Project directory has no name")?
192 .to_string_lossy()
177 .to_string_lossy()
193 .into_owned();
178 .into_owned();
194
179
@@ -200,8 +185,10 @@ where
200 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
185 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
201
186
202 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
187 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
203− let status_file = StatusFile::new(instance_dir.join(STATUS_FILE_NAME));
188+ fs::create_dir_all(&instance_dir)?;
204− status_file.update("preparing VM image...");
189+ emit_status(status, "preparing VM image...");
190+ tracing::info!("preparing VM image...");
191+ let provision_log = instance_dir.join("provision.log");
205
192
206 let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
193 let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
207 let base_compressed = cache_dir.join(basename_compressed);
194 let base_compressed = cache_dir.join(basename_compressed);
@@ -225,13 +212,14 @@ where
225 &base_compressed,
212 &base_compressed,
226 &default_raw,
213 &default_raw,
227 std::slice::from_ref(&mise_directory_share),
214 std::slice::from_ref(&mise_directory_share),
228− Some(&status_file),
215+ Some(&provision_log),
216+ status,
229 )?;
217 )?;
230 let _ = ensure_instance_disk(
218 let _ = ensure_instance_disk(
231 &instance_raw,
219 &instance_raw,
232 &default_raw,
220 &default_raw,
233− args.disk_bytes,
221+ ByteSize(args.disk_bytes),
234− Some(&status_file),
222+ status,
235 )?;
223 )?;
236 let base_size = fs::metadata(&default_raw)?.len();
224 let base_size = fs::metadata(&default_raw)?.len();
237 let instance_size = fs::metadata(&instance_raw)?.len();
225 let instance_size = fs::metadata(&instance_raw)?.len();
@@ -261,7 +249,7 @@ where
261 }
249 }
262
250
263 if needs_resize {
251 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)?;
265 login_actions.push(Send(resize_cmd));
253 login_actions.push(Send(resize_cmd));
266 }
254 }
267
255
@@ -277,25 +265,29 @@ where
277 &directory_shares[..],
265 &directory_shares[..],
278 args.cpu_count,
266 args.cpu_count,
279 args.ram_bytes,
267 args.ram_bytes,
280− Some(&status_file),
268+ status,
281 io_handler,
269 io_handler,
282 )
270 )
283}
271}
284
272
285−pub(crate) fn script_command_from_content(
273+pub fn script_command_from_content(label: &str, script: &str) -> Result<String> {
286− label: &str,
287− script: &str,
288−) -> Result<String, Box<dyn std::error::Error>> {
289 let marker = "VIBE_SCRIPT_EOF";
274 let marker = "VIBE_SCRIPT_EOF";
290 let guest_dir = "/tmp/vibe-scripts";
275 let guest_dir = "/tmp/vibe-scripts";
291 let guest_path = format!("{guest_dir}/{label}.sh");
276 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);
292 let command = format!(
284 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}"
294 );
286 );
295 if script.contains(marker) {
287 if script.contains(marker) {
296− return Err(
288+ bail!(format!(
297− format!("Script '{label}' contains marker '{marker}', cannot safely upload").into(),
289+ "Script '{label}' contains marker '{marker}', cannot safely upload"
298− );
290+ ));
299 }
291 }
300 Ok(command)
292 Ok(command)
301}
293}
@@ -359,6 +351,12 @@ enum WaitResult {
359 Found,
351 Found,
360}
352}
361
353
354+#[derive(PartialEq, Eq)]
355+enum WaitAnyResult {
356+ Timeout,
357+ Found(usize),
358+}
359+
362pub enum VmInput {
360pub enum VmInput {
363 Bytes(Vec<u8>),
361 Bytes(Vec<u8>),
364 Shutdown,
362 Shutdown,
@@ -366,6 +364,7 @@ pub enum VmInput {
366
364
367enum VmOutput {
365enum VmOutput {
368 LoginActionTimeout { action: String, timeout: Duration },
366 LoginActionTimeout { action: String, timeout: Duration },
367+ LoginActionFailed { action: String, reason: String },
369}
368}
370
369
371#[derive(Default)]
370#[derive(Default)]
@@ -402,6 +401,41 @@ impl OutputMonitor {
402 WaitResult::Found
401 WaitResult::Found
403 }
402 }
404 }
403 }
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
405}
439}
406
440
407#[derive(Debug)]
441#[derive(Debug)]
@@ -450,18 +484,16 @@ impl IoControl {
450fn ensure_base_image(
484fn ensure_base_image(
451 base_raw: &Path,
485 base_raw: &Path,
452 base_compressed: &Path,
486 base_compressed: &Path,
453− status: Option<&StatusFile>,
487+ status: Option<&StatusEmitter<'_>>,
454−) -> Result<(), Box<dyn std::error::Error>> {
488+) -> Result<()> {
455 if base_raw.exists() {
489 if base_raw.exists() {
456 return Ok(());
490 return Ok(());
457 }
491 }
458
492
459 if !base_compressed.exists()
493 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
461 {
495 {
462− if let Some(status) = status {
463− status.update("downloading base image...");
496+ emit_status(status, "downloading base image...");
464− }
465 tracing::info!("downloading base image");
497 tracing::info!("downloading base image");
466 let status = Command::new("curl")
498 let status = Command::new("curl")
467 .args([
499 .args([
@@ -476,15 +508,14 @@ fn ensure_base_image(
476 ])
508 ])
477 .status()?;
509 .status()?;
478 if !status.success() {
510 if !status.success() {
479− return Err("Failed to download base image".into());
511+ bail!("failed to download base image");
480 }
512 }
481 }
513 }
482
514
483 // Check SHA
515 // Check SHA
484 {
516 {
485− if let Some(status) = status {
517+ emit_status(status, "verifying base image...");
486− status.update("verifying base image...");
518+ tracing::info!("verifying base image...");
487− }
488 let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
519 let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
489
520
490 let mut child = Command::new("/usr/bin/shasum")
521 let mut child = Command::new("/usr/bin/shasum")
@@ -502,25 +533,25 @@ fn ensure_base_image(
502
533
503 let status = child.wait().expect("failed to wait on child");
534 let status = child.wait().expect("failed to wait on child");
504 if !status.success() {
535 if !status.success() {
536+ bail!(format!(
505− return Err(format!("SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}").into());
537+ "SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}"
538+ ));
506 }
539 }
507 }
540 }
508
541
509− if let Some(status) = status {
510− status.update("decompressing base image...");
542+ emit_status(status, "decompressing base image...");
511− }
512− tracing::info!("decompressing base image");
543+ tracing::info!("decompressing base image...");
513 let status = Command::new("tar")
544 let status = Command::new("tar")
514 .args([
545 .args([
515 "-xOf",
546 "-xOf",
516 &base_compressed.to_string_lossy(),
547 &base_compressed.to_string_lossy(),
517 BASE_DISK_RAW_NAME,
548 BASE_DISK_RAW_NAME,
518 ])
549 ])
519− .stdout(std::fs::File::create(base_raw)?)
550+ .stdout(fs::File::create(base_raw)?)
520 .status()?;
551 .status()?;
521
552
522 if !status.success() {
553 if !status.success() {
523− return Err("Failed to decompress base image".into());
554+ bail!("Failed to decompress base image");
524 }
555 }
525
556
526 Ok(())
557 Ok(())
@@ -531,29 +562,56 @@ fn ensure_default_image(
531 base_compressed: &Path,
562 base_compressed: &Path,
532 default_raw: &Path,
563 default_raw: &Path,
533 directory_shares: &[DirectoryShare],
564 directory_shares: &[DirectoryShare],
565+ provision_log: Option<&Path>,
534− status: Option<&StatusFile>,
566+ status: Option<&StatusEmitter<'_>>,
535−) -> Result<(), Box<dyn std::error::Error>> {
567+) -> Result<()> {
536 if default_raw.exists() {
568 if default_raw.exists() {
537 return Ok(());
569 return Ok(());
538 }
570 }
539
571
540 ensure_base_image(base_raw, base_compressed, status)?;
572 ensure_base_image(base_raw, base_compressed, status)?;
541
573
542− if let Some(status) = status {
543− status.update("configuring base image...");
574+ emit_status(status, "configuring base image...");
544− }
545− tracing::info!("configuring base image");
575+ tracing::info!("configuring base image...");
546 fs::copy(base_raw, default_raw)?;
576 fs::copy(base_raw, default_raw)?;
547
577
548− let provision_command = script_command_from_content(PROVISION_SCRIPT_NAME, PROVISION_SCRIPT)?;
578+ let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?;
549− run_vm(
579+ let provision_actions = [
550− default_raw,
551− &[Send(provision_command)],
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,
552− directory_shares,
604+ directory_shares,
553− DEFAULT_CPU_COUNT,
605+ BoxConfig::default().cpu_count,
554− DEFAULT_RAM_BYTES,
606+ BoxConfig::default().ram_size.as_u64(),
555− None,
607+ status,
556− )?;
608+ )
609+ };
610+
611+ if let Err(err) = provision_result {
612+ let _ = fs::remove_file(default_raw);
613+ return Err(err);
614+ }
557
615
558 Ok(())
616 Ok(())
559}
617}
@@ -561,18 +619,16 @@ fn ensure_default_image(
561fn ensure_instance_disk(
619fn ensure_instance_disk(
562 instance_raw: &Path,
620 instance_raw: &Path,
563 template_raw: &Path,
621 template_raw: &Path,
564− target_bytes: u64,
622+ target_bytes: ByteSize,
565− status: Option<&StatusFile>,
623+ status: Option<&StatusEmitter<'_>>,
566−) -> Result<bool, Box<dyn std::error::Error>> {
624+) -> Result<bool> {
567 if instance_raw.exists() {
625 if instance_raw.exists() {
568− let current_size = fs::metadata(instance_raw)?.len();
626+ let current_size = ByteSize(fs::metadata(instance_raw)?.len());
569 if current_size != target_bytes {
627 if current_size != target_bytes {
570− let current_gb = current_size as f64 / (1024.0 * 1024.0 * 1024.0);
628+ let current_gb = current_size;
571− let target_gb = target_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
629+ let target_gb = target_bytes;
572 tracing::warn!(
630 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.",
576 current_gb,
632 current_gb,
577 target_gb
633 target_gb
578 );
634 );
@@ -580,30 +636,28 @@ fn ensure_instance_disk(
580 return Ok(false);
636 return Ok(false);
581 }
637 }
582
638
583− let template_size = fs::metadata(template_raw)?.len();
639+ let template_size = ByteSize(fs::metadata(template_raw)?.len());
584 if target_bytes < template_size {
640 if target_bytes < template_size {
585− return Err(format!(
641+ bail!(format!(
586− "Requested disk size {} bytes is smaller than base image size {} bytes",
642+ "Requested disk size {} is smaller than base image size {}",
587 target_bytes, template_size
643 target_bytes, template_size
588− )
644+ ));
589− .into());
590 }
645 }
591 let target_size = target_bytes;
646 let target_size = target_bytes;
592 let needs_resize = target_size > template_size;
647 let needs_resize = target_size > template_size;
593
648
594− if let Some(status) = status {
649+ emit_status(status, "creating instance disk...");
595− status.update("creating instance disk...");
650+ tracing::info!("creating instance disk...");
596− }
597 tracing::info!(path = %template_raw.display(), "creating instance disk");
651 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())?;
599 if target_size == template_size {
653 if target_size == template_size {
600 fs::copy(template_raw, instance_raw)?;
654 fs::copy(template_raw, instance_raw)?;
601 return Ok(needs_resize);
655 return Ok(needs_resize);
602 }
656 }
603
657
604− let mut dst = std::fs::File::create(instance_raw)?;
658+ let mut dst = fs::File::create(instance_raw)?;
605− dst.set_len(target_size)?;
659+ dst.set_len(target_size.as_u64())?;
606− let mut src = std::fs::File::open(template_raw)?;
660+ let mut src = fs::File::open(template_raw)?;
607 std::io::copy(&mut src, &mut dst)?;
661 std::io::copy(&mut src, &mut dst)?;
608 Ok(needs_resize)
662 Ok(needs_resize)
609}
663}
@@ -621,18 +675,17 @@ pub fn create_pipe() -> (OwnedFd, OwnedFd) {
621 (read_stream.into(), write_stream.into())
675 (read_stream.into(), write_stream.into())
622}
676}
623
677
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+>(
625 output_monitor: Arc<OutputMonitor>,
682 output_monitor: Arc<OutputMonitor>,
626 vm_output_fd: OwnedFd,
683 vm_output_fd: OwnedFd,
627 vm_input_fd: OwnedFd,
684 vm_input_fd: OwnedFd,
628 io_control: Arc<IoControl>,
685 io_control: Arc<IoControl>,
629 mut on_line: F,
686 mut on_line: F,
630 mut on_output: G,
687 mut on_output: G,
631−) -> IoContext
688+) -> IoContext {
632−where
633− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
634− G: FnMut(&[u8]) + ::std::marker::Send + 'static,
635−{
636 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
689 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
637
690
638 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
691 // 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
780 PollResult::Spurious => continue,
833 PollResult::Spurious => continue,
781 PollResult::Ready(bytes) => {
834 PollResult::Ready(bytes) => {
782 if io_control.forward_output() {
835 if io_control.forward_output() {
783− // enable raw mode, if we haven't already
836+ // enable raw mode if we haven't already
784 if raw_guard.lock().unwrap().is_none()
837 if raw_guard.lock().unwrap().is_none()
785 && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
838 && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
786 {
839 {
787 *raw_guard.lock().unwrap() = Some(guard);
840 *raw_guard.lock().unwrap() = Some(guard);
788 }
841 }
789
842
790− let mut stdout = std::io::stdout().lock();
843+ let mut stdout = io::stdout().lock();
791 if stdout.write_all(bytes).is_err() {
844 if stdout.write_all(bytes).is_err() {
792 break;
845 break;
793 }
846 }
@@ -803,7 +856,7 @@ where
803
856
804 // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
857 // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
805 let mux_thread = thread::spawn(move || {
858 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);
807 loop {
860 loop {
808 match input_rx.recv() {
861 match input_rx.recv() {
809 Ok(VmInput::Bytes(data)) => {
862 Ok(VmInput::Bytes(data)) => {
@@ -833,7 +886,7 @@ pub fn spawn_vm_io_with_line_handler<F>(
833 on_line: F,
886 on_line: F,
834) -> IoContext
887) -> IoContext
835where
888where
836− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
889+ F: FnMut(&str) -> bool + std::marker::Send + 'static,
837{
890{
838 spawn_vm_io_with_hooks(
891 spawn_vm_io_with_hooks(
839 output_monitor,
892 output_monitor,
@@ -853,6 +906,36 @@ pub fn spawn_vm_io(
853 spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
906 spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
854}
907}
855
908
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+
856impl IoContext {
939impl IoContext {
857 pub fn shutdown(self) {
940 pub fn shutdown(self) {
858 let _ = self.input_tx.send(VmInput::Shutdown);
941 let _ = self.input_tx.send(VmInput::Shutdown);
@@ -870,7 +953,7 @@ fn create_vm_configuration(
870 vm_writes_to_fd: OwnedFd,
953 vm_writes_to_fd: OwnedFd,
871 cpu_count: usize,
954 cpu_count: usize,
872 ram_bytes: u64,
955 ram_bytes: u64,
873−) -> Result<Retained<VZVirtualMachineConfiguration>, Box<dyn std::error::Error>> {
956+) -> Result<Retained<VZVirtualMachineConfiguration>> {
874 unsafe {
957 unsafe {
875 let platform =
958 let platform =
876 VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
959 VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
@@ -904,7 +987,7 @@ fn create_vm_configuration(
904 false,
987 false,
905 VZDiskImageCachingMode::Automatic,
988 VZDiskImageCachingMode::Automatic,
906 VZDiskImageSynchronizationMode::Full,
989 VZDiskImageSynchronizationMode::Full,
907− ).unwrap();
990+ )?;
908
991
909 let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
992 let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
910 VZVirtioBlockDeviceConfiguration::alloc(),
993 VZVirtioBlockDeviceConfiguration::alloc(),
@@ -991,7 +1074,7 @@ fn create_vm_configuration(
991 // Validate
1074 // Validate
992 config.validateWithError().map_err(|e| {
1075 config.validateWithError().map_err(|e| {
993 io::Error::other(format!(
1076 io::Error::other(format!(
994− "Invalid VM configuration: {:?}",
1077+ "invalid VM configuration: {:?}",
995 e.localizedDescription()
1078 e.localizedDescription()
996 ))
1079 ))
997 })?;
1080 })?;
@@ -1000,9 +1083,9 @@ fn create_vm_configuration(
1000 }
1083 }
1001}
1084}
1002
1085
1003−fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std::error::Error>> {
1086+fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>> {
1004 unsafe {
1087 unsafe {
1005− let temp_dir = std::env::temp_dir();
1088+ let temp_dir = env::temp_dir();
1006 let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
1089 let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
1007 let url = nsurl_from_path(&temp_path)?;
1090 let url = nsurl_from_path(&temp_path)?;
1008 let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
1091 let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
@@ -1018,8 +1101,8 @@ fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std
1018fn spawn_login_actions_thread(
1101fn spawn_login_actions_thread(
1019 login_actions: Vec<LoginAction>,
1102 login_actions: Vec<LoginAction>,
1020 output_monitor: Arc<OutputMonitor>,
1103 output_monitor: Arc<OutputMonitor>,
1021− input_tx: mpsc::Sender<VmInput>,
1104+ input_tx: Sender<VmInput>,
1022− vm_output_tx: mpsc::Sender<VmOutput>,
1105+ vm_output_tx: Sender<VmOutput>,
1023) -> thread::JoinHandle<()> {
1106) -> thread::JoinHandle<()> {
1024 thread::spawn(move || {
1107 thread::spawn(move || {
1025 for a in login_actions {
1108 for a in login_actions {
@@ -1033,6 +1116,27 @@ fn spawn_login_actions_thread(
1033 return;
1116 return;
1034 }
1117 }
1035 }
1118 }
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+ },
1036 Send(mut text) => {
1140 Send(mut text) => {
1037 text.push('\n'); // Type the newline so the command is actually submitted.
1141 text.push('\n'); // Type the newline so the command is actually submitted.
1038 input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
1142 input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
@@ -1048,9 +1152,9 @@ fn run_vm_with_io<F>(
1048 directory_shares: &[DirectoryShare],
1152 directory_shares: &[DirectoryShare],
1049 cpu_count: usize,
1153 cpu_count: usize,
1050 ram_bytes: u64,
1154 ram_bytes: u64,
1051− status: Option<&StatusFile>,
1155+ status: Option<&StatusEmitter<'_>>,
1052 io_handler: F,
1156 io_handler: F,
1053−) -> Result<(), Box<dyn std::error::Error>>
1157+) -> Result<()>
1054where
1158where
1055 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
1159 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
1056{
1160{
@@ -1097,23 +1201,22 @@ where
1097
1201
1098 match rx.try_recv() {
1202 match rx.try_recv() {
1099 Ok(result) => {
1203 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)))?;
1101 break;
1205 break;
1102 }
1206 }
1103 Err(mpsc::TryRecvError::Empty) => continue,
1207 Err(mpsc::TryRecvError::Empty) => continue,
1104 Err(mpsc::TryRecvError::Disconnected) => {
1208 Err(mpsc::TryRecvError::Disconnected) => {
1105− return Err("VM start channel disconnected".into());
1209+ bail!("VM start channel disconnected");
1106 }
1210 }
1107 }
1211 }
1108 }
1212 }
1109
1213
1110 if Instant::now() >= start_deadline {
1214 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");
1112 }
1216 }
1113
1217
1114− if let Some(status) = status {
1218+ emit_status(status, "vm booting... go vibecoder!");
1115− status.update("vm booting... go vibecoder!");
1219+ tracing::info!("vm booting... go vibecoder!");
1116− }
1117 tracing::info!("vm booting");
1220 tracing::info!("vm booting");
1118
1221
1119 let output_monitor = Arc::new(OutputMonitor::default());
1222 let output_monitor = Arc::new(OutputMonitor::default());
@@ -1159,7 +1262,7 @@ where
1159 );
1262 );
1160
1263
1161 let mut last_state = None;
1264 let mut last_state = None;
1162− let mut exit_result = Ok(());
1265+ let mut exit_result: Result<(), String> = Ok(());
1163 loop {
1266 loop {
1164 unsafe {
1267 unsafe {
1165 NSRunLoop::mainRunLoop().runMode_beforeDate(
1268 NSRunLoop::mainRunLoop().runMode_beforeDate(
@@ -1178,8 +1281,24 @@ where
1178 exit_result = Err(format!(
1281 exit_result = Err(format!(
1179 "Login action ({}) timed out after {:?}; shutting down.",
1282 "Login action ({}) timed out after {:?}; shutting down.",
1180 action, timeout
1283 action, timeout
1181− )
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
1182− .into());
1301+ ));
1183 unsafe {
1302 unsafe {
1184 if vm.canRequestStop() {
1303 if vm.canRequestStop() {
1185 if let Err(err) = vm.requestStopWithError() {
1304 if let Err(err) = vm.requestStopWithError() {
@@ -1195,7 +1314,7 @@ where
1195 Err(mpsc::TryRecvError::Empty) => {}
1314 Err(mpsc::TryRecvError::Empty) => {}
1196 Err(mpsc::TryRecvError::Disconnected) => {}
1315 Err(mpsc::TryRecvError::Disconnected) => {}
1197 }
1316 }
1198− if state != objc2_virtualization::VZVirtualMachineState::Running {
1317+ if state != VZVirtualMachineState::Running {
1199 //eprintln!("VM stopped with state: {:?}", state);
1318 //eprintln!("VM stopped with state: {:?}", state);
1200 break;
1319 break;
1201 }
1320 }
@@ -1205,7 +1324,7 @@ where
1205
1324
1206 io_ctx.shutdown();
1325 io_ctx.shutdown();
1207
1326
1208− exit_result
1327+ exit_result.map_err(Error::msg)
1209}
1328}
1210
1329
1211fn run_vm(
1330fn run_vm(
@@ -1214,8 +1333,8 @@ fn run_vm(
1214 directory_shares: &[DirectoryShare],
1333 directory_shares: &[DirectoryShare],
1215 cpu_count: usize,
1334 cpu_count: usize,
1216 ram_bytes: u64,
1335 ram_bytes: u64,
1217− status: Option<&StatusFile>,
1336+ status: Option<&StatusEmitter<'_>>,
1218−) -> Result<(), Box<dyn std::error::Error>> {
1337+) -> Result<()> {
1219 run_vm_with_io(
1338 run_vm_with_io(
1220 disk_path,
1339 disk_path,
1221 login_actions,
1340 login_actions,
@@ -1227,7 +1346,7 @@ fn run_vm(
1227 )
1346 )
1228}
1347}
1229
1348
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>> {
1231 let abs_path = if path.is_absolute() {
1350 let abs_path = if path.is_absolute() {
1232 path.to_path_buf()
1351 path.to_path_buf()
1233 } else {
1352 } else {
@@ -1236,7 +1355,7 @@ fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::E
1236 let ns_path = NSString::from_str(
1355 let ns_path = NSString::from_str(
1237 abs_path
1356 abs_path
1238 .to_str()
1357 .to_str()
1239− .ok_or("Non-UTF8 path encountered while building NSURL")?,
1358+ .with_context(|| "non-UTF8 path encountered while building NSURL")?,
1240 );
1359 );
1241 Ok(NSURL::fileURLWithPath(&ns_path))
1360 Ok(NSURL::fileURLWithPath(&ns_path))
1242}
1361}
@@ -1251,7 +1370,7 @@ fn enable_raw_mode(fd: i32) -> io::Result<RawModeGuard> {
1251 let original = attributes;
1370 let original = attributes;
1252
1371
1253 // Disable translation of carriage return to newline on input
1372 // Disable translation of carriage return to newline on input
1254− attributes.c_iflag &= !(libc::ICRNL);
1373+ attributes.c_iflag &= !libc::ICRNL;
1255 // Disable canonical mode (line buffering), echo, and signal generation
1374 // Disable canonical mode (line buffering), echo, and signal generation
1256 attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
1375 attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
1257 attributes.c_cc[libc::VMIN] = 0;
1376 attributes.c_cc[libc::VMIN] = 0;
@@ -1279,10 +1398,10 @@ impl Drop for RawModeGuard {
1279
1398
1280// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
1399// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
1281pub fn ensure_signed() {
1400pub 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") {
1283 return;
1402 return;
1284 }
1403 }
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");
1286 let exe_str = exe.to_str().expect("exe path not valid utf-8");
1405 let exe_str = exe.to_str().expect("exe path not valid utf-8");
1287
1406
1288 let has_required_entitlements = {
1407 let has_required_entitlements = {
@@ -1304,8 +1423,8 @@ pub fn ensure_signed() {
1304 }
1423 }
1305
1424
1306 const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1425 const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1307− let entitlements_path = std::env::temp_dir().join("entitlements.plist");
1426+ let entitlements_path = env::temp_dir().join("entitlements.plist");
1308− std::fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
1427+ fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
1309
1428
1310 let output = Command::new("codesign")
1429 let output = Command::new("codesign")
1311 .args([
1430 .args([
@@ -1318,7 +1437,7 @@ pub fn ensure_signed() {
1318 ])
1437 ])
1319 .output();
1438 .output();
1320
1439
1321− let _ = std::fs::remove_file(&entitlements_path);
1440+ let _ = fs::remove_file(&entitlements_path);
1322
1441
1323 match output {
1442 match output {
1324 Ok(o) if o.status.success() => {
1443 Ok(o) if o.status.success() => {
@@ -1326,7 +1445,7 @@ pub fn ensure_signed() {
1326 if !stderr.trim().is_empty() {
1445 if !stderr.trim().is_empty() {
1327 tracing::debug!(codesign_stderr = %stderr.trim(), "codesign output");
1446 tracing::debug!(codesign_stderr = %stderr.trim(), "codesign output");
1328 }
1447 }
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();
1330 tracing::error!(error = %err, "failed to re-exec after signing");
1449 tracing::error!(error = %err, "failed to re-exec after signing");
1331 std::process::exit(1);
1450 std::process::exit(1);
1332 }
1451 }
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};
1use std::{
13use std::{
2 env, fs,
14 env, fs,
3 io::{Read, Write},
15 io::{Read, Write},
4 os::unix::{
16 os::unix::{
5− fs::FileTypeExt,
6 fs::PermissionsExt,
17 fs::PermissionsExt,
7 io::AsRawFd,
18 io::AsRawFd,
8 net::{UnixListener, UnixStream},
19 net::{UnixListener, UnixStream},
9− process::CommandExt,
10 },
20 },
11 path::{Path, PathBuf},
21 path::{Path, PathBuf},
12 process::{Command, Stdio},
22 process::{Command, Stdio},
@@ -15,37 +25,44 @@ use std::{
15 time::{Duration, Instant},
25 time::{Duration, Instant},
16};
26};
17
27
18−use crate::{
28+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
19− config::CONFIG_PATH_ENV,
20− instance::VM_ROOT_LOG_NAME,
29+const VM_ROOT_LOG_NAME: &str = "vm_root.log";
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−
31const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
30const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
32const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
31const 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))]
33const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 12_000;
36const 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+}
34
51
35pub fn ensure_manager(
52pub fn ensure_manager(
36 raw_args: &[std::ffi::OsString],
53 raw_args: &[std::ffi::OsString],
37 auto_shutdown_ms: u64,
54 auto_shutdown_ms: u64,
38 config_path: Option<&Path>,
55 config_path: Option<&Path>,
39−) -> Result<UnixStream, Box<dyn std::error::Error>> {
56+) -> Result<UnixStream> {
40 let project_root = env::current_dir()?;
57 let project_root = env::current_dir()?;
41 tracing::debug!(root = %project_root.display(), "ensure vm manager");
58 tracing::debug!(root = %project_root.display(), "ensure vm manager");
42 let instance_dir = ensure_instance_dir(&project_root)?;
59 let instance_dir = ensure_instance_dir(&project_root)?;
43− cleanup_stale_manager(&instance_dir);
60+ cleanup_stale_manager(&project_root)?;
44 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
61 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
45
62
46 if let Ok(stream) = UnixStream::connect(&socket_path) {
63 if let Ok(stream) = UnixStream::connect(&socket_path) {
47 send_client_pid(&stream);
64 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");
49 return Ok(stream);
66 return Ok(stream);
50 }
67 }
51
68
@@ -83,12 +100,11 @@ pub fn ensure_manager(
83 drop(lock_file.take());
100 drop(lock_file.take());
84 let _ = fs::remove_file(&lock_path);
101 let _ = fs::remove_file(&lock_path);
85 }
102 }
86− return Err(format!(
103+ bail!(format!(
87− "Timed out waiting for vm manager socket: {} ({})",
104+ "timed out waiting for vm manager socket: {} ({})",
88 socket_path.display(),
105 socket_path.display(),
89 err
106 err
90− )
107+ ));
91− .into());
92 }
108 }
93 thread::sleep(Duration::from_millis(100));
109 thread::sleep(Duration::from_millis(100));
94 }
110 }
@@ -96,12 +112,19 @@ pub fn ensure_manager(
96 }
112 }
97}
113}
98
114
99−pub fn run_manager(
100− args: vm::VmArg,
101− auto_shutdown_ms: u64,
115+pub fn run_manager(args: vm::VmArg, auto_shutdown_ms: u64) -> Result<()> {
102−) -> Result<(), Box<dyn std::error::Error>> {
103 let project_root = env::current_dir()?;
116 let project_root = env::current_dir()?;
104 tracing::info!(root = %project_root.display(), "vm manager starting");
117 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+ }
105 let _pid_guard = ensure_pid_file(&project_root)?;
128 let _pid_guard = ensure_pid_file(&project_root)?;
106 #[cfg(feature = "mock-vm")]
129 #[cfg(feature = "mock-vm")]
107 tracing::info!("vm manager using mock executor");
130 tracing::info!("vm manager using mock executor");
@@ -119,7 +142,6 @@ pub fn run_manager(
119 #[cfg(feature = "mock-vm")]
142 #[cfg(feature = "mock-vm")]
120 {
143 {
121 ManagerOptions {
144 ManagerOptions {
122− ensure_signed: false,
123 detach: true,
145 detach: true,
124 prepare_vm: false,
146 prepare_vm: false,
125 }
147 }
@@ -127,7 +149,6 @@ pub fn run_manager(
127 #[cfg(not(feature = "mock-vm"))]
149 #[cfg(not(feature = "mock-vm"))]
128 {
150 {
129 ManagerOptions {
151 ManagerOptions {
130− ensure_signed: true,
131 detach: true,
152 detach: true,
132 prepare_vm: true,
153 prepare_vm: true,
133 }
154 }
@@ -141,25 +162,22 @@ fn spawn_manager_process(
141 auto_shutdown_ms: u64,
162 auto_shutdown_ms: u64,
142 instance_dir: &Path,
163 instance_dir: &Path,
143 config_path: Option<&Path>,
164 config_path: Option<&Path>,
144−) -> Result<(), Box<dyn std::error::Error>> {
165+) -> Result<()> {
145 let exe = env::current_exe()?;
166 let exe = env::current_exe()?;
146 let mut supervisor_exe = exe.clone();
167 let mut supervisor_exe = exe.clone();
147 supervisor_exe.set_file_name("vibebox-supervisor");
168 supervisor_exe.set_file_name("vibebox-supervisor");
148− let use_supervisor = supervisor_exe.exists();
169+ // intentional
149− let mut cmd = if use_supervisor {
170+ if !supervisor_exe.exists() {
150− Command::new(supervisor_exe)
171+ bail!(format!(
151− } else {
152− let mut cmd = Command::new(exe);
153− cmd.arg0("vibebox-supervisor");
172+ "vibebox-supervisor not found at {}",
154− cmd
173+ supervisor_exe.display()
174+ ));
155− };
175+ }
176+ let mut cmd = Command::new(supervisor_exe);
156 if raw_args.len() > 1 {
177 if raw_args.len() > 1 {
157 cmd.args(&raw_args[1..]);
178 cmd.args(&raw_args[1..]);
158 }
179 }
159 cmd.env("VIBEBOX_INTERNAL", "1");
180 cmd.env("VIBEBOX_INTERNAL", "1");
160− if !use_supervisor {
161− cmd.env("VIBEBOX_VM_MANAGER", "1");
162− }
163 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
181 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
164 cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
182 cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
165 if let Some(path) = config_path {
183 if let Some(path) = config_path {
@@ -187,22 +205,22 @@ fn spawn_manager_process(
187 Ok(())
205 Ok(())
188}
206}
189
207
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> {
191 let instance_dir = ensure_instance_dir(project_root)?;
209 let instance_dir = ensure_instance_dir(project_root)?;
192 let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
210 let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
193 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
211 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
194− if let Ok(content) = fs::read_to_string(&pid_path)
212+ match vm_liveness(project_root)? {
195− && let Ok(pid) = content.trim().parse::<u32>()
213+ VmLiveness::RunningWithSocket { pid } => {
196− && pid_is_alive(pid)
197− {
198− if is_socket_path(&socket_path) {
199− return Err(format!("vm manager already running (pid {pid})").into());
214+ bail!("vm manager already running (pid {pid})");
200 }
215 }
216+ VmLiveness::RunningWithoutSocket { pid } => {
201− tracing::warn!(
217+ tracing::warn!(
202− pid,
218+ pid,
203− path = %socket_path.display(),
219+ path = %socket_path.display(),
204− "stale pid file detected with missing socket"
220+ "stale pid file detected with missing socket"
205− );
221+ );
222+ }
223+ VmLiveness::NotRunningOrMissing => {}
206 }
224 }
207 let _ = fs::remove_file(&pid_path);
225 let _ = fs::remove_file(&pid_path);
208 fs::write(&pid_path, format!("{}\n", std::process::id()))?;
226 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
210 Ok(PidFileGuard { path: pid_path })
228 Ok(PidFileGuard { path: pid_path })
211}
229}
212
230
213−fn cleanup_stale_manager(instance_dir: &Path) {
231+fn cleanup_stale_manager(project_root: &Path) -> Result<()> {
232+ let pid_path = project_root
233+ .join(INSTANCE_DIR_NAME)
214− let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
234+ .join(VM_MANAGER_PID_NAME);
215− if let Ok(content) = fs::read_to_string(&pid_path)
235+ if matches!(
216− && let Ok(pid) = content.trim().parse::<u32>()
236+ vm_liveness(project_root)?,
217− && pid_is_alive(pid)
237+ VmLiveness::RunningWithSocket { .. } | VmLiveness::RunningWithoutSocket { .. }
218− {
238+ ) {
219− return;
239+ return Ok(());
220 }
240 }
221 let _ = fs::remove_file(&pid_path);
241 let _ = fs::remove_file(&pid_path);
242+ Ok(())
222}
243}
223
244
224fn inject_project_mount(
245fn inject_project_mount(
@@ -245,12 +266,6 @@ fn inject_project_mount(
245 mounts.insert(0, format!("{host}:{guest_tilde}:read-write"));
266 mounts.insert(0, format!("{host}:{guest_tilde}:read-write"));
246}
267}
247
268
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−
254fn prepare_mounts_and_links(mut args: vm::VmArg, ssh_user: &str) -> (vm::VmArg, String) {
269fn prepare_mounts_and_links(mut args: vm::VmArg, ssh_user: &str) -> (vm::VmArg, String) {
255 let mut links = Vec::new();
270 let mut links = Vec::new();
256 let mut mounts = Vec::with_capacity(args.mounts.len());
271 let mut mounts = Vec::with_capacity(args.mounts.len());
@@ -394,6 +409,30 @@ fn wait_for_disconnect(mut stream: UnixStream) {
394 }
409 }
395}
410}
396
411
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+
397fn send_client_pid(stream: &UnixStream) {
436fn send_client_pid(stream: &UnixStream) {
398 let pid = std::process::id();
437 let pid = std::process::id();
399 let payload = format!("pid={pid}\n");
438 let payload = format!("pid={pid}\n");
@@ -403,7 +442,7 @@ fn send_client_pid(stream: &UnixStream) {
403 }
442 }
404}
443}
405
444
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>> {
407 match fs::OpenOptions::new()
446 match fs::OpenOptions::new()
408 .write(true)
447 .write(true)
409 .create_new(true)
448 .create_new(true)
@@ -437,6 +476,12 @@ fn is_lock_stale(lock_path: &Path) -> bool {
437 }
476 }
438}
477}
439
478
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+
440fn pid_is_alive(pid: u32) -> bool {
485fn pid_is_alive(pid: u32) -> bool {
441 let pid = pid as libc::pid_t;
486 let pid = pid as libc::pid_t;
442 let result = unsafe { libc::kill(pid, 0) };
487 let result = unsafe { libc::kill(pid, 0) };
@@ -450,12 +495,6 @@ fn pid_is_alive(pid: u32) -> bool {
450 }
495 }
451}
496}
452
497
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−
459fn read_client_pid(stream: &UnixStream) -> Option<u32> {
498fn read_client_pid(stream: &UnixStream) -> Option<u32> {
460 let mut stream = stream.try_clone().ok()?;
499 let mut stream = stream.try_clone().ok()?;
461 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
500 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
@@ -491,12 +530,14 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
491#[cfg_attr(feature = "mock-vm", allow(dead_code))]
530#[cfg_attr(feature = "mock-vm", allow(dead_code))]
492fn spawn_manager_io(
531fn spawn_manager_io(
493 config: Arc<Mutex<InstanceConfig>>,
532 config: Arc<Mutex<InstanceConfig>>,
494− instance_dir: PathBuf,
533+ project_dir: PathBuf,
534+ clients: ClientStreams,
535+ latest_status: SharedStatus,
495 output_monitor: Arc<vm::OutputMonitor>,
536 output_monitor: Arc<vm::OutputMonitor>,
496 vm_output_fd: std::os::unix::io::OwnedFd,
537 vm_output_fd: std::os::unix::io::OwnedFd,
497 vm_input_fd: std::os::unix::io::OwnedFd,
538 vm_input_fd: std::os::unix::io::OwnedFd,
498) -> vm::IoContext {
539) -> 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);
500 let log_file = fs::OpenOptions::new()
541 let log_file = fs::OpenOptions::new()
501 .create(true)
542 .create(true)
502 .write(true)
543 .write(true)
@@ -505,7 +546,6 @@ fn spawn_manager_io(
505 .ok()
546 .ok()
506 .map(|file| Arc::new(Mutex::new(file)));
547 .map(|file| Arc::new(Mutex::new(file)));
507
548
508− let instance_path = instance_dir.join(INSTANCE_FILENAME);
509 let config_for_output = config.clone();
549 let config_for_output = config.clone();
510 let log_for_output = log_file.clone();
550 let log_for_output = log_file.clone();
511 let mut line_buf = String::new();
551 let mut line_buf = String::new();
@@ -528,6 +568,17 @@ fn spawn_manager_io(
528 line_buf.drain(..=pos);
568 line_buf.drain(..=pos);
529
569
530 let cleaned = line.trim_start_matches(['\r', ' ']);
570 let cleaned = line.trim_start_matches(['\r', ' ']);
571+ if let Some(script_failure) = cleaned.strip_prefix("VIBEBOX_SCRIPT_ERROR:") {
572+ let failure = script_failure.trim();
573+ if !failure.is_empty() {
574+ tracing::error!(script_failure = %failure, "[vm] script reported failure");
575+ broadcast_status(
576+ &clients,
577+ &latest_status,
578+ &format!("{STATUS_VM_ERROR_PREFIX} {failure}"),
579+ );
580+ }
581+ }
531 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
582 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
532 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
583 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
533 let ip = extract_ipv4(ip_raw).unwrap_or_default();
584 let ip = extract_ipv4(ip_raw).unwrap_or_default();
@@ -536,7 +587,7 @@ fn spawn_manager_io(
536 && cfg.vm_ipv4.as_deref() != Some(ip.as_str())
587 && cfg.vm_ipv4.as_deref() != Some(ip.as_str())
537 {
588 {
538 cfg.vm_ipv4 = Some(ip.clone());
589 cfg.vm_ipv4 = Some(ip.clone());
539− let _ = write_instance_config(&instance_path, &cfg);
590+ let _ = write_instance_config(&project_dir, &cfg);
540 }
591 }
541 }
592 }
542 }
593 }
@@ -559,12 +610,12 @@ enum ManagerEvent {
559}
610}
560
611
561struct ManagerOptions {
612struct ManagerOptions {
562− ensure_signed: bool,
563 detach: bool,
613 detach: bool,
564 prepare_vm: bool,
614 prepare_vm: bool,
565}
615}
566
616
567trait VmExecutor {
617trait VmExecutor {
618+ #[allow(clippy::too_many_arguments)]
568 fn run_vm(
619 fn run_vm(
569 &self,
620 &self,
570 args: vm::VmArg,
621 args: vm::VmArg,
@@ -572,8 +623,10 @@ trait VmExecutor {
572 extra_shares: Vec<DirectoryShare>,
623 extra_shares: Vec<DirectoryShare>,
573 config: Arc<Mutex<InstanceConfig>>,
624 config: Arc<Mutex<InstanceConfig>>,
574 instance_dir: PathBuf,
625 instance_dir: PathBuf,
626+ clients: ClientStreams,
627+ latest_status: SharedStatus,
575 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
628 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
576− ) -> Result<(), Box<dyn std::error::Error>>;
629+ ) -> Result<()>;
577}
630}
578
631
579#[cfg_attr(feature = "mock-vm", allow(dead_code))]
632#[cfg_attr(feature = "mock-vm", allow(dead_code))]
@@ -586,15 +639,22 @@ impl VmExecutor for RealVmExecutor {
586 extra_login_actions: Vec<LoginAction>,
639 extra_login_actions: Vec<LoginAction>,
587 extra_shares: Vec<DirectoryShare>,
640 extra_shares: Vec<DirectoryShare>,
588 config: Arc<Mutex<InstanceConfig>>,
641 config: Arc<Mutex<InstanceConfig>>,
589− instance_dir: PathBuf,
642+ project_dir: PathBuf,
643+ clients: ClientStreams,
644+ latest_status: SharedStatus,
590 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
645 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+ };
592 vm::run_with_args_and_extras(
650 vm::run_with_args_and_extras(
593 args,
651 args,
594 |output_monitor, vm_output_fd, vm_input_fd| {
652 |output_monitor, vm_output_fd, vm_input_fd| {
595 let io_ctx = spawn_manager_io(
653 let io_ctx = spawn_manager_io(
596 config.clone(),
654 config.clone(),
597− instance_dir.clone(),
655+ project_dir.clone(),
656+ clients.clone(),
657+ latest_status.clone(),
598 output_monitor,
658 output_monitor,
599 vm_output_fd,
659 vm_output_fd,
600 vm_input_fd,
660 vm_input_fd,
@@ -604,6 +664,7 @@ impl VmExecutor for RealVmExecutor {
604 },
664 },
605 extra_login_actions,
665 extra_login_actions,
606 extra_shares,
666 extra_shares,
667+ Some(&status_callback),
607 )
668 )
608 }
669 }
609}
670}
@@ -620,8 +681,10 @@ impl VmExecutor for MockVmExecutor {
620 _extra_shares: Vec<DirectoryShare>,
681 _extra_shares: Vec<DirectoryShare>,
621 _config: Arc<Mutex<InstanceConfig>>,
682 _config: Arc<Mutex<InstanceConfig>>,
622 _instance_dir: PathBuf,
683 _instance_dir: PathBuf,
684+ _clients: ClientStreams,
685+ _latest_status: SharedStatus,
623 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
686 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
624− ) -> Result<(), Box<dyn std::error::Error>> {
687+ ) -> Result<()> {
625 let (tx, rx) = mpsc::channel::<VmInput>();
688 let (tx, rx) = mpsc::channel::<VmInput>();
626 *vm_input_tx.lock().unwrap() = Some(tx);
689 *vm_input_tx.lock().unwrap() = Some(tx);
627 tracing::info!("mock vm executor running");
690 tracing::info!("mock vm executor running");
@@ -647,24 +710,14 @@ fn run_manager_with(
647 auto_shutdown_ms: u64,
710 auto_shutdown_ms: u64,
648 executor: &dyn VmExecutor,
711 executor: &dyn VmExecutor,
649 options: ManagerOptions,
712 options: ManagerOptions,
650−) -> Result<(), Box<dyn std::error::Error>> {
713+) -> Result<()> {
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− }
661 if options.detach {
714 if options.detach {
662 detach_from_terminal();
715 detach_from_terminal();
663 }
716 }
664
717
665 let project_name = project_root
718 let project_name = project_root
666 .file_name()
719 .file_name()
667− .ok_or("Project directory has no name")?
720+ .ok_or_else(|| anyhow!("Project directory has no name"))?
668 .to_string_lossy()
721 .to_string_lossy()
669 .into_owned();
722 .into_owned();
670 let instance_dir = ensure_instance_dir(project_root)?;
723 let instance_dir = ensure_instance_dir(project_root)?;
@@ -672,16 +725,16 @@ fn run_manager_with(
672 let _ = ensure_ssh_keypair(&instance_dir)?;
725 let _ = ensure_ssh_keypair(&instance_dir)?;
673 }
726 }
674
727
675− let mut config = load_or_create_instance_config(&instance_dir)?;
728+ let mut config = load_or_create_instance_config(project_root)?;
676 if config.vm_ipv4.is_some() {
729 if config.vm_ipv4.is_some() {
677 config.vm_ipv4 = None;
730 config.vm_ipv4 = None;
678− write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
731+ write_instance_config(project_root, &config)?;
679 }
732 }
680 let config = Arc::new(Mutex::new(config));
733 let config = Arc::new(Mutex::new(config));
681 let ssh_user = config
734 let ssh_user = config
682 .lock()
735 .lock()
683− .map(|cfg| cfg.ssh_user_display())
736+ .map(|cfg| cfg.ssh_user.clone())
684− .unwrap_or_else(|_| DEFAULT_SSH_USER.to_string());
737+ .map_err(|_| anyhow!("failed to acquire ssh user display"))?;
685 if !args.no_default_mounts {
738 if !args.no_default_mounts {
686 inject_project_mount(&mut args.mounts, project_root, &ssh_user, &project_name);
739 inject_project_mount(&mut args.mounts, project_root, &ssh_user, &project_name);
687 }
740 }
@@ -689,11 +742,10 @@ fn run_manager_with(
689
742
690 let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}");
743 let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}");
691 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
744 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
692− let extra_shares = vec![DirectoryShare::new(
745+ let extra_shares = vec![
693− instance_dir.clone(),
694− ssh_guest_dir.clone().into(),
746+ DirectoryShare::new(instance_dir.clone(), ssh_guest_dir.clone().into(), true)
695− true,
747+ .map_err(|err| anyhow!(err.to_string()))?,
696− )?];
748+ ];
697 let extra_login_actions = build_ssh_login_actions(
749 let extra_login_actions = build_ssh_login_actions(
698 &config,
750 &config,
699 &project_name,
751 &project_name,
@@ -716,17 +768,43 @@ fn run_manager_with(
716 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
768 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
717 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
769 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
718
770
771+ let clients: ClientStreams = Arc::new(Mutex::new(Vec::new()));
772+ let latest_status: SharedStatus = Arc::new(Mutex::new(String::new()));
719 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
773 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
720 let event_tx_accept = event_tx.clone();
774 let event_tx_accept = event_tx.clone();
775+ let clients_accept = clients.clone();
776+ let latest_status_accept = latest_status.clone();
721 thread::spawn(move || {
777 thread::spawn(move || {
722 for stream in listener.incoming() {
778 for stream in listener.incoming() {
723 match stream {
779 match stream {
724 Ok(stream) => {
780 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+ }
725 let event_tx_conn = event_tx_accept.clone();
799 let event_tx_conn = event_tx_accept.clone();
800+ let clients_conn = clients_accept.clone();
726 thread::spawn(move || {
801 thread::spawn(move || {
727 let pid = read_client_pid(&stream);
802 let pid = read_client_pid(&stream);
728 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
803 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
729 wait_for_disconnect(stream);
804 wait_for_disconnect(stream);
805+ if let Some(fd) = client_fd {
806+ remove_client(&clients_conn, fd);
807+ }
730 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
808 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
731 });
809 });
732 }
810 }
@@ -746,34 +824,43 @@ fn run_manager_with(
746 extra_login_actions,
824 extra_login_actions,
747 extra_shares,
825 extra_shares,
748 config.clone(),
826 config.clone(),
749− instance_dir.clone(),
827+ project_root.to_path_buf(),
828+ clients.clone(),
829+ latest_status.clone(),
750 vm_input_tx.clone(),
830 vm_input_tx.clone(),
751 );
831 );
752 tracing::info!("vm manager vm run completed");
832 tracing::info!("vm manager vm run completed");
753 let vm_err = vm_result.err().map(|e| e.to_string());
833 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+ }
754 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
841 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
756 .join()
843 .join()
757− .unwrap_or_else(|_| Err("vm manager event loop panicked".into()))
844+ .unwrap_or_else(|_| Err(Error::msg("vm manager event loop panicked")))
758 .map_err(|err| err.to_string());
845 .map_err(|err| err.to_string());
759 let _ = fs::remove_file(&socket_path);
846 let _ = fs::remove_file(&socket_path);
760 if let Err(err) = &event_loop_result {
847 if let Err(err) = &event_loop_result {
761 tracing::error!(error = %err, "vm manager exiting due to event loop error");
848 tracing::error!(error = %err, "vm manager exiting due to event loop error");
762− return Err(err.to_string().into());
849+ bail!(err.to_string());
763 }
850 }
764 if let Some(err) = vm_err {
851 if let Some(err) = vm_err {
765 tracing::error!(error = %err, "vm manager exiting due to vm error");
852 tracing::error!(error = %err, "vm manager exiting due to vm error");
766− return Err(err.into());
853+ bail!(err);
767 }
854 }
768 tracing::info!("vm manager exiting");
855 tracing::info!("vm manager exiting");
769− Ok(event_loop_result?)
856+ event_loop_result.map_err(Error::msg)
770}
857}
771
858
772fn manager_event_loop(
859fn manager_event_loop(
773 event_rx: mpsc::Receiver<ManagerEvent>,
860 event_rx: mpsc::Receiver<ManagerEvent>,
774 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
861 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
775 auto_shutdown_ms: u64,
862 auto_shutdown_ms: u64,
776−) -> Result<(), String> {
863+) -> Result<()> {
777 let mut ref_count: usize = 0;
864 let mut ref_count: usize = 0;
778 let mut shutdown_deadline: Option<Instant> = None;
865 let mut shutdown_deadline: Option<Instant> = None;
779 let mut shutdown_sent = false;
866 let mut shutdown_sent = false;
@@ -782,9 +869,14 @@ fn manager_event_loop(
782 let hard_timeout = Duration::from_millis(HARD_SHUTDOWN_TIMEOUT_MS);
869 let hard_timeout = Duration::from_millis(HARD_SHUTDOWN_TIMEOUT_MS);
783
870
784 loop {
871 loop {
785− let timeout = match shutdown_deadline {
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+ }
786− Some(deadline) => deadline.saturating_duration_since(Instant::now()),
877+ (Some(shutdown), None) => shutdown.saturating_duration_since(Instant::now()),
878+ (None, Some(hard)) => hard.saturating_duration_since(Instant::now()),
787− None => Duration::from_secs(1),
879+ (None, None) => Duration::from_secs(1),
788 };
880 };
789
881
790 match event_rx.recv_timeout(timeout) {
882 match event_rx.recv_timeout(timeout) {
@@ -824,6 +916,9 @@ fn manager_event_loop(
824 && Instant::now() >= deadline
916 && Instant::now() >= deadline
825 && !shutdown_sent
917 && !shutdown_sent
826 {
918 {
919+ if hard_deadline.is_none() {
920+ hard_deadline = Some(Instant::now() + hard_timeout);
921+ }
827 let mut sent = false;
922 let mut sent = false;
828 if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
923 if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
829 if tx
924 if tx
@@ -843,19 +938,26 @@ fn manager_event_loop(
843 shutdown_deadline = None;
938 shutdown_deadline = None;
844 hard_deadline = Some(Instant::now() + hard_timeout);
939 hard_deadline = Some(Instant::now() + hard_timeout);
845 } else {
940 } else {
941+ shutdown_deadline =
846− shutdown_deadline = Some(Instant::now() + Duration::from_millis(500));
942+ Some(Instant::now() + Duration::from_millis(SHUTDOWN_RETRY_MS));
847 }
943 }
848 }
944 }
849 if ref_count == 0
945 if ref_count == 0
850− && shutdown_sent
851 && let Some(deadline) = hard_deadline
946 && let Some(deadline) = hard_deadline
852 && Instant::now() >= deadline
947 && Instant::now() >= deadline
853 {
948 {
949+ if shutdown_sent {
854− tracing::warn!(
950+ tracing::warn!(
855− timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
951+ timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
856− "force exiting: VM did not stop after shutdown timeout"
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"
857− );
958+ );
858− std::process::exit(1);
959+ }
960+ force_exit("vm manager forced exit");
859 }
961 }
860 }
962 }
861 Err(mpsc::RecvTimeoutError::Disconnected) => break,
963 Err(mpsc::RecvTimeoutError::Disconnected) => break,
@@ -868,7 +970,7 @@ fn manager_event_loop(
868#[cfg(test)]
970#[cfg(test)]
869mod tests {
971mod tests {
870 use super::*;
972 use super::*;
871− use std::{sync::mpsc, time::Duration};
973+ use std::{fs, sync::mpsc, thread, time::Duration};
872
974
873 #[test]
975 #[test]
874 fn manager_powers_off_after_grace_when_no_refs() {
976 fn manager_powers_off_after_grace_when_no_refs() {
@@ -901,4 +1003,75 @@ mod tests {
901 let _ = event_tx.send(ManagerEvent::VmExited(None));
1003 let _ = event_tx.send(ManagerEvent::VmExited(None));
902 let _ = manager_thread.join();
1004 let _ = manager_thread.join();
903 }
1005 }
1006+
1007+ #[test]
1008+ fn manager_force_exits_when_vm_input_never_ready() {
1009+ let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
1010+ let vm_input_tx = Arc::new(Mutex::new(None));
1011+
1012+ let manager_thread = thread::spawn(move || {
1013+ let _ = manager_event_loop(event_rx, vm_input_tx, 10);
1014+ });
1015+
1016+ event_tx.send(ManagerEvent::Inc(None)).unwrap();
1017+ event_tx.send(ManagerEvent::Dec(None)).unwrap();
1018+
1019+ let join_result = manager_thread.join();
1020+ assert!(
1021+ join_result.is_err(),
1022+ "expected manager to force-exit when vm input never becomes ready"
1023+ );
1024+ }
1025+
1026+ #[test]
1027+ fn manager_sends_shutdown_after_vm_input_becomes_ready() {
1028+ let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
1029+ let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
1030+ let vm_input_tx = Arc::new(Mutex::new(None));
1031+ let vm_input_for_thread = vm_input_tx.clone();
1032+
1033+ let manager_thread = thread::spawn(move || {
1034+ manager_event_loop(event_rx, vm_input_for_thread, 10).expect("event loop");
1035+ });
1036+
1037+ event_tx.send(ManagerEvent::Inc(None)).unwrap();
1038+ event_tx.send(ManagerEvent::Dec(None)).unwrap();
1039+
1040+ thread::sleep(Duration::from_millis(100));
1041+ *vm_input_tx.lock().unwrap() = Some(vm_tx);
1042+
1043+ let msg = vm_rx
1044+ .recv_timeout(Duration::from_secs(2))
1045+ .expect("poweroff");
1046+ match msg {
1047+ VmInput::Bytes(data) => {
1048+ assert_eq!(data, b"systemctl poweroff\n");
1049+ }
1050+ _ => panic!("unexpected vm input"),
1051+ }
1052+ let _ = event_tx.send(ManagerEvent::VmExited(None));
1053+ let _ = manager_thread.join();
1054+ }
1055+
1056+ #[test]
1057+ fn lock_is_not_stale_when_owner_pid_is_alive() {
1058+ let temp = tempfile::Builder::new()
1059+ .prefix("vb")
1060+ .tempdir_in("/tmp")
1061+ .expect("tempdir");
1062+ let lock_path = temp.path().join("vm.lock");
1063+ fs::write(&lock_path, format!("pid={}\n", std::process::id())).expect("write lock");
1064+ assert!(!is_lock_stale(&lock_path));
1065+ }
1066+
1067+ #[test]
1068+ fn lock_is_stale_when_owner_pid_is_missing() {
1069+ let temp = tempfile::Builder::new()
1070+ .prefix("vb")
1071+ .tempdir_in("/tmp")
1072+ .expect("tempdir");
1073+ let lock_path = temp.path().join("vm.lock");
1074+ fs::write(&lock_path, "pid=999999\n").expect("write lock");
1075+ assert!(is_lock_stale(&lock_path));
1076+ }
904}
1077}