39 Commits

Author SHA1 Message Date
robcholz 423503b27d feat: new version 2026-02-18 12:28:21 -05:00
Finn Sheng 23726d7420 Refactor (#10)
* refactor: cleanup duplicated logic

* refactor: cleanup config.rs

* refactor: cleanup explain.rs

* refactor: cleanup instance.rs

* refactor: cleanup

* refactor: use UnixStream instead of status file

* Refactor vm lifetime (#8)

* fix: handle vm supervisor being killed

* fix: fixed the loop connection retry

* refactor: extracted vm_manager liveness check logic

* Script failure report (#9)

* feat: added script failure report

* feat: vm error report can also report ssh.sh

* refactor: liveness check when connecting to ssh

* fix: fixed the wrong InstanceError::VMError

* fix: fixed the is_lock_stable
2026-02-15 18:09:38 -05:00
robcholz e1d484ee9d fix: now fixed the invalid password issue 2026-02-10 17:08:37 -05:00
robcholz 1201c311e0 feat: new version, added provision.log, and fix the provision bug 2026-02-09 02:21:22 -05:00
robcholz 8669deb078 fix: try to fix provision.sh 2026-02-09 02:18:22 -05:00
robcholz 4d1529905e feat: new version more robust network handle 2026-02-09 01:52:33 -05:00
robcholz a568295bd3 fix: more robust network handling 2026-02-09 01:52:06 -05:00
robcholz b5cd1f2064 feat: added provision error propagation 2026-02-09 01:48:20 -05:00
robcholz b425ae4b77 feat: version 0.2.4 2026-02-09 01:16:56 -05:00
robcholz b433d3ef93 fix: handled edge network issue when doing provisioning 2026-02-09 01:16:20 -05:00
robcholz ecfce7acf7 fix: enforce shutdown timeout when vm input never becomes ready 2026-02-09 00:59:16 -05:00
robcholz 7065144e6f doc: update 2026-02-09 00:28:20 -05:00
robcholz 5e95c09c75 doc: changes 2026-02-08 20:40:30 -05:00
robcholz b1680e54fb doc: changes 2026-02-08 20:09:43 -05:00
robcholz 0e4c4c7f53 doc: changes 2026-02-08 20:00:17 -05:00
Finn Sheng 65bfc0b34d feat: new version, better timeout management (#7) 2026-02-08 19:05:28 -05:00
Finn Sheng f6678e7069 feat: added e2e (#5)
* feat: added e2e

* fix: try fix

* fix: try fix

* fix: try fix

* fix: added more observability

* ci: fixed double trigger

* feat: partial e2e test with mock vm

* feat: more monkey tests

* feat: added coverage
2026-02-08 18:50:35 -05:00
Finn Sheng eafa229542 feat: now it has more timeout and better log (#6) 2026-02-08 17:54:22 -05:00
robcholz 34d0fb965e doc: changed 2026-02-08 15:52:03 -05:00
robcholz a3764a361e doc: changed 2026-02-08 05:03:57 -05:00
robcholz 51c3eff6e5 doc: changed 2026-02-08 03:07:11 -05:00
robcholz 4cb1162ca3 feat: fixed race condition, new version 2026-02-08 02:55:43 -05:00
robcholz 4c9f517107 doc: changed style 2026-02-08 02:54:52 -05:00
robcholz 15245fd245 fix: fixed the race condition brought by shutdown triggerred while vm input channel is not ready or closed 2026-02-08 02:51:35 -05:00
robcholz 1c5a464a68 doc: added zh-CN 2026-02-08 02:32:47 -05:00
robcholz 5659f2a538 fix: now project dir correctly mounted 2026-02-08 02:25:12 -05:00
robcholz 9cf33359f9 doc: updated README.md 2026-02-08 02:02:59 -05:00
robcholz a1056ba5cb doc: added readme and contributing.md 2026-02-08 01:53:51 -05:00
robcholz ad8fb139de feat: new version 2026-02-08 00:38:21 -05:00
robcholz 5e008c9471 fix: fixed the annoying dhcp conflict when having concurrent vm 2026-02-08 00:34:01 -05:00
robcholz 935e0cd566 fix: mise install fail will not fail the ssh 2026-02-08 00:32:29 -05:00
robcholz c53a6eff51 ci: added tests 2026-02-07 23:58:10 -05:00
robcholz 4dd9c31f74 refactor: now code and claude becomes default mounts 2026-02-07 23:50:25 -05:00
robcholz 9dd88dd304 fix: update to v0.2.0 2026-02-07 23:25:26 -05:00
Finn Sheng 4311168fdd Merge pull request #4 from robcholz/disk-support
Disk support
2026-02-07 23:22:59 -05:00
robcholz a4b9d62873 feat: now added warn for disk size mismatch 2026-02-07 23:16:12 -05:00
robcholz 727adffb4c feat: added disk support 2026-02-07 23:05:52 -05:00
robcholz 75adb1696a refactor: make install noninteractive terminal friendly 2026-02-07 22:25:26 -05:00
robcholz 57ebe7ffee refactor: added banner source 2026-02-07 22:14:20 -05:00
31 changed files with 3495 additions and 815 deletions
+43 -1
View File
@@ -6,9 +6,10 @@ on:
- "**"
paths-ignore:
- "README.md"
- "CONTRIBUTING.md"
- "README.zh.md"
- "docs/**"
- "install"
pull_request:
concurrency:
group: ci-${{ github.ref }}
@@ -18,6 +19,8 @@ jobs:
fmt:
name: Format
runs-on: macos-latest
env:
RUST_BACKTRACE: "1"
steps:
- uses: actions/checkout@v6
- name: Install Rust
@@ -32,6 +35,8 @@ jobs:
clippy:
name: Clippy
runs-on: macos-latest
env:
RUST_BACKTRACE: "1"
steps:
- uses: actions/checkout@v6
- name: Install Rust
@@ -46,6 +51,8 @@ jobs:
build:
name: Build
runs-on: macos-latest
env:
RUST_BACKTRACE: "1"
steps:
- uses: actions/checkout@v6
- name: Install Rust
@@ -54,3 +61,38 @@ jobs:
uses: Swatinem/rust-cache@v2
- name: cargo build
run: cargo build --locked
test:
name: Test
runs-on: macos-latest
env:
RUST_BACKTRACE: "full"
steps:
- uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust
uses: Swatinem/rust-cache@v2
- name: cargo test
run: cargo test --locked
coverage:
name: Coverage
runs-on: macos-latest
needs: [fmt, clippy, build, test]
env:
RUST_BACKTRACE: "full"
steps:
- uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- name: Cache Rust
uses: Swatinem/rust-cache@v2
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@v2
with:
tool: cargo-llvm-cov
- name: cargo llvm-cov
run: cargo llvm-cov --locked --features mock-vm --tests -- --nocapture
+44
View File
@@ -0,0 +1,44 @@
# Contributing to Vibebox
Thanks for your interest in contributing! This guide keeps PRs small, reviewable, and consistent with the projects
development workflow.
**Prerequisites**
- macOS on Apple Silicon (required for the virtualization backend)
- Rust `1.91.1` or newer (see `Cargo.toml`)
**Getting Started**
1. Fork the repo and create a feature branch.
2. Build once to validate your toolchain:
```bash
cargo build --locked
```
**Development Commands**
- Format: `cargo fmt --all -- --check`
- Lint: `cargo clippy --all-targets --all-features -- -D warnings`
- Test: `cargo test --locked`
- Build: `cargo build --locked`
**Submitting Changes**
- Keep changes focused and scoped to one problem.
- Update or add tests when behavior changes.
- If you change user-facing behavior, update docs or help text.
- Avoid adding heavy dependencies without a clear reason.
**Reporting Issues**
Please include:
- macOS version and hardware (Apple Silicon model)
- Vibebox version (`vibebox --version`)
- Steps to reproduce
- Logs from `.vibebox/cli.log`, `.vibebox/vm_root.log` and `.vibebox/vm_manager.log`
**Security**
If you believe youve found a security issue, please avoid public disclosure. Open a private report via GitHub Security
Advisories instead.
Generated
+93 -1
View File
@@ -82,6 +82,27 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]]
name = "assert_cmd"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514"
dependencies = [
"anstyle",
"bstr",
"libc",
"predicates",
"predicates-core",
"predicates-tree",
"wait-timeout",
]
[[package]]
name = "backtrace"
version = "0.3.76"
@@ -112,12 +133,32 @@ dependencies = [
"objc2",
]
[[package]]
name = "bstr"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
dependencies = [
"memchr",
"regex-automata",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]]
name = "bytesize"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3"
dependencies = [
"serde_core",
]
[[package]]
name = "cassowary"
version = "0.3.0"
@@ -321,6 +362,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "difflib"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
[[package]]
name = "dispatch2"
version = "0.3.0"
@@ -737,6 +784,33 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "predicates"
version = "3.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
dependencies = [
"anstyle",
"difflib",
"predicates-core",
]
[[package]]
name = "predicates-core"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
[[package]]
name = "predicates-tree"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
dependencies = [
"predicates-core",
"termtree",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -1007,6 +1081,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "termtree"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
[[package]]
name = "thiserror"
version = "2.0.18"
@@ -1238,9 +1318,12 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vibebox"
version = "0.1.1"
version = "0.3.2"
dependencies = [
"anyhow",
"assert_cmd",
"block2",
"bytesize",
"clap",
"color-eyre",
"crossterm",
@@ -1261,6 +1344,15 @@ dependencies = [
"uuid",
]
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
+10 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "vibebox"
version = "0.1.1"
version = "0.3.2"
edition = "2024"
authors = ["Finn Sheng"]
description = "Ultrafast CLI on Apple Silicon macOS for fast, sandboxed development and LLM agents."
@@ -42,3 +42,12 @@ ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dialoguer = "0.12.0"
bytesize = {version = "2.3.1",features = ["serde"]}
anyhow = "1.0.101"
[dev-dependencies]
assert_cmd = "2"
tempfile = "3"
[features]
mock-vm = []
+223
View File
@@ -0,0 +1,223 @@
<p align="center">
<a href="https://vibebox.robcholz.com">
<picture>
<img src="docs/banner.png" alt="VibeBox logo">
</picture>
</a>
</p>
<p align="center">an ultrafast, open-source sandbox for running coding agents safely.</p>
<p align="center">
<a href="https://crates.io/crates/vibebox">
<img alt="Crates.io" src="https://img.shields.io/crates/v/vibebox.svg">
</a>
<a href="https://github.com/robcholz/vibebox/blob/main/LICENSE">
<img alt="MIT licensed" src="https://img.shields.io/badge/license-MIT-blue.svg">
</a>
<a href="https://github.com/robcholz/vibebox/actions?query=workflow%3ACI+branch%3Amain">
<img alt="Build Status" src="https://github.com/robcholz/vibebox/workflows/CI/badge.svg">
</a>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh.md">简体中文</a>
</p>
**VibeBox is a per-project micro-VM sandbox for running coding agents on macOS (Apple Virtualization Framework).**
Its optimized for a *daily-driver* workflow: fast warm re-entry, explicit mounts, and reusable sessions.
**Who its for:** macOS users running coding agents who want real isolation without giving up a fast daily workflow.
**Quick facts:** warm re-entry is typically **<5s** on my M3 (varies by machine/cache); first run downloads and
provisions a Debian base image (network dependent).
**Security model:** Linux guest VM with explicit mount allowlists from `vibebox.toml` (repo-first, everything else
opt-in).
- **enter/attach in seconds:** `vibebox` drops you into a reusable sandbox for the current repo
- **project-scoped by default:** explicit mounts + repo-contained changes (repo-first, everything else is allowlisted)
- **sessioned:** multi-instance + session management (reuse, multiple terminals, cleanup)
### Quick Demo
```bash
# from any repo
cd my-project
vibebox
```
What you should see (roughly):
```text
vibebox: starting (session: my-project)
vibebox: attaching...
vibecoder@vibebox:~/my-project$
```
[![VibeBox Terminal UI](docs/screenshot.png)](https://vibebox.robcholz.com)
---
### Why I built VibeBox
I use coding agents daily, and I wanted to give them a real shell without handing them my host machine.
Lock things down and you get nonstop confirmations; loosen it up and you worry about deleting files, touching secrets,
or wandering outside the repo.
VibeBox is the middle ground: a per-repo sandbox with a hard VM boundary, fast re-entry, and explicit mounts. Its built
to be “always on” for agent work without turning safety into a chore.
### Why a micro-VM (vs containers)?
Containers are great. VibeBox isnt trying to replace Docker/devcontainers for building services.
I specifically wanted a VM-shaped default for agent workflows on macOS:
- **guest-kernel isolation boundary by default:** when Im letting an agent run arbitrary commands, I want “safe mode”
to
be a Linux guest, not my host.
- **sessions as a first-class workflow:** attach/reuse per repo, multiple terminals into the same sandbox, reliable
cleanup to avoid orphan environments.
- **explicit mount allowlists as the primary UX:** repo-scoped by default; anything else is an explicit decision.
- **minimal per-repo setup:** you *can* reproduce parts of this with compose/devcontainers, but I wanted a single
command
that works repo-to-repo without maintaining container configs for the basic “safe shell” workflow.
### Comparison
Heres why I didnt just use existing options:
- **vibe**: super convenient and nails “zero-config, just go”. VibeBox is intentionally on a different axis: per-repo
config + sessions + multi-instance lifecycle.
- **QEMU**: powerful, but the configuration surface area is huge. For day-to-day sandboxing its not “open a repo and
go” — its a project on its own.
- **Docker / devcontainers / devpods**: great ecosystem. My friction wasnt raw startup time, it was the day-to-day
overhead of keeping per-repo agent sandboxes *safe-by-default* (mount allowlists, secrets exposure, attach/reuse,
cleanup) without maintaining container configs per repo for the basic workflow.
Thats what pushed me to build **VibeBox**: I wanted a per-project sandbox thats fast to enter (just `vibebox`),
supports real configuration + sessions, and keeps a hard isolation boundary.
### Installation
```bash
# install script
curl -fsSL https://raw.githubusercontent.com/robcholz/vibebox/main/install | bash
# package managers
cargo install vibebox
# manual install
curl -LO https://github.com/robcholz/vibebox/releases/download/latest/vibebox-macos-arm64.zip
unzip vibebox-macos-arm64.zip
mkdir -p ~/.local/bin
mv vibebox ~/.local/bin
export PATH="$HOME/.local/bin:$PATH"
```
**Requirements**
- macOS on Apple Silicon (VibeBox uses Apple's virtualization APIs).
**First Run**
The first `vibebox` run downloads a Debian base image and provisions it. After that, per-project instances reuse the
cached base image for much faster startups.
### Documentation
**Quick Start**
```bash
cd /path/to/your/project
vibebox
```
On first run, VibeBox creates `vibebox.toml` in your project (if missing) and a `.vibebox/` directory for instance data.
**Configuration (`vibebox.toml`)**
`vibebox.toml` lives in your project root by default. You can override it with `vibebox -c path/to/vibebox.toml` or the
`VIBEBOX_CONFIG_PATH` env var, but the path must stay inside the project directory.
Default config (auto-created when missing):
```toml
[box]
cpu_count = 2
ram_mb = 2048
disk_gb = 5
mounts = [
"~/.codex:~/.codex:read-write",
"~/.claude:~/.claude:read-write",
]
[supervisor]
auto_shutdown_ms = 20000
```
`disk_gb` is only applied when the instance disk is first created. If you change it later, run `vibebox reset` to
recreate the disk.
**Mounts**
- Your project is mounted read-write at `~/<project-name>`, and the shell starts there.
- If a `.git` directory exists, it is masked with a tmpfs mount inside the VM to discourage accidental edits from the
guest.
- Extra mounts come from `box.mounts` with the format `host:guest[:read-only|read-write]`.
- Host paths support `~` expansion. Relative guest paths are treated as `/root/<path>`.
- Guest paths that use `~` are linked into `/home/<ssh-user>` for convenience. Run `vibebox explain` to see the resolved
host/guest mappings.
**CLI Commands**
```bash
vibebox # start or attach to the current project VM
vibebox list # list known project sessions
vibebox reset # delete .vibebox for this project and recreate on next run
vibebox purge-cache # delete the global cache (~/.cache/vibebox)
vibebox explain # show mounts and network info
```
**Inside the VM**
- Default SSH user: `vibecoder`
- Hostname: `vibebox`
- Base image provisioning installs: build tools, `git`, `curl`, `ripgrep`, `openssh-server`, and `sudo`.
- On first login, VibeBox installs `mise` and configures tools like `uv`, `node`, `@openai/codex`, and
`@anthropic-ai/claude-code` (best-effort).
- Shell aliases: `:help` and `:exit`.
**State & Cache**
- Project state lives in `.vibebox/` (instance disk, SSH keys, logs, manager socket/pid). `vibebox reset` removes it.
- Global cache lives in `~/.cache/vibebox` (base image + shared guest cache). `vibebox purge-cache` clears it.
- Session index lives in `~/.vibebox/sessions` and is shown by `vibebox list`.
### Contributing
If you're interested in contributing to VibeBox, please read our [contributing docs](CONTRIBUTING.md) before
submitting a pull request.
### FAQ
#### How is this different from other sandboxes?
VibeBox is built for fast, repeatable local sandboxes with minimal ceremony. Whats different here:
- Warm re-entry is typically **<5s** on my M3 (varies by machine/cache), so you can jump back in quickly.
- One simple command — `vibebox` — drops you into the sandbox from your project.
- Configuration lives in `vibebox.toml`, where you can set CPU, RAM, disk size, and mounts.
- Sessions are first-class: reuse, multiple terminals, cleanup.
### Special thanks
[vibe](https://github.com/lynaghk/vibe) by lynaghk.
And the amazing Rust community — without the ecosystem and toolchain like [crates.io](https://crates.io), this wouldn't
be possible!
---
**Follow me on X** [x.com/robcholz](https://x.com/robcholz)
+209
View File
@@ -0,0 +1,209 @@
<p align="center">
<a href="https://vibebox.robcholz.com">
<picture>
<img src="docs/banner.png" alt="VibeBox logo">
</picture>
</a>
</p>
<p align="center">用于安全运行 coding agents 的超高速开源沙盒。</p>
<p align="center">
<a href="https://crates.io/crates/vibebox">
<img alt="Crates.io" src="https://img.shields.io/crates/v/vibebox.svg">
</a>
<a href="https://github.com/robcholz/vibebox/blob/main/LICENSE">
<img alt="MIT licensed" src="https://img.shields.io/badge/license-MIT-blue.svg">
</a>
<a href="https://github.com/robcholz/vibebox/actions?query=workflow%3ACI+branch%3Amain">
<img alt="Build Status" src="https://github.com/robcholz/vibebox/workflows/CI/badge.svg">
</a>
</p>
<p align="center">
<a href="README.zh.md">简体中文</a> |
<a href="README.md">English</a>
</p>
**VibeBox 是一个按项目划分的 micro-VM 沙盒,用于在 macOS 上运行 coding agents(基于 Apple Virtualization Framework)。**
它面向 *日常使用* 工作流优化:快速热启动、显式挂载、可复用会话。
**适合谁:** 在 macOS 上使用 coding agents,并且既想要真实隔离又不想牺牲日常效率的人。
**快速事实:** 在我的 M3 上,热启动通常 **<5s**(因机器/缓存而异);首次运行会下载并初始化 Debian 基础镜像(受网络影响)。
**安全模型:** Linux 来宾 VM + `vibebox.toml` 显式挂载白名单(默认仅项目目录,其它均需显式允许)。
- **几秒进入/附加:** `vibebox` 直接进入当前仓库的可复用沙盒
- **默认按项目范围:** 显式挂载 + 改动限制在仓库内(repo 优先,其他需 allowlist
- **会话化:** 多实例 + 会话管理(复用、多终端、清理)
### 快速演示
```bash
# 在任意仓库内
cd my-project
vibebox
```
你大致会看到:
```text
vibebox: starting (session: my-project)
vibebox: attaching...
vibecoder@vibebox:~/my-project$
```
[![VibeBox Terminal UI](docs/screenshot.png)](https://vibebox.robcholz.com)
---
### 我为什么做 VibeBox
我每天都在用 coding agents,也希望它们有一个真实的 shell,但不想把宿主机直接交出去。
权限收紧会被不停的确认打断;权限放开又担心误删文件、触及密钥,或者跑出仓库边界。
VibeBox 是中间方案:按项目隔离、硬 VM 边界、快速回到工作状态、显式挂载。它适合把 agent 当成日常工具,而不是把安全变成负担。
### 为什么是 micro-VM(而不是容器)?
容器很好用。VibeBox 并不是用来替代 Docker/devcontainers 去构建服务。
我更想要的是在 macOS 上适合 agent 的 VM 默认形态:
- **默认是 guest-kernel 隔离边界:** 让 agent 跑任意命令时,“安全模式”是 Linux 来宾而不是宿主机。
- **会话是第一等公民:** 按项目附加/复用,多终端进入同一沙盒,可靠清理,避免孤儿环境。
- **显式挂载白名单作为主要 UX:** 默认仅项目目录,其它都需显式允许。
- **最少的每项目配置:** 你可以用 compose/devcontainers 实现一部分,但我想要的是一个命令,在不同仓库间直接工作,不必维护容器配置来获得基础“安全
shell”体验。
### 对比
下面是我为什么没有直接用现成方案的原因:
- **vibe**:非常方便,“零配置、直接用”做得很好。但 VibeBox 走的是另一条路:按项目配置 + 会话 + 多实例生命周期。
- **QEMU**:很强大,但配置面太大了。日常当沙箱用,它不像是“进到 repo 就能用”,更像是你得先把它当成一个项目来折腾。
- **Docker / devcontainers / devpods**:生态很成熟。我的痛点不在启动时间,而是日常保持
safe-by-default(挂载白名单、密钥暴露、附加/复用、清理)的开销,不想为基础 workflow 在每个项目维护容器配置。
这就是我做 **VibeBox** 的原因:我想要一个按项目隔离的沙箱,进入快(直接 `vibebox`),支持真实配置 + 会话,同时保持硬隔离边界。
### 安装
```bash
# YOLO:一键安装
curl -fsSL https://raw.githubusercontent.com/robcholz/vibebox/main/install | bash
# Cargo
cargo install vibebox
# 或者手动安装
curl -LO https://github.com/robcholz/vibebox/releases/download/latest/vibebox-macos-arm64.zip
unzip vibebox-macos-arm64.zip
mkdir -p ~/.local/bin
mv vibebox ~/.local/bin
export PATH="$HOME/.local/bin:$PATH"
```
**系统要求**
- Apple Silicon 的 macOSVibeBox 使用了 Apple 的虚拟化 API)。
**首次运行**
第一次执行 `vibebox` 会下载 Debian 基础镜像并完成初始化。之后每个项目的实例会复用缓存的基础镜像,
启动会快很多。
### 文档
**快速开始**
```bash
cd /path/to/your/project
vibebox
```
第一次运行时,如果项目目录里缺少配置,VibeBox 会自动创建 `vibebox.toml`(放在项目根目录),并创建
`.vibebox/` 用来保存实例数据。
**配置(`vibebox.toml`**
默认情况下,`vibebox.toml` 位于项目根目录。你可以用 `vibebox -c path/to/vibebox.toml` 或设置
`VIBEBOX_CONFIG_PATH` 环境变量来覆盖路径,但配置文件必须仍然位于项目目录内部。
默认配置(缺失时会自动生成):
```toml
[box]
cpu_count = 2
ram_mb = 2048
disk_gb = 5
mounts = [
"~/.codex:~/.codex:read-write",
"~/.claude:~/.claude:read-write",
]
[supervisor]
auto_shutdown_ms = 20000
```
注意:`disk_gb` 只在「首次创建实例磁盘」时生效。之后如果你改了它,需要运行 `vibebox reset` 重新创建磁盘。
**挂载(Mounts**
- 你的项目会以读写方式挂载到 `~/<project-name>`,并且 shell 会默认从那里启动。
- 如果项目里存在 `.git` 目录,VM 内会用 tmpfs 把它遮住,避免你在 guest 里误操作改到 Git 元数据。
- 额外挂载通过 `box.mounts` 配置,格式为 `host:guest[:read-only|read-write]`
- Host 路径支持 `~` 展开;guest 的相对路径会被视为 `/root/<path>`
- guest 路径如果用了 `~`,会为了方便被链接到 `/home/<ssh-user>` 下。你可以运行 `vibebox explain`
查看最终解析后的 host/guest 映射关系。
**CLI 命令**
```bash
vibebox # 启动或连接当前项目的 VM
vibebox list # 列出已知的项目会话
vibebox reset # 删除当前项目的 .vibebox,下一次运行会重新创建
vibebox purge-cache # 删除全局缓存(~/.cache/vibebox
vibebox explain # 显示挂载与网络信息
```
**在 VM 内部**
- 默认 SSH 用户:`vibecoder`
- 主机名:`vibebox`
- 基础镜像初始化会安装:构建工具、`git``curl``ripgrep``openssh-server``sudo`
- 首次登录时,VibeBox 会安装 `mise`,并尽力配置 `uv``node``@openai/codex`
`@anthropic-ai/claude-code` 等工具(best-effort,视网络和环境而定)
- Shell 里有两个别名:`:help``:exit`
**状态与缓存**
- 项目级状态在 `.vibebox/`(实例磁盘、SSH key、日志、manager socket/pid)。`vibebox reset` 会移除它。
- 全局缓存在 `~/.cache/vibebox`(基础镜像 + 共享 guest 缓存)。`vibebox purge-cache` 会清空它。
- 会话索引在 `~/.vibebox/sessions`,可以通过 `vibebox list` 查看。
### 参与贡献
如果你想参与贡献 VibeBox,请先阅读 [贡献指南](CONTRIBUTING.md),再提交 Pull Request。
### FAQ
#### 它和其它 Sandboxes 有什么不同?
VibeBox 追求的是:本地、可复现、启动快、流程简单。主要差异点:
- 在我的 M3 上,热启动通常 **<5s**,可以非常快地回到工作状态。
- 一个命令——`vibebox`——直接把你带进沙盒(从你的项目目录启动)。
- 配置集中在 `vibebox.toml`,CPU / 内存 / 磁盘大小 / 挂载都能一眼看懂、随手改。
- 会话是第一等公民:复用、多终端、清理。
### 特别鸣谢
[vibe](https://github.com/lynaghk/vibe) by lynaghk。
以及 Rust 社区。没有你们丰富的 crates 生态和优秀的工具链(比如 [crates.io](https://crates.io)),
这个项目不可能这么顺利!Rust教。
---
**在 X 上关注我** [X.com](https://x.com/robcholz)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+14 -9
View File
@@ -25,7 +25,7 @@
5. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar.
6. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio.
7. [x] Add a standalone TUI CLI binary (no main.rs wiring) with placeholder VM info and TODOs for VM integration.
8. [ ] Run tests and validate coverage for the new module.
8. [x] Run tests and validate coverage for the new module.
## TUI
@@ -49,18 +49,23 @@
4. [x] set up the cli.
9. [x] fix ui overlap, and consistency issue.
10. [x] `purge-cache` to clear the cache.
11. [ ] intensive integration test.
11. [x] intensive integration test.
## Publish
1. [ ] write the docs.
2. [ ] setup quick install link.
3. [ ] setup website.
1. [x] write the docs.
2. [x] setup quick install link.
3. [x] setup website.
## Stage 2
1. [ ] retouch the cli ux.
2. [ ] refactor the code.
3. [ ] Redirect vm output to log.
4. [ ] Redirect vm output to vibebox starting it.
5. [ ] use anyhow to sync api.
2. [x] refactor the code.
3. [ ] refactor the mount system.
4. [x] refactor the vm process lifetime.
5. [x] Redirect vm output to log.
6. [x] Redirect vm output to vibebox starting it.
7. [x] use anyhow to sync api.
8. [ ] add support for ipv6.
9. [x] use UnixStream instead of status file
10. [x] liveness check should also happen when waiting for ssh port
+26 -22
View File
@@ -47,36 +47,40 @@ ensure_cargo() {
print_message warning "Rust (cargo) is required but not found."
print_message info "You should review and approve the Rust installer before proceeding."
reply=""
if [[ -t 0 ]]; then
read -r -p "Install Rust using rustup now? (y/N) " reply
case "${reply}" in
y|Y)
if command -v curl >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
elif command -v wget >/dev/null 2>&1; then
wget -qO- https://sh.rustup.rs | sh -s -- -y
else
print_message error "Missing required command: curl or wget"
print_message info "Install Rust manually from https://rustup.rs and retry."
exit 1
fi
# shellcheck source=/dev/null
if [[ -f "$HOME/.cargo/env" ]]; then
# shellcheck disable=SC1090
source "$HOME/.cargo/env"
fi
;;
*)
print_message info "Install Rust manually from https://rustup.rs and retry."
exit 1
;;
esac
elif [[ -r /dev/tty ]]; then
read -r -p "Install Rust using rustup now? (y/N) " reply </dev/tty
else
print_message error "Non-interactive shell: cannot prompt to install Rust."
print_message info "Install Rust manually from https://rustup.rs and retry."
exit 1
fi
case "${reply}" in
y|Y)
if command -v curl >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
elif command -v wget >/dev/null 2>&1; then
wget -qO- https://sh.rustup.rs | sh -s -- -y
else
print_message error "Missing required command: curl or wget"
print_message info "Install Rust manually from https://rustup.rs and retry."
exit 1
fi
# shellcheck source=/dev/null
if [[ -f "$HOME/.cargo/env" ]]; then
# shellcheck disable=SC1090
source "$HOME/.cargo/env"
fi
;;
*)
print_message info "Install Rust manually from https://rustup.rs and retry."
exit 1
;;
esac
if ! command -v cargo >/dev/null 2>&1; then
print_message error "Cargo still not available after installation."
print_message info "Open a new shell or run: source \"$HOME/.cargo/env\""
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --all
cargo build --all-targets
+8 -6
View File
@@ -21,14 +21,16 @@ fn main() -> Result<()> {
color_eyre::install()?;
tracing::info!("starting vm supervisor");
let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
let config = config::load_config(&cwd);
let instance_dir = instance::ensure_instance_dir(&cwd)
let project_dir = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
let config = config::load_config(&project_dir)
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
let _ = instance::touch_last_active(&instance_dir);
let _ = instance::ensure_instance_dir(&project_dir)
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
let _ = instance::touch_last_active(&project_dir);
let args = vm::VmArg {
cpu_count: config.box_cfg.cpu_count,
ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
ram_bytes: config.box_cfg.ram_size.as_u64(),
disk_bytes: config.box_cfg.disk_size.as_u64(),
no_default_mounts: false,
mounts: config.box_cfg.mounts.clone(),
};
@@ -36,7 +38,7 @@ fn main() -> Result<()> {
tracing::info!(auto_shutdown_ms, "vm supervisor config");
let result = vm_manager::run_manager(args, auto_shutdown_ms);
let _ = instance::touch_last_active(&instance_dir);
let _ = instance::touch_last_active(&project_dir);
if let Err(err) = result {
tracing::error!(error = %err, "vm supervisor exited");
return Err(color_eyre::eyre::eyre!(err.to_string()));
+70 -82
View File
@@ -1,3 +1,7 @@
use bytesize::ByteSize;
use clap::Parser;
use color_eyre::Result;
use dialoguer::Confirm;
use std::{
env,
ffi::OsString,
@@ -6,10 +10,6 @@ use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use clap::Parser;
use color_eyre::Result;
use dialoguer::Confirm;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use tracing_subscriber::filter::LevelFilter;
@@ -17,8 +17,9 @@ use tracing_subscriber::registry::Registry;
use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload};
use vibebox::tui::{AppState, VmInfo};
use vibebox::utils::relative_to_home;
use vibebox::{
SessionManager, commands, config, explain, instance, session_manager, tui, vm, vm_manager,
SessionManager, commands, config, explain, instance, session_manager, tui, vm_manager,
};
#[derive(Debug, Parser)]
@@ -49,58 +50,33 @@ fn main() -> Result<()> {
let stderr_handle = init_tracing(&cwd);
let cli = Cli::parse();
tracing::info!(cwd = %cwd.display(), "starting vibebox cli");
tracing::debug!(cwd = %cwd.display(), "starting vibebox cli");
if let Some(command) = cli.command {
return handle_command(command, &cwd, cli.config.as_deref());
}
let config_override = cli.config.clone();
let raw_args: Vec<OsString> = env::args_os().collect();
let config = config::load_config_with_path(&cwd, config_override.as_deref());
let config = config::load_config_with_path(&cwd, config_override.as_deref())
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
if env::var("VIBEBOX_VM_MANAGER").as_deref() == Ok("1") {
tracing::info!("starting vm manager mode");
let args = vm::VmArg {
cpu_count: config.box_cfg.cpu_count,
ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
no_default_mounts: false,
mounts: config.box_cfg.mounts.clone(),
};
let auto_shutdown_ms = config.supervisor.auto_shutdown_ms;
tracing::info!(auto_shutdown_ms, "vm manager config");
if let Err(err) = vm_manager::run_manager(args, auto_shutdown_ms) {
tracing::error!(error = %err, "vm manager exited");
return Err(color_eyre::eyre::eyre!(err.to_string()));
}
return Ok(());
}
vm::ensure_signed();
let vm_args = vm::VmArg {
cpu_count: config.box_cfg.cpu_count,
ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
no_default_mounts: false,
mounts: config.box_cfg.mounts.clone(),
};
let auto_shutdown_ms = config.supervisor.auto_shutdown_ms;
let vm_info = VmInfo {
max_memory_mb: vm_args.ram_bytes / (1024 * 1024),
cpu_cores: vm_args.cpu_count,
max_memory: config.box_cfg.ram_size,
cpu_cores: config.box_cfg.cpu_count,
max_disk: config.box_cfg.disk_size,
system_name: "Debian".to_string(), // TODO: read system name from the VM.
auto_shutdown_ms,
auto_shutdown_ms: config.supervisor.auto_shutdown_ms,
};
if let Ok(manager) = SessionManager::new() {
if let Err(err) = manager.update_global_sessions(&cwd) {
tracing::warn!(error = %err, "failed to update a global session list");
}
} else {
tracing::warn!("failed to initialize session manager");
tracing::error!("failed to initialize session manager");
std::process::exit(1);
}
let commands = commands::build_commands();
let app = Arc::new(Mutex::new(AppState::new(cwd.clone(), vm_info, commands)));
{
let mut locked = app.lock().expect("app state poisoned");
tui::render_tui_once(&mut locked)?;
@@ -110,16 +86,39 @@ fn main() -> Result<()> {
writeln!(stdout)?;
stdout.flush()?;
}
warn_disk_size_mismatch(&cwd, config.box_cfg.disk_size);
if let Some(handle) = stderr_handle {
let _ = handle.modify(|filter| *filter = LevelFilter::INFO);
}
tracing::debug!(auto_shutdown_ms, "auto shutdown config");
let manager_conn =
vm_manager::ensure_manager(&raw_args, auto_shutdown_ms, config_override.as_deref())
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
tracing::debug!(config.supervisor.auto_shutdown_ms, "auto shutdown config");
let manager_conn = vm_manager::ensure_manager(
&raw_args,
config.supervisor.auto_shutdown_ms,
config_override.as_deref(),
)
.map_err(|err| {
tracing::error!(error = %err, "failed to ensure vm manager");
color_eyre::eyre::eyre!(err.to_string())
})?;
instance::run_with_ssh(manager_conn).map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
if let Err(err) = instance::run_with_ssh(manager_conn) {
if let Some(instance::InstanceError::UnexpectedDisconnection) =
err.downcast_ref::<instance::InstanceError>()
{
tracing::warn!("vm manager disconnected; exiting vibebox");
} else if let Some(instance::InstanceError::VMError(vm_error)) =
err.downcast_ref::<instance::InstanceError>()
{
tracing::error!("[vm]: {vm_error}");
tracing::info!("vibecoding paused: the VM says today is a rest day 😴");
std::process::exit(1);
} else {
let message = err.to_string();
tracing::error!(error = %message, "vibebox exited: uncaught error");
return Err(color_eyre::eyre::eyre!(message));
}
}
tracing::info!("See you again — keep vibecoding (no SEVs, only vibes) 😈");
@@ -202,13 +201,14 @@ fn handle_command(command: Command, cwd: &Path, config_override: Option<&Path>)
"Purged {} file{} totaling {} from {}",
file_count,
if file_count == 1 { "" } else { "s" },
format_bytes(total_bytes),
ByteSize(total_bytes),
cache_dir.display()
);
Ok(())
}
Command::Explain => {
let config = config::load_config_with_path(cwd, config_override);
let config = config::load_config_with_path(cwd, config_override)
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
let mounts = explain::build_mount_rows(cwd, &config)
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
let networks = explain::build_network_rows(cwd)
@@ -231,20 +231,6 @@ fn project_name(directory: &Path) -> String {
.to_string()
}
fn relative_to_home(directory: &Path) -> String {
let Ok(home) = env::var("HOME") else {
return directory.display().to_string();
};
let home_path = PathBuf::from(home);
if let Ok(stripped) = directory.strip_prefix(&home_path) {
if stripped.components().next().is_none() {
return "~".to_string();
}
return format!("~/{}", stripped.display());
}
directory.display().to_string()
}
fn cache_dir() -> Result<PathBuf> {
let home = env::var("HOME").map(PathBuf::from)?;
let cache_home = env::var("XDG_CACHE_HOME")
@@ -293,23 +279,6 @@ fn measure_dir(path: &Path) -> Result<(u64, u64)> {
Ok((file_count, total_bytes))
}
fn format_bytes(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
const GB: f64 = MB * 1024.0;
let b = bytes as f64;
if b >= GB {
format!("{:.2} GB", b / GB)
} else if b >= MB {
format!("{:.1} MB", b / MB)
} else if b >= KB {
format!("{:.1} KB", b / KB)
} else {
format!("{} B", bytes)
}
}
fn format_last_active(value: Option<&str>) -> String {
let Some(raw) = value else {
return "-".to_string();
@@ -351,18 +320,37 @@ fn format_last_active(value: Option<&str>) -> String {
format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
}
fn warn_disk_size_mismatch(cwd: &Path, configured_size: ByteSize) {
let instance_raw = cwd
.join(session_manager::INSTANCE_DIR_NAME)
.join("instance.raw");
let Ok(meta) = fs::metadata(&instance_raw) else {
return;
};
let current_size = ByteSize::b(meta.len());
if current_size == configured_size {
return;
}
tracing::warn!(
"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 the existing disk.",
current_size,
configured_size,
);
}
type StderrHandle = reload::Handle<LevelFilter, Registry>;
fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
let file_filter = filter.clone();
let stderr_is_tty = std::io::stderr().is_terminal();
let stderr_is_tty = io::stderr().is_terminal();
let ansi = stderr_is_tty && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
let file = instance::ensure_instance_dir(cwd)
.ok()
.and_then(|instance_dir| {
let log_path = instance_dir.join("cli.log");
std::fs::OpenOptions::new()
fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
@@ -371,12 +359,12 @@ fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
});
if stderr_is_tty {
let (stderr_filter, handle) = reload::Layer::new(LevelFilter::OFF);
let (stderr_filter, handle) = reload::Layer::new(LevelFilter::INFO);
let stderr_layer = fmt::layer()
.with_target(false)
.with_ansi(ansi)
.without_time()
.with_writer(std::io::stderr)
.with_writer(io::stderr)
.with_filter(stderr_filter);
let subscriber = tracing_subscriber::registry().with(stderr_layer);
if let Some(file) = file {
@@ -394,7 +382,7 @@ fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
let stderr_layer = fmt::layer()
.with_target(false)
.with_ansi(ansi)
.with_writer(std::io::stderr)
.with_writer(io::stderr)
.with_filter(filter);
let subscriber = tracing_subscriber::registry().with(stderr_layer);
if let Some(file) = file {
+475 -71
View File
@@ -1,18 +1,20 @@
use anyhow::{Context, Error, Result, bail};
use bytesize::ByteSize;
use serde::{Deserialize, Serialize};
use std::{
env, fs, io,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use crate::vm::DirectoryShare;
pub const CONFIG_FILENAME: &str = "vibebox.toml";
const CONFIG_FILENAME: &str = "vibebox.toml";
pub const CONFIG_PATH_ENV: &str = "VIBEBOX_CONFIG_PATH";
const DEFAULT_CPU_COUNT: usize = 2;
const DEFAULT_RAM_MB: u64 = 2048;
const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 20000;
const DEFAULT_DISK_GB: u64 = 5;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
@@ -21,18 +23,83 @@ pub struct Config {
pub supervisor: SupervisorConfig,
}
const MI_B: u64 = 1024 * 1024;
const GI_B: u64 = 1024 * 1024 * 1024;
mod serde_mb {
use super::{ByteSize, MI_B};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(v: &ByteSize, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let bytes = v.0;
if !bytes.is_multiple_of(MI_B) {
return Err(serde::ser::Error::custom(
"ram_mb must be an integer number of MB",
));
}
s.serialize_u64(bytes / MI_B)
}
pub fn deserialize<'de, D>(d: D) -> Result<ByteSize, D::Error>
where
D: Deserializer<'de>,
{
let mb = u64::deserialize(d)?;
let bytes = mb
.checked_mul(MI_B)
.ok_or_else(|| serde::de::Error::custom("ram_mb overflow"))?;
Ok(ByteSize(bytes))
}
}
mod serde_gb {
use super::{ByteSize, GI_B};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(v: &ByteSize, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let bytes = v.0;
if !bytes.is_multiple_of(GI_B) {
return Err(serde::ser::Error::custom(
"disk_gb must be an integer number of GB",
));
}
s.serialize_u64(bytes / GI_B)
}
pub fn deserialize<'de, D>(d: D) -> Result<ByteSize, D::Error>
where
D: Deserializer<'de>,
{
let gb = u64::deserialize(d)?;
let bytes = gb
.checked_mul(GI_B)
.ok_or_else(|| serde::de::Error::custom("disk_gb overflow"))?;
Ok(ByteSize(bytes))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoxConfig {
pub cpu_count: usize,
pub ram_mb: u64,
#[serde(rename = "ram_mb", with = "serde_mb")]
pub ram_size: ByteSize,
#[serde(rename = "disk_gb", with = "serde_gb")]
pub disk_size: ByteSize,
pub mounts: Vec<String>,
}
impl Default for BoxConfig {
fn default() -> Self {
Self {
cpu_count: default_cpu_count(),
ram_mb: default_ram_mb(),
cpu_count: DEFAULT_CPU_COUNT,
ram_size: ByteSize::mib(DEFAULT_RAM_MB),
disk_size: ByteSize::gib(DEFAULT_DISK_GB),
mounts: default_mounts(),
}
}
@@ -46,36 +113,24 @@ pub struct SupervisorConfig {
impl Default for SupervisorConfig {
fn default() -> Self {
Self {
auto_shutdown_ms: default_auto_shutdown_ms(),
auto_shutdown_ms: DEFAULT_AUTO_SHUTDOWN_MS,
}
}
}
fn default_cpu_count() -> usize {
DEFAULT_CPU_COUNT
}
fn default_ram_mb() -> u64 {
DEFAULT_RAM_MB
}
fn default_auto_shutdown_ms() -> u64 {
DEFAULT_AUTO_SHUTDOWN_MS
}
fn default_mounts() -> Vec<String> {
Vec::new()
vec![
"~/.codex:~/.codex:read-write".into(),
"~/.claude:~/.claude:read-write".into(),
]
}
pub fn config_path(project_root: &Path) -> PathBuf {
project_root.join(CONFIG_FILENAME)
}
pub fn ensure_config_file(
project_root: &Path,
override_path: Option<&Path>,
) -> Result<PathBuf, io::Error> {
let path = resolve_config_path(project_root, override_path);
pub fn ensure_config_file(project_root: &Path, override_path: Option<&Path>) -> Result<PathBuf> {
let path = resolve_config_path(project_root, override_path)?;
if !path.exists() {
let default_config = Config::default();
let contents = toml::to_string_pretty(&default_config).unwrap_or_default();
@@ -85,32 +140,24 @@ pub fn ensure_config_file(
Ok(path)
}
pub fn load_config(project_root: &Path) -> Config {
pub fn load_config(project_root: &Path) -> Result<Config> {
load_config_with_path(project_root, None)
}
pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) -> Config {
let path = match ensure_config_file(project_root, override_path) {
Ok(path) => path,
Err(err) => die(&format!("failed to create config: {err}")),
};
let raw = match fs::read_to_string(&path) {
Ok(raw) => raw,
Err(err) => die(&format!("failed to read config: {err}")),
};
pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) -> Result<Config> {
let path =
ensure_config_file(project_root, override_path).context("failed to create config")?;
let raw = fs::read_to_string(&path).context("failed to read config")?;
let trimmed = raw.trim();
tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config");
if trimmed.is_empty() {
die(&format!(
"config file ({}) is empty. Required fields: [box].cpu_count (integer), [box].ram_mb (integer), [box].mounts (array of strings), [supervisor].auto_shutdown_ms (integer)",
bail!(format!(
"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)",
path.display()
));
}
let value: toml::Value = match toml::from_str(trimmed) {
Ok(value) => value,
Err(err) => die(&format!("invalid config: {err}")),
};
let value: toml::Value = toml::from_str(trimmed).context("invalid config")?;
let schema_errors = validate_schema(&value);
if !schema_errors.is_empty() {
let message = format!(
@@ -118,27 +165,28 @@ pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>)
path.display(),
schema_errors.join("\n- ")
);
die(&message);
bail!(message);
}
let config: Config = match toml::from_str(trimmed) {
Ok(config) => config,
Err(err) => die(&format!("invalid config: {err}")),
};
validate_or_exit(&config);
config
let config: Config = toml::from_str(trimmed).context("invalid config")?;
validate_config(&config).map_err(Error::msg)?;
Ok(config)
}
fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> PathBuf {
let root = match fs::canonicalize(project_root) {
Ok(root) => root,
Err(err) => die(&format!("failed to resolve project root: {err}")),
};
fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> Result<PathBuf> {
let env_override = env::var_os(CONFIG_PATH_ENV).map(PathBuf::from);
resolve_config_path_inner(project_root, override_path, env_override)
}
let override_path = override_path
.map(PathBuf::from)
.or_else(|| env::var_os(CONFIG_PATH_ENV).map(PathBuf::from));
let raw_path = if let Some(path) = override_path {
fn resolve_config_path_inner(
project_root: &Path,
override_path: Option<&Path>,
env_override: Option<PathBuf>,
) -> Result<PathBuf> {
let root = fs::canonicalize(project_root).context("failed to resolve project root")?;
let selected_path = override_path.map(PathBuf::from).or(env_override);
let raw_path = if let Some(path) = selected_path {
if path.is_absolute() {
path
} else {
@@ -149,14 +197,50 @@ fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> Pat
};
let normalized = normalize_path(&raw_path);
if !normalized.starts_with(&root) {
die(&format!(
let resolved =
resolve_path_for_boundary_check(&normalized).context("failed to resolve config path")?;
if !resolved.starts_with(&root) {
bail!(
"config path must be within {}: {}",
root.display(),
normalized.display()
));
resolved.display()
);
}
normalized
Ok(normalized)
}
fn resolve_path_for_boundary_check(path: &Path) -> Result<PathBuf, io::Error> {
if path.exists() {
return fs::canonicalize(path);
}
let (ancestor, missing) = nearest_existing_ancestor(path)?;
let mut resolved = fs::canonicalize(ancestor)?;
for part in missing {
resolved.push(part);
}
Ok(resolved)
}
fn nearest_existing_ancestor(path: &Path) -> Result<(&Path, Vec<std::ffi::OsString>), io::Error> {
let mut current = path;
let mut missing = Vec::new();
while !current.exists() {
let name = current.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("path has no existing ancestor: {}", path.display()),
)
})?;
missing.push(name.to_os_string());
current = current.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("path has no parent: {}", path.display()),
)
})?;
}
missing.reverse();
Ok((current, missing))
}
fn normalize_path(path: &Path) -> PathBuf {
@@ -193,6 +277,7 @@ fn validate_schema(value: &toml::Value) -> Vec<String> {
Some(table) => {
validate_int(table, "cpu_count", "[box].cpu_count (integer)", &mut errors);
validate_int(table, "ram_mb", "[box].ram_mb (integer)", &mut errors);
validate_int(table, "disk_gb", "[box].disk_gb (integer)", &mut errors);
validate_string_array(
table,
"mounts",
@@ -252,24 +337,343 @@ fn validate_string_array(
}
}
fn validate_or_exit(config: &Config) {
fn validate_config(config: &Config) -> Result<(), String> {
if config.box_cfg.cpu_count == 0 {
die("box.cpu_count must be >= 1");
return Err("box.cpu_count must be >= 1".to_string());
}
if config.box_cfg.ram_mb == 0 {
die("box.ram_mb must be >= 1");
if config.box_cfg.ram_size.as_mib() == 0.0 {
return Err("box.ram_mb must be >= 1".to_string());
}
if config.box_cfg.disk_size.as_gib() == 0.0 {
return Err("box.disk_gb must be >= 1".to_string());
}
if config.supervisor.auto_shutdown_ms == 0 {
die("supervisor.auto_shutdown_ms must be >= 1");
return Err("supervisor.auto_shutdown_ms must be >= 1".to_string());
}
for spec in &config.box_cfg.mounts {
if let Err(err) = DirectoryShare::from_mount_spec(spec) {
die(&format!("invalid mount spec '{spec}': {err}"));
return Err(format!("invalid mount spec '{spec}': {err}"));
}
}
Ok(())
}
fn die(message: &str) -> ! {
tracing::error!("{message}");
std::process::exit(1);
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
use std::sync::Mutex;
use tempfile::TempDir;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
struct EnvGuard {
key: &'static str,
previous: Option<OsString>,
}
impl EnvGuard {
fn set(key: &'static str, value: &Path) -> Self {
let previous = env::var_os(key);
unsafe {
env::set_var(key, value);
}
Self { key, previous }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.previous {
Some(value) => unsafe {
env::set_var(self.key, value);
},
None => unsafe {
env::remove_var(self.key);
},
}
}
}
#[test]
fn default_config_serializes_with_legacy_keys() {
let cfg = Config::default();
let serialized = toml::to_string(&cfg).expect("default config should serialize");
assert!(serialized.contains("ram_mb = 2048"));
assert!(serialized.contains("disk_gb = 5"));
assert!(!serialized.contains("ram_size"));
assert!(!serialized.contains("disk_size"));
}
#[test]
fn config_deserializes_sizes_from_mb_and_gb() {
let raw = r#"
[box]
cpu_count = 4
ram_mb = 3072
disk_gb = 12
mounts = ["~/src:~/src:read-write"]
[supervisor]
auto_shutdown_ms = 15000
"#;
let cfg: Config = toml::from_str(raw).expect("config should deserialize");
assert_eq!(cfg.box_cfg.cpu_count, 4);
assert_eq!(cfg.box_cfg.ram_size.as_u64(), ByteSize::mib(3072).as_u64());
assert_eq!(cfg.box_cfg.disk_size.as_u64(), ByteSize::gib(12).as_u64());
assert_eq!(cfg.supervisor.auto_shutdown_ms, 15000);
}
#[test]
fn serialize_rejects_non_integral_mb_or_gb() {
let cfg = Config {
box_cfg: BoxConfig {
cpu_count: 2,
ram_size: ByteSize::b((2 * MI_B) + 1),
disk_size: ByteSize::gib(5),
mounts: default_mounts(),
},
supervisor: SupervisorConfig::default(),
};
let err = toml::to_string(&cfg).expect_err("serialization should reject invalid MB");
assert!(
err.to_string()
.contains("ram_mb must be an integer number of MB")
);
let cfg = Config {
box_cfg: BoxConfig {
cpu_count: 2,
ram_size: ByteSize::mib(2048),
disk_size: ByteSize::b((5 * GI_B) + 1),
mounts: default_mounts(),
},
supervisor: SupervisorConfig::default(),
};
let err = toml::to_string(&cfg).expect_err("serialization should reject invalid GB");
assert!(
err.to_string()
.contains("disk_gb must be an integer number of GB")
);
}
#[test]
fn normalize_path_removes_dot_and_parent_components() {
let normalized = normalize_path(Path::new("/tmp/project/./nested/../config.toml"));
assert_eq!(normalized, PathBuf::from("/tmp/project/config.toml"));
}
#[test]
fn validate_schema_returns_errors_for_missing_required_fields() {
let value: toml::Value = toml::from_str(
r#"
[box]
cpu_count = 2
"#,
)
.expect("toml should parse");
let errors = validate_schema(&value);
assert!(errors.iter().any(|e| e == "missing [supervisor] table"));
assert!(errors.iter().any(|e| e == "missing [box].ram_mb (integer)"));
assert!(
errors
.iter()
.any(|e| e == "missing [box].disk_gb (integer)")
);
assert!(
errors
.iter()
.any(|e| e == "missing [box].mounts (array of strings)")
);
}
#[test]
fn validate_schema_errors_when_supervisor_is_not_table() {
let value: toml::Value = toml::from_str(
r#"
supervisor = 123
[box]
cpu_count = 2
ram_mb = 2048
disk_gb = 5
mounts = []
"#,
)
.expect("toml should parse");
let errors = validate_schema(&value);
assert!(errors.iter().any(|e| e == "[supervisor] must be a table"));
}
#[test]
fn ensure_config_file_creates_default_config_if_absent() {
let temp = TempDir::new().expect("temp dir should be created");
let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
let path = ensure_config_file(&root, None).expect("config should be created");
let raw = fs::read_to_string(&path).expect("created config should be readable");
let parsed: Config = toml::from_str(&raw).expect("created config should be valid");
assert_eq!(path, root.join("vibebox.toml"));
assert_eq!(parsed.box_cfg.cpu_count, DEFAULT_CPU_COUNT);
assert_eq!(
parsed.box_cfg.ram_size.as_u64(),
ByteSize::mib(DEFAULT_RAM_MB).as_u64()
);
assert_eq!(
parsed.box_cfg.disk_size.as_u64(),
ByteSize::gib(DEFAULT_DISK_GB).as_u64()
);
}
#[test]
fn load_config_creates_and_loads_default_config() {
let _lock = ENV_MUTEX.lock().expect("env lock should be acquired");
let temp = TempDir::new().expect("temp dir should be created");
let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
let home = root.join("home");
fs::create_dir_all(home.join(".codex")).expect("home .codex should be created");
fs::create_dir_all(home.join(".claude")).expect("home .claude should be created");
let _home_guard = EnvGuard::set("HOME", &home);
let cfg = load_config(&root).expect("load_config should succeed");
assert_eq!(cfg.box_cfg.cpu_count, DEFAULT_CPU_COUNT);
assert_eq!(
cfg.box_cfg.ram_size.as_u64(),
ByteSize::mib(DEFAULT_RAM_MB).as_u64()
);
assert_eq!(
cfg.box_cfg.disk_size.as_u64(),
ByteSize::gib(DEFAULT_DISK_GB).as_u64()
);
assert!(root.join("vibebox.toml").exists());
}
#[test]
fn load_config_with_path_uses_override_path() {
let _lock = ENV_MUTEX.lock().expect("env lock should be acquired");
let temp = TempDir::new().expect("temp dir should be created");
let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
let home = root.join("home");
fs::create_dir_all(home.join(".codex")).expect("home .codex should be created");
fs::create_dir_all(home.join(".claude")).expect("home .claude should be created");
let _home_guard = EnvGuard::set("HOME", &home);
let override_path = root.join("custom.toml");
fs::write(
&override_path,
r#"
[box]
cpu_count = 6
ram_mb = 4096
disk_gb = 9
mounts = ["~/.codex:~/.codex:read-write", "~/.claude:~/.claude:read-write"]
[supervisor]
auto_shutdown_ms = 12345
"#,
)
.expect("override config should be written");
let cfg = load_config_with_path(&root, Some(Path::new("custom.toml")))
.expect("load_config_with_path should succeed");
assert_eq!(cfg.box_cfg.cpu_count, 6);
assert_eq!(cfg.box_cfg.ram_size.as_u64(), ByteSize::mib(4096).as_u64());
assert_eq!(cfg.box_cfg.disk_size.as_u64(), ByteSize::gib(9).as_u64());
assert_eq!(cfg.supervisor.auto_shutdown_ms, 12345);
assert!(!root.join("vibebox.toml").exists());
}
#[test]
fn resolve_config_path_uses_env_override_when_cli_override_missing() {
let temp = TempDir::new().expect("temp dir should be created");
let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
let resolved = resolve_config_path_inner(&root, None, Some(PathBuf::from("custom.toml")))
.expect("env override path should resolve");
assert_eq!(resolved, root.join("custom.toml"));
}
#[test]
fn resolve_config_path_rejects_env_override_outside_project() {
let temp = TempDir::new().expect("temp dir should be created");
let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
let err = resolve_config_path_inner(&root, None, Some(PathBuf::from("../escape.toml")))
.expect_err("outside-project path should be rejected");
assert!(
err.to_string().contains("config path must be within"),
"expected bounds-check error, got: {err}"
);
}
#[test]
fn validate_config_rejects_invalid_values() {
let cfg = Config {
box_cfg: BoxConfig {
cpu_count: 0,
ram_size: ByteSize::mib(2048),
disk_size: ByteSize::gib(5),
mounts: vec![],
},
supervisor: SupervisorConfig::default(),
};
let err = validate_config(&cfg).expect_err("cpu_count=0 should fail");
assert_eq!(err, "box.cpu_count must be >= 1");
let cfg = Config {
box_cfg: BoxConfig {
cpu_count: 2,
ram_size: ByteSize::mib(2048),
disk_size: ByteSize::gib(5),
mounts: vec!["/definitely/missing:/tmp/missing:read-write".to_string()],
},
supervisor: SupervisorConfig::default(),
};
let err = validate_config(&cfg).expect_err("invalid mount should fail");
assert!(err.starts_with("invalid mount spec"));
}
#[test]
fn resolve_config_path_accepts_symlinked_project_root() {
let temp = TempDir::new().expect("temp dir should be created");
let actual_root = temp.path().join("actual");
let link_root = temp.path().join("linked");
fs::create_dir_all(&actual_root).expect("actual root should exist");
std::os::unix::fs::symlink(&actual_root, &link_root).expect("symlink should be created");
let resolved = resolve_config_path_inner(&link_root, Some(Path::new("vibebox.toml")), None)
.expect("symlinked project root should resolve");
assert_eq!(resolved, link_root.join("vibebox.toml"));
}
#[test]
fn resolve_config_path_rejects_symlink_escape() {
let temp = TempDir::new().expect("temp dir should be created");
let project_root = temp.path().join("project");
let outside_root = temp.path().join("outside");
fs::create_dir_all(&project_root).expect("project root should exist");
fs::create_dir_all(&outside_root).expect("outside root should exist");
std::os::unix::fs::symlink(&outside_root, project_root.join("link"))
.expect("escape symlink should be created");
let err = resolve_config_path_inner(&project_root, Some(Path::new("link/cfg.toml")), None)
.expect_err("symlink escape should be rejected");
assert!(
err.to_string().contains("config path must be within"),
"expected bounds-check error, got: {err}"
);
}
}
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -Eeuo pipefail
__vibebox_err_reported=0
__vibebox_report_error() {
local rc="$1"
local line="$2"
local msg="${3:-}"
if [ "$__vibebox_err_reported" -eq 0 ]; then
msg="${msg//$'\n'/ }"
msg="${msg//$'\r'/ }"
if [ -n "$msg" ]; then
echo "VIBEBOX_SCRIPT_ERROR:__LABEL__:${line}:${rc} ${msg}"
else
echo "VIBEBOX_SCRIPT_ERROR:__LABEL__:${line}:${rc}"
fi
__vibebox_err_reported=1
fi
}
vibebox_fail() {
local msg="${1:-script failed}"
local rc="${2:-1}"
__vibebox_report_error "$rc" "${LINENO}" "$msg"
exit "$rc"
}
trap 'rc="$?"; __vibebox_report_error "$rc" "${LINENO}" "command failed: ${BASH_COMMAND:-unknown}"' ERR
trap 'rc="$?"; if [ "$rc" -ne 0 ]; then __vibebox_report_error "$rc" "${LINENO}" "script exited with code ${rc}"; fi' EXIT
__SCRIPT_BODY__
+33 -48
View File
@@ -1,31 +1,31 @@
use crate::instance::InstanceConfig;
use crate::utils::relative_to_home;
use crate::{config, instance, session_manager, tui};
use anyhow::{Context, Result, bail};
use std::{
env,
error::Error,
path::{Path, PathBuf},
};
use crate::{config, instance, session_manager, tui};
pub fn build_mount_rows(
cwd: &Path,
config: &config::Config,
) -> Result<Vec<tui::MountListRow>, Box<dyn Error + Send + Sync>> {
pub fn build_mount_rows(project: &Path, config: &config::Config) -> Result<Vec<tui::MountListRow>> {
let mut rows = Vec::new();
rows.extend(default_mounts(cwd)?);
let guest_home = resolve_guest_home(cwd)?;
rows.extend(default_mounts(project)?);
let guest_home = resolve_guest_home(project);
for spec in &config.box_cfg.mounts {
rows.push(parse_mount_spec(cwd, spec, false, &guest_home)?);
rows.push(parse_mount_spec(project, spec, false, &guest_home)?);
}
Ok(rows)
}
pub fn build_network_rows(
cwd: &Path,
) -> Result<Vec<tui::NetworkListRow>, Box<dyn Error + Send + Sync>> {
let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME);
pub fn build_network_rows(project_dir: &Path) -> Result<Vec<tui::NetworkListRow>> {
let mut vm_ip = "-".to_string();
if let Ok(Some(ip)) = instance::read_instance_vm_ip(&instance_dir) {
vm_ip = ip;
if let Ok(config) = instance::read_instance_config(project_dir) {
match config.vm_ipv4 {
None => {}
Some(ip) => {
vm_ip = ip;
}
}
}
let host_to_vm = if vm_ip == "-" {
"ssh: <pending>:22".to_string()
@@ -41,13 +41,13 @@ pub fn build_network_rows(
Ok(vec![row])
}
fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>, Box<dyn Error + Send + Sync>> {
fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>> {
let project_name = cwd
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("project");
let project_guest = format!("/root/{project_name}");
let project_host = display_path(cwd);
.with_context(|| "failed to get project name")?;
let project_guest = format!("~/{project_name}");
let project_host = relative_to_home(cwd);
let mut rows = vec![tui::MountListRow {
host: project_host,
guest: project_guest,
@@ -57,14 +57,14 @@ fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>, Box<dyn Error +
let home = env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/"));
.with_context(|| "failed to get home directory")?;
let cache_home = env::var("XDG_CACHE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home.join(".cache"));
let cache_dir = cache_home.join(session_manager::GLOBAL_CACHE_DIR_NAME);
let guest_mise_cache = cache_dir.join(".guest-mise-cache");
rows.push(tui::MountListRow {
host: display_path(&guest_mise_cache),
host: relative_to_home(&guest_mise_cache),
guest: "/root/.local/share/mise".to_string(),
mode: "read-write".to_string(),
default_mount: "yes".to_string(),
@@ -77,10 +77,10 @@ fn parse_mount_spec(
spec: &str,
default_mount: bool,
guest_home: &str,
) -> Result<tui::MountListRow, Box<dyn Error + Send + Sync>> {
) -> Result<tui::MountListRow> {
let parts: Vec<&str> = spec.split(':').collect();
if parts.len() < 2 || parts.len() > 3 {
return Err(format!("invalid mount spec: {spec}").into());
bail!["invalid mount spec: {spec}"];
}
let host_part = parts[0];
let guest_part = parts[1];
@@ -89,11 +89,10 @@ fn parse_mount_spec(
"read-only" => "read-only",
"read-write" => "read-write",
other => {
return Err(format!(
bail![format!(
"invalid mount mode '{}'; expected read-only or read-write",
other
)
.into());
)];
}
}
} else {
@@ -116,22 +115,22 @@ fn display_host_spec(cwd: &Path, host: &str) -> String {
}
let host_path = PathBuf::from(host);
if host_path.is_absolute() {
return display_path(&host_path);
return relative_to_home(&host_path);
}
let candidate = cwd.join(&host_path);
if candidate.is_absolute() {
display_path(&candidate)
relative_to_home(&candidate)
} else {
host.to_string()
}
}
fn resolve_guest_home(cwd: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME);
if let Ok(Some(user)) = instance::read_instance_ssh_user(&instance_dir) {
return Ok(format!("/home/{user}"));
fn resolve_guest_home(project_dir: &Path) -> String {
let config = instance::read_instance_config(project_dir);
match config {
Ok(config) => format!("/home/{}", config.ssh_user),
Err(_) => format!("/home/{}", InstanceConfig::default().ssh_user),
}
Ok(format!("/home/{}", instance::DEFAULT_SSH_USER))
}
fn resolve_guest_display(guest: &str, guest_home: &str) -> String {
@@ -155,17 +154,3 @@ fn resolve_guest_display(guest: &str, guest_home: &str) -> String {
format!("/root/{guest}")
}
}
fn display_path(path: &Path) -> String {
let Ok(home) = env::var("HOME") else {
return path.display().to_string();
};
let home_path = PathBuf::from(home);
if let Ok(stripped) = path.strip_prefix(&home_path) {
if stripped.components().next().is_none() {
return "~".to_string();
}
return format!("~/{}", stripped.display());
}
path.display().to_string()
}
+299 -138
View File
@@ -1,81 +1,140 @@
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::os::unix::fs::FileTypeExt;
use std::{
env, fs,
io::{self},
io::{self, IsTerminal, Read},
net::{SocketAddr, TcpStream},
os::unix::{fs::PermissionsExt, net::UnixStream},
path::{Path, PathBuf},
process::{Command, Stdio},
sync::{Arc, Mutex},
process::{Child, Command, Stdio},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread,
time::{Duration, Instant},
};
use serde::{Deserialize, Serialize};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use uuid::Uuid;
use crate::{
commands,
session_manager::{INSTANCE_DIR_NAME, INSTANCE_FILENAME},
session_manager::INSTANCE_DIR_NAME,
session_manager::{VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME},
vm::{self, LoginAction},
};
pub const STATUS_VM_ERROR_PREFIX: &str = "error:";
const SSH_KEY_NAME: &str = "ssh_key";
pub(crate) const VM_ROOT_LOG_NAME: &str = "vm_root.log";
pub(crate) const STATUS_FILE_NAME: &str = "status.txt";
pub(crate) const DEFAULT_SSH_USER: &str = "vibecoder";
const SSH_CONNECT_RETRIES: usize = 30;
const INSTANCE_FILENAME: &str = "instance.toml";
const DEFAULT_SSH_USER: &str = "vibecoder";
const SSH_CONNECT_RETRIES: usize = 10;
const SSH_CONNECT_DELAY_MS: u64 = 500;
const SSH_SETUP_SCRIPT: &str = include_str!("ssh.sh");
const STATUS_PREFIX: &str = "status:";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct InstanceConfig {
#[serde(default)]
id: String,
#[serde(default = "default_ssh_user")]
ssh_user: String,
#[serde(default)]
sudo_password: String,
#[serde(default)]
last_active: Option<String>,
#[serde(default)]
pub(crate) vm_ipv4: Option<String>,
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VmLiveness {
RunningWithSocket { pid: u32 },
RunningWithoutSocket { pid: u32 },
NotRunningOrMissing,
}
impl InstanceConfig {
pub(crate) fn ssh_user_display(&self) -> String {
if self.ssh_user.trim().is_empty() {
DEFAULT_SSH_USER.to_string()
} else {
self.ssh_user.clone()
pub fn vm_liveness(project_root: &Path) -> Result<VmLiveness> {
let instance_dir = ensure_instance_dir(project_root)?;
let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
fn pid_is_alive(pid: u32) -> bool {
let pid = pid as libc::pid_t;
let result = unsafe { libc::kill(pid, 0) };
if result == 0 {
return true;
}
match io::Error::last_os_error().raw_os_error() {
Some(code) if code == libc::EPERM => true,
Some(code) if code == libc::ESRCH => false,
_ => false,
}
}
let Ok(content) = fs::read_to_string(pid_path) else {
return Ok(VmLiveness::NotRunningOrMissing);
};
let Ok(pid) = content.trim().parse::<u32>() else {
return Ok(VmLiveness::NotRunningOrMissing);
};
if !pid_is_alive(pid) {
return Ok(VmLiveness::NotRunningOrMissing);
}
let has_socket = fs::metadata(socket_path)
.map(|meta| meta.file_type().is_socket())
.unwrap_or(false);
if has_socket {
Ok(VmLiveness::RunningWithSocket { pid })
} else {
Ok(VmLiveness::RunningWithoutSocket { pid })
}
}
#[derive(Debug, thiserror::Error)]
pub enum InstanceError {
#[error("unexpected disconnection from vm manager")]
UnexpectedDisconnection,
#[error("{0}")]
VMError(String),
}
fn default_ssh_user() -> String {
DEFAULT_SSH_USER.to_string()
}
pub fn run_with_ssh(manager_conn: UnixStream) -> Result<(), Box<dyn std::error::Error>> {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstanceConfig {
#[serde(default)]
pub id: String,
#[serde(default = "default_ssh_user")]
pub ssh_user: String,
#[serde(default)]
sudo_password: String,
#[serde(default)]
pub last_active: Option<String>,
#[serde(default)]
pub vm_ipv4: Option<String>,
}
impl Default for InstanceConfig {
fn default() -> Self {
Self {
id: Uuid::now_v7().to_string(),
ssh_user: DEFAULT_SSH_USER.to_string(),
sudo_password: Uuid::now_v7().simple().to_string(),
last_active: None,
vm_ipv4: None,
}
}
}
pub fn run_with_ssh(manager_conn: UnixStream) -> Result<()> {
let project_root = env::current_dir()?;
tracing::info!(root = %project_root.display(), "starting ssh session");
let instance_dir = ensure_instance_dir(&project_root)?;
tracing::debug!(instance_dir = %instance_dir.display(), "instance dir ready");
let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?;
let config = load_or_create_instance_config(&instance_dir)?;
let config = load_or_create_instance_config(&project_root)?;
let ssh_user = config.ssh_user.clone();
tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
let _manager_conn = manager_conn;
wait_for_vm_ipv4(&instance_dir, Duration::from_secs(120))?;
wait_for_vm_ipv4(&project_root, Duration::from_secs(480), &manager_conn)?;
let ip = load_or_create_instance_config(&instance_dir)?
let ip = load_or_create_instance_config(&project_root)?
.vm_ipv4
.ok_or("VM IPv4 not available")?;
.with_context(|| "failed to load instance IP address")?;
tracing::info!(ip = %ip, "vm ipv4 ready");
run_ssh_session(ssh_key, ssh_user, ip)
run_ssh_session(ssh_key, ssh_user, ip, manager_conn, project_root)
}
pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
@@ -84,9 +143,7 @@ pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
Ok(instance_dir)
}
pub(crate) fn ensure_ssh_keypair(
instance_dir: &Path,
) -> Result<(PathBuf, PathBuf), Box<dyn std::error::Error>> {
pub fn ensure_ssh_keypair(instance_dir: &Path) -> Result<(PathBuf, PathBuf)> {
let private_key = instance_dir.join(SSH_KEY_NAME);
let public_key = instance_dir.join(format!("{SSH_KEY_NAME}.pub"));
@@ -108,7 +165,9 @@ pub(crate) fn ensure_ssh_keypair(
"-N",
"",
"-f",
private_key.to_str().ok_or("ssh key path not utf-8")?,
private_key
.to_str()
.with_context(|| "ssh key path not utf-8")?,
"-C",
"vibebox",
])
@@ -118,7 +177,7 @@ pub(crate) fn ensure_ssh_keypair(
.status()?;
if !status.success() {
return Err("ssh-keygen failed".into());
bail!("ssh-keygen failed");
}
fs::set_permissions(&private_key, fs::Permissions::from_mode(0o600))?;
@@ -127,97 +186,66 @@ pub(crate) fn ensure_ssh_keypair(
Ok((private_key, public_key))
}
pub(crate) fn load_or_create_instance_config(
instance_dir: &Path,
) -> Result<InstanceConfig, Box<dyn std::error::Error>> {
let config_path = instance_dir.join(INSTANCE_FILENAME);
let mut config = if config_path.exists() {
let raw = fs::read_to_string(&config_path)?;
toml::from_str::<InstanceConfig>(&raw)?
} else {
InstanceConfig {
id: String::new(),
ssh_user: default_ssh_user(),
sudo_password: String::new(),
last_active: None,
vm_ipv4: None,
}
};
pub fn load_or_create_instance_config(project_dir: &Path) -> Result<InstanceConfig> {
let mut exist = true;
let mut config = read_instance_config(project_dir).unwrap_or_else(|_| {
exist = false;
InstanceConfig::default()
});
let mut changed = false;
if config.ssh_user.trim().is_empty() {
config.ssh_user = default_ssh_user();
config.ssh_user = InstanceConfig::default().ssh_user;
changed = true;
}
if config.id.trim().is_empty() {
config.id = Uuid::now_v7().to_string();
config.id = InstanceConfig::default().id;
changed = true;
}
if config.sudo_password.trim().is_empty() {
config.sudo_password = generate_password();
config.sudo_password = InstanceConfig::default().sudo_password;
changed = true;
}
if !config_path.exists() || changed {
write_instance_config(&config_path, &config)?;
if !exist || changed {
write_instance_config(project_dir, &config)?;
}
Ok(config)
}
fn read_instance_config(
instance_dir: &Path,
) -> Result<Option<InstanceConfig>, Box<dyn std::error::Error>> {
let config_path = instance_dir.join(INSTANCE_FILENAME);
pub fn read_instance_config(project_dir: &Path) -> Result<InstanceConfig> {
// todo maybe verify schema?
let config_path = project_dir.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
if !config_path.exists() {
return Ok(None);
bail!("instance config file does not exist");
}
let raw = fs::read_to_string(&config_path)?;
let config = toml::from_str::<InstanceConfig>(&raw)?;
Ok(Some(config))
Ok(config)
}
pub fn read_instance_vm_ip(
instance_dir: &Path,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let config = read_instance_config(instance_dir)?;
Ok(config.and_then(|cfg| cfg.vm_ipv4))
}
pub fn read_instance_ssh_user(
instance_dir: &Path,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let config = read_instance_config(instance_dir)?;
Ok(config
.map(|cfg| cfg.ssh_user)
.filter(|user| !user.trim().is_empty()))
}
pub fn touch_last_active(instance_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
let mut config = load_or_create_instance_config(instance_dir)?;
pub fn touch_last_active(project_dir: &Path) -> Result<()> {
let mut config = load_or_create_instance_config(project_dir)?;
let now = OffsetDateTime::now_utc().format(&Rfc3339)?;
config.last_active = Some(now);
write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
write_instance_config(project_dir, &config)?;
Ok(())
}
pub(crate) fn write_instance_config(
path: &Path,
config: &InstanceConfig,
) -> Result<(), Box<dyn std::error::Error>> {
pub fn write_instance_config(project_dir: &Path, config: &InstanceConfig) -> Result<()> {
let path = project_dir.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
let data = toml::to_string_pretty(config)?;
fs::write(path, data)?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
fs::create_dir_all(project_dir.join(INSTANCE_DIR_NAME))?;
fs::write(&path, data)?;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
Ok(())
}
fn generate_password() -> String {
Uuid::now_v7().simple().to_string()
}
pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
pub fn extract_ipv4(line: &str) -> Option<String> {
let mut current = String::new();
let mut best: Option<String> = None;
@@ -236,25 +264,69 @@ pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
best
}
fn handle_manager_line(line: &str, last_status: &mut Option<String>) -> Result<()> {
if let Some(status) = line.strip_prefix(STATUS_PREFIX) {
let status = status.trim();
if let Some(message) = status.strip_prefix(STATUS_VM_ERROR_PREFIX) {
let message = message.trim();
if message.is_empty() {
bail!("vm manager reported startup failure");
}
return Err(InstanceError::VMError(message.to_string()).into());
}
if !status.is_empty() && last_status.as_deref() != Some(status) {
tracing::info!("[background]: {}", status);
*last_status = Some(status.to_string());
}
}
Ok(())
}
fn wait_for_vm_ipv4(
instance_dir: &Path,
project_dir: &Path,
timeout: Duration,
) -> Result<(), Box<dyn std::error::Error>> {
manager_conn: &UnixStream,
) -> Result<()> {
let start = Instant::now();
let mut next_log_at = start + Duration::from_secs(10);
let mut stream = manager_conn.try_clone()?;
let _ = stream.set_read_timeout(Some(Duration::from_millis(250)));
let mut read_buf = [0u8; 1024];
let mut pending = String::new();
tracing::info!("waiting for vm ipv4");
let status_path = instance_dir.join(STATUS_FILE_NAME);
let mut last_status: Option<String> = None;
let mut once_hint = false;
loop {
let config = load_or_create_instance_config(instance_dir)?;
match stream.read(&mut read_buf) {
Ok(0) => {
bail!("vm manager disconnected before VM became ready");
}
Ok(n) => {
pending.push_str(&String::from_utf8_lossy(&read_buf[..n]));
while let Some(pos) = pending.find('\n') {
let line = pending[..pos].trim().to_string();
pending.drain(..=pos);
handle_manager_line(&line, &mut last_status)?;
}
}
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
Err(err) if err.kind() == io::ErrorKind::TimedOut => {}
Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
Err(err) => {
tracing::warn!(error = %err, "failed to read vm manager status stream");
}
}
let config = load_or_create_instance_config(project_dir)?;
if config.vm_ipv4.is_some() {
return Ok(());
}
if start.elapsed() > timeout {
return Err("Timed out waiting for VM IPv4".into());
bail!("timed out waiting for VM IPv4");
}
if Instant::now() >= next_log_at {
let now = Instant::now();
if now >= next_log_at {
let waited = start.elapsed();
if waited.as_secs() > 15 && !once_hint {
tracing::info!(
@@ -262,18 +334,9 @@ fn wait_for_vm_ipv4(
);
once_hint = true;
}
if let Ok(status) = fs::read_to_string(&status_path) {
let status = status.trim().to_string();
if !status.is_empty() && last_status.as_deref() != Some(status.as_str()) {
tracing::info!("[background]: {}", status);
last_status = Some(status);
}
} else {
tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
}
next_log_at += Duration::from_secs(10);
tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
next_log_at += Duration::from_secs(20);
}
thread::sleep(Duration::from_millis(200));
}
}
@@ -281,9 +344,14 @@ fn run_ssh_session(
ssh_key: PathBuf,
ssh_user: String,
ip: String,
) -> Result<(), Box<dyn std::error::Error>> {
manager_conn: UnixStream,
project_root: PathBuf,
) -> Result<()> {
let mut attempts = 0usize;
loop {
if matches!(vm_liveness(&project_root)?, VmLiveness::NotRunningOrMissing) {
return Err(InstanceError::UnexpectedDisconnection.into());
}
attempts += 1;
if !ssh_port_open(&ip) {
tracing::debug!(attempts, "ssh port doesn't open yet");
@@ -295,9 +363,7 @@ fn run_ssh_session(
SSH_CONNECT_RETRIES
);
if attempts >= SSH_CONNECT_RETRIES {
return Err(
format!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts").into(),
);
bail!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts");
}
thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS));
continue;
@@ -311,10 +377,10 @@ fn run_ssh_session(
attempts,
SSH_CONNECT_RETRIES
);
let status = Command::new("ssh")
let child = Command::new("ssh")
.args([
"-i",
ssh_key.to_str().unwrap_or(".vibebox/ssh_key"),
ssh_key.to_str().with_context(|| "invalid path")?,
"-o",
"IdentitiesOnly=yes",
"-o",
@@ -339,27 +405,67 @@ fn run_ssh_session(
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status();
.spawn();
match status {
Ok(status) if status.success() => {
tracing::info!(status = %status, "ssh exited");
break;
}
Ok(status) if status.code() == Some(255) => {
tracing::warn!(status = %status, "ssh connection failed");
if attempts >= SSH_CONNECT_RETRIES {
return Err(format!("ssh failed after {SSH_CONNECT_RETRIES} attempts").into());
match child {
Ok(mut child) => {
let done = Arc::new(AtomicBool::new(false));
let done_for_monitor = done.clone();
let (disconnect_tx, disconnect_rx) = mpsc::channel::<()>();
let mut manager_stream = manager_conn.try_clone()?;
let _ = manager_stream.set_read_timeout(Some(Duration::from_millis(250)));
thread::spawn(move || {
let mut buf = [0u8; 1];
while !done_for_monitor.load(Ordering::Relaxed) {
match manager_stream.read(&mut buf) {
Ok(0) => {
let _ = disconnect_tx.send(());
return;
}
Ok(_) => {}
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
Err(err) if err.kind() == io::ErrorKind::TimedOut => {}
Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
Err(_) => {
let _ = disconnect_tx.send(());
return;
}
}
}
});
let status = loop {
if disconnect_rx.try_recv().is_ok() {
done.store(true, Ordering::Relaxed);
terminate_ssh_child(&mut child);
restore_terminal_after_disconnect();
return Err(InstanceError::UnexpectedDisconnection.into());
}
if let Some(status) = child.try_wait()? {
done.store(true, Ordering::Relaxed);
break status;
}
thread::sleep(Duration::from_millis(100));
};
if status.success() {
tracing::info!(status = %status, "ssh exited");
break;
}
if status.code() == Some(255) {
tracing::warn!(status = %status, "ssh connection failed");
if attempts >= SSH_CONNECT_RETRIES {
bail!("ssh failed after {SSH_CONNECT_RETRIES} attempts");
}
thread::sleep(Duration::from_millis(500));
continue;
}
thread::sleep(Duration::from_millis(500));
}
Ok(status) => {
tracing::info!(status = %status, "ssh exited");
break;
}
Err(err) => {
tracing::error!(error = %err, "failed to start ssh");
return Err(format!("failed to start ssh: {err}").into());
bail!("failed to start ssh: {err}");
}
}
}
@@ -367,6 +473,31 @@ fn run_ssh_session(
Ok(())
}
fn terminate_ssh_child(child: &mut Child) {
let pid = child.id() as i32;
unsafe {
libc::kill(pid, libc::SIGTERM);
}
let deadline = Instant::now() + Duration::from_millis(700);
while Instant::now() < deadline {
match child.try_wait() {
Ok(Some(_)) => return,
Ok(None) => thread::sleep(Duration::from_millis(50)),
Err(_) => break,
}
}
let _ = child.kill();
let _ = child.wait();
}
fn restore_terminal_after_disconnect() {
if io::stdin().is_terminal() {
let _ = Command::new("stty").arg("sane").status();
}
eprintln!();
}
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
fn is_ipv4_candidate(candidate: &str) -> bool {
let parts: Vec<&str> = candidate.split('.').collect();
if parts.len() != 4 {
@@ -388,12 +519,13 @@ fn ssh_port_open(ip: &str) -> bool {
Ok(addr) => addr,
Err(_) => return false,
};
TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).is_ok()
TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok()
}
pub(crate) fn build_ssh_login_actions(
pub fn build_ssh_login_actions(
config: &Arc<Mutex<InstanceConfig>>,
project_name: &str,
project_guest_dir: &str,
guest_dir: &str,
key_name: &str,
home_links_script: &str,
@@ -405,15 +537,44 @@ pub(crate) fn build_ssh_login_actions(
let key_path = format!("{guest_dir}/{key_name}.pub");
let setup_script = SSH_SETUP_SCRIPT
let ssh_script = SSH_SETUP_SCRIPT
.replace("__SSH_USER__", &ssh_user)
.replace("__SUDO_PASSWORD__", &sudo_password)
.replace("__PROJECT_NAME__", project_name)
.replace("__PROJECT_GUEST_DIR__", project_guest_dir)
.replace("__KEY_PATH__", &key_path)
.replace("__VIBEBOX_SHELL_SCRIPT__", &commands::render_shell_script())
.replace("__VIBEBOX_HOME_LINKS__", home_links_script);
let setup = vm::script_command_from_content("ssh_setup", &setup_script)
let setup = vm::script_command_from_content("ssh.sh", &ssh_script)
.expect("ssh setup script contained invalid marker");
vec![LoginAction::Send(setup)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn handle_manager_line_updates_status() {
let mut last_status = None;
handle_manager_line("status: preparing VM image...", &mut last_status)
.expect("status should be accepted");
assert_eq!(last_status.as_deref(), Some("preparing VM image..."));
}
#[test]
fn handle_manager_line_ignores_non_status_lines() {
let mut last_status = None;
handle_manager_line("pid=123", &mut last_status).expect("non-status lines are ignored");
assert!(last_status.is_none());
}
#[test]
fn handle_manager_line_surfaces_error_status() {
let mut last_status = None;
let err = handle_manager_line("status: error: vm failed to boot", &mut last_status)
.expect_err("error status should fail");
assert_eq!(err.to_string(), "vm failed to boot");
}
}
+1
View File
@@ -8,3 +8,4 @@ pub mod vm_manager;
pub use session_manager::{SessionError, SessionManager, SessionRecord};
pub mod config;
pub mod utils;
+55 -5
View File
@@ -1,12 +1,55 @@
#!/bin/bash
set -eux
set -eEux
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
# Wait for network + DNS before apt-get to avoid early boot flakiness.
wait_for_network() {
echo "[vibebox] waiting for network/DNS readiness"
local deadline=$((SECONDS + 180))
while [ "$SECONDS" -lt "$deadline" ]; do
local has_route=0
if ip -4 route show default >/dev/null 2>&1; then
has_route=1
elif ip -6 route show default >/dev/null 2>&1; then
has_route=1
fi
if [ "$has_route" -eq 1 ]; then
if getent hosts deb.debian.org >/dev/null 2>&1; then
return 0
fi
fi
sleep 1
done
echo "[vibebox][warn] network/DNS still not ready after 180s; continuing" >&2
echo "[vibebox][warn] /etc/resolv.conf:" >&2
cat /etc/resolv.conf >&2 || true
ip -br addr >&2 || true
ip route >&2 || true
ip -6 route >&2 || true
return 0
}
apt_update_with_retries() {
local attempt=1
while [ "$attempt" -le 5 ]; do
if apt-get update; then
return 0
fi
echo "[vibebox][warn] apt-get update failed (attempt ${attempt}/5); retrying..." >&2
attempt=$((attempt + 1))
sleep 2
done
return 1
}
# Don't wait too long for slow mirrors.
echo 'Acquire::http::Timeout "2";' | tee /etc/apt/apt.conf.d/99timeout
echo 'Acquire::https::Timeout "2";' | tee -a /etc/apt/apt.conf.d/99timeout
echo 'Acquire::Retries "2";' | tee -a /etc/apt/apt.conf.d/99timeout
echo 'Acquire::http::Timeout "10";' | tee /etc/apt/apt.conf.d/99timeout
echo 'Acquire::https::Timeout "10";' | tee -a /etc/apt/apt.conf.d/99timeout
echo 'Acquire::Retries "5";' | tee -a /etc/apt/apt.conf.d/99timeout
apt-get update
wait_for_network
apt_update_with_retries
apt-get install -y --no-install-recommends \
build-essential \
pkg-config \
@@ -14,6 +57,7 @@ apt-get install -y --no-install-recommends \
curl \
git \
ripgrep \
cloud-guest-utils \
openssh-server \
sudo
@@ -42,6 +86,11 @@ systemctl restart ssh
# Set this env var so claude doesn't complain about running as root.'
echo "export IS_SANDBOX=1" >> .bashrc
# Ensure cloned instances generate unique machine-id on first boot.
truncate -s 0 /etc/machine-id
rm -f /var/lib/dbus/machine-id
ln -sf /etc/machine-id /var/lib/dbus/machine-id
# Shutdown the VM when you logout
cat > .bash_logout <<EOF
systemctl poweroff
@@ -49,4 +98,5 @@ sleep 100 # sleep here so that we don't see the login screen flash up before the
EOF
# Done provisioning, power off the VM
printf "%s%s\n" VIBEBOX_PROVISION_ OK
systemctl poweroff
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
set -euo pipefail
ROOT_DEV="$(findmnt / -n -o SOURCE || true)"
ROOT_FSTYPE="$(findmnt / -n -o FSTYPE || true)"
if [ -z "$ROOT_DEV" ]; then
exit 0
fi
DISK_DEV=""
PART_NUM=""
if command -v lsblk >/dev/null 2>&1; then
DISK_DEV="$(lsblk -no pkname "$ROOT_DEV" 2>/dev/null | head -n1 || true)"
PART_NUM="$(lsblk -no PARTNUM "$ROOT_DEV" 2>/dev/null | head -n1 || true)"
fi
if [ -z "$DISK_DEV" ] || [ -z "$PART_NUM" ]; then
ROOT_BASENAME="$(basename "$ROOT_DEV")"
if echo "$ROOT_BASENAME" | grep -Eq '^nvme.+p[0-9]+$'; then
DISK_DEV="/dev/${ROOT_BASENAME%p[0-9]*}"
PART_NUM="${ROOT_BASENAME##*p}"
elif echo "$ROOT_BASENAME" | grep -Eq '^[a-z]+[0-9]+$'; then
DISK_DEV="/dev/${ROOT_BASENAME%%[0-9]*}"
PART_NUM="${ROOT_BASENAME##*[a-z]}"
fi
fi
if [ -n "$DISK_DEV" ] && [ -n "$PART_NUM" ]; then
if command -v growpart >/dev/null 2>&1; then
growpart "$DISK_DEV" "$PART_NUM" || true
elif command -v sfdisk >/dev/null 2>&1; then
sfdisk -N "$PART_NUM" --force "$DISK_DEV" <<'EOF' || true
,,
EOF
fi
fi
if command -v partprobe >/dev/null 2>&1; then
partprobe "$DISK_DEV" || true
fi
case "$ROOT_FSTYPE" in
ext4|ext3|ext2)
if command -v resize2fs >/dev/null 2>&1; then
resize2fs "$ROOT_DEV" || true
fi
;;
xfs)
if command -v xfs_growfs >/dev/null 2>&1; then
xfs_growfs / || true
fi
;;
esac
+58 -95
View File
@@ -1,19 +1,18 @@
use anyhow::Result;
use std::{
env, fs,
io::{self, Write},
os::unix::fs::FileTypeExt,
path::{Path, PathBuf},
};
use crate::config::config_path;
use crate::instance::{VmLiveness, read_instance_config, vm_liveness};
use serde::{Deserialize, Serialize};
use crate::config::CONFIG_FILENAME;
pub const INSTANCE_DIR_NAME: &str = ".vibebox";
pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
pub const GLOBAL_DIR_NAME: &str = ".vibebox";
pub const INSTANCE_FILENAME: &str = "instance.toml";
pub const SESSION_TOML_SUFFIX: &str = ".toml";
const SESSION_TOML_SUFFIX: &str = ".toml";
pub const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
pub const VM_MANAGER_PID_NAME: &str = "vm.pid";
const SESSIONS_DIR_NAME: &str = "sessions";
@@ -32,14 +31,6 @@ struct SessionEntry {
pub id: String,
}
#[derive(Debug, Default, Deserialize)]
struct InstanceMetadata {
#[serde(default)]
id: Option<String>,
#[serde(default)]
last_active: Option<String>,
}
#[derive(Debug)]
pub struct SessionManager {
sessions_dir: PathBuf,
@@ -61,7 +52,7 @@ pub enum SessionError {
#[error("Session directory does not exist: {0}")]
MissingDirectory(PathBuf),
#[error(transparent)]
Io(#[from] std::io::Error),
Io(#[from] io::Error),
#[error(transparent)]
TomlDe(#[from] toml::de::Error),
#[error(transparent)]
@@ -94,8 +85,8 @@ impl SessionManager {
let mut added = false;
if has_config {
let meta = read_instance_metadata(&directory)?;
if let Some(id) = meta.id {
let id = read_instance_config(&directory).map_or(None, |config| Some(config.id));
if let Some(id) = id {
let record = SessionEntry {
directory: directory.clone(),
id: id.clone(),
@@ -110,14 +101,13 @@ impl SessionManager {
} else {
tracing::warn!(
directory = %directory.display(),
file = INSTANCE_FILENAME,
"missing session id in instance file"
);
}
}
if removed > 0 || added {
tracing::info!(
tracing::debug!(
path = %self.sessions_dir.display(),
removed,
added,
@@ -148,12 +138,13 @@ impl SessionManager {
}
let mut records = Vec::with_capacity(sessions.len());
for session in sessions {
let meta = read_instance_metadata(&session.directory)?;
let last_active =
read_instance_config(&session.directory).map_or(None, |option| option.last_active);
let active = is_session_active(&session.directory);
records.push(SessionRecord {
directory: session.directory,
id: session.id,
last_active: meta.last_active,
last_active,
active,
});
}
@@ -196,7 +187,7 @@ impl SessionManager {
let path = self.session_path_for(&record.id);
let content = toml::to_string_pretty(record)?;
atomic_write(&path, content.as_bytes())?;
tracing::info!(
tracing::debug!(
path = %path.display(),
"wrote session record"
);
@@ -271,43 +262,22 @@ fn is_vibebox_dir(directory: &Path) -> bool {
if !directory.is_absolute() {
return false;
}
directory.join(CONFIG_FILENAME).is_file()
config_path(directory).is_file()
}
fn is_session_active(directory: &Path) -> bool {
let instance_dir = directory.join(INSTANCE_DIR_NAME);
let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
let pid = read_pid(&pid_path);
let is_alive = pid.map(pid_is_alive).unwrap_or(false);
if !is_alive {
let _ = fs::remove_file(&pid_path);
return false;
}
if let Ok(metadata) = fs::metadata(&socket_path) {
return metadata.file_type().is_socket();
}
true
}
fn read_pid(path: &Path) -> Option<u32> {
let content = fs::read_to_string(path).ok()?;
content.trim().parse::<u32>().ok()
}
fn pid_is_alive(pid: u32) -> bool {
let pid = pid as libc::pid_t;
let result = unsafe { libc::kill(pid, 0) };
if result == 0 {
return true;
}
match std::io::Error::last_os_error().raw_os_error() {
Some(code) if code == libc::EPERM => true,
Some(code) if code == libc::ESRCH => false,
_ => false,
match vm_liveness(directory) {
Ok(liveness) => match liveness {
VmLiveness::RunningWithSocket { .. } => true,
VmLiveness::RunningWithoutSocket { .. } => true,
VmLiveness::NotRunningOrMissing => {
let _ = fs::remove_file(&pid_path);
false
}
},
Err(_) => false,
}
}
@@ -315,31 +285,11 @@ fn read_session_file(path: &Path) -> Result<SessionEntry, SessionError> {
let raw = fs::read_to_string(path)?;
let record: SessionEntry = toml::from_str(&raw)?;
if record.id.trim().is_empty() {
return Err(std::io::Error::new(io::ErrorKind::InvalidData, "session id missing").into());
return Err(io::Error::new(io::ErrorKind::InvalidData, "session id missing").into());
}
Ok(record)
}
fn read_instance_metadata(directory: &Path) -> Result<InstanceMetadata, SessionError> {
let instance_path = directory.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
if !instance_path.exists() {
return Ok(InstanceMetadata::default());
}
let raw = fs::read_to_string(&instance_path)?;
let mut meta: InstanceMetadata = toml::from_str(&raw)?;
if let Some(id) = &meta.id
&& id.trim().is_empty()
{
meta.id = None;
}
if let Some(last_active) = &meta.last_active
&& last_active.trim().is_empty()
{
meta.last_active = None;
}
Ok(meta)
}
fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
let Some(parent) = path.parent() else {
return Err(io::Error::new(
@@ -362,6 +312,7 @@ fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use crate::instance::{InstanceConfig, write_instance_config};
use std::fs;
use tempfile::TempDir;
@@ -375,11 +326,14 @@ mod tests {
dir
}
fn write_instance(project_dir: &Path, id: &str, last_active: &str) {
let instance_dir = project_dir.join(INSTANCE_DIR_NAME);
fs::create_dir_all(&instance_dir).unwrap();
let content = format!("id = \"{id}\"\nlast_active = \"{last_active}\"\n");
fs::write(instance_dir.join(INSTANCE_FILENAME), content).unwrap();
fn write_instance(project_dir: &Path, id: &str, last_active: &str) -> Result<()> {
fs::create_dir_all(project_dir)?;
let mut config = InstanceConfig::default();
config.id = id.to_string();
config.last_active = Some(last_active.to_string());
write_instance_config(project_dir, &config)
}
#[test]
@@ -387,11 +341,14 @@ mod tests {
let temp = TempDir::new().unwrap();
let mgr = manager(&temp);
let project_dir = create_project_dir(&temp);
fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
write_instance(
&project_dir,
"019bf290-cccc-7c23-ba1d-dce7e6d40693",
"2026-02-07T05:00:00Z",
fs::write(config_path(project_dir.as_path()), "").unwrap();
assert!(
write_instance(
&project_dir,
"019bf290-cccc-7c23-ba1d-dce7e6d40693",
"2026-02-07T05:00:00Z",
)
.is_ok()
);
let dirs = mgr.update_global_sessions(&project_dir).unwrap();
@@ -412,15 +369,18 @@ mod tests {
let temp = TempDir::new().unwrap();
let mgr = manager(&temp);
let project_dir = create_project_dir(&temp);
fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
write_instance(
&project_dir,
"019bf290-cccc-7c23-ba1d-dce7e6d40693",
"2026-02-07T05:00:00Z",
fs::write(config_path(project_dir.as_path()), "").unwrap();
assert!(
write_instance(
&project_dir,
"019bf290-cccc-7c23-ba1d-dce7e6d40693",
"2026-02-07T05:00:00Z",
)
.is_ok()
);
let _ = mgr.update_global_sessions(&project_dir).unwrap();
fs::remove_file(project_dir.join(CONFIG_FILENAME)).unwrap();
fs::remove_file(config_path(project_dir.as_path())).unwrap();
let sessions = mgr.list_sessions().unwrap();
assert!(sessions.is_empty());
@@ -451,11 +411,14 @@ mod tests {
let temp = TempDir::new().unwrap();
let mgr = manager(&temp);
let project_dir = create_project_dir(&temp);
fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
write_instance(
&project_dir,
"019bf290-cccc-7c23-ba1d-dce7e6d40693",
"2026-02-07T05:00:00Z",
fs::write(config_path(project_dir.as_path()), "").unwrap();
assert!(
write_instance(
&project_dir,
"019bf290-cccc-7c23-ba1d-dce7e6d40693",
"2026-02-07T05:00:00Z",
)
.is_ok()
);
let _ = mgr.update_global_sessions(&project_dir).unwrap();
+61 -16
View File
@@ -2,7 +2,9 @@
set -eu
SSH_USER="__SSH_USER__"
SUDO_PASSWORD="__SUDO_PASSWORD__"
PROJECT_NAME="__PROJECT_NAME__"
PROJECT_GUEST_DIR="__PROJECT_GUEST_DIR__"
KEY_PATH="__KEY_PATH__"
diag() { echo "[vibebox][diag] $*" >&2; }
@@ -49,7 +51,7 @@ dump_diag() {
}
# 1) tmpfs mount
TARGET="/root/${PROJECT_NAME}/.vibebox"
TARGET="${PROJECT_GUEST_DIR}/.vibebox"
if [ -d "$TARGET" ] && ! mountpoint -q "$TARGET"; then
mount -t tmpfs tmpfs "$TARGET"
fi
@@ -60,6 +62,10 @@ if ! id -u "$SSH_USER" >/dev/null 2>&1; then
usermod -aG sudo "$SSH_USER" || true
fi
if [ -n "$SUDO_PASSWORD" ]; then
echo "${SSH_USER}:${SUDO_PASSWORD}" | chpasswd
fi
install -d -m 700 -o "$SSH_USER" -g "$SSH_USER" "/home/${SSH_USER}/.ssh"
install -m 600 -o "$SSH_USER" -g "$SSH_USER" "$KEY_PATH" "/home/${SSH_USER}/.ssh/authorized_keys"
@@ -78,6 +84,21 @@ __VIBEBOX_SHELL_SCRIPT__
VIBEBOX_SHELL_EOF
chmod 644 /etc/profile.d/vibebox.sh
# Auto-cd into project for interactive shells
cat > /etc/profile.d/vibebox-project.sh <<'VIBEBOX_PROJECT_EOF'
case "$-" in
*i*)
project_home="${HOME}/__PROJECT_NAME__"
if [ "$USER" = "__SSH_USER__" ] && [ -d "$project_home" ]; then
cd "$project_home"
elif [ "$USER" = "__SSH_USER__" ] && [ -d "__PROJECT_GUEST_DIR__" ]; then
cd "__PROJECT_GUEST_DIR__"
fi
;;
esac
VIBEBOX_PROJECT_EOF
chmod 644 /etc/profile.d/vibebox-project.sh
if ! grep -q "vibebox-aliases" "${USER_HOME}/.bashrc" 2>/dev/null; then
{
echo ""
@@ -88,16 +109,22 @@ fi
# Install Mise
MISE_BIN="${USER_HOME}/.local/bin/mise"
if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
curl https://mise.run | HOME="$USER_HOME" sh
fi
echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
mise_warn() { echo "[mise] $*" >&2; }
mise_ok() { command -v mise >/dev/null 2>&1 || [ -x "$MISE_BIN" ]; }
mise_install() {
if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
if ! curl https://mise.run | HOME="$USER_HOME" sh; then
mise_warn "mise install script failed"
return 1
fi
fi
echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
export PATH="${USER_HOME}/.local/bin:/usr/local/bin:$PATH"
export PATH="${USER_HOME}/.local/bin:/usr/local/bin:$PATH"
mkdir -p "${USER_HOME}/.config/mise"
mkdir -p "${USER_HOME}/.config/mise"
cat > "${USER_HOME}/.config/mise/config.toml" <<MISE
cat > "${USER_HOME}/.config/mise/config.toml" <<MISE
[settings]
# Always use the venv created by uv, if available in directory
python.uv_venv_auto = true
@@ -111,11 +138,23 @@ cat > "${USER_HOME}/.config/mise/config.toml" <<MISE
"npm:@anthropic-ai/claude-code" = "latest"
MISE
touch "${USER_HOME}/.config/mise/mise.lock"
if [ -x "$MISE_BIN" ]; then
HOME="$USER_HOME" "$MISE_BIN" install
else
HOME="$USER_HOME" mise install
touch "${USER_HOME}/.config/mise/mise.lock"
if [ -x "$MISE_BIN" ]; then
if ! HOME="$USER_HOME" "$MISE_BIN" install; then
mise_warn "mise install failed"
return 1
fi
else
if ! HOME="$USER_HOME" mise install; then
mise_warn "mise install failed"
return 1
fi
fi
}
if ! mise_install; then
diag "mise installation failed"
vibebox_fail "mise installation failed" 1
fi
# 3) start ssh (don't swallow failures)
@@ -124,7 +163,7 @@ if ! systemctl is-active --quiet ssh; then
if ! systemctl start ssh; then
diag "systemctl start ssh failed"
dump_diag
exit 1
vibebox_fail "failed to start ssh service" 1
fi
fi
@@ -162,7 +201,7 @@ done
if [ -z "$dev" ] || [ -z "$ip" ]; then
diag "no stable IPv4 on default route interface"
dump_diag
exit 1
vibebox_fail "no stable ipv4 route on default interface" 1
fi
# 5) strong verify: ssh must listen externally (0.0.0.0:22 or $ip:22 or [::]:22)
@@ -181,8 +220,14 @@ done
if ! listens_ok; then
diag "sshd not listening on 0.0.0.0:22 / ${ip}:22"
dump_diag
exit 1
vibebox_fail "sshd is not listening on the expected address" 1
fi
ip a
ip link
curl -s https://api.ipify.org ; echo
cat /etc/machine-id
echo VIBEBOX_SSH_READY
echo "VIBEBOX_IPV4=$ip"
+33 -31
View File
@@ -1,10 +1,4 @@
use std::{
io::{self, Write},
os::unix::io::OwnedFd,
path::PathBuf,
sync::{Arc, Mutex},
};
use bytesize::ByteSize;
use color_eyre::Result;
use crossterm::{
cursor::{MoveTo, Show},
@@ -22,9 +16,16 @@ use ratatui::{
text::{Line, Span, Text},
widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table, Widget},
};
use std::{
io::{self, Write},
os::unix::io::OwnedFd,
path::PathBuf,
sync::{Arc, Mutex},
};
use crate::vm;
// https://patorjk.com/software/taag/#p=display&f=ANSI+Shadow&t=VIBEBOX&x=none&v=4&h=4&w=80&we=false
const ASCII_BANNER: [&str; 7] = [
"██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
"██║ ██║██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗╚██╗██╔╝",
@@ -38,8 +39,9 @@ const INFO_LINE_COUNT: u16 = 5;
#[derive(Debug, Clone)]
pub struct VmInfo {
pub max_memory_mb: u64,
pub max_memory: ByteSize,
pub cpu_cores: usize,
pub max_disk: ByteSize,
pub system_name: String,
pub auto_shutdown_ms: u64,
}
@@ -472,27 +474,27 @@ fn write_buffer_with_style(buffer: &Buffer, out: &mut impl Write) -> io::Result<
Ok(())
}
fn map_color(color: ratatui::style::Color) -> CrosstermColor {
fn map_color(color: Color) -> CrosstermColor {
match color {
ratatui::style::Color::Reset => CrosstermColor::Reset,
ratatui::style::Color::Black => CrosstermColor::Black,
ratatui::style::Color::Red => CrosstermColor::DarkRed,
ratatui::style::Color::Green => CrosstermColor::DarkGreen,
ratatui::style::Color::Yellow => CrosstermColor::DarkYellow,
ratatui::style::Color::Blue => CrosstermColor::DarkBlue,
ratatui::style::Color::Magenta => CrosstermColor::DarkMagenta,
ratatui::style::Color::Cyan => CrosstermColor::DarkCyan,
ratatui::style::Color::Gray => CrosstermColor::Grey,
ratatui::style::Color::DarkGray => CrosstermColor::DarkGrey,
ratatui::style::Color::LightRed => CrosstermColor::Red,
ratatui::style::Color::LightGreen => CrosstermColor::Green,
ratatui::style::Color::LightYellow => CrosstermColor::Yellow,
ratatui::style::Color::LightBlue => CrosstermColor::Blue,
ratatui::style::Color::LightMagenta => CrosstermColor::Magenta,
ratatui::style::Color::LightCyan => CrosstermColor::Cyan,
ratatui::style::Color::White => CrosstermColor::White,
ratatui::style::Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
ratatui::style::Color::Indexed(i) => CrosstermColor::AnsiValue(i),
Color::Reset => CrosstermColor::Reset,
Color::Black => CrosstermColor::Black,
Color::Red => CrosstermColor::DarkRed,
Color::Green => CrosstermColor::DarkGreen,
Color::Yellow => CrosstermColor::DarkYellow,
Color::Blue => CrosstermColor::DarkBlue,
Color::Magenta => CrosstermColor::DarkMagenta,
Color::Cyan => CrosstermColor::DarkCyan,
Color::Gray => CrosstermColor::Grey,
Color::DarkGray => CrosstermColor::DarkGrey,
Color::LightRed => CrosstermColor::Red,
Color::LightGreen => CrosstermColor::Green,
Color::LightYellow => CrosstermColor::Yellow,
Color::LightBlue => CrosstermColor::Blue,
Color::LightMagenta => CrosstermColor::Magenta,
Color::LightCyan => CrosstermColor::Cyan,
Color::White => CrosstermColor::White,
Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
Color::Indexed(i) => CrosstermColor::AnsiValue(i),
}
}
@@ -569,11 +571,11 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
Span::styled(&app.vm_info.system_name, Style::default().fg(Color::Green)),
]),
Line::from(vec![
Span::raw("CPU / Memory: "),
Span::raw("CPU / Memory / Disk: "),
Span::styled(
format!(
"{} cores / {} MB",
app.vm_info.cpu_cores, app.vm_info.max_memory_mb
"{} cores / {} / {}",
app.vm_info.cpu_cores, app.vm_info.max_memory, app.vm_info.max_disk
),
Style::default().fg(Color::Green),
),
+18
View File
@@ -0,0 +1,18 @@
use std::{
env,
path::{Path, PathBuf},
};
pub fn relative_to_home(directory: &Path) -> String {
let Ok(home) = env::var("HOME") else {
return directory.display().to_string();
};
let home_path = PathBuf::from(home);
if let Ok(stripped) = directory.strip_prefix(&home_path) {
if stripped.components().next().is_none() {
return "~".to_string();
}
return format!("~/{}", stripped.display());
}
directory.display().to_string()
}
+314 -162
View File
@@ -1,5 +1,5 @@
use crate::instance::STATUS_FILE_NAME;
use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
use anyhow::{Context, Error, Result, bail};
use std::{
env, fs,
io::{self, Write},
@@ -23,6 +23,7 @@ use std::{
};
use block2::RcBlock;
use bytesize::ByteSize;
use dispatch2::DispatchQueue;
use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
use objc2_foundation::*;
@@ -32,74 +33,46 @@ const DEBIAN_COMPRESSED_DISK_URL: &str = "https://cloud.debian.org/images/cloud/
const DEBIAN_COMPRESSED_SHA: &str = "6ab9be9e6834adc975268367f2f0235251671184345c34ee13031749fdfbf66fe4c3aafd949a2d98550426090e9ac645e79009c51eb0eefc984c15786570bb38";
const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
const SHARED_DIRECTORIES_TAG: &str = "shared";
pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts";
const BYTES_PER_MB: u64 = 1024 * 1024;
const DEFAULT_CPU_COUNT: usize = 2;
const DEFAULT_RAM_MB: u64 = 2048;
const DEFAULT_RAM_BYTES: u64 = DEFAULT_RAM_MB * BYTES_PER_MB;
const START_TIMEOUT: Duration = Duration::from_secs(60);
const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
const PROVISION_EXPECT_TIMEOUT: Duration = Duration::from_secs(900);
struct StatusFile {
path: PathBuf,
cleared: AtomicBool,
}
impl StatusFile {
fn new(path: PathBuf) -> Self {
Self {
path,
cleared: AtomicBool::new(false),
}
}
fn update(&self, message: &str) {
let _ = fs::write(&self.path, message);
}
fn clear(&self) {
if !self.cleared.swap(true, Ordering::SeqCst) {
let _ = fs::remove_file(&self.path);
}
}
}
impl Drop for StatusFile {
fn drop(&mut self) {
if !self.cleared.load(Ordering::SeqCst) {
let _ = fs::remove_file(&self.path);
self.cleared.store(true, Ordering::SeqCst);
}
}
}
const ERROR_REPORT_SCRIPT: &str = include_str!("error_report.sh");
const PROVISION_SCRIPT: &str = include_str!("provision.sh");
const PROVISION_SCRIPT_NAME: &str = "provision.sh";
const RESIZE_DISK_SCRIPT: &str = include_str!("resize_disk.sh");
const DEFAULT_RAW_NAME: &str = "default.raw";
const INSTANCE_RAW_NAME: &str = "instance.raw";
const BASE_DISK_RAW_NAME: &str = "disk.raw";
#[derive(Clone)]
pub(crate) enum LoginAction {
Expect { text: String, timeout: Duration },
pub enum LoginAction {
Expect {
text: String,
timeout: Duration,
},
ExpectEither {
success: String,
failure: String,
timeout: Duration,
},
Send(String),
}
use crate::config::BoxConfig;
use LoginAction::*;
#[derive(Clone)]
pub(crate) struct DirectoryShare {
pub struct DirectoryShare {
host: PathBuf,
guest: PathBuf,
read_only: bool,
}
impl DirectoryShare {
pub(crate) fn new(
host: PathBuf,
mut guest: PathBuf,
read_only: bool,
) -> Result<Self, Box<dyn std::error::Error>> {
pub fn new(host: PathBuf, mut guest: PathBuf, read_only: bool) -> Result<Self> {
if !host.exists() {
return Err(format!("Host path does not exist: {}", host.display()).into());
bail!(format!("host path does not exist: {}", host.display()));
}
if !guest.is_absolute() {
guest = PathBuf::from("/root").join(guest);
@@ -111,10 +84,10 @@ impl DirectoryShare {
})
}
pub(crate) fn from_mount_spec(spec: &str) -> Result<Self, Box<dyn std::error::Error>> {
pub fn from_mount_spec(spec: &str) -> Result<Self> {
let parts: Vec<&str> = spec.split(':').collect();
if parts.len() < 2 || parts.len() > 3 {
return Err(format!("Invalid mount spec: {spec}").into());
bail!(format!("invalid mount spec: {spec}"));
}
let host = expand_tilde_path(parts[0]);
let guest = PathBuf::from(parts[1]);
@@ -123,11 +96,10 @@ impl DirectoryShare {
"read-only" => true,
"read-write" => false,
_ => {
return Err(format!(
bail!(format!(
"Invalid mount mode '{}'; expected read-only or read-write",
parts[2]
)
.into());
));
}
}
} else {
@@ -166,23 +138,33 @@ fn expand_tilde_path(value: &str) -> PathBuf {
pub struct VmArg {
pub cpu_count: usize,
pub ram_bytes: u64,
pub disk_bytes: u64,
pub no_default_mounts: bool,
pub mounts: Vec<String>,
}
pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<(), Box<dyn std::error::Error>>
type StatusEmitter<'a> = dyn Fn(&str) + std::marker::Send + Sync + 'a;
fn emit_status(status: Option<&StatusEmitter<'_>>, message: &str) {
if let Some(status) = status {
status(message);
}
}
pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<()>
where
F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
{
run_with_args_and_extras(args, io_handler, Vec::new(), Vec::new())
run_with_args_and_extras(args, io_handler, Vec::new(), Vec::new(), None)
}
pub(crate) fn run_with_args_and_extras<F>(
pub fn run_with_args_and_extras<F>(
args: VmArg,
io_handler: F,
extra_login_actions: Vec<LoginAction>,
extra_directory_shares: Vec<DirectoryShare>,
) -> Result<(), Box<dyn std::error::Error>>
status: Option<&StatusEmitter<'_>>,
) -> Result<()>
where
F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
{
@@ -191,7 +173,7 @@ where
let project_root = env::current_dir()?;
let project_name = project_root
.file_name()
.ok_or("Project directory has no name")?
.with_context(|| "Project directory has no name")?
.to_string_lossy()
.into_owned();
@@ -203,8 +185,10 @@ where
let guest_mise_cache = cache_dir.join(".guest-mise-cache");
let instance_dir = project_root.join(INSTANCE_DIR_NAME);
let status_file = StatusFile::new(instance_dir.join(STATUS_FILE_NAME));
status_file.update("preparing VM image...");
fs::create_dir_all(&instance_dir)?;
emit_status(status, "preparing VM image...");
tracing::info!("preparing VM image...");
let provision_log = instance_dir.join("provision.log");
let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
let base_compressed = cache_dir.join(basename_compressed);
@@ -228,16 +212,26 @@ where
&base_compressed,
&default_raw,
std::slice::from_ref(&mise_directory_share),
Some(&status_file),
Some(&provision_log),
status,
)?;
ensure_instance_disk(&instance_raw, &default_raw, Some(&status_file))?;
let _ = ensure_instance_disk(
&instance_raw,
&default_raw,
ByteSize(args.disk_bytes),
status,
)?;
let base_size = fs::metadata(&default_raw)?.len();
let instance_size = fs::metadata(&instance_raw)?.len();
let needs_resize = instance_size > base_size;
let disk_path = instance_raw;
let mut login_actions = Vec::new();
let mut directory_shares = Vec::new();
if !args.no_default_mounts {
login_actions.push(Send(format!("cd {project_name}")));
let project_guest_dir = PathBuf::from(PROJECT_GUEST_BASE).join(project_name);
login_actions.push(Send(format!("cd {}", project_guest_dir.display())));
// 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.
// I couldn't find an alternative way to do this --- the MacOS sandbox doesn't apply to the Apple Virtualization system
@@ -245,15 +239,6 @@ where
login_actions.push(Send(r"mount -t tmpfs tmpfs .git/".into()));
}
directory_shares.push(
DirectoryShare::new(
project_root,
PathBuf::from("/root/").join(project_name),
false,
)
.expect("Project directory must exist"),
);
directory_shares.push(mise_directory_share);
}
@@ -263,6 +248,11 @@ where
directory_shares.push(DirectoryShare::from_mount_spec(spec)?);
}
if needs_resize {
let resize_cmd = script_command_from_content("resize_disk.sh", RESIZE_DISK_SCRIPT)?;
login_actions.push(Send(resize_cmd));
}
if let Some(motd_action) = motd_login_action(&directory_shares) {
login_actions.push(motd_action);
}
@@ -275,25 +265,29 @@ where
&directory_shares[..],
args.cpu_count,
args.ram_bytes,
Some(&status_file),
status,
io_handler,
)
}
pub(crate) fn script_command_from_content(
label: &str,
script: &str,
) -> Result<String, Box<dyn std::error::Error>> {
pub fn script_command_from_content(label: &str, script: &str) -> Result<String> {
let marker = "VIBE_SCRIPT_EOF";
let guest_dir = "/tmp/vibe-scripts";
let guest_path = format!("{guest_dir}/{label}.sh");
let script_body = match script.split_once('\n') {
Some((first, rest)) if first.starts_with("#!") => rest,
_ => script,
};
let wrapped_script = ERROR_REPORT_SCRIPT
.replace("__LABEL__", label)
.replace("__SCRIPT_BODY__", script_body);
let command = format!(
"mkdir -p {guest_dir}\ncat >{guest_path} <<'{marker}'\n{script}\n{marker}\nchmod +x {guest_path}\n{guest_path}"
"mkdir -p {guest_dir}\ncat >{guest_path} <<'{marker}'\n{wrapped_script}\n{marker}\nchmod +x {guest_path}\n{guest_path}"
);
if script.contains(marker) {
return Err(
format!("Script '{label}' contains marker '{marker}', cannot safely upload").into(),
);
bail!(format!(
"Script '{label}' contains marker '{marker}', cannot safely upload"
));
}
Ok(command)
}
@@ -357,6 +351,12 @@ enum WaitResult {
Found,
}
#[derive(PartialEq, Eq)]
enum WaitAnyResult {
Timeout,
Found(usize),
}
pub enum VmInput {
Bytes(Vec<u8>),
Shutdown,
@@ -364,6 +364,7 @@ pub enum VmInput {
enum VmOutput {
LoginActionTimeout { action: String, timeout: Duration },
LoginActionFailed { action: String, reason: String },
}
#[derive(Default)]
@@ -400,6 +401,41 @@ impl OutputMonitor {
WaitResult::Found
}
}
fn wait_for_any(&self, needles: &[&str], timeout: Duration) -> WaitAnyResult {
let mut found: Option<usize> = None;
let (_unused, timeout_result) = self
.condvar
.wait_timeout_while(self.buffer.lock().unwrap(), timeout, |buf| {
if let Some((pos, idx, len)) = find_any(buf, needles) {
*buf = buf[(pos + len)..].to_string();
found = Some(idx);
false
} else {
true
}
})
.unwrap();
if timeout_result.timed_out() {
WaitAnyResult::Timeout
} else {
WaitAnyResult::Found(found.unwrap_or(0))
}
}
}
fn find_any(buf: &str, needles: &[&str]) -> Option<(usize, usize, usize)> {
let mut best: Option<(usize, usize, usize)> = None; // (pos, idx, len)
for (idx, needle) in needles.iter().enumerate() {
if let Some(pos) = buf.find(needle) {
let candidate = (pos, idx, needle.len());
if best.is_none_or(|b| candidate.0 < b.0) {
best = Some(candidate);
}
}
}
best
}
#[derive(Debug)]
@@ -448,18 +484,16 @@ impl IoControl {
fn ensure_base_image(
base_raw: &Path,
base_compressed: &Path,
status: Option<&StatusFile>,
) -> Result<(), Box<dyn std::error::Error>> {
status: Option<&StatusEmitter<'_>>,
) -> Result<()> {
if base_raw.exists() {
return Ok(());
}
if !base_compressed.exists()
|| std::fs::metadata(base_compressed).map(|m| m.len())? < DEBIAN_COMPRESSED_SIZE_BYTES
|| fs::metadata(base_compressed).map(|m| m.len())? < DEBIAN_COMPRESSED_SIZE_BYTES
{
if let Some(status) = status {
status.update("downloading base image...");
}
emit_status(status, "downloading base image...");
tracing::info!("downloading base image");
let status = Command::new("curl")
.args([
@@ -474,15 +508,14 @@ fn ensure_base_image(
])
.status()?;
if !status.success() {
return Err("Failed to download base image".into());
bail!("failed to download base image");
}
}
// Check SHA
{
if let Some(status) = status {
status.update("verifying base image...");
}
emit_status(status, "verifying base image...");
tracing::info!("verifying base image...");
let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
let mut child = Command::new("/usr/bin/shasum")
@@ -500,25 +533,25 @@ fn ensure_base_image(
let status = child.wait().expect("failed to wait on child");
if !status.success() {
return Err(format!("SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}").into());
bail!(format!(
"SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}"
));
}
}
if let Some(status) = status {
status.update("decompressing base image...");
}
tracing::info!("decompressing base image");
emit_status(status, "decompressing base image...");
tracing::info!("decompressing base image...");
let status = Command::new("tar")
.args([
"-xOf",
&base_compressed.to_string_lossy(),
BASE_DISK_RAW_NAME,
])
.stdout(std::fs::File::create(base_raw)?)
.stdout(fs::File::create(base_raw)?)
.status()?;
if !status.success() {
return Err("Failed to decompress base image".into());
bail!("Failed to decompress base image");
}
Ok(())
@@ -529,29 +562,56 @@ fn ensure_default_image(
base_compressed: &Path,
default_raw: &Path,
directory_shares: &[DirectoryShare],
status: Option<&StatusFile>,
) -> Result<(), Box<dyn std::error::Error>> {
provision_log: Option<&Path>,
status: Option<&StatusEmitter<'_>>,
) -> Result<()> {
if default_raw.exists() {
return Ok(());
}
ensure_base_image(base_raw, base_compressed, status)?;
if let Some(status) = status {
status.update("configuring base image...");
}
tracing::info!("configuring base image");
emit_status(status, "configuring base image...");
tracing::info!("configuring base image...");
fs::copy(base_raw, default_raw)?;
let provision_command = script_command_from_content(PROVISION_SCRIPT_NAME, PROVISION_SCRIPT)?;
run_vm(
default_raw,
&[Send(provision_command)],
directory_shares,
DEFAULT_CPU_COUNT,
DEFAULT_RAM_BYTES,
None,
)?;
let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?;
let provision_actions = [
Send(provision_command),
ExpectEither {
success: "VIBEBOX_PROVISION_OK".to_string(),
failure: "VIBEBOX_PROVISION_FAILED".to_string(),
timeout: PROVISION_EXPECT_TIMEOUT,
},
];
let provision_result = if let Some(log_path) = provision_log {
let log_path = log_path.to_path_buf();
run_vm_with_io(
default_raw,
&provision_actions,
directory_shares,
BoxConfig::default().cpu_count,
BoxConfig::default().ram_size.as_u64(),
status,
move |output_monitor, vm_output_fd, vm_input_fd| {
spawn_vm_io_with_log(output_monitor, vm_output_fd, vm_input_fd, log_path)
},
)
} else {
run_vm(
default_raw,
&provision_actions,
directory_shares,
BoxConfig::default().cpu_count,
BoxConfig::default().ram_size.as_u64(),
status,
)
};
if let Err(err) = provision_result {
let _ = fs::remove_file(default_raw);
return Err(err);
}
Ok(())
}
@@ -559,19 +619,47 @@ fn ensure_default_image(
fn ensure_instance_disk(
instance_raw: &Path,
template_raw: &Path,
status: Option<&StatusFile>,
) -> Result<(), Box<dyn std::error::Error>> {
target_bytes: ByteSize,
status: Option<&StatusEmitter<'_>>,
) -> Result<bool> {
if instance_raw.exists() {
return Ok(());
let current_size = ByteSize(fs::metadata(instance_raw)?.len());
if current_size != target_bytes {
let current_gb = current_size;
let target_gb = target_bytes;
tracing::warn!(
"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.",
current_gb,
target_gb
);
}
return Ok(false);
}
if let Some(status) = status {
status.update("creating instance disk...");
let template_size = ByteSize(fs::metadata(template_raw)?.len());
if target_bytes < template_size {
bail!(format!(
"Requested disk size {} is smaller than base image size {}",
target_bytes, template_size
));
}
let target_size = target_bytes;
let needs_resize = target_size > template_size;
emit_status(status, "creating instance disk...");
tracing::info!("creating instance disk...");
tracing::info!(path = %template_raw.display(), "creating instance disk");
std::fs::create_dir_all(instance_raw.parent().unwrap())?;
fs::copy(template_raw, instance_raw)?;
Ok(())
fs::create_dir_all(instance_raw.parent().unwrap())?;
if target_size == template_size {
fs::copy(template_raw, instance_raw)?;
return Ok(needs_resize);
}
let mut dst = fs::File::create(instance_raw)?;
dst.set_len(target_size.as_u64())?;
let mut src = fs::File::open(template_raw)?;
std::io::copy(&mut src, &mut dst)?;
Ok(needs_resize)
}
pub struct IoContext {
@@ -587,18 +675,17 @@ pub fn create_pipe() -> (OwnedFd, OwnedFd) {
(read_stream.into(), write_stream.into())
}
pub fn spawn_vm_io_with_hooks<F, G>(
pub fn spawn_vm_io_with_hooks<
F: FnMut(&str) -> bool + std::marker::Send + 'static,
G: FnMut(&[u8]) + std::marker::Send + 'static,
>(
output_monitor: Arc<OutputMonitor>,
vm_output_fd: OwnedFd,
vm_input_fd: OwnedFd,
io_control: Arc<IoControl>,
mut on_line: F,
mut on_output: G,
) -> IoContext
where
F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
G: FnMut(&[u8]) + ::std::marker::Send + 'static,
{
) -> IoContext {
let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
// raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
@@ -746,14 +833,14 @@ where
PollResult::Spurious => continue,
PollResult::Ready(bytes) => {
if io_control.forward_output() {
// enable raw mode, if we haven't already
// enable raw mode if we haven't already
if raw_guard.lock().unwrap().is_none()
&& let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
{
*raw_guard.lock().unwrap() = Some(guard);
}
let mut stdout = std::io::stdout().lock();
let mut stdout = io::stdout().lock();
if stdout.write_all(bytes).is_err() {
break;
}
@@ -769,7 +856,7 @@ where
// Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
let mux_thread = thread::spawn(move || {
let mut vm_writer = std::fs::File::from(vm_input_fd);
let mut vm_writer = fs::File::from(vm_input_fd);
loop {
match input_rx.recv() {
Ok(VmInput::Bytes(data)) => {
@@ -799,7 +886,7 @@ pub fn spawn_vm_io_with_line_handler<F>(
on_line: F,
) -> IoContext
where
F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
F: FnMut(&str) -> bool + std::marker::Send + 'static,
{
spawn_vm_io_with_hooks(
output_monitor,
@@ -819,6 +906,36 @@ pub fn spawn_vm_io(
spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
}
fn spawn_vm_io_with_log(
output_monitor: Arc<OutputMonitor>,
vm_output_fd: OwnedFd,
vm_input_fd: OwnedFd,
log_path: PathBuf,
) -> IoContext {
let log_file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&log_path)
.ok()
.map(|file| Arc::new(Mutex::new(file)));
spawn_vm_io_with_hooks(
output_monitor,
vm_output_fd,
vm_input_fd,
IoControl::new(),
|_| false,
move |bytes| {
if let Some(log) = &log_file
&& let Ok(mut file) = log.lock()
{
let _ = file.write_all(bytes);
}
},
)
}
impl IoContext {
pub fn shutdown(self) {
let _ = self.input_tx.send(VmInput::Shutdown);
@@ -836,7 +953,7 @@ fn create_vm_configuration(
vm_writes_to_fd: OwnedFd,
cpu_count: usize,
ram_bytes: u64,
) -> Result<Retained<VZVirtualMachineConfiguration>, Box<dyn std::error::Error>> {
) -> Result<Retained<VZVirtualMachineConfiguration>> {
unsafe {
let platform =
VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
@@ -870,7 +987,7 @@ fn create_vm_configuration(
false,
VZDiskImageCachingMode::Automatic,
VZDiskImageSynchronizationMode::Full,
).unwrap();
)?;
let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
VZVirtioBlockDeviceConfiguration::alloc(),
@@ -957,7 +1074,7 @@ fn create_vm_configuration(
// Validate
config.validateWithError().map_err(|e| {
io::Error::other(format!(
"Invalid VM configuration: {:?}",
"invalid VM configuration: {:?}",
e.localizedDescription()
))
})?;
@@ -966,9 +1083,9 @@ fn create_vm_configuration(
}
}
fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std::error::Error>> {
fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>> {
unsafe {
let temp_dir = std::env::temp_dir();
let temp_dir = env::temp_dir();
let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
let url = nsurl_from_path(&temp_path)?;
let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
@@ -984,8 +1101,8 @@ fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std
fn spawn_login_actions_thread(
login_actions: Vec<LoginAction>,
output_monitor: Arc<OutputMonitor>,
input_tx: mpsc::Sender<VmInput>,
vm_output_tx: mpsc::Sender<VmOutput>,
input_tx: Sender<VmInput>,
vm_output_tx: Sender<VmOutput>,
) -> thread::JoinHandle<()> {
thread::spawn(move || {
for a in login_actions {
@@ -999,6 +1116,27 @@ fn spawn_login_actions_thread(
return;
}
}
ExpectEither {
success,
failure,
timeout,
} => match output_monitor.wait_for_any(&[&success, &failure], timeout) {
WaitAnyResult::Found(0) => {}
WaitAnyResult::Found(_) => {
let _ = vm_output_tx.send(VmOutput::LoginActionFailed {
action: format!("expect '{}'", success),
reason: format!("saw failure marker '{}'", failure),
});
return;
}
WaitAnyResult::Timeout => {
let _ = vm_output_tx.send(VmOutput::LoginActionTimeout {
action: format!("expect '{}'", success),
timeout,
});
return;
}
},
Send(mut text) => {
text.push('\n'); // Type the newline so the command is actually submitted.
input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
@@ -1014,9 +1152,9 @@ fn run_vm_with_io<F>(
directory_shares: &[DirectoryShare],
cpu_count: usize,
ram_bytes: u64,
status: Option<&StatusFile>,
status: Option<&StatusEmitter<'_>>,
io_handler: F,
) -> Result<(), Box<dyn std::error::Error>>
) -> Result<()>
where
F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
{
@@ -1063,24 +1201,22 @@ where
match rx.try_recv() {
Ok(result) => {
result.map_err(|e| format!("Failed to start VM: {}", e))?;
result.map_err(|e| Error::msg(format!("Failed to start VM: {}", e)))?;
break;
}
Err(mpsc::TryRecvError::Empty) => continue,
Err(mpsc::TryRecvError::Disconnected) => {
return Err("VM start channel disconnected".into());
bail!("VM start channel disconnected");
}
}
}
if Instant::now() >= start_deadline {
return Err("Timed out waiting for VM to start".into());
bail!("Timed out waiting for VM to start");
}
if let Some(status) = status {
status.update("vm booting... go vibecoder!");
status.clear();
}
emit_status(status, "vm booting... go vibecoder!");
tracing::info!("vm booting... go vibecoder!");
tracing::info!("vm booting");
let output_monitor = Arc::new(OutputMonitor::default());
@@ -1126,7 +1262,7 @@ where
);
let mut last_state = None;
let mut exit_result = Ok(());
let mut exit_result: Result<(), String> = Ok(());
loop {
unsafe {
NSRunLoop::mainRunLoop().runMode_beforeDate(
@@ -1145,8 +1281,24 @@ where
exit_result = Err(format!(
"Login action ({}) timed out after {:?}; shutting down.",
action, timeout
)
.into());
));
unsafe {
if vm.canRequestStop() {
if let Err(err) = vm.requestStopWithError() {
tracing::error!(error = ?err, "failed to request VM stop");
}
} else if vm.canStop() {
let handler = RcBlock::new(|_error: *mut NSError| {});
vm.stopWithCompletionHandler(&handler);
}
}
break;
}
Ok(VmOutput::LoginActionFailed { action, reason }) => {
exit_result = Err(format!(
"Login action ({}) failed: {}; shutting down.",
action, reason
));
unsafe {
if vm.canRequestStop() {
if let Err(err) = vm.requestStopWithError() {
@@ -1162,7 +1314,7 @@ where
Err(mpsc::TryRecvError::Empty) => {}
Err(mpsc::TryRecvError::Disconnected) => {}
}
if state != objc2_virtualization::VZVirtualMachineState::Running {
if state != VZVirtualMachineState::Running {
//eprintln!("VM stopped with state: {:?}", state);
break;
}
@@ -1172,7 +1324,7 @@ where
io_ctx.shutdown();
exit_result
exit_result.map_err(Error::msg)
}
fn run_vm(
@@ -1181,8 +1333,8 @@ fn run_vm(
directory_shares: &[DirectoryShare],
cpu_count: usize,
ram_bytes: u64,
status: Option<&StatusFile>,
) -> Result<(), Box<dyn std::error::Error>> {
status: Option<&StatusEmitter<'_>>,
) -> Result<()> {
run_vm_with_io(
disk_path,
login_actions,
@@ -1194,7 +1346,7 @@ fn run_vm(
)
}
fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>> {
let abs_path = if path.is_absolute() {
path.to_path_buf()
} else {
@@ -1203,7 +1355,7 @@ fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::E
let ns_path = NSString::from_str(
abs_path
.to_str()
.ok_or("Non-UTF8 path encountered while building NSURL")?,
.with_context(|| "non-UTF8 path encountered while building NSURL")?,
);
Ok(NSURL::fileURLWithPath(&ns_path))
}
@@ -1218,7 +1370,7 @@ fn enable_raw_mode(fd: i32) -> io::Result<RawModeGuard> {
let original = attributes;
// Disable translation of carriage return to newline on input
attributes.c_iflag &= !(libc::ICRNL);
attributes.c_iflag &= !libc::ICRNL;
// Disable canonical mode (line buffering), echo, and signal generation
attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
attributes.c_cc[libc::VMIN] = 0;
@@ -1246,10 +1398,10 @@ impl Drop for RawModeGuard {
// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
pub fn ensure_signed() {
if std::env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
if env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
return;
}
let exe = std::env::current_exe().expect("failed to get current exe path");
let exe = env::current_exe().expect("failed to get current exe path");
let exe_str = exe.to_str().expect("exe path not valid utf-8");
let has_required_entitlements = {
@@ -1271,8 +1423,8 @@ pub fn ensure_signed() {
}
const ENTITLEMENTS: &str = include_str!("entitlements.plist");
let entitlements_path = std::env::temp_dir().join("entitlements.plist");
std::fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
let entitlements_path = env::temp_dir().join("entitlements.plist");
fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
let output = Command::new("codesign")
.args([
@@ -1285,7 +1437,7 @@ pub fn ensure_signed() {
])
.output();
let _ = std::fs::remove_file(&entitlements_path);
let _ = fs::remove_file(&entitlements_path);
match output {
Ok(o) if o.status.success() => {
@@ -1293,7 +1445,7 @@ pub fn ensure_signed() {
if !stderr.trim().is_empty() {
tracing::debug!(codesign_stderr = %stderr.trim(), "codesign output");
}
let err = Command::new(&exe).args(std::env::args_os().skip(1)).exec();
let err = Command::new(&exe).args(env::args_os().skip(1)).exec();
tracing::error!(error = %err, "failed to re-exec after signing");
std::process::exit(1);
}
+414 -127
View File
@@ -1,12 +1,22 @@
use crate::instance::{VmLiveness, vm_liveness};
use crate::session_manager::INSTANCE_DIR_NAME;
use crate::{
config::CONFIG_PATH_ENV,
instance::{
InstanceConfig, STATUS_VM_ERROR_PREFIX, build_ssh_login_actions, ensure_instance_dir,
ensure_ssh_keypair, extract_ipv4, load_or_create_instance_config, write_instance_config,
},
session_manager::{GLOBAL_DIR_NAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME},
vm::{self, DirectoryShare, LoginAction, PROJECT_GUEST_BASE, VmInput},
};
use anyhow::{Error, Result, anyhow, bail};
use std::{
env, fs,
io::{Read, Write},
os::unix::{
fs::FileTypeExt,
fs::PermissionsExt,
io::AsRawFd,
net::{UnixListener, UnixStream},
process::CommandExt,
},
path::{Path, PathBuf},
process::{Command, Stdio},
@@ -15,36 +25,44 @@ use std::{
time::{Duration, Instant},
};
use crate::{
config::CONFIG_PATH_ENV,
instance::VM_ROOT_LOG_NAME,
instance::{
DEFAULT_SSH_USER, InstanceConfig, build_ssh_login_actions, ensure_instance_dir,
ensure_ssh_keypair, extract_ipv4, load_or_create_instance_config, write_instance_config,
},
session_manager::{
GLOBAL_DIR_NAME, INSTANCE_FILENAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME,
},
vm::{self, DirectoryShare, LoginAction, VmInput},
};
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
const VM_ROOT_LOG_NAME: &str = "vm_root.log";
const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
const SHUTDOWN_RETRY_MS: u64 = 500;
#[cfg(test)]
const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 1_000;
#[cfg(not(test))]
const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 12_000;
const STATUS_PREFIX: &str = "status:";
type ClientStreams = Arc<Mutex<Vec<UnixStream>>>;
type SharedStatus = Arc<Mutex<String>>;
#[cfg(not(test))]
fn force_exit(_reason: &str) -> ! {
std::process::exit(1);
}
#[cfg(test)]
fn force_exit(reason: &str) -> ! {
panic!("{reason}");
}
pub fn ensure_manager(
raw_args: &[std::ffi::OsString],
auto_shutdown_ms: u64,
config_path: Option<&Path>,
) -> Result<UnixStream, Box<dyn std::error::Error>> {
) -> Result<UnixStream> {
let project_root = env::current_dir()?;
tracing::debug!(root = %project_root.display(), "ensure vm manager");
let instance_dir = ensure_instance_dir(&project_root)?;
cleanup_stale_manager(&instance_dir);
cleanup_stale_manager(&project_root)?;
let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
if let Ok(stream) = UnixStream::connect(&socket_path) {
send_client_pid(&stream);
tracing::info!(path = %socket_path.display(), "connected to existing vm manager");
tracing::info!(path = %socket_path.display(), "connected to an existing vm manager");
return Ok(stream);
}
@@ -82,12 +100,11 @@ pub fn ensure_manager(
drop(lock_file.take());
let _ = fs::remove_file(&lock_path);
}
return Err(format!(
"Timed out waiting for vm manager socket: {} ({})",
bail!(format!(
"timed out waiting for vm manager socket: {} ({})",
socket_path.display(),
err
)
.into());
));
}
thread::sleep(Duration::from_millis(100));
}
@@ -95,24 +112,49 @@ pub fn ensure_manager(
}
}
pub fn run_manager(
args: vm::VmArg,
auto_shutdown_ms: u64,
) -> Result<(), Box<dyn std::error::Error>> {
pub fn run_manager(args: vm::VmArg, auto_shutdown_ms: u64) -> Result<()> {
let project_root = env::current_dir()?;
tracing::info!(root = %project_root.display(), "vm manager starting");
#[cfg(not(feature = "mock-vm"))]
{
unsafe {
env::remove_var("VIBEBOX_SKIP_CODESIGN");
}
vm::ensure_signed();
unsafe {
env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
}
}
let _pid_guard = ensure_pid_file(&project_root)?;
run_manager_with(
&project_root,
args,
auto_shutdown_ms,
&RealVmExecutor,
ManagerOptions {
ensure_signed: true,
detach: true,
prepare_vm: true,
},
)
#[cfg(feature = "mock-vm")]
tracing::info!("vm manager using mock executor");
let executor: &dyn VmExecutor = {
#[cfg(feature = "mock-vm")]
{
&MockVmExecutor
}
#[cfg(not(feature = "mock-vm"))]
{
&RealVmExecutor
}
};
let options = {
#[cfg(feature = "mock-vm")]
{
ManagerOptions {
detach: true,
prepare_vm: false,
}
}
#[cfg(not(feature = "mock-vm"))]
{
ManagerOptions {
detach: true,
prepare_vm: true,
}
}
};
run_manager_with(&project_root, args, auto_shutdown_ms, executor, options)
}
fn spawn_manager_process(
@@ -120,25 +162,22 @@ fn spawn_manager_process(
auto_shutdown_ms: u64,
instance_dir: &Path,
config_path: Option<&Path>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<()> {
let exe = env::current_exe()?;
let mut supervisor_exe = exe.clone();
supervisor_exe.set_file_name("vibebox-supervisor");
let use_supervisor = supervisor_exe.exists();
let mut cmd = if use_supervisor {
Command::new(supervisor_exe)
} else {
let mut cmd = Command::new(exe);
cmd.arg0("vibebox-supervisor");
cmd
};
// intentional
if !supervisor_exe.exists() {
bail!(format!(
"vibebox-supervisor not found at {}",
supervisor_exe.display()
));
}
let mut cmd = Command::new(supervisor_exe);
if raw_args.len() > 1 {
cmd.args(&raw_args[1..]);
}
cmd.env("VIBEBOX_INTERNAL", "1");
if !use_supervisor {
cmd.env("VIBEBOX_VM_MANAGER", "1");
}
cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
if let Some(path) = config_path {
@@ -166,22 +205,22 @@ fn spawn_manager_process(
Ok(())
}
fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard, Box<dyn std::error::Error>> {
fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard> {
let instance_dir = ensure_instance_dir(project_root)?;
let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
if let Ok(content) = fs::read_to_string(&pid_path)
&& let Ok(pid) = content.trim().parse::<u32>()
&& pid_is_alive(pid)
{
if is_socket_path(&socket_path) {
return Err(format!("vm manager already running (pid {pid})").into());
match vm_liveness(project_root)? {
VmLiveness::RunningWithSocket { pid } => {
bail!("vm manager already running (pid {pid})");
}
tracing::warn!(
pid,
path = %socket_path.display(),
"stale pid file detected with missing socket"
);
VmLiveness::RunningWithoutSocket { pid } => {
tracing::warn!(
pid,
path = %socket_path.display(),
"stale pid file detected with missing socket"
);
}
VmLiveness::NotRunningOrMissing => {}
}
let _ = fs::remove_file(&pid_path);
fs::write(&pid_path, format!("{}\n", std::process::id()))?;
@@ -189,21 +228,42 @@ fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard, Box<dyn std::err
Ok(PidFileGuard { path: pid_path })
}
fn cleanup_stale_manager(instance_dir: &Path) {
let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
if let Ok(content) = fs::read_to_string(&pid_path)
&& let Ok(pid) = content.trim().parse::<u32>()
&& pid_is_alive(pid)
{
return;
fn cleanup_stale_manager(project_root: &Path) -> Result<()> {
let pid_path = project_root
.join(INSTANCE_DIR_NAME)
.join(VM_MANAGER_PID_NAME);
if matches!(
vm_liveness(project_root)?,
VmLiveness::RunningWithSocket { .. } | VmLiveness::RunningWithoutSocket { .. }
) {
return Ok(());
}
let _ = fs::remove_file(&pid_path);
Ok(())
}
fn is_socket_path(path: &Path) -> bool {
fs::metadata(path)
.map(|meta| meta.file_type().is_socket())
.unwrap_or(false)
fn inject_project_mount(
mounts: &mut Vec<String>,
project_root: &Path,
ssh_user: &str,
project_name: &str,
) {
let guest_tilde = format!("~/{project_name}");
let guest_home = format!("/home/{ssh_user}/{project_name}");
let guest_base = format!("{PROJECT_GUEST_BASE}/{project_name}");
let already_mapped = mounts.iter().any(|spec| {
let parts: Vec<&str> = spec.split(':').collect();
if parts.len() < 2 {
return false;
}
let guest = parts[1];
guest == guest_tilde || guest == guest_home || guest == guest_base
});
if already_mapped {
return;
}
let host = project_root.display();
mounts.insert(0, format!("{host}:{guest_tilde}:read-write"));
}
fn prepare_mounts_and_links(mut args: vm::VmArg, ssh_user: &str) -> (vm::VmArg, String) {
@@ -252,7 +312,7 @@ fn rewrite_mount_spec(spec: &str, ssh_user: &str) -> (String, Option<HomeLink>)
return (spec.to_string(), None);
}
let root_base = "/usr/local/vibebox-mounts";
let root_base = PROJECT_GUEST_BASE;
let root_path = if rel.is_empty() {
root_base.to_string()
} else {
@@ -349,6 +409,30 @@ fn wait_for_disconnect(mut stream: UnixStream) {
}
}
fn remove_client(streams: &ClientStreams, fd: std::os::fd::RawFd) {
if let Ok(mut clients) = streams.lock() {
clients.retain(|stream| stream.as_raw_fd() != fd);
}
}
fn send_status_line(stream: &mut UnixStream, status: &str) -> bool {
let mut payload = String::with_capacity(STATUS_PREFIX.len() + status.len() + 1);
payload.push_str(STATUS_PREFIX);
payload.push_str(status);
payload.push('\n');
stream.write_all(payload.as_bytes()).is_ok()
}
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
fn broadcast_status(streams: &ClientStreams, latest_status: &SharedStatus, status: &str) {
if let Ok(mut current) = latest_status.lock() {
*current = status.to_string();
}
if let Ok(mut clients) = streams.lock() {
clients.retain_mut(|stream| send_status_line(stream, status));
}
}
fn send_client_pid(stream: &UnixStream) {
let pid = std::process::id();
let payload = format!("pid={pid}\n");
@@ -358,7 +442,7 @@ fn send_client_pid(stream: &UnixStream) {
}
}
fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>, Box<dyn std::error::Error>> {
fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>> {
match fs::OpenOptions::new()
.write(true)
.create_new(true)
@@ -392,6 +476,12 @@ fn is_lock_stale(lock_path: &Path) -> bool {
}
}
fn read_lock_pid(lock_path: &Path) -> Option<u32> {
let content = fs::read_to_string(lock_path).ok()?;
let line = content.lines().next()?;
line.strip_prefix("pid=")?.trim().parse::<u32>().ok()
}
fn pid_is_alive(pid: u32) -> bool {
let pid = pid as libc::pid_t;
let result = unsafe { libc::kill(pid, 0) };
@@ -405,12 +495,6 @@ fn pid_is_alive(pid: u32) -> bool {
}
}
fn read_lock_pid(lock_path: &Path) -> Option<u32> {
let content = fs::read_to_string(lock_path).ok()?;
let line = content.lines().next()?;
line.strip_prefix("pid=")?.trim().parse::<u32>().ok()
}
fn read_client_pid(stream: &UnixStream) -> Option<u32> {
let mut stream = stream.try_clone().ok()?;
let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
@@ -443,14 +527,17 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
}
}
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
fn spawn_manager_io(
config: Arc<Mutex<InstanceConfig>>,
instance_dir: PathBuf,
project_dir: PathBuf,
clients: ClientStreams,
latest_status: SharedStatus,
output_monitor: Arc<vm::OutputMonitor>,
vm_output_fd: std::os::unix::io::OwnedFd,
vm_input_fd: std::os::unix::io::OwnedFd,
) -> vm::IoContext {
let log_path = instance_dir.join(VM_ROOT_LOG_NAME);
let log_path = project_dir.join(INSTANCE_DIR_NAME).join(VM_ROOT_LOG_NAME);
let log_file = fs::OpenOptions::new()
.create(true)
.write(true)
@@ -459,7 +546,6 @@ fn spawn_manager_io(
.ok()
.map(|file| Arc::new(Mutex::new(file)));
let instance_path = instance_dir.join(INSTANCE_FILENAME);
let config_for_output = config.clone();
let log_for_output = log_file.clone();
let mut line_buf = String::new();
@@ -482,6 +568,17 @@ fn spawn_manager_io(
line_buf.drain(..=pos);
let cleaned = line.trim_start_matches(['\r', ' ']);
if let Some(script_failure) = cleaned.strip_prefix("VIBEBOX_SCRIPT_ERROR:") {
let failure = script_failure.trim();
if !failure.is_empty() {
tracing::error!(script_failure = %failure, "[vm] script reported failure");
broadcast_status(
&clients,
&latest_status,
&format!("{STATUS_VM_ERROR_PREFIX} {failure}"),
);
}
}
if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
let ip = extract_ipv4(ip_raw).unwrap_or_default();
@@ -490,7 +587,7 @@ fn spawn_manager_io(
&& cfg.vm_ipv4.as_deref() != Some(ip.as_str())
{
cfg.vm_ipv4 = Some(ip.clone());
let _ = write_instance_config(&instance_path, &cfg);
let _ = write_instance_config(&project_dir, &cfg);
}
}
}
@@ -513,12 +610,12 @@ enum ManagerEvent {
}
struct ManagerOptions {
ensure_signed: bool,
detach: bool,
prepare_vm: bool,
}
trait VmExecutor {
#[allow(clippy::too_many_arguments)]
fn run_vm(
&self,
args: vm::VmArg,
@@ -526,10 +623,13 @@ trait VmExecutor {
extra_shares: Vec<DirectoryShare>,
config: Arc<Mutex<InstanceConfig>>,
instance_dir: PathBuf,
clients: ClientStreams,
latest_status: SharedStatus,
vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
) -> Result<(), Box<dyn std::error::Error>>;
) -> Result<()>;
}
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
struct RealVmExecutor;
impl VmExecutor for RealVmExecutor {
@@ -539,15 +639,22 @@ impl VmExecutor for RealVmExecutor {
extra_login_actions: Vec<LoginAction>,
extra_shares: Vec<DirectoryShare>,
config: Arc<Mutex<InstanceConfig>>,
instance_dir: PathBuf,
project_dir: PathBuf,
clients: ClientStreams,
latest_status: SharedStatus,
vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<()> {
let status_callback = |status: &str| {
broadcast_status(&clients, &latest_status, status);
};
vm::run_with_args_and_extras(
args,
|output_monitor, vm_output_fd, vm_input_fd| {
let io_ctx = spawn_manager_io(
config.clone(),
instance_dir.clone(),
project_dir.clone(),
clients.clone(),
latest_status.clone(),
output_monitor,
vm_output_fd,
vm_input_fd,
@@ -557,34 +664,60 @@ impl VmExecutor for RealVmExecutor {
},
extra_login_actions,
extra_shares,
Some(&status_callback),
)
}
}
#[cfg(feature = "mock-vm")]
struct MockVmExecutor;
#[cfg(feature = "mock-vm")]
impl VmExecutor for MockVmExecutor {
fn run_vm(
&self,
_args: vm::VmArg,
_extra_login_actions: Vec<LoginAction>,
_extra_shares: Vec<DirectoryShare>,
_config: Arc<Mutex<InstanceConfig>>,
_instance_dir: PathBuf,
_clients: ClientStreams,
_latest_status: SharedStatus,
vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
) -> Result<()> {
let (tx, rx) = mpsc::channel::<VmInput>();
*vm_input_tx.lock().unwrap() = Some(tx);
tracing::info!("mock vm executor running");
while let Ok(input) = rx.recv() {
match input {
VmInput::Shutdown => break,
VmInput::Bytes(bytes) => {
let text = String::from_utf8_lossy(&bytes);
if text.contains("systemctl poweroff") {
break;
}
}
}
}
tracing::info!("mock vm executor exiting");
Ok(())
}
}
fn run_manager_with(
project_root: &Path,
args: vm::VmArg,
mut args: vm::VmArg,
auto_shutdown_ms: u64,
executor: &dyn VmExecutor,
options: ManagerOptions,
) -> Result<(), Box<dyn std::error::Error>> {
if options.ensure_signed {
let _had_skip = env::var("VIBEBOX_SKIP_CODESIGN").ok();
unsafe {
env::remove_var("VIBEBOX_SKIP_CODESIGN");
}
vm::ensure_signed();
unsafe {
env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
}
}
) -> Result<()> {
if options.detach {
detach_from_terminal();
}
let project_name = project_root
.file_name()
.ok_or("Project directory has no name")?
.ok_or_else(|| anyhow!("Project directory has no name"))?
.to_string_lossy()
.into_owned();
let instance_dir = ensure_instance_dir(project_root)?;
@@ -592,27 +725,31 @@ fn run_manager_with(
let _ = ensure_ssh_keypair(&instance_dir)?;
}
let mut config = load_or_create_instance_config(&instance_dir)?;
let mut config = load_or_create_instance_config(project_root)?;
if config.vm_ipv4.is_some() {
config.vm_ipv4 = None;
write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
write_instance_config(project_root, &config)?;
}
let config = Arc::new(Mutex::new(config));
let ssh_user = config
.lock()
.map(|cfg| cfg.ssh_user_display())
.unwrap_or_else(|_| DEFAULT_SSH_USER.to_string());
.map(|cfg| cfg.ssh_user.clone())
.map_err(|_| anyhow!("failed to acquire ssh user display"))?;
if !args.no_default_mounts {
inject_project_mount(&mut args.mounts, project_root, &ssh_user, &project_name);
}
let (args, home_links_script) = prepare_mounts_and_links(args, &ssh_user);
let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}");
let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
let extra_shares = vec![DirectoryShare::new(
instance_dir.clone(),
ssh_guest_dir.clone().into(),
true,
)?];
let extra_shares = vec![
DirectoryShare::new(instance_dir.clone(), ssh_guest_dir.clone().into(), true)
.map_err(|err| anyhow!(err.to_string()))?,
];
let extra_login_actions = build_ssh_login_actions(
&config,
&project_name,
&project_guest_dir,
ssh_guest_dir.as_str(),
"ssh_key",
&home_links_script,
@@ -631,17 +768,43 @@ fn run_manager_with(
let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
tracing::info!(path = %socket_path.display(), "vm manager socket bound");
let clients: ClientStreams = Arc::new(Mutex::new(Vec::new()));
let latest_status: SharedStatus = Arc::new(Mutex::new(String::new()));
let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
let event_tx_accept = event_tx.clone();
let clients_accept = clients.clone();
let latest_status_accept = latest_status.clone();
thread::spawn(move || {
for stream in listener.incoming() {
match stream {
Ok(stream) => {
let mut client_fd: Option<std::os::fd::RawFd> = None;
let latest_status_snapshot = latest_status_accept
.lock()
.ok()
.map(|status| status.clone());
if let Ok(writer) = stream.try_clone() {
let writer_fd = writer.as_raw_fd();
if let Ok(mut connected) = clients_accept.lock() {
connected.push(writer);
client_fd = Some(writer_fd);
if let Some(last) = connected.last_mut()
&& let Some(status) = latest_status_snapshot.as_deref()
&& !status.is_empty()
{
let _ = send_status_line(last, status);
}
}
}
let event_tx_conn = event_tx_accept.clone();
let clients_conn = clients_accept.clone();
thread::spawn(move || {
let pid = read_client_pid(&stream);
let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
wait_for_disconnect(stream);
if let Some(fd) = client_fd {
remove_client(&clients_conn, fd);
}
let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
});
}
@@ -661,43 +824,59 @@ fn run_manager_with(
extra_login_actions,
extra_shares,
config.clone(),
instance_dir.clone(),
project_root.to_path_buf(),
clients.clone(),
latest_status.clone(),
vm_input_tx.clone(),
);
tracing::info!("vm manager vm run completed");
let vm_err = vm_result.err().map(|e| e.to_string());
if let Some(err) = &vm_err {
broadcast_status(
&clients,
&latest_status,
&format!("{STATUS_VM_ERROR_PREFIX} {err}"),
);
}
let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
let event_loop_result: Result<(), String> = event_loop_handle
let event_loop_result = event_loop_handle
.join()
.unwrap_or_else(|_| Err("vm manager event loop panicked".into()))
.unwrap_or_else(|_| Err(Error::msg("vm manager event loop panicked")))
.map_err(|err| err.to_string());
let _ = fs::remove_file(&socket_path);
if let Err(err) = &event_loop_result {
tracing::error!(error = %err, "vm manager exiting due to event loop error");
return Err(err.to_string().into());
bail!(err.to_string());
}
if let Some(err) = vm_err {
tracing::error!(error = %err, "vm manager exiting due to vm error");
return Err(err.into());
bail!(err);
}
tracing::info!("vm manager exiting");
Ok(event_loop_result?)
event_loop_result.map_err(Error::msg)
}
fn manager_event_loop(
event_rx: mpsc::Receiver<ManagerEvent>,
vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
auto_shutdown_ms: u64,
) -> Result<(), String> {
) -> Result<()> {
let mut ref_count: usize = 0;
let mut shutdown_deadline: Option<Instant> = None;
let mut shutdown_sent = false;
let mut hard_deadline: Option<Instant> = None;
let grace = Duration::from_millis(auto_shutdown_ms.max(1));
let hard_timeout = Duration::from_millis(HARD_SHUTDOWN_TIMEOUT_MS);
loop {
let timeout = match shutdown_deadline {
Some(deadline) => deadline.saturating_duration_since(Instant::now()),
None => Duration::from_secs(1),
let timeout = match (shutdown_deadline, hard_deadline) {
(Some(shutdown), Some(hard)) => {
let next = if shutdown <= hard { shutdown } else { hard };
next.saturating_duration_since(Instant::now())
}
(Some(shutdown), None) => shutdown.saturating_duration_since(Instant::now()),
(None, Some(hard)) => hard.saturating_duration_since(Instant::now()),
(None, None) => Duration::from_secs(1),
};
match event_rx.recv_timeout(timeout) {
@@ -711,6 +890,7 @@ fn manager_event_loop(
);
shutdown_deadline = None;
shutdown_sent = false;
hard_deadline = None;
}
Ok(ManagerEvent::Dec(pid)) => {
ref_count = ref_count.saturating_sub(1);
@@ -736,12 +916,48 @@ fn manager_event_loop(
&& Instant::now() >= deadline
&& !shutdown_sent
{
if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
if hard_deadline.is_none() {
hard_deadline = Some(Instant::now() + hard_timeout);
}
tracing::info!("shutdown command sent");
shutdown_sent = true;
shutdown_deadline = None;
let mut sent = false;
if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
if tx
.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()))
.is_ok()
{
sent = true;
} else {
tracing::warn!("shutdown command failed to send");
}
} else {
tracing::warn!("shutdown command deferred; vm input not ready");
}
if sent {
tracing::info!("shutdown command sent");
shutdown_sent = true;
shutdown_deadline = None;
hard_deadline = Some(Instant::now() + hard_timeout);
} else {
shutdown_deadline =
Some(Instant::now() + Duration::from_millis(SHUTDOWN_RETRY_MS));
}
}
if ref_count == 0
&& let Some(deadline) = hard_deadline
&& Instant::now() >= deadline
{
if shutdown_sent {
tracing::warn!(
timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
"force exiting: VM did not stop after shutdown timeout"
);
} else {
tracing::warn!(
timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
"force exiting: VM input not ready after shutdown timeout"
);
}
force_exit("vm manager forced exit");
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
@@ -754,7 +970,7 @@ fn manager_event_loop(
#[cfg(test)]
mod tests {
use super::*;
use std::{sync::mpsc, time::Duration};
use std::{fs, sync::mpsc, thread, time::Duration};
#[test]
fn manager_powers_off_after_grace_when_no_refs() {
@@ -787,4 +1003,75 @@ mod tests {
let _ = event_tx.send(ManagerEvent::VmExited(None));
let _ = manager_thread.join();
}
#[test]
fn manager_force_exits_when_vm_input_never_ready() {
let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
let vm_input_tx = Arc::new(Mutex::new(None));
let manager_thread = thread::spawn(move || {
let _ = manager_event_loop(event_rx, vm_input_tx, 10);
});
event_tx.send(ManagerEvent::Inc(None)).unwrap();
event_tx.send(ManagerEvent::Dec(None)).unwrap();
let join_result = manager_thread.join();
assert!(
join_result.is_err(),
"expected manager to force-exit when vm input never becomes ready"
);
}
#[test]
fn manager_sends_shutdown_after_vm_input_becomes_ready() {
let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
let vm_input_tx = Arc::new(Mutex::new(None));
let vm_input_for_thread = vm_input_tx.clone();
let manager_thread = thread::spawn(move || {
manager_event_loop(event_rx, vm_input_for_thread, 10).expect("event loop");
});
event_tx.send(ManagerEvent::Inc(None)).unwrap();
event_tx.send(ManagerEvent::Dec(None)).unwrap();
thread::sleep(Duration::from_millis(100));
*vm_input_tx.lock().unwrap() = Some(vm_tx);
let msg = vm_rx
.recv_timeout(Duration::from_secs(2))
.expect("poweroff");
match msg {
VmInput::Bytes(data) => {
assert_eq!(data, b"systemctl poweroff\n");
}
_ => panic!("unexpected vm input"),
}
let _ = event_tx.send(ManagerEvent::VmExited(None));
let _ = manager_thread.join();
}
#[test]
fn lock_is_not_stale_when_owner_pid_is_alive() {
let temp = tempfile::Builder::new()
.prefix("vb")
.tempdir_in("/tmp")
.expect("tempdir");
let lock_path = temp.path().join("vm.lock");
fs::write(&lock_path, format!("pid={}\n", std::process::id())).expect("write lock");
assert!(!is_lock_stale(&lock_path));
}
#[test]
fn lock_is_stale_when_owner_pid_is_missing() {
let temp = tempfile::Builder::new()
.prefix("vb")
.tempdir_in("/tmp")
.expect("tempdir");
let lock_path = temp.path().join("vm.lock");
fs::write(&lock_path, "pid=999999\n").expect("write lock");
assert!(is_lock_stale(&lock_path));
}
}
+58
View File
@@ -0,0 +1,58 @@
use assert_cmd::cargo::cargo_bin_cmd;
use tempfile::TempDir;
#[test]
fn cli_version_shows_binary_name() {
let output = cargo_bin_cmd!("vibebox").arg("--version").output().unwrap();
print_output("e2e_cli", &output);
assert!(
output.status.success(),
"expected success, got status: {}",
output.status
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("vibebox"),
"expected --version output to contain 'vibebox', got: {}",
stdout
);
}
#[test]
fn list_reports_no_sessions_when_empty() {
let temp = TempDir::new().unwrap();
let home = temp.path().join("home");
let project = temp.path().join("project");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&project).unwrap();
let output = cargo_bin_cmd!("vibebox")
.current_dir(&project)
.env("HOME", &home)
.arg("list")
.output()
.unwrap();
print_output("e2e_cli", &output);
assert!(
output.status.success(),
"expected success, got status: {}",
output.status
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("No sessions were found."),
"expected empty sessions message, got: {}",
stdout
);
}
fn print_output(prefix: &str, output: &std::process::Output) {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
println!("[{}] {}", prefix, line);
}
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr.lines() {
eprintln!("[{}] {}", prefix, line);
}
}
+278
View File
@@ -0,0 +1,278 @@
use std::{
fs,
io::{BufRead, BufReader, Read},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
thread,
time::{Duration, Instant},
};
use tempfile::TempDir;
#[cfg(target_os = "macos")]
#[test]
#[ignore]
fn vm_boots_and_runs_command() {
if std::env::var("VIBEBOX_E2E_VM").as_deref() != Ok("1") {
eprintln!("skipping: set VIBEBOX_E2E_VM=1 to run this test");
return;
}
if !virtualization_available() {
eprintln!("[e2e_vm] skipping: virtualization not available on this hardware");
return;
}
let temp = TempDir::new().unwrap();
let home = temp.path().join("home");
let cache_home = temp.path().join("cache");
let project = temp.path().join("project");
fs::create_dir_all(&home).unwrap();
fs::create_dir_all(&cache_home).unwrap();
fs::create_dir_all(&project).unwrap();
write_config(&project);
let mut child = Command::new(assert_cmd::cargo_bin!("vibebox-supervisor"))
.current_dir(&project)
.env("HOME", &home)
.env("XDG_CACHE_HOME", &cache_home)
.env("VIBEBOX_INTERNAL", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
if let Some(stdout) = child.stdout.take() {
spawn_prefix_reader("e2e_vm", "stdout", stdout);
}
if let Some(stderr) = child.stderr.take() {
spawn_prefix_reader("e2e_vm", "stderr", stderr);
}
let _child_guard = ChildGuard::new(child);
log_line("e2e_vm", "waiting for vm manager socket");
let socket_path = project.join(".vibebox").join("vm.sock");
let _socket_guard = wait_for_socket(&socket_path, Duration::from_secs(30));
log_line("e2e_vm", "vm manager socket ready");
log_line("e2e_vm", "waiting for vm ipv4");
let instance_path = project.join(".vibebox").join("instance.toml");
let (ip, user) = wait_for_vm_ip(&instance_path, Duration::from_secs(180));
log_line("e2e_vm", &format!("vm ipv4={ip} user={user}"));
let ssh_key = project.join(".vibebox").join("ssh_key");
wait_for_file(&ssh_key, Duration::from_secs(30));
log_line("e2e_vm", "ssh key ready");
let output = wait_for_ssh_command(&ssh_key, &user, &ip, Duration::from_secs(90));
print_output("e2e_vm", &output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Linux"),
"expected ssh command output to contain 'Linux', got: {}",
stdout
);
}
#[cfg(not(target_os = "macos"))]
#[test]
#[ignore]
fn vm_boots_and_runs_command() {
eprintln!("skipping: vm e2e test requires macOS virtualization");
}
struct ChildGuard {
child: Option<Child>,
}
impl ChildGuard {
fn new(child: Child) -> Self {
Self { child: Some(child) }
}
}
impl Drop for ChildGuard {
fn drop(&mut self) {
if let Some(child) = &mut self.child {
let _ = child.kill();
let _ = child.wait();
}
}
}
struct SocketGuard {
_path: PathBuf,
_stream: std::os::unix::net::UnixStream,
}
fn write_config(project: &Path) {
let config = r#"[box]
cpu_count = 2
ram_mb = 2048
disk_gb = 5
mounts = []
[supervisor]
auto_shutdown_ms = 120000
"#;
fs::write(project.join("vibebox.toml"), config).unwrap();
}
fn wait_for_socket(path: &Path, timeout: Duration) -> SocketGuard {
let start = Instant::now();
loop {
if let Ok(stream) = std::os::unix::net::UnixStream::connect(path) {
return SocketGuard {
_path: path.to_path_buf(),
_stream: stream,
};
}
if start.elapsed() > timeout {
panic!(
"timed out waiting for vm manager socket at {}",
path.display()
);
}
thread::sleep(Duration::from_millis(200));
}
}
fn wait_for_vm_ip(instance_path: &Path, timeout: Duration) -> (String, String) {
let start = Instant::now();
loop {
if let Ok(raw) = fs::read_to_string(instance_path)
&& let Ok(value) = toml::from_str::<toml::Value>(&raw)
{
let ip = value
.get("vm_ipv4")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(ip) = ip {
let user = value
.get("ssh_user")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "vibecoder".to_string());
return (ip, user);
}
}
if start.elapsed() > timeout {
panic!(
"timed out waiting for vm_ipv4 in {}",
instance_path.display()
);
}
thread::sleep(Duration::from_millis(500));
}
}
fn wait_for_file(path: &Path, timeout: Duration) {
let start = Instant::now();
loop {
if path.exists() {
return;
}
if start.elapsed() > timeout {
panic!("timed out waiting for file {}", path.display());
}
thread::sleep(Duration::from_millis(200));
}
}
fn wait_for_ssh_command(
ssh_key: &Path,
user: &str,
ip: &str,
timeout: Duration,
) -> std::process::Output {
let start = Instant::now();
loop {
let output = Command::new("ssh")
.args([
"-i",
ssh_key.to_str().unwrap_or(".vibebox/ssh_key"),
"-o",
"IdentitiesOnly=yes",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"GlobalKnownHostsFile=/dev/null",
"-o",
"PasswordAuthentication=no",
"-o",
"BatchMode=yes",
"-o",
"LogLevel=ERROR",
"-o",
"ConnectTimeout=5",
&format!("{user}@{ip}"),
"uname -s",
])
.output()
.unwrap();
if output.status.success() {
return output;
}
if start.elapsed() > timeout {
let stderr = String::from_utf8_lossy(&output.stderr);
panic!("ssh command failed and timed out: {}", stderr);
}
thread::sleep(Duration::from_millis(1000));
}
}
fn spawn_prefix_reader(
label: &'static str,
stream: &'static str,
reader: impl Read + Send + 'static,
) {
thread::spawn(move || {
let buf = BufReader::new(reader);
for line in buf.lines() {
match line {
Ok(line) => {
println!("[{}][{}] {}", label, stream, line);
}
Err(err) => {
eprintln!("[{}][{}] read error: {}", label, stream, err);
break;
}
}
}
});
}
fn log_line(prefix: &str, message: &str) {
println!("[{}] {}", prefix, message);
}
fn print_output(prefix: &str, output: &std::process::Output) {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
println!("[{}] {}", prefix, line);
}
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr.lines() {
eprintln!("[{}] {}", prefix, line);
}
}
fn virtualization_available() -> bool {
let output = Command::new("sysctl")
.args(["-n", "kern.hv_support"])
.output();
match output {
Ok(output) if output.status.success() => {
let value = String::from_utf8_lossy(&output.stdout);
match value.trim() {
"1" => true,
"0" => false,
_ => true,
}
}
_ => true,
}
}
+455
View File
@@ -0,0 +1,455 @@
#![cfg(all(feature = "mock-vm", target_os = "macos"))]
use std::{
fs,
io::{BufRead, BufReader, Read},
os::unix::net::UnixStream,
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::mpsc,
thread,
time::{Duration, Instant},
};
use tempfile::TempDir;
#[test]
fn mock_vm_allows_refcount_concurrency() {
let temp = TempDir::new().unwrap();
let mut supervisor = spawn_supervisor(&temp, 0, 1200, "e2e_vm_mock".to_string());
supervisor.clients = connect_clients(
&supervisor.socket_path,
12,
Duration::from_secs(2),
true,
"e2e_vm_mock",
);
log_line("e2e_vm_mock", "connected 12 clients");
assert_manager_alive_for(
&mut supervisor.child,
Duration::from_millis(900),
"vm manager exited while clients active",
);
let remaining = supervisor.clients.split_off(6);
supervisor.clients = remaining;
log_line("e2e_vm_mock", "dropped 6 clients");
assert_manager_alive_for(
&mut supervisor.child,
Duration::from_millis(900),
"vm manager exited while clients active",
);
supervisor.clients.clear();
log_line("e2e_vm_mock", "dropped remaining clients");
wait_for_exit(&mut supervisor.child, Duration::from_secs(10));
let status = supervisor.child.wait().unwrap();
assert!(status.success(), "vm manager exited with {status}");
}
#[test]
fn mock_vm_many_managers_many_clients() {
let temp = TempDir::new().unwrap();
let mut supervisors = Vec::new();
for idx in 0..3 {
supervisors.push(spawn_supervisor(
&temp,
idx + 1,
1400,
format!("e2e_vm_mock_{idx}"),
));
}
for supervisor in &mut supervisors {
supervisor.clients = connect_clients(
&supervisor.socket_path,
8,
Duration::from_secs(2),
true,
"e2e_vm_mock",
);
}
log_line("e2e_vm_mock", "connected 8 clients per manager");
for supervisor in &mut supervisors {
assert_manager_alive_for(
&mut supervisor.child,
Duration::from_millis(900),
"manager exited while clients active",
);
}
supervisors[0].clients.clear();
log_line("e2e_vm_mock", "dropped all clients for manager 0");
wait_for_exit(&mut supervisors[0].child, Duration::from_secs(10));
let status = supervisors[0].child.wait().unwrap();
assert!(status.success(), "manager 0 exited with {status}");
for supervisor in supervisors.iter_mut().skip(1) {
assert_manager_alive_for(
&mut supervisor.child,
Duration::from_millis(900),
"another manager exited early",
);
}
for supervisor in supervisors.iter_mut().skip(1) {
supervisor.clients.clear();
}
log_line("e2e_vm_mock", "dropped remaining clients");
for supervisor in supervisors.iter_mut().skip(1) {
wait_for_exit(&mut supervisor.child, Duration::from_secs(10));
let status = supervisor.child.wait().unwrap();
assert!(status.success(), "manager exited with {status}");
}
}
#[test]
fn mock_vm_monkey_processes() {
let temp = TempDir::new().unwrap();
let mut rng = Lcg::new(0x5eed_f00d_dead_beef);
let mut supervisors = Vec::new();
let mut next_id = 0usize;
let max_supervisors = 4usize;
let steps = 25usize;
supervisors.push(spawn_supervisor(
&temp,
next_id,
1200,
format!("e2e_vm_monkey_{next_id}"),
));
next_id += 1;
for step in 0..steps {
prune_exited_supervisors(&mut supervisors, "e2e_vm_monkey");
let roll = rng.gen_range(100);
log_line(
"e2e_vm_monkey",
&format!("step {step} roll={roll} supervisors={}", supervisors.len()),
);
if roll < 20 && supervisors.len() < max_supervisors {
supervisors.push(spawn_supervisor(
&temp,
next_id,
1200,
format!("e2e_vm_monkey_{next_id}"),
));
log_line("e2e_vm_monkey", &format!("spawned supervisor {next_id}"));
next_id += 1;
} else if roll < 45 && !supervisors.is_empty() {
let idx = rng.gen_range(supervisors.len());
let mut supervisor = supervisors.swap_remove(idx);
log_line(
"e2e_vm_monkey",
&format!("killing supervisor {}", supervisor.label),
);
kill_supervisor(&mut supervisor, Duration::from_secs(5));
} else if roll < 80 && !supervisors.is_empty() {
let idx = rng.gen_range(supervisors.len());
let burst = 1 + rng.gen_range(3);
let new_clients = connect_clients(
&supervisors[idx].socket_path,
burst,
Duration::from_secs(1),
false,
"e2e_vm_monkey",
);
supervisors[idx].clients.extend(new_clients);
log_line(
"e2e_vm_monkey",
&format!("connected {burst} clients to {}", supervisors[idx].label),
);
} else if !supervisors.is_empty() {
let idx = rng.gen_range(supervisors.len());
if !supervisors[idx].clients.is_empty() {
let len = supervisors[idx].clients.len();
let drop_count = 1 + rng.gen_range(len);
supervisors[idx].clients.drain(0..drop_count.min(len));
log_line(
"e2e_vm_monkey",
&format!(
"dropped {drop_count} clients from {}",
supervisors[idx].label
),
);
}
}
thread::sleep(Duration::from_millis(200));
}
log_line("e2e_vm_monkey", "final cleanup");
for supervisor in supervisors.iter_mut() {
shutdown_supervisor(supervisor, Duration::from_secs(10));
}
}
#[test]
fn mock_vm_exits_without_clients() {
let temp = TempDir::new().unwrap();
let mut supervisor = spawn_supervisor(&temp, 99, 300, "e2e_vm_no_clients".to_string());
wait_for_exit(&mut supervisor.child, Duration::from_secs(5));
let status = supervisor.child.wait().unwrap();
assert!(status.success(), "vm manager exited with {status}");
}
#[test]
fn mock_vm_reconnect_resets_shutdown() {
let temp = TempDir::new().unwrap();
let mut supervisor = spawn_supervisor(&temp, 100, 800, "e2e_vm_reconnect".to_string());
supervisor.clients = connect_clients(
&supervisor.socket_path,
1,
Duration::from_secs(2),
true,
"e2e_vm_reconnect",
);
supervisor.clients.clear();
thread::sleep(Duration::from_millis(400));
supervisor.clients = connect_clients(
&supervisor.socket_path,
1,
Duration::from_secs(2),
true,
"e2e_vm_reconnect",
);
assert_manager_alive_for(
&mut supervisor.child,
Duration::from_millis(600),
"vm manager exited despite reconnect",
);
supervisor.clients.clear();
wait_for_exit(&mut supervisor.child, Duration::from_secs(10));
let status = supervisor.child.wait().unwrap();
assert!(status.success(), "vm manager exited with {status}");
}
struct Supervisor {
child: Child,
socket_path: PathBuf,
clients: Vec<UnixStream>,
label: String,
}
fn write_config(project: &Path, auto_shutdown_ms: u64) {
let config = format!(
r#"[box]
cpu_count = 2
ram_mb = 2048
disk_gb = 5
mounts = []
[supervisor]
auto_shutdown_ms = {auto_shutdown_ms}
"#
);
fs::write(project.join("vibebox.toml"), config).unwrap();
}
fn spawn_supervisor(
temp: &TempDir,
idx: usize,
auto_shutdown_ms: u64,
label: String,
) -> Supervisor {
let home = temp.path().join(format!("home-{idx}"));
let cache_home = temp.path().join(format!("cache-{idx}"));
let project = temp.path().join(format!("project-{idx}"));
fs::create_dir_all(&home).unwrap();
fs::create_dir_all(&cache_home).unwrap();
fs::create_dir_all(&project).unwrap();
write_config(&project, auto_shutdown_ms);
let mut child = Command::new(assert_cmd::cargo_bin!("vibebox-supervisor"))
.current_dir(&project)
.env("HOME", &home)
.env("XDG_CACHE_HOME", &cache_home)
.env("VIBEBOX_INTERNAL", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
if let Some(stdout) = child.stdout.take() {
spawn_prefix_reader(label.clone(), "stdout", stdout);
}
if let Some(stderr) = child.stderr.take() {
spawn_prefix_reader(label.clone(), "stderr", stderr);
}
let socket_path = project.join(".vibebox").join("vm.sock");
wait_for_socket(&socket_path, Duration::from_secs(10));
Supervisor {
child,
socket_path,
clients: Vec::new(),
label,
}
}
fn connect_clients(
socket_path: &Path,
count: usize,
timeout: Duration,
require_all: bool,
label: &str,
) -> Vec<UnixStream> {
let (tx, rx) = mpsc::channel();
let mut handles = Vec::with_capacity(count);
for _ in 0..count {
let path = socket_path.to_path_buf();
let tx = tx.clone();
handles.push(thread::spawn(move || {
let stream = connect_client_with_retry(&path, timeout);
let _ = tx.send(stream);
}));
}
drop(tx);
let mut clients = Vec::with_capacity(count);
for stream in rx.into_iter().flatten() {
clients.push(stream);
}
for handle in handles {
handle.join().unwrap();
}
if require_all && clients.len() != count {
panic!(
"client count mismatch: expected {count} got {}",
clients.len()
);
}
if !require_all && clients.len() != count {
log_line(
label,
&format!("connected {} of {count} clients", clients.len()),
);
}
clients
}
fn connect_client_with_retry(path: &Path, timeout: Duration) -> Option<UnixStream> {
let start = Instant::now();
loop {
match UnixStream::connect(path) {
Ok(stream) => return Some(stream),
Err(_) => {
if start.elapsed() > timeout {
return None;
}
thread::sleep(Duration::from_millis(50));
}
}
}
}
fn wait_for_socket(path: &Path, timeout: Duration) {
let start = Instant::now();
loop {
if UnixStream::connect(path).is_ok() {
return;
}
if start.elapsed() > timeout {
panic!("timed out waiting for socket {}", path.display());
}
thread::sleep(Duration::from_millis(100));
}
}
fn assert_manager_alive(child: &mut Child, message: &str) {
assert!(child.try_wait().unwrap().is_none(), "{message}");
}
fn assert_manager_alive_for(child: &mut Child, duration: Duration, message: &str) {
let start = Instant::now();
while start.elapsed() < duration {
assert_manager_alive(child, message);
thread::sleep(Duration::from_millis(100));
}
}
fn kill_supervisor(supervisor: &mut Supervisor, timeout: Duration) {
supervisor.clients.clear();
let _ = supervisor.child.kill();
wait_for_exit(&mut supervisor.child, timeout);
let _ = supervisor.child.wait();
}
fn prune_exited_supervisors(supervisors: &mut Vec<Supervisor>, label: &str) {
supervisors.retain_mut(|supervisor| {
if supervisor.child.try_wait().unwrap().is_some() {
log_line(label, &format!("removed exited {}", supervisor.label));
false
} else {
true
}
});
}
fn shutdown_supervisor(supervisor: &mut Supervisor, timeout: Duration) {
supervisor.clients.clear();
wait_for_exit(&mut supervisor.child, timeout);
if supervisor.child.try_wait().unwrap().is_none() {
let _ = supervisor.child.kill();
let _ = supervisor.child.wait();
}
}
fn wait_for_exit(child: &mut Child, timeout: Duration) {
let start = Instant::now();
loop {
if child.try_wait().unwrap().is_some() {
return;
}
if start.elapsed() > timeout {
panic!("timed out waiting for mock vm supervisor exit");
}
thread::sleep(Duration::from_millis(200));
}
}
fn spawn_prefix_reader(label: String, stream: &'static str, reader: impl Read + Send + 'static) {
thread::spawn(move || {
let buf = BufReader::new(reader);
for line in buf.lines() {
match line {
Ok(line) => println!("[{}][{}] {}", label, stream, line),
Err(err) => {
eprintln!("[{}][{}] read error: {}", label, stream, err);
break;
}
}
}
});
}
fn log_line(prefix: &str, message: &str) {
println!("[{}] {}", prefix, message);
}
struct Lcg {
state: u64,
}
impl Lcg {
fn new(seed: u64) -> Self {
Self { state: seed }
}
fn next_u32(&mut self) -> u32 {
self.state = self.state.wrapping_mul(6364136223846793005).wrapping_add(1);
(self.state >> 32) as u32
}
fn gen_range(&mut self, upper: usize) -> usize {
if upper == 0 {
return 0;
}
(self.next_u32() as usize) % upper
}
}
+113
View File
@@ -0,0 +1,113 @@
use std::{env, ffi::OsString, fs, path::Path, sync::Mutex};
use tempfile::TempDir;
use vibebox::session_manager::INSTANCE_DIR_NAME;
use vibebox::{config, explain};
static ENV_MUTEX: Mutex<()> = Mutex::new(());
struct EnvGuard {
key: &'static str,
previous: Option<OsString>,
}
impl EnvGuard {
fn set(key: &'static str, value: &Path) -> Self {
let previous = env::var_os(key);
unsafe {
env::set_var(key, value);
}
Self { key, previous }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.previous {
Some(value) => unsafe {
env::set_var(self.key, value);
},
None => unsafe {
env::remove_var(self.key);
},
}
}
}
#[test]
fn build_mount_rows_includes_defaults_and_custom_mounts() {
let _lock = ENV_MUTEX.lock().unwrap();
let temp = TempDir::new().unwrap();
let home = temp.path().join("home");
let project = home.join("project");
let cache_home = home.join("cache");
fs::create_dir_all(&project).unwrap();
fs::create_dir_all(&cache_home).unwrap();
let _home_guard = EnvGuard::set("HOME", &home);
let _cache_guard = EnvGuard::set("XDG_CACHE_HOME", &cache_home);
let box_cfg = config::BoxConfig {
mounts: vec!["data:~/data:read-only".to_string()],
..Default::default()
};
let cfg = config::Config {
box_cfg,
supervisor: config::SupervisorConfig::default(),
};
let rows = explain::build_mount_rows(&project, &cfg).unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].host, "~/project");
assert_eq!(rows[0].guest, "~/project");
assert_eq!(rows[0].mode, "read-write");
assert_eq!(rows[0].default_mount, "yes");
assert_eq!(rows[1].host, "~/cache/vibebox/.guest-mise-cache");
assert_eq!(rows[1].guest, "/root/.local/share/mise");
assert_eq!(rows[1].mode, "read-write");
assert_eq!(rows[1].default_mount, "yes");
assert_eq!(rows[2].host, "~/project/data");
assert_eq!(rows[2].guest, "~/data");
assert_eq!(rows[2].mode, "read-only");
assert_eq!(rows[2].default_mount, "no");
}
#[test]
fn build_network_rows_pending_without_instance_file() {
let temp = TempDir::new().unwrap();
let project = temp.path().join("project");
fs::create_dir_all(&project).unwrap();
let rows = explain::build_network_rows(&project).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].network_type, "NAT");
assert_eq!(rows[0].vm_ip, "-");
assert_eq!(rows[0].host_to_vm, "ssh: <pending>:22");
assert_eq!(rows[0].vm_to_host, "none");
}
#[test]
fn build_network_rows_uses_instance_vm_ip() {
let temp = TempDir::new().unwrap();
let project = temp.path().join("project");
let instance_dir = project.join(INSTANCE_DIR_NAME);
fs::create_dir_all(&instance_dir).unwrap();
fs::write(
instance_dir.join("instance.toml"),
"vm_ipv4 = \"10.1.2.3\"\n",
)
.unwrap();
let rows = explain::build_network_rows(&project).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].network_type, "NAT");
assert_eq!(rows[0].vm_ip, "10.1.2.3");
assert_eq!(rows[0].host_to_vm, "ssh: 10.1.2.3:22");
assert_eq!(rows[0].vm_to_host, "none");
}
+1
View File
@@ -1,6 +1,7 @@
[box]
cpu_count = 2
ram_mb = 2048
disk_gb = 5
mounts = [
"~/.codex:~/.codex:read-write",
"~/.claude:~/.claude:read-write",