mirror of
https://github.com/robcholz/vibebox.git
synced 2026-07-01 12:15:30 +02:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65bfc0b34d | |||
| f6678e7069 | |||
| eafa229542 | |||
| 34d0fb965e | |||
| a3764a361e | |||
| 51c3eff6e5 | |||
| 4cb1162ca3 | |||
| 4c9f517107 | |||
| 15245fd245 | |||
| 1c5a464a68 | |||
| 5659f2a538 | |||
| 9cf33359f9 | |||
| a1056ba5cb | |||
| ad8fb139de | |||
| 5e008c9471 | |||
| 935e0cd566 | |||
| c53a6eff51 | |||
| 4dd9c31f74 | |||
| 9dd88dd304 | |||
| 4311168fdd | |||
| a4b9d62873 | |||
| 727adffb4c | |||
| 75adb1696a | |||
| 57ebe7ffee | |||
| f4aa223ddb |
@@ -6,9 +6,10 @@ on:
|
|||||||
- "**"
|
- "**"
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- "README.md"
|
- "README.md"
|
||||||
|
- "CONTRIBUTING.md"
|
||||||
|
- "README.zh.md"
|
||||||
- "docs/**"
|
- "docs/**"
|
||||||
- "install"
|
- "install"
|
||||||
pull_request:
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.ref }}
|
group: ci-${{ github.ref }}
|
||||||
@@ -18,6 +19,8 @@ jobs:
|
|||||||
fmt:
|
fmt:
|
||||||
name: Format
|
name: Format
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
env:
|
||||||
|
RUST_BACKTRACE: "1"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
@@ -32,6 +35,8 @@ jobs:
|
|||||||
clippy:
|
clippy:
|
||||||
name: Clippy
|
name: Clippy
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
env:
|
||||||
|
RUST_BACKTRACE: "1"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
@@ -46,6 +51,8 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
env:
|
||||||
|
RUST_BACKTRACE: "1"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
@@ -54,3 +61,38 @@ jobs:
|
|||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
- name: cargo build
|
- name: cargo build
|
||||||
run: cargo build --locked
|
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
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Contributing to Vibebox
|
||||||
|
|
||||||
|
Thanks for your interest in contributing! This guide keeps PRs small, reviewable, and consistent with the project’s
|
||||||
|
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 you’ve found a security issue, please avoid public disclosure. Open a private report via GitHub Security
|
||||||
|
Advisories instead.
|
||||||
Generated
+76
-1
@@ -82,6 +82,21 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "backtrace"
|
name = "backtrace"
|
||||||
version = "0.3.76"
|
version = "0.3.76"
|
||||||
@@ -112,6 +127,17 @@ dependencies = [
|
|||||||
"objc2",
|
"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]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.19.1"
|
version = "3.19.1"
|
||||||
@@ -321,6 +347,12 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "difflib"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dispatch2"
|
name = "dispatch2"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -737,6 +769,33 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
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]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -1007,6 +1066,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.18"
|
||||||
@@ -1238,8 +1303,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vibebox"
|
name = "vibebox"
|
||||||
version = "0.1.0"
|
version = "0.2.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"assert_cmd",
|
||||||
"block2",
|
"block2",
|
||||||
"clap",
|
"clap",
|
||||||
"color-eyre",
|
"color-eyre",
|
||||||
@@ -1261,6 +1327,15 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wait-timeout"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
|||||||
+8
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "vibebox"
|
name = "vibebox"
|
||||||
version = "0.1.0"
|
version = "0.2.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["Finn Sheng"]
|
authors = ["Finn Sheng"]
|
||||||
description = "Ultrafast CLI on Apple Silicon macOS for fast, sandboxed development and LLM agents."
|
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 = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
dialoguer = "0.12.0"
|
dialoguer = "0.12.0"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
assert_cmd = "2"
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
mock-vm = []
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
[](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 didn’t 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
|
||||||
|
|
||||||
|
Here’s why I didn’t just use existing options:
|
||||||
|
|
||||||
|
- **vibe**: super convenient, but it’s too minimal for what I need. It lacks basic configuration, and it doesn’t give me
|
||||||
|
the multi-instance + session management my workflow wants.
|
||||||
|
- **QEMU**: powerful, but the configuration surface area is huge. For day-to-day sandboxing it’s not “open a repo and
|
||||||
|
go” — it’s a project on its own.
|
||||||
|
- **Docker / devcontainers**: great ecosystem, but for daily use it feels heavy. Cold starts can be slow, and it’s not
|
||||||
|
something I can jump into instantly, repeatedly, all day.
|
||||||
|
|
||||||
|
That’s what pushed me to build **VibeBox**: I wanted a per-project sandbox that’s 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. What’s 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
@@ -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 在里面怎么折腾,你的真实系统都不会被影响;同时做到了低内存和磁盘占用。
|
||||||
|
|
||||||
|
[](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 的 macOS(VibeBox 使用了 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)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -47,36 +47,40 @@ ensure_cargo() {
|
|||||||
|
|
||||||
print_message warning "Rust (cargo) is required but not found."
|
print_message warning "Rust (cargo) is required but not found."
|
||||||
print_message info "You should review and approve the Rust installer before proceeding."
|
print_message info "You should review and approve the Rust installer before proceeding."
|
||||||
|
reply=""
|
||||||
if [[ -t 0 ]]; then
|
if [[ -t 0 ]]; then
|
||||||
read -r -p "Install Rust using rustup now? (y/N) " reply
|
read -r -p "Install Rust using rustup now? (y/N) " reply
|
||||||
case "${reply}" in
|
elif [[ -r /dev/tty ]]; then
|
||||||
y|Y)
|
read -r -p "Install Rust using rustup now? (y/N) " reply </dev/tty
|
||||||
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
|
|
||||||
else
|
else
|
||||||
print_message error "Non-interactive shell: cannot prompt to install Rust."
|
print_message error "Non-interactive shell: cannot prompt to install Rust."
|
||||||
print_message info "Install Rust manually from https://rustup.rs and retry."
|
print_message info "Install Rust manually from https://rustup.rs and retry."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
if ! command -v cargo >/dev/null 2>&1; then
|
||||||
print_message error "Cargo still not available after installation."
|
print_message error "Cargo still not available after installation."
|
||||||
print_message info "Open a new shell or run: source \"$HOME/.cargo/env\""
|
print_message info "Open a new shell or run: source \"$HOME/.cargo/env\""
|
||||||
|
|||||||
Executable
+6
@@ -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
|
||||||
@@ -29,6 +29,7 @@ fn main() -> Result<()> {
|
|||||||
let args = vm::VmArg {
|
let args = vm::VmArg {
|
||||||
cpu_count: config.box_cfg.cpu_count,
|
cpu_count: config.box_cfg.cpu_count,
|
||||||
ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
|
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,
|
no_default_mounts: false,
|
||||||
mounts: config.box_cfg.mounts.clone(),
|
mounts: config.box_cfg.mounts.clone(),
|
||||||
};
|
};
|
||||||
|
|||||||
+27
-3
@@ -49,7 +49,7 @@ fn main() -> Result<()> {
|
|||||||
let stderr_handle = init_tracing(&cwd);
|
let stderr_handle = init_tracing(&cwd);
|
||||||
|
|
||||||
let cli = Cli::parse();
|
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 {
|
if let Some(command) = cli.command {
|
||||||
return handle_command(command, &cwd, cli.config.as_deref());
|
return handle_command(command, &cwd, cli.config.as_deref());
|
||||||
}
|
}
|
||||||
@@ -63,6 +63,7 @@ fn main() -> Result<()> {
|
|||||||
let args = vm::VmArg {
|
let args = vm::VmArg {
|
||||||
cpu_count: config.box_cfg.cpu_count,
|
cpu_count: config.box_cfg.cpu_count,
|
||||||
ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
|
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,
|
no_default_mounts: false,
|
||||||
mounts: config.box_cfg.mounts.clone(),
|
mounts: config.box_cfg.mounts.clone(),
|
||||||
};
|
};
|
||||||
@@ -80,14 +81,15 @@ fn main() -> Result<()> {
|
|||||||
let vm_args = vm::VmArg {
|
let vm_args = vm::VmArg {
|
||||||
cpu_count: config.box_cfg.cpu_count,
|
cpu_count: config.box_cfg.cpu_count,
|
||||||
ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
|
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,
|
no_default_mounts: false,
|
||||||
mounts: config.box_cfg.mounts.clone(),
|
mounts: config.box_cfg.mounts.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let auto_shutdown_ms = config.supervisor.auto_shutdown_ms;
|
let auto_shutdown_ms = config.supervisor.auto_shutdown_ms;
|
||||||
let vm_info = VmInfo {
|
let vm_info = VmInfo {
|
||||||
max_memory_mb: vm_args.ram_bytes / (1024 * 1024),
|
max_memory_mb: vm_args.ram_bytes / (1024 * 1024),
|
||||||
cpu_cores: vm_args.cpu_count,
|
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.
|
system_name: "Debian".to_string(), // TODO: read system name from the VM.
|
||||||
auto_shutdown_ms,
|
auto_shutdown_ms,
|
||||||
};
|
};
|
||||||
@@ -110,6 +112,7 @@ fn main() -> Result<()> {
|
|||||||
writeln!(stdout)?;
|
writeln!(stdout)?;
|
||||||
stdout.flush()?;
|
stdout.flush()?;
|
||||||
}
|
}
|
||||||
|
warn_disk_size_mismatch(&cwd, vm_args.disk_bytes);
|
||||||
if let Some(handle) = stderr_handle {
|
if let Some(handle) = stderr_handle {
|
||||||
let _ = handle.modify(|filter| *filter = LevelFilter::INFO);
|
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" })
|
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>;
|
type StderrHandle = reload::Handle<LevelFilter, Registry>;
|
||||||
|
|
||||||
fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
|
fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
|
||||||
@@ -371,7 +395,7 @@ fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if stderr_is_tty {
|
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()
|
let stderr_layer = fmt::layer()
|
||||||
.with_target(false)
|
.with_target(false)
|
||||||
.with_ansi(ansi)
|
.with_ansi(ansi)
|
||||||
|
|||||||
+16
-2
@@ -13,6 +13,7 @@ pub const CONFIG_PATH_ENV: &str = "VIBEBOX_CONFIG_PATH";
|
|||||||
const DEFAULT_CPU_COUNT: usize = 2;
|
const DEFAULT_CPU_COUNT: usize = 2;
|
||||||
const DEFAULT_RAM_MB: u64 = 2048;
|
const DEFAULT_RAM_MB: u64 = 2048;
|
||||||
const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 20000;
|
const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 20000;
|
||||||
|
const DEFAULT_DISK_GB: u64 = 5;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
@@ -25,6 +26,7 @@ pub struct Config {
|
|||||||
pub struct BoxConfig {
|
pub struct BoxConfig {
|
||||||
pub cpu_count: usize,
|
pub cpu_count: usize,
|
||||||
pub ram_mb: u64,
|
pub ram_mb: u64,
|
||||||
|
pub disk_gb: u64,
|
||||||
pub mounts: Vec<String>,
|
pub mounts: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +35,7 @@ impl Default for BoxConfig {
|
|||||||
Self {
|
Self {
|
||||||
cpu_count: default_cpu_count(),
|
cpu_count: default_cpu_count(),
|
||||||
ram_mb: default_ram_mb(),
|
ram_mb: default_ram_mb(),
|
||||||
|
disk_gb: default_disk_gb(),
|
||||||
mounts: default_mounts(),
|
mounts: default_mounts(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,7 +67,14 @@ fn default_auto_shutdown_ms() -> u64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn default_mounts() -> Vec<String> {
|
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 {
|
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");
|
tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config");
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
die(&format!(
|
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()
|
path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -193,6 +203,7 @@ fn validate_schema(value: &toml::Value) -> Vec<String> {
|
|||||||
Some(table) => {
|
Some(table) => {
|
||||||
validate_int(table, "cpu_count", "[box].cpu_count (integer)", &mut errors);
|
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, "ram_mb", "[box].ram_mb (integer)", &mut errors);
|
||||||
|
validate_int(table, "disk_gb", "[box].disk_gb (integer)", &mut errors);
|
||||||
validate_string_array(
|
validate_string_array(
|
||||||
table,
|
table,
|
||||||
"mounts",
|
"mounts",
|
||||||
@@ -259,6 +270,9 @@ fn validate_or_exit(config: &Config) {
|
|||||||
if config.box_cfg.ram_mb == 0 {
|
if config.box_cfg.ram_mb == 0 {
|
||||||
die("box.ram_mb must be >= 1");
|
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 {
|
if config.supervisor.auto_shutdown_ms == 0 {
|
||||||
die("supervisor.auto_shutdown_ms must be >= 1");
|
die("supervisor.auto_shutdown_ms must be >= 1");
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>, Box<dyn Error +
|
|||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|name| name.to_str())
|
.and_then(|name| name.to_str())
|
||||||
.unwrap_or("project");
|
.unwrap_or("project");
|
||||||
let project_guest = format!("/root/{project_name}");
|
let project_guest = format!("~/{project_name}");
|
||||||
let project_host = display_path(cwd);
|
let project_host = display_path(cwd);
|
||||||
let mut rows = vec![tui::MountListRow {
|
let mut rows = vec![tui::MountListRow {
|
||||||
host: project_host,
|
host: project_host,
|
||||||
|
|||||||
+31
-10
@@ -21,6 +21,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SSH_KEY_NAME: &str = "ssh_key";
|
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 VM_ROOT_LOG_NAME: &str = "vm_root.log";
|
||||||
pub(crate) const STATUS_FILE_NAME: &str = "status.txt";
|
pub(crate) const STATUS_FILE_NAME: &str = "status.txt";
|
||||||
pub(crate) const DEFAULT_SSH_USER: &str = "vibecoder";
|
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");
|
tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
|
||||||
|
|
||||||
let _manager_conn = manager_conn;
|
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)?
|
let ip = load_or_create_instance_config(&instance_dir)?
|
||||||
.vm_ipv4
|
.vm_ipv4
|
||||||
@@ -217,6 +218,7 @@ fn generate_password() -> String {
|
|||||||
Uuid::now_v7().simple().to_string()
|
Uuid::now_v7().simple().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
|
||||||
pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
|
pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
|
||||||
let mut current = String::new();
|
let mut current = String::new();
|
||||||
let mut best: Option<String> = None;
|
let mut best: Option<String> = None;
|
||||||
@@ -242,19 +244,41 @@ fn wait_for_vm_ipv4(
|
|||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let mut next_log_at = start + Duration::from_secs(10);
|
let mut next_log_at = start + Duration::from_secs(10);
|
||||||
|
let mut next_status_check = start;
|
||||||
tracing::info!("waiting for vm ipv4");
|
tracing::info!("waiting for vm ipv4");
|
||||||
let status_path = instance_dir.join(STATUS_FILE_NAME);
|
let status_path = instance_dir.join(STATUS_FILE_NAME);
|
||||||
let mut last_status: Option<String> = None;
|
let mut last_status: Option<String> = None;
|
||||||
|
let mut status_missing = true;
|
||||||
let mut once_hint = false;
|
let mut once_hint = false;
|
||||||
loop {
|
loop {
|
||||||
let config = load_or_create_instance_config(instance_dir)?;
|
let config = load_or_create_instance_config(instance_dir)?;
|
||||||
if config.vm_ipv4.is_some() {
|
if config.vm_ipv4.is_some() {
|
||||||
|
let _ = fs::remove_file(&status_path);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if start.elapsed() > timeout {
|
if start.elapsed() > timeout {
|
||||||
|
let _ = fs::remove_file(&status_path);
|
||||||
return Err("Timed out waiting for VM IPv4".into());
|
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();
|
let waited = start.elapsed();
|
||||||
if waited.as_secs() > 15 && !once_hint {
|
if waited.as_secs() > 15 && !once_hint {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -262,16 +286,10 @@ fn wait_for_vm_ipv4(
|
|||||||
);
|
);
|
||||||
once_hint = true;
|
once_hint = true;
|
||||||
}
|
}
|
||||||
if let Ok(status) = fs::read_to_string(&status_path) {
|
if status_missing {
|
||||||
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(),);
|
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));
|
thread::sleep(Duration::from_millis(200));
|
||||||
}
|
}
|
||||||
@@ -367,6 +385,7 @@ fn run_ssh_session(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
|
||||||
fn is_ipv4_candidate(candidate: &str) -> bool {
|
fn is_ipv4_candidate(candidate: &str) -> bool {
|
||||||
let parts: Vec<&str> = candidate.split('.').collect();
|
let parts: Vec<&str> = candidate.split('.').collect();
|
||||||
if parts.len() != 4 {
|
if parts.len() != 4 {
|
||||||
@@ -394,6 +413,7 @@ fn ssh_port_open(ip: &str) -> bool {
|
|||||||
pub(crate) fn build_ssh_login_actions(
|
pub(crate) fn build_ssh_login_actions(
|
||||||
config: &Arc<Mutex<InstanceConfig>>,
|
config: &Arc<Mutex<InstanceConfig>>,
|
||||||
project_name: &str,
|
project_name: &str,
|
||||||
|
project_guest_dir: &str,
|
||||||
guest_dir: &str,
|
guest_dir: &str,
|
||||||
key_name: &str,
|
key_name: &str,
|
||||||
home_links_script: &str,
|
home_links_script: &str,
|
||||||
@@ -409,6 +429,7 @@ pub(crate) fn build_ssh_login_actions(
|
|||||||
.replace("__SSH_USER__", &ssh_user)
|
.replace("__SSH_USER__", &ssh_user)
|
||||||
.replace("__SUDO_PASSWORD__", &sudo_password)
|
.replace("__SUDO_PASSWORD__", &sudo_password)
|
||||||
.replace("__PROJECT_NAME__", project_name)
|
.replace("__PROJECT_NAME__", project_name)
|
||||||
|
.replace("__PROJECT_GUEST_DIR__", project_guest_dir)
|
||||||
.replace("__KEY_PATH__", &key_path)
|
.replace("__KEY_PATH__", &key_path)
|
||||||
.replace("__VIBEBOX_SHELL_SCRIPT__", &commands::render_shell_script())
|
.replace("__VIBEBOX_SHELL_SCRIPT__", &commands::render_shell_script())
|
||||||
.replace("__VIBEBOX_HOME_LINKS__", home_links_script);
|
.replace("__VIBEBOX_HOME_LINKS__", home_links_script);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ apt-get install -y --no-install-recommends \
|
|||||||
curl \
|
curl \
|
||||||
git \
|
git \
|
||||||
ripgrep \
|
ripgrep \
|
||||||
|
cloud-guest-utils \
|
||||||
openssh-server \
|
openssh-server \
|
||||||
sudo
|
sudo
|
||||||
|
|
||||||
@@ -42,6 +43,11 @@ systemctl restart ssh
|
|||||||
# Set this env var so claude doesn't complain about running as root.'
|
# Set this env var so claude doesn't complain about running as root.'
|
||||||
echo "export IS_SANDBOX=1" >> .bashrc
|
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
|
# Shutdown the VM when you logout
|
||||||
cat > .bash_logout <<EOF
|
cat > .bash_logout <<EOF
|
||||||
systemctl poweroff
|
systemctl poweroff
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -117,7 +117,7 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if removed > 0 || added {
|
if removed > 0 || added {
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
path = %self.sessions_dir.display(),
|
path = %self.sessions_dir.display(),
|
||||||
removed,
|
removed,
|
||||||
added,
|
added,
|
||||||
@@ -196,7 +196,7 @@ impl SessionManager {
|
|||||||
let path = self.session_path_for(&record.id);
|
let path = self.session_path_for(&record.id);
|
||||||
let content = toml::to_string_pretty(record)?;
|
let content = toml::to_string_pretty(record)?;
|
||||||
atomic_write(&path, content.as_bytes())?;
|
atomic_write(&path, content.as_bytes())?;
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
path = %path.display(),
|
path = %path.display(),
|
||||||
"wrote session record"
|
"wrote session record"
|
||||||
);
|
);
|
||||||
|
|||||||
+51
-14
@@ -3,6 +3,7 @@ set -eu
|
|||||||
|
|
||||||
SSH_USER="__SSH_USER__"
|
SSH_USER="__SSH_USER__"
|
||||||
PROJECT_NAME="__PROJECT_NAME__"
|
PROJECT_NAME="__PROJECT_NAME__"
|
||||||
|
PROJECT_GUEST_DIR="__PROJECT_GUEST_DIR__"
|
||||||
KEY_PATH="__KEY_PATH__"
|
KEY_PATH="__KEY_PATH__"
|
||||||
|
|
||||||
diag() { echo "[vibebox][diag] $*" >&2; }
|
diag() { echo "[vibebox][diag] $*" >&2; }
|
||||||
@@ -49,7 +50,7 @@ dump_diag() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 1) tmpfs mount
|
# 1) tmpfs mount
|
||||||
TARGET="/root/${PROJECT_NAME}/.vibebox"
|
TARGET="${PROJECT_GUEST_DIR}/.vibebox"
|
||||||
if [ -d "$TARGET" ] && ! mountpoint -q "$TARGET"; then
|
if [ -d "$TARGET" ] && ! mountpoint -q "$TARGET"; then
|
||||||
mount -t tmpfs tmpfs "$TARGET"
|
mount -t tmpfs tmpfs "$TARGET"
|
||||||
fi
|
fi
|
||||||
@@ -78,6 +79,21 @@ __VIBEBOX_SHELL_SCRIPT__
|
|||||||
VIBEBOX_SHELL_EOF
|
VIBEBOX_SHELL_EOF
|
||||||
chmod 644 /etc/profile.d/vibebox.sh
|
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
|
if ! grep -q "vibebox-aliases" "${USER_HOME}/.bashrc" 2>/dev/null; then
|
||||||
{
|
{
|
||||||
echo ""
|
echo ""
|
||||||
@@ -88,16 +104,22 @@ fi
|
|||||||
|
|
||||||
# Install Mise
|
# Install Mise
|
||||||
MISE_BIN="${USER_HOME}/.local/bin/mise"
|
MISE_BIN="${USER_HOME}/.local/bin/mise"
|
||||||
if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
|
mise_warn() { echo "[mise] $*" >&2; }
|
||||||
curl https://mise.run | HOME="$USER_HOME" sh
|
mise_ok() { command -v mise >/dev/null 2>&1 || [ -x "$MISE_BIN" ]; }
|
||||||
fi
|
mise_install() {
|
||||||
echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
|
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]
|
[settings]
|
||||||
# Always use the venv created by uv, if available in directory
|
# Always use the venv created by uv, if available in directory
|
||||||
python.uv_venv_auto = true
|
python.uv_venv_auto = true
|
||||||
@@ -111,12 +133,21 @@ cat > "${USER_HOME}/.config/mise/config.toml" <<MISE
|
|||||||
"npm:@anthropic-ai/claude-code" = "latest"
|
"npm:@anthropic-ai/claude-code" = "latest"
|
||||||
MISE
|
MISE
|
||||||
|
|
||||||
touch "${USER_HOME}/.config/mise/mise.lock"
|
touch "${USER_HOME}/.config/mise/mise.lock"
|
||||||
if [ -x "$MISE_BIN" ]; then
|
if [ -x "$MISE_BIN" ]; then
|
||||||
HOME="$USER_HOME" "$MISE_BIN" install
|
if ! HOME="$USER_HOME" "$MISE_BIN" install; then
|
||||||
else
|
mise_warn "mise install failed (continuing)"
|
||||||
HOME="$USER_HOME" mise install
|
return 0
|
||||||
fi
|
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)
|
# 3) start ssh (don't swallow failures)
|
||||||
# If ssh is already active, don't force start/restart.
|
# If ssh is already active, don't force start/restart.
|
||||||
@@ -184,5 +215,11 @@ if ! listens_ok; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
ip a
|
||||||
|
ip link
|
||||||
|
curl -s https://api.ipify.org ; echo
|
||||||
|
|
||||||
|
cat /etc/machine-id
|
||||||
|
|
||||||
echo VIBEBOX_SSH_READY
|
echo VIBEBOX_SSH_READY
|
||||||
echo "VIBEBOX_IPV4=$ip"
|
echo "VIBEBOX_IPV4=$ip"
|
||||||
|
|||||||
+5
-3
@@ -25,6 +25,7 @@ use ratatui::{
|
|||||||
|
|
||||||
use crate::vm;
|
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] = [
|
const ASCII_BANNER: [&str; 7] = [
|
||||||
"██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
|
"██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
|
||||||
"██║ ██║██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗╚██╗██╔╝",
|
"██║ ██║██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗╚██╗██╔╝",
|
||||||
@@ -40,6 +41,7 @@ const INFO_LINE_COUNT: u16 = 5;
|
|||||||
pub struct VmInfo {
|
pub struct VmInfo {
|
||||||
pub max_memory_mb: u64,
|
pub max_memory_mb: u64,
|
||||||
pub cpu_cores: usize,
|
pub cpu_cores: usize,
|
||||||
|
pub max_disk_gb: f32,
|
||||||
pub system_name: String,
|
pub system_name: String,
|
||||||
pub auto_shutdown_ms: u64,
|
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)),
|
Span::styled(&app.vm_info.system_name, Style::default().fg(Color::Green)),
|
||||||
]),
|
]),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::raw("CPU / Memory: "),
|
Span::raw("CPU / Memory / Disk: "),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!(
|
format!(
|
||||||
"{} cores / {} MB",
|
"{} cores / {} MB / {} GB",
|
||||||
app.vm_info.cpu_cores, app.vm_info.max_memory_mb
|
app.vm_info.cpu_cores, app.vm_info.max_memory_mb, app.vm_info.max_disk_gb
|
||||||
),
|
),
|
||||||
Style::default().fg(Color::Green),
|
Style::default().fg(Color::Green),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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_SHA: &str = "6ab9be9e6834adc975268367f2f0235251671184345c34ee13031749fdfbf66fe4c3aafd949a2d98550426090e9ac645e79009c51eb0eefc984c15786570bb38";
|
||||||
const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
|
const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
|
||||||
const SHARED_DIRECTORIES_TAG: &str = "shared";
|
const SHARED_DIRECTORIES_TAG: &str = "shared";
|
||||||
|
pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts";
|
||||||
|
|
||||||
const BYTES_PER_MB: u64 = 1024 * 1024;
|
const BYTES_PER_MB: u64 = 1024 * 1024;
|
||||||
const DEFAULT_CPU_COUNT: usize = 2;
|
const DEFAULT_CPU_COUNT: usize = 2;
|
||||||
@@ -56,12 +57,6 @@ impl StatusFile {
|
|||||||
fn update(&self, message: &str) {
|
fn update(&self, message: &str) {
|
||||||
let _ = fs::write(&self.path, message);
|
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 {
|
impl Drop for StatusFile {
|
||||||
@@ -74,6 +69,7 @@ impl Drop for StatusFile {
|
|||||||
}
|
}
|
||||||
const PROVISION_SCRIPT: &str = include_str!("provision.sh");
|
const PROVISION_SCRIPT: &str = include_str!("provision.sh");
|
||||||
const PROVISION_SCRIPT_NAME: &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 DEFAULT_RAW_NAME: &str = "default.raw";
|
||||||
const INSTANCE_RAW_NAME: &str = "instance.raw";
|
const INSTANCE_RAW_NAME: &str = "instance.raw";
|
||||||
const BASE_DISK_RAW_NAME: &str = "disk.raw";
|
const BASE_DISK_RAW_NAME: &str = "disk.raw";
|
||||||
@@ -166,6 +162,7 @@ fn expand_tilde_path(value: &str) -> PathBuf {
|
|||||||
pub struct VmArg {
|
pub struct VmArg {
|
||||||
pub cpu_count: usize,
|
pub cpu_count: usize,
|
||||||
pub ram_bytes: u64,
|
pub ram_bytes: u64,
|
||||||
|
pub disk_bytes: u64,
|
||||||
pub no_default_mounts: bool,
|
pub no_default_mounts: bool,
|
||||||
pub mounts: Vec<String>,
|
pub mounts: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -230,14 +227,23 @@ where
|
|||||||
std::slice::from_ref(&mise_directory_share),
|
std::slice::from_ref(&mise_directory_share),
|
||||||
Some(&status_file),
|
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 disk_path = instance_raw;
|
||||||
|
|
||||||
let mut login_actions = Vec::new();
|
let mut login_actions = Vec::new();
|
||||||
let mut directory_shares = Vec::new();
|
let mut directory_shares = Vec::new();
|
||||||
|
|
||||||
if !args.no_default_mounts {
|
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.
|
// 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
|
// 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()));
|
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);
|
directory_shares.push(mise_directory_share);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +260,11 @@ where
|
|||||||
directory_shares.push(DirectoryShare::from_mount_spec(spec)?);
|
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) {
|
if let Some(motd_action) = motd_login_action(&directory_shares) {
|
||||||
login_actions.push(motd_action);
|
login_actions.push(motd_action);
|
||||||
}
|
}
|
||||||
@@ -559,19 +561,51 @@ fn ensure_default_image(
|
|||||||
fn ensure_instance_disk(
|
fn ensure_instance_disk(
|
||||||
instance_raw: &Path,
|
instance_raw: &Path,
|
||||||
template_raw: &Path,
|
template_raw: &Path,
|
||||||
|
target_bytes: u64,
|
||||||
status: Option<&StatusFile>,
|
status: Option<&StatusFile>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
if instance_raw.exists() {
|
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 {
|
if let Some(status) = status {
|
||||||
status.update("creating instance disk...");
|
status.update("creating instance disk...");
|
||||||
}
|
}
|
||||||
tracing::info!(path = %template_raw.display(), "creating instance disk");
|
tracing::info!(path = %template_raw.display(), "creating instance disk");
|
||||||
std::fs::create_dir_all(instance_raw.parent().unwrap())?;
|
std::fs::create_dir_all(instance_raw.parent().unwrap())?;
|
||||||
fs::copy(template_raw, instance_raw)?;
|
if target_size == template_size {
|
||||||
Ok(())
|
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 {
|
pub struct IoContext {
|
||||||
@@ -1079,7 +1113,6 @@ where
|
|||||||
|
|
||||||
if let Some(status) = status {
|
if let Some(status) = status {
|
||||||
status.update("vm booting... go vibecoder!");
|
status.update("vm booting... go vibecoder!");
|
||||||
status.clear();
|
|
||||||
}
|
}
|
||||||
tracing::info!("vm booting");
|
tracing::info!("vm booting");
|
||||||
|
|
||||||
|
|||||||
+132
-18
@@ -25,11 +25,12 @@ use crate::{
|
|||||||
session_manager::{
|
session_manager::{
|
||||||
GLOBAL_DIR_NAME, INSTANCE_FILENAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME,
|
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_LOCK_NAME: &str = "vm.lock";
|
||||||
const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
|
const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
|
||||||
|
const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 12_000;
|
||||||
|
|
||||||
pub fn ensure_manager(
|
pub fn ensure_manager(
|
||||||
raw_args: &[std::ffi::OsString],
|
raw_args: &[std::ffi::OsString],
|
||||||
@@ -102,17 +103,37 @@ pub fn run_manager(
|
|||||||
let project_root = env::current_dir()?;
|
let project_root = env::current_dir()?;
|
||||||
tracing::info!(root = %project_root.display(), "vm manager starting");
|
tracing::info!(root = %project_root.display(), "vm manager starting");
|
||||||
let _pid_guard = ensure_pid_file(&project_root)?;
|
let _pid_guard = ensure_pid_file(&project_root)?;
|
||||||
run_manager_with(
|
#[cfg(feature = "mock-vm")]
|
||||||
&project_root,
|
tracing::info!("vm manager using mock executor");
|
||||||
args,
|
let executor: &dyn VmExecutor = {
|
||||||
auto_shutdown_ms,
|
#[cfg(feature = "mock-vm")]
|
||||||
&RealVmExecutor,
|
{
|
||||||
ManagerOptions {
|
&MockVmExecutor
|
||||||
ensure_signed: true,
|
}
|
||||||
detach: true,
|
#[cfg(not(feature = "mock-vm"))]
|
||||||
prepare_vm: true,
|
{
|
||||||
},
|
&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(
|
fn spawn_manager_process(
|
||||||
@@ -200,6 +221,30 @@ fn cleanup_stale_manager(instance_dir: &Path) {
|
|||||||
let _ = fs::remove_file(&pid_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 {
|
fn is_socket_path(path: &Path) -> bool {
|
||||||
fs::metadata(path)
|
fs::metadata(path)
|
||||||
.map(|meta| meta.file_type().is_socket())
|
.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);
|
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() {
|
let root_path = if rel.is_empty() {
|
||||||
root_base.to_string()
|
root_base.to_string()
|
||||||
} else {
|
} 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(
|
fn spawn_manager_io(
|
||||||
config: Arc<Mutex<InstanceConfig>>,
|
config: Arc<Mutex<InstanceConfig>>,
|
||||||
instance_dir: PathBuf,
|
instance_dir: PathBuf,
|
||||||
@@ -530,6 +576,7 @@ trait VmExecutor {
|
|||||||
) -> Result<(), Box<dyn std::error::Error>>;
|
) -> Result<(), Box<dyn std::error::Error>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "mock-vm", allow(dead_code))]
|
||||||
struct RealVmExecutor;
|
struct RealVmExecutor;
|
||||||
|
|
||||||
impl VmExecutor for 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(
|
fn run_manager_with(
|
||||||
project_root: &Path,
|
project_root: &Path,
|
||||||
args: vm::VmArg,
|
mut args: vm::VmArg,
|
||||||
auto_shutdown_ms: u64,
|
auto_shutdown_ms: u64,
|
||||||
executor: &dyn VmExecutor,
|
executor: &dyn VmExecutor,
|
||||||
options: ManagerOptions,
|
options: ManagerOptions,
|
||||||
@@ -602,8 +682,12 @@ fn run_manager_with(
|
|||||||
.lock()
|
.lock()
|
||||||
.map(|cfg| cfg.ssh_user_display())
|
.map(|cfg| cfg.ssh_user_display())
|
||||||
.unwrap_or_else(|_| DEFAULT_SSH_USER.to_string());
|
.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 (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 ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
|
||||||
let extra_shares = vec![DirectoryShare::new(
|
let extra_shares = vec![DirectoryShare::new(
|
||||||
instance_dir.clone(),
|
instance_dir.clone(),
|
||||||
@@ -613,6 +697,7 @@ fn run_manager_with(
|
|||||||
let extra_login_actions = build_ssh_login_actions(
|
let extra_login_actions = build_ssh_login_actions(
|
||||||
&config,
|
&config,
|
||||||
&project_name,
|
&project_name,
|
||||||
|
&project_guest_dir,
|
||||||
ssh_guest_dir.as_str(),
|
ssh_guest_dir.as_str(),
|
||||||
"ssh_key",
|
"ssh_key",
|
||||||
&home_links_script,
|
&home_links_script,
|
||||||
@@ -692,7 +777,9 @@ fn manager_event_loop(
|
|||||||
let mut ref_count: usize = 0;
|
let mut ref_count: usize = 0;
|
||||||
let mut shutdown_deadline: Option<Instant> = None;
|
let mut shutdown_deadline: Option<Instant> = None;
|
||||||
let mut shutdown_sent = false;
|
let mut shutdown_sent = false;
|
||||||
|
let mut hard_deadline: Option<Instant> = None;
|
||||||
let grace = Duration::from_millis(auto_shutdown_ms.max(1));
|
let grace = Duration::from_millis(auto_shutdown_ms.max(1));
|
||||||
|
let hard_timeout = Duration::from_millis(HARD_SHUTDOWN_TIMEOUT_MS);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let timeout = match shutdown_deadline {
|
let timeout = match shutdown_deadline {
|
||||||
@@ -711,6 +798,7 @@ fn manager_event_loop(
|
|||||||
);
|
);
|
||||||
shutdown_deadline = None;
|
shutdown_deadline = None;
|
||||||
shutdown_sent = false;
|
shutdown_sent = false;
|
||||||
|
hard_deadline = None;
|
||||||
}
|
}
|
||||||
Ok(ManagerEvent::Dec(pid)) => {
|
Ok(ManagerEvent::Dec(pid)) => {
|
||||||
ref_count = ref_count.saturating_sub(1);
|
ref_count = ref_count.saturating_sub(1);
|
||||||
@@ -736,12 +824,38 @@ fn manager_event_loop(
|
|||||||
&& Instant::now() >= deadline
|
&& Instant::now() >= deadline
|
||||||
&& !shutdown_sent
|
&& !shutdown_sent
|
||||||
{
|
{
|
||||||
|
let mut sent = false;
|
||||||
if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
|
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");
|
if sent {
|
||||||
shutdown_sent = true;
|
tracing::info!("shutdown command sent");
|
||||||
shutdown_deadline = None;
|
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,
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +1,7 @@
|
|||||||
[box]
|
[box]
|
||||||
cpu_count = 2
|
cpu_count = 2
|
||||||
ram_mb = 2048
|
ram_mb = 2048
|
||||||
|
disk_gb = 5
|
||||||
mounts = [
|
mounts = [
|
||||||
"~/.codex:~/.codex:read-write",
|
"~/.codex:~/.codex:read-write",
|
||||||
"~/.claude:~/.claude:read-write",
|
"~/.claude:~/.claude:read-write",
|
||||||
|
|||||||
Reference in New Issue
Block a user