on fc7e95f help sign in
35 files changed+1672−5472
since splitsnapshots
unifiedsplit
.github/workflows/ci.yml−98 .github/workflows/release.yml−93 CONTRIBUTING.md−44 Cargo.lock+242−259 Cargo.toml+12−27 README.md−223 README.zh.md−209 docs/banner.png docs/implementations.md+1 docs/screenshot.png docs/tasks.md+14−25 install → install.sh+61−54 scripts/commit-check.sh−6 src/bin/vibebox-cli.rs+106 src/bin/vibebox-supervisor.rs+17−45 src/bin/vibebox.rs−400 src/commands.rs−111 src/config.rs−679 src/error_report.sh−28 src/explain.rs−156 src/instance.rs+331−341 src/lib.rs−4 src/provision.sh+5−55 src/resize_disk.sh−55 src/session_manager.rs+326−272 src/ssh.sh+24−78 src/tui.rs+43−276 src/utils.rs−18 src/vm.rs+331−376 src/vm_manager.rs+159−625 tests/e2e_cli.rs−58 tests/e2e_vm.rs−278 tests/e2e_vm_mock.rs−455 tests/explain_integration.rs−113 vibebox.toml−11
.github/workflows/ci.ymldeleted−98
@@ -1,98 +0,0 @@
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.ymldeleted−93
@@ -1,93 +0,0 @@
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.mddeleted−44
@@ -1,44 +0,0 @@
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+242−259
@@ -32,77 +32,6 @@ version = "0.2.21"
3232source = "registry+https://github.com/rust-lang/crates.io-index"
3333checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
3434
35−[[package]]
36−name = "anstream"
37−version = "0.6.21"
38−source = "registry+https://github.com/rust-lang/crates.io-index"
39−checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
40−dependencies = [
41− "anstyle",
42− "anstyle-parse",
43− "anstyle-query",
44− "anstyle-wincon",
45− "colorchoice",
46− "is_terminal_polyfill",
47− "utf8parse",
48−]
49−
50−[[package]]
51−name = "anstyle"
52−version = "1.0.13"
53−source = "registry+https://github.com/rust-lang/crates.io-index"
54−checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
55−
56−[[package]]
57−name = "anstyle-parse"
58−version = "0.2.7"
59−source = "registry+https://github.com/rust-lang/crates.io-index"
60−checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
61−dependencies = [
62− "utf8parse",
63−]
64−
65−[[package]]
66−name = "anstyle-query"
67−version = "1.1.5"
68−source = "registry+https://github.com/rust-lang/crates.io-index"
69−checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
70−dependencies = [
71− "windows-sys 0.61.2",
72−]
73−
74−[[package]]
75−name = "anstyle-wincon"
76−version = "3.0.11"
77−source = "registry+https://github.com/rust-lang/crates.io-index"
78−checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
79−dependencies = [
80− "anstyle",
81− "once_cell_polyfill",
82− "windows-sys 0.61.2",
83−]
84−
85−[[package]]
86−name = "anyhow"
87−version = "1.0.101"
88−source = "registry+https://github.com/rust-lang/crates.io-index"
89−checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
90−
91−[[package]]
92−name = "assert_cmd"
93−version = "2.1.2"
94−source = "registry+https://github.com/rust-lang/crates.io-index"
95−checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514"
96−dependencies = [
97− "anstyle",
98− "bstr",
99− "libc",
100− "predicates",
101− "predicates-core",
102− "predicates-tree",
103− "wait-timeout",
104−]
105−
10635[[package]]
10736name = "backtrace"
10837version = "0.3.76"
@@ -133,17 +62,6 @@ dependencies = [
13362 "objc2",
13463]
13564
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−
14765[[package]]
14866name = "bumpalo"
14967version = "3.19.1"
@@ -151,13 +69,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
15169checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
15270
15371[[package]]
154−name = "bytesize"
155−version = "2.3.1"
72+name = "bytes"
73+version = "1.11.1"
15674source = "registry+https://github.com/rust-lang/crates.io-index"
157−checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3"
158−dependencies = [
159− "serde_core",
160−]
75+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
16176
16277[[package]]
16378name = "cassowary"
@@ -180,46 +95,6 @@ version = "1.0.4"
18095source = "registry+https://github.com/rust-lang/crates.io-index"
18196checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
18297
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−
22398[[package]]
22499name = "color-eyre"
225100version = "0.6.5"
@@ -247,12 +122,6 @@ dependencies = [
247122 "tracing-error",
248123]
249124
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−
256125[[package]]
257126name = "compact_str"
258127version = "0.8.1"
@@ -267,19 +136,6 @@ dependencies = [
267136 "static_assertions",
268137]
269138
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−
283139[[package]]
284140name = "crossterm"
285141version = "0.28.1"
@@ -350,24 +206,6 @@ dependencies = [
350206 "serde_core",
351207]
352208
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−
371209[[package]]
372210name = "dispatch2"
373211version = "0.3.0"
@@ -386,12 +224,6 @@ version = "1.15.0"
386224source = "registry+https://github.com/rust-lang/crates.io-index"
387225checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
388226
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−
395227[[package]]
396228name = "equivalent"
397229version = "1.0.2"
@@ -430,12 +262,95 @@ version = "0.1.5"
430262source = "registry+https://github.com/rust-lang/crates.io-index"
431263checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
432264
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+
433290[[package]]
434291name = "futures-core"
435292version = "0.3.31"
436293source = "registry+https://github.com/rust-lang/crates.io-index"
437294checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
438295
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+
439354[[package]]
440355name = "getrandom"
441356version = "0.3.4"
@@ -521,12 +436,6 @@ dependencies = [
521436 "syn",
522437]
523438
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−
530439[[package]]
531440name = "itertools"
532441version = "0.13.0"
@@ -558,6 +467,12 @@ version = "1.5.0"
558467source = "registry+https://github.com/rust-lang/crates.io-index"
559468checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
560469
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+
561476[[package]]
562477name = "libc"
563478version = "0.2.180"
@@ -642,7 +557,7 @@ version = "0.50.3"
642557source = "registry+https://github.com/rust-lang/crates.io-index"
643558checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
644559dependencies = [
645− "windows-sys 0.61.2",
560+ "windows-sys 0.59.0",
646561]
647562
648563[[package]]
@@ -731,12 +646,6 @@ version = "1.21.3"
731646source = "registry+https://github.com/rust-lang/crates.io-index"
732647checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
733648
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−
740649[[package]]
741650name = "owo-colors"
742651version = "4.2.3"
@@ -779,37 +688,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
779688checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
780689
781690[[package]]
782−name = "powerfmt"
783−version = "0.2.0"
691+name = "pin-utils"
692+version = "0.1.0"
784693source = "registry+https://github.com/rust-lang/crates.io-index"
785−checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
694+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
786695
787696[[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"
697+name = "powerfmt"
698+version = "0.2.0"
807699source = "registry+https://github.com/rust-lang/crates.io-index"
808−checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
809−dependencies = [
810− "predicates-core",
811− "termtree",
812−]
700+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
813701
814702[[package]]
815703name = "proc-macro2"
@@ -980,12 +868,6 @@ dependencies = [
980868 "lazy_static",
981869]
982870
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−
989871[[package]]
990872name = "signal-hook"
991873version = "0.3.18"
@@ -1017,12 +899,28 @@ dependencies = [
1017899 "libc",
1018900]
1019901
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+
1020908[[package]]
1021909name = "smallvec"
1022910version = "1.15.1"
1023911source = "registry+https://github.com/rust-lang/crates.io-index"
1024912checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
1025913
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+
1026924[[package]]
1027925name = "static_assertions"
1028926version = "1.1.0"
@@ -1081,12 +979,6 @@ dependencies = [
1081979 "windows-sys 0.61.2",
1082980]
1083981
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−
1090982[[package]]
1091983name = "thiserror"
1092984version = "2.0.18"
@@ -1147,6 +1039,34 @@ dependencies = [
11471039 "time-core",
11481040]
11491041
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+
11501070[[package]]
11511071name = "toml"
11521072version = "0.9.11+spec-1.1.0"
@@ -1257,6 +1177,16 @@ dependencies = [
12571177 "tracing-log",
12581178]
12591179
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+
12601190[[package]]
12611191name = "unicode-ident"
12621192version = "1.0.22"
@@ -1292,12 +1222,6 @@ version = "0.2.0"
12921222source = "registry+https://github.com/rust-lang/crates.io-index"
12931223checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
12941224
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−
13011225[[package]]
13021226name = "uuid"
13031227version = "1.20.0"
@@ -1318,17 +1242,14 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
13181242
13191243[[package]]
13201244name = "vibebox"
1321−version = "0.3.2"
1245+version = "0.1.0"
13221246dependencies = [
1323− "anyhow",
1324− "assert_cmd",
13251247 "block2",
1326− "bytesize",
1327− "clap",
13281248 "color-eyre",
13291249 "crossterm",
1330− "dialoguer",
13311250 "dispatch2",
1251+ "futures",
1252+ "lexopt",
13321253 "libc",
13331254 "objc2",
13341255 "objc2-foundation",
@@ -1338,21 +1259,15 @@ dependencies = [
13381259 "tempfile",
13391260 "thiserror",
13401261 "time",
1262+ "tokio",
13411263 "toml",
13421264 "tracing",
13431265 "tracing-subscriber",
1266+ "tui-textarea",
1267+ "unicode-width 0.2.0",
13441268 "uuid",
13451269]
13461270
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−
13561271[[package]]
13571272name = "wasi"
13581273version = "0.11.1+wasi-snapshot-preview1"
@@ -1447,7 +1362,16 @@ version = "0.59.0"
14471362source = "registry+https://github.com/rust-lang/crates.io-index"
14481363checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
14491364dependencies = [
1450− "windows-targets",
1365+ "windows-targets 0.52.6",
1366+]
1367+
1368+[[package]]
1369+name = "windows-sys"
1370+version = "0.60.2"
1371+source = "registry+https://github.com/rust-lang/crates.io-index"
1372+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
1373+dependencies = [
1374+ "windows-targets 0.53.5",
14511375]
14521376
14531377[[package]]
@@ -1465,14 +1389,31 @@ version = "0.52.6"
14651389source = "registry+https://github.com/rust-lang/crates.io-index"
14661390checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
14671391dependencies = [
1468− "windows_aarch64_gnullvm",
1469− "windows_aarch64_msvc",
1470− "windows_i686_gnu",
1471− "windows_i686_gnullvm",
1472− "windows_i686_msvc",
1473− "windows_x86_64_gnu",
1474− "windows_x86_64_gnullvm",
1475− "windows_x86_64_msvc",
1392+ "windows_aarch64_gnullvm 0.52.6",
1393+ "windows_aarch64_msvc 0.52.6",
1394+ "windows_i686_gnu 0.52.6",
1395+ "windows_i686_gnullvm 0.52.6",
1396+ "windows_i686_msvc 0.52.6",
1397+ "windows_x86_64_gnu 0.52.6",
1398+ "windows_x86_64_gnullvm 0.52.6",
1399+ "windows_x86_64_msvc 0.52.6",
1400+]
1401+
1402+[[package]]
1403+name = "windows-targets"
1404+version = "0.53.5"
1405+source = "registry+https://github.com/rust-lang/crates.io-index"
1406+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
1407+dependencies = [
1408+ "windows-link",
1409+ "windows_aarch64_gnullvm 0.53.1",
1410+ "windows_aarch64_msvc 0.53.1",
1411+ "windows_i686_gnu 0.53.1",
1412+ "windows_i686_gnullvm 0.53.1",
1413+ "windows_i686_msvc 0.53.1",
1414+ "windows_x86_64_gnu 0.53.1",
1415+ "windows_x86_64_gnullvm 0.53.1",
1416+ "windows_x86_64_msvc 0.53.1",
14761417]
14771418
14781419[[package]]
@@ -1481,48 +1422,96 @@ version = "0.52.6"
14811422source = "registry+https://github.com/rust-lang/crates.io-index"
14821423checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
14831424
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+
14841431[[package]]
14851432name = "windows_aarch64_msvc"
14861433version = "0.52.6"
14871434source = "registry+https://github.com/rust-lang/crates.io-index"
14881435checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
14891436
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+
14901443[[package]]
14911444name = "windows_i686_gnu"
14921445version = "0.52.6"
14931446source = "registry+https://github.com/rust-lang/crates.io-index"
14941447checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
14951448
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+
14961455[[package]]
14971456name = "windows_i686_gnullvm"
14981457version = "0.52.6"
14991458source = "registry+https://github.com/rust-lang/crates.io-index"
15001459checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
15011460
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+
15021467[[package]]
15031468name = "windows_i686_msvc"
15041469version = "0.52.6"
15051470source = "registry+https://github.com/rust-lang/crates.io-index"
15061471checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
15071472
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+
15081479[[package]]
15091480name = "windows_x86_64_gnu"
15101481version = "0.52.6"
15111482source = "registry+https://github.com/rust-lang/crates.io-index"
15121483checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
15131484
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+
15141491[[package]]
15151492name = "windows_x86_64_gnullvm"
15161493version = "0.52.6"
15171494source = "registry+https://github.com/rust-lang/crates.io-index"
15181495checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
15191496
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+
15201503[[package]]
15211504name = "windows_x86_64_msvc"
15221505version = "0.52.6"
15231506source = "registry+https://github.com/rust-lang/crates.io-index"
15241507checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
15251508
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+
15261515[[package]]
15271516name = "winnow"
15281517version = "0.7.14"
@@ -1534,9 +1523,3 @@ name = "wit-bindgen"
15341523version = "0.51.0"
15351524source = "registry+https://github.com/rust-lang/crates.io-index"
15361525checksum = "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+12−27
@@ -1,20 +1,11 @@
11[package]
22name = "vibebox"
3−version = "0.3.2"
3+version = "0.1.0"
44edition = "2024"
5−authors = ["Finn Sheng"]
6−description = "Ultrafast CLI on Apple Silicon macOS for fast, sandboxed development and LLM agents."
7−homepage = "https://github.com/robcholz/vibebox"
8−repository = "https://github.com/robcholz/vibebox"
9−readme = "README.md"
10−license = "MIT"
11−categories = ["virtualization","command-line-utilities"]
12−keywords = ["virtualization", "ai", "macos", "agent", "sandbox"]
13−rust-version = "1.91.1"
145
156[dependencies]
16−objc2 = "0.6.3"
17−objc2-foundation = { version = "0.3.2", features = [
7+objc2 = "*"
8+objc2-foundation = { version = "*", features = [
189 "NSArray",
1910 "NSString",
2011 "NSURL",
@@ -25,11 +16,11 @@ objc2-foundation = { version = "0.3.2", features = [
2516 "NSRunLoop",
2617 "NSObject",
2718] }
28−objc2-virtualization = "0.3.2"
29−block2 = "0.6.2"
30−dispatch2 = "0.3.0"
31−libc = "0.2.180"
32−clap = { version = "4", features = ["derive"] }
19+objc2-virtualization = "*"
20+block2 = "*"
21+dispatch2 = "*"
22+libc = "*"
23+lexopt = "0.3"
3324serde = { version = "1", features = ["derive"] }
3425tempfile = "3"
3526thiserror = "2.0.18"
@@ -38,16 +29,10 @@ toml = "0.9.8"
3829uuid = { version = "1", features = ["v7", "serde"] }
3930color-eyre = "0.6.3"
4031crossterm = { version = "0.28.1", features = ["event-stream"] }
32+futures = "0.3.31"
4133ratatui = { 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"
4237tracing = "0.1"
4338tracing-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.mddeleted−223
@@ -1,223 +0,0 @@
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.mddeleted−209
@@ -1,209 +0,0 @@
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.pngdeleted

Binary file not shown.

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

Binary file not shown.

docs/tasks.md+14−25
@@ -25,7 +25,7 @@
25255. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar.
26266. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio.
27277. [x] Add a standalone TUI CLI binary (no main.rs wiring) with placeholder VM info and TODOs for VM integration.
28−8. [x] Run tests and validate coverage for the new module.
28+8. [ ] Run tests and validate coverage for the new module.
2929
3030## TUI
3131
@@ -39,33 +39,22 @@
39392. [x] Use ssh to connect to vm.
40403. [x] allow multi vibebox to connect to the same vm.
41414. [x] use vm.lock to ensure process concurrency safety.
42−5. [x] wire up SessionManager.
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. [x] setup vibebox commands
45−8. [x] setup cli commands.
46− 1. [x] Organize all the params.
47− 2. [x] Remove old cli.
48− 3. [x] add an actual config file.
49− 4. [x] set up the cli.
50−9. [x] fix ui overlap, and consistency issue.
51−10. [x] `purge-cache` to clear the cache.
52−11. [x] intensive integration test.
42+5. [ ] 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).
44+7. [ ] setup vibebox commands
45+8. [ ] setup cli commands.
46+9. [ ] fix ui overlap.
5347
5448## Publish
5549
56−1. [x] write the docs.
57−2. [x] setup quick install link.
58−3. [x] setup website.
50+1. [ ] write the docs
51+2. [ ] setup quick link.
52+3. [ ] setup website.
5953
6054## Stage 2
6155
62−1. [ ] retouch the cli ux.
63−2. [x] refactor the code.
64−3. [ ] refactor the mount system.
65−4. [x] refactor the vm process lifetime.
66−5. [x] Redirect vm output to log.
67−6. [x] Redirect vm output to vibebox starting it.
68−7. [x] use anyhow to sync api.
69−8. [ ] add support for ipv6.
70−9. [x] use UnixStream instead of status file
71−10. [x] liveness check should also happen when waiting for ssh port
56+1. [ ] Redirect vm output to log.
57+2. [ ] Redirect vm output to vibebox starting it.
58+3. [ ] use anyhow to sync api.
59+
60+[ ]
install → install.shrenamed+61−54
@@ -2,7 +2,7 @@
22set -euo pipefail
33
44APP=vibebox
5−REPO_URL="https://github.com/robcholz/vibebox"
5+REPO_URL="https://github.com/opencode-ai/vibebox"
66REQUESTED_VERSION=${VERSION:-}
77
88RED='\033[0;31m'
@@ -38,58 +38,9 @@ require_cmd() {
3838 fi
3939}
4040
41+require_cmd cargo "Install Rust (cargo) from https://rustup.rs and retry."
4142require_cmd git "Install git and retry."
4243
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−
9344CARGO_HOME=${CARGO_HOME:-$HOME/.cargo}
9445INSTALL_DIR="$CARGO_HOME/bin"
9546
@@ -117,10 +68,66 @@ print_message info "Using cargo install from ${ORANGE}${REPO_URL}${GREEN}..."
11768
11869cargo "${install_args[@]}"
11970
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+
120122if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
121− print_message warning "Cargo bin directory is not on PATH."
122− print_message info "Add this to your shell profile if you can't run ${APP}:"
123− print_message info " export PATH=$INSTALL_DIR:\$PATH"
123+ case $current_shell in
124+ fish)
125+ add_to_path "$config_file" "fish_add_path $INSTALL_DIR"
126+ ;;
127+ *)
128+ add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
129+ ;;
130+ esac
124131fi
125132
126133if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
scripts/commit-check.shdeleted−6
@@ -1,6 +0,0 @@
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.rsnew file+106
@@ -0,0 +1,106 @@
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+17−45
@@ -5,41 +5,24 @@ use std::{
55
66use color_eyre::Result;
77use tracing_subscriber::EnvFilter;
8−use tracing_subscriber::filter::LevelFilter;
9−use tracing_subscriber::fmt;
10−use tracing_subscriber::prelude::*;
118
12−use vibebox::{config, instance, vm, vm_manager};
9+use vibebox::{vm, vm_manager};
1310
14−fn 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− }
11+const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 3000;
1912
13+fn main() -> Result<()> {
2014 init_tracing();
2115 color_eyre::install()?;
2216
2317 tracing::info!("starting vm supervisor");
24− let project_dir = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
25− let config = config::load_config(&project_dir)
26− .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
27− let _ = instance::ensure_instance_dir(&project_dir)
28− .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
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;
18+ let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
19+ let auto_shutdown_ms = env::var("VIBEBOX_AUTO_SHUTDOWN_MS")
20+ .ok()
21+ .and_then(|value| value.parse::<u64>().ok())
22+ .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS);
3823 tracing::info!(auto_shutdown_ms, "vm supervisor config");
3924
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 {
25+ if let Err(err) = vm_manager::run_manager(args, auto_shutdown_ms) {
4326 tracing::error!(error = %err, "vm supervisor exited");
4427 return Err(color_eyre::eyre::eyre!(err.to_string()));
4528 }
@@ -49,23 +32,12 @@ fn main() -> Result<()> {
4932}
5033
5134fn init_tracing() {
52− let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
53− let stderr_is_tty = std::io::stderr().is_terminal();
54− let ansi = stderr_is_tty && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
55− if stderr_is_tty {
56− let stderr_layer = fmt::layer()
57− .with_target(false)
58− .with_ansi(ansi)
59− .without_time()
60− .with_writer(io::stderr)
61− .with_filter(LevelFilter::INFO);
62− let _ = tracing_subscriber::registry().with(stderr_layer).try_init();
63− } else {
64− let stderr_layer = fmt::layer()
65− .with_target(false)
66− .with_ansi(ansi)
67− .with_writer(io::stderr)
68− .with_filter(filter);
69− let _ = tracing_subscriber::registry().with(stderr_layer).try_init();
70− }
35+ let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
36+ let ansi = std::io::stderr().is_terminal() && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
37+ let _ = tracing_subscriber::fmt()
38+ .with_env_filter(filter)
39+ .with_target(false)
40+ .with_ansi(ansi)
41+ .with_writer(io::stderr)
42+ .try_init();
7143}
src/bin/vibebox.rsdeleted−400
@@ -1,400 +0,0 @@
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.rsdeleted−111
@@ -1,111 +0,0 @@
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.rsdeleted−679
@@ -1,679 +0,0 @@
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.shdeleted−28
@@ -1,28 +0,0 @@
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.rsdeleted−156
@@ -1,156 +0,0 @@
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+331−341
@@ -1,149 +1,83 @@
1−use anyhow::{Context, Result, bail};
2−use serde::{Deserialize, Serialize};
3−use std::os::unix::fs::FileTypeExt;
41use std::{
52 env, fs,
6− io::{self, IsTerminal, Read},
3+ io::{self, Write},
74 net::{SocketAddr, TcpStream},
8− os::unix::{fs::PermissionsExt, net::UnixStream},
5+ os::unix::{fs::PermissionsExt, io::OwnedFd, net::UnixStream},
96 path::{Path, PathBuf},
10− process::{Child, Command, Stdio},
7+ process::{Command, Stdio},
118 sync::{
129 Arc, Mutex,
1310 atomic::{AtomicBool, Ordering},
14− mpsc,
1511 },
1612 thread,
1713 time::{Duration, Instant},
1814};
19−use time::{OffsetDateTime, format_description::well_known::Rfc3339};
15+
16+use serde::{Deserialize, Serialize};
17+use std::sync::mpsc::Sender;
2018use uuid::Uuid;
2119
2220use crate::{
23− commands,
2421 session_manager::INSTANCE_DIR_NAME,
25− session_manager::{VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME},
26− vm::{self, LoginAction},
22+ tui::{self, AppState},
23+ vm::{self, LoginAction, VmInput},
2724};
2825
29−pub const STATUS_VM_ERROR_PREFIX: &str = "error:";
30−
26+const INSTANCE_TOML: &str = "instance.toml";
3127const SSH_KEY_NAME: &str = "ssh_key";
32−const INSTANCE_FILENAME: &str = "instance.toml";
28+#[allow(dead_code)]
29+const SERIAL_LOG_NAME: &str = "serial.log";
3330const DEFAULT_SSH_USER: &str = "vibecoder";
34−const SSH_CONNECT_RETRIES: usize = 10;
31+const SSH_CONNECT_RETRIES: usize = 30;
3532const SSH_CONNECT_DELAY_MS: u64 = 500;
3633const 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−}
9234
9335#[derive(Debug, Clone, Serialize, Deserialize)]
94−pub struct InstanceConfig {
95− #[serde(default)]
96− pub id: String,
36+pub(crate) struct InstanceConfig {
9737 #[serde(default = "default_ssh_user")]
98− pub ssh_user: String,
38+ ssh_user: String,
9939 #[serde(default)]
10040 sudo_password: String,
10141 #[serde(default)]
102− pub last_active: Option<String>,
103− #[serde(default)]
104− pub vm_ipv4: Option<String>,
42+ pub(crate) vm_ipv4: Option<String>,
10543}
10644
107−impl Default for InstanceConfig {
108− fn default() -> Self {
109− Self {
110− id: Uuid::now_v7().to_string(),
111− ssh_user: DEFAULT_SSH_USER.to_string(),
112− sudo_password: Uuid::now_v7().simple().to_string(),
113− last_active: None,
114− vm_ipv4: None,
115− }
116− }
45+fn default_ssh_user() -> String {
46+ DEFAULT_SSH_USER.to_string()
11747}
11848
119−pub fn run_with_ssh(manager_conn: UnixStream) -> Result<()> {
49+pub fn run_with_ssh(manager_conn: UnixStream) -> Result<(), Box<dyn std::error::Error>> {
12050 let project_root = env::current_dir()?;
12151 tracing::info!(root = %project_root.display(), "starting ssh session");
12252 let instance_dir = ensure_instance_dir(&project_root)?;
12353 tracing::debug!(instance_dir = %instance_dir.display(), "instance dir ready");
12454 let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?;
12555
126− let config = load_or_create_instance_config(&project_root)?;
56+ let config = load_or_create_instance_config(&instance_dir)?;
12757 let ssh_user = config.ssh_user.clone();
12858 tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
12959
130− wait_for_vm_ipv4(&project_root, Duration::from_secs(480), &manager_conn)?;
60+ let _manager_conn = manager_conn;
61+ tracing::debug!("waiting for vm ipv4");
62+ wait_for_vm_ipv4(&instance_dir, Duration::from_secs(120))?;
13163
132− let ip = load_or_create_instance_config(&project_root)?
64+ let ip = load_or_create_instance_config(&instance_dir)?
13365 .vm_ipv4
134− .with_context(|| "failed to load instance IP address")?;
66+ .ok_or("VM IPv4 not available")?;
13567 tracing::info!(ip = %ip, "vm ipv4 ready");
13668
137− run_ssh_session(ssh_key, ssh_user, ip, manager_conn, project_root)
69+ run_ssh_session(ssh_key, ssh_user, ip)
13870}
13971
140−pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
72+pub(crate) fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
14173 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
14274 fs::create_dir_all(&instance_dir)?;
14375 Ok(instance_dir)
14476}
14577
146−pub fn ensure_ssh_keypair(instance_dir: &Path) -> Result<(PathBuf, PathBuf)> {
78+pub(crate) fn ensure_ssh_keypair(
79+ instance_dir: &Path,
80+) -> Result<(PathBuf, PathBuf), Box<dyn std::error::Error>> {
14781 let private_key = instance_dir.join(SSH_KEY_NAME);
14882 let public_key = instance_dir.join(format!("{SSH_KEY_NAME}.pub"));
14983
@@ -165,19 +99,17 @@ pub fn ensure_ssh_keypair(instance_dir: &Path) -> Result<(PathBuf, PathBuf)> {
16599 "-N",
166100 "",
167101 "-f",
168− private_key
169− .to_str()
170− .with_context(|| "ssh key path not utf-8")?,
102+ private_key.to_str().ok_or("ssh key path not utf-8")?,
171103 "-C",
172104 "vibebox",
173105 ])
174106 .stdin(Stdio::null())
175− .stdout(Stdio::inherit())
107+ .stdout(Stdio::null())
176108 .stderr(Stdio::inherit())
177109 .status()?;
178110
179111 if !status.success() {
180− bail!("ssh-keygen failed");
112+ return Err("ssh-keygen failed".into());
181113 }
182114
183115 fs::set_permissions(&private_key, fs::Permissions::from_mode(0o600))?;
@@ -186,66 +118,54 @@ pub fn ensure_ssh_keypair(instance_dir: &Path) -> Result<(PathBuf, PathBuf)> {
186118 Ok((private_key, public_key))
187119}
188120
189−pub fn load_or_create_instance_config(project_dir: &Path) -> Result<InstanceConfig> {
190− let mut exist = true;
191− let mut config = read_instance_config(project_dir).unwrap_or_else(|_| {
192− exist = false;
193− InstanceConfig::default()
194− });
121+pub(crate) fn load_or_create_instance_config(
122+ instance_dir: &Path,
123+) -> Result<InstanceConfig, Box<dyn std::error::Error>> {
124+ let config_path = instance_dir.join(INSTANCE_TOML);
125+ let mut config = if config_path.exists() {
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+ };
195135
196136 let mut changed = false;
197137 if config.ssh_user.trim().is_empty() {
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;
138+ config.ssh_user = default_ssh_user();
204139 changed = true;
205140 }
206141
207142 if config.sudo_password.trim().is_empty() {
208− config.sudo_password = InstanceConfig::default().sudo_password;
143+ config.sudo_password = generate_password();
209144 changed = true;
210145 }
211146
212− if !exist || changed {
213− write_instance_config(project_dir, &config)?;
147+ if !config_path.exists() || changed {
148+ write_instance_config(&config_path, &config)?;
214149 }
215150
216151 Ok(config)
217152}
218153
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);
234− write_instance_config(project_dir, &config)?;
154+pub(crate) fn write_instance_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))?;
235161 Ok(())
236162}
237163
238−pub fn write_instance_config(project_dir: &Path, config: &InstanceConfig) -> Result<()> {
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(())
164+fn generate_password() -> String {
165+ Uuid::now_v7().simple().to_string()
245166}
246167
247−#[cfg_attr(feature = "mock-vm", allow(dead_code))]
248−pub fn extract_ipv4(line: &str) -> Option<String> {
168+pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
249169 let mut current = String::new();
250170 let mut best: Option<String> = None;
251171
@@ -264,79 +184,23 @@ pub fn extract_ipv4(line: &str) -> Option<String> {
264184 best
265185}
266186
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−
285187fn wait_for_vm_ipv4(
286− project_dir: &Path,
188+ instance_dir: &Path,
287189 timeout: Duration,
288− manager_conn: &UnixStream,
289−) -> Result<()> {
190+) -> Result<(), Box<dyn std::error::Error>> {
290191 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;
299192 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−
320− let config = load_or_create_instance_config(project_dir)?;
193+ let config = load_or_create_instance_config(instance_dir)?;
321194 if config.vm_ipv4.is_some() {
322195 return Ok(());
323196 }
324197 if start.elapsed() > timeout {
325− bail!("timed out waiting for VM IPv4");
198+ return Err("Timed out waiting for VM IPv4".into());
326199 }
327−
328− let now = Instant::now();
329− if now >= next_log_at {
330− let waited = start.elapsed();
331− if waited.as_secs() > 15 && !once_hint {
332− tracing::info!(
333− "if vibebox is just initialized in this directory, it might take up to 1 minute depending on your machine, and then you can enjoy secure & speed vibecoding! go pack!"
334− );
335− once_hint = true;
336− }
337− tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
338− next_log_at += Duration::from_secs(20);
200+ if start.elapsed().as_secs() % 5 == 0 {
201+ tracing::debug!("still waiting for vm ipv4");
339202 }
203+ thread::sleep(Duration::from_millis(200));
340204 }
341205}
342206
@@ -344,43 +208,32 @@ fn run_ssh_session(
344208 ssh_key: PathBuf,
345209 ssh_user: String,
346210 ip: String,
347− manager_conn: UnixStream,
348− project_root: PathBuf,
349−) -> Result<()> {
211+) -> Result<(), Box<dyn std::error::Error>> {
350212 let mut attempts = 0usize;
351213 loop {
352− if matches!(vm_liveness(&project_root)?, VmLiveness::NotRunningOrMissing) {
353− return Err(InstanceError::UnexpectedDisconnection.into());
354− }
355214 attempts += 1;
356215 if !ssh_port_open(&ip) {
357− tracing::debug!(attempts, "ssh port doesn't open yet");
358− tracing::info!(
359− attempts,
360− ip = %ip,
361− "waiting for ssh port ({}/{})",
362− attempts,
363− SSH_CONNECT_RETRIES
216+ tracing::debug!(attempts, "ssh port not open yet");
217+ eprintln!(
218+ "[vibebox] waiting for ssh port on {ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
364219 );
365220 if attempts >= SSH_CONNECT_RETRIES {
366− bail!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts");
221+ return Err(
222+ format!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts").into(),
223+ );
367224 }
368225 thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS));
369226 continue;
370227 }
371228
372− tracing::info!(
373− attempts,
374− user = %ssh_user,
375− ip = %ip,
376− "starting ssh ({}/{})",
377− attempts,
378− SSH_CONNECT_RETRIES
229+ eprintln!(
230+ "[vibebox] starting ssh to {ssh_user}@{ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
379231 );
380− let child = Command::new("ssh")
232+ tracing::info!(attempts, user = %ssh_user, ip = %ip, "starting ssh");
233+ let status = Command::new("ssh")
381234 .args([
382235 "-i",
383− ssh_key.to_str().with_context(|| "invalid path")?,
236+ ssh_key.to_str().unwrap_or(".vibebox/ssh_key"),
384237 "-o",
385238 "IdentitiesOnly=yes",
386239 "-o",
@@ -405,67 +258,25 @@ fn run_ssh_session(
405258 .stdin(Stdio::inherit())
406259 .stdout(Stdio::inherit())
407260 .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);
446− break status;
447− }
448− thread::sleep(Duration::from_millis(100));
449− };
261+ .status();
450262
451− if status.success() {
452− tracing::info!(status = %status, "ssh exited");
453− break;
454− }
455− if status.code() == Some(255) {
456− tracing::warn!(status = %status, "ssh connection failed");
457− if attempts >= SSH_CONNECT_RETRIES {
458− bail!("ssh failed after {SSH_CONNECT_RETRIES} attempts");
459− }
460− thread::sleep(Duration::from_millis(500));
461− continue;
462− }
463− tracing::info!(status = %status, "ssh exited");
263+ match status {
264+ Ok(status) if status.success() => {
265+ eprintln!("[vibebox] ssh exited with status: {status}");
464266 break;
465267 }
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+ }
273+ thread::sleep(Duration::from_millis(500));
274+ }
275+ Ok(status) => {
276+ return Err(format!("ssh exited with status: {status}").into());
277+ }
466278 Err(err) => {
467− tracing::error!(error = %err, "failed to start ssh");
468− bail!("failed to start ssh: {err}");
279+ return Err(format!("failed to start ssh: {err}").into());
469280 }
470281 }
471282 }
@@ -473,31 +284,6 @@ fn run_ssh_session(
473284 Ok(())
474285}
475286
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))]
501287fn is_ipv4_candidate(candidate: &str) -> bool {
502288 let parts: Vec<&str> = candidate.split('.').collect();
503289 if parts.len() != 4 {
@@ -519,16 +305,14 @@ fn ssh_port_open(ip: &str) -> bool {
519305 Ok(addr) => addr,
520306 Err(_) => return false,
521307 };
522− TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok()
308+ TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).is_ok()
523309}
524310
525−pub fn build_ssh_login_actions(
311+pub(crate) fn build_ssh_login_actions(
526312 config: &Arc<Mutex<InstanceConfig>>,
527313 project_name: &str,
528− project_guest_dir: &str,
529314 guest_dir: &str,
530315 key_name: &str,
531− home_links_script: &str,
532316) -> Vec<LoginAction> {
533317 let config_guard = config.lock().expect("config mutex poisoned");
534318 let ssh_user = config_guard.ssh_user.clone();
@@ -537,44 +321,250 @@ pub fn build_ssh_login_actions(
537321
538322 let key_path = format!("{guest_dir}/{key_name}.pub");
539323
540− let ssh_script = SSH_SETUP_SCRIPT
324+ let setup_script = SSH_SETUP_SCRIPT
541325 .replace("__SSH_USER__", &ssh_user)
542326 .replace("__SUDO_PASSWORD__", &sudo_password)
543327 .replace("__PROJECT_NAME__", project_name)
544− .replace("__PROJECT_GUEST_DIR__", project_guest_dir)
545− .replace("__KEY_PATH__", &key_path)
546− .replace("__VIBEBOX_SHELL_SCRIPT__", &commands::render_shell_script())
547− .replace("__VIBEBOX_HOME_LINKS__", home_links_script);
548− let setup = vm::script_command_from_content("ssh.sh", &ssh_script)
328+ .replace("__KEY_PATH__", &key_path);
329+ let setup = vm::script_command_from_content("ssh_setup", &setup_script)
549330 .expect("ssh setup script contained invalid marker");
550331
551332 vec![LoginAction::Send(setup)]
552333}
553334
554−#[cfg(test)]
555−mod tests {
556− use super::*;
335+#[allow(dead_code)]
336+fn spawn_ssh_io(
337+ app: Arc<Mutex<AppState>>,
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+ };
557380
558− #[test]
559− fn handle_manager_line_updates_status() {
560− let mut last_status = None;
561− handle_manager_line("status: preparing VM image...", &mut last_status)
562− .expect("status should be accepted");
563− assert_eq!(last_status.as_deref(), Some("preparing VM image..."));
564− }
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+ }
565389
566− #[test]
567− fn handle_manager_line_ignores_non_status_lines() {
568− let mut last_status = None;
569− handle_manager_line("pid=123", &mut last_status).expect("non-status lines are ignored");
570− assert!(last_status.is_none());
571− }
390+ let text = String::from_utf8_lossy(bytes);
391+ line_buf.push_str(&text);
572392
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− }
393+ while let Some(pos) = line_buf.find('\n') {
394+ let mut line = line_buf[..pos].to_string();
395+ if line.ends_with('\r') {
396+ line.pop();
397+ }
398+ line_buf.drain(..=pos);
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+
565+ thread::sleep(std::time::Duration::from_millis(200));
566+ }
567+ });
568+
569+ io_ctx
580570}
src/lib.rs−4
@@ -1,5 +1,3 @@
1−pub mod commands;
2−pub mod explain;
31pub mod instance;
42pub mod session_manager;
53pub mod tui;
@@ -7,5 +5,3 @@ pub mod vm;
75pub mod vm_manager;
86
97pub use session_manager::{SessionError, SessionManager, SessionRecord};
10−pub mod config;
11−pub mod utils;
src/provision.sh+5−55
@@ -1,55 +1,12 @@
11#!/bin/bash
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−}
2+set -eux
453
464# Don't wait too long for slow mirrors.
47−echo 'Acquire::http::Timeout "10";' | tee /etc/apt/apt.conf.d/99timeout
48−echo 'Acquire::https::Timeout "10";' | tee -a /etc/apt/apt.conf.d/99timeout
49−echo 'Acquire::Retries "5";' | tee -a /etc/apt/apt.conf.d/99timeout
5+echo 'Acquire::http::Timeout "2";' | tee /etc/apt/apt.conf.d/99timeout
6+echo 'Acquire::https::Timeout "2";' | tee -a /etc/apt/apt.conf.d/99timeout
7+echo 'Acquire::Retries "2";' | tee -a /etc/apt/apt.conf.d/99timeout
508
51−wait_for_network
52−apt_update_with_retries
9+apt-get update
5310apt-get install -y --no-install-recommends \
5411 build-essential \
5512 pkg-config \
@@ -57,7 +14,6 @@ apt-get install -y --no-install-recommends \
5714 curl \
5815 git \
5916 ripgrep \
60− cloud-guest-utils \
6117 openssh-server \
6218 sudo
6319
@@ -86,11 +42,6 @@ systemctl restart ssh
8642# Set this env var so claude doesn't complain about running as root.'
8743echo "export IS_SANDBOX=1" >> .bashrc
8844
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−
9445# Shutdown the VM when you logout
9546cat > .bash_logout <<EOF
9647systemctl poweroff
@@ -98,5 +49,4 @@ sleep 100 # sleep here so that we don't see the login screen flash up before the
9849EOF
9950
10051# Done provisioning, power off the VM
101−printf "%s%s\n" VIBEBOX_PROVISION_ OK
10252systemctl poweroff
src/resize_disk.shdeleted−55
@@ -1,55 +0,0 @@
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+326−272
@@ -1,46 +1,39 @@
1−use anyhow::Result;
21use std::{
32 env, fs,
43 io::{self, Write},
54 path::{Path, PathBuf},
65};
76
8−use crate::config::config_path;
9−use crate::instance::{VmLiveness, read_instance_config, vm_liveness};
107use serde::{Deserialize, Serialize};
8+use time::OffsetDateTime;
9+use uuid::Uuid;
1110
1211pub const INSTANCE_DIR_NAME: &str = ".vibebox";
13−pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
1412pub const GLOBAL_DIR_NAME: &str = ".vibebox";
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";
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";
1916
20−#[derive(Debug, Clone, PartialEq, Eq)]
17+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2118pub struct SessionRecord {
19+ pub id: Uuid,
2220 pub directory: PathBuf,
23− pub id: String,
24− pub last_active: Option<String>,
25− pub active: bool,
21+ #[serde(with = "time::serde::rfc3339")]
22+ pub last_active: OffsetDateTime,
23+ #[serde(default)]
24+ pub ref_count: u64,
2625}
2726
28−#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29−struct SessionEntry {
30− pub directory: PathBuf,
31− pub id: String,
27+#[derive(Debug, Default, Serialize, Deserialize)]
28+struct SessionIndex {
29+ #[serde(default)]
30+ sessions: Vec<SessionRecord>,
3231}
3332
3433#[derive(Debug)]
3534pub struct SessionManager {
36− sessions_dir: PathBuf,
37−}
38−
39−#[derive(Debug, Clone, PartialEq, Eq)]
40−pub struct CleanSummary {
41− pub instance_dir: PathBuf,
42− pub removed_instance_dir: bool,
43− pub removed_sessions: usize,
35+ global_dir: PathBuf,
36+ index_path: PathBuf,
4437}
4538
4639#[derive(Debug, thiserror::Error)]
@@ -51,8 +44,14 @@ pub enum SessionError {
5144 NonAbsoluteDirectory(PathBuf),
5245 #[error("Session directory does not exist: {0}")]
5346 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),
5453 #[error(transparent)]
55− Io(#[from] io::Error),
54+ Io(#[from] std::io::Error),
5655 #[error(transparent)]
5756 TomlDe(#[from] toml::de::Error),
5857 #[error(transparent)]
@@ -68,226 +67,201 @@ impl SessionManager {
6867 }
6968
7069 pub fn with_global_dir(global_dir: PathBuf) -> Self {
71− let sessions_dir = global_dir.join(SESSIONS_DIR_NAME);
72− Self { sessions_dir }
70+ let index_path = global_dir.join(SESSION_INDEX_FILENAME);
71+ Self {
72+ global_dir,
73+ index_path,
74+ }
7375 }
7476
7577 pub fn index_path(&self) -> &Path {
76− &self.sessions_dir
78+ &self.index_path
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)
77102 }
78103
79− pub fn update_global_sessions(&self, directory: &Path) -> Result<Vec<PathBuf>, SessionError> {
104+ pub fn get_or_create_session(&self, directory: &Path) -> Result<SessionRecord, SessionError> {
80105 let directory = self.normalize_directory(directory)?;
81− fs::create_dir_all(&self.sessions_dir)?;
82−
83− let (mut sessions, removed) = self.prune_stale_sessions()?;
84− let has_config = is_vibebox_dir(&directory);
85− let mut added = false;
86−
87− if has_config {
88− let id = read_instance_config(&directory).map_or(None, |config| Some(config.id));
89− if let Some(id) = id {
90− let record = SessionEntry {
91− directory: directory.clone(),
92− id: id.clone(),
93− };
94− self.write_session_record(&record)?;
95− if let Some(existing) = sessions.iter_mut().find(|s| s.id == id) {
96− *existing = record;
97− } else {
98− sessions.push(record);
99− }
100− added = true;
106+ let mut index = self.read_index()?;
107+
108+ if let Some(pos) = index.sessions.iter().position(|s| s.directory == directory) {
109+ if !self
110+ .instance_dir_for(&index.sessions[pos].directory)
111+ .is_dir()
112+ {
113+ index.sessions.remove(pos);
101114 } else {
102− tracing::warn!(
103− directory = %directory.display(),
104− "missing session id in instance file"
105− );
115+ let now = OffsetDateTime::now_utc();
116+ let session = &mut index.sessions[pos];
117+ session.ref_count = session.ref_count.saturating_add(1);
118+ session.last_active = now;
119+ let updated = session.clone();
120+ self.write_index(&index)?;
121+ return Ok(updated);
106122 }
107123 }
108124
109− if removed > 0 || added {
110− tracing::debug!(
111− path = %self.sessions_dir.display(),
112− removed,
113− added,
114− entries = sessions.len(),
115− "updated global sessions"
116− );
117− } else {
118− tracing::debug!(
119− path = %self.sessions_dir.display(),
120− entries = sessions.len(),
121− has_config,
122− "global sessions unchanged"
123− );
124− }
125+ let now = OffsetDateTime::now_utc();
126+ let session = SessionRecord {
127+ id: Uuid::now_v7(),
128+ directory: directory.clone(),
129+ last_active: now,
130+ ref_count: 1,
131+ };
132+
133+ fs::create_dir_all(self.instance_dir_for(&directory))?;
134+ index.sessions.push(session.clone());
135+ self.write_index(&index)?;
125136
126− Ok(sessions.into_iter().map(|s| s.directory).collect())
137+ Ok(session)
127138 }
128139
129140 pub fn list_sessions(&self) -> Result<Vec<SessionRecord>, SessionError> {
130− let (sessions, removed) = self.prune_stale_sessions()?;
141+ let mut index = self.read_index()?;
142+ let removed = self.remove_orphans(&mut index);
131143 if removed > 0 {
132− tracing::info!(
133− path = %self.sessions_dir.display(),
134− removed,
135− entries = sessions.len(),
136− "pruned stale sessions"
137− );
144+ self.write_index(&index)?;
138145 }
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)
146+ Ok(index.sessions)
152147 }
153148
154− pub fn clean_project(&self, directory: &Path) -> Result<CleanSummary, SessionError> {
155− let directory = self.normalize_directory(directory)?;
156− let instance_dir = directory.join(INSTANCE_DIR_NAME);
157− let mut removed_instance_dir = false;
158− if instance_dir.exists() {
159− fs::remove_dir_all(&instance_dir)?;
160− removed_instance_dir = true;
149+ pub fn delete_session(&self, id: Uuid) -> Result<bool, SessionError> {
150+ let mut index = self.read_index()?;
151+ let Some(pos) = index.sessions.iter().position(|s| s.id == id) else {
152+ return Ok(false);
153+ };
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) {
158+ Ok(_) => {}
159+ Err(err) if err.kind() == io::ErrorKind::NotFound => {}
160+ Err(err) => return Err(err.into()),
161161 }
162− let removed_sessions = self.remove_session_records_for_directory(&directory)?;
163− Ok(CleanSummary {
164− instance_dir,
165− removed_instance_dir,
166− removed_sessions,
167− })
162+
163+ self.write_index(&index)?;
164+ Ok(true)
168165 }
169166
170− fn normalize_directory(&self, directory: &Path) -> Result<PathBuf, SessionError> {
171− if !directory.is_absolute() {
172− return Err(SessionError::NonAbsoluteDirectory(directory.to_path_buf()));
173− }
174− if !directory.exists() {
175− return Err(SessionError::MissingDirectory(directory.to_path_buf()));
176− }
177− Ok(directory.canonicalize()?)
167+ pub fn bump_last_active(&self, id: Uuid) -> Result<SessionRecord, SessionError> {
168+ let now = OffsetDateTime::now_utc();
169+ self.update_session(id, |session| {
170+ session.last_active = now;
171+ Ok(())
172+ })
178173 }
179174
180− fn session_path_for(&self, id: &str) -> PathBuf {
181− let filename = format!("{id}{SESSION_TOML_SUFFIX}");
182− self.sessions_dir.join(filename)
175+ pub fn increment_ref_count(&self, id: Uuid) -> Result<SessionRecord, SessionError> {
176+ let now = OffsetDateTime::now_utc();
177+ self.update_session(id, |session| {
178+ session.ref_count = session.ref_count.saturating_add(1);
179+ session.last_active = now;
180+ Ok(())
181+ })
183182 }
184183
185− fn write_session_record(&self, record: &SessionEntry) -> Result<(), SessionError> {
186− fs::create_dir_all(&self.sessions_dir)?;
187− let path = self.session_path_for(&record.id);
188− let content = toml::to_string_pretty(record)?;
189− atomic_write(&path, content.as_bytes())?;
190− tracing::debug!(
191− path = %path.display(),
192− "wrote session record"
193− );
194− Ok(())
184+ pub fn decrement_ref_count(&self, id: Uuid) -> Result<SessionRecord, SessionError> {
185+ let now = OffsetDateTime::now_utc();
186+ self.update_session(id, |session| {
187+ if session.ref_count == 0 {
188+ return Err(SessionError::RefCountUnderflow(id));
189+ }
190+ session.ref_count -= 1;
191+ session.last_active = now;
192+ Ok(())
193+ })
195194 }
196195
197− fn prune_stale_sessions(&self) -> Result<(Vec<SessionEntry>, usize), SessionError> {
198− if !self.sessions_dir.exists() {
199− return Ok((Vec::new(), 0));
196+ pub fn cleanup_orphans(&self) -> Result<usize, SessionError> {
197+ let mut index = self.read_index()?;
198+ let removed = self.remove_orphans(&mut index);
199+ if removed > 0 {
200+ self.write_index(&index)?;
200201 }
202+ Ok(removed)
203+ }
201204
202− let mut sessions = Vec::new();
203− let mut removed = 0usize;
204−
205− for entry in fs::read_dir(&self.sessions_dir)? {
206− let entry = entry?;
207− let path = entry.path();
208− if !path.is_file() {
209− continue;
210− }
211− let record = read_session_file(&path)?;
212− if !is_vibebox_dir(&record.directory) {
213− let _ = fs::remove_file(&path);
214− removed += 1;
215− continue;
216− }
217− if let Some(stem) = path.file_stem().and_then(|s| s.to_str())
218− && stem != record.id
219− {
220− let _ = fs::remove_file(&path);
221− removed += 1;
222− continue;
223− }
224− sessions.push(record);
205+ fn update_session<F>(&self, id: Uuid, mut update: F) -> Result<SessionRecord, SessionError>
206+ where
207+ F: FnMut(&mut SessionRecord) -> Result<(), SessionError>,
208+ {
209+ let mut index = self.read_index()?;
210+ let Some(pos) = index.sessions.iter().position(|s| s.id == id) else {
211+ return Err(SessionError::SessionNotFound(id));
212+ };
213+
214+ if !self
215+ .instance_dir_for(&index.sessions[pos].directory)
216+ .is_dir()
217+ {
218+ index.sessions.remove(pos);
219+ self.write_index(&index)?;
220+ return Err(SessionError::SessionNotFound(id));
225221 }
226222
227− Ok((sessions, removed))
223+ update(&mut index.sessions[pos])?;
224+ let updated = index.sessions[pos].clone();
225+ self.write_index(&index)?;
226+ Ok(updated)
228227 }
229228
230− fn remove_session_records_for_directory(
231− &self,
232− directory: &Path,
233− ) -> Result<usize, SessionError> {
234− if !self.sessions_dir.exists() {
235− return Ok(0);
236− }
237− let mut removed = 0usize;
238− for entry in fs::read_dir(&self.sessions_dir)? {
239− let entry = entry?;
240− let path = entry.path();
241− if !path.is_file() {
242− continue;
243− }
244− let record = read_session_file(&path)?;
245− if record.directory == directory {
246− fs::remove_file(&path)?;
247− removed += 1;
248− }
229+ fn normalize_directory(&self, directory: &Path) -> Result<PathBuf, SessionError> {
230+ if !directory.is_absolute() {
231+ return Err(SessionError::NonAbsoluteDirectory(directory.to_path_buf()));
249232 }
250− if removed > 0 {
251− tracing::info!(
252− directory = %directory.display(),
253− removed,
254− "removed session records"
255− );
233+ if !directory.exists() {
234+ return Err(SessionError::MissingDirectory(directory.to_path_buf()));
256235 }
257− Ok(removed)
236+ Ok(directory.canonicalize()?)
258237 }
259−}
260238
261−fn is_vibebox_dir(directory: &Path) -> bool {
262− if !directory.is_absolute() {
263− return false;
239+ fn instance_dir_for(&self, directory: &Path) -> PathBuf {
240+ directory.join(INSTANCE_DIR_NAME)
264241 }
265− config_path(directory).is_file()
266−}
267242
268−fn is_session_active(directory: &Path) -> bool {
269− let instance_dir = directory.join(INSTANCE_DIR_NAME);
270− let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
271− match vm_liveness(directory) {
272− Ok(liveness) => match liveness {
273− VmLiveness::RunningWithSocket { .. } => true,
274− VmLiveness::RunningWithoutSocket { .. } => true,
275− VmLiveness::NotRunningOrMissing => {
276− let _ = fs::remove_file(&pid_path);
277− false
278− }
279− },
280− Err(_) => false,
243+ fn remove_orphans(&self, index: &mut SessionIndex) -> usize {
244+ let before = index.sessions.len();
245+ index
246+ .sessions
247+ .retain(|s| self.instance_dir_for(&s.directory).is_dir());
248+ before - index.sessions.len()
281249 }
282−}
283250
284−fn read_session_file(path: &Path) -> Result<SessionEntry, SessionError> {
285− let raw = fs::read_to_string(path)?;
286− let record: SessionEntry = toml::from_str(&raw)?;
287− if record.id.trim().is_empty() {
288− return Err(io::Error::new(io::ErrorKind::InvalidData, "session id missing").into());
251+ fn read_index(&self) -> Result<SessionIndex, SessionError> {
252+ if !self.index_path.exists() {
253+ return Ok(SessionIndex::default());
254+ }
255+ let content = fs::read_to_string(&self.index_path)?;
256+ Ok(toml::from_str(&content)?)
257+ }
258+
259+ fn write_index(&self, index: &SessionIndex) -> Result<(), SessionError> {
260+ fs::create_dir_all(&self.global_dir)?;
261+ let content = toml::to_string_pretty(index)?;
262+ atomic_write(&self.index_path, content.as_bytes())?;
263+ Ok(())
289264 }
290− Ok(record)
291265}
292266
293267fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
@@ -300,7 +274,7 @@ fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
300274
301275 fs::create_dir_all(parent)?;
302276 let mut temp = tempfile::Builder::new()
303− .prefix(SESSIONS_DIR_NAME)
277+ .prefix(SESSION_TEMP_PREFIX)
304278 .suffix(SESSION_TOML_SUFFIX)
305279 .tempfile_in(parent)?;
306280 temp.write_all(content)?;
@@ -312,9 +286,9 @@ fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
312286#[cfg(test)]
313287mod tests {
314288 use super::*;
315− use crate::instance::{InstanceConfig, write_instance_config};
316289 use std::fs;
317290 use tempfile::TempDir;
291+ use time::OffsetDateTime;
318292
319293 fn manager(temp: &TempDir) -> SessionManager {
320294 SessionManager::with_global_dir(temp.path().join("global"))
@@ -326,108 +300,188 @@ mod tests {
326300 dir
327301 }
328302
329− fn write_instance(project_dir: &Path, id: &str, last_active: &str) -> Result<()> {
330− fs::create_dir_all(project_dir)?;
303+ #[test]
304+ fn create_session_writes_index_and_instance_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");
331332
332− let mut config = InstanceConfig::default();
333− config.id = id.to_string();
334− config.last_active = Some(last_active.to_string());
333+ let err = mgr.create_session(&missing).unwrap_err();
335334
336− write_instance_config(project_dir, &config)
335+ assert!(matches!(err, SessionError::MissingDirectory(_)));
337336 }
338337
339338 #[test]
340− fn update_global_sessions_adds_directory() {
339+ fn create_session_rejects_duplicate_directory() {
341340 let temp = TempDir::new().unwrap();
342341 let mgr = manager(&temp);
343342 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());
359343
360− let session_path = mgr.index_path().join(format!(
361− "019bf290-cccc-7c23-ba1d-dce7e6d40693{}",
362− SESSION_TOML_SUFFIX
363− ));
364− assert!(session_path.exists());
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+
356+ let first = mgr.create_session(&project_dir).unwrap();
357+ let second = mgr.get_or_create_session(&project_dir).unwrap();
358+
359+ assert_eq!(first.id, second.id);
360+ assert_eq!(second.ref_count, 2);
361+ }
362+
363+ #[test]
364+ fn decrement_ref_count_errors_on_underflow() {
365+ let temp = TempDir::new().unwrap();
366+ let mgr = manager(&temp);
367+ let project_dir = create_project_dir(&temp);
368+
369+ let session = mgr.create_session(&project_dir).unwrap();
370+ let session = mgr.decrement_ref_count(session.id).unwrap();
371+ assert_eq!(session.ref_count, 0);
372+
373+ let err = mgr.decrement_ref_count(session.id).unwrap_err();
374+ assert!(matches!(err, SessionError::RefCountUnderflow(_)));
365375 }
366376
367377 #[test]
368− fn update_global_sessions_removes_missing_vibebox_toml() {
378+ fn list_sessions_cleans_orphans() {
369379 let temp = TempDir::new().unwrap();
370380 let mgr = manager(&temp);
371381 let project_dir = create_project_dir(&temp);
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− );
381− let _ = mgr.update_global_sessions(&project_dir).unwrap();
382−
383− fs::remove_file(config_path(project_dir.as_path())).unwrap();
382+
383+ let _session = mgr.create_session(&project_dir).unwrap();
384+ fs::remove_dir_all(project_dir.join(INSTANCE_DIR_NAME)).unwrap();
385+
384386 let sessions = mgr.list_sessions().unwrap();
385387 assert!(sessions.is_empty());
388+ }
386389
387− let session_path = mgr.index_path().join(format!(
388− "019bf290-cccc-7c23-ba1d-dce7e6d40693{}",
389− SESSION_TOML_SUFFIX
390− ));
391− assert!(!session_path.exists());
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+
396+ let session = mgr.create_session(&project_dir).unwrap();
397+ let removed = mgr.delete_session(session.id).unwrap();
398+
399+ assert!(removed);
400+ assert!(!project_dir.join(INSTANCE_DIR_NAME).exists());
401+ assert!(mgr.list_sessions().unwrap().is_empty());
392402 }
393403
394404 #[test]
395405 fn invalid_toml_returns_error() {
396406 let temp = TempDir::new().unwrap();
397407 let mgr = manager(&temp);
398− fs::create_dir_all(mgr.index_path()).unwrap();
399− fs::write(
400− mgr.index_path().join(format!("bad{SESSION_TOML_SUFFIX}")),
401− "not toml",
402− )
403− .unwrap();
408+ let index_path = mgr.index_path();
409+ fs::create_dir_all(index_path.parent().unwrap()).unwrap();
410+ fs::write(index_path, "this is not toml").unwrap();
404411
405412 let err = mgr.list_sessions().unwrap_err();
413+
406414 assert!(matches!(err, SessionError::TomlDe(_)));
407415 }
408416
409417 #[test]
410− fn list_sessions_reads_session_files() {
418+ fn bump_last_active_updates_timestamp() {
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() {
411463 let temp = TempDir::new().unwrap();
412464 let mgr = manager(&temp);
413465 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();
424466
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();
425482 let sessions = mgr.list_sessions().unwrap();
483+
426484 assert_eq!(sessions.len(), 1);
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− );
485+ assert_eq!(sessions[0].id, session.id);
432486 }
433487}
src/ssh.sh+24−78
@@ -2,9 +2,7 @@
22set -eu
33
44SSH_USER="__SSH_USER__"
5−SUDO_PASSWORD="__SUDO_PASSWORD__"
65PROJECT_NAME="__PROJECT_NAME__"
7−PROJECT_GUEST_DIR="__PROJECT_GUEST_DIR__"
86KEY_PATH="__KEY_PATH__"
97
108diag() { echo "[vibebox][diag] $*" >&2; }
@@ -51,7 +49,7 @@ dump_diag() {
5149}
5250
5351# 1) tmpfs mount
54−TARGET="${PROJECT_GUEST_DIR}/.vibebox"
52+TARGET="/root/${PROJECT_NAME}/.vibebox"
5553if [ -d "$TARGET" ] && ! mountpoint -q "$TARGET"; then
5654 mount -t tmpfs tmpfs "$TARGET"
5755fi
@@ -62,69 +60,35 @@ if ! id -u "$SSH_USER" >/dev/null 2>&1; then
6260 usermod -aG sudo "$SSH_USER" || true
6361fi
6462
65−if [ -n "$SUDO_PASSWORD" ]; then
66− echo "${SSH_USER}:${SUDO_PASSWORD}" | chpasswd
67−fi
68−
6963install -d -m 700 -o "$SSH_USER" -g "$SSH_USER" "/home/${SSH_USER}/.ssh"
7064install -m 600 -o "$SSH_USER" -g "$SSH_USER" "$KEY_PATH" "/home/${SSH_USER}/.ssh/authorized_keys"
7165
66+# Ensure codex/claude are visible in the user's HOME
7267USER_HOME="$(getent passwd "$SSH_USER" | cut -d: -f6 2>/dev/null || true)"
7368if [ -z "$USER_HOME" ]; then
7469 USER_HOME="/home/${SSH_USER}"
7570fi
76−
77−# Home mount links (config-driven)
78−__VIBEBOX_HOME_LINKS__
79−
80−# Vibebox shell commands
81−install -d -m 755 /etc/profile.d
82−cat > /etc/profile.d/vibebox.sh <<'VIBEBOX_SHELL_EOF'
83−__VIBEBOX_SHELL_SCRIPT__
84−VIBEBOX_SHELL_EOF
85−chmod 644 /etc/profile.d/vibebox.sh
86−
87−# Auto-cd into project for interactive shells
88−cat > /etc/profile.d/vibebox-project.sh <<'VIBEBOX_PROJECT_EOF'
89−case "$-" in
90− *i*)
91− project_home="${HOME}/__PROJECT_NAME__"
92− if [ "$USER" = "__SSH_USER__" ] && [ -d "$project_home" ]; then
93− cd "$project_home"
94− elif [ "$USER" = "__SSH_USER__" ] && [ -d "__PROJECT_GUEST_DIR__" ]; then
95− cd "__PROJECT_GUEST_DIR__"
96− fi
97− ;;
98−esac
99−VIBEBOX_PROJECT_EOF
100−chmod 644 /etc/profile.d/vibebox-project.sh
101−
102−if ! grep -q "vibebox-aliases" "${USER_HOME}/.bashrc" 2>/dev/null; then
103− {
104− echo ""
105− echo "# vibebox-aliases"
106− echo ". /etc/profile.d/vibebox.sh"
107− } >> "${USER_HOME}/.bashrc"
71+install -d -m 755 /usr/local/codex /usr/local/claude
72+if [ ! -e "${USER_HOME}/.codex" ]; then
73+ ln -s /usr/local/codex "${USER_HOME}/.codex"
10874fi
75+if [ ! -e "${USER_HOME}/.claude" ]; then
76+ ln -s /usr/local/claude "${USER_HOME}/.claude"
77+fi
78+chown -h "${SSH_USER}:${SSH_USER}" "${USER_HOME}/.codex" "${USER_HOME}/.claude" 2>/dev/null || true
10979
11080# Install Mise
11181MISE_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() {
115− if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
116− if ! curl https://mise.run | HOME="$USER_HOME" sh; then
117− mise_warn "mise install script failed"
118− return 1
119− fi
120− fi
121− echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
82+if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
83+ curl https://mise.run | HOME="$USER_HOME" sh
84+fi
85+echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
12286
123− export PATH="${USER_HOME}/.local/bin:/usr/local/bin:$PATH"
87+export PATH="${USER_HOME}/.local/bin:/usr/local/bin:$PATH"
12488
125− mkdir -p "${USER_HOME}/.config/mise"
89+mkdir -p "${USER_HOME}/.config/mise"
12690
127− cat > "${USER_HOME}/.config/mise/config.toml" <<MISE
91+cat > "${USER_HOME}/.config/mise/config.toml" <<MISE
12892 [settings]
12993 # Always use the venv created by uv, if available in directory
13094 python.uv_venv_auto = true
@@ -138,23 +102,11 @@ mise_install() {
138102 "npm:@anthropic-ai/claude-code" = "latest"
139103MISE
140104
141− touch "${USER_HOME}/.config/mise/mise.lock"
142− if [ -x "$MISE_BIN" ]; then
143− if ! HOME="$USER_HOME" "$MISE_BIN" install; then
144− mise_warn "mise install failed"
145− return 1
146− fi
147− else
148− if ! HOME="$USER_HOME" mise install; then
149− mise_warn "mise install failed"
150− return 1
151− fi
152− fi
153−}
154−
155−if ! mise_install; then
156− diag "mise installation failed"
157− vibebox_fail "mise installation failed" 1
105+touch "${USER_HOME}/.config/mise/mise.lock"
106+if [ -x "$MISE_BIN" ]; then
107+ HOME="$USER_HOME" "$MISE_BIN" install
108+else
109+ HOME="$USER_HOME" mise install
158110fi
159111
160112# 3) start ssh (don't swallow failures)
@@ -163,7 +115,7 @@ if ! systemctl is-active --quiet ssh; then
163115 if ! systemctl start ssh; then
164116 diag "systemctl start ssh failed"
165117 dump_diag
166− vibebox_fail "failed to start ssh service" 1
118+ exit 1
167119 fi
168120fi
169121
@@ -201,7 +153,7 @@ done
201153if [ -z "$dev" ] || [ -z "$ip" ]; then
202154 diag "no stable IPv4 on default route interface"
203155 dump_diag
204− vibebox_fail "no stable ipv4 route on default interface" 1
156+ exit 1
205157fi
206158
207159# 5) strong verify: ssh must listen externally (0.0.0.0:22 or $ip:22 or [::]:22)
@@ -220,14 +172,8 @@ done
220172if ! listens_ok; then
221173 diag "sshd not listening on 0.0.0.0:22 / ${ip}:22"
222174 dump_diag
223− vibebox_fail "sshd is not listening on the expected address" 1
175+ exit 1
224176fi
225177
226−ip a
227−ip link
228−curl -s https://api.ipify.org ; echo
229−
230−cat /etc/machine-id
231−
232178echo VIBEBOX_SSH_READY
233179echo "VIBEBOX_IPV4=$ip"
src/tui.rs+43−276
@@ -1,4 +1,10 @@
1−use bytesize::ByteSize;
1+use std::{
2+ io::{self, Write},
3+ os::unix::io::OwnedFd,
4+ path::PathBuf,
5+ sync::{Arc, Mutex},
6+};
7+
28use color_eyre::Result;
39use crossterm::{
410 cursor::{MoveTo, Show},
@@ -14,18 +20,11 @@ use ratatui::{
1420 layout::{Constraint, Direction, Layout, Rect},
1521 style::{Color, Style},
1622 text::{Line, Span, Text},
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},
23+ widgets::{Block, Borders, List, ListItem, Paragraph, Widget},
2424};
2525
2626use crate::vm;
2727
28−// https://patorjk.com/software/taag/#p=display&f=ANSI+Shadow&t=VIBEBOX&x=none&v=4&h=4&w=80&we=false
2928const ASCII_BANNER: [&str; 7] = [
3029 "██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
3130 "██║ ██║██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗╚██╗██╔╝",
@@ -35,15 +34,12 @@ const ASCII_BANNER: [&str; 7] = [
3534 " ╚═══╝ ╚═╝╚═════╝ ╚══════╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝",
3635 "",
3736];
38−const INFO_LINE_COUNT: u16 = 5;
3937
4038#[derive(Debug, Clone)]
4139pub struct VmInfo {
42− pub max_memory: ByteSize,
40+ pub version: String,
41+ pub max_memory_mb: u64,
4342 pub cpu_cores: usize,
44− pub max_disk: ByteSize,
45− pub system_name: String,
46− pub auto_shutdown_ms: u64,
4743}
4844
4945#[derive(Debug)]
@@ -54,7 +50,11 @@ pub struct AppState {
5450}
5551
5652impl AppState {
57− pub fn new(cwd: PathBuf, vm_info: VmInfo, commands: VibeboxCommands) -> Self {
53+ pub fn new(cwd: PathBuf, vm_info: VmInfo) -> Self {
54+ let mut commands = VibeboxCommands::default();
55+ commands.add_command(":new", "Create a new session.");
56+ commands.add_command(":exit", "Exit Vibebox.");
57+
5858 Self {
5959 cwd,
6060 vm_info,
@@ -69,10 +69,6 @@ pub struct VibeboxCommands {
6969}
7070
7171impl VibeboxCommands {
72− pub fn new_empty() -> Self {
73− Self { items: Vec::new() }
74− }
75−
7672 pub fn add_command(&mut self, name: impl Into<String>, description: impl Into<String>) {
7773 self.items.push(VibeboxCommand {
7874 name: name.into(),
@@ -102,31 +98,6 @@ pub struct VibeboxCommand {
10298 pub description: String,
10399}
104100
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−
130101#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
131102struct PageLayout {
132103 header: Rect,
@@ -159,14 +130,10 @@ fn compute_page_layout(app: &AppState, width: u16) -> PageLayout {
159130fn header_height() -> u16 {
160131 let banner_height = ASCII_BANNER.len() as u16;
161132 let welcome_height = 1;
162− let info_height = info_block_height();
133+ let info_height = 4;
163134 welcome_height + banner_height + info_height
164135}
165136
166−fn info_block_height() -> u16 {
167− INFO_LINE_COUNT.saturating_add(1)
168−}
169−
170137pub fn render_tui_once(app: &mut AppState) -> Result<()> {
171138 let (width, _) = crossterm::terminal::size()?;
172139 if width == 0 {
@@ -207,197 +174,6 @@ pub fn render_commands_component(app: &mut AppState) -> Result<()> {
207174 Ok(())
208175}
209176
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−
401177pub fn passthrough_vm_io(
402178 app: Arc<Mutex<AppState>>,
403179 output_monitor: Arc<vm::OutputMonitor>,
@@ -474,27 +250,27 @@ fn write_buffer_with_style(buffer: &Buffer, out: &mut impl Write) -> io::Result<
474250 Ok(())
475251}
476252
477−fn map_color(color: Color) -> CrosstermColor {
253+fn map_color(color: ratatui::style::Color) -> CrosstermColor {
478254 match color {
479− Color::Reset => CrosstermColor::Reset,
480− Color::Black => CrosstermColor::Black,
481− Color::Red => CrosstermColor::DarkRed,
482− Color::Green => CrosstermColor::DarkGreen,
483− Color::Yellow => CrosstermColor::DarkYellow,
484− Color::Blue => CrosstermColor::DarkBlue,
485− Color::Magenta => CrosstermColor::DarkMagenta,
486− Color::Cyan => CrosstermColor::DarkCyan,
487− Color::Gray => CrosstermColor::Grey,
488− Color::DarkGray => CrosstermColor::DarkGrey,
489− Color::LightRed => CrosstermColor::Red,
490− Color::LightGreen => CrosstermColor::Green,
491− Color::LightYellow => CrosstermColor::Yellow,
492− Color::LightBlue => CrosstermColor::Blue,
493− Color::LightMagenta => CrosstermColor::Magenta,
494− Color::LightCyan => CrosstermColor::Cyan,
495− Color::White => CrosstermColor::White,
496− Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
497− Color::Indexed(i) => CrosstermColor::AnsiValue(i),
255+ ratatui::style::Color::Reset => CrosstermColor::Reset,
256+ ratatui::style::Color::Black => CrosstermColor::Black,
257+ ratatui::style::Color::Red => CrosstermColor::DarkRed,
258+ ratatui::style::Color::Green => CrosstermColor::DarkGreen,
259+ ratatui::style::Color::Yellow => CrosstermColor::DarkYellow,
260+ ratatui::style::Color::Blue => CrosstermColor::DarkBlue,
261+ ratatui::style::Color::Magenta => CrosstermColor::DarkMagenta,
262+ ratatui::style::Color::Cyan => CrosstermColor::DarkCyan,
263+ ratatui::style::Color::Gray => CrosstermColor::Grey,
264+ ratatui::style::Color::DarkGray => CrosstermColor::DarkGrey,
265+ ratatui::style::Color::LightRed => CrosstermColor::Red,
266+ ratatui::style::Color::LightGreen => CrosstermColor::Green,
267+ ratatui::style::Color::LightYellow => CrosstermColor::Yellow,
268+ ratatui::style::Color::LightBlue => CrosstermColor::Blue,
269+ ratatui::style::Color::LightMagenta => CrosstermColor::Magenta,
270+ ratatui::style::Color::LightCyan => CrosstermColor::Cyan,
271+ ratatui::style::Color::White => CrosstermColor::White,
272+ ratatui::style::Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
273+ ratatui::style::Color::Indexed(i) => CrosstermColor::AnsiValue(i),
498274 }
499275}
500276
@@ -540,15 +316,13 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
540316 .constraints([
541317 Constraint::Length(1),
542318 Constraint::Length(ASCII_BANNER.len() as u16),
543− Constraint::Length(info_block_height()),
319+ Constraint::Length(4),
544320 ])
545321 .split(area);
546322
547− let version = env!("CARGO_PKG_VERSION");
548−
549323 let welcome = Line::from(vec![
550324 Span::raw("Welcome to Vibebox v"),
551− Span::styled(version, Style::default().fg(Color::Yellow)),
325+ Span::styled(&app.vm_info.version, Style::default().fg(Color::Yellow)),
552326 ]);
553327
554328 Paragraph::new(welcome).render(header_chunks[0], buffer);
@@ -567,26 +341,19 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
567341 Span::styled(app.cwd.to_string_lossy(), Style::default().fg(Color::Cyan)),
568342 ]),
569343 Line::from(vec![
570− Span::raw("System: "),
571− Span::styled(&app.vm_info.system_name, Style::default().fg(Color::Green)),
344+ Span::raw("VM Version: "),
345+ Span::styled(&app.vm_info.version, Style::default().fg(Color::Green)),
572346 ]),
573347 Line::from(vec![
574− Span::raw("CPU / Memory / Disk: "),
348+ Span::raw("CPU / Memory: "),
575349 Span::styled(
576350 format!(
577− "{} cores / {} / {}",
578− app.vm_info.cpu_cores, app.vm_info.max_memory, app.vm_info.max_disk
351+ "{} cores / {} MB",
352+ app.vm_info.cpu_cores, app.vm_info.max_memory_mb
579353 ),
580354 Style::default().fg(Color::Green),
581355 ),
582356 ]),
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− ]),
590357 ];
591358
592359 Paragraph::new(info_lines)
src/utils.rsdeleted−18
@@ -1,18 +0,0 @@
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+331−376
@@ -1,7 +1,8 @@
1−use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
2−use anyhow::{Context, Error, Result, bail};
1+use crate::session_manager::INSTANCE_DIR_NAME;
32use std::{
4− env, fs,
3+ env,
4+ ffi::OsString,
5+ fs,
56 io::{self, Write},
67 os::{
78 fd::RawFd,
@@ -23,8 +24,8 @@ use std::{
2324};
2425
2526use block2::RcBlock;
26−use bytesize::ByteSize;
2727use dispatch2::DispatchQueue;
28+use lexopt::prelude::*;
2829use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
2930use objc2_foundation::*;
3031use objc2_virtualization::*;
@@ -33,46 +34,39 @@ const DEBIAN_COMPRESSED_DISK_URL: &str = "https://cloud.debian.org/images/cloud/
3334const DEBIAN_COMPRESSED_SHA: &str = "6ab9be9e6834adc975268367f2f0235251671184345c34ee13031749fdfbf66fe4c3aafd949a2d98550426090e9ac645e79009c51eb0eefc984c15786570bb38";
3435const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
3536const SHARED_DIRECTORIES_TAG: &str = "shared";
36−pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts";
3737
38+const BYTES_PER_MB: u64 = 1024 * 1024;
39+const DEFAULT_CPU_COUNT: usize = 2;
40+const DEFAULT_RAM_MB: u64 = 2048;
41+const DEFAULT_RAM_BYTES: u64 = DEFAULT_RAM_MB * BYTES_PER_MB;
3842const START_TIMEOUT: Duration = Duration::from_secs(60);
43+const DEFAULT_EXPECT_TIMEOUT: Duration = Duration::from_secs(30);
3944const 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");
4345const 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";
4846
4947#[derive(Clone)]
50−pub enum LoginAction {
51− Expect {
52− text: String,
53− timeout: Duration,
54− },
55− ExpectEither {
56− success: String,
57− failure: String,
58− timeout: Duration,
59− },
48+pub(crate) enum LoginAction {
49+ Expect { text: String, timeout: Duration },
6050 Send(String),
51+ Script { path: PathBuf, index: usize },
6152}
62−use crate::config::BoxConfig;
6353use LoginAction::*;
6454
6555#[derive(Clone)]
66−pub struct DirectoryShare {
56+pub(crate) struct DirectoryShare {
6757 host: PathBuf,
6858 guest: PathBuf,
6959 read_only: bool,
7060}
7161
7262impl DirectoryShare {
73− pub fn new(host: PathBuf, mut guest: PathBuf, read_only: bool) -> Result<Self> {
63+ pub(crate) fn new(
64+ host: PathBuf,
65+ mut guest: PathBuf,
66+ read_only: bool,
67+ ) -> Result<Self, Box<dyn std::error::Error>> {
7468 if !host.exists() {
75− bail!(format!("host path does not exist: {}", host.display()));
69+ return Err(format!("Host path does not exist: {}", host.display()).into());
7670 }
7771 if !guest.is_absolute() {
7872 guest = PathBuf::from("/root").join(guest);
@@ -84,22 +78,23 @@ impl DirectoryShare {
8478 })
8579 }
8680
87− pub fn from_mount_spec(spec: &str) -> Result<Self> {
81+ pub(crate) fn from_mount_spec(spec: &str) -> Result<Self, Box<dyn std::error::Error>> {
8882 let parts: Vec<&str> = spec.split(':').collect();
8983 if parts.len() < 2 || parts.len() > 3 {
90− bail!(format!("invalid mount spec: {spec}"));
84+ return Err(format!("Invalid mount spec: {spec}").into());
9185 }
92− let host = expand_tilde_path(parts[0]);
86+ let host = PathBuf::from(parts[0]);
9387 let guest = PathBuf::from(parts[1]);
9488 let read_only = if parts.len() == 3 {
9589 match parts[2] {
9690 "read-only" => true,
9791 "read-write" => false,
9892 _ => {
99− bail!(format!(
93+ return Err(format!(
10094 "Invalid mount mode '{}'; expected read-only or read-write",
10195 parts[2]
102− ));
96+ )
97+ .into());
10398 }
10499 }
105100 } else {
@@ -122,49 +117,19 @@ impl DirectoryShare {
122117 }
123118}
124119
125−fn expand_tilde_path(value: &str) -> PathBuf {
126− if let Some(stripped) = value.strip_prefix("~/") {
127− if let Ok(home) = env::var("HOME") {
128− return PathBuf::from(home).join(stripped);
129− }
130− } else if value == "~"
131− && let Ok(home) = env::var("HOME")
132− {
133− return PathBuf::from(home);
134− }
135− PathBuf::from(value)
136−}
137−
138−pub struct VmArg {
139− pub cpu_count: usize,
140− pub ram_bytes: u64,
141− pub disk_bytes: u64,
142− pub no_default_mounts: bool,
143− pub mounts: Vec<String>,
144−}
145−
146−type StatusEmitter<'a> = dyn Fn(&str) + std::marker::Send + Sync + 'a;
147−
148−fn emit_status(status: Option<&StatusEmitter<'_>>, message: &str) {
149− if let Some(status) = status {
150− status(message);
151− }
152−}
153−
154−pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<()>
120+pub fn run_with_args<F>(args: CliArgs, io_handler: F) -> Result<(), Box<dyn std::error::Error>>
155121where
156122 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
157123{
158− run_with_args_and_extras(args, io_handler, Vec::new(), Vec::new(), None)
124+ run_with_args_and_extras(args, io_handler, Vec::new(), Vec::new())
159125}
160126
161−pub fn run_with_args_and_extras<F>(
162− args: VmArg,
127+pub(crate) fn run_with_args_and_extras<F>(
128+ args: CliArgs,
163129 io_handler: F,
164130 extra_login_actions: Vec<LoginAction>,
165131 extra_directory_shares: Vec<DirectoryShare>,
166− status: Option<&StatusEmitter<'_>>,
167−) -> Result<()>
132+) -> Result<(), Box<dyn std::error::Error>>
168133where
169134 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
170135{
@@ -173,7 +138,7 @@ where
173138 let project_root = env::current_dir()?;
174139 let project_name = project_root
175140 .file_name()
176− .with_context(|| "Project directory has no name")?
141+ .ok_or("Project directory has no name")?
177142 .to_string_lossy()
178143 .into_owned();
179144
@@ -181,14 +146,10 @@ where
181146 let cache_home = env::var("XDG_CACHE_HOME")
182147 .map(PathBuf::from)
183148 .unwrap_or_else(|_| home.join(".cache"));
184− let cache_dir = cache_home.join(GLOBAL_CACHE_DIR_NAME);
149+ let cache_dir = cache_home.join("vibe");
185150 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
186151
187152 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");
192153
193154 let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
194155 let base_compressed = cache_dir.join(basename_compressed);
@@ -197,8 +158,8 @@ where
197158 basename_compressed.trim_end_matches(".tar.xz")
198159 ));
199160
200− let default_raw = cache_dir.join(DEFAULT_RAW_NAME);
201− let instance_raw = instance_dir.join(INSTANCE_RAW_NAME);
161+ let default_raw = cache_dir.join("default.raw");
162+ let instance_raw = instance_dir.join("instance.raw");
202163
203164 // Prepare system-wide directories
204165 fs::create_dir_all(&cache_dir)?;
@@ -207,31 +168,28 @@ where
207168 let mise_directory_share =
208169 DirectoryShare::new(guest_mise_cache, "/root/.local/share/mise".into(), false)?;
209170
210− ensure_default_image(
211− &base_raw,
212− &base_compressed,
213− &default_raw,
214− std::slice::from_ref(&mise_directory_share),
215− Some(&provision_log),
216− status,
217− )?;
218− let _ = ensure_instance_disk(
219− &instance_raw,
220− &default_raw,
221− ByteSize(args.disk_bytes),
222− status,
223− )?;
224− let base_size = fs::metadata(&default_raw)?.len();
225− let instance_size = fs::metadata(&instance_raw)?.len();
226− let needs_resize = instance_size > base_size;
227− let disk_path = instance_raw;
171+ let disk_path = if let Some(path) = args.disk {
172+ if !path.exists() {
173+ return Err(format!("Disk image does not exist: {}", path.display()).into());
174+ }
175+ path
176+ } else {
177+ ensure_default_image(
178+ &base_raw,
179+ &base_compressed,
180+ &default_raw,
181+ std::slice::from_ref(&mise_directory_share),
182+ )?;
183+ ensure_instance_disk(&instance_raw, &default_raw)?;
184+
185+ instance_raw
186+ };
228187
229188 let mut login_actions = Vec::new();
230189 let mut directory_shares = Vec::new();
231190
232191 if !args.no_default_mounts {
233− let project_guest_dir = PathBuf::from(PROJECT_GUEST_BASE).join(project_name);
234− login_actions.push(Send(format!("cd {}", project_guest_dir.display())));
192+ login_actions.push(Send(format!("cd {project_name}")));
235193
236194 // 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.
237195 // I couldn't find an alternative way to do this --- the MacOS sandbox doesn't apply to the Apple Virtualization system
@@ -239,7 +197,32 @@ where
239197 login_actions.push(Send(r"mount -t tmpfs tmpfs .git/".into()));
240198 }
241199
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+
242209 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+ }
243226 }
244227
245228 directory_shares.extend(extra_directory_shares);
@@ -248,46 +231,199 @@ where
248231 directory_shares.push(DirectoryShare::from_mount_spec(spec)?);
249232 }
250233
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−
256234 if let Some(motd_action) = motd_login_action(&directory_shares) {
257235 login_actions.push(motd_action);
258236 }
259237
260238 login_actions.extend(extra_login_actions);
261239
240+ // Any user-provided login actions must come after our system ones
241+ login_actions.extend(args.login_actions);
242+
262243 run_vm_with_io(
263244 &disk_path,
264245 &login_actions,
265246 &directory_shares[..],
266247 args.cpu_count,
267248 args.ram_bytes,
268− status,
269249 io_handler,
270250 )
271251}
272252
273−pub fn script_command_from_content(label: &str, script: &str) -> Result<String> {
253+pub fn print_help() {
254+ println!(
255+ "Vibe is a quick way to spin up a Linux virtual machine on Mac to sandbox LLM agents.
256+
257+vibe [OPTIONS] [disk-image.raw]
258+
259+Options
260+
261+ --help Print this help message.
262+ --version Print the version (commit SHA).
263+ --no-default-mounts Disable all default mounts.
264+ --mount host-path:guest-path[:read-only | :read-write] Mount `host-path` inside VM at `guest-path`.
265+ Defaults to read-write.
266+ Errors if host-path does not exist.
267+ --cpus <count> Number of virtual CPUs (default {DEFAULT_CPU_COUNT}).
268+ --ram <megabytes> RAM size in megabytes (default {DEFAULT_RAM_MB}).
269+ --script <path/to/script.sh> Run script in VM.
270+ --send <some-command> Type `some-command` followed by newline into the VM.
271+ --expect <string> [timeout-seconds] Wait for `string` to appear in console output before executing next `--script` or `--send`.
272+ If `string` does not appear within timeout (default 30 seconds), shutdown VM with error.
273+"
274+ );
275+}
276+
277+pub fn print_version() {
278+ println!("Vibe");
279+ println!("https://github.com/lynaghk/vibe/");
280+ println!("Git SHA: {}", env!("GIT_SHA"));
281+}
282+
283+pub struct CliArgs {
284+ disk: Option<PathBuf>,
285+ version: bool,
286+ help: bool,
287+ no_default_mounts: bool,
288+ mounts: Vec<String>,
289+ login_actions: Vec<LoginAction>,
290+ cpu_count: usize,
291+ ram_bytes: u64,
292+}
293+
294+impl CliArgs {
295+ pub fn version(&self) -> bool {
296+ self.version
297+ }
298+
299+ pub fn help(&self) -> bool {
300+ self.help
301+ }
302+
303+ pub fn cpu_count(&self) -> usize {
304+ self.cpu_count
305+ }
306+
307+ pub fn ram_bytes(&self) -> u64 {
308+ self.ram_bytes
309+ }
310+
311+ pub fn ram_mb(&self) -> u64 {
312+ self.ram_bytes / BYTES_PER_MB
313+ }
314+}
315+
316+pub fn parse_cli() -> Result<CliArgs, Box<dyn std::error::Error>> {
317+ parse_cli_from(env::args_os())
318+}
319+
320+pub fn parse_cli_from<I>(args: I) -> Result<CliArgs, Box<dyn std::error::Error>>
321+where
322+ I: IntoIterator<Item = OsString>,
323+{
324+ fn os_to_string(value: OsString, flag: &str) -> Result<String, Box<dyn std::error::Error>> {
325+ value
326+ .into_string()
327+ .map_err(|_| format!("{flag} expects valid UTF-8").into())
328+ }
329+
330+ let mut parser = lexopt::Parser::from_iter(args);
331+ let mut disk = None;
332+ let mut version = false;
333+ let mut help = false;
334+ let mut no_default_mounts = false;
335+ let mut mounts = Vec::new();
336+ let mut login_actions = Vec::new();
337+ let mut script_index = 0;
338+ let mut cpu_count = DEFAULT_CPU_COUNT;
339+ let mut ram_bytes = DEFAULT_RAM_BYTES;
340+
341+ while let Some(arg) = parser.next()? {
342+ match arg {
343+ Long("version") => version = true,
344+ Long("help") | Short('h') => help = true,
345+ Long("no-default-mounts") => no_default_mounts = true,
346+ Long("cpus") => {
347+ let value = os_to_string(parser.value()?, "--cpus")?.parse()?;
348+ if value == 0 {
349+ return Err("--cpus must be >= 1".into());
350+ }
351+ cpu_count = value;
352+ }
353+ Long("ram") => {
354+ let value: u64 = os_to_string(parser.value()?, "--ram")?.parse()?;
355+ if value == 0 {
356+ return Err("--ram must be >= 1".into());
357+ }
358+ ram_bytes = value * BYTES_PER_MB;
359+ }
360+ Long("mount") => {
361+ mounts.push(os_to_string(parser.value()?, "--mount")?);
362+ }
363+ Long("script") => {
364+ login_actions.push(Script {
365+ path: os_to_string(parser.value()?, "--script")?.into(),
366+ index: script_index,
367+ });
368+ script_index += 1;
369+ }
370+ Long("send") => {
371+ login_actions.push(Send(os_to_string(parser.value()?, "--send")?));
372+ }
373+ Long("expect") => {
374+ let text = os_to_string(parser.value()?, "--expect")?;
375+ let timeout = match parser.optional_value() {
376+ Some(value) => Duration::from_secs(os_to_string(value, "--expect")?.parse()?),
377+ None => DEFAULT_EXPECT_TIMEOUT,
378+ };
379+ login_actions.push(Expect { text, timeout });
380+ }
381+ Value(value) => {
382+ if disk.is_some() {
383+ return Err("Only one disk path may be provided".into());
384+ }
385+ disk = Some(PathBuf::from(value));
386+ }
387+ _ => return Err(arg.unexpected().into()),
388+ }
389+ }
390+
391+ Ok(CliArgs {
392+ disk,
393+ version,
394+ help,
395+ no_default_mounts,
396+ mounts,
397+ login_actions,
398+ cpu_count,
399+ ram_bytes,
400+ })
401+}
402+
403+fn script_command_from_path(
404+ path: &Path,
405+ index: usize,
406+) -> Result<String, Box<dyn std::error::Error>> {
407+ let script = fs::read_to_string(path)
408+ .map_err(|err| format!("Failed to read script {}: {err}", path.display()))?;
409+ let label = format!("{}_{}", index, path.file_name().unwrap().display());
410+ script_command_from_content(&label, &script)
411+}
412+
413+pub(crate) fn script_command_from_content(
414+ label: &str,
415+ script: &str,
416+) -> Result<String, Box<dyn std::error::Error>> {
274417 let marker = "VIBE_SCRIPT_EOF";
275418 let guest_dir = "/tmp/vibe-scripts";
276419 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);
284420 let command = format!(
285− "mkdir -p {guest_dir}\ncat >{guest_path} <<'{marker}'\n{wrapped_script}\n{marker}\nchmod +x {guest_path}\n{guest_path}"
421+ "mkdir -p {guest_dir}\ncat >{guest_path} <<'{marker}'\n{script}\n{marker}\nchmod +x {guest_path}\n{guest_path}"
286422 );
287423 if script.contains(marker) {
288− bail!(format!(
289− "Script '{label}' contains marker '{marker}', cannot safely upload"
290− ));
424+ return Err(
425+ format!("Script '{label}' contains marker '{marker}', cannot safely upload").into(),
426+ );
291427 }
292428 Ok(command)
293429}
@@ -351,12 +487,6 @@ enum WaitResult {
351487 Found,
352488}
353489
354−#[derive(PartialEq, Eq)]
355−enum WaitAnyResult {
356− Timeout,
357− Found(usize),
358−}
359−
360490pub enum VmInput {
361491 Bytes(Vec<u8>),
362492 Shutdown,
@@ -364,7 +494,6 @@ pub enum VmInput {
364494
365495enum VmOutput {
366496 LoginActionTimeout { action: String, timeout: Duration },
367− LoginActionFailed { action: String, reason: String },
368497}
369498
370499#[derive(Default)]
@@ -401,41 +530,6 @@ impl OutputMonitor {
401530 WaitResult::Found
402531 }
403532 }
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
439533}
440534
441535#[derive(Debug)]
@@ -484,17 +578,15 @@ impl IoControl {
484578fn ensure_base_image(
485579 base_raw: &Path,
486580 base_compressed: &Path,
487− status: Option<&StatusEmitter<'_>>,
488−) -> Result<()> {
581+) -> Result<(), Box<dyn std::error::Error>> {
489582 if base_raw.exists() {
490583 return Ok(());
491584 }
492585
493586 if !base_compressed.exists()
494− || fs::metadata(base_compressed).map(|m| m.len())? < DEBIAN_COMPRESSED_SIZE_BYTES
587+ || std::fs::metadata(base_compressed).map(|m| m.len())? < DEBIAN_COMPRESSED_SIZE_BYTES
495588 {
496− emit_status(status, "downloading base image...");
497− tracing::info!("downloading base image");
589+ println!("Downloading base image...");
498590 let status = Command::new("curl")
499591 .args([
500592 "--continue-at",
@@ -508,14 +600,12 @@ fn ensure_base_image(
508600 ])
509601 .status()?;
510602 if !status.success() {
511− bail!("failed to download base image");
603+ return Err("Failed to download base image".into());
512604 }
513605 }
514606
515607 // Check SHA
516608 {
517− emit_status(status, "verifying base image...");
518− tracing::info!("verifying base image...");
519609 let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
520610
521611 let mut child = Command::new("/usr/bin/shasum")
@@ -533,25 +623,18 @@ fn ensure_base_image(
533623
534624 let status = child.wait().expect("failed to wait on child");
535625 if !status.success() {
536− bail!(format!(
537− "SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}"
538− ));
626+ return Err(format!("SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}").into());
539627 }
540628 }
541629
542− emit_status(status, "decompressing base image...");
543− tracing::info!("decompressing base image...");
630+ println!("Decompressing base image...");
544631 let status = Command::new("tar")
545− .args([
546− "-xOf",
547− &base_compressed.to_string_lossy(),
548− BASE_DISK_RAW_NAME,
549− ])
550− .stdout(fs::File::create(base_raw)?)
632+ .args(["-xOf", &base_compressed.to_string_lossy(), "disk.raw"])
633+ .stdout(std::fs::File::create(base_raw)?)
551634 .status()?;
552635
553636 if !status.success() {
554− bail!("Failed to decompress base image");
637+ return Err("Failed to decompress base image".into());
555638 }
556639
557640 Ok(())
@@ -562,56 +645,24 @@ fn ensure_default_image(
562645 base_compressed: &Path,
563646 default_raw: &Path,
564647 directory_shares: &[DirectoryShare],
565− provision_log: Option<&Path>,
566− status: Option<&StatusEmitter<'_>>,
567−) -> Result<()> {
648+) -> Result<(), Box<dyn std::error::Error>> {
568649 if default_raw.exists() {
569650 return Ok(());
570651 }
571652
572− ensure_base_image(base_raw, base_compressed, status)?;
653+ ensure_base_image(base_raw, base_compressed)?;
573654
574− emit_status(status, "configuring base image...");
575− tracing::info!("configuring base image...");
655+ println!("Configuring base image...");
576656 fs::copy(base_raw, default_raw)?;
577657
578658 let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?;
579− let provision_actions = [
580− Send(provision_command),
581− ExpectEither {
582− success: "VIBEBOX_PROVISION_OK".to_string(),
583− failure: "VIBEBOX_PROVISION_FAILED".to_string(),
584− timeout: PROVISION_EXPECT_TIMEOUT,
585− },
586− ];
587− let provision_result = if let Some(log_path) = provision_log {
588− let log_path = log_path.to_path_buf();
589− run_vm_with_io(
590− default_raw,
591− &provision_actions,
592− directory_shares,
593− BoxConfig::default().cpu_count,
594− BoxConfig::default().ram_size.as_u64(),
595− status,
596− move |output_monitor, vm_output_fd, vm_input_fd| {
597− spawn_vm_io_with_log(output_monitor, vm_output_fd, vm_input_fd, log_path)
598− },
599− )
600− } else {
601− run_vm(
602− default_raw,
603− &provision_actions,
604− directory_shares,
605− BoxConfig::default().cpu_count,
606− BoxConfig::default().ram_size.as_u64(),
607− status,
608− )
609− };
610−
611− if let Err(err) = provision_result {
612− let _ = fs::remove_file(default_raw);
613− return Err(err);
614− }
659+ run_vm(
660+ default_raw,
661+ &[Send(provision_command)],
662+ directory_shares,
663+ DEFAULT_CPU_COUNT,
664+ DEFAULT_RAM_BYTES,
665+ )?;
615666
616667 Ok(())
617668}
@@ -619,47 +670,15 @@ fn ensure_default_image(
619670fn ensure_instance_disk(
620671 instance_raw: &Path,
621672 template_raw: &Path,
622− target_bytes: ByteSize,
623− status: Option<&StatusEmitter<'_>>,
624−) -> Result<bool> {
673+) -> Result<(), Box<dyn std::error::Error>> {
625674 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− }
636− return Ok(false);
637− }
638−
639− let template_size = ByteSize(fs::metadata(template_raw)?.len());
640− if target_bytes < template_size {
641− bail!(format!(
642− "Requested disk size {} is smaller than base image size {}",
643− target_bytes, template_size
644− ));
645− }
646− let target_size = target_bytes;
647− let needs_resize = target_size > template_size;
648−
649− emit_status(status, "creating instance disk...");
650− tracing::info!("creating instance disk...");
651− tracing::info!(path = %template_raw.display(), "creating instance disk");
652− fs::create_dir_all(instance_raw.parent().unwrap())?;
653− if target_size == template_size {
654− fs::copy(template_raw, instance_raw)?;
655− return Ok(needs_resize);
675+ return Ok(());
656676 }
657677
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)
678+ println!("Creating instance disk from {}...", template_raw.display());
679+ std::fs::create_dir_all(instance_raw.parent().unwrap())?;
680+ fs::copy(template_raw, instance_raw)?;
681+ Ok(())
663682}
664683
665684pub struct IoContext {
@@ -675,17 +694,18 @@ pub fn create_pipe() -> (OwnedFd, OwnedFd) {
675694 (read_stream.into(), write_stream.into())
676695}
677696
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−>(
697+pub fn spawn_vm_io_with_hooks<F, G>(
682698 output_monitor: Arc<OutputMonitor>,
683699 vm_output_fd: OwnedFd,
684700 vm_input_fd: OwnedFd,
685701 io_control: Arc<IoControl>,
686702 mut on_line: F,
687703 mut on_output: G,
688−) -> IoContext {
704+) -> IoContext
705+where
706+ F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
707+ G: FnMut(&[u8]) + ::std::marker::Send + 'static,
708+{
689709 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
690710
691711 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
@@ -833,14 +853,14 @@ pub fn spawn_vm_io_with_hooks<
833853 PollResult::Spurious => continue,
834854 PollResult::Ready(bytes) => {
835855 if io_control.forward_output() {
836− // enable raw mode if we haven't already
856+ // enable raw mode, if we haven't already
837857 if raw_guard.lock().unwrap().is_none()
838858 && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
839859 {
840860 *raw_guard.lock().unwrap() = Some(guard);
841861 }
842862
843− let mut stdout = io::stdout().lock();
863+ let mut stdout = std::io::stdout().lock();
844864 if stdout.write_all(bytes).is_err() {
845865 break;
846866 }
@@ -856,7 +876,7 @@ pub fn spawn_vm_io_with_hooks<
856876
857877 // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
858878 let mux_thread = thread::spawn(move || {
859− let mut vm_writer = fs::File::from(vm_input_fd);
879+ let mut vm_writer = std::fs::File::from(vm_input_fd);
860880 loop {
861881 match input_rx.recv() {
862882 Ok(VmInput::Bytes(data)) => {
@@ -886,7 +906,7 @@ pub fn spawn_vm_io_with_line_handler<F>(
886906 on_line: F,
887907) -> IoContext
888908where
889− F: FnMut(&str) -> bool + std::marker::Send + 'static,
909+ F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
890910{
891911 spawn_vm_io_with_hooks(
892912 output_monitor,
@@ -906,36 +926,6 @@ pub fn spawn_vm_io(
906926 spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
907927}
908928
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−
939929impl IoContext {
940930 pub fn shutdown(self) {
941931 let _ = self.input_tx.send(VmInput::Shutdown);
@@ -953,7 +943,7 @@ fn create_vm_configuration(
953943 vm_writes_to_fd: OwnedFd,
954944 cpu_count: usize,
955945 ram_bytes: u64,
956−) -> Result<Retained<VZVirtualMachineConfiguration>> {
946+) -> Result<Retained<VZVirtualMachineConfiguration>, Box<dyn std::error::Error>> {
957947 unsafe {
958948 let platform =
959949 VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
@@ -987,7 +977,7 @@ fn create_vm_configuration(
987977 false,
988978 VZDiskImageCachingMode::Automatic,
989979 VZDiskImageSynchronizationMode::Full,
990− )?;
980+ ).unwrap();
991981
992982 let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
993983 VZVirtioBlockDeviceConfiguration::alloc(),
@@ -1074,7 +1064,7 @@ fn create_vm_configuration(
10741064 // Validate
10751065 config.validateWithError().map_err(|e| {
10761066 io::Error::other(format!(
1077− "invalid VM configuration: {:?}",
1067+ "Invalid VM configuration: {:?}",
10781068 e.localizedDescription()
10791069 ))
10801070 })?;
@@ -1083,9 +1073,9 @@ fn create_vm_configuration(
10831073 }
10841074}
10851075
1086−fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>> {
1076+fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std::error::Error>> {
10871077 unsafe {
1088− let temp_dir = env::temp_dir();
1078+ let temp_dir = std::env::temp_dir();
10891079 let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
10901080 let url = nsurl_from_path(&temp_path)?;
10911081 let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
@@ -1101,8 +1091,8 @@ fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>> {
11011091fn spawn_login_actions_thread(
11021092 login_actions: Vec<LoginAction>,
11031093 output_monitor: Arc<OutputMonitor>,
1104− input_tx: Sender<VmInput>,
1105− vm_output_tx: Sender<VmOutput>,
1094+ input_tx: mpsc::Sender<VmInput>,
1095+ vm_output_tx: mpsc::Sender<VmOutput>,
11061096) -> thread::JoinHandle<()> {
11071097 thread::spawn(move || {
11081098 for a in login_actions {
@@ -1116,31 +1106,22 @@ fn spawn_login_actions_thread(
11161106 return;
11171107 }
11181108 }
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− },
11401109 Send(mut text) => {
11411110 text.push('\n'); // Type the newline so the command is actually submitted.
11421111 input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
11431112 }
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+ }
11441125 }
11451126 }
11461127 })
@@ -1152,9 +1133,8 @@ fn run_vm_with_io<F>(
11521133 directory_shares: &[DirectoryShare],
11531134 cpu_count: usize,
11541135 ram_bytes: u64,
1155− status: Option<&StatusEmitter<'_>>,
11561136 io_handler: F,
1157−) -> Result<()>
1137+) -> Result<(), Box<dyn std::error::Error>>
11581138where
11591139 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
11601140{
@@ -1201,23 +1181,21 @@ where
12011181
12021182 match rx.try_recv() {
12031183 Ok(result) => {
1204− result.map_err(|e| Error::msg(format!("Failed to start VM: {}", e)))?;
1184+ result.map_err(|e| format!("Failed to start VM: {}", e))?;
12051185 break;
12061186 }
12071187 Err(mpsc::TryRecvError::Empty) => continue,
12081188 Err(mpsc::TryRecvError::Disconnected) => {
1209− bail!("VM start channel disconnected");
1189+ return Err("VM start channel disconnected".into());
12101190 }
12111191 }
12121192 }
12131193
12141194 if Instant::now() >= start_deadline {
1215− bail!("Timed out waiting for VM to start");
1195+ return Err("Timed out waiting for VM to start".into());
12161196 }
12171197
1218− emit_status(status, "vm booting... go vibecoder!");
1219− tracing::info!("vm booting... go vibecoder!");
1220− tracing::info!("vm booting");
1198+ println!("VM booting...");
12211199
12221200 let output_monitor = Arc::new(OutputMonitor::default());
12231201 let io_ctx = io_handler(output_monitor.clone(), we_read_from, we_write_to);
@@ -1262,7 +1240,7 @@ where
12621240 );
12631241
12641242 let mut last_state = None;
1265− let mut exit_result: Result<(), String> = Ok(());
1243+ let mut exit_result = Ok(());
12661244 loop {
12671245 unsafe {
12681246 NSRunLoop::mainRunLoop().runMode_beforeDate(
@@ -1281,28 +1259,12 @@ where
12811259 exit_result = Err(format!(
12821260 "Login action ({}) timed out after {:?}; shutting down.",
12831261 action, timeout
1284− ));
1262+ )
1263+ .into());
12851264 unsafe {
12861265 if vm.canRequestStop() {
12871266 if let Err(err) = vm.requestStopWithError() {
1288− tracing::error!(error = ?err, "failed to request VM stop");
1289− }
1290− } else if vm.canStop() {
1291− let handler = RcBlock::new(|_error: *mut NSError| {});
1292− vm.stopWithCompletionHandler(&handler);
1293− }
1294− }
1295− break;
1296− }
1297− Ok(VmOutput::LoginActionFailed { action, reason }) => {
1298− exit_result = Err(format!(
1299− "Login action ({}) failed: {}; shutting down.",
1300− action, reason
1301− ));
1302− unsafe {
1303− if vm.canRequestStop() {
1304− if let Err(err) = vm.requestStopWithError() {
1305− tracing::error!(error = ?err, "failed to request VM stop");
1267+ eprintln!("Failed to request VM stop: {:?}", err);
13061268 }
13071269 } else if vm.canStop() {
13081270 let handler = RcBlock::new(|_error: *mut NSError| {});
@@ -1314,7 +1276,7 @@ where
13141276 Err(mpsc::TryRecvError::Empty) => {}
13151277 Err(mpsc::TryRecvError::Disconnected) => {}
13161278 }
1317− if state != VZVirtualMachineState::Running {
1279+ if state != objc2_virtualization::VZVirtualMachineState::Running {
13181280 //eprintln!("VM stopped with state: {:?}", state);
13191281 break;
13201282 }
@@ -1324,7 +1286,7 @@ where
13241286
13251287 io_ctx.shutdown();
13261288
1327− exit_result.map_err(Error::msg)
1289+ exit_result
13281290}
13291291
13301292fn run_vm(
@@ -1333,20 +1295,18 @@ fn run_vm(
13331295 directory_shares: &[DirectoryShare],
13341296 cpu_count: usize,
13351297 ram_bytes: u64,
1336− status: Option<&StatusEmitter<'_>>,
1337−) -> Result<()> {
1298+) -> Result<(), Box<dyn std::error::Error>> {
13381299 run_vm_with_io(
13391300 disk_path,
13401301 login_actions,
13411302 directory_shares,
13421303 cpu_count,
13431304 ram_bytes,
1344− status,
13451305 spawn_vm_io,
13461306 )
13471307}
13481308
1349−fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>> {
1309+fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
13501310 let abs_path = if path.is_absolute() {
13511311 path.to_path_buf()
13521312 } else {
@@ -1355,7 +1315,7 @@ fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>> {
13551315 let ns_path = NSString::from_str(
13561316 abs_path
13571317 .to_str()
1358− .with_context(|| "non-UTF8 path encountered while building NSURL")?,
1318+ .ok_or("Non-UTF8 path encountered while building NSURL")?,
13591319 );
13601320 Ok(NSURL::fileURLWithPath(&ns_path))
13611321}
@@ -1370,7 +1330,7 @@ fn enable_raw_mode(fd: i32) -> io::Result<RawModeGuard> {
13701330 let original = attributes;
13711331
13721332 // Disable translation of carriage return to newline on input
1373− attributes.c_iflag &= !libc::ICRNL;
1333+ attributes.c_iflag &= !(libc::ICRNL);
13741334 // Disable canonical mode (line buffering), echo, and signal generation
13751335 attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
13761336 attributes.c_cc[libc::VMIN] = 0;
@@ -1398,10 +1358,10 @@ impl Drop for RawModeGuard {
13981358
13991359// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
14001360pub fn ensure_signed() {
1401− if env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
1361+ if std::env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
14021362 return;
14031363 }
1404− let exe = env::current_exe().expect("failed to get current exe path");
1364+ let exe = std::env::current_exe().expect("failed to get current exe path");
14051365 let exe_str = exe.to_str().expect("exe path not valid utf-8");
14061366
14071367 let has_required_entitlements = {
@@ -1423,10 +1383,10 @@ pub fn ensure_signed() {
14231383 }
14241384
14251385 const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1426− let entitlements_path = env::temp_dir().join("entitlements.plist");
1427− fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
1386+ let entitlements_path = std::env::temp_dir().join("entitlements.plist");
1387+ std::fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
14281388
1429− let output = Command::new("codesign")
1389+ let status = Command::new("codesign")
14301390 .args([
14311391 "--sign",
14321392 "-",
@@ -1435,27 +1395,22 @@ pub fn ensure_signed() {
14351395 entitlements_path.to_str().unwrap(),
14361396 exe_str,
14371397 ])
1438− .output();
1398+ .status();
14391399
1440− let _ = fs::remove_file(&entitlements_path);
1400+ let _ = std::fs::remove_file(&entitlements_path);
14411401
1442− match output {
1443− Ok(o) if o.status.success() => {
1444− let stderr = String::from_utf8_lossy(&o.stderr);
1445− if !stderr.trim().is_empty() {
1446− tracing::debug!(codesign_stderr = %stderr.trim(), "codesign output");
1447− }
1448− let err = Command::new(&exe).args(env::args_os().skip(1)).exec();
1449− tracing::error!(error = %err, "failed to re-exec after signing");
1402+ match status {
1403+ Ok(s) if s.success() => {
1404+ let err = Command::new(&exe).args(std::env::args_os().skip(1)).exec();
1405+ eprintln!("failed to re-exec after signing: {err}");
14501406 std::process::exit(1);
14511407 }
1452− Ok(o) => {
1453− let stderr = String::from_utf8_lossy(&o.stderr);
1454− tracing::error!(status = %o.status, codesign_stderr = %stderr.trim(), "codesign failed");
1408+ Ok(s) => {
1409+ eprintln!("codesign failed with status: {s}");
14551410 std::process::exit(1);
14561411 }
14571412 Err(e) => {
1458− tracing::error!(error = %e, "failed to run codesign");
1413+ eprintln!("failed to run codesign: {e}");
14591414 std::process::exit(1);
14601415 }
14611416 }
src/vm_manager.rs+159−625
@@ -1,15 +1,3 @@
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};
131use std::{
142 env, fs,
153 io::{Read, Write},
@@ -17,6 +5,7 @@ use std::{
175 fs::PermissionsExt,
186 io::AsRawFd,
197 net::{UnixListener, UnixStream},
8+ process::CommandExt,
209 },
2110 path::{Path, PathBuf},
2211 process::{Command, Stdio},
@@ -25,44 +14,30 @@ use std::{
2514 time::{Duration, Instant},
2615};
2716
28−#[cfg_attr(feature = "mock-vm", allow(dead_code))]
29−const VM_ROOT_LOG_NAME: &str = "vm_root.log";
30−const 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−}
17+use crate::{
18+ instance::{
19+ InstanceConfig, build_ssh_login_actions, ensure_instance_dir, ensure_ssh_keypair,
20+ extract_ipv4, load_or_create_instance_config, write_instance_config,
21+ },
22+ session_manager::GLOBAL_DIR_NAME,
23+ vm::{self, DirectoryShare, LoginAction, VmInput},
24+};
4625
47−#[cfg(test)]
48−fn force_exit(reason: &str) -> ! {
49− panic!("{reason}");
50−}
26+const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
27+const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
5128
5229pub fn ensure_manager(
5330 raw_args: &[std::ffi::OsString],
5431 auto_shutdown_ms: u64,
55− config_path: Option<&Path>,
56−) -> Result<UnixStream> {
32+) -> Result<UnixStream, Box<dyn std::error::Error>> {
5733 let project_root = env::current_dir()?;
5834 tracing::debug!(root = %project_root.display(), "ensure vm manager");
5935 let instance_dir = ensure_instance_dir(&project_root)?;
60− cleanup_stale_manager(&project_root)?;
6136 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
6237
6338 if let Ok(stream) = UnixStream::connect(&socket_path) {
6439 send_client_pid(&stream);
65− tracing::info!(path = %socket_path.display(), "connected to an existing vm manager");
40+ tracing::info!(path = %socket_path.display(), "connected to existing vm manager");
6641 return Ok(stream);
6742 }
6843
@@ -70,7 +45,7 @@ pub fn ensure_manager(
7045 let mut lock_file = acquire_spawn_lock(&lock_path)?;
7146 if lock_file.is_some() {
7247 tracing::info!(path = %socket_path.display(), "spawning vm manager");
73− spawn_manager_process(raw_args, auto_shutdown_ms, &instance_dir, config_path)?;
48+ spawn_manager_process(raw_args, auto_shutdown_ms, &instance_dir)?;
7449 } else {
7550 tracing::info!(
7651 path = %socket_path.display(),
@@ -100,11 +75,12 @@ pub fn ensure_manager(
10075 drop(lock_file.take());
10176 let _ = fs::remove_file(&lock_path);
10277 }
103− bail!(format!(
104− "timed out waiting for vm manager socket: {} ({})",
78+ return Err(format!(
79+ "Timed out waiting for vm manager socket: {} ({})",
10580 socket_path.display(),
10681 err
107− ));
82+ )
83+ .into());
10884 }
10985 thread::sleep(Duration::from_millis(100));
11086 }
@@ -112,83 +88,54 @@ pub fn ensure_manager(
11288 }
11389}
11490
115−pub fn run_manager(args: vm::VmArg, auto_shutdown_ms: u64) -> Result<()> {
91+pub fn run_manager(
92+ args: vm::CliArgs,
93+ auto_shutdown_ms: u64,
94+) -> Result<(), Box<dyn std::error::Error>> {
11695 let project_root = env::current_dir()?;
11796 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− };
157− run_manager_with(&project_root, args, auto_shutdown_ms, executor, options)
97+ run_manager_with(
98+ &project_root,
99+ args,
100+ auto_shutdown_ms,
101+ &RealVmExecutor,
102+ ManagerOptions {
103+ ensure_signed: true,
104+ detach: true,
105+ prepare_vm: true,
106+ },
107+ )
158108}
159109
160110fn spawn_manager_process(
161111 raw_args: &[std::ffi::OsString],
162112 auto_shutdown_ms: u64,
163113 instance_dir: &Path,
164− config_path: Option<&Path>,
165−) -> Result<()> {
114+) -> Result<(), Box<dyn std::error::Error>> {
166115 let exe = env::current_exe()?;
167116 let mut supervisor_exe = exe.clone();
168117 supervisor_exe.set_file_name("vibebox-supervisor");
169− // intentional
170− if !supervisor_exe.exists() {
171− bail!(format!(
172− "vibebox-supervisor not found at {}",
173− supervisor_exe.display()
174− ));
175− }
176− let mut cmd = Command::new(supervisor_exe);
118+ let use_supervisor = supervisor_exe.exists();
119+ let mut cmd = if use_supervisor {
120+ Command::new(supervisor_exe)
121+ } else {
122+ let mut cmd = Command::new(exe);
123+ cmd.arg0("vibebox-supervisor");
124+ cmd
125+ };
177126 if raw_args.len() > 1 {
178127 cmd.args(&raw_args[1..]);
179128 }
180− cmd.env("VIBEBOX_INTERNAL", "1");
129+ if !use_supervisor {
130+ cmd.env("VIBEBOX_VM_MANAGER", "1");
131+ }
181132 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
182133 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− }
186− tracing::debug!(auto_shutdown_ms, "vm manager process spawn requested");
187− let log_path = instance_dir.join(VM_MANAGER_LOG_NAME);
134+ tracing::info!(auto_shutdown_ms, "vm manager process spawn requested");
135+ let log_path = instance_dir.join("vm_manager.log");
188136 let log_file = fs::OpenOptions::new()
189137 .create(true)
190− .write(true)
191− .truncate(true)
138+ .append(true)
192139 .open(&log_path)
193140 .ok();
194141 if let Some(file) = log_file {
@@ -205,184 +152,6 @@ fn spawn_manager_process(
205152 Ok(())
206153}
207154
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−
386155fn detach_from_terminal() {
387156 unsafe {
388157 libc::setsid();
@@ -409,30 +178,6 @@ fn wait_for_disconnect(mut stream: UnixStream) {
409178 }
410179}
411180
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−
436181fn send_client_pid(stream: &UnixStream) {
437182 let pid = std::process::id();
438183 let payload = format!("pid={pid}\n");
@@ -442,7 +187,7 @@ fn send_client_pid(stream: &UnixStream) {
442187 }
443188}
444189
445−fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>> {
190+fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>, Box<dyn std::error::Error>> {
446191 match fs::OpenOptions::new()
447192 .write(true)
448193 .create_new(true)
@@ -476,12 +221,6 @@ fn is_lock_stale(lock_path: &Path) -> bool {
476221 }
477222}
478223
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−
485224fn pid_is_alive(pid: u32) -> bool {
486225 let pid = pid as libc::pid_t;
487226 let result = unsafe { libc::kill(pid, 0) };
@@ -495,6 +234,12 @@ fn pid_is_alive(pid: u32) -> bool {
495234 }
496235}
497236
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+
498243fn read_client_pid(stream: &UnixStream) -> Option<u32> {
499244 let mut stream = stream.try_clone().ok()?;
500245 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
@@ -505,7 +250,7 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
505250 Ok(0) => break,
506251 Ok(n) => {
507252 len += n;
508− if buf[..len].contains(&b'\n') || len == buf.len() {
253+ if buf[..len].iter().any(|b| *b == b'\n') || len == buf.len() {
509254 break;
510255 }
511256 }
@@ -527,34 +272,31 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
527272 }
528273}
529274
530−#[cfg_attr(feature = "mock-vm", allow(dead_code))]
531275fn spawn_manager_io(
532276 config: Arc<Mutex<InstanceConfig>>,
533− project_dir: PathBuf,
534− clients: ClientStreams,
535− latest_status: SharedStatus,
277+ instance_dir: PathBuf,
536278 output_monitor: Arc<vm::OutputMonitor>,
537279 vm_output_fd: std::os::unix::io::OwnedFd,
538280 vm_input_fd: std::os::unix::io::OwnedFd,
539281) -> vm::IoContext {
540− let log_path = project_dir.join(INSTANCE_DIR_NAME).join(VM_ROOT_LOG_NAME);
282+ let log_path = instance_dir.join("serial.log");
541283 let log_file = fs::OpenOptions::new()
542284 .create(true)
543− .write(true)
544− .truncate(true)
285+ .append(true)
545286 .open(&log_path)
546287 .ok()
547288 .map(|file| Arc::new(Mutex::new(file)));
548289
290+ let instance_path = instance_dir.join("instance.toml");
549291 let config_for_output = config.clone();
550292 let log_for_output = log_file.clone();
551293 let mut line_buf = String::new();
552294
553295 let on_output = move |bytes: &[u8]| {
554− if let Some(log) = &log_for_output
555− && let Ok(mut file) = log.lock()
556− {
557− let _ = file.write_all(bytes);
296+ if let Some(log) = &log_for_output {
297+ if let Ok(mut file) = log.lock() {
298+ let _ = file.write_all(bytes);
299+ }
558300 }
559301
560302 let text = String::from_utf8_lossy(bytes);
@@ -567,27 +309,17 @@ fn spawn_manager_io(
567309 }
568310 line_buf.drain(..=pos);
569311
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− }
312+ let cleaned = line.trim_start_matches(|c: char| c == '\r' || c == ' ');
582313 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
583314 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
584315 let ip = extract_ipv4(ip_raw).unwrap_or_default();
585− if !ip.is_empty()
586− && let Ok(mut cfg) = config_for_output.lock()
587− && cfg.vm_ipv4.as_deref() != Some(ip.as_str())
588− {
589− cfg.vm_ipv4 = Some(ip.clone());
590− let _ = write_instance_config(&project_dir, &cfg);
316+ if !ip.is_empty() {
317+ if let Ok(mut cfg) = config_for_output.lock() {
318+ if cfg.vm_ipv4.as_deref() != Some(ip.as_str()) {
319+ cfg.vm_ipv4 = Some(ip.clone());
320+ let _ = write_instance_config(&instance_path, &cfg);
321+ }
322+ }
591323 }
592324 }
593325 }
@@ -609,52 +341,80 @@ enum ManagerEvent {
609341 VmExited(Option<String>),
610342}
611343
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+
612382struct ManagerOptions {
383+ ensure_signed: bool,
613384 detach: bool,
614385 prepare_vm: bool,
615386}
616387
617388trait VmExecutor {
618− #[allow(clippy::too_many_arguments)]
619389 fn run_vm(
620390 &self,
621− args: vm::VmArg,
391+ args: vm::CliArgs,
622392 extra_login_actions: Vec<LoginAction>,
623393 extra_shares: Vec<DirectoryShare>,
624394 config: Arc<Mutex<InstanceConfig>>,
625395 instance_dir: PathBuf,
626− clients: ClientStreams,
627− latest_status: SharedStatus,
628396 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
629− ) -> Result<()>;
397+ ) -> Result<(), Box<dyn std::error::Error>>;
630398}
631399
632−#[cfg_attr(feature = "mock-vm", allow(dead_code))]
633400struct RealVmExecutor;
634401
635402impl VmExecutor for RealVmExecutor {
636403 fn run_vm(
637404 &self,
638− args: vm::VmArg,
405+ args: vm::CliArgs,
639406 extra_login_actions: Vec<LoginAction>,
640407 extra_shares: Vec<DirectoryShare>,
641408 config: Arc<Mutex<InstanceConfig>>,
642− project_dir: PathBuf,
643− clients: ClientStreams,
644− latest_status: SharedStatus,
409+ instance_dir: PathBuf,
645410 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
646− ) -> Result<()> {
647− let status_callback = |status: &str| {
648− broadcast_status(&clients, &latest_status, status);
649− };
650− vm::run_with_args_and_extras(
411+ ) -> Result<(), Box<dyn std::error::Error>> {
412+ let result = vm::run_with_args_and_extras(
651413 args,
652414 |output_monitor, vm_output_fd, vm_input_fd| {
653415 let io_ctx = spawn_manager_io(
654416 config.clone(),
655− project_dir.clone(),
656− clients.clone(),
657− latest_status.clone(),
417+ instance_dir.clone(),
658418 output_monitor,
659419 vm_output_fd,
660420 vm_input_fd,
@@ -664,60 +424,35 @@ impl VmExecutor for RealVmExecutor {
664424 },
665425 extra_login_actions,
666426 extra_shares,
667− Some(&status_callback),
668− )
669− }
670−}
671−
672−#[cfg(feature = "mock-vm")]
673−struct MockVmExecutor;
674−
675−#[cfg(feature = "mock-vm")]
676−impl VmExecutor for MockVmExecutor {
677− fn run_vm(
678− &self,
679− _args: vm::VmArg,
680− _extra_login_actions: Vec<LoginAction>,
681− _extra_shares: Vec<DirectoryShare>,
682− _config: Arc<Mutex<InstanceConfig>>,
683− _instance_dir: PathBuf,
684− _clients: ClientStreams,
685− _latest_status: SharedStatus,
686− vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
687− ) -> Result<()> {
688− let (tx, rx) = mpsc::channel::<VmInput>();
689− *vm_input_tx.lock().unwrap() = Some(tx);
690− tracing::info!("mock vm executor running");
691− while let Ok(input) = rx.recv() {
692− match input {
693− VmInput::Shutdown => break,
694− VmInput::Bytes(bytes) => {
695− let text = String::from_utf8_lossy(&bytes);
696− if text.contains("systemctl poweroff") {
697− break;
698− }
699− }
700− }
701− }
702− tracing::info!("mock vm executor exiting");
703− Ok(())
427+ );
428+ result
704429 }
705430}
706431
707432fn run_manager_with(
708433 project_root: &Path,
709− mut args: vm::VmArg,
434+ args: vm::CliArgs,
710435 auto_shutdown_ms: u64,
711436 executor: &dyn VmExecutor,
712437 options: ManagerOptions,
713−) -> Result<()> {
438+) -> Result<(), Box<dyn std::error::Error>> {
439+ if options.ensure_signed {
440+ let _had_skip = env::var("VIBEBOX_SKIP_CODESIGN").ok();
441+ unsafe {
442+ env::remove_var("VIBEBOX_SKIP_CODESIGN");
443+ }
444+ vm::ensure_signed();
445+ unsafe {
446+ env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
447+ }
448+ }
714449 if options.detach {
715450 detach_from_terminal();
716451 }
717452
718453 let project_name = project_root
719454 .file_name()
720− .ok_or_else(|| anyhow!("Project directory has no name"))?
455+ .ok_or("Project directory has no name")?
721456 .to_string_lossy()
722457 .into_owned();
723458 let instance_dir = ensure_instance_dir(project_root)?;
@@ -725,35 +460,21 @@ fn run_manager_with(
725460 let _ = ensure_ssh_keypair(&instance_dir)?;
726461 }
727462
728− let mut config = load_or_create_instance_config(project_root)?;
463+ let mut config = load_or_create_instance_config(&instance_dir)?;
729464 if config.vm_ipv4.is_some() {
730465 config.vm_ipv4 = None;
731− write_instance_config(project_root, &config)?;
466+ write_instance_config(&instance_dir.join("instance.toml"), &config)?;
732467 }
733468 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);
742469
743− let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}");
744470 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
745− let extra_shares = vec![
746− DirectoryShare::new(instance_dir.clone(), ssh_guest_dir.clone().into(), true)
747− .map_err(|err| anyhow!(err.to_string()))?,
748− ];
749− let extra_login_actions = build_ssh_login_actions(
750− &config,
751− &project_name,
752− &project_guest_dir,
753− ssh_guest_dir.as_str(),
754− "ssh_key",
755− &home_links_script,
756− );
471+ let extra_shares = vec![DirectoryShare::new(
472+ instance_dir.clone(),
473+ ssh_guest_dir.clone().into(),
474+ true,
475+ )?];
476+ let extra_login_actions =
477+ build_ssh_login_actions(&config, &project_name, ssh_guest_dir.as_str(), "ssh_key");
757478
758479 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
759480 if let Ok(stream) = UnixStream::connect(&socket_path) {
@@ -768,43 +489,17 @@ fn run_manager_with(
768489 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
769490 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
770491
771− let clients: ClientStreams = Arc::new(Mutex::new(Vec::new()));
772− let latest_status: SharedStatus = Arc::new(Mutex::new(String::new()));
773492 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
774493 let event_tx_accept = event_tx.clone();
775− let clients_accept = clients.clone();
776− let latest_status_accept = latest_status.clone();
777494 thread::spawn(move || {
778495 for stream in listener.incoming() {
779496 match stream {
780497 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− }
799498 let event_tx_conn = event_tx_accept.clone();
800− let clients_conn = clients_accept.clone();
801499 thread::spawn(move || {
802500 let pid = read_client_pid(&stream);
803501 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
804502 wait_for_disconnect(stream);
805− if let Some(fd) = client_fd {
806− remove_client(&clients_conn, fd);
807− }
808503 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
809504 });
810505 }
@@ -824,59 +519,43 @@ fn run_manager_with(
824519 extra_login_actions,
825520 extra_shares,
826521 config.clone(),
827− project_root.to_path_buf(),
828− clients.clone(),
829− latest_status.clone(),
522+ instance_dir.clone(),
830523 vm_input_tx.clone(),
831524 );
832525 tracing::info!("vm manager vm run completed");
833526 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− }
841527 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
842− let event_loop_result = event_loop_handle
528+ let event_loop_result: Result<(), String> = event_loop_handle
843529 .join()
844− .unwrap_or_else(|_| Err(Error::msg("vm manager event loop panicked")))
530+ .unwrap_or_else(|_| Err("vm manager event loop panicked".into()))
845531 .map_err(|err| err.to_string());
846532 let _ = fs::remove_file(&socket_path);
847533 if let Err(err) = &event_loop_result {
848534 tracing::error!(error = %err, "vm manager exiting due to event loop error");
849− bail!(err.to_string());
535+ return Err(err.to_string().into());
850536 }
851537 if let Some(err) = vm_err {
852538 tracing::error!(error = %err, "vm manager exiting due to vm error");
853− bail!(err);
539+ return Err(err.into());
854540 }
855541 tracing::info!("vm manager exiting");
856− event_loop_result.map_err(Error::msg)
542+ Ok(event_loop_result?)
857543}
858544
859545fn manager_event_loop(
860546 event_rx: mpsc::Receiver<ManagerEvent>,
861547 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
862548 auto_shutdown_ms: u64,
863−) -> Result<()> {
549+) -> Result<(), String> {
864550 let mut ref_count: usize = 0;
865551 let mut shutdown_deadline: Option<Instant> = None;
866552 let mut shutdown_sent = false;
867− let mut hard_deadline: Option<Instant> = None;
868553 let grace = Duration::from_millis(auto_shutdown_ms.max(1));
869− let hard_timeout = Duration::from_millis(HARD_SHUTDOWN_TIMEOUT_MS);
870554
871555 loop {
872− let timeout = match (shutdown_deadline, hard_deadline) {
873− (Some(shutdown), Some(hard)) => {
874− let next = if shutdown <= hard { shutdown } else { hard };
875− next.saturating_duration_since(Instant::now())
876− }
877− (Some(shutdown), None) => shutdown.saturating_duration_since(Instant::now()),
878− (None, Some(hard)) => hard.saturating_duration_since(Instant::now()),
879− (None, None) => Duration::from_secs(1),
556+ let timeout = match shutdown_deadline {
557+ Some(deadline) => deadline.saturating_duration_since(Instant::now()),
558+ None => Duration::from_secs(1),
880559 };
881560
882561 match event_rx.recv_timeout(timeout) {
@@ -890,10 +569,11 @@ fn manager_event_loop(
890569 );
891570 shutdown_deadline = None;
892571 shutdown_sent = false;
893− hard_deadline = None;
894572 }
895573 Ok(ManagerEvent::Dec(pid)) => {
896− ref_count = ref_count.saturating_sub(1);
574+ if ref_count > 0 {
575+ ref_count -= 1;
576+ }
897577 tracing::info!(
898578 ref_count,
899579 pid = pid.unwrap_or(0),
@@ -912,53 +592,16 @@ fn manager_event_loop(
912592 break;
913593 }
914594 Err(mpsc::RecvTimeoutError::Timeout) => {
915− if let Some(deadline) = shutdown_deadline
916− && Instant::now() >= deadline
917− && !shutdown_sent
918− {
919− if hard_deadline.is_none() {
920− hard_deadline = Some(Instant::now() + hard_timeout);
921− }
922− let mut sent = false;
923− if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
924− if tx
925− .send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()))
926− .is_ok()
927− {
928− sent = true;
929− } else {
930− tracing::warn!("shutdown command failed to send");
595+ if let Some(deadline) = shutdown_deadline {
596+ if Instant::now() >= deadline && !shutdown_sent {
597+ if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
598+ let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
931599 }
932− } else {
933− tracing::warn!("shutdown command deferred; vm input not ready");
934− }
935− if sent {
936600 tracing::info!("shutdown command sent");
937601 shutdown_sent = true;
938602 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));
943603 }
944604 }
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− }
962605 }
963606 Err(mpsc::RecvTimeoutError::Disconnected) => break,
964607 }
@@ -966,112 +609,3 @@ fn manager_event_loop(
966609
967610 Ok(())
968611}
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.rsdeleted−58
@@ -1,58 +0,0 @@
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.rsdeleted−278
@@ -1,278 +0,0 @@
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.rsdeleted−455
@@ -1,455 +0,0 @@
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.rsdeleted−113
@@ -1,113 +0,0 @@
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.tomldeleted−11
@@ -1,11 +0,0 @@
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