on main help sign in
35 files changed+5446−1466
since splitsnapshots
unifiedsplit
.github/workflows/ci.yml+98 .github/workflows/release.yml+93 CONTRIBUTING.md+44 Cargo.lock+259−242 Cargo.toml+27−12 README.md+223 README.zh.md+209 docs/banner.png docs/implementations.md−1 docs/screenshot.png docs/tasks.md+23−12 install.sh → install+54−61 scripts/commit-check.sh+6 src/bin/vibebox-cli.rs−98 src/bin/vibebox-supervisor.rs+42−20 src/bin/vibebox.rs+400 src/commands.rs+111 src/config.rs+679 src/error_report.sh+28 src/explain.rs+156 src/instance.rs+329−339 src/lib.rs+3 src/provision.sh+55−5 src/resize_disk.sh+55 src/session_manager.rs+266−121 src/ssh.sh+78−24 src/tui.rs+276−43 src/utils.rs+18 src/vm.rs+374−329 src/vm_manager.rs+625−159 tests/e2e_cli.rs+58 tests/e2e_vm.rs+278 tests/e2e_vm_mock.rs+455 tests/explain_integration.rs+113 vibebox.toml+11
.github/workflows/ci.ymlnew file+98
@@ -0,0 +1,98 @@
1+name: CI
2+
3+on:
4+ push:
5+ branches:
6+ - "**"
7+ paths-ignore:
8+ - "README.md"
9+ - "CONTRIBUTING.md"
10+ - "README.zh.md"
11+ - "docs/**"
12+ - "install"
13+
14+concurrency:
15+ group: ci-${{ github.ref }}
16+ cancel-in-progress: true
17+
18+jobs:
19+ fmt:
20+ name: Format
21+ runs-on: macos-latest
22+ env:
23+ RUST_BACKTRACE: "1"
24+ steps:
25+ - uses: actions/checkout@v6
26+ - name: Install Rust
27+ uses: dtolnay/rust-toolchain@stable
28+ with:
29+ components: rustfmt
30+ - name: Cache Rust
31+ uses: Swatinem/rust-cache@v2
32+ - name: cargo fmt
33+ run: cargo fmt --all -- --check
34+
35+ clippy:
36+ name: Clippy
37+ runs-on: macos-latest
38+ env:
39+ RUST_BACKTRACE: "1"
40+ steps:
41+ - uses: actions/checkout@v6
42+ - name: Install Rust
43+ uses: dtolnay/rust-toolchain@stable
44+ with:
45+ components: clippy
46+ - name: Cache Rust
47+ uses: Swatinem/rust-cache@v2
48+ - name: cargo clippy
49+ run: cargo clippy --all-targets --all-features -- -D warnings
50+
51+ build:
52+ name: Build
53+ runs-on: macos-latest
54+ env:
55+ RUST_BACKTRACE: "1"
56+ steps:
57+ - uses: actions/checkout@v6
58+ - name: Install Rust
59+ uses: dtolnay/rust-toolchain@stable
60+ - name: Cache Rust
61+ uses: Swatinem/rust-cache@v2
62+ - name: cargo build
63+ run: cargo build --locked
64+
65+ test:
66+ name: Test
67+ runs-on: macos-latest
68+ env:
69+ RUST_BACKTRACE: "full"
70+ steps:
71+ - uses: actions/checkout@v6
72+ - name: Install Rust
73+ uses: dtolnay/rust-toolchain@stable
74+ - name: Cache Rust
75+ uses: Swatinem/rust-cache@v2
76+ - name: cargo test
77+ run: cargo test --locked
78+
79+ coverage:
80+ name: Coverage
81+ runs-on: macos-latest
82+ needs: [fmt, clippy, build, test]
83+ env:
84+ RUST_BACKTRACE: "full"
85+ steps:
86+ - uses: actions/checkout@v6
87+ - name: Install Rust
88+ uses: dtolnay/rust-toolchain@stable
89+ with:
90+ components: llvm-tools-preview
91+ - name: Cache Rust
92+ uses: Swatinem/rust-cache@v2
93+ - name: Install cargo-llvm-cov
94+ uses: taiki-e/install-action@v2
95+ with:
96+ tool: cargo-llvm-cov
97+ - name: cargo llvm-cov
98+ run: cargo llvm-cov --locked --features mock-vm --tests -- --nocapture
.github/workflows/release.ymlnew file+93
@@ -0,0 +1,93 @@
1+name: Build and Release
2+
3+permissions:
4+ contents: write
5+
6+on:
7+ push:
8+ tags:
9+ - "v*.*.*"
10+
11+concurrency:
12+ group: release-${{ github.ref }}
13+ cancel-in-progress: true
14+
15+jobs:
16+ build:
17+ runs-on: macos-latest
18+
19+ steps:
20+ - uses: actions/checkout@v6
21+ with:
22+ fetch-depth: 0
23+
24+ - name: Validate release tag format
25+ run: |
26+ TAG="${GITHUB_REF_NAME}"
27+ if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z]+)?$ ]]; then
28+ echo "Tag '$TAG' does not match required format vX.X.X or vX.X.X-word"
29+ exit 1
30+ fi
31+
32+ - name: Install Rust
33+ uses: dtolnay/rust-toolchain@stable
34+
35+ - name: Cache Rust
36+ uses: Swatinem/rust-cache@v2
37+
38+ - name: Resolve target commit
39+ run: |
40+ TARGET_SHA="$(git rev-list -n 1 "${GITHUB_REF_NAME}")"
41+ echo "TARGET_SHA=$TARGET_SHA" >> "$GITHUB_ENV"
42+
43+ - name: Verify CI passed
44+ env:
45+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46+ run: |
47+ COUNT="$(gh run list --workflow ci.yml --commit "$TARGET_SHA" --json conclusion,createdAt --jq 'length')"
48+ if [ "$COUNT" -eq 0 ]; then
49+ echo "No CI runs found for commit $TARGET_SHA"
50+ exit 1
51+ fi
52+ CONCLUSION="$(gh run list --workflow ci.yml --commit "$TARGET_SHA" --json conclusion,createdAt --jq '.[0].conclusion')"
53+ if [ "$CONCLUSION" != "success" ]; then
54+ echo "Latest CI run for commit $TARGET_SHA is $CONCLUSION"
55+ exit 1
56+ fi
57+
58+ - name: Build release binary
59+ run: cargo build --release --locked
60+
61+ - name: Publish crate
62+ env:
63+ CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
64+ run: cargo publish --locked
65+
66+ - name: Package binary
67+ run: |
68+ cd target/release
69+ zip vibebox-macos-arm64.zip vibebox
70+
71+ - name: Create tag release
72+ run: |
73+ gh release create "${{ github.ref_name }}" \
74+ --title "${{ github.ref_name }}" \
75+ --notes "Built from commit $TARGET_SHA" \
76+ target/release/vibebox-macos-arm64.zip
77+ env:
78+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
79+
80+ - name: Delete existing latest release
81+ run: gh release delete latest --cleanup-tag --yes || true
82+ env:
83+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
84+
85+ - name: Create latest release
86+ run: |
87+ gh release create latest \
88+ --title "Latest" \
89+ --notes "Built from commit $TARGET_SHA" \
90+ --prerelease \
91+ target/release/vibebox-macos-arm64.zip
92+ env:
93+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CONTRIBUTING.mdnew file+44
@@ -0,0 +1,44 @@
1+# Contributing to Vibebox
2+
3+Thanks for your interest in contributing! This guide keeps PRs small, reviewable, and consistent with the project’s
4+development workflow.
5+
6+**Prerequisites**
7+
8+- macOS on Apple Silicon (required for the virtualization backend)
9+- Rust `1.91.1` or newer (see `Cargo.toml`)
10+
11+**Getting Started**
12+
13+1. Fork the repo and create a feature branch.
14+2. Build once to validate your toolchain:
15+
16+```bash
17+cargo build --locked
18+```
19+
20+**Development Commands**
21+
22+- Format: `cargo fmt --all -- --check`
23+- Lint: `cargo clippy --all-targets --all-features -- -D warnings`
24+- Test: `cargo test --locked`
25+- Build: `cargo build --locked`
26+
27+**Submitting Changes**
28+
29+- Keep changes focused and scoped to one problem.
30+- Update or add tests when behavior changes.
31+- If you change user-facing behavior, update docs or help text.
32+- Avoid adding heavy dependencies without a clear reason.
33+
34+**Reporting Issues**
35+Please include:
36+
37+- macOS version and hardware (Apple Silicon model)
38+- Vibebox version (`vibebox --version`)
39+- Steps to reproduce
40+- Logs from `.vibebox/cli.log`, `.vibebox/vm_root.log` and `.vibebox/vm_manager.log`
41+
42+**Security**
43+If you believe you’ve found a security issue, please avoid public disclosure. Open a private report via GitHub Security
44+Advisories instead.
Cargo.lock+259−242
@@ -32,6 +32,77 @@ version = "0.2.21"
3232source = "registry+https://github.com/rust-lang/crates.io-index"
3333checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
3434
35+[[package]]
36+name = "anstream"
37+version = "0.6.21"
38+source = "registry+https://github.com/rust-lang/crates.io-index"
39+checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
40+dependencies = [
41+ "anstyle",
42+ "anstyle-parse",
43+ "anstyle-query",
44+ "anstyle-wincon",
45+ "colorchoice",
46+ "is_terminal_polyfill",
47+ "utf8parse",
48+]
49+
50+[[package]]
51+name = "anstyle"
52+version = "1.0.13"
53+source = "registry+https://github.com/rust-lang/crates.io-index"
54+checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
55+
56+[[package]]
57+name = "anstyle-parse"
58+version = "0.2.7"
59+source = "registry+https://github.com/rust-lang/crates.io-index"
60+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
61+dependencies = [
62+ "utf8parse",
63+]
64+
65+[[package]]
66+name = "anstyle-query"
67+version = "1.1.5"
68+source = "registry+https://github.com/rust-lang/crates.io-index"
69+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
70+dependencies = [
71+ "windows-sys 0.61.2",
72+]
73+
74+[[package]]
75+name = "anstyle-wincon"
76+version = "3.0.11"
77+source = "registry+https://github.com/rust-lang/crates.io-index"
78+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
79+dependencies = [
80+ "anstyle",
81+ "once_cell_polyfill",
82+ "windows-sys 0.61.2",
83+]
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+
91+[[package]]
92+name = "assert_cmd"
93+version = "2.1.2"
94+source = "registry+https://github.com/rust-lang/crates.io-index"
95+checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514"
96+dependencies = [
97+ "anstyle",
98+ "bstr",
99+ "libc",
100+ "predicates",
101+ "predicates-core",
102+ "predicates-tree",
103+ "wait-timeout",
104+]
105+
35106[[package]]
36107name = "backtrace"
37108version = "0.3.76"
@@ -62,6 +133,17 @@ dependencies = [
62133 "objc2",
63134]
64135
136+[[package]]
137+name = "bstr"
138+version = "1.12.1"
139+source = "registry+https://github.com/rust-lang/crates.io-index"
140+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
141+dependencies = [
142+ "memchr",
143+ "regex-automata",
144+ "serde",
145+]
146+
65147[[package]]
66148name = "bumpalo"
67149version = "3.19.1"
@@ -69,10 +151,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
69151checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
70152
71153[[package]]
72−name = "bytes"
73−version = "1.11.1"
154+name = "bytesize"
155+version = "2.3.1"
74156source = "registry+https://github.com/rust-lang/crates.io-index"
75−checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
157+checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3"
158+dependencies = [
159+ "serde_core",
160+]
76161
77162[[package]]
78163name = "cassowary"
@@ -95,6 +180,46 @@ version = "1.0.4"
95180source = "registry+https://github.com/rust-lang/crates.io-index"
96181checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
97182
183+[[package]]
184+name = "clap"
185+version = "4.5.57"
186+source = "registry+https://github.com/rust-lang/crates.io-index"
187+checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a"
188+dependencies = [
189+ "clap_builder",
190+ "clap_derive",
191+]
192+
193+[[package]]
194+name = "clap_builder"
195+version = "4.5.57"
196+source = "registry+https://github.com/rust-lang/crates.io-index"
197+checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238"
198+dependencies = [
199+ "anstream",
200+ "anstyle",
201+ "clap_lex",
202+ "strsim",
203+]
204+
205+[[package]]
206+name = "clap_derive"
207+version = "4.5.55"
208+source = "registry+https://github.com/rust-lang/crates.io-index"
209+checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
210+dependencies = [
211+ "heck",
212+ "proc-macro2",
213+ "quote",
214+ "syn",
215+]
216+
217+[[package]]
218+name = "clap_lex"
219+version = "0.7.7"
220+source = "registry+https://github.com/rust-lang/crates.io-index"
221+checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
222+
98223[[package]]
99224name = "color-eyre"
100225version = "0.6.5"
@@ -122,6 +247,12 @@ dependencies = [
122247 "tracing-error",
123248]
124249
250+[[package]]
251+name = "colorchoice"
252+version = "1.0.4"
253+source = "registry+https://github.com/rust-lang/crates.io-index"
254+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
255+
125256[[package]]
126257name = "compact_str"
127258version = "0.8.1"
@@ -136,6 +267,19 @@ dependencies = [
136267 "static_assertions",
137268]
138269
270+[[package]]
271+name = "console"
272+version = "0.16.2"
273+source = "registry+https://github.com/rust-lang/crates.io-index"
274+checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4"
275+dependencies = [
276+ "encode_unicode",
277+ "libc",
278+ "once_cell",
279+ "unicode-width 0.2.0",
280+ "windows-sys 0.61.2",
281+]
282+
139283[[package]]
140284name = "crossterm"
141285version = "0.28.1"
@@ -206,6 +350,24 @@ dependencies = [
206350 "serde_core",
207351]
208352
353+[[package]]
354+name = "dialoguer"
355+version = "0.12.0"
356+source = "registry+https://github.com/rust-lang/crates.io-index"
357+checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96"
358+dependencies = [
359+ "console",
360+ "shell-words",
361+ "tempfile",
362+ "zeroize",
363+]
364+
365+[[package]]
366+name = "difflib"
367+version = "0.4.0"
368+source = "registry+https://github.com/rust-lang/crates.io-index"
369+checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
370+
209371[[package]]
210372name = "dispatch2"
211373version = "0.3.0"
@@ -224,6 +386,12 @@ version = "1.15.0"
224386source = "registry+https://github.com/rust-lang/crates.io-index"
225387checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
226388
389+[[package]]
390+name = "encode_unicode"
391+version = "1.0.0"
392+source = "registry+https://github.com/rust-lang/crates.io-index"
393+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
394+
227395[[package]]
228396name = "equivalent"
229397version = "1.0.2"
@@ -262,95 +430,12 @@ version = "0.1.5"
262430source = "registry+https://github.com/rust-lang/crates.io-index"
263431checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
264432
265−[[package]]
266−name = "futures"
267−version = "0.3.31"
268−source = "registry+https://github.com/rust-lang/crates.io-index"
269−checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
270−dependencies = [
271− "futures-channel",
272− "futures-core",
273− "futures-executor",
274− "futures-io",
275− "futures-sink",
276− "futures-task",
277− "futures-util",
278−]
279−
280−[[package]]
281−name = "futures-channel"
282−version = "0.3.31"
283−source = "registry+https://github.com/rust-lang/crates.io-index"
284−checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
285−dependencies = [
286− "futures-core",
287− "futures-sink",
288−]
289−
290433[[package]]
291434name = "futures-core"
292435version = "0.3.31"
293436source = "registry+https://github.com/rust-lang/crates.io-index"
294437checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
295438
296−[[package]]
297−name = "futures-executor"
298−version = "0.3.31"
299−source = "registry+https://github.com/rust-lang/crates.io-index"
300−checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
301−dependencies = [
302− "futures-core",
303− "futures-task",
304− "futures-util",
305−]
306−
307−[[package]]
308−name = "futures-io"
309−version = "0.3.31"
310−source = "registry+https://github.com/rust-lang/crates.io-index"
311−checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
312−
313−[[package]]
314−name = "futures-macro"
315−version = "0.3.31"
316−source = "registry+https://github.com/rust-lang/crates.io-index"
317−checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
318−dependencies = [
319− "proc-macro2",
320− "quote",
321− "syn",
322−]
323−
324−[[package]]
325−name = "futures-sink"
326−version = "0.3.31"
327−source = "registry+https://github.com/rust-lang/crates.io-index"
328−checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
329−
330−[[package]]
331−name = "futures-task"
332−version = "0.3.31"
333−source = "registry+https://github.com/rust-lang/crates.io-index"
334−checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
335−
336−[[package]]
337−name = "futures-util"
338−version = "0.3.31"
339−source = "registry+https://github.com/rust-lang/crates.io-index"
340−checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
341−dependencies = [
342− "futures-channel",
343− "futures-core",
344− "futures-io",
345− "futures-macro",
346− "futures-sink",
347− "futures-task",
348− "memchr",
349− "pin-project-lite",
350− "pin-utils",
351− "slab",
352−]
353−
354439[[package]]
355440name = "getrandom"
356441version = "0.3.4"
@@ -436,6 +521,12 @@ dependencies = [
436521 "syn",
437522]
438523
524+[[package]]
525+name = "is_terminal_polyfill"
526+version = "1.70.2"
527+source = "registry+https://github.com/rust-lang/crates.io-index"
528+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
529+
439530[[package]]
440531name = "itertools"
441532version = "0.13.0"
@@ -467,12 +558,6 @@ version = "1.5.0"
467558source = "registry+https://github.com/rust-lang/crates.io-index"
468559checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
469560
470−[[package]]
471−name = "lexopt"
472−version = "0.3.1"
473−source = "registry+https://github.com/rust-lang/crates.io-index"
474−checksum = "9fa0e2a1fcbe2f6be6c42e342259976206b383122fc152e872795338b5a3f3a7"
475−
476561[[package]]
477562name = "libc"
478563version = "0.2.180"
@@ -557,7 +642,7 @@ version = "0.50.3"
557642source = "registry+https://github.com/rust-lang/crates.io-index"
558643checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
559644dependencies = [
560− "windows-sys 0.59.0",
645+ "windows-sys 0.61.2",
561646]
562647
563648[[package]]
@@ -646,6 +731,12 @@ version = "1.21.3"
646731source = "registry+https://github.com/rust-lang/crates.io-index"
647732checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
648733
734+[[package]]
735+name = "once_cell_polyfill"
736+version = "1.70.2"
737+source = "registry+https://github.com/rust-lang/crates.io-index"
738+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
739+
649740[[package]]
650741name = "owo-colors"
651742version = "4.2.3"
@@ -687,18 +778,39 @@ version = "0.2.16"
687778source = "registry+https://github.com/rust-lang/crates.io-index"
688779checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
689780
690−[[package]]
691−name = "pin-utils"
692−version = "0.1.0"
693−source = "registry+https://github.com/rust-lang/crates.io-index"
694−checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
695−
696781[[package]]
697782name = "powerfmt"
698783version = "0.2.0"
699784source = "registry+https://github.com/rust-lang/crates.io-index"
700785checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
701786
787+[[package]]
788+name = "predicates"
789+version = "3.1.3"
790+source = "registry+https://github.com/rust-lang/crates.io-index"
791+checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
792+dependencies = [
793+ "anstyle",
794+ "difflib",
795+ "predicates-core",
796+]
797+
798+[[package]]
799+name = "predicates-core"
800+version = "1.0.9"
801+source = "registry+https://github.com/rust-lang/crates.io-index"
802+checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
803+
804+[[package]]
805+name = "predicates-tree"
806+version = "1.0.12"
807+source = "registry+https://github.com/rust-lang/crates.io-index"
808+checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
809+dependencies = [
810+ "predicates-core",
811+ "termtree",
812+]
813+
702814[[package]]
703815name = "proc-macro2"
704816version = "1.0.106"
@@ -868,6 +980,12 @@ dependencies = [
868980 "lazy_static",
869981]
870982
983+[[package]]
984+name = "shell-words"
985+version = "1.1.1"
986+source = "registry+https://github.com/rust-lang/crates.io-index"
987+checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
988+
871989[[package]]
872990name = "signal-hook"
873991version = "0.3.18"
@@ -899,28 +1017,12 @@ dependencies = [
8991017 "libc",
9001018]
9011019
902−[[package]]
903−name = "slab"
904−version = "0.4.12"
905−source = "registry+https://github.com/rust-lang/crates.io-index"
906−checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
907−
9081020[[package]]
9091021name = "smallvec"
9101022version = "1.15.1"
9111023source = "registry+https://github.com/rust-lang/crates.io-index"
9121024checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
9131025
914−[[package]]
915−name = "socket2"
916−version = "0.6.2"
917−source = "registry+https://github.com/rust-lang/crates.io-index"
918−checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
919−dependencies = [
920− "libc",
921− "windows-sys 0.60.2",
922−]
923−
9241026[[package]]
9251027name = "static_assertions"
9261028version = "1.1.0"
@@ -979,6 +1081,12 @@ dependencies = [
9791081 "windows-sys 0.61.2",
9801082]
9811083
1084+[[package]]
1085+name = "termtree"
1086+version = "0.5.1"
1087+source = "registry+https://github.com/rust-lang/crates.io-index"
1088+checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
1089+
9821090[[package]]
9831091name = "thiserror"
9841092version = "2.0.18"
@@ -1039,34 +1147,6 @@ dependencies = [
10391147 "time-core",
10401148]
10411149
1042−[[package]]
1043−name = "tokio"
1044−version = "1.49.0"
1045−source = "registry+https://github.com/rust-lang/crates.io-index"
1046−checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
1047−dependencies = [
1048− "bytes",
1049− "libc",
1050− "mio",
1051− "parking_lot",
1052− "pin-project-lite",
1053− "signal-hook-registry",
1054− "socket2",
1055− "tokio-macros",
1056− "windows-sys 0.61.2",
1057−]
1058−
1059−[[package]]
1060−name = "tokio-macros"
1061−version = "2.6.0"
1062−source = "registry+https://github.com/rust-lang/crates.io-index"
1063−checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
1064−dependencies = [
1065− "proc-macro2",
1066− "quote",
1067− "syn",
1068−]
1069−
10701150[[package]]
10711151name = "toml"
10721152version = "0.9.11+spec-1.1.0"
@@ -1177,16 +1257,6 @@ dependencies = [
11771257 "tracing-log",
11781258]
11791259
1180−[[package]]
1181−name = "tui-textarea"
1182−version = "0.7.0"
1183−source = "registry+https://github.com/rust-lang/crates.io-index"
1184−checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae"
1185−dependencies = [
1186− "ratatui",
1187− "unicode-width 0.2.0",
1188−]
1189−
11901260[[package]]
11911261name = "unicode-ident"
11921262version = "1.0.22"
@@ -1222,6 +1292,12 @@ version = "0.2.0"
12221292source = "registry+https://github.com/rust-lang/crates.io-index"
12231293checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
12241294
1295+[[package]]
1296+name = "utf8parse"
1297+version = "0.2.2"
1298+source = "registry+https://github.com/rust-lang/crates.io-index"
1299+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
1300+
12251301[[package]]
12261302name = "uuid"
12271303version = "1.20.0"
@@ -1242,14 +1318,17 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
12421318
12431319[[package]]
12441320name = "vibebox"
1245−version = "0.1.0"
1321+version = "0.3.2"
12461322dependencies = [
1323+ "anyhow",
1324+ "assert_cmd",
12471325 "block2",
1326+ "bytesize",
1327+ "clap",
12481328 "color-eyre",
12491329 "crossterm",
1330+ "dialoguer",
12501331 "dispatch2",
1251− "futures",
1252− "lexopt",
12531332 "libc",
12541333 "objc2",
12551334 "objc2-foundation",
@@ -1259,15 +1338,21 @@ dependencies = [
12591338 "tempfile",
12601339 "thiserror",
12611340 "time",
1262− "tokio",
12631341 "toml",
12641342 "tracing",
12651343 "tracing-subscriber",
1266− "tui-textarea",
1267− "unicode-width 0.2.0",
12681344 "uuid",
12691345]
12701346
1347+[[package]]
1348+name = "wait-timeout"
1349+version = "0.2.1"
1350+source = "registry+https://github.com/rust-lang/crates.io-index"
1351+checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
1352+dependencies = [
1353+ "libc",
1354+]
1355+
12711356[[package]]
12721357name = "wasi"
12731358version = "0.11.1+wasi-snapshot-preview1"
@@ -1362,16 +1447,7 @@ version = "0.59.0"
13621447source = "registry+https://github.com/rust-lang/crates.io-index"
13631448checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
13641449dependencies = [
1365− "windows-targets 0.52.6",
1366−]
1367−
1368−[[package]]
1369−name = "windows-sys"
1370−version = "0.60.2"
1371−source = "registry+https://github.com/rust-lang/crates.io-index"
1372−checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
1373−dependencies = [
1374− "windows-targets 0.53.5",
1450+ "windows-targets",
13751451]
13761452
13771453[[package]]
@@ -1389,31 +1465,14 @@ version = "0.52.6"
13891465source = "registry+https://github.com/rust-lang/crates.io-index"
13901466checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
13911467dependencies = [
1392− "windows_aarch64_gnullvm 0.52.6",
1393− "windows_aarch64_msvc 0.52.6",
1394− "windows_i686_gnu 0.52.6",
1395− "windows_i686_gnullvm 0.52.6",
1396− "windows_i686_msvc 0.52.6",
1397− "windows_x86_64_gnu 0.52.6",
1398− "windows_x86_64_gnullvm 0.52.6",
1399− "windows_x86_64_msvc 0.52.6",
1400−]
1401−
1402−[[package]]
1403−name = "windows-targets"
1404−version = "0.53.5"
1405−source = "registry+https://github.com/rust-lang/crates.io-index"
1406−checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
1407−dependencies = [
1408− "windows-link",
1409− "windows_aarch64_gnullvm 0.53.1",
1410− "windows_aarch64_msvc 0.53.1",
1411− "windows_i686_gnu 0.53.1",
1412− "windows_i686_gnullvm 0.53.1",
1413− "windows_i686_msvc 0.53.1",
1414− "windows_x86_64_gnu 0.53.1",
1415− "windows_x86_64_gnullvm 0.53.1",
1416− "windows_x86_64_msvc 0.53.1",
1468+ "windows_aarch64_gnullvm",
1469+ "windows_aarch64_msvc",
1470+ "windows_i686_gnu",
1471+ "windows_i686_gnullvm",
1472+ "windows_i686_msvc",
1473+ "windows_x86_64_gnu",
1474+ "windows_x86_64_gnullvm",
1475+ "windows_x86_64_msvc",
14171476]
14181477
14191478[[package]]
@@ -1422,96 +1481,48 @@ version = "0.52.6"
14221481source = "registry+https://github.com/rust-lang/crates.io-index"
14231482checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
14241483
1425−[[package]]
1426−name = "windows_aarch64_gnullvm"
1427−version = "0.53.1"
1428−source = "registry+https://github.com/rust-lang/crates.io-index"
1429−checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
1430−
14311484[[package]]
14321485name = "windows_aarch64_msvc"
14331486version = "0.52.6"
14341487source = "registry+https://github.com/rust-lang/crates.io-index"
14351488checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
14361489
1437−[[package]]
1438−name = "windows_aarch64_msvc"
1439−version = "0.53.1"
1440−source = "registry+https://github.com/rust-lang/crates.io-index"
1441−checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
1442−
14431490[[package]]
14441491name = "windows_i686_gnu"
14451492version = "0.52.6"
14461493source = "registry+https://github.com/rust-lang/crates.io-index"
14471494checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
14481495
1449−[[package]]
1450−name = "windows_i686_gnu"
1451−version = "0.53.1"
1452−source = "registry+https://github.com/rust-lang/crates.io-index"
1453−checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
1454−
14551496[[package]]
14561497name = "windows_i686_gnullvm"
14571498version = "0.52.6"
14581499source = "registry+https://github.com/rust-lang/crates.io-index"
14591500checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
14601501
1461−[[package]]
1462−name = "windows_i686_gnullvm"
1463−version = "0.53.1"
1464−source = "registry+https://github.com/rust-lang/crates.io-index"
1465−checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
1466−
14671502[[package]]
14681503name = "windows_i686_msvc"
14691504version = "0.52.6"
14701505source = "registry+https://github.com/rust-lang/crates.io-index"
14711506checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
14721507
1473−[[package]]
1474−name = "windows_i686_msvc"
1475−version = "0.53.1"
1476−source = "registry+https://github.com/rust-lang/crates.io-index"
1477−checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
1478−
14791508[[package]]
14801509name = "windows_x86_64_gnu"
14811510version = "0.52.6"
14821511source = "registry+https://github.com/rust-lang/crates.io-index"
14831512checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
14841513
1485−[[package]]
1486−name = "windows_x86_64_gnu"
1487−version = "0.53.1"
1488−source = "registry+https://github.com/rust-lang/crates.io-index"
1489−checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
1490−
14911514[[package]]
14921515name = "windows_x86_64_gnullvm"
14931516version = "0.52.6"
14941517source = "registry+https://github.com/rust-lang/crates.io-index"
14951518checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
14961519
1497−[[package]]
1498−name = "windows_x86_64_gnullvm"
1499−version = "0.53.1"
1500−source = "registry+https://github.com/rust-lang/crates.io-index"
1501−checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
1502−
15031520[[package]]
15041521name = "windows_x86_64_msvc"
15051522version = "0.52.6"
15061523source = "registry+https://github.com/rust-lang/crates.io-index"
15071524checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
15081525
1509−[[package]]
1510−name = "windows_x86_64_msvc"
1511−version = "0.53.1"
1512−source = "registry+https://github.com/rust-lang/crates.io-index"
1513−checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
1514−
15151526[[package]]
15161527name = "winnow"
15171528version = "0.7.14"
@@ -1523,3 +1534,9 @@ name = "wit-bindgen"
15231534version = "0.51.0"
15241535source = "registry+https://github.com/rust-lang/crates.io-index"
15251536checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
1537+
1538+[[package]]
1539+name = "zeroize"
1540+version = "1.8.2"
1541+source = "registry+https://github.com/rust-lang/crates.io-index"
1542+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
Cargo.toml+27−12
@@ -1,11 +1,20 @@
11[package]
22name = "vibebox"
3−version = "0.1.0"
3+version = "0.3.2"
44edition = "2024"
5+authors = ["Finn Sheng"]
6+description = "Ultrafast CLI on Apple Silicon macOS for fast, sandboxed development and LLM agents."
7+homepage = "https://github.com/robcholz/vibebox"
8+repository = "https://github.com/robcholz/vibebox"
9+readme = "README.md"
10+license = "MIT"
11+categories = ["virtualization","command-line-utilities"]
12+keywords = ["virtualization", "ai", "macos", "agent", "sandbox"]
13+rust-version = "1.91.1"
514
615[dependencies]
7−objc2 = "*"
8−objc2-foundation = { version = "*", features = [
16+objc2 = "0.6.3"
17+objc2-foundation = { version = "0.3.2", features = [
918 "NSArray",
1019 "NSString",
1120 "NSURL",
@@ -16,11 +25,11 @@ objc2-foundation = { version = "*", features = [
1625 "NSRunLoop",
1726 "NSObject",
1827] }
19−objc2-virtualization = "*"
20−block2 = "*"
21−dispatch2 = "*"
22−libc = "*"
23−lexopt = "0.3"
28+objc2-virtualization = "0.3.2"
29+block2 = "0.6.2"
30+dispatch2 = "0.3.0"
31+libc = "0.2.180"
32+clap = { version = "4", features = ["derive"] }
2433serde = { version = "1", features = ["derive"] }
2534tempfile = "3"
2635thiserror = "2.0.18"
@@ -29,10 +38,16 @@ toml = "0.9.8"
2938uuid = { version = "1", features = ["v7", "serde"] }
3039color-eyre = "0.6.3"
3140crossterm = { version = "0.28.1", features = ["event-stream"] }
32−futures = "0.3.31"
3341ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] }
34−tokio = { version = "1.40.0", features = ["full"] }
35−tui-textarea = { version = "0.7.0", default-features = false, features = ["ratatui"] }
36−unicode-width = "0.2"
3742tracing = "0.1"
3843tracing-subscriber = { version = "0.3", features = ["env-filter"] }
44+dialoguer = "0.12.0"
45+bytesize = {version = "2.3.1",features = ["serde"]}
46+anyhow = "1.0.101"
47+
48+[dev-dependencies]
49+assert_cmd = "2"
50+tempfile = "3"
51+
52+[features]
53+mock-vm = []
README.mdnew file+223
@@ -0,0 +1,223 @@
1+<p align="center">
2+ <a href="https://vibebox.robcholz.com">
3+ <picture>
4+ <img src="docs/banner.png" alt="VibeBox logo">
5+ </picture>
6+ </a>
7+</p>
8+<p align="center">an ultrafast, open-source sandbox for running coding agents safely.</p>
9+
10+<p align="center">
11+ <a href="https://crates.io/crates/vibebox">
12+ <img alt="Crates.io" src="https://img.shields.io/crates/v/vibebox.svg">
13+ </a>
14+ <a href="https://github.com/robcholz/vibebox/blob/main/LICENSE">
15+ <img alt="MIT licensed" src="https://img.shields.io/badge/license-MIT-blue.svg">
16+ </a>
17+ <a href="https://github.com/robcholz/vibebox/actions?query=workflow%3ACI+branch%3Amain">
18+ <img alt="Build Status" src="https://github.com/robcholz/vibebox/workflows/CI/badge.svg">
19+ </a>
20+</p>
21+
22+<p align="center">
23+ <a href="README.md">English</a> |
24+ <a href="README.zh.md">简体中文</a>
25+</p>
26+
27+**VibeBox is a per-project micro-VM sandbox for running coding agents on macOS (Apple Virtualization Framework).**
28+It’s optimized for a *daily-driver* workflow: fast warm re-entry, explicit mounts, and reusable sessions.
29+
30+**Who it’s for:** macOS users running coding agents who want real isolation without giving up a fast daily workflow.
31+
32+**Quick facts:** warm re-entry is typically **<5s** on my M3 (varies by machine/cache); first run downloads and
33+provisions a Debian base image (network dependent).
34+
35+**Security model:** Linux guest VM with explicit mount allowlists from `vibebox.toml` (repo-first, everything else
36+opt-in).
37+
38+- **enter/attach in seconds:** `vibebox` drops you into a reusable sandbox for the current repo
39+- **project-scoped by default:** explicit mounts + repo-contained changes (repo-first, everything else is allowlisted)
40+- **sessioned:** multi-instance + session management (reuse, multiple terminals, cleanup)
41+
42+### Quick Demo
43+
44+```bash
45+# from any repo
46+cd my-project
47+vibebox
48+```
49+
50+What you should see (roughly):
51+
52+```text
53+vibebox: starting (session: my-project)
54+vibebox: attaching...
55+vibecoder@vibebox:~/my-project$
56+```
57+
58+[![VibeBox Terminal UI](docs/screenshot.png)](https://vibebox.robcholz.com)
59+
60+---
61+
62+### Why I built VibeBox
63+
64+I use coding agents daily, and I wanted to give them a real shell without handing them my host machine.
65+Lock things down and you get nonstop confirmations; loosen it up and you worry about deleting files, touching secrets,
66+or wandering outside the repo.
67+
68+VibeBox is the middle ground: a per-repo sandbox with a hard VM boundary, fast re-entry, and explicit mounts. It’s built
69+to be “always on” for agent work without turning safety into a chore.
70+
71+### Why a micro-VM (vs containers)?
72+
73+Containers are great. VibeBox isn’t trying to replace Docker/devcontainers for building services.
74+
75+I specifically wanted a VM-shaped default for agent workflows on macOS:
76+
77+- **guest-kernel isolation boundary by default:** when I’m letting an agent run arbitrary commands, I want “safe mode”
78+ to
79+ be a Linux guest, not my host.
80+- **sessions as a first-class workflow:** attach/reuse per repo, multiple terminals into the same sandbox, reliable
81+ cleanup to avoid orphan environments.
82+- **explicit mount allowlists as the primary UX:** repo-scoped by default; anything else is an explicit decision.
83+- **minimal per-repo setup:** you *can* reproduce parts of this with compose/devcontainers, but I wanted a single
84+ command
85+ that works repo-to-repo without maintaining container configs for the basic “safe shell” workflow.
86+
87+### Comparison
88+
89+Here’s why I didn’t just use existing options:
90+
91+- **vibe**: super convenient and nails “zero-config, just go”. VibeBox is intentionally on a different axis: per-repo
92+ config + sessions + multi-instance lifecycle.
93+- **QEMU**: powerful, but the configuration surface area is huge. For day-to-day sandboxing it’s not “open a repo and
94+ go” — it’s a project on its own.
95+- **Docker / devcontainers / devpods**: great ecosystem. My friction wasn’t raw startup time, it was the day-to-day
96+ overhead of keeping per-repo agent sandboxes *safe-by-default* (mount allowlists, secrets exposure, attach/reuse,
97+ cleanup) without maintaining container configs per repo for the basic workflow.
98+
99+That’s what pushed me to build **VibeBox**: I wanted a per-project sandbox that’s fast to enter (just `vibebox`),
100+supports real configuration + sessions, and keeps a hard isolation boundary.
101+
102+### Installation
103+
104+```bash
105+# install script
106+curl -fsSL https://raw.githubusercontent.com/robcholz/vibebox/main/install | bash
107+
108+# package managers
109+cargo install vibebox
110+
111+# manual install
112+curl -LO https://github.com/robcholz/vibebox/releases/download/latest/vibebox-macos-arm64.zip
113+unzip vibebox-macos-arm64.zip
114+mkdir -p ~/.local/bin
115+mv vibebox ~/.local/bin
116+export PATH="$HOME/.local/bin:$PATH"
117+```
118+
119+**Requirements**
120+
121+- macOS on Apple Silicon (VibeBox uses Apple's virtualization APIs).
122+
123+**First Run**
124+
125+The first `vibebox` run downloads a Debian base image and provisions it. After that, per-project instances reuse the
126+cached base image for much faster startups.
127+
128+### Documentation
129+
130+**Quick Start**
131+
132+```bash
133+cd /path/to/your/project
134+vibebox
135+```
136+
137+On first run, VibeBox creates `vibebox.toml` in your project (if missing) and a `.vibebox/` directory for instance data.
138+
139+**Configuration (`vibebox.toml`)**
140+
141+`vibebox.toml` lives in your project root by default. You can override it with `vibebox -c path/to/vibebox.toml` or the
142+`VIBEBOX_CONFIG_PATH` env var, but the path must stay inside the project directory.
143+
144+Default config (auto-created when missing):
145+
146+```toml
147+[box]
148+cpu_count = 2
149+ram_mb = 2048
150+disk_gb = 5
151+mounts = [
152+ "~/.codex:~/.codex:read-write",
153+ "~/.claude:~/.claude:read-write",
154+]
155+
156+[supervisor]
157+auto_shutdown_ms = 20000
158+```
159+
160+`disk_gb` is only applied when the instance disk is first created. If you change it later, run `vibebox reset` to
161+recreate the disk.
162+
163+**Mounts**
164+
165+- Your project is mounted read-write at `~/<project-name>`, and the shell starts there.
166+- If a `.git` directory exists, it is masked with a tmpfs mount inside the VM to discourage accidental edits from the
167+ guest.
168+- Extra mounts come from `box.mounts` with the format `host:guest[:read-only|read-write]`.
169+- Host paths support `~` expansion. Relative guest paths are treated as `/root/<path>`.
170+- Guest paths that use `~` are linked into `/home/<ssh-user>` for convenience. Run `vibebox explain` to see the resolved
171+ host/guest mappings.
172+
173+**CLI Commands**
174+
175+```bash
176+vibebox # start or attach to the current project VM
177+vibebox list # list known project sessions
178+vibebox reset # delete .vibebox for this project and recreate on next run
179+vibebox purge-cache # delete the global cache (~/.cache/vibebox)
180+vibebox explain # show mounts and network info
181+```
182+
183+**Inside the VM**
184+
185+- Default SSH user: `vibecoder`
186+- Hostname: `vibebox`
187+- Base image provisioning installs: build tools, `git`, `curl`, `ripgrep`, `openssh-server`, and `sudo`.
188+- On first login, VibeBox installs `mise` and configures tools like `uv`, `node`, `@openai/codex`, and
189+ `@anthropic-ai/claude-code` (best-effort).
190+- Shell aliases: `:help` and `:exit`.
191+
192+**State & Cache**
193+
194+- Project state lives in `.vibebox/` (instance disk, SSH keys, logs, manager socket/pid). `vibebox reset` removes it.
195+- Global cache lives in `~/.cache/vibebox` (base image + shared guest cache). `vibebox purge-cache` clears it.
196+- Session index lives in `~/.vibebox/sessions` and is shown by `vibebox list`.
197+
198+### Contributing
199+
200+If you're interested in contributing to VibeBox, please read our [contributing docs](CONTRIBUTING.md) before
201+submitting a pull request.
202+
203+### FAQ
204+
205+#### How is this different from other sandboxes?
206+
207+VibeBox is built for fast, repeatable local sandboxes with minimal ceremony. What’s different here:
208+
209+- Warm re-entry is typically **<5s** on my M3 (varies by machine/cache), so you can jump back in quickly.
210+- One simple command — `vibebox` — drops you into the sandbox from your project.
211+- Configuration lives in `vibebox.toml`, where you can set CPU, RAM, disk size, and mounts.
212+- Sessions are first-class: reuse, multiple terminals, cleanup.
213+
214+### Special thanks
215+
216+[vibe](https://github.com/lynaghk/vibe) by lynaghk.
217+
218+And the amazing Rust community — without the ecosystem and toolchain like [crates.io](https://crates.io), this wouldn't
219+be possible!
220+
221+---
222+
223+**Follow me on X** [x.com/robcholz](https://x.com/robcholz)
README.zh.mdnew file+209
@@ -0,0 +1,209 @@
1+<p align="center">
2+ <a href="https://vibebox.robcholz.com">
3+ <picture>
4+ <img src="docs/banner.png" alt="VibeBox logo">
5+ </picture>
6+ </a>
7+</p>
8+<p align="center">用于安全运行 coding agents 的超高速开源沙盒。</p>
9+
10+<p align="center">
11+ <a href="https://crates.io/crates/vibebox">
12+ <img alt="Crates.io" src="https://img.shields.io/crates/v/vibebox.svg">
13+ </a>
14+ <a href="https://github.com/robcholz/vibebox/blob/main/LICENSE">
15+ <img alt="MIT licensed" src="https://img.shields.io/badge/license-MIT-blue.svg">
16+ </a>
17+ <a href="https://github.com/robcholz/vibebox/actions?query=workflow%3ACI+branch%3Amain">
18+ <img alt="Build Status" src="https://github.com/robcholz/vibebox/workflows/CI/badge.svg">
19+ </a>
20+</p>
21+
22+<p align="center">
23+ <a href="README.zh.md">简体中文</a> |
24+ <a href="README.md">English</a>
25+</p>
26+
27+**VibeBox 是一个按项目划分的 micro-VM 沙盒,用于在 macOS 上运行 coding agents(基于 Apple Virtualization Framework)。**
28+它面向 *日常使用* 工作流优化:快速热启动、显式挂载、可复用会话。
29+
30+**适合谁:** 在 macOS 上使用 coding agents,并且既想要真实隔离又不想牺牲日常效率的人。
31+
32+**快速事实:** 在我的 M3 上,热启动通常 **<5s**(因机器/缓存而异);首次运行会下载并初始化 Debian 基础镜像(受网络影响)。
33+
34+**安全模型:** Linux 来宾 VM + `vibebox.toml` 显式挂载白名单(默认仅项目目录,其它均需显式允许)。
35+
36+- **几秒进入/附加:** `vibebox` 直接进入当前仓库的可复用沙盒
37+- **默认按项目范围:** 显式挂载 + 改动限制在仓库内(repo 优先,其他需 allowlist)
38+- **会话化:** 多实例 + 会话管理(复用、多终端、清理)
39+
40+### 快速演示
41+
42+```bash
43+# 在任意仓库内
44+cd my-project
45+vibebox
46+```
47+
48+你大致会看到:
49+
50+```text
51+vibebox: starting (session: my-project)
52+vibebox: attaching...
53+vibecoder@vibebox:~/my-project$
54+```
55+
56+[![VibeBox Terminal UI](docs/screenshot.png)](https://vibebox.robcholz.com)
57+
58+---
59+
60+### 我为什么做 VibeBox
61+
62+我每天都在用 coding agents,也希望它们有一个真实的 shell,但不想把宿主机直接交出去。
63+权限收紧会被不停的确认打断;权限放开又担心误删文件、触及密钥,或者跑出仓库边界。
64+
65+VibeBox 是中间方案:按项目隔离、硬 VM 边界、快速回到工作状态、显式挂载。它适合把 agent 当成日常工具,而不是把安全变成负担。
66+
67+### 为什么是 micro-VM(而不是容器)?
68+
69+容器很好用。VibeBox 并不是用来替代 Docker/devcontainers 去构建服务。
70+
71+我更想要的是在 macOS 上适合 agent 的 VM 默认形态:
72+
73+- **默认是 guest-kernel 隔离边界:** 让 agent 跑任意命令时,“安全模式”是 Linux 来宾而不是宿主机。
74+- **会话是第一等公民:** 按项目附加/复用,多终端进入同一沙盒,可靠清理,避免孤儿环境。
75+- **显式挂载白名单作为主要 UX:** 默认仅项目目录,其它都需显式允许。
76+- **最少的每项目配置:** 你可以用 compose/devcontainers 实现一部分,但我想要的是一个命令,在不同仓库间直接工作,不必维护容器配置来获得基础“安全
77+ shell”体验。
78+
79+### 对比
80+
81+下面是我为什么没有直接用现成方案的原因:
82+
83+- **vibe**:非常方便,“零配置、直接用”做得很好。但 VibeBox 走的是另一条路:按项目配置 + 会话 + 多实例生命周期。
84+- **QEMU**:很强大,但配置面太大了。日常当沙箱用,它不像是“进到 repo 就能用”,更像是你得先把它当成一个项目来折腾。
85+- **Docker / devcontainers / devpods**:生态很成熟。我的痛点不在启动时间,而是日常保持
86+ safe-by-default(挂载白名单、密钥暴露、附加/复用、清理)的开销,不想为基础 workflow 在每个项目维护容器配置。
87+
88+这就是我做 **VibeBox** 的原因:我想要一个按项目隔离的沙箱,进入快(直接 `vibebox`),支持真实配置 + 会话,同时保持硬隔离边界。
89+
90+### 安装
91+
92+```bash
93+# YOLO:一键安装
94+curl -fsSL https://raw.githubusercontent.com/robcholz/vibebox/main/install | bash
95+
96+# Cargo
97+cargo install vibebox
98+
99+# 或者手动安装
100+curl -LO https://github.com/robcholz/vibebox/releases/download/latest/vibebox-macos-arm64.zip
101+unzip vibebox-macos-arm64.zip
102+mkdir -p ~/.local/bin
103+mv vibebox ~/.local/bin
104+export PATH="$HOME/.local/bin:$PATH"
105+```
106+
107+**系统要求**
108+
109+- Apple Silicon 的 macOS(VibeBox 使用了 Apple 的虚拟化 API)。
110+
111+**首次运行**
112+
113+第一次执行 `vibebox` 会下载 Debian 基础镜像并完成初始化。之后每个项目的实例会复用缓存的基础镜像,
114+启动会快很多。
115+
116+### 文档
117+
118+**快速开始**
119+
120+```bash
121+cd /path/to/your/project
122+vibebox
123+```
124+
125+第一次运行时,如果项目目录里缺少配置,VibeBox 会自动创建 `vibebox.toml`(放在项目根目录),并创建
126+`.vibebox/` 用来保存实例数据。
127+
128+**配置(`vibebox.toml`)**
129+
130+默认情况下,`vibebox.toml` 位于项目根目录。你可以用 `vibebox -c path/to/vibebox.toml` 或设置
131+`VIBEBOX_CONFIG_PATH` 环境变量来覆盖路径,但配置文件必须仍然位于项目目录内部。
132+
133+默认配置(缺失时会自动生成):
134+
135+```toml
136+[box]
137+cpu_count = 2
138+ram_mb = 2048
139+disk_gb = 5
140+mounts = [
141+ "~/.codex:~/.codex:read-write",
142+ "~/.claude:~/.claude:read-write",
143+]
144+
145+[supervisor]
146+auto_shutdown_ms = 20000
147+```
148+
149+注意:`disk_gb` 只在「首次创建实例磁盘」时生效。之后如果你改了它,需要运行 `vibebox reset` 重新创建磁盘。
150+
151+**挂载(Mounts)**
152+
153+- 你的项目会以读写方式挂载到 `~/<project-name>`,并且 shell 会默认从那里启动。
154+- 如果项目里存在 `.git` 目录,VM 内会用 tmpfs 把它遮住,避免你在 guest 里误操作改到 Git 元数据。
155+- 额外挂载通过 `box.mounts` 配置,格式为 `host:guest[:read-only|read-write]`。
156+- Host 路径支持 `~` 展开;guest 的相对路径会被视为 `/root/<path>`。
157+- guest 路径如果用了 `~`,会为了方便被链接到 `/home/<ssh-user>` 下。你可以运行 `vibebox explain`
158+ 查看最终解析后的 host/guest 映射关系。
159+
160+**CLI 命令**
161+
162+```bash
163+vibebox # 启动或连接当前项目的 VM
164+vibebox list # 列出已知的项目会话
165+vibebox reset # 删除当前项目的 .vibebox,下一次运行会重新创建
166+vibebox purge-cache # 删除全局缓存(~/.cache/vibebox)
167+vibebox explain # 显示挂载与网络信息
168+```
169+
170+**在 VM 内部**
171+
172+- 默认 SSH 用户:`vibecoder`
173+- 主机名:`vibebox`
174+- 基础镜像初始化会安装:构建工具、`git`、`curl`、`ripgrep`、`openssh-server`、`sudo`
175+- 首次登录时,VibeBox 会安装 `mise`,并尽力配置 `uv`、`node`、`@openai/codex`、
176+ `@anthropic-ai/claude-code` 等工具(best-effort,视网络和环境而定)
177+- Shell 里有两个别名:`:help` 和 `:exit`
178+
179+**状态与缓存**
180+
181+- 项目级状态在 `.vibebox/`(实例磁盘、SSH key、日志、manager socket/pid)。`vibebox reset` 会移除它。
182+- 全局缓存在 `~/.cache/vibebox`(基础镜像 + 共享 guest 缓存)。`vibebox purge-cache` 会清空它。
183+- 会话索引在 `~/.vibebox/sessions`,可以通过 `vibebox list` 查看。
184+
185+### 参与贡献
186+
187+如果你想参与贡献 VibeBox,请先阅读 [贡献指南](CONTRIBUTING.md),再提交 Pull Request。
188+
189+### FAQ
190+
191+#### 它和其它 Sandboxes 有什么不同?
192+
193+VibeBox 追求的是:本地、可复现、启动快、流程简单。主要差异点:
194+
195+- 在我的 M3 上,热启动通常 **<5s**,可以非常快地回到工作状态。
196+- 一个命令——`vibebox`——直接把你带进沙盒(从你的项目目录启动)。
197+- 配置集中在 `vibebox.toml`,CPU / 内存 / 磁盘大小 / 挂载都能一眼看懂、随手改。
198+- 会话是第一等公民:复用、多终端、清理。
199+
200+### 特别鸣谢
201+
202+[vibe](https://github.com/lynaghk/vibe) by lynaghk。
203+
204+以及 Rust 社区。没有你们丰富的 crates 生态和优秀的工具链(比如 [crates.io](https://crates.io)),
205+这个项目不可能这么顺利!Rust教。
206+
207+---
208+
209+**在 X 上关注我** [X.com](https://x.com/robcholz)
docs/banner.pngnew file

Binary file not shown.

docs/implementations.md−1
@@ -68,7 +68,6 @@ In host cli:
6868
6969In vibebox:
7070
71−- use `:new` to prompt user to delete and create a session.
7271- use `:exit` to exit vibebox.
7372
7473### (Shows all the mounts/network/visibility)
docs/screenshot.pngnew file

Binary file not shown.

docs/tasks.md+23−12
@@ -25,7 +25,7 @@
25255. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar.
26266. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio.
27277. [x] Add a standalone TUI CLI binary (no main.rs wiring) with placeholder VM info and TODOs for VM integration.
28−8. [ ] Run tests and validate coverage for the new module.
28+8. [x] Run tests and validate coverage for the new module.
2929
3030## TUI
3131
@@ -39,22 +39,33 @@
39392. [x] Use ssh to connect to vm.
40403. [x] allow multi vibebox to connect to the same vm.
41414. [x] use vm.lock to ensure process concurrency safety.
42−5. [ ] wire up SessionManager.
42+5. [x] wire up SessionManager.
43436. [x] VM should be separated by a per-session VM daemon process (only accepts if to shut down vm and itself).
44−7. [ ] setup vibebox commands
45−8. [ ] setup cli commands.
46−9. [ ] fix ui overlap.
44+7. [x] setup vibebox commands
45+8. [x] setup cli commands.
46+ 1. [x] Organize all the params.
47+ 2. [x] Remove old cli.
48+ 3. [x] add an actual config file.
49+ 4. [x] set up the cli.
50+9. [x] fix ui overlap, and consistency issue.
51+10. [x] `purge-cache` to clear the cache.
52+11. [x] intensive integration test.
4753
4854## Publish
4955
50−1. [ ] write the docs
51−2. [ ] setup quick link.
52−3. [ ] setup website.
56+1. [x] write the docs.
57+2. [x] setup quick install link.
58+3. [x] setup website.
5359
5460## Stage 2
5561
56621. [ ] retouch the cli ux.
57−2. [ ] refactor the code.
58−3. [ ] Redirect vm output to log.
59−4. [ ] Redirect vm output to vibebox starting it.
60−5. [ ] use anyhow to sync api.
63+2. [x] refactor the code.
64+3. [ ] refactor the mount system.
65+4. [x] refactor the vm process lifetime.
66+5. [x] Redirect vm output to log.
67+6. [x] Redirect vm output to vibebox starting it.
68+7. [x] use anyhow to sync api.
69+8. [ ] add support for ipv6.
70+9. [x] use UnixStream instead of status file
71+10. [x] liveness check should also happen when waiting for ssh port
install.sh → installrenamed+54−61
@@ -2,7 +2,7 @@
22set -euo pipefail
33
44APP=vibebox
5−REPO_URL="https://github.com/opencode-ai/vibebox"
5+REPO_URL="https://github.com/robcholz/vibebox"
66REQUESTED_VERSION=${VERSION:-}
77
88RED='\033[0;31m'
@@ -38,9 +38,58 @@ require_cmd() {
3838 fi
3939}
4040
41−require_cmd cargo "Install Rust (cargo) from https://rustup.rs and retry."
4241require_cmd git "Install git and retry."
4342
43+ensure_cargo() {
44+ if command -v cargo >/dev/null 2>&1; then
45+ return 0
46+ fi
47+
48+ print_message warning "Rust (cargo) is required but not found."
49+ print_message info "You should review and approve the Rust installer before proceeding."
50+ reply=""
51+ if [[ -t 0 ]]; then
52+ read -r -p "Install Rust using rustup now? (y/N) " reply
53+ elif [[ -r /dev/tty ]]; then
54+ read -r -p "Install Rust using rustup now? (y/N) " reply </dev/tty
55+ else
56+ print_message error "Non-interactive shell: cannot prompt to install Rust."
57+ print_message info "Install Rust manually from https://rustup.rs and retry."
58+ exit 1
59+ fi
60+
61+ case "${reply}" in
62+ y|Y)
63+ if command -v curl >/dev/null 2>&1; then
64+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
65+ elif command -v wget >/dev/null 2>&1; then
66+ wget -qO- https://sh.rustup.rs | sh -s -- -y
67+ else
68+ print_message error "Missing required command: curl or wget"
69+ print_message info "Install Rust manually from https://rustup.rs and retry."
70+ exit 1
71+ fi
72+ # shellcheck source=/dev/null
73+ if [[ -f "$HOME/.cargo/env" ]]; then
74+ # shellcheck disable=SC1090
75+ source "$HOME/.cargo/env"
76+ fi
77+ ;;
78+ *)
79+ print_message info "Install Rust manually from https://rustup.rs and retry."
80+ exit 1
81+ ;;
82+ esac
83+
84+ if ! command -v cargo >/dev/null 2>&1; then
85+ print_message error "Cargo still not available after installation."
86+ print_message info "Open a new shell or run: source \"$HOME/.cargo/env\""
87+ exit 1
88+ fi
89+}
90+
91+ensure_cargo
92+
4493CARGO_HOME=${CARGO_HOME:-$HOME/.cargo}
4594INSTALL_DIR="$CARGO_HOME/bin"
4695
@@ -68,66 +117,10 @@ print_message info "Using cargo install from ${ORANGE}${REPO_URL}${GREEN}..."
68117
69118cargo "${install_args[@]}"
70119
71−add_to_path() {
72− local config_file=$1
73− local command=$2
74−
75− if [[ -w $config_file ]]; then
76− echo -e "\n# vibebox" >> "$config_file"
77− echo "$command" >> "$config_file"
78− print_message info "Added ${ORANGE}${APP}${GREEN} to \$PATH in $config_file"
79− else
80− print_message warning "Manually add the directory to $config_file (or similar):"
81− print_message info " $command"
82− fi
83−}
84−
85−XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config}
86−current_shell=$(basename "$SHELL")
87−
88−case $current_shell in
89− fish)
90− config_files="$HOME/.config/fish/config.fish"
91− ;;
92− zsh)
93− config_files="$HOME/.zshrc $HOME/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv"
94− ;;
95− bash)
96− config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"
97− ;;
98− ash)
99− config_files="$HOME/.ashrc $HOME/.profile /etc/profile"
100− ;;
101− sh)
102− config_files="$HOME/.ashrc $HOME/.profile /etc/profile"
103− ;;
104− *)
105− config_files="$HOME/.bashrc $HOME/.bash_profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"
106− ;;
107−esac
108−
109−config_file=""
110−for file in $config_files; do
111− if [[ -f $file ]]; then
112− config_file=$file
113− break
114− fi
115−done
116−
117−if [[ -z $config_file ]]; then
118− print_message error "No config file found for $current_shell. Checked files: ${config_files[@]}"
119− exit 1
120−fi
121−
122120if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
123− case $current_shell in
124− fish)
125− add_to_path "$config_file" "fish_add_path $INSTALL_DIR"
126− ;;
127− *)
128− add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
129− ;;
130− esac
121+ print_message warning "Cargo bin directory is not on PATH."
122+ print_message info "Add this to your shell profile if you can't run ${APP}:"
123+ print_message info " export PATH=$INSTALL_DIR:\$PATH"
131124fi
132125
133126if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
scripts/commit-check.shnew file+6
@@ -0,0 +1,6 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+cargo clippy --all-targets --all-features -- -D warnings
5+cargo fmt --all
6+cargo build --all-targets
src/bin/vibebox-cli.rsdeleted−98
@@ -1,98 +0,0 @@
1−use std::{
2− env,
3− ffi::OsString,
4− io::{self, IsTerminal, Write},
5− sync::{Arc, Mutex},
6−};
7−
8−use color_eyre::Result;
9−use tracing_subscriber::EnvFilter;
10−
11−use vibebox::tui::{AppState, VmInfo};
12−use vibebox::{SessionManager, config, instance, tui, vm, vm_manager};
13−
14−const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 3000;
15−
16−fn main() -> Result<()> {
17− init_tracing();
18− color_eyre::install()?;
19−
20− let raw_args: Vec<OsString> = env::args_os().collect();
21−
22− if env::var("VIBEBOX_VM_MANAGER").as_deref() == Ok("1") {
23− tracing::info!("starting vm manager mode");
24− let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
25− let auto_shutdown_ms = env::var("VIBEBOX_AUTO_SHUTDOWN_MS")
26− .ok()
27− .and_then(|value| value.parse::<u64>().ok())
28− .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS);
29− tracing::info!(auto_shutdown_ms, "vm manager config");
30− if let Err(err) = vm_manager::run_manager(args, auto_shutdown_ms) {
31− tracing::error!(error = %err, "vm manager exited");
32− return Err(color_eyre::eyre::eyre!(err.to_string()));
33− }
34− return Ok(());
35− }
36−
37− let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
38− tracing::debug!("parsed cli args");
39− if args.version() {
40− vm::print_version();
41− return Ok(());
42− }
43− if args.help() {
44− vm::print_help();
45− return Ok(());
46− }
47−
48− vm::ensure_signed();
49−
50− let vm_info = VmInfo {
51− version: env!("CARGO_PKG_VERSION").to_string(),
52− max_memory_mb: args.ram_mb(),
53− cpu_cores: args.cpu_count(),
54− };
55− let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
56− tracing::info!(cwd = %cwd.display(), "starting vibebox cli");
57− if let Ok(manager) = SessionManager::new() {
58− if let Err(err) = manager.update_global_sessions(&cwd) {
59− tracing::warn!(error = %err, "failed to update global session list");
60− }
61− } else {
62− tracing::warn!("failed to initialize session manager");
63− }
64− let app = Arc::new(Mutex::new(AppState::new(cwd.clone(), vm_info)));
65−
66− {
67− let mut locked = app.lock().expect("app state poisoned");
68− tui::render_tui_once(&mut locked)?;
69− }
70− {
71− let mut stdout = io::stdout().lock();
72− writeln!(stdout)?;
73− stdout.flush()?;
74− }
75−
76− let auto_shutdown_ms = config::load_config(&cwd)
77− .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?
78− .auto_shutdown_ms
79− .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS);
80− tracing::info!(auto_shutdown_ms, "auto shutdown config");
81− let manager_conn = vm_manager::ensure_manager(&raw_args, auto_shutdown_ms)
82− .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
83−
84− instance::run_with_ssh(manager_conn).map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
85−
86− Ok(())
87−}
88−
89−fn init_tracing() {
90− let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
91− let ansi = std::io::stderr().is_terminal() && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
92− let _ = tracing_subscriber::fmt()
93− .with_env_filter(filter)
94− .with_target(false)
95− .with_ansi(ansi)
96− .with_writer(std::io::stderr)
97− .try_init();
98−}
src/bin/vibebox-supervisor.rs+42−20
@@ -5,29 +5,40 @@ use std::{
55
66use color_eyre::Result;
77use tracing_subscriber::EnvFilter;
8+use tracing_subscriber::filter::LevelFilter;
9+use tracing_subscriber::fmt;
10+use tracing_subscriber::prelude::*;
811
9−use vibebox::{instance, vm, vm_manager};
10−
11−const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 3000;
12+use vibebox::{config, instance, vm, vm_manager};
1213
1314fn main() -> Result<()> {
15+ if env::var("VIBEBOX_INTERNAL").as_deref() != Ok("1") {
16+ eprintln!("vibebox-supervisor is internal. Use `vibebox` instead.");
17+ std::process::exit(2);
18+ }
19+
1420 init_tracing();
1521 color_eyre::install()?;
1622
1723 tracing::info!("starting vm supervisor");
18− let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
19− let instance_dir = instance::ensure_instance_dir(&cwd)
24+ let project_dir = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
25+ let config = config::load_config(&project_dir)
2026 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
21− let _ = instance::touch_last_active(&instance_dir);
22− let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
23− let auto_shutdown_ms = env::var("VIBEBOX_AUTO_SHUTDOWN_MS")
24− .ok()
25− .and_then(|value| value.parse::<u64>().ok())
26− .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS);
27+ let _ = instance::ensure_instance_dir(&project_dir)
28+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
29+ let _ = instance::touch_last_active(&project_dir);
30+ let args = vm::VmArg {
31+ cpu_count: config.box_cfg.cpu_count,
32+ ram_bytes: config.box_cfg.ram_size.as_u64(),
33+ disk_bytes: config.box_cfg.disk_size.as_u64(),
34+ no_default_mounts: false,
35+ mounts: config.box_cfg.mounts.clone(),
36+ };
37+ let auto_shutdown_ms = config.supervisor.auto_shutdown_ms;
2738 tracing::info!(auto_shutdown_ms, "vm supervisor config");
2839
2940 let result = vm_manager::run_manager(args, auto_shutdown_ms);
30− let _ = instance::touch_last_active(&instance_dir);
41+ let _ = instance::touch_last_active(&project_dir);
3142 if let Err(err) = result {
3243 tracing::error!(error = %err, "vm supervisor exited");
3344 return Err(color_eyre::eyre::eyre!(err.to_string()));
@@ -38,12 +49,23 @@ fn main() -> Result<()> {
3849}
3950
4051fn init_tracing() {
41− let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
42− let ansi = std::io::stderr().is_terminal() && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
43− let _ = tracing_subscriber::fmt()
44− .with_env_filter(filter)
45− .with_target(false)
46− .with_ansi(ansi)
47− .with_writer(io::stderr)
48− .try_init();
52+ let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
53+ let stderr_is_tty = std::io::stderr().is_terminal();
54+ let ansi = stderr_is_tty && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
55+ if stderr_is_tty {
56+ let stderr_layer = fmt::layer()
57+ .with_target(false)
58+ .with_ansi(ansi)
59+ .without_time()
60+ .with_writer(io::stderr)
61+ .with_filter(LevelFilter::INFO);
62+ let _ = tracing_subscriber::registry().with(stderr_layer).try_init();
63+ } else {
64+ let stderr_layer = fmt::layer()
65+ .with_target(false)
66+ .with_ansi(ansi)
67+ .with_writer(io::stderr)
68+ .with_filter(filter);
69+ let _ = tracing_subscriber::registry().with(stderr_layer).try_init();
70+ }
4971}
src/bin/vibebox.rsnew file+400
@@ -0,0 +1,400 @@
1+use bytesize::ByteSize;
2+use clap::Parser;
3+use color_eyre::Result;
4+use dialoguer::Confirm;
5+use std::{
6+ env,
7+ ffi::OsString,
8+ fs,
9+ io::{self, IsTerminal, Write},
10+ path::{Path, PathBuf},
11+ sync::{Arc, Mutex},
12+};
13+use time::OffsetDateTime;
14+use time::format_description::well_known::Rfc3339;
15+use tracing_subscriber::filter::LevelFilter;
16+use tracing_subscriber::registry::Registry;
17+use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload};
18+
19+use vibebox::tui::{AppState, VmInfo};
20+use vibebox::utils::relative_to_home;
21+use vibebox::{
22+ SessionManager, commands, config, explain, instance, session_manager, tui, vm_manager,
23+};
24+
25+#[derive(Debug, Parser)]
26+#[command(name = "vibebox", version, about = "Vibebox CLI")]
27+struct Cli {
28+ /// Path to vibebox.toml (relative to the current directory)
29+ #[arg(short = 'c', long = "config", value_name = "PATH", global = true)]
30+ config: Option<PathBuf>,
31+ #[command(subcommand)]
32+ command: Option<Command>,
33+}
34+
35+#[derive(Debug, clap::Subcommand)]
36+enum Command {
37+ /// List all sessions
38+ List,
39+ /// Reset the current project's .vibebox directory
40+ Reset,
41+ /// Purge the global cache directory
42+ PurgeCache,
43+ /// Explain mounts and mappings
44+ Explain,
45+}
46+
47+fn main() -> Result<()> {
48+ color_eyre::install()?;
49+ let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
50+ let stderr_handle = init_tracing(&cwd);
51+
52+ let cli = Cli::parse();
53+ tracing::debug!(cwd = %cwd.display(), "starting vibebox cli");
54+ if let Some(command) = cli.command {
55+ return handle_command(command, &cwd, cli.config.as_deref());
56+ }
57+
58+ let config_override = cli.config.clone();
59+ let raw_args: Vec<OsString> = env::args_os().collect();
60+ let config = config::load_config_with_path(&cwd, config_override.as_deref())
61+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
62+
63+ let vm_info = VmInfo {
64+ max_memory: config.box_cfg.ram_size,
65+ cpu_cores: config.box_cfg.cpu_count,
66+ max_disk: config.box_cfg.disk_size,
67+ system_name: "Debian".to_string(), // TODO: read system name from the VM.
68+ auto_shutdown_ms: config.supervisor.auto_shutdown_ms,
69+ };
70+ if let Ok(manager) = SessionManager::new() {
71+ if let Err(err) = manager.update_global_sessions(&cwd) {
72+ tracing::warn!(error = %err, "failed to update a global session list");
73+ }
74+ } else {
75+ tracing::error!("failed to initialize session manager");
76+ std::process::exit(1);
77+ }
78+ let commands = commands::build_commands();
79+ let app = Arc::new(Mutex::new(AppState::new(cwd.clone(), vm_info, commands)));
80+ {
81+ let mut locked = app.lock().expect("app state poisoned");
82+ tui::render_tui_once(&mut locked)?;
83+ }
84+ {
85+ let mut stdout = io::stdout().lock();
86+ writeln!(stdout)?;
87+ stdout.flush()?;
88+ }
89+ warn_disk_size_mismatch(&cwd, config.box_cfg.disk_size);
90+ if let Some(handle) = stderr_handle {
91+ let _ = handle.modify(|filter| *filter = LevelFilter::INFO);
92+ }
93+
94+ tracing::debug!(config.supervisor.auto_shutdown_ms, "auto shutdown config");
95+ let manager_conn = vm_manager::ensure_manager(
96+ &raw_args,
97+ config.supervisor.auto_shutdown_ms,
98+ config_override.as_deref(),
99+ )
100+ .map_err(|err| {
101+ tracing::error!(error = %err, "failed to ensure vm manager");
102+ color_eyre::eyre::eyre!(err.to_string())
103+ })?;
104+
105+ if let Err(err) = instance::run_with_ssh(manager_conn) {
106+ if let Some(instance::InstanceError::UnexpectedDisconnection) =
107+ err.downcast_ref::<instance::InstanceError>()
108+ {
109+ tracing::warn!("vm manager disconnected; exiting vibebox");
110+ } else if let Some(instance::InstanceError::VMError(vm_error)) =
111+ err.downcast_ref::<instance::InstanceError>()
112+ {
113+ tracing::error!("[vm]: {vm_error}");
114+ tracing::info!("vibecoding paused: the VM says today is a rest day 😴");
115+ std::process::exit(1);
116+ } else {
117+ let message = err.to_string();
118+ tracing::error!(error = %message, "vibebox exited: uncaught error");
119+ return Err(color_eyre::eyre::eyre!(message));
120+ }
121+ }
122+
123+ tracing::info!("See you again — keep vibecoding (no SEVs, only vibes) 😈");
124+
125+ Ok(())
126+}
127+
128+fn handle_command(command: Command, cwd: &Path, config_override: Option<&Path>) -> Result<()> {
129+ match command {
130+ Command::List => {
131+ let manager = SessionManager::new()?;
132+ let sessions = manager.list_sessions()?;
133+ if sessions.is_empty() {
134+ println!("No sessions were found.");
135+ return Ok(());
136+ }
137+ let rows: Vec<tui::SessionListRow> = sessions
138+ .into_iter()
139+ .map(|session| tui::SessionListRow {
140+ name: project_name(&session.directory),
141+ id: session.id,
142+ directory: relative_to_home(&session.directory),
143+ last_active: format_last_active(session.last_active.as_deref()),
144+ active: if session.active {
145+ "yes".to_string()
146+ } else {
147+ "no".to_string()
148+ },
149+ })
150+ .collect();
151+ tui::render_sessions_table(&rows)?;
152+ Ok(())
153+ }
154+ Command::Reset => {
155+ let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME);
156+ if !instance_dir.exists() {
157+ println!("No .vibebox directory found at {}", instance_dir.display());
158+ return Ok(());
159+ }
160+ let confirmed = Confirm::new()
161+ .with_prompt(format!(
162+ "Delete {} and all its contents?",
163+ instance_dir.display()
164+ ))
165+ .default(false)
166+ .interact()?;
167+ if !confirmed {
168+ println!("Cancelled.");
169+ return Ok(());
170+ }
171+ let manager = SessionManager::new()?;
172+ let summary = manager.clean_project(cwd)?;
173+ println!(
174+ "Deleted {} (removed={}, session_records_removed={})",
175+ summary.instance_dir.display(),
176+ summary.removed_instance_dir,
177+ summary.removed_sessions
178+ );
179+ Ok(())
180+ }
181+ Command::PurgeCache => {
182+ let cache_dir = cache_dir()?;
183+ if !cache_dir.exists() {
184+ println!("No cache directory found at {}", cache_dir.display());
185+ return Ok(());
186+ }
187+ let (file_count, total_bytes) = measure_dir(&cache_dir)?;
188+ let confirmed = Confirm::new()
189+ .with_prompt(format!(
190+ "Delete cache directory {} and all its contents?",
191+ cache_dir.display()
192+ ))
193+ .default(false)
194+ .interact()?;
195+ if !confirmed {
196+ println!("Cancelled.");
197+ return Ok(());
198+ }
199+ fs::remove_dir_all(&cache_dir)?;
200+ println!(
201+ "Purged {} file{} totaling {} from {}",
202+ file_count,
203+ if file_count == 1 { "" } else { "s" },
204+ ByteSize(total_bytes),
205+ cache_dir.display()
206+ );
207+ Ok(())
208+ }
209+ Command::Explain => {
210+ let config = config::load_config_with_path(cwd, config_override)
211+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
212+ let mounts = explain::build_mount_rows(cwd, &config)
213+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
214+ let networks = explain::build_network_rows(cwd)
215+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
216+ if mounts.is_empty() && networks.is_empty() {
217+ println!("No mounts or network info available.");
218+ return Ok(());
219+ }
220+ tui::render_explain_tables(&mounts, &networks)?;
221+ Ok(())
222+ }
223+ }
224+}
225+
226+fn project_name(directory: &Path) -> String {
227+ directory
228+ .file_name()
229+ .and_then(|name| name.to_str())
230+ .unwrap_or("-")
231+ .to_string()
232+}
233+
234+fn cache_dir() -> Result<PathBuf> {
235+ let home = env::var("HOME").map(PathBuf::from)?;
236+ let cache_home = env::var("XDG_CACHE_HOME")
237+ .map(PathBuf::from)
238+ .unwrap_or_else(|_| home.join(".cache"));
239+ Ok(cache_home.join(session_manager::GLOBAL_CACHE_DIR_NAME))
240+}
241+
242+fn measure_dir(path: &Path) -> Result<(u64, u64)> {
243+ let mut total_bytes = 0u64;
244+ let mut file_count = 0u64;
245+ let mut stack = vec![path.to_path_buf()];
246+ while let Some(current) = stack.pop() {
247+ let entries = match fs::read_dir(&current) {
248+ Ok(entries) => entries,
249+ Err(err) => {
250+ tracing::warn!(path = %current.display(), error = %err, "failed to read directory");
251+ continue;
252+ }
253+ };
254+ for entry in entries {
255+ let entry = match entry {
256+ Ok(entry) => entry,
257+ Err(err) => {
258+ tracing::warn!(path = %current.display(), error = %err, "failed to read directory entry");
259+ continue;
260+ }
261+ };
262+ let path = entry.path();
263+ let metadata = match fs::symlink_metadata(&path) {
264+ Ok(metadata) => metadata,
265+ Err(err) => {
266+ tracing::warn!(path = %path.display(), error = %err, "failed to stat path");
267+ continue;
268+ }
269+ };
270+ let file_type = metadata.file_type();
271+ if file_type.is_dir() {
272+ stack.push(path);
273+ } else {
274+ file_count += 1;
275+ total_bytes = total_bytes.saturating_add(metadata.len());
276+ }
277+ }
278+ }
279+ Ok((file_count, total_bytes))
280+}
281+
282+fn format_last_active(value: Option<&str>) -> String {
283+ let Some(raw) = value else {
284+ return "-".to_string();
285+ };
286+ let parsed = OffsetDateTime::parse(raw, &Rfc3339);
287+ let Ok(timestamp) = parsed else {
288+ return raw.to_string();
289+ };
290+ let now = OffsetDateTime::now_utc();
291+ let mut seconds = (now - timestamp).whole_seconds();
292+ if seconds < 0 {
293+ seconds = 0;
294+ }
295+ let week_seconds = 7 * 24 * 60 * 60;
296+ if seconds >= week_seconds {
297+ let formatted =
298+ match time::format_description::parse("[year]-[month]-[day] [hour]:[minute]Z") {
299+ Ok(format) => timestamp
300+ .format(&format)
301+ .unwrap_or_else(|_| raw.to_string()),
302+ Err(_) => timestamp
303+ .format(&Rfc3339)
304+ .unwrap_or_else(|_| raw.to_string()),
305+ };
306+ return formatted;
307+ }
308+ if seconds < 60 {
309+ return "just now".to_string();
310+ }
311+ if seconds < 60 * 60 {
312+ let mins = seconds / 60;
313+ return format!("{} min{} ago", mins, if mins == 1 { "" } else { "s" });
314+ }
315+ if seconds < 60 * 60 * 24 {
316+ let hours = seconds / (60 * 60);
317+ return format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" });
318+ }
319+ let days = seconds / (60 * 60 * 24);
320+ format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
321+}
322+
323+fn warn_disk_size_mismatch(cwd: &Path, configured_size: ByteSize) {
324+ let instance_raw = cwd
325+ .join(session_manager::INSTANCE_DIR_NAME)
326+ .join("instance.raw");
327+ let Ok(meta) = fs::metadata(&instance_raw) else {
328+ return;
329+ };
330+ let current_size = ByteSize::b(meta.len());
331+ if current_size == configured_size {
332+ return;
333+ }
334+ tracing::warn!(
335+ "instance disk size does not match config (current {}, config {}). \
336+disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using the existing disk.",
337+ current_size,
338+ configured_size,
339+ );
340+}
341+
342+type StderrHandle = reload::Handle<LevelFilter, Registry>;
343+
344+fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
345+ let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
346+ let file_filter = filter.clone();
347+ let stderr_is_tty = io::stderr().is_terminal();
348+ let ansi = stderr_is_tty && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
349+ let file = instance::ensure_instance_dir(cwd)
350+ .ok()
351+ .and_then(|instance_dir| {
352+ let log_path = instance_dir.join("cli.log");
353+ fs::OpenOptions::new()
354+ .create(true)
355+ .write(true)
356+ .truncate(true)
357+ .open(log_path)
358+ .ok()
359+ });
360+
361+ if stderr_is_tty {
362+ let (stderr_filter, handle) = reload::Layer::new(LevelFilter::INFO);
363+ let stderr_layer = fmt::layer()
364+ .with_target(false)
365+ .with_ansi(ansi)
366+ .without_time()
367+ .with_writer(io::stderr)
368+ .with_filter(stderr_filter);
369+ let subscriber = tracing_subscriber::registry().with(stderr_layer);
370+ if let Some(file) = file {
371+ let file_layer = fmt::layer()
372+ .with_target(false)
373+ .with_ansi(false)
374+ .with_writer(file)
375+ .with_filter(file_filter);
376+ let _ = subscriber.with(file_layer).try_init();
377+ } else {
378+ let _ = subscriber.try_init();
379+ }
380+ Some(handle)
381+ } else {
382+ let stderr_layer = fmt::layer()
383+ .with_target(false)
384+ .with_ansi(ansi)
385+ .with_writer(io::stderr)
386+ .with_filter(filter);
387+ let subscriber = tracing_subscriber::registry().with(stderr_layer);
388+ if let Some(file) = file {
389+ let file_layer = fmt::layer()
390+ .with_target(false)
391+ .with_ansi(false)
392+ .with_writer(file)
393+ .with_filter(file_filter);
394+ let _ = subscriber.with(file_layer).try_init();
395+ } else {
396+ let _ = subscriber.try_init();
397+ }
398+ None
399+ }
400+}
src/commands.rsnew file+111
@@ -0,0 +1,111 @@
1+use std::collections::HashMap;
2+use std::sync::{Arc, Mutex};
3+
4+use crate::tui::{AppState, VibeboxCommands};
5+use crate::vm::IoControl;
6+
7+#[derive(Clone, Copy)]
8+enum CommandKind {
9+ Help,
10+ Exit,
11+}
12+
13+struct CommandSpec {
14+ name: &'static str,
15+ description: &'static str,
16+ kind: CommandKind,
17+ shell_alias: Option<&'static str>,
18+}
19+
20+const COMMAND_SPECS: &[CommandSpec] = &[
21+ CommandSpec {
22+ name: ":help",
23+ description: "Show Vibebox commands.",
24+ kind: CommandKind::Help,
25+ shell_alias: Some("vibebox_help"),
26+ },
27+ CommandSpec {
28+ name: ":exit",
29+ description: "Exit Vibebox.",
30+ kind: CommandKind::Exit,
31+ shell_alias: Some("exit"),
32+ },
33+];
34+
35+pub struct CommandHandlers {
36+ handlers: HashMap<String, Box<dyn Fn() + Send + Sync>>,
37+}
38+
39+impl CommandHandlers {
40+ fn new() -> Self {
41+ Self {
42+ handlers: HashMap::new(),
43+ }
44+ }
45+
46+ fn register<F>(&mut self, name: &str, handler: F)
47+ where
48+ F: Fn() + Send + Sync + 'static,
49+ {
50+ self.handlers.insert(name.to_string(), Box::new(handler));
51+ }
52+
53+ pub fn handle(&self, line: &str) -> bool {
54+ if let Some(handler) = self.handlers.get(line) {
55+ handler();
56+ true
57+ } else {
58+ false
59+ }
60+ }
61+}
62+
63+pub fn build_commands() -> VibeboxCommands {
64+ let mut commands = VibeboxCommands::new_empty();
65+ for spec in COMMAND_SPECS {
66+ commands.add_command(spec.name, spec.description);
67+ }
68+ commands
69+}
70+
71+pub fn render_shell_script() -> String {
72+ let mut lines = Vec::new();
73+ lines.push("vibebox_help() {".to_string());
74+ lines.push(" cat <<'VIBEBOX_HELP'".to_string());
75+ lines.push("Vibebox Commands".to_string());
76+ for spec in COMMAND_SPECS {
77+ lines.push(format!("{} {}", spec.name, spec.description));
78+ }
79+ lines.push("VIBEBOX_HELP".to_string());
80+ lines.push("}".to_string());
81+ for spec in COMMAND_SPECS {
82+ if let Some(alias) = spec.shell_alias {
83+ lines.push(format!("alias {}='{}'", spec.name, alias));
84+ }
85+ }
86+ lines.join("\n")
87+}
88+
89+pub fn build_handlers(app: Arc<Mutex<AppState>>, io_control: Arc<IoControl>) -> CommandHandlers {
90+ let mut handlers = CommandHandlers::new();
91+ for spec in COMMAND_SPECS {
92+ match spec.kind {
93+ CommandKind::Help => {
94+ let app = app.clone();
95+ handlers.register(spec.name, move || {
96+ if let Ok(mut locked) = app.lock() {
97+ let _ = crate::tui::render_commands_component(&mut locked);
98+ }
99+ });
100+ }
101+ CommandKind::Exit => {
102+ let io_control = io_control.clone();
103+ handlers.register(spec.name, move || {
104+ io_control.request_terminal_restore();
105+ std::process::exit(0);
106+ });
107+ }
108+ }
109+ }
110+ handlers
111+}
src/config.rsnew file+679
@@ -0,0 +1,679 @@
1+use anyhow::{Context, Error, Result, bail};
2+use bytesize::ByteSize;
3+use serde::{Deserialize, Serialize};
4+use std::{
5+ env, fs, io,
6+ path::{Path, PathBuf},
7+};
8+
9+use crate::vm::DirectoryShare;
10+
11+const CONFIG_FILENAME: &str = "vibebox.toml";
12+pub const CONFIG_PATH_ENV: &str = "VIBEBOX_CONFIG_PATH";
13+
14+const DEFAULT_CPU_COUNT: usize = 2;
15+const DEFAULT_RAM_MB: u64 = 2048;
16+const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 20000;
17+const DEFAULT_DISK_GB: u64 = 5;
18+
19+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20+pub struct Config {
21+ #[serde(rename = "box")]
22+ pub box_cfg: BoxConfig,
23+ pub supervisor: SupervisorConfig,
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+
87+#[derive(Debug, Clone, Serialize, Deserialize)]
88+pub struct BoxConfig {
89+ pub cpu_count: usize,
90+ #[serde(rename = "ram_mb", with = "serde_mb")]
91+ pub ram_size: ByteSize,
92+ #[serde(rename = "disk_gb", with = "serde_gb")]
93+ pub disk_size: ByteSize,
94+ pub mounts: Vec<String>,
95+}
96+
97+impl Default for BoxConfig {
98+ fn default() -> Self {
99+ Self {
100+ cpu_count: DEFAULT_CPU_COUNT,
101+ ram_size: ByteSize::mib(DEFAULT_RAM_MB),
102+ disk_size: ByteSize::gib(DEFAULT_DISK_GB),
103+ mounts: default_mounts(),
104+ }
105+ }
106+}
107+
108+#[derive(Debug, Clone, Serialize, Deserialize)]
109+pub struct SupervisorConfig {
110+ pub auto_shutdown_ms: u64,
111+}
112+
113+impl Default for SupervisorConfig {
114+ fn default() -> Self {
115+ Self {
116+ auto_shutdown_ms: DEFAULT_AUTO_SHUTDOWN_MS,
117+ }
118+ }
119+}
120+
121+fn default_mounts() -> Vec<String> {
122+ vec![
123+ "~/.codex:~/.codex:read-write".into(),
124+ "~/.claude:~/.claude:read-write".into(),
125+ ]
126+}
127+
128+pub fn config_path(project_root: &Path) -> PathBuf {
129+ project_root.join(CONFIG_FILENAME)
130+}
131+
132+pub fn ensure_config_file(project_root: &Path, override_path: Option<&Path>) -> Result<PathBuf> {
133+ let path = resolve_config_path(project_root, override_path)?;
134+ if !path.exists() {
135+ let default_config = Config::default();
136+ let contents = toml::to_string_pretty(&default_config).unwrap_or_default();
137+ fs::write(&path, contents)?;
138+ tracing::info!(path = %path.display(), "created vibebox config");
139+ }
140+ Ok(path)
141+}
142+
143+pub fn load_config(project_root: &Path) -> Result<Config> {
144+ load_config_with_path(project_root, None)
145+}
146+
147+pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) -> Result<Config> {
148+ let path =
149+ ensure_config_file(project_root, override_path).context("failed to create config")?;
150+ let raw = fs::read_to_string(&path).context("failed to read config")?;
151+ let trimmed = raw.trim();
152+ tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config");
153+ if trimmed.is_empty() {
154+ bail!(format!(
155+ "config file ({}) is empty. Required fields: [box].cpu_count (integer), [box].ram_mb (integer), [box].disk_gb (integer), [box].mounts (array of strings), [supervisor].auto_shutdown_ms (integer)",
156+ path.display()
157+ ));
158+ }
159+
160+ let value: toml::Value = toml::from_str(trimmed).context("invalid config")?;
161+ let schema_errors = validate_schema(&value);
162+ if !schema_errors.is_empty() {
163+ let message = format!(
164+ "config file ({}) is missing or invalid fields:\n- {}",
165+ path.display(),
166+ schema_errors.join("\n- ")
167+ );
168+ bail!(message);
169+ }
170+
171+ let config: Config = toml::from_str(trimmed).context("invalid config")?;
172+ validate_config(&config).map_err(Error::msg)?;
173+ Ok(config)
174+}
175+
176+fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> Result<PathBuf> {
177+ let env_override = env::var_os(CONFIG_PATH_ENV).map(PathBuf::from);
178+ resolve_config_path_inner(project_root, override_path, env_override)
179+}
180+
181+fn resolve_config_path_inner(
182+ project_root: &Path,
183+ override_path: Option<&Path>,
184+ env_override: Option<PathBuf>,
185+) -> Result<PathBuf> {
186+ let root = fs::canonicalize(project_root).context("failed to resolve project root")?;
187+
188+ let selected_path = override_path.map(PathBuf::from).or(env_override);
189+ let raw_path = if let Some(path) = selected_path {
190+ if path.is_absolute() {
191+ path
192+ } else {
193+ project_root.join(path)
194+ }
195+ } else {
196+ config_path(project_root)
197+ };
198+
199+ let normalized = normalize_path(&raw_path);
200+ let resolved =
201+ resolve_path_for_boundary_check(&normalized).context("failed to resolve config path")?;
202+ if !resolved.starts_with(&root) {
203+ bail!(
204+ "config path must be within {}: {}",
205+ root.display(),
206+ resolved.display()
207+ );
208+ }
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))
244+}
245+
246+fn normalize_path(path: &Path) -> PathBuf {
247+ let mut normalized = PathBuf::new();
248+ for component in path.components() {
249+ match component {
250+ std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
251+ std::path::Component::RootDir => {
252+ normalized.push(std::path::MAIN_SEPARATOR.to_string());
253+ }
254+ std::path::Component::CurDir => {}
255+ std::path::Component::ParentDir => {
256+ let _ = normalized.pop();
257+ }
258+ std::path::Component::Normal(part) => normalized.push(part),
259+ }
260+ }
261+ normalized
262+}
263+
264+fn validate_schema(value: &toml::Value) -> Vec<String> {
265+ let mut errors = Vec::new();
266+ let root = match value.as_table() {
267+ Some(table) => table,
268+ None => {
269+ errors.push("config must be a table".to_string());
270+ return errors;
271+ }
272+ };
273+
274+ match root.get("box") {
275+ None => errors.push("missing [box] table".to_string()),
276+ Some(value) => match value.as_table() {
277+ Some(table) => {
278+ validate_int(table, "cpu_count", "[box].cpu_count (integer)", &mut errors);
279+ validate_int(table, "ram_mb", "[box].ram_mb (integer)", &mut errors);
280+ validate_int(table, "disk_gb", "[box].disk_gb (integer)", &mut errors);
281+ validate_string_array(
282+ table,
283+ "mounts",
284+ "[box].mounts (array of strings)",
285+ &mut errors,
286+ );
287+ }
288+ None => errors.push("[box] must be a table".to_string()),
289+ },
290+ }
291+
292+ match root.get("supervisor") {
293+ None => errors.push("missing [supervisor] table".to_string()),
294+ Some(value) => match value.as_table() {
295+ Some(table) => {
296+ validate_int(
297+ table,
298+ "auto_shutdown_ms",
299+ "[supervisor].auto_shutdown_ms (integer)",
300+ &mut errors,
301+ );
302+ }
303+ None => errors.push("[supervisor] must be a table".to_string()),
304+ },
305+ }
306+
307+ errors
308+}
309+
310+fn validate_int(table: &toml::value::Table, key: &str, label: &str, errors: &mut Vec<String>) {
311+ match table.get(key) {
312+ None => errors.push(format!("missing {label}")),
313+ Some(value) => {
314+ if value.as_integer().is_none() {
315+ errors.push(format!("invalid {label}: expected integer"));
316+ }
317+ }
318+ }
319+}
320+
321+fn validate_string_array(
322+ table: &toml::value::Table,
323+ key: &str,
324+ label: &str,
325+ errors: &mut Vec<String>,
326+) {
327+ match table.get(key) {
328+ None => errors.push(format!("missing {label}")),
329+ Some(value) => match value.as_array() {
330+ Some(values) => {
331+ if values.iter().any(|value| !value.is_str()) {
332+ errors.push(format!("invalid {label}: expected array of strings"));
333+ }
334+ }
335+ None => errors.push(format!("invalid {label}: expected array of strings")),
336+ },
337+ }
338+}
339+
340+fn validate_config(config: &Config) -> Result<(), String> {
341+ if config.box_cfg.cpu_count == 0 {
342+ return Err("box.cpu_count must be >= 1".to_string());
343+ }
344+ if config.box_cfg.ram_size.as_mib() == 0.0 {
345+ return Err("box.ram_mb must be >= 1".to_string());
346+ }
347+ if config.box_cfg.disk_size.as_gib() == 0.0 {
348+ return Err("box.disk_gb must be >= 1".to_string());
349+ }
350+ if config.supervisor.auto_shutdown_ms == 0 {
351+ return Err("supervisor.auto_shutdown_ms must be >= 1".to_string());
352+ }
353+ for spec in &config.box_cfg.mounts {
354+ if let Err(err) = DirectoryShare::from_mount_spec(spec) {
355+ return Err(format!("invalid mount spec '{spec}': {err}"));
356+ }
357+ }
358+ Ok(())
359+}
360+
361+#[cfg(test)]
362+mod tests {
363+ use super::*;
364+ use std::ffi::OsString;
365+ use std::sync::Mutex;
366+ use tempfile::TempDir;
367+
368+ static ENV_MUTEX: Mutex<()> = Mutex::new(());
369+
370+ struct EnvGuard {
371+ key: &'static str,
372+ previous: Option<OsString>,
373+ }
374+
375+ impl EnvGuard {
376+ fn set(key: &'static str, value: &Path) -> Self {
377+ let previous = env::var_os(key);
378+ unsafe {
379+ env::set_var(key, value);
380+ }
381+ Self { key, previous }
382+ }
383+ }
384+
385+ impl Drop for EnvGuard {
386+ fn drop(&mut self) {
387+ match &self.previous {
388+ Some(value) => unsafe {
389+ env::set_var(self.key, value);
390+ },
391+ None => unsafe {
392+ env::remove_var(self.key);
393+ },
394+ }
395+ }
396+ }
397+
398+ #[test]
399+ fn default_config_serializes_with_legacy_keys() {
400+ let cfg = Config::default();
401+ let serialized = toml::to_string(&cfg).expect("default config should serialize");
402+
403+ assert!(serialized.contains("ram_mb = 2048"));
404+ assert!(serialized.contains("disk_gb = 5"));
405+ assert!(!serialized.contains("ram_size"));
406+ assert!(!serialized.contains("disk_size"));
407+ }
408+
409+ #[test]
410+ fn config_deserializes_sizes_from_mb_and_gb() {
411+ let raw = r#"
412+[box]
413+cpu_count = 4
414+ram_mb = 3072
415+disk_gb = 12
416+mounts = ["~/src:~/src:read-write"]
417+
418+[supervisor]
419+auto_shutdown_ms = 15000
420+"#;
421+ let cfg: Config = toml::from_str(raw).expect("config should deserialize");
422+
423+ assert_eq!(cfg.box_cfg.cpu_count, 4);
424+ assert_eq!(cfg.box_cfg.ram_size.as_u64(), ByteSize::mib(3072).as_u64());
425+ assert_eq!(cfg.box_cfg.disk_size.as_u64(), ByteSize::gib(12).as_u64());
426+ assert_eq!(cfg.supervisor.auto_shutdown_ms, 15000);
427+ }
428+
429+ #[test]
430+ fn serialize_rejects_non_integral_mb_or_gb() {
431+ let cfg = Config {
432+ box_cfg: BoxConfig {
433+ cpu_count: 2,
434+ ram_size: ByteSize::b((2 * MI_B) + 1),
435+ disk_size: ByteSize::gib(5),
436+ mounts: default_mounts(),
437+ },
438+ supervisor: SupervisorConfig::default(),
439+ };
440+
441+ let err = toml::to_string(&cfg).expect_err("serialization should reject invalid MB");
442+ assert!(
443+ err.to_string()
444+ .contains("ram_mb must be an integer number of MB")
445+ );
446+
447+ let cfg = Config {
448+ box_cfg: BoxConfig {
449+ cpu_count: 2,
450+ ram_size: ByteSize::mib(2048),
451+ disk_size: ByteSize::b((5 * GI_B) + 1),
452+ mounts: default_mounts(),
453+ },
454+ supervisor: SupervisorConfig::default(),
455+ };
456+
457+ let err = toml::to_string(&cfg).expect_err("serialization should reject invalid GB");
458+ assert!(
459+ err.to_string()
460+ .contains("disk_gb must be an integer number of GB")
461+ );
462+ }
463+
464+ #[test]
465+ fn normalize_path_removes_dot_and_parent_components() {
466+ let normalized = normalize_path(Path::new("/tmp/project/./nested/../config.toml"));
467+ assert_eq!(normalized, PathBuf::from("/tmp/project/config.toml"));
468+ }
469+
470+ #[test]
471+ fn validate_schema_returns_errors_for_missing_required_fields() {
472+ let value: toml::Value = toml::from_str(
473+ r#"
474+[box]
475+cpu_count = 2
476+"#,
477+ )
478+ .expect("toml should parse");
479+
480+ let errors = validate_schema(&value);
481+
482+ assert!(errors.iter().any(|e| e == "missing [supervisor] table"));
483+ assert!(errors.iter().any(|e| e == "missing [box].ram_mb (integer)"));
484+ assert!(
485+ errors
486+ .iter()
487+ .any(|e| e == "missing [box].disk_gb (integer)")
488+ );
489+ assert!(
490+ errors
491+ .iter()
492+ .any(|e| e == "missing [box].mounts (array of strings)")
493+ );
494+ }
495+
496+ #[test]
497+ fn validate_schema_errors_when_supervisor_is_not_table() {
498+ let value: toml::Value = toml::from_str(
499+ r#"
500+supervisor = 123
501+
502+[box]
503+cpu_count = 2
504+ram_mb = 2048
505+disk_gb = 5
506+mounts = []
507+"#,
508+ )
509+ .expect("toml should parse");
510+
511+ let errors = validate_schema(&value);
512+ assert!(errors.iter().any(|e| e == "[supervisor] must be a table"));
513+ }
514+
515+ #[test]
516+ fn ensure_config_file_creates_default_config_if_absent() {
517+ let temp = TempDir::new().expect("temp dir should be created");
518+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
519+
520+ let path = ensure_config_file(&root, None).expect("config should be created");
521+ let raw = fs::read_to_string(&path).expect("created config should be readable");
522+ let parsed: Config = toml::from_str(&raw).expect("created config should be valid");
523+
524+ assert_eq!(path, root.join("vibebox.toml"));
525+ assert_eq!(parsed.box_cfg.cpu_count, DEFAULT_CPU_COUNT);
526+ assert_eq!(
527+ parsed.box_cfg.ram_size.as_u64(),
528+ ByteSize::mib(DEFAULT_RAM_MB).as_u64()
529+ );
530+ assert_eq!(
531+ parsed.box_cfg.disk_size.as_u64(),
532+ ByteSize::gib(DEFAULT_DISK_GB).as_u64()
533+ );
534+ }
535+
536+ #[test]
537+ fn load_config_creates_and_loads_default_config() {
538+ let _lock = ENV_MUTEX.lock().expect("env lock should be acquired");
539+ let temp = TempDir::new().expect("temp dir should be created");
540+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
541+ let home = root.join("home");
542+ fs::create_dir_all(home.join(".codex")).expect("home .codex should be created");
543+ fs::create_dir_all(home.join(".claude")).expect("home .claude should be created");
544+ let _home_guard = EnvGuard::set("HOME", &home);
545+
546+ let cfg = load_config(&root).expect("load_config should succeed");
547+
548+ assert_eq!(cfg.box_cfg.cpu_count, DEFAULT_CPU_COUNT);
549+ assert_eq!(
550+ cfg.box_cfg.ram_size.as_u64(),
551+ ByteSize::mib(DEFAULT_RAM_MB).as_u64()
552+ );
553+ assert_eq!(
554+ cfg.box_cfg.disk_size.as_u64(),
555+ ByteSize::gib(DEFAULT_DISK_GB).as_u64()
556+ );
557+ assert!(root.join("vibebox.toml").exists());
558+ }
559+
560+ #[test]
561+ fn load_config_with_path_uses_override_path() {
562+ let _lock = ENV_MUTEX.lock().expect("env lock should be acquired");
563+ let temp = TempDir::new().expect("temp dir should be created");
564+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
565+ let home = root.join("home");
566+ fs::create_dir_all(home.join(".codex")).expect("home .codex should be created");
567+ fs::create_dir_all(home.join(".claude")).expect("home .claude should be created");
568+ let _home_guard = EnvGuard::set("HOME", &home);
569+ let override_path = root.join("custom.toml");
570+
571+ fs::write(
572+ &override_path,
573+ r#"
574+[box]
575+cpu_count = 6
576+ram_mb = 4096
577+disk_gb = 9
578+mounts = ["~/.codex:~/.codex:read-write", "~/.claude:~/.claude:read-write"]
579+
580+[supervisor]
581+auto_shutdown_ms = 12345
582+"#,
583+ )
584+ .expect("override config should be written");
585+
586+ let cfg = load_config_with_path(&root, Some(Path::new("custom.toml")))
587+ .expect("load_config_with_path should succeed");
588+
589+ assert_eq!(cfg.box_cfg.cpu_count, 6);
590+ assert_eq!(cfg.box_cfg.ram_size.as_u64(), ByteSize::mib(4096).as_u64());
591+ assert_eq!(cfg.box_cfg.disk_size.as_u64(), ByteSize::gib(9).as_u64());
592+ assert_eq!(cfg.supervisor.auto_shutdown_ms, 12345);
593+ assert!(!root.join("vibebox.toml").exists());
594+ }
595+
596+ #[test]
597+ fn resolve_config_path_uses_env_override_when_cli_override_missing() {
598+ let temp = TempDir::new().expect("temp dir should be created");
599+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
600+
601+ let resolved = resolve_config_path_inner(&root, None, Some(PathBuf::from("custom.toml")))
602+ .expect("env override path should resolve");
603+
604+ assert_eq!(resolved, root.join("custom.toml"));
605+ }
606+
607+ #[test]
608+ fn resolve_config_path_rejects_env_override_outside_project() {
609+ let temp = TempDir::new().expect("temp dir should be created");
610+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
611+
612+ let err = resolve_config_path_inner(&root, None, Some(PathBuf::from("../escape.toml")))
613+ .expect_err("outside-project path should be rejected");
614+
615+ assert!(
616+ err.to_string().contains("config path must be within"),
617+ "expected bounds-check error, got: {err}"
618+ );
619+ }
620+
621+ #[test]
622+ fn validate_config_rejects_invalid_values() {
623+ let cfg = Config {
624+ box_cfg: BoxConfig {
625+ cpu_count: 0,
626+ ram_size: ByteSize::mib(2048),
627+ disk_size: ByteSize::gib(5),
628+ mounts: vec![],
629+ },
630+ supervisor: SupervisorConfig::default(),
631+ };
632+ let err = validate_config(&cfg).expect_err("cpu_count=0 should fail");
633+ assert_eq!(err, "box.cpu_count must be >= 1");
634+
635+ let cfg = Config {
636+ box_cfg: BoxConfig {
637+ cpu_count: 2,
638+ ram_size: ByteSize::mib(2048),
639+ disk_size: ByteSize::gib(5),
640+ mounts: vec!["/definitely/missing:/tmp/missing:read-write".to_string()],
641+ },
642+ supervisor: SupervisorConfig::default(),
643+ };
644+ let err = validate_config(&cfg).expect_err("invalid mount should fail");
645+ assert!(err.starts_with("invalid mount spec"));
646+ }
647+
648+ #[test]
649+ fn resolve_config_path_accepts_symlinked_project_root() {
650+ let temp = TempDir::new().expect("temp dir should be created");
651+ let actual_root = temp.path().join("actual");
652+ let link_root = temp.path().join("linked");
653+ fs::create_dir_all(&actual_root).expect("actual root should exist");
654+ std::os::unix::fs::symlink(&actual_root, &link_root).expect("symlink should be created");
655+
656+ let resolved = resolve_config_path_inner(&link_root, Some(Path::new("vibebox.toml")), None)
657+ .expect("symlinked project root should resolve");
658+
659+ assert_eq!(resolved, link_root.join("vibebox.toml"));
660+ }
661+
662+ #[test]
663+ fn resolve_config_path_rejects_symlink_escape() {
664+ let temp = TempDir::new().expect("temp dir should be created");
665+ let project_root = temp.path().join("project");
666+ let outside_root = temp.path().join("outside");
667+ fs::create_dir_all(&project_root).expect("project root should exist");
668+ fs::create_dir_all(&outside_root).expect("outside root should exist");
669+ std::os::unix::fs::symlink(&outside_root, project_root.join("link"))
670+ .expect("escape symlink should be created");
671+
672+ let err = resolve_config_path_inner(&project_root, Some(Path::new("link/cfg.toml")), None)
673+ .expect_err("symlink escape should be rejected");
674+ assert!(
675+ err.to_string().contains("config path must be within"),
676+ "expected bounds-check error, got: {err}"
677+ );
678+ }
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.rsnew file+156
@@ -0,0 +1,156 @@
1+use crate::instance::InstanceConfig;
2+use crate::utils::relative_to_home;
3+use crate::{config, instance, session_manager, tui};
4+use anyhow::{Context, Result, bail};
5+use std::{
6+ env,
7+ path::{Path, PathBuf},
8+};
9+
10+pub fn build_mount_rows(project: &Path, config: &config::Config) -> Result<Vec<tui::MountListRow>> {
11+ let mut rows = Vec::new();
12+ rows.extend(default_mounts(project)?);
13+ let guest_home = resolve_guest_home(project);
14+ for spec in &config.box_cfg.mounts {
15+ rows.push(parse_mount_spec(project, spec, false, &guest_home)?);
16+ }
17+ Ok(rows)
18+}
19+
20+pub fn build_network_rows(project_dir: &Path) -> Result<Vec<tui::NetworkListRow>> {
21+ let mut vm_ip = "-".to_string();
22+ if let Ok(config) = instance::read_instance_config(project_dir) {
23+ match config.vm_ipv4 {
24+ None => {}
25+ Some(ip) => {
26+ vm_ip = ip;
27+ }
28+ }
29+ }
30+ let host_to_vm = if vm_ip == "-" {
31+ "ssh: <pending>:22".to_string()
32+ } else {
33+ format!("ssh: {vm_ip}:22")
34+ };
35+ let row = tui::NetworkListRow {
36+ network_type: "NAT".to_string(),
37+ vm_ip: vm_ip.clone(),
38+ host_to_vm,
39+ vm_to_host: "none".to_string(),
40+ };
41+ Ok(vec![row])
42+}
43+
44+fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>> {
45+ let project_name = cwd
46+ .file_name()
47+ .and_then(|name| name.to_str())
48+ .with_context(|| "failed to get project name")?;
49+ let project_guest = format!("~/{project_name}");
50+ let project_host = relative_to_home(cwd);
51+ let mut rows = vec![tui::MountListRow {
52+ host: project_host,
53+ guest: project_guest,
54+ mode: "read-write".to_string(),
55+ default_mount: "yes".to_string(),
56+ }];
57+
58+ let home = env::var("HOME")
59+ .map(PathBuf::from)
60+ .with_context(|| "failed to get home directory")?;
61+ let cache_home = env::var("XDG_CACHE_HOME")
62+ .map(PathBuf::from)
63+ .unwrap_or_else(|_| home.join(".cache"));
64+ let cache_dir = cache_home.join(session_manager::GLOBAL_CACHE_DIR_NAME);
65+ let guest_mise_cache = cache_dir.join(".guest-mise-cache");
66+ rows.push(tui::MountListRow {
67+ host: relative_to_home(&guest_mise_cache),
68+ guest: "/root/.local/share/mise".to_string(),
69+ mode: "read-write".to_string(),
70+ default_mount: "yes".to_string(),
71+ });
72+ Ok(rows)
73+}
74+
75+fn parse_mount_spec(
76+ cwd: &Path,
77+ spec: &str,
78+ default_mount: bool,
79+ guest_home: &str,
80+) -> Result<tui::MountListRow> {
81+ let parts: Vec<&str> = spec.split(':').collect();
82+ if parts.len() < 2 || parts.len() > 3 {
83+ bail!["invalid mount spec: {spec}"];
84+ }
85+ let host_part = parts[0];
86+ let guest_part = parts[1];
87+ let mode = if parts.len() == 3 {
88+ match parts[2] {
89+ "read-only" => "read-only",
90+ "read-write" => "read-write",
91+ other => {
92+ bail![format!(
93+ "invalid mount mode '{}'; expected read-only or read-write",
94+ other
95+ )];
96+ }
97+ }
98+ } else {
99+ "read-write"
100+ };
101+
102+ let host_display = display_host_spec(cwd, host_part);
103+ let guest_display = resolve_guest_display(guest_part, guest_home);
104+ Ok(tui::MountListRow {
105+ host: host_display,
106+ guest: guest_display,
107+ mode: mode.to_string(),
108+ default_mount: if default_mount { "yes" } else { "no" }.to_string(),
109+ })
110+}
111+
112+fn display_host_spec(cwd: &Path, host: &str) -> String {
113+ if host == "~" || host.starts_with("~/") {
114+ return host.to_string();
115+ }
116+ let host_path = PathBuf::from(host);
117+ if host_path.is_absolute() {
118+ return relative_to_home(&host_path);
119+ }
120+ let candidate = cwd.join(&host_path);
121+ if candidate.is_absolute() {
122+ relative_to_home(&candidate)
123+ } else {
124+ host.to_string()
125+ }
126+}
127+
128+fn resolve_guest_home(project_dir: &Path) -> String {
129+ let config = instance::read_instance_config(project_dir);
130+ match config {
131+ Ok(config) => format!("/home/{}", config.ssh_user),
132+ Err(_) => format!("/home/{}", InstanceConfig::default().ssh_user),
133+ }
134+}
135+
136+fn resolve_guest_display(guest: &str, guest_home: &str) -> String {
137+ if guest == "~" {
138+ return "~".to_string();
139+ }
140+ if let Some(stripped) = guest.strip_prefix("~/") {
141+ return format!("~/{stripped}");
142+ }
143+ if Path::new(guest).is_absolute() {
144+ if let Ok(stripped) = Path::new(guest).strip_prefix(guest_home) {
145+ if stripped.components().next().is_none() {
146+ "~".to_string()
147+ } else {
148+ format!("~/{}", stripped.display())
149+ }
150+ } else {
151+ guest.to_string()
152+ }
153+ } else {
154+ format!("/root/{guest}")
155+ }
156+}
src/instance.rs+329−339
@@ -1,77 +1,140 @@
1−use crate::session_manager::INSTANCE_TOML_FILENAME;
1+use anyhow::{Context, Result, bail};
2+use serde::{Deserialize, Serialize};
3+use std::os::unix::fs::FileTypeExt;
24use std::{
35 env, fs,
4− io::{self, Write},
6+ io::{self, IsTerminal, Read},
57 net::{SocketAddr, TcpStream},
6− os::unix::{fs::PermissionsExt, io::OwnedFd, net::UnixStream},
8+ os::unix::{fs::PermissionsExt, net::UnixStream},
79 path::{Path, PathBuf},
8− process::{Command, Stdio},
10+ process::{Child, Command, Stdio},
911 sync::{
1012 Arc, Mutex,
1113 atomic::{AtomicBool, Ordering},
14+ mpsc,
1215 },
1316 thread,
1417 time::{Duration, Instant},
1518};
16−
17−use serde::{Deserialize, Serialize};
18−use std::sync::mpsc::Sender;
1919use time::{OffsetDateTime, format_description::well_known::Rfc3339};
2020use uuid::Uuid;
2121
2222use crate::{
23+ commands,
2324 session_manager::INSTANCE_DIR_NAME,
24− tui::{self, AppState},
25− vm::{self, LoginAction, VmInput},
25+ session_manager::{VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME},
26+ vm::{self, LoginAction},
2627};
2728
29+pub const STATUS_VM_ERROR_PREFIX: &str = "error:";
30+
2831const SSH_KEY_NAME: &str = "ssh_key";
29−#[allow(dead_code)]
30−const SERIAL_LOG_NAME: &str = "serial.log";
32+const INSTANCE_FILENAME: &str = "instance.toml";
3133const DEFAULT_SSH_USER: &str = "vibecoder";
32−const SSH_CONNECT_RETRIES: usize = 30;
34+const SSH_CONNECT_RETRIES: usize = 10;
3335const SSH_CONNECT_DELAY_MS: u64 = 500;
3436const 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+}
3592
3693#[derive(Debug, Clone, Serialize, Deserialize)]
37−pub(crate) struct InstanceConfig {
94+pub struct InstanceConfig {
3895 #[serde(default)]
39− id: String,
96+ pub id: String,
4097 #[serde(default = "default_ssh_user")]
41− ssh_user: String,
98+ pub ssh_user: String,
4299 #[serde(default)]
43100 sudo_password: String,
44101 #[serde(default)]
45− last_active: Option<String>,
102+ pub last_active: Option<String>,
46103 #[serde(default)]
47− pub(crate) vm_ipv4: Option<String>,
104+ pub vm_ipv4: Option<String>,
48105}
49106
50−fn default_ssh_user() -> String {
51− DEFAULT_SSH_USER.to_string()
107+impl Default for InstanceConfig {
108+ fn default() -> Self {
109+ Self {
110+ id: Uuid::now_v7().to_string(),
111+ ssh_user: DEFAULT_SSH_USER.to_string(),
112+ sudo_password: Uuid::now_v7().simple().to_string(),
113+ last_active: None,
114+ vm_ipv4: None,
115+ }
116+ }
52117}
53118
54−pub fn run_with_ssh(manager_conn: UnixStream) -> Result<(), Box<dyn std::error::Error>> {
119+pub fn run_with_ssh(manager_conn: UnixStream) -> Result<()> {
55120 let project_root = env::current_dir()?;
56121 tracing::info!(root = %project_root.display(), "starting ssh session");
57122 let instance_dir = ensure_instance_dir(&project_root)?;
58123 tracing::debug!(instance_dir = %instance_dir.display(), "instance dir ready");
59124 let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?;
60125
61− let config = load_or_create_instance_config(&instance_dir)?;
126+ let config = load_or_create_instance_config(&project_root)?;
62127 let ssh_user = config.ssh_user.clone();
63128 tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
64129
65− let _manager_conn = manager_conn;
66− tracing::debug!("waiting for vm ipv4");
67− wait_for_vm_ipv4(&instance_dir, Duration::from_secs(120))?;
130+ wait_for_vm_ipv4(&project_root, Duration::from_secs(480), &manager_conn)?;
68131
69− let ip = load_or_create_instance_config(&instance_dir)?
132+ let ip = load_or_create_instance_config(&project_root)?
70133 .vm_ipv4
71− .ok_or("VM IPv4 not available")?;
134+ .with_context(|| "failed to load instance IP address")?;
72135 tracing::info!(ip = %ip, "vm ipv4 ready");
73136
74− run_ssh_session(ssh_key, ssh_user, ip)
137+ run_ssh_session(ssh_key, ssh_user, ip, manager_conn, project_root)
75138}
76139
77140pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
@@ -80,9 +143,7 @@ pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
80143 Ok(instance_dir)
81144}
82145
83−pub(crate) fn ensure_ssh_keypair(
84− instance_dir: &Path,
85−) -> Result<(PathBuf, PathBuf), Box<dyn std::error::Error>> {
146+pub fn ensure_ssh_keypair(instance_dir: &Path) -> Result<(PathBuf, PathBuf)> {
86147 let private_key = instance_dir.join(SSH_KEY_NAME);
87148 let public_key = instance_dir.join(format!("{SSH_KEY_NAME}.pub"));
88149
@@ -104,17 +165,19 @@ pub(crate) fn ensure_ssh_keypair(
104165 "-N",
105166 "",
106167 "-f",
107− private_key.to_str().ok_or("ssh key path not utf-8")?,
168+ private_key
169+ .to_str()
170+ .with_context(|| "ssh key path not utf-8")?,
108171 "-C",
109172 "vibebox",
110173 ])
111174 .stdin(Stdio::null())
112− .stdout(Stdio::null())
175+ .stdout(Stdio::inherit())
113176 .stderr(Stdio::inherit())
114177 .status()?;
115178
116179 if !status.success() {
117− return Err("ssh-keygen failed".into());
180+ bail!("ssh-keygen failed");
118181 }
119182
120183 fs::set_permissions(&private_key, fs::Permissions::from_mode(0o600))?;
@@ -123,69 +186,66 @@ pub(crate) fn ensure_ssh_keypair(
123186 Ok((private_key, public_key))
124187}
125188
126−pub(crate) fn load_or_create_instance_config(
127− instance_dir: &Path,
128−) -> Result<InstanceConfig, Box<dyn std::error::Error>> {
129− let config_path = instance_dir.join(INSTANCE_TOML_FILENAME);
130− let mut config = if config_path.exists() {
131− let raw = fs::read_to_string(&config_path)?;
132− toml::from_str::<InstanceConfig>(&raw)?
133− } else {
134− InstanceConfig {
135− id: String::new(),
136− ssh_user: default_ssh_user(),
137− sudo_password: String::new(),
138− last_active: None,
139− vm_ipv4: None,
140− }
141− };
189+pub fn load_or_create_instance_config(project_dir: &Path) -> Result<InstanceConfig> {
190+ let mut exist = true;
191+ let mut config = read_instance_config(project_dir).unwrap_or_else(|_| {
192+ exist = false;
193+ InstanceConfig::default()
194+ });
142195
143196 let mut changed = false;
144197 if config.ssh_user.trim().is_empty() {
145− config.ssh_user = default_ssh_user();
198+ config.ssh_user = InstanceConfig::default().ssh_user;
146199 changed = true;
147200 }
148201
149202 if config.id.trim().is_empty() {
150− config.id = Uuid::now_v7().to_string();
203+ config.id = InstanceConfig::default().id;
151204 changed = true;
152205 }
153206
154207 if config.sudo_password.trim().is_empty() {
155− config.sudo_password = generate_password();
208+ config.sudo_password = InstanceConfig::default().sudo_password;
156209 changed = true;
157210 }
158211
159− if !config_path.exists() || changed {
160− write_instance_config(&config_path, &config)?;
212+ if !exist || changed {
213+ write_instance_config(project_dir, &config)?;
161214 }
162215
163216 Ok(config)
164217}
165218
166−pub fn touch_last_active(instance_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
167− let mut config = load_or_create_instance_config(instance_dir)?;
219+pub fn read_instance_config(project_dir: &Path) -> Result<InstanceConfig> {
220+ // todo maybe verify schema?
221+ let config_path = project_dir.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
222+ if !config_path.exists() {
223+ bail!("instance config file does not exist");
224+ }
225+ let raw = fs::read_to_string(&config_path)?;
226+ let config = toml::from_str::<InstanceConfig>(&raw)?;
227+ Ok(config)
228+}
229+
230+pub fn touch_last_active(project_dir: &Path) -> Result<()> {
231+ let mut config = load_or_create_instance_config(project_dir)?;
168232 let now = OffsetDateTime::now_utc().format(&Rfc3339)?;
169233 config.last_active = Some(now);
170− write_instance_config(&instance_dir.join(INSTANCE_TOML_FILENAME), &config)?;
234+ write_instance_config(project_dir, &config)?;
171235 Ok(())
172236}
173237
174−pub(crate) fn write_instance_config(
175− path: &Path,
176− config: &InstanceConfig,
177−) -> Result<(), Box<dyn std::error::Error>> {
238+pub fn write_instance_config(project_dir: &Path, config: &InstanceConfig) -> Result<()> {
239+ let path = project_dir.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
178240 let data = toml::to_string_pretty(config)?;
179− fs::write(path, data)?;
180− fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
241+ fs::create_dir_all(project_dir.join(INSTANCE_DIR_NAME))?;
242+ fs::write(&path, data)?;
243+ fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
181244 Ok(())
182245}
183246
184−fn generate_password() -> String {
185− Uuid::now_v7().simple().to_string()
186−}
187−
188−pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
247+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
248+pub fn extract_ipv4(line: &str) -> Option<String> {
189249 let mut current = String::new();
190250 let mut best: Option<String> = None;
191251
@@ -204,23 +264,79 @@ pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
204264 best
205265}
206266
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+
207285fn wait_for_vm_ipv4(
208− instance_dir: &Path,
286+ project_dir: &Path,
209287 timeout: Duration,
210−) -> Result<(), Box<dyn std::error::Error>> {
288+ manager_conn: &UnixStream,
289+) -> Result<()> {
211290 let start = Instant::now();
291+ let mut next_log_at = start + Duration::from_secs(10);
292+ let mut stream = manager_conn.try_clone()?;
293+ let _ = stream.set_read_timeout(Some(Duration::from_millis(250)));
294+ let mut read_buf = [0u8; 1024];
295+ let mut pending = String::new();
296+ tracing::info!("waiting for vm ipv4");
297+ let mut last_status: Option<String> = None;
298+ let mut once_hint = false;
212299 loop {
213− let config = load_or_create_instance_config(instance_dir)?;
300+ match stream.read(&mut read_buf) {
301+ Ok(0) => {
302+ bail!("vm manager disconnected before VM became ready");
303+ }
304+ Ok(n) => {
305+ pending.push_str(&String::from_utf8_lossy(&read_buf[..n]));
306+ while let Some(pos) = pending.find('\n') {
307+ let line = pending[..pos].trim().to_string();
308+ pending.drain(..=pos);
309+ handle_manager_line(&line, &mut last_status)?;
310+ }
311+ }
312+ Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
313+ Err(err) if err.kind() == io::ErrorKind::TimedOut => {}
314+ Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
315+ Err(err) => {
316+ tracing::warn!(error = %err, "failed to read vm manager status stream");
317+ }
318+ }
319+
320+ let config = load_or_create_instance_config(project_dir)?;
214321 if config.vm_ipv4.is_some() {
215322 return Ok(());
216323 }
217324 if start.elapsed() > timeout {
218− return Err("Timed out waiting for VM IPv4".into());
325+ bail!("timed out waiting for VM IPv4");
219326 }
220− if start.elapsed().as_secs() % 5 == 0 {
221− tracing::debug!("still waiting for vm ipv4");
327+
328+ let now = Instant::now();
329+ if now >= next_log_at {
330+ let waited = start.elapsed();
331+ if waited.as_secs() > 15 && !once_hint {
332+ tracing::info!(
333+ "if vibebox is just initialized in this directory, it might take up to 1 minute depending on your machine, and then you can enjoy secure & speed vibecoding! go pack!"
334+ );
335+ once_hint = true;
336+ }
337+ tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
338+ next_log_at += Duration::from_secs(20);
222339 }
223− thread::sleep(Duration::from_millis(200));
224340 }
225341}
226342
@@ -228,32 +344,43 @@ fn run_ssh_session(
228344 ssh_key: PathBuf,
229345 ssh_user: String,
230346 ip: String,
231−) -> Result<(), Box<dyn std::error::Error>> {
347+ manager_conn: UnixStream,
348+ project_root: PathBuf,
349+) -> Result<()> {
232350 let mut attempts = 0usize;
233351 loop {
352+ if matches!(vm_liveness(&project_root)?, VmLiveness::NotRunningOrMissing) {
353+ return Err(InstanceError::UnexpectedDisconnection.into());
354+ }
234355 attempts += 1;
235356 if !ssh_port_open(&ip) {
236− tracing::debug!(attempts, "ssh port not open yet");
237− eprintln!(
238− "[vibebox] waiting for ssh port on {ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
357+ tracing::debug!(attempts, "ssh port doesn't open yet");
358+ tracing::info!(
359+ attempts,
360+ ip = %ip,
361+ "waiting for ssh port ({}/{})",
362+ attempts,
363+ SSH_CONNECT_RETRIES
239364 );
240365 if attempts >= SSH_CONNECT_RETRIES {
241− return Err(
242− format!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts").into(),
243− );
366+ bail!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts");
244367 }
245368 thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS));
246369 continue;
247370 }
248371
249− eprintln!(
250− "[vibebox] starting ssh to {ssh_user}@{ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
372+ tracing::info!(
373+ attempts,
374+ user = %ssh_user,
375+ ip = %ip,
376+ "starting ssh ({}/{})",
377+ attempts,
378+ SSH_CONNECT_RETRIES
251379 );
252− tracing::info!(attempts, user = %ssh_user, ip = %ip, "starting ssh");
253− let status = Command::new("ssh")
380+ let child = Command::new("ssh")
254381 .args([
255382 "-i",
256− ssh_key.to_str().unwrap_or(".vibebox/ssh_key"),
383+ ssh_key.to_str().with_context(|| "invalid path")?,
257384 "-o",
258385 "IdentitiesOnly=yes",
259386 "-o",
@@ -278,25 +405,67 @@ fn run_ssh_session(
278405 .stdin(Stdio::inherit())
279406 .stdout(Stdio::inherit())
280407 .stderr(Stdio::inherit())
281− .status();
408+ .spawn();
409+
410+ match child {
411+ Ok(mut child) => {
412+ let done = Arc::new(AtomicBool::new(false));
413+ let done_for_monitor = done.clone();
414+ let (disconnect_tx, disconnect_rx) = mpsc::channel::<()>();
415+ let mut manager_stream = manager_conn.try_clone()?;
416+ let _ = manager_stream.set_read_timeout(Some(Duration::from_millis(250)));
417+ thread::spawn(move || {
418+ let mut buf = [0u8; 1];
419+ while !done_for_monitor.load(Ordering::Relaxed) {
420+ match manager_stream.read(&mut buf) {
421+ Ok(0) => {
422+ let _ = disconnect_tx.send(());
423+ return;
424+ }
425+ Ok(_) => {}
426+ Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
427+ Err(err) if err.kind() == io::ErrorKind::TimedOut => {}
428+ Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
429+ Err(_) => {
430+ let _ = disconnect_tx.send(());
431+ return;
432+ }
433+ }
434+ }
435+ });
436+
437+ let status = loop {
438+ if disconnect_rx.try_recv().is_ok() {
439+ done.store(true, Ordering::Relaxed);
440+ terminate_ssh_child(&mut child);
441+ restore_terminal_after_disconnect();
442+ return Err(InstanceError::UnexpectedDisconnection.into());
443+ }
444+ if let Some(status) = child.try_wait()? {
445+ done.store(true, Ordering::Relaxed);
446+ break status;
447+ }
448+ thread::sleep(Duration::from_millis(100));
449+ };
282450
283− match status {
284− Ok(status) if status.success() => {
285− eprintln!("[vibebox] ssh exited with status: {status}");
286− break;
287− }
288− Ok(status) if status.code() == Some(255) => {
289− eprintln!("[vibebox] ssh connection failed: {status}");
290− if attempts >= SSH_CONNECT_RETRIES {
291− return Err(format!("ssh failed after {SSH_CONNECT_RETRIES} attempts").into());
451+ if status.success() {
452+ tracing::info!(status = %status, "ssh exited");
453+ break;
292454 }
293− thread::sleep(Duration::from_millis(500));
294− }
295− Ok(status) => {
296− return Err(format!("ssh exited with status: {status}").into());
455+ if status.code() == Some(255) {
456+ tracing::warn!(status = %status, "ssh connection failed");
457+ if attempts >= SSH_CONNECT_RETRIES {
458+ bail!("ssh failed after {SSH_CONNECT_RETRIES} attempts");
459+ }
460+ thread::sleep(Duration::from_millis(500));
461+ continue;
462+ }
463+ tracing::info!(status = %status, "ssh exited");
464+ break;
297465 }
298466 Err(err) => {
299− return Err(format!("failed to start ssh: {err}").into());
467+ tracing::error!(error = %err, "failed to start ssh");
468+ bail!("failed to start ssh: {err}");
300469 }
301470 }
302471 }
@@ -304,6 +473,31 @@ fn run_ssh_session(
304473 Ok(())
305474}
306475
476+fn terminate_ssh_child(child: &mut Child) {
477+ let pid = child.id() as i32;
478+ unsafe {
479+ libc::kill(pid, libc::SIGTERM);
480+ }
481+ let deadline = Instant::now() + Duration::from_millis(700);
482+ while Instant::now() < deadline {
483+ match child.try_wait() {
484+ Ok(Some(_)) => return,
485+ Ok(None) => thread::sleep(Duration::from_millis(50)),
486+ Err(_) => break,
487+ }
488+ }
489+ let _ = child.kill();
490+ let _ = child.wait();
491+}
492+
493+fn restore_terminal_after_disconnect() {
494+ if io::stdin().is_terminal() {
495+ let _ = Command::new("stty").arg("sane").status();
496+ }
497+ eprintln!();
498+}
499+
500+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
307501fn is_ipv4_candidate(candidate: &str) -> bool {
308502 let parts: Vec<&str> = candidate.split('.').collect();
309503 if parts.len() != 4 {
@@ -325,14 +519,16 @@ fn ssh_port_open(ip: &str) -> bool {
325519 Ok(addr) => addr,
326520 Err(_) => return false,
327521 };
328− TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).is_ok()
522+ TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok()
329523}
330524
331−pub(crate) fn build_ssh_login_actions(
525+pub fn build_ssh_login_actions(
332526 config: &Arc<Mutex<InstanceConfig>>,
333527 project_name: &str,
528+ project_guest_dir: &str,
334529 guest_dir: &str,
335530 key_name: &str,
531+ home_links_script: &str,
336532) -> Vec<LoginAction> {
337533 let config_guard = config.lock().expect("config mutex poisoned");
338534 let ssh_user = config_guard.ssh_user.clone();
@@ -341,250 +537,44 @@ pub(crate) fn build_ssh_login_actions(
341537
342538 let key_path = format!("{guest_dir}/{key_name}.pub");
343539
344− let setup_script = SSH_SETUP_SCRIPT
540+ let ssh_script = SSH_SETUP_SCRIPT
345541 .replace("__SSH_USER__", &ssh_user)
346542 .replace("__SUDO_PASSWORD__", &sudo_password)
347543 .replace("__PROJECT_NAME__", project_name)
348− .replace("__KEY_PATH__", &key_path);
349− let setup = vm::script_command_from_content("ssh_setup", &setup_script)
544+ .replace("__PROJECT_GUEST_DIR__", project_guest_dir)
545+ .replace("__KEY_PATH__", &key_path)
546+ .replace("__VIBEBOX_SHELL_SCRIPT__", &commands::render_shell_script())
547+ .replace("__VIBEBOX_HOME_LINKS__", home_links_script);
548+ let setup = vm::script_command_from_content("ssh.sh", &ssh_script)
350549 .expect("ssh setup script contained invalid marker");
351550
352551 vec![LoginAction::Send(setup)]
353552}
354553
355−#[allow(dead_code)]
356−fn spawn_ssh_io(
357− app: Arc<Mutex<AppState>>,
358− config: Arc<Mutex<InstanceConfig>>,
359− instance_dir: PathBuf,
360− ssh_key: PathBuf,
361− output_monitor: Arc<vm::OutputMonitor>,
362− vm_output_fd: OwnedFd,
363− vm_input_fd: OwnedFd,
364−) -> vm::IoContext {
365− let io_control = vm::IoControl::new();
366−
367− let log_path = instance_dir.join(SERIAL_LOG_NAME);
368− let log_file = fs::OpenOptions::new()
369− .create(true)
370− .append(true)
371− .open(&log_path)
372− .ok()
373− .map(|file| Arc::new(Mutex::new(file)));
374−
375− let ssh_connected = Arc::new(AtomicBool::new(false));
376− let ssh_started = Arc::new(AtomicBool::new(false));
377− let ssh_ready = Arc::new(AtomicBool::new(false));
378− let input_tx_holder: Arc<Mutex<Option<Sender<VmInput>>>> = Arc::new(Mutex::new(None));
379−
380− let instance_path = instance_dir.join(INSTANCE_TOML_FILENAME);
381− let config_for_output = config.clone();
382− let log_for_output = log_file.clone();
383− let ssh_connected_for_output = ssh_connected.clone();
384− let ssh_ready_for_output = ssh_ready.clone();
385−
386− let mut line_buf = String::new();
387−
388− let on_line = {
389− let app = app.clone();
390− move |line: &str| {
391− if line == ":help" {
392− if let Ok(mut locked) = app.lock() {
393− let _ = tui::render_commands_component(&mut locked);
394− }
395− return true;
396− }
397− false
398− }
399− };
400−
401− let on_output = move |bytes: &[u8]| {
402− if ssh_connected_for_output.load(Ordering::SeqCst) {
403− if let Some(log) = &log_for_output {
404− if let Ok(mut file) = log.lock() {
405− let _ = file.write_all(bytes);
406− }
407− }
408− }
409−
410− let text = String::from_utf8_lossy(bytes);
411− line_buf.push_str(&text);
554+#[cfg(test)]
555+mod tests {
556+ use super::*;
412557
413− while let Some(pos) = line_buf.find('\n') {
414− let mut line = line_buf[..pos].to_string();
415− if line.ends_with('\r') {
416− line.pop();
417− }
418− line_buf.drain(..=pos);
419−
420− let cleaned = line.trim_start_matches(|c: char| c == '\r' || c == ' ');
421−
422− if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
423− let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
424− let ip = extract_ipv4(ip_raw).unwrap_or_default();
425− if !ip.is_empty() {
426− if let Ok(mut cfg) = config_for_output.lock() {
427− if cfg.vm_ipv4.as_deref() != Some(ip.as_str()) {
428− cfg.vm_ipv4 = Some(ip.clone());
429− let _ = write_instance_config(&instance_path, &cfg);
430− eprintln!("[vibebox] detected vm IPv4: {ip}");
431− }
432− }
433− }
434− }
435−
436− if cleaned.contains("VIBEBOX_SSH_READY") {
437− ssh_ready_for_output.store(true, Ordering::SeqCst);
438− eprintln!("[vibebox] sshd ready");
439− }
440− }
441− };
442−
443− let io_ctx = vm::spawn_vm_io_with_hooks(
444− output_monitor,
445− vm_output_fd,
446− vm_input_fd,
447− io_control.clone(),
448− on_line,
449− on_output,
450− );
451−
452− *input_tx_holder.lock().unwrap() = Some(io_ctx.input_tx.clone());
453−
454− let ssh_ready_for_thread = ssh_ready.clone();
455− let ssh_started_for_thread = ssh_started.clone();
456− let ssh_connected_for_thread = ssh_connected.clone();
457− let io_control_for_thread = io_control.clone();
458− let ssh_key_for_thread = ssh_key.clone();
459− let config_for_thread = config.clone();
460− let input_tx_holder_for_thread = input_tx_holder.clone();
461−
462− thread::spawn(move || {
463− loop {
464− if ssh_started_for_thread.load(Ordering::SeqCst) {
465− break;
466− }
467− if !ssh_ready_for_thread.load(Ordering::SeqCst) {
468− thread::sleep(std::time::Duration::from_millis(200));
469− continue;
470− }
471−
472− let ip = config_for_thread
473− .lock()
474− .ok()
475− .and_then(|cfg| cfg.vm_ipv4.clone());
476− let ssh_user = config_for_thread
477− .lock()
478− .map(|cfg| cfg.ssh_user.clone())
479− .unwrap_or_else(|_| DEFAULT_SSH_USER.to_string());
480−
481− if let Some(ip) = ip {
482− if ssh_started_for_thread
483− .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
484− .is_err()
485− {
486− break;
487− }
488−
489− eprintln!("[vibebox] starting ssh to {ssh_user}@{ip}");
490− io_control_for_thread.request_terminal_restore();
491− io_control_for_thread.set_forward_output(false);
492− io_control_for_thread.set_forward_input(false);
493− ssh_connected_for_thread.store(true, Ordering::SeqCst);
494−
495− let mut attempts = 0usize;
496− loop {
497− attempts += 1;
498− if !ssh_port_open(&ip) {
499− eprintln!(
500− "[vibebox] waiting for ssh port on {ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
501− );
502− if attempts >= SSH_CONNECT_RETRIES {
503− ssh_connected_for_thread.store(false, Ordering::SeqCst);
504− eprintln!(
505− "[vibebox] ssh port not ready after {SSH_CONNECT_RETRIES} attempts"
506− );
507− break;
508− }
509− thread::sleep(std::time::Duration::from_millis(SSH_CONNECT_DELAY_MS));
510− continue;
511− }
512−
513− eprintln!(
514− "[vibebox] starting ssh to {ssh_user}@{ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
515− );
516− let status = Command::new("ssh")
517− .args([
518− "-i",
519− ssh_key_for_thread.to_str().unwrap_or(".vibebox/ssh_key"),
520− "-o",
521− "IdentitiesOnly=yes",
522− "-o",
523− "StrictHostKeyChecking=no",
524− "-o",
525− "UserKnownHostsFile=/dev/null",
526− "-o",
527− "GlobalKnownHostsFile=/dev/null",
528− "-o",
529− "PasswordAuthentication=no",
530− "-o",
531− "BatchMode=yes",
532− "-o",
533− "LogLevel=ERROR",
534− "-o",
535− "ConnectTimeout=5",
536− ])
537− .env_remove("LC_CTYPE")
538− .env_remove("LC_ALL")
539− // .env_remove("LANG")
540− .arg(format!("{ssh_user}@{ip}"))
541− .stdin(Stdio::inherit())
542− .stdout(Stdio::inherit())
543− .stderr(Stdio::inherit())
544− .status();
545−
546− match status {
547− Ok(status) if status.success() => {
548− ssh_connected_for_thread.store(false, Ordering::SeqCst);
549− eprintln!("[vibebox] ssh exited with status: {status}");
550− if let Some(tx) = input_tx_holder_for_thread.lock().unwrap().clone() {
551− let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
552− }
553− break;
554− }
555− Ok(status) if status.code() == Some(255) => {
556− eprintln!("[vibebox] ssh connection failed: {status}");
557− if attempts >= SSH_CONNECT_RETRIES {
558− ssh_connected_for_thread.store(false, Ordering::SeqCst);
559− eprintln!(
560− "[vibebox] ssh failed after {SSH_CONNECT_RETRIES} attempts"
561− );
562− break;
563− }
564− thread::sleep(std::time::Duration::from_millis(500));
565− continue;
566− }
567− Ok(status) => {
568− ssh_connected_for_thread.store(false, Ordering::SeqCst);
569− eprintln!("[vibebox] ssh exited with status: {status}");
570− if let Some(tx) = input_tx_holder_for_thread.lock().unwrap().clone() {
571− let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
572− }
573− break;
574− }
575− Err(err) => {
576− ssh_connected_for_thread.store(false, Ordering::SeqCst);
577− eprintln!("[vibebox] failed to start ssh: {err}");
578− break;
579− }
580− }
581− }
582− break;
583− }
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+ }
584565
585− thread::sleep(std::time::Duration::from_millis(200));
586− }
587− });
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+ }
588572
589− io_ctx
573+ #[test]
574+ fn handle_manager_line_surfaces_error_status() {
575+ let mut last_status = None;
576+ let err = handle_manager_line("status: error: vm failed to boot", &mut last_status)
577+ .expect_err("error status should fail");
578+ assert_eq!(err.to_string(), "vm failed to boot");
579+ }
590580}
src/lib.rs+3
@@ -1,3 +1,5 @@
1+pub mod commands;
2+pub mod explain;
13pub mod instance;
24pub mod session_manager;
35pub mod tui;
@@ -6,3 +8,4 @@ pub mod vm_manager;
68
79pub use session_manager::{SessionError, SessionManager, SessionRecord};
810pub mod config;
11+pub mod utils;
src/provision.sh+55−5
@@ -1,12 +1,55 @@
11#!/bin/bash
2−set -eux
2+set -eEux
3+
4+trap 'rc=$?; echo "[vibebox][error] provisioning failed at: ${BASH_COMMAND} (exit ${rc})"; printf "%s%s\n" VIBEBOX_PROVISION_ FAILED; systemctl poweroff || true; exit 1' ERR
5+
6+# Wait for network + DNS before apt-get to avoid early boot flakiness.
7+wait_for_network() {
8+ echo "[vibebox] waiting for network/DNS readiness"
9+ local deadline=$((SECONDS + 180))
10+ while [ "$SECONDS" -lt "$deadline" ]; do
11+ local has_route=0
12+ if ip -4 route show default >/dev/null 2>&1; then
13+ has_route=1
14+ elif ip -6 route show default >/dev/null 2>&1; then
15+ has_route=1
16+ fi
17+ if [ "$has_route" -eq 1 ]; then
18+ if getent hosts deb.debian.org >/dev/null 2>&1; then
19+ return 0
20+ fi
21+ fi
22+ sleep 1
23+ done
24+ echo "[vibebox][warn] network/DNS still not ready after 180s; continuing" >&2
25+ echo "[vibebox][warn] /etc/resolv.conf:" >&2
26+ cat /etc/resolv.conf >&2 || true
27+ ip -br addr >&2 || true
28+ ip route >&2 || true
29+ ip -6 route >&2 || true
30+ return 0
31+}
32+
33+apt_update_with_retries() {
34+ local attempt=1
35+ while [ "$attempt" -le 5 ]; do
36+ if apt-get update; then
37+ return 0
38+ fi
39+ echo "[vibebox][warn] apt-get update failed (attempt ${attempt}/5); retrying..." >&2
40+ attempt=$((attempt + 1))
41+ sleep 2
42+ done
43+ return 1
44+}
345
446# Don't wait too long for slow mirrors.
5−echo 'Acquire::http::Timeout "2";' | tee /etc/apt/apt.conf.d/99timeout
6−echo 'Acquire::https::Timeout "2";' | tee -a /etc/apt/apt.conf.d/99timeout
7−echo 'Acquire::Retries "2";' | tee -a /etc/apt/apt.conf.d/99timeout
47+echo 'Acquire::http::Timeout "10";' | tee /etc/apt/apt.conf.d/99timeout
48+echo 'Acquire::https::Timeout "10";' | tee -a /etc/apt/apt.conf.d/99timeout
49+echo 'Acquire::Retries "5";' | tee -a /etc/apt/apt.conf.d/99timeout
850
9−apt-get update
51+wait_for_network
52+apt_update_with_retries
1053apt-get install -y --no-install-recommends \
1154 build-essential \
1255 pkg-config \
@@ -14,6 +57,7 @@ apt-get install -y --no-install-recommends \
1457 curl \
1558 git \
1659 ripgrep \
60+ cloud-guest-utils \
1761 openssh-server \
1862 sudo
1963
@@ -42,6 +86,11 @@ systemctl restart ssh
4286# Set this env var so claude doesn't complain about running as root.'
4387echo "export IS_SANDBOX=1" >> .bashrc
4488
89+# Ensure cloned instances generate unique machine-id on first boot.
90+truncate -s 0 /etc/machine-id
91+rm -f /var/lib/dbus/machine-id
92+ln -sf /etc/machine-id /var/lib/dbus/machine-id
93+
4594# Shutdown the VM when you logout
4695cat > .bash_logout <<EOF
4796systemctl poweroff
@@ -49,4 +98,5 @@ sleep 100 # sleep here so that we don't see the login screen flash up before the
4998EOF
5099
51100# Done provisioning, power off the VM
101+printf "%s%s\n" VIBEBOX_PROVISION_ OK
52102systemctl poweroff
src/resize_disk.shnew file+55
@@ -0,0 +1,55 @@
1+#!/bin/bash
2+set -euo pipefail
3+
4+ROOT_DEV="$(findmnt / -n -o SOURCE || true)"
5+ROOT_FSTYPE="$(findmnt / -n -o FSTYPE || true)"
6+
7+if [ -z "$ROOT_DEV" ]; then
8+ exit 0
9+fi
10+
11+DISK_DEV=""
12+PART_NUM=""
13+
14+if command -v lsblk >/dev/null 2>&1; then
15+ DISK_DEV="$(lsblk -no pkname "$ROOT_DEV" 2>/dev/null | head -n1 || true)"
16+ PART_NUM="$(lsblk -no PARTNUM "$ROOT_DEV" 2>/dev/null | head -n1 || true)"
17+fi
18+
19+if [ -z "$DISK_DEV" ] || [ -z "$PART_NUM" ]; then
20+ ROOT_BASENAME="$(basename "$ROOT_DEV")"
21+ if echo "$ROOT_BASENAME" | grep -Eq '^nvme.+p[0-9]+$'; then
22+ DISK_DEV="/dev/${ROOT_BASENAME%p[0-9]*}"
23+ PART_NUM="${ROOT_BASENAME##*p}"
24+ elif echo "$ROOT_BASENAME" | grep -Eq '^[a-z]+[0-9]+$'; then
25+ DISK_DEV="/dev/${ROOT_BASENAME%%[0-9]*}"
26+ PART_NUM="${ROOT_BASENAME##*[a-z]}"
27+ fi
28+fi
29+
30+if [ -n "$DISK_DEV" ] && [ -n "$PART_NUM" ]; then
31+ if command -v growpart >/dev/null 2>&1; then
32+ growpart "$DISK_DEV" "$PART_NUM" || true
33+ elif command -v sfdisk >/dev/null 2>&1; then
34+ sfdisk -N "$PART_NUM" --force "$DISK_DEV" <<'EOF' || true
35+,,
36+EOF
37+ fi
38+fi
39+
40+if command -v partprobe >/dev/null 2>&1; then
41+ partprobe "$DISK_DEV" || true
42+fi
43+
44+case "$ROOT_FSTYPE" in
45+ ext4|ext3|ext2)
46+ if command -v resize2fs >/dev/null 2>&1; then
47+ resize2fs "$ROOT_DEV" || true
48+ fi
49+ ;;
50+ xfs)
51+ if command -v xfs_growfs >/dev/null 2>&1; then
52+ xfs_growfs / || true
53+ fi
54+ ;;
55+esac
src/session_manager.rs+266−121
@@ -1,47 +1,46 @@
1+use anyhow::Result;
12use std::{
23 env, fs,
34 io::{self, Write},
45 path::{Path, PathBuf},
56};
67
8+use crate::config::config_path;
9+use crate::instance::{VmLiveness, read_instance_config, vm_liveness};
710use serde::{Deserialize, Serialize};
811
912pub const INSTANCE_DIR_NAME: &str = ".vibebox";
1013pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
1114pub const GLOBAL_DIR_NAME: &str = ".vibebox";
12−pub const GLOBAL_SESSION_FILENAME: &str = "session.toml";
13−pub const SESSION_TEMP_PREFIX: &str = "sessions";
14−pub const SESSION_TOML_SUFFIX: &str = ".toml";
15−use crate::config::CONFIG_FILENAME;
16−pub const INSTANCE_TOML_FILENAME: &str = "instance.toml";
15+const SESSION_TOML_SUFFIX: &str = ".toml";
16+pub const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
17+pub const VM_MANAGER_PID_NAME: &str = "vm.pid";
18+const SESSIONS_DIR_NAME: &str = "sessions";
1719
18−#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20+#[derive(Debug, Clone, PartialEq, Eq)]
1921pub struct SessionRecord {
2022 pub directory: PathBuf,
21− #[serde(default)]
22− pub id: Option<String>,
23− #[serde(default)]
23+ pub id: String,
2424 pub last_active: Option<String>,
25+ pub active: bool,
2526}
2627
27−#[derive(Debug, Default, Serialize, Deserialize)]
28−struct GlobalSessionIndex {
29− #[serde(default)]
30− directories: Vec<PathBuf>,
31−}
32−
33−#[derive(Debug, Default, Deserialize)]
34−struct InstanceMetadata {
35− #[serde(default)]
36− id: Option<String>,
37− #[serde(default)]
38− last_active: Option<String>,
28+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29+struct SessionEntry {
30+ pub directory: PathBuf,
31+ pub id: String,
3932}
4033
4134#[derive(Debug)]
4235pub struct SessionManager {
43− global_dir: PathBuf,
44− session_path: PathBuf,
36+ sessions_dir: PathBuf,
37+}
38+
39+#[derive(Debug, Clone, PartialEq, Eq)]
40+pub struct CleanSummary {
41+ pub instance_dir: PathBuf,
42+ pub removed_instance_dir: bool,
43+ pub removed_sessions: usize,
4544}
4645
4746#[derive(Debug, thiserror::Error)]
@@ -53,7 +52,7 @@ pub enum SessionError {
5352 #[error("Session directory does not exist: {0}")]
5453 MissingDirectory(PathBuf),
5554 #[error(transparent)]
56− Io(#[from] std::io::Error),
55+ Io(#[from] io::Error),
5756 #[error(transparent)]
5857 TomlDe(#[from] toml::de::Error),
5958 #[error(transparent)]
@@ -69,55 +68,103 @@ impl SessionManager {
6968 }
7069
7170 pub fn with_global_dir(global_dir: PathBuf) -> Self {
72− let session_path = global_dir.join(GLOBAL_SESSION_FILENAME);
73− Self {
74− global_dir,
75− session_path,
76− }
71+ let sessions_dir = global_dir.join(SESSIONS_DIR_NAME);
72+ Self { sessions_dir }
7773 }
7874
7975 pub fn index_path(&self) -> &Path {
80− &self.session_path
76+ &self.sessions_dir
8177 }
8278
8379 pub fn update_global_sessions(&self, directory: &Path) -> Result<Vec<PathBuf>, SessionError> {
8480 let directory = self.normalize_directory(directory)?;
85− let mut index = self.read_global_index()?;
86− let mut changed = false;
87−
88− let removed = prune_invalid_dirs(&mut index);
89− if removed > 0 {
90− changed = true;
91− }
92−
93− if is_vibebox_dir(&directory) && !index.directories.iter().any(|dir| dir == &directory) {
94− index.directories.push(directory);
95− changed = true;
81+ fs::create_dir_all(&self.sessions_dir)?;
82+
83+ let (mut sessions, removed) = self.prune_stale_sessions()?;
84+ let has_config = is_vibebox_dir(&directory);
85+ let mut added = false;
86+
87+ if has_config {
88+ let id = read_instance_config(&directory).map_or(None, |config| Some(config.id));
89+ if let Some(id) = id {
90+ let record = SessionEntry {
91+ directory: directory.clone(),
92+ id: id.clone(),
93+ };
94+ self.write_session_record(&record)?;
95+ if let Some(existing) = sessions.iter_mut().find(|s| s.id == id) {
96+ *existing = record;
97+ } else {
98+ sessions.push(record);
99+ }
100+ added = true;
101+ } else {
102+ tracing::warn!(
103+ directory = %directory.display(),
104+ "missing session id in instance file"
105+ );
106+ }
96107 }
97108
98− if changed {
99− self.write_global_index(&index)?;
109+ if removed > 0 || added {
110+ tracing::debug!(
111+ path = %self.sessions_dir.display(),
112+ removed,
113+ added,
114+ entries = sessions.len(),
115+ "updated global sessions"
116+ );
117+ } else {
118+ tracing::debug!(
119+ path = %self.sessions_dir.display(),
120+ entries = sessions.len(),
121+ has_config,
122+ "global sessions unchanged"
123+ );
100124 }
101125
102− Ok(index.directories)
126+ Ok(sessions.into_iter().map(|s| s.directory).collect())
103127 }
104128
105129 pub fn list_sessions(&self) -> Result<Vec<SessionRecord>, SessionError> {
106− let mut index = self.read_global_index()?;
107− let removed = prune_invalid_dirs(&mut index);
130+ let (sessions, removed) = self.prune_stale_sessions()?;
108131 if removed > 0 {
109− self.write_global_index(&index)?;
132+ tracing::info!(
133+ path = %self.sessions_dir.display(),
134+ removed,
135+ entries = sessions.len(),
136+ "pruned stale sessions"
137+ );
110138 }
111− let mut sessions = Vec::with_capacity(index.directories.len());
112− for directory in index.directories {
113− let meta = read_instance_metadata(&directory)?;
114− sessions.push(SessionRecord {
115− directory,
116− id: meta.id,
117− last_active: meta.last_active,
139+ let mut records = Vec::with_capacity(sessions.len());
140+ for session in sessions {
141+ let last_active =
142+ read_instance_config(&session.directory).map_or(None, |option| option.last_active);
143+ let active = is_session_active(&session.directory);
144+ records.push(SessionRecord {
145+ directory: session.directory,
146+ id: session.id,
147+ last_active,
148+ active,
118149 });
119150 }
120− Ok(sessions)
151+ Ok(records)
152+ }
153+
154+ pub fn clean_project(&self, directory: &Path) -> Result<CleanSummary, SessionError> {
155+ let directory = self.normalize_directory(directory)?;
156+ let instance_dir = directory.join(INSTANCE_DIR_NAME);
157+ let mut removed_instance_dir = false;
158+ if instance_dir.exists() {
159+ fs::remove_dir_all(&instance_dir)?;
160+ removed_instance_dir = true;
161+ }
162+ let removed_sessions = self.remove_session_records_for_directory(&directory)?;
163+ Ok(CleanSummary {
164+ instance_dir,
165+ removed_instance_dir,
166+ removed_sessions,
167+ })
121168 }
122169
123170 fn normalize_directory(&self, directory: &Path) -> Result<PathBuf, SessionError> {
@@ -130,55 +177,117 @@ impl SessionManager {
130177 Ok(directory.canonicalize()?)
131178 }
132179
133− fn read_global_index(&self) -> Result<GlobalSessionIndex, SessionError> {
134− if !self.session_path.exists() {
135− return Ok(GlobalSessionIndex::default());
136− }
137− let content = fs::read_to_string(&self.session_path)?;
138− Ok(toml::from_str(&content)?)
180+ fn session_path_for(&self, id: &str) -> PathBuf {
181+ let filename = format!("{id}{SESSION_TOML_SUFFIX}");
182+ self.sessions_dir.join(filename)
139183 }
140184
141− fn write_global_index(&self, index: &GlobalSessionIndex) -> Result<(), SessionError> {
142− fs::create_dir_all(&self.global_dir)?;
143− let content = toml::to_string_pretty(index)?;
144− atomic_write(&self.session_path, content.as_bytes())?;
185+ fn write_session_record(&self, record: &SessionEntry) -> Result<(), SessionError> {
186+ fs::create_dir_all(&self.sessions_dir)?;
187+ let path = self.session_path_for(&record.id);
188+ let content = toml::to_string_pretty(record)?;
189+ atomic_write(&path, content.as_bytes())?;
190+ tracing::debug!(
191+ path = %path.display(),
192+ "wrote session record"
193+ );
145194 Ok(())
146195 }
196+
197+ fn prune_stale_sessions(&self) -> Result<(Vec<SessionEntry>, usize), SessionError> {
198+ if !self.sessions_dir.exists() {
199+ return Ok((Vec::new(), 0));
200+ }
201+
202+ let mut sessions = Vec::new();
203+ let mut removed = 0usize;
204+
205+ for entry in fs::read_dir(&self.sessions_dir)? {
206+ let entry = entry?;
207+ let path = entry.path();
208+ if !path.is_file() {
209+ continue;
210+ }
211+ let record = read_session_file(&path)?;
212+ if !is_vibebox_dir(&record.directory) {
213+ let _ = fs::remove_file(&path);
214+ removed += 1;
215+ continue;
216+ }
217+ if let Some(stem) = path.file_stem().and_then(|s| s.to_str())
218+ && stem != record.id
219+ {
220+ let _ = fs::remove_file(&path);
221+ removed += 1;
222+ continue;
223+ }
224+ sessions.push(record);
225+ }
226+
227+ Ok((sessions, removed))
228+ }
229+
230+ fn remove_session_records_for_directory(
231+ &self,
232+ directory: &Path,
233+ ) -> Result<usize, SessionError> {
234+ if !self.sessions_dir.exists() {
235+ return Ok(0);
236+ }
237+ let mut removed = 0usize;
238+ for entry in fs::read_dir(&self.sessions_dir)? {
239+ let entry = entry?;
240+ let path = entry.path();
241+ if !path.is_file() {
242+ continue;
243+ }
244+ let record = read_session_file(&path)?;
245+ if record.directory == directory {
246+ fs::remove_file(&path)?;
247+ removed += 1;
248+ }
249+ }
250+ if removed > 0 {
251+ tracing::info!(
252+ directory = %directory.display(),
253+ removed,
254+ "removed session records"
255+ );
256+ }
257+ Ok(removed)
258+ }
147259}
148260
149261fn is_vibebox_dir(directory: &Path) -> bool {
150262 if !directory.is_absolute() {
151263 return false;
152264 }
153− directory.join(CONFIG_FILENAME).is_file()
265+ config_path(directory).is_file()
154266}
155267
156−fn prune_invalid_dirs(index: &mut GlobalSessionIndex) -> usize {
157− let before = index.directories.len();
158− index.directories.retain(|dir| is_vibebox_dir(dir));
159− before - index.directories.len()
268+fn is_session_active(directory: &Path) -> bool {
269+ let instance_dir = directory.join(INSTANCE_DIR_NAME);
270+ let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
271+ match vm_liveness(directory) {
272+ Ok(liveness) => match liveness {
273+ VmLiveness::RunningWithSocket { .. } => true,
274+ VmLiveness::RunningWithoutSocket { .. } => true,
275+ VmLiveness::NotRunningOrMissing => {
276+ let _ = fs::remove_file(&pid_path);
277+ false
278+ }
279+ },
280+ Err(_) => false,
281+ }
160282}
161283
162−fn read_instance_metadata(directory: &Path) -> Result<InstanceMetadata, SessionError> {
163− let instance_path = directory
164− .join(INSTANCE_DIR_NAME)
165− .join(INSTANCE_TOML_FILENAME);
166− if !instance_path.exists() {
167− return Ok(InstanceMetadata::default());
284+fn read_session_file(path: &Path) -> Result<SessionEntry, SessionError> {
285+ let raw = fs::read_to_string(path)?;
286+ let record: SessionEntry = toml::from_str(&raw)?;
287+ if record.id.trim().is_empty() {
288+ return Err(io::Error::new(io::ErrorKind::InvalidData, "session id missing").into());
168289 }
169− let raw = fs::read_to_string(&instance_path)?;
170− let mut meta: InstanceMetadata = toml::from_str(&raw)?;
171− if let Some(id) = &meta.id {
172− if id.trim().is_empty() {
173− meta.id = None;
174− }
175− }
176− if let Some(last_active) = &meta.last_active {
177− if last_active.trim().is_empty() {
178− meta.last_active = None;
179− }
180− }
181− Ok(meta)
290+ Ok(record)
182291}
183292
184293fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
@@ -191,7 +300,7 @@ fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
191300
192301 fs::create_dir_all(parent)?;
193302 let mut temp = tempfile::Builder::new()
194− .prefix(SESSION_TEMP_PREFIX)
303+ .prefix(SESSIONS_DIR_NAME)
195304 .suffix(SESSION_TOML_SUFFIX)
196305 .tempfile_in(parent)?;
197306 temp.write_all(content)?;
@@ -203,6 +312,7 @@ fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
203312#[cfg(test)]
204313mod tests {
205314 use super::*;
315+ use crate::instance::{InstanceConfig, write_instance_config};
206316 use std::fs;
207317 use tempfile::TempDir;
208318
@@ -216,31 +326,42 @@ mod tests {
216326 dir
217327 }
218328
329+ fn write_instance(project_dir: &Path, id: &str, last_active: &str) -> Result<()> {
330+ fs::create_dir_all(project_dir)?;
331+
332+ let mut config = InstanceConfig::default();
333+ config.id = id.to_string();
334+ config.last_active = Some(last_active.to_string());
335+
336+ write_instance_config(project_dir, &config)
337+ }
338+
219339 #[test]
220340 fn update_global_sessions_adds_directory() {
221341 let temp = TempDir::new().unwrap();
222342 let mgr = manager(&temp);
223343 let project_dir = create_project_dir(&temp);
224− fs::write(project_dir.join(VIBEBOX_CONFIG_FILENAME), "").unwrap();
344+ fs::write(config_path(project_dir.as_path()), "").unwrap();
345+ assert!(
346+ write_instance(
347+ &project_dir,
348+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
349+ "2026-02-07T05:00:00Z",
350+ )
351+ .is_ok()
352+ );
225353
226354 let dirs = mgr.update_global_sessions(&project_dir).unwrap();
227355
228356 assert_eq!(dirs.len(), 1);
229357 assert_eq!(dirs[0], project_dir.canonicalize().unwrap());
230358 assert!(mgr.index_path().exists());
231− }
232−
233− #[test]
234− fn invalid_toml_returns_error() {
235− let temp = TempDir::new().unwrap();
236− let mgr = manager(&temp);
237− let index_path = mgr.index_path();
238− fs::create_dir_all(index_path.parent().unwrap()).unwrap();
239− fs::write(index_path, "this is not toml").unwrap();
240−
241− let err = mgr.list_sessions().unwrap_err();
242359
243− assert!(matches!(err, SessionError::TomlDe(_)));
360+ let session_path = mgr.index_path().join(format!(
361+ "019bf290-cccc-7c23-ba1d-dce7e6d40693{}",
362+ SESSION_TOML_SUFFIX
363+ ));
364+ assert!(session_path.exists());
244365 }
245366
246367 #[test]
@@ -248,38 +369,62 @@ mod tests {
248369 let temp = TempDir::new().unwrap();
249370 let mgr = manager(&temp);
250371 let project_dir = create_project_dir(&temp);
251− fs::write(project_dir.join(VIBEBOX_CONFIG_FILENAME), "").unwrap();
252−
372+ fs::write(config_path(project_dir.as_path()), "").unwrap();
373+ assert!(
374+ write_instance(
375+ &project_dir,
376+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
377+ "2026-02-07T05:00:00Z",
378+ )
379+ .is_ok()
380+ );
253381 let _ = mgr.update_global_sessions(&project_dir).unwrap();
254− fs::remove_file(project_dir.join(VIBEBOX_CONFIG_FILENAME)).unwrap();
255382
256− let dirs = mgr.update_global_sessions(&project_dir).unwrap();
257− assert!(dirs.is_empty());
383+ fs::remove_file(config_path(project_dir.as_path())).unwrap();
384+ let sessions = mgr.list_sessions().unwrap();
385+ assert!(sessions.is_empty());
386+
387+ let session_path = mgr.index_path().join(format!(
388+ "019bf290-cccc-7c23-ba1d-dce7e6d40693{}",
389+ SESSION_TOML_SUFFIX
390+ ));
391+ assert!(!session_path.exists());
258392 }
259393
260394 #[test]
261− fn list_sessions_reads_instance_metadata() {
395+ fn invalid_toml_returns_error() {
262396 let temp = TempDir::new().unwrap();
263397 let mgr = manager(&temp);
264− let project_dir = create_project_dir(&temp);
265− fs::write(project_dir.join(VIBEBOX_CONFIG_FILENAME), "").unwrap();
266−
267− let instance_dir = project_dir.join(INSTANCE_DIR_NAME);
268− fs::create_dir_all(&instance_dir).unwrap();
398+ fs::create_dir_all(mgr.index_path()).unwrap();
269399 fs::write(
270− instance_dir.join(INSTANCE_TOML_FILENAME),
271− "id = \"019bf290-cccc-7c23-ba1d-dce7e6d40693\"\nlast_active = \"2026-02-07T05:00:00Z\"\n",
400+ mgr.index_path().join(format!("bad{SESSION_TOML_SUFFIX}")),
401+ "not toml",
272402 )
273403 .unwrap();
274404
405+ let err = mgr.list_sessions().unwrap_err();
406+ assert!(matches!(err, SessionError::TomlDe(_)));
407+ }
408+
409+ #[test]
410+ fn list_sessions_reads_session_files() {
411+ let temp = TempDir::new().unwrap();
412+ let mgr = manager(&temp);
413+ let project_dir = create_project_dir(&temp);
414+ fs::write(config_path(project_dir.as_path()), "").unwrap();
415+ assert!(
416+ write_instance(
417+ &project_dir,
418+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
419+ "2026-02-07T05:00:00Z",
420+ )
421+ .is_ok()
422+ );
275423 let _ = mgr.update_global_sessions(&project_dir).unwrap();
276− let sessions = mgr.list_sessions().unwrap();
277424
425+ let sessions = mgr.list_sessions().unwrap();
278426 assert_eq!(sessions.len(), 1);
279− assert_eq!(
280− sessions[0].id.as_deref(),
281− Some("019bf290-cccc-7c23-ba1d-dce7e6d40693")
282− );
427+ assert_eq!(sessions[0].id, "019bf290-cccc-7c23-ba1d-dce7e6d40693");
283428 assert_eq!(
284429 sessions[0].last_active.as_deref(),
285430 Some("2026-02-07T05:00:00Z")
src/ssh.sh+78−24
@@ -2,7 +2,9 @@
22set -eu
33
44SSH_USER="__SSH_USER__"
5+SUDO_PASSWORD="__SUDO_PASSWORD__"
56PROJECT_NAME="__PROJECT_NAME__"
7+PROJECT_GUEST_DIR="__PROJECT_GUEST_DIR__"
68KEY_PATH="__KEY_PATH__"
79
810diag() { echo "[vibebox][diag] $*" >&2; }
@@ -49,7 +51,7 @@ dump_diag() {
4951}
5052
5153# 1) tmpfs mount
52−TARGET="/root/${PROJECT_NAME}/.vibebox"
54+TARGET="${PROJECT_GUEST_DIR}/.vibebox"
5355if [ -d "$TARGET" ] && ! mountpoint -q "$TARGET"; then
5456 mount -t tmpfs tmpfs "$TARGET"
5557fi
@@ -60,35 +62,69 @@ if ! id -u "$SSH_USER" >/dev/null 2>&1; then
6062 usermod -aG sudo "$SSH_USER" || true
6163fi
6264
65+if [ -n "$SUDO_PASSWORD" ]; then
66+ echo "${SSH_USER}:${SUDO_PASSWORD}" | chpasswd
67+fi
68+
6369install -d -m 700 -o "$SSH_USER" -g "$SSH_USER" "/home/${SSH_USER}/.ssh"
6470install -m 600 -o "$SSH_USER" -g "$SSH_USER" "$KEY_PATH" "/home/${SSH_USER}/.ssh/authorized_keys"
6571
66−# Ensure codex/claude are visible in the user's HOME
6772USER_HOME="$(getent passwd "$SSH_USER" | cut -d: -f6 2>/dev/null || true)"
6873if [ -z "$USER_HOME" ]; then
6974 USER_HOME="/home/${SSH_USER}"
7075fi
71−install -d -m 755 /usr/local/codex /usr/local/claude
72−if [ ! -e "${USER_HOME}/.codex" ]; then
73− ln -s /usr/local/codex "${USER_HOME}/.codex"
74−fi
75−if [ ! -e "${USER_HOME}/.claude" ]; then
76− ln -s /usr/local/claude "${USER_HOME}/.claude"
76+
77+# Home mount links (config-driven)
78+__VIBEBOX_HOME_LINKS__
79+
80+# Vibebox shell commands
81+install -d -m 755 /etc/profile.d
82+cat > /etc/profile.d/vibebox.sh <<'VIBEBOX_SHELL_EOF'
83+__VIBEBOX_SHELL_SCRIPT__
84+VIBEBOX_SHELL_EOF
85+chmod 644 /etc/profile.d/vibebox.sh
86+
87+# Auto-cd into project for interactive shells
88+cat > /etc/profile.d/vibebox-project.sh <<'VIBEBOX_PROJECT_EOF'
89+case "$-" in
90+ *i*)
91+ project_home="${HOME}/__PROJECT_NAME__"
92+ if [ "$USER" = "__SSH_USER__" ] && [ -d "$project_home" ]; then
93+ cd "$project_home"
94+ elif [ "$USER" = "__SSH_USER__" ] && [ -d "__PROJECT_GUEST_DIR__" ]; then
95+ cd "__PROJECT_GUEST_DIR__"
96+ fi
97+ ;;
98+esac
99+VIBEBOX_PROJECT_EOF
100+chmod 644 /etc/profile.d/vibebox-project.sh
101+
102+if ! grep -q "vibebox-aliases" "${USER_HOME}/.bashrc" 2>/dev/null; then
103+ {
104+ echo ""
105+ echo "# vibebox-aliases"
106+ echo ". /etc/profile.d/vibebox.sh"
107+ } >> "${USER_HOME}/.bashrc"
77108fi
78−chown -h "${SSH_USER}:${SSH_USER}" "${USER_HOME}/.codex" "${USER_HOME}/.claude" 2>/dev/null || true
79109
80110# Install Mise
81111MISE_BIN="${USER_HOME}/.local/bin/mise"
82−if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
83− curl https://mise.run | HOME="$USER_HOME" sh
84−fi
85−echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
112+mise_warn() { echo "[mise] $*" >&2; }
113+mise_ok() { command -v mise >/dev/null 2>&1 || [ -x "$MISE_BIN" ]; }
114+mise_install() {
115+ if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
116+ if ! curl https://mise.run | HOME="$USER_HOME" sh; then
117+ mise_warn "mise install script failed"
118+ return 1
119+ fi
120+ fi
121+ echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
86122
87−export PATH="${USER_HOME}/.local/bin:/usr/local/bin:$PATH"
123+ export PATH="${USER_HOME}/.local/bin:/usr/local/bin:$PATH"
88124
89−mkdir -p "${USER_HOME}/.config/mise"
125+ mkdir -p "${USER_HOME}/.config/mise"
90126
91−cat > "${USER_HOME}/.config/mise/config.toml" <<MISE
127+ cat > "${USER_HOME}/.config/mise/config.toml" <<MISE
92128 [settings]
93129 # Always use the venv created by uv, if available in directory
94130 python.uv_venv_auto = true
@@ -102,11 +138,23 @@ cat > "${USER_HOME}/.config/mise/config.toml" <<MISE
102138 "npm:@anthropic-ai/claude-code" = "latest"
103139MISE
104140
105−touch "${USER_HOME}/.config/mise/mise.lock"
106−if [ -x "$MISE_BIN" ]; then
107− HOME="$USER_HOME" "$MISE_BIN" install
108−else
109− HOME="$USER_HOME" mise install
141+ touch "${USER_HOME}/.config/mise/mise.lock"
142+ if [ -x "$MISE_BIN" ]; then
143+ if ! HOME="$USER_HOME" "$MISE_BIN" install; then
144+ mise_warn "mise install failed"
145+ return 1
146+ fi
147+ else
148+ if ! HOME="$USER_HOME" mise install; then
149+ mise_warn "mise install failed"
150+ return 1
151+ fi
152+ fi
153+}
154+
155+if ! mise_install; then
156+ diag "mise installation failed"
157+ vibebox_fail "mise installation failed" 1
110158fi
111159
112160# 3) start ssh (don't swallow failures)
@@ -115,7 +163,7 @@ if ! systemctl is-active --quiet ssh; then
115163 if ! systemctl start ssh; then
116164 diag "systemctl start ssh failed"
117165 dump_diag
118− exit 1
166+ vibebox_fail "failed to start ssh service" 1
119167 fi
120168fi
121169
@@ -153,7 +201,7 @@ done
153201if [ -z "$dev" ] || [ -z "$ip" ]; then
154202 diag "no stable IPv4 on default route interface"
155203 dump_diag
156− exit 1
204+ vibebox_fail "no stable ipv4 route on default interface" 1
157205fi
158206
159207# 5) strong verify: ssh must listen externally (0.0.0.0:22 or $ip:22 or [::]:22)
@@ -172,8 +220,14 @@ done
172220if ! listens_ok; then
173221 diag "sshd not listening on 0.0.0.0:22 / ${ip}:22"
174222 dump_diag
175− exit 1
223+ vibebox_fail "sshd is not listening on the expected address" 1
176224fi
177225
226+ip a
227+ip link
228+curl -s https://api.ipify.org ; echo
229+
230+cat /etc/machine-id
231+
178232echo VIBEBOX_SSH_READY
179233echo "VIBEBOX_IPV4=$ip"
src/tui.rs+276−43
@@ -1,10 +1,4 @@
1−use std::{
2− io::{self, Write},
3− os::unix::io::OwnedFd,
4− path::PathBuf,
5− sync::{Arc, Mutex},
6−};
7−
1+use bytesize::ByteSize;
82use color_eyre::Result;
93use crossterm::{
104 cursor::{MoveTo, Show},
@@ -20,11 +14,18 @@ use ratatui::{
2014 layout::{Constraint, Direction, Layout, Rect},
2115 style::{Color, Style},
2216 text::{Line, Span, Text},
23− widgets::{Block, Borders, List, ListItem, Paragraph, Widget},
17+ widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table, Widget},
18+};
19+use std::{
20+ io::{self, Write},
21+ os::unix::io::OwnedFd,
22+ path::PathBuf,
23+ sync::{Arc, Mutex},
2424};
2525
2626use crate::vm;
2727
28+// https://patorjk.com/software/taag/#p=display&f=ANSI+Shadow&t=VIBEBOX&x=none&v=4&h=4&w=80&we=false
2829const ASCII_BANNER: [&str; 7] = [
2930 "██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
3031 "██║ ██║██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗╚██╗██╔╝",
@@ -34,12 +35,15 @@ const ASCII_BANNER: [&str; 7] = [
3435 " ╚═══╝ ╚═╝╚═════╝ ╚══════╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝",
3536 "",
3637];
38+const INFO_LINE_COUNT: u16 = 5;
3739
3840#[derive(Debug, Clone)]
3941pub struct VmInfo {
40− pub version: String,
41− pub max_memory_mb: u64,
42+ pub max_memory: ByteSize,
4243 pub cpu_cores: usize,
44+ pub max_disk: ByteSize,
45+ pub system_name: String,
46+ pub auto_shutdown_ms: u64,
4347}
4448
4549#[derive(Debug)]
@@ -50,11 +54,7 @@ pub struct AppState {
5054}
5155
5256impl AppState {
53− pub fn new(cwd: PathBuf, vm_info: VmInfo) -> Self {
54− let mut commands = VibeboxCommands::default();
55− commands.add_command(":new", "Create a new session.");
56− commands.add_command(":exit", "Exit Vibebox.");
57−
57+ pub fn new(cwd: PathBuf, vm_info: VmInfo, commands: VibeboxCommands) -> Self {
5858 Self {
5959 cwd,
6060 vm_info,
@@ -69,6 +69,10 @@ pub struct VibeboxCommands {
6969}
7070
7171impl VibeboxCommands {
72+ pub fn new_empty() -> Self {
73+ Self { items: Vec::new() }
74+ }
75+
7276 pub fn add_command(&mut self, name: impl Into<String>, description: impl Into<String>) {
7377 self.items.push(VibeboxCommand {
7478 name: name.into(),
@@ -98,6 +102,31 @@ pub struct VibeboxCommand {
98102 pub description: String,
99103}
100104
105+#[derive(Debug, Clone)]
106+pub struct SessionListRow {
107+ pub name: String,
108+ pub directory: String,
109+ pub last_active: String,
110+ pub active: String,
111+ pub id: String,
112+}
113+
114+#[derive(Debug, Clone)]
115+pub struct MountListRow {
116+ pub host: String,
117+ pub guest: String,
118+ pub mode: String,
119+ pub default_mount: String,
120+}
121+
122+#[derive(Debug, Clone)]
123+pub struct NetworkListRow {
124+ pub network_type: String,
125+ pub vm_ip: String,
126+ pub host_to_vm: String,
127+ pub vm_to_host: String,
128+}
129+
101130#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
102131struct PageLayout {
103132 header: Rect,
@@ -130,10 +159,14 @@ fn compute_page_layout(app: &AppState, width: u16) -> PageLayout {
130159fn header_height() -> u16 {
131160 let banner_height = ASCII_BANNER.len() as u16;
132161 let welcome_height = 1;
133− let info_height = 4;
162+ let info_height = info_block_height();
134163 welcome_height + banner_height + info_height
135164}
136165
166+fn info_block_height() -> u16 {
167+ INFO_LINE_COUNT.saturating_add(1)
168+}
169+
137170pub fn render_tui_once(app: &mut AppState) -> Result<()> {
138171 let (width, _) = crossterm::terminal::size()?;
139172 if width == 0 {
@@ -174,6 +207,197 @@ pub fn render_commands_component(app: &mut AppState) -> Result<()> {
174207 Ok(())
175208}
176209
210+pub fn render_sessions_table(rows: &[SessionListRow]) -> Result<()> {
211+ let (width, _) = crossterm::terminal::size()?;
212+ if width == 0 {
213+ return Ok(());
214+ }
215+
216+ let height = (rows.len() as u16).saturating_add(3);
217+ let mut buffer = Buffer::empty(Rect::new(0, 0, width, height));
218+ let area = Rect::new(0, 0, width, height);
219+
220+ let header = Row::new(vec![
221+ Cell::from("Name"),
222+ Cell::from("Last Active"),
223+ Cell::from("Active"),
224+ Cell::from("ID"),
225+ Cell::from("Directory"),
226+ ])
227+ .style(Style::default().fg(Color::Cyan));
228+
229+ let table_rows = rows.iter().map(|row| {
230+ Row::new(vec![
231+ Cell::from(row.name.clone()),
232+ Cell::from(row.last_active.clone()),
233+ Cell::from(row.active.clone()),
234+ Cell::from(row.id.clone()),
235+ Cell::from(row.directory.clone()),
236+ ])
237+ });
238+
239+ let table = Table::new(
240+ table_rows,
241+ [
242+ Constraint::Length(16),
243+ Constraint::Length(14),
244+ Constraint::Length(8),
245+ Constraint::Length(36),
246+ Constraint::Min(24),
247+ ],
248+ )
249+ .header(header)
250+ .block(Block::default().title("Sessions").borders(Borders::ALL))
251+ .column_spacing(2);
252+
253+ table.render(area, &mut buffer);
254+
255+ let mut stdout = io::stdout();
256+ execute!(stdout, Clear(ClearType::All), MoveTo(0, 0), Show)?;
257+ write_buffer_with_style(&buffer, &mut stdout)?;
258+ stdout.flush()?;
259+ Ok(())
260+}
261+
262+pub fn render_mounts_table(rows: &[MountListRow]) -> Result<()> {
263+ let (width, _) = crossterm::terminal::size()?;
264+ if width == 0 {
265+ return Ok(());
266+ }
267+
268+ let height = (rows.len() as u16).saturating_add(3);
269+ let mut buffer = Buffer::empty(Rect::new(0, 0, width, height));
270+ let area = Rect::new(0, 0, width, height);
271+
272+ render_mounts_table_into(rows, area, &mut buffer);
273+
274+ let mut stdout = io::stdout();
275+ execute!(stdout, Clear(ClearType::All), MoveTo(0, 0), Show)?;
276+ write_buffer_with_style(&buffer, &mut stdout)?;
277+ stdout.flush()?;
278+ Ok(())
279+}
280+
281+pub fn render_explain_tables(mounts: &[MountListRow], networks: &[NetworkListRow]) -> Result<()> {
282+ let (width, _) = crossterm::terminal::size()?;
283+ if width == 0 {
284+ return Ok(());
285+ }
286+
287+ let mounts_height = if mounts.is_empty() {
288+ 0
289+ } else {
290+ (mounts.len() as u16).saturating_add(3)
291+ };
292+ let networks_height = if networks.is_empty() {
293+ 0
294+ } else {
295+ (networks.len() as u16).saturating_add(3)
296+ };
297+ let gap = if mounts_height > 0 && networks_height > 0 {
298+ 1
299+ } else {
300+ 0
301+ };
302+ let total_height = mounts_height
303+ .saturating_add(gap)
304+ .saturating_add(networks_height);
305+ if total_height == 0 {
306+ return Ok(());
307+ }
308+
309+ let mut buffer = Buffer::empty(Rect::new(0, 0, width, total_height));
310+ let mut y = 0u16;
311+
312+ if mounts_height > 0 {
313+ let area = Rect::new(0, y, width, mounts_height);
314+ render_mounts_table_into(mounts, area, &mut buffer);
315+ y = y.saturating_add(mounts_height).saturating_add(gap);
316+ }
317+
318+ if networks_height > 0 {
319+ let area = Rect::new(0, y, width, networks_height);
320+ render_networks_table_into(networks, area, &mut buffer);
321+ }
322+
323+ let mut stdout = io::stdout();
324+ execute!(stdout, Clear(ClearType::All), MoveTo(0, 0), Show)?;
325+ write_buffer_with_style(&buffer, &mut stdout)?;
326+ stdout.flush()?;
327+ Ok(())
328+}
329+
330+fn render_mounts_table_into(rows: &[MountListRow], area: Rect, buffer: &mut Buffer) {
331+ let header = Row::new(vec![
332+ Cell::from("Host"),
333+ Cell::from("Guest"),
334+ Cell::from("Mode"),
335+ Cell::from(""),
336+ Cell::from("Default"),
337+ ])
338+ .style(Style::default().fg(Color::Cyan));
339+
340+ let table_rows = rows.iter().map(|row| {
341+ Row::new(vec![
342+ Cell::from(row.host.clone()),
343+ Cell::from(row.guest.clone()),
344+ Cell::from(row.mode.clone()),
345+ Cell::from(""),
346+ Cell::from(row.default_mount.clone()),
347+ ])
348+ });
349+
350+ let table = Table::new(
351+ table_rows,
352+ [
353+ Constraint::Min(24),
354+ Constraint::Min(24),
355+ Constraint::Length(10),
356+ Constraint::Length(1),
357+ Constraint::Length(8),
358+ ],
359+ )
360+ .header(header)
361+ .block(Block::default().title("Mounts").borders(Borders::ALL))
362+ .column_spacing(1);
363+
364+ table.render(area, buffer);
365+}
366+
367+fn render_networks_table_into(rows: &[NetworkListRow], area: Rect, buffer: &mut Buffer) {
368+ let header = Row::new(vec![
369+ Cell::from("Type"),
370+ Cell::from("VM IP"),
371+ Cell::from("Host \u{2192} VM"),
372+ Cell::from("VM \u{2192} Host"),
373+ ])
374+ .style(Style::default().fg(Color::Cyan));
375+
376+ let table_rows = rows.iter().map(|row| {
377+ Row::new(vec![
378+ Cell::from(row.network_type.clone()),
379+ Cell::from(row.vm_ip.clone()),
380+ Cell::from(row.host_to_vm.clone()),
381+ Cell::from(row.vm_to_host.clone()),
382+ ])
383+ });
384+
385+ let table = Table::new(
386+ table_rows,
387+ [
388+ Constraint::Length(8),
389+ Constraint::Length(16),
390+ Constraint::Min(24),
391+ Constraint::Min(20),
392+ ],
393+ )
394+ .header(header)
395+ .block(Block::default().title("Network").borders(Borders::ALL))
396+ .column_spacing(1);
397+
398+ table.render(area, buffer);
399+}
400+
177401pub fn passthrough_vm_io(
178402 app: Arc<Mutex<AppState>>,
179403 output_monitor: Arc<vm::OutputMonitor>,
@@ -250,27 +474,27 @@ fn write_buffer_with_style(buffer: &Buffer, out: &mut impl Write) -> io::Result<
250474 Ok(())
251475}
252476
253−fn map_color(color: ratatui::style::Color) -> CrosstermColor {
477+fn map_color(color: Color) -> CrosstermColor {
254478 match color {
255− ratatui::style::Color::Reset => CrosstermColor::Reset,
256− ratatui::style::Color::Black => CrosstermColor::Black,
257− ratatui::style::Color::Red => CrosstermColor::DarkRed,
258− ratatui::style::Color::Green => CrosstermColor::DarkGreen,
259− ratatui::style::Color::Yellow => CrosstermColor::DarkYellow,
260− ratatui::style::Color::Blue => CrosstermColor::DarkBlue,
261− ratatui::style::Color::Magenta => CrosstermColor::DarkMagenta,
262− ratatui::style::Color::Cyan => CrosstermColor::DarkCyan,
263− ratatui::style::Color::Gray => CrosstermColor::Grey,
264− ratatui::style::Color::DarkGray => CrosstermColor::DarkGrey,
265− ratatui::style::Color::LightRed => CrosstermColor::Red,
266− ratatui::style::Color::LightGreen => CrosstermColor::Green,
267− ratatui::style::Color::LightYellow => CrosstermColor::Yellow,
268− ratatui::style::Color::LightBlue => CrosstermColor::Blue,
269− ratatui::style::Color::LightMagenta => CrosstermColor::Magenta,
270− ratatui::style::Color::LightCyan => CrosstermColor::Cyan,
271− ratatui::style::Color::White => CrosstermColor::White,
272− ratatui::style::Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
273− ratatui::style::Color::Indexed(i) => CrosstermColor::AnsiValue(i),
479+ Color::Reset => CrosstermColor::Reset,
480+ Color::Black => CrosstermColor::Black,
481+ Color::Red => CrosstermColor::DarkRed,
482+ Color::Green => CrosstermColor::DarkGreen,
483+ Color::Yellow => CrosstermColor::DarkYellow,
484+ Color::Blue => CrosstermColor::DarkBlue,
485+ Color::Magenta => CrosstermColor::DarkMagenta,
486+ Color::Cyan => CrosstermColor::DarkCyan,
487+ Color::Gray => CrosstermColor::Grey,
488+ Color::DarkGray => CrosstermColor::DarkGrey,
489+ Color::LightRed => CrosstermColor::Red,
490+ Color::LightGreen => CrosstermColor::Green,
491+ Color::LightYellow => CrosstermColor::Yellow,
492+ Color::LightBlue => CrosstermColor::Blue,
493+ Color::LightMagenta => CrosstermColor::Magenta,
494+ Color::LightCyan => CrosstermColor::Cyan,
495+ Color::White => CrosstermColor::White,
496+ Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
497+ Color::Indexed(i) => CrosstermColor::AnsiValue(i),
274498 }
275499}
276500
@@ -316,13 +540,15 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
316540 .constraints([
317541 Constraint::Length(1),
318542 Constraint::Length(ASCII_BANNER.len() as u16),
319− Constraint::Length(4),
543+ Constraint::Length(info_block_height()),
320544 ])
321545 .split(area);
322546
547+ let version = env!("CARGO_PKG_VERSION");
548+
323549 let welcome = Line::from(vec![
324550 Span::raw("Welcome to Vibebox v"),
325− Span::styled(&app.vm_info.version, Style::default().fg(Color::Yellow)),
551+ Span::styled(version, Style::default().fg(Color::Yellow)),
326552 ]);
327553
328554 Paragraph::new(welcome).render(header_chunks[0], buffer);
@@ -341,19 +567,26 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
341567 Span::styled(app.cwd.to_string_lossy(), Style::default().fg(Color::Cyan)),
342568 ]),
343569 Line::from(vec![
344− Span::raw("VM Version: "),
345− Span::styled(&app.vm_info.version, Style::default().fg(Color::Green)),
570+ Span::raw("System: "),
571+ Span::styled(&app.vm_info.system_name, Style::default().fg(Color::Green)),
346572 ]),
347573 Line::from(vec![
348− Span::raw("CPU / Memory: "),
574+ Span::raw("CPU / Memory / Disk: "),
349575 Span::styled(
350576 format!(
351− "{} cores / {} MB",
352− app.vm_info.cpu_cores, app.vm_info.max_memory_mb
577+ "{} cores / {} / {}",
578+ app.vm_info.cpu_cores, app.vm_info.max_memory, app.vm_info.max_disk
353579 ),
354580 Style::default().fg(Color::Green),
355581 ),
356582 ]),
583+ Line::from(vec![
584+ Span::raw("Auto Shutdown: "),
585+ Span::styled(
586+ format!("{} ms", app.vm_info.auto_shutdown_ms),
587+ Style::default().fg(Color::Green),
588+ ),
589+ ]),
357590 ];
358591
359592 Paragraph::new(info_lines)
src/utils.rsnew file+18
@@ -0,0 +1,18 @@
1+use std::{
2+ env,
3+ path::{Path, PathBuf},
4+};
5+
6+pub fn relative_to_home(directory: &Path) -> String {
7+ let Ok(home) = env::var("HOME") else {
8+ return directory.display().to_string();
9+ };
10+ let home_path = PathBuf::from(home);
11+ if let Ok(stripped) = directory.strip_prefix(&home_path) {
12+ if stripped.components().next().is_none() {
13+ return "~".to_string();
14+ }
15+ return format!("~/{}", stripped.display());
16+ }
17+ directory.display().to_string()
18+}
src/vm.rs+374−329
@@ -1,8 +1,7 @@
11use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
2+use anyhow::{Context, Error, Result, bail};
23use std::{
3− env,
4− ffi::OsString,
5− fs,
4+ env, fs,
65 io::{self, Write},
76 os::{
87 fd::RawFd,
@@ -24,8 +23,8 @@ use std::{
2423};
2524
2625use block2::RcBlock;
26+use bytesize::ByteSize;
2727use dispatch2::DispatchQueue;
28−use lexopt::prelude::*;
2928use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
3029use objc2_foundation::*;
3130use objc2_virtualization::*;
@@ -34,39 +33,46 @@ const DEBIAN_COMPRESSED_DISK_URL: &str = "https://cloud.debian.org/images/cloud/
3433const DEBIAN_COMPRESSED_SHA: &str = "6ab9be9e6834adc975268367f2f0235251671184345c34ee13031749fdfbf66fe4c3aafd949a2d98550426090e9ac645e79009c51eb0eefc984c15786570bb38";
3534const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
3635const SHARED_DIRECTORIES_TAG: &str = "shared";
36+pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts";
3737
38−const BYTES_PER_MB: u64 = 1024 * 1024;
39−const DEFAULT_CPU_COUNT: usize = 2;
40−const DEFAULT_RAM_MB: u64 = 2048;
41−const DEFAULT_RAM_BYTES: u64 = DEFAULT_RAM_MB * BYTES_PER_MB;
4238const START_TIMEOUT: Duration = Duration::from_secs(60);
43−const DEFAULT_EXPECT_TIMEOUT: Duration = Duration::from_secs(30);
4439const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
40+const PROVISION_EXPECT_TIMEOUT: Duration = Duration::from_secs(900);
41+
42+const ERROR_REPORT_SCRIPT: &str = include_str!("error_report.sh");
4543const PROVISION_SCRIPT: &str = include_str!("provision.sh");
44+const RESIZE_DISK_SCRIPT: &str = include_str!("resize_disk.sh");
45+const DEFAULT_RAW_NAME: &str = "default.raw";
46+const INSTANCE_RAW_NAME: &str = "instance.raw";
47+const BASE_DISK_RAW_NAME: &str = "disk.raw";
4648
4749#[derive(Clone)]
48−pub(crate) enum LoginAction {
49− Expect { text: String, timeout: Duration },
50+pub enum LoginAction {
51+ Expect {
52+ text: String,
53+ timeout: Duration,
54+ },
55+ ExpectEither {
56+ success: String,
57+ failure: String,
58+ timeout: Duration,
59+ },
5060 Send(String),
51− Script { path: PathBuf, index: usize },
5261}
62+use crate::config::BoxConfig;
5363use LoginAction::*;
5464
5565#[derive(Clone)]
56−pub(crate) struct DirectoryShare {
66+pub struct DirectoryShare {
5767 host: PathBuf,
5868 guest: PathBuf,
5969 read_only: bool,
6070}
6171
6272impl DirectoryShare {
63− pub(crate) fn new(
64− host: PathBuf,
65− mut guest: PathBuf,
66− read_only: bool,
67− ) -> Result<Self, Box<dyn std::error::Error>> {
73+ pub fn new(host: PathBuf, mut guest: PathBuf, read_only: bool) -> Result<Self> {
6874 if !host.exists() {
69− return Err(format!("Host path does not exist: {}", host.display()).into());
75+ bail!(format!("host path does not exist: {}", host.display()));
7076 }
7177 if !guest.is_absolute() {
7278 guest = PathBuf::from("/root").join(guest);
@@ -78,23 +84,22 @@ impl DirectoryShare {
7884 })
7985 }
8086
81− pub(crate) fn from_mount_spec(spec: &str) -> Result<Self, Box<dyn std::error::Error>> {
87+ pub fn from_mount_spec(spec: &str) -> Result<Self> {
8288 let parts: Vec<&str> = spec.split(':').collect();
8389 if parts.len() < 2 || parts.len() > 3 {
84− return Err(format!("Invalid mount spec: {spec}").into());
90+ bail!(format!("invalid mount spec: {spec}"));
8591 }
86− let host = PathBuf::from(parts[0]);
92+ let host = expand_tilde_path(parts[0]);
8793 let guest = PathBuf::from(parts[1]);
8894 let read_only = if parts.len() == 3 {
8995 match parts[2] {
9096 "read-only" => true,
9197 "read-write" => false,
9298 _ => {
93− return Err(format!(
99+ bail!(format!(
94100 "Invalid mount mode '{}'; expected read-only or read-write",
95101 parts[2]
96− )
97− .into());
102+ ));
98103 }
99104 }
100105 } else {
@@ -117,19 +122,49 @@ impl DirectoryShare {
117122 }
118123}
119124
120−pub fn run_with_args<F>(args: CliArgs, io_handler: F) -> Result<(), Box<dyn std::error::Error>>
125+fn expand_tilde_path(value: &str) -> PathBuf {
126+ if let Some(stripped) = value.strip_prefix("~/") {
127+ if let Ok(home) = env::var("HOME") {
128+ return PathBuf::from(home).join(stripped);
129+ }
130+ } else if value == "~"
131+ && let Ok(home) = env::var("HOME")
132+ {
133+ return PathBuf::from(home);
134+ }
135+ PathBuf::from(value)
136+}
137+
138+pub struct VmArg {
139+ pub cpu_count: usize,
140+ pub ram_bytes: u64,
141+ pub disk_bytes: u64,
142+ pub no_default_mounts: bool,
143+ pub mounts: Vec<String>,
144+}
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+
154+pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<()>
121155where
122156 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
123157{
124− 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)
125159}
126160
127−pub(crate) fn run_with_args_and_extras<F>(
128− args: CliArgs,
161+pub fn run_with_args_and_extras<F>(
162+ args: VmArg,
129163 io_handler: F,
130164 extra_login_actions: Vec<LoginAction>,
131165 extra_directory_shares: Vec<DirectoryShare>,
132−) -> Result<(), Box<dyn std::error::Error>>
166+ status: Option<&StatusEmitter<'_>>,
167+) -> Result<()>
133168where
134169 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
135170{
@@ -138,7 +173,7 @@ where
138173 let project_root = env::current_dir()?;
139174 let project_name = project_root
140175 .file_name()
141− .ok_or("Project directory has no name")?
176+ .with_context(|| "Project directory has no name")?
142177 .to_string_lossy()
143178 .into_owned();
144179
@@ -150,6 +185,10 @@ where
150185 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
151186
152187 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
188+ fs::create_dir_all(&instance_dir)?;
189+ emit_status(status, "preparing VM image...");
190+ tracing::info!("preparing VM image...");
191+ let provision_log = instance_dir.join("provision.log");
153192
154193 let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
155194 let base_compressed = cache_dir.join(basename_compressed);
@@ -158,8 +197,8 @@ where
158197 basename_compressed.trim_end_matches(".tar.xz")
159198 ));
160199
161− let default_raw = cache_dir.join("default.raw");
162− let instance_raw = instance_dir.join("instance.raw");
200+ let default_raw = cache_dir.join(DEFAULT_RAW_NAME);
201+ let instance_raw = instance_dir.join(INSTANCE_RAW_NAME);
163202
164203 // Prepare system-wide directories
165204 fs::create_dir_all(&cache_dir)?;
@@ -168,28 +207,31 @@ where
168207 let mise_directory_share =
169208 DirectoryShare::new(guest_mise_cache, "/root/.local/share/mise".into(), false)?;
170209
171− let disk_path = if let Some(path) = args.disk {
172− if !path.exists() {
173− return Err(format!("Disk image does not exist: {}", path.display()).into());
174− }
175− path
176− } else {
177− ensure_default_image(
178− &base_raw,
179− &base_compressed,
180− &default_raw,
181− std::slice::from_ref(&mise_directory_share),
182− )?;
183− ensure_instance_disk(&instance_raw, &default_raw)?;
184−
185− instance_raw
186− };
210+ ensure_default_image(
211+ &base_raw,
212+ &base_compressed,
213+ &default_raw,
214+ std::slice::from_ref(&mise_directory_share),
215+ Some(&provision_log),
216+ status,
217+ )?;
218+ let _ = ensure_instance_disk(
219+ &instance_raw,
220+ &default_raw,
221+ ByteSize(args.disk_bytes),
222+ status,
223+ )?;
224+ let base_size = fs::metadata(&default_raw)?.len();
225+ let instance_size = fs::metadata(&instance_raw)?.len();
226+ let needs_resize = instance_size > base_size;
227+ let disk_path = instance_raw;
187228
188229 let mut login_actions = Vec::new();
189230 let mut directory_shares = Vec::new();
190231
191232 if !args.no_default_mounts {
192− login_actions.push(Send(format!("cd {project_name}")));
233+ let project_guest_dir = PathBuf::from(PROJECT_GUEST_BASE).join(project_name);
234+ login_actions.push(Send(format!("cd {}", project_guest_dir.display())));
193235
194236 // discourage read/write of .git folder from within the VM. note that this isn't secure, since the VM runs as root and could unmount this.
195237 // I couldn't find an alternative way to do this --- the MacOS sandbox doesn't apply to the Apple Virtualization system
@@ -197,32 +239,7 @@ where
197239 login_actions.push(Send(r"mount -t tmpfs tmpfs .git/".into()));
198240 }
199241
200− directory_shares.push(
201− DirectoryShare::new(
202− project_root,
203− PathBuf::from("/root/").join(project_name),
204− false,
205− )
206− .expect("Project directory must exist"),
207− );
208−
209242 directory_shares.push(mise_directory_share);
210−
211− // Add default shares, if they exist
212− for share in [
213− DirectoryShare::new(home.join(".codex"), "/usr/local/codex".into(), false),
214− DirectoryShare::new(home.join(".claude"), "/usr/local/claude".into(), false),
215− DirectoryShare::new(
216− "/Users/zhangjie/Documents/Code/CompletePrograms/vibebox/.ssh".into(),
217− "/usr/local/ssh".into(),
218− true,
219− ),
220− ]
221− .into_iter()
222− .flatten()
223− {
224− directory_shares.push(share)
225− }
226243 }
227244
228245 directory_shares.extend(extra_directory_shares);
@@ -231,199 +248,46 @@ where
231248 directory_shares.push(DirectoryShare::from_mount_spec(spec)?);
232249 }
233250
251+ if needs_resize {
252+ let resize_cmd = script_command_from_content("resize_disk.sh", RESIZE_DISK_SCRIPT)?;
253+ login_actions.push(Send(resize_cmd));
254+ }
255+
234256 if let Some(motd_action) = motd_login_action(&directory_shares) {
235257 login_actions.push(motd_action);
236258 }
237259
238260 login_actions.extend(extra_login_actions);
239261
240− // Any user-provided login actions must come after our system ones
241− login_actions.extend(args.login_actions);
242−
243262 run_vm_with_io(
244263 &disk_path,
245264 &login_actions,
246265 &directory_shares[..],
247266 args.cpu_count,
248267 args.ram_bytes,
268+ status,
249269 io_handler,
250270 )
251271}
252272
253−pub fn print_help() {
254− println!(
255− "Vibe is a quick way to spin up a Linux virtual machine on Mac to sandbox LLM agents.
256−
257−vibe [OPTIONS] [disk-image.raw]
258−
259−Options
260−
261− --help Print this help message.
262− --version Print the version (commit SHA).
263− --no-default-mounts Disable all default mounts.
264− --mount host-path:guest-path[:read-only | :read-write] Mount `host-path` inside VM at `guest-path`.
265− Defaults to read-write.
266− Errors if host-path does not exist.
267− --cpus <count> Number of virtual CPUs (default {DEFAULT_CPU_COUNT}).
268− --ram <megabytes> RAM size in megabytes (default {DEFAULT_RAM_MB}).
269− --script <path/to/script.sh> Run script in VM.
270− --send <some-command> Type `some-command` followed by newline into the VM.
271− --expect <string> [timeout-seconds] Wait for `string` to appear in console output before executing next `--script` or `--send`.
272− If `string` does not appear within timeout (default 30 seconds), shutdown VM with error.
273−"
274− );
275−}
276−
277−pub fn print_version() {
278− println!("Vibe");
279− println!("https://github.com/lynaghk/vibe/");
280− println!("Git SHA: {}", env!("GIT_SHA"));
281−}
282−
283−pub struct CliArgs {
284− disk: Option<PathBuf>,
285− version: bool,
286− help: bool,
287− no_default_mounts: bool,
288− mounts: Vec<String>,
289− login_actions: Vec<LoginAction>,
290− cpu_count: usize,
291− ram_bytes: u64,
292−}
293−
294−impl CliArgs {
295− pub fn version(&self) -> bool {
296− self.version
297− }
298−
299− pub fn help(&self) -> bool {
300− self.help
301− }
302−
303− pub fn cpu_count(&self) -> usize {
304− self.cpu_count
305− }
306−
307− pub fn ram_bytes(&self) -> u64 {
308− self.ram_bytes
309− }
310−
311− pub fn ram_mb(&self) -> u64 {
312− self.ram_bytes / BYTES_PER_MB
313− }
314−}
315−
316−pub fn parse_cli() -> Result<CliArgs, Box<dyn std::error::Error>> {
317− parse_cli_from(env::args_os())
318−}
319−
320−pub fn parse_cli_from<I>(args: I) -> Result<CliArgs, Box<dyn std::error::Error>>
321−where
322− I: IntoIterator<Item = OsString>,
323−{
324− fn os_to_string(value: OsString, flag: &str) -> Result<String, Box<dyn std::error::Error>> {
325− value
326− .into_string()
327− .map_err(|_| format!("{flag} expects valid UTF-8").into())
328− }
329−
330− let mut parser = lexopt::Parser::from_iter(args);
331− let mut disk = None;
332− let mut version = false;
333− let mut help = false;
334− let mut no_default_mounts = false;
335− let mut mounts = Vec::new();
336− let mut login_actions = Vec::new();
337− let mut script_index = 0;
338− let mut cpu_count = DEFAULT_CPU_COUNT;
339− let mut ram_bytes = DEFAULT_RAM_BYTES;
340−
341− while let Some(arg) = parser.next()? {
342− match arg {
343− Long("version") => version = true,
344− Long("help") | Short('h') => help = true,
345− Long("no-default-mounts") => no_default_mounts = true,
346− Long("cpus") => {
347− let value = os_to_string(parser.value()?, "--cpus")?.parse()?;
348− if value == 0 {
349− return Err("--cpus must be >= 1".into());
350− }
351− cpu_count = value;
352− }
353− Long("ram") => {
354− let value: u64 = os_to_string(parser.value()?, "--ram")?.parse()?;
355− if value == 0 {
356− return Err("--ram must be >= 1".into());
357− }
358− ram_bytes = value * BYTES_PER_MB;
359− }
360− Long("mount") => {
361− mounts.push(os_to_string(parser.value()?, "--mount")?);
362− }
363− Long("script") => {
364− login_actions.push(Script {
365− path: os_to_string(parser.value()?, "--script")?.into(),
366− index: script_index,
367− });
368− script_index += 1;
369− }
370− Long("send") => {
371− login_actions.push(Send(os_to_string(parser.value()?, "--send")?));
372− }
373− Long("expect") => {
374− let text = os_to_string(parser.value()?, "--expect")?;
375− let timeout = match parser.optional_value() {
376− Some(value) => Duration::from_secs(os_to_string(value, "--expect")?.parse()?),
377− None => DEFAULT_EXPECT_TIMEOUT,
378− };
379− login_actions.push(Expect { text, timeout });
380− }
381− Value(value) => {
382− if disk.is_some() {
383− return Err("Only one disk path may be provided".into());
384− }
385− disk = Some(PathBuf::from(value));
386− }
387− _ => return Err(arg.unexpected().into()),
388− }
389− }
390−
391− Ok(CliArgs {
392− disk,
393− version,
394− help,
395− no_default_mounts,
396− mounts,
397− login_actions,
398− cpu_count,
399− ram_bytes,
400− })
401−}
402−
403−fn script_command_from_path(
404− path: &Path,
405− index: usize,
406−) -> Result<String, Box<dyn std::error::Error>> {
407− let script = fs::read_to_string(path)
408− .map_err(|err| format!("Failed to read script {}: {err}", path.display()))?;
409− let label = format!("{}_{}", index, path.file_name().unwrap().display());
410− script_command_from_content(&label, &script)
411−}
412−
413−pub(crate) fn script_command_from_content(
414− label: &str,
415− script: &str,
416−) -> Result<String, Box<dyn std::error::Error>> {
273+pub fn script_command_from_content(label: &str, script: &str) -> Result<String> {
417274 let marker = "VIBE_SCRIPT_EOF";
418275 let guest_dir = "/tmp/vibe-scripts";
419276 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);
420284 let command = format!(
421− "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}"
422286 );
423287 if script.contains(marker) {
424− return Err(
425− format!("Script '{label}' contains marker '{marker}', cannot safely upload").into(),
426− );
288+ bail!(format!(
289+ "Script '{label}' contains marker '{marker}', cannot safely upload"
290+ ));
427291 }
428292 Ok(command)
429293}
@@ -487,6 +351,12 @@ enum WaitResult {
487351 Found,
488352}
489353
354+#[derive(PartialEq, Eq)]
355+enum WaitAnyResult {
356+ Timeout,
357+ Found(usize),
358+}
359+
490360pub enum VmInput {
491361 Bytes(Vec<u8>),
492362 Shutdown,
@@ -494,6 +364,7 @@ pub enum VmInput {
494364
495365enum VmOutput {
496366 LoginActionTimeout { action: String, timeout: Duration },
367+ LoginActionFailed { action: String, reason: String },
497368}
498369
499370#[derive(Default)]
@@ -530,6 +401,41 @@ impl OutputMonitor {
530401 WaitResult::Found
531402 }
532403 }
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
533439}
534440
535441#[derive(Debug)]
@@ -578,15 +484,17 @@ impl IoControl {
578484fn ensure_base_image(
579485 base_raw: &Path,
580486 base_compressed: &Path,
581−) -> Result<(), Box<dyn std::error::Error>> {
487+ status: Option<&StatusEmitter<'_>>,
488+) -> Result<()> {
582489 if base_raw.exists() {
583490 return Ok(());
584491 }
585492
586493 if !base_compressed.exists()
587− || 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
588495 {
589− println!("Downloading base image...");
496+ emit_status(status, "downloading base image...");
497+ tracing::info!("downloading base image");
590498 let status = Command::new("curl")
591499 .args([
592500 "--continue-at",
@@ -600,12 +508,14 @@ fn ensure_base_image(
600508 ])
601509 .status()?;
602510 if !status.success() {
603− return Err("Failed to download base image".into());
511+ bail!("failed to download base image");
604512 }
605513 }
606514
607515 // Check SHA
608516 {
517+ emit_status(status, "verifying base image...");
518+ tracing::info!("verifying base image...");
609519 let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
610520
611521 let mut child = Command::new("/usr/bin/shasum")
@@ -623,18 +533,25 @@ fn ensure_base_image(
623533
624534 let status = child.wait().expect("failed to wait on child");
625535 if !status.success() {
626− return Err(format!("SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}").into());
536+ bail!(format!(
537+ "SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}"
538+ ));
627539 }
628540 }
629541
630− println!("Decompressing base image...");
542+ emit_status(status, "decompressing base image...");
543+ tracing::info!("decompressing base image...");
631544 let status = Command::new("tar")
632− .args(["-xOf", &base_compressed.to_string_lossy(), "disk.raw"])
633− .stdout(std::fs::File::create(base_raw)?)
545+ .args([
546+ "-xOf",
547+ &base_compressed.to_string_lossy(),
548+ BASE_DISK_RAW_NAME,
549+ ])
550+ .stdout(fs::File::create(base_raw)?)
634551 .status()?;
635552
636553 if !status.success() {
637− return Err("Failed to decompress base image".into());
554+ bail!("Failed to decompress base image");
638555 }
639556
640557 Ok(())
@@ -645,24 +562,56 @@ fn ensure_default_image(
645562 base_compressed: &Path,
646563 default_raw: &Path,
647564 directory_shares: &[DirectoryShare],
648−) -> Result<(), Box<dyn std::error::Error>> {
565+ provision_log: Option<&Path>,
566+ status: Option<&StatusEmitter<'_>>,
567+) -> Result<()> {
649568 if default_raw.exists() {
650569 return Ok(());
651570 }
652571
653− ensure_base_image(base_raw, base_compressed)?;
572+ ensure_base_image(base_raw, base_compressed, status)?;
654573
655− println!("Configuring base image...");
574+ emit_status(status, "configuring base image...");
575+ tracing::info!("configuring base image...");
656576 fs::copy(base_raw, default_raw)?;
657577
658578 let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?;
659− run_vm(
660− default_raw,
661− &[Send(provision_command)],
662− directory_shares,
663− DEFAULT_CPU_COUNT,
664− DEFAULT_RAM_BYTES,
665− )?;
579+ let provision_actions = [
580+ Send(provision_command),
581+ ExpectEither {
582+ success: "VIBEBOX_PROVISION_OK".to_string(),
583+ failure: "VIBEBOX_PROVISION_FAILED".to_string(),
584+ timeout: PROVISION_EXPECT_TIMEOUT,
585+ },
586+ ];
587+ let provision_result = if let Some(log_path) = provision_log {
588+ let log_path = log_path.to_path_buf();
589+ run_vm_with_io(
590+ default_raw,
591+ &provision_actions,
592+ directory_shares,
593+ BoxConfig::default().cpu_count,
594+ BoxConfig::default().ram_size.as_u64(),
595+ status,
596+ move |output_monitor, vm_output_fd, vm_input_fd| {
597+ spawn_vm_io_with_log(output_monitor, vm_output_fd, vm_input_fd, log_path)
598+ },
599+ )
600+ } else {
601+ run_vm(
602+ default_raw,
603+ &provision_actions,
604+ directory_shares,
605+ BoxConfig::default().cpu_count,
606+ BoxConfig::default().ram_size.as_u64(),
607+ status,
608+ )
609+ };
610+
611+ if let Err(err) = provision_result {
612+ let _ = fs::remove_file(default_raw);
613+ return Err(err);
614+ }
666615
667616 Ok(())
668617}
@@ -670,15 +619,47 @@ fn ensure_default_image(
670619fn ensure_instance_disk(
671620 instance_raw: &Path,
672621 template_raw: &Path,
673−) -> Result<(), Box<dyn std::error::Error>> {
622+ target_bytes: ByteSize,
623+ status: Option<&StatusEmitter<'_>>,
624+) -> Result<bool> {
674625 if instance_raw.exists() {
675− return Ok(());
626+ let current_size = ByteSize(fs::metadata(instance_raw)?.len());
627+ if current_size != target_bytes {
628+ let current_gb = current_size;
629+ let target_gb = target_bytes;
630+ tracing::warn!(
631+ "instance disk size does not match config (current {}, config {}); disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using existing disk.",
632+ current_gb,
633+ target_gb
634+ );
635+ }
636+ return Ok(false);
676637 }
677638
678− println!("Creating instance disk from {}...", template_raw.display());
679− std::fs::create_dir_all(instance_raw.parent().unwrap())?;
680− fs::copy(template_raw, instance_raw)?;
681− Ok(())
639+ let template_size = ByteSize(fs::metadata(template_raw)?.len());
640+ if target_bytes < template_size {
641+ bail!(format!(
642+ "Requested disk size {} is smaller than base image size {}",
643+ target_bytes, template_size
644+ ));
645+ }
646+ let target_size = target_bytes;
647+ let needs_resize = target_size > template_size;
648+
649+ emit_status(status, "creating instance disk...");
650+ tracing::info!("creating instance disk...");
651+ tracing::info!(path = %template_raw.display(), "creating instance disk");
652+ fs::create_dir_all(instance_raw.parent().unwrap())?;
653+ if target_size == template_size {
654+ fs::copy(template_raw, instance_raw)?;
655+ return Ok(needs_resize);
656+ }
657+
658+ let mut dst = fs::File::create(instance_raw)?;
659+ dst.set_len(target_size.as_u64())?;
660+ let mut src = fs::File::open(template_raw)?;
661+ std::io::copy(&mut src, &mut dst)?;
662+ Ok(needs_resize)
682663}
683664
684665pub struct IoContext {
@@ -694,18 +675,17 @@ pub fn create_pipe() -> (OwnedFd, OwnedFd) {
694675 (read_stream.into(), write_stream.into())
695676}
696677
697−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+>(
698682 output_monitor: Arc<OutputMonitor>,
699683 vm_output_fd: OwnedFd,
700684 vm_input_fd: OwnedFd,
701685 io_control: Arc<IoControl>,
702686 mut on_line: F,
703687 mut on_output: G,
704−) -> IoContext
705−where
706− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
707− G: FnMut(&[u8]) + ::std::marker::Send + 'static,
708−{
688+) -> IoContext {
709689 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
710690
711691 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
@@ -853,14 +833,14 @@ where
853833 PollResult::Spurious => continue,
854834 PollResult::Ready(bytes) => {
855835 if io_control.forward_output() {
856− // enable raw mode, if we haven't already
836+ // enable raw mode if we haven't already
857837 if raw_guard.lock().unwrap().is_none()
858838 && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
859839 {
860840 *raw_guard.lock().unwrap() = Some(guard);
861841 }
862842
863− let mut stdout = std::io::stdout().lock();
843+ let mut stdout = io::stdout().lock();
864844 if stdout.write_all(bytes).is_err() {
865845 break;
866846 }
@@ -876,7 +856,7 @@ where
876856
877857 // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
878858 let mux_thread = thread::spawn(move || {
879− let mut vm_writer = std::fs::File::from(vm_input_fd);
859+ let mut vm_writer = fs::File::from(vm_input_fd);
880860 loop {
881861 match input_rx.recv() {
882862 Ok(VmInput::Bytes(data)) => {
@@ -906,7 +886,7 @@ pub fn spawn_vm_io_with_line_handler<F>(
906886 on_line: F,
907887) -> IoContext
908888where
909− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
889+ F: FnMut(&str) -> bool + std::marker::Send + 'static,
910890{
911891 spawn_vm_io_with_hooks(
912892 output_monitor,
@@ -926,6 +906,36 @@ pub fn spawn_vm_io(
926906 spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
927907}
928908
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+
929939impl IoContext {
930940 pub fn shutdown(self) {
931941 let _ = self.input_tx.send(VmInput::Shutdown);
@@ -943,7 +953,7 @@ fn create_vm_configuration(
943953 vm_writes_to_fd: OwnedFd,
944954 cpu_count: usize,
945955 ram_bytes: u64,
946−) -> Result<Retained<VZVirtualMachineConfiguration>, Box<dyn std::error::Error>> {
956+) -> Result<Retained<VZVirtualMachineConfiguration>> {
947957 unsafe {
948958 let platform =
949959 VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
@@ -977,7 +987,7 @@ fn create_vm_configuration(
977987 false,
978988 VZDiskImageCachingMode::Automatic,
979989 VZDiskImageSynchronizationMode::Full,
980− ).unwrap();
990+ )?;
981991
982992 let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
983993 VZVirtioBlockDeviceConfiguration::alloc(),
@@ -1064,7 +1074,7 @@ fn create_vm_configuration(
10641074 // Validate
10651075 config.validateWithError().map_err(|e| {
10661076 io::Error::other(format!(
1067− "Invalid VM configuration: {:?}",
1077+ "invalid VM configuration: {:?}",
10681078 e.localizedDescription()
10691079 ))
10701080 })?;
@@ -1073,9 +1083,9 @@ fn create_vm_configuration(
10731083 }
10741084}
10751085
1076−fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std::error::Error>> {
1086+fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>> {
10771087 unsafe {
1078− let temp_dir = std::env::temp_dir();
1088+ let temp_dir = env::temp_dir();
10791089 let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
10801090 let url = nsurl_from_path(&temp_path)?;
10811091 let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
@@ -1091,8 +1101,8 @@ fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std
10911101fn spawn_login_actions_thread(
10921102 login_actions: Vec<LoginAction>,
10931103 output_monitor: Arc<OutputMonitor>,
1094− input_tx: mpsc::Sender<VmInput>,
1095− vm_output_tx: mpsc::Sender<VmOutput>,
1104+ input_tx: Sender<VmInput>,
1105+ vm_output_tx: Sender<VmOutput>,
10961106) -> thread::JoinHandle<()> {
10971107 thread::spawn(move || {
10981108 for a in login_actions {
@@ -1106,22 +1116,31 @@ fn spawn_login_actions_thread(
11061116 return;
11071117 }
11081118 }
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+ },
11091140 Send(mut text) => {
11101141 text.push('\n'); // Type the newline so the command is actually submitted.
11111142 input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
11121143 }
1113− Script { path, index } => {
1114− let command = match script_command_from_path(&path, index) {
1115− Ok(command) => command,
1116− Err(err) => {
1117− eprintln!("{err}");
1118− return;
1119− }
1120− };
1121− let mut text = command;
1122− text.push('\n');
1123− input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
1124− }
11251144 }
11261145 }
11271146 })
@@ -1133,8 +1152,9 @@ fn run_vm_with_io<F>(
11331152 directory_shares: &[DirectoryShare],
11341153 cpu_count: usize,
11351154 ram_bytes: u64,
1155+ status: Option<&StatusEmitter<'_>>,
11361156 io_handler: F,
1137−) -> Result<(), Box<dyn std::error::Error>>
1157+) -> Result<()>
11381158where
11391159 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
11401160{
@@ -1181,21 +1201,23 @@ where
11811201
11821202 match rx.try_recv() {
11831203 Ok(result) => {
1184− result.map_err(|e| format!("Failed to start VM: {}", e))?;
1204+ result.map_err(|e| Error::msg(format!("Failed to start VM: {}", e)))?;
11851205 break;
11861206 }
11871207 Err(mpsc::TryRecvError::Empty) => continue,
11881208 Err(mpsc::TryRecvError::Disconnected) => {
1189− return Err("VM start channel disconnected".into());
1209+ bail!("VM start channel disconnected");
11901210 }
11911211 }
11921212 }
11931213
11941214 if Instant::now() >= start_deadline {
1195− return Err("Timed out waiting for VM to start".into());
1215+ bail!("Timed out waiting for VM to start");
11961216 }
11971217
1198− println!("VM booting...");
1218+ emit_status(status, "vm booting... go vibecoder!");
1219+ tracing::info!("vm booting... go vibecoder!");
1220+ tracing::info!("vm booting");
11991221
12001222 let output_monitor = Arc::new(OutputMonitor::default());
12011223 let io_ctx = io_handler(output_monitor.clone(), we_read_from, we_write_to);
@@ -1240,7 +1262,7 @@ where
12401262 );
12411263
12421264 let mut last_state = None;
1243− let mut exit_result = Ok(());
1265+ let mut exit_result: Result<(), String> = Ok(());
12441266 loop {
12451267 unsafe {
12461268 NSRunLoop::mainRunLoop().runMode_beforeDate(
@@ -1259,12 +1281,28 @@ where
12591281 exit_result = Err(format!(
12601282 "Login action ({}) timed out after {:?}; shutting down.",
12611283 action, timeout
1262− )
1263− .into());
1284+ ));
12641285 unsafe {
12651286 if vm.canRequestStop() {
12661287 if let Err(err) = vm.requestStopWithError() {
1267− eprintln!("Failed to request VM stop: {:?}", err);
1288+ tracing::error!(error = ?err, "failed to request VM stop");
1289+ }
1290+ } else if vm.canStop() {
1291+ let handler = RcBlock::new(|_error: *mut NSError| {});
1292+ vm.stopWithCompletionHandler(&handler);
1293+ }
1294+ }
1295+ break;
1296+ }
1297+ Ok(VmOutput::LoginActionFailed { action, reason }) => {
1298+ exit_result = Err(format!(
1299+ "Login action ({}) failed: {}; shutting down.",
1300+ action, reason
1301+ ));
1302+ unsafe {
1303+ if vm.canRequestStop() {
1304+ if let Err(err) = vm.requestStopWithError() {
1305+ tracing::error!(error = ?err, "failed to request VM stop");
12681306 }
12691307 } else if vm.canStop() {
12701308 let handler = RcBlock::new(|_error: *mut NSError| {});
@@ -1276,7 +1314,7 @@ where
12761314 Err(mpsc::TryRecvError::Empty) => {}
12771315 Err(mpsc::TryRecvError::Disconnected) => {}
12781316 }
1279− if state != objc2_virtualization::VZVirtualMachineState::Running {
1317+ if state != VZVirtualMachineState::Running {
12801318 //eprintln!("VM stopped with state: {:?}", state);
12811319 break;
12821320 }
@@ -1286,7 +1324,7 @@ where
12861324
12871325 io_ctx.shutdown();
12881326
1289− exit_result
1327+ exit_result.map_err(Error::msg)
12901328}
12911329
12921330fn run_vm(
@@ -1295,18 +1333,20 @@ fn run_vm(
12951333 directory_shares: &[DirectoryShare],
12961334 cpu_count: usize,
12971335 ram_bytes: u64,
1298−) -> Result<(), Box<dyn std::error::Error>> {
1336+ status: Option<&StatusEmitter<'_>>,
1337+) -> Result<()> {
12991338 run_vm_with_io(
13001339 disk_path,
13011340 login_actions,
13021341 directory_shares,
13031342 cpu_count,
13041343 ram_bytes,
1344+ status,
13051345 spawn_vm_io,
13061346 )
13071347}
13081348
1309−fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
1349+fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>> {
13101350 let abs_path = if path.is_absolute() {
13111351 path.to_path_buf()
13121352 } else {
@@ -1315,7 +1355,7 @@ fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::E
13151355 let ns_path = NSString::from_str(
13161356 abs_path
13171357 .to_str()
1318− .ok_or("Non-UTF8 path encountered while building NSURL")?,
1358+ .with_context(|| "non-UTF8 path encountered while building NSURL")?,
13191359 );
13201360 Ok(NSURL::fileURLWithPath(&ns_path))
13211361}
@@ -1330,7 +1370,7 @@ fn enable_raw_mode(fd: i32) -> io::Result<RawModeGuard> {
13301370 let original = attributes;
13311371
13321372 // Disable translation of carriage return to newline on input
1333− attributes.c_iflag &= !(libc::ICRNL);
1373+ attributes.c_iflag &= !libc::ICRNL;
13341374 // Disable canonical mode (line buffering), echo, and signal generation
13351375 attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
13361376 attributes.c_cc[libc::VMIN] = 0;
@@ -1358,10 +1398,10 @@ impl Drop for RawModeGuard {
13581398
13591399// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
13601400pub fn ensure_signed() {
1361− if std::env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
1401+ if env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
13621402 return;
13631403 }
1364− 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");
13651405 let exe_str = exe.to_str().expect("exe path not valid utf-8");
13661406
13671407 let has_required_entitlements = {
@@ -1383,10 +1423,10 @@ pub fn ensure_signed() {
13831423 }
13841424
13851425 const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1386− let entitlements_path = std::env::temp_dir().join("entitlements.plist");
1387− std::fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
1426+ let entitlements_path = env::temp_dir().join("entitlements.plist");
1427+ fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
13881428
1389− let status = Command::new("codesign")
1429+ let output = Command::new("codesign")
13901430 .args([
13911431 "--sign",
13921432 "-",
@@ -1395,22 +1435,27 @@ pub fn ensure_signed() {
13951435 entitlements_path.to_str().unwrap(),
13961436 exe_str,
13971437 ])
1398− .status();
1438+ .output();
13991439
1400− let _ = std::fs::remove_file(&entitlements_path);
1440+ let _ = fs::remove_file(&entitlements_path);
14011441
1402− match status {
1403− Ok(s) if s.success() => {
1404− let err = Command::new(&exe).args(std::env::args_os().skip(1)).exec();
1405− eprintln!("failed to re-exec after signing: {err}");
1442+ match output {
1443+ Ok(o) if o.status.success() => {
1444+ let stderr = String::from_utf8_lossy(&o.stderr);
1445+ if !stderr.trim().is_empty() {
1446+ tracing::debug!(codesign_stderr = %stderr.trim(), "codesign output");
1447+ }
1448+ let err = Command::new(&exe).args(env::args_os().skip(1)).exec();
1449+ tracing::error!(error = %err, "failed to re-exec after signing");
14061450 std::process::exit(1);
14071451 }
1408− Ok(s) => {
1409− eprintln!("codesign failed with status: {s}");
1452+ Ok(o) => {
1453+ let stderr = String::from_utf8_lossy(&o.stderr);
1454+ tracing::error!(status = %o.status, codesign_stderr = %stderr.trim(), "codesign failed");
14101455 std::process::exit(1);
14111456 }
14121457 Err(e) => {
1413− eprintln!("failed to run codesign: {e}");
1458+ tracing::error!(error = %e, "failed to run codesign");
14141459 std::process::exit(1);
14151460 }
14161461 }
src/vm_manager.rs+625−159
@@ -1,3 +1,15 @@
1+use crate::instance::{VmLiveness, vm_liveness};
2+use crate::session_manager::INSTANCE_DIR_NAME;
3+use crate::{
4+ config::CONFIG_PATH_ENV,
5+ instance::{
6+ InstanceConfig, STATUS_VM_ERROR_PREFIX, build_ssh_login_actions, ensure_instance_dir,
7+ ensure_ssh_keypair, extract_ipv4, load_or_create_instance_config, write_instance_config,
8+ },
9+ session_manager::{GLOBAL_DIR_NAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME},
10+ vm::{self, DirectoryShare, LoginAction, PROJECT_GUEST_BASE, VmInput},
11+};
12+use anyhow::{Error, Result, anyhow, bail};
113use std::{
214 env, fs,
315 io::{Read, Write},
@@ -5,7 +17,6 @@ use std::{
517 fs::PermissionsExt,
618 io::AsRawFd,
719 net::{UnixListener, UnixStream},
8− process::CommandExt,
920 },
1021 path::{Path, PathBuf},
1122 process::{Command, Stdio},
@@ -14,30 +25,44 @@ use std::{
1425 time::{Duration, Instant},
1526};
1627
17−use crate::{
18− instance::{
19− InstanceConfig, build_ssh_login_actions, ensure_instance_dir, ensure_ssh_keypair,
20− extract_ipv4, load_or_create_instance_config, write_instance_config,
21− },
22− session_manager::GLOBAL_DIR_NAME,
23− vm::{self, DirectoryShare, LoginAction, VmInput},
24−};
25−
26−const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
28+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
29+const VM_ROOT_LOG_NAME: &str = "vm_root.log";
2730const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
31+const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
32+const SHUTDOWN_RETRY_MS: u64 = 500;
33+#[cfg(test)]
34+const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 1_000;
35+#[cfg(not(test))]
36+const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 12_000;
37+const STATUS_PREFIX: &str = "status:";
38+
39+type ClientStreams = Arc<Mutex<Vec<UnixStream>>>;
40+type SharedStatus = Arc<Mutex<String>>;
41+
42+#[cfg(not(test))]
43+fn force_exit(_reason: &str) -> ! {
44+ std::process::exit(1);
45+}
46+
47+#[cfg(test)]
48+fn force_exit(reason: &str) -> ! {
49+ panic!("{reason}");
50+}
2851
2952pub fn ensure_manager(
3053 raw_args: &[std::ffi::OsString],
3154 auto_shutdown_ms: u64,
32−) -> Result<UnixStream, Box<dyn std::error::Error>> {
55+ config_path: Option<&Path>,
56+) -> Result<UnixStream> {
3357 let project_root = env::current_dir()?;
3458 tracing::debug!(root = %project_root.display(), "ensure vm manager");
3559 let instance_dir = ensure_instance_dir(&project_root)?;
60+ cleanup_stale_manager(&project_root)?;
3661 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
3762
3863 if let Ok(stream) = UnixStream::connect(&socket_path) {
3964 send_client_pid(&stream);
40− tracing::info!(path = %socket_path.display(), "connected to existing vm manager");
65+ tracing::info!(path = %socket_path.display(), "connected to an existing vm manager");
4166 return Ok(stream);
4267 }
4368
@@ -45,7 +70,7 @@ pub fn ensure_manager(
4570 let mut lock_file = acquire_spawn_lock(&lock_path)?;
4671 if lock_file.is_some() {
4772 tracing::info!(path = %socket_path.display(), "spawning vm manager");
48− spawn_manager_process(raw_args, auto_shutdown_ms, &instance_dir)?;
73+ spawn_manager_process(raw_args, auto_shutdown_ms, &instance_dir, config_path)?;
4974 } else {
5075 tracing::info!(
5176 path = %socket_path.display(),
@@ -75,12 +100,11 @@ pub fn ensure_manager(
75100 drop(lock_file.take());
76101 let _ = fs::remove_file(&lock_path);
77102 }
78− return Err(format!(
79− "Timed out waiting for vm manager socket: {} ({})",
103+ bail!(format!(
104+ "timed out waiting for vm manager socket: {} ({})",
80105 socket_path.display(),
81106 err
82− )
83− .into());
107+ ));
84108 }
85109 thread::sleep(Duration::from_millis(100));
86110 }
@@ -88,54 +112,83 @@ pub fn ensure_manager(
88112 }
89113}
90114
91−pub fn run_manager(
92− args: vm::CliArgs,
93− auto_shutdown_ms: u64,
94−) -> Result<(), Box<dyn std::error::Error>> {
115+pub fn run_manager(args: vm::VmArg, auto_shutdown_ms: u64) -> Result<()> {
95116 let project_root = env::current_dir()?;
96117 tracing::info!(root = %project_root.display(), "vm manager starting");
97− run_manager_with(
98− &project_root,
99− args,
100− auto_shutdown_ms,
101− &RealVmExecutor,
102− ManagerOptions {
103− ensure_signed: true,
104− detach: true,
105− prepare_vm: true,
106− },
107− )
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+ }
128+ let _pid_guard = ensure_pid_file(&project_root)?;
129+ #[cfg(feature = "mock-vm")]
130+ tracing::info!("vm manager using mock executor");
131+ let executor: &dyn VmExecutor = {
132+ #[cfg(feature = "mock-vm")]
133+ {
134+ &MockVmExecutor
135+ }
136+ #[cfg(not(feature = "mock-vm"))]
137+ {
138+ &RealVmExecutor
139+ }
140+ };
141+ let options = {
142+ #[cfg(feature = "mock-vm")]
143+ {
144+ ManagerOptions {
145+ detach: true,
146+ prepare_vm: false,
147+ }
148+ }
149+ #[cfg(not(feature = "mock-vm"))]
150+ {
151+ ManagerOptions {
152+ detach: true,
153+ prepare_vm: true,
154+ }
155+ }
156+ };
157+ run_manager_with(&project_root, args, auto_shutdown_ms, executor, options)
108158}
109159
110160fn spawn_manager_process(
111161 raw_args: &[std::ffi::OsString],
112162 auto_shutdown_ms: u64,
113163 instance_dir: &Path,
114−) -> Result<(), Box<dyn std::error::Error>> {
164+ config_path: Option<&Path>,
165+) -> Result<()> {
115166 let exe = env::current_exe()?;
116167 let mut supervisor_exe = exe.clone();
117168 supervisor_exe.set_file_name("vibebox-supervisor");
118− let use_supervisor = supervisor_exe.exists();
119− let mut cmd = if use_supervisor {
120− Command::new(supervisor_exe)
121− } else {
122− let mut cmd = Command::new(exe);
123− cmd.arg0("vibebox-supervisor");
124− cmd
125− };
169+ // intentional
170+ if !supervisor_exe.exists() {
171+ bail!(format!(
172+ "vibebox-supervisor not found at {}",
173+ supervisor_exe.display()
174+ ));
175+ }
176+ let mut cmd = Command::new(supervisor_exe);
126177 if raw_args.len() > 1 {
127178 cmd.args(&raw_args[1..]);
128179 }
129− if !use_supervisor {
130− cmd.env("VIBEBOX_VM_MANAGER", "1");
131− }
180+ cmd.env("VIBEBOX_INTERNAL", "1");
132181 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
133182 cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
134− tracing::info!(auto_shutdown_ms, "vm manager process spawn requested");
135− let log_path = instance_dir.join("vm_manager.log");
183+ if let Some(path) = config_path {
184+ cmd.env(CONFIG_PATH_ENV, path);
185+ }
186+ tracing::debug!(auto_shutdown_ms, "vm manager process spawn requested");
187+ let log_path = instance_dir.join(VM_MANAGER_LOG_NAME);
136188 let log_file = fs::OpenOptions::new()
137189 .create(true)
138− .append(true)
190+ .write(true)
191+ .truncate(true)
139192 .open(&log_path)
140193 .ok();
141194 if let Some(file) = log_file {
@@ -152,6 +205,184 @@ fn spawn_manager_process(
152205 Ok(())
153206}
154207
208+fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard> {
209+ let instance_dir = ensure_instance_dir(project_root)?;
210+ let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
211+ let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
212+ match vm_liveness(project_root)? {
213+ VmLiveness::RunningWithSocket { pid } => {
214+ bail!("vm manager already running (pid {pid})");
215+ }
216+ VmLiveness::RunningWithoutSocket { pid } => {
217+ tracing::warn!(
218+ pid,
219+ path = %socket_path.display(),
220+ "stale pid file detected with missing socket"
221+ );
222+ }
223+ VmLiveness::NotRunningOrMissing => {}
224+ }
225+ let _ = fs::remove_file(&pid_path);
226+ fs::write(&pid_path, format!("{}\n", std::process::id()))?;
227+ let _ = fs::set_permissions(&pid_path, fs::Permissions::from_mode(0o600));
228+ Ok(PidFileGuard { path: pid_path })
229+}
230+
231+fn cleanup_stale_manager(project_root: &Path) -> Result<()> {
232+ let pid_path = project_root
233+ .join(INSTANCE_DIR_NAME)
234+ .join(VM_MANAGER_PID_NAME);
235+ if matches!(
236+ vm_liveness(project_root)?,
237+ VmLiveness::RunningWithSocket { .. } | VmLiveness::RunningWithoutSocket { .. }
238+ ) {
239+ return Ok(());
240+ }
241+ let _ = fs::remove_file(&pid_path);
242+ Ok(())
243+}
244+
245+fn inject_project_mount(
246+ mounts: &mut Vec<String>,
247+ project_root: &Path,
248+ ssh_user: &str,
249+ project_name: &str,
250+) {
251+ let guest_tilde = format!("~/{project_name}");
252+ let guest_home = format!("/home/{ssh_user}/{project_name}");
253+ let guest_base = format!("{PROJECT_GUEST_BASE}/{project_name}");
254+ let already_mapped = mounts.iter().any(|spec| {
255+ let parts: Vec<&str> = spec.split(':').collect();
256+ if parts.len() < 2 {
257+ return false;
258+ }
259+ let guest = parts[1];
260+ guest == guest_tilde || guest == guest_home || guest == guest_base
261+ });
262+ if already_mapped {
263+ return;
264+ }
265+ let host = project_root.display();
266+ mounts.insert(0, format!("{host}:{guest_tilde}:read-write"));
267+}
268+
269+fn prepare_mounts_and_links(mut args: vm::VmArg, ssh_user: &str) -> (vm::VmArg, String) {
270+ let mut links = Vec::new();
271+ let mut mounts = Vec::with_capacity(args.mounts.len());
272+ for spec in args.mounts {
273+ let (rewritten, link) = rewrite_mount_spec(&spec, ssh_user);
274+ if let Some(link) = link {
275+ links.push(link);
276+ }
277+ mounts.push(rewritten);
278+ }
279+ args.mounts = mounts;
280+ let script = render_home_links_script(&links, ssh_user);
281+ (args, script)
282+}
283+
284+struct HomeLink {
285+ source: String,
286+ target: String,
287+}
288+
289+fn rewrite_mount_spec(spec: &str, ssh_user: &str) -> (String, Option<HomeLink>) {
290+ let parts: Vec<&str> = spec.split(':').collect();
291+ if parts.len() < 2 || parts.len() > 3 {
292+ return (spec.to_string(), None);
293+ }
294+ let host = parts[0];
295+ let guest = parts[1];
296+ let mode = parts.get(2).copied();
297+
298+ let home_prefix = format!("/home/{ssh_user}");
299+ let (rel, is_home) = if guest == "~" {
300+ (String::new(), true)
301+ } else if let Some(stripped) = guest.strip_prefix("~/") {
302+ (stripped.to_string(), true)
303+ } else if guest == home_prefix {
304+ (String::new(), true)
305+ } else if let Some(stripped) = guest.strip_prefix(&(home_prefix.clone() + "/")) {
306+ (stripped.to_string(), true)
307+ } else {
308+ (String::new(), false)
309+ };
310+
311+ if !is_home {
312+ return (spec.to_string(), None);
313+ }
314+
315+ let root_base = PROJECT_GUEST_BASE;
316+ let root_path = if rel.is_empty() {
317+ root_base.to_string()
318+ } else {
319+ format!("{root_base}/{rel}")
320+ };
321+ let target = if rel.is_empty() {
322+ home_prefix
323+ } else {
324+ format!("{home_prefix}/{rel}")
325+ };
326+
327+ let rewritten = match mode {
328+ Some(mode) => format!("{host}:{root_path}:{mode}"),
329+ None => format!("{host}:{root_path}"),
330+ };
331+
332+ (
333+ rewritten,
334+ Some(HomeLink {
335+ source: root_path,
336+ target,
337+ }),
338+ )
339+}
340+
341+fn render_home_links_script(links: &[HomeLink], ssh_user: &str) -> String {
342+ if links.is_empty() {
343+ return String::new();
344+ }
345+ let mut lines = Vec::new();
346+ lines.push("link_home() {".to_string());
347+ lines.push(" src=\"$1\"".to_string());
348+ lines.push(" dest=\"$2\"".to_string());
349+ lines.push(" if [ -L \"$dest\" ]; then".to_string());
350+ lines.push(" current=\"$(readlink \"$dest\" || true)\"".to_string());
351+ lines.push(" if [ \"$current\" != \"$src\" ]; then".to_string());
352+ lines.push(" rm -f \"$dest\"".to_string());
353+ lines.push(" fi".to_string());
354+ lines.push(" fi".to_string());
355+ lines.push(" if [ ! -e \"$dest\" ]; then".to_string());
356+ lines.push(" mkdir -p \"$(dirname \"$dest\")\"".to_string());
357+ lines.push(" ln -s \"$src\" \"$dest\"".to_string());
358+ lines.push(" fi".to_string());
359+ lines.push(format!(
360+ " chown -h \"{ssh_user}:{ssh_user}\" \"$dest\" 2>/dev/null || true"
361+ ));
362+ lines.push("}".to_string());
363+ for link in links {
364+ let src = shell_escape(&link.source);
365+ let dest = shell_escape(&link.target);
366+ lines.push(format!("link_home {src} {dest}"));
367+ }
368+ lines.join("\n")
369+}
370+
371+fn shell_escape(value: &str) -> String {
372+ let escaped = value.replace('\'', "'\"'\"'");
373+ format!("'{escaped}'")
374+}
375+
376+struct PidFileGuard {
377+ path: PathBuf,
378+}
379+
380+impl Drop for PidFileGuard {
381+ fn drop(&mut self) {
382+ let _ = fs::remove_file(&self.path);
383+ }
384+}
385+
155386fn detach_from_terminal() {
156387 unsafe {
157388 libc::setsid();
@@ -178,6 +409,30 @@ fn wait_for_disconnect(mut stream: UnixStream) {
178409 }
179410}
180411
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+
181436fn send_client_pid(stream: &UnixStream) {
182437 let pid = std::process::id();
183438 let payload = format!("pid={pid}\n");
@@ -187,7 +442,7 @@ fn send_client_pid(stream: &UnixStream) {
187442 }
188443}
189444
190−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>> {
191446 match fs::OpenOptions::new()
192447 .write(true)
193448 .create_new(true)
@@ -221,6 +476,12 @@ fn is_lock_stale(lock_path: &Path) -> bool {
221476 }
222477}
223478
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+
224485fn pid_is_alive(pid: u32) -> bool {
225486 let pid = pid as libc::pid_t;
226487 let result = unsafe { libc::kill(pid, 0) };
@@ -234,12 +495,6 @@ fn pid_is_alive(pid: u32) -> bool {
234495 }
235496}
236497
237−fn read_lock_pid(lock_path: &Path) -> Option<u32> {
238− let content = fs::read_to_string(lock_path).ok()?;
239− let line = content.lines().next()?;
240− line.strip_prefix("pid=")?.trim().parse::<u32>().ok()
241−}
242−
243498fn read_client_pid(stream: &UnixStream) -> Option<u32> {
244499 let mut stream = stream.try_clone().ok()?;
245500 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
@@ -250,7 +505,7 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
250505 Ok(0) => break,
251506 Ok(n) => {
252507 len += n;
253− if buf[..len].iter().any(|b| *b == b'\n') || len == buf.len() {
508+ if buf[..len].contains(&b'\n') || len == buf.len() {
254509 break;
255510 }
256511 }
@@ -272,31 +527,34 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
272527 }
273528}
274529
530+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
275531fn spawn_manager_io(
276532 config: Arc<Mutex<InstanceConfig>>,
277− instance_dir: PathBuf,
533+ project_dir: PathBuf,
534+ clients: ClientStreams,
535+ latest_status: SharedStatus,
278536 output_monitor: Arc<vm::OutputMonitor>,
279537 vm_output_fd: std::os::unix::io::OwnedFd,
280538 vm_input_fd: std::os::unix::io::OwnedFd,
281539) -> vm::IoContext {
282− let log_path = instance_dir.join("serial.log");
540+ let log_path = project_dir.join(INSTANCE_DIR_NAME).join(VM_ROOT_LOG_NAME);
283541 let log_file = fs::OpenOptions::new()
284542 .create(true)
285− .append(true)
543+ .write(true)
544+ .truncate(true)
286545 .open(&log_path)
287546 .ok()
288547 .map(|file| Arc::new(Mutex::new(file)));
289548
290− let instance_path = instance_dir.join("instance.toml");
291549 let config_for_output = config.clone();
292550 let log_for_output = log_file.clone();
293551 let mut line_buf = String::new();
294552
295553 let on_output = move |bytes: &[u8]| {
296− if let Some(log) = &log_for_output {
297− if let Ok(mut file) = log.lock() {
298− let _ = file.write_all(bytes);
299− }
554+ if let Some(log) = &log_for_output
555+ && let Ok(mut file) = log.lock()
556+ {
557+ let _ = file.write_all(bytes);
300558 }
301559
302560 let text = String::from_utf8_lossy(bytes);
@@ -309,17 +567,27 @@ fn spawn_manager_io(
309567 }
310568 line_buf.drain(..=pos);
311569
312− let cleaned = line.trim_start_matches(|c: char| c == '\r' || c == ' ');
570+ let cleaned = line.trim_start_matches(['\r', ' ']);
571+ if let Some(script_failure) = cleaned.strip_prefix("VIBEBOX_SCRIPT_ERROR:") {
572+ let failure = script_failure.trim();
573+ if !failure.is_empty() {
574+ tracing::error!(script_failure = %failure, "[vm] script reported failure");
575+ broadcast_status(
576+ &clients,
577+ &latest_status,
578+ &format!("{STATUS_VM_ERROR_PREFIX} {failure}"),
579+ );
580+ }
581+ }
313582 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
314583 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
315584 let ip = extract_ipv4(ip_raw).unwrap_or_default();
316− if !ip.is_empty() {
317− if let Ok(mut cfg) = config_for_output.lock() {
318− if cfg.vm_ipv4.as_deref() != Some(ip.as_str()) {
319− cfg.vm_ipv4 = Some(ip.clone());
320− let _ = write_instance_config(&instance_path, &cfg);
321− }
322− }
585+ if !ip.is_empty()
586+ && let Ok(mut cfg) = config_for_output.lock()
587+ && cfg.vm_ipv4.as_deref() != Some(ip.as_str())
588+ {
589+ cfg.vm_ipv4 = Some(ip.clone());
590+ let _ = write_instance_config(&project_dir, &cfg);
323591 }
324592 }
325593 }
@@ -341,80 +609,52 @@ enum ManagerEvent {
341609 VmExited(Option<String>),
342610}
343611
344−#[cfg(test)]
345−mod tests {
346− use super::*;
347− use std::{sync::mpsc, time::Duration};
348−
349− #[test]
350− fn manager_powers_off_after_grace_when_no_refs() {
351− let _temp = tempfile::Builder::new()
352− .prefix("vb")
353− .tempdir_in("/tmp")
354− .expect("tempdir");
355−
356− let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
357− let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
358− let vm_input_tx = Arc::new(Mutex::new(Some(vm_tx)));
359−
360− let manager_thread = thread::spawn(move || {
361− manager_event_loop(event_rx, vm_input_tx, 50).expect("event loop");
362− });
363−
364− event_tx.send(ManagerEvent::Inc(None)).unwrap();
365− assert!(vm_rx.recv_timeout(Duration::from_millis(100)).is_err());
366−
367− event_tx.send(ManagerEvent::Dec(None)).unwrap();
368− let msg = vm_rx
369− .recv_timeout(Duration::from_secs(2))
370− .expect("poweroff");
371− match msg {
372− VmInput::Bytes(data) => {
373− assert_eq!(data, b"systemctl poweroff\n");
374− }
375− _ => panic!("unexpected vm input"),
376− }
377− let _ = event_tx.send(ManagerEvent::VmExited(None));
378− let _ = manager_thread.join();
379− }
380−}
381−
382612struct ManagerOptions {
383− ensure_signed: bool,
384613 detach: bool,
385614 prepare_vm: bool,
386615}
387616
388617trait VmExecutor {
618+ #[allow(clippy::too_many_arguments)]
389619 fn run_vm(
390620 &self,
391− args: vm::CliArgs,
621+ args: vm::VmArg,
392622 extra_login_actions: Vec<LoginAction>,
393623 extra_shares: Vec<DirectoryShare>,
394624 config: Arc<Mutex<InstanceConfig>>,
395625 instance_dir: PathBuf,
626+ clients: ClientStreams,
627+ latest_status: SharedStatus,
396628 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
397− ) -> Result<(), Box<dyn std::error::Error>>;
629+ ) -> Result<()>;
398630}
399631
632+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
400633struct RealVmExecutor;
401634
402635impl VmExecutor for RealVmExecutor {
403636 fn run_vm(
404637 &self,
405− args: vm::CliArgs,
638+ args: vm::VmArg,
406639 extra_login_actions: Vec<LoginAction>,
407640 extra_shares: Vec<DirectoryShare>,
408641 config: Arc<Mutex<InstanceConfig>>,
409− instance_dir: PathBuf,
642+ project_dir: PathBuf,
643+ clients: ClientStreams,
644+ latest_status: SharedStatus,
410645 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
411− ) -> Result<(), Box<dyn std::error::Error>> {
412− let result = vm::run_with_args_and_extras(
646+ ) -> Result<()> {
647+ let status_callback = |status: &str| {
648+ broadcast_status(&clients, &latest_status, status);
649+ };
650+ vm::run_with_args_and_extras(
413651 args,
414652 |output_monitor, vm_output_fd, vm_input_fd| {
415653 let io_ctx = spawn_manager_io(
416654 config.clone(),
417− instance_dir.clone(),
655+ project_dir.clone(),
656+ clients.clone(),
657+ latest_status.clone(),
418658 output_monitor,
419659 vm_output_fd,
420660 vm_input_fd,
@@ -424,35 +664,60 @@ impl VmExecutor for RealVmExecutor {
424664 },
425665 extra_login_actions,
426666 extra_shares,
427− );
428− result
667+ Some(&status_callback),
668+ )
669+ }
670+}
671+
672+#[cfg(feature = "mock-vm")]
673+struct MockVmExecutor;
674+
675+#[cfg(feature = "mock-vm")]
676+impl VmExecutor for MockVmExecutor {
677+ fn run_vm(
678+ &self,
679+ _args: vm::VmArg,
680+ _extra_login_actions: Vec<LoginAction>,
681+ _extra_shares: Vec<DirectoryShare>,
682+ _config: Arc<Mutex<InstanceConfig>>,
683+ _instance_dir: PathBuf,
684+ _clients: ClientStreams,
685+ _latest_status: SharedStatus,
686+ vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
687+ ) -> Result<()> {
688+ let (tx, rx) = mpsc::channel::<VmInput>();
689+ *vm_input_tx.lock().unwrap() = Some(tx);
690+ tracing::info!("mock vm executor running");
691+ while let Ok(input) = rx.recv() {
692+ match input {
693+ VmInput::Shutdown => break,
694+ VmInput::Bytes(bytes) => {
695+ let text = String::from_utf8_lossy(&bytes);
696+ if text.contains("systemctl poweroff") {
697+ break;
698+ }
699+ }
700+ }
701+ }
702+ tracing::info!("mock vm executor exiting");
703+ Ok(())
429704 }
430705}
431706
432707fn run_manager_with(
433708 project_root: &Path,
434− args: vm::CliArgs,
709+ mut args: vm::VmArg,
435710 auto_shutdown_ms: u64,
436711 executor: &dyn VmExecutor,
437712 options: ManagerOptions,
438−) -> Result<(), Box<dyn std::error::Error>> {
439− if options.ensure_signed {
440− let _had_skip = env::var("VIBEBOX_SKIP_CODESIGN").ok();
441− unsafe {
442− env::remove_var("VIBEBOX_SKIP_CODESIGN");
443− }
444− vm::ensure_signed();
445− unsafe {
446− env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
447− }
448− }
713+) -> Result<()> {
449714 if options.detach {
450715 detach_from_terminal();
451716 }
452717
453718 let project_name = project_root
454719 .file_name()
455− .ok_or("Project directory has no name")?
720+ .ok_or_else(|| anyhow!("Project directory has no name"))?
456721 .to_string_lossy()
457722 .into_owned();
458723 let instance_dir = ensure_instance_dir(project_root)?;
@@ -460,21 +725,35 @@ fn run_manager_with(
460725 let _ = ensure_ssh_keypair(&instance_dir)?;
461726 }
462727
463− let mut config = load_or_create_instance_config(&instance_dir)?;
728+ let mut config = load_or_create_instance_config(project_root)?;
464729 if config.vm_ipv4.is_some() {
465730 config.vm_ipv4 = None;
466− write_instance_config(&instance_dir.join("instance.toml"), &config)?;
731+ write_instance_config(project_root, &config)?;
467732 }
468733 let config = Arc::new(Mutex::new(config));
734+ let ssh_user = config
735+ .lock()
736+ .map(|cfg| cfg.ssh_user.clone())
737+ .map_err(|_| anyhow!("failed to acquire ssh user display"))?;
738+ if !args.no_default_mounts {
739+ inject_project_mount(&mut args.mounts, project_root, &ssh_user, &project_name);
740+ }
741+ let (args, home_links_script) = prepare_mounts_and_links(args, &ssh_user);
469742
743+ let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}");
470744 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
471− let extra_shares = vec![DirectoryShare::new(
472− instance_dir.clone(),
473− ssh_guest_dir.clone().into(),
474− true,
475− )?];
476− let extra_login_actions =
477− build_ssh_login_actions(&config, &project_name, ssh_guest_dir.as_str(), "ssh_key");
745+ let extra_shares = vec![
746+ DirectoryShare::new(instance_dir.clone(), ssh_guest_dir.clone().into(), true)
747+ .map_err(|err| anyhow!(err.to_string()))?,
748+ ];
749+ let extra_login_actions = build_ssh_login_actions(
750+ &config,
751+ &project_name,
752+ &project_guest_dir,
753+ ssh_guest_dir.as_str(),
754+ "ssh_key",
755+ &home_links_script,
756+ );
478757
479758 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
480759 if let Ok(stream) = UnixStream::connect(&socket_path) {
@@ -489,17 +768,43 @@ fn run_manager_with(
489768 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
490769 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
491770
771+ let clients: ClientStreams = Arc::new(Mutex::new(Vec::new()));
772+ let latest_status: SharedStatus = Arc::new(Mutex::new(String::new()));
492773 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
493774 let event_tx_accept = event_tx.clone();
775+ let clients_accept = clients.clone();
776+ let latest_status_accept = latest_status.clone();
494777 thread::spawn(move || {
495778 for stream in listener.incoming() {
496779 match stream {
497780 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+ }
498799 let event_tx_conn = event_tx_accept.clone();
800+ let clients_conn = clients_accept.clone();
499801 thread::spawn(move || {
500802 let pid = read_client_pid(&stream);
501803 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
502804 wait_for_disconnect(stream);
805+ if let Some(fd) = client_fd {
806+ remove_client(&clients_conn, fd);
807+ }
503808 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
504809 });
505810 }
@@ -519,43 +824,59 @@ fn run_manager_with(
519824 extra_login_actions,
520825 extra_shares,
521826 config.clone(),
522− instance_dir.clone(),
827+ project_root.to_path_buf(),
828+ clients.clone(),
829+ latest_status.clone(),
523830 vm_input_tx.clone(),
524831 );
525832 tracing::info!("vm manager vm run completed");
526833 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+ }
527841 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
528− let event_loop_result: Result<(), String> = event_loop_handle
842+ let event_loop_result = event_loop_handle
529843 .join()
530− .unwrap_or_else(|_| Err("vm manager event loop panicked".into()))
844+ .unwrap_or_else(|_| Err(Error::msg("vm manager event loop panicked")))
531845 .map_err(|err| err.to_string());
532846 let _ = fs::remove_file(&socket_path);
533847 if let Err(err) = &event_loop_result {
534848 tracing::error!(error = %err, "vm manager exiting due to event loop error");
535− return Err(err.to_string().into());
849+ bail!(err.to_string());
536850 }
537851 if let Some(err) = vm_err {
538852 tracing::error!(error = %err, "vm manager exiting due to vm error");
539− return Err(err.into());
853+ bail!(err);
540854 }
541855 tracing::info!("vm manager exiting");
542− Ok(event_loop_result?)
856+ event_loop_result.map_err(Error::msg)
543857}
544858
545859fn manager_event_loop(
546860 event_rx: mpsc::Receiver<ManagerEvent>,
547861 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
548862 auto_shutdown_ms: u64,
549−) -> Result<(), String> {
863+) -> Result<()> {
550864 let mut ref_count: usize = 0;
551865 let mut shutdown_deadline: Option<Instant> = None;
552866 let mut shutdown_sent = false;
867+ let mut hard_deadline: Option<Instant> = None;
553868 let grace = Duration::from_millis(auto_shutdown_ms.max(1));
869+ let hard_timeout = Duration::from_millis(HARD_SHUTDOWN_TIMEOUT_MS);
554870
555871 loop {
556− let timeout = match shutdown_deadline {
557− Some(deadline) => deadline.saturating_duration_since(Instant::now()),
558− None => Duration::from_secs(1),
872+ let timeout = match (shutdown_deadline, hard_deadline) {
873+ (Some(shutdown), Some(hard)) => {
874+ let next = if shutdown <= hard { shutdown } else { hard };
875+ next.saturating_duration_since(Instant::now())
876+ }
877+ (Some(shutdown), None) => shutdown.saturating_duration_since(Instant::now()),
878+ (None, Some(hard)) => hard.saturating_duration_since(Instant::now()),
879+ (None, None) => Duration::from_secs(1),
559880 };
560881
561882 match event_rx.recv_timeout(timeout) {
@@ -569,11 +890,10 @@ fn manager_event_loop(
569890 );
570891 shutdown_deadline = None;
571892 shutdown_sent = false;
893+ hard_deadline = None;
572894 }
573895 Ok(ManagerEvent::Dec(pid)) => {
574− if ref_count > 0 {
575− ref_count -= 1;
576− }
896+ ref_count = ref_count.saturating_sub(1);
577897 tracing::info!(
578898 ref_count,
579899 pid = pid.unwrap_or(0),
@@ -592,16 +912,53 @@ fn manager_event_loop(
592912 break;
593913 }
594914 Err(mpsc::RecvTimeoutError::Timeout) => {
595− if let Some(deadline) = shutdown_deadline {
596− if Instant::now() >= deadline && !shutdown_sent {
597− if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
598− let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
915+ if let Some(deadline) = shutdown_deadline
916+ && Instant::now() >= deadline
917+ && !shutdown_sent
918+ {
919+ if hard_deadline.is_none() {
920+ hard_deadline = Some(Instant::now() + hard_timeout);
921+ }
922+ let mut sent = false;
923+ if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
924+ if tx
925+ .send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()))
926+ .is_ok()
927+ {
928+ sent = true;
929+ } else {
930+ tracing::warn!("shutdown command failed to send");
599931 }
932+ } else {
933+ tracing::warn!("shutdown command deferred; vm input not ready");
934+ }
935+ if sent {
600936 tracing::info!("shutdown command sent");
601937 shutdown_sent = true;
602938 shutdown_deadline = None;
939+ hard_deadline = Some(Instant::now() + hard_timeout);
940+ } else {
941+ shutdown_deadline =
942+ Some(Instant::now() + Duration::from_millis(SHUTDOWN_RETRY_MS));
603943 }
604944 }
945+ if ref_count == 0
946+ && let Some(deadline) = hard_deadline
947+ && Instant::now() >= deadline
948+ {
949+ if shutdown_sent {
950+ tracing::warn!(
951+ timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
952+ "force exiting: VM did not stop after shutdown timeout"
953+ );
954+ } else {
955+ tracing::warn!(
956+ timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
957+ "force exiting: VM input not ready after shutdown timeout"
958+ );
959+ }
960+ force_exit("vm manager forced exit");
961+ }
605962 }
606963 Err(mpsc::RecvTimeoutError::Disconnected) => break,
607964 }
@@ -609,3 +966,112 @@ fn manager_event_loop(
609966
610967 Ok(())
611968}
969+
970+#[cfg(test)]
971+mod tests {
972+ use super::*;
973+ use std::{fs, sync::mpsc, thread, time::Duration};
974+
975+ #[test]
976+ fn manager_powers_off_after_grace_when_no_refs() {
977+ let _temp = tempfile::Builder::new()
978+ .prefix("vb")
979+ .tempdir_in("/tmp")
980+ .expect("tempdir");
981+
982+ let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
983+ let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
984+ let vm_input_tx = Arc::new(Mutex::new(Some(vm_tx)));
985+
986+ let manager_thread = thread::spawn(move || {
987+ manager_event_loop(event_rx, vm_input_tx, 50).expect("event loop");
988+ });
989+
990+ event_tx.send(ManagerEvent::Inc(None)).unwrap();
991+ assert!(vm_rx.recv_timeout(Duration::from_millis(100)).is_err());
992+
993+ event_tx.send(ManagerEvent::Dec(None)).unwrap();
994+ let msg = vm_rx
995+ .recv_timeout(Duration::from_secs(2))
996+ .expect("poweroff");
997+ match msg {
998+ VmInput::Bytes(data) => {
999+ assert_eq!(data, b"systemctl poweroff\n");
1000+ }
1001+ _ => panic!("unexpected vm input"),
1002+ }
1003+ let _ = event_tx.send(ManagerEvent::VmExited(None));
1004+ let _ = manager_thread.join();
1005+ }
1006+
1007+ #[test]
1008+ fn manager_force_exits_when_vm_input_never_ready() {
1009+ let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
1010+ let vm_input_tx = Arc::new(Mutex::new(None));
1011+
1012+ let manager_thread = thread::spawn(move || {
1013+ let _ = manager_event_loop(event_rx, vm_input_tx, 10);
1014+ });
1015+
1016+ event_tx.send(ManagerEvent::Inc(None)).unwrap();
1017+ event_tx.send(ManagerEvent::Dec(None)).unwrap();
1018+
1019+ let join_result = manager_thread.join();
1020+ assert!(
1021+ join_result.is_err(),
1022+ "expected manager to force-exit when vm input never becomes ready"
1023+ );
1024+ }
1025+
1026+ #[test]
1027+ fn manager_sends_shutdown_after_vm_input_becomes_ready() {
1028+ let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
1029+ let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
1030+ let vm_input_tx = Arc::new(Mutex::new(None));
1031+ let vm_input_for_thread = vm_input_tx.clone();
1032+
1033+ let manager_thread = thread::spawn(move || {
1034+ manager_event_loop(event_rx, vm_input_for_thread, 10).expect("event loop");
1035+ });
1036+
1037+ event_tx.send(ManagerEvent::Inc(None)).unwrap();
1038+ event_tx.send(ManagerEvent::Dec(None)).unwrap();
1039+
1040+ thread::sleep(Duration::from_millis(100));
1041+ *vm_input_tx.lock().unwrap() = Some(vm_tx);
1042+
1043+ let msg = vm_rx
1044+ .recv_timeout(Duration::from_secs(2))
1045+ .expect("poweroff");
1046+ match msg {
1047+ VmInput::Bytes(data) => {
1048+ assert_eq!(data, b"systemctl poweroff\n");
1049+ }
1050+ _ => panic!("unexpected vm input"),
1051+ }
1052+ let _ = event_tx.send(ManagerEvent::VmExited(None));
1053+ let _ = manager_thread.join();
1054+ }
1055+
1056+ #[test]
1057+ fn lock_is_not_stale_when_owner_pid_is_alive() {
1058+ let temp = tempfile::Builder::new()
1059+ .prefix("vb")
1060+ .tempdir_in("/tmp")
1061+ .expect("tempdir");
1062+ let lock_path = temp.path().join("vm.lock");
1063+ fs::write(&lock_path, format!("pid={}\n", std::process::id())).expect("write lock");
1064+ assert!(!is_lock_stale(&lock_path));
1065+ }
1066+
1067+ #[test]
1068+ fn lock_is_stale_when_owner_pid_is_missing() {
1069+ let temp = tempfile::Builder::new()
1070+ .prefix("vb")
1071+ .tempdir_in("/tmp")
1072+ .expect("tempdir");
1073+ let lock_path = temp.path().join("vm.lock");
1074+ fs::write(&lock_path, "pid=999999\n").expect("write lock");
1075+ assert!(is_lock_stale(&lock_path));
1076+ }
1077+}
tests/e2e_cli.rsnew file+58
@@ -0,0 +1,58 @@
1+use assert_cmd::cargo::cargo_bin_cmd;
2+use tempfile::TempDir;
3+
4+#[test]
5+fn cli_version_shows_binary_name() {
6+ let output = cargo_bin_cmd!("vibebox").arg("--version").output().unwrap();
7+ print_output("e2e_cli", &output);
8+ assert!(
9+ output.status.success(),
10+ "expected success, got status: {}",
11+ output.status
12+ );
13+ let stdout = String::from_utf8_lossy(&output.stdout);
14+ assert!(
15+ stdout.contains("vibebox"),
16+ "expected --version output to contain 'vibebox', got: {}",
17+ stdout
18+ );
19+}
20+
21+#[test]
22+fn list_reports_no_sessions_when_empty() {
23+ let temp = TempDir::new().unwrap();
24+ let home = temp.path().join("home");
25+ let project = temp.path().join("project");
26+ std::fs::create_dir_all(&home).unwrap();
27+ std::fs::create_dir_all(&project).unwrap();
28+
29+ let output = cargo_bin_cmd!("vibebox")
30+ .current_dir(&project)
31+ .env("HOME", &home)
32+ .arg("list")
33+ .output()
34+ .unwrap();
35+ print_output("e2e_cli", &output);
36+ assert!(
37+ output.status.success(),
38+ "expected success, got status: {}",
39+ output.status
40+ );
41+ let stdout = String::from_utf8_lossy(&output.stdout);
42+ assert!(
43+ stdout.contains("No sessions were found."),
44+ "expected empty sessions message, got: {}",
45+ stdout
46+ );
47+}
48+
49+fn print_output(prefix: &str, output: &std::process::Output) {
50+ let stdout = String::from_utf8_lossy(&output.stdout);
51+ for line in stdout.lines() {
52+ println!("[{}] {}", prefix, line);
53+ }
54+ let stderr = String::from_utf8_lossy(&output.stderr);
55+ for line in stderr.lines() {
56+ eprintln!("[{}] {}", prefix, line);
57+ }
58+}
tests/e2e_vm.rsnew file+278
@@ -0,0 +1,278 @@
1+use std::{
2+ fs,
3+ io::{BufRead, BufReader, Read},
4+ path::{Path, PathBuf},
5+ process::{Child, Command, Stdio},
6+ thread,
7+ time::{Duration, Instant},
8+};
9+
10+use tempfile::TempDir;
11+
12+#[cfg(target_os = "macos")]
13+#[test]
14+#[ignore]
15+fn vm_boots_and_runs_command() {
16+ if std::env::var("VIBEBOX_E2E_VM").as_deref() != Ok("1") {
17+ eprintln!("skipping: set VIBEBOX_E2E_VM=1 to run this test");
18+ return;
19+ }
20+ if !virtualization_available() {
21+ eprintln!("[e2e_vm] skipping: virtualization not available on this hardware");
22+ return;
23+ }
24+
25+ let temp = TempDir::new().unwrap();
26+ let home = temp.path().join("home");
27+ let cache_home = temp.path().join("cache");
28+ let project = temp.path().join("project");
29+ fs::create_dir_all(&home).unwrap();
30+ fs::create_dir_all(&cache_home).unwrap();
31+ fs::create_dir_all(&project).unwrap();
32+
33+ write_config(&project);
34+
35+ let mut child = Command::new(assert_cmd::cargo_bin!("vibebox-supervisor"))
36+ .current_dir(&project)
37+ .env("HOME", &home)
38+ .env("XDG_CACHE_HOME", &cache_home)
39+ .env("VIBEBOX_INTERNAL", "1")
40+ .stdout(Stdio::piped())
41+ .stderr(Stdio::piped())
42+ .spawn()
43+ .unwrap();
44+ if let Some(stdout) = child.stdout.take() {
45+ spawn_prefix_reader("e2e_vm", "stdout", stdout);
46+ }
47+ if let Some(stderr) = child.stderr.take() {
48+ spawn_prefix_reader("e2e_vm", "stderr", stderr);
49+ }
50+ let _child_guard = ChildGuard::new(child);
51+
52+ log_line("e2e_vm", "waiting for vm manager socket");
53+ let socket_path = project.join(".vibebox").join("vm.sock");
54+ let _socket_guard = wait_for_socket(&socket_path, Duration::from_secs(30));
55+ log_line("e2e_vm", "vm manager socket ready");
56+
57+ log_line("e2e_vm", "waiting for vm ipv4");
58+ let instance_path = project.join(".vibebox").join("instance.toml");
59+ let (ip, user) = wait_for_vm_ip(&instance_path, Duration::from_secs(180));
60+ log_line("e2e_vm", &format!("vm ipv4={ip} user={user}"));
61+
62+ let ssh_key = project.join(".vibebox").join("ssh_key");
63+ wait_for_file(&ssh_key, Duration::from_secs(30));
64+ log_line("e2e_vm", "ssh key ready");
65+
66+ let output = wait_for_ssh_command(&ssh_key, &user, &ip, Duration::from_secs(90));
67+ print_output("e2e_vm", &output);
68+ let stdout = String::from_utf8_lossy(&output.stdout);
69+ assert!(
70+ stdout.contains("Linux"),
71+ "expected ssh command output to contain 'Linux', got: {}",
72+ stdout
73+ );
74+}
75+
76+#[cfg(not(target_os = "macos"))]
77+#[test]
78+#[ignore]
79+fn vm_boots_and_runs_command() {
80+ eprintln!("skipping: vm e2e test requires macOS virtualization");
81+}
82+
83+struct ChildGuard {
84+ child: Option<Child>,
85+}
86+
87+impl ChildGuard {
88+ fn new(child: Child) -> Self {
89+ Self { child: Some(child) }
90+ }
91+}
92+
93+impl Drop for ChildGuard {
94+ fn drop(&mut self) {
95+ if let Some(child) = &mut self.child {
96+ let _ = child.kill();
97+ let _ = child.wait();
98+ }
99+ }
100+}
101+
102+struct SocketGuard {
103+ _path: PathBuf,
104+ _stream: std::os::unix::net::UnixStream,
105+}
106+
107+fn write_config(project: &Path) {
108+ let config = r#"[box]
109+cpu_count = 2
110+ram_mb = 2048
111+disk_gb = 5
112+mounts = []
113+
114+[supervisor]
115+auto_shutdown_ms = 120000
116+"#;
117+ fs::write(project.join("vibebox.toml"), config).unwrap();
118+}
119+
120+fn wait_for_socket(path: &Path, timeout: Duration) -> SocketGuard {
121+ let start = Instant::now();
122+ loop {
123+ if let Ok(stream) = std::os::unix::net::UnixStream::connect(path) {
124+ return SocketGuard {
125+ _path: path.to_path_buf(),
126+ _stream: stream,
127+ };
128+ }
129+ if start.elapsed() > timeout {
130+ panic!(
131+ "timed out waiting for vm manager socket at {}",
132+ path.display()
133+ );
134+ }
135+ thread::sleep(Duration::from_millis(200));
136+ }
137+}
138+
139+fn wait_for_vm_ip(instance_path: &Path, timeout: Duration) -> (String, String) {
140+ let start = Instant::now();
141+ loop {
142+ if let Ok(raw) = fs::read_to_string(instance_path)
143+ && let Ok(value) = toml::from_str::<toml::Value>(&raw)
144+ {
145+ let ip = value
146+ .get("vm_ipv4")
147+ .and_then(|v| v.as_str())
148+ .map(|s| s.trim().to_string())
149+ .filter(|s| !s.is_empty());
150+ if let Some(ip) = ip {
151+ let user = value
152+ .get("ssh_user")
153+ .and_then(|v| v.as_str())
154+ .map(|s| s.trim().to_string())
155+ .filter(|s| !s.is_empty())
156+ .unwrap_or_else(|| "vibecoder".to_string());
157+ return (ip, user);
158+ }
159+ }
160+ if start.elapsed() > timeout {
161+ panic!(
162+ "timed out waiting for vm_ipv4 in {}",
163+ instance_path.display()
164+ );
165+ }
166+ thread::sleep(Duration::from_millis(500));
167+ }
168+}
169+
170+fn wait_for_file(path: &Path, timeout: Duration) {
171+ let start = Instant::now();
172+ loop {
173+ if path.exists() {
174+ return;
175+ }
176+ if start.elapsed() > timeout {
177+ panic!("timed out waiting for file {}", path.display());
178+ }
179+ thread::sleep(Duration::from_millis(200));
180+ }
181+}
182+
183+fn wait_for_ssh_command(
184+ ssh_key: &Path,
185+ user: &str,
186+ ip: &str,
187+ timeout: Duration,
188+) -> std::process::Output {
189+ let start = Instant::now();
190+ loop {
191+ let output = Command::new("ssh")
192+ .args([
193+ "-i",
194+ ssh_key.to_str().unwrap_or(".vibebox/ssh_key"),
195+ "-o",
196+ "IdentitiesOnly=yes",
197+ "-o",
198+ "StrictHostKeyChecking=no",
199+ "-o",
200+ "UserKnownHostsFile=/dev/null",
201+ "-o",
202+ "GlobalKnownHostsFile=/dev/null",
203+ "-o",
204+ "PasswordAuthentication=no",
205+ "-o",
206+ "BatchMode=yes",
207+ "-o",
208+ "LogLevel=ERROR",
209+ "-o",
210+ "ConnectTimeout=5",
211+ &format!("{user}@{ip}"),
212+ "uname -s",
213+ ])
214+ .output()
215+ .unwrap();
216+ if output.status.success() {
217+ return output;
218+ }
219+ if start.elapsed() > timeout {
220+ let stderr = String::from_utf8_lossy(&output.stderr);
221+ panic!("ssh command failed and timed out: {}", stderr);
222+ }
223+ thread::sleep(Duration::from_millis(1000));
224+ }
225+}
226+
227+fn spawn_prefix_reader(
228+ label: &'static str,
229+ stream: &'static str,
230+ reader: impl Read + Send + 'static,
231+) {
232+ thread::spawn(move || {
233+ let buf = BufReader::new(reader);
234+ for line in buf.lines() {
235+ match line {
236+ Ok(line) => {
237+ println!("[{}][{}] {}", label, stream, line);
238+ }
239+ Err(err) => {
240+ eprintln!("[{}][{}] read error: {}", label, stream, err);
241+ break;
242+ }
243+ }
244+ }
245+ });
246+}
247+
248+fn log_line(prefix: &str, message: &str) {
249+ println!("[{}] {}", prefix, message);
250+}
251+
252+fn print_output(prefix: &str, output: &std::process::Output) {
253+ let stdout = String::from_utf8_lossy(&output.stdout);
254+ for line in stdout.lines() {
255+ println!("[{}] {}", prefix, line);
256+ }
257+ let stderr = String::from_utf8_lossy(&output.stderr);
258+ for line in stderr.lines() {
259+ eprintln!("[{}] {}", prefix, line);
260+ }
261+}
262+
263+fn virtualization_available() -> bool {
264+ let output = Command::new("sysctl")
265+ .args(["-n", "kern.hv_support"])
266+ .output();
267+ match output {
268+ Ok(output) if output.status.success() => {
269+ let value = String::from_utf8_lossy(&output.stdout);
270+ match value.trim() {
271+ "1" => true,
272+ "0" => false,
273+ _ => true,
274+ }
275+ }
276+ _ => true,
277+ }
278+}
tests/e2e_vm_mock.rsnew file+455
@@ -0,0 +1,455 @@
1+#![cfg(all(feature = "mock-vm", target_os = "macos"))]
2+
3+use std::{
4+ fs,
5+ io::{BufRead, BufReader, Read},
6+ os::unix::net::UnixStream,
7+ path::{Path, PathBuf},
8+ process::{Child, Command, Stdio},
9+ sync::mpsc,
10+ thread,
11+ time::{Duration, Instant},
12+};
13+
14+use tempfile::TempDir;
15+
16+#[test]
17+fn mock_vm_allows_refcount_concurrency() {
18+ let temp = TempDir::new().unwrap();
19+ let mut supervisor = spawn_supervisor(&temp, 0, 1200, "e2e_vm_mock".to_string());
20+
21+ supervisor.clients = connect_clients(
22+ &supervisor.socket_path,
23+ 12,
24+ Duration::from_secs(2),
25+ true,
26+ "e2e_vm_mock",
27+ );
28+ log_line("e2e_vm_mock", "connected 12 clients");
29+
30+ assert_manager_alive_for(
31+ &mut supervisor.child,
32+ Duration::from_millis(900),
33+ "vm manager exited while clients active",
34+ );
35+
36+ let remaining = supervisor.clients.split_off(6);
37+ supervisor.clients = remaining;
38+ log_line("e2e_vm_mock", "dropped 6 clients");
39+ assert_manager_alive_for(
40+ &mut supervisor.child,
41+ Duration::from_millis(900),
42+ "vm manager exited while clients active",
43+ );
44+
45+ supervisor.clients.clear();
46+ log_line("e2e_vm_mock", "dropped remaining clients");
47+ wait_for_exit(&mut supervisor.child, Duration::from_secs(10));
48+ let status = supervisor.child.wait().unwrap();
49+ assert!(status.success(), "vm manager exited with {status}");
50+}
51+
52+#[test]
53+fn mock_vm_many_managers_many_clients() {
54+ let temp = TempDir::new().unwrap();
55+ let mut supervisors = Vec::new();
56+
57+ for idx in 0..3 {
58+ supervisors.push(spawn_supervisor(
59+ &temp,
60+ idx + 1,
61+ 1400,
62+ format!("e2e_vm_mock_{idx}"),
63+ ));
64+ }
65+
66+ for supervisor in &mut supervisors {
67+ supervisor.clients = connect_clients(
68+ &supervisor.socket_path,
69+ 8,
70+ Duration::from_secs(2),
71+ true,
72+ "e2e_vm_mock",
73+ );
74+ }
75+ log_line("e2e_vm_mock", "connected 8 clients per manager");
76+
77+ for supervisor in &mut supervisors {
78+ assert_manager_alive_for(
79+ &mut supervisor.child,
80+ Duration::from_millis(900),
81+ "manager exited while clients active",
82+ );
83+ }
84+
85+ supervisors[0].clients.clear();
86+ log_line("e2e_vm_mock", "dropped all clients for manager 0");
87+ wait_for_exit(&mut supervisors[0].child, Duration::from_secs(10));
88+ let status = supervisors[0].child.wait().unwrap();
89+ assert!(status.success(), "manager 0 exited with {status}");
90+
91+ for supervisor in supervisors.iter_mut().skip(1) {
92+ assert_manager_alive_for(
93+ &mut supervisor.child,
94+ Duration::from_millis(900),
95+ "another manager exited early",
96+ );
97+ }
98+
99+ for supervisor in supervisors.iter_mut().skip(1) {
100+ supervisor.clients.clear();
101+ }
102+ log_line("e2e_vm_mock", "dropped remaining clients");
103+ for supervisor in supervisors.iter_mut().skip(1) {
104+ wait_for_exit(&mut supervisor.child, Duration::from_secs(10));
105+ let status = supervisor.child.wait().unwrap();
106+ assert!(status.success(), "manager exited with {status}");
107+ }
108+}
109+
110+#[test]
111+fn mock_vm_monkey_processes() {
112+ let temp = TempDir::new().unwrap();
113+ let mut rng = Lcg::new(0x5eed_f00d_dead_beef);
114+ let mut supervisors = Vec::new();
115+ let mut next_id = 0usize;
116+ let max_supervisors = 4usize;
117+ let steps = 25usize;
118+
119+ supervisors.push(spawn_supervisor(
120+ &temp,
121+ next_id,
122+ 1200,
123+ format!("e2e_vm_monkey_{next_id}"),
124+ ));
125+ next_id += 1;
126+
127+ for step in 0..steps {
128+ prune_exited_supervisors(&mut supervisors, "e2e_vm_monkey");
129+ let roll = rng.gen_range(100);
130+ log_line(
131+ "e2e_vm_monkey",
132+ &format!("step {step} roll={roll} supervisors={}", supervisors.len()),
133+ );
134+ if roll < 20 && supervisors.len() < max_supervisors {
135+ supervisors.push(spawn_supervisor(
136+ &temp,
137+ next_id,
138+ 1200,
139+ format!("e2e_vm_monkey_{next_id}"),
140+ ));
141+ log_line("e2e_vm_monkey", &format!("spawned supervisor {next_id}"));
142+ next_id += 1;
143+ } else if roll < 45 && !supervisors.is_empty() {
144+ let idx = rng.gen_range(supervisors.len());
145+ let mut supervisor = supervisors.swap_remove(idx);
146+ log_line(
147+ "e2e_vm_monkey",
148+ &format!("killing supervisor {}", supervisor.label),
149+ );
150+ kill_supervisor(&mut supervisor, Duration::from_secs(5));
151+ } else if roll < 80 && !supervisors.is_empty() {
152+ let idx = rng.gen_range(supervisors.len());
153+ let burst = 1 + rng.gen_range(3);
154+ let new_clients = connect_clients(
155+ &supervisors[idx].socket_path,
156+ burst,
157+ Duration::from_secs(1),
158+ false,
159+ "e2e_vm_monkey",
160+ );
161+ supervisors[idx].clients.extend(new_clients);
162+ log_line(
163+ "e2e_vm_monkey",
164+ &format!("connected {burst} clients to {}", supervisors[idx].label),
165+ );
166+ } else if !supervisors.is_empty() {
167+ let idx = rng.gen_range(supervisors.len());
168+ if !supervisors[idx].clients.is_empty() {
169+ let len = supervisors[idx].clients.len();
170+ let drop_count = 1 + rng.gen_range(len);
171+ supervisors[idx].clients.drain(0..drop_count.min(len));
172+ log_line(
173+ "e2e_vm_monkey",
174+ &format!(
175+ "dropped {drop_count} clients from {}",
176+ supervisors[idx].label
177+ ),
178+ );
179+ }
180+ }
181+ thread::sleep(Duration::from_millis(200));
182+ }
183+
184+ log_line("e2e_vm_monkey", "final cleanup");
185+ for supervisor in supervisors.iter_mut() {
186+ shutdown_supervisor(supervisor, Duration::from_secs(10));
187+ }
188+}
189+
190+#[test]
191+fn mock_vm_exits_without_clients() {
192+ let temp = TempDir::new().unwrap();
193+ let mut supervisor = spawn_supervisor(&temp, 99, 300, "e2e_vm_no_clients".to_string());
194+ wait_for_exit(&mut supervisor.child, Duration::from_secs(5));
195+ let status = supervisor.child.wait().unwrap();
196+ assert!(status.success(), "vm manager exited with {status}");
197+}
198+
199+#[test]
200+fn mock_vm_reconnect_resets_shutdown() {
201+ let temp = TempDir::new().unwrap();
202+ let mut supervisor = spawn_supervisor(&temp, 100, 800, "e2e_vm_reconnect".to_string());
203+
204+ supervisor.clients = connect_clients(
205+ &supervisor.socket_path,
206+ 1,
207+ Duration::from_secs(2),
208+ true,
209+ "e2e_vm_reconnect",
210+ );
211+ supervisor.clients.clear();
212+ thread::sleep(Duration::from_millis(400));
213+
214+ supervisor.clients = connect_clients(
215+ &supervisor.socket_path,
216+ 1,
217+ Duration::from_secs(2),
218+ true,
219+ "e2e_vm_reconnect",
220+ );
221+ assert_manager_alive_for(
222+ &mut supervisor.child,
223+ Duration::from_millis(600),
224+ "vm manager exited despite reconnect",
225+ );
226+
227+ supervisor.clients.clear();
228+ wait_for_exit(&mut supervisor.child, Duration::from_secs(10));
229+ let status = supervisor.child.wait().unwrap();
230+ assert!(status.success(), "vm manager exited with {status}");
231+}
232+
233+struct Supervisor {
234+ child: Child,
235+ socket_path: PathBuf,
236+ clients: Vec<UnixStream>,
237+ label: String,
238+}
239+
240+fn write_config(project: &Path, auto_shutdown_ms: u64) {
241+ let config = format!(
242+ r#"[box]
243+cpu_count = 2
244+ram_mb = 2048
245+disk_gb = 5
246+mounts = []
247+
248+[supervisor]
249+auto_shutdown_ms = {auto_shutdown_ms}
250+"#
251+ );
252+ fs::write(project.join("vibebox.toml"), config).unwrap();
253+}
254+
255+fn spawn_supervisor(
256+ temp: &TempDir,
257+ idx: usize,
258+ auto_shutdown_ms: u64,
259+ label: String,
260+) -> Supervisor {
261+ let home = temp.path().join(format!("home-{idx}"));
262+ let cache_home = temp.path().join(format!("cache-{idx}"));
263+ let project = temp.path().join(format!("project-{idx}"));
264+ fs::create_dir_all(&home).unwrap();
265+ fs::create_dir_all(&cache_home).unwrap();
266+ fs::create_dir_all(&project).unwrap();
267+ write_config(&project, auto_shutdown_ms);
268+
269+ let mut child = Command::new(assert_cmd::cargo_bin!("vibebox-supervisor"))
270+ .current_dir(&project)
271+ .env("HOME", &home)
272+ .env("XDG_CACHE_HOME", &cache_home)
273+ .env("VIBEBOX_INTERNAL", "1")
274+ .stdout(Stdio::piped())
275+ .stderr(Stdio::piped())
276+ .spawn()
277+ .unwrap();
278+ if let Some(stdout) = child.stdout.take() {
279+ spawn_prefix_reader(label.clone(), "stdout", stdout);
280+ }
281+ if let Some(stderr) = child.stderr.take() {
282+ spawn_prefix_reader(label.clone(), "stderr", stderr);
283+ }
284+
285+ let socket_path = project.join(".vibebox").join("vm.sock");
286+ wait_for_socket(&socket_path, Duration::from_secs(10));
287+
288+ Supervisor {
289+ child,
290+ socket_path,
291+ clients: Vec::new(),
292+ label,
293+ }
294+}
295+
296+fn connect_clients(
297+ socket_path: &Path,
298+ count: usize,
299+ timeout: Duration,
300+ require_all: bool,
301+ label: &str,
302+) -> Vec<UnixStream> {
303+ let (tx, rx) = mpsc::channel();
304+ let mut handles = Vec::with_capacity(count);
305+ for _ in 0..count {
306+ let path = socket_path.to_path_buf();
307+ let tx = tx.clone();
308+ handles.push(thread::spawn(move || {
309+ let stream = connect_client_with_retry(&path, timeout);
310+ let _ = tx.send(stream);
311+ }));
312+ }
313+ drop(tx);
314+ let mut clients = Vec::with_capacity(count);
315+ for stream in rx.into_iter().flatten() {
316+ clients.push(stream);
317+ }
318+ for handle in handles {
319+ handle.join().unwrap();
320+ }
321+ if require_all && clients.len() != count {
322+ panic!(
323+ "client count mismatch: expected {count} got {}",
324+ clients.len()
325+ );
326+ }
327+ if !require_all && clients.len() != count {
328+ log_line(
329+ label,
330+ &format!("connected {} of {count} clients", clients.len()),
331+ );
332+ }
333+ clients
334+}
335+
336+fn connect_client_with_retry(path: &Path, timeout: Duration) -> Option<UnixStream> {
337+ let start = Instant::now();
338+ loop {
339+ match UnixStream::connect(path) {
340+ Ok(stream) => return Some(stream),
341+ Err(_) => {
342+ if start.elapsed() > timeout {
343+ return None;
344+ }
345+ thread::sleep(Duration::from_millis(50));
346+ }
347+ }
348+ }
349+}
350+
351+fn wait_for_socket(path: &Path, timeout: Duration) {
352+ let start = Instant::now();
353+ loop {
354+ if UnixStream::connect(path).is_ok() {
355+ return;
356+ }
357+ if start.elapsed() > timeout {
358+ panic!("timed out waiting for socket {}", path.display());
359+ }
360+ thread::sleep(Duration::from_millis(100));
361+ }
362+}
363+
364+fn assert_manager_alive(child: &mut Child, message: &str) {
365+ assert!(child.try_wait().unwrap().is_none(), "{message}");
366+}
367+
368+fn assert_manager_alive_for(child: &mut Child, duration: Duration, message: &str) {
369+ let start = Instant::now();
370+ while start.elapsed() < duration {
371+ assert_manager_alive(child, message);
372+ thread::sleep(Duration::from_millis(100));
373+ }
374+}
375+
376+fn kill_supervisor(supervisor: &mut Supervisor, timeout: Duration) {
377+ supervisor.clients.clear();
378+ let _ = supervisor.child.kill();
379+ wait_for_exit(&mut supervisor.child, timeout);
380+ let _ = supervisor.child.wait();
381+}
382+
383+fn prune_exited_supervisors(supervisors: &mut Vec<Supervisor>, label: &str) {
384+ supervisors.retain_mut(|supervisor| {
385+ if supervisor.child.try_wait().unwrap().is_some() {
386+ log_line(label, &format!("removed exited {}", supervisor.label));
387+ false
388+ } else {
389+ true
390+ }
391+ });
392+}
393+
394+fn shutdown_supervisor(supervisor: &mut Supervisor, timeout: Duration) {
395+ supervisor.clients.clear();
396+ wait_for_exit(&mut supervisor.child, timeout);
397+ if supervisor.child.try_wait().unwrap().is_none() {
398+ let _ = supervisor.child.kill();
399+ let _ = supervisor.child.wait();
400+ }
401+}
402+
403+fn wait_for_exit(child: &mut Child, timeout: Duration) {
404+ let start = Instant::now();
405+ loop {
406+ if child.try_wait().unwrap().is_some() {
407+ return;
408+ }
409+ if start.elapsed() > timeout {
410+ panic!("timed out waiting for mock vm supervisor exit");
411+ }
412+ thread::sleep(Duration::from_millis(200));
413+ }
414+}
415+
416+fn spawn_prefix_reader(label: String, stream: &'static str, reader: impl Read + Send + 'static) {
417+ thread::spawn(move || {
418+ let buf = BufReader::new(reader);
419+ for line in buf.lines() {
420+ match line {
421+ Ok(line) => println!("[{}][{}] {}", label, stream, line),
422+ Err(err) => {
423+ eprintln!("[{}][{}] read error: {}", label, stream, err);
424+ break;
425+ }
426+ }
427+ }
428+ });
429+}
430+
431+fn log_line(prefix: &str, message: &str) {
432+ println!("[{}] {}", prefix, message);
433+}
434+
435+struct Lcg {
436+ state: u64,
437+}
438+
439+impl Lcg {
440+ fn new(seed: u64) -> Self {
441+ Self { state: seed }
442+ }
443+
444+ fn next_u32(&mut self) -> u32 {
445+ self.state = self.state.wrapping_mul(6364136223846793005).wrapping_add(1);
446+ (self.state >> 32) as u32
447+ }
448+
449+ fn gen_range(&mut self, upper: usize) -> usize {
450+ if upper == 0 {
451+ return 0;
452+ }
453+ (self.next_u32() as usize) % upper
454+ }
455+}
tests/explain_integration.rsnew file+113
@@ -0,0 +1,113 @@
1+use std::{env, ffi::OsString, fs, path::Path, sync::Mutex};
2+
3+use tempfile::TempDir;
4+
5+use vibebox::session_manager::INSTANCE_DIR_NAME;
6+use vibebox::{config, explain};
7+
8+static ENV_MUTEX: Mutex<()> = Mutex::new(());
9+
10+struct EnvGuard {
11+ key: &'static str,
12+ previous: Option<OsString>,
13+}
14+
15+impl EnvGuard {
16+ fn set(key: &'static str, value: &Path) -> Self {
17+ let previous = env::var_os(key);
18+ unsafe {
19+ env::set_var(key, value);
20+ }
21+ Self { key, previous }
22+ }
23+}
24+
25+impl Drop for EnvGuard {
26+ fn drop(&mut self) {
27+ match &self.previous {
28+ Some(value) => unsafe {
29+ env::set_var(self.key, value);
30+ },
31+ None => unsafe {
32+ env::remove_var(self.key);
33+ },
34+ }
35+ }
36+}
37+
38+#[test]
39+fn build_mount_rows_includes_defaults_and_custom_mounts() {
40+ let _lock = ENV_MUTEX.lock().unwrap();
41+ let temp = TempDir::new().unwrap();
42+ let home = temp.path().join("home");
43+ let project = home.join("project");
44+ let cache_home = home.join("cache");
45+ fs::create_dir_all(&project).unwrap();
46+ fs::create_dir_all(&cache_home).unwrap();
47+
48+ let _home_guard = EnvGuard::set("HOME", &home);
49+ let _cache_guard = EnvGuard::set("XDG_CACHE_HOME", &cache_home);
50+
51+ let box_cfg = config::BoxConfig {
52+ mounts: vec!["data:~/data:read-only".to_string()],
53+ ..Default::default()
54+ };
55+ let cfg = config::Config {
56+ box_cfg,
57+ supervisor: config::SupervisorConfig::default(),
58+ };
59+
60+ let rows = explain::build_mount_rows(&project, &cfg).unwrap();
61+
62+ assert_eq!(rows.len(), 3);
63+ assert_eq!(rows[0].host, "~/project");
64+ assert_eq!(rows[0].guest, "~/project");
65+ assert_eq!(rows[0].mode, "read-write");
66+ assert_eq!(rows[0].default_mount, "yes");
67+
68+ assert_eq!(rows[1].host, "~/cache/vibebox/.guest-mise-cache");
69+ assert_eq!(rows[1].guest, "/root/.local/share/mise");
70+ assert_eq!(rows[1].mode, "read-write");
71+ assert_eq!(rows[1].default_mount, "yes");
72+
73+ assert_eq!(rows[2].host, "~/project/data");
74+ assert_eq!(rows[2].guest, "~/data");
75+ assert_eq!(rows[2].mode, "read-only");
76+ assert_eq!(rows[2].default_mount, "no");
77+}
78+
79+#[test]
80+fn build_network_rows_pending_without_instance_file() {
81+ let temp = TempDir::new().unwrap();
82+ let project = temp.path().join("project");
83+ fs::create_dir_all(&project).unwrap();
84+
85+ let rows = explain::build_network_rows(&project).unwrap();
86+
87+ assert_eq!(rows.len(), 1);
88+ assert_eq!(rows[0].network_type, "NAT");
89+ assert_eq!(rows[0].vm_ip, "-");
90+ assert_eq!(rows[0].host_to_vm, "ssh: <pending>:22");
91+ assert_eq!(rows[0].vm_to_host, "none");
92+}
93+
94+#[test]
95+fn build_network_rows_uses_instance_vm_ip() {
96+ let temp = TempDir::new().unwrap();
97+ let project = temp.path().join("project");
98+ let instance_dir = project.join(INSTANCE_DIR_NAME);
99+ fs::create_dir_all(&instance_dir).unwrap();
100+ fs::write(
101+ instance_dir.join("instance.toml"),
102+ "vm_ipv4 = \"10.1.2.3\"\n",
103+ )
104+ .unwrap();
105+
106+ let rows = explain::build_network_rows(&project).unwrap();
107+
108+ assert_eq!(rows.len(), 1);
109+ assert_eq!(rows[0].network_type, "NAT");
110+ assert_eq!(rows[0].vm_ip, "10.1.2.3");
111+ assert_eq!(rows[0].host_to_vm, "ssh: 10.1.2.3:22");
112+ assert_eq!(rows[0].vm_to_host, "none");
113+}
vibebox.toml+11
@@ -0,0 +1,11 @@
1+[box]
2+cpu_count = 2
3+ram_mb = 2048
4+disk_gb = 5
5+mounts = [
6+ "~/.codex:~/.codex:read-write",
7+ "~/.claude:~/.claude:read-write",
8+]
9+
10+[supervisor]
11+auto_shutdown_ms = 2000