on main help sign in
.github/workflows/ci.ymlnew file+98
since splitsnapshots
unifiedsplit
@@ -0,0 +1,98 @@
1+name: CI
2+
3+on:
4+ push:
5+ branches:
6+ - "**"
7+ paths-ignore:
8+ - "README.md"
9+ - "CONTRIBUTING.md"
10+ - "README.zh.md"
11+ - "docs/**"
12+ - "install"
13+
14+concurrency:
15+ group: ci-${{ github.ref }}
16+ cancel-in-progress: true
17+
18+jobs:
19+ fmt:
20+ name: Format
21+ runs-on: macos-latest
22+ env:
23+ RUST_BACKTRACE: "1"
24+ steps:
25+ - uses: actions/checkout@v6
26+ - name: Install Rust
27+ uses: dtolnay/rust-toolchain@stable
28+ with:
29+ components: rustfmt
30+ - name: Cache Rust
31+ uses: Swatinem/rust-cache@v2
32+ - name: cargo fmt
33+ run: cargo fmt --all -- --check
34+
35+ clippy:
36+ name: Clippy
37+ runs-on: macos-latest
38+ env:
39+ RUST_BACKTRACE: "1"
40+ steps:
41+ - uses: actions/checkout@v6
42+ - name: Install Rust
43+ uses: dtolnay/rust-toolchain@stable
44+ with:
45+ components: clippy
46+ - name: Cache Rust
47+ uses: Swatinem/rust-cache@v2
48+ - name: cargo clippy
49+ run: cargo clippy --all-targets --all-features -- -D warnings
50+
51+ build:
52+ name: Build
53+ runs-on: macos-latest
54+ env:
55+ RUST_BACKTRACE: "1"
56+ steps:
57+ - uses: actions/checkout@v6
58+ - name: Install Rust
59+ uses: dtolnay/rust-toolchain@stable
60+ - name: Cache Rust
61+ uses: Swatinem/rust-cache@v2
62+ - name: cargo build
63+ run: cargo build --locked
64+
65+ test:
66+ name: Test
67+ runs-on: macos-latest
68+ env:
69+ RUST_BACKTRACE: "full"
70+ steps:
71+ - uses: actions/checkout@v6
72+ - name: Install Rust
73+ uses: dtolnay/rust-toolchain@stable
74+ - name: Cache Rust
75+ uses: Swatinem/rust-cache@v2
76+ - name: cargo test
77+ run: cargo test --locked
78+
79+ coverage:
80+ name: Coverage
81+ runs-on: macos-latest
82+ needs: [fmt, clippy, build, test]
83+ env:
84+ RUST_BACKTRACE: "full"
85+ steps:
86+ - uses: actions/checkout@v6
87+ - name: Install Rust
88+ uses: dtolnay/rust-toolchain@stable
89+ with:
90+ components: llvm-tools-preview
91+ - name: Cache Rust
92+ uses: Swatinem/rust-cache@v2
93+ - name: Install cargo-llvm-cov
94+ uses: taiki-e/install-action@v2
95+ with:
96+ tool: cargo-llvm-cov
97+ - name: cargo llvm-cov
98+ run: cargo llvm-cov --locked --features mock-vm --tests -- --nocapture
.github/workflows/release.ymlnew file+93
@@ -0,0 +1,93 @@
1+name: Build and Release
2+
3+permissions:
4+ contents: write
5+
6+on:
7+ push:
8+ tags:
9+ - "v*.*.*"
10+
11+concurrency:
12+ group: release-${{ github.ref }}
13+ cancel-in-progress: true
14+
15+jobs:
16+ build:
17+ runs-on: macos-latest
18+
19+ steps:
20+ - uses: actions/checkout@v6
21+ with:
22+ fetch-depth: 0
23+
24+ - name: Validate release tag format
25+ run: |
26+ TAG="${GITHUB_REF_NAME}"
27+ if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z]+)?$ ]]; then
28+ echo "Tag '$TAG' does not match required format vX.X.X or vX.X.X-word"
29+ exit 1
30+ fi
31+
32+ - name: Install Rust
33+ uses: dtolnay/rust-toolchain@stable
34+
35+ - name: Cache Rust
36+ uses: Swatinem/rust-cache@v2
37+
38+ - name: Resolve target commit
39+ run: |
40+ TARGET_SHA="$(git rev-list -n 1 "${GITHUB_REF_NAME}")"
41+ echo "TARGET_SHA=$TARGET_SHA" >> "$GITHUB_ENV"
42+
43+ - name: Verify CI passed
44+ env:
45+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46+ run: |
47+ COUNT="$(gh run list --workflow ci.yml --commit "$TARGET_SHA" --json conclusion,createdAt --jq 'length')"
48+ if [ "$COUNT" -eq 0 ]; then
49+ echo "No CI runs found for commit $TARGET_SHA"
50+ exit 1
51+ fi
52+ CONCLUSION="$(gh run list --workflow ci.yml --commit "$TARGET_SHA" --json conclusion,createdAt --jq '.[0].conclusion')"
53+ if [ "$CONCLUSION" != "success" ]; then
54+ echo "Latest CI run for commit $TARGET_SHA is $CONCLUSION"
55+ exit 1
56+ fi
57+
58+ - name: Build release binary
59+ run: cargo build --release --locked
60+
61+ - name: Publish crate
62+ env:
63+ CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
64+ run: cargo publish --locked
65+
66+ - name: Package binary
67+ run: |
68+ cd target/release
69+ zip vibebox-macos-arm64.zip vibebox
70+
71+ - name: Create tag release
72+ run: |
73+ gh release create "${{ github.ref_name }}" \
74+ --title "${{ github.ref_name }}" \
75+ --notes "Built from commit $TARGET_SHA" \
76+ target/release/vibebox-macos-arm64.zip
77+ env:
78+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
79+
80+ - name: Delete existing latest release
81+ run: gh release delete latest --cleanup-tag --yes || true
82+ env:
83+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
84+
85+ - name: Create latest release
86+ run: |
87+ gh release create latest \
88+ --title "Latest" \
89+ --notes "Built from commit $TARGET_SHA" \
90+ --prerelease \
91+ target/release/vibebox-macos-arm64.zip
92+ env:
93+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CONTRIBUTING.mdnew file+44
@@ -0,0 +1,44 @@
1+# Contributing to Vibebox
2+
3+Thanks for your interest in contributing! This guide keeps PRs small, reviewable, and consistent with the project’s
4+development workflow.
5+
6+**Prerequisites**
7+
8+- macOS on Apple Silicon (required for the virtualization backend)
9+- Rust `1.91.1` or newer (see `Cargo.toml`)
10+
11+**Getting Started**
12+
13+1. Fork the repo and create a feature branch.
14+2. Build once to validate your toolchain:
15+
16+```bash
17+cargo build --locked
18+```
19+
20+**Development Commands**
21+
22+- Format: `cargo fmt --all -- --check`
23+- Lint: `cargo clippy --all-targets --all-features -- -D warnings`
24+- Test: `cargo test --locked`
25+- Build: `cargo build --locked`
26+
27+**Submitting Changes**
28+
29+- Keep changes focused and scoped to one problem.
30+- Update or add tests when behavior changes.
31+- If you change user-facing behavior, update docs or help text.
32+- Avoid adding heavy dependencies without a clear reason.
33+
34+**Reporting Issues**
35+Please include:
36+
37+- macOS version and hardware (Apple Silicon model)
38+- Vibebox version (`vibebox --version`)
39+- Steps to reproduce
40+- Logs from `.vibebox/cli.log`, `.vibebox/vm_root.log` and `.vibebox/vm_manager.log`
41+
42+**Security**
43+If you believe you’ve found a security issue, please avoid public disclosure. Open a private report via GitHub Security
44+Advisories instead.
Cargo.lock+93−1
@@ -82,6 +82,27 @@ dependencies = [
82 "windows-sys 0.61.2",
82 "windows-sys 0.61.2",
83]
83]
84
84
85+[[package]]
86+name = "anyhow"
87+version = "1.0.101"
88+source = "registry+https://github.com/rust-lang/crates.io-index"
89+checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
90+
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+
85[[package]]
106[[package]]
86name = "backtrace"
107name = "backtrace"
87version = "0.3.76"
108version = "0.3.76"
@@ -112,12 +133,32 @@ dependencies = [
112 "objc2",
133 "objc2",
113]
134]
114
135
136+[[package]]
137+name = "bstr"
138+version = "1.12.1"
139+source = "registry+https://github.com/rust-lang/crates.io-index"
140+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
141+dependencies = [
142+ "memchr",
143+ "regex-automata",
144+ "serde",
145+]
146+
115[[package]]
147[[package]]
116name = "bumpalo"
148name = "bumpalo"
117version = "3.19.1"
149version = "3.19.1"
118source = "registry+https://github.com/rust-lang/crates.io-index"
150source = "registry+https://github.com/rust-lang/crates.io-index"
119checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
151checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
120
152
153+[[package]]
154+name = "bytesize"
155+version = "2.3.1"
156+source = "registry+https://github.com/rust-lang/crates.io-index"
157+checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3"
158+dependencies = [
159+ "serde_core",
160+]
161+
121[[package]]
162[[package]]
122name = "cassowary"
163name = "cassowary"
123version = "0.3.0"
164version = "0.3.0"
@@ -321,6 +362,12 @@ dependencies = [
321 "zeroize",
362 "zeroize",
322]
363]
323
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+
324[[package]]
371[[package]]
325name = "dispatch2"
372name = "dispatch2"
326version = "0.3.0"
373version = "0.3.0"
@@ -737,6 +784,33 @@ version = "0.2.0"
737source = "registry+https://github.com/rust-lang/crates.io-index"
784source = "registry+https://github.com/rust-lang/crates.io-index"
738checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
785checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
739
786
787+[[package]]
788+name = "predicates"
789+version = "3.1.3"
790+source = "registry+https://github.com/rust-lang/crates.io-index"
791+checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
792+dependencies = [
793+ "anstyle",
794+ "difflib",
795+ "predicates-core",
796+]
797+
798+[[package]]
799+name = "predicates-core"
800+version = "1.0.9"
801+source = "registry+https://github.com/rust-lang/crates.io-index"
802+checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
803+
804+[[package]]
805+name = "predicates-tree"
806+version = "1.0.12"
807+source = "registry+https://github.com/rust-lang/crates.io-index"
808+checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
809+dependencies = [
810+ "predicates-core",
811+ "termtree",
812+]
813+
740[[package]]
814[[package]]
741name = "proc-macro2"
815name = "proc-macro2"
742version = "1.0.106"
816version = "1.0.106"
@@ -1007,6 +1081,12 @@ dependencies = [
1007 "windows-sys 0.61.2",
1081 "windows-sys 0.61.2",
1008]
1082]
1009
1083
1084+[[package]]
1085+name = "termtree"
1086+version = "0.5.1"
1087+source = "registry+https://github.com/rust-lang/crates.io-index"
1088+checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
1089+
1010[[package]]
1090[[package]]
1011name = "thiserror"
1091name = "thiserror"
1012version = "2.0.18"
1092version = "2.0.18"
@@ -1238,9 +1318,12 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
1238
1318
1239[[package]]
1319[[package]]
1240name = "vibebox"
1320name = "vibebox"
1241−version = "0.1.0"
1321+version = "0.3.2"
1242dependencies = [
1322dependencies = [
1323+ "anyhow",
1324+ "assert_cmd",
1243 "block2",
1325 "block2",
1326+ "bytesize",
1244 "clap",
1327 "clap",
1245 "color-eyre",
1328 "color-eyre",
1246 "crossterm",
1329 "crossterm",
@@ -1261,6 +1344,15 @@ dependencies = [
1261 "uuid",
1344 "uuid",
1262]
1345]
1263
1346
1347+[[package]]
1348+name = "wait-timeout"
1349+version = "0.2.1"
1350+source = "registry+https://github.com/rust-lang/crates.io-index"
1351+checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
1352+dependencies = [
1353+ "libc",
1354+]
1355+
1264[[package]]
1356[[package]]
1265name = "wasi"
1357name = "wasi"
1266version = "0.11.1+wasi-snapshot-preview1"
1358version = "0.11.1+wasi-snapshot-preview1"
Cargo.toml+25−7
@@ -1,11 +1,20 @@
1[package]
1[package]
2name = "vibebox"
2name = "vibebox"
3−version = "0.1.0"
3+version = "0.3.2"
4edition = "2024"
4edition = "2024"
5+authors = ["Finn Sheng"]
6+description = "Ultrafast CLI on Apple Silicon macOS for fast, sandboxed development and LLM agents."
7+homepage = "https://github.com/robcholz/vibebox"
8+repository = "https://github.com/robcholz/vibebox"
9+readme = "README.md"
10+license = "MIT"
11+categories = ["virtualization","command-line-utilities"]
12+keywords = ["virtualization", "ai", "macos", "agent", "sandbox"]
13+rust-version = "1.91.1"
5
14
6[dependencies]
15[dependencies]
7−objc2 = "*"
16+objc2 = "0.6.3"
8−objc2-foundation = { version = "*", features = [
17+objc2-foundation = { version = "0.3.2", features = [
9 "NSArray",
18 "NSArray",
10 "NSString",
19 "NSString",
11 "NSURL",
20 "NSURL",
@@ -16,10 +25,10 @@ objc2-foundation = { version = "*", features = [
16 "NSRunLoop",
25 "NSRunLoop",
17 "NSObject",
26 "NSObject",
18] }
27] }
19−objc2-virtualization = "*"
28+objc2-virtualization = "0.3.2"
20−block2 = "*"
29+block2 = "0.6.2"
21−dispatch2 = "*"
30+dispatch2 = "0.3.0"
22−libc = "*"
31+libc = "0.2.180"
23clap = { version = "4", features = ["derive"] }
32clap = { version = "4", features = ["derive"] }
24serde = { version = "1", features = ["derive"] }
33serde = { version = "1", features = ["derive"] }
25tempfile = "3"
34tempfile = "3"
@@ -33,3 +42,12 @@ ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] }
33tracing = "0.1"
42tracing = "0.1"
34tracing-subscriber = { version = "0.3", features = ["env-filter"] }
43tracing-subscriber = { version = "0.3", features = ["env-filter"] }
35dialoguer = "0.12.0"
44dialoguer = "0.12.0"
45+bytesize = {version = "2.3.1",features = ["serde"]}
46+anyhow = "1.0.101"
47+
48+[dev-dependencies]
49+assert_cmd = "2"
50+tempfile = "3"
51+
52+[features]
53+mock-vm = []
README.mdnew file+223
@@ -0,0 +1,223 @@
1+<p align="center">
2+ <a href="https://vibebox.robcholz.com">
3+ <picture>
4+ <img src="docs/banner.png" alt="VibeBox logo">
5+ </picture>
6+ </a>
7+</p>
8+<p align="center">an ultrafast, open-source sandbox for running coding agents safely.</p>
9+
10+<p align="center">
11+ <a href="https://crates.io/crates/vibebox">
12+ <img alt="Crates.io" src="https://img.shields.io/crates/v/vibebox.svg">
13+ </a>
14+ <a href="https://github.com/robcholz/vibebox/blob/main/LICENSE">
15+ <img alt="MIT licensed" src="https://img.shields.io/badge/license-MIT-blue.svg">
16+ </a>
17+ <a href="https://github.com/robcholz/vibebox/actions?query=workflow%3ACI+branch%3Amain">
18+ <img alt="Build Status" src="https://github.com/robcholz/vibebox/workflows/CI/badge.svg">
19+ </a>
20+</p>
21+
22+<p align="center">
23+ <a href="README.md">English</a> |
24+ <a href="README.zh.md">简体中文</a>
25+</p>
26+
27+**VibeBox is a per-project micro-VM sandbox for running coding agents on macOS (Apple Virtualization Framework).**
28+It’s optimized for a *daily-driver* workflow: fast warm re-entry, explicit mounts, and reusable sessions.
29+
30+**Who it’s for:** macOS users running coding agents who want real isolation without giving up a fast daily workflow.
31+
32+**Quick facts:** warm re-entry is typically **<5s** on my M3 (varies by machine/cache); first run downloads and
33+provisions a Debian base image (network dependent).
34+
35+**Security model:** Linux guest VM with explicit mount allowlists from `vibebox.toml` (repo-first, everything else
36+opt-in).
37+
38+- **enter/attach in seconds:** `vibebox` drops you into a reusable sandbox for the current repo
39+- **project-scoped by default:** explicit mounts + repo-contained changes (repo-first, everything else is allowlisted)
40+- **sessioned:** multi-instance + session management (reuse, multiple terminals, cleanup)
41+
42+### Quick Demo
43+
44+```bash
45+# from any repo
46+cd my-project
47+vibebox
48+```
49+
50+What you should see (roughly):
51+
52+```text
53+vibebox: starting (session: my-project)
54+vibebox: attaching...
55+vibecoder@vibebox:~/my-project$
56+```
57+
58+[![VibeBox Terminal UI](docs/screenshot.png)](https://vibebox.robcholz.com)
59+
60+---
61+
62+### Why I built VibeBox
63+
64+I use coding agents daily, and I wanted to give them a real shell without handing them my host machine.
65+Lock things down and you get nonstop confirmations; loosen it up and you worry about deleting files, touching secrets,
66+or wandering outside the repo.
67+
68+VibeBox is the middle ground: a per-repo sandbox with a hard VM boundary, fast re-entry, and explicit mounts. It’s built
69+to be “always on” for agent work without turning safety into a chore.
70+
71+### Why a micro-VM (vs containers)?
72+
73+Containers are great. VibeBox isn’t trying to replace Docker/devcontainers for building services.
74+
75+I specifically wanted a VM-shaped default for agent workflows on macOS:
76+
77+- **guest-kernel isolation boundary by default:** when I’m letting an agent run arbitrary commands, I want “safe mode”
78+ to
79+ be a Linux guest, not my host.
80+- **sessions as a first-class workflow:** attach/reuse per repo, multiple terminals into the same sandbox, reliable
81+ cleanup to avoid orphan environments.
82+- **explicit mount allowlists as the primary UX:** repo-scoped by default; anything else is an explicit decision.
83+- **minimal per-repo setup:** you *can* reproduce parts of this with compose/devcontainers, but I wanted a single
84+ command
85+ that works repo-to-repo without maintaining container configs for the basic “safe shell” workflow.
86+
87+### Comparison
88+
89+Here’s why I didn’t just use existing options:
90+
91+- **vibe**: super convenient and nails “zero-config, just go”. VibeBox is intentionally on a different axis: per-repo
92+ config + sessions + multi-instance lifecycle.
93+- **QEMU**: powerful, but the configuration surface area is huge. For day-to-day sandboxing it’s not “open a repo and
94+ go” — it’s a project on its own.
95+- **Docker / devcontainers / devpods**: great ecosystem. My friction wasn’t raw startup time, it was the day-to-day
96+ overhead of keeping per-repo agent sandboxes *safe-by-default* (mount allowlists, secrets exposure, attach/reuse,
97+ cleanup) without maintaining container configs per repo for the basic workflow.
98+
99+That’s what pushed me to build **VibeBox**: I wanted a per-project sandbox that’s fast to enter (just `vibebox`),
100+supports real configuration + sessions, and keeps a hard isolation boundary.
101+
102+### Installation
103+
104+```bash
105+# install script
106+curl -fsSL https://raw.githubusercontent.com/robcholz/vibebox/main/install | bash
107+
108+# package managers
109+cargo install vibebox
110+
111+# manual install
112+curl -LO https://github.com/robcholz/vibebox/releases/download/latest/vibebox-macos-arm64.zip
113+unzip vibebox-macos-arm64.zip
114+mkdir -p ~/.local/bin
115+mv vibebox ~/.local/bin
116+export PATH="$HOME/.local/bin:$PATH"
117+```
118+
119+**Requirements**
120+
121+- macOS on Apple Silicon (VibeBox uses Apple's virtualization APIs).
122+
123+**First Run**
124+
125+The first `vibebox` run downloads a Debian base image and provisions it. After that, per-project instances reuse the
126+cached base image for much faster startups.
127+
128+### Documentation
129+
130+**Quick Start**
131+
132+```bash
133+cd /path/to/your/project
134+vibebox
135+```
136+
137+On first run, VibeBox creates `vibebox.toml` in your project (if missing) and a `.vibebox/` directory for instance data.
138+
139+**Configuration (`vibebox.toml`)**
140+
141+`vibebox.toml` lives in your project root by default. You can override it with `vibebox -c path/to/vibebox.toml` or the
142+`VIBEBOX_CONFIG_PATH` env var, but the path must stay inside the project directory.
143+
144+Default config (auto-created when missing):
145+
146+```toml
147+[box]
148+cpu_count = 2
149+ram_mb = 2048
150+disk_gb = 5
151+mounts = [
152+ "~/.codex:~/.codex:read-write",
153+ "~/.claude:~/.claude:read-write",
154+]
155+
156+[supervisor]
157+auto_shutdown_ms = 20000
158+```
159+
160+`disk_gb` is only applied when the instance disk is first created. If you change it later, run `vibebox reset` to
161+recreate the disk.
162+
163+**Mounts**
164+
165+- Your project is mounted read-write at `~/<project-name>`, and the shell starts there.
166+- If a `.git` directory exists, it is masked with a tmpfs mount inside the VM to discourage accidental edits from the
167+ guest.
168+- Extra mounts come from `box.mounts` with the format `host:guest[:read-only|read-write]`.
169+- Host paths support `~` expansion. Relative guest paths are treated as `/root/<path>`.
170+- Guest paths that use `~` are linked into `/home/<ssh-user>` for convenience. Run `vibebox explain` to see the resolved
171+ host/guest mappings.
172+
173+**CLI Commands**
174+
175+```bash
176+vibebox # start or attach to the current project VM
177+vibebox list # list known project sessions
178+vibebox reset # delete .vibebox for this project and recreate on next run
179+vibebox purge-cache # delete the global cache (~/.cache/vibebox)
180+vibebox explain # show mounts and network info
181+```
182+
183+**Inside the VM**
184+
185+- Default SSH user: `vibecoder`
186+- Hostname: `vibebox`
187+- Base image provisioning installs: build tools, `git`, `curl`, `ripgrep`, `openssh-server`, and `sudo`.
188+- On first login, VibeBox installs `mise` and configures tools like `uv`, `node`, `@openai/codex`, and
189+ `@anthropic-ai/claude-code` (best-effort).
190+- Shell aliases: `:help` and `:exit`.
191+
192+**State & Cache**
193+
194+- Project state lives in `.vibebox/` (instance disk, SSH keys, logs, manager socket/pid). `vibebox reset` removes it.
195+- Global cache lives in `~/.cache/vibebox` (base image + shared guest cache). `vibebox purge-cache` clears it.
196+- Session index lives in `~/.vibebox/sessions` and is shown by `vibebox list`.
197+
198+### Contributing
199+
200+If you're interested in contributing to VibeBox, please read our [contributing docs](CONTRIBUTING.md) before
201+submitting a pull request.
202+
203+### FAQ
204+
205+#### How is this different from other sandboxes?
206+
207+VibeBox is built for fast, repeatable local sandboxes with minimal ceremony. What’s different here:
208+
209+- Warm re-entry is typically **<5s** on my M3 (varies by machine/cache), so you can jump back in quickly.
210+- One simple command — `vibebox` — drops you into the sandbox from your project.
211+- Configuration lives in `vibebox.toml`, where you can set CPU, RAM, disk size, and mounts.
212+- Sessions are first-class: reuse, multiple terminals, cleanup.
213+
214+### Special thanks
215+
216+[vibe](https://github.com/lynaghk/vibe) by lynaghk.
217+
218+And the amazing Rust community — without the ecosystem and toolchain like [crates.io](https://crates.io), this wouldn't
219+be possible!
220+
221+---
222+
223+**Follow me on X** [x.com/robcholz](https://x.com/robcholz)
README.zh.mdnew file+209
@@ -0,0 +1,209 @@
1+<p align="center">
2+ <a href="https://vibebox.robcholz.com">
3+ <picture>
4+ <img src="docs/banner.png" alt="VibeBox logo">
5+ </picture>
6+ </a>
7+</p>
8+<p align="center">用于安全运行 coding agents 的超高速开源沙盒。</p>
9+
10+<p align="center">
11+ <a href="https://crates.io/crates/vibebox">
12+ <img alt="Crates.io" src="https://img.shields.io/crates/v/vibebox.svg">
13+ </a>
14+ <a href="https://github.com/robcholz/vibebox/blob/main/LICENSE">
15+ <img alt="MIT licensed" src="https://img.shields.io/badge/license-MIT-blue.svg">
16+ </a>
17+ <a href="https://github.com/robcholz/vibebox/actions?query=workflow%3ACI+branch%3Amain">
18+ <img alt="Build Status" src="https://github.com/robcholz/vibebox/workflows/CI/badge.svg">
19+ </a>
20+</p>
21+
22+<p align="center">
23+ <a href="README.zh.md">简体中文</a> |
24+ <a href="README.md">English</a>
25+</p>
26+
27+**VibeBox 是一个按项目划分的 micro-VM 沙盒,用于在 macOS 上运行 coding agents(基于 Apple Virtualization Framework)。**
28+它面向 *日常使用* 工作流优化:快速热启动、显式挂载、可复用会话。
29+
30+**适合谁:** 在 macOS 上使用 coding agents,并且既想要真实隔离又不想牺牲日常效率的人。
31+
32+**快速事实:** 在我的 M3 上,热启动通常 **<5s**(因机器/缓存而异);首次运行会下载并初始化 Debian 基础镜像(受网络影响)。
33+
34+**安全模型:** Linux 来宾 VM + `vibebox.toml` 显式挂载白名单(默认仅项目目录,其它均需显式允许)。
35+
36+- **几秒进入/附加:** `vibebox` 直接进入当前仓库的可复用沙盒
37+- **默认按项目范围:** 显式挂载 + 改动限制在仓库内(repo 优先,其他需 allowlist)
38+- **会话化:** 多实例 + 会话管理(复用、多终端、清理)
39+
40+### 快速演示
41+
42+```bash
43+# 在任意仓库内
44+cd my-project
45+vibebox
46+```
47+
48+你大致会看到:
49+
50+```text
51+vibebox: starting (session: my-project)
52+vibebox: attaching...
53+vibecoder@vibebox:~/my-project$
54+```
55+
56+[![VibeBox Terminal UI](docs/screenshot.png)](https://vibebox.robcholz.com)
57+
58+---
59+
60+### 我为什么做 VibeBox
61+
62+我每天都在用 coding agents,也希望它们有一个真实的 shell,但不想把宿主机直接交出去。
63+权限收紧会被不停的确认打断;权限放开又担心误删文件、触及密钥,或者跑出仓库边界。
64+
65+VibeBox 是中间方案:按项目隔离、硬 VM 边界、快速回到工作状态、显式挂载。它适合把 agent 当成日常工具,而不是把安全变成负担。
66+
67+### 为什么是 micro-VM(而不是容器)?
68+
69+容器很好用。VibeBox 并不是用来替代 Docker/devcontainers 去构建服务。
70+
71+我更想要的是在 macOS 上适合 agent 的 VM 默认形态:
72+
73+- **默认是 guest-kernel 隔离边界:** 让 agent 跑任意命令时,“安全模式”是 Linux 来宾而不是宿主机。
74+- **会话是第一等公民:** 按项目附加/复用,多终端进入同一沙盒,可靠清理,避免孤儿环境。
75+- **显式挂载白名单作为主要 UX:** 默认仅项目目录,其它都需显式允许。
76+- **最少的每项目配置:** 你可以用 compose/devcontainers 实现一部分,但我想要的是一个命令,在不同仓库间直接工作,不必维护容器配置来获得基础“安全
77+ shell”体验。
78+
79+### 对比
80+
81+下面是我为什么没有直接用现成方案的原因:
82+
83+- **vibe**:非常方便,“零配置、直接用”做得很好。但 VibeBox 走的是另一条路:按项目配置 + 会话 + 多实例生命周期。
84+- **QEMU**:很强大,但配置面太大了。日常当沙箱用,它不像是“进到 repo 就能用”,更像是你得先把它当成一个项目来折腾。
85+- **Docker / devcontainers / devpods**:生态很成熟。我的痛点不在启动时间,而是日常保持
86+ safe-by-default(挂载白名单、密钥暴露、附加/复用、清理)的开销,不想为基础 workflow 在每个项目维护容器配置。
87+
88+这就是我做 **VibeBox** 的原因:我想要一个按项目隔离的沙箱,进入快(直接 `vibebox`),支持真实配置 + 会话,同时保持硬隔离边界。
89+
90+### 安装
91+
92+```bash
93+# YOLO:一键安装
94+curl -fsSL https://raw.githubusercontent.com/robcholz/vibebox/main/install | bash
95+
96+# Cargo
97+cargo install vibebox
98+
99+# 或者手动安装
100+curl -LO https://github.com/robcholz/vibebox/releases/download/latest/vibebox-macos-arm64.zip
101+unzip vibebox-macos-arm64.zip
102+mkdir -p ~/.local/bin
103+mv vibebox ~/.local/bin
104+export PATH="$HOME/.local/bin:$PATH"
105+```
106+
107+**系统要求**
108+
109+- Apple Silicon 的 macOS(VibeBox 使用了 Apple 的虚拟化 API)。
110+
111+**首次运行**
112+
113+第一次执行 `vibebox` 会下载 Debian 基础镜像并完成初始化。之后每个项目的实例会复用缓存的基础镜像,
114+启动会快很多。
115+
116+### 文档
117+
118+**快速开始**
119+
120+```bash
121+cd /path/to/your/project
122+vibebox
123+```
124+
125+第一次运行时,如果项目目录里缺少配置,VibeBox 会自动创建 `vibebox.toml`(放在项目根目录),并创建
126+`.vibebox/` 用来保存实例数据。
127+
128+**配置(`vibebox.toml`)**
129+
130+默认情况下,`vibebox.toml` 位于项目根目录。你可以用 `vibebox -c path/to/vibebox.toml` 或设置
131+`VIBEBOX_CONFIG_PATH` 环境变量来覆盖路径,但配置文件必须仍然位于项目目录内部。
132+
133+默认配置(缺失时会自动生成):
134+
135+```toml
136+[box]
137+cpu_count = 2
138+ram_mb = 2048
139+disk_gb = 5
140+mounts = [
141+ "~/.codex:~/.codex:read-write",
142+ "~/.claude:~/.claude:read-write",
143+]
144+
145+[supervisor]
146+auto_shutdown_ms = 20000
147+```
148+
149+注意:`disk_gb` 只在「首次创建实例磁盘」时生效。之后如果你改了它,需要运行 `vibebox reset` 重新创建磁盘。
150+
151+**挂载(Mounts)**
152+
153+- 你的项目会以读写方式挂载到 `~/<project-name>`,并且 shell 会默认从那里启动。
154+- 如果项目里存在 `.git` 目录,VM 内会用 tmpfs 把它遮住,避免你在 guest 里误操作改到 Git 元数据。
155+- 额外挂载通过 `box.mounts` 配置,格式为 `host:guest[:read-only|read-write]`。
156+- Host 路径支持 `~` 展开;guest 的相对路径会被视为 `/root/<path>`。
157+- guest 路径如果用了 `~`,会为了方便被链接到 `/home/<ssh-user>` 下。你可以运行 `vibebox explain`
158+ 查看最终解析后的 host/guest 映射关系。
159+
160+**CLI 命令**
161+
162+```bash
163+vibebox # 启动或连接当前项目的 VM
164+vibebox list # 列出已知的项目会话
165+vibebox reset # 删除当前项目的 .vibebox,下一次运行会重新创建
166+vibebox purge-cache # 删除全局缓存(~/.cache/vibebox)
167+vibebox explain # 显示挂载与网络信息
168+```
169+
170+**在 VM 内部**
171+
172+- 默认 SSH 用户:`vibecoder`
173+- 主机名:`vibebox`
174+- 基础镜像初始化会安装:构建工具、`git`、`curl`、`ripgrep`、`openssh-server`、`sudo`
175+- 首次登录时,VibeBox 会安装 `mise`,并尽力配置 `uv`、`node`、`@openai/codex`、
176+ `@anthropic-ai/claude-code` 等工具(best-effort,视网络和环境而定)
177+- Shell 里有两个别名:`:help` 和 `:exit`
178+
179+**状态与缓存**
180+
181+- 项目级状态在 `.vibebox/`(实例磁盘、SSH key、日志、manager socket/pid)。`vibebox reset` 会移除它。
182+- 全局缓存在 `~/.cache/vibebox`(基础镜像 + 共享 guest 缓存)。`vibebox purge-cache` 会清空它。
183+- 会话索引在 `~/.vibebox/sessions`,可以通过 `vibebox list` 查看。
184+
185+### 参与贡献
186+
187+如果你想参与贡献 VibeBox,请先阅读 [贡献指南](CONTRIBUTING.md),再提交 Pull Request。
188+
189+### FAQ
190+
191+#### 它和其它 Sandboxes 有什么不同?
192+
193+VibeBox 追求的是:本地、可复现、启动快、流程简单。主要差异点:
194+
195+- 在我的 M3 上,热启动通常 **<5s**,可以非常快地回到工作状态。
196+- 一个命令——`vibebox`——直接把你带进沙盒(从你的项目目录启动)。
197+- 配置集中在 `vibebox.toml`,CPU / 内存 / 磁盘大小 / 挂载都能一眼看懂、随手改。
198+- 会话是第一等公民:复用、多终端、清理。
199+
200+### 特别鸣谢
201+
202+[vibe](https://github.com/lynaghk/vibe) by lynaghk。
203+
204+以及 Rust 社区。没有你们丰富的 crates 生态和优秀的工具链(比如 [crates.io](https://crates.io)),
205+这个项目不可能这么顺利!Rust教。
206+
207+---
208+
209+**在 X 上关注我** [X.com](https://x.com/robcholz)
docs/banner.pngnew file

Binary file not shown.

docs/screenshot.pngnew file

Binary file not shown.

docs/tasks.md+14−9
@@ -25,7 +25,7 @@
255. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar.
255. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar.
266. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio.
266. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio.
277. [x] Add a standalone TUI CLI binary (no main.rs wiring) with placeholder VM info and TODOs for VM integration.
277. [x] Add a standalone TUI CLI binary (no main.rs wiring) with placeholder VM info and TODOs for VM integration.
28−8. [ ] Run tests and validate coverage for the new module.
28+8. [x] Run tests and validate coverage for the new module.
29
29
30## TUI
30## TUI
31
31
@@ -49,18 +49,23 @@
49 4. [x] set up the cli.
49 4. [x] set up the cli.
509. [x] fix ui overlap, and consistency issue.
509. [x] fix ui overlap, and consistency issue.
5110. [x] `purge-cache` to clear the cache.
5110. [x] `purge-cache` to clear the cache.
52−11. [ ] intensive integration test.
52+11. [x] intensive integration test.
53
53
54## Publish
54## Publish
55
55
56−1. [ ] write the docs.
56+1. [x] write the docs.
57−2. [ ] setup quick install link.
57+2. [x] setup quick install link.
58−3. [ ] setup website.
58+3. [x] setup website.
59
59
60## Stage 2
60## Stage 2
61
61
621. [ ] retouch the cli ux.
621. [ ] retouch the cli ux.
63−2. [ ] refactor the code.
63+2. [x] refactor the code.
64−3. [ ] Redirect vm output to log.
64+3. [ ] refactor the mount system.
65+4. [x] refactor the vm process lifetime.
66+5. [x] Redirect vm output to log.
65−4. [ ] Redirect vm output to vibebox starting it.
67+6. [x] Redirect vm output to vibebox starting it.
66−5. [ ] use anyhow to sync api.
68+7. [x] use anyhow to sync api.
69+8. [ ] add support for ipv6.
70+9. [x] use UnixStream instead of status file
71+10. [x] liveness check should also happen when waiting for ssh port
install.sh → installrenamed+54−61
@@ -2,7 +2,7 @@
2set -euo pipefail
2set -euo pipefail
3
3
4APP=vibebox
4APP=vibebox
5−REPO_URL="https://github.com/opencode-ai/vibebox"
5+REPO_URL="https://github.com/robcholz/vibebox"
6REQUESTED_VERSION=${VERSION:-}
6REQUESTED_VERSION=${VERSION:-}
7
7
8RED='\033[0;31m'
8RED='\033[0;31m'
@@ -38,9 +38,58 @@ require_cmd() {
38 fi
38 fi
39}
39}
40
40
41−require_cmd cargo "Install Rust (cargo) from https://rustup.rs and retry."
42require_cmd git "Install git and retry."
41require_cmd git "Install git and retry."
43
42
43+ensure_cargo() {
44+ if command -v cargo >/dev/null 2>&1; then
45+ return 0
46+ fi
47+
48+ print_message warning "Rust (cargo) is required but not found."
49+ print_message info "You should review and approve the Rust installer before proceeding."
50+ reply=""
51+ if [[ -t 0 ]]; then
52+ read -r -p "Install Rust using rustup now? (y/N) " reply
53+ elif [[ -r /dev/tty ]]; then
54+ read -r -p "Install Rust using rustup now? (y/N) " reply </dev/tty
55+ else
56+ print_message error "Non-interactive shell: cannot prompt to install Rust."
57+ print_message info "Install Rust manually from https://rustup.rs and retry."
58+ exit 1
59+ fi
60+
61+ case "${reply}" in
62+ y|Y)
63+ if command -v curl >/dev/null 2>&1; then
64+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
65+ elif command -v wget >/dev/null 2>&1; then
66+ wget -qO- https://sh.rustup.rs | sh -s -- -y
67+ else
68+ print_message error "Missing required command: curl or wget"
69+ print_message info "Install Rust manually from https://rustup.rs and retry."
70+ exit 1
71+ fi
72+ # shellcheck source=/dev/null
73+ if [[ -f "$HOME/.cargo/env" ]]; then
74+ # shellcheck disable=SC1090
75+ source "$HOME/.cargo/env"
76+ fi
77+ ;;
78+ *)
79+ print_message info "Install Rust manually from https://rustup.rs and retry."
80+ exit 1
81+ ;;
82+ esac
83+
84+ if ! command -v cargo >/dev/null 2>&1; then
85+ print_message error "Cargo still not available after installation."
86+ print_message info "Open a new shell or run: source \"$HOME/.cargo/env\""
87+ exit 1
88+ fi
89+}
90+
91+ensure_cargo
92+
44CARGO_HOME=${CARGO_HOME:-$HOME/.cargo}
93CARGO_HOME=${CARGO_HOME:-$HOME/.cargo}
45INSTALL_DIR="$CARGO_HOME/bin"
94INSTALL_DIR="$CARGO_HOME/bin"
46
95
@@ -68,66 +117,10 @@ print_message info "Using cargo install from ${ORANGE}${REPO_URL}${GREEN}..."
68
117
69cargo "${install_args[@]}"
118cargo "${install_args[@]}"
70
119
71−add_to_path() {
72− local config_file=$1
73− local command=$2
74−
75− if [[ -w $config_file ]]; then
76− echo -e "\n# vibebox" >> "$config_file"
77− echo "$command" >> "$config_file"
78− print_message info "Added ${ORANGE}${APP}${GREEN} to \$PATH in $config_file"
79− else
80− print_message warning "Manually add the directory to $config_file (or similar):"
81− print_message info " $command"
82− fi
83−}
84−
85−XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config}
86−current_shell=$(basename "$SHELL")
87−
88−case $current_shell in
89− fish)
90− config_files="$HOME/.config/fish/config.fish"
91− ;;
92− zsh)
93− config_files="$HOME/.zshrc $HOME/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv"
94− ;;
95− bash)
96− config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"
97− ;;
98− ash)
99− config_files="$HOME/.ashrc $HOME/.profile /etc/profile"
100− ;;
101− sh)
102− config_files="$HOME/.ashrc $HOME/.profile /etc/profile"
103− ;;
104− *)
105− config_files="$HOME/.bashrc $HOME/.bash_profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"
106− ;;
107−esac
108−
109−config_file=""
110−for file in $config_files; do
111− if [[ -f $file ]]; then
112− config_file=$file
113− break
114− fi
115−done
116−
117−if [[ -z $config_file ]]; then
118− print_message error "No config file found for $current_shell. Checked files: ${config_files[@]}"
119− exit 1
120−fi
121−
122if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
120if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
123− case $current_shell in
121+ print_message warning "Cargo bin directory is not on PATH."
124− fish)
122+ print_message info "Add this to your shell profile if you can't run ${APP}:"
125− add_to_path "$config_file" "fish_add_path $INSTALL_DIR"
126− ;;
127− *)
128− add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
123+ print_message info " export PATH=$INSTALL_DIR:\$PATH"
129− ;;
130− esac
131fi
124fi
132
125
133if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
126if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
scripts/commit-check.shnew file+6
@@ -0,0 +1,6 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+cargo clippy --all-targets --all-features -- -D warnings
5+cargo fmt --all
6+cargo build --all-targets
src/bin/vibebox-supervisor.rs+9−7
@@ -21,14 +21,16 @@ fn main() -> Result<()> {
21 color_eyre::install()?;
21 color_eyre::install()?;
22
22
23 tracing::info!("starting vm supervisor");
23 tracing::info!("starting vm supervisor");
24− let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
24+ let project_dir = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
25− let config = config::load_config(&cwd);
25+ let config = config::load_config(&project_dir)
26− let instance_dir = instance::ensure_instance_dir(&cwd)
27 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
26 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
27+ let _ = instance::ensure_instance_dir(&project_dir)
28+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
28− let _ = instance::touch_last_active(&instance_dir);
29+ let _ = instance::touch_last_active(&project_dir);
29 let args = vm::VmArg {
30 let args = vm::VmArg {
30 cpu_count: config.box_cfg.cpu_count,
31 cpu_count: config.box_cfg.cpu_count,
31− ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
32+ ram_bytes: config.box_cfg.ram_size.as_u64(),
33+ disk_bytes: config.box_cfg.disk_size.as_u64(),
32 no_default_mounts: false,
34 no_default_mounts: false,
33 mounts: config.box_cfg.mounts.clone(),
35 mounts: config.box_cfg.mounts.clone(),
34 };
36 };
@@ -36,7 +38,7 @@ fn main() -> Result<()> {
36 tracing::info!(auto_shutdown_ms, "vm supervisor config");
38 tracing::info!(auto_shutdown_ms, "vm supervisor config");
37
39
38 let result = vm_manager::run_manager(args, auto_shutdown_ms);
40 let result = vm_manager::run_manager(args, auto_shutdown_ms);
39− let _ = instance::touch_last_active(&instance_dir);
41+ let _ = instance::touch_last_active(&project_dir);
40 if let Err(err) = result {
42 if let Err(err) = result {
41 tracing::error!(error = %err, "vm supervisor exited");
43 tracing::error!(error = %err, "vm supervisor exited");
42 return Err(color_eyre::eyre::eyre!(err.to_string()));
44 return Err(color_eyre::eyre::eyre!(err.to_string()));
@@ -47,7 +49,7 @@ fn main() -> Result<()> {
47}
49}
48
50
49fn init_tracing() {
51fn init_tracing() {
50− let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
52+ let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
51 let stderr_is_tty = std::io::stderr().is_terminal();
53 let stderr_is_tty = std::io::stderr().is_terminal();
52 let ansi = stderr_is_tty && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
54 let ansi = stderr_is_tty && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
53 if stderr_is_tty {
55 if stderr_is_tty {
src/bin/vibebox.rs+75−88
@@ -1,3 +1,7 @@
1+use bytesize::ByteSize;
2+use clap::Parser;
3+use color_eyre::Result;
4+use dialoguer::Confirm;
1use std::{
5use std::{
2 env,
6 env,
3 ffi::OsString,
7 ffi::OsString,
@@ -6,19 +10,16 @@ use std::{
6 path::{Path, PathBuf},
10 path::{Path, PathBuf},
7 sync::{Arc, Mutex},
11 sync::{Arc, Mutex},
8};
12};
9−
10−use clap::Parser;
11−use color_eyre::Result;
12−use dialoguer::Confirm;
13−use time::format_description::well_known::Rfc3339;
14use time::OffsetDateTime;
13use time::OffsetDateTime;
14+use time::format_description::well_known::Rfc3339;
15use tracing_subscriber::filter::LevelFilter;
15use tracing_subscriber::filter::LevelFilter;
16use tracing_subscriber::registry::Registry;
16use tracing_subscriber::registry::Registry;
17−use tracing_subscriber::{fmt, prelude::*, reload, EnvFilter};
17+use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload};
18
18
19use vibebox::tui::{AppState, VmInfo};
19use vibebox::tui::{AppState, VmInfo};
20+use vibebox::utils::relative_to_home;
20use vibebox::{
21use vibebox::{
21− commands, config, explain, instance, session_manager, tui, vm, vm_manager, SessionManager,
22+ SessionManager, commands, config, explain, instance, session_manager, tui, vm_manager,
22};
23};
23
24
24#[derive(Debug, Parser)]
25#[derive(Debug, Parser)]
@@ -49,58 +50,33 @@ fn main() -> Result<()> {
49 let stderr_handle = init_tracing(&cwd);
50 let stderr_handle = init_tracing(&cwd);
50
51
51 let cli = Cli::parse();
52 let cli = Cli::parse();
52− tracing::info!(cwd = %cwd.display(), "starting vibebox cli");
53+ tracing::debug!(cwd = %cwd.display(), "starting vibebox cli");
53 if let Some(command) = cli.command {
54 if let Some(command) = cli.command {
54 return handle_command(command, &cwd, cli.config.as_deref());
55 return handle_command(command, &cwd, cli.config.as_deref());
55 }
56 }
56
57
57 let config_override = cli.config.clone();
58 let config_override = cli.config.clone();
58 let raw_args: Vec<OsString> = env::args_os().collect();
59 let raw_args: Vec<OsString> = env::args_os().collect();
59− let config = config::load_config_with_path(&cwd, config_override.as_deref());
60+ let config = config::load_config_with_path(&cwd, config_override.as_deref())
61+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
60
62
61− if env::var("VIBEBOX_VM_MANAGER").as_deref() == Ok("1") {
62− tracing::info!("starting vm manager mode");
63− let args = vm::VmArg {
64− cpu_count: config.box_cfg.cpu_count,
65− ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
66− no_default_mounts: false,
67− mounts: config.box_cfg.mounts.clone(),
68− };
69− let auto_shutdown_ms = config.supervisor.auto_shutdown_ms;
70− tracing::info!(auto_shutdown_ms, "vm manager config");
71− if let Err(err) = vm_manager::run_manager(args, auto_shutdown_ms) {
72− tracing::error!(error = %err, "vm manager exited");
73− return Err(color_eyre::eyre::eyre!(err.to_string()));
74− }
75− return Ok(());
76− }
77−
78− vm::ensure_signed();
79−
80− let vm_args = vm::VmArg {
81− cpu_count: config.box_cfg.cpu_count,
82− ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
83− no_default_mounts: false,
84− mounts: config.box_cfg.mounts.clone(),
85− };
86−
87− let auto_shutdown_ms = config.supervisor.auto_shutdown_ms;
88 let vm_info = VmInfo {
63 let vm_info = VmInfo {
89− max_memory_mb: vm_args.ram_bytes / (1024 * 1024),
64+ max_memory: config.box_cfg.ram_size,
90− cpu_cores: vm_args.cpu_count,
65+ cpu_cores: config.box_cfg.cpu_count,
66+ max_disk: config.box_cfg.disk_size,
91 system_name: "Debian".to_string(), // TODO: read system name from the VM.
67 system_name: "Debian".to_string(), // TODO: read system name from the VM.
92− auto_shutdown_ms,
68+ auto_shutdown_ms: config.supervisor.auto_shutdown_ms,
93 };
69 };
94 if let Ok(manager) = SessionManager::new() {
70 if let Ok(manager) = SessionManager::new() {
95 if let Err(err) = manager.update_global_sessions(&cwd) {
71 if let Err(err) = manager.update_global_sessions(&cwd) {
96 tracing::warn!(error = %err, "failed to update a global session list");
72 tracing::warn!(error = %err, "failed to update a global session list");
97 }
73 }
98 } else {
74 } else {
99− tracing::warn!("failed to initialize session manager");
75+ tracing::error!("failed to initialize session manager");
76+ std::process::exit(1);
100 }
77 }
101 let commands = commands::build_commands();
78 let commands = commands::build_commands();
102 let app = Arc::new(Mutex::new(AppState::new(cwd.clone(), vm_info, commands)));
79 let app = Arc::new(Mutex::new(AppState::new(cwd.clone(), vm_info, commands)));
103−
104 {
80 {
105 let mut locked = app.lock().expect("app state poisoned");
81 let mut locked = app.lock().expect("app state poisoned");
106 tui::render_tui_once(&mut locked)?;
82 tui::render_tui_once(&mut locked)?;
@@ -110,23 +86,46 @@ fn main() -> Result<()> {
110 writeln!(stdout)?;
86 writeln!(stdout)?;
111 stdout.flush()?;
87 stdout.flush()?;
112 }
88 }
89+ warn_disk_size_mismatch(&cwd, config.box_cfg.disk_size);
113 if let Some(handle) = stderr_handle {
90 if let Some(handle) = stderr_handle {
114 let _ = handle.modify(|filter| *filter = LevelFilter::INFO);
91 let _ = handle.modify(|filter| *filter = LevelFilter::INFO);
115 }
92 }
116
93
117− tracing::debug!(auto_shutdown_ms, "auto shutdown config");
94+ tracing::debug!(config.supervisor.auto_shutdown_ms, "auto shutdown config");
118− let manager_conn =
95+ let manager_conn = vm_manager::ensure_manager(
96+ &raw_args,
97+ config.supervisor.auto_shutdown_ms,
119− vm_manager::ensure_manager(&raw_args, auto_shutdown_ms, config_override.as_deref())
98+ config_override.as_deref(),
99+ )
100+ .map_err(|err| {
101+ tracing::error!(error = %err, "failed to ensure vm manager");
120− .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
102+ color_eyre::eyre::eyre!(err.to_string())
103+ })?;
121
104
122− instance::run_with_ssh(manager_conn).map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
105+ if let Err(err) = instance::run_with_ssh(manager_conn) {
106+ if let Some(instance::InstanceError::UnexpectedDisconnection) =
107+ err.downcast_ref::<instance::InstanceError>()
108+ {
109+ tracing::warn!("vm manager disconnected; exiting vibebox");
110+ } else if let Some(instance::InstanceError::VMError(vm_error)) =
111+ err.downcast_ref::<instance::InstanceError>()
112+ {
113+ tracing::error!("[vm]: {vm_error}");
114+ tracing::info!("vibecoding paused: the VM says today is a rest day 😴");
115+ std::process::exit(1);
116+ } else {
117+ let message = err.to_string();
118+ tracing::error!(error = %message, "vibebox exited: uncaught error");
119+ return Err(color_eyre::eyre::eyre!(message));
120+ }
121+ }
123
122
124 tracing::info!("See you again — keep vibecoding (no SEVs, only vibes) 😈");
123 tracing::info!("See you again — keep vibecoding (no SEVs, only vibes) 😈");
125
124
126 Ok(())
125 Ok(())
127}
126}
128
127
129−fn handle_command(command: Command, cwd: &PathBuf, config_override: Option<&Path>) -> Result<()> {
128+fn handle_command(command: Command, cwd: &Path, config_override: Option<&Path>) -> Result<()> {
130 match command {
129 match command {
131 Command::List => {
130 Command::List => {
132 let manager = SessionManager::new()?;
131 let manager = SessionManager::new()?;
@@ -202,13 +201,14 @@ fn handle_command(command: Command, cwd: &PathBuf, config_override: Option<&Path
202 "Purged {} file{} totaling {} from {}",
201 "Purged {} file{} totaling {} from {}",
203 file_count,
202 file_count,
204 if file_count == 1 { "" } else { "s" },
203 if file_count == 1 { "" } else { "s" },
205− format_bytes(total_bytes),
204+ ByteSize(total_bytes),
206 cache_dir.display()
205 cache_dir.display()
207 );
206 );
208 Ok(())
207 Ok(())
209 }
208 }
210 Command::Explain => {
209 Command::Explain => {
211− let config = config::load_config_with_path(cwd, config_override);
210+ let config = config::load_config_with_path(cwd, config_override)
211+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
212 let mounts = explain::build_mount_rows(cwd, &config)
212 let mounts = explain::build_mount_rows(cwd, &config)
213 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
213 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
214 let networks = explain::build_network_rows(cwd)
214 let networks = explain::build_network_rows(cwd)
@@ -223,7 +223,7 @@ fn handle_command(command: Command, cwd: &PathBuf, config_override: Option<&Path
223 }
223 }
224}
224}
225
225
226−fn project_name(directory: &PathBuf) -> String {
226+fn project_name(directory: &Path) -> String {
227 directory
227 directory
228 .file_name()
228 .file_name()
229 .and_then(|name| name.to_str())
229 .and_then(|name| name.to_str())
@@ -231,20 +231,6 @@ fn project_name(directory: &PathBuf) -> String {
231 .to_string()
231 .to_string()
232}
232}
233
233
234−fn relative_to_home(directory: &PathBuf) -> String {
235− let Ok(home) = env::var("HOME") else {
236− return directory.display().to_string();
237− };
238− let home_path = PathBuf::from(home);
239− if let Ok(stripped) = directory.strip_prefix(&home_path) {
240− if stripped.components().next().is_none() {
241− return "~".to_string();
242− }
243− return format!("~/{}", stripped.display());
244− }
245− directory.display().to_string()
246−}
247−
248fn cache_dir() -> Result<PathBuf> {
234fn cache_dir() -> Result<PathBuf> {
249 let home = env::var("HOME").map(PathBuf::from)?;
235 let home = env::var("HOME").map(PathBuf::from)?;
250 let cache_home = env::var("XDG_CACHE_HOME")
236 let cache_home = env::var("XDG_CACHE_HOME")
@@ -293,23 +279,6 @@ fn measure_dir(path: &Path) -> Result<(u64, u64)> {
293 Ok((file_count, total_bytes))
279 Ok((file_count, total_bytes))
294}
280}
295
281
296−fn format_bytes(bytes: u64) -> String {
297− const KB: f64 = 1024.0;
298− const MB: f64 = KB * 1024.0;
299− const GB: f64 = MB * 1024.0;
300−
301− let b = bytes as f64;
302− if b >= GB {
303− format!("{:.2} GB", b / GB)
304− } else if b >= MB {
305− format!("{:.1} MB", b / MB)
306− } else if b >= KB {
307− format!("{:.1} KB", b / KB)
308− } else {
309− format!("{} B", bytes)
310− }
311−}
312−
313fn format_last_active(value: Option<&str>) -> String {
282fn format_last_active(value: Option<&str>) -> String {
314 let Some(raw) = value else {
283 let Some(raw) = value else {
315 return "-".to_string();
284 return "-".to_string();
@@ -323,7 +292,6 @@ fn format_last_active(value: Option<&str>) -> String {
323 if seconds < 0 {
292 if seconds < 0 {
324 seconds = 0;
293 seconds = 0;
325 }
294 }
326− let seconds = seconds as i64;
327 let week_seconds = 7 * 24 * 60 * 60;
295 let week_seconds = 7 * 24 * 60 * 60;
328 if seconds >= week_seconds {
296 if seconds >= week_seconds {
329 let formatted =
297 let formatted =
@@ -352,18 +320,37 @@ fn format_last_active(value: Option<&str>) -> String {
352 format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
320 format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
353}
321}
354
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+
355type StderrHandle = reload::Handle<LevelFilter, Registry>;
342type StderrHandle = reload::Handle<LevelFilter, Registry>;
356
343
357fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
344fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
358− let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
345+ let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
359 let file_filter = filter.clone();
346 let file_filter = filter.clone();
360− let stderr_is_tty = std::io::stderr().is_terminal();
347+ let stderr_is_tty = io::stderr().is_terminal();
361 let ansi = stderr_is_tty && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
348 let ansi = stderr_is_tty && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
362 let file = instance::ensure_instance_dir(cwd)
349 let file = instance::ensure_instance_dir(cwd)
363 .ok()
350 .ok()
364 .and_then(|instance_dir| {
351 .and_then(|instance_dir| {
365 let log_path = instance_dir.join("cli.log");
352 let log_path = instance_dir.join("cli.log");
366− std::fs::OpenOptions::new()
353+ fs::OpenOptions::new()
367 .create(true)
354 .create(true)
368 .write(true)
355 .write(true)
369 .truncate(true)
356 .truncate(true)
@@ -372,12 +359,12 @@ fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
372 });
359 });
373
360
374 if stderr_is_tty {
361 if stderr_is_tty {
375− let (stderr_filter, handle) = reload::Layer::new(LevelFilter::OFF);
362+ let (stderr_filter, handle) = reload::Layer::new(LevelFilter::INFO);
376 let stderr_layer = fmt::layer()
363 let stderr_layer = fmt::layer()
377 .with_target(false)
364 .with_target(false)
378 .with_ansi(ansi)
365 .with_ansi(ansi)
379 .without_time()
366 .without_time()
380− .with_writer(std::io::stderr)
367+ .with_writer(io::stderr)
381 .with_filter(stderr_filter);
368 .with_filter(stderr_filter);
382 let subscriber = tracing_subscriber::registry().with(stderr_layer);
369 let subscriber = tracing_subscriber::registry().with(stderr_layer);
383 if let Some(file) = file {
370 if let Some(file) = file {
@@ -395,7 +382,7 @@ fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
395 let stderr_layer = fmt::layer()
382 let stderr_layer = fmt::layer()
396 .with_target(false)
383 .with_target(false)
397 .with_ansi(ansi)
384 .with_ansi(ansi)
398− .with_writer(std::io::stderr)
385+ .with_writer(io::stderr)
399 .with_filter(filter);
386 .with_filter(filter);
400 let subscriber = tracing_subscriber::registry().with(stderr_layer);
387 let subscriber = tracing_subscriber::registry().with(stderr_layer);
401 if let Some(file) = file {
388 if let Some(file) = file {
src/config.rs+472−77
@@ -1,47 +1,105 @@
1+use anyhow::{Context, Error, Result, bail};
2+use bytesize::ByteSize;
3+use serde::{Deserialize, Serialize};
1use std::{
4use std::{
2 env, fs, io,
5 env, fs, io,
3 path::{Path, PathBuf},
6 path::{Path, PathBuf},
4};
7};
5
8
6−use serde::{Deserialize, Serialize};
7−
8use crate::vm::DirectoryShare;
9use crate::vm::DirectoryShare;
9
10
10−pub const CONFIG_FILENAME: &str = "vibebox.toml";
11+const CONFIG_FILENAME: &str = "vibebox.toml";
11pub const CONFIG_PATH_ENV: &str = "VIBEBOX_CONFIG_PATH";
12pub const CONFIG_PATH_ENV: &str = "VIBEBOX_CONFIG_PATH";
12
13
13const DEFAULT_CPU_COUNT: usize = 2;
14const DEFAULT_CPU_COUNT: usize = 2;
14const DEFAULT_RAM_MB: u64 = 2048;
15const DEFAULT_RAM_MB: u64 = 2048;
15const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 20000;
16const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 20000;
17+const DEFAULT_DISK_GB: u64 = 5;
16
18
17−#[derive(Debug, Clone, Serialize, Deserialize)]
19+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18pub struct Config {
20pub struct Config {
19 #[serde(rename = "box")]
21 #[serde(rename = "box")]
20 pub box_cfg: BoxConfig,
22 pub box_cfg: BoxConfig,
21 pub supervisor: SupervisorConfig,
23 pub supervisor: SupervisorConfig,
22}
24}
23
25
24−impl Default for Config {
26+const MI_B: u64 = 1024 * 1024;
25− fn default() -> Self {
27+const GI_B: u64 = 1024 * 1024 * 1024;
26− Self {
28+
27− box_cfg: BoxConfig::default(),
29+mod serde_mb {
28− supervisor: SupervisorConfig::default(),
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+ ));
29 }
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))
30 }
84 }
31}
85}
32
86
33#[derive(Debug, Clone, Serialize, Deserialize)]
87#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct BoxConfig {
88pub struct BoxConfig {
35 pub cpu_count: usize,
89 pub cpu_count: usize,
36− pub ram_mb: u64,
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,
37 pub mounts: Vec<String>,
94 pub mounts: Vec<String>,
38}
95}
39
96
40impl Default for BoxConfig {
97impl Default for BoxConfig {
41 fn default() -> Self {
98 fn default() -> Self {
42 Self {
99 Self {
43− cpu_count: default_cpu_count(),
100+ cpu_count: DEFAULT_CPU_COUNT,
44− ram_mb: default_ram_mb(),
101+ ram_size: ByteSize::mib(DEFAULT_RAM_MB),
102+ disk_size: ByteSize::gib(DEFAULT_DISK_GB),
45 mounts: default_mounts(),
103 mounts: default_mounts(),
46 }
104 }
47 }
105 }
@@ -55,36 +113,24 @@ pub struct SupervisorConfig {
55impl Default for SupervisorConfig {
113impl Default for SupervisorConfig {
56 fn default() -> Self {
114 fn default() -> Self {
57 Self {
115 Self {
58− auto_shutdown_ms: default_auto_shutdown_ms(),
116+ auto_shutdown_ms: DEFAULT_AUTO_SHUTDOWN_MS,
59 }
117 }
60 }
118 }
61}
119}
62
120
63−fn default_cpu_count() -> usize {
64− DEFAULT_CPU_COUNT
65−}
66−
67−fn default_ram_mb() -> u64 {
68− DEFAULT_RAM_MB
69−}
70−
71−fn default_auto_shutdown_ms() -> u64 {
72− DEFAULT_AUTO_SHUTDOWN_MS
73−}
74−
75fn default_mounts() -> Vec<String> {
121fn default_mounts() -> Vec<String> {
76− Vec::new()
122+ vec![
123+ "~/.codex:~/.codex:read-write".into(),
124+ "~/.claude:~/.claude:read-write".into(),
125+ ]
77}
126}
78
127
79pub fn config_path(project_root: &Path) -> PathBuf {
128pub fn config_path(project_root: &Path) -> PathBuf {
80 project_root.join(CONFIG_FILENAME)
129 project_root.join(CONFIG_FILENAME)
81}
130}
82
131
83−pub fn ensure_config_file(
132+pub fn ensure_config_file(project_root: &Path, override_path: Option<&Path>) -> Result<PathBuf> {
84− project_root: &Path,
85− override_path: Option<&Path>,
86−) -> Result<PathBuf, io::Error> {
87− let path = resolve_config_path(project_root, override_path);
133+ let path = resolve_config_path(project_root, override_path)?;
88 if !path.exists() {
134 if !path.exists() {
89 let default_config = Config::default();
135 let default_config = Config::default();
90 let contents = toml::to_string_pretty(&default_config).unwrap_or_default();
136 let contents = toml::to_string_pretty(&default_config).unwrap_or_default();
@@ -94,32 +140,24 @@ pub fn ensure_config_file(
94 Ok(path)
140 Ok(path)
95}
141}
96
142
97−pub fn load_config(project_root: &Path) -> Config {
143+pub fn load_config(project_root: &Path) -> Result<Config> {
98 load_config_with_path(project_root, None)
144 load_config_with_path(project_root, None)
99}
145}
100
146
101−pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) -> Config {
147+pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) -> Result<Config> {
148+ let path =
102− let path = match ensure_config_file(project_root, override_path) {
149+ ensure_config_file(project_root, override_path).context("failed to create config")?;
103− Ok(path) => path,
104− Err(err) => die(&format!("failed to create config: {err}")),
105− };
106− let raw = match fs::read_to_string(&path) {
150+ let raw = fs::read_to_string(&path).context("failed to read config")?;
107− Ok(raw) => raw,
108− Err(err) => die(&format!("failed to read config: {err}")),
109− };
110 let trimmed = raw.trim();
151 let trimmed = raw.trim();
111 tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config");
152 tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config");
112 if trimmed.is_empty() {
153 if trimmed.is_empty() {
113− die(&format!(
154+ bail!(format!(
114− "config file ({}) is empty. Required fields: [box].cpu_count (integer), [box].ram_mb (integer), [box].mounts (array of strings), [supervisor].auto_shutdown_ms (integer)",
155+ "config file ({}) is empty. Required fields: [box].cpu_count (integer), [box].ram_mb (integer), [box].disk_gb (integer), [box].mounts (array of strings), [supervisor].auto_shutdown_ms (integer)",
115 path.display()
156 path.display()
116 ));
157 ));
117 }
158 }
118
159
119− let value: toml::Value = match toml::from_str(trimmed) {
160+ let value: toml::Value = toml::from_str(trimmed).context("invalid config")?;
120− Ok(value) => value,
121− Err(err) => die(&format!("invalid config: {err}")),
122− };
123 let schema_errors = validate_schema(&value);
161 let schema_errors = validate_schema(&value);
124 if !schema_errors.is_empty() {
162 if !schema_errors.is_empty() {
125 let message = format!(
163 let message = format!(
@@ -127,27 +165,28 @@ pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>)
127 path.display(),
165 path.display(),
128 schema_errors.join("\n- ")
166 schema_errors.join("\n- ")
129 );
167 );
130− die(&message);
168+ bail!(message);
131 }
169 }
132
170
133− let config: Config = match toml::from_str(trimmed) {
171+ let config: Config = toml::from_str(trimmed).context("invalid config")?;
172+ validate_config(&config).map_err(Error::msg)?;
134− Ok(config) => config,
173+ Ok(config)
135− Err(err) => die(&format!("invalid config: {err}")),
136− };
137− validate_or_exit(&config);
138− config
139}
174}
140
175
141−fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> PathBuf {
176+fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> Result<PathBuf> {
142− let root = match fs::canonicalize(project_root) {
177+ let env_override = env::var_os(CONFIG_PATH_ENV).map(PathBuf::from);
143− Ok(root) => root,
178+ resolve_config_path_inner(project_root, override_path, env_override)
144− Err(err) => die(&format!("failed to resolve project root: {err}")),
179+}
145− };
146
180
181+fn resolve_config_path_inner(
182+ project_root: &Path,
147− let override_path = override_path
183+ override_path: Option<&Path>,
184+ env_override: Option<PathBuf>,
148− .map(PathBuf::from)
185+) -> Result<PathBuf> {
149− .or_else(|| env::var_os(CONFIG_PATH_ENV).map(PathBuf::from));
186+ let root = fs::canonicalize(project_root).context("failed to resolve project root")?;
187+
188+ let selected_path = override_path.map(PathBuf::from).or(env_override);
150− let raw_path = if let Some(path) = override_path {
189+ let raw_path = if let Some(path) = selected_path {
151 if path.is_absolute() {
190 if path.is_absolute() {
152 path
191 path
153 } else {
192 } else {
@@ -158,14 +197,50 @@ fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> Pat
158 };
197 };
159
198
160 let normalized = normalize_path(&raw_path);
199 let normalized = normalize_path(&raw_path);
200+ let resolved =
201+ resolve_path_for_boundary_check(&normalized).context("failed to resolve config path")?;
161− if !normalized.starts_with(&root) {
202+ if !resolved.starts_with(&root) {
162− die(&format!(
203+ bail!(
163 "config path must be within {}: {}",
204 "config path must be within {}: {}",
164 root.display(),
205 root.display(),
165− normalized.display()
206+ resolved.display()
166− ));
207+ );
167 }
208 }
168− normalized
209+ Ok(normalized)
210+}
211+
212+fn resolve_path_for_boundary_check(path: &Path) -> Result<PathBuf, io::Error> {
213+ if path.exists() {
214+ return fs::canonicalize(path);
215+ }
216+ let (ancestor, missing) = nearest_existing_ancestor(path)?;
217+ let mut resolved = fs::canonicalize(ancestor)?;
218+ for part in missing {
219+ resolved.push(part);
220+ }
221+ Ok(resolved)
222+}
223+
224+fn nearest_existing_ancestor(path: &Path) -> Result<(&Path, Vec<std::ffi::OsString>), io::Error> {
225+ let mut current = path;
226+ let mut missing = Vec::new();
227+ while !current.exists() {
228+ let name = current.file_name().ok_or_else(|| {
229+ io::Error::new(
230+ io::ErrorKind::InvalidInput,
231+ format!("path has no existing ancestor: {}", path.display()),
232+ )
233+ })?;
234+ missing.push(name.to_os_string());
235+ current = current.parent().ok_or_else(|| {
236+ io::Error::new(
237+ io::ErrorKind::InvalidInput,
238+ format!("path has no parent: {}", path.display()),
239+ )
240+ })?;
241+ }
242+ missing.reverse();
243+ Ok((current, missing))
169}
244}
170
245
171fn normalize_path(path: &Path) -> PathBuf {
246fn normalize_path(path: &Path) -> PathBuf {
@@ -202,6 +277,7 @@ fn validate_schema(value: &toml::Value) -> Vec<String> {
202 Some(table) => {
277 Some(table) => {
203 validate_int(table, "cpu_count", "[box].cpu_count (integer)", &mut errors);
278 validate_int(table, "cpu_count", "[box].cpu_count (integer)", &mut errors);
204 validate_int(table, "ram_mb", "[box].ram_mb (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);
205 validate_string_array(
281 validate_string_array(
206 table,
282 table,
207 "mounts",
283 "mounts",
@@ -261,24 +337,343 @@ fn validate_string_array(
261 }
337 }
262}
338}
263
339
264−fn validate_or_exit(config: &Config) {
340+fn validate_config(config: &Config) -> Result<(), String> {
265 if config.box_cfg.cpu_count == 0 {
341 if config.box_cfg.cpu_count == 0 {
266− die("box.cpu_count must be >= 1");
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());
267 }
346 }
268− if config.box_cfg.ram_mb == 0 {
347+ if config.box_cfg.disk_size.as_gib() == 0.0 {
269− die("box.ram_mb must be >= 1");
348+ return Err("box.disk_gb must be >= 1".to_string());
270 }
349 }
271 if config.supervisor.auto_shutdown_ms == 0 {
350 if config.supervisor.auto_shutdown_ms == 0 {
272− die("supervisor.auto_shutdown_ms must be >= 1");
351+ return Err("supervisor.auto_shutdown_ms must be >= 1".to_string());
273 }
352 }
274 for spec in &config.box_cfg.mounts {
353 for spec in &config.box_cfg.mounts {
275 if let Err(err) = DirectoryShare::from_mount_spec(spec) {
354 if let Err(err) = DirectoryShare::from_mount_spec(spec) {
276− die(&format!("invalid mount spec '{spec}': {err}"));
355+ return Err(format!("invalid mount spec '{spec}': {err}"));
277 }
356 }
278 }
357 }
358+ Ok(())
279}
359}
280
360
281−fn die(message: &str) -> ! {
361+#[cfg(test)]
282− tracing::error!("{message}");
362+mod tests {
283− std::process::exit(1);
363+ use super::*;
364+ use std::ffi::OsString;
365+ use std::sync::Mutex;
366+ use tempfile::TempDir;
367+
368+ static ENV_MUTEX: Mutex<()> = Mutex::new(());
369+
370+ struct EnvGuard {
371+ key: &'static str,
372+ previous: Option<OsString>,
373+ }
374+
375+ impl EnvGuard {
376+ fn set(key: &'static str, value: &Path) -> Self {
377+ let previous = env::var_os(key);
378+ unsafe {
379+ env::set_var(key, value);
380+ }
381+ Self { key, previous }
382+ }
383+ }
384+
385+ impl Drop for EnvGuard {
386+ fn drop(&mut self) {
387+ match &self.previous {
388+ Some(value) => unsafe {
389+ env::set_var(self.key, value);
390+ },
391+ None => unsafe {
392+ env::remove_var(self.key);
393+ },
394+ }
395+ }
396+ }
397+
398+ #[test]
399+ fn default_config_serializes_with_legacy_keys() {
400+ let cfg = Config::default();
401+ let serialized = toml::to_string(&cfg).expect("default config should serialize");
402+
403+ assert!(serialized.contains("ram_mb = 2048"));
404+ assert!(serialized.contains("disk_gb = 5"));
405+ assert!(!serialized.contains("ram_size"));
406+ assert!(!serialized.contains("disk_size"));
407+ }
408+
409+ #[test]
410+ fn config_deserializes_sizes_from_mb_and_gb() {
411+ let raw = r#"
412+[box]
413+cpu_count = 4
414+ram_mb = 3072
415+disk_gb = 12
416+mounts = ["~/src:~/src:read-write"]
417+
418+[supervisor]
419+auto_shutdown_ms = 15000
420+"#;
421+ let cfg: Config = toml::from_str(raw).expect("config should deserialize");
422+
423+ assert_eq!(cfg.box_cfg.cpu_count, 4);
424+ assert_eq!(cfg.box_cfg.ram_size.as_u64(), ByteSize::mib(3072).as_u64());
425+ assert_eq!(cfg.box_cfg.disk_size.as_u64(), ByteSize::gib(12).as_u64());
426+ assert_eq!(cfg.supervisor.auto_shutdown_ms, 15000);
427+ }
428+
429+ #[test]
430+ fn serialize_rejects_non_integral_mb_or_gb() {
431+ let cfg = Config {
432+ box_cfg: BoxConfig {
433+ cpu_count: 2,
434+ ram_size: ByteSize::b((2 * MI_B) + 1),
435+ disk_size: ByteSize::gib(5),
436+ mounts: default_mounts(),
437+ },
438+ supervisor: SupervisorConfig::default(),
439+ };
440+
441+ let err = toml::to_string(&cfg).expect_err("serialization should reject invalid MB");
442+ assert!(
443+ err.to_string()
444+ .contains("ram_mb must be an integer number of MB")
445+ );
446+
447+ let cfg = Config {
448+ box_cfg: BoxConfig {
449+ cpu_count: 2,
450+ ram_size: ByteSize::mib(2048),
451+ disk_size: ByteSize::b((5 * GI_B) + 1),
452+ mounts: default_mounts(),
453+ },
454+ supervisor: SupervisorConfig::default(),
455+ };
456+
457+ let err = toml::to_string(&cfg).expect_err("serialization should reject invalid GB");
458+ assert!(
459+ err.to_string()
460+ .contains("disk_gb must be an integer number of GB")
461+ );
462+ }
463+
464+ #[test]
465+ fn normalize_path_removes_dot_and_parent_components() {
466+ let normalized = normalize_path(Path::new("/tmp/project/./nested/../config.toml"));
467+ assert_eq!(normalized, PathBuf::from("/tmp/project/config.toml"));
468+ }
469+
470+ #[test]
471+ fn validate_schema_returns_errors_for_missing_required_fields() {
472+ let value: toml::Value = toml::from_str(
473+ r#"
474+[box]
475+cpu_count = 2
476+"#,
477+ )
478+ .expect("toml should parse");
479+
480+ let errors = validate_schema(&value);
481+
482+ assert!(errors.iter().any(|e| e == "missing [supervisor] table"));
483+ assert!(errors.iter().any(|e| e == "missing [box].ram_mb (integer)"));
484+ assert!(
485+ errors
486+ .iter()
487+ .any(|e| e == "missing [box].disk_gb (integer)")
488+ );
489+ assert!(
490+ errors
491+ .iter()
492+ .any(|e| e == "missing [box].mounts (array of strings)")
493+ );
494+ }
495+
496+ #[test]
497+ fn validate_schema_errors_when_supervisor_is_not_table() {
498+ let value: toml::Value = toml::from_str(
499+ r#"
500+supervisor = 123
501+
502+[box]
503+cpu_count = 2
504+ram_mb = 2048
505+disk_gb = 5
506+mounts = []
507+"#,
508+ )
509+ .expect("toml should parse");
510+
511+ let errors = validate_schema(&value);
512+ assert!(errors.iter().any(|e| e == "[supervisor] must be a table"));
513+ }
514+
515+ #[test]
516+ fn ensure_config_file_creates_default_config_if_absent() {
517+ let temp = TempDir::new().expect("temp dir should be created");
518+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
519+
520+ let path = ensure_config_file(&root, None).expect("config should be created");
521+ let raw = fs::read_to_string(&path).expect("created config should be readable");
522+ let parsed: Config = toml::from_str(&raw).expect("created config should be valid");
523+
524+ assert_eq!(path, root.join("vibebox.toml"));
525+ assert_eq!(parsed.box_cfg.cpu_count, DEFAULT_CPU_COUNT);
526+ assert_eq!(
527+ parsed.box_cfg.ram_size.as_u64(),
528+ ByteSize::mib(DEFAULT_RAM_MB).as_u64()
529+ );
530+ assert_eq!(
531+ parsed.box_cfg.disk_size.as_u64(),
532+ ByteSize::gib(DEFAULT_DISK_GB).as_u64()
533+ );
534+ }
535+
536+ #[test]
537+ fn load_config_creates_and_loads_default_config() {
538+ let _lock = ENV_MUTEX.lock().expect("env lock should be acquired");
539+ let temp = TempDir::new().expect("temp dir should be created");
540+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
541+ let home = root.join("home");
542+ fs::create_dir_all(home.join(".codex")).expect("home .codex should be created");
543+ fs::create_dir_all(home.join(".claude")).expect("home .claude should be created");
544+ let _home_guard = EnvGuard::set("HOME", &home);
545+
546+ let cfg = load_config(&root).expect("load_config should succeed");
547+
548+ assert_eq!(cfg.box_cfg.cpu_count, DEFAULT_CPU_COUNT);
549+ assert_eq!(
550+ cfg.box_cfg.ram_size.as_u64(),
551+ ByteSize::mib(DEFAULT_RAM_MB).as_u64()
552+ );
553+ assert_eq!(
554+ cfg.box_cfg.disk_size.as_u64(),
555+ ByteSize::gib(DEFAULT_DISK_GB).as_u64()
556+ );
557+ assert!(root.join("vibebox.toml").exists());
558+ }
559+
560+ #[test]
561+ fn load_config_with_path_uses_override_path() {
562+ let _lock = ENV_MUTEX.lock().expect("env lock should be acquired");
563+ let temp = TempDir::new().expect("temp dir should be created");
564+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
565+ let home = root.join("home");
566+ fs::create_dir_all(home.join(".codex")).expect("home .codex should be created");
567+ fs::create_dir_all(home.join(".claude")).expect("home .claude should be created");
568+ let _home_guard = EnvGuard::set("HOME", &home);
569+ let override_path = root.join("custom.toml");
570+
571+ fs::write(
572+ &override_path,
573+ r#"
574+[box]
575+cpu_count = 6
576+ram_mb = 4096
577+disk_gb = 9
578+mounts = ["~/.codex:~/.codex:read-write", "~/.claude:~/.claude:read-write"]
579+
580+[supervisor]
581+auto_shutdown_ms = 12345
582+"#,
583+ )
584+ .expect("override config should be written");
585+
586+ let cfg = load_config_with_path(&root, Some(Path::new("custom.toml")))
587+ .expect("load_config_with_path should succeed");
588+
589+ assert_eq!(cfg.box_cfg.cpu_count, 6);
590+ assert_eq!(cfg.box_cfg.ram_size.as_u64(), ByteSize::mib(4096).as_u64());
591+ assert_eq!(cfg.box_cfg.disk_size.as_u64(), ByteSize::gib(9).as_u64());
592+ assert_eq!(cfg.supervisor.auto_shutdown_ms, 12345);
593+ assert!(!root.join("vibebox.toml").exists());
594+ }
595+
596+ #[test]
597+ fn resolve_config_path_uses_env_override_when_cli_override_missing() {
598+ let temp = TempDir::new().expect("temp dir should be created");
599+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
600+
601+ let resolved = resolve_config_path_inner(&root, None, Some(PathBuf::from("custom.toml")))
602+ .expect("env override path should resolve");
603+
604+ assert_eq!(resolved, root.join("custom.toml"));
605+ }
606+
607+ #[test]
608+ fn resolve_config_path_rejects_env_override_outside_project() {
609+ let temp = TempDir::new().expect("temp dir should be created");
610+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
611+
612+ let err = resolve_config_path_inner(&root, None, Some(PathBuf::from("../escape.toml")))
613+ .expect_err("outside-project path should be rejected");
614+
615+ assert!(
616+ err.to_string().contains("config path must be within"),
617+ "expected bounds-check error, got: {err}"
618+ );
619+ }
620+
621+ #[test]
622+ fn validate_config_rejects_invalid_values() {
623+ let cfg = Config {
624+ box_cfg: BoxConfig {
625+ cpu_count: 0,
626+ ram_size: ByteSize::mib(2048),
627+ disk_size: ByteSize::gib(5),
628+ mounts: vec![],
629+ },
630+ supervisor: SupervisorConfig::default(),
631+ };
632+ let err = validate_config(&cfg).expect_err("cpu_count=0 should fail");
633+ assert_eq!(err, "box.cpu_count must be >= 1");
634+
635+ let cfg = Config {
636+ box_cfg: BoxConfig {
637+ cpu_count: 2,
638+ ram_size: ByteSize::mib(2048),
639+ disk_size: ByteSize::gib(5),
640+ mounts: vec!["/definitely/missing:/tmp/missing:read-write".to_string()],
641+ },
642+ supervisor: SupervisorConfig::default(),
643+ };
644+ let err = validate_config(&cfg).expect_err("invalid mount should fail");
645+ assert!(err.starts_with("invalid mount spec"));
646+ }
647+
648+ #[test]
649+ fn resolve_config_path_accepts_symlinked_project_root() {
650+ let temp = TempDir::new().expect("temp dir should be created");
651+ let actual_root = temp.path().join("actual");
652+ let link_root = temp.path().join("linked");
653+ fs::create_dir_all(&actual_root).expect("actual root should exist");
654+ std::os::unix::fs::symlink(&actual_root, &link_root).expect("symlink should be created");
655+
656+ let resolved = resolve_config_path_inner(&link_root, Some(Path::new("vibebox.toml")), None)
657+ .expect("symlinked project root should resolve");
658+
659+ assert_eq!(resolved, link_root.join("vibebox.toml"));
660+ }
661+
662+ #[test]
663+ fn resolve_config_path_rejects_symlink_escape() {
664+ let temp = TempDir::new().expect("temp dir should be created");
665+ let project_root = temp.path().join("project");
666+ let outside_root = temp.path().join("outside");
667+ fs::create_dir_all(&project_root).expect("project root should exist");
668+ fs::create_dir_all(&outside_root).expect("outside root should exist");
669+ std::os::unix::fs::symlink(&outside_root, project_root.join("link"))
670+ .expect("escape symlink should be created");
671+
672+ let err = resolve_config_path_inner(&project_root, Some(Path::new("link/cfg.toml")), None)
673+ .expect_err("symlink escape should be rejected");
674+ assert!(
675+ err.to_string().contains("config path must be within"),
676+ "expected bounds-check error, got: {err}"
677+ );
678+ }
284}
679}
src/error_report.shnew file+28
@@ -0,0 +1,28 @@
1+#!/usr/bin/env bash
2+set -Eeuo pipefail
3+__vibebox_err_reported=0
4+__vibebox_report_error() {
5+ local rc="$1"
6+ local line="$2"
7+ local msg="${3:-}"
8+ if [ "$__vibebox_err_reported" -eq 0 ]; then
9+ msg="${msg//$'\n'/ }"
10+ msg="${msg//$'\r'/ }"
11+ if [ -n "$msg" ]; then
12+ echo "VIBEBOX_SCRIPT_ERROR:__LABEL__:${line}:${rc} ${msg}"
13+ else
14+ echo "VIBEBOX_SCRIPT_ERROR:__LABEL__:${line}:${rc}"
15+ fi
16+ __vibebox_err_reported=1
17+ fi
18+}
19+vibebox_fail() {
20+ local msg="${1:-script failed}"
21+ local rc="${2:-1}"
22+ __vibebox_report_error "$rc" "${LINENO}" "$msg"
23+ exit "$rc"
24+}
25+trap 'rc="$?"; __vibebox_report_error "$rc" "${LINENO}" "command failed: ${BASH_COMMAND:-unknown}"' ERR
26+trap 'rc="$?"; if [ "$rc" -ne 0 ]; then __vibebox_report_error "$rc" "${LINENO}" "script exited with code ${rc}"; fi' EXIT
27+
28+__SCRIPT_BODY__
src/explain.rs+33−48
@@ -1,31 +1,31 @@
1+use crate::instance::InstanceConfig;
2+use crate::utils::relative_to_home;
3+use crate::{config, instance, session_manager, tui};
4+use anyhow::{Context, Result, bail};
1use std::{
5use std::{
2 env,
6 env,
3− error::Error,
4 path::{Path, PathBuf},
7 path::{Path, PathBuf},
5};
8};
6
9
7−use crate::{config, instance, session_manager, tui};
8−
9−pub fn build_mount_rows(
10+pub fn build_mount_rows(project: &Path, config: &config::Config) -> Result<Vec<tui::MountListRow>> {
10− cwd: &Path,
11− config: &config::Config,
12−) -> Result<Vec<tui::MountListRow>, Box<dyn Error + Send + Sync>> {
13 let mut rows = Vec::new();
11 let mut rows = Vec::new();
14− rows.extend(default_mounts(cwd)?);
12+ rows.extend(default_mounts(project)?);
15− let guest_home = resolve_guest_home(cwd)?;
13+ let guest_home = resolve_guest_home(project);
16 for spec in &config.box_cfg.mounts {
14 for spec in &config.box_cfg.mounts {
17− rows.push(parse_mount_spec(cwd, spec, false, &guest_home)?);
15+ rows.push(parse_mount_spec(project, spec, false, &guest_home)?);
18 }
16 }
19 Ok(rows)
17 Ok(rows)
20}
18}
21
19
22−pub fn build_network_rows(
20+pub fn build_network_rows(project_dir: &Path) -> Result<Vec<tui::NetworkListRow>> {
23− cwd: &Path,
24−) -> Result<Vec<tui::NetworkListRow>, Box<dyn Error + Send + Sync>> {
25− let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME);
26 let mut vm_ip = "-".to_string();
21 let mut vm_ip = "-".to_string();
27− if let Ok(Some(ip)) = instance::read_instance_vm_ip(&instance_dir) {
22+ if let Ok(config) = instance::read_instance_config(project_dir) {
23+ match config.vm_ipv4 {
24+ None => {}
25+ Some(ip) => {
28− vm_ip = ip;
26+ vm_ip = ip;
27+ }
28+ }
29 }
29 }
30 let host_to_vm = if vm_ip == "-" {
30 let host_to_vm = if vm_ip == "-" {
31 "ssh: <pending>:22".to_string()
31 "ssh: <pending>:22".to_string()
@@ -41,13 +41,13 @@ pub fn build_network_rows(
41 Ok(vec![row])
41 Ok(vec![row])
42}
42}
43
43
44−fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>, Box<dyn Error + Send + Sync>> {
44+fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>> {
45 let project_name = cwd
45 let project_name = cwd
46 .file_name()
46 .file_name()
47 .and_then(|name| name.to_str())
47 .and_then(|name| name.to_str())
48− .unwrap_or("project");
48+ .with_context(|| "failed to get project name")?;
49− let project_guest = format!("/root/{project_name}");
49+ let project_guest = format!("~/{project_name}");
50− let project_host = display_path(cwd);
50+ let project_host = relative_to_home(cwd);
51 let mut rows = vec![tui::MountListRow {
51 let mut rows = vec![tui::MountListRow {
52 host: project_host,
52 host: project_host,
53 guest: project_guest,
53 guest: project_guest,
@@ -57,14 +57,14 @@ fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>, Box<dyn Error +
57
57
58 let home = env::var("HOME")
58 let home = env::var("HOME")
59 .map(PathBuf::from)
59 .map(PathBuf::from)
60− .unwrap_or_else(|_| PathBuf::from("/"));
60+ .with_context(|| "failed to get home directory")?;
61 let cache_home = env::var("XDG_CACHE_HOME")
61 let cache_home = env::var("XDG_CACHE_HOME")
62 .map(PathBuf::from)
62 .map(PathBuf::from)
63 .unwrap_or_else(|_| home.join(".cache"));
63 .unwrap_or_else(|_| home.join(".cache"));
64 let cache_dir = cache_home.join(session_manager::GLOBAL_CACHE_DIR_NAME);
64 let cache_dir = cache_home.join(session_manager::GLOBAL_CACHE_DIR_NAME);
65 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
65 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
66 rows.push(tui::MountListRow {
66 rows.push(tui::MountListRow {
67− host: display_path(&guest_mise_cache),
67+ host: relative_to_home(&guest_mise_cache),
68 guest: "/root/.local/share/mise".to_string(),
68 guest: "/root/.local/share/mise".to_string(),
69 mode: "read-write".to_string(),
69 mode: "read-write".to_string(),
70 default_mount: "yes".to_string(),
70 default_mount: "yes".to_string(),
@@ -77,10 +77,10 @@ fn parse_mount_spec(
77 spec: &str,
77 spec: &str,
78 default_mount: bool,
78 default_mount: bool,
79 guest_home: &str,
79 guest_home: &str,
80−) -> Result<tui::MountListRow, Box<dyn Error + Send + Sync>> {
80+) -> Result<tui::MountListRow> {
81 let parts: Vec<&str> = spec.split(':').collect();
81 let parts: Vec<&str> = spec.split(':').collect();
82 if parts.len() < 2 || parts.len() > 3 {
82 if parts.len() < 2 || parts.len() > 3 {
83− return Err(format!("invalid mount spec: {spec}").into());
83+ bail!["invalid mount spec: {spec}"];
84 }
84 }
85 let host_part = parts[0];
85 let host_part = parts[0];
86 let guest_part = parts[1];
86 let guest_part = parts[1];
@@ -89,11 +89,10 @@ fn parse_mount_spec(
89 "read-only" => "read-only",
89 "read-only" => "read-only",
90 "read-write" => "read-write",
90 "read-write" => "read-write",
91 other => {
91 other => {
92− return Err(format!(
92+ bail![format!(
93 "invalid mount mode '{}'; expected read-only or read-write",
93 "invalid mount mode '{}'; expected read-only or read-write",
94 other
94 other
95− )
95+ )];
96− .into());
97 }
96 }
98 }
97 }
99 } else {
98 } else {
@@ -116,22 +115,22 @@ fn display_host_spec(cwd: &Path, host: &str) -> String {
116 }
115 }
117 let host_path = PathBuf::from(host);
116 let host_path = PathBuf::from(host);
118 if host_path.is_absolute() {
117 if host_path.is_absolute() {
119− return display_path(&host_path);
118+ return relative_to_home(&host_path);
120 }
119 }
121 let candidate = cwd.join(&host_path);
120 let candidate = cwd.join(&host_path);
122 if candidate.is_absolute() {
121 if candidate.is_absolute() {
123− display_path(&candidate)
122+ relative_to_home(&candidate)
124 } else {
123 } else {
125 host.to_string()
124 host.to_string()
126 }
125 }
127}
126}
128
127
129−fn resolve_guest_home(cwd: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
128+fn resolve_guest_home(project_dir: &Path) -> String {
130− let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME);
129+ let config = instance::read_instance_config(project_dir);
131− if let Ok(Some(user)) = instance::read_instance_ssh_user(&instance_dir) {
130+ match config {
132− return Ok(format!("/home/{user}"));
131+ Ok(config) => format!("/home/{}", config.ssh_user),
132+ Err(_) => format!("/home/{}", InstanceConfig::default().ssh_user),
133 }
133 }
134− Ok(format!("/home/{}", instance::DEFAULT_SSH_USER))
135}
134}
136
135
137fn resolve_guest_display(guest: &str, guest_home: &str) -> String {
136fn resolve_guest_display(guest: &str, guest_home: &str) -> String {
@@ -155,17 +154,3 @@ fn resolve_guest_display(guest: &str, guest_home: &str) -> String {
155 format!("/root/{guest}")
154 format!("/root/{guest}")
156 }
155 }
157}
156}
158−
159−fn display_path(path: &Path) -> String {
160− let Ok(home) = env::var("HOME") else {
161− return path.display().to_string();
162− };
163− let home_path = PathBuf::from(home);
164− if let Ok(stripped) = path.strip_prefix(&home_path) {
165− if stripped.components().next().is_none() {
166− return "~".to_string();
167− }
168− return format!("~/{}", stripped.display());
169− }
170− path.display().to_string()
171−}
src/instance.rs+289−373
@@ -1,86 +1,140 @@
1+use anyhow::{Context, Result, bail};
2+use serde::{Deserialize, Serialize};
3+use std::os::unix::fs::FileTypeExt;
1use std::{
4use std::{
2 env, fs,
5 env, fs,
3− io::{self, Write},
6+ io::{self, IsTerminal, Read},
4 net::{SocketAddr, TcpStream},
7 net::{SocketAddr, TcpStream},
5− os::unix::{fs::PermissionsExt, io::OwnedFd, net::UnixStream},
8+ os::unix::{fs::PermissionsExt, net::UnixStream},
6 path::{Path, PathBuf},
9 path::{Path, PathBuf},
7− process::{Command, Stdio},
10+ process::{Child, Command, Stdio},
8 sync::{
11 sync::{
9 Arc, Mutex,
12 Arc, Mutex,
10 atomic::{AtomicBool, Ordering},
13 atomic::{AtomicBool, Ordering},
14+ mpsc,
11 },
15 },
12 thread,
16 thread,
13 time::{Duration, Instant},
17 time::{Duration, Instant},
14};
18};
15−
16−use serde::{Deserialize, Serialize};
17−use std::sync::mpsc::Sender;
18use time::{OffsetDateTime, format_description::well_known::Rfc3339};
19use time::{OffsetDateTime, format_description::well_known::Rfc3339};
19use uuid::Uuid;
20use uuid::Uuid;
20
21
21use crate::{
22use crate::{
22 commands,
23 commands,
23− session_manager::{INSTANCE_DIR_NAME, INSTANCE_FILENAME},
24+ session_manager::INSTANCE_DIR_NAME,
24− tui::AppState,
25+ session_manager::{VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME},
25− vm::{self, LoginAction, VmInput},
26+ vm::{self, LoginAction},
26};
27};
27
28
29+pub const STATUS_VM_ERROR_PREFIX: &str = "error:";
30+
28const SSH_KEY_NAME: &str = "ssh_key";
31const SSH_KEY_NAME: &str = "ssh_key";
29−pub(crate) const VM_ROOT_LOG_NAME: &str = "vm_root.log";
32+const INSTANCE_FILENAME: &str = "instance.toml";
30−pub(crate) const STATUS_FILE_NAME: &str = "status.txt";
31−pub(crate) const DEFAULT_SSH_USER: &str = "vibecoder";
33+const DEFAULT_SSH_USER: &str = "vibecoder";
32−const SSH_CONNECT_RETRIES: usize = 30;
34+const SSH_CONNECT_RETRIES: usize = 10;
33const SSH_CONNECT_DELAY_MS: u64 = 500;
35const SSH_CONNECT_DELAY_MS: u64 = 500;
34const SSH_SETUP_SCRIPT: &str = include_str!("ssh.sh");
36const SSH_SETUP_SCRIPT: &str = include_str!("ssh.sh");
37+const STATUS_PREFIX: &str = "status:";
38+
39+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40+pub enum VmLiveness {
41+ RunningWithSocket { pid: u32 },
42+ RunningWithoutSocket { pid: u32 },
43+ NotRunningOrMissing,
44+}
45+
46+pub fn vm_liveness(project_root: &Path) -> Result<VmLiveness> {
47+ let instance_dir = ensure_instance_dir(project_root)?;
48+ let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
49+ let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
50+ fn pid_is_alive(pid: u32) -> bool {
51+ let pid = pid as libc::pid_t;
52+ let result = unsafe { libc::kill(pid, 0) };
53+ if result == 0 {
54+ return true;
55+ }
56+ match io::Error::last_os_error().raw_os_error() {
57+ Some(code) if code == libc::EPERM => true,
58+ Some(code) if code == libc::ESRCH => false,
59+ _ => false,
60+ }
61+ }
62+ let Ok(content) = fs::read_to_string(pid_path) else {
63+ return Ok(VmLiveness::NotRunningOrMissing);
64+ };
65+ let Ok(pid) = content.trim().parse::<u32>() else {
66+ return Ok(VmLiveness::NotRunningOrMissing);
67+ };
68+ if !pid_is_alive(pid) {
69+ return Ok(VmLiveness::NotRunningOrMissing);
70+ }
71+ let has_socket = fs::metadata(socket_path)
72+ .map(|meta| meta.file_type().is_socket())
73+ .unwrap_or(false);
74+ if has_socket {
75+ Ok(VmLiveness::RunningWithSocket { pid })
76+ } else {
77+ Ok(VmLiveness::RunningWithoutSocket { pid })
78+ }
79+}
80+
81+#[derive(Debug, thiserror::Error)]
82+pub enum InstanceError {
83+ #[error("unexpected disconnection from vm manager")]
84+ UnexpectedDisconnection,
85+ #[error("{0}")]
86+ VMError(String),
87+}
88+
89+fn default_ssh_user() -> String {
90+ DEFAULT_SSH_USER.to_string()
91+}
35
92
36#[derive(Debug, Clone, Serialize, Deserialize)]
93#[derive(Debug, Clone, Serialize, Deserialize)]
37−pub(crate) struct InstanceConfig {
94+pub struct InstanceConfig {
38 #[serde(default)]
95 #[serde(default)]
39− id: String,
96+ pub id: String,
40 #[serde(default = "default_ssh_user")]
97 #[serde(default = "default_ssh_user")]
41− ssh_user: String,
98+ pub ssh_user: String,
42 #[serde(default)]
99 #[serde(default)]
43 sudo_password: String,
100 sudo_password: String,
44 #[serde(default)]
101 #[serde(default)]
45− last_active: Option<String>,
102+ pub last_active: Option<String>,
46 #[serde(default)]
103 #[serde(default)]
47− pub(crate) vm_ipv4: Option<String>,
104+ pub vm_ipv4: Option<String>,
48}
105}
49
106
50−impl InstanceConfig {
107+impl Default for InstanceConfig {
51− pub(crate) fn ssh_user_display(&self) -> String {
108+ fn default() -> Self {
52− if self.ssh_user.trim().is_empty() {
109+ Self {
110+ id: Uuid::now_v7().to_string(),
53− DEFAULT_SSH_USER.to_string()
111+ ssh_user: DEFAULT_SSH_USER.to_string(),
54− } else {
112+ sudo_password: Uuid::now_v7().simple().to_string(),
55− self.ssh_user.clone()
113+ last_active: None,
114+ vm_ipv4: None,
56 }
115 }
57 }
116 }
58}
117}
59
118
60−fn default_ssh_user() -> String {
61− DEFAULT_SSH_USER.to_string()
62−}
63−
64−pub fn run_with_ssh(manager_conn: UnixStream) -> Result<(), Box<dyn std::error::Error>> {
119+pub fn run_with_ssh(manager_conn: UnixStream) -> Result<()> {
65 let project_root = env::current_dir()?;
120 let project_root = env::current_dir()?;
66 tracing::info!(root = %project_root.display(), "starting ssh session");
121 tracing::info!(root = %project_root.display(), "starting ssh session");
67 let instance_dir = ensure_instance_dir(&project_root)?;
122 let instance_dir = ensure_instance_dir(&project_root)?;
68 tracing::debug!(instance_dir = %instance_dir.display(), "instance dir ready");
123 tracing::debug!(instance_dir = %instance_dir.display(), "instance dir ready");
69 let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?;
124 let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?;
70
125
71− let config = load_or_create_instance_config(&instance_dir)?;
126+ let config = load_or_create_instance_config(&project_root)?;
72 let ssh_user = config.ssh_user.clone();
127 let ssh_user = config.ssh_user.clone();
73 tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
128 tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
74
129
75− let _manager_conn = manager_conn;
76− wait_for_vm_ipv4(&instance_dir, Duration::from_secs(120))?;
130+ wait_for_vm_ipv4(&project_root, Duration::from_secs(480), &manager_conn)?;
77
131
78− let ip = load_or_create_instance_config(&instance_dir)?
132+ let ip = load_or_create_instance_config(&project_root)?
79 .vm_ipv4
133 .vm_ipv4
80− .ok_or("VM IPv4 not available")?;
134+ .with_context(|| "failed to load instance IP address")?;
81 tracing::info!(ip = %ip, "vm ipv4 ready");
135 tracing::info!(ip = %ip, "vm ipv4 ready");
82
136
83− run_ssh_session(ssh_key, ssh_user, ip)
137+ run_ssh_session(ssh_key, ssh_user, ip, manager_conn, project_root)
84}
138}
85
139
86pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
140pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
@@ -89,9 +143,7 @@ pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
89 Ok(instance_dir)
143 Ok(instance_dir)
90}
144}
91
145
92−pub(crate) fn ensure_ssh_keypair(
146+pub fn ensure_ssh_keypair(instance_dir: &Path) -> Result<(PathBuf, PathBuf)> {
93− instance_dir: &Path,
94−) -> Result<(PathBuf, PathBuf), Box<dyn std::error::Error>> {
95 let private_key = instance_dir.join(SSH_KEY_NAME);
147 let private_key = instance_dir.join(SSH_KEY_NAME);
96 let public_key = instance_dir.join(format!("{SSH_KEY_NAME}.pub"));
148 let public_key = instance_dir.join(format!("{SSH_KEY_NAME}.pub"));
97
149
@@ -113,7 +165,9 @@ pub(crate) fn ensure_ssh_keypair(
113 "-N",
165 "-N",
114 "",
166 "",
115 "-f",
167 "-f",
168+ private_key
169+ .to_str()
116− private_key.to_str().ok_or("ssh key path not utf-8")?,
170+ .with_context(|| "ssh key path not utf-8")?,
117 "-C",
171 "-C",
118 "vibebox",
172 "vibebox",
119 ])
173 ])
@@ -123,7 +177,7 @@ pub(crate) fn ensure_ssh_keypair(
123 .status()?;
177 .status()?;
124
178
125 if !status.success() {
179 if !status.success() {
126− return Err("ssh-keygen failed".into());
180+ bail!("ssh-keygen failed");
127 }
181 }
128
182
129 fs::set_permissions(&private_key, fs::Permissions::from_mode(0o600))?;
183 fs::set_permissions(&private_key, fs::Permissions::from_mode(0o600))?;
@@ -132,97 +186,66 @@ pub(crate) fn ensure_ssh_keypair(
132 Ok((private_key, public_key))
186 Ok((private_key, public_key))
133}
187}
134
188
135−pub(crate) fn load_or_create_instance_config(
189+pub fn load_or_create_instance_config(project_dir: &Path) -> Result<InstanceConfig> {
136− instance_dir: &Path,
190+ let mut exist = true;
137−) -> Result<InstanceConfig, Box<dyn std::error::Error>> {
191+ let mut config = read_instance_config(project_dir).unwrap_or_else(|_| {
138− let config_path = instance_dir.join(INSTANCE_FILENAME);
192+ exist = false;
139− let mut config = if config_path.exists() {
140− let raw = fs::read_to_string(&config_path)?;
141− toml::from_str::<InstanceConfig>(&raw)?
142− } else {
143− InstanceConfig {
193+ InstanceConfig::default()
144− id: String::new(),
145− ssh_user: default_ssh_user(),
146− sudo_password: String::new(),
147− last_active: None,
148− vm_ipv4: None,
149− }
150− };
194+ });
151
195
152 let mut changed = false;
196 let mut changed = false;
153 if config.ssh_user.trim().is_empty() {
197 if config.ssh_user.trim().is_empty() {
154− config.ssh_user = default_ssh_user();
198+ config.ssh_user = InstanceConfig::default().ssh_user;
155 changed = true;
199 changed = true;
156 }
200 }
157
201
158 if config.id.trim().is_empty() {
202 if config.id.trim().is_empty() {
159− config.id = Uuid::now_v7().to_string();
203+ config.id = InstanceConfig::default().id;
160 changed = true;
204 changed = true;
161 }
205 }
162
206
163 if config.sudo_password.trim().is_empty() {
207 if config.sudo_password.trim().is_empty() {
164− config.sudo_password = generate_password();
208+ config.sudo_password = InstanceConfig::default().sudo_password;
165 changed = true;
209 changed = true;
166 }
210 }
167
211
168− if !config_path.exists() || changed {
212+ if !exist || changed {
169− write_instance_config(&config_path, &config)?;
213+ write_instance_config(project_dir, &config)?;
170 }
214 }
171
215
172 Ok(config)
216 Ok(config)
173}
217}
174
218
175−fn read_instance_config(
219+pub fn read_instance_config(project_dir: &Path) -> Result<InstanceConfig> {
176− instance_dir: &Path,
220+ // todo maybe verify schema?
177−) -> Result<Option<InstanceConfig>, Box<dyn std::error::Error>> {
178− let config_path = instance_dir.join(INSTANCE_FILENAME);
221+ let config_path = project_dir.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
179 if !config_path.exists() {
222 if !config_path.exists() {
180− return Ok(None);
223+ bail!("instance config file does not exist");
181 }
224 }
182 let raw = fs::read_to_string(&config_path)?;
225 let raw = fs::read_to_string(&config_path)?;
183 let config = toml::from_str::<InstanceConfig>(&raw)?;
226 let config = toml::from_str::<InstanceConfig>(&raw)?;
184− Ok(Some(config))
227+ Ok(config)
185−}
186−
187−pub fn read_instance_vm_ip(
188− instance_dir: &Path,
189−) -> Result<Option<String>, Box<dyn std::error::Error>> {
190− let config = read_instance_config(instance_dir)?;
191− Ok(config.and_then(|cfg| cfg.vm_ipv4))
192−}
193−
194−pub fn read_instance_ssh_user(
195− instance_dir: &Path,
196−) -> Result<Option<String>, Box<dyn std::error::Error>> {
197− let config = read_instance_config(instance_dir)?;
198− Ok(config
199− .map(|cfg| cfg.ssh_user)
200− .filter(|user| !user.trim().is_empty()))
201}
228}
202
229
203−pub fn touch_last_active(instance_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
230+pub fn touch_last_active(project_dir: &Path) -> Result<()> {
204− let mut config = load_or_create_instance_config(instance_dir)?;
231+ let mut config = load_or_create_instance_config(project_dir)?;
205 let now = OffsetDateTime::now_utc().format(&Rfc3339)?;
232 let now = OffsetDateTime::now_utc().format(&Rfc3339)?;
206 config.last_active = Some(now);
233 config.last_active = Some(now);
207− write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
234+ write_instance_config(project_dir, &config)?;
208 Ok(())
235 Ok(())
209}
236}
210
237
211−pub(crate) fn write_instance_config(
238+pub fn write_instance_config(project_dir: &Path, config: &InstanceConfig) -> Result<()> {
212− path: &Path,
239+ let path = project_dir.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
213− config: &InstanceConfig,
214−) -> Result<(), Box<dyn std::error::Error>> {
215 let data = toml::to_string_pretty(config)?;
240 let data = toml::to_string_pretty(config)?;
241+ fs::create_dir_all(project_dir.join(INSTANCE_DIR_NAME))?;
216− fs::write(path, data)?;
242+ fs::write(&path, data)?;
217− fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
243+ fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
218 Ok(())
244 Ok(())
219}
245}
220
246
221−fn generate_password() -> String {
247+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
222− Uuid::now_v7().simple().to_string()
223−}
224−
225−pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
248+pub fn extract_ipv4(line: &str) -> Option<String> {
226 let mut current = String::new();
249 let mut current = String::new();
227 let mut best: Option<String> = None;
250 let mut best: Option<String> = None;
228
251
@@ -241,25 +264,69 @@ pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
241 best
264 best
242}
265}
243
266
267+fn handle_manager_line(line: &str, last_status: &mut Option<String>) -> Result<()> {
268+ if let Some(status) = line.strip_prefix(STATUS_PREFIX) {
269+ let status = status.trim();
270+ if let Some(message) = status.strip_prefix(STATUS_VM_ERROR_PREFIX) {
271+ let message = message.trim();
272+ if message.is_empty() {
273+ bail!("vm manager reported startup failure");
274+ }
275+ return Err(InstanceError::VMError(message.to_string()).into());
276+ }
277+ if !status.is_empty() && last_status.as_deref() != Some(status) {
278+ tracing::info!("[background]: {}", status);
279+ *last_status = Some(status.to_string());
280+ }
281+ }
282+ Ok(())
283+}
284+
244fn wait_for_vm_ipv4(
285fn wait_for_vm_ipv4(
245− instance_dir: &Path,
286+ project_dir: &Path,
246 timeout: Duration,
287 timeout: Duration,
247−) -> Result<(), Box<dyn std::error::Error>> {
288+ manager_conn: &UnixStream,
289+) -> Result<()> {
248 let start = Instant::now();
290 let start = Instant::now();
249 let mut next_log_at = start + Duration::from_secs(10);
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();
250 tracing::info!("waiting for vm ipv4");
296 tracing::info!("waiting for vm ipv4");
251− let status_path = instance_dir.join(STATUS_FILE_NAME);
252 let mut last_status: Option<String> = None;
297 let mut last_status: Option<String> = None;
253 let mut once_hint = false;
298 let mut once_hint = false;
254 loop {
299 loop {
300+ match stream.read(&mut read_buf) {
301+ Ok(0) => {
302+ bail!("vm manager disconnected before VM became ready");
303+ }
304+ Ok(n) => {
305+ pending.push_str(&String::from_utf8_lossy(&read_buf[..n]));
306+ while let Some(pos) = pending.find('\n') {
307+ let line = pending[..pos].trim().to_string();
308+ pending.drain(..=pos);
309+ handle_manager_line(&line, &mut last_status)?;
310+ }
311+ }
312+ Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
313+ Err(err) if err.kind() == io::ErrorKind::TimedOut => {}
314+ Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
315+ Err(err) => {
316+ tracing::warn!(error = %err, "failed to read vm manager status stream");
317+ }
318+ }
319+
255− let config = load_or_create_instance_config(instance_dir)?;
320+ let config = load_or_create_instance_config(project_dir)?;
256 if config.vm_ipv4.is_some() {
321 if config.vm_ipv4.is_some() {
257 return Ok(());
322 return Ok(());
258 }
323 }
259 if start.elapsed() > timeout {
324 if start.elapsed() > timeout {
260− return Err("Timed out waiting for VM IPv4".into());
325+ bail!("timed out waiting for VM IPv4");
261 }
326 }
327+
328+ let now = Instant::now();
262− if Instant::now() >= next_log_at {
329+ if now >= next_log_at {
263 let waited = start.elapsed();
330 let waited = start.elapsed();
264 if waited.as_secs() > 15 && !once_hint {
331 if waited.as_secs() > 15 && !once_hint {
265 tracing::info!(
332 tracing::info!(
@@ -267,18 +334,9 @@ fn wait_for_vm_ipv4(
267 );
334 );
268 once_hint = true;
335 once_hint = true;
269 }
336 }
270− if let Ok(status) = fs::read_to_string(&status_path) {
271− let status = status.trim().to_string();
272− if !status.is_empty() && last_status.as_deref() != Some(status.as_str()) {
273− tracing::info!("[background]: {}", status);
274− last_status = Some(status);
275− }
276− } else {
277− tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
337+ tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
278− }
279− next_log_at += Duration::from_secs(10);
338+ next_log_at += Duration::from_secs(20);
280 }
339 }
281− thread::sleep(Duration::from_millis(200));
282 }
340 }
283}
341}
284
342
@@ -286,9 +344,14 @@ fn run_ssh_session(
286 ssh_key: PathBuf,
344 ssh_key: PathBuf,
287 ssh_user: String,
345 ssh_user: String,
288 ip: String,
346 ip: String,
289−) -> Result<(), Box<dyn std::error::Error>> {
347+ manager_conn: UnixStream,
348+ project_root: PathBuf,
349+) -> Result<()> {
290 let mut attempts = 0usize;
350 let mut attempts = 0usize;
291 loop {
351 loop {
352+ if matches!(vm_liveness(&project_root)?, VmLiveness::NotRunningOrMissing) {
353+ return Err(InstanceError::UnexpectedDisconnection.into());
354+ }
292 attempts += 1;
355 attempts += 1;
293 if !ssh_port_open(&ip) {
356 if !ssh_port_open(&ip) {
294 tracing::debug!(attempts, "ssh port doesn't open yet");
357 tracing::debug!(attempts, "ssh port doesn't open yet");
@@ -300,9 +363,7 @@ fn run_ssh_session(
300 SSH_CONNECT_RETRIES
363 SSH_CONNECT_RETRIES
301 );
364 );
302 if attempts >= SSH_CONNECT_RETRIES {
365 if attempts >= SSH_CONNECT_RETRIES {
303− return Err(
304− format!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts").into(),
366+ bail!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts");
305− );
306 }
367 }
307 thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS));
368 thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS));
308 continue;
369 continue;
@@ -316,10 +377,10 @@ fn run_ssh_session(
316 attempts,
377 attempts,
317 SSH_CONNECT_RETRIES
378 SSH_CONNECT_RETRIES
318 );
379 );
319− let status = Command::new("ssh")
380+ let child = Command::new("ssh")
320 .args([
381 .args([
321 "-i",
382 "-i",
322− ssh_key.to_str().unwrap_or(".vibebox/ssh_key"),
383+ ssh_key.to_str().with_context(|| "invalid path")?,
323 "-o",
384 "-o",
324 "IdentitiesOnly=yes",
385 "IdentitiesOnly=yes",
325 "-o",
386 "-o",
@@ -344,27 +405,67 @@ fn run_ssh_session(
344 .stdin(Stdio::inherit())
405 .stdin(Stdio::inherit())
345 .stdout(Stdio::inherit())
406 .stdout(Stdio::inherit())
346 .stderr(Stdio::inherit())
407 .stderr(Stdio::inherit())
408+ .spawn();
409+
410+ match child {
411+ Ok(mut child) => {
412+ let done = Arc::new(AtomicBool::new(false));
413+ let done_for_monitor = done.clone();
414+ let (disconnect_tx, disconnect_rx) = mpsc::channel::<()>();
415+ let mut manager_stream = manager_conn.try_clone()?;
416+ let _ = manager_stream.set_read_timeout(Some(Duration::from_millis(250)));
417+ thread::spawn(move || {
418+ let mut buf = [0u8; 1];
419+ while !done_for_monitor.load(Ordering::Relaxed) {
420+ match manager_stream.read(&mut buf) {
421+ Ok(0) => {
422+ let _ = disconnect_tx.send(());
423+ return;
424+ }
425+ Ok(_) => {}
426+ Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
427+ Err(err) if err.kind() == io::ErrorKind::TimedOut => {}
428+ Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
429+ Err(_) => {
430+ let _ = disconnect_tx.send(());
431+ return;
432+ }
433+ }
434+ }
435+ });
436+
437+ let status = loop {
438+ if disconnect_rx.try_recv().is_ok() {
439+ done.store(true, Ordering::Relaxed);
440+ terminate_ssh_child(&mut child);
441+ restore_terminal_after_disconnect();
442+ return Err(InstanceError::UnexpectedDisconnection.into());
443+ }
444+ if let Some(status) = child.try_wait()? {
445+ done.store(true, Ordering::Relaxed);
347− .status();
446+ break status;
447+ }
448+ thread::sleep(Duration::from_millis(100));
449+ };
348
450
349− match status {
350− Ok(status) if status.success() => {
451+ if status.success() {
351− tracing::info!(status = %status, "ssh exited");
452+ tracing::info!(status = %status, "ssh exited");
352− break;
453+ break;
353− }
454+ }
354− Ok(status) if status.code() == Some(255) => {
455+ if status.code() == Some(255) {
355− tracing::warn!(status = %status, "ssh connection failed");
456+ tracing::warn!(status = %status, "ssh connection failed");
356− if attempts >= SSH_CONNECT_RETRIES {
457+ if attempts >= SSH_CONNECT_RETRIES {
357− return Err(format!("ssh failed after {SSH_CONNECT_RETRIES} attempts").into());
458+ bail!("ssh failed after {SSH_CONNECT_RETRIES} attempts");
459+ }
460+ thread::sleep(Duration::from_millis(500));
461+ continue;
358 }
462 }
359− thread::sleep(Duration::from_millis(500));
360− }
361− Ok(status) => {
362 tracing::info!(status = %status, "ssh exited");
463 tracing::info!(status = %status, "ssh exited");
363 break;
464 break;
364 }
465 }
365 Err(err) => {
466 Err(err) => {
366 tracing::error!(error = %err, "failed to start ssh");
467 tracing::error!(error = %err, "failed to start ssh");
367− return Err(format!("failed to start ssh: {err}").into());
468+ bail!("failed to start ssh: {err}");
368 }
469 }
369 }
470 }
370 }
471 }
@@ -372,6 +473,31 @@ fn run_ssh_session(
372 Ok(())
473 Ok(())
373}
474}
374
475
476+fn terminate_ssh_child(child: &mut Child) {
477+ let pid = child.id() as i32;
478+ unsafe {
479+ libc::kill(pid, libc::SIGTERM);
480+ }
481+ let deadline = Instant::now() + Duration::from_millis(700);
482+ while Instant::now() < deadline {
483+ match child.try_wait() {
484+ Ok(Some(_)) => return,
485+ Ok(None) => thread::sleep(Duration::from_millis(50)),
486+ Err(_) => break,
487+ }
488+ }
489+ let _ = child.kill();
490+ let _ = child.wait();
491+}
492+
493+fn restore_terminal_after_disconnect() {
494+ if io::stdin().is_terminal() {
495+ let _ = Command::new("stty").arg("sane").status();
496+ }
497+ eprintln!();
498+}
499+
500+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
375fn is_ipv4_candidate(candidate: &str) -> bool {
501fn is_ipv4_candidate(candidate: &str) -> bool {
376 let parts: Vec<&str> = candidate.split('.').collect();
502 let parts: Vec<&str> = candidate.split('.').collect();
377 if parts.len() != 4 {
503 if parts.len() != 4 {
@@ -393,12 +519,13 @@ fn ssh_port_open(ip: &str) -> bool {
393 Ok(addr) => addr,
519 Ok(addr) => addr,
394 Err(_) => return false,
520 Err(_) => return false,
395 };
521 };
396− TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).is_ok()
522+ TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok()
397}
523}
398
524
399−pub(crate) fn build_ssh_login_actions(
525+pub fn build_ssh_login_actions(
400 config: &Arc<Mutex<InstanceConfig>>,
526 config: &Arc<Mutex<InstanceConfig>>,
401 project_name: &str,
527 project_name: &str,
528+ project_guest_dir: &str,
402 guest_dir: &str,
529 guest_dir: &str,
403 key_name: &str,
530 key_name: &str,
404 home_links_script: &str,
531 home_links_script: &str,
@@ -410,255 +537,44 @@ pub(crate) fn build_ssh_login_actions(
410
537
411 let key_path = format!("{guest_dir}/{key_name}.pub");
538 let key_path = format!("{guest_dir}/{key_name}.pub");
412
539
413− let setup_script = SSH_SETUP_SCRIPT
540+ let ssh_script = SSH_SETUP_SCRIPT
414 .replace("__SSH_USER__", &ssh_user)
541 .replace("__SSH_USER__", &ssh_user)
415 .replace("__SUDO_PASSWORD__", &sudo_password)
542 .replace("__SUDO_PASSWORD__", &sudo_password)
416 .replace("__PROJECT_NAME__", project_name)
543 .replace("__PROJECT_NAME__", project_name)
544+ .replace("__PROJECT_GUEST_DIR__", project_guest_dir)
417 .replace("__KEY_PATH__", &key_path)
545 .replace("__KEY_PATH__", &key_path)
418 .replace("__VIBEBOX_SHELL_SCRIPT__", &commands::render_shell_script())
546 .replace("__VIBEBOX_SHELL_SCRIPT__", &commands::render_shell_script())
419 .replace("__VIBEBOX_HOME_LINKS__", home_links_script);
547 .replace("__VIBEBOX_HOME_LINKS__", home_links_script);
420− let setup = vm::script_command_from_content("ssh_setup", &setup_script)
548+ let setup = vm::script_command_from_content("ssh.sh", &ssh_script)
421 .expect("ssh setup script contained invalid marker");
549 .expect("ssh setup script contained invalid marker");
422
550
423 vec![LoginAction::Send(setup)]
551 vec![LoginAction::Send(setup)]
424}
552}
425
553
426−fn spawn_ssh_io(
554+#[cfg(test)]
427− app: Arc<Mutex<AppState>>,
555+mod tests {
428− config: Arc<Mutex<InstanceConfig>>,
556+ use super::*;
429− instance_dir: PathBuf,
430− ssh_key: PathBuf,
431− output_monitor: Arc<vm::OutputMonitor>,
432− vm_output_fd: OwnedFd,
433− vm_input_fd: OwnedFd,
434−) -> vm::IoContext {
435− let io_control = vm::IoControl::new();
436−
437− let log_path = instance_dir.join(VM_ROOT_LOG_NAME);
438− let log_file = fs::OpenOptions::new()
439− .create(true)
440− .write(true)
441− .truncate(true)
442− .open(&log_path)
443− .ok()
444− .map(|file| Arc::new(Mutex::new(file)));
445−
446− let ssh_connected = Arc::new(AtomicBool::new(false));
447− let ssh_started = Arc::new(AtomicBool::new(false));
448− let ssh_ready = Arc::new(AtomicBool::new(false));
449− let input_tx_holder: Arc<Mutex<Option<Sender<VmInput>>>> = Arc::new(Mutex::new(None));
450−
451− let instance_path = instance_dir.join(INSTANCE_FILENAME);
452− let config_for_output = config.clone();
453− let log_for_output = log_file.clone();
454− let ssh_connected_for_output = ssh_connected.clone();
455− let ssh_ready_for_output = ssh_ready.clone();
456−
457− let mut line_buf = String::new();
458−
459− let handlers = commands::build_handlers(app.clone(), io_control.clone());
460− let on_line = move |line: &str| handlers.handle(line);
461−
462− let on_output = move |bytes: &[u8]| {
463− if ssh_connected_for_output.load(Ordering::SeqCst) {
464− if let Some(log) = &log_for_output {
465− if let Ok(mut file) = log.lock() {
466− let _ = file.write_all(bytes);
467− }
468− }
469− }
470−
471− let text = String::from_utf8_lossy(bytes);
472− line_buf.push_str(&text);
473−
474− while let Some(pos) = line_buf.find('\n') {
475− let mut line = line_buf[..pos].to_string();
476− if line.ends_with('\r') {
477− line.pop();
478− }
479− line_buf.drain(..=pos);
480−
481− let cleaned = line.trim_start_matches(|c: char| c == '\r' || c == ' ');
482−
483− if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
484− let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
485− let ip = extract_ipv4(ip_raw).unwrap_or_default();
486− if !ip.is_empty() {
487− if let Ok(mut cfg) = config_for_output.lock() {
488− if cfg.vm_ipv4.as_deref() != Some(ip.as_str()) {
489− cfg.vm_ipv4 = Some(ip.clone());
490− let _ = write_instance_config(&instance_path, &cfg);
491− tracing::info!(ip = %ip, "detected vm ipv4");
492− }
493− }
494− }
495− }
496
557
497− if cleaned.contains("VIBEBOX_SSH_READY") {
558+ #[test]
498− ssh_ready_for_output.store(true, Ordering::SeqCst);
559+ fn handle_manager_line_updates_status() {
499− tracing::info!("sshd ready");
560+ let mut last_status = None;
500− }
561+ handle_manager_line("status: preparing VM image...", &mut last_status)
501− }
562+ .expect("status should be accepted");
502− };
563+ assert_eq!(last_status.as_deref(), Some("preparing VM image..."));
503−
504− let io_ctx = vm::spawn_vm_io_with_hooks(
505− output_monitor,
506− vm_output_fd,
507− vm_input_fd,
508− io_control.clone(),
509− on_line,
510− on_output,
511− );
512−
513− *input_tx_holder.lock().unwrap() = Some(io_ctx.input_tx.clone());
514−
515− let ssh_ready_for_thread = ssh_ready.clone();
516− let ssh_started_for_thread = ssh_started.clone();
517− let ssh_connected_for_thread = ssh_connected.clone();
518− let io_control_for_thread = io_control.clone();
519− let ssh_key_for_thread = ssh_key.clone();
520− let config_for_thread = config.clone();
521− let input_tx_holder_for_thread = input_tx_holder.clone();
522−
523− thread::spawn(move || {
524− loop {
525− if ssh_started_for_thread.load(Ordering::SeqCst) {
526− break;
527− }
528− if !ssh_ready_for_thread.load(Ordering::SeqCst) {
529− thread::sleep(std::time::Duration::from_millis(200));
530− continue;
531− }
532−
533− let ip = config_for_thread
534− .lock()
535− .ok()
536− .and_then(|cfg| cfg.vm_ipv4.clone());
537− let ssh_user = config_for_thread
538− .lock()
539− .map(|cfg| cfg.ssh_user.clone())
540− .unwrap_or_else(|_| DEFAULT_SSH_USER.to_string());
541−
542− if let Some(ip) = ip {
543− if ssh_started_for_thread
544− .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
545− .is_err()
546− {
547− break;
548− }
549−
550− tracing::info!(user = %ssh_user, ip = %ip, "starting ssh");
551− io_control_for_thread.request_terminal_restore();
552− io_control_for_thread.set_forward_output(false);
553− io_control_for_thread.set_forward_input(false);
554− ssh_connected_for_thread.store(true, Ordering::SeqCst);
555−
556− let mut attempts = 0usize;
557− loop {
558− attempts += 1;
559− if !ssh_port_open(&ip) {
560− tracing::info!(
561− attempts,
562− ip = %ip,
563− "waiting for ssh port ({}/{})",
564− attempts,
565− SSH_CONNECT_RETRIES
566− );
567− if attempts >= SSH_CONNECT_RETRIES {
568− ssh_connected_for_thread.store(false, Ordering::SeqCst);
569− tracing::warn!(
570− attempts,
571− "ssh port not ready after {} attempts",
572− SSH_CONNECT_RETRIES
573− );
574− break;
575− }
576− thread::sleep(std::time::Duration::from_millis(SSH_CONNECT_DELAY_MS));
577− continue;
578− }
579−
580− tracing::info!(
581− attempts,
582− user = %ssh_user,
583− ip = %ip,
584− "starting ssh ({}/{})",
585− attempts,
586− SSH_CONNECT_RETRIES
587− );
588− let status = Command::new("ssh")
589− .args([
590− "-i",
591− ssh_key_for_thread.to_str().unwrap_or(".vibebox/ssh_key"),
592− "-o",
593− "IdentitiesOnly=yes",
594− "-o",
595− "StrictHostKeyChecking=no",
596− "-o",
597− "UserKnownHostsFile=/dev/null",
598− "-o",
599− "GlobalKnownHostsFile=/dev/null",
600− "-o",
601− "PasswordAuthentication=no",
602− "-o",
603− "BatchMode=yes",
604− "-o",
605− "LogLevel=ERROR",
606− "-o",
607− "ConnectTimeout=5",
608− ])
609− .env_remove("LC_CTYPE")
610− .env_remove("LC_ALL")
611− // .env_remove("LANG")
612− .arg(format!("{ssh_user}@{ip}"))
613− .stdin(Stdio::inherit())
614− .stdout(Stdio::inherit())
615− .stderr(Stdio::inherit())
616− .status();
617−
618− match status {
619− Ok(status) if status.success() => {
620− ssh_connected_for_thread.store(false, Ordering::SeqCst);
621− tracing::info!(status = %status, "ssh exited");
622− if let Some(tx) = input_tx_holder_for_thread.lock().unwrap().clone() {
623− let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
624− }
625− break;
626− }
627− Ok(status) if status.code() == Some(255) => {
628− tracing::warn!(status = %status, "ssh connection failed");
629− if attempts >= SSH_CONNECT_RETRIES {
630− ssh_connected_for_thread.store(false, Ordering::SeqCst);
631− tracing::warn!(
632− attempts,
633− "ssh failed after {} attempts",
634− SSH_CONNECT_RETRIES
635− );
636− break;
637− }
638− thread::sleep(std::time::Duration::from_millis(500));
639− continue;
640− }
641− Ok(status) => {
642− ssh_connected_for_thread.store(false, Ordering::SeqCst);
643− tracing::info!(status = %status, "ssh exited");
644− if let Some(tx) = input_tx_holder_for_thread.lock().unwrap().clone() {
645− let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
646− }
647− break;
648− }
649− Err(err) => {
650− ssh_connected_for_thread.store(false, Ordering::SeqCst);
651− tracing::error!(error = %err, "failed to start ssh");
652− break;
653− }
654− }
655− }
656− break;
657− }
564+ }
658
565
659− thread::sleep(std::time::Duration::from_millis(200));
566+ #[test]
660− }
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());
661− });
571+ }
662
572
663− io_ctx
573+ #[test]
574+ fn handle_manager_line_surfaces_error_status() {
575+ let mut last_status = None;
576+ let err = handle_manager_line("status: error: vm failed to boot", &mut last_status)
577+ .expect_err("error status should fail");
578+ assert_eq!(err.to_string(), "vm failed to boot");
579+ }
664}
580}
src/lib.rs+1
@@ -8,3 +8,4 @@ pub mod vm_manager;
8
8
9pub use session_manager::{SessionError, SessionManager, SessionRecord};
9pub use session_manager::{SessionError, SessionManager, SessionRecord};
10pub mod config;
10pub mod config;
11+pub mod utils;
src/provision.sh+55−5
@@ -1,12 +1,55 @@
1#!/bin/bash
1#!/bin/bash
2−set -eux
2+set -eEux
3+
4+trap 'rc=$?; echo "[vibebox][error] provisioning failed at: ${BASH_COMMAND} (exit ${rc})"; printf "%s%s\n" VIBEBOX_PROVISION_ FAILED; systemctl poweroff || true; exit 1' ERR
5+
6+# Wait for network + DNS before apt-get to avoid early boot flakiness.
7+wait_for_network() {
8+ echo "[vibebox] waiting for network/DNS readiness"
9+ local deadline=$((SECONDS + 180))
10+ while [ "$SECONDS" -lt "$deadline" ]; do
11+ local has_route=0
12+ if ip -4 route show default >/dev/null 2>&1; then
13+ has_route=1
14+ elif ip -6 route show default >/dev/null 2>&1; then
15+ has_route=1
16+ fi
17+ if [ "$has_route" -eq 1 ]; then
18+ if getent hosts deb.debian.org >/dev/null 2>&1; then
19+ return 0
20+ fi
21+ fi
22+ sleep 1
23+ done
24+ echo "[vibebox][warn] network/DNS still not ready after 180s; continuing" >&2
25+ echo "[vibebox][warn] /etc/resolv.conf:" >&2
26+ cat /etc/resolv.conf >&2 || true
27+ ip -br addr >&2 || true
28+ ip route >&2 || true
29+ ip -6 route >&2 || true
30+ return 0
31+}
32+
33+apt_update_with_retries() {
34+ local attempt=1
35+ while [ "$attempt" -le 5 ]; do
36+ if apt-get update; then
37+ return 0
38+ fi
39+ echo "[vibebox][warn] apt-get update failed (attempt ${attempt}/5); retrying..." >&2
40+ attempt=$((attempt + 1))
41+ sleep 2
42+ done
43+ return 1
44+}
3
45
4# Don't wait too long for slow mirrors.
46# Don't wait too long for slow mirrors.
5−echo 'Acquire::http::Timeout "2";' | tee /etc/apt/apt.conf.d/99timeout
47+echo 'Acquire::http::Timeout "10";' | tee /etc/apt/apt.conf.d/99timeout
6−echo 'Acquire::https::Timeout "2";' | tee -a /etc/apt/apt.conf.d/99timeout
48+echo 'Acquire::https::Timeout "10";' | tee -a /etc/apt/apt.conf.d/99timeout
7−echo 'Acquire::Retries "2";' | tee -a /etc/apt/apt.conf.d/99timeout
49+echo 'Acquire::Retries "5";' | tee -a /etc/apt/apt.conf.d/99timeout
8
50
9−apt-get update
51+wait_for_network
52+apt_update_with_retries
10apt-get install -y --no-install-recommends \
53apt-get install -y --no-install-recommends \
11 build-essential \
54 build-essential \
12 pkg-config \
55 pkg-config \
@@ -14,6 +57,7 @@ apt-get install -y --no-install-recommends \
14 curl \
57 curl \
15 git \
58 git \
16 ripgrep \
59 ripgrep \
60+ cloud-guest-utils \
17 openssh-server \
61 openssh-server \
18 sudo
62 sudo
19
63
@@ -42,6 +86,11 @@ systemctl restart ssh
42# Set this env var so claude doesn't complain about running as root.'
86# Set this env var so claude doesn't complain about running as root.'
43echo "export IS_SANDBOX=1" >> .bashrc
87echo "export IS_SANDBOX=1" >> .bashrc
44
88
89+# Ensure cloned instances generate unique machine-id on first boot.
90+truncate -s 0 /etc/machine-id
91+rm -f /var/lib/dbus/machine-id
92+ln -sf /etc/machine-id /var/lib/dbus/machine-id
93+
45# Shutdown the VM when you logout
94# Shutdown the VM when you logout
46cat > .bash_logout <<EOF
95cat > .bash_logout <<EOF
47systemctl poweroff
96systemctl poweroff
@@ -49,4 +98,5 @@ sleep 100 # sleep here so that we don't see the login screen flash up before the
49EOF
98EOF
50
99
51# Done provisioning, power off the VM
100# Done provisioning, power off the VM
101+printf "%s%s\n" VIBEBOX_PROVISION_ OK
52systemctl poweroff
102systemctl poweroff
src/resize_disk.shnew file+55
@@ -0,0 +1,55 @@
1+#!/bin/bash
2+set -euo pipefail
3+
4+ROOT_DEV="$(findmnt / -n -o SOURCE || true)"
5+ROOT_FSTYPE="$(findmnt / -n -o FSTYPE || true)"
6+
7+if [ -z "$ROOT_DEV" ]; then
8+ exit 0
9+fi
10+
11+DISK_DEV=""
12+PART_NUM=""
13+
14+if command -v lsblk >/dev/null 2>&1; then
15+ DISK_DEV="$(lsblk -no pkname "$ROOT_DEV" 2>/dev/null | head -n1 || true)"
16+ PART_NUM="$(lsblk -no PARTNUM "$ROOT_DEV" 2>/dev/null | head -n1 || true)"
17+fi
18+
19+if [ -z "$DISK_DEV" ] || [ -z "$PART_NUM" ]; then
20+ ROOT_BASENAME="$(basename "$ROOT_DEV")"
21+ if echo "$ROOT_BASENAME" | grep -Eq '^nvme.+p[0-9]+$'; then
22+ DISK_DEV="/dev/${ROOT_BASENAME%p[0-9]*}"
23+ PART_NUM="${ROOT_BASENAME##*p}"
24+ elif echo "$ROOT_BASENAME" | grep -Eq '^[a-z]+[0-9]+$'; then
25+ DISK_DEV="/dev/${ROOT_BASENAME%%[0-9]*}"
26+ PART_NUM="${ROOT_BASENAME##*[a-z]}"
27+ fi
28+fi
29+
30+if [ -n "$DISK_DEV" ] && [ -n "$PART_NUM" ]; then
31+ if command -v growpart >/dev/null 2>&1; then
32+ growpart "$DISK_DEV" "$PART_NUM" || true
33+ elif command -v sfdisk >/dev/null 2>&1; then
34+ sfdisk -N "$PART_NUM" --force "$DISK_DEV" <<'EOF' || true
35+,,
36+EOF
37+ fi
38+fi
39+
40+if command -v partprobe >/dev/null 2>&1; then
41+ partprobe "$DISK_DEV" || true
42+fi
43+
44+case "$ROOT_FSTYPE" in
45+ ext4|ext3|ext2)
46+ if command -v resize2fs >/dev/null 2>&1; then
47+ resize2fs "$ROOT_DEV" || true
48+ fi
49+ ;;
50+ xfs)
51+ if command -v xfs_growfs >/dev/null 2>&1; then
52+ xfs_growfs / || true
53+ fi
54+ ;;
55+esac
src/session_manager.rs+64−101
@@ -1,19 +1,18 @@
1+use anyhow::Result;
1use std::{
2use std::{
2 env, fs,
3 env, fs,
3 io::{self, Write},
4 io::{self, Write},
4− os::unix::fs::FileTypeExt,
5 path::{Path, PathBuf},
5 path::{Path, PathBuf},
6};
6};
7
7
8+use crate::config::config_path;
9+use crate::instance::{VmLiveness, read_instance_config, vm_liveness};
8use serde::{Deserialize, Serialize};
10use serde::{Deserialize, Serialize};
9
11
10−use crate::config::CONFIG_FILENAME;
11−
12pub const INSTANCE_DIR_NAME: &str = ".vibebox";
12pub const INSTANCE_DIR_NAME: &str = ".vibebox";
13pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
13pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
14pub const GLOBAL_DIR_NAME: &str = ".vibebox";
14pub const GLOBAL_DIR_NAME: &str = ".vibebox";
15−pub const INSTANCE_FILENAME: &str = "instance.toml";
16−pub const SESSION_TOML_SUFFIX: &str = ".toml";
15+const SESSION_TOML_SUFFIX: &str = ".toml";
17pub const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
16pub const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
18pub const VM_MANAGER_PID_NAME: &str = "vm.pid";
17pub const VM_MANAGER_PID_NAME: &str = "vm.pid";
19const SESSIONS_DIR_NAME: &str = "sessions";
18const SESSIONS_DIR_NAME: &str = "sessions";
@@ -32,14 +31,6 @@ struct SessionEntry {
32 pub id: String,
31 pub id: String,
33}
32}
34
33
35−#[derive(Debug, Default, Deserialize)]
36−struct InstanceMetadata {
37− #[serde(default)]
38− id: Option<String>,
39− #[serde(default)]
40− last_active: Option<String>,
41−}
42−
43#[derive(Debug)]
34#[derive(Debug)]
44pub struct SessionManager {
35pub struct SessionManager {
45 sessions_dir: PathBuf,
36 sessions_dir: PathBuf,
@@ -61,7 +52,7 @@ pub enum SessionError {
61 #[error("Session directory does not exist: {0}")]
52 #[error("Session directory does not exist: {0}")]
62 MissingDirectory(PathBuf),
53 MissingDirectory(PathBuf),
63 #[error(transparent)]
54 #[error(transparent)]
64− Io(#[from] std::io::Error),
55+ Io(#[from] io::Error),
65 #[error(transparent)]
56 #[error(transparent)]
66 TomlDe(#[from] toml::de::Error),
57 TomlDe(#[from] toml::de::Error),
67 #[error(transparent)]
58 #[error(transparent)]
@@ -94,8 +85,8 @@ impl SessionManager {
94 let mut added = false;
85 let mut added = false;
95
86
96 if has_config {
87 if has_config {
97− let meta = read_instance_metadata(&directory)?;
88+ let id = read_instance_config(&directory).map_or(None, |config| Some(config.id));
98− if let Some(id) = meta.id {
89+ if let Some(id) = id {
99 let record = SessionEntry {
90 let record = SessionEntry {
100 directory: directory.clone(),
91 directory: directory.clone(),
101 id: id.clone(),
92 id: id.clone(),
@@ -110,14 +101,13 @@ impl SessionManager {
110 } else {
101 } else {
111 tracing::warn!(
102 tracing::warn!(
112 directory = %directory.display(),
103 directory = %directory.display(),
113− file = INSTANCE_FILENAME,
114 "missing session id in instance file"
104 "missing session id in instance file"
115 );
105 );
116 }
106 }
117 }
107 }
118
108
119 if removed > 0 || added {
109 if removed > 0 || added {
120− tracing::info!(
110+ tracing::debug!(
121 path = %self.sessions_dir.display(),
111 path = %self.sessions_dir.display(),
122 removed,
112 removed,
123 added,
113 added,
@@ -148,12 +138,13 @@ impl SessionManager {
148 }
138 }
149 let mut records = Vec::with_capacity(sessions.len());
139 let mut records = Vec::with_capacity(sessions.len());
150 for session in sessions {
140 for session in sessions {
151− let meta = read_instance_metadata(&session.directory)?;
141+ let last_active =
142+ read_instance_config(&session.directory).map_or(None, |option| option.last_active);
152 let active = is_session_active(&session.directory);
143 let active = is_session_active(&session.directory);
153 records.push(SessionRecord {
144 records.push(SessionRecord {
154 directory: session.directory,
145 directory: session.directory,
155 id: session.id,
146 id: session.id,
156− last_active: meta.last_active,
147+ last_active,
157 active,
148 active,
158 });
149 });
159 }
150 }
@@ -196,7 +187,7 @@ impl SessionManager {
196 let path = self.session_path_for(&record.id);
187 let path = self.session_path_for(&record.id);
197 let content = toml::to_string_pretty(record)?;
188 let content = toml::to_string_pretty(record)?;
198 atomic_write(&path, content.as_bytes())?;
189 atomic_write(&path, content.as_bytes())?;
199− tracing::info!(
190+ tracing::debug!(
200 path = %path.display(),
191 path = %path.display(),
201 "wrote session record"
192 "wrote session record"
202 );
193 );
@@ -223,12 +214,12 @@ impl SessionManager {
223 removed += 1;
214 removed += 1;
224 continue;
215 continue;
225 }
216 }
226− if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
217+ if let Some(stem) = path.file_stem().and_then(|s| s.to_str())
227− if stem != record.id {
218+ && stem != record.id
219+ {
228− let _ = fs::remove_file(&path);
220+ let _ = fs::remove_file(&path);
229− removed += 1;
221+ removed += 1;
230− continue;
222+ continue;
231− }
232 }
223 }
233 sessions.push(record);
224 sessions.push(record);
234 }
225 }
@@ -271,43 +262,22 @@ fn is_vibebox_dir(directory: &Path) -> bool {
271 if !directory.is_absolute() {
262 if !directory.is_absolute() {
272 return false;
263 return false;
273 }
264 }
274− directory.join(CONFIG_FILENAME).is_file()
265+ config_path(directory).is_file()
275}
266}
276
267
277fn is_session_active(directory: &Path) -> bool {
268fn is_session_active(directory: &Path) -> bool {
278 let instance_dir = directory.join(INSTANCE_DIR_NAME);
269 let instance_dir = directory.join(INSTANCE_DIR_NAME);
279 let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
270 let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
280− let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
271+ match vm_liveness(directory) {
281−
272+ Ok(liveness) => match liveness {
282− let pid = read_pid(&pid_path);
273+ VmLiveness::RunningWithSocket { .. } => true,
283− let is_alive = pid.map(pid_is_alive).unwrap_or(false);
274+ VmLiveness::RunningWithoutSocket { .. } => true,
284− if !is_alive {
275+ VmLiveness::NotRunningOrMissing => {
285− let _ = fs::remove_file(&pid_path);
276+ let _ = fs::remove_file(&pid_path);
286− return false;
277+ false
287− }
278+ }
288−
289− if let Ok(metadata) = fs::metadata(&socket_path) {
290− return metadata.file_type().is_socket();
291− }
292−
293− true
294−}
295−
296−fn read_pid(path: &Path) -> Option<u32> {
297− let content = fs::read_to_string(path).ok()?;
298− content.trim().parse::<u32>().ok()
299−}
300−
301−fn pid_is_alive(pid: u32) -> bool {
302− let pid = pid as libc::pid_t;
303− let result = unsafe { libc::kill(pid, 0) };
304− if result == 0 {
305− return true;
306− }
279+ },
307− match std::io::Error::last_os_error().raw_os_error() {
308− Some(code) if code == libc::EPERM => true,
309− Some(code) if code == libc::ESRCH => false,
310− _ => false,
280+ Err(_) => false,
311 }
281 }
312}
282}
313
283
@@ -315,31 +285,11 @@ fn read_session_file(path: &Path) -> Result<SessionEntry, SessionError> {
315 let raw = fs::read_to_string(path)?;
285 let raw = fs::read_to_string(path)?;
316 let record: SessionEntry = toml::from_str(&raw)?;
286 let record: SessionEntry = toml::from_str(&raw)?;
317 if record.id.trim().is_empty() {
287 if record.id.trim().is_empty() {
318− return Err(std::io::Error::new(io::ErrorKind::InvalidData, "session id missing").into());
288+ return Err(io::Error::new(io::ErrorKind::InvalidData, "session id missing").into());
319 }
289 }
320 Ok(record)
290 Ok(record)
321}
291}
322
292
323−fn read_instance_metadata(directory: &Path) -> Result<InstanceMetadata, SessionError> {
324− let instance_path = directory.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
325− if !instance_path.exists() {
326− return Ok(InstanceMetadata::default());
327− }
328− let raw = fs::read_to_string(&instance_path)?;
329− let mut meta: InstanceMetadata = toml::from_str(&raw)?;
330− if let Some(id) = &meta.id {
331− if id.trim().is_empty() {
332− meta.id = None;
333− }
334− }
335− if let Some(last_active) = &meta.last_active {
336− if last_active.trim().is_empty() {
337− meta.last_active = None;
338− }
339− }
340− Ok(meta)
341−}
342−
343fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
293fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
344 let Some(parent) = path.parent() else {
294 let Some(parent) = path.parent() else {
345 return Err(io::Error::new(
295 return Err(io::Error::new(
@@ -362,6 +312,7 @@ fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
362#[cfg(test)]
312#[cfg(test)]
363mod tests {
313mod tests {
364 use super::*;
314 use super::*;
315+ use crate::instance::{InstanceConfig, write_instance_config};
365 use std::fs;
316 use std::fs;
366 use tempfile::TempDir;
317 use tempfile::TempDir;
367
318
@@ -375,11 +326,14 @@ mod tests {
375 dir
326 dir
376 }
327 }
377
328
378− fn write_instance(project_dir: &Path, id: &str, last_active: &str) {
329+ fn write_instance(project_dir: &Path, id: &str, last_active: &str) -> Result<()> {
379− let instance_dir = project_dir.join(INSTANCE_DIR_NAME);
380− fs::create_dir_all(&instance_dir).unwrap();
330+ fs::create_dir_all(project_dir)?;
381− let content = format!("id = \"{id}\"\nlast_active = \"{last_active}\"\n");
331+
382− fs::write(instance_dir.join(INSTANCE_FILENAME), content).unwrap();
332+ let mut config = InstanceConfig::default();
333+ config.id = id.to_string();
334+ config.last_active = Some(last_active.to_string());
335+
336+ write_instance_config(project_dir, &config)
383 }
337 }
384
338
385 #[test]
339 #[test]
@@ -387,11 +341,14 @@ mod tests {
387 let temp = TempDir::new().unwrap();
341 let temp = TempDir::new().unwrap();
388 let mgr = manager(&temp);
342 let mgr = manager(&temp);
389 let project_dir = create_project_dir(&temp);
343 let project_dir = create_project_dir(&temp);
390− fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
344+ fs::write(config_path(project_dir.as_path()), "").unwrap();
345+ assert!(
391− write_instance(
346+ write_instance(
392− &project_dir,
347+ &project_dir,
393− "019bf290-cccc-7c23-ba1d-dce7e6d40693",
348+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
394− "2026-02-07T05:00:00Z",
349+ "2026-02-07T05:00:00Z",
350+ )
351+ .is_ok()
395 );
352 );
396
353
397 let dirs = mgr.update_global_sessions(&project_dir).unwrap();
354 let dirs = mgr.update_global_sessions(&project_dir).unwrap();
@@ -412,15 +369,18 @@ mod tests {
412 let temp = TempDir::new().unwrap();
369 let temp = TempDir::new().unwrap();
413 let mgr = manager(&temp);
370 let mgr = manager(&temp);
414 let project_dir = create_project_dir(&temp);
371 let project_dir = create_project_dir(&temp);
415− fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
372+ fs::write(config_path(project_dir.as_path()), "").unwrap();
373+ assert!(
416− write_instance(
374+ write_instance(
417− &project_dir,
375+ &project_dir,
418− "019bf290-cccc-7c23-ba1d-dce7e6d40693",
376+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
419− "2026-02-07T05:00:00Z",
377+ "2026-02-07T05:00:00Z",
378+ )
379+ .is_ok()
420 );
380 );
421 let _ = mgr.update_global_sessions(&project_dir).unwrap();
381 let _ = mgr.update_global_sessions(&project_dir).unwrap();
422
382
423− fs::remove_file(project_dir.join(CONFIG_FILENAME)).unwrap();
383+ fs::remove_file(config_path(project_dir.as_path())).unwrap();
424 let sessions = mgr.list_sessions().unwrap();
384 let sessions = mgr.list_sessions().unwrap();
425 assert!(sessions.is_empty());
385 assert!(sessions.is_empty());
426
386
@@ -451,11 +411,14 @@ mod tests {
451 let temp = TempDir::new().unwrap();
411 let temp = TempDir::new().unwrap();
452 let mgr = manager(&temp);
412 let mgr = manager(&temp);
453 let project_dir = create_project_dir(&temp);
413 let project_dir = create_project_dir(&temp);
454− fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
414+ fs::write(config_path(project_dir.as_path()), "").unwrap();
415+ assert!(
455− write_instance(
416+ write_instance(
456− &project_dir,
417+ &project_dir,
457− "019bf290-cccc-7c23-ba1d-dce7e6d40693",
418+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
458− "2026-02-07T05:00:00Z",
419+ "2026-02-07T05:00:00Z",
420+ )
421+ .is_ok()
459 );
422 );
460 let _ = mgr.update_global_sessions(&project_dir).unwrap();
423 let _ = mgr.update_global_sessions(&project_dir).unwrap();
461
424
src/ssh.sh+61−16
@@ -2,7 +2,9 @@
2set -eu
2set -eu
3
3
4SSH_USER="__SSH_USER__"
4SSH_USER="__SSH_USER__"
5+SUDO_PASSWORD="__SUDO_PASSWORD__"
5PROJECT_NAME="__PROJECT_NAME__"
6PROJECT_NAME="__PROJECT_NAME__"
7+PROJECT_GUEST_DIR="__PROJECT_GUEST_DIR__"
6KEY_PATH="__KEY_PATH__"
8KEY_PATH="__KEY_PATH__"
7
9
8diag() { echo "[vibebox][diag] $*" >&2; }
10diag() { echo "[vibebox][diag] $*" >&2; }
@@ -49,7 +51,7 @@ dump_diag() {
49}
51}
50
52
51# 1) tmpfs mount
53# 1) tmpfs mount
52−TARGET="/root/${PROJECT_NAME}/.vibebox"
54+TARGET="${PROJECT_GUEST_DIR}/.vibebox"
53if [ -d "$TARGET" ] && ! mountpoint -q "$TARGET"; then
55if [ -d "$TARGET" ] && ! mountpoint -q "$TARGET"; then
54 mount -t tmpfs tmpfs "$TARGET"
56 mount -t tmpfs tmpfs "$TARGET"
55fi
57fi
@@ -60,6 +62,10 @@ if ! id -u "$SSH_USER" >/dev/null 2>&1; then
60 usermod -aG sudo "$SSH_USER" || true
62 usermod -aG sudo "$SSH_USER" || true
61fi
63fi
62
64
65+if [ -n "$SUDO_PASSWORD" ]; then
66+ echo "${SSH_USER}:${SUDO_PASSWORD}" | chpasswd
67+fi
68+
63install -d -m 700 -o "$SSH_USER" -g "$SSH_USER" "/home/${SSH_USER}/.ssh"
69install -d -m 700 -o "$SSH_USER" -g "$SSH_USER" "/home/${SSH_USER}/.ssh"
64install -m 600 -o "$SSH_USER" -g "$SSH_USER" "$KEY_PATH" "/home/${SSH_USER}/.ssh/authorized_keys"
70install -m 600 -o "$SSH_USER" -g "$SSH_USER" "$KEY_PATH" "/home/${SSH_USER}/.ssh/authorized_keys"
65
71
@@ -78,6 +84,21 @@ __VIBEBOX_SHELL_SCRIPT__
78VIBEBOX_SHELL_EOF
84VIBEBOX_SHELL_EOF
79chmod 644 /etc/profile.d/vibebox.sh
85chmod 644 /etc/profile.d/vibebox.sh
80
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+
81if ! grep -q "vibebox-aliases" "${USER_HOME}/.bashrc" 2>/dev/null; then
102if ! grep -q "vibebox-aliases" "${USER_HOME}/.bashrc" 2>/dev/null; then
82 {
103 {
83 echo ""
104 echo ""
@@ -88,16 +109,22 @@ fi
88
109
89# Install Mise
110# Install Mise
90MISE_BIN="${USER_HOME}/.local/bin/mise"
111MISE_BIN="${USER_HOME}/.local/bin/mise"
112+mise_warn() { echo "[mise] $*" >&2; }
113+mise_ok() { command -v mise >/dev/null 2>&1 || [ -x "$MISE_BIN" ]; }
114+mise_install() {
91−if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
115+ if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
92− curl https://mise.run | HOME="$USER_HOME" sh
116+ if ! curl https://mise.run | HOME="$USER_HOME" sh; then
117+ mise_warn "mise install script failed"
118+ return 1
119+ fi
93−fi
120+ fi
94−echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
121+ echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
95
122
96−export PATH="${USER_HOME}/.local/bin:/usr/local/bin:$PATH"
123+ export PATH="${USER_HOME}/.local/bin:/usr/local/bin:$PATH"
97
124
98−mkdir -p "${USER_HOME}/.config/mise"
125+ mkdir -p "${USER_HOME}/.config/mise"
99
126
100−cat > "${USER_HOME}/.config/mise/config.toml" <<MISE
127+ cat > "${USER_HOME}/.config/mise/config.toml" <<MISE
101 [settings]
128 [settings]
102 # Always use the venv created by uv, if available in directory
129 # Always use the venv created by uv, if available in directory
103 python.uv_venv_auto = true
130 python.uv_venv_auto = true
@@ -111,11 +138,23 @@ cat > "${USER_HOME}/.config/mise/config.toml" <<MISE
111 "npm:@anthropic-ai/claude-code" = "latest"
138 "npm:@anthropic-ai/claude-code" = "latest"
112MISE
139MISE
113
140
114−touch "${USER_HOME}/.config/mise/mise.lock"
141+ touch "${USER_HOME}/.config/mise/mise.lock"
115−if [ -x "$MISE_BIN" ]; then
142+ if [ -x "$MISE_BIN" ]; then
116− HOME="$USER_HOME" "$MISE_BIN" install
143+ if ! HOME="$USER_HOME" "$MISE_BIN" install; then
144+ mise_warn "mise install failed"
145+ return 1
146+ fi
117−else
147+ else
118− HOME="$USER_HOME" mise install
148+ if ! HOME="$USER_HOME" mise install; then
149+ mise_warn "mise install failed"
150+ return 1
151+ fi
152+ fi
153+}
154+
155+if ! mise_install; then
156+ diag "mise installation failed"
157+ vibebox_fail "mise installation failed" 1
119fi
158fi
120
159
121# 3) start ssh (don't swallow failures)
160# 3) start ssh (don't swallow failures)
@@ -124,7 +163,7 @@ if ! systemctl is-active --quiet ssh; then
124 if ! systemctl start ssh; then
163 if ! systemctl start ssh; then
125 diag "systemctl start ssh failed"
164 diag "systemctl start ssh failed"
126 dump_diag
165 dump_diag
127− exit 1
166+ vibebox_fail "failed to start ssh service" 1
128 fi
167 fi
129fi
168fi
130
169
@@ -162,7 +201,7 @@ done
162if [ -z "$dev" ] || [ -z "$ip" ]; then
201if [ -z "$dev" ] || [ -z "$ip" ]; then
163 diag "no stable IPv4 on default route interface"
202 diag "no stable IPv4 on default route interface"
164 dump_diag
203 dump_diag
165− exit 1
204+ vibebox_fail "no stable ipv4 route on default interface" 1
166fi
205fi
167
206
168# 5) strong verify: ssh must listen externally (0.0.0.0:22 or $ip:22 or [::]:22)
207# 5) strong verify: ssh must listen externally (0.0.0.0:22 or $ip:22 or [::]:22)
@@ -181,8 +220,14 @@ done
181if ! listens_ok; then
220if ! listens_ok; then
182 diag "sshd not listening on 0.0.0.0:22 / ${ip}:22"
221 diag "sshd not listening on 0.0.0.0:22 / ${ip}:22"
183 dump_diag
222 dump_diag
184− exit 1
223+ vibebox_fail "sshd is not listening on the expected address" 1
185fi
224fi
186
225
226+ip a
227+ip link
228+curl -s https://api.ipify.org ; echo
229+
230+cat /etc/machine-id
231+
187echo VIBEBOX_SSH_READY
232echo VIBEBOX_SSH_READY
188echo "VIBEBOX_IPV4=$ip"
233echo "VIBEBOX_IPV4=$ip"
src/tui.rs+33−31
@@ -1,10 +1,4 @@
1−use std::{
1+use bytesize::ByteSize;
2− io::{self, Write},
3− os::unix::io::OwnedFd,
4− path::PathBuf,
5− sync::{Arc, Mutex},
6−};
7−
8use color_eyre::Result;
2use color_eyre::Result;
9use crossterm::{
3use crossterm::{
10 cursor::{MoveTo, Show},
4 cursor::{MoveTo, Show},
@@ -22,9 +16,16 @@ use ratatui::{
22 text::{Line, Span, Text},
16 text::{Line, Span, Text},
23 widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table, Widget},
17 widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table, Widget},
24};
18};
19+use std::{
20+ io::{self, Write},
21+ os::unix::io::OwnedFd,
22+ path::PathBuf,
23+ sync::{Arc, Mutex},
24+};
25
25
26use crate::vm;
26use crate::vm;
27
27
28+// https://patorjk.com/software/taag/#p=display&f=ANSI+Shadow&t=VIBEBOX&x=none&v=4&h=4&w=80&we=false
28const ASCII_BANNER: [&str; 7] = [
29const ASCII_BANNER: [&str; 7] = [
29 "██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
30 "██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
30 "██║ ██║██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗╚██╗██╔╝",
31 "██║ ██║██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗╚██╗██╔╝",
@@ -38,8 +39,9 @@ const INFO_LINE_COUNT: u16 = 5;
38
39
39#[derive(Debug, Clone)]
40#[derive(Debug, Clone)]
40pub struct VmInfo {
41pub struct VmInfo {
41− pub max_memory_mb: u64,
42+ pub max_memory: ByteSize,
42 pub cpu_cores: usize,
43 pub cpu_cores: usize,
44+ pub max_disk: ByteSize,
43 pub system_name: String,
45 pub system_name: String,
44 pub auto_shutdown_ms: u64,
46 pub auto_shutdown_ms: u64,
45}
47}
@@ -472,27 +474,27 @@ fn write_buffer_with_style(buffer: &Buffer, out: &mut impl Write) -> io::Result<
472 Ok(())
474 Ok(())
473}
475}
474
476
475−fn map_color(color: ratatui::style::Color) -> CrosstermColor {
477+fn map_color(color: Color) -> CrosstermColor {
476 match color {
478 match color {
477− ratatui::style::Color::Reset => CrosstermColor::Reset,
479+ Color::Reset => CrosstermColor::Reset,
478− ratatui::style::Color::Black => CrosstermColor::Black,
480+ Color::Black => CrosstermColor::Black,
479− ratatui::style::Color::Red => CrosstermColor::DarkRed,
481+ Color::Red => CrosstermColor::DarkRed,
480− ratatui::style::Color::Green => CrosstermColor::DarkGreen,
482+ Color::Green => CrosstermColor::DarkGreen,
481− ratatui::style::Color::Yellow => CrosstermColor::DarkYellow,
483+ Color::Yellow => CrosstermColor::DarkYellow,
482− ratatui::style::Color::Blue => CrosstermColor::DarkBlue,
484+ Color::Blue => CrosstermColor::DarkBlue,
483− ratatui::style::Color::Magenta => CrosstermColor::DarkMagenta,
485+ Color::Magenta => CrosstermColor::DarkMagenta,
484− ratatui::style::Color::Cyan => CrosstermColor::DarkCyan,
486+ Color::Cyan => CrosstermColor::DarkCyan,
485− ratatui::style::Color::Gray => CrosstermColor::Grey,
487+ Color::Gray => CrosstermColor::Grey,
486− ratatui::style::Color::DarkGray => CrosstermColor::DarkGrey,
488+ Color::DarkGray => CrosstermColor::DarkGrey,
487− ratatui::style::Color::LightRed => CrosstermColor::Red,
489+ Color::LightRed => CrosstermColor::Red,
488− ratatui::style::Color::LightGreen => CrosstermColor::Green,
490+ Color::LightGreen => CrosstermColor::Green,
489− ratatui::style::Color::LightYellow => CrosstermColor::Yellow,
491+ Color::LightYellow => CrosstermColor::Yellow,
490− ratatui::style::Color::LightBlue => CrosstermColor::Blue,
492+ Color::LightBlue => CrosstermColor::Blue,
491− ratatui::style::Color::LightMagenta => CrosstermColor::Magenta,
493+ Color::LightMagenta => CrosstermColor::Magenta,
492− ratatui::style::Color::LightCyan => CrosstermColor::Cyan,
494+ Color::LightCyan => CrosstermColor::Cyan,
493− ratatui::style::Color::White => CrosstermColor::White,
495+ Color::White => CrosstermColor::White,
494− ratatui::style::Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
496+ Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
495− ratatui::style::Color::Indexed(i) => CrosstermColor::AnsiValue(i),
497+ Color::Indexed(i) => CrosstermColor::AnsiValue(i),
496 }
498 }
497}
499}
498
500
@@ -569,11 +571,11 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
569 Span::styled(&app.vm_info.system_name, Style::default().fg(Color::Green)),
571 Span::styled(&app.vm_info.system_name, Style::default().fg(Color::Green)),
570 ]),
572 ]),
571 Line::from(vec![
573 Line::from(vec![
572− Span::raw("CPU / Memory: "),
574+ Span::raw("CPU / Memory / Disk: "),
573 Span::styled(
575 Span::styled(
574 format!(
576 format!(
575− "{} cores / {} MB",
577+ "{} cores / {} / {}",
576− app.vm_info.cpu_cores, app.vm_info.max_memory_mb
578+ app.vm_info.cpu_cores, app.vm_info.max_memory, app.vm_info.max_disk
577 ),
579 ),
578 Style::default().fg(Color::Green),
580 Style::default().fg(Color::Green),
579 ),
581 ),
src/utils.rsnew file+18
@@ -0,0 +1,18 @@
1+use std::{
2+ env,
3+ path::{Path, PathBuf},
4+};
5+
6+pub fn relative_to_home(directory: &Path) -> String {
7+ let Ok(home) = env::var("HOME") else {
8+ return directory.display().to_string();
9+ };
10+ let home_path = PathBuf::from(home);
11+ if let Ok(stripped) = directory.strip_prefix(&home_path) {
12+ if stripped.components().next().is_none() {
13+ return "~".to_string();
14+ }
15+ return format!("~/{}", stripped.display());
16+ }
17+ directory.display().to_string()
18+}
src/vm.rs+318−189
@@ -1,5 +1,5 @@
1−use crate::instance::STATUS_FILE_NAME;
2use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
1use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
2+use anyhow::{Context, Error, Result, bail};
3use std::{
3use std::{
4 env, fs,
4 env, fs,
5 io::{self, Write},
5 io::{self, Write},
@@ -23,6 +23,7 @@ use std::{
23};
23};
24
24
25use block2::RcBlock;
25use block2::RcBlock;
26+use bytesize::ByteSize;
26use dispatch2::DispatchQueue;
27use dispatch2::DispatchQueue;
27use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
28use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
28use objc2_foundation::*;
29use objc2_foundation::*;
@@ -32,75 +33,46 @@ const DEBIAN_COMPRESSED_DISK_URL: &str = "https://cloud.debian.org/images/cloud/
32const DEBIAN_COMPRESSED_SHA: &str = "6ab9be9e6834adc975268367f2f0235251671184345c34ee13031749fdfbf66fe4c3aafd949a2d98550426090e9ac645e79009c51eb0eefc984c15786570bb38";
33const DEBIAN_COMPRESSED_SHA: &str = "6ab9be9e6834adc975268367f2f0235251671184345c34ee13031749fdfbf66fe4c3aafd949a2d98550426090e9ac645e79009c51eb0eefc984c15786570bb38";
33const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
34const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
34const SHARED_DIRECTORIES_TAG: &str = "shared";
35const SHARED_DIRECTORIES_TAG: &str = "shared";
36+pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts";
35
37
36−const BYTES_PER_MB: u64 = 1024 * 1024;
37−const DEFAULT_CPU_COUNT: usize = 2;
38−const DEFAULT_RAM_MB: u64 = 2048;
39−const DEFAULT_RAM_BYTES: u64 = DEFAULT_RAM_MB * BYTES_PER_MB;
40const START_TIMEOUT: Duration = Duration::from_secs(60);
38const START_TIMEOUT: Duration = Duration::from_secs(60);
41const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
39const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
40+const PROVISION_EXPECT_TIMEOUT: Duration = Duration::from_secs(900);
42
41
43−struct StatusFile {
42+const ERROR_REPORT_SCRIPT: &str = include_str!("error_report.sh");
44− path: PathBuf,
45− cleared: AtomicBool,
46−}
47−
48−impl StatusFile {
49− fn new(path: PathBuf) -> Self {
50− Self {
51− path,
52− cleared: AtomicBool::new(false),
53− }
54− }
55−
56− fn update(&self, message: &str) {
57− let _ = fs::write(&self.path, message);
58− }
59−
60− fn clear(&self) {
61− if !self.cleared.swap(true, Ordering::SeqCst) {
62− let _ = fs::remove_file(&self.path);
63− }
64− }
65−}
66−
67−impl Drop for StatusFile {
68− fn drop(&mut self) {
69− if !self.cleared.load(Ordering::SeqCst) {
70− let _ = fs::remove_file(&self.path);
71− self.cleared.store(true, Ordering::SeqCst);
72− }
73− }
74−}
75const PROVISION_SCRIPT: &str = include_str!("provision.sh");
43const PROVISION_SCRIPT: &str = include_str!("provision.sh");
76−const PROVISION_SCRIPT_NAME: &str = "provision.sh";
44+const RESIZE_DISK_SCRIPT: &str = include_str!("resize_disk.sh");
77const DEFAULT_RAW_NAME: &str = "default.raw";
45const DEFAULT_RAW_NAME: &str = "default.raw";
78const INSTANCE_RAW_NAME: &str = "instance.raw";
46const INSTANCE_RAW_NAME: &str = "instance.raw";
79const BASE_DISK_RAW_NAME: &str = "disk.raw";
47const BASE_DISK_RAW_NAME: &str = "disk.raw";
80
48
81#[derive(Clone)]
49#[derive(Clone)]
82−pub(crate) enum LoginAction {
50+pub enum LoginAction {
51+ Expect {
52+ text: String,
83− Expect { text: String, timeout: Duration },
53+ timeout: Duration,
54+ },
55+ ExpectEither {
56+ success: String,
57+ failure: String,
58+ timeout: Duration,
59+ },
84 Send(String),
60 Send(String),
85− Script { path: PathBuf, index: usize },
86}
61}
62+use crate::config::BoxConfig;
87use LoginAction::*;
63use LoginAction::*;
88
64
89#[derive(Clone)]
65#[derive(Clone)]
90−pub(crate) struct DirectoryShare {
66+pub struct DirectoryShare {
91 host: PathBuf,
67 host: PathBuf,
92 guest: PathBuf,
68 guest: PathBuf,
93 read_only: bool,
69 read_only: bool,
94}
70}
95
71
96impl DirectoryShare {
72impl DirectoryShare {
97− pub(crate) fn new(
98− host: PathBuf,
99− mut guest: PathBuf,
73+ pub fn new(host: PathBuf, mut guest: PathBuf, read_only: bool) -> Result<Self> {
100− read_only: bool,
101− ) -> Result<Self, Box<dyn std::error::Error>> {
102 if !host.exists() {
74 if !host.exists() {
103− return Err(format!("Host path does not exist: {}", host.display()).into());
75+ bail!(format!("host path does not exist: {}", host.display()));
104 }
76 }
105 if !guest.is_absolute() {
77 if !guest.is_absolute() {
106 guest = PathBuf::from("/root").join(guest);
78 guest = PathBuf::from("/root").join(guest);
@@ -112,10 +84,10 @@ impl DirectoryShare {
112 })
84 })
113 }
85 }
114
86
115− pub(crate) fn from_mount_spec(spec: &str) -> Result<Self, Box<dyn std::error::Error>> {
87+ pub fn from_mount_spec(spec: &str) -> Result<Self> {
116 let parts: Vec<&str> = spec.split(':').collect();
88 let parts: Vec<&str> = spec.split(':').collect();
117 if parts.len() < 2 || parts.len() > 3 {
89 if parts.len() < 2 || parts.len() > 3 {
118− return Err(format!("Invalid mount spec: {spec}").into());
90+ bail!(format!("invalid mount spec: {spec}"));
119 }
91 }
120 let host = expand_tilde_path(parts[0]);
92 let host = expand_tilde_path(parts[0]);
121 let guest = PathBuf::from(parts[1]);
93 let guest = PathBuf::from(parts[1]);
@@ -124,11 +96,10 @@ impl DirectoryShare {
124 "read-only" => true,
96 "read-only" => true,
125 "read-write" => false,
97 "read-write" => false,
126 _ => {
98 _ => {
127− return Err(format!(
99+ bail!(format!(
128 "Invalid mount mode '{}'; expected read-only or read-write",
100 "Invalid mount mode '{}'; expected read-only or read-write",
129 parts[2]
101 parts[2]
130− )
102+ ));
131− .into());
132 }
103 }
133 }
104 }
134 } else {
105 } else {
@@ -156,10 +127,10 @@ fn expand_tilde_path(value: &str) -> PathBuf {
156 if let Ok(home) = env::var("HOME") {
127 if let Ok(home) = env::var("HOME") {
157 return PathBuf::from(home).join(stripped);
128 return PathBuf::from(home).join(stripped);
158 }
129 }
159− } else if value == "~" {
130+ } else if value == "~"
160− if let Ok(home) = env::var("HOME") {
131+ && let Ok(home) = env::var("HOME")
132+ {
161− return PathBuf::from(home);
133+ return PathBuf::from(home);
162− }
163 }
134 }
164 PathBuf::from(value)
135 PathBuf::from(value)
165}
136}
@@ -167,23 +138,33 @@ fn expand_tilde_path(value: &str) -> PathBuf {
167pub struct VmArg {
138pub struct VmArg {
168 pub cpu_count: usize,
139 pub cpu_count: usize,
169 pub ram_bytes: u64,
140 pub ram_bytes: u64,
141+ pub disk_bytes: u64,
170 pub no_default_mounts: bool,
142 pub no_default_mounts: bool,
171 pub mounts: Vec<String>,
143 pub mounts: Vec<String>,
172}
144}
173
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+
174−pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<(), Box<dyn std::error::Error>>
154+pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<()>
175where
155where
176 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
156 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
177{
157{
178− run_with_args_and_extras(args, io_handler, Vec::new(), Vec::new())
158+ run_with_args_and_extras(args, io_handler, Vec::new(), Vec::new(), None)
179}
159}
180
160
181−pub(crate) fn run_with_args_and_extras<F>(
161+pub fn run_with_args_and_extras<F>(
182 args: VmArg,
162 args: VmArg,
183 io_handler: F,
163 io_handler: F,
184 extra_login_actions: Vec<LoginAction>,
164 extra_login_actions: Vec<LoginAction>,
185 extra_directory_shares: Vec<DirectoryShare>,
165 extra_directory_shares: Vec<DirectoryShare>,
186−) -> Result<(), Box<dyn std::error::Error>>
166+ status: Option<&StatusEmitter<'_>>,
167+) -> Result<()>
187where
168where
188 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
169 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
189{
170{
@@ -192,7 +173,7 @@ where
192 let project_root = env::current_dir()?;
173 let project_root = env::current_dir()?;
193 let project_name = project_root
174 let project_name = project_root
194 .file_name()
175 .file_name()
195− .ok_or("Project directory has no name")?
176+ .with_context(|| "Project directory has no name")?
196 .to_string_lossy()
177 .to_string_lossy()
197 .into_owned();
178 .into_owned();
198
179
@@ -204,8 +185,10 @@ where
204 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
185 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
205
186
206 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
187 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
207− let status_file = StatusFile::new(instance_dir.join(STATUS_FILE_NAME));
188+ fs::create_dir_all(&instance_dir)?;
208− status_file.update("preparing VM image...");
189+ emit_status(status, "preparing VM image...");
190+ tracing::info!("preparing VM image...");
191+ let provision_log = instance_dir.join("provision.log");
209
192
210 let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
193 let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
211 let base_compressed = cache_dir.join(basename_compressed);
194 let base_compressed = cache_dir.join(basename_compressed);
@@ -229,16 +212,26 @@ where
229 &base_compressed,
212 &base_compressed,
230 &default_raw,
213 &default_raw,
231 std::slice::from_ref(&mise_directory_share),
214 std::slice::from_ref(&mise_directory_share),
232− Some(&status_file),
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,
233 )?;
223 )?;
234− ensure_instance_disk(&instance_raw, &default_raw, Some(&status_file))?;
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;
235 let disk_path = instance_raw;
227 let disk_path = instance_raw;
236
228
237 let mut login_actions = Vec::new();
229 let mut login_actions = Vec::new();
238 let mut directory_shares = Vec::new();
230 let mut directory_shares = Vec::new();
239
231
240 if !args.no_default_mounts {
232 if !args.no_default_mounts {
233+ let project_guest_dir = PathBuf::from(PROJECT_GUEST_BASE).join(project_name);
241− login_actions.push(Send(format!("cd {project_name}")));
234+ login_actions.push(Send(format!("cd {}", project_guest_dir.display())));
242
235
243 // discourage read/write of .git folder from within the VM. note that this isn't secure, since the VM runs as root and could unmount this.
236 // discourage read/write of .git folder from within the VM. note that this isn't secure, since the VM runs as root and could unmount this.
244 // I couldn't find an alternative way to do this --- the MacOS sandbox doesn't apply to the Apple Virtualization system
237 // I couldn't find an alternative way to do this --- the MacOS sandbox doesn't apply to the Apple Virtualization system
@@ -246,15 +239,6 @@ where
246 login_actions.push(Send(r"mount -t tmpfs tmpfs .git/".into()));
239 login_actions.push(Send(r"mount -t tmpfs tmpfs .git/".into()));
247 }
240 }
248
241
249− directory_shares.push(
250− DirectoryShare::new(
251− project_root,
252− PathBuf::from("/root/").join(project_name),
253− false,
254− )
255− .expect("Project directory must exist"),
256− );
257−
258 directory_shares.push(mise_directory_share);
242 directory_shares.push(mise_directory_share);
259 }
243 }
260
244
@@ -264,6 +248,11 @@ where
264 directory_shares.push(DirectoryShare::from_mount_spec(spec)?);
248 directory_shares.push(DirectoryShare::from_mount_spec(spec)?);
265 }
249 }
266
250
251+ if needs_resize {
252+ let resize_cmd = script_command_from_content("resize_disk.sh", RESIZE_DISK_SCRIPT)?;
253+ login_actions.push(Send(resize_cmd));
254+ }
255+
267 if let Some(motd_action) = motd_login_action(&directory_shares) {
256 if let Some(motd_action) = motd_login_action(&directory_shares) {
268 login_actions.push(motd_action);
257 login_actions.push(motd_action);
269 }
258 }
@@ -276,35 +265,29 @@ where
276 &directory_shares[..],
265 &directory_shares[..],
277 args.cpu_count,
266 args.cpu_count,
278 args.ram_bytes,
267 args.ram_bytes,
279− Some(&status_file),
268+ status,
280 io_handler,
269 io_handler,
281 )
270 )
282}
271}
283
272
284−fn script_command_from_path(
285− path: &Path,
286− index: usize,
287−) -> Result<String, Box<dyn std::error::Error>> {
288− let script = fs::read_to_string(path)
289− .map_err(|err| format!("Failed to read script {}: {err}", path.display()))?;
290− let label = format!("{}_{}", index, path.file_name().unwrap().display());
291− script_command_from_content(&label, &script)
292−}
293−
294−pub(crate) fn script_command_from_content(
273+pub fn script_command_from_content(label: &str, script: &str) -> Result<String> {
295− label: &str,
296− script: &str,
297−) -> Result<String, Box<dyn std::error::Error>> {
298 let marker = "VIBE_SCRIPT_EOF";
274 let marker = "VIBE_SCRIPT_EOF";
299 let guest_dir = "/tmp/vibe-scripts";
275 let guest_dir = "/tmp/vibe-scripts";
300 let guest_path = format!("{guest_dir}/{label}.sh");
276 let guest_path = format!("{guest_dir}/{label}.sh");
277+ let script_body = match script.split_once('\n') {
278+ Some((first, rest)) if first.starts_with("#!") => rest,
279+ _ => script,
280+ };
281+ let wrapped_script = ERROR_REPORT_SCRIPT
282+ .replace("__LABEL__", label)
283+ .replace("__SCRIPT_BODY__", script_body);
301 let command = format!(
284 let command = format!(
302− "mkdir -p {guest_dir}\ncat >{guest_path} <<'{marker}'\n{script}\n{marker}\nchmod +x {guest_path}\n{guest_path}"
285+ "mkdir -p {guest_dir}\ncat >{guest_path} <<'{marker}'\n{wrapped_script}\n{marker}\nchmod +x {guest_path}\n{guest_path}"
303 );
286 );
304 if script.contains(marker) {
287 if script.contains(marker) {
305− return Err(
288+ bail!(format!(
306− format!("Script '{label}' contains marker '{marker}', cannot safely upload").into(),
289+ "Script '{label}' contains marker '{marker}', cannot safely upload"
307− );
290+ ));
308 }
291 }
309 Ok(command)
292 Ok(command)
310}
293}
@@ -368,6 +351,12 @@ enum WaitResult {
368 Found,
351 Found,
369}
352}
370
353
354+#[derive(PartialEq, Eq)]
355+enum WaitAnyResult {
356+ Timeout,
357+ Found(usize),
358+}
359+
371pub enum VmInput {
360pub enum VmInput {
372 Bytes(Vec<u8>),
361 Bytes(Vec<u8>),
373 Shutdown,
362 Shutdown,
@@ -375,6 +364,7 @@ pub enum VmInput {
375
364
376enum VmOutput {
365enum VmOutput {
377 LoginActionTimeout { action: String, timeout: Duration },
366 LoginActionTimeout { action: String, timeout: Duration },
367+ LoginActionFailed { action: String, reason: String },
378}
368}
379
369
380#[derive(Default)]
370#[derive(Default)]
@@ -411,6 +401,41 @@ impl OutputMonitor {
411 WaitResult::Found
401 WaitResult::Found
412 }
402 }
413 }
403 }
404+
405+ fn wait_for_any(&self, needles: &[&str], timeout: Duration) -> WaitAnyResult {
406+ let mut found: Option<usize> = None;
407+ let (_unused, timeout_result) = self
408+ .condvar
409+ .wait_timeout_while(self.buffer.lock().unwrap(), timeout, |buf| {
410+ if let Some((pos, idx, len)) = find_any(buf, needles) {
411+ *buf = buf[(pos + len)..].to_string();
412+ found = Some(idx);
413+ false
414+ } else {
415+ true
416+ }
417+ })
418+ .unwrap();
419+
420+ if timeout_result.timed_out() {
421+ WaitAnyResult::Timeout
422+ } else {
423+ WaitAnyResult::Found(found.unwrap_or(0))
424+ }
425+ }
426+}
427+
428+fn find_any(buf: &str, needles: &[&str]) -> Option<(usize, usize, usize)> {
429+ let mut best: Option<(usize, usize, usize)> = None; // (pos, idx, len)
430+ for (idx, needle) in needles.iter().enumerate() {
431+ if let Some(pos) = buf.find(needle) {
432+ let candidate = (pos, idx, needle.len());
433+ if best.is_none_or(|b| candidate.0 < b.0) {
434+ best = Some(candidate);
435+ }
436+ }
437+ }
438+ best
414}
439}
415
440
416#[derive(Debug)]
441#[derive(Debug)]
@@ -459,18 +484,16 @@ impl IoControl {
459fn ensure_base_image(
484fn ensure_base_image(
460 base_raw: &Path,
485 base_raw: &Path,
461 base_compressed: &Path,
486 base_compressed: &Path,
462− status: Option<&StatusFile>,
487+ status: Option<&StatusEmitter<'_>>,
463−) -> Result<(), Box<dyn std::error::Error>> {
488+) -> Result<()> {
464 if base_raw.exists() {
489 if base_raw.exists() {
465 return Ok(());
490 return Ok(());
466 }
491 }
467
492
468 if !base_compressed.exists()
493 if !base_compressed.exists()
469− || std::fs::metadata(base_compressed).map(|m| m.len())? < DEBIAN_COMPRESSED_SIZE_BYTES
494+ || fs::metadata(base_compressed).map(|m| m.len())? < DEBIAN_COMPRESSED_SIZE_BYTES
470 {
495 {
471− if let Some(status) = status {
472− status.update("downloading base image...");
496+ emit_status(status, "downloading base image...");
473− }
474 tracing::info!("downloading base image");
497 tracing::info!("downloading base image");
475 let status = Command::new("curl")
498 let status = Command::new("curl")
476 .args([
499 .args([
@@ -485,15 +508,14 @@ fn ensure_base_image(
485 ])
508 ])
486 .status()?;
509 .status()?;
487 if !status.success() {
510 if !status.success() {
488− return Err("Failed to download base image".into());
511+ bail!("failed to download base image");
489 }
512 }
490 }
513 }
491
514
492 // Check SHA
515 // Check SHA
493 {
516 {
494− if let Some(status) = status {
517+ emit_status(status, "verifying base image...");
495− status.update("verifying base image...");
518+ tracing::info!("verifying base image...");
496− }
497 let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
519 let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
498
520
499 let mut child = Command::new("/usr/bin/shasum")
521 let mut child = Command::new("/usr/bin/shasum")
@@ -511,25 +533,25 @@ fn ensure_base_image(
511
533
512 let status = child.wait().expect("failed to wait on child");
534 let status = child.wait().expect("failed to wait on child");
513 if !status.success() {
535 if !status.success() {
536+ bail!(format!(
514− return Err(format!("SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}").into());
537+ "SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}"
538+ ));
515 }
539 }
516 }
540 }
517
541
518− if let Some(status) = status {
519− status.update("decompressing base image...");
542+ emit_status(status, "decompressing base image...");
520− }
521− tracing::info!("decompressing base image");
543+ tracing::info!("decompressing base image...");
522 let status = Command::new("tar")
544 let status = Command::new("tar")
523 .args([
545 .args([
524 "-xOf",
546 "-xOf",
525 &base_compressed.to_string_lossy(),
547 &base_compressed.to_string_lossy(),
526 BASE_DISK_RAW_NAME,
548 BASE_DISK_RAW_NAME,
527 ])
549 ])
528− .stdout(std::fs::File::create(base_raw)?)
550+ .stdout(fs::File::create(base_raw)?)
529 .status()?;
551 .status()?;
530
552
531 if !status.success() {
553 if !status.success() {
532− return Err("Failed to decompress base image".into());
554+ bail!("Failed to decompress base image");
533 }
555 }
534
556
535 Ok(())
557 Ok(())
@@ -540,29 +562,56 @@ fn ensure_default_image(
540 base_compressed: &Path,
562 base_compressed: &Path,
541 default_raw: &Path,
563 default_raw: &Path,
542 directory_shares: &[DirectoryShare],
564 directory_shares: &[DirectoryShare],
565+ provision_log: Option<&Path>,
543− status: Option<&StatusFile>,
566+ status: Option<&StatusEmitter<'_>>,
544−) -> Result<(), Box<dyn std::error::Error>> {
567+) -> Result<()> {
545 if default_raw.exists() {
568 if default_raw.exists() {
546 return Ok(());
569 return Ok(());
547 }
570 }
548
571
549 ensure_base_image(base_raw, base_compressed, status)?;
572 ensure_base_image(base_raw, base_compressed, status)?;
550
573
551− if let Some(status) = status {
552− status.update("configuring base image...");
574+ emit_status(status, "configuring base image...");
553− }
554− tracing::info!("configuring base image");
575+ tracing::info!("configuring base image...");
555 fs::copy(base_raw, default_raw)?;
576 fs::copy(base_raw, default_raw)?;
556
577
557− let provision_command = script_command_from_content(PROVISION_SCRIPT_NAME, PROVISION_SCRIPT)?;
578+ let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?;
558− run_vm(
579+ let provision_actions = [
559− default_raw,
560− &[Send(provision_command)],
580+ Send(provision_command),
581+ ExpectEither {
582+ success: "VIBEBOX_PROVISION_OK".to_string(),
583+ failure: "VIBEBOX_PROVISION_FAILED".to_string(),
584+ timeout: PROVISION_EXPECT_TIMEOUT,
585+ },
586+ ];
587+ let provision_result = if let Some(log_path) = provision_log {
588+ let log_path = log_path.to_path_buf();
589+ run_vm_with_io(
590+ default_raw,
591+ &provision_actions,
592+ directory_shares,
593+ BoxConfig::default().cpu_count,
594+ BoxConfig::default().ram_size.as_u64(),
595+ status,
596+ move |output_monitor, vm_output_fd, vm_input_fd| {
597+ spawn_vm_io_with_log(output_monitor, vm_output_fd, vm_input_fd, log_path)
598+ },
599+ )
600+ } else {
601+ run_vm(
602+ default_raw,
603+ &provision_actions,
561− directory_shares,
604+ directory_shares,
562− DEFAULT_CPU_COUNT,
605+ BoxConfig::default().cpu_count,
563− DEFAULT_RAM_BYTES,
606+ BoxConfig::default().ram_size.as_u64(),
564− None,
607+ status,
565− )?;
608+ )
609+ };
610+
611+ if let Err(err) = provision_result {
612+ let _ = fs::remove_file(default_raw);
613+ return Err(err);
614+ }
566
615
567 Ok(())
616 Ok(())
568}
617}
@@ -570,19 +619,47 @@ fn ensure_default_image(
570fn ensure_instance_disk(
619fn ensure_instance_disk(
571 instance_raw: &Path,
620 instance_raw: &Path,
572 template_raw: &Path,
621 template_raw: &Path,
622+ target_bytes: ByteSize,
573− status: Option<&StatusFile>,
623+ status: Option<&StatusEmitter<'_>>,
574−) -> Result<(), Box<dyn std::error::Error>> {
624+) -> Result<bool> {
575 if instance_raw.exists() {
625 if instance_raw.exists() {
626+ let current_size = ByteSize(fs::metadata(instance_raw)?.len());
627+ if current_size != target_bytes {
628+ let current_gb = current_size;
629+ let target_gb = target_bytes;
630+ tracing::warn!(
631+ "instance disk size does not match config (current {}, config {}); disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using existing disk.",
632+ current_gb,
633+ target_gb
634+ );
635+ }
576− return Ok(());
636+ return Ok(false);
577 }
637 }
578
638
579− if let Some(status) = status {
639+ let template_size = ByteSize(fs::metadata(template_raw)?.len());
580− status.update("creating instance disk...");
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+ ));
581 }
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...");
582 tracing::info!(path = %template_raw.display(), "creating instance disk");
651 tracing::info!(path = %template_raw.display(), "creating instance disk");
583− std::fs::create_dir_all(instance_raw.parent().unwrap())?;
652+ fs::create_dir_all(instance_raw.parent().unwrap())?;
653+ if target_size == template_size {
584− fs::copy(template_raw, instance_raw)?;
654+ fs::copy(template_raw, instance_raw)?;
585− Ok(())
655+ return Ok(needs_resize);
656+ }
657+
658+ let mut dst = fs::File::create(instance_raw)?;
659+ dst.set_len(target_size.as_u64())?;
660+ let mut src = fs::File::open(template_raw)?;
661+ std::io::copy(&mut src, &mut dst)?;
662+ Ok(needs_resize)
586}
663}
587
664
588pub struct IoContext {
665pub struct IoContext {
@@ -598,18 +675,17 @@ pub fn create_pipe() -> (OwnedFd, OwnedFd) {
598 (read_stream.into(), write_stream.into())
675 (read_stream.into(), write_stream.into())
599}
676}
600
677
601−pub fn spawn_vm_io_with_hooks<F, G>(
678+pub fn spawn_vm_io_with_hooks<
679+ F: FnMut(&str) -> bool + std::marker::Send + 'static,
680+ G: FnMut(&[u8]) + std::marker::Send + 'static,
681+>(
602 output_monitor: Arc<OutputMonitor>,
682 output_monitor: Arc<OutputMonitor>,
603 vm_output_fd: OwnedFd,
683 vm_output_fd: OwnedFd,
604 vm_input_fd: OwnedFd,
684 vm_input_fd: OwnedFd,
605 io_control: Arc<IoControl>,
685 io_control: Arc<IoControl>,
606 mut on_line: F,
686 mut on_line: F,
607 mut on_output: G,
687 mut on_output: G,
608−) -> IoContext
688+) -> IoContext {
609−where
610− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
611− G: FnMut(&[u8]) + ::std::marker::Send + 'static,
612−{
613 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
689 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
614
690
615 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
691 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
@@ -757,14 +833,14 @@ where
757 PollResult::Spurious => continue,
833 PollResult::Spurious => continue,
758 PollResult::Ready(bytes) => {
834 PollResult::Ready(bytes) => {
759 if io_control.forward_output() {
835 if io_control.forward_output() {
760− // enable raw mode, if we haven't already
836+ // enable raw mode if we haven't already
761 if raw_guard.lock().unwrap().is_none()
837 if raw_guard.lock().unwrap().is_none()
762 && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
838 && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
763 {
839 {
764 *raw_guard.lock().unwrap() = Some(guard);
840 *raw_guard.lock().unwrap() = Some(guard);
765 }
841 }
766
842
767− let mut stdout = std::io::stdout().lock();
843+ let mut stdout = io::stdout().lock();
768 if stdout.write_all(bytes).is_err() {
844 if stdout.write_all(bytes).is_err() {
769 break;
845 break;
770 }
846 }
@@ -780,7 +856,7 @@ where
780
856
781 // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
857 // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
782 let mux_thread = thread::spawn(move || {
858 let mux_thread = thread::spawn(move || {
783− let mut vm_writer = std::fs::File::from(vm_input_fd);
859+ let mut vm_writer = fs::File::from(vm_input_fd);
784 loop {
860 loop {
785 match input_rx.recv() {
861 match input_rx.recv() {
786 Ok(VmInput::Bytes(data)) => {
862 Ok(VmInput::Bytes(data)) => {
@@ -810,7 +886,7 @@ pub fn spawn_vm_io_with_line_handler<F>(
810 on_line: F,
886 on_line: F,
811) -> IoContext
887) -> IoContext
812where
888where
813− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
889+ F: FnMut(&str) -> bool + std::marker::Send + 'static,
814{
890{
815 spawn_vm_io_with_hooks(
891 spawn_vm_io_with_hooks(
816 output_monitor,
892 output_monitor,
@@ -830,6 +906,36 @@ pub fn spawn_vm_io(
830 spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
906 spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
831}
907}
832
908
909+fn spawn_vm_io_with_log(
910+ output_monitor: Arc<OutputMonitor>,
911+ vm_output_fd: OwnedFd,
912+ vm_input_fd: OwnedFd,
913+ log_path: PathBuf,
914+) -> IoContext {
915+ let log_file = fs::OpenOptions::new()
916+ .create(true)
917+ .write(true)
918+ .truncate(true)
919+ .open(&log_path)
920+ .ok()
921+ .map(|file| Arc::new(Mutex::new(file)));
922+
923+ spawn_vm_io_with_hooks(
924+ output_monitor,
925+ vm_output_fd,
926+ vm_input_fd,
927+ IoControl::new(),
928+ |_| false,
929+ move |bytes| {
930+ if let Some(log) = &log_file
931+ && let Ok(mut file) = log.lock()
932+ {
933+ let _ = file.write_all(bytes);
934+ }
935+ },
936+ )
937+}
938+
833impl IoContext {
939impl IoContext {
834 pub fn shutdown(self) {
940 pub fn shutdown(self) {
835 let _ = self.input_tx.send(VmInput::Shutdown);
941 let _ = self.input_tx.send(VmInput::Shutdown);
@@ -847,7 +953,7 @@ fn create_vm_configuration(
847 vm_writes_to_fd: OwnedFd,
953 vm_writes_to_fd: OwnedFd,
848 cpu_count: usize,
954 cpu_count: usize,
849 ram_bytes: u64,
955 ram_bytes: u64,
850−) -> Result<Retained<VZVirtualMachineConfiguration>, Box<dyn std::error::Error>> {
956+) -> Result<Retained<VZVirtualMachineConfiguration>> {
851 unsafe {
957 unsafe {
852 let platform =
958 let platform =
853 VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
959 VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
@@ -881,7 +987,7 @@ fn create_vm_configuration(
881 false,
987 false,
882 VZDiskImageCachingMode::Automatic,
988 VZDiskImageCachingMode::Automatic,
883 VZDiskImageSynchronizationMode::Full,
989 VZDiskImageSynchronizationMode::Full,
884− ).unwrap();
990+ )?;
885
991
886 let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
992 let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
887 VZVirtioBlockDeviceConfiguration::alloc(),
993 VZVirtioBlockDeviceConfiguration::alloc(),
@@ -968,7 +1074,7 @@ fn create_vm_configuration(
968 // Validate
1074 // Validate
969 config.validateWithError().map_err(|e| {
1075 config.validateWithError().map_err(|e| {
970 io::Error::other(format!(
1076 io::Error::other(format!(
971− "Invalid VM configuration: {:?}",
1077+ "invalid VM configuration: {:?}",
972 e.localizedDescription()
1078 e.localizedDescription()
973 ))
1079 ))
974 })?;
1080 })?;
@@ -977,9 +1083,9 @@ fn create_vm_configuration(
977 }
1083 }
978}
1084}
979
1085
980−fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std::error::Error>> {
1086+fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>> {
981 unsafe {
1087 unsafe {
982− let temp_dir = std::env::temp_dir();
1088+ let temp_dir = env::temp_dir();
983 let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
1089 let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
984 let url = nsurl_from_path(&temp_path)?;
1090 let url = nsurl_from_path(&temp_path)?;
985 let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
1091 let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
@@ -995,8 +1101,8 @@ fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std
995fn spawn_login_actions_thread(
1101fn spawn_login_actions_thread(
996 login_actions: Vec<LoginAction>,
1102 login_actions: Vec<LoginAction>,
997 output_monitor: Arc<OutputMonitor>,
1103 output_monitor: Arc<OutputMonitor>,
998− input_tx: mpsc::Sender<VmInput>,
1104+ input_tx: Sender<VmInput>,
999− vm_output_tx: mpsc::Sender<VmOutput>,
1105+ vm_output_tx: Sender<VmOutput>,
1000) -> thread::JoinHandle<()> {
1106) -> thread::JoinHandle<()> {
1001 thread::spawn(move || {
1107 thread::spawn(move || {
1002 for a in login_actions {
1108 for a in login_actions {
@@ -1010,22 +1116,31 @@ fn spawn_login_actions_thread(
1010 return;
1116 return;
1011 }
1117 }
1012 }
1118 }
1119+ ExpectEither {
1120+ success,
1121+ failure,
1122+ timeout,
1123+ } => match output_monitor.wait_for_any(&[&success, &failure], timeout) {
1124+ WaitAnyResult::Found(0) => {}
1125+ WaitAnyResult::Found(_) => {
1126+ let _ = vm_output_tx.send(VmOutput::LoginActionFailed {
1127+ action: format!("expect '{}'", success),
1128+ reason: format!("saw failure marker '{}'", failure),
1129+ });
1130+ return;
1131+ }
1132+ WaitAnyResult::Timeout => {
1133+ let _ = vm_output_tx.send(VmOutput::LoginActionTimeout {
1134+ action: format!("expect '{}'", success),
1135+ timeout,
1136+ });
1137+ return;
1138+ }
1139+ },
1013 Send(mut text) => {
1140 Send(mut text) => {
1014 text.push('\n'); // Type the newline so the command is actually submitted.
1141 text.push('\n'); // Type the newline so the command is actually submitted.
1015 input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
1142 input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
1016 }
1143 }
1017− Script { path, index } => {
1018− let command = match script_command_from_path(&path, index) {
1019− Ok(command) => command,
1020− Err(err) => {
1021− tracing::error!(error = %err, "failed to build login script command");
1022− return;
1023− }
1024− };
1025− let mut text = command;
1026− text.push('\n');
1027− input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
1028− }
1029 }
1144 }
1030 }
1145 }
1031 })
1146 })
@@ -1037,9 +1152,9 @@ fn run_vm_with_io<F>(
1037 directory_shares: &[DirectoryShare],
1152 directory_shares: &[DirectoryShare],
1038 cpu_count: usize,
1153 cpu_count: usize,
1039 ram_bytes: u64,
1154 ram_bytes: u64,
1040− status: Option<&StatusFile>,
1155+ status: Option<&StatusEmitter<'_>>,
1041 io_handler: F,
1156 io_handler: F,
1042−) -> Result<(), Box<dyn std::error::Error>>
1157+) -> Result<()>
1043where
1158where
1044 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
1159 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
1045{
1160{
@@ -1086,24 +1201,22 @@ where
1086
1201
1087 match rx.try_recv() {
1202 match rx.try_recv() {
1088 Ok(result) => {
1203 Ok(result) => {
1089− result.map_err(|e| format!("Failed to start VM: {}", e))?;
1204+ result.map_err(|e| Error::msg(format!("Failed to start VM: {}", e)))?;
1090 break;
1205 break;
1091 }
1206 }
1092 Err(mpsc::TryRecvError::Empty) => continue,
1207 Err(mpsc::TryRecvError::Empty) => continue,
1093 Err(mpsc::TryRecvError::Disconnected) => {
1208 Err(mpsc::TryRecvError::Disconnected) => {
1094− return Err("VM start channel disconnected".into());
1209+ bail!("VM start channel disconnected");
1095 }
1210 }
1096 }
1211 }
1097 }
1212 }
1098
1213
1099 if Instant::now() >= start_deadline {
1214 if Instant::now() >= start_deadline {
1100− return Err("Timed out waiting for VM to start".into());
1215+ bail!("Timed out waiting for VM to start");
1101 }
1216 }
1102
1217
1103− if let Some(status) = status {
1218+ emit_status(status, "vm booting... go vibecoder!");
1104− status.update("vm booting... go vibecoder!");
1219+ tracing::info!("vm booting... go vibecoder!");
1105− status.clear();
1106− }
1107 tracing::info!("vm booting");
1220 tracing::info!("vm booting");
1108
1221
1109 let output_monitor = Arc::new(OutputMonitor::default());
1222 let output_monitor = Arc::new(OutputMonitor::default());
@@ -1149,7 +1262,7 @@ where
1149 );
1262 );
1150
1263
1151 let mut last_state = None;
1264 let mut last_state = None;
1152− let mut exit_result = Ok(());
1265+ let mut exit_result: Result<(), String> = Ok(());
1153 loop {
1266 loop {
1154 unsafe {
1267 unsafe {
1155 NSRunLoop::mainRunLoop().runMode_beforeDate(
1268 NSRunLoop::mainRunLoop().runMode_beforeDate(
@@ -1168,8 +1281,24 @@ where
1168 exit_result = Err(format!(
1281 exit_result = Err(format!(
1169 "Login action ({}) timed out after {:?}; shutting down.",
1282 "Login action ({}) timed out after {:?}; shutting down.",
1170 action, timeout
1283 action, timeout
1171− )
1284+ ));
1285+ unsafe {
1286+ if vm.canRequestStop() {
1287+ if let Err(err) = vm.requestStopWithError() {
1288+ tracing::error!(error = ?err, "failed to request VM stop");
1289+ }
1290+ } else if vm.canStop() {
1291+ let handler = RcBlock::new(|_error: *mut NSError| {});
1292+ vm.stopWithCompletionHandler(&handler);
1293+ }
1294+ }
1295+ break;
1296+ }
1297+ Ok(VmOutput::LoginActionFailed { action, reason }) => {
1298+ exit_result = Err(format!(
1299+ "Login action ({}) failed: {}; shutting down.",
1300+ action, reason
1172− .into());
1301+ ));
1173 unsafe {
1302 unsafe {
1174 if vm.canRequestStop() {
1303 if vm.canRequestStop() {
1175 if let Err(err) = vm.requestStopWithError() {
1304 if let Err(err) = vm.requestStopWithError() {
@@ -1185,7 +1314,7 @@ where
1185 Err(mpsc::TryRecvError::Empty) => {}
1314 Err(mpsc::TryRecvError::Empty) => {}
1186 Err(mpsc::TryRecvError::Disconnected) => {}
1315 Err(mpsc::TryRecvError::Disconnected) => {}
1187 }
1316 }
1188− if state != objc2_virtualization::VZVirtualMachineState::Running {
1317+ if state != VZVirtualMachineState::Running {
1189 //eprintln!("VM stopped with state: {:?}", state);
1318 //eprintln!("VM stopped with state: {:?}", state);
1190 break;
1319 break;
1191 }
1320 }
@@ -1195,7 +1324,7 @@ where
1195
1324
1196 io_ctx.shutdown();
1325 io_ctx.shutdown();
1197
1326
1198− exit_result
1327+ exit_result.map_err(Error::msg)
1199}
1328}
1200
1329
1201fn run_vm(
1330fn run_vm(
@@ -1204,8 +1333,8 @@ fn run_vm(
1204 directory_shares: &[DirectoryShare],
1333 directory_shares: &[DirectoryShare],
1205 cpu_count: usize,
1334 cpu_count: usize,
1206 ram_bytes: u64,
1335 ram_bytes: u64,
1207− status: Option<&StatusFile>,
1336+ status: Option<&StatusEmitter<'_>>,
1208−) -> Result<(), Box<dyn std::error::Error>> {
1337+) -> Result<()> {
1209 run_vm_with_io(
1338 run_vm_with_io(
1210 disk_path,
1339 disk_path,
1211 login_actions,
1340 login_actions,
@@ -1217,7 +1346,7 @@ fn run_vm(
1217 )
1346 )
1218}
1347}
1219
1348
1220−fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
1349+fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>> {
1221 let abs_path = if path.is_absolute() {
1350 let abs_path = if path.is_absolute() {
1222 path.to_path_buf()
1351 path.to_path_buf()
1223 } else {
1352 } else {
@@ -1226,7 +1355,7 @@ fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::E
1226 let ns_path = NSString::from_str(
1355 let ns_path = NSString::from_str(
1227 abs_path
1356 abs_path
1228 .to_str()
1357 .to_str()
1229− .ok_or("Non-UTF8 path encountered while building NSURL")?,
1358+ .with_context(|| "non-UTF8 path encountered while building NSURL")?,
1230 );
1359 );
1231 Ok(NSURL::fileURLWithPath(&ns_path))
1360 Ok(NSURL::fileURLWithPath(&ns_path))
1232}
1361}
@@ -1241,7 +1370,7 @@ fn enable_raw_mode(fd: i32) -> io::Result<RawModeGuard> {
1241 let original = attributes;
1370 let original = attributes;
1242
1371
1243 // Disable translation of carriage return to newline on input
1372 // Disable translation of carriage return to newline on input
1244− attributes.c_iflag &= !(libc::ICRNL);
1373+ attributes.c_iflag &= !libc::ICRNL;
1245 // Disable canonical mode (line buffering), echo, and signal generation
1374 // Disable canonical mode (line buffering), echo, and signal generation
1246 attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
1375 attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
1247 attributes.c_cc[libc::VMIN] = 0;
1376 attributes.c_cc[libc::VMIN] = 0;
@@ -1269,10 +1398,10 @@ impl Drop for RawModeGuard {
1269
1398
1270// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
1399// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
1271pub fn ensure_signed() {
1400pub fn ensure_signed() {
1272− if std::env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
1401+ if env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
1273 return;
1402 return;
1274 }
1403 }
1275− let exe = std::env::current_exe().expect("failed to get current exe path");
1404+ let exe = env::current_exe().expect("failed to get current exe path");
1276 let exe_str = exe.to_str().expect("exe path not valid utf-8");
1405 let exe_str = exe.to_str().expect("exe path not valid utf-8");
1277
1406
1278 let has_required_entitlements = {
1407 let has_required_entitlements = {
@@ -1294,8 +1423,8 @@ pub fn ensure_signed() {
1294 }
1423 }
1295
1424
1296 const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1425 const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1297− let entitlements_path = std::env::temp_dir().join("entitlements.plist");
1426+ let entitlements_path = env::temp_dir().join("entitlements.plist");
1298− std::fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
1427+ fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
1299
1428
1300 let output = Command::new("codesign")
1429 let output = Command::new("codesign")
1301 .args([
1430 .args([
@@ -1308,7 +1437,7 @@ pub fn ensure_signed() {
1308 ])
1437 ])
1309 .output();
1438 .output();
1310
1439
1311− let _ = std::fs::remove_file(&entitlements_path);
1440+ let _ = fs::remove_file(&entitlements_path);
1312
1441
1313 match output {
1442 match output {
1314 Ok(o) if o.status.success() => {
1443 Ok(o) if o.status.success() => {
@@ -1316,7 +1445,7 @@ pub fn ensure_signed() {
1316 if !stderr.trim().is_empty() {
1445 if !stderr.trim().is_empty() {
1317 tracing::debug!(codesign_stderr = %stderr.trim(), "codesign output");
1446 tracing::debug!(codesign_stderr = %stderr.trim(), "codesign output");
1318 }
1447 }
1319− let err = Command::new(&exe).args(std::env::args_os().skip(1)).exec();
1448+ let err = Command::new(&exe).args(env::args_os().skip(1)).exec();
1320 tracing::error!(error = %err, "failed to re-exec after signing");
1449 tracing::error!(error = %err, "failed to re-exec after signing");
1321 std::process::exit(1);
1450 std::process::exit(1);
1322 }
1451 }
src/vm_manager.rs+466−184
@@ -1,12 +1,22 @@
1+use crate::instance::{VmLiveness, vm_liveness};
2+use crate::session_manager::INSTANCE_DIR_NAME;
3+use crate::{
4+ config::CONFIG_PATH_ENV,
5+ instance::{
6+ InstanceConfig, STATUS_VM_ERROR_PREFIX, build_ssh_login_actions, ensure_instance_dir,
7+ ensure_ssh_keypair, extract_ipv4, load_or_create_instance_config, write_instance_config,
8+ },
9+ session_manager::{GLOBAL_DIR_NAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME},
10+ vm::{self, DirectoryShare, LoginAction, PROJECT_GUEST_BASE, VmInput},
11+};
12+use anyhow::{Error, Result, anyhow, bail};
1use std::{
13use std::{
2 env, fs,
14 env, fs,
3 io::{Read, Write},
15 io::{Read, Write},
4 os::unix::{
16 os::unix::{
5− fs::FileTypeExt,
6 fs::PermissionsExt,
17 fs::PermissionsExt,
7 io::AsRawFd,
18 io::AsRawFd,
8 net::{UnixListener, UnixStream},
19 net::{UnixListener, UnixStream},
9− process::CommandExt,
10 },
20 },
11 path::{Path, PathBuf},
21 path::{Path, PathBuf},
12 process::{Command, Stdio},
22 process::{Command, Stdio},
@@ -15,36 +25,44 @@ use std::{
15 time::{Duration, Instant},
25 time::{Duration, Instant},
16};
26};
17
27
18−use crate::{
28+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
19− config::CONFIG_PATH_ENV,
29+const VM_ROOT_LOG_NAME: &str = "vm_root.log";
20− instance::VM_ROOT_LOG_NAME,
21− instance::{
22− DEFAULT_SSH_USER, InstanceConfig, build_ssh_login_actions, ensure_instance_dir,
23− ensure_ssh_keypair, extract_ipv4, load_or_create_instance_config, write_instance_config,
24− },
25− session_manager::{
26− GLOBAL_DIR_NAME, INSTANCE_FILENAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME,
27− },
28− vm::{self, DirectoryShare, LoginAction, VmInput},
29−};
30−
31const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
30const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
32const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
31const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
32+const SHUTDOWN_RETRY_MS: u64 = 500;
33+#[cfg(test)]
34+const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 1_000;
35+#[cfg(not(test))]
36+const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 12_000;
37+const STATUS_PREFIX: &str = "status:";
38+
39+type ClientStreams = Arc<Mutex<Vec<UnixStream>>>;
40+type SharedStatus = Arc<Mutex<String>>;
41+
42+#[cfg(not(test))]
43+fn force_exit(_reason: &str) -> ! {
44+ std::process::exit(1);
45+}
46+
47+#[cfg(test)]
48+fn force_exit(reason: &str) -> ! {
49+ panic!("{reason}");
50+}
33
51
34pub fn ensure_manager(
52pub fn ensure_manager(
35 raw_args: &[std::ffi::OsString],
53 raw_args: &[std::ffi::OsString],
36 auto_shutdown_ms: u64,
54 auto_shutdown_ms: u64,
37 config_path: Option<&Path>,
55 config_path: Option<&Path>,
38−) -> Result<UnixStream, Box<dyn std::error::Error>> {
56+) -> Result<UnixStream> {
39 let project_root = env::current_dir()?;
57 let project_root = env::current_dir()?;
40 tracing::debug!(root = %project_root.display(), "ensure vm manager");
58 tracing::debug!(root = %project_root.display(), "ensure vm manager");
41 let instance_dir = ensure_instance_dir(&project_root)?;
59 let instance_dir = ensure_instance_dir(&project_root)?;
42− cleanup_stale_manager(&instance_dir);
60+ cleanup_stale_manager(&project_root)?;
43 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
61 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
44
62
45 if let Ok(stream) = UnixStream::connect(&socket_path) {
63 if let Ok(stream) = UnixStream::connect(&socket_path) {
46 send_client_pid(&stream);
64 send_client_pid(&stream);
47− tracing::info!(path = %socket_path.display(), "connected to existing vm manager");
65+ tracing::info!(path = %socket_path.display(), "connected to an existing vm manager");
48 return Ok(stream);
66 return Ok(stream);
49 }
67 }
50
68
@@ -82,12 +100,11 @@ pub fn ensure_manager(
82 drop(lock_file.take());
100 drop(lock_file.take());
83 let _ = fs::remove_file(&lock_path);
101 let _ = fs::remove_file(&lock_path);
84 }
102 }
85− return Err(format!(
103+ bail!(format!(
86− "Timed out waiting for vm manager socket: {} ({})",
104+ "timed out waiting for vm manager socket: {} ({})",
87 socket_path.display(),
105 socket_path.display(),
88 err
106 err
89− )
107+ ));
90− .into());
91 }
108 }
92 thread::sleep(Duration::from_millis(100));
109 thread::sleep(Duration::from_millis(100));
93 }
110 }
@@ -95,24 +112,49 @@ pub fn ensure_manager(
95 }
112 }
96}
113}
97
114
98−pub fn run_manager(
99− args: vm::VmArg,
100− auto_shutdown_ms: u64,
115+pub fn run_manager(args: vm::VmArg, auto_shutdown_ms: u64) -> Result<()> {
101−) -> Result<(), Box<dyn std::error::Error>> {
102 let project_root = env::current_dir()?;
116 let project_root = env::current_dir()?;
103 tracing::info!(root = %project_root.display(), "vm manager starting");
117 tracing::info!(root = %project_root.display(), "vm manager starting");
118+ #[cfg(not(feature = "mock-vm"))]
119+ {
120+ unsafe {
121+ env::remove_var("VIBEBOX_SKIP_CODESIGN");
122+ }
123+ vm::ensure_signed();
124+ unsafe {
125+ env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
126+ }
127+ }
104 let _pid_guard = ensure_pid_file(&project_root)?;
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+ };
105− run_manager_with(
157+ run_manager_with(&project_root, args, auto_shutdown_ms, executor, options)
106− &project_root,
107− args,
108− auto_shutdown_ms,
109− &RealVmExecutor,
110− ManagerOptions {
111− ensure_signed: true,
112− detach: true,
113− prepare_vm: true,
114− },
115− )
116}
158}
117
159
118fn spawn_manager_process(
160fn spawn_manager_process(
@@ -120,25 +162,22 @@ fn spawn_manager_process(
120 auto_shutdown_ms: u64,
162 auto_shutdown_ms: u64,
121 instance_dir: &Path,
163 instance_dir: &Path,
122 config_path: Option<&Path>,
164 config_path: Option<&Path>,
123−) -> Result<(), Box<dyn std::error::Error>> {
165+) -> Result<()> {
124 let exe = env::current_exe()?;
166 let exe = env::current_exe()?;
125 let mut supervisor_exe = exe.clone();
167 let mut supervisor_exe = exe.clone();
126 supervisor_exe.set_file_name("vibebox-supervisor");
168 supervisor_exe.set_file_name("vibebox-supervisor");
127− let use_supervisor = supervisor_exe.exists();
169+ // intentional
128− let mut cmd = if use_supervisor {
170+ if !supervisor_exe.exists() {
129− Command::new(supervisor_exe)
171+ bail!(format!(
130− } else {
131− let mut cmd = Command::new(exe);
132− cmd.arg0("vibebox-supervisor");
172+ "vibebox-supervisor not found at {}",
133− cmd
173+ supervisor_exe.display()
174+ ));
134− };
175+ }
176+ let mut cmd = Command::new(supervisor_exe);
135 if raw_args.len() > 1 {
177 if raw_args.len() > 1 {
136 cmd.args(&raw_args[1..]);
178 cmd.args(&raw_args[1..]);
137 }
179 }
138 cmd.env("VIBEBOX_INTERNAL", "1");
180 cmd.env("VIBEBOX_INTERNAL", "1");
139− if !use_supervisor {
140− cmd.env("VIBEBOX_VM_MANAGER", "1");
141− }
142 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
181 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
143 cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
182 cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
144 if let Some(path) = config_path {
183 if let Some(path) = config_path {
@@ -166,46 +205,65 @@ fn spawn_manager_process(
166 Ok(())
205 Ok(())
167}
206}
168
207
169−fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard, Box<dyn std::error::Error>> {
208+fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard> {
170 let instance_dir = ensure_instance_dir(project_root)?;
209 let instance_dir = ensure_instance_dir(project_root)?;
171 let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
210 let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
172 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
211 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
173− if let Ok(content) = fs::read_to_string(&pid_path) {
212+ match vm_liveness(project_root)? {
174− if let Ok(pid) = content.trim().parse::<u32>() {
213+ VmLiveness::RunningWithSocket { pid } => {
175− if pid_is_alive(pid) {
176− if is_socket_path(&socket_path) {
177− return Err(format!("vm manager already running (pid {pid})").into());
214+ bail!("vm manager already running (pid {pid})");
178− }
215+ }
216+ VmLiveness::RunningWithoutSocket { pid } => {
179− tracing::warn!(
217+ tracing::warn!(
180− pid,
218+ pid,
181− path = %socket_path.display(),
219+ path = %socket_path.display(),
182− "stale pid file detected with missing socket"
220+ "stale pid file detected with missing socket"
183− );
221+ );
184− }
185 }
222 }
186− let _ = fs::remove_file(&pid_path);
223+ VmLiveness::NotRunningOrMissing => {}
187 }
224 }
225+ let _ = fs::remove_file(&pid_path);
188 fs::write(&pid_path, format!("{}\n", std::process::id()))?;
226 fs::write(&pid_path, format!("{}\n", std::process::id()))?;
189 let _ = fs::set_permissions(&pid_path, fs::Permissions::from_mode(0o600));
227 let _ = fs::set_permissions(&pid_path, fs::Permissions::from_mode(0o600));
190 Ok(PidFileGuard { path: pid_path })
228 Ok(PidFileGuard { path: pid_path })
191}
229}
192
230
193−fn cleanup_stale_manager(instance_dir: &Path) {
231+fn cleanup_stale_manager(project_root: &Path) -> Result<()> {
232+ let pid_path = project_root
233+ .join(INSTANCE_DIR_NAME)
194− let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
234+ .join(VM_MANAGER_PID_NAME);
195− if let Ok(content) = fs::read_to_string(&pid_path) {
235+ if matches!(
196− if let Ok(pid) = content.trim().parse::<u32>() {
236+ vm_liveness(project_root)?,
197− if pid_is_alive(pid) {
237+ VmLiveness::RunningWithSocket { .. } | VmLiveness::RunningWithoutSocket { .. }
238+ ) {
198− return;
239+ return Ok(());
199− }
200− }
201 }
240 }
202 let _ = fs::remove_file(&pid_path);
241 let _ = fs::remove_file(&pid_path);
242+ Ok(())
203}
243}
204
244
205−fn is_socket_path(path: &Path) -> bool {
245+fn inject_project_mount(
206− fs::metadata(path)
246+ mounts: &mut Vec<String>,
207− .map(|meta| meta.file_type().is_socket())
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 {
208− .unwrap_or(false)
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"));
209}
267}
210
268
211fn prepare_mounts_and_links(mut args: vm::VmArg, ssh_user: &str) -> (vm::VmArg, String) {
269fn prepare_mounts_and_links(mut args: vm::VmArg, ssh_user: &str) -> (vm::VmArg, String) {
@@ -254,7 +312,7 @@ fn rewrite_mount_spec(spec: &str, ssh_user: &str) -> (String, Option<HomeLink>)
254 return (spec.to_string(), None);
312 return (spec.to_string(), None);
255 }
313 }
256
314
257− let root_base = "/usr/local/vibebox-mounts";
315+ let root_base = PROJECT_GUEST_BASE;
258 let root_path = if rel.is_empty() {
316 let root_path = if rel.is_empty() {
259 root_base.to_string()
317 root_base.to_string()
260 } else {
318 } else {
@@ -351,6 +409,30 @@ fn wait_for_disconnect(mut stream: UnixStream) {
351 }
409 }
352}
410}
353
411
412+fn remove_client(streams: &ClientStreams, fd: std::os::fd::RawFd) {
413+ if let Ok(mut clients) = streams.lock() {
414+ clients.retain(|stream| stream.as_raw_fd() != fd);
415+ }
416+}
417+
418+fn send_status_line(stream: &mut UnixStream, status: &str) -> bool {
419+ let mut payload = String::with_capacity(STATUS_PREFIX.len() + status.len() + 1);
420+ payload.push_str(STATUS_PREFIX);
421+ payload.push_str(status);
422+ payload.push('\n');
423+ stream.write_all(payload.as_bytes()).is_ok()
424+}
425+
426+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
427+fn broadcast_status(streams: &ClientStreams, latest_status: &SharedStatus, status: &str) {
428+ if let Ok(mut current) = latest_status.lock() {
429+ *current = status.to_string();
430+ }
431+ if let Ok(mut clients) = streams.lock() {
432+ clients.retain_mut(|stream| send_status_line(stream, status));
433+ }
434+}
435+
354fn send_client_pid(stream: &UnixStream) {
436fn send_client_pid(stream: &UnixStream) {
355 let pid = std::process::id();
437 let pid = std::process::id();
356 let payload = format!("pid={pid}\n");
438 let payload = format!("pid={pid}\n");
@@ -360,7 +442,7 @@ fn send_client_pid(stream: &UnixStream) {
360 }
442 }
361}
443}
362
444
363−fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>, Box<dyn std::error::Error>> {
445+fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>> {
364 match fs::OpenOptions::new()
446 match fs::OpenOptions::new()
365 .write(true)
447 .write(true)
366 .create_new(true)
448 .create_new(true)
@@ -394,6 +476,12 @@ fn is_lock_stale(lock_path: &Path) -> bool {
394 }
476 }
395}
477}
396
478
479+fn read_lock_pid(lock_path: &Path) -> Option<u32> {
480+ let content = fs::read_to_string(lock_path).ok()?;
481+ let line = content.lines().next()?;
482+ line.strip_prefix("pid=")?.trim().parse::<u32>().ok()
483+}
484+
397fn pid_is_alive(pid: u32) -> bool {
485fn pid_is_alive(pid: u32) -> bool {
398 let pid = pid as libc::pid_t;
486 let pid = pid as libc::pid_t;
399 let result = unsafe { libc::kill(pid, 0) };
487 let result = unsafe { libc::kill(pid, 0) };
@@ -407,12 +495,6 @@ fn pid_is_alive(pid: u32) -> bool {
407 }
495 }
408}
496}
409
497
410−fn read_lock_pid(lock_path: &Path) -> Option<u32> {
411− let content = fs::read_to_string(lock_path).ok()?;
412− let line = content.lines().next()?;
413− line.strip_prefix("pid=")?.trim().parse::<u32>().ok()
414−}
415−
416fn read_client_pid(stream: &UnixStream) -> Option<u32> {
498fn read_client_pid(stream: &UnixStream) -> Option<u32> {
417 let mut stream = stream.try_clone().ok()?;
499 let mut stream = stream.try_clone().ok()?;
418 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
500 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
@@ -423,7 +505,7 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
423 Ok(0) => break,
505 Ok(0) => break,
424 Ok(n) => {
506 Ok(n) => {
425 len += n;
507 len += n;
426− if buf[..len].iter().any(|b| *b == b'\n') || len == buf.len() {
508+ if buf[..len].contains(&b'\n') || len == buf.len() {
427 break;
509 break;
428 }
510 }
429 }
511 }
@@ -445,14 +527,17 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
445 }
527 }
446}
528}
447
529
530+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
448fn spawn_manager_io(
531fn spawn_manager_io(
449 config: Arc<Mutex<InstanceConfig>>,
532 config: Arc<Mutex<InstanceConfig>>,
450− instance_dir: PathBuf,
533+ project_dir: PathBuf,
534+ clients: ClientStreams,
535+ latest_status: SharedStatus,
451 output_monitor: Arc<vm::OutputMonitor>,
536 output_monitor: Arc<vm::OutputMonitor>,
452 vm_output_fd: std::os::unix::io::OwnedFd,
537 vm_output_fd: std::os::unix::io::OwnedFd,
453 vm_input_fd: std::os::unix::io::OwnedFd,
538 vm_input_fd: std::os::unix::io::OwnedFd,
454) -> vm::IoContext {
539) -> vm::IoContext {
455− let log_path = instance_dir.join(VM_ROOT_LOG_NAME);
540+ let log_path = project_dir.join(INSTANCE_DIR_NAME).join(VM_ROOT_LOG_NAME);
456 let log_file = fs::OpenOptions::new()
541 let log_file = fs::OpenOptions::new()
457 .create(true)
542 .create(true)
458 .write(true)
543 .write(true)
@@ -461,16 +546,15 @@ fn spawn_manager_io(
461 .ok()
546 .ok()
462 .map(|file| Arc::new(Mutex::new(file)));
547 .map(|file| Arc::new(Mutex::new(file)));
463
548
464− let instance_path = instance_dir.join(INSTANCE_FILENAME);
465 let config_for_output = config.clone();
549 let config_for_output = config.clone();
466 let log_for_output = log_file.clone();
550 let log_for_output = log_file.clone();
467 let mut line_buf = String::new();
551 let mut line_buf = String::new();
468
552
469 let on_output = move |bytes: &[u8]| {
553 let on_output = move |bytes: &[u8]| {
470− if let Some(log) = &log_for_output {
554+ if let Some(log) = &log_for_output
471− if let Ok(mut file) = log.lock() {
555+ && let Ok(mut file) = log.lock()
556+ {
472− let _ = file.write_all(bytes);
557+ let _ = file.write_all(bytes);
473− }
474 }
558 }
475
559
476 let text = String::from_utf8_lossy(bytes);
560 let text = String::from_utf8_lossy(bytes);
@@ -483,17 +567,27 @@ fn spawn_manager_io(
483 }
567 }
484 line_buf.drain(..=pos);
568 line_buf.drain(..=pos);
485
569
486− let cleaned = line.trim_start_matches(|c: char| c == '\r' || c == ' ');
570+ let cleaned = line.trim_start_matches(['\r', ' ']);
571+ if let Some(script_failure) = cleaned.strip_prefix("VIBEBOX_SCRIPT_ERROR:") {
572+ let failure = script_failure.trim();
573+ if !failure.is_empty() {
574+ tracing::error!(script_failure = %failure, "[vm] script reported failure");
575+ broadcast_status(
576+ &clients,
577+ &latest_status,
578+ &format!("{STATUS_VM_ERROR_PREFIX} {failure}"),
579+ );
580+ }
581+ }
487 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
582 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
488 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
583 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
489 let ip = extract_ipv4(ip_raw).unwrap_or_default();
584 let ip = extract_ipv4(ip_raw).unwrap_or_default();
490− if !ip.is_empty() {
585+ if !ip.is_empty()
491− if let Ok(mut cfg) = config_for_output.lock() {
586+ && let Ok(mut cfg) = config_for_output.lock()
492− if cfg.vm_ipv4.as_deref() != Some(ip.as_str()) {
587+ && cfg.vm_ipv4.as_deref() != Some(ip.as_str())
588+ {
493− cfg.vm_ipv4 = Some(ip.clone());
589+ cfg.vm_ipv4 = Some(ip.clone());
494− let _ = write_instance_config(&instance_path, &cfg);
590+ let _ = write_instance_config(&project_dir, &cfg);
495− }
496− }
497 }
591 }
498 }
592 }
499 }
593 }
@@ -515,51 +609,13 @@ enum ManagerEvent {
515 VmExited(Option<String>),
609 VmExited(Option<String>),
516}
610}
517
611
518−#[cfg(test)]
519−mod tests {
520− use super::*;
521− use std::{sync::mpsc, time::Duration};
522−
523− #[test]
524− fn manager_powers_off_after_grace_when_no_refs() {
525− let _temp = tempfile::Builder::new()
526− .prefix("vb")
527− .tempdir_in("/tmp")
528− .expect("tempdir");
529−
530− let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
531− let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
532− let vm_input_tx = Arc::new(Mutex::new(Some(vm_tx)));
533−
534− let manager_thread = thread::spawn(move || {
535− manager_event_loop(event_rx, vm_input_tx, 50).expect("event loop");
536− });
537−
538− event_tx.send(ManagerEvent::Inc(None)).unwrap();
539− assert!(vm_rx.recv_timeout(Duration::from_millis(100)).is_err());
540−
541− event_tx.send(ManagerEvent::Dec(None)).unwrap();
542− let msg = vm_rx
543− .recv_timeout(Duration::from_secs(2))
544− .expect("poweroff");
545− match msg {
546− VmInput::Bytes(data) => {
547− assert_eq!(data, b"systemctl poweroff\n");
548− }
549− _ => panic!("unexpected vm input"),
550− }
551− let _ = event_tx.send(ManagerEvent::VmExited(None));
552− let _ = manager_thread.join();
553− }
554−}
555−
556struct ManagerOptions {
612struct ManagerOptions {
557− ensure_signed: bool,
558 detach: bool,
613 detach: bool,
559 prepare_vm: bool,
614 prepare_vm: bool,
560}
615}
561
616
562trait VmExecutor {
617trait VmExecutor {
618+ #[allow(clippy::too_many_arguments)]
563 fn run_vm(
619 fn run_vm(
564 &self,
620 &self,
565 args: vm::VmArg,
621 args: vm::VmArg,
@@ -567,10 +623,13 @@ trait VmExecutor {
567 extra_shares: Vec<DirectoryShare>,
623 extra_shares: Vec<DirectoryShare>,
568 config: Arc<Mutex<InstanceConfig>>,
624 config: Arc<Mutex<InstanceConfig>>,
569 instance_dir: PathBuf,
625 instance_dir: PathBuf,
626+ clients: ClientStreams,
627+ latest_status: SharedStatus,
570 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
628 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
571− ) -> Result<(), Box<dyn std::error::Error>>;
629+ ) -> Result<()>;
572}
630}
573
631
632+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
574struct RealVmExecutor;
633struct RealVmExecutor;
575
634
576impl VmExecutor for RealVmExecutor {
635impl VmExecutor for RealVmExecutor {
@@ -580,15 +639,22 @@ impl VmExecutor for RealVmExecutor {
580 extra_login_actions: Vec<LoginAction>,
639 extra_login_actions: Vec<LoginAction>,
581 extra_shares: Vec<DirectoryShare>,
640 extra_shares: Vec<DirectoryShare>,
582 config: Arc<Mutex<InstanceConfig>>,
641 config: Arc<Mutex<InstanceConfig>>,
583− instance_dir: PathBuf,
642+ project_dir: PathBuf,
643+ clients: ClientStreams,
644+ latest_status: SharedStatus,
584 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
645 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
585− ) -> Result<(), Box<dyn std::error::Error>> {
646+ ) -> Result<()> {
647+ let status_callback = |status: &str| {
648+ broadcast_status(&clients, &latest_status, status);
649+ };
586− let result = vm::run_with_args_and_extras(
650+ vm::run_with_args_and_extras(
587 args,
651 args,
588 |output_monitor, vm_output_fd, vm_input_fd| {
652 |output_monitor, vm_output_fd, vm_input_fd| {
589 let io_ctx = spawn_manager_io(
653 let io_ctx = spawn_manager_io(
590 config.clone(),
654 config.clone(),
591− instance_dir.clone(),
655+ project_dir.clone(),
656+ clients.clone(),
657+ latest_status.clone(),
592 output_monitor,
658 output_monitor,
593 vm_output_fd,
659 vm_output_fd,
594 vm_input_fd,
660 vm_input_fd,
@@ -598,35 +664,60 @@ impl VmExecutor for RealVmExecutor {
598 },
664 },
599 extra_login_actions,
665 extra_login_actions,
600 extra_shares,
666 extra_shares,
601− );
667+ Some(&status_callback),
602− result
668+ )
669+ }
670+}
671+
672+#[cfg(feature = "mock-vm")]
673+struct MockVmExecutor;
674+
675+#[cfg(feature = "mock-vm")]
676+impl VmExecutor for MockVmExecutor {
677+ fn run_vm(
678+ &self,
679+ _args: vm::VmArg,
680+ _extra_login_actions: Vec<LoginAction>,
681+ _extra_shares: Vec<DirectoryShare>,
682+ _config: Arc<Mutex<InstanceConfig>>,
683+ _instance_dir: PathBuf,
684+ _clients: ClientStreams,
685+ _latest_status: SharedStatus,
686+ vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
687+ ) -> Result<()> {
688+ let (tx, rx) = mpsc::channel::<VmInput>();
689+ *vm_input_tx.lock().unwrap() = Some(tx);
690+ tracing::info!("mock vm executor running");
691+ while let Ok(input) = rx.recv() {
692+ match input {
693+ VmInput::Shutdown => break,
694+ VmInput::Bytes(bytes) => {
695+ let text = String::from_utf8_lossy(&bytes);
696+ if text.contains("systemctl poweroff") {
697+ break;
698+ }
699+ }
700+ }
701+ }
702+ tracing::info!("mock vm executor exiting");
703+ Ok(())
603 }
704 }
604}
705}
605
706
606fn run_manager_with(
707fn run_manager_with(
607 project_root: &Path,
708 project_root: &Path,
608− args: vm::VmArg,
709+ mut args: vm::VmArg,
609 auto_shutdown_ms: u64,
710 auto_shutdown_ms: u64,
610 executor: &dyn VmExecutor,
711 executor: &dyn VmExecutor,
611 options: ManagerOptions,
712 options: ManagerOptions,
612−) -> Result<(), Box<dyn std::error::Error>> {
713+) -> Result<()> {
613− if options.ensure_signed {
614− let _had_skip = env::var("VIBEBOX_SKIP_CODESIGN").ok();
615− unsafe {
616− env::remove_var("VIBEBOX_SKIP_CODESIGN");
617− }
618− vm::ensure_signed();
619− unsafe {
620− env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
621− }
622− }
623 if options.detach {
714 if options.detach {
624 detach_from_terminal();
715 detach_from_terminal();
625 }
716 }
626
717
627 let project_name = project_root
718 let project_name = project_root
628 .file_name()
719 .file_name()
629− .ok_or("Project directory has no name")?
720+ .ok_or_else(|| anyhow!("Project directory has no name"))?
630 .to_string_lossy()
721 .to_string_lossy()
631 .into_owned();
722 .into_owned();
632 let instance_dir = ensure_instance_dir(project_root)?;
723 let instance_dir = ensure_instance_dir(project_root)?;
@@ -634,27 +725,31 @@ fn run_manager_with(
634 let _ = ensure_ssh_keypair(&instance_dir)?;
725 let _ = ensure_ssh_keypair(&instance_dir)?;
635 }
726 }
636
727
637− let mut config = load_or_create_instance_config(&instance_dir)?;
728+ let mut config = load_or_create_instance_config(project_root)?;
638 if config.vm_ipv4.is_some() {
729 if config.vm_ipv4.is_some() {
639 config.vm_ipv4 = None;
730 config.vm_ipv4 = None;
640− write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
731+ write_instance_config(project_root, &config)?;
641 }
732 }
642 let config = Arc::new(Mutex::new(config));
733 let config = Arc::new(Mutex::new(config));
643 let ssh_user = config
734 let ssh_user = config
644 .lock()
735 .lock()
645− .map(|cfg| cfg.ssh_user_display())
736+ .map(|cfg| cfg.ssh_user.clone())
646− .unwrap_or_else(|_| DEFAULT_SSH_USER.to_string());
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+ }
647 let (args, home_links_script) = prepare_mounts_and_links(args, &ssh_user);
741 let (args, home_links_script) = prepare_mounts_and_links(args, &ssh_user);
648
742
743+ let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}");
649 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
744 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
650− let extra_shares = vec![DirectoryShare::new(
745+ let extra_shares = vec![
651− instance_dir.clone(),
652− ssh_guest_dir.clone().into(),
746+ DirectoryShare::new(instance_dir.clone(), ssh_guest_dir.clone().into(), true)
653− true,
747+ .map_err(|err| anyhow!(err.to_string()))?,
654− )?];
748+ ];
655 let extra_login_actions = build_ssh_login_actions(
749 let extra_login_actions = build_ssh_login_actions(
656 &config,
750 &config,
657 &project_name,
751 &project_name,
752+ &project_guest_dir,
658 ssh_guest_dir.as_str(),
753 ssh_guest_dir.as_str(),
659 "ssh_key",
754 "ssh_key",
660 &home_links_script,
755 &home_links_script,
@@ -673,17 +768,43 @@ fn run_manager_with(
673 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
768 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
674 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
769 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
675
770
771+ let clients: ClientStreams = Arc::new(Mutex::new(Vec::new()));
772+ let latest_status: SharedStatus = Arc::new(Mutex::new(String::new()));
676 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
773 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
677 let event_tx_accept = event_tx.clone();
774 let event_tx_accept = event_tx.clone();
775+ let clients_accept = clients.clone();
776+ let latest_status_accept = latest_status.clone();
678 thread::spawn(move || {
777 thread::spawn(move || {
679 for stream in listener.incoming() {
778 for stream in listener.incoming() {
680 match stream {
779 match stream {
681 Ok(stream) => {
780 Ok(stream) => {
781+ let mut client_fd: Option<std::os::fd::RawFd> = None;
782+ let latest_status_snapshot = latest_status_accept
783+ .lock()
784+ .ok()
785+ .map(|status| status.clone());
786+ if let Ok(writer) = stream.try_clone() {
787+ let writer_fd = writer.as_raw_fd();
788+ if let Ok(mut connected) = clients_accept.lock() {
789+ connected.push(writer);
790+ client_fd = Some(writer_fd);
791+ if let Some(last) = connected.last_mut()
792+ && let Some(status) = latest_status_snapshot.as_deref()
793+ && !status.is_empty()
794+ {
795+ let _ = send_status_line(last, status);
796+ }
797+ }
798+ }
682 let event_tx_conn = event_tx_accept.clone();
799 let event_tx_conn = event_tx_accept.clone();
800+ let clients_conn = clients_accept.clone();
683 thread::spawn(move || {
801 thread::spawn(move || {
684 let pid = read_client_pid(&stream);
802 let pid = read_client_pid(&stream);
685 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
803 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
686 wait_for_disconnect(stream);
804 wait_for_disconnect(stream);
805+ if let Some(fd) = client_fd {
806+ remove_client(&clients_conn, fd);
807+ }
687 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
808 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
688 });
809 });
689 }
810 }
@@ -703,43 +824,59 @@ fn run_manager_with(
703 extra_login_actions,
824 extra_login_actions,
704 extra_shares,
825 extra_shares,
705 config.clone(),
826 config.clone(),
706− instance_dir.clone(),
827+ project_root.to_path_buf(),
828+ clients.clone(),
829+ latest_status.clone(),
707 vm_input_tx.clone(),
830 vm_input_tx.clone(),
708 );
831 );
709 tracing::info!("vm manager vm run completed");
832 tracing::info!("vm manager vm run completed");
710 let vm_err = vm_result.err().map(|e| e.to_string());
833 let vm_err = vm_result.err().map(|e| e.to_string());
834+ if let Some(err) = &vm_err {
835+ broadcast_status(
836+ &clients,
837+ &latest_status,
838+ &format!("{STATUS_VM_ERROR_PREFIX} {err}"),
839+ );
840+ }
711 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
841 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
712− let event_loop_result: Result<(), String> = event_loop_handle
842+ let event_loop_result = event_loop_handle
713 .join()
843 .join()
714− .unwrap_or_else(|_| Err("vm manager event loop panicked".into()))
844+ .unwrap_or_else(|_| Err(Error::msg("vm manager event loop panicked")))
715 .map_err(|err| err.to_string());
845 .map_err(|err| err.to_string());
716 let _ = fs::remove_file(&socket_path);
846 let _ = fs::remove_file(&socket_path);
717 if let Err(err) = &event_loop_result {
847 if let Err(err) = &event_loop_result {
718 tracing::error!(error = %err, "vm manager exiting due to event loop error");
848 tracing::error!(error = %err, "vm manager exiting due to event loop error");
719− return Err(err.to_string().into());
849+ bail!(err.to_string());
720 }
850 }
721 if let Some(err) = vm_err {
851 if let Some(err) = vm_err {
722 tracing::error!(error = %err, "vm manager exiting due to vm error");
852 tracing::error!(error = %err, "vm manager exiting due to vm error");
723− return Err(err.into());
853+ bail!(err);
724 }
854 }
725 tracing::info!("vm manager exiting");
855 tracing::info!("vm manager exiting");
726− Ok(event_loop_result?)
856+ event_loop_result.map_err(Error::msg)
727}
857}
728
858
729fn manager_event_loop(
859fn manager_event_loop(
730 event_rx: mpsc::Receiver<ManagerEvent>,
860 event_rx: mpsc::Receiver<ManagerEvent>,
731 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
861 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
732 auto_shutdown_ms: u64,
862 auto_shutdown_ms: u64,
733−) -> Result<(), String> {
863+) -> Result<()> {
734 let mut ref_count: usize = 0;
864 let mut ref_count: usize = 0;
735 let mut shutdown_deadline: Option<Instant> = None;
865 let mut shutdown_deadline: Option<Instant> = None;
736 let mut shutdown_sent = false;
866 let mut shutdown_sent = false;
867+ let mut hard_deadline: Option<Instant> = None;
737 let grace = Duration::from_millis(auto_shutdown_ms.max(1));
868 let grace = Duration::from_millis(auto_shutdown_ms.max(1));
869+ let hard_timeout = Duration::from_millis(HARD_SHUTDOWN_TIMEOUT_MS);
738
870
739 loop {
871 loop {
740− let timeout = match shutdown_deadline {
872+ let timeout = match (shutdown_deadline, hard_deadline) {
873+ (Some(shutdown), Some(hard)) => {
874+ let next = if shutdown <= hard { shutdown } else { hard };
875+ next.saturating_duration_since(Instant::now())
876+ }
741− Some(deadline) => deadline.saturating_duration_since(Instant::now()),
877+ (Some(shutdown), None) => shutdown.saturating_duration_since(Instant::now()),
878+ (None, Some(hard)) => hard.saturating_duration_since(Instant::now()),
742− None => Duration::from_secs(1),
879+ (None, None) => Duration::from_secs(1),
743 };
880 };
744
881
745 match event_rx.recv_timeout(timeout) {
882 match event_rx.recv_timeout(timeout) {
@@ -753,11 +890,10 @@ fn manager_event_loop(
753 );
890 );
754 shutdown_deadline = None;
891 shutdown_deadline = None;
755 shutdown_sent = false;
892 shutdown_sent = false;
893+ hard_deadline = None;
756 }
894 }
757 Ok(ManagerEvent::Dec(pid)) => {
895 Ok(ManagerEvent::Dec(pid)) => {
758− if ref_count > 0 {
759− ref_count -= 1;
896+ ref_count = ref_count.saturating_sub(1);
760− }
761 tracing::info!(
897 tracing::info!(
762 ref_count,
898 ref_count,
763 pid = pid.unwrap_or(0),
899 pid = pid.unwrap_or(0),
@@ -776,15 +912,52 @@ fn manager_event_loop(
776 break;
912 break;
777 }
913 }
778 Err(mpsc::RecvTimeoutError::Timeout) => {
914 Err(mpsc::RecvTimeoutError::Timeout) => {
779− if let Some(deadline) = shutdown_deadline {
915+ if let Some(deadline) = shutdown_deadline
780− if Instant::now() >= deadline && !shutdown_sent {
916+ && Instant::now() >= deadline
917+ && !shutdown_sent
918+ {
919+ if hard_deadline.is_none() {
920+ hard_deadline = Some(Instant::now() + hard_timeout);
921+ }
922+ let mut sent = false;
781− if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
923+ if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
924+ if tx
782− let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
925+ .send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()))
926+ .is_ok()
927+ {
928+ sent = true;
929+ } else {
930+ tracing::warn!("shutdown command failed to send");
783 }
931 }
932+ } else {
933+ tracing::warn!("shutdown command deferred; vm input not ready");
934+ }
935+ if sent {
784 tracing::info!("shutdown command sent");
936 tracing::info!("shutdown command sent");
785 shutdown_sent = true;
937 shutdown_sent = true;
786 shutdown_deadline = None;
938 shutdown_deadline = None;
939+ hard_deadline = Some(Instant::now() + hard_timeout);
940+ } else {
941+ shutdown_deadline =
942+ Some(Instant::now() + Duration::from_millis(SHUTDOWN_RETRY_MS));
943+ }
944+ }
945+ if ref_count == 0
946+ && let Some(deadline) = hard_deadline
947+ && Instant::now() >= deadline
948+ {
949+ if shutdown_sent {
950+ tracing::warn!(
951+ timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
952+ "force exiting: VM did not stop after shutdown timeout"
953+ );
954+ } else {
955+ tracing::warn!(
956+ timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
957+ "force exiting: VM input not ready after shutdown timeout"
958+ );
787 }
959 }
960+ force_exit("vm manager forced exit");
788 }
961 }
789 }
962 }
790 Err(mpsc::RecvTimeoutError::Disconnected) => break,
963 Err(mpsc::RecvTimeoutError::Disconnected) => break,
@@ -793,3 +966,112 @@ fn manager_event_loop(
793
966
794 Ok(())
967 Ok(())
795}
968}
969+
970+#[cfg(test)]
971+mod tests {
972+ use super::*;
973+ use std::{fs, sync::mpsc, thread, time::Duration};
974+
975+ #[test]
976+ fn manager_powers_off_after_grace_when_no_refs() {
977+ let _temp = tempfile::Builder::new()
978+ .prefix("vb")
979+ .tempdir_in("/tmp")
980+ .expect("tempdir");
981+
982+ let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
983+ let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
984+ let vm_input_tx = Arc::new(Mutex::new(Some(vm_tx)));
985+
986+ let manager_thread = thread::spawn(move || {
987+ manager_event_loop(event_rx, vm_input_tx, 50).expect("event loop");
988+ });
989+
990+ event_tx.send(ManagerEvent::Inc(None)).unwrap();
991+ assert!(vm_rx.recv_timeout(Duration::from_millis(100)).is_err());
992+
993+ event_tx.send(ManagerEvent::Dec(None)).unwrap();
994+ let msg = vm_rx
995+ .recv_timeout(Duration::from_secs(2))
996+ .expect("poweroff");
997+ match msg {
998+ VmInput::Bytes(data) => {
999+ assert_eq!(data, b"systemctl poweroff\n");
1000+ }
1001+ _ => panic!("unexpected vm input"),
1002+ }
1003+ let _ = event_tx.send(ManagerEvent::VmExited(None));
1004+ let _ = manager_thread.join();
1005+ }
1006+
1007+ #[test]
1008+ fn manager_force_exits_when_vm_input_never_ready() {
1009+ let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
1010+ let vm_input_tx = Arc::new(Mutex::new(None));
1011+
1012+ let manager_thread = thread::spawn(move || {
1013+ let _ = manager_event_loop(event_rx, vm_input_tx, 10);
1014+ });
1015+
1016+ event_tx.send(ManagerEvent::Inc(None)).unwrap();
1017+ event_tx.send(ManagerEvent::Dec(None)).unwrap();
1018+
1019+ let join_result = manager_thread.join();
1020+ assert!(
1021+ join_result.is_err(),
1022+ "expected manager to force-exit when vm input never becomes ready"
1023+ );
1024+ }
1025+
1026+ #[test]
1027+ fn manager_sends_shutdown_after_vm_input_becomes_ready() {
1028+ let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
1029+ let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
1030+ let vm_input_tx = Arc::new(Mutex::new(None));
1031+ let vm_input_for_thread = vm_input_tx.clone();
1032+
1033+ let manager_thread = thread::spawn(move || {
1034+ manager_event_loop(event_rx, vm_input_for_thread, 10).expect("event loop");
1035+ });
1036+
1037+ event_tx.send(ManagerEvent::Inc(None)).unwrap();
1038+ event_tx.send(ManagerEvent::Dec(None)).unwrap();
1039+
1040+ thread::sleep(Duration::from_millis(100));
1041+ *vm_input_tx.lock().unwrap() = Some(vm_tx);
1042+
1043+ let msg = vm_rx
1044+ .recv_timeout(Duration::from_secs(2))
1045+ .expect("poweroff");
1046+ match msg {
1047+ VmInput::Bytes(data) => {
1048+ assert_eq!(data, b"systemctl poweroff\n");
1049+ }
1050+ _ => panic!("unexpected vm input"),
1051+ }
1052+ let _ = event_tx.send(ManagerEvent::VmExited(None));
1053+ let _ = manager_thread.join();
1054+ }
1055+
1056+ #[test]
1057+ fn lock_is_not_stale_when_owner_pid_is_alive() {
1058+ let temp = tempfile::Builder::new()
1059+ .prefix("vb")
1060+ .tempdir_in("/tmp")
1061+ .expect("tempdir");
1062+ let lock_path = temp.path().join("vm.lock");
1063+ fs::write(&lock_path, format!("pid={}\n", std::process::id())).expect("write lock");
1064+ assert!(!is_lock_stale(&lock_path));
1065+ }
1066+
1067+ #[test]
1068+ fn lock_is_stale_when_owner_pid_is_missing() {
1069+ let temp = tempfile::Builder::new()
1070+ .prefix("vb")
1071+ .tempdir_in("/tmp")
1072+ .expect("tempdir");
1073+ let lock_path = temp.path().join("vm.lock");
1074+ fs::write(&lock_path, "pid=999999\n").expect("write lock");
1075+ assert!(is_lock_stale(&lock_path));
1076+ }
1077+}
tests/e2e_cli.rsnew file+58
@@ -0,0 +1,58 @@
1+use assert_cmd::cargo::cargo_bin_cmd;
2+use tempfile::TempDir;
3+
4+#[test]
5+fn cli_version_shows_binary_name() {
6+ let output = cargo_bin_cmd!("vibebox").arg("--version").output().unwrap();
7+ print_output("e2e_cli", &output);
8+ assert!(
9+ output.status.success(),
10+ "expected success, got status: {}",
11+ output.status
12+ );
13+ let stdout = String::from_utf8_lossy(&output.stdout);
14+ assert!(
15+ stdout.contains("vibebox"),
16+ "expected --version output to contain 'vibebox', got: {}",
17+ stdout
18+ );
19+}
20+
21+#[test]
22+fn list_reports_no_sessions_when_empty() {
23+ let temp = TempDir::new().unwrap();
24+ let home = temp.path().join("home");
25+ let project = temp.path().join("project");
26+ std::fs::create_dir_all(&home).unwrap();
27+ std::fs::create_dir_all(&project).unwrap();
28+
29+ let output = cargo_bin_cmd!("vibebox")
30+ .current_dir(&project)
31+ .env("HOME", &home)
32+ .arg("list")
33+ .output()
34+ .unwrap();
35+ print_output("e2e_cli", &output);
36+ assert!(
37+ output.status.success(),
38+ "expected success, got status: {}",
39+ output.status
40+ );
41+ let stdout = String::from_utf8_lossy(&output.stdout);
42+ assert!(
43+ stdout.contains("No sessions were found."),
44+ "expected empty sessions message, got: {}",
45+ stdout
46+ );
47+}
48+
49+fn print_output(prefix: &str, output: &std::process::Output) {
50+ let stdout = String::from_utf8_lossy(&output.stdout);
51+ for line in stdout.lines() {
52+ println!("[{}] {}", prefix, line);
53+ }
54+ let stderr = String::from_utf8_lossy(&output.stderr);
55+ for line in stderr.lines() {
56+ eprintln!("[{}] {}", prefix, line);
57+ }
58+}
tests/e2e_vm.rsnew file+278
@@ -0,0 +1,278 @@
1+use std::{
2+ fs,
3+ io::{BufRead, BufReader, Read},
4+ path::{Path, PathBuf},
5+ process::{Child, Command, Stdio},
6+ thread,
7+ time::{Duration, Instant},
8+};
9+
10+use tempfile::TempDir;
11+
12+#[cfg(target_os = "macos")]
13+#[test]
14+#[ignore]
15+fn vm_boots_and_runs_command() {
16+ if std::env::var("VIBEBOX_E2E_VM").as_deref() != Ok("1") {
17+ eprintln!("skipping: set VIBEBOX_E2E_VM=1 to run this test");
18+ return;
19+ }
20+ if !virtualization_available() {
21+ eprintln!("[e2e_vm] skipping: virtualization not available on this hardware");
22+ return;
23+ }
24+
25+ let temp = TempDir::new().unwrap();
26+ let home = temp.path().join("home");
27+ let cache_home = temp.path().join("cache");
28+ let project = temp.path().join("project");
29+ fs::create_dir_all(&home).unwrap();
30+ fs::create_dir_all(&cache_home).unwrap();
31+ fs::create_dir_all(&project).unwrap();
32+
33+ write_config(&project);
34+
35+ let mut child = Command::new(assert_cmd::cargo_bin!("vibebox-supervisor"))
36+ .current_dir(&project)
37+ .env("HOME", &home)
38+ .env("XDG_CACHE_HOME", &cache_home)
39+ .env("VIBEBOX_INTERNAL", "1")
40+ .stdout(Stdio::piped())
41+ .stderr(Stdio::piped())
42+ .spawn()
43+ .unwrap();
44+ if let Some(stdout) = child.stdout.take() {
45+ spawn_prefix_reader("e2e_vm", "stdout", stdout);
46+ }
47+ if let Some(stderr) = child.stderr.take() {
48+ spawn_prefix_reader("e2e_vm", "stderr", stderr);
49+ }
50+ let _child_guard = ChildGuard::new(child);
51+
52+ log_line("e2e_vm", "waiting for vm manager socket");
53+ let socket_path = project.join(".vibebox").join("vm.sock");
54+ let _socket_guard = wait_for_socket(&socket_path, Duration::from_secs(30));
55+ log_line("e2e_vm", "vm manager socket ready");
56+
57+ log_line("e2e_vm", "waiting for vm ipv4");
58+ let instance_path = project.join(".vibebox").join("instance.toml");
59+ let (ip, user) = wait_for_vm_ip(&instance_path, Duration::from_secs(180));
60+ log_line("e2e_vm", &format!("vm ipv4={ip} user={user}"));
61+
62+ let ssh_key = project.join(".vibebox").join("ssh_key");
63+ wait_for_file(&ssh_key, Duration::from_secs(30));
64+ log_line("e2e_vm", "ssh key ready");
65+
66+ let output = wait_for_ssh_command(&ssh_key, &user, &ip, Duration::from_secs(90));
67+ print_output("e2e_vm", &output);
68+ let stdout = String::from_utf8_lossy(&output.stdout);
69+ assert!(
70+ stdout.contains("Linux"),
71+ "expected ssh command output to contain 'Linux', got: {}",
72+ stdout
73+ );
74+}
75+
76+#[cfg(not(target_os = "macos"))]
77+#[test]
78+#[ignore]
79+fn vm_boots_and_runs_command() {
80+ eprintln!("skipping: vm e2e test requires macOS virtualization");
81+}
82+
83+struct ChildGuard {
84+ child: Option<Child>,
85+}
86+
87+impl ChildGuard {
88+ fn new(child: Child) -> Self {
89+ Self { child: Some(child) }
90+ }
91+}
92+
93+impl Drop for ChildGuard {
94+ fn drop(&mut self) {
95+ if let Some(child) = &mut self.child {
96+ let _ = child.kill();
97+ let _ = child.wait();
98+ }
99+ }
100+}
101+
102+struct SocketGuard {
103+ _path: PathBuf,
104+ _stream: std::os::unix::net::UnixStream,
105+}
106+
107+fn write_config(project: &Path) {
108+ let config = r#"[box]
109+cpu_count = 2
110+ram_mb = 2048
111+disk_gb = 5
112+mounts = []
113+
114+[supervisor]
115+auto_shutdown_ms = 120000
116+"#;
117+ fs::write(project.join("vibebox.toml"), config).unwrap();
118+}
119+
120+fn wait_for_socket(path: &Path, timeout: Duration) -> SocketGuard {
121+ let start = Instant::now();
122+ loop {
123+ if let Ok(stream) = std::os::unix::net::UnixStream::connect(path) {
124+ return SocketGuard {
125+ _path: path.to_path_buf(),
126+ _stream: stream,
127+ };
128+ }
129+ if start.elapsed() > timeout {
130+ panic!(
131+ "timed out waiting for vm manager socket at {}",
132+ path.display()
133+ );
134+ }
135+ thread::sleep(Duration::from_millis(200));
136+ }
137+}
138+
139+fn wait_for_vm_ip(instance_path: &Path, timeout: Duration) -> (String, String) {
140+ let start = Instant::now();
141+ loop {
142+ if let Ok(raw) = fs::read_to_string(instance_path)
143+ && let Ok(value) = toml::from_str::<toml::Value>(&raw)
144+ {
145+ let ip = value
146+ .get("vm_ipv4")
147+ .and_then(|v| v.as_str())
148+ .map(|s| s.trim().to_string())
149+ .filter(|s| !s.is_empty());
150+ if let Some(ip) = ip {
151+ let user = value
152+ .get("ssh_user")
153+ .and_then(|v| v.as_str())
154+ .map(|s| s.trim().to_string())
155+ .filter(|s| !s.is_empty())
156+ .unwrap_or_else(|| "vibecoder".to_string());
157+ return (ip, user);
158+ }
159+ }
160+ if start.elapsed() > timeout {
161+ panic!(
162+ "timed out waiting for vm_ipv4 in {}",
163+ instance_path.display()
164+ );
165+ }
166+ thread::sleep(Duration::from_millis(500));
167+ }
168+}
169+
170+fn wait_for_file(path: &Path, timeout: Duration) {
171+ let start = Instant::now();
172+ loop {
173+ if path.exists() {
174+ return;
175+ }
176+ if start.elapsed() > timeout {
177+ panic!("timed out waiting for file {}", path.display());
178+ }
179+ thread::sleep(Duration::from_millis(200));
180+ }
181+}
182+
183+fn wait_for_ssh_command(
184+ ssh_key: &Path,
185+ user: &str,
186+ ip: &str,
187+ timeout: Duration,
188+) -> std::process::Output {
189+ let start = Instant::now();
190+ loop {
191+ let output = Command::new("ssh")
192+ .args([
193+ "-i",
194+ ssh_key.to_str().unwrap_or(".vibebox/ssh_key"),
195+ "-o",
196+ "IdentitiesOnly=yes",
197+ "-o",
198+ "StrictHostKeyChecking=no",
199+ "-o",
200+ "UserKnownHostsFile=/dev/null",
201+ "-o",
202+ "GlobalKnownHostsFile=/dev/null",
203+ "-o",
204+ "PasswordAuthentication=no",
205+ "-o",
206+ "BatchMode=yes",
207+ "-o",
208+ "LogLevel=ERROR",
209+ "-o",
210+ "ConnectTimeout=5",
211+ &format!("{user}@{ip}"),
212+ "uname -s",
213+ ])
214+ .output()
215+ .unwrap();
216+ if output.status.success() {
217+ return output;
218+ }
219+ if start.elapsed() > timeout {
220+ let stderr = String::from_utf8_lossy(&output.stderr);
221+ panic!("ssh command failed and timed out: {}", stderr);
222+ }
223+ thread::sleep(Duration::from_millis(1000));
224+ }
225+}
226+
227+fn spawn_prefix_reader(
228+ label: &'static str,
229+ stream: &'static str,
230+ reader: impl Read + Send + 'static,
231+) {
232+ thread::spawn(move || {
233+ let buf = BufReader::new(reader);
234+ for line in buf.lines() {
235+ match line {
236+ Ok(line) => {
237+ println!("[{}][{}] {}", label, stream, line);
238+ }
239+ Err(err) => {
240+ eprintln!("[{}][{}] read error: {}", label, stream, err);
241+ break;
242+ }
243+ }
244+ }
245+ });
246+}
247+
248+fn log_line(prefix: &str, message: &str) {
249+ println!("[{}] {}", prefix, message);
250+}
251+
252+fn print_output(prefix: &str, output: &std::process::Output) {
253+ let stdout = String::from_utf8_lossy(&output.stdout);
254+ for line in stdout.lines() {
255+ println!("[{}] {}", prefix, line);
256+ }
257+ let stderr = String::from_utf8_lossy(&output.stderr);
258+ for line in stderr.lines() {
259+ eprintln!("[{}] {}", prefix, line);
260+ }
261+}
262+
263+fn virtualization_available() -> bool {
264+ let output = Command::new("sysctl")
265+ .args(["-n", "kern.hv_support"])
266+ .output();
267+ match output {
268+ Ok(output) if output.status.success() => {
269+ let value = String::from_utf8_lossy(&output.stdout);
270+ match value.trim() {
271+ "1" => true,
272+ "0" => false,
273+ _ => true,
274+ }
275+ }
276+ _ => true,
277+ }
278+}
tests/e2e_vm_mock.rsnew file+455
@@ -0,0 +1,455 @@
1+#![cfg(all(feature = "mock-vm", target_os = "macos"))]
2+
3+use std::{
4+ fs,
5+ io::{BufRead, BufReader, Read},
6+ os::unix::net::UnixStream,
7+ path::{Path, PathBuf},
8+ process::{Child, Command, Stdio},
9+ sync::mpsc,
10+ thread,
11+ time::{Duration, Instant},
12+};
13+
14+use tempfile::TempDir;
15+
16+#[test]
17+fn mock_vm_allows_refcount_concurrency() {
18+ let temp = TempDir::new().unwrap();
19+ let mut supervisor = spawn_supervisor(&temp, 0, 1200, "e2e_vm_mock".to_string());
20+
21+ supervisor.clients = connect_clients(
22+ &supervisor.socket_path,
23+ 12,
24+ Duration::from_secs(2),
25+ true,
26+ "e2e_vm_mock",
27+ );
28+ log_line("e2e_vm_mock", "connected 12 clients");
29+
30+ assert_manager_alive_for(
31+ &mut supervisor.child,
32+ Duration::from_millis(900),
33+ "vm manager exited while clients active",
34+ );
35+
36+ let remaining = supervisor.clients.split_off(6);
37+ supervisor.clients = remaining;
38+ log_line("e2e_vm_mock", "dropped 6 clients");
39+ assert_manager_alive_for(
40+ &mut supervisor.child,
41+ Duration::from_millis(900),
42+ "vm manager exited while clients active",
43+ );
44+
45+ supervisor.clients.clear();
46+ log_line("e2e_vm_mock", "dropped remaining clients");
47+ wait_for_exit(&mut supervisor.child, Duration::from_secs(10));
48+ let status = supervisor.child.wait().unwrap();
49+ assert!(status.success(), "vm manager exited with {status}");
50+}
51+
52+#[test]
53+fn mock_vm_many_managers_many_clients() {
54+ let temp = TempDir::new().unwrap();
55+ let mut supervisors = Vec::new();
56+
57+ for idx in 0..3 {
58+ supervisors.push(spawn_supervisor(
59+ &temp,
60+ idx + 1,
61+ 1400,
62+ format!("e2e_vm_mock_{idx}"),
63+ ));
64+ }
65+
66+ for supervisor in &mut supervisors {
67+ supervisor.clients = connect_clients(
68+ &supervisor.socket_path,
69+ 8,
70+ Duration::from_secs(2),
71+ true,
72+ "e2e_vm_mock",
73+ );
74+ }
75+ log_line("e2e_vm_mock", "connected 8 clients per manager");
76+
77+ for supervisor in &mut supervisors {
78+ assert_manager_alive_for(
79+ &mut supervisor.child,
80+ Duration::from_millis(900),
81+ "manager exited while clients active",
82+ );
83+ }
84+
85+ supervisors[0].clients.clear();
86+ log_line("e2e_vm_mock", "dropped all clients for manager 0");
87+ wait_for_exit(&mut supervisors[0].child, Duration::from_secs(10));
88+ let status = supervisors[0].child.wait().unwrap();
89+ assert!(status.success(), "manager 0 exited with {status}");
90+
91+ for supervisor in supervisors.iter_mut().skip(1) {
92+ assert_manager_alive_for(
93+ &mut supervisor.child,
94+ Duration::from_millis(900),
95+ "another manager exited early",
96+ );
97+ }
98+
99+ for supervisor in supervisors.iter_mut().skip(1) {
100+ supervisor.clients.clear();
101+ }
102+ log_line("e2e_vm_mock", "dropped remaining clients");
103+ for supervisor in supervisors.iter_mut().skip(1) {
104+ wait_for_exit(&mut supervisor.child, Duration::from_secs(10));
105+ let status = supervisor.child.wait().unwrap();
106+ assert!(status.success(), "manager exited with {status}");
107+ }
108+}
109+
110+#[test]
111+fn mock_vm_monkey_processes() {
112+ let temp = TempDir::new().unwrap();
113+ let mut rng = Lcg::new(0x5eed_f00d_dead_beef);
114+ let mut supervisors = Vec::new();
115+ let mut next_id = 0usize;
116+ let max_supervisors = 4usize;
117+ let steps = 25usize;
118+
119+ supervisors.push(spawn_supervisor(
120+ &temp,
121+ next_id,
122+ 1200,
123+ format!("e2e_vm_monkey_{next_id}"),
124+ ));
125+ next_id += 1;
126+
127+ for step in 0..steps {
128+ prune_exited_supervisors(&mut supervisors, "e2e_vm_monkey");
129+ let roll = rng.gen_range(100);
130+ log_line(
131+ "e2e_vm_monkey",
132+ &format!("step {step} roll={roll} supervisors={}", supervisors.len()),
133+ );
134+ if roll < 20 && supervisors.len() < max_supervisors {
135+ supervisors.push(spawn_supervisor(
136+ &temp,
137+ next_id,
138+ 1200,
139+ format!("e2e_vm_monkey_{next_id}"),
140+ ));
141+ log_line("e2e_vm_monkey", &format!("spawned supervisor {next_id}"));
142+ next_id += 1;
143+ } else if roll < 45 && !supervisors.is_empty() {
144+ let idx = rng.gen_range(supervisors.len());
145+ let mut supervisor = supervisors.swap_remove(idx);
146+ log_line(
147+ "e2e_vm_monkey",
148+ &format!("killing supervisor {}", supervisor.label),
149+ );
150+ kill_supervisor(&mut supervisor, Duration::from_secs(5));
151+ } else if roll < 80 && !supervisors.is_empty() {
152+ let idx = rng.gen_range(supervisors.len());
153+ let burst = 1 + rng.gen_range(3);
154+ let new_clients = connect_clients(
155+ &supervisors[idx].socket_path,
156+ burst,
157+ Duration::from_secs(1),
158+ false,
159+ "e2e_vm_monkey",
160+ );
161+ supervisors[idx].clients.extend(new_clients);
162+ log_line(
163+ "e2e_vm_monkey",
164+ &format!("connected {burst} clients to {}", supervisors[idx].label),
165+ );
166+ } else if !supervisors.is_empty() {
167+ let idx = rng.gen_range(supervisors.len());
168+ if !supervisors[idx].clients.is_empty() {
169+ let len = supervisors[idx].clients.len();
170+ let drop_count = 1 + rng.gen_range(len);
171+ supervisors[idx].clients.drain(0..drop_count.min(len));
172+ log_line(
173+ "e2e_vm_monkey",
174+ &format!(
175+ "dropped {drop_count} clients from {}",
176+ supervisors[idx].label
177+ ),
178+ );
179+ }
180+ }
181+ thread::sleep(Duration::from_millis(200));
182+ }
183+
184+ log_line("e2e_vm_monkey", "final cleanup");
185+ for supervisor in supervisors.iter_mut() {
186+ shutdown_supervisor(supervisor, Duration::from_secs(10));
187+ }
188+}
189+
190+#[test]
191+fn mock_vm_exits_without_clients() {
192+ let temp = TempDir::new().unwrap();
193+ let mut supervisor = spawn_supervisor(&temp, 99, 300, "e2e_vm_no_clients".to_string());
194+ wait_for_exit(&mut supervisor.child, Duration::from_secs(5));
195+ let status = supervisor.child.wait().unwrap();
196+ assert!(status.success(), "vm manager exited with {status}");
197+}
198+
199+#[test]
200+fn mock_vm_reconnect_resets_shutdown() {
201+ let temp = TempDir::new().unwrap();
202+ let mut supervisor = spawn_supervisor(&temp, 100, 800, "e2e_vm_reconnect".to_string());
203+
204+ supervisor.clients = connect_clients(
205+ &supervisor.socket_path,
206+ 1,
207+ Duration::from_secs(2),
208+ true,
209+ "e2e_vm_reconnect",
210+ );
211+ supervisor.clients.clear();
212+ thread::sleep(Duration::from_millis(400));
213+
214+ supervisor.clients = connect_clients(
215+ &supervisor.socket_path,
216+ 1,
217+ Duration::from_secs(2),
218+ true,
219+ "e2e_vm_reconnect",
220+ );
221+ assert_manager_alive_for(
222+ &mut supervisor.child,
223+ Duration::from_millis(600),
224+ "vm manager exited despite reconnect",
225+ );
226+
227+ supervisor.clients.clear();
228+ wait_for_exit(&mut supervisor.child, Duration::from_secs(10));
229+ let status = supervisor.child.wait().unwrap();
230+ assert!(status.success(), "vm manager exited with {status}");
231+}
232+
233+struct Supervisor {
234+ child: Child,
235+ socket_path: PathBuf,
236+ clients: Vec<UnixStream>,
237+ label: String,
238+}
239+
240+fn write_config(project: &Path, auto_shutdown_ms: u64) {
241+ let config = format!(
242+ r#"[box]
243+cpu_count = 2
244+ram_mb = 2048
245+disk_gb = 5
246+mounts = []
247+
248+[supervisor]
249+auto_shutdown_ms = {auto_shutdown_ms}
250+"#
251+ );
252+ fs::write(project.join("vibebox.toml"), config).unwrap();
253+}
254+
255+fn spawn_supervisor(
256+ temp: &TempDir,
257+ idx: usize,
258+ auto_shutdown_ms: u64,
259+ label: String,
260+) -> Supervisor {
261+ let home = temp.path().join(format!("home-{idx}"));
262+ let cache_home = temp.path().join(format!("cache-{idx}"));
263+ let project = temp.path().join(format!("project-{idx}"));
264+ fs::create_dir_all(&home).unwrap();
265+ fs::create_dir_all(&cache_home).unwrap();
266+ fs::create_dir_all(&project).unwrap();
267+ write_config(&project, auto_shutdown_ms);
268+
269+ let mut child = Command::new(assert_cmd::cargo_bin!("vibebox-supervisor"))
270+ .current_dir(&project)
271+ .env("HOME", &home)
272+ .env("XDG_CACHE_HOME", &cache_home)
273+ .env("VIBEBOX_INTERNAL", "1")
274+ .stdout(Stdio::piped())
275+ .stderr(Stdio::piped())
276+ .spawn()
277+ .unwrap();
278+ if let Some(stdout) = child.stdout.take() {
279+ spawn_prefix_reader(label.clone(), "stdout", stdout);
280+ }
281+ if let Some(stderr) = child.stderr.take() {
282+ spawn_prefix_reader(label.clone(), "stderr", stderr);
283+ }
284+
285+ let socket_path = project.join(".vibebox").join("vm.sock");
286+ wait_for_socket(&socket_path, Duration::from_secs(10));
287+
288+ Supervisor {
289+ child,
290+ socket_path,
291+ clients: Vec::new(),
292+ label,
293+ }
294+}
295+
296+fn connect_clients(
297+ socket_path: &Path,
298+ count: usize,
299+ timeout: Duration,
300+ require_all: bool,
301+ label: &str,
302+) -> Vec<UnixStream> {
303+ let (tx, rx) = mpsc::channel();
304+ let mut handles = Vec::with_capacity(count);
305+ for _ in 0..count {
306+ let path = socket_path.to_path_buf();
307+ let tx = tx.clone();
308+ handles.push(thread::spawn(move || {
309+ let stream = connect_client_with_retry(&path, timeout);
310+ let _ = tx.send(stream);
311+ }));
312+ }
313+ drop(tx);
314+ let mut clients = Vec::with_capacity(count);
315+ for stream in rx.into_iter().flatten() {
316+ clients.push(stream);
317+ }
318+ for handle in handles {
319+ handle.join().unwrap();
320+ }
321+ if require_all && clients.len() != count {
322+ panic!(
323+ "client count mismatch: expected {count} got {}",
324+ clients.len()
325+ );
326+ }
327+ if !require_all && clients.len() != count {
328+ log_line(
329+ label,
330+ &format!("connected {} of {count} clients", clients.len()),
331+ );
332+ }
333+ clients
334+}
335+
336+fn connect_client_with_retry(path: &Path, timeout: Duration) -> Option<UnixStream> {
337+ let start = Instant::now();
338+ loop {
339+ match UnixStream::connect(path) {
340+ Ok(stream) => return Some(stream),
341+ Err(_) => {
342+ if start.elapsed() > timeout {
343+ return None;
344+ }
345+ thread::sleep(Duration::from_millis(50));
346+ }
347+ }
348+ }
349+}
350+
351+fn wait_for_socket(path: &Path, timeout: Duration) {
352+ let start = Instant::now();
353+ loop {
354+ if UnixStream::connect(path).is_ok() {
355+ return;
356+ }
357+ if start.elapsed() > timeout {
358+ panic!("timed out waiting for socket {}", path.display());
359+ }
360+ thread::sleep(Duration::from_millis(100));
361+ }
362+}
363+
364+fn assert_manager_alive(child: &mut Child, message: &str) {
365+ assert!(child.try_wait().unwrap().is_none(), "{message}");
366+}
367+
368+fn assert_manager_alive_for(child: &mut Child, duration: Duration, message: &str) {
369+ let start = Instant::now();
370+ while start.elapsed() < duration {
371+ assert_manager_alive(child, message);
372+ thread::sleep(Duration::from_millis(100));
373+ }
374+}
375+
376+fn kill_supervisor(supervisor: &mut Supervisor, timeout: Duration) {
377+ supervisor.clients.clear();
378+ let _ = supervisor.child.kill();
379+ wait_for_exit(&mut supervisor.child, timeout);
380+ let _ = supervisor.child.wait();
381+}
382+
383+fn prune_exited_supervisors(supervisors: &mut Vec<Supervisor>, label: &str) {
384+ supervisors.retain_mut(|supervisor| {
385+ if supervisor.child.try_wait().unwrap().is_some() {
386+ log_line(label, &format!("removed exited {}", supervisor.label));
387+ false
388+ } else {
389+ true
390+ }
391+ });
392+}
393+
394+fn shutdown_supervisor(supervisor: &mut Supervisor, timeout: Duration) {
395+ supervisor.clients.clear();
396+ wait_for_exit(&mut supervisor.child, timeout);
397+ if supervisor.child.try_wait().unwrap().is_none() {
398+ let _ = supervisor.child.kill();
399+ let _ = supervisor.child.wait();
400+ }
401+}
402+
403+fn wait_for_exit(child: &mut Child, timeout: Duration) {
404+ let start = Instant::now();
405+ loop {
406+ if child.try_wait().unwrap().is_some() {
407+ return;
408+ }
409+ if start.elapsed() > timeout {
410+ panic!("timed out waiting for mock vm supervisor exit");
411+ }
412+ thread::sleep(Duration::from_millis(200));
413+ }
414+}
415+
416+fn spawn_prefix_reader(label: String, stream: &'static str, reader: impl Read + Send + 'static) {
417+ thread::spawn(move || {
418+ let buf = BufReader::new(reader);
419+ for line in buf.lines() {
420+ match line {
421+ Ok(line) => println!("[{}][{}] {}", label, stream, line),
422+ Err(err) => {
423+ eprintln!("[{}][{}] read error: {}", label, stream, err);
424+ break;
425+ }
426+ }
427+ }
428+ });
429+}
430+
431+fn log_line(prefix: &str, message: &str) {
432+ println!("[{}] {}", prefix, message);
433+}
434+
435+struct Lcg {
436+ state: u64,
437+}
438+
439+impl Lcg {
440+ fn new(seed: u64) -> Self {
441+ Self { state: seed }
442+ }
443+
444+ fn next_u32(&mut self) -> u32 {
445+ self.state = self.state.wrapping_mul(6364136223846793005).wrapping_add(1);
446+ (self.state >> 32) as u32
447+ }
448+
449+ fn gen_range(&mut self, upper: usize) -> usize {
450+ if upper == 0 {
451+ return 0;
452+ }
453+ (self.next_u32() as usize) % upper
454+ }
455+}
tests/explain_integration.rsnew file+113
@@ -0,0 +1,113 @@
1+use std::{env, ffi::OsString, fs, path::Path, sync::Mutex};
2+
3+use tempfile::TempDir;
4+
5+use vibebox::session_manager::INSTANCE_DIR_NAME;
6+use vibebox::{config, explain};
7+
8+static ENV_MUTEX: Mutex<()> = Mutex::new(());
9+
10+struct EnvGuard {
11+ key: &'static str,
12+ previous: Option<OsString>,
13+}
14+
15+impl EnvGuard {
16+ fn set(key: &'static str, value: &Path) -> Self {
17+ let previous = env::var_os(key);
18+ unsafe {
19+ env::set_var(key, value);
20+ }
21+ Self { key, previous }
22+ }
23+}
24+
25+impl Drop for EnvGuard {
26+ fn drop(&mut self) {
27+ match &self.previous {
28+ Some(value) => unsafe {
29+ env::set_var(self.key, value);
30+ },
31+ None => unsafe {
32+ env::remove_var(self.key);
33+ },
34+ }
35+ }
36+}
37+
38+#[test]
39+fn build_mount_rows_includes_defaults_and_custom_mounts() {
40+ let _lock = ENV_MUTEX.lock().unwrap();
41+ let temp = TempDir::new().unwrap();
42+ let home = temp.path().join("home");
43+ let project = home.join("project");
44+ let cache_home = home.join("cache");
45+ fs::create_dir_all(&project).unwrap();
46+ fs::create_dir_all(&cache_home).unwrap();
47+
48+ let _home_guard = EnvGuard::set("HOME", &home);
49+ let _cache_guard = EnvGuard::set("XDG_CACHE_HOME", &cache_home);
50+
51+ let box_cfg = config::BoxConfig {
52+ mounts: vec!["data:~/data:read-only".to_string()],
53+ ..Default::default()
54+ };
55+ let cfg = config::Config {
56+ box_cfg,
57+ supervisor: config::SupervisorConfig::default(),
58+ };
59+
60+ let rows = explain::build_mount_rows(&project, &cfg).unwrap();
61+
62+ assert_eq!(rows.len(), 3);
63+ assert_eq!(rows[0].host, "~/project");
64+ assert_eq!(rows[0].guest, "~/project");
65+ assert_eq!(rows[0].mode, "read-write");
66+ assert_eq!(rows[0].default_mount, "yes");
67+
68+ assert_eq!(rows[1].host, "~/cache/vibebox/.guest-mise-cache");
69+ assert_eq!(rows[1].guest, "/root/.local/share/mise");
70+ assert_eq!(rows[1].mode, "read-write");
71+ assert_eq!(rows[1].default_mount, "yes");
72+
73+ assert_eq!(rows[2].host, "~/project/data");
74+ assert_eq!(rows[2].guest, "~/data");
75+ assert_eq!(rows[2].mode, "read-only");
76+ assert_eq!(rows[2].default_mount, "no");
77+}
78+
79+#[test]
80+fn build_network_rows_pending_without_instance_file() {
81+ let temp = TempDir::new().unwrap();
82+ let project = temp.path().join("project");
83+ fs::create_dir_all(&project).unwrap();
84+
85+ let rows = explain::build_network_rows(&project).unwrap();
86+
87+ assert_eq!(rows.len(), 1);
88+ assert_eq!(rows[0].network_type, "NAT");
89+ assert_eq!(rows[0].vm_ip, "-");
90+ assert_eq!(rows[0].host_to_vm, "ssh: <pending>:22");
91+ assert_eq!(rows[0].vm_to_host, "none");
92+}
93+
94+#[test]
95+fn build_network_rows_uses_instance_vm_ip() {
96+ let temp = TempDir::new().unwrap();
97+ let project = temp.path().join("project");
98+ let instance_dir = project.join(INSTANCE_DIR_NAME);
99+ fs::create_dir_all(&instance_dir).unwrap();
100+ fs::write(
101+ instance_dir.join("instance.toml"),
102+ "vm_ipv4 = \"10.1.2.3\"\n",
103+ )
104+ .unwrap();
105+
106+ let rows = explain::build_network_rows(&project).unwrap();
107+
108+ assert_eq!(rows.len(), 1);
109+ assert_eq!(rows[0].network_type, "NAT");
110+ assert_eq!(rows[0].vm_ip, "10.1.2.3");
111+ assert_eq!(rows[0].host_to_vm, "ssh: 10.1.2.3:22");
112+ assert_eq!(rows[0].vm_to_host, "none");
113+}
vibebox.toml+1
@@ -1,6 +1,7 @@
1[box]
1[box]
2cpu_count = 2
2cpu_count = 2
3ram_mb = 2048
3ram_mb = 2048
4+disk_gb = 5
4mounts = [
5mounts = [
5 "~/.codex:~/.codex:read-write",
6 "~/.codex:~/.codex:read-write",
6 "~/.claude:~/.claude:read-write",
7 "~/.claude:~/.claude:read-write",