24 Commits

Author SHA1 Message Date
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
27 changed files with 1854 additions and 100 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
+76 -1
View File
@@ -82,6 +82,21 @@ dependencies = [
"windows-sys 0.61.2",
]
[[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,6 +127,17 @@ 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"
@@ -321,6 +347,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 +769,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 +1066,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,8 +1303,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vibebox"
version = "0.1.1"
version = "0.2.3"
dependencies = [
"assert_cmd",
"block2",
"clap",
"color-eyre",
@@ -1261,6 +1327,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"
+8 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "vibebox"
version = "0.1.1"
version = "0.2.3"
edition = "2024"
authors = ["Finn Sheng"]
description = "Ultrafast CLI on Apple Silicon macOS for fast, sandboxed development and LLM agents."
@@ -42,3 +42,10 @@ 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"
[dev-dependencies]
assert_cmd = "2"
tempfile = "3"
[features]
mock-vm = []
+186
View File
@@ -0,0 +1,186 @@
<p align="center">
<a href="https://vibebox.robcholz.com">
<picture>
<img src="docs/banner.png" alt="VibeBox logo">
</picture>
</a>
</p>
<p align="center">Your ultrafast open source AI sandbox.</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 lightweight, ultra-fast sandbox for AI agents to run commands, edit files, and execute code inside an
isolated Apple Virtualization Framework micro-VM, no repeated permission prompts, minimal memory/disk overhead.
[![VibeBox Terminal UI](docs/screenshot.png)](https://vibebox.robcholz.com)
---
### Why I built VibeBox
I use agents like Codex and CC a lot, but I always felt uneasy running them directly on my host machine. If I lock
things
down, I get interrupted by constant “are you sure?” prompts. If I loosen it up, I worry the agent might touch the
wrong files or run something I didnt intend.
I wanted something that feels as frictionless as giving an agent a real shell, but with a hard isolation boundary. So I
built VibeBox: a per-project micro-VM sandbox that starts fast, keeps changes contained to the repo, and lets me iterate
without babysitting permissions.
### Comparison
Heres why I didnt just use existing options:
- **vibe**: super convenient, but its too minimal for what I need. It lacks basic configuration, and it doesnt give me
the multi-instance + session management my workflow wants.
- **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**: great ecosystem, but for daily use it feels heavy. Cold starts can be slow, and its not
something I can jump into instantly, repeatedly, all day.
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
# YOLO
curl -fsSL https://raw.githubusercontent.com/robcholz/vibebox/main/install | bash
# Package managers
cargo install vibebox
# Or manually (bad)
curl -LO https://github.com/robcholz/vibebox/releases/download/latest/vibebox-macos-arm64.zip
unzip vibebox-macos-arm64.zip
mkdir -p ~/.local/bin
mv vibe ~/.local/bin
export PATH="$HOME/.local/bin:$PATH"
```
> [!TIP]
> We truly recommend you to use `YOLO` to install.
**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.
### Using VibeBox
Feel free to use, but remember to promote VibeBox as well!
### FAQ
#### How is this different from other Sandboxes?
Vibebox is built for fast, repeatable local sandboxes with minimal ceremony. Whats different here:
- Warm startup is typically under **6 seconds** on my M3, 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.
### Special Thank
[vibe](https://github.com/lynaghk/vibe) by lynaghk.
And amazing Rust community, without your rich crates and fantastic toolchain like [crates.io](https://crates.io), this
wouldn't be possible!
---
**Follow me on X** [X.com](https://x.com/robcholz)
+178
View File
@@ -0,0 +1,178 @@
<p align="center">
<a href="https://vibebox.robcholz.com">
<picture>
<img src="docs/banner.png" alt="VibeBox logo">
</picture>
</a>
</p>
<p align="center">超高速、开源的 AI 沙盒。</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 是一个轻量、启动极快的沙盒环境,让 AI Agent 可以安全地直接跑命令、改文件、执行代码,不会不停弹“要不要允许”的提示。它基于
Apple 的 Virtualization Framework 做到彻底隔离,所以无论 Agent 在里面怎么折腾,你的真实系统都不会被影响;同时做到了低内存和磁盘占用。
[![VibeBox Terminal UI](docs/screenshot.png)](https://vibebox.robcholz.com)
---
### 我为什么做 VibeBox
我平时经常用像 Codex 和 CC 这样的
agent,但一直不太敢让它们直接在我的宿主机上跑。把权限收紧一点,就会被各种“你确定吗?”的确认弹窗不停打断;放松一点,又担心它哪天误操作,碰到不该碰的文件,或者跑了我没打算执行的命令。
我想要的是一种体验:像给 agent 一个真实 shell 一样顺滑,但同时又有一道硬隔离的安全边界。于是我做了 VibeBox:一个按项目划分的
micro-VM 沙箱,启动很快,改动被限制在仓库范围内,让我可以更高频地迭代,而不用一直盯着权限确认。
### 对比
下面是我为什么没有直接用现成方案的原因:
- **vibe**:非常方便,但对我来说太“极简”了。它缺少一些基础配置能力,也没法提供我工作流里需要的多开和 session 管理。
- **QEMU**:很强大,但配置面太大了。日常当沙箱用,它不像是“进到 repo 就能用”,更像是你得先把它当成一个项目来折腾。
- **Docker / devcontainers**:生态很成熟,但日常使用对我来说偏重。冷启动有时会慢,而且它不是那种我能一天反复、随时秒进秒出的工具。
这些就促使我做了 **VibeBox**:我想要一个按项目隔离的沙箱,进入速度快(直接用 `vibebox`),支持真正可用的配置和
sessions,同时还保留明确的硬隔离边界。
### 安装
```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 vibe ~/.local/bin
export PATH="$HOME/.local/bin:$PATH"
```
> [!TIP]
> 强烈建议直接用 `YOLO` 方式安装,省事且更不容易踩坑。
**系统要求**
- 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。
### 使用 VibeBox
欢迎使用,也别忘了顺手帮 VibeBox 做点宣传!
### FAQ
#### 它和其它 Sandboxes 有什么不同?
Vibebox 追求的是:本地、可复现、启动快、流程简单。主要差异点:
- 在我的 M3 上,热启动通常 **6 秒以内**,可以非常快地回到工作状态。
- 一个命令——`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

+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
+1
View File
@@ -29,6 +29,7 @@ fn main() -> Result<()> {
let args = vm::VmArg {
cpu_count: config.box_cfg.cpu_count,
ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
disk_bytes: config.box_cfg.disk_gb.saturating_mul(1024 * 1024 * 1024),
no_default_mounts: false,
mounts: config.box_cfg.mounts.clone(),
};
+27 -3
View File
@@ -49,7 +49,7 @@ 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());
}
@@ -63,6 +63,7 @@ fn main() -> Result<()> {
let args = vm::VmArg {
cpu_count: config.box_cfg.cpu_count,
ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
disk_bytes: config.box_cfg.disk_gb.saturating_mul(1024 * 1024 * 1024),
no_default_mounts: false,
mounts: config.box_cfg.mounts.clone(),
};
@@ -80,14 +81,15 @@ fn main() -> Result<()> {
let vm_args = vm::VmArg {
cpu_count: config.box_cfg.cpu_count,
ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
disk_bytes: config.box_cfg.disk_gb.saturating_mul(1024 * 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_disk_gb: (vm_args.disk_bytes as f32) / 1024.0 / 1024.0 / 1024.0,
system_name: "Debian".to_string(), // TODO: read system name from the VM.
auto_shutdown_ms,
};
@@ -110,6 +112,7 @@ fn main() -> Result<()> {
writeln!(stdout)?;
stdout.flush()?;
}
warn_disk_size_mismatch(&cwd, vm_args.disk_bytes);
if let Some(handle) = stderr_handle {
let _ = handle.modify(|filter| *filter = LevelFilter::INFO);
}
@@ -351,6 +354,27 @@ 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_bytes: u64) {
let instance_raw = cwd
.join(session_manager::INSTANCE_DIR_NAME)
.join("instance.raw");
let Ok(meta) = fs::metadata(&instance_raw) else {
return;
};
let current_bytes = meta.len();
if current_bytes == configured_bytes {
return;
}
let current_gb = current_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
let target_gb = configured_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
tracing::warn!(
"instance disk size does not match config (current {:.2} GB, config {:.2} GB). \
disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using the existing disk.",
current_gb,
target_gb
);
}
type StderrHandle = reload::Handle<LevelFilter, Registry>;
fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
@@ -371,7 +395,7 @@ 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)
+16 -2
View File
@@ -13,6 +13,7 @@ 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 {
@@ -25,6 +26,7 @@ pub struct Config {
pub struct BoxConfig {
pub cpu_count: usize,
pub ram_mb: u64,
pub disk_gb: u64,
pub mounts: Vec<String>,
}
@@ -33,6 +35,7 @@ impl Default for BoxConfig {
Self {
cpu_count: default_cpu_count(),
ram_mb: default_ram_mb(),
disk_gb: default_disk_gb(),
mounts: default_mounts(),
}
}
@@ -64,7 +67,14 @@ fn default_auto_shutdown_ms() -> u64 {
}
fn default_mounts() -> Vec<String> {
Vec::new()
vec![
"~/.codex:~/.codex:read-write".into(),
"~/.claude:~/.claude:read-write".into(),
]
}
fn default_disk_gb() -> u64 {
DEFAULT_DISK_GB
}
pub fn config_path(project_root: &Path) -> PathBuf {
@@ -102,7 +112,7 @@ pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>)
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)",
"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()
));
}
@@ -193,6 +203,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",
@@ -259,6 +270,9 @@ fn validate_or_exit(config: &Config) {
if config.box_cfg.ram_mb == 0 {
die("box.ram_mb must be >= 1");
}
if config.box_cfg.disk_gb == 0 {
die("box.disk_gb must be >= 1");
}
if config.supervisor.auto_shutdown_ms == 0 {
die("supervisor.auto_shutdown_ms must be >= 1");
}
+1 -1
View File
@@ -46,7 +46,7 @@ fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>, Box<dyn Error +
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("project");
let project_guest = format!("/root/{project_name}");
let project_guest = format!("~/{project_name}");
let project_host = display_path(cwd);
let mut rows = vec![tui::MountListRow {
host: project_host,
+31 -10
View File
@@ -21,6 +21,7 @@ use crate::{
};
const SSH_KEY_NAME: &str = "ssh_key";
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
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";
@@ -68,7 +69,7 @@ pub fn run_with_ssh(manager_conn: UnixStream) -> Result<(), Box<dyn std::error::
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(&instance_dir, Duration::from_secs(480))?;
let ip = load_or_create_instance_config(&instance_dir)?
.vm_ipv4
@@ -217,6 +218,7 @@ fn generate_password() -> String {
Uuid::now_v7().simple().to_string()
}
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
let mut current = String::new();
let mut best: Option<String> = None;
@@ -242,19 +244,41 @@ fn wait_for_vm_ipv4(
) -> Result<(), Box<dyn std::error::Error>> {
let start = Instant::now();
let mut next_log_at = start + Duration::from_secs(10);
let mut next_status_check = start;
tracing::info!("waiting for vm ipv4");
let status_path = instance_dir.join(STATUS_FILE_NAME);
let mut last_status: Option<String> = None;
let mut status_missing = true;
let mut once_hint = false;
loop {
let config = load_or_create_instance_config(instance_dir)?;
if config.vm_ipv4.is_some() {
let _ = fs::remove_file(&status_path);
return Ok(());
}
if start.elapsed() > timeout {
let _ = fs::remove_file(&status_path);
return Err("Timed out waiting for VM IPv4".into());
}
if Instant::now() >= next_log_at {
let now = Instant::now();
if now >= next_status_check {
match fs::read_to_string(&status_path) {
Ok(status) => {
status_missing = false;
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);
next_log_at = now + Duration::from_secs(20);
}
}
Err(_) => {
status_missing = true;
}
}
next_status_check = now + Duration::from_millis(500);
}
if now >= next_log_at {
let waited = start.elapsed();
if waited.as_secs() > 15 && !once_hint {
tracing::info!(
@@ -262,16 +286,10 @@ 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 {
if status_missing {
tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
}
next_log_at += Duration::from_secs(10);
next_log_at += Duration::from_secs(20);
}
thread::sleep(Duration::from_millis(200));
}
@@ -367,6 +385,7 @@ fn run_ssh_session(
Ok(())
}
#[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 {
@@ -394,6 +413,7 @@ fn ssh_port_open(ip: &str) -> bool {
pub(crate) 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,
@@ -409,6 +429,7 @@ pub(crate) fn build_ssh_login_actions(
.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);
+6
View File
@@ -14,6 +14,7 @@ apt-get install -y --no-install-recommends \
curl \
git \
ripgrep \
cloud-guest-utils \
openssh-server \
sudo
@@ -42,6 +43,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
+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
+2 -2
View File
@@ -117,7 +117,7 @@ impl SessionManager {
}
if removed > 0 || added {
tracing::info!(
tracing::debug!(
path = %self.sessions_dir.display(),
removed,
added,
@@ -196,7 +196,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"
);
+51 -14
View File
@@ -3,6 +3,7 @@ set -eu
SSH_USER="__SSH_USER__"
PROJECT_NAME="__PROJECT_NAME__"
PROJECT_GUEST_DIR="__PROJECT_GUEST_DIR__"
KEY_PATH="__KEY_PATH__"
diag() { echo "[vibebox][diag] $*" >&2; }
@@ -49,7 +50,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
@@ -78,6 +79,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 +104,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 (continuing)"
return 0
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,12 +133,21 @@ 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
fi
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 (continuing)"
return 0
fi
else
if ! HOME="$USER_HOME" mise install; then
mise_warn "mise install failed (continuing)"
return 0
fi
fi
}
mise_install || true
# 3) start ssh (don't swallow failures)
# If ssh is already active, don't force start/restart.
@@ -184,5 +215,11 @@ if ! listens_ok; then
exit 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"
+5 -3
View File
@@ -25,6 +25,7 @@ use ratatui::{
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] = [
"██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
"██║ ██║██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗╚██╗██╔╝",
@@ -40,6 +41,7 @@ const INFO_LINE_COUNT: u16 = 5;
pub struct VmInfo {
pub max_memory_mb: u64,
pub cpu_cores: usize,
pub max_disk_gb: f32,
pub system_name: String,
pub auto_shutdown_ms: u64,
}
@@ -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 / {} MB / {} GB",
app.vm_info.cpu_cores, app.vm_info.max_memory_mb, app.vm_info.max_disk_gb
),
Style::default().fg(Color::Green),
),
+55 -22
View File
@@ -32,6 +32,7 @@ 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;
@@ -56,12 +57,6 @@ impl StatusFile {
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 {
@@ -74,6 +69,7 @@ impl Drop for StatusFile {
}
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";
@@ -166,6 +162,7 @@ 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>,
}
@@ -230,14 +227,23 @@ where
std::slice::from_ref(&mise_directory_share),
Some(&status_file),
)?;
ensure_instance_disk(&instance_raw, &default_raw, Some(&status_file))?;
let _ = ensure_instance_disk(
&instance_raw,
&default_raw,
args.disk_bytes,
Some(&status_file),
)?;
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 +251,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 +260,11 @@ where
directory_shares.push(DirectoryShare::from_mount_spec(spec)?);
}
if needs_resize {
let resize_cmd = script_command_from_content("resize_disk", RESIZE_DISK_SCRIPT)?;
login_actions.push(Send(resize_cmd));
}
if let Some(motd_action) = motd_login_action(&directory_shares) {
login_actions.push(motd_action);
}
@@ -559,19 +561,51 @@ fn ensure_default_image(
fn ensure_instance_disk(
instance_raw: &Path,
template_raw: &Path,
target_bytes: u64,
status: Option<&StatusFile>,
) -> Result<(), Box<dyn std::error::Error>> {
) -> Result<bool, Box<dyn std::error::Error>> {
if instance_raw.exists() {
return Ok(());
let current_size = fs::metadata(instance_raw)?.len();
if current_size != target_bytes {
let current_gb = current_size as f64 / (1024.0 * 1024.0 * 1024.0);
let target_gb = target_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
tracing::warn!(
current_bytes = current_size,
target_bytes,
"instance disk size does not match config (current {:.2} GB, config {:.2} GB); 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);
}
let template_size = fs::metadata(template_raw)?.len();
if target_bytes < template_size {
return Err(format!(
"Requested disk size {} bytes is smaller than base image size {} bytes",
target_bytes, template_size
)
.into());
}
let target_size = target_bytes;
let needs_resize = target_size > template_size;
if let Some(status) = status {
status.update("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(())
if target_size == template_size {
fs::copy(template_raw, instance_raw)?;
return Ok(needs_resize);
}
let mut dst = std::fs::File::create(instance_raw)?;
dst.set_len(target_size)?;
let mut src = std::fs::File::open(template_raw)?;
std::io::copy(&mut src, &mut dst)?;
Ok(needs_resize)
}
pub struct IoContext {
@@ -1079,7 +1113,6 @@ where
if let Some(status) = status {
status.update("vm booting... go vibecoder!");
status.clear();
}
tracing::info!("vm booting");
+132 -18
View File
@@ -25,11 +25,12 @@ use crate::{
session_manager::{
GLOBAL_DIR_NAME, INSTANCE_FILENAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME,
},
vm::{self, DirectoryShare, LoginAction, VmInput},
vm::{self, DirectoryShare, LoginAction, PROJECT_GUEST_BASE, VmInput},
};
const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 12_000;
pub fn ensure_manager(
raw_args: &[std::ffi::OsString],
@@ -102,17 +103,37 @@ pub fn run_manager(
let project_root = env::current_dir()?;
tracing::info!(root = %project_root.display(), "vm manager starting");
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 {
ensure_signed: false,
detach: true,
prepare_vm: false,
}
}
#[cfg(not(feature = "mock-vm"))]
{
ManagerOptions {
ensure_signed: true,
detach: true,
prepare_vm: true,
}
}
};
run_manager_with(&project_root, args, auto_shutdown_ms, executor, options)
}
fn spawn_manager_process(
@@ -200,6 +221,30 @@ fn cleanup_stale_manager(instance_dir: &Path) {
let _ = fs::remove_file(&pid_path);
}
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 is_socket_path(path: &Path) -> bool {
fs::metadata(path)
.map(|meta| meta.file_type().is_socket())
@@ -252,7 +297,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 {
@@ -443,6 +488,7 @@ 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,
@@ -530,6 +576,7 @@ trait VmExecutor {
) -> Result<(), Box<dyn std::error::Error>>;
}
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
struct RealVmExecutor;
impl VmExecutor for RealVmExecutor {
@@ -561,9 +608,42 @@ impl VmExecutor for RealVmExecutor {
}
}
#[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,
vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
) -> Result<(), Box<dyn std::error::Error>> {
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,
@@ -602,8 +682,12 @@ fn run_manager_with(
.lock()
.map(|cfg| cfg.ssh_user_display())
.unwrap_or_else(|_| DEFAULT_SSH_USER.to_string());
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(),
@@ -613,6 +697,7 @@ fn run_manager_with(
let extra_login_actions = build_ssh_login_actions(
&config,
&project_name,
&project_guest_dir,
ssh_guest_dir.as_str(),
"ssh_key",
&home_links_script,
@@ -692,7 +777,9 @@ fn manager_event_loop(
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 {
@@ -711,6 +798,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 +824,38 @@ fn manager_event_loop(
&& Instant::now() >= deadline
&& !shutdown_sent
{
let mut sent = false;
if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
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");
}
tracing::info!("shutdown command sent");
shutdown_sent = true;
shutdown_deadline = None;
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(500));
}
}
if ref_count == 0
&& shutdown_sent
&& let Some(deadline) = hard_deadline
&& Instant::now() >= deadline
{
tracing::warn!(
timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
"force exiting: VM did not stop after shutdown timeout"
);
std::process::exit(1);
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
+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",