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 = []
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+25−14
@@ -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
@@ -39,22 +39,33 @@
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.
414. [x] use vm.lock to ensure process concurrency safety.
414. [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## Stage 2
60## Stage 2
55
61
56−1. [ ] Redirect vm output to log.
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.
57−2. [ ] Redirect vm output to vibebox starting it.
67+6. [x] Redirect vm output to vibebox starting it.
58−3. [ ] use anyhow to sync api.
68+7. [x] use anyhow to sync api.
59−
69+8. [ ] add support for ipv6.
60−[ ]
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
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+625−159
@@ -1,3 +1,15 @@
1+use crate::instance::{VmLiveness, vm_liveness};
2+use crate::session_manager::INSTANCE_DIR_NAME;
3+use crate::{
4+ config::CONFIG_PATH_ENV,
5+ instance::{
6+ InstanceConfig, STATUS_VM_ERROR_PREFIX, build_ssh_login_actions, ensure_instance_dir,
7+ ensure_ssh_keypair, extract_ipv4, load_or_create_instance_config, write_instance_config,
8+ },
9+ session_manager::{GLOBAL_DIR_NAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME},
10+ vm::{self, DirectoryShare, LoginAction, PROJECT_GUEST_BASE, VmInput},
11+};
12+use anyhow::{Error, Result, anyhow, bail};
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,30 +25,44 @@ 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,
20− extract_ipv4, load_or_create_instance_config, write_instance_config,
21− },
22− session_manager::GLOBAL_DIR_NAME,
23− vm::{self, DirectoryShare, LoginAction, VmInput},
24−};
25−
26−const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
27const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
30const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
31+const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
32+const SHUTDOWN_RETRY_MS: u64 = 500;
33+#[cfg(test)]
34+const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 1_000;
35+#[cfg(not(test))]
36+const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 12_000;
37+const STATUS_PREFIX: &str = "status:";
38+
39+type ClientStreams = Arc<Mutex<Vec<UnixStream>>>;
40+type SharedStatus = Arc<Mutex<String>>;
41+
42+#[cfg(not(test))]
43+fn force_exit(_reason: &str) -> ! {
44+ std::process::exit(1);
45+}
46+
47+#[cfg(test)]
48+fn force_exit(reason: &str) -> ! {
49+ panic!("{reason}");
50+}
28
51
29pub fn ensure_manager(
52pub fn ensure_manager(
30 raw_args: &[std::ffi::OsString],
53 raw_args: &[std::ffi::OsString],
31 auto_shutdown_ms: u64,
54 auto_shutdown_ms: u64,
55+ config_path: Option<&Path>,
32−) -> Result<UnixStream, Box<dyn std::error::Error>> {
56+) -> Result<UnixStream> {
33 let project_root = env::current_dir()?;
57 let project_root = env::current_dir()?;
34 tracing::debug!(root = %project_root.display(), "ensure vm manager");
58 tracing::debug!(root = %project_root.display(), "ensure vm manager");
35 let instance_dir = ensure_instance_dir(&project_root)?;
59 let instance_dir = ensure_instance_dir(&project_root)?;
60+ cleanup_stale_manager(&project_root)?;
36 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
61 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
37
62
38 if let Ok(stream) = UnixStream::connect(&socket_path) {
63 if let Ok(stream) = UnixStream::connect(&socket_path) {
39 send_client_pid(&stream);
64 send_client_pid(&stream);
40− tracing::info!(path = %socket_path.display(), "connected to existing vm manager");
65+ tracing::info!(path = %socket_path.display(), "connected to an existing vm manager");
41 return Ok(stream);
66 return Ok(stream);
42 }
67 }
43
68
@@ -45,7 +70,7 @@ pub fn ensure_manager(
45 let mut lock_file = acquire_spawn_lock(&lock_path)?;
70 let mut lock_file = acquire_spawn_lock(&lock_path)?;
46 if lock_file.is_some() {
71 if lock_file.is_some() {
47 tracing::info!(path = %socket_path.display(), "spawning vm manager");
72 tracing::info!(path = %socket_path.display(), "spawning vm manager");
48− spawn_manager_process(raw_args, auto_shutdown_ms, &instance_dir)?;
73+ spawn_manager_process(raw_args, auto_shutdown_ms, &instance_dir, config_path)?;
49 } else {
74 } else {
50 tracing::info!(
75 tracing::info!(
51 path = %socket_path.display(),
76 path = %socket_path.display(),
@@ -75,12 +100,11 @@ pub fn ensure_manager(
75 drop(lock_file.take());
100 drop(lock_file.take());
76 let _ = fs::remove_file(&lock_path);
101 let _ = fs::remove_file(&lock_path);
77 }
102 }
78− return Err(format!(
103+ bail!(format!(
79− "Timed out waiting for vm manager socket: {} ({})",
104+ "timed out waiting for vm manager socket: {} ({})",
80 socket_path.display(),
105 socket_path.display(),
81 err
106 err
82− )
107+ ));
83− .into());
84 }
108 }
85 thread::sleep(Duration::from_millis(100));
109 thread::sleep(Duration::from_millis(100));
86 }
110 }
@@ -88,54 +112,83 @@ pub fn ensure_manager(
88 }
112 }
89}
113}
90
114
91−pub fn run_manager(
92− args: vm::CliArgs,
93− auto_shutdown_ms: u64,
115+pub fn run_manager(args: vm::VmArg, auto_shutdown_ms: u64) -> Result<()> {
94−) -> Result<(), Box<dyn std::error::Error>> {
95 let project_root = env::current_dir()?;
116 let project_root = env::current_dir()?;
96 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+ };
97− run_manager_with(
157+ run_manager_with(&project_root, args, auto_shutdown_ms, executor, options)
98− &project_root,
99− args,
100− auto_shutdown_ms,
101− &RealVmExecutor,
102− ManagerOptions {
103− ensure_signed: true,
104− detach: true,
105− prepare_vm: true,
106− },
107− )
108}
158}
109
159
110fn spawn_manager_process(
160fn spawn_manager_process(
111 raw_args: &[std::ffi::OsString],
161 raw_args: &[std::ffi::OsString],
112 auto_shutdown_ms: u64,
162 auto_shutdown_ms: u64,
113 instance_dir: &Path,
163 instance_dir: &Path,
114−) -> Result<(), Box<dyn std::error::Error>> {
164+ config_path: Option<&Path>,
165+) -> Result<()> {
115 let exe = env::current_exe()?;
166 let exe = env::current_exe()?;
116 let mut supervisor_exe = exe.clone();
167 let mut supervisor_exe = exe.clone();
117 supervisor_exe.set_file_name("vibebox-supervisor");
168 supervisor_exe.set_file_name("vibebox-supervisor");
118− let use_supervisor = supervisor_exe.exists();
169+ // intentional
119− let mut cmd = if use_supervisor {
170+ if !supervisor_exe.exists() {
120− Command::new(supervisor_exe)
171+ bail!(format!(
121− } else {
122− let mut cmd = Command::new(exe);
123− cmd.arg0("vibebox-supervisor");
172+ "vibebox-supervisor not found at {}",
124− cmd
173+ supervisor_exe.display()
174+ ));
125− };
175+ }
176+ let mut cmd = Command::new(supervisor_exe);
126 if raw_args.len() > 1 {
177 if raw_args.len() > 1 {
127 cmd.args(&raw_args[1..]);
178 cmd.args(&raw_args[1..]);
128 }
179 }
129− if !use_supervisor {
180+ cmd.env("VIBEBOX_INTERNAL", "1");
130− cmd.env("VIBEBOX_VM_MANAGER", "1");
131− }
132 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
181 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
133 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+ }
134− tracing::info!(auto_shutdown_ms, "vm manager process spawn requested");
186+ tracing::debug!(auto_shutdown_ms, "vm manager process spawn requested");
135− let log_path = instance_dir.join("vm_manager.log");
187+ let log_path = instance_dir.join(VM_MANAGER_LOG_NAME);
136 let log_file = fs::OpenOptions::new()
188 let log_file = fs::OpenOptions::new()
137 .create(true)
189 .create(true)
138− .append(true)
190+ .write(true)
191+ .truncate(true)
139 .open(&log_path)
192 .open(&log_path)
140 .ok();
193 .ok();
141 if let Some(file) = log_file {
194 if let Some(file) = log_file {
@@ -152,6 +205,184 @@ fn spawn_manager_process(
152 Ok(())
205 Ok(())
153}
206}
154
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+
155fn detach_from_terminal() {
386fn detach_from_terminal() {
156 unsafe {
387 unsafe {
157 libc::setsid();
388 libc::setsid();
@@ -178,6 +409,30 @@ fn wait_for_disconnect(mut stream: UnixStream) {
178 }
409 }
179}
410}
180
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+
181fn send_client_pid(stream: &UnixStream) {
436fn send_client_pid(stream: &UnixStream) {
182 let pid = std::process::id();
437 let pid = std::process::id();
183 let payload = format!("pid={pid}\n");
438 let payload = format!("pid={pid}\n");
@@ -187,7 +442,7 @@ fn send_client_pid(stream: &UnixStream) {
187 }
442 }
188}
443}
189
444
190−fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>, Box<dyn std::error::Error>> {
445+fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>> {
191 match fs::OpenOptions::new()
446 match fs::OpenOptions::new()
192 .write(true)
447 .write(true)
193 .create_new(true)
448 .create_new(true)
@@ -221,6 +476,12 @@ fn is_lock_stale(lock_path: &Path) -> bool {
221 }
476 }
222}
477}
223
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+
224fn pid_is_alive(pid: u32) -> bool {
485fn pid_is_alive(pid: u32) -> bool {
225 let pid = pid as libc::pid_t;
486 let pid = pid as libc::pid_t;
226 let result = unsafe { libc::kill(pid, 0) };
487 let result = unsafe { libc::kill(pid, 0) };
@@ -234,12 +495,6 @@ fn pid_is_alive(pid: u32) -> bool {
234 }
495 }
235}
496}
236
497
237−fn read_lock_pid(lock_path: &Path) -> Option<u32> {
238− let content = fs::read_to_string(lock_path).ok()?;
239− let line = content.lines().next()?;
240− line.strip_prefix("pid=")?.trim().parse::<u32>().ok()
241−}
242−
243fn read_client_pid(stream: &UnixStream) -> Option<u32> {
498fn read_client_pid(stream: &UnixStream) -> Option<u32> {
244 let mut stream = stream.try_clone().ok()?;
499 let mut stream = stream.try_clone().ok()?;
245 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
500 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
@@ -250,7 +505,7 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
250 Ok(0) => break,
505 Ok(0) => break,
251 Ok(n) => {
506 Ok(n) => {
252 len += n;
507 len += n;
253− if buf[..len].iter().any(|b| *b == b'\n') || len == buf.len() {
508+ if buf[..len].contains(&b'\n') || len == buf.len() {
254 break;
509 break;
255 }
510 }
256 }
511 }
@@ -272,31 +527,34 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
272 }
527 }
273}
528}
274
529
530+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
275fn spawn_manager_io(
531fn spawn_manager_io(
276 config: Arc<Mutex<InstanceConfig>>,
532 config: Arc<Mutex<InstanceConfig>>,
277− instance_dir: PathBuf,
533+ project_dir: PathBuf,
534+ clients: ClientStreams,
535+ latest_status: SharedStatus,
278 output_monitor: Arc<vm::OutputMonitor>,
536 output_monitor: Arc<vm::OutputMonitor>,
279 vm_output_fd: std::os::unix::io::OwnedFd,
537 vm_output_fd: std::os::unix::io::OwnedFd,
280 vm_input_fd: std::os::unix::io::OwnedFd,
538 vm_input_fd: std::os::unix::io::OwnedFd,
281) -> vm::IoContext {
539) -> vm::IoContext {
282− let log_path = instance_dir.join("serial.log");
540+ let log_path = project_dir.join(INSTANCE_DIR_NAME).join(VM_ROOT_LOG_NAME);
283 let log_file = fs::OpenOptions::new()
541 let log_file = fs::OpenOptions::new()
284 .create(true)
542 .create(true)
285− .append(true)
543+ .write(true)
544+ .truncate(true)
286 .open(&log_path)
545 .open(&log_path)
287 .ok()
546 .ok()
288 .map(|file| Arc::new(Mutex::new(file)));
547 .map(|file| Arc::new(Mutex::new(file)));
289
548
290− let instance_path = instance_dir.join("instance.toml");
291 let config_for_output = config.clone();
549 let config_for_output = config.clone();
292 let log_for_output = log_file.clone();
550 let log_for_output = log_file.clone();
293 let mut line_buf = String::new();
551 let mut line_buf = String::new();
294
552
295 let on_output = move |bytes: &[u8]| {
553 let on_output = move |bytes: &[u8]| {
296− if let Some(log) = &log_for_output {
554+ if let Some(log) = &log_for_output
297− if let Ok(mut file) = log.lock() {
555+ && let Ok(mut file) = log.lock()
556+ {
298− let _ = file.write_all(bytes);
557+ let _ = file.write_all(bytes);
299− }
300 }
558 }
301
559
302 let text = String::from_utf8_lossy(bytes);
560 let text = String::from_utf8_lossy(bytes);
@@ -309,17 +567,27 @@ fn spawn_manager_io(
309 }
567 }
310 line_buf.drain(..=pos);
568 line_buf.drain(..=pos);
311
569
312− let cleaned = line.trim_start_matches(|c: char| c == '\r' || c == ' ');
570+ let cleaned = line.trim_start_matches(['\r', ' ']);
571+ if let Some(script_failure) = cleaned.strip_prefix("VIBEBOX_SCRIPT_ERROR:") {
572+ let failure = script_failure.trim();
573+ if !failure.is_empty() {
574+ tracing::error!(script_failure = %failure, "[vm] script reported failure");
575+ broadcast_status(
576+ &clients,
577+ &latest_status,
578+ &format!("{STATUS_VM_ERROR_PREFIX} {failure}"),
579+ );
580+ }
581+ }
313 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
582 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
314 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
583 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
315 let ip = extract_ipv4(ip_raw).unwrap_or_default();
584 let ip = extract_ipv4(ip_raw).unwrap_or_default();
316− if !ip.is_empty() {
585+ if !ip.is_empty()
317− if let Ok(mut cfg) = config_for_output.lock() {
586+ && let Ok(mut cfg) = config_for_output.lock()
318− if cfg.vm_ipv4.as_deref() != Some(ip.as_str()) {
587+ && cfg.vm_ipv4.as_deref() != Some(ip.as_str())
588+ {
319− cfg.vm_ipv4 = Some(ip.clone());
589+ cfg.vm_ipv4 = Some(ip.clone());
320− let _ = write_instance_config(&instance_path, &cfg);
590+ let _ = write_instance_config(&project_dir, &cfg);
321− }
322− }
323 }
591 }
324 }
592 }
325 }
593 }
@@ -341,80 +609,52 @@ enum ManagerEvent {
341 VmExited(Option<String>),
609 VmExited(Option<String>),
342}
610}
343
611
344−#[cfg(test)]
345−mod tests {
346− use super::*;
347− use std::{sync::mpsc, time::Duration};
348−
349− #[test]
350− fn manager_powers_off_after_grace_when_no_refs() {
351− let _temp = tempfile::Builder::new()
352− .prefix("vb")
353− .tempdir_in("/tmp")
354− .expect("tempdir");
355−
356− let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
357− let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
358− let vm_input_tx = Arc::new(Mutex::new(Some(vm_tx)));
359−
360− let manager_thread = thread::spawn(move || {
361− manager_event_loop(event_rx, vm_input_tx, 50).expect("event loop");
362− });
363−
364− event_tx.send(ManagerEvent::Inc(None)).unwrap();
365− assert!(vm_rx.recv_timeout(Duration::from_millis(100)).is_err());
366−
367− event_tx.send(ManagerEvent::Dec(None)).unwrap();
368− let msg = vm_rx
369− .recv_timeout(Duration::from_secs(2))
370− .expect("poweroff");
371− match msg {
372− VmInput::Bytes(data) => {
373− assert_eq!(data, b"systemctl poweroff\n");
374− }
375− _ => panic!("unexpected vm input"),
376− }
377− let _ = event_tx.send(ManagerEvent::VmExited(None));
378− let _ = manager_thread.join();
379− }
380−}
381−
382struct ManagerOptions {
612struct ManagerOptions {
383− ensure_signed: bool,
384 detach: bool,
613 detach: bool,
385 prepare_vm: bool,
614 prepare_vm: bool,
386}
615}
387
616
388trait VmExecutor {
617trait VmExecutor {
618+ #[allow(clippy::too_many_arguments)]
389 fn run_vm(
619 fn run_vm(
390 &self,
620 &self,
391− args: vm::CliArgs,
621+ args: vm::VmArg,
392 extra_login_actions: Vec<LoginAction>,
622 extra_login_actions: Vec<LoginAction>,
393 extra_shares: Vec<DirectoryShare>,
623 extra_shares: Vec<DirectoryShare>,
394 config: Arc<Mutex<InstanceConfig>>,
624 config: Arc<Mutex<InstanceConfig>>,
395 instance_dir: PathBuf,
625 instance_dir: PathBuf,
626+ clients: ClientStreams,
627+ latest_status: SharedStatus,
396 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
628 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
397− ) -> Result<(), Box<dyn std::error::Error>>;
629+ ) -> Result<()>;
398}
630}
399
631
632+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
400struct RealVmExecutor;
633struct RealVmExecutor;
401
634
402impl VmExecutor for RealVmExecutor {
635impl VmExecutor for RealVmExecutor {
403 fn run_vm(
636 fn run_vm(
404 &self,
637 &self,
405− args: vm::CliArgs,
638+ args: vm::VmArg,
406 extra_login_actions: Vec<LoginAction>,
639 extra_login_actions: Vec<LoginAction>,
407 extra_shares: Vec<DirectoryShare>,
640 extra_shares: Vec<DirectoryShare>,
408 config: Arc<Mutex<InstanceConfig>>,
641 config: Arc<Mutex<InstanceConfig>>,
409− instance_dir: PathBuf,
642+ project_dir: PathBuf,
643+ clients: ClientStreams,
644+ latest_status: SharedStatus,
410 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
645 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
411− ) -> Result<(), Box<dyn std::error::Error>> {
646+ ) -> Result<()> {
647+ let status_callback = |status: &str| {
648+ broadcast_status(&clients, &latest_status, status);
649+ };
412− let result = vm::run_with_args_and_extras(
650+ vm::run_with_args_and_extras(
413 args,
651 args,
414 |output_monitor, vm_output_fd, vm_input_fd| {
652 |output_monitor, vm_output_fd, vm_input_fd| {
415 let io_ctx = spawn_manager_io(
653 let io_ctx = spawn_manager_io(
416 config.clone(),
654 config.clone(),
417− instance_dir.clone(),
655+ project_dir.clone(),
656+ clients.clone(),
657+ latest_status.clone(),
418 output_monitor,
658 output_monitor,
419 vm_output_fd,
659 vm_output_fd,
420 vm_input_fd,
660 vm_input_fd,
@@ -424,35 +664,60 @@ impl VmExecutor for RealVmExecutor {
424 },
664 },
425 extra_login_actions,
665 extra_login_actions,
426 extra_shares,
666 extra_shares,
427− );
667+ Some(&status_callback),
428− 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(())
429 }
704 }
430}
705}
431
706
432fn run_manager_with(
707fn run_manager_with(
433 project_root: &Path,
708 project_root: &Path,
434− args: vm::CliArgs,
709+ mut args: vm::VmArg,
435 auto_shutdown_ms: u64,
710 auto_shutdown_ms: u64,
436 executor: &dyn VmExecutor,
711 executor: &dyn VmExecutor,
437 options: ManagerOptions,
712 options: ManagerOptions,
438−) -> Result<(), Box<dyn std::error::Error>> {
713+) -> Result<()> {
439− if options.ensure_signed {
440− let _had_skip = env::var("VIBEBOX_SKIP_CODESIGN").ok();
441− unsafe {
442− env::remove_var("VIBEBOX_SKIP_CODESIGN");
443− }
444− vm::ensure_signed();
445− unsafe {
446− env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
447− }
448− }
449 if options.detach {
714 if options.detach {
450 detach_from_terminal();
715 detach_from_terminal();
451 }
716 }
452
717
453 let project_name = project_root
718 let project_name = project_root
454 .file_name()
719 .file_name()
455− .ok_or("Project directory has no name")?
720+ .ok_or_else(|| anyhow!("Project directory has no name"))?
456 .to_string_lossy()
721 .to_string_lossy()
457 .into_owned();
722 .into_owned();
458 let instance_dir = ensure_instance_dir(project_root)?;
723 let instance_dir = ensure_instance_dir(project_root)?;
@@ -460,21 +725,35 @@ fn run_manager_with(
460 let _ = ensure_ssh_keypair(&instance_dir)?;
725 let _ = ensure_ssh_keypair(&instance_dir)?;
461 }
726 }
462
727
463− let mut config = load_or_create_instance_config(&instance_dir)?;
728+ let mut config = load_or_create_instance_config(project_root)?;
464 if config.vm_ipv4.is_some() {
729 if config.vm_ipv4.is_some() {
465 config.vm_ipv4 = None;
730 config.vm_ipv4 = None;
466− write_instance_config(&instance_dir.join("instance.toml"), &config)?;
731+ write_instance_config(project_root, &config)?;
467 }
732 }
468 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);
469
742
743+ let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}");
470 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
744 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
471− let extra_shares = vec![DirectoryShare::new(
745+ let extra_shares = vec![
472− instance_dir.clone(),
473− ssh_guest_dir.clone().into(),
746+ DirectoryShare::new(instance_dir.clone(), ssh_guest_dir.clone().into(), true)
474− true,
747+ .map_err(|err| anyhow!(err.to_string()))?,
475− )?];
748+ ];
476− let extra_login_actions =
749+ let extra_login_actions = build_ssh_login_actions(
750+ &config,
751+ &project_name,
752+ &project_guest_dir,
477− 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+ );
478
757
479 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
758 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
480 if let Ok(stream) = UnixStream::connect(&socket_path) {
759 if let Ok(stream) = UnixStream::connect(&socket_path) {
@@ -489,17 +768,43 @@ fn run_manager_with(
489 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
768 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
490 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
769 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
491
770
771+ let clients: ClientStreams = Arc::new(Mutex::new(Vec::new()));
772+ let latest_status: SharedStatus = Arc::new(Mutex::new(String::new()));
492 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
773 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
493 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();
494 thread::spawn(move || {
777 thread::spawn(move || {
495 for stream in listener.incoming() {
778 for stream in listener.incoming() {
496 match stream {
779 match stream {
497 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+ }
498 let event_tx_conn = event_tx_accept.clone();
799 let event_tx_conn = event_tx_accept.clone();
800+ let clients_conn = clients_accept.clone();
499 thread::spawn(move || {
801 thread::spawn(move || {
500 let pid = read_client_pid(&stream);
802 let pid = read_client_pid(&stream);
501 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
803 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
502 wait_for_disconnect(stream);
804 wait_for_disconnect(stream);
805+ if let Some(fd) = client_fd {
806+ remove_client(&clients_conn, fd);
807+ }
503 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
808 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
504 });
809 });
505 }
810 }
@@ -519,43 +824,59 @@ fn run_manager_with(
519 extra_login_actions,
824 extra_login_actions,
520 extra_shares,
825 extra_shares,
521 config.clone(),
826 config.clone(),
522− instance_dir.clone(),
827+ project_root.to_path_buf(),
828+ clients.clone(),
829+ latest_status.clone(),
523 vm_input_tx.clone(),
830 vm_input_tx.clone(),
524 );
831 );
525 tracing::info!("vm manager vm run completed");
832 tracing::info!("vm manager vm run completed");
526 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+ }
527 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
841 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
528− let event_loop_result: Result<(), String> = event_loop_handle
842+ let event_loop_result = event_loop_handle
529 .join()
843 .join()
530− .unwrap_or_else(|_| Err("vm manager event loop panicked".into()))
844+ .unwrap_or_else(|_| Err(Error::msg("vm manager event loop panicked")))
531 .map_err(|err| err.to_string());
845 .map_err(|err| err.to_string());
532 let _ = fs::remove_file(&socket_path);
846 let _ = fs::remove_file(&socket_path);
533 if let Err(err) = &event_loop_result {
847 if let Err(err) = &event_loop_result {
534 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");
535− return Err(err.to_string().into());
849+ bail!(err.to_string());
536 }
850 }
537 if let Some(err) = vm_err {
851 if let Some(err) = vm_err {
538 tracing::error!(error = %err, "vm manager exiting due to vm error");
852 tracing::error!(error = %err, "vm manager exiting due to vm error");
539− return Err(err.into());
853+ bail!(err);
540 }
854 }
541 tracing::info!("vm manager exiting");
855 tracing::info!("vm manager exiting");
542− Ok(event_loop_result?)
856+ event_loop_result.map_err(Error::msg)
543}
857}
544
858
545fn manager_event_loop(
859fn manager_event_loop(
546 event_rx: mpsc::Receiver<ManagerEvent>,
860 event_rx: mpsc::Receiver<ManagerEvent>,
547 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
861 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
548 auto_shutdown_ms: u64,
862 auto_shutdown_ms: u64,
549−) -> Result<(), String> {
863+) -> Result<()> {
550 let mut ref_count: usize = 0;
864 let mut ref_count: usize = 0;
551 let mut shutdown_deadline: Option<Instant> = None;
865 let mut shutdown_deadline: Option<Instant> = None;
552 let mut shutdown_sent = false;
866 let mut shutdown_sent = false;
867+ let mut hard_deadline: Option<Instant> = None;
553 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);
554
870
555 loop {
871 loop {
556− 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+ }
557− 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()),
558− None => Duration::from_secs(1),
879+ (None, None) => Duration::from_secs(1),
559 };
880 };
560
881
561 match event_rx.recv_timeout(timeout) {
882 match event_rx.recv_timeout(timeout) {
@@ -569,11 +890,10 @@ fn manager_event_loop(
569 );
890 );
570 shutdown_deadline = None;
891 shutdown_deadline = None;
571 shutdown_sent = false;
892 shutdown_sent = false;
893+ hard_deadline = None;
572 }
894 }
573 Ok(ManagerEvent::Dec(pid)) => {
895 Ok(ManagerEvent::Dec(pid)) => {
574− if ref_count > 0 {
575− ref_count -= 1;
896+ ref_count = ref_count.saturating_sub(1);
576− }
577 tracing::info!(
897 tracing::info!(
578 ref_count,
898 ref_count,
579 pid = pid.unwrap_or(0),
899 pid = pid.unwrap_or(0),
@@ -592,16 +912,53 @@ fn manager_event_loop(
592 break;
912 break;
593 }
913 }
594 Err(mpsc::RecvTimeoutError::Timeout) => {
914 Err(mpsc::RecvTimeoutError::Timeout) => {
595− if let Some(deadline) = shutdown_deadline {
915+ if let Some(deadline) = shutdown_deadline
596− 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;
597− if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
923+ if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
924+ if tx
598− 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");
599 }
931 }
932+ } else {
933+ tracing::warn!("shutdown command deferred; vm input not ready");
934+ }
935+ if sent {
600 tracing::info!("shutdown command sent");
936 tracing::info!("shutdown command sent");
601 shutdown_sent = true;
937 shutdown_sent = true;
602 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));
603 }
943 }
604 }
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+ }
605 }
962 }
606 Err(mpsc::RecvTimeoutError::Disconnected) => break,
963 Err(mpsc::RecvTimeoutError::Disconnected) => break,
607 }
964 }
@@ -609,3 +966,112 @@ fn manager_event_loop(
609
966
610 Ok(())
967 Ok(())
611}
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