on main help sign in
.github/workflows/ci.ymlnew file+98
since splitsnapshots
unifiedsplit
@@ -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"
32source = "registry+https://github.com/rust-lang/crates.io-index"
32source = "registry+https://github.com/rust-lang/crates.io-index"
33checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
33checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
34
34
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+
35[[package]]
106[[package]]
36name = "backtrace"
107name = "backtrace"
37version = "0.3.76"
108version = "0.3.76"
@@ -62,6 +133,17 @@ dependencies = [
62 "objc2",
133 "objc2",
63]
134]
64
135
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+
65[[package]]
147[[package]]
66name = "bumpalo"
148name = "bumpalo"
67version = "3.19.1"
149version = "3.19.1"
@@ -69,10 +151,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
69checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
151checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
70
152
71[[package]]
153[[package]]
72−name = "bytes"
154+name = "bytesize"
73−version = "1.11.1"
155+version = "2.3.1"
74source = "registry+https://github.com/rust-lang/crates.io-index"
156source = "registry+https://github.com/rust-lang/crates.io-index"
75−checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
157+checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3"
158+dependencies = [
159+ "serde_core",
160+]
76
161
77[[package]]
162[[package]]
78name = "cassowary"
163name = "cassowary"
@@ -95,6 +180,46 @@ version = "1.0.4"
95source = "registry+https://github.com/rust-lang/crates.io-index"
180source = "registry+https://github.com/rust-lang/crates.io-index"
96checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
181checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
97
182
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+
98[[package]]
223[[package]]
99name = "color-eyre"
224name = "color-eyre"
100version = "0.6.5"
225version = "0.6.5"
@@ -122,6 +247,12 @@ dependencies = [
122 "tracing-error",
247 "tracing-error",
123]
248]
124
249
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+
125[[package]]
256[[package]]
126name = "compact_str"
257name = "compact_str"
127version = "0.8.1"
258version = "0.8.1"
@@ -136,6 +267,19 @@ dependencies = [
136 "static_assertions",
267 "static_assertions",
137]
268]
138
269
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+
139[[package]]
283[[package]]
140name = "crossterm"
284name = "crossterm"
141version = "0.28.1"
285version = "0.28.1"
@@ -206,6 +350,24 @@ dependencies = [
206 "serde_core",
350 "serde_core",
207]
351]
208
352
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+
209[[package]]
371[[package]]
210name = "dispatch2"
372name = "dispatch2"
211version = "0.3.0"
373version = "0.3.0"
@@ -224,6 +386,12 @@ version = "1.15.0"
224source = "registry+https://github.com/rust-lang/crates.io-index"
386source = "registry+https://github.com/rust-lang/crates.io-index"
225checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
387checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
226
388
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+
227[[package]]
395[[package]]
228name = "equivalent"
396name = "equivalent"
229version = "1.0.2"
397version = "1.0.2"
@@ -262,95 +430,12 @@ version = "0.1.5"
262source = "registry+https://github.com/rust-lang/crates.io-index"
430source = "registry+https://github.com/rust-lang/crates.io-index"
263checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
431checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
264
432
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−
290[[package]]
433[[package]]
291name = "futures-core"
434name = "futures-core"
292version = "0.3.31"
435version = "0.3.31"
293source = "registry+https://github.com/rust-lang/crates.io-index"
436source = "registry+https://github.com/rust-lang/crates.io-index"
294checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
437checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
295
438
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−
354[[package]]
439[[package]]
355name = "getrandom"
440name = "getrandom"
356version = "0.3.4"
441version = "0.3.4"
@@ -436,6 +521,12 @@ dependencies = [
436 "syn",
521 "syn",
437]
522]
438
523
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+
439[[package]]
530[[package]]
440name = "itertools"
531name = "itertools"
441version = "0.13.0"
532version = "0.13.0"
@@ -467,12 +558,6 @@ version = "1.5.0"
467source = "registry+https://github.com/rust-lang/crates.io-index"
558source = "registry+https://github.com/rust-lang/crates.io-index"
468checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
559checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
469
560
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−
476[[package]]
561[[package]]
477name = "libc"
562name = "libc"
478version = "0.2.180"
563version = "0.2.180"
@@ -557,7 +642,7 @@ version = "0.50.3"
557source = "registry+https://github.com/rust-lang/crates.io-index"
642source = "registry+https://github.com/rust-lang/crates.io-index"
558checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
643checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
559dependencies = [
644dependencies = [
560− "windows-sys 0.59.0",
645+ "windows-sys 0.61.2",
561]
646]
562
647
563[[package]]
648[[package]]
@@ -646,6 +731,12 @@ version = "1.21.3"
646source = "registry+https://github.com/rust-lang/crates.io-index"
731source = "registry+https://github.com/rust-lang/crates.io-index"
647checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
732checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
648
733
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+
649[[package]]
740[[package]]
650name = "owo-colors"
741name = "owo-colors"
651version = "4.2.3"
742version = "4.2.3"
@@ -687,18 +778,39 @@ version = "0.2.16"
687source = "registry+https://github.com/rust-lang/crates.io-index"
778source = "registry+https://github.com/rust-lang/crates.io-index"
688checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
779checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
689
780
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−
696[[package]]
781[[package]]
697name = "powerfmt"
782name = "powerfmt"
698version = "0.2.0"
783version = "0.2.0"
699source = "registry+https://github.com/rust-lang/crates.io-index"
784source = "registry+https://github.com/rust-lang/crates.io-index"
700checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
785checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
701
786
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+
702[[package]]
814[[package]]
703name = "proc-macro2"
815name = "proc-macro2"
704version = "1.0.106"
816version = "1.0.106"
@@ -868,6 +980,12 @@ dependencies = [
868 "lazy_static",
980 "lazy_static",
869]
981]
870
982
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+
871[[package]]
989[[package]]
872name = "signal-hook"
990name = "signal-hook"
873version = "0.3.18"
991version = "0.3.18"
@@ -899,28 +1017,12 @@ dependencies = [
899 "libc",
1017 "libc",
900]
1018]
901
1019
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−
908[[package]]
1020[[package]]
909name = "smallvec"
1021name = "smallvec"
910version = "1.15.1"
1022version = "1.15.1"
911source = "registry+https://github.com/rust-lang/crates.io-index"
1023source = "registry+https://github.com/rust-lang/crates.io-index"
912checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
1024checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
913
1025
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−
924[[package]]
1026[[package]]
925name = "static_assertions"
1027name = "static_assertions"
926version = "1.1.0"
1028version = "1.1.0"
@@ -979,6 +1081,12 @@ dependencies = [
979 "windows-sys 0.61.2",
1081 "windows-sys 0.61.2",
980]
1082]
981
1083
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+
982[[package]]
1090[[package]]
983name = "thiserror"
1091name = "thiserror"
984version = "2.0.18"
1092version = "2.0.18"
@@ -1039,34 +1147,6 @@ dependencies = [
1039 "time-core",
1147 "time-core",
1040]
1148]
1041
1149
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−
1070[[package]]
1150[[package]]
1071name = "toml"
1151name = "toml"
1072version = "0.9.11+spec-1.1.0"
1152version = "0.9.11+spec-1.1.0"
@@ -1177,16 +1257,6 @@ dependencies = [
1177 "tracing-log",
1257 "tracing-log",
1178]
1258]
1179
1259
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−
1190[[package]]
1260[[package]]
1191name = "unicode-ident"
1261name = "unicode-ident"
1192version = "1.0.22"
1262version = "1.0.22"
@@ -1222,6 +1292,12 @@ version = "0.2.0"
1222source = "registry+https://github.com/rust-lang/crates.io-index"
1292source = "registry+https://github.com/rust-lang/crates.io-index"
1223checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
1293checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
1224
1294
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+
1225[[package]]
1301[[package]]
1226name = "uuid"
1302name = "uuid"
1227version = "1.20.0"
1303version = "1.20.0"
@@ -1242,14 +1318,17 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
1242
1318
1243[[package]]
1319[[package]]
1244name = "vibebox"
1320name = "vibebox"
1245−version = "0.1.0"
1321+version = "0.3.2"
1246dependencies = [
1322dependencies = [
1323+ "anyhow",
1324+ "assert_cmd",
1247 "block2",
1325 "block2",
1326+ "bytesize",
1327+ "clap",
1248 "color-eyre",
1328 "color-eyre",
1249 "crossterm",
1329 "crossterm",
1330+ "dialoguer",
1250 "dispatch2",
1331 "dispatch2",
1251− "futures",
1252− "lexopt",
1253 "libc",
1332 "libc",
1254 "objc2",
1333 "objc2",
1255 "objc2-foundation",
1334 "objc2-foundation",
@@ -1259,15 +1338,21 @@ dependencies = [
1259 "tempfile",
1338 "tempfile",
1260 "thiserror",
1339 "thiserror",
1261 "time",
1340 "time",
1262− "tokio",
1263 "toml",
1341 "toml",
1264 "tracing",
1342 "tracing",
1265 "tracing-subscriber",
1343 "tracing-subscriber",
1266− "tui-textarea",
1267− "unicode-width 0.2.0",
1268 "uuid",
1344 "uuid",
1269]
1345]
1270
1346
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+
1271[[package]]
1356[[package]]
1272name = "wasi"
1357name = "wasi"
1273version = "0.11.1+wasi-snapshot-preview1"
1358version = "0.11.1+wasi-snapshot-preview1"
@@ -1362,16 +1447,7 @@ version = "0.59.0"
1362source = "registry+https://github.com/rust-lang/crates.io-index"
1447source = "registry+https://github.com/rust-lang/crates.io-index"
1363checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
1448checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
1364dependencies = [
1449dependencies = [
1365− "windows-targets 0.52.6",
1450+ "windows-targets",
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",
1375]
1451]
1376
1452
1377[[package]]
1453[[package]]
@@ -1389,31 +1465,14 @@ version = "0.52.6"
1389source = "registry+https://github.com/rust-lang/crates.io-index"
1465source = "registry+https://github.com/rust-lang/crates.io-index"
1390checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
1466checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
1391dependencies = [
1467dependencies = [
1392− "windows_aarch64_gnullvm 0.52.6",
1468+ "windows_aarch64_gnullvm",
1393− "windows_aarch64_msvc 0.52.6",
1469+ "windows_aarch64_msvc",
1394− "windows_i686_gnu 0.52.6",
1470+ "windows_i686_gnu",
1395− "windows_i686_gnullvm 0.52.6",
1471+ "windows_i686_gnullvm",
1396− "windows_i686_msvc 0.52.6",
1472+ "windows_i686_msvc",
1397− "windows_x86_64_gnu 0.52.6",
1473+ "windows_x86_64_gnu",
1398− "windows_x86_64_gnullvm 0.52.6",
1474+ "windows_x86_64_gnullvm",
1399− "windows_x86_64_msvc 0.52.6",
1475+ "windows_x86_64_msvc",
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",
1417]
1476]
1418
1477
1419[[package]]
1478[[package]]
@@ -1422,96 +1481,48 @@ version = "0.52.6"
1422source = "registry+https://github.com/rust-lang/crates.io-index"
1481source = "registry+https://github.com/rust-lang/crates.io-index"
1423checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
1482checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
1424
1483
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−
1431[[package]]
1484[[package]]
1432name = "windows_aarch64_msvc"
1485name = "windows_aarch64_msvc"
1433version = "0.52.6"
1486version = "0.52.6"
1434source = "registry+https://github.com/rust-lang/crates.io-index"
1487source = "registry+https://github.com/rust-lang/crates.io-index"
1435checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
1488checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
1436
1489
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−
1443[[package]]
1490[[package]]
1444name = "windows_i686_gnu"
1491name = "windows_i686_gnu"
1445version = "0.52.6"
1492version = "0.52.6"
1446source = "registry+https://github.com/rust-lang/crates.io-index"
1493source = "registry+https://github.com/rust-lang/crates.io-index"
1447checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
1494checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
1448
1495
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−
1455[[package]]
1496[[package]]
1456name = "windows_i686_gnullvm"
1497name = "windows_i686_gnullvm"
1457version = "0.52.6"
1498version = "0.52.6"
1458source = "registry+https://github.com/rust-lang/crates.io-index"
1499source = "registry+https://github.com/rust-lang/crates.io-index"
1459checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
1500checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
1460
1501
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−
1467[[package]]
1502[[package]]
1468name = "windows_i686_msvc"
1503name = "windows_i686_msvc"
1469version = "0.52.6"
1504version = "0.52.6"
1470source = "registry+https://github.com/rust-lang/crates.io-index"
1505source = "registry+https://github.com/rust-lang/crates.io-index"
1471checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
1506checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
1472
1507
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−
1479[[package]]
1508[[package]]
1480name = "windows_x86_64_gnu"
1509name = "windows_x86_64_gnu"
1481version = "0.52.6"
1510version = "0.52.6"
1482source = "registry+https://github.com/rust-lang/crates.io-index"
1511source = "registry+https://github.com/rust-lang/crates.io-index"
1483checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
1512checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
1484
1513
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−
1491[[package]]
1514[[package]]
1492name = "windows_x86_64_gnullvm"
1515name = "windows_x86_64_gnullvm"
1493version = "0.52.6"
1516version = "0.52.6"
1494source = "registry+https://github.com/rust-lang/crates.io-index"
1517source = "registry+https://github.com/rust-lang/crates.io-index"
1495checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
1518checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
1496
1519
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−
1503[[package]]
1520[[package]]
1504name = "windows_x86_64_msvc"
1521name = "windows_x86_64_msvc"
1505version = "0.52.6"
1522version = "0.52.6"
1506source = "registry+https://github.com/rust-lang/crates.io-index"
1523source = "registry+https://github.com/rust-lang/crates.io-index"
1507checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
1524checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
1508
1525
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−
1515[[package]]
1526[[package]]
1516name = "winnow"
1527name = "winnow"
1517version = "0.7.14"
1528version = "0.7.14"
@@ -1523,3 +1534,9 @@ name = "wit-bindgen"
1523version = "0.51.0"
1534version = "0.51.0"
1524source = "registry+https://github.com/rust-lang/crates.io-index"
1535source = "registry+https://github.com/rust-lang/crates.io-index"
1525checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
1536checksum = "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 @@
1[package]
1[package]
2name = "vibebox"
2name = "vibebox"
3−version = "0.1.0"
3+version = "0.3.2"
4edition = "2024"
4edition = "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"
5
14
6[dependencies]
15[dependencies]
7−objc2 = "*"
16+objc2 = "0.6.3"
8−objc2-foundation = { version = "*", features = [
17+objc2-foundation = { version = "0.3.2", features = [
9 "NSArray",
18 "NSArray",
10 "NSString",
19 "NSString",
11 "NSURL",
20 "NSURL",
@@ -16,11 +25,11 @@ objc2-foundation = { version = "*", features = [
16 "NSRunLoop",
25 "NSRunLoop",
17 "NSObject",
26 "NSObject",
18] }
27] }
19−objc2-virtualization = "*"
28+objc2-virtualization = "0.3.2"
20−block2 = "*"
29+block2 = "0.6.2"
21−dispatch2 = "*"
30+dispatch2 = "0.3.0"
22−libc = "*"
31+libc = "0.2.180"
23−lexopt = "0.3"
32+clap = { version = "4", features = ["derive"] }
24serde = { version = "1", features = ["derive"] }
33serde = { version = "1", features = ["derive"] }
25tempfile = "3"
34tempfile = "3"
26thiserror = "2.0.18"
35thiserror = "2.0.18"
@@ -29,10 +38,16 @@ toml = "0.9.8"
29uuid = { version = "1", features = ["v7", "serde"] }
38uuid = { version = "1", features = ["v7", "serde"] }
30color-eyre = "0.6.3"
39color-eyre = "0.6.3"
31crossterm = { version = "0.28.1", features = ["event-stream"] }
40crossterm = { version = "0.28.1", features = ["event-stream"] }
32−futures = "0.3.31"
33ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] }
41ratatui = { 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"
37tracing = "0.1"
42tracing = "0.1"
38tracing-subscriber = { version = "0.3", features = ["env-filter"] }
43tracing-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 = []
LICENSE+1−1
@@ -1,4 +1,4 @@
1−Copyright 2026 Kevin Lynagh
1+Copyright 2026 Finn Sheng
2
2
3Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
3Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
4
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:
68
68
69In vibebox:
69In vibebox:
70
70
71−- use `:new` to prompt user to delete and create a session.
72- use `:exit` to exit vibebox.
71- use `:exit` to exit vibebox.
73
72
74### (Shows all the mounts/network/visibility)
73### (Shows all the mounts/network/visibility)
docs/screenshot.pngnew file

Binary file not shown.

docs/tasks.md+28−11
@@ -25,7 +25,7 @@
255. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar.
255. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar.
266. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio.
266. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio.
277. [x] Add a standalone TUI CLI binary (no main.rs wiring) with placeholder VM info and TODOs for VM integration.
277. [x] Add a standalone TUI CLI binary (no main.rs wiring) with placeholder VM info and TODOs for VM integration.
28−8. [ ] Run tests and validate coverage for the new module.
28+8. [x] Run tests and validate coverage for the new module.
29
29
30## TUI
30## TUI
31
31
@@ -38,17 +38,34 @@
381. [x] Wire up the vm and tui.
381. [x] Wire up the vm and tui.
392. [x] Use ssh to connect to vm.
392. [x] Use ssh to connect to vm.
403. [x] allow multi vibebox to connect to the same vm.
403. [x] allow multi vibebox to connect to the same vm.
41−4. [ ] use vm.lock to ensure process concurrency safety.
41+4. [x] use vm.lock to ensure process concurrency safety.
42−5. [ ] wire up SessionManager.
42+5. [x] wire up SessionManager.
43−6. [ ] VM should be separated by a per-session VM daemon process (only accepts if to shut down vm and itself).
43+6. [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
44+7. [x] setup vibebox commands
45−8. [ ] setup cli 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.
46−9. [ ] fix ui overlap.
50+9. [x] fix ui overlap, and consistency issue.
51+10. [x] `purge-cache` to clear the cache.
52+11. [x] intensive integration test.
47
53
48## Publish
54## Publish
49
55
50−1. [ ] write the docs
56+1. [x] write the docs.
51−2. [ ] setup quick link.
57+2. [x] setup quick install link.
52−3. [ ] setup website.
58+3. [x] setup website.
53
59
54−[ ]
60+## Stage 2
61+
62+1. [ ] retouch the cli ux.
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 @@
2set -euo pipefail
2set -euo pipefail
3
3
4APP=vibebox
4APP=vibebox
5−REPO_URL="https://github.com/opencode-ai/vibebox"
5+REPO_URL="https://github.com/robcholz/vibebox"
6REQUESTED_VERSION=${VERSION:-}
6REQUESTED_VERSION=${VERSION:-}
7
7
8RED='\033[0;31m'
8RED='\033[0;31m'
@@ -38,9 +38,58 @@ require_cmd() {
38 fi
38 fi
39}
39}
40
40
41−require_cmd cargo "Install Rust (cargo) from https://rustup.rs and retry."
42require_cmd git "Install git and retry."
41require_cmd git "Install git and retry."
43
42
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+
44CARGO_HOME=${CARGO_HOME:-$HOME/.cargo}
93CARGO_HOME=${CARGO_HOME:-$HOME/.cargo}
45INSTALL_DIR="$CARGO_HOME/bin"
94INSTALL_DIR="$CARGO_HOME/bin"
46
95
@@ -68,66 +117,10 @@ print_message info "Using cargo install from ${ORANGE}${REPO_URL}${GREEN}..."
68
117
69cargo "${install_args[@]}"
118cargo "${install_args[@]}"
70
119
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−
122if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
120if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
123− case $current_shell in
121+ print_message warning "Cargo bin directory is not on PATH."
124− fish)
122+ print_message info "Add this to your shell profile if you can't run ${APP}:"
125− add_to_path "$config_file" "fish_add_path $INSTALL_DIR"
126− ;;
127− *)
128− add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
123+ print_message info " export PATH=$INSTALL_DIR:\$PATH"
129− ;;
130− esac
131fi
124fi
132
125
133if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
126if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
readme.mddeleted−208
@@ -1,208 +0,0 @@
1−Vibe is a quick, zero-configuration way to spin up a Linux virtual machine on Mac to sandbox LLM agents:
2−
3−```
4−$ cd my-project
5−$ vibe
6−
7−░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░
8−░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
9− ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
10− ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓███████▓▒░░▒▓██████▓▒░
11− ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
12− ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
13− ░▒▓██▓▒░ ░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░
14−
15−Host Guest Mode
16−---------------------------------------- ----------------------- ----------
17−/Users/dev/work/my-project /root/my-project read-write
18−/Users/dev/.cache/vibe/.guest-mise-cache /root/.local/share/mise read-write
19−/Users/dev/.m2 /root/.m2 read-write
20−/Users/dev/.cargo/registry /root/.cargo/registry read-write
21−/Users/dev/.codex /root/.codex read-write
22−/Users/dev/.claude /root/.claude read-write
23−
24−root@vibe:~/my-project#
25−```
26−
27−On my M1 MacBook Air it takes ~10 seconds to boot.
28−
29−
30−Dependencies:
31−
32−- An ARM-based Mac running MacOS 13 (Ventura) or higher.
33−- A network connection is required on the first run to download and configure the Debian Linux base image.
34−- That's it!
35−
36−
37−## Why use Vibe?
38−
39−- LLM agents are more fun to use with `--yolo`, since they're not always interrupting you to approve their commands.
40−- Sandboxing the agent in a VM lets it install/remove whatever tools its lil' transformer heart desires, *without* wrecking your actual machine.
41−- You control what the agent (and thus the upstream LLM provider) can actually see, by controlling exactly what's shared into the VM sandbox.
42− (This project was inspired by me running `codex` *without* `--yolo` and seeing it reading files outside of the directory I started it in --- not cool, bro.)
43−
44−I'm using virtual machines rather than containers because:
45−
46−- Virtualization is more secure against malicious escapes than containers or the MacOS sandbox framework.
47−- Containers on MacOS require spinning up a virtual machine anyway.
48−
49−Finally, as a matter of taste and style:
50−
51−- The binary is < 1 MB.
52−- I wrote the entire README myself, 100% with my human brain.
53−- The entire implementation is in one ~1200 line Rust file.
54−- The only Rust dependencies are the [Objc2](https://github.com/madsmtm/objc2) interop crates and the [lexopt](https://github.com/blyxxyz/lexopt) argument parser.
55−- There are no emoji anywhere in this repository.
56−
57−
58−## Install
59−
60−Vibe is a single binary built with Rust.
61−
62−Download [the latest binary built by GitHub actions](https://github.com/lynaghk/vibe/releases/tag/latest) and put it somewhere on your `$PATH`:
63−
64− curl -LO https://github.com/lynaghk/vibe/releases/download/latest/vibe-macos-arm64.zip
65− unzip vibe-macos-arm64.zip
66− mkdir -p ~/.local/bin
67− mv vibe ~/.local/bin
68− export PATH="$HOME/.local/bin:$PATH"
69−
70−If you use [mise-en-place](https://mise.jdx.dev/):
71−
72− mise use github:lynaghk/vibe@latest
73−
74−I'm not making formal releases or keeping a change log.
75−I recommend reading the commit history and pinning to a specific version.
76−
77−You can also install via `cargo`:
78−
79− cargo install --locked --git https://github.com/lynaghk/vibe.git
80−
81−If you don't have `cargo`, you need to install Rust:
82−
83− curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
84−
85−
86−## Using Vibe
87−
88−
89−```
90−vibe [OPTIONS] [disk-image.raw]
91−
92−Options
93−
94− --help Print this help message.
95− --version Print the version (commit SHA).
96− --no-default-mounts Disable all default mounts.
97− --mount host-path:guest-path[:read-only | :read-write] Mount `host-path` inside VM at `guest-path`.
98− Defaults to read-write.
99− Errors if host-path does not exist.
100− --cpus <count> Number of virtual CPUs (default 2).
101− --ram <megabytes> RAM size in megabytes (default 2048).
102− --script <path/to/script.sh> Run script in VM.
103− --send <some-command> Type `some-command` followed by newline into the VM.
104− --expect <string> [timeout-seconds] Wait for `string` to appear in console output before executing next `--script` or `--send`.
105− If `string` does not appear within timeout (default 30 seconds), shutdown VM with error.
106−```
107−
108−Invoking vibe without a disk image:
109−
110−- shares the current directory with the VM
111−- shares package manager cache directories with the VM, so that packages are not re-downloaded
112−- shares the `~/.codex` directory with the VM, so you can use OpenAI's [codex](https://openai.com/codex/)
113−- shares the `~/.claude` directory with the VM, so you can use Anthropic's [claude](https://claude.com/product/claude-code)
114−
115−The first time you run `vibe`, a Debian Linux image is downloaded to `~/.cache/vibe/`, configured with basic tools like gcc, [mise-en-place](https://mise.jdx.dev/), ripgrep, rust, etc., and saved as `default.raw`.
116−(See [provision.sh](/src/provision.sh) for details.)
117−
118−Then when you run `vibe` in a project directory, it copies this default image to `.vibe/instance.raw`, boots it up, and attaches your terminal to this VM.
119−
120−When you `exit` this shell, the VM is shutdown.
121−The disk state persists until you delete it.
122−There is no centralized registry of VMs --- if you want to delete a VM, just delete its disk image file.
123−
124−## Other notes
125−
126−- Apple Filesystem is copy-on-write, so instance images only use disk space when they diverge from the default image.
127− You can use `du -h` to see how much space is actually consumed:
128−
129− $ /bin/ls -lah .vibe/instance.raw
130− -rw-r--r-- 1 dev staff 10G Jan 25 20:41 .vibe/instance.raw
131−
132− $ du -h .vibe/instance.raw
133− 2.3G .vibe/instance.raw
134−
135−- MacOS only lets binaries signed with the `com.apple.security.virtualization` entitlement run virtual machines, so `vibe` checks itself on startup and, if necessary, signs itself using `codesign`. SeCuRiTy!
136−
137−- Debian "nocloud" is used as a base image because it boots directly to a root prompt.
138− The other images use [cloudinit](https://cloudinit.readthedocs.io/en/latest/), which I found much more complex:
139− - Network requests are made during the boot process, and if you're offline they take several *minutes* to timeout before the login prompt is reached (thanks, `systemd-networkd-wait-online.service`).
140− - Subsequent boots are much slower (at least, I couldn't easily figure out how to remove the associated cloud init machinery).
141−
142−
143−## Alternatives
144−
145−Here's what I tried before writing this solution:
146−
147−- [Sandboxtron](https://github.com/lynaghk/sandboxtron/) - My own little wrapper around Mac's `sandbox-exec`.
148−Turns out both Claude Code and Codex rely on this as well, and MacOS doesn't allow creating a sandbox from within a sandbox.
149−I considered writing my own sandboxing rules and running the agents `--yolo`, but didn't like the risk of configuration typos and/or Mac sandbox escapes (there are a lot --- I'm not an expert, but from [this HN discussion](https://news.ycombinator.com/item?id=42084588) I figured virtualization would be safer).
150−
151−- [Lima](https://github.com/lima-vm/lima/), quick Linux VMs on Mac. I wanted to like this, ran into too many issues in first 30 minutes to trust it:
152− - The recommended Debian image took 8 seconds to get to a login prompt, even after the VM was already running.
153− - The CLI flags *mutate hidden state*. E.g., If you `limactl start --mount foo` and then later `limactl start --mount bar`, both `foo` and `bar` will be mounted.
154− - Some capabilities are only available via yaml. E.g., the `--mount` CLI flag always mounts at the same path in the guest. If you want to mount at a different path, you have to do that via YAML.
155− - There are many layers of inheritance/defaults, so even if you do write YAML, you can't see the full configuration.
156−
157−- [Vagrant](https://developer.hashicorp.com/vagrant/) - I fondly remember using this back in the early 2010's, but based on this [2025 Reddit discussion](https://www.reddit.com/r/devops/comments/1axws75/vagrant_doesnt_support_mac_m1/) it seemed like running it on an ARM-based Mac was A Project and so I figured it'd be easier to roll my own thing.
158−
159−- [Tart](https://tart.run/) - I found this via some positive HN comments, but unfortunately wasn't able to run the release binary from their GitHub because it's not signed.
160−They apparently hack around that when installing with homebrew, but I don't use homebrew either.
161−I tried cloning the repo and compiling myself, but the build failed with lots of language syntax errors despite the repo SHA is the same as one of their releases.
162−I assume this is a Swift problem and not Tart's, since this sort of mess happens most times when I try to build Swift. `¯\_(ツ)_/¯`
163−
164−- [OrbStack](https://orbstack.dev/) - This looked nice, but seems mostly geared towards container stuff.
165−It runs a single VM, and I couldn't figure out how to have this VM run *without* my entire disk mounted inside of it.
166−I didn't want to run agents via containers, since containers aren't security boundaries.
167−
168−- [Apple Container Framework](https://github.com/apple/container) - This looks technically promising, as it runs every container within a lightweight VM.
169−Unfortunately it requires MacOS 26 Tahoe, which wrecks [window resizing](https://news.ycombinator.com/item?id=46579864), adds [useless icons everywhere](https://news.ycombinator.com/item?id=46497712), and otherwise seems to be a mess.
170−Sorry excellent Apple programmers and hardware designers, I hope your management can reign in the haute couture folks before we all have to switch to Linux for professional computing.
171−
172−- [QEMU](https://wiki.qemu.org/) - The first prototype of this app was [a single bash script](https://github.com/lynaghk/vibe/blob/1c82fd3b9fabf93abba2680fc856458e97a105cd/qemu.sh) wrapping `qemu`. This worked swimmingly, except for host/guest directory sharing, which ended up being a show-stopper. This is because QEMU doesn't support [virtiofs](https://virtio-fs.gitlab.io/) on Mac hosts, it only supports "9p", which is way slower --- e.g., `mise use node@latest` takes > 10 minutes on 9p and 5 seconds on virtiofs.
173−
174−
175−## Roadmap / Collaboration
176−
177−I wrote this software for myself, and I'm open to pull requests and otherwise collaborating on features that I'd personally use:
178−
179−- resizing disk images
180−- forwarding ports from the host to a guest
181−- running `vibe` against a disk image that's already running should connect to the already-running VM
182− - the VM shouldn't shutdown until all host terminals have logged out
183−- if not the above, at least a check and throw a nice error message when you try to start a VM that's already running
184−- a way to make faster-booting even more minimal Linux virtual machines
185− - this should be bootstrappable on Mac; i.e., if the only way to make a small Linux image is with Linux-only tools, the entire process should still be runnable on MacOS via intermediate VMs
186−- propagate an exit code from within VM to the `vibe` command
187−- don't propagate user typing until all provided `--expect` and `--send` actions have completed
188−- CPU core / memory / networking configuration, possibly via flags or via extended attributes on the disk image file
189−- a `--plan` flag which pretty-prints a CLI invocation with all of the default arguments shown
190− - to keep ourselves honest, we should use the same codepath for the actual execution (maybe we can `exec` into the generated command?)
191− - Being fully "explicit" is tricky due to flag interactions.
192− E.g., the friendly `--mount` would need to be decomposed into two flags: One that exposes the host directory in the guest's staging area at `/mnt/shared/` and another flag `--send 'mount --bind ...'`to bind this to the desired guest location.
193−
194−I'm not sure about (but open to discussing proposals via GitHub issues):
195−
196−- running VMs in the background
197−- supporting Linux hosts
198−- supporting guests beyond Debian Linux
199−- using SSH as a login mechanism; this would eliminate the current stdin/stdout-to-console plumbing (yay!) but require additional setup/configuration (boo!)
200−- making Claude Code work seamlessly
201− - I tried the native installer but it sometimes fails during setup (see 6352d13), so I switched back to NPM via mise which is more reliable.
202− - The `~/.claude` folder is shared in the VM by default, by apparently the `~/.claude.json` file is also required for auth credentials and session history.
203− I'm not sure the best way to share a file between host and VM (virtioFS only works with folders).
204− Also: Wild to me that Anthropic puts both a file and a folder in your home directory --- how rude!
205−
206−I'm not interested in:
207−
208−- anything related to Docker / containers / Kubernetes / distributed systems
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−106
@@ -1,106 +0,0 @@
1−use std::{
2− env,
3− ffi::OsString,
4− fs,
5− io::{self, IsTerminal, Write},
6− path::Path,
7− sync::{Arc, Mutex},
8−};
9−
10−use color_eyre::Result;
11−use serde::Deserialize;
12−use tracing_subscriber::EnvFilter;
13−
14−use vibebox::tui::{AppState, VmInfo};
15−use vibebox::{instance, tui, vm, vm_manager};
16−
17−const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 3000;
18−
19−#[derive(Debug, Default, Deserialize)]
20−struct ProjectConfig {
21− auto_shutdown_ms: Option<u64>,
22−}
23−
24−fn main() -> Result<()> {
25− init_tracing();
26− color_eyre::install()?;
27−
28− let raw_args: Vec<OsString> = env::args_os().collect();
29−
30− if env::var("VIBEBOX_VM_MANAGER").as_deref() == Ok("1") {
31− tracing::info!("starting vm manager mode");
32− let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
33− let auto_shutdown_ms = env::var("VIBEBOX_AUTO_SHUTDOWN_MS")
34− .ok()
35− .and_then(|value| value.parse::<u64>().ok())
36− .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS);
37− tracing::info!(auto_shutdown_ms, "vm manager config");
38− if let Err(err) = vm_manager::run_manager(args, auto_shutdown_ms) {
39− tracing::error!(error = %err, "vm manager exited");
40− return Err(color_eyre::eyre::eyre!(err.to_string()));
41− }
42− return Ok(());
43− }
44−
45− let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
46− tracing::debug!("parsed cli args");
47− if args.version() {
48− vm::print_version();
49− return Ok(());
50− }
51− if args.help() {
52− vm::print_help();
53− return Ok(());
54− }
55−
56− vm::ensure_signed();
57−
58− let vm_info = VmInfo {
59− version: env!("CARGO_PKG_VERSION").to_string(),
60− max_memory_mb: args.ram_mb(),
61− cpu_cores: args.cpu_count(),
62− };
63− let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
64− tracing::info!(cwd = %cwd.display(), "starting vibebox cli");
65− let app = Arc::new(Mutex::new(AppState::new(cwd.clone(), vm_info)));
66−
67− {
68− let mut locked = app.lock().expect("app state poisoned");
69− tui::render_tui_once(&mut locked)?;
70− }
71− {
72− let mut stdout = io::stdout().lock();
73− writeln!(stdout)?;
74− stdout.flush()?;
75− }
76−
77− let auto_shutdown_ms = load_auto_shutdown_ms(&cwd)?;
78− tracing::info!(auto_shutdown_ms, "auto shutdown config");
79− let manager_conn = vm_manager::ensure_manager(&raw_args, auto_shutdown_ms)
80− .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
81−
82− instance::run_with_ssh(manager_conn).map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
83−
84− Ok(())
85−}
86−
87−fn load_auto_shutdown_ms(project_root: &Path) -> Result<u64> {
88− let path = project_root.join("vibebox.toml");
89− let config = match fs::read_to_string(&path) {
90− Ok(raw) => toml::from_str::<ProjectConfig>(&raw)?,
91− Err(err) if err.kind() == io::ErrorKind::NotFound => ProjectConfig::default(),
92− Err(err) => return Err(err.into()),
93− };
94− Ok(config.auto_shutdown_ms.unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS))
95−}
96−
97−fn init_tracing() {
98− let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
99− let ansi = std::io::stderr().is_terminal() && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
100− let _ = tracing_subscriber::fmt()
101− .with_env_filter(filter)
102− .with_target(false)
103− .with_ansi(ansi)
104− .with_writer(std::io::stderr)
105− .try_init();
106−}
src/bin/vibebox-supervisor.rs+45−17
@@ -5,24 +5,41 @@ use std::{
5
5
6use color_eyre::Result;
6use color_eyre::Result;
7use tracing_subscriber::EnvFilter;
7use tracing_subscriber::EnvFilter;
8+use tracing_subscriber::filter::LevelFilter;
9+use tracing_subscriber::fmt;
10+use tracing_subscriber::prelude::*;
8
11
9−use vibebox::{vm, vm_manager};
12+use vibebox::{config, instance, vm, vm_manager};
10−
11−const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 3000;
12
13
13fn main() -> Result<()> {
14fn 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+
14 init_tracing();
20 init_tracing();
15 color_eyre::install()?;
21 color_eyre::install()?;
16
22
17 tracing::info!("starting vm supervisor");
23 tracing::info!("starting vm supervisor");
18− let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
24+ let project_dir = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
19− let auto_shutdown_ms = env::var("VIBEBOX_AUTO_SHUTDOWN_MS")
25+ let config = config::load_config(&project_dir)
20− .ok()
26+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
21− .and_then(|value| value.parse::<u64>().ok())
27+ let _ = instance::ensure_instance_dir(&project_dir)
22− .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS);
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;
23 tracing::info!(auto_shutdown_ms, "vm supervisor config");
38 tracing::info!(auto_shutdown_ms, "vm supervisor config");
24
39
25− if let Err(err) = vm_manager::run_manager(args, auto_shutdown_ms) {
40+ let result = vm_manager::run_manager(args, auto_shutdown_ms);
41+ let _ = instance::touch_last_active(&project_dir);
42+ if let Err(err) = result {
26 tracing::error!(error = %err, "vm supervisor exited");
43 tracing::error!(error = %err, "vm supervisor exited");
27 return Err(color_eyre::eyre::eyre!(err.to_string()));
44 return Err(color_eyre::eyre::eyre!(err.to_string()));
28 }
45 }
@@ -32,12 +49,23 @@ fn main() -> Result<()> {
32}
49}
33
50
34fn init_tracing() {
51fn init_tracing() {
35− let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
52+ let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
53+ let stderr_is_tty = std::io::stderr().is_terminal();
36− let ansi = std::io::stderr().is_terminal() && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
54+ let ansi = stderr_is_tty && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
37− let _ = tracing_subscriber::fmt()
55+ if stderr_is_tty {
38− .with_env_filter(filter)
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()
39− .with_target(false)
65+ .with_target(false)
40− .with_ansi(ansi)
66+ .with_ansi(ansi)
41− .with_writer(io::stderr)
67+ .with_writer(io::stderr)
42− .try_init();
68+ .with_filter(filter);
69+ let _ = tracing_subscriber::registry().with(stderr_layer).try_init();
70+ }
43}
71}
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+341−331
@@ -1,83 +1,149 @@
1+use anyhow::{Context, Result, bail};
2+use serde::{Deserialize, Serialize};
3+use std::os::unix::fs::FileTypeExt;
1use std::{
4use std::{
2 env, fs,
5 env, fs,
3− io::{self, Write},
6+ io::{self, IsTerminal, Read},
4 net::{SocketAddr, TcpStream},
7 net::{SocketAddr, TcpStream},
5− os::unix::{fs::PermissionsExt, io::OwnedFd, net::UnixStream},
8+ os::unix::{fs::PermissionsExt, net::UnixStream},
6 path::{Path, PathBuf},
9 path::{Path, PathBuf},
7− process::{Command, Stdio},
10+ process::{Child, Command, Stdio},
8 sync::{
11 sync::{
9 Arc, Mutex,
12 Arc, Mutex,
10 atomic::{AtomicBool, Ordering},
13 atomic::{AtomicBool, Ordering},
14+ mpsc,
11 },
15 },
12 thread,
16 thread,
13 time::{Duration, Instant},
17 time::{Duration, Instant},
14};
18};
15−
19+use time::{OffsetDateTime, format_description::well_known::Rfc3339};
16−use serde::{Deserialize, Serialize};
17−use std::sync::mpsc::Sender;
18use uuid::Uuid;
20use uuid::Uuid;
19
21
20use crate::{
22use crate::{
23+ commands,
21 session_manager::INSTANCE_DIR_NAME,
24 session_manager::INSTANCE_DIR_NAME,
22− tui::{self, AppState},
25+ session_manager::{VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME},
23− vm::{self, LoginAction, VmInput},
26+ vm::{self, LoginAction},
24};
27};
25
28
26−const INSTANCE_TOML: &str = "instance.toml";
29+pub const STATUS_VM_ERROR_PREFIX: &str = "error:";
30+
27const SSH_KEY_NAME: &str = "ssh_key";
31const SSH_KEY_NAME: &str = "ssh_key";
28−#[allow(dead_code)]
32+const INSTANCE_FILENAME: &str = "instance.toml";
29−const SERIAL_LOG_NAME: &str = "serial.log";
30const DEFAULT_SSH_USER: &str = "vibecoder";
33const DEFAULT_SSH_USER: &str = "vibecoder";
31−const SSH_CONNECT_RETRIES: usize = 30;
34+const SSH_CONNECT_RETRIES: usize = 10;
32const SSH_CONNECT_DELAY_MS: u64 = 500;
35const SSH_CONNECT_DELAY_MS: u64 = 500;
33const SSH_SETUP_SCRIPT: &str = include_str!("ssh.sh");
36const SSH_SETUP_SCRIPT: &str = include_str!("ssh.sh");
37+const STATUS_PREFIX: &str = "status:";
38+
39+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40+pub enum VmLiveness {
41+ RunningWithSocket { pid: u32 },
42+ RunningWithoutSocket { pid: u32 },
43+ NotRunningOrMissing,
44+}
45+
46+pub fn vm_liveness(project_root: &Path) -> Result<VmLiveness> {
47+ let instance_dir = ensure_instance_dir(project_root)?;
48+ let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
49+ let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
50+ fn pid_is_alive(pid: u32) -> bool {
51+ let pid = pid as libc::pid_t;
52+ let result = unsafe { libc::kill(pid, 0) };
53+ if result == 0 {
54+ return true;
55+ }
56+ match io::Error::last_os_error().raw_os_error() {
57+ Some(code) if code == libc::EPERM => true,
58+ Some(code) if code == libc::ESRCH => false,
59+ _ => false,
60+ }
61+ }
62+ let Ok(content) = fs::read_to_string(pid_path) else {
63+ return Ok(VmLiveness::NotRunningOrMissing);
64+ };
65+ let Ok(pid) = content.trim().parse::<u32>() else {
66+ return Ok(VmLiveness::NotRunningOrMissing);
67+ };
68+ if !pid_is_alive(pid) {
69+ return Ok(VmLiveness::NotRunningOrMissing);
70+ }
71+ let has_socket = fs::metadata(socket_path)
72+ .map(|meta| meta.file_type().is_socket())
73+ .unwrap_or(false);
74+ if has_socket {
75+ Ok(VmLiveness::RunningWithSocket { pid })
76+ } else {
77+ Ok(VmLiveness::RunningWithoutSocket { pid })
78+ }
79+}
80+
81+#[derive(Debug, thiserror::Error)]
82+pub enum InstanceError {
83+ #[error("unexpected disconnection from vm manager")]
84+ UnexpectedDisconnection,
85+ #[error("{0}")]
86+ VMError(String),
87+}
88+
89+fn default_ssh_user() -> String {
90+ DEFAULT_SSH_USER.to_string()
91+}
34
92
35#[derive(Debug, Clone, Serialize, Deserialize)]
93#[derive(Debug, Clone, Serialize, Deserialize)]
36−pub(crate) struct InstanceConfig {
94+pub struct InstanceConfig {
95+ #[serde(default)]
96+ pub id: String,
37 #[serde(default = "default_ssh_user")]
97 #[serde(default = "default_ssh_user")]
38− ssh_user: String,
98+ pub ssh_user: String,
39 #[serde(default)]
99 #[serde(default)]
40 sudo_password: String,
100 sudo_password: String,
41 #[serde(default)]
101 #[serde(default)]
102+ pub last_active: Option<String>,
103+ #[serde(default)]
42− pub(crate) vm_ipv4: Option<String>,
104+ pub vm_ipv4: Option<String>,
43}
105}
44
106
45−fn default_ssh_user() -> String {
107+impl Default for InstanceConfig {
108+ fn default() -> Self {
109+ Self {
110+ id: Uuid::now_v7().to_string(),
46− DEFAULT_SSH_USER.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+ }
47}
117}
48
118
49−pub fn run_with_ssh(manager_conn: UnixStream) -> Result<(), Box<dyn std::error::Error>> {
119+pub fn run_with_ssh(manager_conn: UnixStream) -> Result<()> {
50 let project_root = env::current_dir()?;
120 let project_root = env::current_dir()?;
51 tracing::info!(root = %project_root.display(), "starting ssh session");
121 tracing::info!(root = %project_root.display(), "starting ssh session");
52 let instance_dir = ensure_instance_dir(&project_root)?;
122 let instance_dir = ensure_instance_dir(&project_root)?;
53 tracing::debug!(instance_dir = %instance_dir.display(), "instance dir ready");
123 tracing::debug!(instance_dir = %instance_dir.display(), "instance dir ready");
54 let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?;
124 let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?;
55
125
56− let config = load_or_create_instance_config(&instance_dir)?;
126+ let config = load_or_create_instance_config(&project_root)?;
57 let ssh_user = config.ssh_user.clone();
127 let ssh_user = config.ssh_user.clone();
58 tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
128 tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
59
129
60− let _manager_conn = manager_conn;
61− tracing::debug!("waiting for vm ipv4");
62− wait_for_vm_ipv4(&instance_dir, Duration::from_secs(120))?;
130+ wait_for_vm_ipv4(&project_root, Duration::from_secs(480), &manager_conn)?;
63
131
64− let ip = load_or_create_instance_config(&instance_dir)?
132+ let ip = load_or_create_instance_config(&project_root)?
65 .vm_ipv4
133 .vm_ipv4
66− .ok_or("VM IPv4 not available")?;
134+ .with_context(|| "failed to load instance IP address")?;
67 tracing::info!(ip = %ip, "vm ipv4 ready");
135 tracing::info!(ip = %ip, "vm ipv4 ready");
68
136
69− run_ssh_session(ssh_key, ssh_user, ip)
137+ run_ssh_session(ssh_key, ssh_user, ip, manager_conn, project_root)
70}
138}
71
139
72−pub(crate) fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
140+pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
73 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
141 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
74 fs::create_dir_all(&instance_dir)?;
142 fs::create_dir_all(&instance_dir)?;
75 Ok(instance_dir)
143 Ok(instance_dir)
76}
144}
77
145
78−pub(crate) fn ensure_ssh_keypair(
146+pub fn ensure_ssh_keypair(instance_dir: &Path) -> Result<(PathBuf, PathBuf)> {
79− instance_dir: &Path,
80−) -> Result<(PathBuf, PathBuf), Box<dyn std::error::Error>> {
81 let private_key = instance_dir.join(SSH_KEY_NAME);
147 let private_key = instance_dir.join(SSH_KEY_NAME);
82 let public_key = instance_dir.join(format!("{SSH_KEY_NAME}.pub"));
148 let public_key = instance_dir.join(format!("{SSH_KEY_NAME}.pub"));
83
149
@@ -99,17 +165,19 @@ pub(crate) fn ensure_ssh_keypair(
99 "-N",
165 "-N",
100 "",
166 "",
101 "-f",
167 "-f",
168+ private_key
169+ .to_str()
102− private_key.to_str().ok_or("ssh key path not utf-8")?,
170+ .with_context(|| "ssh key path not utf-8")?,
103 "-C",
171 "-C",
104 "vibebox",
172 "vibebox",
105 ])
173 ])
106 .stdin(Stdio::null())
174 .stdin(Stdio::null())
107− .stdout(Stdio::null())
175+ .stdout(Stdio::inherit())
108 .stderr(Stdio::inherit())
176 .stderr(Stdio::inherit())
109 .status()?;
177 .status()?;
110
178
111 if !status.success() {
179 if !status.success() {
112− return Err("ssh-keygen failed".into());
180+ bail!("ssh-keygen failed");
113 }
181 }
114
182
115 fs::set_permissions(&private_key, fs::Permissions::from_mode(0o600))?;
183 fs::set_permissions(&private_key, fs::Permissions::from_mode(0o600))?;
@@ -118,54 +186,66 @@ pub(crate) fn ensure_ssh_keypair(
118 Ok((private_key, public_key))
186 Ok((private_key, public_key))
119}
187}
120
188
121−pub(crate) fn load_or_create_instance_config(
189+pub fn load_or_create_instance_config(project_dir: &Path) -> Result<InstanceConfig> {
122− instance_dir: &Path,
190+ let mut exist = true;
123−) -> Result<InstanceConfig, Box<dyn std::error::Error>> {
191+ let mut config = read_instance_config(project_dir).unwrap_or_else(|_| {
124− let config_path = instance_dir.join(INSTANCE_TOML);
192+ exist = false;
125− let mut config = if config_path.exists() {
193+ InstanceConfig::default()
126− let raw = fs::read_to_string(&config_path)?;
127− toml::from_str::<InstanceConfig>(&raw)?
128− } else {
129− InstanceConfig {
130− ssh_user: default_ssh_user(),
131− sudo_password: String::new(),
132− vm_ipv4: None,
133− }
134− };
194+ });
135
195
136 let mut changed = false;
196 let mut changed = false;
137 if config.ssh_user.trim().is_empty() {
197 if config.ssh_user.trim().is_empty() {
138− config.ssh_user = default_ssh_user();
198+ config.ssh_user = InstanceConfig::default().ssh_user;
199+ changed = true;
200+ }
201+
202+ if config.id.trim().is_empty() {
203+ config.id = InstanceConfig::default().id;
139 changed = true;
204 changed = true;
140 }
205 }
141
206
142 if config.sudo_password.trim().is_empty() {
207 if config.sudo_password.trim().is_empty() {
143− config.sudo_password = generate_password();
208+ config.sudo_password = InstanceConfig::default().sudo_password;
144 changed = true;
209 changed = true;
145 }
210 }
146
211
147− if !config_path.exists() || changed {
212+ if !exist || changed {
148− write_instance_config(&config_path, &config)?;
213+ write_instance_config(project_dir, &config)?;
149 }
214 }
150
215
151 Ok(config)
216 Ok(config)
152}
217}
153
218
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)?;
232+ let now = OffsetDateTime::now_utc().format(&Rfc3339)?;
233+ config.last_active = Some(now);
154−pub(crate) fn write_instance_config(
234+ write_instance_config(project_dir, &config)?;
155− path: &Path,
156− config: &InstanceConfig,
157−) -> Result<(), Box<dyn std::error::Error>> {
158− let data = toml::to_string_pretty(config)?;
159− fs::write(path, data)?;
160− fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
161 Ok(())
235 Ok(())
162}
236}
163
237
164−fn generate_password() -> String {
238+pub fn write_instance_config(project_dir: &Path, config: &InstanceConfig) -> Result<()> {
165− Uuid::now_v7().simple().to_string()
239+ let path = project_dir.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
240+ let data = toml::to_string_pretty(config)?;
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))?;
244+ Ok(())
166}
245}
167
246
247+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
168−pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
248+pub fn extract_ipv4(line: &str) -> Option<String> {
169 let mut current = String::new();
249 let mut current = String::new();
170 let mut best: Option<String> = None;
250 let mut best: Option<String> = None;
171
251
@@ -184,23 +264,79 @@ pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
184 best
264 best
185}
265}
186
266
267+fn handle_manager_line(line: &str, last_status: &mut Option<String>) -> Result<()> {
268+ if let Some(status) = line.strip_prefix(STATUS_PREFIX) {
269+ let status = status.trim();
270+ if let Some(message) = status.strip_prefix(STATUS_VM_ERROR_PREFIX) {
271+ let message = message.trim();
272+ if message.is_empty() {
273+ bail!("vm manager reported startup failure");
274+ }
275+ return Err(InstanceError::VMError(message.to_string()).into());
276+ }
277+ if !status.is_empty() && last_status.as_deref() != Some(status) {
278+ tracing::info!("[background]: {}", status);
279+ *last_status = Some(status.to_string());
280+ }
281+ }
282+ Ok(())
283+}
284+
187fn wait_for_vm_ipv4(
285fn wait_for_vm_ipv4(
188− instance_dir: &Path,
286+ project_dir: &Path,
189 timeout: Duration,
287 timeout: Duration,
190−) -> Result<(), Box<dyn std::error::Error>> {
288+ manager_conn: &UnixStream,
289+) -> Result<()> {
191 let start = Instant::now();
290 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;
192 loop {
299 loop {
300+ match stream.read(&mut read_buf) {
301+ Ok(0) => {
302+ bail!("vm manager disconnected before VM became ready");
303+ }
304+ Ok(n) => {
305+ pending.push_str(&String::from_utf8_lossy(&read_buf[..n]));
306+ while let Some(pos) = pending.find('\n') {
307+ let line = pending[..pos].trim().to_string();
308+ pending.drain(..=pos);
309+ handle_manager_line(&line, &mut last_status)?;
310+ }
311+ }
312+ Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
313+ Err(err) if err.kind() == io::ErrorKind::TimedOut => {}
314+ Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
315+ Err(err) => {
316+ tracing::warn!(error = %err, "failed to read vm manager status stream");
317+ }
318+ }
319+
193− let config = load_or_create_instance_config(instance_dir)?;
320+ let config = load_or_create_instance_config(project_dir)?;
194 if config.vm_ipv4.is_some() {
321 if config.vm_ipv4.is_some() {
195 return Ok(());
322 return Ok(());
196 }
323 }
197 if start.elapsed() > timeout {
324 if start.elapsed() > timeout {
198− return Err("Timed out waiting for VM IPv4".into());
325+ bail!("timed out waiting for VM IPv4");
199 }
326 }
327+
328+ let now = Instant::now();
329+ if now >= next_log_at {
200− if start.elapsed().as_secs() % 5 == 0 {
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+ }
201− tracing::debug!("still waiting for vm ipv4");
337+ tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
338+ next_log_at += Duration::from_secs(20);
202 }
339 }
203− thread::sleep(Duration::from_millis(200));
204 }
340 }
205}
341}
206
342
@@ -208,32 +344,43 @@ fn run_ssh_session(
208 ssh_key: PathBuf,
344 ssh_key: PathBuf,
209 ssh_user: String,
345 ssh_user: String,
210 ip: String,
346 ip: String,
211−) -> Result<(), Box<dyn std::error::Error>> {
347+ manager_conn: UnixStream,
348+ project_root: PathBuf,
349+) -> Result<()> {
212 let mut attempts = 0usize;
350 let mut attempts = 0usize;
213 loop {
351 loop {
352+ if matches!(vm_liveness(&project_root)?, VmLiveness::NotRunningOrMissing) {
353+ return Err(InstanceError::UnexpectedDisconnection.into());
354+ }
214 attempts += 1;
355 attempts += 1;
215 if !ssh_port_open(&ip) {
356 if !ssh_port_open(&ip) {
216− tracing::debug!(attempts, "ssh port not open yet");
357+ tracing::debug!(attempts, "ssh port doesn't open yet");
217− eprintln!(
358+ tracing::info!(
359+ attempts,
360+ ip = %ip,
361+ "waiting for ssh port ({}/{})",
362+ attempts,
218− "[vibebox] waiting for ssh port on {ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
363+ SSH_CONNECT_RETRIES
219 );
364 );
220 if attempts >= SSH_CONNECT_RETRIES {
365 if attempts >= SSH_CONNECT_RETRIES {
221− return Err(
222− format!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts").into(),
366+ bail!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts");
223− );
224 }
367 }
225 thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS));
368 thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS));
226 continue;
369 continue;
227 }
370 }
228
371
229− eprintln!(
372+ tracing::info!(
373+ attempts,
374+ user = %ssh_user,
375+ ip = %ip,
376+ "starting ssh ({}/{})",
377+ attempts,
230− "[vibebox] starting ssh to {ssh_user}@{ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
378+ SSH_CONNECT_RETRIES
231 );
379 );
232− tracing::info!(attempts, user = %ssh_user, ip = %ip, "starting ssh");
233− let status = Command::new("ssh")
380+ let child = Command::new("ssh")
234 .args([
381 .args([
235 "-i",
382 "-i",
236− ssh_key.to_str().unwrap_or(".vibebox/ssh_key"),
383+ ssh_key.to_str().with_context(|| "invalid path")?,
237 "-o",
384 "-o",
238 "IdentitiesOnly=yes",
385 "IdentitiesOnly=yes",
239 "-o",
386 "-o",
@@ -258,25 +405,67 @@ fn run_ssh_session(
258 .stdin(Stdio::inherit())
405 .stdin(Stdio::inherit())
259 .stdout(Stdio::inherit())
406 .stdout(Stdio::inherit())
260 .stderr(Stdio::inherit())
407 .stderr(Stdio::inherit())
408+ .spawn();
409+
410+ match child {
411+ Ok(mut child) => {
412+ let done = Arc::new(AtomicBool::new(false));
413+ let done_for_monitor = done.clone();
414+ let (disconnect_tx, disconnect_rx) = mpsc::channel::<()>();
415+ let mut manager_stream = manager_conn.try_clone()?;
416+ let _ = manager_stream.set_read_timeout(Some(Duration::from_millis(250)));
417+ thread::spawn(move || {
418+ let mut buf = [0u8; 1];
419+ while !done_for_monitor.load(Ordering::Relaxed) {
420+ match manager_stream.read(&mut buf) {
421+ Ok(0) => {
422+ let _ = disconnect_tx.send(());
423+ return;
424+ }
425+ Ok(_) => {}
426+ Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
427+ Err(err) if err.kind() == io::ErrorKind::TimedOut => {}
428+ Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
429+ Err(_) => {
430+ let _ = disconnect_tx.send(());
431+ return;
432+ }
433+ }
434+ }
435+ });
436+
437+ let status = loop {
438+ if disconnect_rx.try_recv().is_ok() {
439+ done.store(true, Ordering::Relaxed);
440+ terminate_ssh_child(&mut child);
441+ restore_terminal_after_disconnect();
442+ return Err(InstanceError::UnexpectedDisconnection.into());
443+ }
444+ if let Some(status) = child.try_wait()? {
445+ done.store(true, Ordering::Relaxed);
261− .status();
446+ break status;
447+ }
448+ thread::sleep(Duration::from_millis(100));
449+ };
262
450
263− match status {
264− Ok(status) if status.success() => {
451+ if status.success() {
265− eprintln!("[vibebox] ssh exited with status: {status}");
452+ tracing::info!(status = %status, "ssh exited");
266− break;
453+ break;
267− }
268− Ok(status) if status.code() == Some(255) => {
269− eprintln!("[vibebox] ssh connection failed: {status}");
270− if attempts >= SSH_CONNECT_RETRIES {
271− return Err(format!("ssh failed after {SSH_CONNECT_RETRIES} attempts").into());
272 }
454 }
455+ if status.code() == Some(255) {
456+ tracing::warn!(status = %status, "ssh connection failed");
457+ if attempts >= SSH_CONNECT_RETRIES {
458+ bail!("ssh failed after {SSH_CONNECT_RETRIES} attempts");
459+ }
273− thread::sleep(Duration::from_millis(500));
460+ thread::sleep(Duration::from_millis(500));
461+ continue;
274− }
462+ }
275− Ok(status) => {
463+ tracing::info!(status = %status, "ssh exited");
276− return Err(format!("ssh exited with status: {status}").into());
464+ break;
277 }
465 }
278 Err(err) => {
466 Err(err) => {
467+ tracing::error!(error = %err, "failed to start ssh");
279− return Err(format!("failed to start ssh: {err}").into());
468+ bail!("failed to start ssh: {err}");
280 }
469 }
281 }
470 }
282 }
471 }
@@ -284,6 +473,31 @@ fn run_ssh_session(
284 Ok(())
473 Ok(())
285}
474}
286
475
476+fn terminate_ssh_child(child: &mut Child) {
477+ let pid = child.id() as i32;
478+ unsafe {
479+ libc::kill(pid, libc::SIGTERM);
480+ }
481+ let deadline = Instant::now() + Duration::from_millis(700);
482+ while Instant::now() < deadline {
483+ match child.try_wait() {
484+ Ok(Some(_)) => return,
485+ Ok(None) => thread::sleep(Duration::from_millis(50)),
486+ Err(_) => break,
487+ }
488+ }
489+ let _ = child.kill();
490+ let _ = child.wait();
491+}
492+
493+fn restore_terminal_after_disconnect() {
494+ if io::stdin().is_terminal() {
495+ let _ = Command::new("stty").arg("sane").status();
496+ }
497+ eprintln!();
498+}
499+
500+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
287fn is_ipv4_candidate(candidate: &str) -> bool {
501fn is_ipv4_candidate(candidate: &str) -> bool {
288 let parts: Vec<&str> = candidate.split('.').collect();
502 let parts: Vec<&str> = candidate.split('.').collect();
289 if parts.len() != 4 {
503 if parts.len() != 4 {
@@ -305,14 +519,16 @@ fn ssh_port_open(ip: &str) -> bool {
305 Ok(addr) => addr,
519 Ok(addr) => addr,
306 Err(_) => return false,
520 Err(_) => return false,
307 };
521 };
308− TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).is_ok()
522+ TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok()
309}
523}
310
524
311−pub(crate) fn build_ssh_login_actions(
525+pub fn build_ssh_login_actions(
312 config: &Arc<Mutex<InstanceConfig>>,
526 config: &Arc<Mutex<InstanceConfig>>,
313 project_name: &str,
527 project_name: &str,
528+ project_guest_dir: &str,
314 guest_dir: &str,
529 guest_dir: &str,
315 key_name: &str,
530 key_name: &str,
531+ home_links_script: &str,
316) -> Vec<LoginAction> {
532) -> Vec<LoginAction> {
317 let config_guard = config.lock().expect("config mutex poisoned");
533 let config_guard = config.lock().expect("config mutex poisoned");
318 let ssh_user = config_guard.ssh_user.clone();
534 let ssh_user = config_guard.ssh_user.clone();
@@ -321,250 +537,44 @@ pub(crate) fn build_ssh_login_actions(
321
537
322 let key_path = format!("{guest_dir}/{key_name}.pub");
538 let key_path = format!("{guest_dir}/{key_name}.pub");
323
539
324− let setup_script = SSH_SETUP_SCRIPT
540+ let ssh_script = SSH_SETUP_SCRIPT
325 .replace("__SSH_USER__", &ssh_user)
541 .replace("__SSH_USER__", &ssh_user)
326 .replace("__SUDO_PASSWORD__", &sudo_password)
542 .replace("__SUDO_PASSWORD__", &sudo_password)
327 .replace("__PROJECT_NAME__", project_name)
543 .replace("__PROJECT_NAME__", project_name)
544+ .replace("__PROJECT_GUEST_DIR__", project_guest_dir)
328− .replace("__KEY_PATH__", &key_path);
545+ .replace("__KEY_PATH__", &key_path)
546+ .replace("__VIBEBOX_SHELL_SCRIPT__", &commands::render_shell_script())
547+ .replace("__VIBEBOX_HOME_LINKS__", home_links_script);
329− let setup = vm::script_command_from_content("ssh_setup", &setup_script)
548+ let setup = vm::script_command_from_content("ssh.sh", &ssh_script)
330 .expect("ssh setup script contained invalid marker");
549 .expect("ssh setup script contained invalid marker");
331
550
332 vec![LoginAction::Send(setup)]
551 vec![LoginAction::Send(setup)]
333}
552}
334
553
335−#[allow(dead_code)]
554+#[cfg(test)]
336−fn spawn_ssh_io(
555+mod tests {
337− app: Arc<Mutex<AppState>>,
556+ use super::*;
338− config: Arc<Mutex<InstanceConfig>>,
339− instance_dir: PathBuf,
340− ssh_key: PathBuf,
341− output_monitor: Arc<vm::OutputMonitor>,
342− vm_output_fd: OwnedFd,
343− vm_input_fd: OwnedFd,
344−) -> vm::IoContext {
345− let io_control = vm::IoControl::new();
346−
347− let log_path = instance_dir.join(SERIAL_LOG_NAME);
348− let log_file = fs::OpenOptions::new()
349− .create(true)
350− .append(true)
351− .open(&log_path)
352− .ok()
353− .map(|file| Arc::new(Mutex::new(file)));
354−
355− let ssh_connected = Arc::new(AtomicBool::new(false));
356− let ssh_started = Arc::new(AtomicBool::new(false));
357− let ssh_ready = Arc::new(AtomicBool::new(false));
358− let input_tx_holder: Arc<Mutex<Option<Sender<VmInput>>>> = Arc::new(Mutex::new(None));
359−
360− let instance_path = instance_dir.join(INSTANCE_TOML);
361− let config_for_output = config.clone();
362− let log_for_output = log_file.clone();
363− let ssh_connected_for_output = ssh_connected.clone();
364− let ssh_ready_for_output = ssh_ready.clone();
365−
366− let mut line_buf = String::new();
367−
368− let on_line = {
369− let app = app.clone();
370− move |line: &str| {
371− if line == ":help" {
372− if let Ok(mut locked) = app.lock() {
373− let _ = tui::render_commands_component(&mut locked);
374− }
375− return true;
376− }
377− false
378− }
379− };
380−
381− let on_output = move |bytes: &[u8]| {
382− if ssh_connected_for_output.load(Ordering::SeqCst) {
383− if let Some(log) = &log_for_output {
384− if let Ok(mut file) = log.lock() {
385− let _ = file.write_all(bytes);
386− }
387− }
388− }
389−
390− let text = String::from_utf8_lossy(bytes);
391− line_buf.push_str(&text);
392
557
393− while let Some(pos) = line_buf.find('\n') {
558+ #[test]
394− let mut line = line_buf[..pos].to_string();
559+ fn handle_manager_line_updates_status() {
395− if line.ends_with('\r') {
560+ let mut last_status = None;
396− line.pop();
561+ handle_manager_line("status: preparing VM image...", &mut last_status)
397− }
562+ .expect("status should be accepted");
398− line_buf.drain(..=pos);
563+ assert_eq!(last_status.as_deref(), Some("preparing VM image..."));
399−
400− let cleaned = line.trim_start_matches(|c: char| c == '\r' || c == ' ');
401−
402− if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
403− let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
404− let ip = extract_ipv4(ip_raw).unwrap_or_default();
405− if !ip.is_empty() {
406− if let Ok(mut cfg) = config_for_output.lock() {
407− if cfg.vm_ipv4.as_deref() != Some(ip.as_str()) {
408− cfg.vm_ipv4 = Some(ip.clone());
409− let _ = write_instance_config(&instance_path, &cfg);
410− eprintln!("[vibebox] detected vm IPv4: {ip}");
411− }
412− }
413− }
414− }
415−
416− if cleaned.contains("VIBEBOX_SSH_READY") {
417− ssh_ready_for_output.store(true, Ordering::SeqCst);
418− eprintln!("[vibebox] sshd ready");
419− }
420− }
421− };
422−
423− let io_ctx = vm::spawn_vm_io_with_hooks(
424− output_monitor,
425− vm_output_fd,
426− vm_input_fd,
427− io_control.clone(),
428− on_line,
429− on_output,
430− );
431−
432− *input_tx_holder.lock().unwrap() = Some(io_ctx.input_tx.clone());
433−
434− let ssh_ready_for_thread = ssh_ready.clone();
435− let ssh_started_for_thread = ssh_started.clone();
436− let ssh_connected_for_thread = ssh_connected.clone();
437− let io_control_for_thread = io_control.clone();
438− let ssh_key_for_thread = ssh_key.clone();
439− let config_for_thread = config.clone();
440− let input_tx_holder_for_thread = input_tx_holder.clone();
441−
442− thread::spawn(move || {
443− loop {
444− if ssh_started_for_thread.load(Ordering::SeqCst) {
445− break;
446− }
447− if !ssh_ready_for_thread.load(Ordering::SeqCst) {
448− thread::sleep(std::time::Duration::from_millis(200));
449− continue;
450− }
451−
452− let ip = config_for_thread
453− .lock()
454− .ok()
455− .and_then(|cfg| cfg.vm_ipv4.clone());
456− let ssh_user = config_for_thread
457− .lock()
458− .map(|cfg| cfg.ssh_user.clone())
459− .unwrap_or_else(|_| DEFAULT_SSH_USER.to_string());
460−
461− if let Some(ip) = ip {
462− if ssh_started_for_thread
463− .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
464− .is_err()
465− {
466− break;
467− }
468−
469− eprintln!("[vibebox] starting ssh to {ssh_user}@{ip}");
470− io_control_for_thread.request_terminal_restore();
471− io_control_for_thread.set_forward_output(false);
472− io_control_for_thread.set_forward_input(false);
473− ssh_connected_for_thread.store(true, Ordering::SeqCst);
474−
475− let mut attempts = 0usize;
476− loop {
477− attempts += 1;
478− if !ssh_port_open(&ip) {
479− eprintln!(
480− "[vibebox] waiting for ssh port on {ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
481− );
482− if attempts >= SSH_CONNECT_RETRIES {
483− ssh_connected_for_thread.store(false, Ordering::SeqCst);
484− eprintln!(
485− "[vibebox] ssh port not ready after {SSH_CONNECT_RETRIES} attempts"
486− );
487− break;
488− }
489− thread::sleep(std::time::Duration::from_millis(SSH_CONNECT_DELAY_MS));
490− continue;
491− }
492−
493− eprintln!(
494− "[vibebox] starting ssh to {ssh_user}@{ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
495− );
496− let status = Command::new("ssh")
497− .args([
498− "-i",
499− ssh_key_for_thread.to_str().unwrap_or(".vibebox/ssh_key"),
500− "-o",
501− "IdentitiesOnly=yes",
502− "-o",
503− "StrictHostKeyChecking=no",
504− "-o",
505− "UserKnownHostsFile=/dev/null",
506− "-o",
507− "GlobalKnownHostsFile=/dev/null",
508− "-o",
509− "PasswordAuthentication=no",
510− "-o",
511− "BatchMode=yes",
512− "-o",
513− "LogLevel=ERROR",
514− "-o",
515− "ConnectTimeout=5",
516− ])
517− .env_remove("LC_CTYPE")
518− .env_remove("LC_ALL")
519− // .env_remove("LANG")
520− .arg(format!("{ssh_user}@{ip}"))
521− .stdin(Stdio::inherit())
522− .stdout(Stdio::inherit())
523− .stderr(Stdio::inherit())
524− .status();
525−
526− match status {
527− Ok(status) if status.success() => {
528− ssh_connected_for_thread.store(false, Ordering::SeqCst);
529− eprintln!("[vibebox] ssh exited with status: {status}");
530− if let Some(tx) = input_tx_holder_for_thread.lock().unwrap().clone() {
531− let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
532− }
533− break;
534− }
535− Ok(status) if status.code() == Some(255) => {
536− eprintln!("[vibebox] ssh connection failed: {status}");
537− if attempts >= SSH_CONNECT_RETRIES {
538− ssh_connected_for_thread.store(false, Ordering::SeqCst);
539− eprintln!(
540− "[vibebox] ssh failed after {SSH_CONNECT_RETRIES} attempts"
541− );
542− break;
543− }
544− thread::sleep(std::time::Duration::from_millis(500));
545− continue;
546− }
547− Ok(status) => {
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− Err(err) => {
556− ssh_connected_for_thread.store(false, Ordering::SeqCst);
557− eprintln!("[vibebox] failed to start ssh: {err}");
558− break;
559− }
560− }
561− }
562− break;
563− }
564+ }
564
565
565− thread::sleep(std::time::Duration::from_millis(200));
566+ #[test]
566− }
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());
567− });
571+ }
568
572
569− 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+ }
570}
580}
src/lib.rs+4
@@ -1,3 +1,5 @@
1+pub mod commands;
2+pub mod explain;
1pub mod instance;
3pub mod instance;
2pub mod session_manager;
4pub mod session_manager;
3pub mod tui;
5pub mod tui;
@@ -5,3 +7,5 @@ pub mod vm;
5pub mod vm_manager;
7pub mod vm_manager;
6
8
7pub use session_manager::{SessionError, SessionManager, SessionRecord};
9pub use session_manager::{SessionError, SessionManager, SessionRecord};
10+pub mod config;
11+pub mod utils;
src/provision.sh+55−5
@@ -1,12 +1,55 @@
1#!/bin/bash
1#!/bin/bash
2−set -eux
2+set -eEux
3+
4+trap 'rc=$?; echo "[vibebox][error] provisioning failed at: ${BASH_COMMAND} (exit ${rc})"; printf "%s%s\n" VIBEBOX_PROVISION_ FAILED; systemctl poweroff || true; exit 1' ERR
5+
6+# Wait for network + DNS before apt-get to avoid early boot flakiness.
7+wait_for_network() {
8+ echo "[vibebox] waiting for network/DNS readiness"
9+ local deadline=$((SECONDS + 180))
10+ while [ "$SECONDS" -lt "$deadline" ]; do
11+ local has_route=0
12+ if ip -4 route show default >/dev/null 2>&1; then
13+ has_route=1
14+ elif ip -6 route show default >/dev/null 2>&1; then
15+ has_route=1
16+ fi
17+ if [ "$has_route" -eq 1 ]; then
18+ if getent hosts deb.debian.org >/dev/null 2>&1; then
19+ return 0
20+ fi
21+ fi
22+ sleep 1
23+ done
24+ echo "[vibebox][warn] network/DNS still not ready after 180s; continuing" >&2
25+ echo "[vibebox][warn] /etc/resolv.conf:" >&2
26+ cat /etc/resolv.conf >&2 || true
27+ ip -br addr >&2 || true
28+ ip route >&2 || true
29+ ip -6 route >&2 || true
30+ return 0
31+}
32+
33+apt_update_with_retries() {
34+ local attempt=1
35+ while [ "$attempt" -le 5 ]; do
36+ if apt-get update; then
37+ return 0
38+ fi
39+ echo "[vibebox][warn] apt-get update failed (attempt ${attempt}/5); retrying..." >&2
40+ attempt=$((attempt + 1))
41+ sleep 2
42+ done
43+ return 1
44+}
3
45
4# Don't wait too long for slow mirrors.
46# Don't wait too long for slow mirrors.
5−echo 'Acquire::http::Timeout "2";' | tee /etc/apt/apt.conf.d/99timeout
47+echo 'Acquire::http::Timeout "10";' | tee /etc/apt/apt.conf.d/99timeout
6−echo 'Acquire::https::Timeout "2";' | tee -a /etc/apt/apt.conf.d/99timeout
48+echo 'Acquire::https::Timeout "10";' | tee -a /etc/apt/apt.conf.d/99timeout
7−echo 'Acquire::Retries "2";' | tee -a /etc/apt/apt.conf.d/99timeout
49+echo 'Acquire::Retries "5";' | tee -a /etc/apt/apt.conf.d/99timeout
8
50
9−apt-get update
51+wait_for_network
52+apt_update_with_retries
10apt-get install -y --no-install-recommends \
53apt-get install -y --no-install-recommends \
11 build-essential \
54 build-essential \
12 pkg-config \
55 pkg-config \
@@ -14,6 +57,7 @@ apt-get install -y --no-install-recommends \
14 curl \
57 curl \
15 git \
58 git \
16 ripgrep \
59 ripgrep \
60+ cloud-guest-utils \
17 openssh-server \
61 openssh-server \
18 sudo
62 sudo
19
63
@@ -42,6 +86,11 @@ systemctl restart ssh
42# Set this env var so claude doesn't complain about running as root.'
86# Set this env var so claude doesn't complain about running as root.'
43echo "export IS_SANDBOX=1" >> .bashrc
87echo "export IS_SANDBOX=1" >> .bashrc
44
88
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+
45# Shutdown the VM when you logout
94# Shutdown the VM when you logout
46cat > .bash_logout <<EOF
95cat > .bash_logout <<EOF
47systemctl poweroff
96systemctl poweroff
@@ -49,4 +98,5 @@ sleep 100 # sleep here so that we don't see the login screen flash up before the
49EOF
98EOF
50
99
51# Done provisioning, power off the VM
100# Done provisioning, power off the VM
101+printf "%s%s\n" VIBEBOX_PROVISION_ OK
52systemctl poweroff
102systemctl 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+272−326
@@ -1,39 +1,46 @@
1+use anyhow::Result;
1use std::{
2use std::{
2 env, fs,
3 env, fs,
3 io::{self, Write},
4 io::{self, Write},
4 path::{Path, PathBuf},
5 path::{Path, PathBuf},
5};
6};
6
7
8+use crate::config::config_path;
9+use crate::instance::{VmLiveness, read_instance_config, vm_liveness};
7use serde::{Deserialize, Serialize};
10use serde::{Deserialize, Serialize};
8−use time::OffsetDateTime;
9−use uuid::Uuid;
10
11
11pub const INSTANCE_DIR_NAME: &str = ".vibebox";
12pub const INSTANCE_DIR_NAME: &str = ".vibebox";
13+pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
12pub const GLOBAL_DIR_NAME: &str = ".vibebox";
14pub const GLOBAL_DIR_NAME: &str = ".vibebox";
13−pub const SESSION_INDEX_FILENAME: &str = "sessions.toml";
14−pub const SESSION_TEMP_PREFIX: &str = "sessions";
15−pub const SESSION_TOML_SUFFIX: &str = ".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";
16
19
17−#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20+#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct SessionRecord {
21pub struct SessionRecord {
19− pub id: Uuid,
20 pub directory: PathBuf,
22 pub directory: PathBuf,
21− #[serde(with = "time::serde::rfc3339")]
23+ pub id: String,
22− pub last_active: OffsetDateTime,
24+ pub last_active: Option<String>,
23− #[serde(default)]
25+ pub active: bool,
24− pub ref_count: u64,
25}
26}
26
27
27−#[derive(Debug, Default, Serialize, Deserialize)]
28+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28−struct SessionIndex {
29+struct SessionEntry {
29− #[serde(default)]
30+ pub directory: PathBuf,
30− sessions: Vec<SessionRecord>,
31+ pub id: String,
31}
32}
32
33
33#[derive(Debug)]
34#[derive(Debug)]
34pub struct SessionManager {
35pub struct SessionManager {
35− global_dir: PathBuf,
36+ sessions_dir: PathBuf,
36− index_path: 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,
37}
44}
38
45
39#[derive(Debug, thiserror::Error)]
46#[derive(Debug, thiserror::Error)]
@@ -44,14 +51,8 @@ pub enum SessionError {
44 NonAbsoluteDirectory(PathBuf),
51 NonAbsoluteDirectory(PathBuf),
45 #[error("Session directory does not exist: {0}")]
52 #[error("Session directory does not exist: {0}")]
46 MissingDirectory(PathBuf),
53 MissingDirectory(PathBuf),
47− #[error("Session already exists for directory: {0}")]
48− DirectoryAlreadyHasSession(PathBuf),
49− #[error("Session not found: {0}")]
50− SessionNotFound(Uuid),
51− #[error("Ref count underflow for session: {0}")]
52− RefCountUnderflow(Uuid),
53 #[error(transparent)]
54 #[error(transparent)]
54− Io(#[from] std::io::Error),
55+ Io(#[from] io::Error),
55 #[error(transparent)]
56 #[error(transparent)]
56 TomlDe(#[from] toml::de::Error),
57 TomlDe(#[from] toml::de::Error),
57 #[error(transparent)]
58 #[error(transparent)]
@@ -67,201 +68,226 @@ impl SessionManager {
67 }
68 }
68
69
69 pub fn with_global_dir(global_dir: PathBuf) -> Self {
70 pub fn with_global_dir(global_dir: PathBuf) -> Self {
70− let index_path = global_dir.join(SESSION_INDEX_FILENAME);
71+ let sessions_dir = global_dir.join(SESSIONS_DIR_NAME);
71− Self {
72+ Self { sessions_dir }
72− global_dir,
73− index_path,
74− }
75 }
73 }
76
74
77 pub fn index_path(&self) -> &Path {
75 pub fn index_path(&self) -> &Path {
78− &self.index_path
76+ &self.sessions_dir
79− }
80−
81− pub fn create_session(&self, directory: &Path) -> Result<SessionRecord, SessionError> {
82− let directory = self.normalize_directory(directory)?;
83− let mut index = self.read_index()?;
84−
85− if index.sessions.iter().any(|s| s.directory == directory) {
86− return Err(SessionError::DirectoryAlreadyHasSession(directory));
87− }
88−
89− let now = OffsetDateTime::now_utc();
90− let session = SessionRecord {
91− id: Uuid::now_v7(),
92− directory: directory.clone(),
93− last_active: now,
94− ref_count: 1,
95− };
96−
97− fs::create_dir_all(self.instance_dir_for(&directory))?;
98− index.sessions.push(session.clone());
99− self.write_index(&index)?;
100−
101− Ok(session)
102 }
77 }
103
78
104− pub fn get_or_create_session(&self, directory: &Path) -> Result<SessionRecord, SessionError> {
79+ pub fn update_global_sessions(&self, directory: &Path) -> Result<Vec<PathBuf>, SessionError> {
105 let directory = self.normalize_directory(directory)?;
80 let directory = self.normalize_directory(directory)?;
106− let mut index = self.read_index()?;
81+ fs::create_dir_all(&self.sessions_dir)?;
107−
82+
108− if let Some(pos) = index.sessions.iter().position(|s| s.directory == directory) {
83+ let (mut sessions, removed) = self.prune_stale_sessions()?;
109− if !self
84+ let has_config = is_vibebox_dir(&directory);
110− .instance_dir_for(&index.sessions[pos].directory)
85+ let mut added = false;
111− .is_dir()
86+
112− {
87+ if has_config {
113− index.sessions.remove(pos);
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;
114 } else {
101 } else {
115− let now = OffsetDateTime::now_utc();
102+ tracing::warn!(
116− let session = &mut index.sessions[pos];
103+ directory = %directory.display(),
117− session.ref_count = session.ref_count.saturating_add(1);
104+ "missing session id in instance file"
118− session.last_active = now;
105+ );
119− let updated = session.clone();
120− self.write_index(&index)?;
121− return Ok(updated);
122 }
106 }
123 }
107 }
124
108
125− let now = OffsetDateTime::now_utc();
109+ if removed > 0 || added {
126− let session = SessionRecord {
110+ tracing::debug!(
127− id: Uuid::now_v7(),
111+ path = %self.sessions_dir.display(),
128− directory: directory.clone(),
112+ removed,
129− last_active: now,
113+ added,
130− ref_count: 1,
114+ entries = sessions.len(),
131− };
115+ "updated global sessions"
132−
116+ );
133− fs::create_dir_all(self.instance_dir_for(&directory))?;
117+ } else {
134− index.sessions.push(session.clone());
118+ tracing::debug!(
135− self.write_index(&index)?;
119+ path = %self.sessions_dir.display(),
120+ entries = sessions.len(),
121+ has_config,
122+ "global sessions unchanged"
123+ );
124+ }
136
125
137− Ok(session)
126+ Ok(sessions.into_iter().map(|s| s.directory).collect())
138 }
127 }
139
128
140 pub fn list_sessions(&self) -> Result<Vec<SessionRecord>, SessionError> {
129 pub fn list_sessions(&self) -> Result<Vec<SessionRecord>, SessionError> {
141− let mut index = self.read_index()?;
130+ let (sessions, removed) = self.prune_stale_sessions()?;
142− let removed = self.remove_orphans(&mut index);
143 if removed > 0 {
131 if removed > 0 {
144− self.write_index(&index)?;
132+ tracing::info!(
133+ path = %self.sessions_dir.display(),
134+ removed,
135+ entries = sessions.len(),
136+ "pruned stale sessions"
137+ );
145 }
138 }
146− Ok(index.sessions)
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,
149+ });
150+ }
151+ Ok(records)
147 }
152 }
148
153
149− pub fn delete_session(&self, id: Uuid) -> Result<bool, SessionError> {
154+ pub fn clean_project(&self, directory: &Path) -> Result<CleanSummary, SessionError> {
150− let mut index = self.read_index()?;
155+ let directory = self.normalize_directory(directory)?;
151− let Some(pos) = index.sessions.iter().position(|s| s.id == id) else {
156+ let instance_dir = directory.join(INSTANCE_DIR_NAME);
152− return Ok(false);
157+ let mut removed_instance_dir = false;
153− };
158+ if instance_dir.exists() {
154−
155− let session = index.sessions.remove(pos);
156− let instance_dir = self.instance_dir_for(&session.directory);
157− match fs::remove_dir_all(&instance_dir) {
159+ fs::remove_dir_all(&instance_dir)?;
158− Ok(_) => {}
160+ removed_instance_dir = true;
159− Err(err) if err.kind() == io::ErrorKind::NotFound => {}
160− Err(err) => return Err(err.into()),
161 }
161 }
162−
162+ let removed_sessions = self.remove_session_records_for_directory(&directory)?;
163− self.write_index(&index)?;
163+ Ok(CleanSummary {
164− Ok(true)
164+ instance_dir,
165+ removed_instance_dir,
166+ removed_sessions,
167+ })
165 }
168 }
166
169
167− pub fn bump_last_active(&self, id: Uuid) -> Result<SessionRecord, SessionError> {
170+ fn normalize_directory(&self, directory: &Path) -> Result<PathBuf, SessionError> {
168− let now = OffsetDateTime::now_utc();
171+ if !directory.is_absolute() {
169− self.update_session(id, |session| {
172+ return Err(SessionError::NonAbsoluteDirectory(directory.to_path_buf()));
170− session.last_active = now;
173+ }
171− Ok(())
174+ if !directory.exists() {
172− })
175+ return Err(SessionError::MissingDirectory(directory.to_path_buf()));
176+ }
177+ Ok(directory.canonicalize()?)
173 }
178 }
174
179
175− pub fn increment_ref_count(&self, id: Uuid) -> Result<SessionRecord, SessionError> {
180+ fn session_path_for(&self, id: &str) -> PathBuf {
176− let now = OffsetDateTime::now_utc();
181+ let filename = format!("{id}{SESSION_TOML_SUFFIX}");
177− self.update_session(id, |session| {
182+ self.sessions_dir.join(filename)
178− session.ref_count = session.ref_count.saturating_add(1);
179− session.last_active = now;
180− Ok(())
181− })
182 }
183 }
183
184
184− pub fn decrement_ref_count(&self, id: Uuid) -> Result<SessionRecord, SessionError> {
185+ fn write_session_record(&self, record: &SessionEntry) -> Result<(), SessionError> {
185− let now = OffsetDateTime::now_utc();
186+ fs::create_dir_all(&self.sessions_dir)?;
186− self.update_session(id, |session| {
187+ let path = self.session_path_for(&record.id);
187− if session.ref_count == 0 {
188+ let content = toml::to_string_pretty(record)?;
188− return Err(SessionError::RefCountUnderflow(id));
189+ atomic_write(&path, content.as_bytes())?;
189− }
190+ tracing::debug!(
190− session.ref_count -= 1;
191+ path = %path.display(),
191− session.last_active = now;
192+ "wrote session record"
193+ );
192− Ok(())
194+ Ok(())
193− })
194 }
195 }
195
196
196− pub fn cleanup_orphans(&self) -> Result<usize, SessionError> {
197+ fn prune_stale_sessions(&self) -> Result<(Vec<SessionEntry>, usize), SessionError> {
197− let mut index = self.read_index()?;
198+ if !self.sessions_dir.exists() {
198− let removed = self.remove_orphans(&mut index);
199+ return Ok((Vec::new(), 0));
199− if removed > 0 {
200− self.write_index(&index)?;
201 }
200 }
202− Ok(removed)
203− }
204
201
205− fn update_session<F>(&self, id: Uuid, mut update: F) -> Result<SessionRecord, SessionError>
202+ let mut sessions = Vec::new();
206− where
203+ let mut removed = 0usize;
207− F: FnMut(&mut SessionRecord) -> Result<(), SessionError>,
204+
208− {
205+ for entry in fs::read_dir(&self.sessions_dir)? {
209− let mut index = self.read_index()?;
206+ let entry = entry?;
210− let Some(pos) = index.sessions.iter().position(|s| s.id == id) else {
207+ let path = entry.path();
211− return Err(SessionError::SessionNotFound(id));
208+ if !path.is_file() {
212− };
209+ continue;
213−
210+ }
214− if !self
211+ let record = read_session_file(&path)?;
215− .instance_dir_for(&index.sessions[pos].directory)
212+ if !is_vibebox_dir(&record.directory) {
216− .is_dir()
213+ let _ = fs::remove_file(&path);
217− {
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+ }
218− index.sessions.remove(pos);
224+ sessions.push(record);
219− self.write_index(&index)?;
220− return Err(SessionError::SessionNotFound(id));
221 }
225 }
222
226
223− update(&mut index.sessions[pos])?;
227+ Ok((sessions, removed))
224− let updated = index.sessions[pos].clone();
225− self.write_index(&index)?;
226− Ok(updated)
227 }
228 }
228
229
230+ fn remove_session_records_for_directory(
231+ &self,
232+ directory: &Path,
229− fn normalize_directory(&self, directory: &Path) -> Result<PathBuf, SessionError> {
233+ ) -> Result<usize, SessionError> {
230− if !directory.is_absolute() {
234+ if !self.sessions_dir.exists() {
231− return Err(SessionError::NonAbsoluteDirectory(directory.to_path_buf()));
235+ return Ok(0);
232 }
236 }
233− if !directory.exists() {
237+ let mut removed = 0usize;
234− return Err(SessionError::MissingDirectory(directory.to_path_buf()));
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+ }
235 }
249 }
236− Ok(directory.canonicalize()?)
250+ if removed > 0 {
251+ tracing::info!(
252+ directory = %directory.display(),
253+ removed,
254+ "removed session records"
255+ );
237− }
256+ }
238−
257+ Ok(removed)
239− fn instance_dir_for(&self, directory: &Path) -> PathBuf {
240− directory.join(INSTANCE_DIR_NAME)
241 }
258 }
259+}
242
260
243− fn remove_orphans(&self, index: &mut SessionIndex) -> usize {
261+fn is_vibebox_dir(directory: &Path) -> bool {
244− let before = index.sessions.len();
262+ if !directory.is_absolute() {
245− index
263+ return false;
246− .sessions
247− .retain(|s| self.instance_dir_for(&s.directory).is_dir());
248− before - index.sessions.len()
249 }
264 }
265+ config_path(directory).is_file()
266+}
250
267
251− fn read_index(&self) -> Result<SessionIndex, SessionError> {
268+fn is_session_active(directory: &Path) -> bool {
252− if !self.index_path.exists() {
269+ let instance_dir = directory.join(INSTANCE_DIR_NAME);
253− return Ok(SessionIndex::default());
270+ let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
254− }
271+ match vm_liveness(directory) {
255− let content = fs::read_to_string(&self.index_path)?;
272+ Ok(liveness) => match liveness {
256− Ok(toml::from_str(&content)?)
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,
257 }
281 }
282+}
258
283
259− fn write_index(&self, index: &SessionIndex) -> Result<(), SessionError> {
284+fn read_session_file(path: &Path) -> Result<SessionEntry, SessionError> {
260− fs::create_dir_all(&self.global_dir)?;
285+ let raw = fs::read_to_string(path)?;
261− let content = toml::to_string_pretty(index)?;
286+ let record: SessionEntry = toml::from_str(&raw)?;
262− atomic_write(&self.index_path, content.as_bytes())?;
287+ if record.id.trim().is_empty() {
263− Ok(())
288+ return Err(io::Error::new(io::ErrorKind::InvalidData, "session id missing").into());
264 }
289 }
290+ Ok(record)
265}
291}
266
292
267fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
293fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
@@ -274,7 +300,7 @@ fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
274
300
275 fs::create_dir_all(parent)?;
301 fs::create_dir_all(parent)?;
276 let mut temp = tempfile::Builder::new()
302 let mut temp = tempfile::Builder::new()
277− .prefix(SESSION_TEMP_PREFIX)
303+ .prefix(SESSIONS_DIR_NAME)
278 .suffix(SESSION_TOML_SUFFIX)
304 .suffix(SESSION_TOML_SUFFIX)
279 .tempfile_in(parent)?;
305 .tempfile_in(parent)?;
280 temp.write_all(content)?;
306 temp.write_all(content)?;
@@ -286,9 +312,9 @@ fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
286#[cfg(test)]
312#[cfg(test)]
287mod tests {
313mod tests {
288 use super::*;
314 use super::*;
315+ use crate::instance::{InstanceConfig, write_instance_config};
289 use std::fs;
316 use std::fs;
290 use tempfile::TempDir;
317 use tempfile::TempDir;
291− use time::OffsetDateTime;
292
318
293 fn manager(temp: &TempDir) -> SessionManager {
319 fn manager(temp: &TempDir) -> SessionManager {
294 SessionManager::with_global_dir(temp.path().join("global"))
320 SessionManager::with_global_dir(temp.path().join("global"))
@@ -300,188 +326,108 @@ mod tests {
300 dir
326 dir
301 }
327 }
302
328
303− #[test]
329+ fn write_instance(project_dir: &Path, id: &str, last_active: &str) -> Result<()> {
304− fn create_session_writes_index_and_instance_dir() {
330+ fs::create_dir_all(project_dir)?;
305− let temp = TempDir::new().unwrap();
306− let mgr = manager(&temp);
307− let project_dir = create_project_dir(&temp);
308−
309− let session = mgr.create_session(&project_dir).unwrap();
310−
311− assert!(mgr.index_path().exists());
312− assert_eq!(session.directory, project_dir.canonicalize().unwrap());
313− assert_eq!(session.ref_count, 1);
314− assert!(project_dir.join(INSTANCE_DIR_NAME).is_dir());
315− }
316−
317− #[test]
318− fn create_session_rejects_non_absolute_directory() {
319− let temp = TempDir::new().unwrap();
320− let mgr = manager(&temp);
321−
322− let err = mgr.create_session(Path::new("relative/path")).unwrap_err();
323−
324− assert!(matches!(err, SessionError::NonAbsoluteDirectory(_)));
325− }
326−
327− #[test]
328− fn create_session_rejects_missing_directory() {
329− let temp = TempDir::new().unwrap();
330− let mgr = manager(&temp);
331− let missing = temp.path().join("missing");
332−
333− let err = mgr.create_session(&missing).unwrap_err();
334−
335− assert!(matches!(err, SessionError::MissingDirectory(_)));
336− }
337−
338− #[test]
339− fn create_session_rejects_duplicate_directory() {
340− let temp = TempDir::new().unwrap();
341− let mgr = manager(&temp);
342− let project_dir = create_project_dir(&temp);
343−
344− let _session = mgr.create_session(&project_dir).unwrap();
345− let err = mgr.create_session(&project_dir).unwrap_err();
346−
347− assert!(matches!(err, SessionError::DirectoryAlreadyHasSession(_)));
348− }
349−
350− #[test]
351− fn get_or_create_increments_ref_count_for_existing_session() {
352− let temp = TempDir::new().unwrap();
353− let mgr = manager(&temp);
354− let project_dir = create_project_dir(&temp);
355
331
356− let first = mgr.create_session(&project_dir).unwrap();
332+ let mut config = InstanceConfig::default();
357− let second = mgr.get_or_create_session(&project_dir).unwrap();
333+ config.id = id.to_string();
334+ config.last_active = Some(last_active.to_string());
358
335
359− assert_eq!(first.id, second.id);
336+ write_instance_config(project_dir, &config)
360− assert_eq!(second.ref_count, 2);
361 }
337 }
362
338
363 #[test]
339 #[test]
364− fn decrement_ref_count_errors_on_underflow() {
340+ fn update_global_sessions_adds_directory() {
365 let temp = TempDir::new().unwrap();
341 let temp = TempDir::new().unwrap();
366 let mgr = manager(&temp);
342 let mgr = manager(&temp);
367 let project_dir = create_project_dir(&temp);
343 let project_dir = create_project_dir(&temp);
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+ );
353+
354+ let dirs = mgr.update_global_sessions(&project_dir).unwrap();
355+
356+ assert_eq!(dirs.len(), 1);
357+ assert_eq!(dirs[0], project_dir.canonicalize().unwrap());
358+ assert!(mgr.index_path().exists());
368
359
369− let session = mgr.create_session(&project_dir).unwrap();
360+ let session_path = mgr.index_path().join(format!(
370− let session = mgr.decrement_ref_count(session.id).unwrap();
361+ "019bf290-cccc-7c23-ba1d-dce7e6d40693{}",
371− assert_eq!(session.ref_count, 0);
362+ SESSION_TOML_SUFFIX
372−
363+ ));
373− let err = mgr.decrement_ref_count(session.id).unwrap_err();
364+ assert!(session_path.exists());
374− assert!(matches!(err, SessionError::RefCountUnderflow(_)));
375 }
365 }
376
366
377 #[test]
367 #[test]
378− fn list_sessions_cleans_orphans() {
368+ fn update_global_sessions_removes_missing_vibebox_toml() {
379 let temp = TempDir::new().unwrap();
369 let temp = TempDir::new().unwrap();
380 let mgr = manager(&temp);
370 let mgr = manager(&temp);
381 let project_dir = create_project_dir(&temp);
371 let project_dir = create_project_dir(&temp);
382−
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+ );
383− let _session = mgr.create_session(&project_dir).unwrap();
381+ let _ = mgr.update_global_sessions(&project_dir).unwrap();
384− fs::remove_dir_all(project_dir.join(INSTANCE_DIR_NAME)).unwrap();
382+
385−
383+ fs::remove_file(config_path(project_dir.as_path())).unwrap();
386 let sessions = mgr.list_sessions().unwrap();
384 let sessions = mgr.list_sessions().unwrap();
387 assert!(sessions.is_empty());
385 assert!(sessions.is_empty());
388− }
389−
390− #[test]
391− fn delete_session_removes_instance_dir_and_index() {
392− let temp = TempDir::new().unwrap();
393− let mgr = manager(&temp);
394− let project_dir = create_project_dir(&temp);
395
386
396− let session = mgr.create_session(&project_dir).unwrap();
387+ let session_path = mgr.index_path().join(format!(
397− let removed = mgr.delete_session(session.id).unwrap();
388+ "019bf290-cccc-7c23-ba1d-dce7e6d40693{}",
398−
389+ SESSION_TOML_SUFFIX
399− assert!(removed);
390+ ));
400− assert!(!project_dir.join(INSTANCE_DIR_NAME).exists());
391+ assert!(!session_path.exists());
401− assert!(mgr.list_sessions().unwrap().is_empty());
402 }
392 }
403
393
404 #[test]
394 #[test]
405 fn invalid_toml_returns_error() {
395 fn invalid_toml_returns_error() {
406 let temp = TempDir::new().unwrap();
396 let temp = TempDir::new().unwrap();
407 let mgr = manager(&temp);
397 let mgr = manager(&temp);
408− let index_path = mgr.index_path();
409− fs::create_dir_all(index_path.parent().unwrap()).unwrap();
398+ fs::create_dir_all(mgr.index_path()).unwrap();
410− fs::write(index_path, "this is not toml").unwrap();
399+ fs::write(
400+ mgr.index_path().join(format!("bad{SESSION_TOML_SUFFIX}")),
401+ "not toml",
402+ )
403+ .unwrap();
411
404
412 let err = mgr.list_sessions().unwrap_err();
405 let err = mgr.list_sessions().unwrap_err();
413−
414 assert!(matches!(err, SessionError::TomlDe(_)));
406 assert!(matches!(err, SessionError::TomlDe(_)));
415 }
407 }
416
408
417 #[test]
409 #[test]
418− fn bump_last_active_updates_timestamp() {
410+ fn list_sessions_reads_session_files() {
419− let temp = TempDir::new().unwrap();
420− let mgr = manager(&temp);
421− let project_dir = create_project_dir(&temp);
422−
423− let session = mgr.create_session(&project_dir).unwrap();
424− let before = session.last_active;
425− std::thread::sleep(std::time::Duration::from_millis(5));
426− let updated = mgr.bump_last_active(session.id).unwrap();
427− let now = OffsetDateTime::now_utc();
428−
429− assert!(updated.last_active >= before);
430− assert!(updated.last_active <= now);
431− }
432−
433− #[test]
434− fn cleanup_orphans_returns_removed_count() {
435− let temp = TempDir::new().unwrap();
436− let mgr = manager(&temp);
437− let project_dir = create_project_dir(&temp);
438−
439− let _session = mgr.create_session(&project_dir).unwrap();
440− fs::remove_dir_all(project_dir.join(INSTANCE_DIR_NAME)).unwrap();
441−
442− let removed = mgr.cleanup_orphans().unwrap();
443− assert_eq!(removed, 1);
444− assert!(mgr.list_sessions().unwrap().is_empty());
445− }
446−
447− #[test]
448− fn increment_ref_count_updates_last_active() {
449− let temp = TempDir::new().unwrap();
450− let mgr = manager(&temp);
451− let project_dir = create_project_dir(&temp);
452−
453− let session = mgr.create_session(&project_dir).unwrap();
454− std::thread::sleep(std::time::Duration::from_millis(5));
455− let updated = mgr.increment_ref_count(session.id).unwrap();
456−
457− assert_eq!(updated.ref_count, session.ref_count + 1);
458− assert!(updated.last_active >= session.last_active);
459− }
460−
461− #[test]
462− fn decrement_ref_count_updates_last_active() {
463 let temp = TempDir::new().unwrap();
411 let temp = TempDir::new().unwrap();
464 let mgr = manager(&temp);
412 let mgr = manager(&temp);
465 let project_dir = create_project_dir(&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+ );
423+ let _ = mgr.update_global_sessions(&project_dir).unwrap();
466
424
467− let session = mgr.create_session(&project_dir).unwrap();
468− std::thread::sleep(std::time::Duration::from_millis(5));
469− let updated = mgr.decrement_ref_count(session.id).unwrap();
470−
471− assert_eq!(updated.ref_count, session.ref_count - 1);
472− assert!(updated.last_active >= session.last_active);
473− }
474−
475− #[test]
476− fn list_sessions_returns_active_sessions() {
477− let temp = TempDir::new().unwrap();
478− let mgr = manager(&temp);
479− let project_dir = create_project_dir(&temp);
480−
481− let session = mgr.create_session(&project_dir).unwrap();
482 let sessions = mgr.list_sessions().unwrap();
425 let sessions = mgr.list_sessions().unwrap();
483−
484 assert_eq!(sessions.len(), 1);
426 assert_eq!(sessions.len(), 1);
485− assert_eq!(sessions[0].id, session.id);
427+ assert_eq!(sessions[0].id, "019bf290-cccc-7c23-ba1d-dce7e6d40693");
428+ assert_eq!(
429+ sessions[0].last_active.as_deref(),
430+ Some("2026-02-07T05:00:00Z")
431+ );
486 }
432 }
487}
433}
src/ssh.sh+78−24
@@ -2,7 +2,9 @@
2set -eu
2set -eu
3
3
4SSH_USER="__SSH_USER__"
4SSH_USER="__SSH_USER__"
5+SUDO_PASSWORD="__SUDO_PASSWORD__"
5PROJECT_NAME="__PROJECT_NAME__"
6PROJECT_NAME="__PROJECT_NAME__"
7+PROJECT_GUEST_DIR="__PROJECT_GUEST_DIR__"
6KEY_PATH="__KEY_PATH__"
8KEY_PATH="__KEY_PATH__"
7
9
8diag() { echo "[vibebox][diag] $*" >&2; }
10diag() { echo "[vibebox][diag] $*" >&2; }
@@ -49,7 +51,7 @@ dump_diag() {
49}
51}
50
52
51# 1) tmpfs mount
53# 1) tmpfs mount
52−TARGET="/root/${PROJECT_NAME}/.vibebox"
54+TARGET="${PROJECT_GUEST_DIR}/.vibebox"
53if [ -d "$TARGET" ] && ! mountpoint -q "$TARGET"; then
55if [ -d "$TARGET" ] && ! mountpoint -q "$TARGET"; then
54 mount -t tmpfs tmpfs "$TARGET"
56 mount -t tmpfs tmpfs "$TARGET"
55fi
57fi
@@ -60,35 +62,69 @@ if ! id -u "$SSH_USER" >/dev/null 2>&1; then
60 usermod -aG sudo "$SSH_USER" || true
62 usermod -aG sudo "$SSH_USER" || true
61fi
63fi
62
64
65+if [ -n "$SUDO_PASSWORD" ]; then
66+ echo "${SSH_USER}:${SUDO_PASSWORD}" | chpasswd
67+fi
68+
63install -d -m 700 -o "$SSH_USER" -g "$SSH_USER" "/home/${SSH_USER}/.ssh"
69install -d -m 700 -o "$SSH_USER" -g "$SSH_USER" "/home/${SSH_USER}/.ssh"
64install -m 600 -o "$SSH_USER" -g "$SSH_USER" "$KEY_PATH" "/home/${SSH_USER}/.ssh/authorized_keys"
70install -m 600 -o "$SSH_USER" -g "$SSH_USER" "$KEY_PATH" "/home/${SSH_USER}/.ssh/authorized_keys"
65
71
66−# Ensure codex/claude are visible in the user's HOME
67USER_HOME="$(getent passwd "$SSH_USER" | cut -d: -f6 2>/dev/null || true)"
72USER_HOME="$(getent passwd "$SSH_USER" | cut -d: -f6 2>/dev/null || true)"
68if [ -z "$USER_HOME" ]; then
73if [ -z "$USER_HOME" ]; then
69 USER_HOME="/home/${SSH_USER}"
74 USER_HOME="/home/${SSH_USER}"
70fi
75fi
71−install -d -m 755 /usr/local/codex /usr/local/claude
76+
72−if [ ! -e "${USER_HOME}/.codex" ]; then
77+# Home mount links (config-driven)
73− ln -s /usr/local/codex "${USER_HOME}/.codex"
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__"
74−fi
96+ fi
97+ ;;
98+esac
99+VIBEBOX_PROJECT_EOF
100+chmod 644 /etc/profile.d/vibebox-project.sh
101+
75−if [ ! -e "${USER_HOME}/.claude" ]; then
102+if ! grep -q "vibebox-aliases" "${USER_HOME}/.bashrc" 2>/dev/null; then
76− ln -s /usr/local/claude "${USER_HOME}/.claude"
103+ {
104+ echo ""
105+ echo "# vibebox-aliases"
106+ echo ". /etc/profile.d/vibebox.sh"
107+ } >> "${USER_HOME}/.bashrc"
77fi
108fi
78−chown -h "${SSH_USER}:${SSH_USER}" "${USER_HOME}/.codex" "${USER_HOME}/.claude" 2>/dev/null || true
79
109
80# Install Mise
110# Install Mise
81MISE_BIN="${USER_HOME}/.local/bin/mise"
111MISE_BIN="${USER_HOME}/.local/bin/mise"
112+mise_warn() { echo "[mise] $*" >&2; }
113+mise_ok() { command -v mise >/dev/null 2>&1 || [ -x "$MISE_BIN" ]; }
114+mise_install() {
82−if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
115+ if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
83− curl https://mise.run | HOME="$USER_HOME" sh
116+ if ! curl https://mise.run | HOME="$USER_HOME" sh; then
117+ mise_warn "mise install script failed"
118+ return 1
119+ fi
84−fi
120+ fi
85−echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
121+ echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
86
122
87−export PATH="${USER_HOME}/.local/bin:/usr/local/bin:$PATH"
123+ export PATH="${USER_HOME}/.local/bin:/usr/local/bin:$PATH"
88
124
89−mkdir -p "${USER_HOME}/.config/mise"
125+ mkdir -p "${USER_HOME}/.config/mise"
90
126
91−cat > "${USER_HOME}/.config/mise/config.toml" <<MISE
127+ cat > "${USER_HOME}/.config/mise/config.toml" <<MISE
92 [settings]
128 [settings]
93 # Always use the venv created by uv, if available in directory
129 # Always use the venv created by uv, if available in directory
94 python.uv_venv_auto = true
130 python.uv_venv_auto = true
@@ -102,11 +138,23 @@ cat > "${USER_HOME}/.config/mise/config.toml" <<MISE
102 "npm:@anthropic-ai/claude-code" = "latest"
138 "npm:@anthropic-ai/claude-code" = "latest"
103MISE
139MISE
104
140
105−touch "${USER_HOME}/.config/mise/mise.lock"
141+ touch "${USER_HOME}/.config/mise/mise.lock"
106−if [ -x "$MISE_BIN" ]; then
142+ if [ -x "$MISE_BIN" ]; then
107− HOME="$USER_HOME" "$MISE_BIN" install
143+ if ! HOME="$USER_HOME" "$MISE_BIN" install; then
144+ mise_warn "mise install failed"
145+ return 1
146+ fi
108−else
147+ else
109− HOME="$USER_HOME" mise install
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
110fi
158fi
111
159
112# 3) start ssh (don't swallow failures)
160# 3) start ssh (don't swallow failures)
@@ -115,7 +163,7 @@ if ! systemctl is-active --quiet ssh; then
115 if ! systemctl start ssh; then
163 if ! systemctl start ssh; then
116 diag "systemctl start ssh failed"
164 diag "systemctl start ssh failed"
117 dump_diag
165 dump_diag
118− exit 1
166+ vibebox_fail "failed to start ssh service" 1
119 fi
167 fi
120fi
168fi
121
169
@@ -153,7 +201,7 @@ done
153if [ -z "$dev" ] || [ -z "$ip" ]; then
201if [ -z "$dev" ] || [ -z "$ip" ]; then
154 diag "no stable IPv4 on default route interface"
202 diag "no stable IPv4 on default route interface"
155 dump_diag
203 dump_diag
156− exit 1
204+ vibebox_fail "no stable ipv4 route on default interface" 1
157fi
205fi
158
206
159# 5) strong verify: ssh must listen externally (0.0.0.0:22 or $ip:22 or [::]:22)
207# 5) strong verify: ssh must listen externally (0.0.0.0:22 or $ip:22 or [::]:22)
@@ -172,8 +220,14 @@ done
172if ! listens_ok; then
220if ! listens_ok; then
173 diag "sshd not listening on 0.0.0.0:22 / ${ip}:22"
221 diag "sshd not listening on 0.0.0.0:22 / ${ip}:22"
174 dump_diag
222 dump_diag
175− exit 1
223+ vibebox_fail "sshd is not listening on the expected address" 1
176fi
224fi
177
225
226+ip a
227+ip link
228+curl -s https://api.ipify.org ; echo
229+
230+cat /etc/machine-id
231+
178echo VIBEBOX_SSH_READY
232echo VIBEBOX_SSH_READY
179echo "VIBEBOX_IPV4=$ip"
233echo "VIBEBOX_IPV4=$ip"
src/tui.rs+276−43
@@ -1,10 +1,4 @@
1−use std::{
1+use bytesize::ByteSize;
2− io::{self, Write},
3− os::unix::io::OwnedFd,
4− path::PathBuf,
5− sync::{Arc, Mutex},
6−};
7−
8use color_eyre::Result;
2use color_eyre::Result;
9use crossterm::{
3use crossterm::{
10 cursor::{MoveTo, Show},
4 cursor::{MoveTo, Show},
@@ -20,11 +14,18 @@ use ratatui::{
20 layout::{Constraint, Direction, Layout, Rect},
14 layout::{Constraint, Direction, Layout, Rect},
21 style::{Color, Style},
15 style::{Color, Style},
22 text::{Line, Span, Text},
16 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},
24};
24};
25
25
26use crate::vm;
26use crate::vm;
27
27
28+// https://patorjk.com/software/taag/#p=display&f=ANSI+Shadow&t=VIBEBOX&x=none&v=4&h=4&w=80&we=false
28const ASCII_BANNER: [&str; 7] = [
29const ASCII_BANNER: [&str; 7] = [
29 "██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
30 "██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
30 "██║ ██║██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗╚██╗██╔╝",
31 "██║ ██║██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗╚██╗██╔╝",
@@ -34,12 +35,15 @@ const ASCII_BANNER: [&str; 7] = [
34 " ╚═══╝ ╚═╝╚═════╝ ╚══════╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝",
35 " ╚═══╝ ╚═╝╚═════╝ ╚══════╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝",
35 "",
36 "",
36];
37];
38+const INFO_LINE_COUNT: u16 = 5;
37
39
38#[derive(Debug, Clone)]
40#[derive(Debug, Clone)]
39pub struct VmInfo {
41pub struct VmInfo {
40− pub version: String,
42+ pub max_memory: ByteSize,
41− pub max_memory_mb: u64,
42 pub cpu_cores: usize,
43 pub cpu_cores: usize,
44+ pub max_disk: ByteSize,
45+ pub system_name: String,
46+ pub auto_shutdown_ms: u64,
43}
47}
44
48
45#[derive(Debug)]
49#[derive(Debug)]
@@ -50,11 +54,7 @@ pub struct AppState {
50}
54}
51
55
52impl AppState {
56impl AppState {
53− pub fn new(cwd: PathBuf, vm_info: VmInfo) -> Self {
57+ pub fn new(cwd: PathBuf, vm_info: VmInfo, commands: VibeboxCommands) -> Self {
54− let mut commands = VibeboxCommands::default();
55− commands.add_command(":new", "Create a new session.");
56− commands.add_command(":exit", "Exit Vibebox.");
57−
58 Self {
58 Self {
59 cwd,
59 cwd,
60 vm_info,
60 vm_info,
@@ -69,6 +69,10 @@ pub struct VibeboxCommands {
69}
69}
70
70
71impl VibeboxCommands {
71impl VibeboxCommands {
72+ pub fn new_empty() -> Self {
73+ Self { items: Vec::new() }
74+ }
75+
72 pub fn add_command(&mut self, name: impl Into<String>, description: impl Into<String>) {
76 pub fn add_command(&mut self, name: impl Into<String>, description: impl Into<String>) {
73 self.items.push(VibeboxCommand {
77 self.items.push(VibeboxCommand {
74 name: name.into(),
78 name: name.into(),
@@ -98,6 +102,31 @@ pub struct VibeboxCommand {
98 pub description: String,
102 pub description: String,
99}
103}
100
104
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+
101#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
130#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
102struct PageLayout {
131struct PageLayout {
103 header: Rect,
132 header: Rect,
@@ -130,10 +159,14 @@ fn compute_page_layout(app: &AppState, width: u16) -> PageLayout {
130fn header_height() -> u16 {
159fn header_height() -> u16 {
131 let banner_height = ASCII_BANNER.len() as u16;
160 let banner_height = ASCII_BANNER.len() as u16;
132 let welcome_height = 1;
161 let welcome_height = 1;
133− let info_height = 4;
162+ let info_height = info_block_height();
134 welcome_height + banner_height + info_height
163 welcome_height + banner_height + info_height
135}
164}
136
165
166+fn info_block_height() -> u16 {
167+ INFO_LINE_COUNT.saturating_add(1)
168+}
169+
137pub fn render_tui_once(app: &mut AppState) -> Result<()> {
170pub fn render_tui_once(app: &mut AppState) -> Result<()> {
138 let (width, _) = crossterm::terminal::size()?;
171 let (width, _) = crossterm::terminal::size()?;
139 if width == 0 {
172 if width == 0 {
@@ -174,6 +207,197 @@ pub fn render_commands_component(app: &mut AppState) -> Result<()> {
174 Ok(())
207 Ok(())
175}
208}
176
209
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+
177pub fn passthrough_vm_io(
401pub fn passthrough_vm_io(
178 app: Arc<Mutex<AppState>>,
402 app: Arc<Mutex<AppState>>,
179 output_monitor: Arc<vm::OutputMonitor>,
403 output_monitor: Arc<vm::OutputMonitor>,
@@ -250,27 +474,27 @@ fn write_buffer_with_style(buffer: &Buffer, out: &mut impl Write) -> io::Result<
250 Ok(())
474 Ok(())
251}
475}
252
476
253−fn map_color(color: ratatui::style::Color) -> CrosstermColor {
477+fn map_color(color: Color) -> CrosstermColor {
254 match color {
478 match color {
255− ratatui::style::Color::Reset => CrosstermColor::Reset,
479+ Color::Reset => CrosstermColor::Reset,
256− ratatui::style::Color::Black => CrosstermColor::Black,
480+ Color::Black => CrosstermColor::Black,
257− ratatui::style::Color::Red => CrosstermColor::DarkRed,
481+ Color::Red => CrosstermColor::DarkRed,
258− ratatui::style::Color::Green => CrosstermColor::DarkGreen,
482+ Color::Green => CrosstermColor::DarkGreen,
259− ratatui::style::Color::Yellow => CrosstermColor::DarkYellow,
483+ Color::Yellow => CrosstermColor::DarkYellow,
260− ratatui::style::Color::Blue => CrosstermColor::DarkBlue,
484+ Color::Blue => CrosstermColor::DarkBlue,
261− ratatui::style::Color::Magenta => CrosstermColor::DarkMagenta,
485+ Color::Magenta => CrosstermColor::DarkMagenta,
262− ratatui::style::Color::Cyan => CrosstermColor::DarkCyan,
486+ Color::Cyan => CrosstermColor::DarkCyan,
263− ratatui::style::Color::Gray => CrosstermColor::Grey,
487+ Color::Gray => CrosstermColor::Grey,
264− ratatui::style::Color::DarkGray => CrosstermColor::DarkGrey,
488+ Color::DarkGray => CrosstermColor::DarkGrey,
265− ratatui::style::Color::LightRed => CrosstermColor::Red,
489+ Color::LightRed => CrosstermColor::Red,
266− ratatui::style::Color::LightGreen => CrosstermColor::Green,
490+ Color::LightGreen => CrosstermColor::Green,
267− ratatui::style::Color::LightYellow => CrosstermColor::Yellow,
491+ Color::LightYellow => CrosstermColor::Yellow,
268− ratatui::style::Color::LightBlue => CrosstermColor::Blue,
492+ Color::LightBlue => CrosstermColor::Blue,
269− ratatui::style::Color::LightMagenta => CrosstermColor::Magenta,
493+ Color::LightMagenta => CrosstermColor::Magenta,
270− ratatui::style::Color::LightCyan => CrosstermColor::Cyan,
494+ Color::LightCyan => CrosstermColor::Cyan,
271− ratatui::style::Color::White => CrosstermColor::White,
495+ Color::White => CrosstermColor::White,
272− ratatui::style::Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
496+ Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
273− ratatui::style::Color::Indexed(i) => CrosstermColor::AnsiValue(i),
497+ Color::Indexed(i) => CrosstermColor::AnsiValue(i),
274 }
498 }
275}
499}
276
500
@@ -316,13 +540,15 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
316 .constraints([
540 .constraints([
317 Constraint::Length(1),
541 Constraint::Length(1),
318 Constraint::Length(ASCII_BANNER.len() as u16),
542 Constraint::Length(ASCII_BANNER.len() as u16),
319− Constraint::Length(4),
543+ Constraint::Length(info_block_height()),
320 ])
544 ])
321 .split(area);
545 .split(area);
322
546
547+ let version = env!("CARGO_PKG_VERSION");
548+
323 let welcome = Line::from(vec![
549 let welcome = Line::from(vec![
324 Span::raw("Welcome to Vibebox v"),
550 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)),
326 ]);
552 ]);
327
553
328 Paragraph::new(welcome).render(header_chunks[0], buffer);
554 Paragraph::new(welcome).render(header_chunks[0], buffer);
@@ -341,19 +567,26 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
341 Span::styled(app.cwd.to_string_lossy(), Style::default().fg(Color::Cyan)),
567 Span::styled(app.cwd.to_string_lossy(), Style::default().fg(Color::Cyan)),
342 ]),
568 ]),
343 Line::from(vec![
569 Line::from(vec![
344− Span::raw("VM Version: "),
570+ Span::raw("System: "),
345− Span::styled(&app.vm_info.version, Style::default().fg(Color::Green)),
571+ Span::styled(&app.vm_info.system_name, Style::default().fg(Color::Green)),
346 ]),
572 ]),
347 Line::from(vec![
573 Line::from(vec![
348− Span::raw("CPU / Memory: "),
574+ Span::raw("CPU / Memory / Disk: "),
349 Span::styled(
575 Span::styled(
350 format!(
576 format!(
351− "{} cores / {} MB",
577+ "{} cores / {} / {}",
352− app.vm_info.cpu_cores, app.vm_info.max_memory_mb
578+ app.vm_info.cpu_cores, app.vm_info.max_memory, app.vm_info.max_disk
353 ),
579 ),
354 Style::default().fg(Color::Green),
580 Style::default().fg(Color::Green),
355 ),
581 ),
356 ]),
582 ]),
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+ ]),
357 ];
590 ];
358
591
359 Paragraph::new(info_lines)
592 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+376−331
@@ -1,8 +1,7 @@
1−use crate::session_manager::INSTANCE_DIR_NAME;
1+use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
2+use anyhow::{Context, Error, Result, bail};
2use std::{
3use std::{
3− env,
4+ env, fs,
4− ffi::OsString,
5− fs,
6 io::{self, Write},
5 io::{self, Write},
7 os::{
6 os::{
8 fd::RawFd,
7 fd::RawFd,
@@ -24,8 +23,8 @@ use std::{
24};
23};
25
24
26use block2::RcBlock;
25use block2::RcBlock;
26+use bytesize::ByteSize;
27use dispatch2::DispatchQueue;
27use dispatch2::DispatchQueue;
28−use lexopt::prelude::*;
29use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
28use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
30use objc2_foundation::*;
29use objc2_foundation::*;
31use objc2_virtualization::*;
30use objc2_virtualization::*;
@@ -34,39 +33,46 @@ const DEBIAN_COMPRESSED_DISK_URL: &str = "https://cloud.debian.org/images/cloud/
34const DEBIAN_COMPRESSED_SHA: &str = "6ab9be9e6834adc975268367f2f0235251671184345c34ee13031749fdfbf66fe4c3aafd949a2d98550426090e9ac645e79009c51eb0eefc984c15786570bb38";
33const DEBIAN_COMPRESSED_SHA: &str = "6ab9be9e6834adc975268367f2f0235251671184345c34ee13031749fdfbf66fe4c3aafd949a2d98550426090e9ac645e79009c51eb0eefc984c15786570bb38";
35const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
34const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
36const SHARED_DIRECTORIES_TAG: &str = "shared";
35const SHARED_DIRECTORIES_TAG: &str = "shared";
36+pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts";
37
37
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;
42const START_TIMEOUT: Duration = Duration::from_secs(60);
38const START_TIMEOUT: Duration = Duration::from_secs(60);
43−const DEFAULT_EXPECT_TIMEOUT: Duration = Duration::from_secs(30);
44const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
39const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
40+const PROVISION_EXPECT_TIMEOUT: Duration = Duration::from_secs(900);
41+
42+const ERROR_REPORT_SCRIPT: &str = include_str!("error_report.sh");
45const PROVISION_SCRIPT: &str = include_str!("provision.sh");
43const 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";
46
48
47#[derive(Clone)]
49#[derive(Clone)]
48−pub(crate) enum LoginAction {
50+pub enum LoginAction {
51+ Expect {
52+ text: String,
49− Expect { text: String, timeout: Duration },
53+ timeout: Duration,
54+ },
55+ ExpectEither {
56+ success: String,
57+ failure: String,
58+ timeout: Duration,
59+ },
50 Send(String),
60 Send(String),
51− Script { path: PathBuf, index: usize },
52}
61}
62+use crate::config::BoxConfig;
53use LoginAction::*;
63use LoginAction::*;
54
64
55#[derive(Clone)]
65#[derive(Clone)]
56−pub(crate) struct DirectoryShare {
66+pub struct DirectoryShare {
57 host: PathBuf,
67 host: PathBuf,
58 guest: PathBuf,
68 guest: PathBuf,
59 read_only: bool,
69 read_only: bool,
60}
70}
61
71
62impl DirectoryShare {
72impl DirectoryShare {
63− pub(crate) fn new(
64− host: PathBuf,
65− mut guest: PathBuf,
73+ pub fn new(host: PathBuf, mut guest: PathBuf, read_only: bool) -> Result<Self> {
66− read_only: bool,
67− ) -> Result<Self, Box<dyn std::error::Error>> {
68 if !host.exists() {
74 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()));
70 }
76 }
71 if !guest.is_absolute() {
77 if !guest.is_absolute() {
72 guest = PathBuf::from("/root").join(guest);
78 guest = PathBuf::from("/root").join(guest);
@@ -78,23 +84,22 @@ impl DirectoryShare {
78 })
84 })
79 }
85 }
80
86
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> {
82 let parts: Vec<&str> = spec.split(':').collect();
88 let parts: Vec<&str> = spec.split(':').collect();
83 if parts.len() < 2 || parts.len() > 3 {
89 if parts.len() < 2 || parts.len() > 3 {
84− return Err(format!("Invalid mount spec: {spec}").into());
90+ bail!(format!("invalid mount spec: {spec}"));
85 }
91 }
86− let host = PathBuf::from(parts[0]);
92+ let host = expand_tilde_path(parts[0]);
87 let guest = PathBuf::from(parts[1]);
93 let guest = PathBuf::from(parts[1]);
88 let read_only = if parts.len() == 3 {
94 let read_only = if parts.len() == 3 {
89 match parts[2] {
95 match parts[2] {
90 "read-only" => true,
96 "read-only" => true,
91 "read-write" => false,
97 "read-write" => false,
92 _ => {
98 _ => {
93− return Err(format!(
99+ bail!(format!(
94 "Invalid mount mode '{}'; expected read-only or read-write",
100 "Invalid mount mode '{}'; expected read-only or read-write",
95 parts[2]
101 parts[2]
96− )
102+ ));
97− .into());
98 }
103 }
99 }
104 }
100 } else {
105 } else {
@@ -117,19 +122,49 @@ impl DirectoryShare {
117 }
122 }
118}
123}
119
124
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+
120−pub fn run_with_args<F>(args: CliArgs, io_handler: F) -> Result<(), Box<dyn std::error::Error>>
154+pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<()>
121where
155where
122 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
156 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
123{
157{
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)
125}
159}
126
160
127−pub(crate) fn run_with_args_and_extras<F>(
161+pub fn run_with_args_and_extras<F>(
128− args: CliArgs,
162+ args: VmArg,
129 io_handler: F,
163 io_handler: F,
130 extra_login_actions: Vec<LoginAction>,
164 extra_login_actions: Vec<LoginAction>,
131 extra_directory_shares: Vec<DirectoryShare>,
165 extra_directory_shares: Vec<DirectoryShare>,
132−) -> Result<(), Box<dyn std::error::Error>>
166+ status: Option<&StatusEmitter<'_>>,
167+) -> Result<()>
133where
168where
134 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
169 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
135{
170{
@@ -138,7 +173,7 @@ where
138 let project_root = env::current_dir()?;
173 let project_root = env::current_dir()?;
139 let project_name = project_root
174 let project_name = project_root
140 .file_name()
175 .file_name()
141− .ok_or("Project directory has no name")?
176+ .with_context(|| "Project directory has no name")?
142 .to_string_lossy()
177 .to_string_lossy()
143 .into_owned();
178 .into_owned();
144
179
@@ -146,10 +181,14 @@ where
146 let cache_home = env::var("XDG_CACHE_HOME")
181 let cache_home = env::var("XDG_CACHE_HOME")
147 .map(PathBuf::from)
182 .map(PathBuf::from)
148 .unwrap_or_else(|_| home.join(".cache"));
183 .unwrap_or_else(|_| home.join(".cache"));
149− let cache_dir = cache_home.join("vibe");
184+ let cache_dir = cache_home.join(GLOBAL_CACHE_DIR_NAME);
150 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
185 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
151
186
152 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
187 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");
153
192
154 let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
193 let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
155 let base_compressed = cache_dir.join(basename_compressed);
194 let base_compressed = cache_dir.join(basename_compressed);
@@ -158,8 +197,8 @@ where
158 basename_compressed.trim_end_matches(".tar.xz")
197 basename_compressed.trim_end_matches(".tar.xz")
159 ));
198 ));
160
199
161− let default_raw = cache_dir.join("default.raw");
200+ let default_raw = cache_dir.join(DEFAULT_RAW_NAME);
162− let instance_raw = instance_dir.join("instance.raw");
201+ let instance_raw = instance_dir.join(INSTANCE_RAW_NAME);
163
202
164 // Prepare system-wide directories
203 // Prepare system-wide directories
165 fs::create_dir_all(&cache_dir)?;
204 fs::create_dir_all(&cache_dir)?;
@@ -168,28 +207,31 @@ where
168 let mise_directory_share =
207 let mise_directory_share =
169 DirectoryShare::new(guest_mise_cache, "/root/.local/share/mise".into(), false)?;
208 DirectoryShare::new(guest_mise_cache, "/root/.local/share/mise".into(), false)?;
170
209
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(
210+ ensure_default_image(
178− &base_raw,
211+ &base_raw,
179− &base_compressed,
212+ &base_compressed,
180− &default_raw,
213+ &default_raw,
181− std::slice::from_ref(&mise_directory_share),
214+ std::slice::from_ref(&mise_directory_share),
215+ Some(&provision_log),
216+ status,
182− )?;
217+ )?;
183− ensure_instance_disk(&instance_raw, &default_raw)?;
218+ let _ = ensure_instance_disk(
184−
219+ &instance_raw,
220+ &default_raw,
221+ ByteSize(args.disk_bytes),
222+ status,
223+ )?;
224+ let base_size = fs::metadata(&default_raw)?.len();
185− instance_raw
225+ let instance_size = fs::metadata(&instance_raw)?.len();
186− };
226+ let needs_resize = instance_size > base_size;
227+ let disk_path = instance_raw;
187
228
188 let mut login_actions = Vec::new();
229 let mut login_actions = Vec::new();
189 let mut directory_shares = Vec::new();
230 let mut directory_shares = Vec::new();
190
231
191 if !args.no_default_mounts {
232 if !args.no_default_mounts {
233+ let project_guest_dir = PathBuf::from(PROJECT_GUEST_BASE).join(project_name);
192− login_actions.push(Send(format!("cd {project_name}")));
234+ login_actions.push(Send(format!("cd {}", project_guest_dir.display())));
193
235
194 // 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.
236 // 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.
195 // I couldn't find an alternative way to do this --- the MacOS sandbox doesn't apply to the Apple Virtualization system
237 // 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
197 login_actions.push(Send(r"mount -t tmpfs tmpfs .git/".into()));
239 login_actions.push(Send(r"mount -t tmpfs tmpfs .git/".into()));
198 }
240 }
199
241
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−
209 directory_shares.push(mise_directory_share);
242 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− }
226 }
243 }
227
244
228 directory_shares.extend(extra_directory_shares);
245 directory_shares.extend(extra_directory_shares);
@@ -231,199 +248,46 @@ where
231 directory_shares.push(DirectoryShare::from_mount_spec(spec)?);
248 directory_shares.push(DirectoryShare::from_mount_spec(spec)?);
232 }
249 }
233
250
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+
234 if let Some(motd_action) = motd_login_action(&directory_shares) {
256 if let Some(motd_action) = motd_login_action(&directory_shares) {
235 login_actions.push(motd_action);
257 login_actions.push(motd_action);
236 }
258 }
237
259
238 login_actions.extend(extra_login_actions);
260 login_actions.extend(extra_login_actions);
239
261
240− // Any user-provided login actions must come after our system ones
241− login_actions.extend(args.login_actions);
242−
243 run_vm_with_io(
262 run_vm_with_io(
244 &disk_path,
263 &disk_path,
245 &login_actions,
264 &login_actions,
246 &directory_shares[..],
265 &directory_shares[..],
247 args.cpu_count,
266 args.cpu_count,
248 args.ram_bytes,
267 args.ram_bytes,
268+ status,
249 io_handler,
269 io_handler,
250 )
270 )
251}
271}
252
272
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)
273+pub fn script_command_from_content(label: &str, script: &str) -> Result<String> {
411−}
412−
413−pub(crate) fn script_command_from_content(
414− label: &str,
415− script: &str,
416−) -> Result<String, Box<dyn std::error::Error>> {
417 let marker = "VIBE_SCRIPT_EOF";
274 let marker = "VIBE_SCRIPT_EOF";
418 let guest_dir = "/tmp/vibe-scripts";
275 let guest_dir = "/tmp/vibe-scripts";
419 let guest_path = format!("{guest_dir}/{label}.sh");
276 let guest_path = format!("{guest_dir}/{label}.sh");
277+ let script_body = match script.split_once('\n') {
278+ Some((first, rest)) if first.starts_with("#!") => rest,
279+ _ => script,
280+ };
281+ let wrapped_script = ERROR_REPORT_SCRIPT
282+ .replace("__LABEL__", label)
283+ .replace("__SCRIPT_BODY__", script_body);
420 let command = format!(
284 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}"
422 );
286 );
423 if script.contains(marker) {
287 if script.contains(marker) {
424− return Err(
288+ bail!(format!(
425− format!("Script '{label}' contains marker '{marker}', cannot safely upload").into(),
289+ "Script '{label}' contains marker '{marker}', cannot safely upload"
426− );
290+ ));
427 }
291 }
428 Ok(command)
292 Ok(command)
429}
293}
@@ -487,6 +351,12 @@ enum WaitResult {
487 Found,
351 Found,
488}
352}
489
353
354+#[derive(PartialEq, Eq)]
355+enum WaitAnyResult {
356+ Timeout,
357+ Found(usize),
358+}
359+
490pub enum VmInput {
360pub enum VmInput {
491 Bytes(Vec<u8>),
361 Bytes(Vec<u8>),
492 Shutdown,
362 Shutdown,
@@ -494,6 +364,7 @@ pub enum VmInput {
494
364
495enum VmOutput {
365enum VmOutput {
496 LoginActionTimeout { action: String, timeout: Duration },
366 LoginActionTimeout { action: String, timeout: Duration },
367+ LoginActionFailed { action: String, reason: String },
497}
368}
498
369
499#[derive(Default)]
370#[derive(Default)]
@@ -530,6 +401,41 @@ impl OutputMonitor {
530 WaitResult::Found
401 WaitResult::Found
531 }
402 }
532 }
403 }
404+
405+ fn wait_for_any(&self, needles: &[&str], timeout: Duration) -> WaitAnyResult {
406+ let mut found: Option<usize> = None;
407+ let (_unused, timeout_result) = self
408+ .condvar
409+ .wait_timeout_while(self.buffer.lock().unwrap(), timeout, |buf| {
410+ if let Some((pos, idx, len)) = find_any(buf, needles) {
411+ *buf = buf[(pos + len)..].to_string();
412+ found = Some(idx);
413+ false
414+ } else {
415+ true
416+ }
417+ })
418+ .unwrap();
419+
420+ if timeout_result.timed_out() {
421+ WaitAnyResult::Timeout
422+ } else {
423+ WaitAnyResult::Found(found.unwrap_or(0))
424+ }
425+ }
426+}
427+
428+fn find_any(buf: &str, needles: &[&str]) -> Option<(usize, usize, usize)> {
429+ let mut best: Option<(usize, usize, usize)> = None; // (pos, idx, len)
430+ for (idx, needle) in needles.iter().enumerate() {
431+ if let Some(pos) = buf.find(needle) {
432+ let candidate = (pos, idx, needle.len());
433+ if best.is_none_or(|b| candidate.0 < b.0) {
434+ best = Some(candidate);
435+ }
436+ }
437+ }
438+ best
533}
439}
534
440
535#[derive(Debug)]
441#[derive(Debug)]
@@ -578,15 +484,17 @@ impl IoControl {
578fn ensure_base_image(
484fn ensure_base_image(
579 base_raw: &Path,
485 base_raw: &Path,
580 base_compressed: &Path,
486 base_compressed: &Path,
581−) -> Result<(), Box<dyn std::error::Error>> {
487+ status: Option<&StatusEmitter<'_>>,
488+) -> Result<()> {
582 if base_raw.exists() {
489 if base_raw.exists() {
583 return Ok(());
490 return Ok(());
584 }
491 }
585
492
586 if !base_compressed.exists()
493 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
588 {
495 {
589− println!("Downloading base image...");
496+ emit_status(status, "downloading base image...");
497+ tracing::info!("downloading base image");
590 let status = Command::new("curl")
498 let status = Command::new("curl")
591 .args([
499 .args([
592 "--continue-at",
500 "--continue-at",
@@ -600,12 +508,14 @@ fn ensure_base_image(
600 ])
508 ])
601 .status()?;
509 .status()?;
602 if !status.success() {
510 if !status.success() {
603− return Err("Failed to download base image".into());
511+ bail!("failed to download base image");
604 }
512 }
605 }
513 }
606
514
607 // Check SHA
515 // Check SHA
608 {
516 {
517+ emit_status(status, "verifying base image...");
518+ tracing::info!("verifying base image...");
609 let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
519 let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
610
520
611 let mut child = Command::new("/usr/bin/shasum")
521 let mut child = Command::new("/usr/bin/shasum")
@@ -623,18 +533,25 @@ fn ensure_base_image(
623
533
624 let status = child.wait().expect("failed to wait on child");
534 let status = child.wait().expect("failed to wait on child");
625 if !status.success() {
535 if !status.success() {
536+ bail!(format!(
626− return Err(format!("SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}").into());
537+ "SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}"
538+ ));
627 }
539 }
628 }
540 }
629
541
630− println!("Decompressing base image...");
542+ emit_status(status, "decompressing base image...");
543+ tracing::info!("decompressing base image...");
631 let status = Command::new("tar")
544 let status = Command::new("tar")
545+ .args([
546+ "-xOf",
632− .args(["-xOf", &base_compressed.to_string_lossy(), "disk.raw"])
547+ &base_compressed.to_string_lossy(),
548+ BASE_DISK_RAW_NAME,
549+ ])
633− .stdout(std::fs::File::create(base_raw)?)
550+ .stdout(fs::File::create(base_raw)?)
634 .status()?;
551 .status()?;
635
552
636 if !status.success() {
553 if !status.success() {
637− return Err("Failed to decompress base image".into());
554+ bail!("Failed to decompress base image");
638 }
555 }
639
556
640 Ok(())
557 Ok(())
@@ -645,24 +562,56 @@ fn ensure_default_image(
645 base_compressed: &Path,
562 base_compressed: &Path,
646 default_raw: &Path,
563 default_raw: &Path,
647 directory_shares: &[DirectoryShare],
564 directory_shares: &[DirectoryShare],
648−) -> Result<(), Box<dyn std::error::Error>> {
565+ provision_log: Option<&Path>,
566+ status: Option<&StatusEmitter<'_>>,
567+) -> Result<()> {
649 if default_raw.exists() {
568 if default_raw.exists() {
650 return Ok(());
569 return Ok(());
651 }
570 }
652
571
653− ensure_base_image(base_raw, base_compressed)?;
572+ ensure_base_image(base_raw, base_compressed, status)?;
654
573
655− println!("Configuring base image...");
574+ emit_status(status, "configuring base image...");
575+ tracing::info!("configuring base image...");
656 fs::copy(base_raw, default_raw)?;
576 fs::copy(base_raw, default_raw)?;
657
577
658 let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?;
578 let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?;
659− run_vm(
579+ let provision_actions = [
660− default_raw,
661− &[Send(provision_command)],
580+ Send(provision_command),
581+ ExpectEither {
582+ success: "VIBEBOX_PROVISION_OK".to_string(),
583+ failure: "VIBEBOX_PROVISION_FAILED".to_string(),
584+ timeout: PROVISION_EXPECT_TIMEOUT,
585+ },
586+ ];
587+ let provision_result = if let Some(log_path) = provision_log {
588+ let log_path = log_path.to_path_buf();
589+ run_vm_with_io(
590+ default_raw,
591+ &provision_actions,
592+ directory_shares,
593+ BoxConfig::default().cpu_count,
594+ BoxConfig::default().ram_size.as_u64(),
595+ status,
596+ move |output_monitor, vm_output_fd, vm_input_fd| {
597+ spawn_vm_io_with_log(output_monitor, vm_output_fd, vm_input_fd, log_path)
598+ },
599+ )
600+ } else {
601+ run_vm(
602+ default_raw,
603+ &provision_actions,
662− directory_shares,
604+ directory_shares,
663− DEFAULT_CPU_COUNT,
605+ BoxConfig::default().cpu_count,
664− DEFAULT_RAM_BYTES,
606+ BoxConfig::default().ram_size.as_u64(),
665− )?;
607+ status,
608+ )
609+ };
610+
611+ if let Err(err) = provision_result {
612+ let _ = fs::remove_file(default_raw);
613+ return Err(err);
614+ }
666
615
667 Ok(())
616 Ok(())
668}
617}
@@ -670,15 +619,47 @@ fn ensure_default_image(
670fn ensure_instance_disk(
619fn ensure_instance_disk(
671 instance_raw: &Path,
620 instance_raw: &Path,
672 template_raw: &Path,
621 template_raw: &Path,
673−) -> Result<(), Box<dyn std::error::Error>> {
622+ target_bytes: ByteSize,
623+ status: Option<&StatusEmitter<'_>>,
624+) -> Result<bool> {
674 if instance_raw.exists() {
625 if instance_raw.exists() {
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+ }
675− return Ok(());
636+ return Ok(false);
676 }
637 }
677
638
678− println!("Creating instance disk from {}...", template_raw.display());
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");
679− std::fs::create_dir_all(instance_raw.parent().unwrap())?;
652+ fs::create_dir_all(instance_raw.parent().unwrap())?;
653+ if target_size == template_size {
680− fs::copy(template_raw, instance_raw)?;
654+ fs::copy(template_raw, instance_raw)?;
681− Ok(())
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)
682}
663}
683
664
684pub struct IoContext {
665pub struct IoContext {
@@ -694,18 +675,17 @@ pub fn create_pipe() -> (OwnedFd, OwnedFd) {
694 (read_stream.into(), write_stream.into())
675 (read_stream.into(), write_stream.into())
695}
676}
696
677
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+>(
698 output_monitor: Arc<OutputMonitor>,
682 output_monitor: Arc<OutputMonitor>,
699 vm_output_fd: OwnedFd,
683 vm_output_fd: OwnedFd,
700 vm_input_fd: OwnedFd,
684 vm_input_fd: OwnedFd,
701 io_control: Arc<IoControl>,
685 io_control: Arc<IoControl>,
702 mut on_line: F,
686 mut on_line: F,
703 mut on_output: G,
687 mut on_output: G,
704−) -> IoContext
688+) -> IoContext {
705−where
706− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
707− G: FnMut(&[u8]) + ::std::marker::Send + 'static,
708−{
709 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
689 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
710
690
711 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
691 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
@@ -853,14 +833,14 @@ where
853 PollResult::Spurious => continue,
833 PollResult::Spurious => continue,
854 PollResult::Ready(bytes) => {
834 PollResult::Ready(bytes) => {
855 if io_control.forward_output() {
835 if io_control.forward_output() {
856− // enable raw mode, if we haven't already
836+ // enable raw mode if we haven't already
857 if raw_guard.lock().unwrap().is_none()
837 if raw_guard.lock().unwrap().is_none()
858 && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
838 && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
859 {
839 {
860 *raw_guard.lock().unwrap() = Some(guard);
840 *raw_guard.lock().unwrap() = Some(guard);
861 }
841 }
862
842
863− let mut stdout = std::io::stdout().lock();
843+ let mut stdout = io::stdout().lock();
864 if stdout.write_all(bytes).is_err() {
844 if stdout.write_all(bytes).is_err() {
865 break;
845 break;
866 }
846 }
@@ -876,7 +856,7 @@ where
876
856
877 // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
857 // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
878 let mux_thread = thread::spawn(move || {
858 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);
880 loop {
860 loop {
881 match input_rx.recv() {
861 match input_rx.recv() {
882 Ok(VmInput::Bytes(data)) => {
862 Ok(VmInput::Bytes(data)) => {
@@ -906,7 +886,7 @@ pub fn spawn_vm_io_with_line_handler<F>(
906 on_line: F,
886 on_line: F,
907) -> IoContext
887) -> IoContext
908where
888where
909− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
889+ F: FnMut(&str) -> bool + std::marker::Send + 'static,
910{
890{
911 spawn_vm_io_with_hooks(
891 spawn_vm_io_with_hooks(
912 output_monitor,
892 output_monitor,
@@ -926,6 +906,36 @@ pub fn spawn_vm_io(
926 spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
906 spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
927}
907}
928
908
909+fn spawn_vm_io_with_log(
910+ output_monitor: Arc<OutputMonitor>,
911+ vm_output_fd: OwnedFd,
912+ vm_input_fd: OwnedFd,
913+ log_path: PathBuf,
914+) -> IoContext {
915+ let log_file = fs::OpenOptions::new()
916+ .create(true)
917+ .write(true)
918+ .truncate(true)
919+ .open(&log_path)
920+ .ok()
921+ .map(|file| Arc::new(Mutex::new(file)));
922+
923+ spawn_vm_io_with_hooks(
924+ output_monitor,
925+ vm_output_fd,
926+ vm_input_fd,
927+ IoControl::new(),
928+ |_| false,
929+ move |bytes| {
930+ if let Some(log) = &log_file
931+ && let Ok(mut file) = log.lock()
932+ {
933+ let _ = file.write_all(bytes);
934+ }
935+ },
936+ )
937+}
938+
929impl IoContext {
939impl IoContext {
930 pub fn shutdown(self) {
940 pub fn shutdown(self) {
931 let _ = self.input_tx.send(VmInput::Shutdown);
941 let _ = self.input_tx.send(VmInput::Shutdown);
@@ -943,7 +953,7 @@ fn create_vm_configuration(
943 vm_writes_to_fd: OwnedFd,
953 vm_writes_to_fd: OwnedFd,
944 cpu_count: usize,
954 cpu_count: usize,
945 ram_bytes: u64,
955 ram_bytes: u64,
946−) -> Result<Retained<VZVirtualMachineConfiguration>, Box<dyn std::error::Error>> {
956+) -> Result<Retained<VZVirtualMachineConfiguration>> {
947 unsafe {
957 unsafe {
948 let platform =
958 let platform =
949 VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
959 VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
@@ -977,7 +987,7 @@ fn create_vm_configuration(
977 false,
987 false,
978 VZDiskImageCachingMode::Automatic,
988 VZDiskImageCachingMode::Automatic,
979 VZDiskImageSynchronizationMode::Full,
989 VZDiskImageSynchronizationMode::Full,
980− ).unwrap();
990+ )?;
981
991
982 let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
992 let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
983 VZVirtioBlockDeviceConfiguration::alloc(),
993 VZVirtioBlockDeviceConfiguration::alloc(),
@@ -1064,7 +1074,7 @@ fn create_vm_configuration(
1064 // Validate
1074 // Validate
1065 config.validateWithError().map_err(|e| {
1075 config.validateWithError().map_err(|e| {
1066 io::Error::other(format!(
1076 io::Error::other(format!(
1067− "Invalid VM configuration: {:?}",
1077+ "invalid VM configuration: {:?}",
1068 e.localizedDescription()
1078 e.localizedDescription()
1069 ))
1079 ))
1070 })?;
1080 })?;
@@ -1073,9 +1083,9 @@ fn create_vm_configuration(
1073 }
1083 }
1074}
1084}
1075
1085
1076−fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std::error::Error>> {
1086+fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>> {
1077 unsafe {
1087 unsafe {
1078− let temp_dir = std::env::temp_dir();
1088+ let temp_dir = env::temp_dir();
1079 let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
1089 let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
1080 let url = nsurl_from_path(&temp_path)?;
1090 let url = nsurl_from_path(&temp_path)?;
1081 let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
1091 let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
@@ -1091,8 +1101,8 @@ fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std
1091fn spawn_login_actions_thread(
1101fn spawn_login_actions_thread(
1092 login_actions: Vec<LoginAction>,
1102 login_actions: Vec<LoginAction>,
1093 output_monitor: Arc<OutputMonitor>,
1103 output_monitor: Arc<OutputMonitor>,
1094− input_tx: mpsc::Sender<VmInput>,
1104+ input_tx: Sender<VmInput>,
1095− vm_output_tx: mpsc::Sender<VmOutput>,
1105+ vm_output_tx: Sender<VmOutput>,
1096) -> thread::JoinHandle<()> {
1106) -> thread::JoinHandle<()> {
1097 thread::spawn(move || {
1107 thread::spawn(move || {
1098 for a in login_actions {
1108 for a in login_actions {
@@ -1106,22 +1116,31 @@ fn spawn_login_actions_thread(
1106 return;
1116 return;
1107 }
1117 }
1108 }
1118 }
1119+ ExpectEither {
1120+ success,
1121+ failure,
1122+ timeout,
1123+ } => match output_monitor.wait_for_any(&[&success, &failure], timeout) {
1124+ WaitAnyResult::Found(0) => {}
1125+ WaitAnyResult::Found(_) => {
1126+ let _ = vm_output_tx.send(VmOutput::LoginActionFailed {
1127+ action: format!("expect '{}'", success),
1128+ reason: format!("saw failure marker '{}'", failure),
1129+ });
1130+ return;
1131+ }
1132+ WaitAnyResult::Timeout => {
1133+ let _ = vm_output_tx.send(VmOutput::LoginActionTimeout {
1134+ action: format!("expect '{}'", success),
1135+ timeout,
1136+ });
1137+ return;
1138+ }
1139+ },
1109 Send(mut text) => {
1140 Send(mut text) => {
1110 text.push('\n'); // Type the newline so the command is actually submitted.
1141 text.push('\n'); // Type the newline so the command is actually submitted.
1111 input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
1142 input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
1112 }
1143 }
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− }
1125 }
1144 }
1126 }
1145 }
1127 })
1146 })
@@ -1133,8 +1152,9 @@ fn run_vm_with_io<F>(
1133 directory_shares: &[DirectoryShare],
1152 directory_shares: &[DirectoryShare],
1134 cpu_count: usize,
1153 cpu_count: usize,
1135 ram_bytes: u64,
1154 ram_bytes: u64,
1155+ status: Option<&StatusEmitter<'_>>,
1136 io_handler: F,
1156 io_handler: F,
1137−) -> Result<(), Box<dyn std::error::Error>>
1157+) -> Result<()>
1138where
1158where
1139 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
1159 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
1140{
1160{
@@ -1181,21 +1201,23 @@ where
1181
1201
1182 match rx.try_recv() {
1202 match rx.try_recv() {
1183 Ok(result) => {
1203 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)))?;
1185 break;
1205 break;
1186 }
1206 }
1187 Err(mpsc::TryRecvError::Empty) => continue,
1207 Err(mpsc::TryRecvError::Empty) => continue,
1188 Err(mpsc::TryRecvError::Disconnected) => {
1208 Err(mpsc::TryRecvError::Disconnected) => {
1189− return Err("VM start channel disconnected".into());
1209+ bail!("VM start channel disconnected");
1190 }
1210 }
1191 }
1211 }
1192 }
1212 }
1193
1213
1194 if Instant::now() >= start_deadline {
1214 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");
1196 }
1216 }
1197
1217
1198− println!("VM booting...");
1218+ emit_status(status, "vm booting... go vibecoder!");
1219+ tracing::info!("vm booting... go vibecoder!");
1220+ tracing::info!("vm booting");
1199
1221
1200 let output_monitor = Arc::new(OutputMonitor::default());
1222 let output_monitor = Arc::new(OutputMonitor::default());
1201 let io_ctx = io_handler(output_monitor.clone(), we_read_from, we_write_to);
1223 let io_ctx = io_handler(output_monitor.clone(), we_read_from, we_write_to);
@@ -1240,7 +1262,7 @@ where
1240 );
1262 );
1241
1263
1242 let mut last_state = None;
1264 let mut last_state = None;
1243− let mut exit_result = Ok(());
1265+ let mut exit_result: Result<(), String> = Ok(());
1244 loop {
1266 loop {
1245 unsafe {
1267 unsafe {
1246 NSRunLoop::mainRunLoop().runMode_beforeDate(
1268 NSRunLoop::mainRunLoop().runMode_beforeDate(
@@ -1259,12 +1281,28 @@ where
1259 exit_result = Err(format!(
1281 exit_result = Err(format!(
1260 "Login action ({}) timed out after {:?}; shutting down.",
1282 "Login action ({}) timed out after {:?}; shutting down.",
1261 action, timeout
1283 action, timeout
1262− )
1284+ ));
1263− .into());
1264 unsafe {
1285 unsafe {
1265 if vm.canRequestStop() {
1286 if vm.canRequestStop() {
1266 if let Err(err) = vm.requestStopWithError() {
1287 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");
1268 }
1306 }
1269 } else if vm.canStop() {
1307 } else if vm.canStop() {
1270 let handler = RcBlock::new(|_error: *mut NSError| {});
1308 let handler = RcBlock::new(|_error: *mut NSError| {});
@@ -1276,7 +1314,7 @@ where
1276 Err(mpsc::TryRecvError::Empty) => {}
1314 Err(mpsc::TryRecvError::Empty) => {}
1277 Err(mpsc::TryRecvError::Disconnected) => {}
1315 Err(mpsc::TryRecvError::Disconnected) => {}
1278 }
1316 }
1279− if state != objc2_virtualization::VZVirtualMachineState::Running {
1317+ if state != VZVirtualMachineState::Running {
1280 //eprintln!("VM stopped with state: {:?}", state);
1318 //eprintln!("VM stopped with state: {:?}", state);
1281 break;
1319 break;
1282 }
1320 }
@@ -1286,7 +1324,7 @@ where
1286
1324
1287 io_ctx.shutdown();
1325 io_ctx.shutdown();
1288
1326
1289− exit_result
1327+ exit_result.map_err(Error::msg)
1290}
1328}
1291
1329
1292fn run_vm(
1330fn run_vm(
@@ -1295,18 +1333,20 @@ fn run_vm(
1295 directory_shares: &[DirectoryShare],
1333 directory_shares: &[DirectoryShare],
1296 cpu_count: usize,
1334 cpu_count: usize,
1297 ram_bytes: u64,
1335 ram_bytes: u64,
1298−) -> Result<(), Box<dyn std::error::Error>> {
1336+ status: Option<&StatusEmitter<'_>>,
1337+) -> Result<()> {
1299 run_vm_with_io(
1338 run_vm_with_io(
1300 disk_path,
1339 disk_path,
1301 login_actions,
1340 login_actions,
1302 directory_shares,
1341 directory_shares,
1303 cpu_count,
1342 cpu_count,
1304 ram_bytes,
1343 ram_bytes,
1344+ status,
1305 spawn_vm_io,
1345 spawn_vm_io,
1306 )
1346 )
1307}
1347}
1308
1348
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>> {
1310 let abs_path = if path.is_absolute() {
1350 let abs_path = if path.is_absolute() {
1311 path.to_path_buf()
1351 path.to_path_buf()
1312 } else {
1352 } else {
@@ -1315,7 +1355,7 @@ fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::E
1315 let ns_path = NSString::from_str(
1355 let ns_path = NSString::from_str(
1316 abs_path
1356 abs_path
1317 .to_str()
1357 .to_str()
1318− .ok_or("Non-UTF8 path encountered while building NSURL")?,
1358+ .with_context(|| "non-UTF8 path encountered while building NSURL")?,
1319 );
1359 );
1320 Ok(NSURL::fileURLWithPath(&ns_path))
1360 Ok(NSURL::fileURLWithPath(&ns_path))
1321}
1361}
@@ -1330,7 +1370,7 @@ fn enable_raw_mode(fd: i32) -> io::Result<RawModeGuard> {
1330 let original = attributes;
1370 let original = attributes;
1331
1371
1332 // Disable translation of carriage return to newline on input
1372 // Disable translation of carriage return to newline on input
1333− attributes.c_iflag &= !(libc::ICRNL);
1373+ attributes.c_iflag &= !libc::ICRNL;
1334 // Disable canonical mode (line buffering), echo, and signal generation
1374 // Disable canonical mode (line buffering), echo, and signal generation
1335 attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
1375 attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
1336 attributes.c_cc[libc::VMIN] = 0;
1376 attributes.c_cc[libc::VMIN] = 0;
@@ -1358,10 +1398,10 @@ impl Drop for RawModeGuard {
1358
1398
1359// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
1399// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
1360pub fn ensure_signed() {
1400pub 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") {
1362 return;
1402 return;
1363 }
1403 }
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");
1365 let exe_str = exe.to_str().expect("exe path not valid utf-8");
1405 let exe_str = exe.to_str().expect("exe path not valid utf-8");
1366
1406
1367 let has_required_entitlements = {
1407 let has_required_entitlements = {
@@ -1383,10 +1423,10 @@ pub fn ensure_signed() {
1383 }
1423 }
1384
1424
1385 const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1425 const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1386− let entitlements_path = std::env::temp_dir().join("entitlements.plist");
1426+ let entitlements_path = env::temp_dir().join("entitlements.plist");
1387− std::fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
1427+ fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
1388
1428
1389− let status = Command::new("codesign")
1429+ let output = Command::new("codesign")
1390 .args([
1430 .args([
1391 "--sign",
1431 "--sign",
1392 "-",
1432 "-",
@@ -1395,22 +1435,27 @@ pub fn ensure_signed() {
1395 entitlements_path.to_str().unwrap(),
1435 entitlements_path.to_str().unwrap(),
1396 exe_str,
1436 exe_str,
1397 ])
1437 ])
1398− .status();
1438+ .output();
1399
1439
1400− let _ = std::fs::remove_file(&entitlements_path);
1440+ let _ = fs::remove_file(&entitlements_path);
1401
1441
1402− match status {
1442+ match output {
1403− Ok(s) if s.success() => {
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+ }
1404− let err = Command::new(&exe).args(std::env::args_os().skip(1)).exec();
1448+ let err = Command::new(&exe).args(env::args_os().skip(1)).exec();
1405− eprintln!("failed to re-exec after signing: {err}");
1449+ tracing::error!(error = %err, "failed to re-exec after signing");
1406 std::process::exit(1);
1450 std::process::exit(1);
1407 }
1451 }
1408− Ok(s) => {
1452+ Ok(o) => {
1409− eprintln!("codesign failed with status: {s}");
1453+ let stderr = String::from_utf8_lossy(&o.stderr);
1454+ tracing::error!(status = %o.status, codesign_stderr = %stderr.trim(), "codesign failed");
1410 std::process::exit(1);
1455 std::process::exit(1);
1411 }
1456 }
1412 Err(e) => {
1457 Err(e) => {
1413− eprintln!("failed to run codesign: {e}");
1458+ tracing::error!(error = %e, "failed to run codesign");
1414 std::process::exit(1);
1459 std::process::exit(1);
1415 }
1460 }
1416 }
1461 }
src/vm_manager.rs+692−153
@@ -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};
1use std::{
13use std::{
2 env, fs,
14 env, fs,
3 io::{Read, Write},
15 io::{Read, Write},
@@ -5,7 +17,6 @@ use std::{
5 fs::PermissionsExt,
17 fs::PermissionsExt,
6 io::AsRawFd,
18 io::AsRawFd,
7 net::{UnixListener, UnixStream},
19 net::{UnixListener, UnixStream},
8− process::CommandExt,
9 },
20 },
10 path::{Path, PathBuf},
21 path::{Path, PathBuf},
11 process::{Command, Stdio},
22 process::{Command, Stdio},
@@ -14,34 +25,60 @@ use std::{
14 time::{Duration, Instant},
25 time::{Duration, Instant},
15};
26};
16
27
17−use crate::{
28+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
18− instance::{
29+const VM_ROOT_LOG_NAME: &str = "vm_root.log";
19− InstanceConfig, build_ssh_login_actions, ensure_instance_dir, ensure_ssh_keypair,
30+const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
20− extract_ipv4, load_or_create_instance_config, write_instance_config,
31+const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
21− },
32+const SHUTDOWN_RETRY_MS: u64 = 500;
22− session_manager::GLOBAL_DIR_NAME,
33+#[cfg(test)]
23− vm::{self, DirectoryShare, LoginAction, VmInput},
34+const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 1_000;
24−};
35+#[cfg(not(test))]
36+const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 12_000;
37+const STATUS_PREFIX: &str = "status:";
25
38
26−const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
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+}
27
51
28pub fn ensure_manager(
52pub fn ensure_manager(
29 raw_args: &[std::ffi::OsString],
53 raw_args: &[std::ffi::OsString],
30 auto_shutdown_ms: u64,
54 auto_shutdown_ms: u64,
55+ config_path: Option<&Path>,
31−) -> Result<UnixStream, Box<dyn std::error::Error>> {
56+) -> Result<UnixStream> {
32 let project_root = env::current_dir()?;
57 let project_root = env::current_dir()?;
33 tracing::debug!(root = %project_root.display(), "ensure vm manager");
58 tracing::debug!(root = %project_root.display(), "ensure vm manager");
34 let instance_dir = ensure_instance_dir(&project_root)?;
59 let instance_dir = ensure_instance_dir(&project_root)?;
60+ cleanup_stale_manager(&project_root)?;
35 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
61 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
36
62
37 if let Ok(stream) = UnixStream::connect(&socket_path) {
63 if let Ok(stream) = UnixStream::connect(&socket_path) {
38 send_client_pid(&stream);
64 send_client_pid(&stream);
39− tracing::info!(path = %socket_path.display(), "connected to existing vm manager");
65+ tracing::info!(path = %socket_path.display(), "connected to an existing vm manager");
40 return Ok(stream);
66 return Ok(stream);
41 }
67 }
42
68
69+ let lock_path = instance_dir.join(VM_MANAGER_LOCK_NAME);
70+ let mut lock_file = acquire_spawn_lock(&lock_path)?;
71+ if lock_file.is_some() {
43− tracing::info!(path = %socket_path.display(), "spawning vm manager");
72+ tracing::info!(path = %socket_path.display(), "spawning vm manager");
44− spawn_manager_process(raw_args, auto_shutdown_ms, &instance_dir)?;
73+ spawn_manager_process(raw_args, auto_shutdown_ms, &instance_dir, config_path)?;
74+ } else {
75+ tracing::info!(
76+ path = %socket_path.display(),
77+ lock = %lock_path.display(),
78+ lock_pid = read_lock_pid(&lock_path).unwrap_or(0),
79+ "waiting for vm manager spawn lock"
80+ );
81+ }
45
82
46 let start = Instant::now();
83 let start = Instant::now();
47 let timeout = Duration::from_secs(10);
84 let timeout = Duration::from_secs(10);
@@ -50,17 +87,24 @@ pub fn ensure_manager(
50 Ok(stream) => {
87 Ok(stream) => {
51 send_client_pid(&stream);
88 send_client_pid(&stream);
52 tracing::info!(path = %socket_path.display(), "connected to vm manager");
89 tracing::info!(path = %socket_path.display(), "connected to vm manager");
90+ if lock_file.is_some() {
91+ drop(lock_file.take());
92+ let _ = fs::remove_file(&lock_path);
93+ }
53 return Ok(stream);
94 return Ok(stream);
54 }
95 }
55 Err(err) => {
96 Err(err) => {
56 tracing::debug!(error = %err, "waiting for vm manager socket");
97 tracing::debug!(error = %err, "waiting for vm manager socket");
57 if start.elapsed() > timeout {
98 if start.elapsed() > timeout {
99+ if lock_file.is_some() {
100+ drop(lock_file.take());
101+ let _ = fs::remove_file(&lock_path);
102+ }
58− return Err(format!(
103+ bail!(format!(
59− "Timed out waiting for vm manager socket: {} ({})",
104+ "timed out waiting for vm manager socket: {} ({})",
60 socket_path.display(),
105 socket_path.display(),
61 err
106 err
62− )
107+ ));
63− .into());
64 }
108 }
65 thread::sleep(Duration::from_millis(100));
109 thread::sleep(Duration::from_millis(100));
66 }
110 }
@@ -68,54 +112,83 @@ pub fn ensure_manager(
68 }
112 }
69}
113}
70
114
71−pub fn run_manager(
72− args: vm::CliArgs,
73− auto_shutdown_ms: u64,
115+pub fn run_manager(args: vm::VmArg, auto_shutdown_ms: u64) -> Result<()> {
74−) -> Result<(), Box<dyn std::error::Error>> {
75 let project_root = env::current_dir()?;
116 let project_root = env::current_dir()?;
76 tracing::info!(root = %project_root.display(), "vm manager starting");
117 tracing::info!(root = %project_root.display(), "vm manager starting");
118+ #[cfg(not(feature = "mock-vm"))]
119+ {
120+ unsafe {
121+ env::remove_var("VIBEBOX_SKIP_CODESIGN");
122+ }
123+ vm::ensure_signed();
124+ unsafe {
125+ env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
126+ }
127+ }
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+ };
77− run_manager_with(
157+ run_manager_with(&project_root, args, auto_shutdown_ms, executor, options)
78− &project_root,
79− args,
80− auto_shutdown_ms,
81− &RealVmExecutor,
82− ManagerOptions {
83− ensure_signed: true,
84− detach: true,
85− prepare_vm: true,
86− },
87− )
88}
158}
89
159
90fn spawn_manager_process(
160fn spawn_manager_process(
91 raw_args: &[std::ffi::OsString],
161 raw_args: &[std::ffi::OsString],
92 auto_shutdown_ms: u64,
162 auto_shutdown_ms: u64,
93 instance_dir: &Path,
163 instance_dir: &Path,
94−) -> Result<(), Box<dyn std::error::Error>> {
164+ config_path: Option<&Path>,
165+) -> Result<()> {
95 let exe = env::current_exe()?;
166 let exe = env::current_exe()?;
96 let mut supervisor_exe = exe.clone();
167 let mut supervisor_exe = exe.clone();
97 supervisor_exe.set_file_name("vibebox-supervisor");
168 supervisor_exe.set_file_name("vibebox-supervisor");
98− let use_supervisor = supervisor_exe.exists();
169+ // intentional
99− let mut cmd = if use_supervisor {
170+ if !supervisor_exe.exists() {
100− Command::new(supervisor_exe)
171+ bail!(format!(
101− } else {
102− let mut cmd = Command::new(exe);
103− cmd.arg0("vibebox-supervisor");
172+ "vibebox-supervisor not found at {}",
104− cmd
173+ supervisor_exe.display()
174+ ));
105− };
175+ }
176+ let mut cmd = Command::new(supervisor_exe);
106 if raw_args.len() > 1 {
177 if raw_args.len() > 1 {
107 cmd.args(&raw_args[1..]);
178 cmd.args(&raw_args[1..]);
108 }
179 }
109− if !use_supervisor {
180+ cmd.env("VIBEBOX_INTERNAL", "1");
110− cmd.env("VIBEBOX_VM_MANAGER", "1");
111− }
112 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
181 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
113 cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
182 cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
183+ if let Some(path) = config_path {
184+ cmd.env(CONFIG_PATH_ENV, path);
185+ }
114− tracing::info!(auto_shutdown_ms, "vm manager process spawn requested");
186+ tracing::debug!(auto_shutdown_ms, "vm manager process spawn requested");
115− let log_path = instance_dir.join("vm_manager.log");
187+ let log_path = instance_dir.join(VM_MANAGER_LOG_NAME);
116 let log_file = fs::OpenOptions::new()
188 let log_file = fs::OpenOptions::new()
117 .create(true)
189 .create(true)
118− .append(true)
190+ .write(true)
191+ .truncate(true)
119 .open(&log_path)
192 .open(&log_path)
120 .ok();
193 .ok();
121 if let Some(file) = log_file {
194 if let Some(file) = log_file {
@@ -132,6 +205,184 @@ fn spawn_manager_process(
132 Ok(())
205 Ok(())
133}
206}
134
207
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+
135fn detach_from_terminal() {
386fn detach_from_terminal() {
136 unsafe {
387 unsafe {
137 libc::setsid();
388 libc::setsid();
@@ -158,6 +409,30 @@ fn wait_for_disconnect(mut stream: UnixStream) {
158 }
409 }
159}
410}
160
411
412+fn remove_client(streams: &ClientStreams, fd: std::os::fd::RawFd) {
413+ if let Ok(mut clients) = streams.lock() {
414+ clients.retain(|stream| stream.as_raw_fd() != fd);
415+ }
416+}
417+
418+fn send_status_line(stream: &mut UnixStream, status: &str) -> bool {
419+ let mut payload = String::with_capacity(STATUS_PREFIX.len() + status.len() + 1);
420+ payload.push_str(STATUS_PREFIX);
421+ payload.push_str(status);
422+ payload.push('\n');
423+ stream.write_all(payload.as_bytes()).is_ok()
424+}
425+
426+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
427+fn broadcast_status(streams: &ClientStreams, latest_status: &SharedStatus, status: &str) {
428+ if let Ok(mut current) = latest_status.lock() {
429+ *current = status.to_string();
430+ }
431+ if let Ok(mut clients) = streams.lock() {
432+ clients.retain_mut(|stream| send_status_line(stream, status));
433+ }
434+}
435+
161fn send_client_pid(stream: &UnixStream) {
436fn send_client_pid(stream: &UnixStream) {
162 let pid = std::process::id();
437 let pid = std::process::id();
163 let payload = format!("pid={pid}\n");
438 let payload = format!("pid={pid}\n");
@@ -167,6 +442,59 @@ fn send_client_pid(stream: &UnixStream) {
167 }
442 }
168}
443}
169
444
445+fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>> {
446+ match fs::OpenOptions::new()
447+ .write(true)
448+ .create_new(true)
449+ .open(lock_path)
450+ {
451+ Ok(mut file) => {
452+ let pid = std::process::id();
453+ let _ = writeln!(file, "pid={pid}");
454+ Ok(Some(file))
455+ }
456+ Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
457+ if is_lock_stale(lock_path) {
458+ tracing::warn!(
459+ lock = %lock_path.display(),
460+ lock_pid = read_lock_pid(lock_path).unwrap_or(0),
461+ "stale vm manager lock removed"
462+ );
463+ let _ = fs::remove_file(lock_path);
464+ return acquire_spawn_lock(lock_path);
465+ }
466+ Ok(None)
467+ }
468+ Err(err) => Err(err.into()),
469+ }
470+}
471+
472+fn is_lock_stale(lock_path: &Path) -> bool {
473+ match read_lock_pid(lock_path) {
474+ Some(pid) => !pid_is_alive(pid),
475+ None => true,
476+ }
477+}
478+
479+fn read_lock_pid(lock_path: &Path) -> Option<u32> {
480+ let content = fs::read_to_string(lock_path).ok()?;
481+ let line = content.lines().next()?;
482+ line.strip_prefix("pid=")?.trim().parse::<u32>().ok()
483+}
484+
485+fn pid_is_alive(pid: u32) -> bool {
486+ let pid = pid as libc::pid_t;
487+ let result = unsafe { libc::kill(pid, 0) };
488+ if result == 0 {
489+ return true;
490+ }
491+ match std::io::Error::last_os_error().raw_os_error() {
492+ Some(code) if code == libc::EPERM => true,
493+ Some(code) if code == libc::ESRCH => false,
494+ _ => false,
495+ }
496+}
497+
170fn read_client_pid(stream: &UnixStream) -> Option<u32> {
498fn read_client_pid(stream: &UnixStream) -> Option<u32> {
171 let mut stream = stream.try_clone().ok()?;
499 let mut stream = stream.try_clone().ok()?;
172 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
500 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
@@ -177,7 +505,7 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
177 Ok(0) => break,
505 Ok(0) => break,
178 Ok(n) => {
506 Ok(n) => {
179 len += n;
507 len += n;
180− if buf[..len].iter().any(|b| *b == b'\n') || len == buf.len() {
508+ if buf[..len].contains(&b'\n') || len == buf.len() {
181 break;
509 break;
182 }
510 }
183 }
511 }
@@ -199,31 +527,34 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
199 }
527 }
200}
528}
201
529
530+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
202fn spawn_manager_io(
531fn spawn_manager_io(
203 config: Arc<Mutex<InstanceConfig>>,
532 config: Arc<Mutex<InstanceConfig>>,
204− instance_dir: PathBuf,
533+ project_dir: PathBuf,
534+ clients: ClientStreams,
535+ latest_status: SharedStatus,
205 output_monitor: Arc<vm::OutputMonitor>,
536 output_monitor: Arc<vm::OutputMonitor>,
206 vm_output_fd: std::os::unix::io::OwnedFd,
537 vm_output_fd: std::os::unix::io::OwnedFd,
207 vm_input_fd: std::os::unix::io::OwnedFd,
538 vm_input_fd: std::os::unix::io::OwnedFd,
208) -> vm::IoContext {
539) -> vm::IoContext {
209− let log_path = instance_dir.join("serial.log");
540+ let log_path = project_dir.join(INSTANCE_DIR_NAME).join(VM_ROOT_LOG_NAME);
210 let log_file = fs::OpenOptions::new()
541 let log_file = fs::OpenOptions::new()
211 .create(true)
542 .create(true)
212− .append(true)
543+ .write(true)
544+ .truncate(true)
213 .open(&log_path)
545 .open(&log_path)
214 .ok()
546 .ok()
215 .map(|file| Arc::new(Mutex::new(file)));
547 .map(|file| Arc::new(Mutex::new(file)));
216
548
217− let instance_path = instance_dir.join("instance.toml");
218 let config_for_output = config.clone();
549 let config_for_output = config.clone();
219 let log_for_output = log_file.clone();
550 let log_for_output = log_file.clone();
220 let mut line_buf = String::new();
551 let mut line_buf = String::new();
221
552
222 let on_output = move |bytes: &[u8]| {
553 let on_output = move |bytes: &[u8]| {
223− if let Some(log) = &log_for_output {
554+ if let Some(log) = &log_for_output
224− if let Ok(mut file) = log.lock() {
555+ && let Ok(mut file) = log.lock()
556+ {
225− let _ = file.write_all(bytes);
557+ let _ = file.write_all(bytes);
226− }
227 }
558 }
228
559
229 let text = String::from_utf8_lossy(bytes);
560 let text = String::from_utf8_lossy(bytes);
@@ -236,17 +567,27 @@ fn spawn_manager_io(
236 }
567 }
237 line_buf.drain(..=pos);
568 line_buf.drain(..=pos);
238
569
239− 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+ }
240 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
582 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
241 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
583 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
242 let ip = extract_ipv4(ip_raw).unwrap_or_default();
584 let ip = extract_ipv4(ip_raw).unwrap_or_default();
243− if !ip.is_empty() {
585+ if !ip.is_empty()
244− if let Ok(mut cfg) = config_for_output.lock() {
586+ && let Ok(mut cfg) = config_for_output.lock()
245− if cfg.vm_ipv4.as_deref() != Some(ip.as_str()) {
587+ && cfg.vm_ipv4.as_deref() != Some(ip.as_str())
588+ {
246− cfg.vm_ipv4 = Some(ip.clone());
589+ cfg.vm_ipv4 = Some(ip.clone());
247− let _ = write_instance_config(&instance_path, &cfg);
590+ let _ = write_instance_config(&project_dir, &cfg);
248− }
249− }
250 }
591 }
251 }
592 }
252 }
593 }
@@ -268,80 +609,52 @@ enum ManagerEvent {
268 VmExited(Option<String>),
609 VmExited(Option<String>),
269}
610}
270
611
271−#[cfg(test)]
272−mod tests {
273− use super::*;
274− use std::{sync::mpsc, time::Duration};
275−
276− #[test]
277− fn manager_powers_off_after_grace_when_no_refs() {
278− let _temp = tempfile::Builder::new()
279− .prefix("vb")
280− .tempdir_in("/tmp")
281− .expect("tempdir");
282−
283− let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
284− let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
285− let vm_input_tx = Arc::new(Mutex::new(Some(vm_tx)));
286−
287− let manager_thread = thread::spawn(move || {
288− manager_event_loop(event_rx, vm_input_tx, 50).expect("event loop");
289− });
290−
291− event_tx.send(ManagerEvent::Inc(None)).unwrap();
292− assert!(vm_rx.recv_timeout(Duration::from_millis(100)).is_err());
293−
294− event_tx.send(ManagerEvent::Dec(None)).unwrap();
295− let msg = vm_rx
296− .recv_timeout(Duration::from_secs(2))
297− .expect("poweroff");
298− match msg {
299− VmInput::Bytes(data) => {
300− assert_eq!(data, b"systemctl poweroff\n");
301− }
302− _ => panic!("unexpected vm input"),
303− }
304− let _ = event_tx.send(ManagerEvent::VmExited(None));
305− let _ = manager_thread.join();
306− }
307−}
308−
309struct ManagerOptions {
612struct ManagerOptions {
310− ensure_signed: bool,
311 detach: bool,
613 detach: bool,
312 prepare_vm: bool,
614 prepare_vm: bool,
313}
615}
314
616
315trait VmExecutor {
617trait VmExecutor {
618+ #[allow(clippy::too_many_arguments)]
316 fn run_vm(
619 fn run_vm(
317 &self,
620 &self,
318− args: vm::CliArgs,
621+ args: vm::VmArg,
319 extra_login_actions: Vec<LoginAction>,
622 extra_login_actions: Vec<LoginAction>,
320 extra_shares: Vec<DirectoryShare>,
623 extra_shares: Vec<DirectoryShare>,
321 config: Arc<Mutex<InstanceConfig>>,
624 config: Arc<Mutex<InstanceConfig>>,
322 instance_dir: PathBuf,
625 instance_dir: PathBuf,
626+ clients: ClientStreams,
627+ latest_status: SharedStatus,
323 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
628 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
324− ) -> Result<(), Box<dyn std::error::Error>>;
629+ ) -> Result<()>;
325}
630}
326
631
632+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
327struct RealVmExecutor;
633struct RealVmExecutor;
328
634
329impl VmExecutor for RealVmExecutor {
635impl VmExecutor for RealVmExecutor {
330 fn run_vm(
636 fn run_vm(
331 &self,
637 &self,
332− args: vm::CliArgs,
638+ args: vm::VmArg,
333 extra_login_actions: Vec<LoginAction>,
639 extra_login_actions: Vec<LoginAction>,
334 extra_shares: Vec<DirectoryShare>,
640 extra_shares: Vec<DirectoryShare>,
335 config: Arc<Mutex<InstanceConfig>>,
641 config: Arc<Mutex<InstanceConfig>>,
336− instance_dir: PathBuf,
642+ project_dir: PathBuf,
643+ clients: ClientStreams,
644+ latest_status: SharedStatus,
337 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
645 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
338− ) -> Result<(), Box<dyn std::error::Error>> {
646+ ) -> Result<()> {
647+ let status_callback = |status: &str| {
648+ broadcast_status(&clients, &latest_status, status);
649+ };
339− let result = vm::run_with_args_and_extras(
650+ vm::run_with_args_and_extras(
340 args,
651 args,
341 |output_monitor, vm_output_fd, vm_input_fd| {
652 |output_monitor, vm_output_fd, vm_input_fd| {
342 let io_ctx = spawn_manager_io(
653 let io_ctx = spawn_manager_io(
343 config.clone(),
654 config.clone(),
344− instance_dir.clone(),
655+ project_dir.clone(),
656+ clients.clone(),
657+ latest_status.clone(),
345 output_monitor,
658 output_monitor,
346 vm_output_fd,
659 vm_output_fd,
347 vm_input_fd,
660 vm_input_fd,
@@ -351,35 +664,60 @@ impl VmExecutor for RealVmExecutor {
351 },
664 },
352 extra_login_actions,
665 extra_login_actions,
353 extra_shares,
666 extra_shares,
354− );
667+ Some(&status_callback),
355− result
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(())
356 }
704 }
357}
705}
358
706
359fn run_manager_with(
707fn run_manager_with(
360 project_root: &Path,
708 project_root: &Path,
361− args: vm::CliArgs,
709+ mut args: vm::VmArg,
362 auto_shutdown_ms: u64,
710 auto_shutdown_ms: u64,
363 executor: &dyn VmExecutor,
711 executor: &dyn VmExecutor,
364 options: ManagerOptions,
712 options: ManagerOptions,
365−) -> Result<(), Box<dyn std::error::Error>> {
713+) -> Result<()> {
366− if options.ensure_signed {
367− let _had_skip = env::var("VIBEBOX_SKIP_CODESIGN").ok();
368− unsafe {
369− env::remove_var("VIBEBOX_SKIP_CODESIGN");
370− }
371− vm::ensure_signed();
372− unsafe {
373− env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
374− }
375− }
376 if options.detach {
714 if options.detach {
377 detach_from_terminal();
715 detach_from_terminal();
378 }
716 }
379
717
380 let project_name = project_root
718 let project_name = project_root
381 .file_name()
719 .file_name()
382− .ok_or("Project directory has no name")?
720+ .ok_or_else(|| anyhow!("Project directory has no name"))?
383 .to_string_lossy()
721 .to_string_lossy()
384 .into_owned();
722 .into_owned();
385 let instance_dir = ensure_instance_dir(project_root)?;
723 let instance_dir = ensure_instance_dir(project_root)?;
@@ -387,21 +725,35 @@ fn run_manager_with(
387 let _ = ensure_ssh_keypair(&instance_dir)?;
725 let _ = ensure_ssh_keypair(&instance_dir)?;
388 }
726 }
389
727
390− let mut config = load_or_create_instance_config(&instance_dir)?;
728+ let mut config = load_or_create_instance_config(project_root)?;
391 if config.vm_ipv4.is_some() {
729 if config.vm_ipv4.is_some() {
392 config.vm_ipv4 = None;
730 config.vm_ipv4 = None;
393− write_instance_config(&instance_dir.join("instance.toml"), &config)?;
731+ write_instance_config(project_root, &config)?;
394 }
732 }
395 let config = Arc::new(Mutex::new(config));
733 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);
396
742
743+ let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}");
397 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
744 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
398− let extra_shares = vec![DirectoryShare::new(
745+ let extra_shares = vec![
399− instance_dir.clone(),
400− ssh_guest_dir.clone().into(),
746+ DirectoryShare::new(instance_dir.clone(), ssh_guest_dir.clone().into(), true)
401− true,
747+ .map_err(|err| anyhow!(err.to_string()))?,
402− )?];
748+ ];
403− let extra_login_actions =
749+ let extra_login_actions = build_ssh_login_actions(
750+ &config,
751+ &project_name,
752+ &project_guest_dir,
404− build_ssh_login_actions(&config, &project_name, ssh_guest_dir.as_str(), "ssh_key");
753+ ssh_guest_dir.as_str(),
754+ "ssh_key",
755+ &home_links_script,
756+ );
405
757
406 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
758 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
407 if let Ok(stream) = UnixStream::connect(&socket_path) {
759 if let Ok(stream) = UnixStream::connect(&socket_path) {
@@ -416,17 +768,43 @@ fn run_manager_with(
416 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
768 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
417 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
769 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
418
770
771+ let clients: ClientStreams = Arc::new(Mutex::new(Vec::new()));
772+ let latest_status: SharedStatus = Arc::new(Mutex::new(String::new()));
419 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
773 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
420 let event_tx_accept = event_tx.clone();
774 let event_tx_accept = event_tx.clone();
775+ let clients_accept = clients.clone();
776+ let latest_status_accept = latest_status.clone();
421 thread::spawn(move || {
777 thread::spawn(move || {
422 for stream in listener.incoming() {
778 for stream in listener.incoming() {
423 match stream {
779 match stream {
424 Ok(stream) => {
780 Ok(stream) => {
781+ let mut client_fd: Option<std::os::fd::RawFd> = None;
782+ let latest_status_snapshot = latest_status_accept
783+ .lock()
784+ .ok()
785+ .map(|status| status.clone());
786+ if let Ok(writer) = stream.try_clone() {
787+ let writer_fd = writer.as_raw_fd();
788+ if let Ok(mut connected) = clients_accept.lock() {
789+ connected.push(writer);
790+ client_fd = Some(writer_fd);
791+ if let Some(last) = connected.last_mut()
792+ && let Some(status) = latest_status_snapshot.as_deref()
793+ && !status.is_empty()
794+ {
795+ let _ = send_status_line(last, status);
796+ }
797+ }
798+ }
425 let event_tx_conn = event_tx_accept.clone();
799 let event_tx_conn = event_tx_accept.clone();
800+ let clients_conn = clients_accept.clone();
426 thread::spawn(move || {
801 thread::spawn(move || {
427 let pid = read_client_pid(&stream);
802 let pid = read_client_pid(&stream);
428 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
803 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
429 wait_for_disconnect(stream);
804 wait_for_disconnect(stream);
805+ if let Some(fd) = client_fd {
806+ remove_client(&clients_conn, fd);
807+ }
430 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
808 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
431 });
809 });
432 }
810 }
@@ -446,43 +824,59 @@ fn run_manager_with(
446 extra_login_actions,
824 extra_login_actions,
447 extra_shares,
825 extra_shares,
448 config.clone(),
826 config.clone(),
449− instance_dir.clone(),
827+ project_root.to_path_buf(),
828+ clients.clone(),
829+ latest_status.clone(),
450 vm_input_tx.clone(),
830 vm_input_tx.clone(),
451 );
831 );
452 tracing::info!("vm manager vm run completed");
832 tracing::info!("vm manager vm run completed");
453 let vm_err = vm_result.err().map(|e| e.to_string());
833 let vm_err = vm_result.err().map(|e| e.to_string());
834+ if let Some(err) = &vm_err {
835+ broadcast_status(
836+ &clients,
837+ &latest_status,
838+ &format!("{STATUS_VM_ERROR_PREFIX} {err}"),
839+ );
840+ }
454 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
841 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
455− let event_loop_result: Result<(), String> = event_loop_handle
842+ let event_loop_result = event_loop_handle
456 .join()
843 .join()
457− .unwrap_or_else(|_| Err("vm manager event loop panicked".into()))
844+ .unwrap_or_else(|_| Err(Error::msg("vm manager event loop panicked")))
458 .map_err(|err| err.to_string());
845 .map_err(|err| err.to_string());
459 let _ = fs::remove_file(&socket_path);
846 let _ = fs::remove_file(&socket_path);
460 if let Err(err) = &event_loop_result {
847 if let Err(err) = &event_loop_result {
461 tracing::error!(error = %err, "vm manager exiting due to event loop error");
848 tracing::error!(error = %err, "vm manager exiting due to event loop error");
462− return Err(err.to_string().into());
849+ bail!(err.to_string());
463 }
850 }
464 if let Some(err) = vm_err {
851 if let Some(err) = vm_err {
465 tracing::error!(error = %err, "vm manager exiting due to vm error");
852 tracing::error!(error = %err, "vm manager exiting due to vm error");
466− return Err(err.into());
853+ bail!(err);
467 }
854 }
468 tracing::info!("vm manager exiting");
855 tracing::info!("vm manager exiting");
469− Ok(event_loop_result?)
856+ event_loop_result.map_err(Error::msg)
470}
857}
471
858
472fn manager_event_loop(
859fn manager_event_loop(
473 event_rx: mpsc::Receiver<ManagerEvent>,
860 event_rx: mpsc::Receiver<ManagerEvent>,
474 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
861 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
475 auto_shutdown_ms: u64,
862 auto_shutdown_ms: u64,
476−) -> Result<(), String> {
863+) -> Result<()> {
477 let mut ref_count: usize = 0;
864 let mut ref_count: usize = 0;
478 let mut shutdown_deadline: Option<Instant> = None;
865 let mut shutdown_deadline: Option<Instant> = None;
479 let mut shutdown_sent = false;
866 let mut shutdown_sent = false;
867+ let mut hard_deadline: Option<Instant> = None;
480 let grace = Duration::from_millis(auto_shutdown_ms.max(1));
868 let grace = Duration::from_millis(auto_shutdown_ms.max(1));
869+ let hard_timeout = Duration::from_millis(HARD_SHUTDOWN_TIMEOUT_MS);
481
870
482 loop {
871 loop {
483− let timeout = match shutdown_deadline {
872+ let timeout = match (shutdown_deadline, hard_deadline) {
873+ (Some(shutdown), Some(hard)) => {
874+ let next = if shutdown <= hard { shutdown } else { hard };
875+ next.saturating_duration_since(Instant::now())
876+ }
484− Some(deadline) => deadline.saturating_duration_since(Instant::now()),
877+ (Some(shutdown), None) => shutdown.saturating_duration_since(Instant::now()),
878+ (None, Some(hard)) => hard.saturating_duration_since(Instant::now()),
485− None => Duration::from_secs(1),
879+ (None, None) => Duration::from_secs(1),
486 };
880 };
487
881
488 match event_rx.recv_timeout(timeout) {
882 match event_rx.recv_timeout(timeout) {
@@ -496,11 +890,10 @@ fn manager_event_loop(
496 );
890 );
497 shutdown_deadline = None;
891 shutdown_deadline = None;
498 shutdown_sent = false;
892 shutdown_sent = false;
893+ hard_deadline = None;
499 }
894 }
500 Ok(ManagerEvent::Dec(pid)) => {
895 Ok(ManagerEvent::Dec(pid)) => {
501− if ref_count > 0 {
502− ref_count -= 1;
896+ ref_count = ref_count.saturating_sub(1);
503− }
504 tracing::info!(
897 tracing::info!(
505 ref_count,
898 ref_count,
506 pid = pid.unwrap_or(0),
899 pid = pid.unwrap_or(0),
@@ -514,21 +907,58 @@ fn manager_event_loop(
514 }
907 }
515 Ok(ManagerEvent::VmExited(err)) => {
908 Ok(ManagerEvent::VmExited(err)) => {
516 if let Some(err) = err {
909 if let Some(err) = err {
517− tracing::error!(error = %err, "vm exited with error");
910+ tracing::error!(error = %err, "vm exited with an error");
518 }
911 }
519 break;
912 break;
520 }
913 }
521 Err(mpsc::RecvTimeoutError::Timeout) => {
914 Err(mpsc::RecvTimeoutError::Timeout) => {
522− if let Some(deadline) = shutdown_deadline {
915+ if let Some(deadline) = shutdown_deadline
523− if Instant::now() >= deadline && !shutdown_sent {
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;
524− if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
923+ if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
924+ if tx
525− let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
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");
526 }
931 }
932+ } else {
933+ tracing::warn!("shutdown command deferred; vm input not ready");
934+ }
935+ if sent {
527 tracing::info!("shutdown command sent");
936 tracing::info!("shutdown command sent");
528 shutdown_sent = true;
937 shutdown_sent = true;
529 shutdown_deadline = None;
938 shutdown_deadline = None;
939+ hard_deadline = Some(Instant::now() + hard_timeout);
940+ } else {
941+ shutdown_deadline =
942+ Some(Instant::now() + Duration::from_millis(SHUTDOWN_RETRY_MS));
530 }
943 }
531 }
944 }
945+ if ref_count == 0
946+ && let Some(deadline) = hard_deadline
947+ && Instant::now() >= deadline
948+ {
949+ if shutdown_sent {
950+ tracing::warn!(
951+ timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
952+ "force exiting: VM did not stop after shutdown timeout"
953+ );
954+ } else {
955+ tracing::warn!(
956+ timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
957+ "force exiting: VM input not ready after shutdown timeout"
958+ );
959+ }
960+ force_exit("vm manager forced exit");
961+ }
532 }
962 }
533 Err(mpsc::RecvTimeoutError::Disconnected) => break,
963 Err(mpsc::RecvTimeoutError::Disconnected) => break,
534 }
964 }
@@ -536,3 +966,112 @@ fn manager_event_loop(
536
966
537 Ok(())
967 Ok(())
538}
968}
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.tomlnew file+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