diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c6d49a2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,228 @@ +name: CI + +on: + push: + branches: [master, pi-sandbox-refactor] + pull_request: + branches: [master] + +jobs: + rust-tests: + name: Rust tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + crates/nixosandbox/target + key: cargo-${{ runner.os }}-${{ hashFiles('crates/nixosandbox/Cargo.lock') }} + restore-keys: cargo-${{ runner.os }}- + + - name: Build + working-directory: crates/nixosandbox + run: cargo build + + - name: Test + working-directory: crates/nixosandbox + run: cargo test + + typescript-check: + name: TypeScript typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: "22" + + - name: Install dependencies + working-directory: packages/pi-sandbox-extension + run: npm install + + - name: Typecheck + working-directory: packages/pi-sandbox-extension + run: npx tsc --noEmit + + nix-eval: + name: Nix evaluation & catalog + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: cachix/install-nix-action@v30 + with: + extra_nix_config: | + experimental-features = nix-command flakes + extra-substituters = https://cache.numtide.com + extra-trusted-public-keys = niks3.numtide.com-1:DTx8wZduET09hRmMtKdQDxNNthLQETkc/yaX7M4qK0g= + + - name: Flake check + run: nix flake check --accept-flake-config + + - name: Evaluate catalog agents + run: | + nix eval --accept-flake-config .#catalog.agents --apply 'x: builtins.attrNames x' + + - name: Evaluate catalog tools + run: | + nix eval --accept-flake-config .#catalog.tools --apply 'x: builtins.attrNames x' + + - name: Verify existing profiles + run: | + nix eval --accept-flake-config .#packages.x86_64-linux.sandbox-strict.name + + nix-build: + name: Nix build & smoke test + runs-on: ubuntu-latest + needs: [rust-tests, nix-eval] + steps: + - uses: actions/checkout@v6 + + - uses: cachix/install-nix-action@v30 + with: + extra_nix_config: | + experimental-features = nix-command flakes + extra-substituters = https://cache.numtide.com + extra-trusted-public-keys = niks3.numtide.com-1:DTx8wZduET09hRmMtKdQDxNNthLQETkc/yaX7M4qK0g= + + - name: Build nixosandbox CLI + run: nix build --accept-flake-config .#nixosandbox + + - name: Test catalog subcommand + run: | + NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox catalog --json | python3 -m json.tool > /dev/null + echo "Catalog JSON is valid" + + - name: Verify catalog agent count and new packages + run: | + catalog_json=$(NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox catalog --json) + agent_count=$(echo "$catalog_json" | python3 -c "import sys, json; d=json.load(sys.stdin); print(len(d['agents']))") + echo "Catalog agent count: $agent_count" + # Threshold is conservative — guards against empty catalog, not exact upstream count. + # llm-agents.nix is a third-party input that may fluctuate; 50 is well above any realistic minimum. + [ "$agent_count" -gt 50 ] || { echo "ERROR: expected >50 agents, got $agent_count"; exit 1; } + echo "$catalog_json" | python3 -c ' + import sys, json + d = json.load(sys.stdin) + agents = d["agents"] + missing = [n for n in ["openclaw", "hermes-agent", "jules"] if n not in agents] + if missing: + print(f"ERROR: missing agents: {missing}", file=sys.stderr) + sys.exit(1) + print(f"All expected agents present in {len(agents)} total") + ' + + - name: Test catalog filter + run: | + output=$(NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox catalog --filter claude) + echo "$output" + echo "$output" | grep -q "claude-code" + + - name: Test --with rootfs creation + run: | + output=$(NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox create \ + --with bash,coreutils --network off --name ci-smoke --json) + echo "$output" + session_id=$(echo "$output" | python3 -c "import sys,json; print(json.load(sys.stdin)['sessionId'])") + echo "Session created: $session_id" + NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox destroy "$session_id" + echo "Session destroyed" + + - name: Test mutual exclusivity + run: | + if NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox create --profile strict --with bash 2>&1; then + echo "ERROR: should have failed" && exit 1 + else + echo "Mutual exclusivity check passed" + fi + + agent-smoke-tests: + name: Agent sandbox smoke tests + runs-on: ubuntu-latest + needs: [nix-build] + strategy: + fail-fast: false + matrix: + include: + - agent: claude-code + binary: claude + check: "--version" + - agent: codex + binary: codex + check: "--help" + - agent: opencode + binary: opencode + check: "--version" + - agent: amp + binary: amp + check: "--help" + - agent: droid + binary: droid + check: "--version" + - agent: pi + binary: pi + check: "--help" + - agent: openclaw + binary: openclaw + check: "--help" + - agent: hermes-agent + binary: hermes + check: "--version" + - agent: jules + binary: jules + check: "--help" + steps: + - uses: actions/checkout@v6 + + - uses: cachix/install-nix-action@v30 + with: + extra_nix_config: | + experimental-features = nix-command flakes + extra-substituters = https://cache.numtide.com + extra-trusted-public-keys = niks3.numtide.com-1:DTx8wZduET09hRmMtKdQDxNNthLQETkc/yaX7M4qK0g= + + - name: Install and verify bubblewrap + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq bubblewrap + echo "bwrap path: $(which bwrap)" + bwrap --version + # Verify bwrap can actually create sandboxes. + # Ubuntu 24.04 may restrict user namespaces via AppArmor (containers/bubblewrap#632). + if ! bwrap --ro-bind / / --dev /dev --proc /proc -- echo "bwrap sandbox works"; then + echo "bwrap failed — relaxing AppArmor unprivileged userns restriction" + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + bwrap --ro-bind / / --dev /dev --proc /proc -- echo "bwrap sandbox works after sysctl fix" + fi + + - name: Build nixosandbox CLI + run: nix build --accept-flake-config .#nixosandbox + + - name: Create sandbox with ${{ matrix.agent }} + run: | + echo "Creating sandbox with ${{ matrix.agent }} + bash..." + output=$(NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox create \ + --with ${{ matrix.agent }},bash --network off --name ci-${{ matrix.agent }} --json) + echo "$output" + echo "$output" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Session: {d[\"sessionId\"]}, Profile: {d[\"profile\"]}')" + session_id=$(echo "$output" | python3 -c "import sys,json; print(json.load(sys.stdin)['sessionId'])") + echo "SESSION_ID=$session_id" >> "$GITHUB_ENV" + + - name: Verify ${{ matrix.agent }} binary launches + run: | + echo "Running: ${{ matrix.binary }} ${{ matrix.check }}" + NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox exec "$SESSION_ID" --json \ + -- ${{ matrix.binary }} ${{ matrix.check }} + echo "${{ matrix.agent }} smoke test passed" + + - name: Cleanup session + if: always() + run: | + if [ -n "$SESSION_ID" ]; then + NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox destroy "$SESSION_ID" 2>/dev/null || true + fi diff --git a/.gitignore b/.gitignore index be1f6f6..1bcc82f 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,14 @@ tests/__pycache__/ *.key credentials.json secrets.json + +# Pi Sandbox Extension +packages/pi-sandbox-extension/node_modules/ +packages/pi-sandbox-extension/dist/ + +# Rust / Cargo +crates/nixosandbox/target/ + +# Protocol tests +tests/protocol/node_modules/ +.pi/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..02f1c83 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,96 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build and test commands + +### Rust CLI +```bash +cd crates/nixosandbox +cargo build # build +cargo test # run all 20 tests +cargo test session::tests # run tests in one module +cargo test metadata_roundtrip # run a single test by name +``` + +### TypeScript extension +```bash +cd packages/pi-sandbox-extension +npm install +npx tsc --noEmit # typecheck only +npm run build # compile to dist/ +``` + +### Nix +```bash +nix flake check --accept-flake-config +nix build --accept-flake-config .#nixosandbox # build CLI as Nix package +nix eval --accept-flake-config .#catalog.agents --apply 'x: builtins.attrNames x' +nix eval --accept-flake-config .#catalog.tools --apply 'x: builtins.attrNames x' +``` + +### CLI smoke test (Linux only, requires bwrap) +```bash +NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox catalog --json +NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox create --with bash,coreutils --network off --json +NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox exec -- echo hello +NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox destroy +``` + +## Architecture + +### Core data flow + +1. **User runs** `nixosandbox create --with claude-code,bash --network off` +2. **nix.rs** resolves package names via `build_with_catalog()` which generates a Nix expression calling `mkAgentSandbox` +3. **mkAgentSandbox.nix** resolves names from the catalog (agents first, then tools) and delegates to `mkSandboxRootfs` +4. **mkSandboxRootfs.nix** uses `pkgs.buildEnv` to merge packages, then creates a rootfs directory with symlinks into `/nix/store` +5. **session.rs** creates a session directory under `~/.local/share/nixosandbox/sessions//` with metadata, workspace, home, and cache dirs +6. **User runs** `nixosandbox exec -- command` +7. **plan_builder.rs** constructs bwrap argv: `--ro-bind /`, `--ro-bind /nix/store /nix/store`, writable bind mounts for workspace/home/cache, namespace flags, env vars +8. **main.rs** spawns bwrap (detected path from `bubblewrap::detect()`) with the constructed argv + +### Key design decisions + +- **Profile field is overloaded**: `session.profile` stores either a built-in profile name (e.g., `"strict"`) which maps to `nix/profiles/.json`, or `"custom:,"` for `--with` sessions. Code must check `meta.profile.starts_with("custom:")` before calling `load_profile()`. +- **Rootfs symlinks require Nix store**: The rootfs contains absolute symlinks into `/nix/store`. bwrap must bind-mount `/nix/store` read-only, and the rootfs must have `/nix/store` as an empty mount point directory. +- **macOS support via Docker sidecar**: On non-Linux, `bubblewrap::detect()` tries a Docker sidecar container (`nixosandbox-sidecar`) with bwrap inside. Session paths are rewritten from host to container paths via `docker::rewrite_path()`. +- **Package name validation**: `nix::validate_package_name()` rejects names not matching `[a-zA-Z0-9_.-]+` to prevent Nix expression injection via `--with`. +- **Network mode storage**: For `--with` sessions, the network mode is stored in `session.network` (not derivable from a profile file). For built-in profiles, it's in the profile JSON. + +### Rust module responsibilities + +| Module | Owns | +|--------|------| +| `cli.rs` | clap argument parsing | +| `main.rs` | command dispatch, `cmd_create`, `cmd_exec`, `cmd_catalog`, etc. | +| `session.rs` | session CRUD, metadata serialization, directory layout | +| `nix.rs` | `find_flake_root()`, `build_profile()`, `build_with_catalog()`, `query_catalog()` (filters non-derivation attrs via `filterDrvs` before reading `.meta.description`) | +| `plan_builder.rs` | bwrap argv construction (`--ro-bind`, `--bind`, `--unshare-*`, `--setenv`) | +| `bubblewrap.rs` | bwrap detection: `NIXOSANDBOX_BWRAP_PATH` env var, then `which bwrap`, Docker fallback on macOS | +| `docker.rs` | Docker sidecar lifecycle (find, start, create, image build) | +| `spec.rs` | profile/spec loading from JSON, validation | + +### Nix module responsibilities + +| File | Owns | +|------|------| +| `nix/catalog.nix` | Unified `{ agents, tools }` attrset — agents is a full dynamic passthrough of `llm-agents-pkgs` (no whitelist), tools from nixpkgs | +| `nix/mkSandboxRootfs.nix` | Builds rootfs directory tree from package list (symlinks, /etc, certs) | +| `nix/mkAgentSandbox.nix` | Resolves catalog names to packages, delegates to mkSandboxRootfs | +| `nix/profiles/*.json` | Built-in profile specs (strict, build-install, offline-review, debug-network) | + +### TypeScript extension (packages/pi-sandbox-extension) + +Provides Pi coding agent integration. Key files: +- `cli-client.ts` — spawns the nixosandbox CLI as a subprocess, provides `createSession()`, `execCommand()`, `catalogPackages()` +- `extension.ts` — registers Pi tools: `sandboxRun`, `sandboxReadFile`, `sandboxWriteFile`, `sandboxListFiles`, `sandboxSessionInfo`, `sandboxBrowser`, `sandboxCatalog` +- `contract.ts` — TypeScript type definitions for the NDJSON protocol + +## Session tests use env var serialization + +Tests in `session.rs` mutate `NIXOSANDBOX_DATA_DIR` to use temp directories. They serialize via `static ENV_LOCK: Mutex<()>` acquired in `with_temp_data_dir()`. If adding session tests, always use this helper. + +## CI runs on nixos-25.11 + +The flake pins `nixpkgs` to `nixos-25.11`. CI uses `cachix/install-nix-action@v30` with the numtide binary cache. Agent smoke tests use system apt bwrap (setuid) because Nix-built bwrap lacks setuid and fails on Ubuntu 24.04's AppArmor user namespace restrictions. diff --git a/README.md b/README.md index c9b2ae7..9bf95a5 100644 --- a/README.md +++ b/README.md @@ -1,269 +1,357 @@ -# NixOS Sandbox for AI Agents +# nixosandbox -A lightweight, self-hosted sandbox environment for AI agents with browser automation, shell access, code execution, and file operations — all controlled via REST API. +Reproducible, isolated sandbox environments for AI coding agents. Compose sandboxes from 88+ agents and 24+ tools using Nix, run them in Bubblewrap containers with configurable network and filesystem policies. -## Features +## What it does -- **Shell** — Execute commands with streaming output (SSE) -- **Code Execution** — Python, JavaScript, TypeScript, Go, Rust, Bash -- **File System** — Read, write, list, upload, download -- **Browser** — CDP-based Chromium automation (goto, screenshot, evaluate, click, type) -- **Skills** — Filesystem-based skill registry with CRUD + search -- **TEE** — Optional Trusted Execution Environment support (dstack integration) +nixosandbox creates lightweight Linux sandboxes from a catalog of Nix packages. You pick the agent and tools you need, and it builds an isolated rootfs with just those packages — no Docker images, no VMs, no manual setup. -## Tech Stack +```bash +# Create a sandbox with Claude Code + Git + Python +nixosandbox create --with claude-code,git,python312 --network off --json -- **Rust** with Axum 0.8 + Tokio async runtime -- **chromiumoxide** for browser automation via CDP -- **Nix** for reproducible runtime environments +# Run a command inside it +nixosandbox exec -- claude --version -## Quick Start +# Or drop into an interactive shell +nixosandbox enter +``` -### 1. Build the Rust API server +## Architecture -```bash -cd sandbox-rs -cargo build --release +``` +nixosandbox CLI (Rust) + ├── Nix: builds rootfs from catalog packages + ├── Bubblewrap: creates isolated mount/pid/net namespaces + ├── Session manager: tracks sandbox lifecycle + └── Catalog: 88+ AI agents (llm-agents.nix) + 24+ dev tools (nixpkgs) ``` -### 2. Run the server +**On Linux:** uses bubblewrap directly (setuid or user namespaces). +**On macOS:** uses a Docker sidecar container with bwrap inside. -```bash -# Default port 8080 -cargo run --release +## Install -# Custom port -PORT=9090 cargo run --release +### From source (requires Nix with flakes) -# With TEE support -cargo run --release --features tee +```bash +nix build github:HashWarlock/nixosandbox +./result/bin/nixosandbox --help ``` -### 3. Verify it's running +### Development shell ```bash -curl http://localhost:8080/health -# {"status":"healthy","uptime":1.23,"services":{"display":false,"browser":false}} +nix develop +cargo build ``` -## API Endpoints +## Quick start -### Health & Info +### 1. Browse the catalog -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/health` | Health check with uptime and service status | -| GET | `/sandbox/info` | Sandbox environment info | +```bash +nixosandbox catalog +nixosandbox catalog --filter claude +nixosandbox catalog --json | jq '.agents | keys' +``` -### Shell +### 2. Create a sandbox -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/shell/exec` | Execute command, return stdout/stderr | -| POST | `/shell/stream` | Stream command output via SSE | +```bash +# From catalog packages (compose what you need) +nixosandbox create --with claude-code,bash,git --network off --name my-sandbox --json -### Code Execution +# From a built-in profile +nixosandbox create --profile strict --json -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/code/execute` | Run code (python, javascript, typescript, go, rust, bash) | +# With a host workspace mounted +nixosandbox create --with opencode,bash --workspace ~/projects/myapp --json +``` -### Files +### 3. Execute commands -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/file/read?path=...` | Read file content | -| POST | `/file/write` | Write file content | -| GET | `/file/list?path=...` | List directory contents | -| POST | `/file/upload` | Upload file (multipart) | -| GET | `/file/download?path=...` | Download file | +```bash +# Run a single command +nixosandbox exec -- echo "Hello from sandbox" -### Browser (chromiumoxide) +# Stream NDJSON events (for programmatic use) +nixosandbox exec --json -- python3 -c "print('hello')" -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/browser/goto` | Navigate to URL, return title | -| POST | `/browser/screenshot` | Take screenshot, return base64 PNG | -| POST | `/browser/evaluate` | Execute JavaScript, return result | -| POST | `/browser/click` | Click element by CSS selector | -| POST | `/browser/type` | Type text into element | -| GET | `/browser/status` | Check if browser is running | +# With extra environment variables +nixosandbox exec --env API_KEY=test -- node script.js +``` -### Skills +### 4. Interactive shell -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/skills` | List all skills | -| POST | `/skills` | Create a new skill | -| GET | `/skills/search?q=...` | Search skills by name/description | -| GET | `/skills/{name}` | Get skill by name | -| PUT | `/skills/{name}` | Update skill | -| DELETE | `/skills/{name}` | Delete skill | -| POST | `/skills/{name}/scripts/{script}` | Execute skill script | +```bash +nixosandbox enter +``` -### Factory (Skill Creation Dialogue) +### 5. Manage sessions -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/factory/start` | Start skill creation session | -| POST | `/factory/continue` | Continue with user input | -| POST | `/factory/check` | Check for trigger phrases | +```bash +nixosandbox list # list all sessions +nixosandbox list --json # as JSON +nixosandbox status # detailed session info +nixosandbox destroy # clean up +``` -### TEE (Trusted Execution Environment) +## CLI reference + +| Command | Description | +|---------|-------------| +| `create` | Create a new sandbox session | +| `exec` | Execute a command inside a sandbox | +| `enter` | Enter a sandbox interactively (bash) | +| `list` | List active sandbox sessions | +| `destroy` | Destroy a sandbox session | +| `status` | Show detailed session status | +| `build` | Build a rootfs without creating a session | +| `catalog` | List available packages from the catalog | + +### `create` flags + +| Flag | Description | +|------|-------------| +| `--with ` | Compose from catalog packages | +| `--profile ` | Use a built-in profile | +| `--spec ` | Use a custom JSON spec file | +| `--network ` | Network mode (default: `off`) | +| `--workspace ` | Mount host directory as `/workspace` | +| `--name ` | Human-readable session name | +| `--agent ` | Agent identifier (e.g. `claude:opus-4-6`) | +| `--description ` | Purpose of this sandbox | +| `--json` | Output as JSON | + +`--with`, `--profile`, and `--spec` are mutually exclusive. + +## Catalog + +The catalog merges two sources: + +**AI agents** from [numtide/llm-agents.nix](https://github.com/numtide/llm-agents.nix) — all 88+ packages exposed dynamically, automatically updated when the flake input is bumped: + +| Agent | Description | +|-------|-------------| +| `amp` | Sourcegraph coding agent | +| `claude-code` | Anthropic's CLI coding agent | +| `codex` | OpenAI's coding agent | +| `copilot-cli` | GitHub Copilot CLI | +| `cursor-agent` | Cursor's headless agent | +| `droid` | Factory AI's development agent | +| `forge` | Code forging agent | +| `gemini-cli` | Google Gemini CLI | +| `goose-cli` | Block's coding agent | +| `hermes-agent` | Nous Research self-improving agent | +| `jules` | Google's async coding agent | +| `openclaw` | OpenClaw AI assistant | +| `opencode` | Open-source coding agent | +| `pi` | Pi coding agent | +| `qwen-code` | Alibaba's coding agent | +| ... | 88+ agents total — run `nixosandbox catalog` to see all | + +**Development tools** from nixpkgs: + +`python312` `nodejs_22` `rustc` `cargo` `go` `git` `coreutils` `bash` `findutils` `gnugrep` `gnused` `gawk` `gnumake` `gcc` `gnutar` `gzip` `curl` `cacert` `ripgrep` `fd` `jq` `less` `zsh` `nix` + +## Built-in profiles + +| Profile | Network | Packages | Use case | +|---------|---------|----------|----------| +| `strict` | off | coreutils, bash, cacert | Minimal, locked-down | +| `offline-review` | off | git, coreutils, bash, grep, sed, jq | Code review without network | +| `build-install` | full | node, python, rust, git, gcc, make | Building and installing | +| `debug-network` | full | node, python, curl, netcat, dig | Network debugging | + +## How it works + +### Rootfs composition + +When you run `nixosandbox create --with claude-code,bash`, the CLI: + +1. Resolves `claude-code` and `bash` from the catalog (agents first, then tools) +2. Calls `mkAgentSandbox` which delegates to `mkSandboxRootfs` +3. Nix builds a merged environment with all requested packages +4. Creates a minimal rootfs directory tree with symlinks into `/nix/store` + +The rootfs contains: `/bin`, `/lib`, `/etc` (passwd, hosts, certs), `/usr/bin/env`, and mount points for `/workspace`, `/home/sandbox`, `/cache`, `/tmp`, `/dev`, `/proc`, `/nix/store`. + +### Sandbox execution + +When you run `nixosandbox exec -- command`: + +1. Loads session metadata (rootfs path, network mode, profile) +2. Detects bubblewrap (native Linux or Docker sidecar on macOS) +3. Builds bwrap arguments: + - `--ro-bind /` (read-only root) + - `--ro-bind /nix/store /nix/store` (symlink targets) + - `--bind /workspace` (writable) + - `--bind /home/sandbox` (writable) + - `--bind /cache` (writable) + - `--tmpfs /tmp`, `--dev /dev`, `--proc /proc` + - Namespace isolation (`--unshare-pid`, `--unshare-uts`, etc.) + - `--unshare-net` when network is `off` + - `--die-with-parent`, `--new-session` (lifecycle safety) +4. Spawns bwrap with the constructed arguments +5. In `--json` mode, streams NDJSON lifecycle/stdout/stderr/result events + +### Session storage + +Sessions live in `~/.local/share/nixosandbox/sessions//`: -*Requires `--features tee` build flag* +``` +/ + metadata.json # session config, profile, rootfs path + workspace/ # working directory (or symlink to --workspace) + home/ # persistent home directory + cache/ # persistent cache +``` -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/tee/info` | Get TEE environment info | -| POST | `/tee/quote` | Generate attestation quote | -| POST | `/tee/derive-key` | Derive key from path | -| POST | `/tee/sign` | Sign data with TEE key | -| POST | `/tee/verify` | Verify signature | -| POST | `/tee/emit-event` | Emit TEE event | +## Custom spec files -## Usage Examples +Create a JSON spec for full control: -### Shell Execution +```json +{ + "name": "my-environment", + "packages": ["nodejs_22", "python312", "git"], + "env": { "NODE_ENV": "development" }, + "network": "off", + "namespaces": ["pid", "mount", "uts", "ipc", "net"], + "writable": ["/workspace", "/home/sandbox", "/tmp"] +} +``` ```bash -curl -X POST http://localhost:8080/shell/exec \ - -H "Content-Type: application/json" \ - -d '{"command": "echo hello && uname -a"}' +nixosandbox create --spec my-env.json --json ``` -### Code Execution - -```bash -curl -X POST http://localhost:8080/code/execute \ - -H "Content-Type: application/json" \ - -d '{"code": "print(2 + 2)", "language": "python"}' +## Environment variables + +| Variable | Description | +|----------|-------------| +| `NIXOSANDBOX_FLAKE_ROOT` | Path to the nixosandbox flake (for development) | +| `NIXOSANDBOX_DATA_DIR` | Override session storage directory | +| `NIXOSANDBOX_BWRAP_PATH` | Explicit path to bwrap binary | +| `NIXOSANDBOX_NO_DOCKER` | Set to `1` to disable Docker fallback on macOS | + +## Nix flake outputs + +```nix +{ + # CLI binary + packages.x86_64-linux.nixosandbox + packages.x86_64-linux.default + + # Pre-built profile rootfs + packages.x86_64-linux.sandbox-strict + packages.x86_64-linux.sandbox-build-install + packages.x86_64-linux.sandbox-offline-review + packages.x86_64-linux.sandbox-debug-network + + # Library functions + lib.mkSandboxRootfs # { name, packages, env? } -> rootfs derivation + lib.mkAgentSandbox # { name, packages } -> rootfs (catalog-aware) + + # Queryable catalog + catalog.agents # attrset of agent packages + catalog.tools # attrset of tool packages +} ``` -### Browser Automation +## Project structure -```bash -# Navigate and get title -curl -X POST http://localhost:8080/browser/goto \ - -H "Content-Type: application/json" \ - -d '{"url": "https://example.com"}' - -# Take screenshot -curl -X POST http://localhost:8080/browser/screenshot \ - -H "Content-Type: application/json" \ - -d '{"url": "https://example.com"}' | jq -r '.data' | base64 -d > screenshot.png - -# Execute JavaScript -curl -X POST http://localhost:8080/browser/evaluate \ - -H "Content-Type: application/json" \ - -d '{"url": "https://example.com", "script": "document.title"}' +``` +nixosandbox/ + crates/nixosandbox/ # Rust CLI + src/ + main.rs # Command handlers + cli.rs # Argument parsing (clap) + session.rs # Session CRUD and metadata + nix.rs # Nix build integration + bubblewrap.rs # bwrap detection + docker.rs # Docker sidecar (macOS) + plan_builder.rs # bwrap argv construction + spec.rs # Profile/spec loading + nix/ + catalog.nix # Unified agent + tool catalog + mkSandboxRootfs.nix # Rootfs builder + mkAgentSandbox.nix # Catalog-aware composition + profiles/ # Built-in profile specs (JSON) + packages/ + pi-sandbox-extension/ # TypeScript Pi agent integration + .github/workflows/ + ci.yml # CI: Rust, TypeScript, Nix, agent smoke tests ``` -### File Operations +## Pi extension + +nixosandbox includes a [Pi coding agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) extension that registers 7 sandbox tools: `sandbox_run`, `sandbox_read_file`, `sandbox_write_file`, `sandbox_list_files`, `sandbox_session_info`, `sandbox_catalog`, and `sandbox_browser`. + +### Setup + +1. Build the extension: ```bash -# Write file -curl -X POST http://localhost:8080/file/write \ - -H "Content-Type: application/json" \ - -d '{"path": "/tmp/test.txt", "content": "Hello, World!"}' +cd packages/pi-sandbox-extension +npm install +npm run build +``` -# Read file -curl "http://localhost:8080/file/read?path=/tmp/test.txt" +2. Create an extension wrapper at `.pi/extensions/sandbox.ts` (project-local) or `~/.pi/agent/extensions/sandbox.ts` (global): -# List directory -curl "http://localhost:8080/file/list?path=/tmp" +```typescript +import sandboxExtension from "/packages/pi-sandbox-extension/dist/index.js"; + +export default function (pi: any) { + sandboxExtension(pi, { + // Absolute path to the nixosandbox binary (cargo build --release) + binaryPath: "/crates/nixosandbox/target/release/nixosandbox", + }); +} ``` -### Skills +3. Or load it directly with the `-e` flag: ```bash -# Create a skill -curl -X POST http://localhost:8080/skills \ - -H "Content-Type: application/json" \ - -d '{ - "name": "my-helper", - "description": "A helpful skill", - "body": "Instructions for the skill..." - }' - -# Search skills -curl "http://localhost:8080/skills/search?q=helper" +pi -e .pi/extensions/sandbox.ts ``` -## Configuration +### What the tools do -Environment variables: +| Tool | Description | +|------|-------------| +| `sandbox_run` | Execute commands in an isolated sandbox (creates session on first use) | +| `sandbox_read_file` | Read a file from the sandbox workspace | +| `sandbox_write_file` | Write a file to the sandbox workspace | +| `sandbox_list_files` | List files in the sandbox workspace | +| `sandbox_session_info` | Show session details or list all sessions | +| `sandbox_catalog` | List available agent and tool packages | +| `sandbox_browser` | Browser automation (goto, screenshot, evaluate, click, type) | -| Variable | Default | Description | -|----------|---------|-------------| -| `PORT` | `8080` | API server port | -| `WORKSPACE` | `/home/sandbox/workspace` | Default working directory | -| `DISPLAY` | `:99` | X11 display for browser | -| `CDP_PORT` | `9222` | Chrome DevTools Protocol port | -| `SKILLS_DIR` | `./skills` | Skills storage directory | -| `BROWSER_HEADLESS` | `true` | Run browser in headless mode | -| `BROWSER_EXECUTABLE` | (auto-detect) | Path to Chromium binary | -| `BROWSER_VIEWPORT_WIDTH` | `1280` | Default viewport width | -| `BROWSER_VIEWPORT_HEIGHT` | `720` | Default viewport height | -| `BROWSER_TIMEOUT` | `30` | Default operation timeout (seconds) | +### Environment -## Testing +Set `NIXOSANDBOX_FLAKE_ROOT` to the repo root if the binary can't find `flake.nix` automatically: ```bash -cd sandbox-rs - -# Run unit tests -cargo test --bin sandbox-api +export NIXOSANDBOX_FLAKE_ROOT=/path/to/nixosandbox +``` -# Run integration tests (requires running server) -PORT=9090 cargo run & -TEST_BASE_URL=http://localhost:9090 cargo test +## Testing -# Run browser tests (requires Chromium) -TEST_BASE_URL=http://localhost:9090 cargo test --test browser_test -- --ignored -``` +```bash +# Rust unit tests +cd crates/nixosandbox && cargo test -## Project Structure +# Nix evaluation +nix flake check +# TypeScript typecheck +cd packages/pi-sandbox-extension && npx tsc --noEmit ``` -sandbox-rs/ -├── Cargo.toml -├── src/ -│ ├── main.rs # Entry point, router setup -│ ├── config.rs # Environment configuration -│ ├── error.rs # Error types -│ ├── state.rs # Application state -│ ├── browser/ # Browser automation -│ │ ├── mod.rs -│ │ ├── service.rs # BrowserService with lazy init -│ │ └── types.rs # Request/response types -│ ├── handlers/ # HTTP handlers -│ │ ├── mod.rs -│ │ ├── health.rs -│ │ ├── shell.rs -│ │ ├── code.rs -│ │ ├── file.rs -│ │ ├── browser.rs -│ │ ├── skills.rs -│ │ ├── factory.rs -│ │ └── tee.rs -│ ├── skills/ # Skills system -│ │ ├── mod.rs -│ │ ├── registry.rs # Filesystem-based registry -│ │ ├── types.rs # Skill types -│ │ └── factory.rs # Skill creation dialogue -│ └── tee/ # TEE integration (feature-gated) -│ └── mod.rs -└── tests/ - ├── health_test.rs - ├── shell_test.rs - ├── code_test.rs - ├── file_test.rs - ├── browser_test.rs - ├── skills_test.rs - ├── factory_test.rs - └── tee_test.rs -``` + +CI runs agent smoke tests that create sandboxes with each agent (claude-code, codex, opencode, amp, droid, pi, openclaw, hermes-agent, jules) and verify the binary launches inside bwrap. ## License diff --git a/crates/nixosandbox/Cargo.lock b/crates/nixosandbox/Cargo.lock new file mode 100644 index 0000000..5e5cd06 --- /dev/null +++ b/crates/nixosandbox/Cargo.lock @@ -0,0 +1,741 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "nixosandbox" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "libc", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/nixosandbox/Cargo.toml b/crates/nixosandbox/Cargo.toml new file mode 100644 index 0000000..f172f29 --- /dev/null +++ b/crates/nixosandbox/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "nixosandbox" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "nixosandbox" +path = "src/main.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4", features = ["derive"] } +uuid = { version = "1", features = ["v4"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/crates/nixosandbox/src/bubblewrap.rs b/crates/nixosandbox/src/bubblewrap.rs new file mode 100644 index 0000000..dee5b3e --- /dev/null +++ b/crates/nixosandbox/src/bubblewrap.rs @@ -0,0 +1,128 @@ +use std::path::PathBuf; + +/// Whether Bubblewrap is available for sandboxed execution. +#[derive(Debug, Clone)] +pub enum BwrapAvailability { + Available { path: PathBuf }, + DockerAvailable { + container_id: String, + host_sessions_dir: String, + container_sessions_dir: String, + }, + Unavailable { reason: String }, +} + +/// Detect whether Bubblewrap is available on this platform. +/// +/// Resolution order: +/// 1. `NIXOSANDBOX_BWRAP_PATH` env var (if set and file exists) +/// 2. `which bwrap` on PATH (Linux only) +/// 3. Unavailable +/// +/// On non-Linux platforms, always returns Unavailable. +pub fn detect() -> BwrapAvailability { + #[cfg(not(target_os = "linux"))] + { + // Check opt-out env var + if std::env::var("NIXOSANDBOX_NO_DOCKER").map_or(false, |v| v == "1") { + return BwrapAvailability::Unavailable { + reason: "Docker fallback disabled via NIXOSANDBOX_NO_DOCKER=1".to_string(), + }; + } + + // Try Docker sidecar for bwrap support on macOS + match crate::docker::detect_docker_sidecar() { + Ok(sidecar) => { + return BwrapAvailability::DockerAvailable { + container_id: sidecar.container_id, + host_sessions_dir: sidecar.host_sessions_dir, + container_sessions_dir: sidecar.container_sessions_dir, + }; + } + Err(reason) => { + return BwrapAvailability::Unavailable { + reason: format!( + "Bubblewrap requires Linux; Docker fallback failed: {reason}" + ), + }; + } + } + } + + #[cfg(target_os = "linux")] + { + // 1. Check env var + if let Ok(path_str) = std::env::var("NIXOSANDBOX_BWRAP_PATH") { + let path = PathBuf::from(&path_str); + if path.exists() { + return BwrapAvailability::Available { path }; + } + return BwrapAvailability::Unavailable { + reason: format!( + "NIXOSANDBOX_BWRAP_PATH set to '{}' but file does not exist", + path_str + ), + }; + } + + // 2. Try which bwrap + match std::process::Command::new("which") + .arg("bwrap") + .output() + { + Ok(output) if output.status.success() => { + let path_str = String::from_utf8_lossy(&output.stdout) + .trim() + .to_string(); + let path = PathBuf::from(&path_str); + if path.exists() { + return BwrapAvailability::Available { path }; + } + BwrapAvailability::Unavailable { + reason: format!("which bwrap returned '{}' but file does not exist", path_str), + } + } + _ => BwrapAvailability::Unavailable { + reason: "bwrap not found on PATH".to_string(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_returns_a_result() { + let result = detect(); + match &result { + BwrapAvailability::Available { path } => { + assert!(path.exists()); + } + BwrapAvailability::DockerAvailable { container_id, .. } => { + assert!(!container_id.is_empty()); + } + BwrapAvailability::Unavailable { reason } => { + assert!(!reason.is_empty()); + } + } + } + + #[test] + #[cfg(not(target_os = "linux"))] + fn non_linux_returns_docker_or_unavailable() { + let result = detect(); + match result { + BwrapAvailability::Unavailable { reason } => { + assert!(!reason.is_empty(), "reason: {}", reason); + } + BwrapAvailability::DockerAvailable { container_id, .. } => { + assert!(!container_id.is_empty()); + } + BwrapAvailability::Available { .. } => { + panic!("Should not return native Available on non-Linux"); + } + } + } +} diff --git a/crates/nixosandbox/src/cli.rs b/crates/nixosandbox/src/cli.rs new file mode 100644 index 0000000..14db473 --- /dev/null +++ b/crates/nixosandbox/src/cli.rs @@ -0,0 +1,128 @@ +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "nixosandbox", about = "Reproducible, isolated sandbox environments")] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Create a new sandbox session + Create { + /// Use a built-in profile + #[arg(long)] + profile: Option, + + /// Use a custom spec file + #[arg(long)] + spec: Option, + + /// Compose from catalog packages (comma-separated, e.g. claude-code,git,python312) + #[arg(long, value_delimiter = ',')] + with: Option>, + + /// Network mode for --with sandboxes + #[arg(long, default_value = "off")] + network: String, + + /// Host directory to mount as /workspace + #[arg(long)] + workspace: Option, + + /// Human-readable session name + #[arg(long)] + name: Option, + + /// Agent runtime identifier (e.g. 'claude:opus-4-6') + #[arg(long)] + agent: Option, + + /// Purpose of this sandbox session + #[arg(long)] + description: Option, + + /// Output session info as JSON + #[arg(long)] + json: bool, + }, + + /// Execute a command inside a sandbox + Exec { + /// Session ID + session_id: String, + + /// Stream NDJSON events + #[arg(long)] + json: bool, + + /// Kill after timeout (seconds) + #[arg(long)] + timeout: Option, + + /// Additional environment variable (KEY=VALUE) + #[arg(long = "env", value_name = "KEY=VALUE")] + extra_env: Vec, + + /// Command to execute (after --) + #[arg(last = true)] + command: Vec, + }, + + /// Enter a sandbox interactively + Enter { + /// Session ID + session_id: String, + }, + + /// List active sandbox sessions + List { + /// Output as JSON array + #[arg(long)] + json: bool, + }, + + /// Destroy a sandbox session + Destroy { + /// Session ID + session_id: String, + }, + + /// Show detailed session status (battlecard) + Status { + /// Session ID + session_id: String, + + /// Output as JSON + #[arg(long)] + json: bool, + }, + + /// Build a rootfs without creating a session + Build { + /// Use a built-in profile + #[arg(long)] + profile: Option, + + /// Use a custom spec file + #[arg(long)] + spec: Option, + + /// Output rootfs path as JSON + #[arg(long)] + json: bool, + }, + + /// List available packages from the catalog + Catalog { + /// Output as JSON + #[arg(long)] + json: bool, + + /// Filter by name substring + #[arg(long)] + filter: Option, + }, + +} diff --git a/crates/nixosandbox/src/docker.rs b/crates/nixosandbox/src/docker.rs new file mode 100644 index 0000000..a000bf0 --- /dev/null +++ b/crates/nixosandbox/src/docker.rs @@ -0,0 +1,242 @@ +use std::process::{Command, Stdio}; + +const SIDECAR_NAME: &str = "nixosandbox-sidecar"; +const IMAGE_NAME: &str = "nixosandbox-sidecar:latest"; +/// Mount point for the host data dir inside the container. +/// Note: this is the data dir mount point, not the sessions subdir. +/// Sessions live at `/sessions//...` inside the container. +const CONTAINER_DATA_MOUNT: &str = "/nixosandbox/sessions"; + +/// Information about a running Docker sidecar container. +pub struct DockerSidecar { + pub container_id: String, + /// Host data directory (e.g. `~/.local/share/nixosandbox`), mounted into the container. + pub host_sessions_dir: String, + /// Container-side mount point for the host data dir. + pub container_sessions_dir: String, +} + +/// Get the nixosandbox data directory on the host. +/// +/// Uses `NIXOSANDBOX_DATA_DIR` env var if set, otherwise `$HOME/.local/share/nixosandbox`. +fn get_data_dir() -> Result { + if let Ok(dir) = std::env::var("NIXOSANDBOX_DATA_DIR") { + return Ok(dir); + } + let home = std::env::var("HOME") + .map_err(|_| "HOME environment variable not set".to_string())?; + Ok(format!("{home}/.local/share/nixosandbox")) +} + +/// Check whether Docker is available by running `docker info`. +pub fn is_docker_available() -> bool { + Command::new("docker") + .args(["info"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Find a running sidecar container. Returns its short ID if found. +fn find_running_sidecar() -> Option { + let output = Command::new("docker") + .args([ + "ps", + "--filter", &format!("name={SIDECAR_NAME}"), + "--format", "{{.ID}}", + ]) + .output() + .ok()?; + if output.status.success() { + let id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if id.is_empty() { None } else { Some(id) } + } else { + None + } +} + +/// Find a stopped sidecar container. Returns its short ID if found. +fn find_stopped_sidecar() -> Option { + let output = Command::new("docker") + .args([ + "ps", "-a", + "--filter", &format!("name={SIDECAR_NAME}"), + "--format", "{{.ID}}", + ]) + .output() + .ok()?; + if output.status.success() { + let id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if id.is_empty() { None } else { Some(id) } + } else { + None + } +} + +/// Start a stopped container. +fn start_container(id: &str) -> Result<(), String> { + let status = Command::new("docker") + .args(["start", id]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|e| format!("failed to start container: {e}"))?; + if status.success() { + Ok(()) + } else { + Err("docker start failed".to_string()) + } +} + +/// Build the sidecar Docker image if it doesn't already exist. +fn ensure_image() -> Result<(), String> { + let output = Command::new("docker") + .args(["images", IMAGE_NAME, "--format", "{{.ID}}"]) + .output() + .map_err(|e| format!("docker images check failed: {e}"))?; + + let id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !id.is_empty() { + return Ok(()); + } + + eprintln!("nixosandbox: building Docker sidecar image (one-time setup)..."); + let output = Command::new("docker") + .args([ + "build", "-t", IMAGE_NAME, + "-f", "docker/nixosandbox-sidecar.Dockerfile", ".", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .map_err(|e| format!("docker build failed: {e}"))?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("docker build failed: {}", stderr.trim())) + } +} + +/// Create and start a new sidecar container. +fn create_sidecar(host_sessions_dir: &str) -> Result { + let sessions_volume = format!("{host_sessions_dir}:{CONTAINER_DATA_MOUNT}"); + let output = Command::new("docker") + .args([ + "run", "-d", + "--name", SIDECAR_NAME, + "--cap-add", "SYS_ADMIN", + "--cap-add", "NET_ADMIN", + "--security-opt", "seccomp=unconfined", + "-v", &sessions_volume, + "-v", "/nix/store:/nix/store:ro", + IMAGE_NAME, + "sleep", "infinity", + ]) + .output() + .map_err(|e| format!("docker run failed: {e}"))?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("docker run failed: {stderr}")) + } +} + +/// Detect and ensure a Docker sidecar is running. +/// +/// This is the main entry point called from `bubblewrap::detect()` on macOS. +/// Returns a `DockerSidecar` with container info and path mapping, +/// or an error string explaining why Docker is not available. +pub fn detect_docker_sidecar() -> Result { + if !is_docker_available() { + return Err("Docker not available (docker info failed)".to_string()); + } + + let host_sessions_dir = get_data_dir()?; + + // Ensure the data directory exists on the host + std::fs::create_dir_all(&host_sessions_dir) + .map_err(|e| format!("failed to create data dir {host_sessions_dir}: {e}"))?; + + // 1. Check if container is already running + if let Some(id) = find_running_sidecar() { + return Ok(DockerSidecar { + container_id: id, + host_sessions_dir, + container_sessions_dir: CONTAINER_DATA_MOUNT.to_string(), + }); + } + + // 2. Check if container exists but is stopped + if let Some(id) = find_stopped_sidecar() { + start_container(&id)?; + return Ok(DockerSidecar { + container_id: id, + host_sessions_dir, + container_sessions_dir: CONTAINER_DATA_MOUNT.to_string(), + }); + } + + // 3. Container doesn't exist — build image and create it + ensure_image()?; + let id = create_sidecar(&host_sessions_dir)?; + + Ok(DockerSidecar { + container_id: id, + host_sessions_dir, + container_sessions_dir: CONTAINER_DATA_MOUNT.to_string(), + }) +} + +/// Rewrite a single host path to its container-side equivalent. +/// +/// If the path starts with `host_prefix`, replace that prefix with `container_prefix`. +/// Otherwise return the path unchanged. +pub fn rewrite_path(path: &str, host_prefix: &str, container_prefix: &str) -> String { + if path.starts_with(host_prefix) { + path.replacen(host_prefix, container_prefix, 1) + } else { + path.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rewrite_path_replaces_matching_prefix() { + let result = rewrite_path( + "/Users/me/.local/share/nixosandbox/sessions/abc/workspace", + "/Users/me/.local/share/nixosandbox/sessions", + "/nixosandbox/sessions", + ); + assert_eq!(result, "/nixosandbox/sessions/abc/workspace"); + } + + #[test] + fn rewrite_path_leaves_non_matching_path_unchanged() { + let result = rewrite_path( + "/nix/store/abc123-sandbox-strict", + "/Users/me/.local/share/nixosandbox/sessions", + "/nixosandbox/sessions", + ); + assert_eq!(result, "/nix/store/abc123-sandbox-strict"); + } + + #[test] + fn rewrite_path_replaces_only_first_occurrence() { + let result = rewrite_path( + "/data/data/nested", + "/data", + "/mnt", + ); + assert_eq!(result, "/mnt/data/nested"); + } + +} diff --git a/crates/nixosandbox/src/main.rs b/crates/nixosandbox/src/main.rs new file mode 100644 index 0000000..e9b95b2 --- /dev/null +++ b/crates/nixosandbox/src/main.rs @@ -0,0 +1,635 @@ +mod bubblewrap; +mod cli; +mod docker; +mod nix; +mod plan_builder; +mod session; +mod spec; +mod timestamps; + +use clap::Parser; +use cli::{Cli, Commands}; + +fn main() { + let cli = Cli::parse(); + + match cli.command { + Commands::Create { profile, spec: spec_file, with, network, workspace, name, agent, description, json } => { + cmd_create(profile, spec_file, with, network, workspace, name, agent, description, json); + } + Commands::Exec { session_id, json, timeout: _timeout, extra_env, command } => { + cmd_exec(&session_id, json, extra_env, command); + } + Commands::Enter { session_id } => { + cmd_enter(&session_id); + } + Commands::List { json } => { + cmd_list(json); + } + Commands::Destroy { session_id } => { + cmd_destroy(&session_id); + } + Commands::Status { session_id, json } => { + cmd_status(&session_id, json); + } + Commands::Build { profile, spec: spec_file, json } => { + cmd_build(profile, spec_file, json); + } + Commands::Catalog { json, filter } => { + cmd_catalog(json, filter); + } + } +} + +fn resolve_spec(profile: Option, spec_file: Option) -> spec::SandboxSpec { + match (profile, spec_file) { + (Some(p), None) => { + let flake_root = nix::find_flake_root().unwrap_or_else(|e| { + eprintln!("error: {e}"); + std::process::exit(1); + }); + spec::load_profile(&p, &flake_root).unwrap_or_else(|e| { + eprintln!("error: {e}"); + std::process::exit(1); + }) + } + (None, Some(s)) => { + spec::load_spec(&s).unwrap_or_else(|e| { + eprintln!("error: {e}"); + std::process::exit(1); + }) + } + (Some(_), Some(_)) => { + eprintln!("error: specify --profile or --spec, not both"); + std::process::exit(1); + } + (None, None) => { + eprintln!("error: specify --profile or --spec"); + std::process::exit(1); + } + } +} + +fn build_rootfs_for_spec(spec: &spec::SandboxSpec, profile: &Option) -> String { + if let Err(errors) = spec::validate_spec(spec) { + for e in &errors { + eprintln!("validation error: {e}"); + } + std::process::exit(1); + } + let rootfs = if let Some(p) = profile { + nix::build_profile(p) + } else { + nix::build_spec(spec) + }; + rootfs.unwrap_or_else(|e| { + eprintln!("nix build failed: {e}"); + std::process::exit(1); + }) +} + +fn cmd_create( + profile: Option, + spec_file: Option, + with: Option>, + network: String, + workspace: Option, + name: Option, + agent: Option, + description: Option, + json: bool, +) { + // Validate mutual exclusivity: --with vs --profile vs --spec + let source_count = [with.is_some(), profile.is_some(), spec_file.is_some()] + .iter() + .filter(|&&b| b) + .count(); + if source_count > 1 { + eprintln!("error: specify only one of --profile, --spec, or --with"); + std::process::exit(1); + } + if source_count == 0 { + eprintln!("error: specify --profile, --spec, or --with"); + std::process::exit(1); + } + + let (rootfs_path, profile_name, session_network) = if let Some(ref packages) = with { + // Catalog-based composition + if packages.is_empty() { + eprintln!("error: --with requires at least one package name"); + std::process::exit(1); + } + match network.as_str() { + "off" | "full" => {} + other => { + eprintln!("error: --network must be 'off' or 'full', got '{other}'"); + std::process::exit(1); + } + } + let rootfs = nix::build_with_catalog(packages, &network).unwrap_or_else(|e| { + eprintln!("nix build failed: {e}"); + std::process::exit(1); + }); + nix::validate_rootfs(&rootfs).unwrap_or_else(|e| { + eprintln!("rootfs validation failed: {e}"); + std::process::exit(1); + }); + (rootfs, format!("custom:{}", packages.join(",")), Some(network.clone())) + } else { + // Profile or spec-based + let sandbox_spec = resolve_spec(profile.clone(), spec_file); + let rootfs = build_rootfs_for_spec(&sandbox_spec, &profile); + nix::validate_rootfs(&rootfs).unwrap_or_else(|e| { + eprintln!("rootfs validation failed: {e}"); + std::process::exit(1); + }); + (rootfs, sandbox_spec.name.clone(), None) + }; + + let session_name = name.unwrap_or_else(|| profile_name.clone()); + let meta = session::create_session( + &session_name, + &profile_name, + &rootfs_path, + workspace.as_deref(), + agent.as_deref(), + description.as_deref(), + session_network.as_deref(), + ).unwrap_or_else(|e| { + eprintln!("session creation failed: {e}"); + std::process::exit(1); + }); + + if json { + println!("{}", serde_json::to_string_pretty(&meta).unwrap()); + } else { + println!("{}", meta.session_id); + } +} + +fn cmd_exec(session_id: &str, json: bool, extra_env: Vec, command: Vec) { + if command.is_empty() { + eprintln!("error: no command specified (use -- )"); + std::process::exit(1); + } + + let meta = session::load_session(session_id).unwrap_or_else(|e| { + eprintln!("error: {e}"); + std::process::exit(1); + }); + + let flake_root = nix::find_flake_root().unwrap_or_else(|e| { + eprintln!("error: {e}"); + std::process::exit(1); + }); + + // Load the spec/profile to get network and namespace config. + // For --with sessions (profile starts with "custom:"), build spec from session metadata directly. + let sandbox_spec = if meta.profile.starts_with("custom:") { + let network = meta.network.clone().unwrap_or_else(|| "off".to_string()); + spec::SandboxSpec { + name: meta.profile.clone(), + packages: vec![], + env: std::collections::HashMap::new(), + network, + namespaces: vec!["pid".to_string(), "mount".to_string(), "uts".to_string(), "ipc".to_string()], + writable: vec!["/workspace".to_string(), "/home/sandbox".to_string(), "/cache".to_string(), "/tmp".to_string()], + } + } else { + spec::load_profile(&meta.profile, &flake_root).unwrap_or_else(|e| { + eprintln!("warning: could not load profile '{}': {e}", meta.profile); + let network = meta.network.clone().unwrap_or_else(|| "full".to_string()); + spec::SandboxSpec { + name: meta.profile.clone(), + packages: vec![], + env: std::collections::HashMap::new(), + network, + namespaces: vec!["pid".to_string(), "mount".to_string(), "uts".to_string(), "ipc".to_string()], + writable: vec!["/workspace".to_string(), "/home/sandbox".to_string(), "/cache".to_string(), "/tmp".to_string()], + } + }) + }; + + let dirs = session::session_dirs(session_id); + + // Merge extra env vars + let mut env = sandbox_spec.env.clone(); + for kv in &extra_env { + if let Some((k, v)) = kv.split_once('=') { + env.insert(k.to_string(), v.to_string()); + } else { + eprintln!("warning: ignoring invalid --env value: {kv}"); + } + } + + // Check bwrap availability + let bwrap = bubblewrap::detect(); + match &bwrap { + bubblewrap::BwrapAvailability::Available { .. } => {} + bubblewrap::BwrapAvailability::DockerAvailable { .. } => {} + bubblewrap::BwrapAvailability::Unavailable { reason } => { + eprintln!("error: bwrap is not available: {reason}"); + std::process::exit(1); + } + }; + + // For Docker, rewrite session directory paths from host to container paths. + // Nix store paths need no rewriting — identical on host and container. + let rootfs_dirs = match &bwrap { + bubblewrap::BwrapAvailability::DockerAvailable { + host_sessions_dir, + container_sessions_dir, + .. + } => plan_builder::RootfsSessionDirs { + workspace: docker::rewrite_path( + &dirs.workspace.to_string_lossy(), + host_sessions_dir, + container_sessions_dir, + ), + home: docker::rewrite_path( + &dirs.home.to_string_lossy(), + host_sessions_dir, + container_sessions_dir, + ), + cache: docker::rewrite_path( + &dirs.cache.to_string_lossy(), + host_sessions_dir, + container_sessions_dir, + ), + }, + _ => plan_builder::RootfsSessionDirs { + workspace: dirs.workspace.to_string_lossy().to_string(), + home: dirs.home.to_string_lossy().to_string(), + cache: dirs.cache.to_string_lossy().to_string(), + }, + }; + + let bwrap_argv = plan_builder::build_rootfs( + &meta.rootfs_path, + &rootfs_dirs, + &command, + &env, + &sandbox_spec.network, + &sandbox_spec.namespaces, + ); + + let _ = session::touch_last_exec(session_id); + + if json { + // NDJSON mode: pipe stdout/stderr, stream lifecycle + data events + use std::process::{Command, Stdio}; + use std::io::{BufRead, BufReader}; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + + let seq = Arc::new(AtomicU64::new(1)); + + let mut child = match &bwrap { + bubblewrap::BwrapAvailability::DockerAvailable { container_id, .. } => { + let mut cmd_args = vec!["exec".to_string(), "-i".to_string(), container_id.clone(), "bwrap".to_string()]; + cmd_args.extend(bwrap_argv); + Command::new("docker") + .args(&cmd_args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|e| { + eprintln!("error: failed to spawn docker+bwrap: {e}"); + std::process::exit(1); + }) + } + bubblewrap::BwrapAvailability::Available { path } => { + Command::new(path) + .args(&bwrap_argv) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|e| { + eprintln!("error: failed to spawn bwrap at {}: {e}", path.display()); + std::process::exit(1); + }) + } + bubblewrap::BwrapAvailability::Unavailable { reason } => { + eprintln!("error: bwrap is not available: {reason}"); + std::process::exit(1); + } + }; + + let start = std::time::Instant::now(); + + // Emit lifecycle started + let started_event = serde_json::json!({ + "type": "lifecycle", + "sequence": seq.fetch_add(1, Ordering::SeqCst), + "ts": timestamps::now_iso8601(), + "payload": { "event": "started" } + }); + println!("{}", started_event); + + // Stream stdout and stderr in parallel threads + let child_stdout = child.stdout.take(); + let child_stderr = child.stderr.take(); + + let seq_stdout = Arc::clone(&seq); + let stdout_thread = std::thread::spawn(move || { + if let Some(stdout) = child_stdout { + let reader = BufReader::new(stdout); + for line in reader.lines() { + if let Ok(line) = line { + let event = serde_json::json!({ + "type": "stdout", + "sequence": seq_stdout.fetch_add(1, Ordering::SeqCst), + "ts": timestamps::now_iso8601(), + "payload": { "data": line } + }); + println!("{}", event); + } + } + } + }); + + let seq_stderr = Arc::clone(&seq); + let stderr_thread = std::thread::spawn(move || { + if let Some(stderr) = child_stderr { + let reader = BufReader::new(stderr); + for line in reader.lines() { + if let Ok(line) = line { + let event = serde_json::json!({ + "type": "stderr", + "sequence": seq_stderr.fetch_add(1, Ordering::SeqCst), + "ts": timestamps::now_iso8601(), + "payload": { "data": line } + }); + println!("{}", event); + } + } + } + }); + + let status = child.wait().unwrap_or_else(|e| { + eprintln!("error: wait: {e}"); + std::process::exit(1); + }); + + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + + let duration_ms = start.elapsed().as_millis() as u64; + + // Extract exit code and signal + let (exit_code, signal) = { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(sig) = status.signal() { + (None, Some(format!("SIG{sig}"))) + } else { + (status.code(), None) + } + } + #[cfg(not(unix))] + { + (status.code(), None::) + } + }; + + // Emit lifecycle exited + let exited_event = serde_json::json!({ + "type": "lifecycle", + "sequence": seq.fetch_add(1, Ordering::SeqCst), + "ts": timestamps::now_iso8601(), + "payload": { "event": "exited" } + }); + println!("{}", exited_event); + + // Emit result + let result = serde_json::json!({ + "type": "result", + "sequence": seq.fetch_add(1, Ordering::SeqCst), + "ts": timestamps::now_iso8601(), + "payload": { + "exitCode": exit_code.unwrap_or(-1), + "signal": signal, + "timedOut": false, + "durationMs": duration_ms, + } + }); + println!("{}", result); + std::process::exit(exit_code.unwrap_or(1)); + } else { + // Interactive mode: inherit stdio + use std::process::Command; + let status = match &bwrap { + bubblewrap::BwrapAvailability::DockerAvailable { container_id, .. } => { + let mut cmd_args = vec!["exec".to_string(), "-i".to_string(), container_id.clone(), "bwrap".to_string()]; + cmd_args.extend(bwrap_argv); + Command::new("docker") + .args(&cmd_args) + .status() + .unwrap_or_else(|e| { + eprintln!("error: failed to run docker+bwrap: {e}"); + std::process::exit(1); + }) + } + bubblewrap::BwrapAvailability::Available { path } => { + Command::new(path) + .args(&bwrap_argv) + .status() + .unwrap_or_else(|e| { + eprintln!("error: failed to run bwrap at {}: {e}", path.display()); + std::process::exit(1); + }) + } + bubblewrap::BwrapAvailability::Unavailable { reason } => { + eprintln!("error: bwrap is not available: {reason}"); + std::process::exit(1); + } + }; + std::process::exit(status.code().unwrap_or(1)); + } +} + +fn cmd_enter(session_id: &str) { + cmd_exec(session_id, false, vec![], vec!["/bin/bash".to_string()]); +} + +fn cmd_list(json: bool) { + let sessions = session::list_sessions().unwrap_or_else(|e| { + eprintln!("error: {e}"); + std::process::exit(1); + }); + if json { + println!("{}", serde_json::to_string_pretty(&sessions).unwrap()); + } else { + if sessions.is_empty() { + println!("No active sessions."); + return; + } + println!("{:<12} {:<20} {:<16} {}", "SESSION", "NAME", "PROFILE", "CREATED"); + for s in &sessions { + println!("{:<12} {:<20} {:<16} {}", s.session_id, s.name, s.profile, s.created_at); + } + } +} + +fn cmd_destroy(session_id: &str) { + session::destroy_session(session_id).unwrap_or_else(|e| { + eprintln!("error: {e}"); + std::process::exit(1); + }); + eprintln!("Session {} destroyed.", session_id); +} + +fn cmd_build(profile: Option, spec_file: Option, json: bool) { + let sandbox_spec = resolve_spec(profile.clone(), spec_file); + let rootfs_path = build_rootfs_for_spec(&sandbox_spec, &profile); + + if json { + println!("{}", serde_json::json!({ "rootfsPath": rootfs_path })); + } else { + println!("{}", rootfs_path); + } +} + +fn cmd_catalog(json: bool, filter: Option) { + let catalog_json = nix::query_catalog().unwrap_or_else(|e| { + eprintln!("error: {e}"); + std::process::exit(1); + }); + + if json && filter.is_none() { + println!("{}", catalog_json); + return; + } + + // Parse for display or filtering + let catalog: serde_json::Value = serde_json::from_str(&catalog_json).unwrap_or_else(|e| { + eprintln!("error: failed to parse catalog: {e}"); + std::process::exit(1); + }); + + let filter_lower = filter.as_ref().map(|f| f.to_lowercase()); + + if json { + // Filtered JSON output + let mut filtered = serde_json::json!({ "agents": {}, "tools": {} }); + for section in ["agents", "tools"] { + if let Some(entries) = catalog.get(section).and_then(|v| v.as_object()) { + let filt = filter_lower.as_ref().unwrap(); + let matched: serde_json::Map = entries + .iter() + .filter(|(k, v)| { + k.to_lowercase().contains(filt) + || v.get("description") + .and_then(|d| d.as_str()) + .map(|d| d.to_lowercase().contains(filt)) + .unwrap_or(false) + }) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + filtered[section] = serde_json::Value::Object(matched); + } + } + println!("{}", serde_json::to_string_pretty(&filtered).unwrap()); + return; + } + + // Human-readable output + for (section, label) in [("agents", "Agents (from llm-agents.nix)"), ("tools", "Tools (from nixpkgs)")] { + if let Some(entries) = catalog.get(section).and_then(|v| v.as_object()) { + println!("{}:", label); + let mut names: Vec<&String> = entries.keys().collect(); + names.sort(); + for name in names { + let desc = entries[name] + .get("description") + .and_then(|d| d.as_str()) + .unwrap_or(""); + if let Some(ref filt) = filter_lower { + if !name.to_lowercase().contains(filt) && !desc.to_lowercase().contains(filt) { + continue; + } + } + println!(" {:<20} {}", name, desc); + } + println!(); + } + } +} + +fn cmd_status(session_id: &str, json: bool) { + let meta = session::load_session(session_id).unwrap_or_else(|e| { + eprintln!("error: {e}"); + std::process::exit(1); + }); + + // Derive isolation backend + let isolation = match bubblewrap::detect() { + bubblewrap::BwrapAvailability::Available { .. } => "native", + bubblewrap::BwrapAvailability::DockerAvailable { .. } => "docker", + bubblewrap::BwrapAvailability::Unavailable { .. } => "unavailable", + }; + + // Derive network mode: custom profiles store it in metadata, built-in profiles in spec files + let network = if meta.profile.starts_with("custom:") { + meta.network.clone().unwrap_or_else(|| "off".to_string()) + } else { + let flake_root = nix::find_flake_root().ok(); + if let Some(ref root) = flake_root { + spec::load_profile(&meta.profile, root) + .map(|s| s.network.clone()) + .unwrap_or_else(|_| "unknown".to_string()) + } else { + "unknown".to_string() + } + }; + + if json { + let status = serde_json::json!({ + "sessionId": meta.session_id, + "name": meta.name, + "profile": meta.profile, + "rootfsPath": meta.rootfs_path, + "workspace": meta.workspace, + "createdAt": meta.created_at, + "lastExecAt": meta.last_exec_at, + "agent": meta.agent, + "description": meta.description, + "isolation": isolation, + "network": network, + }); + println!("{}", serde_json::to_string_pretty(&status).unwrap()); + } else { + let truncate = |s: &str, max: usize| -> String { + if s.chars().count() > max { + let truncated: String = s.chars().take(max - 3).collect(); + format!("{truncated}...") + } else { + s.to_string() + } + }; + + let desc = meta.description.as_deref().unwrap_or("-"); + let agent = meta.agent.as_deref().unwrap_or("-"); + let last_exec = meta.last_exec_at.as_deref().unwrap_or("-"); + let rootfs_display = truncate(&meta.rootfs_path, 36); + let workspace_display = truncate(&meta.workspace, 36); + + let w = 48; + println!("╭{}╮", "─".repeat(w)); + println!("│ {: Result { + if let Ok(root) = std::env::var("NIXOSANDBOX_FLAKE_ROOT") { + if Path::new(&root).join("flake.nix").exists() { + return Ok(root); + } + } + if let Ok(exe) = std::env::current_exe() { + let mut dir = exe.parent().map(|p| p.to_path_buf()); + while let Some(d) = dir { + if d.join("flake.nix").exists() { + return Ok(d.to_string_lossy().to_string()); + } + dir = d.parent().map(|p| p.to_path_buf()); + } + } + if Path::new("flake.nix").exists() { + return Ok(std::env::current_dir().map_err(|e| format!("cwd: {e}"))?.to_string_lossy().to_string()); + } + Err("Could not find flake.nix. Set NIXOSANDBOX_FLAKE_ROOT or run from repo root.".to_string()) +} + +/// Build a rootfs for a built-in profile. Returns the Nix store path. +pub fn build_profile(profile_name: &str) -> Result { + let flake_root = find_flake_root()?; + nix_build(&format!("{}#sandbox-{}", flake_root, profile_name)) +} + +/// Build a rootfs from a custom spec. Returns the Nix store path. +pub fn build_spec(spec: &SandboxSpec) -> Result { + let flake_root = find_flake_root()?; + let packages_nix = spec.packages.iter().map(|p| format!("pkgs.{}", p)).collect::>().join(" "); + let env_nix = spec.env.iter().map(|(k, v)| format!("\"{}\" = \"{}\";", k, v)).collect::>().join(" "); + let expr = format!( + r#"let pkgs = import (builtins.getFlake "{}").inputs.nixpkgs {{}}; mkSandboxRootfs = import {}/nix/mkSandboxRootfs.nix {{ inherit pkgs; }}; in mkSandboxRootfs {{ name = "{}"; packages = [ {} ]; env = {{ {} }}; }}"#, + flake_root, flake_root, spec.name, packages_nix, env_nix + ); + nix_build_expr(&expr) +} + +fn nix_build(flake_attr: &str) -> Result { + let output = Command::new("nix") + .args(["build", flake_attr, "--no-link", "--print-out-paths"]) + .stdout(Stdio::piped()).stderr(Stdio::piped()) + .output().map_err(|e| format!("nix build: {e}"))?; + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { Err("nix build produced no output".into()) } else { Ok(path) } + } else { + Err(format!("nix build failed: {}", String::from_utf8_lossy(&output.stderr))) + } +} + +fn nix_build_expr(expr: &str) -> Result { + let output = Command::new("nix") + .args(["build", "--impure", "--expr", expr, "--no-link", "--print-out-paths"]) + .stdout(Stdio::piped()).stderr(Stdio::piped()) + .output().map_err(|e| format!("nix build --expr: {e}"))?; + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { Err("nix build --expr produced no output".into()) } else { Ok(path) } + } else { + Err(format!("nix build --expr failed: {}", String::from_utf8_lossy(&output.stderr))) + } +} + +/// Check if a rootfs path looks valid. +pub fn validate_rootfs(rootfs_path: &str) -> Result<(), String> { + let root = Path::new(rootfs_path); + if !root.exists() { return Err(format!("rootfs not found: {rootfs_path}")); } + if !root.join("bin").exists() { return Err(format!("rootfs missing /bin: {rootfs_path}")); } + if !root.join("etc").exists() { return Err(format!("rootfs missing /etc: {rootfs_path}")); } + Ok(()) +} + +/// Validate that a package name is safe for interpolation into Nix expressions. +/// Only allows alphanumeric, hyphen, underscore, and dot characters. +fn validate_package_name(name: &str) -> Result<(), String> { + if name.is_empty() { + return Err("package name must not be empty".to_string()); + } + if !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') { + return Err(format!( + "invalid package name '{}': only alphanumeric, hyphen, underscore, and dot are allowed", + name + )); + } + Ok(()) +} + +/// Build a rootfs from catalog package names using mkAgentSandbox. +/// Returns the Nix store path of the resulting rootfs. +pub fn build_with_catalog(names: &[String], _network: &str) -> Result { + let flake_root = find_flake_root()?; + + // Validate all package names before interpolating into Nix expression + for name in names { + validate_package_name(name)?; + } + + let packages_nix = names + .iter() + .map(|n| format!("\"{}\"", n)) + .collect::>() + .join(" "); + + // Generate a deterministic name from the sorted package list + let mut sorted_names = names.to_vec(); + sorted_names.sort(); + let hash_input = sorted_names.join(","); + let mut h: u64 = 0; + for b in hash_input.bytes() { + h = h.wrapping_mul(31).wrapping_add(b as u64); + } + let name_hash = format!("{:08x}", h); + + let expr = format!( + r#"let flake = builtins.getFlake "{}"; in flake.lib.mkAgentSandbox {{ name = "custom-{}"; packages = [ {} ]; }}"#, + flake_root, name_hash, packages_nix + ); + nix_build_expr(&expr) +} + +/// Query the flake catalog and return JSON with agent/tool names and descriptions. +pub fn query_catalog() -> Result { + let flake_root = find_flake_root()?; + + // Evaluate a Nix expression that extracts names and meta.description + // from both catalog.agents and catalog.tools. + let expr = format!( + r#"let + flake = builtins.getFlake "{}"; + catalog = flake.catalog; + filterDrvs = attrs: + let keys = builtins.filter (k: (attrs.${{k}}.type or "") == "derivation") + (builtins.attrNames attrs); + in builtins.listToAttrs (map (k: {{ name = k; value = attrs.${{k}}; }}) keys); + extractMeta = attrs: + builtins.mapAttrs (name: pkg: {{ description = pkg.meta.description or ""; }}) + (filterDrvs attrs); +in {{ agents = extractMeta catalog.agents; tools = extractMeta catalog.tools; }}"#, + flake_root + ); + + let output = std::process::Command::new("nix") + .args(["eval", "--impure", "--expr", &expr, "--json"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|e| format!("nix eval: {e}"))?; + + if output.status.success() { + let json = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if json.is_empty() { + Err("nix eval produced no output".into()) + } else { + Ok(json) + } + } else { + Err(format!( + "nix eval failed: {}", + String::from_utf8_lossy(&output.stderr) + )) + } +} diff --git a/crates/nixosandbox/src/plan_builder.rs b/crates/nixosandbox/src/plan_builder.rs new file mode 100644 index 0000000..44975dd --- /dev/null +++ b/crates/nixosandbox/src/plan_builder.rs @@ -0,0 +1,97 @@ +/// Session directory paths for rootfs-mode execution. +pub struct RootfsSessionDirs { + pub workspace: String, + pub home: String, + pub cache: String, +} + +/// Build bwrap argument vector for sandboxed execution with a Nix rootfs. +pub fn build_rootfs( + rootfs_path: &str, + session_dirs: &RootfsSessionDirs, + command: &[String], + env: &std::collections::HashMap, + _network: &str, + namespaces: &[String], +) -> Vec { + let mut argv: Vec = Vec::new(); + // Lifecycle: kill sandbox when parent dies, isolate from terminal signals + argv.push("--die-with-parent".to_string()); + argv.push("--new-session".to_string()); + // Mount the Nix rootfs as the new / (bwrap internally does pivot_root) + argv.extend(["--ro-bind".to_string(), rootfs_path.to_string(), "/".to_string()]); + // Mount Nix store read-only — rootfs symlinks point back into /nix/store + argv.extend(["--ro-bind".to_string(), "/nix/store".to_string(), "/nix/store".to_string()]); + argv.extend(["--bind".to_string(), session_dirs.workspace.clone(), "/workspace".to_string()]); + argv.extend(["--bind".to_string(), session_dirs.home.clone(), "/home/sandbox".to_string()]); + argv.extend(["--bind".to_string(), session_dirs.cache.clone(), "/cache".to_string()]); + argv.extend(["--tmpfs".to_string(), "/tmp".to_string()]); + argv.extend(["--dev".to_string(), "/dev".to_string()]); + argv.extend(["--proc".to_string(), "/proc".to_string()]); + for ns in namespaces { + match ns.as_str() { + "pid" => argv.push("--unshare-pid".to_string()), + "mount" => {} // implicit with --ro-bind / + "uts" => argv.push("--unshare-uts".to_string()), + "ipc" => argv.push("--unshare-ipc".to_string()), + "net" => argv.push("--unshare-net".to_string()), + "user" => argv.push("--unshare-user".to_string()), + "cgroup" => argv.push("--unshare-cgroup-try".to_string()), + _ => {} + } + } + argv.push("--clearenv".to_string()); + argv.extend(["--setenv".to_string(), "HOME".to_string(), "/home/sandbox".to_string()]); + argv.extend(["--setenv".to_string(), "PATH".to_string(), "/bin:/usr/bin".to_string()]); + argv.extend(["--setenv".to_string(), "TERM".to_string(), "xterm-256color".to_string()]); + for (key, value) in env { + argv.extend(["--setenv".to_string(), key.clone(), value.clone()]); + } + argv.extend(["--chdir".to_string(), "/workspace".to_string()]); + argv.push("--".to_string()); + argv.extend(command.iter().cloned()); + argv +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_rootfs_produces_ro_bind_root_argv() { + let dirs = RootfsSessionDirs { + workspace: "/tmp/ws".to_string(), + home: "/tmp/home".to_string(), + cache: "/tmp/cache".to_string(), + }; + let cmd = vec!["echo".to_string(), "hello".to_string()]; + let env = std::collections::HashMap::new(); + let argv = build_rootfs("/nix/store/fake", &dirs, &cmd, &env, "full", &["pid".to_string()]); + // Rootfs is mounted read-only at / (bwrap internally does pivot_root) + assert!(argv.contains(&"--ro-bind".to_string())); + assert!(argv.contains(&"/nix/store/fake".to_string())); + // Verify rootfs is bound to / + let ro_bind_pos = argv.iter().position(|a| a == "--ro-bind").unwrap(); + assert_eq!(argv[ro_bind_pos + 1], "/nix/store/fake"); + assert_eq!(argv[ro_bind_pos + 2], "/"); + assert!(argv.contains(&"--bind".to_string())); + assert!(argv.contains(&"--tmpfs".to_string())); + assert!(argv.contains(&"--dev".to_string())); + assert!(argv.contains(&"--proc".to_string())); + assert!(argv.contains(&"--clearenv".to_string())); + let sep = argv.iter().position(|a| a == "--").unwrap(); + assert_eq!(argv[sep + 1], "echo"); + assert_eq!(argv[sep + 2], "hello"); + } + + #[test] + fn build_rootfs_network_off_adds_unshare_net() { + let dirs = RootfsSessionDirs { + workspace: "/tmp/ws".to_string(), home: "/tmp/home".to_string(), cache: "/tmp/cache".to_string(), + }; + let cmd = vec!["echo".to_string()]; + let env = std::collections::HashMap::new(); + let argv = build_rootfs("/nix/store/fake", &dirs, &cmd, &env, "off", &["pid".to_string(), "net".to_string()]); + assert!(argv.contains(&"--unshare-net".to_string())); + } +} diff --git a/crates/nixosandbox/src/session.rs b/crates/nixosandbox/src/session.rs new file mode 100644 index 0000000..de6bc67 --- /dev/null +++ b/crates/nixosandbox/src/session.rs @@ -0,0 +1,246 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionMetadata { + pub session_id: String, + pub name: String, + pub profile: String, + pub rootfs_path: String, + pub workspace: String, + pub created_at: String, + pub last_exec_at: Option, + pub pid: Option, + #[serde(default)] + pub agent: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub network: Option, +} + +pub struct SessionDirs { + pub root: PathBuf, + pub workspace: PathBuf, + pub home: PathBuf, + pub cache: PathBuf, + pub metadata_path: PathBuf, +} + +pub fn sessions_base_dir() -> PathBuf { + let data_dir = std::env::var("NIXOSANDBOX_DATA_DIR") + .unwrap_or_else(|_| { + let home = std::env::var("HOME").expect("HOME not set"); + format!("{}/.local/share/nixosandbox", home) + }); + PathBuf::from(data_dir).join("sessions") +} + +fn generate_session_id() -> String { + uuid::Uuid::new_v4().to_string()[..8].to_string() +} + +pub fn create_session( + name: &str, profile: &str, rootfs_path: &str, workspace: Option<&str>, + agent: Option<&str>, description: Option<&str>, network: Option<&str>, +) -> Result { + let session_id = generate_session_id(); + let base = sessions_base_dir(); + let session_dir = base.join(&session_id); + fs::create_dir_all(&session_dir).map_err(|e| format!("failed to create session dir: {e}"))?; + let home_dir = session_dir.join("home"); + let cache_dir = session_dir.join("cache"); + fs::create_dir_all(&home_dir).map_err(|e| format!("failed to create home dir: {e}"))?; + fs::create_dir_all(&cache_dir).map_err(|e| format!("failed to create cache dir: {e}"))?; + + let workspace_dir = session_dir.join("workspace"); + let workspace_path = if let Some(ws) = workspace { + let ws_path = Path::new(ws); + if !ws_path.exists() { + return Err(format!("workspace path does not exist: {ws}")); + } + #[cfg(unix)] + std::os::unix::fs::symlink(ws_path, &workspace_dir) + .map_err(|e| format!("failed to symlink workspace: {e}"))?; + ws.to_string() + } else { + fs::create_dir_all(&workspace_dir).map_err(|e| format!("failed to create workspace: {e}"))?; + workspace_dir.to_string_lossy().to_string() + }; + + let metadata = SessionMetadata { + session_id: session_id.clone(), + name: name.to_string(), + profile: profile.to_string(), + rootfs_path: rootfs_path.to_string(), + workspace: workspace_path, + created_at: crate::timestamps::now_iso8601(), + last_exec_at: None, + pid: None, + agent: agent.map(|s| s.to_string()), + description: description.map(|s| s.to_string()), + network: network.map(|s| s.to_string()), + }; + let metadata_path = session_dir.join("metadata.json"); + let json = serde_json::to_string_pretty(&metadata).map_err(|e| format!("serialize: {e}"))?; + fs::write(&metadata_path, json).map_err(|e| format!("write metadata: {e}"))?; + Ok(metadata) +} + +pub fn list_sessions() -> Result, String> { + let base = sessions_base_dir(); + if !base.exists() { return Ok(vec![]); } + let mut sessions = Vec::new(); + let entries = fs::read_dir(&base).map_err(|e| format!("read sessions dir: {e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("read dir entry: {e}"))?; + let metadata_path = entry.path().join("metadata.json"); + if metadata_path.exists() { + let content = fs::read_to_string(&metadata_path).map_err(|e| format!("read metadata: {e}"))?; + if let Ok(meta) = serde_json::from_str::(&content) { + sessions.push(meta); + } + } + } + sessions.sort_by(|a, b| a.created_at.cmp(&b.created_at)); + Ok(sessions) +} + +pub fn load_session(session_id: &str) -> Result { + let path = sessions_base_dir().join(session_id).join("metadata.json"); + if !path.exists() { return Err(format!("session '{}' not found", session_id)); } + let content = fs::read_to_string(&path).map_err(|e| format!("read metadata: {e}"))?; + serde_json::from_str(&content).map_err(|e| format!("parse metadata: {e}")) +} + +pub fn session_dirs(session_id: &str) -> SessionDirs { + let root = sessions_base_dir().join(session_id); + SessionDirs { + workspace: root.join("workspace"), home: root.join("home"), + cache: root.join("cache"), + metadata_path: root.join("metadata.json"), root, + } +} + +pub fn touch_last_exec(session_id: &str) -> Result<(), String> { + let mut meta = load_session(session_id)?; + meta.last_exec_at = Some(crate::timestamps::now_iso8601()); + let dirs = session_dirs(session_id); + let json = serde_json::to_string_pretty(&meta).map_err(|e| format!("serialize: {e}"))?; + fs::write(&dirs.metadata_path, json).map_err(|e| format!("write metadata: {e}")) +} + +pub fn destroy_session(session_id: &str) -> Result<(), String> { + let dirs = session_dirs(session_id); + if !dirs.root.exists() { return Err(format!("session '{}' not found", session_id)); } + fs::remove_dir_all(&dirs.root).map_err(|e| format!("remove session dir: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // Serialize tests that mutate NIXOSANDBOX_DATA_DIR to avoid races. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn with_temp_data_dir(f: F) { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = std::env::temp_dir().join(format!("nixosandbox-test-{}", uuid::Uuid::new_v4())); + std::env::set_var("NIXOSANDBOX_DATA_DIR", &dir); + f(); + let _ = fs::remove_dir_all(&dir); + std::env::remove_var("NIXOSANDBOX_DATA_DIR"); + } + + #[test] + fn create_and_list_sessions() { + with_temp_data_dir(|| { + let meta = create_session("test-session", "strict", "/nix/store/fake", None, None, None, None).unwrap(); + assert_eq!(meta.name, "test-session"); + let sessions = list_sessions().unwrap(); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].session_id, meta.session_id); + }); + } + + #[test] + fn load_session_by_id() { + with_temp_data_dir(|| { + let meta = create_session("load-test", "strict", "/nix/store/fake", None, None, None, None).unwrap(); + let loaded = load_session(&meta.session_id).unwrap(); + assert_eq!(loaded.name, "load-test"); + }); + } + + #[test] + fn destroy_session_removes_dir() { + with_temp_data_dir(|| { + let meta = create_session("rm-test", "strict", "/nix/store/fake", None, None, None, None).unwrap(); + let dirs = session_dirs(&meta.session_id); + assert!(dirs.root.exists()); + destroy_session(&meta.session_id).unwrap(); + assert!(!dirs.root.exists()); + }); + } + + #[test] + fn destroy_nonexistent_errors() { + with_temp_data_dir(|| { + assert!(destroy_session("nonexistent").is_err()); + }); + } + + #[test] + fn create_with_external_workspace() { + with_temp_data_dir(|| { + let ws = std::env::temp_dir().join(format!("ws-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&ws).unwrap(); + let meta = create_session("ws-test", "strict", "/nix/store/fake", Some(ws.to_str().unwrap()), None, None, None).unwrap(); + let dirs = session_dirs(&meta.session_id); + assert!(dirs.workspace.is_symlink()); + destroy_session(&meta.session_id).unwrap(); + assert!(ws.exists()); // external workspace preserved + let _ = fs::remove_dir_all(&ws); + }); + } + + #[test] + fn metadata_roundtrip() { + let meta = SessionMetadata { + session_id: "abc".to_string(), name: "test".to_string(), + profile: "strict".to_string(), rootfs_path: "/nix/store/fake".to_string(), + workspace: "/tmp/ws".to_string(), created_at: "2026-04-08T12:00:00Z".to_string(), + last_exec_at: None, pid: None, + agent: Some("claude:opus-4-6".to_string()), + description: Some("test session".to_string()), + network: Some("off".to_string()), + }; + let json = serde_json::to_string(&meta).unwrap(); + let de: SessionMetadata = serde_json::from_str(&json).unwrap(); + assert_eq!(de.session_id, "abc"); + assert_eq!(de.agent.as_deref(), Some("claude:opus-4-6")); + assert_eq!(de.description.as_deref(), Some("test session")); + assert_eq!(de.network.as_deref(), Some("off")); + } + + #[test] + fn metadata_deserializes_without_new_fields() { + let json = r#"{ + "sessionId": "abc", + "name": "test", + "profile": "strict", + "rootfsPath": "/nix/store/fake", + "workspace": "/tmp/ws", + "createdAt": "2026-04-08T12:00:00Z", + "lastExecAt": null, + "pid": null + }"#; + let de: SessionMetadata = serde_json::from_str(json).unwrap(); + assert_eq!(de.session_id, "abc"); + assert!(de.agent.is_none()); + assert!(de.description.is_none()); + } +} diff --git a/crates/nixosandbox/src/spec.rs b/crates/nixosandbox/src/spec.rs new file mode 100644 index 0000000..c5213d4 --- /dev/null +++ b/crates/nixosandbox/src/spec.rs @@ -0,0 +1,133 @@ +use std::collections::HashMap; +use std::path::Path; +use serde::{Deserialize, Serialize}; + +/// A sandbox environment specification. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxSpec { + pub name: String, + pub packages: Vec, + #[serde(default)] + pub env: HashMap, + #[serde(default = "default_network")] + pub network: String, + #[serde(default = "default_namespaces")] + pub namespaces: Vec, + #[serde(default = "default_writable")] + pub writable: Vec, +} + +fn default_network() -> String { "full".to_string() } + +fn default_namespaces() -> Vec { + vec!["pid".to_string(), "mount".to_string(), "uts".to_string(), "ipc".to_string()] +} + +fn default_writable() -> Vec { + vec!["/workspace".to_string(), "/home/sandbox".to_string(), "/cache".to_string(), "/tmp".to_string()] +} + +/// Load a spec from a JSON file path. +pub fn load_spec(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("failed to read spec file '{}': {}", path, e))?; + serde_json::from_str(&content) + .map_err(|e| format!("failed to parse spec file '{}': {}", path, e)) +} + +/// Load a built-in profile by name. +pub fn load_profile(name: &str, flake_root: &str) -> Result { + let path = format!("{}/nix/profiles/{}.json", flake_root, name); + if !Path::new(&path).exists() { + return Err(format!( + "unknown profile '{}'. Available: build-install, offline-review, strict, debug-network", + name + )); + } + load_spec(&path) +} + +/// Validate a spec for basic correctness. +pub fn validate_spec(spec: &SandboxSpec) -> Result<(), Vec> { + let mut errors = Vec::new(); + if spec.name.is_empty() { + errors.push("spec.name must not be empty".to_string()); + } + if spec.packages.is_empty() { + errors.push("spec.packages must not be empty".to_string()); + } + match spec.network.as_str() { + "off" | "full" => {} + other => errors.push(format!("spec.network must be 'off' or 'full', got '{}'", other)), + } + for ns in &spec.namespaces { + match ns.as_str() { + "pid" | "mount" | "uts" | "ipc" | "net" | "user" | "cgroup" => {} + other => errors.push(format!("unknown namespace '{}' in spec.namespaces", other)), + } + } + if errors.is_empty() { Ok(()) } else { Err(errors) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_minimal_spec() { + let json = r#"{"name":"test","packages":["bash"]}"#; + let spec: SandboxSpec = serde_json::from_str(json).unwrap(); + assert_eq!(spec.name, "test"); + assert_eq!(spec.packages, vec!["bash"]); + assert_eq!(spec.network, "full"); + assert_eq!(spec.namespaces, vec!["pid", "mount", "uts", "ipc"]); + } + + #[test] + fn deserialize_full_spec() { + let json = r#"{"name":"web","packages":["nodejs_22","git"],"env":{"NODE_ENV":"dev"},"network":"off","namespaces":["pid","net"],"writable":["/tmp"]}"#; + let spec: SandboxSpec = serde_json::from_str(json).unwrap(); + assert_eq!(spec.network, "off"); + assert_eq!(spec.env.get("NODE_ENV").unwrap(), "dev"); + } + + #[test] + fn validate_valid_spec() { + let spec = SandboxSpec { + name: "test".to_string(), packages: vec!["bash".to_string()], + env: HashMap::new(), network: "full".to_string(), + namespaces: vec!["pid".to_string()], writable: vec!["/tmp".to_string()], + }; + assert!(validate_spec(&spec).is_ok()); + } + + #[test] + fn validate_empty_name_fails() { + let spec = SandboxSpec { + name: "".to_string(), packages: vec!["bash".to_string()], + env: HashMap::new(), network: "full".to_string(), + namespaces: vec![], writable: vec![], + }; + assert!(validate_spec(&spec).unwrap_err().iter().any(|e| e.contains("name"))); + } + + #[test] + fn validate_bad_network_fails() { + let spec = SandboxSpec { + name: "test".to_string(), packages: vec!["bash".to_string()], + env: HashMap::new(), network: "allowlist".to_string(), + namespaces: vec![], writable: vec![], + }; + assert!(validate_spec(&spec).unwrap_err().iter().any(|e| e.contains("network"))); + } + + #[test] + fn validate_empty_packages_fails() { + let spec = SandboxSpec { + name: "test".to_string(), packages: vec![], + env: HashMap::new(), network: "full".to_string(), + namespaces: vec![], writable: vec![], + }; + assert!(validate_spec(&spec).unwrap_err().iter().any(|e| e.contains("packages"))); + } +} diff --git a/crates/nixosandbox/src/timestamps.rs b/crates/nixosandbox/src/timestamps.rs new file mode 100644 index 0000000..4e2e234 --- /dev/null +++ b/crates/nixosandbox/src/timestamps.rs @@ -0,0 +1,6 @@ +use chrono::Utc; + +/// Return current UTC time as ISO 8601 string. +pub fn now_iso8601() -> String { + Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index ee9c6e2..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,82 +0,0 @@ -version: "3.8" - -services: - nixos-sandbox: - image: nixos/nix:latest - container_name: nixos-sandbox - hostname: sandbox - privileged: true - security_opt: - - seccomp:unconfined - - apparmor:unconfined - cap_add: - - SYS_ADMIN - - NET_ADMIN - ports: - - "${SANDBOX_API_PORT:-8080}:8080" # Control API - - "${VNC_PORT:-5900}:5900" # VNC - - "${NOVNC_PORT:-6080}:6080" # noVNC web client - - "${CDP_PORT:-9222}:9222" # Chrome DevTools Protocol - volumes: - - sandbox-app:/app # Cloned repo and state - - sandbox-data:/home/sandbox # Persistent workspace - - sandbox-nix:/nix # Nix store cache - - /dev/shm:/dev/shm # Shared memory for browser - environment: - - PORT=8080 - - DISPLAY=:99 - - HOME=/home/sandbox - - WORKSPACE=/home/sandbox/workspace - - SKILLS_DIR=/home/sandbox/skills - - BROWSER_HEADLESS=${BROWSER_HEADLESS:-true} - - BROWSER_EXECUTABLE=/nix/var/nix/profiles/default/bin/chromium - - TZ=${TZ:-UTC} - - GITHUB_REPO=${GITHUB_REPO:-https://github.com/HashWarlock/nixosandbox.git} - - GIT_COMMIT_HASH=${GIT_COMMIT_HASH:-HEAD} - - UPDATE_CODE=${UPDATE_CODE:-false} - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 120s - restart: unless-stopped - command: > - sh -c " - mkdir -p /app/.state && - if [ ! -f /app/.state/initialized ] || [ \"$$UPDATE_CODE\" = 'true' ]; then - echo 'Cloning repository...' && - rm -rf /app/repo && - git clone $$GITHUB_REPO /app/repo && - cd /app/repo && - git checkout $$GIT_COMMIT_HASH && - touch /app/.state/initialized && - echo 'Repository cloned and checked out to commit: '$$GIT_COMMIT_HASH; - else - echo 'Repository already initialized, skipping clone...'; - fi && - cd /app/repo && - nix-channel --add https://nixos.org/channels/nixos-25.11 nixpkgs && - nix-channel --update && - nix-shell /app/repo/nix/shell.nix --run ' - cd /app/repo/sandbox-rs && - if [ ! -f target/release/sandbox-api ] || [ \"$$UPDATE_CODE\" = \"true\" ]; then - echo \"Building Rust API...\" && - cargo build --release; - fi && - echo \"Starting sandbox API...\" && - ./target/release/sandbox-api - ' - " - -volumes: - sandbox-app: - driver: local - sandbox-data: - driver: local - sandbox-nix: - driver: local - -networks: - default: - name: sandbox-network diff --git a/docker/nixosandbox-sidecar.Dockerfile b/docker/nixosandbox-sidecar.Dockerfile new file mode 100644 index 0000000..3ba770a --- /dev/null +++ b/docker/nixosandbox-sidecar.Dockerfile @@ -0,0 +1,11 @@ +# docker/nixosandbox-sidecar.Dockerfile +# +# Minimal Linux sidecar for running bwrap on macOS via Docker Desktop. +# All runtime packages come from the Nix rootfs via --pivot-root. +# This container only provides bwrap (sandbox primitive) and iptables (network enforcement). +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bubblewrap \ + iptables \ + && rm -rf /var/lib/apt/lists/* diff --git a/docs/superpowers/plans/2026-04-03-pi-sandbox-phases-8-10.md b/docs/superpowers/plans/2026-04-03-pi-sandbox-phases-8-10.md new file mode 100644 index 0000000..9d36859 --- /dev/null +++ b/docs/superpowers/plans/2026-04-03-pi-sandbox-phases-8-10.md @@ -0,0 +1,2660 @@ +# Pi Sandbox Phases 8-10 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the stub execution and observation in the Pi Sandbox Rust runtime with real Bubblewrap isolation and `/proc/net/tcp` network observation, validated by real-world build flow integration tests. + +**Architecture:** The Rust runtime gains bwrap binary discovery, a pure-function plan builder that converts manifests+policy into bwrap argv, and a background network observer that polls `/proc/net/tcp`. On macOS (no bwrap), the runtime falls back to direct execution with degraded warnings — truthful reporting throughout. Integration tests use checked-in fixture repos (tiny-npm, tiny-python, tiny-rust) to validate real build workflows. + +**Tech Stack:** Rust (std only, no new deps), TypeScript (vitest), Bubblewrap (`bwrap`), `/proc/net/tcp` + +**Spec:** `docs/superpowers/specs/2026-04-03-pi-sandbox-phases-8-10-design.md` + +--- + +## File Map + +### New Files + +| File | Responsibility | +|------|----------------| +| `crates/pi-sandbox-runtime/src/bubblewrap.rs` | Bwrap binary discovery, platform detection, `BwrapAvailability` enum | +| `crates/pi-sandbox-runtime/src/plan_builder.rs` | Pure function: `PlanPayload` + `EffectiveState` → `Vec` bwrap argv | +| `tests/protocol/bwrap-integration.test.ts` | Linux-only protocol test for bwrap isolation | +| `tests/integration/fixtures/tiny-npm/package.json` | Empty npm project fixture | +| `tests/integration/fixtures/tiny-python/setup.py` | Stdlib-only Python fixture | +| `tests/integration/fixtures/tiny-python/mypackage/__init__.py` | Python fixture package init | +| `tests/integration/fixtures/tiny-rust/Cargo.toml` | No-deps Rust fixture | +| `tests/integration/fixtures/tiny-rust/src/main.rs` | Rust fixture entrypoint | +| `tests/integration/helpers.ts` | Integration test utilities (copyFixture, makeIntegrationPlan) | +| `tests/integration/globalSetup.ts` | Build Rust binary for integration tests | +| `tests/integration/vitest.config.ts` | Vitest configuration for integration suite | +| `tests/integration/package.json` | Package manifest for integration tests | +| `tests/integration/tsconfig.json` | TypeScript config for integration tests | +| `tests/integration/build-npm.test.ts` | npm install integration test | +| `tests/integration/build-python.test.ts` | pip install integration test | +| `tests/integration/build-rust.test.ts` | cargo build integration test | +| `tests/integration/network-smoke.test.ts` | Optional network smoke test | +| `tests/protocol/network-observation.test.ts` | Linux-only network observation protocol test | + +### Modified Files + +| File | Changes | +|------|---------| +| `crates/pi-sandbox-runtime/src/contract.rs` | Add `namespaces_applied` and `env_applied` to `EffectiveState` | +| `crates/pi-sandbox-runtime/src/validator.rs` | Accept bwrap availability, resolve namespaces/env, emit NAMESPACE_DEGRADED | +| `crates/pi-sandbox-runtime/src/supervisor.rs` | Accept bwrap availability, dispatch to bwrap or direct, integrate observer | +| `crates/pi-sandbox-runtime/src/main.rs` | Call `bubblewrap::detect()`, pass to validator/supervisor | +| `crates/pi-sandbox-runtime/src/observer.rs` | Replace stub with `NetworkObserver` struct + `/proc/net/tcp` polling | + +--- + +## Phase 8: Bubblewrap Integration + +### Task 1: Extend EffectiveState in contract.rs + +**Files:** +- Modify: `crates/pi-sandbox-runtime/src/contract.rs:135-138` + +- [ ] **Step 1: Add `namespaces_applied` and `env_applied` fields to `EffectiveState`** + +In `crates/pi-sandbox-runtime/src/contract.rs`, replace the existing `EffectiveState` struct: + +```rust +// OLD (lines 135-138): +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EffectiveState { + pub network: EffectiveNetwork, +} +``` + +With: + +```rust +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EffectiveState { + pub network: EffectiveNetwork, + pub namespaces_applied: Vec, + pub env_applied: Vec, +} +``` + +- [ ] **Step 2: Fix all compilation errors from the new fields** + +Every place that constructs an `EffectiveState` must now provide the two new fields. There is one place in `validator.rs` (around line 115-117): + +In `crates/pi-sandbox-runtime/src/validator.rs`, replace: + +```rust + let effective_state = Some(EffectiveState { + network: effective_network, + }); +``` + +With: + +```rust + let env_applied: Vec = plan.manifest.env.keys().cloned().collect(); + + let effective_state = Some(EffectiveState { + network: effective_network, + namespaces_applied: vec![], + env_applied, + }); +``` + +Note: `namespaces_applied` is empty for now (no bwrap yet). Task 4 will populate it properly. + +- [ ] **Step 3: Verify compilation** + +Run: `cd crates/pi-sandbox-runtime && cargo build --release 2>&1` +Expected: Builds successfully (may have dead_code warnings, that's fine). + +- [ ] **Step 4: Verify existing protocol tests still pass** + +Run: `cd tests/protocol && npx vitest run 2>&1` +Expected: All 6 tests pass (7 individual tests). The new `namespacesApplied` and `envApplied` fields appear in the validation JSON but existing tests don't assert their absence, so they pass unchanged. + +- [ ] **Step 5: Commit** + +```bash +git add crates/pi-sandbox-runtime/src/contract.rs crates/pi-sandbox-runtime/src/validator.rs +git commit -m "feat: add namespacesApplied and envApplied to EffectiveState" +``` + +--- + +### Task 2: Create bubblewrap.rs — binary discovery and platform detection + +**Files:** +- Create: `crates/pi-sandbox-runtime/src/bubblewrap.rs` +- Modify: `crates/pi-sandbox-runtime/src/main.rs:1` (add `mod bubblewrap;`) + +- [ ] **Step 1: Create the bubblewrap module** + +Create `crates/pi-sandbox-runtime/src/bubblewrap.rs`: + +```rust +use std::path::PathBuf; + +/// Whether Bubblewrap is available for sandboxed execution. +#[derive(Debug, Clone)] +pub enum BwrapAvailability { + Available { path: PathBuf }, + Unavailable { reason: String }, +} + +/// Detect whether Bubblewrap is available on this platform. +/// +/// Resolution order: +/// 1. `PI_SANDBOX_BWRAP_PATH` env var (if set and file exists) +/// 2. `which bwrap` on PATH (Linux only) +/// 3. Unavailable +/// +/// On non-Linux platforms, always returns Unavailable. +pub fn detect() -> BwrapAvailability { + #[cfg(not(target_os = "linux"))] + { + return BwrapAvailability::Unavailable { + reason: "Bubblewrap requires Linux".to_string(), + }; + } + + #[cfg(target_os = "linux")] + { + // 1. Check env var + if let Ok(path_str) = std::env::var("PI_SANDBOX_BWRAP_PATH") { + let path = PathBuf::from(&path_str); + if path.exists() { + return BwrapAvailability::Available { path }; + } + return BwrapAvailability::Unavailable { + reason: format!( + "PI_SANDBOX_BWRAP_PATH set to '{}' but file does not exist", + path_str + ), + }; + } + + // 2. Try which bwrap + match std::process::Command::new("which") + .arg("bwrap") + .output() + { + Ok(output) if output.status.success() => { + let path_str = String::from_utf8_lossy(&output.stdout) + .trim() + .to_string(); + let path = PathBuf::from(&path_str); + if path.exists() { + return BwrapAvailability::Available { path }; + } + BwrapAvailability::Unavailable { + reason: format!("which bwrap returned '{}' but file does not exist", path_str), + } + } + _ => BwrapAvailability::Unavailable { + reason: "bwrap not found on PATH".to_string(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_returns_a_result() { + // On any platform, detect() must return without panicking. + let result = detect(); + match &result { + BwrapAvailability::Available { path } => { + assert!(path.exists()); + } + BwrapAvailability::Unavailable { reason } => { + assert!(!reason.is_empty()); + } + } + } + + #[test] + #[cfg(not(target_os = "linux"))] + fn non_linux_always_unavailable() { + let result = detect(); + match result { + BwrapAvailability::Unavailable { reason } => { + assert!(reason.contains("Linux"), "reason: {}", reason); + } + BwrapAvailability::Available { .. } => { + panic!("Should not be available on non-Linux"); + } + } + } +} +``` + +- [ ] **Step 2: Register the module in main.rs** + +In `crates/pi-sandbox-runtime/src/main.rs`, add `mod bubblewrap;` to the module declarations. The top of the file should become: + +```rust +mod bubblewrap; +mod contract; +mod observer; +mod supervisor; +mod timestamps; +mod validator; +``` + +- [ ] **Step 3: Run Rust tests** + +Run: `cd crates/pi-sandbox-runtime && cargo test 2>&1` +Expected: `bubblewrap::tests::detect_returns_a_result` passes. On macOS, `bubblewrap::tests::non_linux_always_unavailable` also passes. + +- [ ] **Step 4: Verify protocol tests still pass** + +Run: `cd tests/protocol && npx vitest run 2>&1` +Expected: All pass (no behavioral change yet). + +- [ ] **Step 5: Commit** + +```bash +git add crates/pi-sandbox-runtime/src/bubblewrap.rs crates/pi-sandbox-runtime/src/main.rs +git commit -m "feat: add bubblewrap binary discovery module" +``` + +--- + +### Task 3: Create plan_builder.rs — bwrap argv construction + +**Files:** +- Create: `crates/pi-sandbox-runtime/src/plan_builder.rs` +- Modify: `crates/pi-sandbox-runtime/src/main.rs:1` (add `mod plan_builder;`) + +- [ ] **Step 1: Write the plan_builder module with tests** + +Create `crates/pi-sandbox-runtime/src/plan_builder.rs`: + +```rust +use crate::contract::{EffectiveNetwork, EffectiveState, PlanPayload}; + +/// Build the Bubblewrap argument vector from a validated plan and its effective state. +/// +/// This is a pure function: no I/O, no side effects. +/// The returned Vec is suitable for `Command::new("bwrap").args(result)`. +/// +/// Construction order: +/// 1. Mounts (ro-bind / bind / tmpfs) +/// 2. Devices (hardcoded minimal set) +/// 3. Proc filesystem +/// 4. Namespaces +/// 5. Environment (clearenv + setenv) +/// 6. Working directory +/// 7. Command (after --) +pub fn build(plan: &PlanPayload, effective_state: &EffectiveState) -> Vec { + let mut argv: Vec = Vec::new(); + + // 1. Mounts + for mount in &plan.manifest.mounts { + match mount.mount_type.as_str() { + "directory" | "file" => { + let flag = if mount.writable { "--bind" } else { "--ro-bind" }; + let source = mount.source.as_deref().unwrap_or(&mount.target); + argv.push(flag.to_string()); + argv.push(source.to_string()); + argv.push(mount.target.clone()); + } + "tmpfs" => { + argv.push("--tmpfs".to_string()); + argv.push(mount.target.clone()); + } + _ => { + // Unknown mount type — skip (validator should have caught this) + } + } + } + + // 2. Devices — hardcoded minimal set + for dev in &["/dev/null", "/dev/zero", "/dev/urandom", "/dev/random"] { + argv.push("--dev-bind".to_string()); + argv.push(dev.to_string()); + argv.push(dev.to_string()); + } + + // 3. Proc filesystem + argv.push("--proc".to_string()); + argv.push("/proc".to_string()); + + // 4. Namespaces (from effective state, not requested) + for ns in &effective_state.namespaces_applied { + match ns.as_str() { + "pid" => argv.push("--unshare-pid".to_string()), + "ipc" => argv.push("--unshare-ipc".to_string()), + "uts" => argv.push("--unshare-uts".to_string()), + "net" => { + // Only unshare network if actual mode is "off" + if effective_state.network.actual == "off" { + argv.push("--unshare-net".to_string()); + } + } + "cgroup-try" => argv.push("--unshare-cgroup-try".to_string()), + // "user" is implicit in bwrap — do not add --unshare-user + "user" => {} + _ => { + // Unknown namespace — skip + } + } + } + + // 5. Environment + argv.push("--clearenv".to_string()); + for (key, value) in &plan.manifest.env { + argv.push("--setenv".to_string()); + argv.push(key.clone()); + argv.push(value.clone()); + } + + // 6. Working directory + argv.push("--chdir".to_string()); + argv.push(plan.manifest.cwd.clone()); + + // 7. Command (after --) + argv.push("--".to_string()); + for part in &plan.command { + argv.push(part.clone()); + } + + argv +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::contract::{Manifest, Mount, NetworkConfig, Policy}; + use std::collections::HashMap; + + fn make_plan(overrides: Option) -> PlanPayload { + let o = overrides.unwrap_or_default(); + PlanPayload { + version: 1, + session_id: "test".to_string(), + execution_id: "test".to_string(), + requested_profile: "build-install".to_string(), + runtime_base_name: None, + manifest: Manifest { + mounts: o.mounts.unwrap_or_else(|| vec![ + Mount { + mount_type: "directory".to_string(), + source: Some("/host/workspace".to_string()), + target: "/workspace".to_string(), + writable: true, + }, + ]), + env: o.env.unwrap_or_else(|| { + let mut m = HashMap::new(); + m.insert("HOME".to_string(), "/home/sandbox".to_string()); + m.insert("PATH".to_string(), "/usr/bin:/bin".to_string()); + m + }), + cwd: o.cwd.unwrap_or_else(|| "/workspace".to_string()), + }, + policy: Policy { + namespaces: vec!["user".to_string(), "pid".to_string()], + network: NetworkConfig { + mode: o.network_mode.unwrap_or_else(|| "full".to_string()), + allowlist: None, + }, + resource_limits: None, + allowed_writable_targets: vec!["/workspace".to_string(), "/tmp".to_string()], + strict_write_policy: false, + env_allowlist: None, + deny_commands: None, + }, + command: o.command.unwrap_or_else(|| vec!["echo".to_string(), "hello".to_string()]), + } + } + + fn make_effective_state(overrides: Option) -> EffectiveState { + let o = overrides.unwrap_or_default(); + EffectiveState { + network: EffectiveNetwork { + requested: o.network_requested.unwrap_or_else(|| "full".to_string()), + actual: o.network_actual.unwrap_or_else(|| "full".to_string()), + enforcement: o.network_enforcement.unwrap_or_else(|| "none".to_string()), + degraded: o.network_degraded.unwrap_or(false), + }, + namespaces_applied: o.namespaces.unwrap_or_else(|| vec!["user".to_string(), "pid".to_string()]), + env_applied: vec!["HOME".to_string(), "PATH".to_string()], + } + } + + #[derive(Default)] + struct PlanOverrides { + mounts: Option>, + env: Option>, + cwd: Option, + command: Option>, + network_mode: Option, + } + + #[derive(Default)] + struct EffectiveOverrides { + namespaces: Option>, + network_requested: Option, + network_actual: Option, + network_enforcement: Option, + network_degraded: Option, + } + + #[test] + fn read_only_directory_mount_produces_ro_bind() { + let plan = make_plan(Some(PlanOverrides { + mounts: Some(vec![Mount { + mount_type: "directory".to_string(), + source: Some("/host/src".to_string()), + target: "/src".to_string(), + writable: false, + }]), + ..Default::default() + })); + let state = make_effective_state(None); + let argv = build(&plan, &state); + let idx = argv.iter().position(|a| a == "--ro-bind").unwrap(); + assert_eq!(argv[idx + 1], "/host/src"); + assert_eq!(argv[idx + 2], "/src"); + } + + #[test] + fn writable_directory_mount_produces_bind() { + let plan = make_plan(Some(PlanOverrides { + mounts: Some(vec![Mount { + mount_type: "directory".to_string(), + source: Some("/host/workspace".to_string()), + target: "/workspace".to_string(), + writable: true, + }]), + ..Default::default() + })); + let state = make_effective_state(None); + let argv = build(&plan, &state); + let idx = argv.iter().position(|a| a == "--bind").unwrap(); + assert_eq!(argv[idx + 1], "/host/workspace"); + assert_eq!(argv[idx + 2], "/workspace"); + } + + #[test] + fn tmpfs_mount_produces_tmpfs() { + let plan = make_plan(Some(PlanOverrides { + mounts: Some(vec![Mount { + mount_type: "tmpfs".to_string(), + source: None, + target: "/tmp".to_string(), + writable: true, + }]), + ..Default::default() + })); + let state = make_effective_state(None); + let argv = build(&plan, &state); + let idx = argv.iter().position(|a| a == "--tmpfs").unwrap(); + assert_eq!(argv[idx + 1], "/tmp"); + } + + #[test] + fn network_off_produces_unshare_net() { + let plan = make_plan(Some(PlanOverrides { + network_mode: Some("off".to_string()), + ..Default::default() + })); + let state = make_effective_state(Some(EffectiveOverrides { + namespaces: Some(vec!["user".to_string(), "pid".to_string(), "net".to_string()]), + network_actual: Some("off".to_string()), + network_enforcement: Some("enforced".to_string()), + ..Default::default() + })); + let argv = build(&plan, &state); + assert!(argv.contains(&"--unshare-net".to_string())); + } + + #[test] + fn network_full_does_not_produce_unshare_net() { + let plan = make_plan(None); + let state = make_effective_state(None); + let argv = build(&plan, &state); + assert!(!argv.contains(&"--unshare-net".to_string())); + } + + #[test] + fn env_produces_clearenv_and_setenv() { + let mut env = HashMap::new(); + env.insert("HOME".to_string(), "/home/test".to_string()); + let plan = make_plan(Some(PlanOverrides { + env: Some(env), + ..Default::default() + })); + let state = make_effective_state(None); + let argv = build(&plan, &state); + assert!(argv.contains(&"--clearenv".to_string())); + let idx = argv.iter().position(|a| a == "--setenv").unwrap(); + assert_eq!(argv[idx + 1], "HOME"); + assert_eq!(argv[idx + 2], "/home/test"); + } + + #[test] + fn cwd_produces_chdir() { + let plan = make_plan(Some(PlanOverrides { + cwd: Some("/my/cwd".to_string()), + ..Default::default() + })); + let state = make_effective_state(None); + let argv = build(&plan, &state); + let idx = argv.iter().position(|a| a == "--chdir").unwrap(); + assert_eq!(argv[idx + 1], "/my/cwd"); + } + + #[test] + fn devices_always_present() { + let plan = make_plan(None); + let state = make_effective_state(None); + let argv = build(&plan, &state); + // Count --dev-bind occurrences + let dev_bind_count = argv.iter().filter(|a| a.as_str() == "--dev-bind").count(); + assert_eq!(dev_bind_count, 4); // null, zero, urandom, random + } + + #[test] + fn proc_always_present() { + let plan = make_plan(None); + let state = make_effective_state(None); + let argv = build(&plan, &state); + let idx = argv.iter().position(|a| a == "--proc").unwrap(); + assert_eq!(argv[idx + 1], "/proc"); + } + + #[test] + fn command_is_last_after_separator() { + let plan = make_plan(Some(PlanOverrides { + command: Some(vec!["npm".to_string(), "install".to_string()]), + ..Default::default() + })); + let state = make_effective_state(None); + let argv = build(&plan, &state); + let separator_idx = argv.iter().position(|a| a == "--").unwrap(); + assert_eq!(argv[separator_idx + 1], "npm"); + assert_eq!(argv[separator_idx + 2], "install"); + assert_eq!(separator_idx + 3, argv.len()); + } + + #[test] + fn user_namespace_is_not_in_argv() { + let plan = make_plan(None); + let state = make_effective_state(Some(EffectiveOverrides { + namespaces: Some(vec!["user".to_string(), "pid".to_string()]), + ..Default::default() + })); + let argv = build(&plan, &state); + assert!(!argv.contains(&"--unshare-user".to_string())); + assert!(argv.contains(&"--unshare-pid".to_string())); + } + + #[test] + fn pid_ipc_uts_cgroup_namespaces() { + let plan = make_plan(None); + let state = make_effective_state(Some(EffectiveOverrides { + namespaces: Some(vec![ + "pid".to_string(), + "ipc".to_string(), + "uts".to_string(), + "cgroup-try".to_string(), + ]), + ..Default::default() + })); + let argv = build(&plan, &state); + assert!(argv.contains(&"--unshare-pid".to_string())); + assert!(argv.contains(&"--unshare-ipc".to_string())); + assert!(argv.contains(&"--unshare-uts".to_string())); + assert!(argv.contains(&"--unshare-cgroup-try".to_string())); + } + + #[test] + fn file_mount_uses_ro_bind_or_bind() { + let plan = make_plan(Some(PlanOverrides { + mounts: Some(vec![ + Mount { + mount_type: "file".to_string(), + source: Some("/etc/resolv.conf".to_string()), + target: "/etc/resolv.conf".to_string(), + writable: false, + }, + ]), + ..Default::default() + })); + let state = make_effective_state(None); + let argv = build(&plan, &state); + let idx = argv.iter().position(|a| a == "--ro-bind").unwrap(); + assert_eq!(argv[idx + 1], "/etc/resolv.conf"); + assert_eq!(argv[idx + 2], "/etc/resolv.conf"); + } +} +``` + +- [ ] **Step 2: Register the module in main.rs** + +In `crates/pi-sandbox-runtime/src/main.rs`, add `mod plan_builder;` to the module declarations. The top should now be: + +```rust +mod bubblewrap; +mod contract; +mod observer; +mod plan_builder; +mod supervisor; +mod timestamps; +mod validator; +``` + +- [ ] **Step 3: Run Rust tests** + +Run: `cd crates/pi-sandbox-runtime && cargo test 2>&1` +Expected: All `plan_builder::tests::*` tests pass (12 tests). All `bubblewrap::tests::*` tests also pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/pi-sandbox-runtime/src/plan_builder.rs crates/pi-sandbox-runtime/src/main.rs +git commit -m "feat: add plan_builder module for bwrap argv construction" +``` + +--- + +### Task 4: Update validator.rs — namespace resolution and NAMESPACE_DEGRADED warnings + +**Files:** +- Modify: `crates/pi-sandbox-runtime/src/validator.rs` + +- [ ] **Step 1: Update the `validate` function signature to accept bwrap availability** + +In `crates/pi-sandbox-runtime/src/validator.rs`, change the imports and function signature. Replace the entire file with: + +```rust +use crate::bubblewrap::BwrapAvailability; +use crate::contract::{ + EffectiveNetwork, EffectiveState, PlanPayload, ValidationError, ValidationPayload, + ValidationWarning, PROTOCOL_VERSION, +}; + +/// Validate a PlanPayload and resolve effective state. +pub fn validate(plan: &PlanPayload, bwrap: &BwrapAvailability) -> ValidationPayload { + // 1. Version check — early return + if plan.version != PROTOCOL_VERSION { + return ValidationPayload { + ok: false, + errors: vec![ValidationError { + code: "VERSION_MISMATCH".to_string(), + message: format!( + "Protocol version mismatch: expected {PROTOCOL_VERSION}, got {}", + plan.version + ), + field: Some("payload.version".to_string()), + }], + warnings: vec![], + effective_state: None, + }; + } + + let mut errors: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + + // 2. Empty command check + if plan.command.is_empty() { + errors.push(ValidationError { + code: "MISSING_REQUIRED_FIELD".to_string(), + message: "command must not be empty".to_string(), + field: Some("payload.command".to_string()), + }); + } + + // 3. Writable mounts against allowedWritableTargets + for mount in &plan.manifest.mounts { + if mount.writable + && !plan + .policy + .allowed_writable_targets + .iter() + .any(|t| t == &mount.target) + { + errors.push(ValidationError { + code: "RW_TARGET_NOT_ALLOWED".to_string(), + message: format!( + "Writable mount target '{}' is not in allowedWritableTargets", + mount.target + ), + field: Some("payload.manifest.mounts".to_string()), + }); + } + } + + // 4. Denied commands check (only if command is non-empty) + if !plan.command.is_empty() { + if let Some(deny) = &plan.policy.deny_commands { + if deny.iter().any(|d| d == &plan.command[0]) { + errors.push(ValidationError { + code: "COMMAND_DENIED".to_string(), + message: format!("Command '{}' is denied by policy", plan.command[0]), + field: Some("payload.command".to_string()), + }); + } + } + } + + // 5. Resolve effective network + let effective_network = match plan.policy.network.mode.as_str() { + "off" => EffectiveNetwork { + requested: "off".to_string(), + actual: "off".to_string(), + enforcement: "enforced".to_string(), + degraded: false, + }, + "full" => EffectiveNetwork { + requested: "full".to_string(), + actual: "full".to_string(), + enforcement: "none".to_string(), + degraded: false, + }, + "allowlist" => EffectiveNetwork { + requested: "allowlist".to_string(), + actual: "full".to_string(), + enforcement: "observed".to_string(), + degraded: true, + }, + _ => EffectiveNetwork { + requested: plan.policy.network.mode.clone(), + actual: "full".to_string(), + enforcement: "none".to_string(), + degraded: false, + }, + }; + + // 6. Allowlist-degraded warning + if effective_network.degraded { + warnings.push(ValidationWarning { + code: "ALLOWLIST_NOT_ENFORCED".to_string(), + message: + "Network allowlist requested but cannot be enforced; running in observed mode" + .to_string(), + }); + } + + // 7. Resolve namespaces based on bwrap availability + let namespaces_applied = match bwrap { + BwrapAvailability::Available { .. } => { + // All requested namespaces can be applied (bwrap handles them) + plan.policy.namespaces.clone() + } + BwrapAvailability::Unavailable { .. } => { + // No namespaces can be applied — emit warnings + for ns in &plan.policy.namespaces { + warnings.push(ValidationWarning { + code: "NAMESPACE_DEGRADED".to_string(), + message: format!( + "Namespace '{}' requested but cannot be applied (bwrap unavailable)", + ns + ), + }); + } + vec![] + } + }; + + // 8. Resolve applied environment keys + let env_applied: Vec = if let Some(allowlist) = &plan.policy.env_allowlist { + plan.manifest + .env + .keys() + .filter(|k| allowlist.contains(k)) + .cloned() + .collect() + } else { + plan.manifest.env.keys().cloned().collect() + }; + + let effective_state = Some(EffectiveState { + network: effective_network, + namespaces_applied, + env_applied, + }); + + ValidationPayload { + ok: errors.is_empty(), + errors, + warnings, + effective_state, + } +} +``` + +- [ ] **Step 2: Update the `validate` call site in main.rs** + +In `crates/pi-sandbox-runtime/src/main.rs`, after the plan is extracted (around line 55), add bwrap detection and update the validate call. Replace from line 57 onward (starting at `// 4. Validate the plan.`): + +The full `main.rs` should now be: + +```rust +mod bubblewrap; +mod contract; +mod observer; +mod plan_builder; +mod supervisor; +mod timestamps; +mod validator; + +use std::io::{self, BufRead}; +use std::sync::mpsc; + +use contract::{ + emit, InboundMessage, ReconciliationHints, ResultEnvelope, ResultPayload, ValidationEnvelope, + ValidationError, ValidationPayload, +}; + +fn main() { + let stdin = io::stdin(); + let mut first_line = String::new(); + + // 1. Read exactly one line from stdin and parse it as an InboundMessage. + if stdin.lock().read_line(&mut first_line).is_err() { + eprintln!("pi-sandbox-runtime: failed to read from stdin"); + std::process::exit(1); + } + + let first_line = first_line.trim(); + + // 2. On parse error: emit PARSE_ERROR validation, exit. + let message: InboundMessage = match serde_json::from_str(first_line) { + Ok(m) => m, + Err(e) => { + emit(&ValidationEnvelope::new(ValidationPayload { + ok: false, + errors: vec![ValidationError { + code: "PARSE_ERROR".to_string(), + message: format!("Failed to parse inbound message: {e}"), + field: None, + }], + warnings: vec![], + effective_state: None, + })); + std::process::exit(0); + } + }; + + // 3. On Cancel before Plan: log to stderr, exit. + let plan = match message { + InboundMessage::Plan { payload } => payload, + InboundMessage::Cancel { payload } => { + eprintln!( + "pi-sandbox-runtime: received Cancel before Plan: reason={:?}", + payload.reason + ); + std::process::exit(0); + } + }; + + // 4. Detect Bubblewrap availability. + let bwrap = bubblewrap::detect(); + + // 5. Validate the plan. + let validation = validator::validate(&plan, &bwrap); + + // 6. Emit validation message. + emit(&ValidationEnvelope::new(validation.clone())); + + // 7. If validation failed: exit(0). + if !validation.ok { + std::process::exit(0); + } + + // 8. Clone effectiveState from validation (safe: ok == true guarantees it's Some). + let effective_state = validation + .effective_state + .expect("effectiveState must be Some when ok=true"); + + // 9. Spawn background thread to read remaining stdin lines for cancel signal. + let (cancel_tx, cancel_rx) = mpsc::channel::<()>(); + std::thread::spawn(move || { + let stdin = io::stdin(); + for line in stdin.lock().lines() { + let Ok(text) = line else { break }; + let text = text.trim().to_string(); + if text.is_empty() { + continue; + } + match serde_json::from_str::(&text) { + Ok(InboundMessage::Cancel { .. }) => { + let _ = cancel_tx.send(()); + break; + } + _ => { + // Ignore unknown messages during execution. + } + } + } + }); + + // 10. Run the supervisor. + let result = supervisor::supervise(&plan, &effective_state, cancel_rx, &bwrap); + + // 11. Emit final ResultEnvelope from supervision result. + emit(&ResultEnvelope::new(ResultPayload { + exit_code: result.exit_code, + signal: result.signal, + timed_out: result.timed_out, + duration_ms: result.duration_ms, + effective_network: result.effective_network, + observed_connections: result.observed_connections, + would_have_blocked: result.would_have_blocked, + resource_peaks: None, + reconciliation_hints: ReconciliationHints { + terminal_state: result.terminal_state, + workspace_modified: result.workspace_modified, + cleanup_succeeded: true, + }, + })); + + // 12. Exit. + std::process::exit(0); +} +``` + +- [ ] **Step 3: Verify compilation (will fail — supervisor signature not updated yet)** + +Run: `cd crates/pi-sandbox-runtime && cargo build --release 2>&1` +Expected: Compilation fails with error about `supervisor::supervise` not accepting `&BwrapAvailability`. This is expected — Task 5 fixes it. + +Note: If implementing tasks sequentially, proceed to Task 5 to fix the supervisor. If this task is being implemented standalone, you may stub the bwrap parameter temporarily to get compilation passing. + +- [ ] **Step 4: Commit (partial — validator and main updated, supervisor fix in next task)** + +```bash +git add crates/pi-sandbox-runtime/src/validator.rs crates/pi-sandbox-runtime/src/main.rs +git commit -m "feat: update validator for namespace resolution and NAMESPACE_DEGRADED warnings" +``` + +--- + +### Task 5: Update supervisor.rs — bwrap dispatch with direct-execution fallback + +**Files:** +- Modify: `crates/pi-sandbox-runtime/src/supervisor.rs` + +- [ ] **Step 1: Update supervisor to accept bwrap availability and dispatch accordingly** + +Replace the entire contents of `crates/pi-sandbox-runtime/src/supervisor.rs` with: + +```rust +use std::io::{BufRead, BufReader}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::sync::mpsc::Receiver; +use std::time::Instant; + +use crate::bubblewrap::BwrapAvailability; +use crate::contract::{ + emit, EffectiveNetwork, EffectiveState, LifecycleEnvelope, ObservedConnection, + StderrEnvelope, StdoutEnvelope, PlanPayload, +}; +use crate::observer::{compute_would_have_blocked, observe_connections}; +use crate::plan_builder; + +pub struct SupervisionResult { + pub exit_code: Option, + pub signal: Option, + pub timed_out: bool, + pub duration_ms: f64, + pub effective_network: EffectiveNetwork, + pub observed_connections: Vec, + pub would_have_blocked: Vec, + pub terminal_state: String, + pub workspace_modified: bool, +} + +/// Supervise execution of the plan's command. +pub fn supervise( + plan: &PlanPayload, + effective_state: &EffectiveState, + cancel_rx: Receiver<()>, + bwrap: &BwrapAvailability, +) -> SupervisionResult { + // Shared atomic sequence number across all output threads. + let seq = Arc::new(AtomicU64::new(0)); + + // Helper: fetch-and-increment sequence number. + let next_seq = |counter: &AtomicU64| counter.fetch_add(1, Ordering::SeqCst); + + // Emit lifecycle: started + emit(&LifecycleEnvelope::new( + next_seq(&seq), + "started".to_string(), + )); + + let start = Instant::now(); + + // Build the child process — either via bwrap or direct execution. + let mut cmd = match bwrap { + BwrapAvailability::Available { path } => { + let argv = plan_builder::build(plan, effective_state); + let mut c = Command::new(path); + c.args(&argv); + c + } + BwrapAvailability::Unavailable { .. } => { + let mut c = Command::new(&plan.command[0]); + if plan.command.len() > 1 { + c.args(&plan.command[1..]); + } + c.current_dir(&plan.manifest.cwd) + .envs(&plan.manifest.env); + c + } + }; + + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + // Emit an error lifecycle and return immediately. + let seq_val = next_seq(&seq); + emit(&LifecycleEnvelope::new( + seq_val, + format!("spawn_failed: {e}"), + )); + let duration_ms = start.elapsed().as_secs_f64() * 1000.0; + return SupervisionResult { + exit_code: None, + signal: None, + timed_out: false, + duration_ms, + effective_network: effective_state.network.clone(), + observed_connections: vec![], + would_have_blocked: vec![], + terminal_state: "supervisor_crash".to_string(), + workspace_modified: false, + }; + } + }; + + // Take ownership of stdout/stderr pipes. + let child_stdout = child.stdout.take().expect("stdout was piped"); + let child_stderr = child.stderr.take().expect("stderr was piped"); + + // Spawn stdout reader thread. + let seq_stdout = Arc::clone(&seq); + let stdout_thread = std::thread::spawn(move || { + let reader = BufReader::new(child_stdout); + for line in reader.lines() { + match line { + Ok(text) => { + let s = seq_stdout.fetch_add(1, Ordering::SeqCst); + emit(&StdoutEnvelope::new(s, text)); + } + Err(_) => break, + } + } + }); + + // Spawn stderr reader thread. + let seq_stderr = Arc::clone(&seq); + let stderr_thread = std::thread::spawn(move || { + let reader = BufReader::new(child_stderr); + for line in reader.lines() { + match line { + Ok(text) => { + let s = seq_stderr.fetch_add(1, Ordering::SeqCst); + emit(&StderrEnvelope::new(s, text)); + } + Err(_) => break, + } + } + }); + + // Poll the cancel channel while the child is running. + let mut cancelled = false; + let exit_status = loop { + // Check for cancel signal (non-blocking). + if cancel_rx.try_recv().is_ok() { + cancelled = true; + let s = seq.fetch_add(1, Ordering::SeqCst); + emit(&LifecycleEnvelope::new(s, "cancel_requested".to_string())); + + // Kill the process. + #[cfg(unix)] + { + let pid = child.id(); + unsafe { + libc::kill(-(pid as libc::pid_t), libc::SIGTERM); + } + } + #[cfg(not(unix))] + { + let _ = child.kill(); + } + + let s2 = seq.fetch_add(1, Ordering::SeqCst); + emit(&LifecycleEnvelope::new(s2, "killing".to_string())); + + // Wait for the child to exit after signaling. + break child.wait().ok(); + } + + // Check if the child has already exited (non-blocking). + match child.try_wait() { + Ok(Some(status)) => break Some(status), + Ok(None) => { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(_) => break None, + } + }; + + // Wait for I/O threads to finish draining output. + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + + let duration_ms = start.elapsed().as_secs_f64() * 1000.0; + + // Parse exit code / signal. + let (exit_code, signal) = match exit_status { + Some(status) => { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(sig) = status.signal() { + (None, Some(format!("SIG{sig}"))) + } else { + (status.code(), None) + } + } + #[cfg(not(unix))] + { + (status.code(), None) + } + } + None => (None, None), + }; + + // Determine terminal state. + let terminal_state = if cancelled { + "killed_on_cancel".to_string() + } else if signal.is_some() { + "killed_on_timeout".to_string() + } else { + "clean_exit".to_string() + }; + + // Emit lifecycle: exited + let s = seq.fetch_add(1, Ordering::SeqCst); + emit(&LifecycleEnvelope::new(s, "exited".to_string())); + + // Network observation (stub for now — Phase 10 replaces this). + let observed = observe_connections(); + let would_have_blocked = + compute_would_have_blocked(&observed, &plan.policy.network.allowlist); + + SupervisionResult { + exit_code, + signal, + timed_out: false, + duration_ms, + effective_network: effective_state.network.clone(), + observed_connections: observed, + would_have_blocked, + terminal_state, + workspace_modified: false, + } +} +``` + +- [ ] **Step 2: Verify compilation** + +Run: `cd crates/pi-sandbox-runtime && cargo build --release 2>&1` +Expected: Builds successfully. + +- [ ] **Step 3: Run all Rust tests** + +Run: `cd crates/pi-sandbox-runtime && cargo test 2>&1` +Expected: All plan_builder and bubblewrap tests pass. + +- [ ] **Step 4: Run protocol tests** + +Run: `cd tests/protocol && npx vitest run 2>&1` +Expected: All 6 tests pass (7 individual tests). On macOS, supervisor falls back to direct execution (same behavior as before). The validation messages now include `namespacesApplied: []` and `envApplied: [...]` but existing tests don't assert these fields negatively. + +- [ ] **Step 5: Commit** + +```bash +git add crates/pi-sandbox-runtime/src/supervisor.rs +git commit -m "feat: update supervisor for bwrap dispatch with direct-execution fallback" +``` + +--- + +### Task 6: Add bwrap-integration protocol test (Linux-only) + +**Files:** +- Create: `tests/protocol/bwrap-integration.test.ts` + +- [ ] **Step 1: Write the bwrap integration test** + +Create `tests/protocol/bwrap-integration.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { makePlan, spawnRuntime } from "./helpers.js"; + +describe("Protocol Test 7: Bwrap Integration (Linux only)", () => { + const isLinux = process.platform === "linux"; + + it.skipIf(!isLinux)("runs command via bwrap with namespaces applied", async () => { + const rt = spawnRuntime(); + + rt.send( + makePlan({ + command: ["echo", "bwrap-test"], + manifest: { + mounts: [ + { type: "tmpfs", target: "/tmp", writable: true }, + ], + env: { HOME: "/home/sandbox", PATH: "/usr/bin:/bin" }, + cwd: "/tmp", + }, + policy: { + namespaces: ["user", "pid", "ipc"], + network: { mode: "full" }, + allowedWritableTargets: ["/workspace", "/tmp"], + strictWritePolicy: false, + }, + }), + ); + + const events = await rt.readAllEvents(); + + // Validation must succeed + const validation = events[0]; + expect(validation.type).toBe("validation"); + const validationPayload = validation.payload as any; + expect(validationPayload.ok).toBe(true); + + // namespacesApplied must include the requested namespaces (bwrap available) + expect(validationPayload.effectiveState.namespacesApplied).toContain("user"); + expect(validationPayload.effectiveState.namespacesApplied).toContain("pid"); + expect(validationPayload.effectiveState.namespacesApplied).toContain("ipc"); + + // envApplied must include the env keys + expect(validationPayload.effectiveState.envApplied).toContain("HOME"); + expect(validationPayload.effectiveState.envApplied).toContain("PATH"); + + // Execution must succeed + const result = events[events.length - 1]; + expect(result.type).toBe("result"); + const resultPayload = result.payload as any; + expect(resultPayload.exitCode).toBe(0); + expect(resultPayload.reconciliationHints.terminalState).toBe("clean_exit"); + + // Find stdout with "bwrap-test" + const stdoutEvents = events.filter((e) => e.type === "stdout"); + const bwrapOutput = stdoutEvents.find( + (e) => ((e.payload as any).data as string).includes("bwrap-test"), + ); + expect(bwrapOutput).toBeDefined(); + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }); + + it.skipIf(isLinux)("falls back to direct execution on non-Linux with NAMESPACE_DEGRADED warnings", async () => { + const rt = spawnRuntime(); + + rt.send( + makePlan({ + command: ["echo", "fallback-test"], + manifest: { + mounts: [ + { type: "tmpfs", target: "/tmp", writable: true }, + ], + env: { HOME: "/home/sandbox", PATH: "/usr/bin:/bin" }, + cwd: "/tmp", + }, + policy: { + namespaces: ["user", "pid"], + network: { mode: "full" }, + allowedWritableTargets: ["/workspace", "/tmp"], + strictWritePolicy: false, + }, + }), + ); + + const events = await rt.readAllEvents(); + + const validation = events[0]; + expect(validation.type).toBe("validation"); + const validationPayload = validation.payload as any; + expect(validationPayload.ok).toBe(true); + + // namespacesApplied must be empty (no bwrap) + expect(validationPayload.effectiveState.namespacesApplied).toEqual([]); + + // Must have NAMESPACE_DEGRADED warnings + const nsWarnings = (validationPayload.warnings as any[]).filter( + (w: any) => w.code === "NAMESPACE_DEGRADED", + ); + expect(nsWarnings.length).toBe(2); // one for "user", one for "pid" + + // envApplied must still be populated + expect(validationPayload.effectiveState.envApplied).toContain("HOME"); + expect(validationPayload.effectiveState.envApplied).toContain("PATH"); + + // Execution must still succeed (direct execution fallback) + const result = events[events.length - 1]; + expect(result.type).toBe("result"); + expect((result.payload as any).exitCode).toBe(0); + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run protocol tests** + +Run: `cd tests/protocol && npx vitest run 2>&1` +Expected: On macOS, the Linux test is skipped, the fallback test passes. All original tests still pass. + +- [ ] **Step 3: Commit** + +```bash +git add tests/protocol/bwrap-integration.test.ts +git commit -m "test: add bwrap integration protocol test with macOS fallback" +``` + +--- + +## Phase 9: Real Build Flows (Integration Tests) + +### Task 7: Create integration test scaffolding + +**Files:** +- Create: `tests/integration/package.json` +- Create: `tests/integration/tsconfig.json` +- Create: `tests/integration/vitest.config.ts` +- Create: `tests/integration/globalSetup.ts` + +- [ ] **Step 1: Create package.json** + +Create `tests/integration/package.json`: + +```json +{ + "name": "@pi-sandbox/integration-tests", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "test:network": "RUN_NETWORK_TESTS=1 vitest run network-smoke.test.ts" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +Create `tests/integration/tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": ".", + "declaration": false + }, + "include": ["*.ts", "**/*.ts"], + "exclude": ["node_modules", "dist", "fixtures"] +} +``` + +- [ ] **Step 3: Create vitest.config.ts** + +Create `tests/integration/vitest.config.ts`: + +```typescript +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["*.test.ts"], + globalSetup: "./globalSetup.ts", + testTimeout: 120000, // 2 minutes — build commands can be slow + }, +}); +``` + +- [ ] **Step 4: Create globalSetup.ts** + +Create `tests/integration/globalSetup.ts`: + +```typescript +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const CRATE_DIR = resolve(import.meta.dirname, "../../crates/pi-sandbox-runtime"); + +export async function setup() { + console.log("Building pi-sandbox-runtime for integration tests..."); + execFileSync("cargo", ["build", "--release"], { + cwd: CRATE_DIR, + stdio: "inherit", + }); + + const binaryPath = resolve(CRATE_DIR, "target/release/pi-sandbox-runtime"); + if (!existsSync(binaryPath)) { + throw new Error(`Binary not found at ${binaryPath}`); + } + + process.env.RUNTIME_BINARY_PATH = binaryPath; + console.log(`Runtime binary: ${binaryPath}`); +} +``` + +- [ ] **Step 5: Install dependencies** + +Run: `cd tests/integration && npm install 2>&1` +Expected: `node_modules/` created with vitest and typescript. + +- [ ] **Step 6: Commit** + +```bash +git add tests/integration/package.json tests/integration/tsconfig.json tests/integration/vitest.config.ts tests/integration/globalSetup.ts +git commit -m "feat: scaffold integration test infrastructure" +``` + +--- + +### Task 8: Create integration test helpers and fixture repos + +**Files:** +- Create: `tests/integration/helpers.ts` +- Create: `tests/integration/fixtures/tiny-npm/package.json` +- Create: `tests/integration/fixtures/tiny-python/setup.py` +- Create: `tests/integration/fixtures/tiny-python/mypackage/__init__.py` +- Create: `tests/integration/fixtures/tiny-rust/Cargo.toml` +- Create: `tests/integration/fixtures/tiny-rust/src/main.rs` + +- [ ] **Step 1: Create integration test helpers** + +Create `tests/integration/helpers.ts`: + +```typescript +import { spawn, type ChildProcess } from "node:child_process"; +import { createInterface } from "node:readline"; +import { mkdtempSync, cpSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +// --------------------------------------------------------------------------- +// TestRuntime — identical to protocol test helpers +// --------------------------------------------------------------------------- + +export interface TestRuntime { + send(message: Record): void; + readline(): Promise>; + readAllEvents(): Promise[]>; + kill(signal?: NodeJS.Signals): void; + waitForExit(): Promise<{ code: number | null; signal: string | null }>; + stderr: string; + process: ChildProcess; +} + +export function spawnRuntime(): TestRuntime { + const binaryPath = process.env.RUNTIME_BINARY_PATH; + if (!binaryPath) { + throw new Error("RUNTIME_BINARY_PATH not set. Did globalSetup run?"); + } + + const child = spawn(binaryPath, [], { + stdio: ["pipe", "pipe", "pipe"], + }); + + const rl = createInterface({ input: child.stdout! }); + const lineQueue: string[] = []; + let lineResolve: ((line: string) => void) | null = null; + let closed = false; + + rl.on("line", (line) => { + if (lineResolve) { + const resolve = lineResolve; + lineResolve = null; + resolve(line); + } else { + lineQueue.push(line); + } + }); + + rl.on("close", () => { + closed = true; + if (lineResolve) { + const resolve = lineResolve; + lineResolve = null; + resolve(""); + } + }); + + let stderrBuf = ""; + child.stderr!.on("data", (chunk: Buffer) => { + stderrBuf += chunk.toString(); + }); + + function nextLine(): Promise { + if (lineQueue.length > 0) { + return Promise.resolve(lineQueue.shift()!); + } + if (closed) { + return Promise.reject(new Error("stdout closed before line received")); + } + return new Promise((resolve) => { + lineResolve = resolve; + }); + } + + const runtime: TestRuntime = { + send(message: Record): void { + child.stdin!.write(JSON.stringify(message) + "\n"); + }, + + async readline(): Promise> { + const line = await nextLine(); + return JSON.parse(line) as Record; + }, + + async readAllEvents(): Promise[]> { + const events: Record[] = []; + while (true) { + let line: string; + try { + line = await nextLine(); + } catch { + break; + } + const parsed = JSON.parse(line) as Record; + events.push(parsed); + if (parsed.type === "result") { + break; + } + } + return events; + }, + + kill(signal: NodeJS.Signals = "SIGTERM"): void { + child.kill(signal); + }, + + waitForExit(): Promise<{ code: number | null; signal: string | null }> { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve({ code: child.exitCode, signal: child.signalCode }); + return; + } + child.on("exit", (code, signal) => { + resolve({ code, signal }); + }); + }); + }, + + get stderr(): string { + return stderrBuf; + }, + + process: child, + }; + + return runtime; +} + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +const FIXTURES_DIR = resolve(import.meta.dirname, "fixtures"); + +export interface FixtureWorkspace { + /** Absolute path to the temp workspace directory (contains the fixture files) */ + workspaceDir: string; + /** Clean up the temp directory */ + cleanup: () => void; +} + +/** + * Copy a fixture into a fresh temp directory. + * Returns the temp dir path and a cleanup function. + */ +export function copyFixture(fixtureName: string): FixtureWorkspace { + const fixtureDir = join(FIXTURES_DIR, fixtureName); + const tempDir = mkdtempSync(join(tmpdir(), `pi-sandbox-integ-${fixtureName}-`)); + cpSync(fixtureDir, tempDir, { recursive: true }); + return { + workspaceDir: tempDir, + cleanup: () => rmSync(tempDir, { recursive: true, force: true }), + }; +} + +/** + * Build a plan message for integration tests. + * + * Sets up: + * - workspace mounted writable at the temp dir + * - /tmp as tmpfs + * - build-install profile defaults + * - inherits current process PATH so commands (npm, pip, cargo) are found + */ +export function makeIntegrationPlan(opts: { + workspaceDir: string; + command: string[]; + networkMode?: string; +}): Record { + const currentPath = process.env.PATH ?? "/usr/bin:/bin"; + return { + type: "plan", + payload: { + version: 1, + sessionId: "integ-session-001", + executionId: "integ-exec-001", + requestedProfile: "build-install", + runtimeBaseName: "host-derived", + manifest: { + mounts: [ + { + type: "directory", + source: opts.workspaceDir, + target: opts.workspaceDir, + writable: true, + }, + { + type: "tmpfs", + target: "/tmp", + writable: true, + }, + ], + env: { + HOME: opts.workspaceDir, + PATH: currentPath, + }, + cwd: opts.workspaceDir, + }, + policy: { + namespaces: ["user", "pid"], + network: { + mode: opts.networkMode ?? "full", + }, + allowedWritableTargets: [opts.workspaceDir, "/tmp"], + strictWritePolicy: false, + envAllowlist: ["HOME", "PATH"], + denyCommands: [], + }, + command: opts.command, + }, + }; +} +``` + +- [ ] **Step 2: Create tiny-npm fixture** + +Create `tests/integration/fixtures/tiny-npm/package.json`: + +```json +{ + "name": "tiny-npm-fixture", + "version": "1.0.0", + "private": true, + "dependencies": {} +} +``` + +- [ ] **Step 3: Create tiny-python fixture** + +Create `tests/integration/fixtures/tiny-python/setup.py`: + +```python +from setuptools import setup + +setup( + name="tiny-python-fixture", + version="1.0.0", + packages=["mypackage"], +) +``` + +Create `tests/integration/fixtures/tiny-python/mypackage/__init__.py`: + +```python +"""Tiny fixture package.""" +``` + +- [ ] **Step 4: Create tiny-rust fixture** + +Create `tests/integration/fixtures/tiny-rust/Cargo.toml`: + +```toml +[package] +name = "tiny-rust-fixture" +version = "0.1.0" +edition = "2021" +``` + +Create `tests/integration/fixtures/tiny-rust/src/main.rs`: + +```rust +fn main() { + println!("built"); +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add tests/integration/helpers.ts tests/integration/fixtures/ +git commit -m "feat: add integration test helpers and fixture repos" +``` + +--- + +### Task 9: Write build-npm integration test + +**Files:** +- Create: `tests/integration/build-npm.test.ts` + +- [ ] **Step 1: Write the test** + +Create `tests/integration/build-npm.test.ts`: + +```typescript +import { describe, expect, it, afterEach } from "vitest"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { + spawnRuntime, + copyFixture, + makeIntegrationPlan, + type FixtureWorkspace, +} from "./helpers.js"; + +describe("Integration: npm install", () => { + let fixture: FixtureWorkspace | null = null; + + afterEach(() => { + fixture?.cleanup(); + fixture = null; + }); + + it("runs npm install on tiny-npm fixture and exits cleanly", async () => { + fixture = copyFixture("tiny-npm"); + const rt = spawnRuntime(); + + rt.send( + makeIntegrationPlan({ + workspaceDir: fixture.workspaceDir, + command: ["npm", "install"], + }), + ); + + const events = await rt.readAllEvents(); + + // Validation must pass + const validation = events[0]; + expect(validation.type).toBe("validation"); + expect((validation.payload as any).ok).toBe(true); + + // Result must be clean exit + const result = events[events.length - 1]; + expect(result.type).toBe("result"); + const resultPayload = result.payload as any; + expect(resultPayload.exitCode).toBe(0); + expect(resultPayload.reconciliationHints.terminalState).toBe("clean_exit"); + + // npm install should have created a package-lock.json + expect( + existsSync(join(fixture.workspaceDir, "package-lock.json")), + ).toBe(true); + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run the test** + +Run: `cd tests/integration && npx vitest run build-npm.test.ts 2>&1` +Expected: PASS — npm install on an empty project completes quickly with exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add tests/integration/build-npm.test.ts +git commit -m "test: add npm install integration test" +``` + +--- + +### Task 10: Write build-python integration test + +**Files:** +- Create: `tests/integration/build-python.test.ts` + +- [ ] **Step 1: Write the test** + +Create `tests/integration/build-python.test.ts`: + +```typescript +import { describe, expect, it, afterEach } from "vitest"; +import { execFileSync } from "node:child_process"; +import { + spawnRuntime, + copyFixture, + makeIntegrationPlan, + type FixtureWorkspace, +} from "./helpers.js"; + +// Check if python3 and pip are available +function hasPython(): boolean { + try { + execFileSync("python3", ["--version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +describe("Integration: pip install", () => { + let fixture: FixtureWorkspace | null = null; + + afterEach(() => { + fixture?.cleanup(); + fixture = null; + }); + + it.skipIf(!hasPython())( + "runs pip install -e . on tiny-python fixture and exits cleanly", + async () => { + fixture = copyFixture("tiny-python"); + const rt = spawnRuntime(); + + rt.send( + makeIntegrationPlan({ + workspaceDir: fixture.workspaceDir, + command: ["pip", "install", "-e", ".", "--break-system-packages"], + }), + ); + + const events = await rt.readAllEvents(); + + // Validation must pass + const validation = events[0]; + expect(validation.type).toBe("validation"); + expect((validation.payload as any).ok).toBe(true); + + // Result must be clean exit + const result = events[events.length - 1]; + expect(result.type).toBe("result"); + const resultPayload = result.payload as any; + expect(resultPayload.exitCode).toBe(0); + expect(resultPayload.reconciliationHints.terminalState).toBe( + "clean_exit", + ); + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }, + ); +}); +``` + +- [ ] **Step 2: Run the test** + +Run: `cd tests/integration && npx vitest run build-python.test.ts 2>&1` +Expected: PASS if python3/pip are available, SKIP if not. + +- [ ] **Step 3: Commit** + +```bash +git add tests/integration/build-python.test.ts +git commit -m "test: add pip install integration test" +``` + +--- + +### Task 11: Write build-rust integration test + +**Files:** +- Create: `tests/integration/build-rust.test.ts` + +- [ ] **Step 1: Write the test** + +Create `tests/integration/build-rust.test.ts`: + +```typescript +import { describe, expect, it, afterEach } from "vitest"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { + spawnRuntime, + copyFixture, + makeIntegrationPlan, + type FixtureWorkspace, +} from "./helpers.js"; + +describe("Integration: cargo build", () => { + let fixture: FixtureWorkspace | null = null; + + afterEach(() => { + fixture?.cleanup(); + fixture = null; + }); + + it("runs cargo build on tiny-rust fixture and exits cleanly", async () => { + fixture = copyFixture("tiny-rust"); + const rt = spawnRuntime(); + + rt.send( + makeIntegrationPlan({ + workspaceDir: fixture.workspaceDir, + command: ["cargo", "build"], + }), + ); + + const events = await rt.readAllEvents(); + + // Validation must pass + const validation = events[0]; + expect(validation.type).toBe("validation"); + expect((validation.payload as any).ok).toBe(true); + + // Result must be clean exit + const result = events[events.length - 1]; + expect(result.type).toBe("result"); + const resultPayload = result.payload as any; + expect(resultPayload.exitCode).toBe(0); + expect(resultPayload.reconciliationHints.terminalState).toBe("clean_exit"); + + // cargo build should have created target/ directory + expect(existsSync(join(fixture.workspaceDir, "target"))).toBe(true); + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run the test** + +Run: `cd tests/integration && npx vitest run build-rust.test.ts 2>&1` +Expected: PASS — cargo builds a tiny no-dep crate successfully. + +- [ ] **Step 3: Commit** + +```bash +git add tests/integration/build-rust.test.ts +git commit -m "test: add cargo build integration test" +``` + +--- + +### Task 12: Write network smoke test (optional, skipped by default) + +**Files:** +- Create: `tests/integration/network-smoke.test.ts` + +- [ ] **Step 1: Write the test** + +Create `tests/integration/network-smoke.test.ts`: + +```typescript +import { describe, expect, it, afterEach } from "vitest"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { spawnRuntime, makeIntegrationPlan } from "./helpers.js"; + +const RUN_NETWORK_TESTS = process.env.RUN_NETWORK_TESTS === "1"; + +describe("Integration: Network Smoke Tests", () => { + let tempDir: string | null = null; + + afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it.skipIf(!RUN_NETWORK_TESTS)( + "npm install with real network fetches a dependency", + async () => { + // Create a temp workspace with a real dependency + tempDir = mkdtempSync(join(tmpdir(), "pi-sandbox-network-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ + name: "network-smoke-test", + version: "1.0.0", + private: true, + dependencies: { + "is-odd": "3.0.1", + }, + }), + ); + + const rt = spawnRuntime(); + + rt.send( + makeIntegrationPlan({ + workspaceDir: tempDir, + command: ["npm", "install"], + networkMode: "full", + }), + ); + + const events = await rt.readAllEvents(); + + // Validation must pass + const validation = events[0]; + expect(validation.type).toBe("validation"); + expect((validation.payload as any).ok).toBe(true); + + // Result must be clean exit + const result = events[events.length - 1]; + expect(result.type).toBe("result"); + const resultPayload = result.payload as any; + expect(resultPayload.exitCode).toBe(0); + expect(resultPayload.reconciliationHints.terminalState).toBe( + "clean_exit", + ); + + // Dependency must have been installed + expect(existsSync(join(tempDir, "node_modules", "is-odd"))).toBe(true); + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }, + ); +}); +``` + +- [ ] **Step 2: Run the test (should skip)** + +Run: `cd tests/integration && npx vitest run network-smoke.test.ts 2>&1` +Expected: Test is skipped (RUN_NETWORK_TESTS not set). + +- [ ] **Step 3: Commit** + +```bash +git add tests/integration/network-smoke.test.ts +git commit -m "test: add optional network smoke test (skipped by default)" +``` + +--- + +### Task 13: Run full integration test suite + +**Files:** None (validation only) + +- [ ] **Step 1: Run all integration tests** + +Run: `cd tests/integration && npx vitest run 2>&1` +Expected: build-npm passes, build-python passes (or skips if no python), build-rust passes, network-smoke skips. + +- [ ] **Step 2: Run protocol tests to confirm no regressions** + +Run: `cd tests/protocol && npx vitest run 2>&1` +Expected: All 8 tests pass (6 original + 2 new from bwrap-integration on macOS where one runs and one skips). + +- [ ] **Step 3: Commit (no files — this is a verification step)** + +No commit needed. If tests fail, fix the issue in the relevant file and re-run. + +--- + +## Phase 10: Network Observation + +### Task 14: Replace observer.rs stub with NetworkObserver and `/proc/net/tcp` parser + +**Files:** +- Modify: `crates/pi-sandbox-runtime/src/observer.rs` + +- [ ] **Step 1: Rewrite observer.rs with the NetworkObserver implementation** + +Replace the entire contents of `crates/pi-sandbox-runtime/src/observer.rs` with: + +```rust +use std::collections::HashSet; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use crate::contract::{emit, BlockedConnection, NetworkEnvelope, ObservedConnection}; + +/// Background network observer that polls /proc/net/tcp for outbound connections. +/// +/// On Linux: polls at ~500ms intervals, deduplicates, emits network events. +/// On non-Linux: no-op (returns empty results immediately). +pub struct NetworkObserver { + handle: Option>>, + stop_flag: Arc, +} + +impl NetworkObserver { + /// Start the observer. On Linux, spawns a polling thread. + /// On non-Linux, returns a no-op observer. + pub fn start(seq: Arc) -> Self { + let stop_flag = Arc::new(AtomicBool::new(false)); + + #[cfg(target_os = "linux")] + { + let flag = Arc::clone(&stop_flag); + let handle = thread::spawn(move || poll_loop(flag, seq)); + NetworkObserver { + handle: Some(handle), + stop_flag, + } + } + + #[cfg(not(target_os = "linux"))] + { + let _ = seq; // unused on non-Linux + NetworkObserver { + handle: None, + stop_flag, + } + } + } + + /// Stop the observer and return all observed connections. + pub fn stop(self) -> Vec { + self.stop_flag.store(true, Ordering::Relaxed); + match self.handle { + Some(h) => h.join().unwrap_or_default(), + None => vec![], + } + } +} + +/// The polling loop (Linux only). +#[cfg(target_os = "linux")] +fn poll_loop(stop_flag: Arc, seq: Arc) -> Vec { + let mut seen: HashSet<(String, u16)> = HashSet::new(); + let mut results: Vec = Vec::new(); + + loop { + if stop_flag.load(Ordering::Relaxed) { + break; + } + + if let Ok(connections) = parse_proc_net_tcp("/proc/net/tcp") { + for conn in connections { + if seen.insert((conn.host.clone(), conn.port)) { + let s = seq.fetch_add(1, Ordering::SeqCst); + emit(&NetworkEnvelope::new( + s, + "outbound".to_string(), + conn.host.clone(), + conn.port, + Some("tcp".to_string()), + )); + results.push(conn); + } + } + } + + thread::sleep(Duration::from_millis(500)); + } + + results +} + +/// Parse /proc/net/tcp and return outbound established connections. +/// +/// Each line after the header has format: +/// sl local_address rem_address st ... +/// 0: 0100007F:1F90 0100007F:C000 01 ... +/// +/// We extract rem_address (field 2), filter to state 01 (ESTABLISHED), +/// and exclude loopback (127.x.x.x) and unspecified (0.0.0.0). +#[cfg(target_os = "linux")] +fn parse_proc_net_tcp(path: &str) -> std::io::Result> { + use std::io::{BufRead, BufReader}; + use std::fs::File; + + let file = File::open(path)?; + let reader = BufReader::new(file); + let mut connections = Vec::new(); + + for (i, line) in reader.lines().enumerate() { + let line = line?; + // Skip header (line 0) + if i == 0 { + continue; + } + + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() < 4 { + continue; + } + + // State must be 01 (ESTABLISHED) + let state = fields[3]; + if state != "01" { + continue; + } + + // Parse remote address (field 2): "HEXIP:HEXPORT" + let rem_addr = fields[2]; + let parts: Vec<&str> = rem_addr.split(':').collect(); + if parts.len() != 2 { + continue; + } + + let ip_hex = parts[0]; + let port_hex = parts[1]; + + // Parse IP (little-endian on x86) + let ip_u32 = match u32::from_str_radix(ip_hex, 16) { + Ok(v) => v, + Err(_) => continue, + }; + + let a = (ip_u32 & 0xFF) as u8; + let b = ((ip_u32 >> 8) & 0xFF) as u8; + let c = ((ip_u32 >> 16) & 0xFF) as u8; + let d = ((ip_u32 >> 24) & 0xFF) as u8; + + // Filter loopback and unspecified + if a == 127 || (a == 0 && b == 0 && c == 0 && d == 0) { + continue; + } + + let host = format!("{a}.{b}.{c}.{d}"); + + let port = match u16::from_str_radix(port_hex, 16) { + Ok(v) => v, + Err(_) => continue, + }; + + connections.push(ObservedConnection { + direction: "outbound".to_string(), + host, + port, + protocol: Some("tcp".to_string()), + }); + } + + Ok(connections) +} + +/// Compute which observed connections would have been blocked under the given allowlist. +/// +/// The allowlist contains entries in "host:port" format. +/// A connection is "would-have-blocked" if it is not matched by any allowlist entry. +pub fn compute_would_have_blocked( + observed: &[ObservedConnection], + allowlist: &Option>, +) -> Vec { + let Some(list) = allowlist else { + return vec![]; + }; + + observed + .iter() + .filter(|conn| { + let entry = format!("{}:{}", conn.host, conn.port); + !list.iter().any(|allowed| allowed == &entry) + }) + .map(|conn| BlockedConnection { + direction: conn.direction.clone(), + host: conn.host.clone(), + port: conn.port, + protocol: conn.protocol.clone(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compute_would_have_blocked_with_no_allowlist() { + let observed = vec![ObservedConnection { + direction: "outbound".to_string(), + host: "1.2.3.4".to_string(), + port: 443, + protocol: Some("tcp".to_string()), + }]; + let blocked = compute_would_have_blocked(&observed, &None); + assert!(blocked.is_empty()); + } + + #[test] + fn compute_would_have_blocked_with_matching_allowlist() { + let observed = vec![ObservedConnection { + direction: "outbound".to_string(), + host: "1.2.3.4".to_string(), + port: 443, + protocol: Some("tcp".to_string()), + }]; + let allowlist = Some(vec!["1.2.3.4:443".to_string()]); + let blocked = compute_would_have_blocked(&observed, &allowlist); + assert!(blocked.is_empty()); + } + + #[test] + fn compute_would_have_blocked_with_non_matching_allowlist() { + let observed = vec![ObservedConnection { + direction: "outbound".to_string(), + host: "1.2.3.4".to_string(), + port: 443, + protocol: Some("tcp".to_string()), + }]; + let allowlist = Some(vec!["5.6.7.8:443".to_string()]); + let blocked = compute_would_have_blocked(&observed, &allowlist); + assert_eq!(blocked.len(), 1); + assert_eq!(blocked[0].host, "1.2.3.4"); + assert_eq!(blocked[0].port, 443); + } + + #[test] + #[cfg(target_os = "linux")] + fn parse_proc_net_tcp_on_linux() { + // This test reads the actual /proc/net/tcp + let result = parse_proc_net_tcp("/proc/net/tcp"); + assert!(result.is_ok()); + // We can't assert specific connections, just that parsing doesn't panic + } + + #[test] + fn network_observer_noop_on_stop() { + // Start and immediately stop — should return empty on all platforms + let seq = Arc::new(AtomicU64::new(0)); + let observer = NetworkObserver::start(seq); + let connections = observer.stop(); + // On non-Linux, always empty. On Linux, may or may not be empty depending on timing. + // Just assert it doesn't panic. + let _ = connections; + } +} +``` + +- [ ] **Step 2: Verify compilation** + +Run: `cd crates/pi-sandbox-runtime && cargo build --release 2>&1` +Expected: Builds successfully. + +- [ ] **Step 3: Run Rust tests** + +Run: `cd crates/pi-sandbox-runtime && cargo test 2>&1` +Expected: All observer tests pass. On macOS, the Linux-specific `parse_proc_net_tcp_on_linux` test is compiled out. + +- [ ] **Step 4: Commit** + +```bash +git add crates/pi-sandbox-runtime/src/observer.rs +git commit -m "feat: replace observer stub with NetworkObserver and /proc/net/tcp parser" +``` + +--- + +### Task 15: Wire NetworkObserver into supervisor.rs + +**Files:** +- Modify: `crates/pi-sandbox-runtime/src/supervisor.rs` + +- [ ] **Step 1: Replace the stub `observe_connections()` call with `NetworkObserver`** + +In `crates/pi-sandbox-runtime/src/supervisor.rs`, make these changes: + +Replace the import line: + +```rust +use crate::observer::{compute_would_have_blocked, observe_connections}; +``` + +With: + +```rust +use crate::observer::{compute_would_have_blocked, NetworkObserver}; +``` + +Then, after the child process is spawned (after the `let mut child = match cmd.spawn()` block succeeds), add the observer start. Find the line: + +```rust + // Take ownership of stdout/stderr pipes. + let child_stdout = child.stdout.take().expect("stdout was piped"); +``` + +Insert before it: + +```rust + // Start network observer (Linux: polls /proc/net/tcp; non-Linux: no-op). + let observer = NetworkObserver::start(Arc::clone(&seq)); + +``` + +Then, replace the network observation block near the end (after the `// Emit lifecycle: exited` block). Find: + +```rust + // Network observation (stub for now — Phase 10 replaces this). + let observed = observe_connections(); + let would_have_blocked = + compute_would_have_blocked(&observed, &plan.policy.network.allowlist); +``` + +Replace with: + +```rust + // Stop observer and collect observed connections. + let observed = observer.stop(); + let would_have_blocked = + compute_would_have_blocked(&observed, &plan.policy.network.allowlist); +``` + +- [ ] **Step 2: Verify compilation** + +Run: `cd crates/pi-sandbox-runtime && cargo build --release 2>&1` +Expected: Builds successfully. + +- [ ] **Step 3: Run all Rust tests** + +Run: `cd crates/pi-sandbox-runtime && cargo test 2>&1` +Expected: All pass. + +- [ ] **Step 4: Run protocol tests** + +Run: `cd tests/protocol && npx vitest run 2>&1` +Expected: All pass. On macOS, observer is no-op so behavior is identical. + +- [ ] **Step 5: Commit** + +```bash +git add crates/pi-sandbox-runtime/src/supervisor.rs +git commit -m "feat: wire NetworkObserver into supervisor for live connection tracking" +``` + +--- + +### Task 16: Add network observation protocol test (Linux-only) + +**Files:** +- Create: `tests/protocol/network-observation.test.ts` + +- [ ] **Step 1: Write the test** + +Create `tests/protocol/network-observation.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { makePlan, spawnRuntime } from "./helpers.js"; + +const isLinux = process.platform === "linux"; + +describe("Protocol Test 8: Network Observation (Linux only)", () => { + it.skipIf(!isLinux)( + "observes outbound connections during execution", + async () => { + const rt = spawnRuntime(); + + // Use curl or python to make an outbound connection + rt.send( + makePlan({ + command: [ + "python3", + "-c", + "import urllib.request; urllib.request.urlopen('http://example.com')", + ], + manifest: { + mounts: [{ type: "tmpfs", target: "/tmp", writable: true }], + env: { + HOME: "/home/sandbox", + PATH: "/usr/bin:/bin:/usr/local/bin", + }, + cwd: "/tmp", + }, + policy: { + namespaces: ["user", "pid"], + network: { mode: "full" }, + allowedWritableTargets: ["/workspace", "/tmp"], + strictWritePolicy: false, + }, + }), + ); + + const events = await rt.readAllEvents(); + + // Validation must succeed + const validation = events[0]; + expect(validation.type).toBe("validation"); + expect((validation.payload as any).ok).toBe(true); + + // Should have at least one network event + const networkEvents = events.filter((e) => e.type === "network"); + expect(networkEvents.length).toBeGreaterThanOrEqual(1); + + // Network event should have expected shape + const firstNet = networkEvents[0]; + expect((firstNet.payload as any).direction).toBe("outbound"); + expect((firstNet.payload as any).port).toBeGreaterThan(0); + expect(typeof (firstNet.payload as any).host).toBe("string"); + + // Result should have observedConnections + const result = events[events.length - 1]; + expect(result.type).toBe("result"); + const resultPayload = result.payload as any; + expect(resultPayload.observedConnections.length).toBeGreaterThanOrEqual( + 1, + ); + + expect(resultPayload.exitCode).toBe(0); + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }, + ); + + it.skipIf(isLinux)( + "returns empty observations on non-Linux (no-op observer)", + async () => { + const rt = spawnRuntime(); + + rt.send( + makePlan({ + command: ["echo", "no-network-needed"], + manifest: { + mounts: [{ type: "tmpfs", target: "/tmp", writable: true }], + env: { HOME: "/home/sandbox", PATH: "/usr/bin:/bin" }, + cwd: "/tmp", + }, + }), + ); + + const events = await rt.readAllEvents(); + + // No network events on non-Linux + const networkEvents = events.filter((e) => e.type === "network"); + expect(networkEvents.length).toBe(0); + + // Result should have empty observedConnections + const result = events[events.length - 1]; + expect(result.type).toBe("result"); + const resultPayload = result.payload as any; + expect(resultPayload.observedConnections).toEqual([]); + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }, + ); +}); +``` + +- [ ] **Step 2: Run protocol tests** + +Run: `cd tests/protocol && npx vitest run 2>&1` +Expected: On macOS, the Linux test skips and the non-Linux test passes. All other tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add tests/protocol/network-observation.test.ts +git commit -m "test: add network observation protocol test (Linux/macOS)" +``` + +--- + +### Task 17: Final verification — all tests pass + +**Files:** None (verification only) + +- [ ] **Step 1: Run all Rust tests** + +Run: `cd crates/pi-sandbox-runtime && cargo test 2>&1` +Expected: All pass (plan_builder, bubblewrap, observer tests). + +- [ ] **Step 2: Run protocol tests** + +Run: `cd tests/protocol && npx vitest run 2>&1` +Expected: All pass (original 6 + bwrap-integration + network-observation). + +- [ ] **Step 3: Run integration tests** + +Run: `cd tests/integration && npx vitest run 2>&1` +Expected: All pass (npm, python, rust builds; network-smoke skipped). + +- [ ] **Step 4: Verify Rust compilation has no errors** + +Run: `cd crates/pi-sandbox-runtime && cargo build --release 2>&1` +Expected: Compiles cleanly (warnings about unused fields are acceptable). + +- [ ] **Step 5: Tag the milestone** + +```bash +git tag v1-phases-8-10-complete +``` diff --git a/docs/superpowers/plans/2026-04-03-pi-sandbox-v1.md b/docs/superpowers/plans/2026-04-03-pi-sandbox-v1.md new file mode 100644 index 0000000..3d4f536 --- /dev/null +++ b/docs/superpowers/plans/2026-04-03-pi-sandbox-v1.md @@ -0,0 +1,1690 @@ +# Pi Sandbox v1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the Pi-native sandbox runtime with NDJSON protocol, TS extension, and 6 passing protocol tests. + +**Architecture:** TypeScript Pi extension (packages/pi-sandbox-extension) orchestrates a Rust subprocess (crates/pi-sandbox-runtime) via NDJSON over stdin/stdout. The extension registers 5 tools with Pi, manages sandbox sessions on disk, and synthesizes crash results. The Rust runtime validates plans, supervises command execution, streams events, and reports results. + +**Tech Stack:** TypeScript (vitest, TypeBox, @mariozechner/pi-coding-agent), Rust (serde, serde_json, chrono), NDJSON protocol. + +**Spec:** `docs/superpowers/specs/2026-04-03-pi-sandbox-v1-design.md` + +--- + +## File Map + +### New files to create + +**TS package (`packages/pi-sandbox-extension/`):** +| File | Responsibility | +|------|---------------| +| `package.json` | Package config, dependencies | +| `tsconfig.json` | TypeScript config | +| `vitest.config.ts` | Test runner config | +| `src/contract.ts` | NDJSON message types (TypeBox schemas + TS interfaces) | +| `src/runtime-client.ts` | Spawn Rust subprocess, write/read NDJSON, cancel support | +| `src/crash-synthesis.ts` | Synthesize result when Rust exits without emitting one | +| `src/session-manager.ts` | Session directories, session records, mount manifest generation | +| `src/runtime-base.ts` | HostDerivedBase: resolve host binary paths into mount lists | +| `src/profiles.ts` | Profile registry (4 hardcoded profiles) | +| `src/reconciler.ts` | Scan/recover orphaned sessions on startup | +| `src/extension.ts` | 5 Pi tool definitions (sandbox_run, read/write/list files, session info) | +| `src/index.ts` | Extension entry point (ExtensionFactory) | + +**Rust crate (`crates/pi-sandbox-runtime/`):** +| File | Responsibility | +|------|---------------| +| `Cargo.toml` | Crate config, dependencies | +| `src/contract.rs` | Serde structs mirroring contract.ts | +| `src/timestamps.rs` | ISO 8601 timestamp helper | +| `src/validator.rs` | Plan validation, effective state resolution | +| `src/supervisor.rs` | Process spawn, stdout/stderr streaming, cancel, result | +| `src/observer.rs` | Network observation stub, would-have-blocked computation | +| `src/main.rs` | Entry point: read plan, validate, supervise, emit result | + +**Protocol tests (`tests/protocol/`):** +| File | Responsibility | +|------|---------------| +| `package.json` | Test package config | +| `tsconfig.json` | TypeScript config for tests | +| `vitest.config.ts` | Test runner config with globalSetup | +| `globalSetup.ts` | Build Rust binary before tests | +| `helpers.ts` | spawnRuntime(), makePlan(), NDJSON I/O utilities | +| `version-mismatch.test.ts` | Test 1 | +| `validation-failure.test.ts` | Test 2 | +| `successful-run.test.ts` | Test 3 | +| `cancel-flow.test.ts` | Test 4 | +| `crash-synthesis.test.ts` | Test 5 (TS-only) | +| `degraded-allowlist.test.ts` | Test 6 | + +**Root files:** +| File | Responsibility | +|------|---------------| +| `.gitignore` (modify) | Add node_modules, Rust target for new locations | + +--- + +## Task 1: Phase 0 — Tag Current State and Create Refactor Branch + +**Files:** +- No files created or modified + +- [ ] **Step 1: Tag the current server state** + +```bash +git tag v0-legacy-server -m "Legacy Axum server state before Pi sandbox refactor" +``` + +- [ ] **Step 2: Create long-lived refactor branch** + +```bash +git checkout -b pi-sandbox-refactor +``` + +- [ ] **Step 3: Verify branch** + +Run: `git branch --show-current` +Expected: `pi-sandbox-refactor` + +--- + +## Task 2: Phase 1 — Bootstrap TS Package Scaffolding + +**Files:** +- Create: `packages/pi-sandbox-extension/package.json` +- Create: `packages/pi-sandbox-extension/tsconfig.json` +- Create: `packages/pi-sandbox-extension/vitest.config.ts` +- Create: `packages/pi-sandbox-extension/src/index.ts` +- Modify: `.gitignore` + +- [ ] **Step 1: Create the TS package directory and package.json** + +```bash +mkdir -p packages/pi-sandbox-extension/src +``` + +Create `packages/pi-sandbox-extension/package.json`: + +```json +{ + "name": "@pi-sandbox/extension", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "devDependencies": { + "@mariozechner/pi-coding-agent": "*", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +Create `packages/pi-sandbox-extension/tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} +``` + +- [ ] **Step 3: Create vitest.config.ts** + +Create `packages/pi-sandbox-extension/vitest.config.ts`: + +```typescript +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); +``` + +- [ ] **Step 4: Create placeholder index.ts** + +Create `packages/pi-sandbox-extension/src/index.ts`: + +```typescript +// Pi Sandbox Extension entry point +// Will export ExtensionFactory once contract and tools are implemented +export {}; +``` + +- [ ] **Step 5: Update .gitignore for new package locations** + +Append to `.gitignore`: + +``` +# Pi Sandbox Extension +packages/pi-sandbox-extension/node_modules/ +packages/pi-sandbox-extension/dist/ + +# Pi Sandbox Runtime +crates/pi-sandbox-runtime/target/ + +# Protocol tests +tests/protocol/node_modules/ +``` + +- [ ] **Step 6: Install dependencies** + +```bash +cd packages/pi-sandbox-extension && npm install +``` + +- [ ] **Step 7: Verify TypeScript compiles** + +```bash +cd packages/pi-sandbox-extension && npx tsc --noEmit +``` + +Expected: No errors. + +- [ ] **Step 8: Commit** + +```bash +git add packages/pi-sandbox-extension/ .gitignore +git commit -m "feat: bootstrap pi-sandbox-extension TS package (Phase 1)" +``` + +--- + +## Task 3: Phase 1 — Bootstrap Rust Crate Scaffolding + +**Files:** +- Create: `crates/pi-sandbox-runtime/Cargo.toml` +- Create: `crates/pi-sandbox-runtime/src/main.rs` + +- [ ] **Step 1: Create the Rust crate directory** + +```bash +mkdir -p crates/pi-sandbox-runtime/src +``` + +- [ ] **Step 2: Create Cargo.toml** + +Create `crates/pi-sandbox-runtime/Cargo.toml`: + +```toml +[package] +name = "pi-sandbox-runtime" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +chrono = { version = "0.4", features = ["serde"] } +``` + +- [ ] **Step 3: Create placeholder main.rs** + +Create `crates/pi-sandbox-runtime/src/main.rs`: + +```rust +use std::io::{self, BufRead, Write}; + +fn main() { + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + + // Read one line from stdin (the plan message) + let mut line = String::new(); + if stdin.lock().read_line(&mut line).is_err() { + eprintln!("Failed to read from stdin"); + std::process::exit(1); + } + + // Stub: echo back a validation failure for now + let response = serde_json::json!({ + "type": "validation", + "v": 1, + "payload": { + "ok": false, + "errors": [{"code": "NOT_IMPLEMENTED", "message": "Stub runtime", "field": null}], + "warnings": [], + "effectiveState": null + } + }); + + writeln!(stdout, "{}", response).unwrap(); + stdout.flush().unwrap(); +} +``` + +- [ ] **Step 4: Verify Rust compiles** + +```bash +cd crates/pi-sandbox-runtime && cargo build +``` + +Expected: Compiles successfully. + +- [ ] **Step 5: Verify binary runs** + +```bash +echo '{"type":"plan","payload":{}}' | cargo run --manifest-path crates/pi-sandbox-runtime/Cargo.toml +``` + +Expected: JSON validation response on stdout. + +- [ ] **Step 6: Commit** + +```bash +git add crates/pi-sandbox-runtime/ +git commit -m "feat: bootstrap pi-sandbox-runtime Rust crate (Phase 1)" +``` + +--- + +## Task 4: Phase 1 — Bootstrap Protocol Test Scaffolding + +**Files:** +- Create: `tests/protocol/package.json` +- Create: `tests/protocol/tsconfig.json` +- Create: `tests/protocol/vitest.config.ts` +- Create: `tests/protocol/globalSetup.ts` +- Create: `tests/protocol/helpers.ts` + +- [ ] **Step 1: Create test directory and package.json** + +```bash +mkdir -p tests/protocol tests/integration +``` + +Create `tests/protocol/package.json`: + +```json +{ + "name": "@pi-sandbox/protocol-tests", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +Create `tests/protocol/tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["*.ts"], + "exclude": ["node_modules", "dist"] +} +``` + +- [ ] **Step 3: Create vitest.config.ts with globalSetup** + +Create `tests/protocol/vitest.config.ts`: + +```typescript +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["*.test.ts"], + globalSetup: "./globalSetup.ts", + testTimeout: 30000, + }, +}); +``` + +- [ ] **Step 4: Create globalSetup.ts** + +This builds the Rust binary before any tests run. + +Create `tests/protocol/globalSetup.ts`: + +```typescript +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const CRATE_DIR = resolve(import.meta.dirname, "../../crates/pi-sandbox-runtime"); + +export async function setup() { + console.log("Building pi-sandbox-runtime..."); + execFileSync("cargo", ["build", "--release"], { + cwd: CRATE_DIR, + stdio: "inherit", + }); + + const binaryPath = resolve(CRATE_DIR, "target/release/pi-sandbox-runtime"); + if (!existsSync(binaryPath)) { + throw new Error(`Binary not found at ${binaryPath}`); + } + + process.env.RUNTIME_BINARY_PATH = binaryPath; + console.log(`Runtime binary: ${binaryPath}`); +} +``` + +- [ ] **Step 5: Create helpers.ts** + +Create `tests/protocol/helpers.ts`: + +```typescript +import { spawn, type ChildProcess } from "node:child_process"; +import { createInterface } from "node:readline"; + +/** + * Wrapper around the Rust runtime subprocess for protocol testing. + * Provides typed NDJSON read/write over stdin/stdout. + */ +export interface TestRuntime { + /** Write a JSON message as NDJSON line to stdin */ + send(message: Record): void; + /** Read one NDJSON line from stdout, parsed as JSON */ + readline(): Promise>; + /** Read all remaining events until a "result" message or process exit */ + readAllEvents(): Promise[]>; + /** Send a signal to the process */ + kill(signal?: NodeJS.Signals): void; + /** Wait for the process to exit, returns exit code and signal */ + waitForExit(): Promise<{ code: number | null; signal: string | null }>; + /** Accumulated stderr output */ + stderr: string; + /** The underlying child process */ + process: ChildProcess; +} + +/** + * Spawn the Rust runtime binary and return a TestRuntime handle. + */ +export function spawnRuntime(): TestRuntime { + const binaryPath = process.env.RUNTIME_BINARY_PATH; + if (!binaryPath) { + throw new Error( + "RUNTIME_BINARY_PATH not set. Did globalSetup run?" + ); + } + + const child = spawn(binaryPath, [], { + stdio: ["pipe", "pipe", "pipe"], + }); + + const rl = createInterface({ input: child.stdout! }); + const lineQueue: string[] = []; + let lineResolve: ((line: string) => void) | null = null; + let closed = false; + + rl.on("line", (line) => { + if (lineResolve) { + const resolve = lineResolve; + lineResolve = null; + resolve(line); + } else { + lineQueue.push(line); + } + }); + + rl.on("close", () => { + closed = true; + if (lineResolve) { + const resolve = lineResolve; + lineResolve = null; + resolve(""); + } + }); + + let stderrBuf = ""; + child.stderr!.on("data", (chunk: Buffer) => { + stderrBuf += chunk.toString(); + }); + + function nextLine(): Promise { + if (lineQueue.length > 0) { + return Promise.resolve(lineQueue.shift()!); + } + if (closed) { + return Promise.reject(new Error("stdout closed before line received")); + } + return new Promise((resolve) => { + lineResolve = resolve; + }); + } + + const runtime: TestRuntime = { + send(message: Record): void { + child.stdin!.write(JSON.stringify(message) + "\n"); + }, + + async readline(): Promise> { + const line = await nextLine(); + return JSON.parse(line) as Record; + }, + + async readAllEvents(): Promise[]> { + const events: Record[] = []; + while (true) { + let line: string; + try { + line = await nextLine(); + } catch { + break; + } + const parsed = JSON.parse(line) as Record; + events.push(parsed); + if (parsed.type === "result") { + break; + } + } + return events; + }, + + kill(signal: NodeJS.Signals = "SIGTERM"): void { + child.kill(signal); + }, + + waitForExit(): Promise<{ code: number | null; signal: string | null }> { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve({ code: child.exitCode, signal: child.signalCode }); + return; + } + child.on("exit", (code, signal) => { + resolve({ code, signal }); + }); + }); + }, + + get stderr(): string { + return stderrBuf; + }, + + process: child, + }; + + return runtime; +} + +/** + * Build a valid plan message with sensible defaults. + * Override any nested field via the overrides parameter. + */ +export function makePlan(overrides?: { + version?: number; + sessionId?: string; + executionId?: string; + requestedProfile?: string; + runtimeBaseName?: string; + manifest?: { + mounts?: Array<{ + type: string; + source?: string; + target: string; + writable: boolean; + }>; + env?: Record; + cwd?: string; + }; + policy?: { + namespaces?: string[]; + network?: { + mode: string; + allowlist?: string[]; + }; + resourceLimits?: Record; + allowedWritableTargets?: string[]; + strictWritePolicy?: boolean; + envAllowlist?: string[]; + denyCommands?: string[]; + }; + command?: string[]; +}): Record { + const defaults = { + version: 1, + sessionId: "test-session-001", + executionId: "test-exec-001", + requestedProfile: "build-install", + runtimeBaseName: "host-derived", + manifest: { + mounts: [ + { + type: "directory", + source: "/tmp/pi-sandbox-test/workspace", + target: "/workspace", + writable: true, + }, + { + type: "tmpfs", + target: "/tmp", + writable: true, + }, + ], + env: { + HOME: "/home/sandbox", + PATH: "/usr/bin:/bin", + }, + cwd: "/tmp/pi-sandbox-test/workspace", + }, + policy: { + namespaces: ["user", "pid"], + network: { + mode: "full", + }, + allowedWritableTargets: ["/workspace", "/tmp"], + strictWritePolicy: false, + envAllowlist: ["HOME", "PATH"], + denyCommands: [], + }, + command: ["echo", "hello"], + }; + + const merged = { + ...defaults, + ...overrides, + manifest: { + ...defaults.manifest, + ...overrides?.manifest, + }, + policy: { + ...defaults.policy, + ...overrides?.policy, + network: { + ...defaults.policy.network, + ...overrides?.policy?.network, + }, + }, + }; + + return { + type: "plan", + payload: merged, + }; +} +``` + +- [ ] **Step 6: Install test dependencies** + +```bash +cd tests/protocol && npm install +``` + +- [ ] **Step 7: Verify test setup works (no tests yet, just config)** + +```bash +cd tests/protocol && npx tsc --noEmit +``` + +Expected: No errors. + +- [ ] **Step 8: Commit** + +```bash +git add tests/protocol/ tests/integration/ +git commit -m "feat: bootstrap protocol test scaffolding (Phase 1)" +``` + +--- + +## Task 5: Phase 2 — Frozen TS Contract Types + +**Files:** +- Create: `packages/pi-sandbox-extension/src/contract.ts` + +- [ ] **Step 1: Write the contract types** + +Create `packages/pi-sandbox-extension/src/contract.ts`: + +```typescript +/** + * Pi Sandbox NDJSON Protocol Contract v1 + * + * Defines all message types for the TS <-> Rust boundary. + * The Rust side mirrors these as serde structs in contract.rs. + * + * FROZEN: Changes require explicit protocol version bump. + */ + +import { Type, type Static } from "@sinclair/typebox"; + +// ============================================================================ +// Protocol Version +// ============================================================================ + +export const PROTOCOL_VERSION = 1; + +// ============================================================================ +// Shared Types +// ============================================================================ + +export const MountSchema = Type.Object({ + type: Type.Union([ + Type.Literal("directory"), + Type.Literal("file"), + Type.Literal("tmpfs"), + ]), + source: Type.Optional(Type.String()), + target: Type.String(), + writable: Type.Boolean(), +}); +export type Mount = Static; + +export const NetworkModeSchema = Type.Union([ + Type.Literal("off"), + Type.Literal("full"), + Type.Literal("allowlist"), +]); +export type NetworkMode = Static; + +export const NetworkConfigSchema = Type.Object({ + mode: NetworkModeSchema, + allowlist: Type.Optional(Type.Array(Type.String())), +}); +export type NetworkConfig = Static; + +export const ResourceLimitsSchema = Type.Object({ + maxCpuSeconds: Type.Optional(Type.Number()), + maxMemoryBytes: Type.Optional(Type.Number()), + maxPids: Type.Optional(Type.Number()), + maxOutputBytes: Type.Optional(Type.Number()), +}); +export type ResourceLimits = Static; + +export const ManifestSchema = Type.Object({ + mounts: Type.Array(MountSchema), + env: Type.Record(Type.String(), Type.String()), + cwd: Type.String(), +}); +export type Manifest = Static; + +export const PolicySchema = Type.Object({ + namespaces: Type.Array(Type.String()), + network: NetworkConfigSchema, + resourceLimits: Type.Optional(ResourceLimitsSchema), + allowedWritableTargets: Type.Array(Type.String()), + strictWritePolicy: Type.Boolean(), + envAllowlist: Type.Optional(Type.Array(Type.String())), + denyCommands: Type.Optional(Type.Array(Type.String())), +}); +export type Policy = Static; + +// ============================================================================ +// TS -> Rust Messages +// ============================================================================ + +export const PlanPayloadSchema = Type.Object({ + version: Type.Number(), + sessionId: Type.String(), + executionId: Type.String(), + requestedProfile: Type.String(), + runtimeBaseName: Type.Optional(Type.String()), + manifest: ManifestSchema, + policy: PolicySchema, + command: Type.Array(Type.String()), +}); +export type PlanPayload = Static; + +export interface PlanMessage { + type: "plan"; + payload: PlanPayload; +} + +export const CancelPayloadSchema = Type.Object({ + reason: Type.Optional(Type.String()), +}); +export type CancelPayload = Static; + +export interface CancelMessage { + type: "cancel"; + payload: CancelPayload; +} + +export type InboundMessage = PlanMessage | CancelMessage; + +// ============================================================================ +// Rust -> TS Messages +// ============================================================================ + +// --- Effective State --- + +export const EffectiveNetworkSchema = Type.Object({ + requested: NetworkModeSchema, + actual: Type.Union([Type.Literal("off"), Type.Literal("full")]), + enforcement: Type.Union([ + Type.Literal("enforced"), + Type.Literal("observed"), + Type.Literal("none"), + ]), + degraded: Type.Boolean(), +}); +export type EffectiveNetwork = Static; + +export const EffectiveStateSchema = Type.Object({ + network: EffectiveNetworkSchema, + namespacesApplied: Type.Array(Type.String()), + envApplied: Type.Array(Type.String()), +}); +export type EffectiveState = Static; + +// --- Validation --- + +export interface ValidationError { + code: string; + message: string; + field?: string; +} + +export interface ValidationWarning { + code: string; + message: string; +} + +export interface ValidationPayload { + ok: boolean; + errors: ValidationError[]; + warnings: ValidationWarning[]; + effectiveState: EffectiveState | null; +} + +export interface ValidationMessage { + type: "validation"; + v: number; + payload: ValidationPayload; +} + +// --- Streamed Events --- + +export interface StdoutEvent { + type: "stdout"; + sequence: number; + ts: string; + payload: { data: string }; +} + +export interface StderrEvent { + type: "stderr"; + sequence: number; + ts: string; + payload: { data: string }; +} + +export type LifecycleEventName = + | "started" + | "cancel_requested" + | "killing" + | "exited"; + +export interface LifecycleEvent { + type: "lifecycle"; + sequence: number; + ts: string; + payload: { event: LifecycleEventName }; +} + +export interface NetworkEvent { + type: "network"; + sequence: number; + ts: string; + payload: { + direction: "outbound"; + host: string; + port: number; + protocol?: string; + }; +} + +export interface WarningEvent { + type: "warning"; + sequence: number; + ts: string; + payload: { code: string; message: string }; +} + +export type StreamEvent = + | StdoutEvent + | StderrEvent + | LifecycleEvent + | NetworkEvent + | WarningEvent; + +// --- Result --- + +export type TerminalState = + | "clean_exit" + | "killed_on_cancel" + | "killed_on_timeout" + | "supervisor_crash" + | "partial_cleanup"; + +export interface ObservedConnection { + host: string; + port: number; + timestamp: string; +} + +export interface BlockedConnection { + host: string; + port: number; +} + +export interface ReconciliationHints { + terminalState: TerminalState; + workspaceModified: boolean; + cleanupSucceeded: boolean; +} + +export interface ResultPayload { + exitCode: number | null; + signal: string | null; + timedOut: boolean; + durationMs: number; + effectiveNetwork: EffectiveNetwork; + observedConnections: ObservedConnection[]; + wouldHaveBlocked: BlockedConnection[]; + resourcePeaks?: { + memoryBytes?: number; + cpuSeconds?: number; + }; + reconciliationHints: ReconciliationHints; +} + +export interface ResultMessage { + type: "result"; + v: number; + payload: ResultPayload; +} + +// --- Union --- + +export type OutboundMessage = ValidationMessage | StreamEvent | ResultMessage; + +// ============================================================================ +// Error & Warning Codes +// ============================================================================ + +export const ErrorCodes = { + VERSION_MISMATCH: "VERSION_MISMATCH", + RW_TARGET_NOT_ALLOWED: "RW_TARGET_NOT_ALLOWED", + COMMAND_DENIED: "COMMAND_DENIED", + INVALID_MOUNT: "INVALID_MOUNT", + MISSING_REQUIRED_FIELD: "MISSING_REQUIRED_FIELD", +} as const; + +export const WarningCodes = { + ALLOWLIST_NOT_ENFORCED: "ALLOWLIST_NOT_ENFORCED", + NAMESPACE_DEGRADED: "NAMESPACE_DEGRADED", + RESOURCE_LIMIT_IGNORED: "RESOURCE_LIMIT_IGNORED", +} as const; +``` + +- [ ] **Step 2: Update index.ts to export contract** + +Replace `packages/pi-sandbox-extension/src/index.ts`: + +```typescript +export * from "./contract.js"; +``` + +- [ ] **Step 3: Verify contract compiles** + +```bash +cd packages/pi-sandbox-extension && npx tsc --noEmit +``` + +Expected: No errors. + +- [ ] **Step 4: Commit** + +```bash +git add packages/pi-sandbox-extension/src/contract.ts packages/pi-sandbox-extension/src/index.ts +git commit -m "feat: add frozen TS contract types (Phase 2)" +``` + +--- + +## Task 6: Phase 2 — Frozen Rust Contract Types + +**Files:** +- Create: `crates/pi-sandbox-runtime/src/contract.rs` +- Create: `crates/pi-sandbox-runtime/src/timestamps.rs` +- Modify: `crates/pi-sandbox-runtime/src/main.rs` + +- [ ] **Step 1: Create timestamps.rs** + +Create `crates/pi-sandbox-runtime/src/timestamps.rs`: + +```rust +use chrono::Utc; + +/// Return current UTC time as ISO 8601 string. +pub fn now_iso8601() -> String { + Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} +``` + +- [ ] **Step 2: Create contract.rs** + +Create `crates/pi-sandbox-runtime/src/contract.rs`: + +```rust +//! Pi Sandbox NDJSON Protocol Contract v1 +//! +//! Serde structs mirroring contract.ts. FROZEN: changes require protocol version bump. + +use serde::{Deserialize, Serialize}; + +pub const PROTOCOL_VERSION: u32 = 1; + +// ============================================================================ +// Inbound Messages (TS -> Rust) +// ============================================================================ + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum InboundMessage { + Plan { payload: PlanPayload }, + Cancel { payload: CancelPayload }, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlanPayload { + pub version: u32, + pub session_id: String, + pub execution_id: String, + pub requested_profile: String, + pub runtime_base_name: Option, + pub manifest: Manifest, + pub policy: Policy, + pub command: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Manifest { + pub mounts: Vec, + pub env: std::collections::HashMap, + pub cwd: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Mount { + #[serde(rename = "type")] + pub mount_type: String, + pub source: Option, + pub target: String, + pub writable: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Policy { + pub namespaces: Vec, + pub network: NetworkConfig, + pub resource_limits: Option, + pub allowed_writable_targets: Vec, + pub strict_write_policy: bool, + pub env_allowlist: Option>, + pub deny_commands: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkConfig { + pub mode: String, + pub allowlist: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourceLimits { + pub max_cpu_seconds: Option, + pub max_memory_bytes: Option, + pub max_pids: Option, + pub max_output_bytes: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CancelPayload { + pub reason: Option, +} + +// ============================================================================ +// Outbound Messages (Rust -> TS) +// ============================================================================ + +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum OutboundMessage { + Validation(ValidationEnvelope), + Stdout(StdoutEnvelope), + Stderr(StderrEnvelope), + Lifecycle(LifecycleEnvelope), + Network(NetworkEnvelope), + Warning(WarningEnvelope), + Result(ResultEnvelope), +} + +// --- Validation --- + +#[derive(Debug, Serialize)] +pub struct ValidationEnvelope { + #[serde(rename = "type")] + pub msg_type: &'static str, + pub v: u32, + pub payload: ValidationPayload, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ValidationPayload { + pub ok: bool, + pub errors: Vec, + pub warnings: Vec, + pub effective_state: Option, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ValidationError { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub field: Option, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ValidationWarning { + pub code: String, + pub message: String, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct EffectiveState { + pub network: EffectiveNetwork, + pub namespaces_applied: Vec, + pub env_applied: Vec, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct EffectiveNetwork { + pub requested: String, + pub actual: String, + pub enforcement: String, + pub degraded: bool, +} + +// --- Streamed Events --- + +#[derive(Debug, Serialize)] +pub struct StdoutEnvelope { + #[serde(rename = "type")] + pub msg_type: &'static str, + pub sequence: u64, + pub ts: String, + pub payload: DataPayload, +} + +#[derive(Debug, Serialize)] +pub struct StderrEnvelope { + #[serde(rename = "type")] + pub msg_type: &'static str, + pub sequence: u64, + pub ts: String, + pub payload: DataPayload, +} + +#[derive(Debug, Serialize)] +pub struct DataPayload { + pub data: String, +} + +#[derive(Debug, Serialize)] +pub struct LifecycleEnvelope { + #[serde(rename = "type")] + pub msg_type: &'static str, + pub sequence: u64, + pub ts: String, + pub payload: LifecyclePayload, +} + +#[derive(Debug, Serialize)] +pub struct LifecyclePayload { + pub event: String, +} + +#[derive(Debug, Serialize)] +pub struct NetworkEnvelope { + #[serde(rename = "type")] + pub msg_type: &'static str, + pub sequence: u64, + pub ts: String, + pub payload: NetworkEventPayload, +} + +#[derive(Debug, Serialize)] +pub struct NetworkEventPayload { + pub direction: String, + pub host: String, + pub port: u16, + #[serde(skip_serializing_if = "Option::is_none")] + pub protocol: Option, +} + +#[derive(Debug, Serialize)] +pub struct WarningEnvelope { + #[serde(rename = "type")] + pub msg_type: &'static str, + pub sequence: u64, + pub ts: String, + pub payload: WarningPayload, +} + +#[derive(Debug, Serialize)] +pub struct WarningPayload { + pub code: String, + pub message: String, +} + +// --- Result --- + +#[derive(Debug, Serialize)] +pub struct ResultEnvelope { + #[serde(rename = "type")] + pub msg_type: &'static str, + pub v: u32, + pub payload: ResultPayload, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResultPayload { + pub exit_code: Option, + pub signal: Option, + pub timed_out: bool, + pub duration_ms: f64, + pub effective_network: EffectiveNetwork, + pub observed_connections: Vec, + pub would_have_blocked: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_peaks: Option, + pub reconciliation_hints: ReconciliationHints, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ObservedConnection { + pub host: String, + pub port: u16, + pub timestamp: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BlockedConnection { + pub host: String, + pub port: u16, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourcePeaks { + pub memory_bytes: Option, + pub cpu_seconds: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReconciliationHints { + pub terminal_state: String, + pub workspace_modified: bool, + pub cleanup_succeeded: bool, +} + +// ============================================================================ +// Constructors +// ============================================================================ + +impl ValidationEnvelope { + pub fn new(payload: ValidationPayload) -> OutboundMessage { + OutboundMessage::Validation(Self { + msg_type: "validation", + v: PROTOCOL_VERSION, + payload, + }) + } +} + +impl StdoutEnvelope { + pub fn new(sequence: u64, ts: String, data: String) -> OutboundMessage { + OutboundMessage::Stdout(Self { + msg_type: "stdout", + sequence, + ts, + payload: DataPayload { data }, + }) + } +} + +impl StderrEnvelope { + pub fn new(sequence: u64, ts: String, data: String) -> OutboundMessage { + OutboundMessage::Stderr(Self { + msg_type: "stderr", + sequence, + ts, + payload: DataPayload { data }, + }) + } +} + +impl LifecycleEnvelope { + pub fn new(sequence: u64, ts: String, event: &str) -> OutboundMessage { + OutboundMessage::Lifecycle(Self { + msg_type: "lifecycle", + sequence, + ts, + payload: LifecyclePayload { + event: event.to_string(), + }, + }) + } +} + +impl WarningEnvelope { + pub fn new(sequence: u64, ts: String, code: &str, message: &str) -> OutboundMessage { + OutboundMessage::Warning(Self { + msg_type: "warning", + sequence, + ts, + payload: WarningPayload { + code: code.to_string(), + message: message.to_string(), + }, + }) + } +} + +impl ResultEnvelope { + pub fn new(payload: ResultPayload) -> OutboundMessage { + OutboundMessage::Result(Self { + msg_type: "result", + v: PROTOCOL_VERSION, + payload, + }) + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/// Write an outbound message as a single NDJSON line to stdout. +pub fn emit(message: &OutboundMessage) { + let json = serde_json::to_string(message).expect("Failed to serialize outbound message"); + println!("{}", json); +} +``` + +- [ ] **Step 3: Update main.rs to use contract module** + +Replace `crates/pi-sandbox-runtime/src/main.rs`: + +```rust +mod contract; +mod timestamps; + +use std::io::{self, BufRead}; + +use contract::{emit, InboundMessage, ValidationEnvelope, ValidationError, ValidationPayload}; + +fn main() { + let stdin = io::stdin(); + let mut line = String::new(); + + if stdin.lock().read_line(&mut line).is_err() || line.trim().is_empty() { + eprintln!("Failed to read plan from stdin"); + std::process::exit(1); + } + + let message: InboundMessage = match serde_json::from_str(line.trim()) { + Ok(msg) => msg, + Err(e) => { + eprintln!("Failed to parse plan: {}", e); + let validation = ValidationEnvelope::new(ValidationPayload { + ok: false, + errors: vec![ValidationError { + code: "PARSE_ERROR".to_string(), + message: format!("Failed to parse plan message: {}", e), + field: None, + }], + warnings: vec![], + effective_state: None, + }); + emit(&validation); + return; + } + }; + + match message { + InboundMessage::Plan { payload } => { + // Stub: validate version only, then exit + if payload.version != contract::PROTOCOL_VERSION { + let validation = ValidationEnvelope::new(ValidationPayload { + ok: false, + errors: vec![ValidationError { + code: "VERSION_MISMATCH".to_string(), + message: format!( + "Unsupported protocol version: {}. Expected: {}", + payload.version, + contract::PROTOCOL_VERSION + ), + field: Some("version".to_string()), + }], + warnings: vec![], + effective_state: None, + }); + emit(&validation); + return; + } + + // For now, emit a simple success validation and exit + let validation = ValidationEnvelope::new(ValidationPayload { + ok: true, + errors: vec![], + warnings: vec![], + effective_state: Some(contract::EffectiveState { + network: contract::EffectiveNetwork { + requested: payload.policy.network.mode.clone(), + actual: if payload.policy.network.mode == "off" { + "off".to_string() + } else { + "full".to_string() + }, + enforcement: "none".to_string(), + degraded: false, + }, + namespaces_applied: vec![], + env_applied: payload.manifest.env.keys().cloned().collect(), + }), + }); + emit(&validation); + } + InboundMessage::Cancel { .. } => { + eprintln!("Received cancel before plan -- ignoring"); + } + } +} +``` + +- [ ] **Step 4: Verify Rust compiles** + +```bash +cd crates/pi-sandbox-runtime && cargo build +``` + +Expected: Compiles successfully. + +- [ ] **Step 5: Test round-trip serialization** + +```bash +echo '{"type":"plan","payload":{"version":1,"sessionId":"s1","executionId":"e1","requestedProfile":"build-install","manifest":{"mounts":[],"env":{"HOME":"/home"},"cwd":"/workspace"},"policy":{"namespaces":[],"network":{"mode":"full"},"allowedWritableTargets":[],"strictWritePolicy":false},"command":["echo","hi"]}}' | cargo run --manifest-path crates/pi-sandbox-runtime/Cargo.toml +``` + +Expected: JSON validation message with `ok: true` on stdout. + +- [ ] **Step 6: Test version mismatch** + +```bash +echo '{"type":"plan","payload":{"version":99,"sessionId":"s1","executionId":"e1","requestedProfile":"x","manifest":{"mounts":[],"env":{},"cwd":"/"},"policy":{"namespaces":[],"network":{"mode":"full"},"allowedWritableTargets":[],"strictWritePolicy":false},"command":["echo"]}}' | cargo run --manifest-path crates/pi-sandbox-runtime/Cargo.toml +``` + +Expected: JSON validation with `ok: false` and `VERSION_MISMATCH`. + +- [ ] **Step 7: Commit** + +```bash +git add crates/pi-sandbox-runtime/src/ +git commit -m "feat: add frozen Rust contract types and basic plan parsing (Phase 2)" +``` + +--- + +## Task 7: Phase 3 — TS Crash Synthesis + +**Files:** +- Create: `packages/pi-sandbox-extension/src/crash-synthesis.ts` + +- [ ] **Step 1: Write crash-synthesis.ts** + +Create `packages/pi-sandbox-extension/src/crash-synthesis.ts`: + +```typescript +/** + * Crash Synthesis + * + * When the Rust runtime exits without emitting a "result" message, + * the TS client synthesizes one to ensure the extension always has + * a complete execution result. + */ + +import type { + EffectiveNetwork, + PlanPayload, + ResultPayload, + ValidationPayload, +} from "./contract.js"; + +/** + * Synthesize a crash result when Rust exits without emitting a result. + * + * Case 1: Validation was received -- preserve last-known effective state. + * workspaceModified = true (execution likely started) + * + * Case 2: No validation received -- use conservative fallback. + * workspaceModified = false (execution likely never started) + */ +export function synthesizeCrashResult( + lastValidation: ValidationPayload | null, + plan: PlanPayload, + exitCode: number | null, + signal: string | null, + durationMs: number, +): ResultPayload { + let effectiveNetwork: EffectiveNetwork; + let workspaceModified: boolean; + + if (lastValidation?.effectiveState) { + // Case 1: Validation received -- preserve known state + effectiveNetwork = lastValidation.effectiveState.network; + workspaceModified = true; + } else { + // Case 2: No validation -- conservative fallback + effectiveNetwork = { + requested: plan.policy.network.mode, + actual: "full", + enforcement: "none", + degraded: true, + }; + workspaceModified = false; + } + + return { + exitCode: exitCode ?? -1, + signal, + timedOut: false, + durationMs, + effectiveNetwork, + observedConnections: [], + wouldHaveBlocked: [], + reconciliationHints: { + terminalState: "supervisor_crash", + workspaceModified, + cleanupSucceeded: false, + }, + }; +} +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +cd packages/pi-sandbox-extension && npx tsc --noEmit +``` + +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add packages/pi-sandbox-extension/src/crash-synthesis.ts +git commit -m "feat: add crash synthesis for supervisor crash recovery (Phase 3)" +``` + +--- + +## Tasks 8-24: Remaining Implementation + +Tasks 8 through 24 follow the same pattern established above. Due to the plan's length, the remaining tasks are summarized here with exact file paths and key implementation notes. Each task follows the same write-test-verify-commit cycle. + +### Task 8: Phase 3 — TS Runtime Client +- **Create:** `packages/pi-sandbox-extension/src/runtime-client.ts` +- **Key:** RuntimeClient class that spawns Rust binary, writes plan NDJSON to stdin, reads NDJSON from stdout line-by-line, dispatches events, supports cancel via stdin write, crash synthesis on abnormal exit. Timeout enforced via SIGTERM then SIGKILL. +- **Update:** `packages/pi-sandbox-extension/src/index.ts` to export RuntimeClient + +### Task 9: Phase 4 — Rust Validator +- **Create:** `crates/pi-sandbox-runtime/src/validator.rs` +- **Key:** `validate(plan) -> ValidationPayload`. Checks: VERSION_MISMATCH (early return, no effectiveState), RW_TARGET_NOT_ALLOWED (writable mounts vs allowedWritableTargets), COMMAND_DENIED (command vs denyCommands), empty command. Resolves effective network: off->off/enforced, full->full/none, allowlist->full/observed/degraded. Emits ALLOWLIST_NOT_ENFORCED warning. + +### Task 10: Phase 4 — Rust Observer Stub +- **Create:** `crates/pi-sandbox-runtime/src/observer.rs` +- **Key:** `observe_connections()` returns empty vec. `compute_would_have_blocked(observed, allowlist)` filters connections against allowlist entries in "host:port" format. + +### Task 11: Phase 4 — Rust Supervisor +- **Create:** `crates/pi-sandbox-runtime/src/supervisor.rs` +- **Modify:** `crates/pi-sandbox-runtime/Cargo.toml` (add `libc = "0.2"` under `[target.'cfg(unix)'.dependencies]`) +- **Key:** Spawns child process, streams stdout/stderr in threads using BufReader, polls cancel_rx channel, emits lifecycle events (started, cancel_requested, killing, exited). Returns SupervisionResult with exit_code, terminal_state, effective_network. + +### Task 12: Phase 4 — Wire Rust main.rs +- **Modify:** `crates/pi-sandbox-runtime/src/main.rs` +- **Key:** Full pipeline: read plan line -> parse -> validate -> if ok supervise -> emit result. Cancel channel: background thread reads remaining stdin lines for cancel message. Smoke test with `echo hello` and version 99. + +### Tasks 13-18: Phase 5 — Protocol Tests +- **Create:** `tests/protocol/version-mismatch.test.ts` (Task 13) +- **Create:** `tests/protocol/validation-failure.test.ts` (Task 14) +- **Create:** `tests/protocol/successful-run.test.ts` (Task 15) +- **Create:** `tests/protocol/cancel-flow.test.ts` (Task 16) +- **Create:** `tests/protocol/crash-synthesis.test.ts` (Task 17, TS-only, imports from extension src) +- **Create:** `tests/protocol/degraded-allowlist.test.ts` (Task 18) +- **Key:** Each test uses `spawnRuntime()` and `makePlan()` from helpers.ts. Tests assert exact error codes, effective state fields, sequence ordering, and terminal states. Task 18 ends with running ALL 6 tests together. + +### Task 19: Phase 6 — Profiles +- **Create:** `packages/pi-sandbox-extension/src/profiles.ts` +- **Key:** 4 profiles as hardcoded map: offline-review (net off, core+git), strict (net off, core), build-install (net full, all bundles), debug-network (net full, core+git+node+python). DEFAULT_PROFILE = "build-install". Functions: getProfile(name), listProfiles(). + +### Task 20: Phase 6 — Runtime Base +- **Create:** `packages/pi-sandbox-extension/src/runtime-base.ts` +- **Key:** `createHostDerivedBase()` returns RuntimeBase with `resolveBundleMounts(bundles)`. Bundle specs: core (/usr/bin, /usr/lib, /lib, /lib64), certs, git/node/python/rust (dynamic via `which`). Fingerprint = sha256 of sorted paths + mtimes. + +### Task 21: Phase 6 — Session Manager +- **Create:** `packages/pi-sandbox-extension/src/session-manager.ts` +- **Key:** SessionManager class. Creates session dirs (workspace, artifacts, logs, tmp, home, cache). Persists SessionRecord as session.json. Methods: create, load, list, buildMountManifest (combines session dirs + runtime base mounts + profile env), markExecutionStarted/Finished, cleanTmp, tombstone. + +### Task 22: Phase 6 — Reconciler +- **Create:** `packages/pi-sandbox-extension/src/reconciler.ts` +- **Key:** `reconcileAll(sessionManager)` scans sessions. Active sessions: kill orphaned PIDs, mark recovered, clean tmp. Recovered sessions older than 7 days: tombstone. Returns list of RecoveryAction. + +### Task 23: Phase 7 — Extension Tools +- **Create:** `packages/pi-sandbox-extension/src/extension.ts` +- **Key:** `createSandboxTools(sessionManager, runtimeBase, binaryPath)` returns 5 ToolDefinition objects. sandbox_run: resolves session/profile, builds manifest+plan, spawns via RuntimeClient, streams events, formats result. sandbox_read/write/list_files: path traversal protection via safeResolvePath. sandbox_session_info: lists or describes sessions. + +### Task 24: Phase 7 — Extension Entry Point +- **Modify:** `packages/pi-sandbox-extension/src/index.ts` +- **Key:** Default export `sandboxExtension(pi)`. Registers tools via pi.registerTool(). pi.on("session_start") runs reconciler. pi.on("session_shutdown") marks sessions idle. Exports all public types. + +### Task 25: Final Verification +- Build Rust binary, run all 6 protocol tests, type-check TS. Tag `v1-protocol-passing`. + +--- + +## Summary + +| Task | Phase | What it builds | +|------|-------|---------------| +| 1 | 0 | Tag + branch | +| 2 | 1 | TS package scaffold | +| 3 | 1 | Rust crate scaffold | +| 4 | 1 | Protocol test scaffold | +| 5 | 2 | TS contract types | +| 6 | 2 | Rust contract types | +| 7 | 3 | Crash synthesis | +| 8 | 3 | Runtime client | +| 9 | 4 | Rust validator | +| 10 | 4 | Rust observer stub | +| 11 | 4 | Rust supervisor | +| 12 | 4 | Wire Rust main.rs | +| 13 | 5 | Protocol test 1 (version mismatch) | +| 14 | 5 | Protocol test 2 (validation failure) | +| 15 | 5 | Protocol test 3 (successful run) | +| 16 | 5 | Protocol test 4 (cancel flow) | +| 17 | 5 | Protocol test 5 (crash synthesis) | +| 18 | 5 | Protocol test 6 (degraded allowlist) | +| 19 | 6 | Profiles | +| 20 | 6 | Runtime base | +| 21 | 6 | Session manager | +| 22 | 6 | Reconciler | +| 23 | 7 | Extension tools (5 tools) | +| 24 | 7 | Extension entry point | +| 25 | -- | Final verification | diff --git a/docs/superpowers/plans/2026-04-06-pi-sandbox-phases-11-12.md b/docs/superpowers/plans/2026-04-06-pi-sandbox-phases-11-12.md new file mode 100644 index 0000000..f193397 --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-pi-sandbox-phases-11-12.md @@ -0,0 +1,1618 @@ +# Pi Sandbox Phases 11-12 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the legacy sandbox-rs server, add session-based Playwright browser automation, and implement real iptables-based network allowlist enforcement. + +**Architecture:** Phase 11 hard-deletes `sandbox-rs/`. Phase 12a adds a BrowserManager + `sandbox_browser` tool in the TS extension using `playwright-core`. Phase 12b adds DNS pre-resolution in the Rust validator, iptables wrapper script generation in plan_builder, and enforcement leak detection in the supervisor. + +**Tech Stack:** TypeScript (Playwright, vitest), Rust (std::net for DNS, iptables CLI), NDJSON protocol (unchanged for browser; contract extended for allowlist enforcement) + +--- + +## File Map + +### Phase 11 — Delete + +| Path | Action | +|---|---| +| `sandbox-rs/` (entire directory) | Delete | + +### Phase 12a — Browser + +| Path | Action | Responsibility | +|---|---|---| +| `packages/pi-sandbox-extension/src/browser.ts` | Create | BrowserManager: lazy Playwright lifecycle, session-scoped pages | +| `packages/pi-sandbox-extension/src/extension.ts` | Modify | Register `sandbox_browser` tool | +| `packages/pi-sandbox-extension/src/session-manager.ts` | Modify | Call BrowserManager.closePage on session teardown | +| `packages/pi-sandbox-extension/package.json` | Modify | Add `playwright-core` dependency | +| `tests/extension/browser.test.ts` | Create | BrowserManager unit + integration tests | +| `tests/extension/package.json` | Create | Test package config | +| `tests/extension/tsconfig.json` | Create | TypeScript config for tests | +| `tests/extension/vitest.config.ts` | Create | vitest config | + +### Phase 12b — Allowlist Enforcement + +| Path | Action | Responsibility | +|---|---|---| +| `packages/pi-sandbox-extension/src/contract.ts` | Modify | Add `"allowlist"` to actual, `"best_effort"` to enforcement, new warning codes | +| `crates/pi-sandbox-runtime/src/contract.rs` | Modify | Add `ResolvedAllowlistEntry`, `resolved_allowlist` field | +| `crates/pi-sandbox-runtime/src/validator.rs` | Modify | DNS resolution, iptables detection, enforced allowlist states | +| `crates/pi-sandbox-runtime/src/plan_builder.rs` | Modify | iptables wrapper script, `--unshare-net` for allowlist, iptables mount | +| `crates/pi-sandbox-runtime/src/supervisor.rs` | Modify | Enforcement leak detection warning | +| `tests/protocol/allowlist-enforced.test.ts` | Create | Linux enforcement + macOS degradation tests | + +--- + +### Task 1: Delete legacy sandbox-rs server + +**Files:** +- Delete: `sandbox-rs/` (entire directory) + +- [ ] **Step 1: Verify current state builds and tests pass** + +Run: `cd /Users/hashwarlock/Projects/nixosandbox && cargo build -p pi-sandbox-runtime` +Expected: SUCCESS + +- [ ] **Step 2: Delete the sandbox-rs directory** + +```bash +rm -rf sandbox-rs/ +``` + +- [ ] **Step 3: Verify pi-sandbox-runtime still builds** + +Run: `cargo build -p pi-sandbox-runtime` +Expected: SUCCESS — the runtime crate has no dependency on sandbox-rs. + +- [ ] **Step 4: Verify protocol tests still pass** + +Run: `cd tests/protocol && npm test` +Expected: All existing tests pass. + +- [ ] **Step 5: Verify integration tests still pass** + +Run: `cd tests/integration && npm test` +Expected: All existing tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "chore: remove legacy sandbox-rs server (Phase 11) + +The v0-legacy-server tag preserves the full Axum REST server history. +All functionality has been replaced by pi-sandbox-runtime (Rust) and +pi-sandbox-extension (TypeScript) in Phases 0-10." +``` + +--- + +### Task 2: Add playwright-core dependency and scaffold browser.ts + +**Files:** +- Modify: `packages/pi-sandbox-extension/package.json` +- Create: `packages/pi-sandbox-extension/src/browser.ts` + +- [ ] **Step 1: Add playwright-core dependency** + +In `packages/pi-sandbox-extension/package.json`, add `playwright-core` to dependencies: + +```json +{ + "dependencies": { + "@sinclair/typebox": "^0.34.0", + "playwright-core": "^1.50.0" + } +} +``` + +Run: `cd packages/pi-sandbox-extension && npm install` + +- [ ] **Step 2: Create BrowserManager class** + +Create `packages/pi-sandbox-extension/src/browser.ts`: + +```typescript +/** + * Browser Manager + * + * Manages a shared Playwright browser instance with session-scoped pages. + * Lazy-initialized on first use. Each sandbox session gets one persistent + * page that maintains state across navigation/click/type calls. + */ + +import type { Browser, BrowserContext, Page } from "playwright-core"; +import { chromium } from "playwright-core"; + +export class BrowserManager { + private browser: Browser | null = null; + private context: BrowserContext | null = null; + private pages: Map = new Map(); + + /** + * Launch the browser if not already running. + * Uses PLAYWRIGHT_CHROMIUM_PATH env var or system chromium. + */ + private async ensureBrowser(): Promise { + if (this.context) return this.context; + + const executablePath = process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined; + this.browser = await chromium.launch({ + headless: true, + executablePath, + }); + this.context = await this.browser.newContext(); + return this.context; + } + + /** + * Get or create a page for the given session. + */ + async getOrCreatePage(sessionId: string): Promise { + const existing = this.pages.get(sessionId); + if (existing && !existing.isClosed()) return existing; + + const ctx = await this.ensureBrowser(); + const page = await ctx.newPage(); + this.pages.set(sessionId, page); + return page; + } + + /** + * Close the page for a specific session (e.g., on session teardown). + */ + async closePage(sessionId: string): Promise { + const page = this.pages.get(sessionId); + if (page && !page.isClosed()) { + await page.close(); + } + this.pages.delete(sessionId); + } + + /** + * Execute a browser action for a session. + */ + async execute( + sessionId: string, + action: string, + params: { + url?: string; + selector?: string; + text?: string; + script?: string; + }, + ): Promise { + if (action === "close") { + await this.closePage(sessionId); + return "Browser page closed."; + } + + const page = await this.getOrCreatePage(sessionId); + + switch (action) { + case "goto": { + if (!params.url) throw new Error("url is required for goto action"); + const response = await page.goto(params.url, { + waitUntil: "domcontentloaded", + }); + const title = await page.title(); + const textContent = await page.evaluate(() => { + const body = document.body; + return body ? body.innerText.slice(0, 4000) : ""; + }); + const status = response?.status() ?? 0; + return [ + `url: ${page.url()}`, + `status: ${status}`, + `title: ${title}`, + "--- content ---", + textContent, + ].join("\n"); + } + + case "screenshot": { + const buffer = await page.screenshot({ type: "png" }); + return buffer.toString("base64"); + } + + case "evaluate": { + if (!params.script) + throw new Error("script is required for evaluate action"); + const result = await page.evaluate(params.script); + return JSON.stringify(result); + } + + case "click": { + if (!params.selector) + throw new Error("selector is required for click action"); + await page.click(params.selector); + return `Clicked: ${params.selector}`; + } + + case "type": { + if (!params.selector) + throw new Error("selector is required for type action"); + if (!params.text) + throw new Error("text is required for type action"); + await page.fill(params.selector, params.text); + return `Typed into: ${params.selector}`; + } + + default: + throw new Error( + `Unknown browser action: "${action}". Valid: goto, screenshot, evaluate, click, type, close`, + ); + } + } + + /** + * Shut down the browser entirely. Called on extension teardown. + */ + async shutdown(): Promise { + for (const [id, page] of this.pages) { + if (!page.isClosed()) { + await page.close(); + } + this.pages.delete(id); + } + if (this.context) { + await this.context.close(); + this.context = null; + } + if (this.browser) { + await this.browser.close(); + this.browser = null; + } + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/pi-sandbox-extension/package.json packages/pi-sandbox-extension/package-lock.json packages/pi-sandbox-extension/src/browser.ts +git commit -m "feat: add BrowserManager with Playwright session-scoped pages (Phase 12a)" +``` + +--- + +### Task 3: Register sandbox_browser tool in extension.ts + +**Files:** +- Modify: `packages/pi-sandbox-extension/src/extension.ts` + +- [ ] **Step 1: Add BrowserManager import and instance** + +At the top of `packages/pi-sandbox-extension/src/extension.ts`, add the import: + +```typescript +import { BrowserManager } from "./browser.js"; +``` + +- [ ] **Step 2: Add sandbox_browser tool to createSandboxTools** + +The `createSandboxTools` function currently accepts `(sessionManager, runtimeBase, binaryPath)`. Change its signature to also accept a `BrowserManager`: + +```typescript +export function createSandboxTools( + sessionManager: SessionManager, + runtimeBase: RuntimeBase, + binaryPath: string, + browserManager: BrowserManager, +): ToolDefinition[] { +``` + +Add the `sandbox_browser` tool definition after `sandboxSessionInfo` and before the return array: + +```typescript + // ------------------------------------------------------------------------- + // Tool: sandbox_browser + // ------------------------------------------------------------------------- + const sandboxBrowser: ToolDefinition = { + name: "sandbox_browser", + description: + "Interact with a web browser within a sandbox session. Supports goto, screenshot, evaluate, click, type, and close actions. The page persists between calls within the same session.", + parameters: Type.Object({ + sessionId: Type.String({ description: "Session ID to operate within." }), + action: Type.Union( + [ + Type.Literal("goto"), + Type.Literal("screenshot"), + Type.Literal("evaluate"), + Type.Literal("click"), + Type.Literal("type"), + Type.Literal("close"), + ], + { description: "Browser action to perform." }, + ), + url: Type.Optional( + Type.String({ description: "URL to navigate to (goto action)." }), + ), + selector: Type.Optional( + Type.String({ + description: "CSS selector for element (click/type actions).", + }), + ), + text: Type.Optional( + Type.String({ description: "Text to type (type action)." }), + ), + script: Type.Optional( + Type.String({ + description: "JavaScript to evaluate (evaluate action).", + }), + ), + }), + async execute(args: unknown): Promise { + const { sessionId, action, url, selector, text, script } = args as { + sessionId: string; + action: string; + url?: string; + selector?: string; + text?: string; + script?: string; + }; + + // Verify session exists (except for close which is best-effort) + if (action !== "close") { + resolveSession(sessionId); + } + + return browserManager.execute(sessionId, action, { + url, + selector, + text, + script, + }); + }, + }; +``` + +Update the return array to include `sandboxBrowser`: + +```typescript + return [ + sandboxRun, + sandboxReadFile, + sandboxWriteFile, + sandboxListFiles, + sandboxSessionInfo, + sandboxBrowser, + ]; +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/pi-sandbox-extension/src/extension.ts +git commit -m "feat: register sandbox_browser tool in extension (Phase 12a)" +``` + +--- + +### Task 4: Wire browser cleanup into session manager + +**Files:** +- Modify: `packages/pi-sandbox-extension/src/session-manager.ts` + +- [ ] **Step 1: Add optional BrowserManager reference to SessionManager** + +Add an optional browser manager field and a setter. At the top of `packages/pi-sandbox-extension/src/session-manager.ts`, add the import: + +```typescript +import type { BrowserManager } from "./browser.js"; +``` + +Inside the `SessionManager` class, after the `private readonly baseDir: string;` field, add: + +```typescript + private browserManager: BrowserManager | null = null; + + setBrowserManager(bm: BrowserManager): void { + this.browserManager = bm; + } +``` + +- [ ] **Step 2: Call closePage in tombstone method** + +In the `tombstone` method of `SessionManager`, add browser cleanup before writing the record: + +```typescript + tombstone(session: Session): Session { + // Close browser page if browser manager is wired + if (this.browserManager) { + this.browserManager.closePage(session.record.sessionId).catch(() => {}); + } + const record: SessionRecord = { + ...session.record, + state: "tombstoned", + activeExecution: null, + }; + this._writeRecord(session.dir, record); + return { record, dir: session.dir }; + } +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/pi-sandbox-extension/src/session-manager.ts +git commit -m "feat: wire browser cleanup into session manager tombstone (Phase 12a)" +``` + +--- + +### Task 5: Add browser extension tests + +**Files:** +- Create: `tests/extension/package.json` +- Create: `tests/extension/tsconfig.json` +- Create: `tests/extension/vitest.config.ts` +- Create: `tests/extension/browser.test.ts` + +- [ ] **Step 1: Scaffold test infrastructure** + +Create `tests/extension/package.json`: + +```json +{ + "name": "@pi-sandbox/extension-tests", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0", + "playwright-core": "^1.50.0" + } +} +``` + +Create `tests/extension/tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "esModuleInterop": true, + "strict": true, + "outDir": "dist", + "rootDir": ".", + "skipLibCheck": true + }, + "include": ["*.ts"] +} +``` + +Create `tests/extension/vitest.config.ts`: + +```typescript +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + testTimeout: 30_000, + }, +}); +``` + +Run: `cd tests/extension && npm install` + +- [ ] **Step 2: Write browser manager tests** + +Create `tests/extension/browser.test.ts`: + +```typescript +import { describe, it, expect, afterAll } from "vitest"; +import { chromium } from "playwright-core"; + +// Import the BrowserManager from the extension source +// We test it directly rather than through the extension tool layer. +import { BrowserManager } from "../../packages/pi-sandbox-extension/src/browser.js"; + +// Skip all tests if no browser is available +const hasBrowser = await (async () => { + try { + const b = await chromium.launch({ headless: true }); + await b.close(); + return true; + } catch { + return false; + } +})(); + +describe.skipIf(!hasBrowser)("BrowserManager", () => { + const manager = new BrowserManager(); + + afterAll(async () => { + await manager.shutdown(); + }); + + it("getOrCreatePage returns a page for a session", async () => { + const page = await manager.getOrCreatePage("session-1"); + expect(page).toBeDefined(); + expect(page.isClosed()).toBe(false); + }); + + it("getOrCreatePage returns the SAME page for the same session", async () => { + const page1 = await manager.getOrCreatePage("session-2"); + const page2 = await manager.getOrCreatePage("session-2"); + expect(page1).toBe(page2); + }); + + it("closePage closes the page and removes it from the map", async () => { + const page = await manager.getOrCreatePage("session-close"); + expect(page.isClosed()).toBe(false); + await manager.closePage("session-close"); + expect(page.isClosed()).toBe(true); + // A new call should create a fresh page + const page2 = await manager.getOrCreatePage("session-close"); + expect(page2).not.toBe(page); + }); + + it("execute goto navigates and returns content", async () => { + const result = await manager.execute("session-goto", "goto", { + url: "data:text/html,TestHello World", + }); + expect(result).toContain("title: Test"); + expect(result).toContain("Hello World"); + }); + + it("execute screenshot returns base64 PNG", async () => { + // First navigate somewhere + await manager.execute("session-ss", "goto", { + url: "data:text/html,Screenshot Test", + }); + const result = await manager.execute("session-ss", "screenshot", {}); + // PNG base64 starts with iVBOR + expect(result.startsWith("iVBOR")).toBe(true); + }); + + it("execute evaluate runs JavaScript and returns result", async () => { + await manager.execute("session-eval", "goto", { + url: "data:text/html,", + }); + const result = await manager.execute("session-eval", "evaluate", { + script: "1 + 2", + }); + expect(result).toBe("3"); + }); + + it("execute click clicks an element", async () => { + await manager.execute("session-click", "goto", { + url: 'data:text/html,', + }); + await manager.execute("session-click", "click", { selector: "#btn" }); + const title = await manager.execute("session-click", "evaluate", { + script: "document.title", + }); + expect(title).toBe('"clicked"'); + }); + + it("execute type fills an input", async () => { + await manager.execute("session-type", "goto", { + url: 'data:text/html,', + }); + await manager.execute("session-type", "type", { + selector: "#inp", + text: "hello", + }); + const value = await manager.execute("session-type", "evaluate", { + script: 'document.querySelector("#inp").value', + }); + expect(value).toBe('"hello"'); + }); + + it("execute close closes the page", async () => { + await manager.getOrCreatePage("session-close2"); + const result = await manager.execute("session-close2", "close", {}); + expect(result).toBe("Browser page closed."); + }); + + it("execute throws on unknown action", async () => { + await expect( + manager.execute("session-err", "invalid" as any, {}), + ).rejects.toThrow("Unknown browser action"); + }); + + it("shutdown closes all pages and the browser", async () => { + await manager.getOrCreatePage("session-shutdown-1"); + await manager.getOrCreatePage("session-shutdown-2"); + await manager.shutdown(); + // After shutdown, a new call should re-launch + const page = await manager.getOrCreatePage("session-after-shutdown"); + expect(page.isClosed()).toBe(false); + await manager.shutdown(); + }); +}); +``` + +- [ ] **Step 3: Run tests** + +Run: `cd tests/extension && npm test` +Expected: All tests pass (or all skip if no Chromium available). + +- [ ] **Step 4: Commit** + +```bash +git add tests/extension/ +git commit -m "test: add BrowserManager unit and integration tests (Phase 12a)" +``` + +--- + +### Task 6: Update TS contract for allowlist enforcement types + +**Files:** +- Modify: `packages/pi-sandbox-extension/src/contract.ts` + +- [ ] **Step 1: Add new warning codes** + +In `packages/pi-sandbox-extension/src/contract.ts`, update the `WarningCode` type (around line 31): + +```typescript +export type WarningCode = + | "ALLOWLIST_NOT_ENFORCED" + | "NAMESPACE_DEGRADED" + | "RESOURCE_LIMIT_IGNORED" + | "DNS_RESOLUTION_PARTIAL" + | "ALLOWLIST_DNS_FAILED" + | "ENFORCEMENT_LEAK" + | "IPTABLES_NOT_FOUND"; +``` + +- [ ] **Step 2: Update EffectiveNetwork.actual to include "allowlist"** + +Update the `EffectiveNetworkSchema` (around line 128): + +```typescript +export const EffectiveNetworkSchema = Type.Object({ + requested: NetworkModeSchema, + actual: Type.Union([ + Type.Literal("off"), + Type.Literal("full"), + Type.Literal("allowlist"), + ]), + enforcement: Type.Union([ + Type.Literal("enforced"), + Type.Literal("observed"), + Type.Literal("none"), + Type.Literal("best_effort"), + ]), + degraded: Type.Boolean(), +}); +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/pi-sandbox-extension/src/contract.ts +git commit -m "feat: update TS contract for allowlist enforcement types (Phase 12b)" +``` + +--- + +### Task 7: Add ResolvedAllowlistEntry and resolved_allowlist to Rust contract + +**Files:** +- Modify: `crates/pi-sandbox-runtime/src/contract.rs` + +- [ ] **Step 1: Add ResolvedAllowlistEntry struct** + +In `crates/pi-sandbox-runtime/src/contract.rs`, after the `EffectiveNetwork` struct (after line 150), add: + +```rust +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedAllowlistEntry { + pub hostname: String, + pub ips: Vec, + pub resolved: bool, +} +``` + +- [ ] **Step 2: Add resolved_allowlist to EffectiveState** + +Update the `EffectiveState` struct (around line 136): + +```rust +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EffectiveState { + pub network: EffectiveNetwork, + pub namespaces_applied: Vec, + pub env_applied: Vec, + pub resolved_allowlist: Vec, +} +``` + +- [ ] **Step 3: Fix all EffectiveState construction sites** + +In `crates/pi-sandbox-runtime/src/validator.rs`, the `EffectiveState` construction (around line 140) needs the new field: + +```rust + let effective_state = Some(EffectiveState { + network: effective_network, + namespaces_applied, + env_applied, + resolved_allowlist: vec![], + }); +``` + +In `crates/pi-sandbox-runtime/src/plan_builder.rs` tests, update `make_effective_state` (around line 139): + +```rust + fn make_effective_state(overrides: Option) -> EffectiveState { + let o = overrides.unwrap_or_default(); + EffectiveState { + network: EffectiveNetwork { + requested: o.network_requested.unwrap_or_else(|| "full".to_string()), + actual: o.network_actual.unwrap_or_else(|| "full".to_string()), + enforcement: o.network_enforcement.unwrap_or_else(|| "none".to_string()), + degraded: o.network_degraded.unwrap_or(false), + }, + namespaces_applied: o.namespaces.unwrap_or_else(|| vec!["user".to_string(), "pid".to_string()]), + env_applied: vec!["HOME".to_string(), "PATH".to_string()], + resolved_allowlist: vec![], + } + } +``` + +- [ ] **Step 4: Verify it compiles** + +Run: `cargo build -p pi-sandbox-runtime` +Expected: SUCCESS + +- [ ] **Step 5: Commit** + +```bash +git add crates/pi-sandbox-runtime/src/contract.rs crates/pi-sandbox-runtime/src/validator.rs crates/pi-sandbox-runtime/src/plan_builder.rs +git commit -m "feat: add ResolvedAllowlistEntry and resolved_allowlist to Rust contract (Phase 12b)" +``` + +--- + +### Task 8: Implement DNS resolution and iptables detection in validator + +**Files:** +- Modify: `crates/pi-sandbox-runtime/src/validator.rs` + +- [ ] **Step 1: Add DNS resolution and iptables detection functions** + +At the top of `crates/pi-sandbox-runtime/src/validator.rs`, add the necessary imports and helper functions: + +```rust +use std::net::ToSocketAddrs; +use std::path::PathBuf; + +use crate::bubblewrap::BwrapAvailability; +use crate::contract::{ + EffectiveNetwork, EffectiveState, PlanPayload, ResolvedAllowlistEntry, + ValidationError, ValidationPayload, ValidationWarning, PROTOCOL_VERSION, +}; + +/// Resolve a hostname to IP addresses using system DNS. +fn resolve_hostname(hostname: &str) -> Vec { + // Try resolving hostname:0 to get IPs + let addr = format!("{hostname}:0"); + match addr.to_socket_addrs() { + Ok(addrs) => addrs + .map(|a| a.ip().to_string()) + .collect::>(), + Err(_) => vec![], + } +} + +/// Check if iptables binary is available on the host. +fn detect_iptables() -> Option { + #[cfg(not(target_os = "linux"))] + { + return None; + } + + #[cfg(target_os = "linux")] + { + match std::process::Command::new("which") + .arg("iptables") + .output() + { + Ok(output) if output.status.success() => { + let path_str = String::from_utf8_lossy(&output.stdout) + .trim() + .to_string(); + let path = PathBuf::from(&path_str); + if path.exists() { + Some(path) + } else { + None + } + } + _ => None, + } + } +} +``` + +- [ ] **Step 2: Rewrite the effective network resolution logic** + +Replace the current step 5-6 block (effective network resolution + allowlist warning, around lines 72-107) in the `validate` function with the expanded logic: + +```rust + // 5. Resolve effective network + let has_net_namespace = plan.policy.namespaces.iter().any(|ns| ns == "net"); + let bwrap_available = matches!(bwrap, BwrapAvailability::Available { .. }); + + let (effective_network, resolved_allowlist) = match plan.policy.network.mode.as_str() { + "off" => { + let enforcement = if bwrap_available && has_net_namespace { + "enforced" + } else { + "best_effort" + }; + let degraded = enforcement != "enforced"; + ( + EffectiveNetwork { + requested: "off".to_string(), + actual: "off".to_string(), + enforcement: enforcement.to_string(), + degraded, + }, + vec![], + ) + } + "full" => ( + EffectiveNetwork { + requested: "full".to_string(), + actual: "full".to_string(), + enforcement: "observed".to_string(), + degraded: false, + }, + vec![], + ), + "allowlist" => { + let allowlist_hosts = plan + .policy + .network + .allowlist + .as_deref() + .unwrap_or(&[]); + + // Resolve DNS for each hostname + let mut entries: Vec = Vec::new(); + for hostname in allowlist_hosts { + let ips = resolve_hostname(hostname); + let resolved = !ips.is_empty(); + if !resolved { + warnings.push(ValidationWarning { + code: "DNS_RESOLUTION_PARTIAL".to_string(), + message: format!( + "Failed to resolve allowlist hostname '{hostname}'" + ), + }); + } + entries.push(ResolvedAllowlistEntry { + hostname: hostname.clone(), + ips, + resolved, + }); + } + + let any_resolved = entries.iter().any(|e| e.resolved); + let iptables_path = detect_iptables(); + + // Determine if we can enforce + let can_enforce = bwrap_available + && has_net_namespace + && any_resolved + && iptables_path.is_some(); + + if !bwrap_available || !has_net_namespace { + warnings.push(ValidationWarning { + code: "ALLOWLIST_NOT_ENFORCED".to_string(), + message: + "Network allowlist requested but cannot be enforced; running in observed mode" + .to_string(), + }); + } else if iptables_path.is_none() { + warnings.push(ValidationWarning { + code: "IPTABLES_NOT_FOUND".to_string(), + message: + "iptables binary not found on host; allowlist degraded to full/observed" + .to_string(), + }); + } else if !any_resolved { + warnings.push(ValidationWarning { + code: "ALLOWLIST_DNS_FAILED".to_string(), + message: + "All allowlist hostnames failed DNS resolution; degraded to full/observed" + .to_string(), + }); + } + + if can_enforce { + ( + EffectiveNetwork { + requested: "allowlist".to_string(), + actual: "allowlist".to_string(), + enforcement: "enforced".to_string(), + degraded: false, + }, + entries, + ) + } else { + ( + EffectiveNetwork { + requested: "allowlist".to_string(), + actual: "full".to_string(), + enforcement: "observed".to_string(), + degraded: true, + }, + entries, + ) + } + } + _ => ( + EffectiveNetwork { + requested: plan.policy.network.mode.clone(), + actual: "full".to_string(), + enforcement: "none".to_string(), + degraded: false, + }, + vec![], + ), + }; +``` + +- [ ] **Step 3: Update the EffectiveState construction** + +Update the effective_state construction to use the new `resolved_allowlist`: + +```rust + let effective_state = Some(EffectiveState { + network: effective_network, + namespaces_applied, + env_applied, + resolved_allowlist, + }); +``` + +- [ ] **Step 4: Verify it compiles** + +Run: `cargo build -p pi-sandbox-runtime` +Expected: SUCCESS + +- [ ] **Step 5: Run existing tests to check nothing broke** + +Run: `cargo test -p pi-sandbox-runtime` +Expected: All existing tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add crates/pi-sandbox-runtime/src/validator.rs +git commit -m "feat: add DNS resolution and iptables detection to validator (Phase 12b)" +``` + +--- + +### Task 9: Add iptables wrapper script generation to plan_builder + +**Files:** +- Modify: `crates/pi-sandbox-runtime/src/plan_builder.rs` + +- [ ] **Step 1: Update the build function to handle allowlist enforcement** + +In `crates/pi-sandbox-runtime/src/plan_builder.rs`, update the namespace handling (step 4) to also unshare-net for allowlist mode, and add wrapper script logic. + +First, update the imports at the top: + +```rust +use crate::contract::{EffectiveState, PlanPayload, ResolvedAllowlistEntry}; +``` + +Replace the current `net` arm in the namespace match (around line 56-60) with: + +```rust + "net" => { + // Unshare network for "off" mode AND for enforced "allowlist" mode + if effective_state.network.actual == "off" + || (effective_state.network.actual == "allowlist" + && effective_state.network.enforcement == "enforced") + { + argv.push("--unshare-net".to_string()); + } + } +``` + +- [ ] **Step 2: Add iptables wrapper generation function** + +After the `build` function (before `#[cfg(test)]`), add: + +```rust +/// Generate an iptables wrapper script for allowlist enforcement. +/// +/// The script sets iptables OUTPUT policy to DROP, adds ACCEPT rules for +/// each resolved IP + loopback + ESTABLISHED, then exec's the user command. +pub fn generate_iptables_wrapper(entries: &[ResolvedAllowlistEntry]) -> String { + let mut script = String::new(); + script.push_str("#!/bin/sh\nset -e\n"); + script.push_str("iptables -P OUTPUT DROP\n"); + script.push_str("iptables -A OUTPUT -o lo -j ACCEPT\n"); + script.push_str("iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT\n"); + + for entry in entries { + for ip in &entry.ips { + script.push_str(&format!("iptables -A OUTPUT -d {ip} -j ACCEPT\n")); + } + } + + script.push_str("exec \"$@\"\n"); + script +} + +/// Build the Bubblewrap argument vector, wrapping the command with an iptables +/// script when allowlist enforcement is active. +/// +/// When allowlist enforcement is enforced: +/// 1. The iptables binary is mounted read-only +/// 2. A wrapper script is written to /tmp/.pi-sandbox-allowlist.sh +/// 3. The bwrap command becomes: /bin/sh /tmp/.pi-sandbox-allowlist.sh +pub fn build_with_allowlist( + plan: &PlanPayload, + effective_state: &EffectiveState, + iptables_path: Option<&str>, +) -> Vec { + let needs_wrapper = effective_state.network.actual == "allowlist" + && effective_state.network.enforcement == "enforced" + && iptables_path.is_some(); + + let mut argv = Vec::new(); + + // 1. Mounts + for mount in &plan.manifest.mounts { + match mount.mount_type.as_str() { + "directory" | "file" => { + let flag = if mount.writable { "--bind" } else { "--ro-bind" }; + let source = mount.source.as_deref().unwrap_or(&mount.target); + argv.push(flag.to_string()); + argv.push(source.to_string()); + argv.push(mount.target.clone()); + } + "tmpfs" => { + argv.push("--tmpfs".to_string()); + argv.push(mount.target.clone()); + } + _ => {} + } + } + + // Mount iptables binary if needed + if let (true, Some(ipt)) = (needs_wrapper, iptables_path) { + argv.push("--ro-bind".to_string()); + argv.push(ipt.to_string()); + argv.push("/usr/sbin/iptables".to_string()); + } + + // 2. Devices + for dev in &["/dev/null", "/dev/zero", "/dev/urandom", "/dev/random"] { + argv.push("--dev-bind".to_string()); + argv.push(dev.to_string()); + argv.push(dev.to_string()); + } + + // 3. Proc + argv.push("--proc".to_string()); + argv.push("/proc".to_string()); + + // 4. Namespaces + for ns in &effective_state.namespaces_applied { + match ns.as_str() { + "pid" => argv.push("--unshare-pid".to_string()), + "ipc" => argv.push("--unshare-ipc".to_string()), + "uts" => argv.push("--unshare-uts".to_string()), + "net" => { + if effective_state.network.actual == "off" + || (effective_state.network.actual == "allowlist" + && effective_state.network.enforcement == "enforced") + { + argv.push("--unshare-net".to_string()); + } + } + "cgroup-try" => argv.push("--unshare-cgroup-try".to_string()), + "user" => {} + _ => {} + } + } + + // 5. Environment + argv.push("--clearenv".to_string()); + for (key, value) in &plan.manifest.env { + argv.push("--setenv".to_string()); + argv.push(key.clone()); + argv.push(value.clone()); + } + + // 6. Working directory + argv.push("--chdir".to_string()); + argv.push(plan.manifest.cwd.clone()); + + // 7. Command + argv.push("--".to_string()); + if needs_wrapper { + // Wrapper script is written by supervisor to a temp file then bind-mounted + argv.push("/bin/sh".to_string()); + argv.push("/tmp/.pi-sandbox-allowlist.sh".to_string()); + } + for part in &plan.command { + argv.push(part.clone()); + } + + argv +} +``` + +- [ ] **Step 3: Add tests for the new functions** + +Add to the existing `#[cfg(test)] mod tests` block in plan_builder.rs: + +```rust + #[test] + fn generate_iptables_wrapper_produces_valid_script() { + let entries = vec![ + ResolvedAllowlistEntry { + hostname: "example.com".to_string(), + ips: vec!["93.184.216.34".to_string(), "2606:2800:220:1::1".to_string()], + resolved: true, + }, + ]; + let script = generate_iptables_wrapper(&entries); + assert!(script.contains("#!/bin/sh")); + assert!(script.contains("iptables -P OUTPUT DROP")); + assert!(script.contains("iptables -A OUTPUT -d 93.184.216.34 -j ACCEPT")); + assert!(script.contains("iptables -A OUTPUT -d 2606:2800:220:1::1 -j ACCEPT")); + assert!(script.contains("iptables -A OUTPUT -o lo -j ACCEPT")); + assert!(script.contains("exec \"$@\"")); + } + + #[test] + fn generate_iptables_wrapper_with_no_entries_still_valid() { + let script = generate_iptables_wrapper(&[]); + assert!(script.contains("iptables -P OUTPUT DROP")); + assert!(script.contains("exec \"$@\"")); + } + + #[test] + fn build_with_allowlist_enforced_includes_unshare_net() { + let plan = make_plan(None); + let state = make_effective_state(Some(EffectiveOverrides { + namespaces: Some(vec!["user".to_string(), "pid".to_string(), "net".to_string()]), + network_requested: Some("allowlist".to_string()), + network_actual: Some("allowlist".to_string()), + network_enforcement: Some("enforced".to_string()), + ..Default::default() + })); + let argv = build_with_allowlist(&plan, &state, Some("/usr/sbin/iptables")); + assert!(argv.contains(&"--unshare-net".to_string())); + } + + #[test] + fn build_with_allowlist_enforced_mounts_iptables() { + let plan = make_plan(None); + let state = make_effective_state(Some(EffectiveOverrides { + namespaces: Some(vec!["user".to_string(), "pid".to_string(), "net".to_string()]), + network_requested: Some("allowlist".to_string()), + network_actual: Some("allowlist".to_string()), + network_enforcement: Some("enforced".to_string()), + ..Default::default() + })); + let argv = build_with_allowlist(&plan, &state, Some("/usr/sbin/iptables")); + // Check iptables is mounted + let idx = argv.windows(3).position(|w| { + w[0] == "--ro-bind" && w[1] == "/usr/sbin/iptables" && w[2] == "/usr/sbin/iptables" + }); + assert!(idx.is_some()); + } + + #[test] + fn build_with_allowlist_enforced_uses_wrapper_command() { + let plan = make_plan(Some(PlanOverrides { + command: Some(vec!["curl".to_string(), "https://example.com".to_string()]), + ..Default::default() + })); + let state = make_effective_state(Some(EffectiveOverrides { + namespaces: Some(vec!["user".to_string(), "pid".to_string(), "net".to_string()]), + network_requested: Some("allowlist".to_string()), + network_actual: Some("allowlist".to_string()), + network_enforcement: Some("enforced".to_string()), + ..Default::default() + })); + let argv = build_with_allowlist(&plan, &state, Some("/usr/sbin/iptables")); + let sep = argv.iter().position(|a| a == "--").unwrap(); + assert_eq!(argv[sep + 1], "/bin/sh"); + assert_eq!(argv[sep + 2], "/tmp/.pi-sandbox-allowlist.sh"); + assert_eq!(argv[sep + 3], "curl"); + assert_eq!(argv[sep + 4], "https://example.com"); + } + + #[test] + fn build_with_allowlist_not_enforced_skips_wrapper() { + let plan = make_plan(None); + let state = make_effective_state(Some(EffectiveOverrides { + network_requested: Some("allowlist".to_string()), + network_actual: Some("full".to_string()), + network_enforcement: Some("observed".to_string()), + network_degraded: Some(true), + ..Default::default() + })); + let argv = build_with_allowlist(&plan, &state, None); + // Should NOT contain wrapper + assert!(!argv.contains(&"/tmp/.pi-sandbox-allowlist.sh".to_string())); + // Should have the original command directly + let sep = argv.iter().position(|a| a == "--").unwrap(); + assert_eq!(argv[sep + 1], "echo"); + } +``` + +- [ ] **Step 4: Verify tests pass** + +Run: `cargo test -p pi-sandbox-runtime` +Expected: All tests pass, including the new ones. + +- [ ] **Step 5: Commit** + +```bash +git add crates/pi-sandbox-runtime/src/plan_builder.rs +git commit -m "feat: add iptables wrapper generation and allowlist-aware plan builder (Phase 12b)" +``` + +--- + +### Task 10: Wire allowlist enforcement into supervisor and main + +**Files:** +- Modify: `crates/pi-sandbox-runtime/src/supervisor.rs` +- Modify: `crates/pi-sandbox-runtime/src/main.rs` + +- [ ] **Step 1: Update supervisor to use build_with_allowlist and write wrapper script** + +In `crates/pi-sandbox-runtime/src/supervisor.rs`, update the imports at the top: + +```rust +use crate::plan_builder; +``` + +(Add this if not already imported. Currently the file uses `crate::plan_builder` only in the `Available` branch.) + +In the `supervise` function, update the `BwrapAvailability::Available` branch (around line 46-52) to handle the allowlist wrapper: + +```rust + BwrapAvailability::Available { path } => { + // Detect iptables path for allowlist enforcement + let iptables_path = if effective_state.network.actual == "allowlist" + && effective_state.network.enforcement == "enforced" + { + // Try to find iptables + std::process::Command::new("which") + .arg("iptables") + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + } else { + None + }; + + let argv = plan_builder::build_with_allowlist( + plan, + effective_state, + iptables_path.as_deref(), + ); + + // If allowlist enforcement is active, write the wrapper script to a temp file + // that bwrap will bind-mount into the sandbox + if effective_state.network.actual == "allowlist" + && effective_state.network.enforcement == "enforced" + { + let script = plan_builder::generate_iptables_wrapper( + &effective_state.resolved_allowlist, + ); + let script_path = std::env::temp_dir().join(".pi-sandbox-allowlist.sh"); + std::fs::write(&script_path, &script).expect("failed to write iptables wrapper"); + // Add bind mount for the script + let mut full_argv = vec![ + "--ro-bind".to_string(), + script_path.to_string_lossy().to_string(), + "/tmp/.pi-sandbox-allowlist.sh".to_string(), + ]; + full_argv.extend(argv); + let mut c = Command::new(path); + c.args(&full_argv); + c + } else { + let mut c = Command::new(path); + c.args(&argv); + c + } + } +``` + +- [ ] **Step 2: Add enforcement leak detection after observer stops** + +In `supervisor.rs`, after the observer `stop()` call and `compute_would_have_blocked` (around line 194), add enforcement leak detection: + +```rust + // Stop observer and collect observed connections. + let observed = observer.stop(); + let would_have_blocked = + compute_would_have_blocked(&observed, &plan.policy.network.allowlist); + + // Enforcement leak detection: if enforcement was active but observer saw blocked connections + if effective_state.network.enforcement == "enforced" + && effective_state.network.actual == "allowlist" + && !would_have_blocked.is_empty() + { + let s = seq.fetch_add(1, Ordering::SeqCst); + emit(&crate::contract::WarningEnvelope::new( + s, + "ENFORCEMENT_LEAK".to_string(), + format!( + "Observer detected {} connection(s) that should have been blocked by iptables", + would_have_blocked.len() + ), + )); + } +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cargo build -p pi-sandbox-runtime` +Expected: SUCCESS + +- [ ] **Step 4: Commit** + +```bash +git add crates/pi-sandbox-runtime/src/supervisor.rs +git commit -m "feat: wire allowlist enforcement into supervisor with leak detection (Phase 12b)" +``` + +--- + +### Task 11: Add allowlist enforcement protocol tests + +**Files:** +- Create: `tests/protocol/allowlist-enforced.test.ts` + +- [ ] **Step 1: Write the protocol test** + +Create `tests/protocol/allowlist-enforced.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { makePlan, spawnRuntime } from "./helpers.js"; +import { platform } from "node:os"; + +describe("Protocol Test 8: Allowlist Enforcement", () => { + // On Linux with bwrap + iptables: enforcement should be "enforced" + // On macOS: should degrade to observed (existing behavior) + const isLinux = platform() === "linux"; + + it("enforces allowlist on Linux with bwrap and iptables", async () => { + if (!isLinux) { + console.log("Skipping: allowlist enforcement requires Linux with bwrap"); + return; + } + + const rt = spawnRuntime(); + + rt.send( + makePlan({ + command: ["echo", "allowlist-enforced"], + manifest: { + mounts: [{ type: "tmpfs", target: "/tmp", writable: true }], + env: { HOME: "/home/sandbox", PATH: "/usr/bin:/bin:/usr/sbin" }, + cwd: "/tmp", + }, + policy: { + namespaces: ["user", "pid", "net"], + network: { + mode: "allowlist", + allowlist: ["localhost"], + }, + allowedWritableTargets: ["/workspace", "/tmp"], + strictWritePolicy: false, + }, + }), + ); + + const events = await rt.readAllEvents(); + + const validation = events[0]; + expect(validation.type).toBe("validation"); + const vPayload = validation.payload as any; + expect(vPayload.ok).toBe(true); + + const eff = vPayload.effectiveState; + + // If bwrap and iptables are available, enforcement should be "enforced" + // Otherwise it degrades — check what the runtime actually reports + if (eff.network.enforcement === "enforced") { + expect(eff.network.actual).toBe("allowlist"); + expect(eff.network.degraded).toBe(false); + // resolved_allowlist should have entries + expect(eff.resolvedAllowlist.length).toBeGreaterThan(0); + expect(eff.resolvedAllowlist[0].hostname).toBe("localhost"); + expect(eff.resolvedAllowlist[0].resolved).toBe(true); + } else { + // Degraded — bwrap or iptables not available + expect(eff.network.actual).toBe("full"); + expect(eff.network.enforcement).toBe("observed"); + expect(eff.network.degraded).toBe(true); + } + + const result = events[events.length - 1]; + expect(result.type).toBe("result"); + const rPayload = result.payload as any; + expect(rPayload.exitCode).toBe(0); + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }); + + it("degrades allowlist to observed on macOS", async () => { + if (isLinux) { + console.log("Skipping: this test is for macOS degradation"); + return; + } + + const rt = spawnRuntime(); + + rt.send( + makePlan({ + command: ["echo", "allowlist-degraded"], + manifest: { + mounts: [{ type: "tmpfs", target: "/tmp", writable: true }], + env: { HOME: "/home/sandbox", PATH: "/usr/bin:/bin" }, + cwd: "/tmp", + }, + policy: { + namespaces: ["user", "pid", "net"], + network: { + mode: "allowlist", + allowlist: ["example.com"], + }, + allowedWritableTargets: ["/workspace", "/tmp"], + strictWritePolicy: false, + }, + }), + ); + + const events = await rt.readAllEvents(); + + const validation = events[0]; + expect(validation.type).toBe("validation"); + const vPayload = validation.payload as any; + expect(vPayload.ok).toBe(true); + + // macOS: always degraded + expect(vPayload.effectiveState.network.requested).toBe("allowlist"); + expect(vPayload.effectiveState.network.actual).toBe("full"); + expect(vPayload.effectiveState.network.enforcement).toBe("observed"); + expect(vPayload.effectiveState.network.degraded).toBe(true); + + // Should have ALLOWLIST_NOT_ENFORCED warning + const warnings: any[] = vPayload.warnings ?? []; + const found = warnings.some( + (w: any) => w.code === "ALLOWLIST_NOT_ENFORCED", + ); + expect(found).toBe(true); + + const result = events[events.length - 1]; + expect(result.type).toBe("result"); + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }); + + it("emits DNS_RESOLUTION_PARTIAL for unresolvable hostname", async () => { + const rt = spawnRuntime(); + + rt.send( + makePlan({ + command: ["echo", "dns-partial"], + manifest: { + mounts: [{ type: "tmpfs", target: "/tmp", writable: true }], + env: { HOME: "/home/sandbox", PATH: "/usr/bin:/bin" }, + cwd: "/tmp", + }, + policy: { + namespaces: ["user", "pid", "net"], + network: { + mode: "allowlist", + allowlist: ["this-host-definitely-does-not-exist.invalid"], + }, + allowedWritableTargets: ["/workspace", "/tmp"], + strictWritePolicy: false, + }, + }), + ); + + const events = await rt.readAllEvents(); + + const validation = events[0]; + expect(validation.type).toBe("validation"); + const vPayload = validation.payload as any; + expect(vPayload.ok).toBe(true); + + // Should have DNS resolution warning + const warnings: any[] = vPayload.warnings ?? []; + const dnsWarning = warnings.find( + (w: any) => + w.code === "DNS_RESOLUTION_PARTIAL" || + w.code === "ALLOWLIST_DNS_FAILED", + ); + expect(dnsWarning).toBeDefined(); + + // Should degrade since no IPs resolved + expect(vPayload.effectiveState.network.actual).toBe("full"); + expect(vPayload.effectiveState.network.degraded).toBe(true); + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run protocol tests** + +Run: `cd tests/protocol && npm test` +Expected: All tests pass (new tests execute on both platforms appropriately). + +- [ ] **Step 3: Commit** + +```bash +git add tests/protocol/allowlist-enforced.test.ts +git commit -m "test: add allowlist enforcement protocol tests (Phase 12b)" +``` + +--- + +### Task 12: Run full test suite and tag completion + +**Files:** None (verification only) + +- [ ] **Step 1: Build Rust runtime** + +Run: `cargo build -p pi-sandbox-runtime --release` +Expected: SUCCESS with no errors. + +- [ ] **Step 2: Run Rust unit tests** + +Run: `cargo test -p pi-sandbox-runtime` +Expected: All tests pass (existing + new plan_builder tests + observer tests). + +- [ ] **Step 3: Run protocol tests** + +Run: `cd tests/protocol && npm test` +Expected: All tests pass. + +- [ ] **Step 4: Run integration tests** + +Run: `cd tests/integration && npm test` +Expected: All tests pass. + +- [ ] **Step 5: Run extension tests (if browser available)** + +Run: `cd tests/extension && npm test` +Expected: All tests pass (or skip if no Chromium). + +- [ ] **Step 6: Tag completion** + +```bash +git tag v1-phases-11-12-complete +``` diff --git a/docs/superpowers/plans/2026-04-08-docker-fallback-macos.md b/docs/superpowers/plans/2026-04-08-docker-fallback-macos.md new file mode 100644 index 0000000..3452ef6 --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-docker-fallback-macos.md @@ -0,0 +1,1312 @@ +# Docker Fallback for macOS Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Docker-based bwrap sandboxing on macOS so macOS users get real kernel-level isolation via a Docker sidecar container. + +**Architecture:** When macOS is detected and Docker Desktop is available, the Rust runtime starts a lightweight Debian-slim sidecar container with bwrap installed. The supervisor delegates bwrap execution via `docker exec -i bwrap `. Host paths are rewritten to container paths before building the bwrap command. The NDJSON protocol and plan_builder are unchanged. + +**Tech Stack:** Rust (pi-sandbox-runtime crate), Docker CLI, Debian bookworm-slim, bubblewrap, TypeScript (contract types) + +**Design Spec:** `docs/superpowers/specs/2026-04-08-docker-fallback-macos-design.md` + +--- + +## File Map + +### New Files + +| Path | Responsibility | +|------|----------------| +| `docker/pi-sandbox-sidecar.Dockerfile` | Debian-slim image with bwrap + iptables + common tools | +| `crates/pi-sandbox-runtime/src/docker.rs` | Docker detection, sidecar lifecycle, path rewriting | +| `tests/protocol/docker-sidecar.test.ts` | Docker sidecar lifecycle and execution tests (env-gated) | + +### Modified Files + +| Path | Change | +|------|--------| +| `crates/pi-sandbox-runtime/src/contract.rs:19-30` | Add `Clone` to plan types | +| `crates/pi-sandbox-runtime/src/contract.rs:135-142` | Add `isolation_backend` to `EffectiveState` | +| `crates/pi-sandbox-runtime/src/plan_builder.rs:257-269` | Update test helper `make_effective_state()` | +| `packages/pi-sandbox-extension/src/contract.ts:31-38` | Add new warning codes | +| `packages/pi-sandbox-extension/src/contract.ts:149-154` | Add `isolationBackend` to EffectiveState | +| `crates/pi-sandbox-runtime/src/bubblewrap.rs:4-8` | Add `DockerAvailable` variant | +| `crates/pi-sandbox-runtime/src/bubblewrap.rs:18-24` | Update `detect()` for macOS Docker | +| `crates/pi-sandbox-runtime/src/validator.rs:49,114,236-252,266` | Treat `DockerAvailable` as `Available`, set `isolation_backend` | +| `crates/pi-sandbox-runtime/src/supervisor.rs:46-102` | Add Docker execution branch + crash recovery | +| `crates/pi-sandbox-runtime/src/main.rs:1` | Add `mod docker;` | + +--- + +### Task 1: Create the sidecar Dockerfile + +**Files:** +- Create: `docker/pi-sandbox-sidecar.Dockerfile` + +- [ ] **Step 1: Create the docker directory and Dockerfile** + +```dockerfile +# docker/pi-sandbox-sidecar.Dockerfile +# +# Lightweight Linux sidecar for running bwrap on macOS via Docker Desktop. +# The container provides a Linux kernel for namespace isolation; +# bwrap inside it provides per-execution sandboxing. +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bubblewrap \ + iptables \ + python3 \ + nodejs \ + git \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* +``` + +- [ ] **Step 2: Build the image to verify it works** + +Run: `docker build -t pi-sandbox-base:latest -f docker/pi-sandbox-sidecar.Dockerfile .` + +Expected: Image builds successfully. Final output like `Successfully tagged pi-sandbox-base:latest`. + +- [ ] **Step 3: Verify bwrap is available inside the image** + +Run: `docker run --rm pi-sandbox-base:latest which bwrap` + +Expected: `/usr/bin/bwrap` + +- [ ] **Step 4: Commit** + +```bash +git add docker/pi-sandbox-sidecar.Dockerfile +git commit -m "feat: add Docker sidecar Dockerfile for macOS bwrap fallback" +``` + +--- + +### Task 2: Rust contract — Clone derives + isolation_backend field + +**Files:** +- Modify: `crates/pi-sandbox-runtime/src/contract.rs:19-30,40-48,50-60,62-67,69-76,135-142` +- Modify: `crates/pi-sandbox-runtime/src/validator.rs:266-271` +- Modify: `crates/pi-sandbox-runtime/src/plan_builder.rs:257-269` + +The `PlanPayload` and its nested types need `Clone` so the Docker path-rewriting function can clone and modify the plan. The `EffectiveState` needs a new `isolation_backend` field for observability. + +- [ ] **Step 1: Add Clone derive to plan types in contract.rs** + +In `crates/pi-sandbox-runtime/src/contract.rs`, update the derive macros on these structs: + +```rust +// Line 19: PlanPayload +#[derive(Debug, Clone, Deserialize)] + +// Line 32: Manifest +#[derive(Debug, Clone, Deserialize)] + +// Line 40: Mount +#[derive(Debug, Clone, Deserialize)] + +// Line 50: Policy +#[derive(Debug, Clone, Deserialize)] + +// Line 62: NetworkConfig +#[derive(Debug, Clone, Deserialize)] + +// Line 69: ResourceLimits +#[derive(Debug, Clone, Deserialize)] + +// Line 79: CancelPayload +#[derive(Debug, Clone, Deserialize)] +``` + +- [ ] **Step 2: Add isolation_backend to EffectiveState in contract.rs** + +In `crates/pi-sandbox-runtime/src/contract.rs`, update the `EffectiveState` struct (line 135-142): + +```rust +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EffectiveState { + pub network: EffectiveNetwork, + pub namespaces_applied: Vec, + pub env_applied: Vec, + pub resolved_allowlist: Vec, + pub isolation_backend: String, +} +``` + +- [ ] **Step 3: Update validator.rs to set isolation_backend** + +In `crates/pi-sandbox-runtime/src/validator.rs`, update the `EffectiveState` construction (line 266-271): + +```rust + let isolation_backend = match bwrap { + BwrapAvailability::Available { .. } => "native".to_string(), + BwrapAvailability::Unavailable { .. } => "none".to_string(), + }; + + let effective_state = Some(EffectiveState { + network: effective_network, + namespaces_applied, + env_applied, + resolved_allowlist, + isolation_backend, + }); +``` + +- [ ] **Step 4: Update plan_builder.rs test helper** + +In `crates/pi-sandbox-runtime/src/plan_builder.rs`, update `make_effective_state()` (line 257-269): + +```rust + fn make_effective_state(overrides: Option) -> EffectiveState { + let o = overrides.unwrap_or_default(); + EffectiveState { + network: EffectiveNetwork { + requested: o.network_requested.unwrap_or_else(|| "full".to_string()), + actual: o.network_actual.unwrap_or_else(|| "full".to_string()), + enforcement: o.network_enforcement.unwrap_or_else(|| "none".to_string()), + degraded: o.network_degraded.unwrap_or(false), + }, + namespaces_applied: o.namespaces.unwrap_or_else(|| vec!["user".to_string(), "pid".to_string()]), + env_applied: vec!["HOME".to_string(), "PATH".to_string()], + resolved_allowlist: vec![], + isolation_backend: "native".to_string(), + } + } +``` + +- [ ] **Step 5: Run Rust tests to verify compilation and correctness** + +Run: `cd crates/pi-sandbox-runtime && cargo test` + +Expected: All existing tests pass. No compilation errors. + +- [ ] **Step 6: Commit** + +```bash +git add crates/pi-sandbox-runtime/src/contract.rs crates/pi-sandbox-runtime/src/validator.rs crates/pi-sandbox-runtime/src/plan_builder.rs +git commit -m "feat: add Clone to plan types and isolation_backend to EffectiveState" +``` + +--- + +### Task 3: TS contract — isolationBackend + warning codes + +**Files:** +- Modify: `packages/pi-sandbox-extension/src/contract.ts:31-38,149-154` + +- [ ] **Step 1: Add new warning codes** + +In `packages/pi-sandbox-extension/src/contract.ts`, update the `WarningCode` type (line 31-38): + +```typescript +export type WarningCode = + | "ALLOWLIST_NOT_ENFORCED" + | "NAMESPACE_DEGRADED" + | "RESOURCE_LIMIT_IGNORED" + | "DNS_RESOLUTION_PARTIAL" + | "ALLOWLIST_DNS_FAILED" + | "ENFORCEMENT_LEAK" + | "IPTABLES_NOT_FOUND" + | "DOCKER_NOT_AVAILABLE" + | "DOCKER_SIDECAR_RESTARTED"; +``` + +- [ ] **Step 2: Add isolationBackend to EffectiveStateSchema** + +In `packages/pi-sandbox-extension/src/contract.ts`, update the `EffectiveStateSchema` (line 149-154): + +```typescript +export const EffectiveStateSchema = Type.Object({ + network: EffectiveNetworkSchema, + namespacesApplied: Type.Array(Type.String()), + envApplied: Type.Array(Type.String()), + isolationBackend: Type.Union([ + Type.Literal("native"), + Type.Literal("docker"), + Type.Literal("none"), + ]), +}); +export type EffectiveState = Static; +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/pi-sandbox-extension/src/contract.ts +git commit -m "feat: add isolationBackend and Docker warning codes to TS contract" +``` + +--- + +### Task 4: docker.rs — path rewriting with unit tests + +**Files:** +- Create: `crates/pi-sandbox-runtime/src/docker.rs` +- Modify: `crates/pi-sandbox-runtime/src/main.rs:1` + +This task creates the `docker.rs` module with the pure path-rewriting functions and their unit tests. The Docker lifecycle code comes in Task 6. + +- [ ] **Step 1: Write the failing tests for path rewriting** + +Create `crates/pi-sandbox-runtime/src/docker.rs`: + +```rust +use crate::contract::PlanPayload; + +/// Rewrite a single host path to its container-side equivalent. +/// +/// If the path starts with `host_prefix`, replace that prefix with `container_prefix`. +/// Otherwise return the path unchanged. +pub fn rewrite_path(path: &str, host_prefix: &str, container_prefix: &str) -> String { + todo!() +} + +/// Clone a PlanPayload and rewrite all host paths to container paths. +/// +/// Rewrites: +/// - `manifest.mounts[].source` for directory/file bind mounts +/// - `manifest.cwd` +pub fn rewrite_plan( + plan: &PlanPayload, + host_prefix: &str, + container_prefix: &str, +) -> PlanPayload { + todo!() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::contract::{Manifest, Mount, NetworkConfig, Policy}; + use std::collections::HashMap; + + #[test] + fn rewrite_path_replaces_matching_prefix() { + let result = rewrite_path( + "/Users/me/.local/share/pi-sandbox/sessions/abc/workspace", + "/Users/me/.local/share/pi-sandbox", + "/pi-sandbox", + ); + assert_eq!(result, "/pi-sandbox/sessions/abc/workspace"); + } + + #[test] + fn rewrite_path_leaves_non_matching_path_unchanged() { + let result = rewrite_path( + "/usr/bin/python3", + "/Users/me/.local/share/pi-sandbox", + "/pi-sandbox", + ); + assert_eq!(result, "/usr/bin/python3"); + } + + #[test] + fn rewrite_path_replaces_only_first_occurrence() { + let result = rewrite_path( + "/data/data/nested", + "/data", + "/mnt", + ); + assert_eq!(result, "/mnt/data/nested"); + } + + #[test] + fn rewrite_plan_rewrites_mount_sources_and_cwd() { + let plan = PlanPayload { + version: 1, + session_id: "test".to_string(), + execution_id: "test".to_string(), + requested_profile: "build-install".to_string(), + runtime_base_name: None, + manifest: Manifest { + mounts: vec![ + Mount { + mount_type: "directory".to_string(), + source: Some("/Users/me/.local/share/pi-sandbox/sessions/s1/workspace".to_string()), + target: "/workspace".to_string(), + writable: true, + }, + Mount { + mount_type: "tmpfs".to_string(), + source: None, + target: "/tmp".to_string(), + writable: true, + }, + ], + env: HashMap::new(), + cwd: "/Users/me/.local/share/pi-sandbox/sessions/s1/workspace".to_string(), + }, + policy: Policy { + namespaces: vec![], + network: NetworkConfig { + mode: "full".to_string(), + allowlist: None, + }, + resource_limits: None, + allowed_writable_targets: vec!["/workspace".to_string(), "/tmp".to_string()], + strict_write_policy: false, + env_allowlist: None, + deny_commands: None, + }, + command: vec!["echo".to_string(), "hello".to_string()], + }; + + let rewritten = rewrite_plan( + &plan, + "/Users/me/.local/share/pi-sandbox", + "/pi-sandbox", + ); + + assert_eq!( + rewritten.manifest.mounts[0].source.as_deref(), + Some("/pi-sandbox/sessions/s1/workspace") + ); + assert_eq!(rewritten.manifest.mounts[1].source, None); + assert_eq!(rewritten.manifest.cwd, "/pi-sandbox/sessions/s1/workspace"); + // Original plan is unchanged + assert_eq!( + plan.manifest.cwd, + "/Users/me/.local/share/pi-sandbox/sessions/s1/workspace" + ); + } +} +``` + +- [ ] **Step 2: Add `mod docker;` to main.rs** + +In `crates/pi-sandbox-runtime/src/main.rs`, add `docker` to the module declarations (replace lines 1-7): + +```rust +mod bubblewrap; +mod contract; +mod docker; +mod observer; +mod plan_builder; +mod supervisor; +mod timestamps; +mod validator; +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `cd crates/pi-sandbox-runtime && cargo test docker` + +Expected: FAIL — `todo!()` panics. + +- [ ] **Step 4: Implement rewrite_path** + +In `crates/pi-sandbox-runtime/src/docker.rs`, replace the `rewrite_path` body: + +```rust +pub fn rewrite_path(path: &str, host_prefix: &str, container_prefix: &str) -> String { + if path.starts_with(host_prefix) { + path.replacen(host_prefix, container_prefix, 1) + } else { + path.to_string() + } +} +``` + +- [ ] **Step 5: Implement rewrite_plan** + +In `crates/pi-sandbox-runtime/src/docker.rs`, replace the `rewrite_plan` body: + +```rust +pub fn rewrite_plan( + plan: &PlanPayload, + host_prefix: &str, + container_prefix: &str, +) -> PlanPayload { + let mut rewritten = plan.clone(); + + for mount in &mut rewritten.manifest.mounts { + if let Some(ref mut source) = mount.source { + *source = rewrite_path(source, host_prefix, container_prefix); + } + } + + rewritten.manifest.cwd = rewrite_path( + &rewritten.manifest.cwd, + host_prefix, + container_prefix, + ); + + rewritten +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cd crates/pi-sandbox-runtime && cargo test docker` + +Expected: All 4 tests pass. + +- [ ] **Step 7: Run full test suite** + +Run: `cd crates/pi-sandbox-runtime && cargo test` + +Expected: All tests pass (existing + new). + +- [ ] **Step 8: Commit** + +```bash +git add crates/pi-sandbox-runtime/src/docker.rs crates/pi-sandbox-runtime/src/main.rs +git commit -m "feat: add docker.rs with path rewriting functions and tests" +``` + +--- + +### Task 5: BwrapAvailability::DockerAvailable + validator + supervisor stub + +**Files:** +- Modify: `crates/pi-sandbox-runtime/src/bubblewrap.rs:4-8,71-82,84-96` +- Modify: `crates/pi-sandbox-runtime/src/validator.rs:114,236-252,266` +- Modify: `crates/pi-sandbox-runtime/src/supervisor.rs:46-102` + +This task adds the `DockerAvailable` variant to the `BwrapAvailability` enum and updates all exhaustive match arms. The supervisor gets a temporary fallback arm (replaced with real Docker execution in Task 8). The validator treats `DockerAvailable` identically to `Available`. + +- [ ] **Step 1: Add DockerAvailable variant to BwrapAvailability** + +In `crates/pi-sandbox-runtime/src/bubblewrap.rs`, update the enum (line 4-8): + +```rust +#[derive(Debug, Clone)] +pub enum BwrapAvailability { + Available { path: PathBuf }, + DockerAvailable { + container_id: String, + host_sessions_dir: String, + container_sessions_dir: String, + }, + Unavailable { reason: String }, +} +``` + +- [ ] **Step 2: Update bubblewrap.rs test for the new variant** + +In `crates/pi-sandbox-runtime/src/bubblewrap.rs`, update the `detect_returns_a_result` test (line 71-82): + +```rust + #[test] + fn detect_returns_a_result() { + let result = detect(); + match &result { + BwrapAvailability::Available { path } => { + assert!(path.exists()); + } + BwrapAvailability::DockerAvailable { container_id, .. } => { + assert!(!container_id.is_empty()); + } + BwrapAvailability::Unavailable { reason } => { + assert!(!reason.is_empty()); + } + } + } +``` + +- [ ] **Step 3: Update validator.rs — bwrap_available check** + +In `crates/pi-sandbox-runtime/src/validator.rs`, update line 114: + +```rust + let bwrap_available = matches!( + bwrap, + BwrapAvailability::Available { .. } | BwrapAvailability::DockerAvailable { .. } + ); +``` + +- [ ] **Step 4: Update validator.rs — namespace resolution match** + +In `crates/pi-sandbox-runtime/src/validator.rs`, update the namespace match (line 236-252): + +```rust + let namespaces_applied = match bwrap { + BwrapAvailability::Available { .. } | BwrapAvailability::DockerAvailable { .. } => { + plan.policy.namespaces.clone() + } + BwrapAvailability::Unavailable { .. } => { + for ns in &plan.policy.namespaces { + warnings.push(ValidationWarning { + code: "NAMESPACE_DEGRADED".to_string(), + message: format!( + "Namespace '{}' requested but cannot be applied (bwrap unavailable)", + ns + ), + }); + } + vec![] + } + }; +``` + +- [ ] **Step 5: Update validator.rs — isolation_backend match** + +In `crates/pi-sandbox-runtime/src/validator.rs`, update the `isolation_backend` assignment (added in Task 2): + +```rust + let isolation_backend = match bwrap { + BwrapAvailability::Available { .. } => "native".to_string(), + BwrapAvailability::DockerAvailable { .. } => "docker".to_string(), + BwrapAvailability::Unavailable { .. } => "none".to_string(), + }; +``` + +- [ ] **Step 6: Emit DOCKER_NOT_AVAILABLE warning on macOS when degrading** + +In `crates/pi-sandbox-runtime/src/validator.rs`, add after the `isolation_backend` assignment and before the `EffectiveState` construction: + +```rust + // On non-Linux, if bwrap is unavailable (Docker not found), emit a warning + #[cfg(not(target_os = "linux"))] + if matches!(bwrap, BwrapAvailability::Unavailable { .. }) { + warnings.push(ValidationWarning { + code: "DOCKER_NOT_AVAILABLE".to_string(), + message: "macOS detected but Docker not available; running without isolation" + .to_string(), + }); + } +``` + +- [ ] **Step 7: Add temporary DockerAvailable arm to supervisor.rs** + +In `crates/pi-sandbox-runtime/src/supervisor.rs`, update the match (line 46-102). Add a new arm between `Available` and `Unavailable`: + +```rust + BwrapAvailability::DockerAvailable { .. } => { + // Placeholder: Docker execution implemented in Task 8. + // This arm is unreachable until the detection chain (Task 7) + // returns DockerAvailable. Falls through to direct execution. + let mut c = Command::new(&plan.command[0]); + if plan.command.len() > 1 { + c.args(&plan.command[1..]); + } + c.current_dir(&plan.manifest.cwd) + .envs(&plan.manifest.env); + c + } +``` + +- [ ] **Step 8: Run full Rust test suite** + +Run: `cd crates/pi-sandbox-runtime && cargo test` + +Expected: All tests pass. No compilation errors. + +- [ ] **Step 9: Commit** + +```bash +git add crates/pi-sandbox-runtime/src/bubblewrap.rs crates/pi-sandbox-runtime/src/validator.rs crates/pi-sandbox-runtime/src/supervisor.rs +git commit -m "feat: add DockerAvailable variant to BwrapAvailability, update all match arms" +``` + +--- + +### Task 6: docker.rs — Docker detection and sidecar lifecycle + +**Files:** +- Modify: `crates/pi-sandbox-runtime/src/docker.rs` + +This task adds the Docker CLI interaction functions: checking Docker availability, finding/starting/creating the sidecar container, and determining the pi-sandbox data directory. + +- [ ] **Step 1: Add imports and constants at the top of docker.rs** + +In `crates/pi-sandbox-runtime/src/docker.rs`, add at the very top (before the existing `use crate::contract::PlanPayload;` line): + +```rust +use std::process::{Command, Stdio}; + +use crate::contract::PlanPayload; + +const SIDECAR_NAME: &str = "pi-sandbox-sidecar"; +const IMAGE_NAME: &str = "pi-sandbox-base:latest"; +const CONTAINER_SESSIONS_DIR: &str = "/pi-sandbox"; + +/// Information about a running Docker sidecar container. +pub struct DockerSidecar { + pub container_id: String, + pub host_sessions_dir: String, + pub container_sessions_dir: String, +} +``` + +(Remove the old standalone `use crate::contract::PlanPayload;` line to avoid duplication.) + +- [ ] **Step 2: Add get_data_dir helper** + +Add after the `DockerSidecar` struct, before `rewrite_path`: + +```rust +/// Get the pi-sandbox data directory on the host. +/// +/// Uses `PI_SANDBOX_DATA_DIR` env var if set, otherwise `$HOME/.local/share/pi-sandbox`. +fn get_data_dir() -> Result { + if let Ok(dir) = std::env::var("PI_SANDBOX_DATA_DIR") { + return Ok(dir); + } + let home = std::env::var("HOME") + .map_err(|_| "HOME environment variable not set".to_string())?; + Ok(format!("{home}/.local/share/pi-sandbox")) +} +``` + +- [ ] **Step 3: Add is_docker_available function** + +Add after `get_data_dir`: + +```rust +/// Check whether Docker is available by running `docker info`. +pub fn is_docker_available() -> bool { + Command::new("docker") + .args(["info"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} +``` + +- [ ] **Step 4: Add sidecar discovery functions** + +Add after `is_docker_available`: + +```rust +/// Find a running sidecar container. Returns its short ID if found. +fn find_running_sidecar() -> Option { + let output = Command::new("docker") + .args([ + "ps", + "--filter", &format!("name={SIDECAR_NAME}"), + "--format", "{{.ID}}", + ]) + .output() + .ok()?; + if output.status.success() { + let id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if id.is_empty() { None } else { Some(id) } + } else { + None + } +} + +/// Find a stopped sidecar container. Returns its short ID if found. +fn find_stopped_sidecar() -> Option { + let output = Command::new("docker") + .args([ + "ps", "-a", + "--filter", &format!("name={SIDECAR_NAME}"), + "--format", "{{.ID}}", + ]) + .output() + .ok()?; + if output.status.success() { + let id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if id.is_empty() { None } else { Some(id) } + } else { + None + } +} +``` + +- [ ] **Step 5: Add start_container and ensure_image functions** + +```rust +/// Start a stopped container. +fn start_container(id: &str) -> Result<(), String> { + let status = Command::new("docker") + .args(["start", id]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|e| format!("failed to start container: {e}"))?; + if status.success() { + Ok(()) + } else { + Err("docker start failed".to_string()) + } +} + +/// Build the sidecar Docker image if it doesn't already exist. +fn ensure_image() -> Result<(), String> { + let output = Command::new("docker") + .args(["images", IMAGE_NAME, "--format", "{{.ID}}"]) + .output() + .map_err(|e| format!("docker images check failed: {e}"))?; + + let id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !id.is_empty() { + return Ok(()); + } + + eprintln!("pi-sandbox: building Docker sidecar image (one-time setup)..."); + let status = Command::new("docker") + .args([ + "build", "-t", IMAGE_NAME, + "-f", "docker/pi-sandbox-sidecar.Dockerfile", ".", + ]) + .status() + .map_err(|e| format!("docker build failed: {e}"))?; + + if status.success() { + Ok(()) + } else { + Err("docker build failed with non-zero exit".to_string()) + } +} +``` + +- [ ] **Step 6: Add create_sidecar function** + +```rust +/// Create and start a new sidecar container. +fn create_sidecar(host_sessions_dir: &str) -> Result { + let volume_arg = format!("{host_sessions_dir}:{CONTAINER_SESSIONS_DIR}"); + let output = Command::new("docker") + .args([ + "run", "-d", + "--name", SIDECAR_NAME, + "--cap-add", "SYS_ADMIN", + "--cap-add", "NET_ADMIN", + "-v", &volume_arg, + IMAGE_NAME, + "sleep", "infinity", + ]) + .output() + .map_err(|e| format!("docker run failed: {e}"))?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("docker run failed: {stderr}")) + } +} +``` + +- [ ] **Step 7: Add the top-level detect_docker_sidecar and restart_sidecar functions** + +```rust +/// Detect and ensure a Docker sidecar is running. +/// +/// This is the main entry point called from `bubblewrap::detect()` on macOS. +/// Returns a `DockerSidecar` with container info and path mapping, +/// or an error string explaining why Docker is not available. +pub fn detect_docker_sidecar() -> Result { + if !is_docker_available() { + return Err("Docker not available (docker info failed)".to_string()); + } + + let host_sessions_dir = get_data_dir()?; + + // Ensure the data directory exists on the host + std::fs::create_dir_all(&host_sessions_dir) + .map_err(|e| format!("failed to create data dir {host_sessions_dir}: {e}"))?; + + // 1. Check if container is already running + if let Some(id) = find_running_sidecar() { + return Ok(DockerSidecar { + container_id: id, + host_sessions_dir, + container_sessions_dir: CONTAINER_SESSIONS_DIR.to_string(), + }); + } + + // 2. Check if container exists but is stopped + if let Some(id) = find_stopped_sidecar() { + start_container(&id)?; + return Ok(DockerSidecar { + container_id: id, + host_sessions_dir, + container_sessions_dir: CONTAINER_SESSIONS_DIR.to_string(), + }); + } + + // 3. Container doesn't exist — build image and create it + ensure_image()?; + let id = create_sidecar(&host_sessions_dir)?; + + Ok(DockerSidecar { + container_id: id, + host_sessions_dir, + container_sessions_dir: CONTAINER_SESSIONS_DIR.to_string(), + }) +} + +/// Restart the sidecar container after a failure. +pub fn restart_sidecar(container_id: &str) -> Result<(), String> { + let status = Command::new("docker") + .args(["restart", container_id]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|e| format!("docker restart failed: {e}"))?; + if status.success() { + Ok(()) + } else { + Err("docker restart failed".to_string()) + } +} +``` + +- [ ] **Step 8: Run Rust tests** + +Run: `cd crates/pi-sandbox-runtime && cargo test` + +Expected: All tests pass (no new tests here — lifecycle functions require Docker and are tested in Task 9). + +- [ ] **Step 9: Commit** + +```bash +git add crates/pi-sandbox-runtime/src/docker.rs +git commit -m "feat: add Docker detection and sidecar lifecycle management" +``` + +--- + +### Task 7: bubblewrap.rs — detection chain with Docker on macOS + +**Files:** +- Modify: `crates/pi-sandbox-runtime/src/bubblewrap.rs:19-24,84-96` + +This is the critical wiring task. On non-Linux platforms, `detect()` now checks `PI_SANDBOX_NO_DOCKER` and then tries Docker before returning `Unavailable`. + +- [ ] **Step 1: Update the non-Linux detection path** + +In `crates/pi-sandbox-runtime/src/bubblewrap.rs`, replace the `#[cfg(not(target_os = "linux"))]` block (lines 19-24): + +```rust + #[cfg(not(target_os = "linux"))] + { + // Check opt-out env var + if std::env::var("PI_SANDBOX_NO_DOCKER").map_or(false, |v| v == "1") { + return BwrapAvailability::Unavailable { + reason: "Docker fallback disabled via PI_SANDBOX_NO_DOCKER=1".to_string(), + }; + } + + // Try Docker sidecar for bwrap support on macOS + match crate::docker::detect_docker_sidecar() { + Ok(sidecar) => { + return BwrapAvailability::DockerAvailable { + container_id: sidecar.container_id, + host_sessions_dir: sidecar.host_sessions_dir, + container_sessions_dir: sidecar.container_sessions_dir, + }; + } + Err(reason) => { + return BwrapAvailability::Unavailable { + reason: format!( + "Bubblewrap requires Linux; Docker fallback failed: {reason}" + ), + }; + } + } + } +``` + +- [ ] **Step 2: Update the non-Linux test** + +In `crates/pi-sandbox-runtime/src/bubblewrap.rs`, replace the `non_linux_always_unavailable` test (lines 84-96): + +```rust + #[test] + #[cfg(not(target_os = "linux"))] + fn non_linux_returns_docker_or_unavailable() { + let result = detect(); + match result { + BwrapAvailability::Unavailable { reason } => { + assert!(!reason.is_empty(), "reason: {}", reason); + } + BwrapAvailability::DockerAvailable { container_id, .. } => { + assert!(!container_id.is_empty()); + } + BwrapAvailability::Available { .. } => { + panic!("Should not return native Available on non-Linux"); + } + } + } +``` + +- [ ] **Step 3: Run Rust tests** + +Run: `cd crates/pi-sandbox-runtime && cargo test` + +Expected: All tests pass. On macOS with Docker: `detect()` returns `DockerAvailable`. On macOS without Docker: `detect()` returns `Unavailable`. On Linux: behavior unchanged. + +- [ ] **Step 4: Commit** + +```bash +git add crates/pi-sandbox-runtime/src/bubblewrap.rs +git commit -m "feat: wire Docker sidecar detection into bubblewrap detect() on macOS" +``` + +--- + +### Task 8: supervisor.rs — Docker execution branch + crash recovery + +**Files:** +- Modify: `crates/pi-sandbox-runtime/src/supervisor.rs` + +This task replaces the temporary `DockerAvailable` stub with the real Docker execution path: path rewriting, `docker exec -i bwrap`, and crash recovery with sidecar restart. + +- [ ] **Step 1: Add docker import to supervisor.rs** + +In `crates/pi-sandbox-runtime/src/supervisor.rs`, add to the imports (after line 14): + +```rust +use crate::docker; +``` + +- [ ] **Step 2: Add the build_docker_command helper function** + +Add this function before the `supervise` function (before line 29): + +```rust +/// Build a Command for Docker-based bwrap execution. +/// +/// Rewrites plan paths from host to container, builds bwrap argv via plan_builder, +/// and prefixes with `docker exec -i bwrap`. +fn build_docker_command( + plan: &PlanPayload, + effective_state: &EffectiveState, + container_id: &str, + host_sessions_dir: &str, + container_sessions_dir: &str, +) -> Command { + let rewritten_plan = docker::rewrite_plan(plan, host_sessions_dir, container_sessions_dir); + + // Inside the Docker container, iptables is always at /usr/sbin/iptables + let iptables_path = if effective_state.network.actual == "allowlist" + && effective_state.network.enforcement == "enforced" + { + Some("/usr/sbin/iptables".to_string()) + } else { + None + }; + + let argv = plan_builder::build_with_allowlist( + &rewritten_plan, + effective_state, + iptables_path.as_deref(), + ); + + // If allowlist enforcement is active, write the wrapper script to the sessions dir + // (which is mounted in the container) so bwrap can bind-mount it + let full_argv = if effective_state.network.actual == "allowlist" + && effective_state.network.enforcement == "enforced" + { + let script = plan_builder::generate_iptables_wrapper(&effective_state.resolved_allowlist); + let host_script_dir = format!("{host_sessions_dir}/tmp"); + let host_script_path = format!("{host_script_dir}/.pi-sandbox-allowlist.sh"); + let container_script_path = + format!("{container_sessions_dir}/tmp/.pi-sandbox-allowlist.sh"); + std::fs::create_dir_all(&host_script_dir).ok(); + std::fs::write(&host_script_path, &script).expect("failed to write iptables wrapper"); + + let mut full = vec![ + "--ro-bind".to_string(), + container_script_path, + "/tmp/.pi-sandbox-allowlist.sh".to_string(), + ]; + full.extend(argv); + full + } else { + argv + }; + + let mut cmd = Command::new("docker"); + cmd.args(["exec", "-i", container_id, "bwrap"]); + cmd.args(&full_argv); + cmd +} +``` + +- [ ] **Step 3: Replace the DockerAvailable stub arm in supervise()** + +In `crates/pi-sandbox-runtime/src/supervisor.rs`, replace the placeholder `DockerAvailable` match arm with: + +```rust + BwrapAvailability::DockerAvailable { + ref container_id, + ref host_sessions_dir, + ref container_sessions_dir, + } => build_docker_command( + plan, + effective_state, + container_id, + host_sessions_dir, + container_sessions_dir, + ), +``` + +- [ ] **Step 4: Add crash recovery to the spawn error handling** + +In `crates/pi-sandbox-runtime/src/supervisor.rs`, replace the spawn error handling block (the `Err(e)` arm of `cmd.spawn()`) with: + +```rust + Err(e) => { + // For Docker: if spawn failed, try restarting the sidecar once + if let BwrapAvailability::DockerAvailable { + ref container_id, + ref host_sessions_dir, + ref container_sessions_dir, + } = bwrap + { + let seq_val = next_seq(&seq); + emit(&WarningEnvelope::new( + seq_val, + "DOCKER_SIDECAR_RESTARTED".to_string(), + format!("docker exec failed ({e}), restarting sidecar"), + )); + + if docker::restart_sidecar(container_id).is_ok() { + let mut retry_cmd = build_docker_command( + plan, + effective_state, + container_id, + host_sessions_dir, + container_sessions_dir, + ); + retry_cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + match retry_cmd.spawn() { + Ok(c) => c, + Err(e2) => { + let s = next_seq(&seq); + emit(&LifecycleEnvelope::new( + s, + format!("spawn_failed_after_recovery: {e2}"), + )); + let duration_ms = start.elapsed().as_secs_f64() * 1000.0; + return SupervisionResult { + exit_code: None, + signal: None, + timed_out: false, + duration_ms, + effective_network: effective_state.network.clone(), + observed_connections: vec![], + would_have_blocked: vec![], + terminal_state: "supervisor_crash".to_string(), + workspace_modified: false, + }; + } + } + } else { + let s = next_seq(&seq); + emit(&LifecycleEnvelope::new( + s, + format!("spawn_failed: {e} (sidecar restart also failed)"), + )); + let duration_ms = start.elapsed().as_secs_f64() * 1000.0; + return SupervisionResult { + exit_code: None, + signal: None, + timed_out: false, + duration_ms, + effective_network: effective_state.network.clone(), + observed_connections: vec![], + would_have_blocked: vec![], + terminal_state: "supervisor_crash".to_string(), + workspace_modified: false, + }; + } + } else { + let seq_val = next_seq(&seq); + emit(&LifecycleEnvelope::new( + seq_val, + format!("spawn_failed: {e}"), + )); + let duration_ms = start.elapsed().as_secs_f64() * 1000.0; + return SupervisionResult { + exit_code: None, + signal: None, + timed_out: false, + duration_ms, + effective_network: effective_state.network.clone(), + observed_connections: vec![], + would_have_blocked: vec![], + terminal_state: "supervisor_crash".to_string(), + workspace_modified: false, + }; + } + } +``` + +- [ ] **Step 5: Run Rust tests** + +Run: `cd crates/pi-sandbox-runtime && cargo test` + +Expected: All tests pass. The Docker execution path compiles correctly. + +- [ ] **Step 6: Commit** + +```bash +git add crates/pi-sandbox-runtime/src/supervisor.rs +git commit -m "feat: add Docker execution branch with path rewriting and crash recovery" +``` + +--- + +### Task 9: Docker integration tests + +**Files:** +- Create: `tests/protocol/docker-sidecar.test.ts` + +These tests are gated behind `RUN_DOCKER_TESTS=1` and require Docker Desktop to be running. They verify the full Docker sidecar flow: detection, execution with isolation, and the `PI_SANDBOX_NO_DOCKER` opt-out. + +- [ ] **Step 1: Write the test file** + +Create `tests/protocol/docker-sidecar.test.ts`: + +```typescript +/** + * Docker sidecar integration tests. + * + * These tests require Docker Desktop to be running and are gated behind + * the RUN_DOCKER_TESTS=1 environment variable. + * + * Run: RUN_DOCKER_TESTS=1 npx vitest run tests/protocol/docker-sidecar.test.ts + */ +import { execFileSync } from "node:child_process"; +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import { describe, it, expect, beforeAll } from "vitest"; +import { spawnRuntime, makePlan } from "./helpers.js"; + +const DOCKER_TESTS = process.env.RUN_DOCKER_TESTS === "1"; + +describe.skipIf(!DOCKER_TESTS)("Docker sidecar", () => { + beforeAll(() => { + // Clean up any leftover sidecar from previous runs + try { + execFileSync("docker", ["rm", "-f", "pi-sandbox-sidecar"], { + stdio: "ignore", + }); + } catch { + // Container didn't exist, that's fine + } + }); + + it("runs echo through Docker+bwrap and reports isolationBackend=docker", async () => { + const rt = spawnRuntime(); + const plan = makePlan({ + command: ["echo", "hello from docker sidecar"], + }); + + rt.send(plan); + + const validation = await rt.readline(); + expect(validation).toHaveProperty("type", "validation"); + + const payload = (validation as any).payload; + expect(payload.ok).toBe(true); + expect(payload.effectiveState.isolationBackend).toBe("docker"); + + const events = await rt.readAllEvents(); + const result = events.find((e: any) => e.type === "result") as any; + expect(result).toBeDefined(); + expect(result.payload.exitCode).toBe(0); + + const stdout = events + .filter((e: any) => e.type === "stdout") + .map((e: any) => e.payload.data) + .join("\n"); + expect(stdout).toContain("hello from docker sidecar"); + + await rt.waitForExit(); + }, 60_000); // 60s timeout for first-time image build + + it("reports enforcement=enforced for network=off", async () => { + const rt = spawnRuntime(); + const plan = makePlan({ + command: ["echo", "offline test"], + policy: { + namespaces: ["user", "pid", "net"], + network: { mode: "off" }, + allowedWritableTargets: ["/workspace", "/tmp"], + strictWritePolicy: false, + }, + }); + + rt.send(plan); + + const validation = await rt.readline(); + const payload = (validation as any).payload; + expect(payload.ok).toBe(true); + expect(payload.effectiveState.network.actual).toBe("off"); + expect(payload.effectiveState.network.enforcement).toBe("enforced"); + expect(payload.effectiveState.isolationBackend).toBe("docker"); + + const events = await rt.readAllEvents(); + const result = events.find((e: any) => e.type === "result") as any; + expect(result.payload.exitCode).toBe(0); + + await rt.waitForExit(); + }, 30_000); + + it("PI_SANDBOX_NO_DOCKER=1 skips Docker and degrades", async () => { + const binaryPath = process.env.RUNTIME_BINARY_PATH; + if (!binaryPath) throw new Error("RUNTIME_BINARY_PATH not set"); + + const child = spawn(binaryPath, [], { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, PI_SANDBOX_NO_DOCKER: "1" }, + }); + + const rl = createInterface({ input: child.stdout! }); + const lines: string[] = []; + rl.on("line", (line: string) => lines.push(line)); + + const plan = makePlan({ command: ["echo", "no docker"] }); + child.stdin!.write(JSON.stringify(plan) + "\n"); + + await new Promise((resolve) => child.on("exit", () => resolve())); + + const validation = JSON.parse(lines[0]); + expect(validation.payload.effectiveState.isolationBackend).toBe("none"); + }, 15_000); +}); +``` + +- [ ] **Step 2: Run the tests (Docker required)** + +Run: `RUN_DOCKER_TESTS=1 npx vitest run tests/protocol/docker-sidecar.test.ts` + +Expected: All 3 tests pass (on macOS with Docker Desktop running). + +- [ ] **Step 3: Run without Docker flag to verify skip** + +Run: `npx vitest run tests/protocol/docker-sidecar.test.ts` + +Expected: Tests are skipped (not failed). + +- [ ] **Step 4: Run full test suite to verify no regressions** + +Run: `cd crates/pi-sandbox-runtime && cargo test && cd ../.. && npx vitest run tests/protocol/` + +Expected: All Rust tests pass. All protocol tests pass (Docker tests skipped unless flag set). + +- [ ] **Step 5: Commit** + +```bash +git add tests/protocol/docker-sidecar.test.ts +git commit -m "feat: add Docker sidecar integration tests (gated behind RUN_DOCKER_TESTS=1)" +``` + +--- + +## Phase Gate Checklist + +After all tasks are complete, verify: + +- [ ] Dockerfile builds successfully (`docker build -t pi-sandbox-base:latest -f docker/pi-sandbox-sidecar.Dockerfile .`) +- [ ] Sidecar starts, stops, and recovers from crash +- [ ] `sandbox_run` on macOS with Docker produces `enforcement: "enforced"` and `isolationBackend: "docker"` +- [ ] `sandbox_run` on macOS without Docker still degrades gracefully + `isolationBackend: "none"` +- [ ] `PI_SANDBOX_NO_DOCKER=1` skips Docker detection +- [ ] All existing Linux and macOS tests continue to pass +- [ ] Path rewriting unit tests pass on any platform diff --git a/docs/superpowers/plans/2026-04-08-nix-flake-part-b.md b/docs/superpowers/plans/2026-04-08-nix-flake-part-b.md new file mode 100644 index 0000000..3d4f396 --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-nix-flake-part-b.md @@ -0,0 +1,2011 @@ +# Nix Flake Runtime Part B Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver Docker sidecar support for macOS (with /nix/store mount), clean up all legacy NDJSON protocol code, and add integration tests for both Linux native and macOS Docker execution paths. + +**Architecture:** The Docker sidecar container is stripped to bwrap+iptables only — all runtime packages come from the Nix rootfs via --pivot-root with /nix/store mounted read-only. Legacy NDJSON subprocess mode is deleted; exec --json becomes the sole machine-readable interface with full lifecycle event streaming. Two independently-gated integration test suites validate the rootfs pipeline end-to-end. + +**Tech Stack:** Rust (clap, serde_json, chrono), Nix flakes, Bubblewrap, Docker, TypeScript/Vitest + +--- + +## File Structure + +### Modified +| File | Responsibility | +|------|---------------| +| `crates/nixosandbox/src/bubblewrap.rs` | Rename env vars PI_SANDBOX_* → NIXOSANDBOX_* | +| `crates/nixosandbox/src/docker.rs` | Rename constants, add /nix/store mount, update session paths | +| `crates/nixosandbox/src/main.rs` | Delete legacy entry point, enhance exec --json, wire Docker path rewriting | +| `crates/nixosandbox/src/cli.rs` | Remove LegacyNdjson variant | +| `crates/nixosandbox/src/contract.rs` | Remove inbound types (InboundMessage, CancelPayload) | + +### Deleted +| File | Reason | +|------|--------| +| `docker/pi-sandbox-sidecar.Dockerfile` | Replaced by nixosandbox-sidecar.Dockerfile | +| `tests/protocol/version-mismatch.test.ts` | Legacy NDJSON-only test | +| `tests/protocol/validation-failure.test.ts` | Legacy NDJSON-only test | +| `tests/protocol/degraded-allowlist.test.ts` | Legacy NDJSON-only test | +| `tests/protocol/network-observation.test.ts` | Legacy NDJSON-only test | +| `tests/protocol/allowlist-enforced.test.ts` | Legacy NDJSON-only test | + +### Created +| File | Responsibility | +|------|---------------| +| `docker/nixosandbox-sidecar.Dockerfile` | Minimal sidecar: bwrap + iptables only | +| `tests/integration/package.json` | Integration test project dependencies | +| `tests/integration/tsconfig.json` | TypeScript config for integration tests | +| `tests/integration/vitest.config.ts` | Vitest config with globalSetup | +| `tests/integration/globalSetup.ts` | Cargo build, set NIXOSANDBOX_BINARY | +| `tests/integration/helpers.ts` | CLI wrapper functions (build, create, execCmd, list, destroy) | +| `tests/integration/rootfs-pipeline.test.ts` | Linux native integration tests (RUN_INTEGRATION_TESTS=1) | +| `tests/integration/docker-rootfs.test.ts` | macOS Docker integration tests (RUN_DOCKER_TESTS=1) | + +### Adapted +| File | Changes | +|------|---------| +| `tests/protocol/globalSetup.ts` | Rename RUNTIME_BINARY_PATH → NIXOSANDBOX_BINARY, PI_SANDBOX_NO_DOCKER → NIXOSANDBOX_NO_DOCKER | +| `tests/protocol/helpers.ts` | Add spawnExecJson() for exec --json mode, keep spawnRuntime() for remaining legacy tests | +| `tests/protocol/cancel-flow.test.ts` | Rewrite to use exec --json with a pre-created session, gate behind RUN_INTEGRATION_TESTS | +| `tests/protocol/crash-synthesis.test.ts` | No changes needed (TS-only, no runtime dependency) | +| `tests/protocol/docker-sidecar.test.ts` | Rewrite to test rootfs execution through Docker, update naming | + +--- + +### Task 1: Rename Dockerfile and strip to minimum + +**Files:** +- Delete: `docker/pi-sandbox-sidecar.Dockerfile` +- Create: `docker/nixosandbox-sidecar.Dockerfile` + +- [ ] **Step 1: Delete the old Dockerfile** + +```bash +rm docker/pi-sandbox-sidecar.Dockerfile +``` + +- [ ] **Step 2: Create the new minimal Dockerfile** + +Create `docker/nixosandbox-sidecar.Dockerfile`: + +```dockerfile +# docker/nixosandbox-sidecar.Dockerfile +# +# Minimal Linux sidecar for running bwrap on macOS via Docker Desktop. +# All runtime packages come from the Nix rootfs via --pivot-root. +# This container only provides bwrap (sandbox primitive) and iptables (network enforcement). +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bubblewrap \ + iptables \ + && rm -rf /var/lib/apt/lists/* +``` + +- [ ] **Step 3: Commit** + +```bash +git add docker/pi-sandbox-sidecar.Dockerfile docker/nixosandbox-sidecar.Dockerfile +git commit -m "refactor: rename and strip Dockerfile to nixosandbox-sidecar + +Remove pre-installed packages (python3, nodejs, git, curl, ca-certificates). +All runtime packages now come from Nix rootfs via --pivot-root. +Only bwrap and iptables remain." +``` + +--- + +### Task 2: Rename env vars in bubblewrap.rs + +**Files:** +- Modify: `crates/nixosandbox/src/bubblewrap.rs:27,55` + +- [ ] **Step 1: Rename PI_SANDBOX_NO_DOCKER to NIXOSANDBOX_NO_DOCKER** + +In `crates/nixosandbox/src/bubblewrap.rs`, replace: + +```rust + if std::env::var("PI_SANDBOX_NO_DOCKER").map_or(false, |v| v == "1") { + return BwrapAvailability::Unavailable { + reason: "Docker fallback disabled via PI_SANDBOX_NO_DOCKER=1".to_string(), + }; + } +``` + +with: + +```rust + if std::env::var("NIXOSANDBOX_NO_DOCKER").map_or(false, |v| v == "1") { + return BwrapAvailability::Unavailable { + reason: "Docker fallback disabled via NIXOSANDBOX_NO_DOCKER=1".to_string(), + }; + } +``` + +- [ ] **Step 2: Rename PI_SANDBOX_BWRAP_PATH to NIXOSANDBOX_BWRAP_PATH** + +In the same file, replace: + +```rust + if let Ok(path_str) = std::env::var("PI_SANDBOX_BWRAP_PATH") { +``` + +with: + +```rust + if let Ok(path_str) = std::env::var("NIXOSANDBOX_BWRAP_PATH") { +``` + +And replace: + +```rust + reason: format!( + "PI_SANDBOX_BWRAP_PATH set to '{}' but file does not exist", + path_str + ), +``` + +with: + +```rust + reason: format!( + "NIXOSANDBOX_BWRAP_PATH set to '{}' but file does not exist", + path_str + ), +``` + +- [ ] **Step 3: Run tests to verify** + +Run: `cd crates/nixosandbox && cargo test --test-threads=1 -- bubblewrap 2>&1` +Expected: All bubblewrap tests pass + +- [ ] **Step 4: Commit** + +```bash +git add crates/nixosandbox/src/bubblewrap.rs +git commit -m "refactor: rename PI_SANDBOX_* env vars to NIXOSANDBOX_* + +PI_SANDBOX_NO_DOCKER → NIXOSANDBOX_NO_DOCKER +PI_SANDBOX_BWRAP_PATH → NIXOSANDBOX_BWRAP_PATH" +``` + +--- + +### Task 3: Update docker.rs — naming, /nix/store mount, session paths + +**Files:** +- Modify: `crates/nixosandbox/src/docker.rs:5-7,19-26,102-116,119-141,243-338` + +- [ ] **Step 1: Update the three constants** + +In `crates/nixosandbox/src/docker.rs`, replace: + +```rust +const SIDECAR_NAME: &str = "pi-sandbox-sidecar"; +const IMAGE_NAME: &str = "pi-sandbox-base:latest"; +const CONTAINER_SESSIONS_DIR: &str = "/pi-sandbox"; +``` + +with: + +```rust +const SIDECAR_NAME: &str = "nixosandbox-sidecar"; +const IMAGE_NAME: &str = "nixosandbox-sidecar:latest"; +const CONTAINER_SESSIONS_DIR: &str = "/nixosandbox/sessions"; +``` + +- [ ] **Step 2: Update get_data_dir to use new env var and path** + +Replace: + +```rust +fn get_data_dir() -> Result { + if let Ok(dir) = std::env::var("PI_SANDBOX_DATA_DIR") { + return Ok(dir); + } + let home = std::env::var("HOME") + .map_err(|_| "HOME environment variable not set".to_string())?; + Ok(format!("{home}/.local/share/pi-sandbox")) +} +``` + +with: + +```rust +fn get_data_dir() -> Result { + if let Ok(dir) = std::env::var("NIXOSANDBOX_DATA_DIR") { + return Ok(dir); + } + let home = std::env::var("HOME") + .map_err(|_| "HOME environment variable not set".to_string())?; + Ok(format!("{home}/.local/share/nixosandbox")) +} +``` + +- [ ] **Step 3: Update ensure_image to reference new Dockerfile** + +Replace: + +```rust + eprintln!("pi-sandbox: building Docker sidecar image (one-time setup)..."); + let status = Command::new("docker") + .args([ + "build", "-t", IMAGE_NAME, + "-f", "docker/pi-sandbox-sidecar.Dockerfile", ".", + ]) +``` + +with: + +```rust + eprintln!("nixosandbox: building Docker sidecar image (one-time setup)..."); + let status = Command::new("docker") + .args([ + "build", "-t", IMAGE_NAME, + "-f", "docker/nixosandbox-sidecar.Dockerfile", ".", + ]) +``` + +- [ ] **Step 4: Add /nix/store volume mount in create_sidecar** + +Replace the `create_sidecar` function: + +```rust +fn create_sidecar(host_sessions_dir: &str) -> Result { + let volume_arg = format!("{host_sessions_dir}:{CONTAINER_SESSIONS_DIR}"); + let output = Command::new("docker") + .args([ + "run", "-d", + "--name", SIDECAR_NAME, + "--cap-add", "SYS_ADMIN", + "--cap-add", "NET_ADMIN", + "--security-opt", "seccomp=unconfined", + "-v", &volume_arg, + IMAGE_NAME, + "sleep", "infinity", + ]) + .output() + .map_err(|e| format!("docker run failed: {e}"))?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("docker run failed: {stderr}")) + } +} +``` + +with: + +```rust +fn create_sidecar(host_sessions_dir: &str) -> Result { + let sessions_volume = format!("{host_sessions_dir}:{CONTAINER_SESSIONS_DIR}"); + let output = Command::new("docker") + .args([ + "run", "-d", + "--name", SIDECAR_NAME, + "--cap-add", "SYS_ADMIN", + "--cap-add", "NET_ADMIN", + "--security-opt", "seccomp=unconfined", + "-v", &sessions_volume, + "-v", "/nix/store:/nix/store:ro", + IMAGE_NAME, + "sleep", "infinity", + ]) + .output() + .map_err(|e| format!("docker run failed: {e}"))?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("docker run failed: {stderr}")) + } +} +``` + +- [ ] **Step 5: Update rewrite_path unit tests to use new paths** + +Replace the test `rewrite_path_replaces_matching_prefix`: + +```rust + #[test] + fn rewrite_path_replaces_matching_prefix() { + let result = rewrite_path( + "/Users/me/.local/share/pi-sandbox/sessions/abc/workspace", + "/Users/me/.local/share/pi-sandbox", + "/pi-sandbox", + ); + assert_eq!(result, "/pi-sandbox/sessions/abc/workspace"); + } +``` + +with: + +```rust + #[test] + fn rewrite_path_replaces_matching_prefix() { + let result = rewrite_path( + "/Users/me/.local/share/nixosandbox/sessions/abc/workspace", + "/Users/me/.local/share/nixosandbox/sessions", + "/nixosandbox/sessions", + ); + assert_eq!(result, "/nixosandbox/sessions/abc/workspace"); + } +``` + +And replace the test `rewrite_path_leaves_non_matching_path_unchanged`: + +```rust + #[test] + fn rewrite_path_leaves_non_matching_path_unchanged() { + let result = rewrite_path( + "/usr/bin/python3", + "/Users/me/.local/share/pi-sandbox", + "/pi-sandbox", + ); + assert_eq!(result, "/usr/bin/python3"); + } +``` + +with: + +```rust + #[test] + fn rewrite_path_leaves_non_matching_path_unchanged() { + let result = rewrite_path( + "/nix/store/abc123-sandbox-strict", + "/Users/me/.local/share/nixosandbox/sessions", + "/nixosandbox/sessions", + ); + assert_eq!(result, "/nix/store/abc123-sandbox-strict"); + } +``` + +And update the `rewrite_plan_rewrites_mount_sources_and_cwd` test — replace all `/Users/me/.local/share/pi-sandbox` with `/Users/me/.local/share/nixosandbox` and `/pi-sandbox` with `/nixosandbox/sessions`: + +```rust + #[test] + fn rewrite_plan_rewrites_mount_sources_and_cwd() { + let plan = PlanPayload { + version: 1, + session_id: "test".to_string(), + execution_id: "test".to_string(), + requested_profile: "build-install".to_string(), + runtime_base_name: None, + manifest: Manifest { + mounts: vec![ + Mount { + mount_type: "directory".to_string(), + source: Some("/Users/me/.local/share/nixosandbox/sessions/s1/workspace".to_string()), + target: "/workspace".to_string(), + writable: true, + }, + Mount { + mount_type: "tmpfs".to_string(), + source: None, + target: "/tmp".to_string(), + writable: true, + }, + ], + env: HashMap::new(), + cwd: "/Users/me/.local/share/nixosandbox/sessions/s1/workspace".to_string(), + }, + policy: Policy { + namespaces: vec![], + network: NetworkConfig { + mode: "full".to_string(), + allowlist: None, + }, + resource_limits: None, + allowed_writable_targets: vec!["/workspace".to_string(), "/tmp".to_string()], + strict_write_policy: false, + env_allowlist: None, + deny_commands: None, + }, + command: vec!["echo".to_string(), "hello".to_string()], + }; + + let rewritten = rewrite_plan( + &plan, + "/Users/me/.local/share/nixosandbox/sessions", + "/nixosandbox/sessions", + ); + + assert_eq!( + rewritten.manifest.mounts[0].source.as_deref(), + Some("/nixosandbox/sessions/s1/workspace") + ); + assert_eq!(rewritten.manifest.mounts[1].source, None); + assert_eq!(rewritten.manifest.cwd, "/nixosandbox/sessions/s1/workspace"); + // Original plan is unchanged + assert_eq!( + plan.manifest.cwd, + "/Users/me/.local/share/nixosandbox/sessions/s1/workspace" + ); + } +``` + +- [ ] **Step 6: Run tests to verify** + +Run: `cd crates/nixosandbox && cargo test --test-threads=1 -- docker 2>&1` +Expected: All docker tests pass + +- [ ] **Step 7: Commit** + +```bash +git add crates/nixosandbox/src/docker.rs +git commit -m "refactor: update docker.rs naming, add /nix/store mount + +- Rename sidecar to nixosandbox-sidecar, image to nixosandbox-sidecar:latest +- Container sessions dir /pi-sandbox → /nixosandbox/sessions +- Add -v /nix/store:/nix/store:ro volume mount +- Update env var to NIXOSANDBOX_DATA_DIR +- Reference new Dockerfile path" +``` + +--- + +### Task 4: Wire Docker exec with session path rewriting in main.rs + +**Files:** +- Modify: `crates/nixosandbox/src/main.rs:178-283` + +- [ ] **Step 1: Add path rewriting for Docker exec in cmd_exec** + +In `crates/nixosandbox/src/main.rs`, the `cmd_exec` function has a Docker branch that currently just prints a warning. Replace the entire bwrap availability check and execution section (lines 177-283). The key change: when Docker is detected, rewrite session directory paths from host to container paths before building bwrap argv for the Docker execution path. + +Find this block in cmd_exec: + +```rust + // Check bwrap availability + let bwrap = bubblewrap::detect(); + match &bwrap { + bubblewrap::BwrapAvailability::Available { .. } => {} + bubblewrap::BwrapAvailability::DockerAvailable { .. } => { + eprintln!("warning: Docker execution with rootfs not yet fully supported"); + } + bubblewrap::BwrapAvailability::Unavailable { reason } => { + eprintln!("error: bwrap is not available: {reason}"); + std::process::exit(1); + } + }; +``` + +Replace it with: + +```rust + // Check bwrap availability + let bwrap = bubblewrap::detect(); + match &bwrap { + bubblewrap::BwrapAvailability::Available { .. } => {} + bubblewrap::BwrapAvailability::DockerAvailable { .. } => {} + bubblewrap::BwrapAvailability::Unavailable { reason } => { + eprintln!("error: bwrap is not available: {reason}"); + std::process::exit(1); + } + }; +``` + +Then find the bwrap_argv construction and update the Docker branch to rewrite session paths. Replace the existing `rootfs_dirs` and `bwrap_argv` construction: + +```rust + let rootfs_dirs = plan_builder::RootfsSessionDirs { + workspace: dirs.workspace.to_string_lossy().to_string(), + home: dirs.home.to_string_lossy().to_string(), + cache: dirs.cache.to_string_lossy().to_string(), + }; + + let bwrap_argv = plan_builder::build_rootfs( + &meta.rootfs_path, + &rootfs_dirs, + &command, + &env, + &sandbox_spec.network, + &sandbox_spec.namespaces, + ); +``` + +with: + +```rust + // For Docker, rewrite session directory paths from host to container paths. + // Nix store paths need no rewriting — identical on host and container. + let rootfs_dirs = match &bwrap { + bubblewrap::BwrapAvailability::DockerAvailable { + host_sessions_dir, + container_sessions_dir, + .. + } => plan_builder::RootfsSessionDirs { + workspace: docker::rewrite_path( + &dirs.workspace.to_string_lossy(), + host_sessions_dir, + container_sessions_dir, + ), + home: docker::rewrite_path( + &dirs.home.to_string_lossy(), + host_sessions_dir, + container_sessions_dir, + ), + cache: docker::rewrite_path( + &dirs.cache.to_string_lossy(), + host_sessions_dir, + container_sessions_dir, + ), + }, + _ => plan_builder::RootfsSessionDirs { + workspace: dirs.workspace.to_string_lossy().to_string(), + home: dirs.home.to_string_lossy().to_string(), + cache: dirs.cache.to_string_lossy().to_string(), + }, + }; + + let bwrap_argv = plan_builder::build_rootfs( + &meta.rootfs_path, + &rootfs_dirs, + &command, + &env, + &sandbox_spec.network, + &sandbox_spec.namespaces, + ); +``` + +- [ ] **Step 2: Run cargo check to verify compilation** + +Run: `cd crates/nixosandbox && cargo check 2>&1` +Expected: Compiles without errors + +- [ ] **Step 3: Commit** + +```bash +git add crates/nixosandbox/src/main.rs +git commit -m "feat: wire Docker exec with session path rewriting + +When running via Docker sidecar, rewrite session directory paths +(workspace, home, cache) from host paths to container paths. +Nix store paths are identical on host and container, so rootfs_path +needs no rewriting." +``` + +--- + +### Task 5: Enhance exec --json with full event stream + +**Files:** +- Modify: `crates/nixosandbox/src/main.rs:191-256` + +- [ ] **Step 1: Rewrite the JSON mode block in cmd_exec** + +In `crates/nixosandbox/src/main.rs`, replace the entire `if json {` block inside `cmd_exec` (the NDJSON mode section). The current code only streams stdout events and a basic result. Replace it with full lifecycle events, stderr events, and proper signal handling. + +Find: + +```rust + if json { + // NDJSON mode: pipe stdout/stderr, stream events + use std::process::{Command, Stdio}; + let mut child = match &bwrap { +``` + +Replace the entire `if json { ... }` branch (from `if json {` to just before `} else {`) with: + +```rust + if json { + // NDJSON mode: pipe stdout/stderr, stream lifecycle + data events + use std::process::{Command, Stdio}; + use std::io::{BufRead, BufReader}; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + + let seq = Arc::new(AtomicU64::new(1)); + + let mut child = match &bwrap { + bubblewrap::BwrapAvailability::DockerAvailable { container_id, .. } => { + let mut cmd_args = vec!["exec".to_string(), "-i".to_string(), container_id.clone(), "bwrap".to_string()]; + cmd_args.extend(bwrap_argv); + Command::new("docker") + .args(&cmd_args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|e| { + eprintln!("error: failed to spawn docker+bwrap: {e}"); + std::process::exit(1); + }) + } + _ => { + Command::new("bwrap") + .args(&bwrap_argv) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|e| { + eprintln!("error: failed to spawn bwrap: {e}"); + std::process::exit(1); + }) + } + }; + + let start = std::time::Instant::now(); + + // Emit lifecycle started + let started_event = serde_json::json!({ + "type": "lifecycle", + "sequence": seq.fetch_add(1, Ordering::SeqCst), + "ts": timestamps::now_iso8601(), + "payload": { "event": "started" } + }); + println!("{}", started_event); + + // Stream stdout and stderr in parallel threads + let child_stdout = child.stdout.take(); + let child_stderr = child.stderr.take(); + + let seq_stdout = Arc::clone(&seq); + let stdout_thread = std::thread::spawn(move || { + if let Some(stdout) = child_stdout { + let reader = BufReader::new(stdout); + for line in reader.lines() { + if let Ok(line) = line { + let event = serde_json::json!({ + "type": "stdout", + "sequence": seq_stdout.fetch_add(1, Ordering::SeqCst), + "ts": timestamps::now_iso8601(), + "payload": { "data": line } + }); + println!("{}", event); + } + } + } + }); + + let seq_stderr = Arc::clone(&seq); + let stderr_thread = std::thread::spawn(move || { + if let Some(stderr) = child_stderr { + let reader = BufReader::new(stderr); + for line in reader.lines() { + if let Ok(line) = line { + let event = serde_json::json!({ + "type": "stderr", + "sequence": seq_stderr.fetch_add(1, Ordering::SeqCst), + "ts": timestamps::now_iso8601(), + "payload": { "data": line } + }); + println!("{}", event); + } + } + } + }); + + let status = child.wait().unwrap_or_else(|e| { + eprintln!("error: wait: {e}"); + std::process::exit(1); + }); + + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + + let duration_ms = start.elapsed().as_millis() as u64; + + // Extract exit code and signal + let (exit_code, signal) = { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(sig) = status.signal() { + (None, Some(format!("SIG{sig}"))) + } else { + (status.code(), None) + } + } + #[cfg(not(unix))] + { + (status.code(), None::) + } + }; + + // Emit lifecycle exited + let exited_event = serde_json::json!({ + "type": "lifecycle", + "sequence": seq.fetch_add(1, Ordering::SeqCst), + "ts": timestamps::now_iso8601(), + "payload": { "event": "exited" } + }); + println!("{}", exited_event); + + // Emit result + let result = serde_json::json!({ + "type": "result", + "payload": { + "exitCode": exit_code.unwrap_or(-1), + "signal": signal, + "timedOut": false, + "durationMs": duration_ms, + } + }); + println!("{}", result); + std::process::exit(exit_code.unwrap_or(1)); + } +``` + +- [ ] **Step 2: Run cargo check to verify compilation** + +Run: `cd crates/nixosandbox && cargo check 2>&1` +Expected: Compiles without errors (may have unused import warnings for sync types, which is fine) + +- [ ] **Step 3: Commit** + +```bash +git add crates/nixosandbox/src/main.rs +git commit -m "feat: enhance exec --json with full event stream + +- Emit lifecycle 'started' event when bwrap spawns +- Stream stderr as separate 'stderr' events (parallel thread) +- Emit lifecycle 'exited' event before result +- Include signal field in result payload +- Sequence numbers strictly increasing across all event types" +``` + +--- + +### Task 6: Delete legacy protocol test files + +**Files:** +- Delete: `tests/protocol/version-mismatch.test.ts` +- Delete: `tests/protocol/validation-failure.test.ts` +- Delete: `tests/protocol/degraded-allowlist.test.ts` +- Delete: `tests/protocol/network-observation.test.ts` +- Delete: `tests/protocol/allowlist-enforced.test.ts` + +- [ ] **Step 1: Delete the five legacy-only test files** + +```bash +rm tests/protocol/version-mismatch.test.ts +rm tests/protocol/validation-failure.test.ts +rm tests/protocol/degraded-allowlist.test.ts +rm tests/protocol/network-observation.test.ts +rm tests/protocol/allowlist-enforced.test.ts +``` + +- [ ] **Step 2: Commit** + +```bash +git add tests/protocol/version-mismatch.test.ts tests/protocol/validation-failure.test.ts tests/protocol/degraded-allowlist.test.ts tests/protocol/network-observation.test.ts tests/protocol/allowlist-enforced.test.ts +git commit -m "chore: delete legacy NDJSON-only protocol tests + +These tests exercise the legacy-ndjson subprocess protocol which is +being removed. Tests for version-mismatch, validation-failure, +degraded-allowlist, network-observation, and allowlist-enforced +are no longer needed." +``` + +--- + +### Task 7: Delete legacy-ndjson subcommand and dead inbound types + +**Files:** +- Modify: `crates/nixosandbox/src/cli.rs:91-94` +- Modify: `crates/nixosandbox/src/main.rs:39-41,328-404` +- Modify: `crates/nixosandbox/src/contract.rs:10-82` + +- [ ] **Step 1: Remove LegacyNdjson from cli.rs** + +In `crates/nixosandbox/src/cli.rs`, delete the LegacyNdjson variant: + +```rust + /// Run in legacy NDJSON subprocess mode (for backward compatibility) + #[command(hide = true)] + LegacyNdjson, +``` + +- [ ] **Step 2: Remove the LegacyNdjson match arm and legacy_ndjson_main() from main.rs** + +In `crates/nixosandbox/src/main.rs`, delete the match arm: + +```rust + Commands::LegacyNdjson => { + legacy_ndjson_main(); + } +``` + +And delete the entire `legacy_ndjson_main()` function (lines 328-404). + +- [ ] **Step 3: Remove InboundMessage, CancelPayload, and ValidationEnvelope from contract.rs** + +In `crates/nixosandbox/src/contract.rs`, delete the `InboundMessage` enum and `CancelPayload` struct: + +```rust +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum InboundMessage { + Plan { payload: PlanPayload }, + Cancel { payload: CancelPayload }, +} +``` + +and: + +```rust +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CancelPayload { + pub reason: Option, +} +``` + +Also delete the `ValidationEnvelope` struct and its `impl` block (the NDJSON wrapper — only used by the legacy protocol): + +```rust +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidationEnvelope { + #[serde(rename = "type")] + pub msg_type: &'static str, + pub v: u32, + pub payload: ValidationPayload, +} +``` + +and: + +```rust +impl ValidationEnvelope { + pub fn new(payload: ValidationPayload) -> OutboundMessage { + OutboundMessage::Validation(ValidationEnvelope { + msg_type: "validation", + v: PROTOCOL_VERSION, + payload, + }) + } +} +``` + +Also remove the `Validation(ValidationEnvelope)` variant from `OutboundMessage`: + +```rust +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum OutboundMessage { + Validation(ValidationEnvelope), // DELETE THIS LINE + Stdout(StdoutEnvelope), + ... +``` + +Keep `ValidationPayload`, `ValidationError`, `ValidationWarning`, `EffectiveState`, and all other outbound types — they are used by `validator::validate()` which remains. + +- [ ] **Step 4: Run cargo check and fix any remaining dead code** + +Run: `cd crates/nixosandbox && cargo check 2>&1` + +The compiler may report warnings for unused types/functions. Expected surviving code: +- `supervisor::supervise()` — referenced by tests, may be reused later; keep +- `validator::validate()` — returns `ValidationPayload`; keep +- `ValidationPayload`, `ValidationError`, `ValidationWarning` — used by validator; keep +- `PlanPayload` and sub-types — used by plan_builder, docker, supervisor; keep +- `EffectiveState` and related types — used by supervisor, validator; keep + +If there are hard errors (not just warnings), fix them. The `use contract::{..., InboundMessage, ...}` in the now-deleted `legacy_ndjson_main` should already be gone. Check for any remaining `use` statements referencing deleted types in other modules. + +Expected: Compiles with possible unused-code warnings but no errors. + +- [ ] **Step 5: Run full test suite** + +Run: `cd crates/nixosandbox && cargo test --test-threads=1 2>&1` +Expected: All Rust tests pass (42 tests). Some code may have `dead_code` warnings. + +- [ ] **Step 6: Commit** + +```bash +git add crates/nixosandbox/src/cli.rs crates/nixosandbox/src/main.rs crates/nixosandbox/src/contract.rs +git commit -m "feat: delete legacy-ndjson subcommand and inbound types + +Remove LegacyNdjson CLI variant and legacy_ndjson_main() entry point. +Delete InboundMessage enum and CancelPayload from contract.rs. +exec --json is now the sole machine-readable interface." +``` + +--- + +### Task 8: Update protocol test infrastructure + +**Files:** +- Modify: `tests/protocol/globalSetup.ts` +- Modify: `tests/protocol/helpers.ts` + +- [ ] **Step 1: Update globalSetup.ts with new env var names** + +Replace the entire content of `tests/protocol/globalSetup.ts`: + +```typescript +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const CRATE_DIR = resolve(import.meta.dirname, "../../crates/nixosandbox"); + +export async function setup() { + console.log("Building nixosandbox..."); + execFileSync("cargo", ["build", "--release"], { + cwd: CRATE_DIR, + stdio: "inherit", + }); + + const binaryPath = resolve(CRATE_DIR, "target/release/nixosandbox"); + if (!existsSync(binaryPath)) { + throw new Error(`Binary not found at ${binaryPath}`); + } + + process.env.NIXOSANDBOX_BINARY = binaryPath; + // Disable Docker sidecar for non-Docker tests. + // Docker-specific tests override this via their own env. + process.env.NIXOSANDBOX_NO_DOCKER = "1"; + console.log(`Runtime binary: ${binaryPath}`); +} +``` + +- [ ] **Step 2: Add spawnExecJson helper to helpers.ts** + +Replace the entire content of `tests/protocol/helpers.ts`: + +```typescript +import { spawn, type ChildProcess } from "node:child_process"; +import { createInterface } from "node:readline"; + +export interface TestRuntime { + send(message: Record): void; + readline(): Promise>; + readAllEvents(): Promise[]>; + kill(signal?: NodeJS.Signals): void; + waitForExit(): Promise<{ code: number | null; signal: string | null }>; + stderr: string; + process: ChildProcess; +} + +/** + * Spawn `nixosandbox exec --json -- ` and return + * a TestRuntime that reads NDJSON events from stdout. + */ +export function spawnExecJson( + sessionId: string, + command: string[], + options?: { env?: NodeJS.ProcessEnv; extraArgs?: string[] }, +): TestRuntime { + const binaryPath = process.env.NIXOSANDBOX_BINARY; + if (!binaryPath) { + throw new Error("NIXOSANDBOX_BINARY not set. Did globalSetup run?"); + } + + const args = [ + "exec", + "--json", + ...(options?.extraArgs ?? []), + sessionId, + "--", + ...command, + ]; + + const child = spawn(binaryPath, args, { + stdio: ["pipe", "pipe", "pipe"], + env: options?.env ?? process.env, + }); + + return wrapChildProcess(child); +} + +/** + * Wrap a ChildProcess into a TestRuntime for NDJSON event reading. + */ +function wrapChildProcess(child: ChildProcess): TestRuntime { + const rl = createInterface({ input: child.stdout! }); + const lineQueue: string[] = []; + let lineResolve: ((line: string) => void) | null = null; + let closed = false; + + rl.on("line", (line) => { + if (lineResolve) { + const resolve = lineResolve; + lineResolve = null; + resolve(line); + } else { + lineQueue.push(line); + } + }); + + rl.on("close", () => { + closed = true; + if (lineResolve) { + const resolve = lineResolve; + lineResolve = null; + resolve(""); + } + }); + + let stderrBuf = ""; + child.stderr!.on("data", (chunk: Buffer) => { + stderrBuf += chunk.toString(); + }); + + function nextLine(): Promise { + if (lineQueue.length > 0) { + return Promise.resolve(lineQueue.shift()!); + } + if (closed) { + return Promise.reject(new Error("stdout closed before line received")); + } + return new Promise((resolve) => { + lineResolve = resolve; + }); + } + + const runtime: TestRuntime = { + send(message: Record): void { + child.stdin!.write(JSON.stringify(message) + "\n"); + }, + + async readline(): Promise> { + const line = await nextLine(); + if (!line) throw new Error("Empty line received"); + return JSON.parse(line) as Record; + }, + + async readAllEvents(): Promise[]> { + const events: Record[] = []; + while (true) { + let line: string; + try { + line = await nextLine(); + } catch { + break; + } + if (!line) break; + const parsed = JSON.parse(line) as Record; + events.push(parsed); + if (parsed.type === "result") { + break; + } + } + return events; + }, + + kill(signal: NodeJS.Signals = "SIGTERM"): void { + child.kill(signal); + }, + + waitForExit(): Promise<{ code: number | null; signal: string | null }> { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve({ code: child.exitCode, signal: child.signalCode }); + return; + } + child.on("exit", (code, signal) => { + resolve({ code, signal }); + }); + }); + }, + + get stderr(): string { + return stderrBuf; + }, + + process: child, + }; + + return runtime; +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add tests/protocol/globalSetup.ts tests/protocol/helpers.ts +git commit -m "refactor: update protocol test infrastructure for exec --json + +- Rename RUNTIME_BINARY_PATH → NIXOSANDBOX_BINARY +- Rename PI_SANDBOX_NO_DOCKER → NIXOSANDBOX_NO_DOCKER +- Replace spawnRuntime/makePlan with spawnExecJson helper +- New helper spawns 'nixosandbox exec --json -- '" +``` + +--- + +### Task 9: Adapt remaining protocol tests + +**Files:** +- Modify: `tests/protocol/cancel-flow.test.ts` +- Modify: `tests/protocol/crash-synthesis.test.ts` (minimal — verify it still compiles) +- Modify: `tests/protocol/docker-sidecar.test.ts` + +- [ ] **Step 1: Rewrite cancel-flow.test.ts** + +Replace the entire content of `tests/protocol/cancel-flow.test.ts`: + +```typescript +import { describe, expect, it, beforeAll, afterAll } from "vitest"; +import { execFileSync } from "node:child_process"; +import { spawnExecJson } from "./helpers.js"; + +const RUN_INTEGRATION = process.env.RUN_INTEGRATION_TESTS === "1"; +const RUN_DOCKER = process.env.RUN_DOCKER_TESTS === "1"; + +describe.skipIf(!RUN_INTEGRATION && !RUN_DOCKER)( + "Cancel Flow (exec --json)", + () => { + let sessionId: string; + + beforeAll(() => { + const binaryPath = process.env.NIXOSANDBOX_BINARY; + if (!binaryPath) throw new Error("NIXOSANDBOX_BINARY not set"); + + // Create a session for testing + const env = RUN_DOCKER + ? { ...process.env, NIXOSANDBOX_NO_DOCKER: undefined } as NodeJS.ProcessEnv + : process.env; + const output = execFileSync(binaryPath, [ + "create", "--profile", "strict", "--json", + ], { env, encoding: "utf-8" }); + const meta = JSON.parse(output); + sessionId = meta.sessionId; + }); + + afterAll(() => { + const binaryPath = process.env.NIXOSANDBOX_BINARY; + if (binaryPath && sessionId) { + try { + execFileSync(binaryPath, ["destroy", sessionId], { stdio: "ignore" }); + } catch { + // Cleanup best-effort + } + } + }); + + it("cancels a running process via SIGTERM and observes lifecycle events", async () => { + const env = RUN_DOCKER + ? { ...process.env, NIXOSANDBOX_NO_DOCKER: undefined } as NodeJS.ProcessEnv + : process.env; + const rt = spawnExecJson(sessionId, ["sleep", "3600"], { env }); + + // Read events until we see "started" lifecycle + let startedSeen = false; + const preEvents: Record[] = []; + while (!startedSeen) { + const event = await rt.readline(); + preEvents.push(event); + if ( + event.type === "lifecycle" && + (event.payload as any).event === "started" + ) { + startedSeen = true; + } + } + expect(startedSeen).toBe(true); + + // Send SIGTERM to the nixosandbox process (which kills the bwrap child) + rt.kill("SIGTERM"); + + // Read remaining events — should include result with non-zero exit or signal + const resultPromise = new Promise | null>( + async (resolve) => { + const timer = setTimeout(() => resolve(null), 10000); + try { + while (true) { + const event = await rt.readline(); + if (event.type === "result") { + clearTimeout(timer); + resolve(event); + return; + } + } + } catch { + clearTimeout(timer); + resolve(null); + } + }, + ); + + const resultEvent = await resultPromise; + + if (resultEvent) { + const resultPayload = resultEvent.payload as any; + // Process was killed — either signal or non-zero exit + expect( + resultPayload.exitCode !== 0 || resultPayload.signal !== null, + ).toBe(true); + } else { + // Force-kill if no result received + rt.kill("SIGKILL"); + } + + const exit = await rt.waitForExit(); + expect(exit.signal !== null || exit.code !== null).toBe(true); + }, 30_000); + }, +); +``` + +- [ ] **Step 2: Verify crash-synthesis.test.ts needs no changes** + +The crash-synthesis test imports from `packages/pi-sandbox-extension/src/crash-synthesis.js` and is TS-only. It does not spawn the runtime binary. Verify it still compiles by reading it — no changes needed. + +- [ ] **Step 3: Rewrite docker-sidecar.test.ts** + +Replace the entire content of `tests/protocol/docker-sidecar.test.ts`: + +```typescript +/** + * Docker sidecar integration tests with rootfs execution. + * + * These tests require Docker Desktop + Nix and are gated behind + * RUN_DOCKER_TESTS=1. + * + * Run: RUN_DOCKER_TESTS=1 npx vitest run tests/protocol/docker-sidecar.test.ts + */ +import { execFileSync } from "node:child_process"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { spawnExecJson } from "./helpers.js"; + +const DOCKER_TESTS = process.env.RUN_DOCKER_TESTS === "1"; + +// Docker tests need NIXOSANDBOX_NO_DOCKER unset +const dockerEnv = { + ...process.env, + NIXOSANDBOX_NO_DOCKER: undefined, +} as NodeJS.ProcessEnv; + +describe.skipIf(!DOCKER_TESTS)("Docker sidecar (rootfs)", () => { + let sessionId: string; + + beforeAll(() => { + const binaryPath = process.env.NIXOSANDBOX_BINARY; + if (!binaryPath) throw new Error("NIXOSANDBOX_BINARY not set"); + + // Clean up any leftover sidecar from previous runs + try { + execFileSync("docker", ["rm", "-f", "nixosandbox-sidecar"], { + stdio: "ignore", + }); + } catch { + // Container didn't exist + } + + // Create a session with Docker enabled + const output = execFileSync( + binaryPath, + ["create", "--profile", "strict", "--json"], + { env: dockerEnv, encoding: "utf-8" }, + ); + const meta = JSON.parse(output); + sessionId = meta.sessionId; + }, 120_000); // 2min for first-time rootfs build + Docker image build + + afterAll(() => { + const binaryPath = process.env.NIXOSANDBOX_BINARY; + if (binaryPath && sessionId) { + try { + execFileSync(binaryPath, ["destroy", sessionId], { stdio: "ignore" }); + } catch { + // Cleanup best-effort + } + } + }); + + it("runs echo through Docker+bwrap with rootfs and gets lifecycle events", async () => { + const rt = spawnExecJson(sessionId, ["echo", "hello from docker"], { + env: dockerEnv, + }); + + const events = await rt.readAllEvents(); + + // Should have lifecycle(started), stdout, lifecycle(exited), result + expect(events.length).toBeGreaterThanOrEqual(3); + + const startedEvent = events.find( + (e) => + e.type === "lifecycle" && (e.payload as any).event === "started", + ); + expect(startedEvent).toBeDefined(); + + const stdoutEvents = events.filter((e) => e.type === "stdout"); + const helloEvent = stdoutEvents.find((e) => + ((e.payload as any).data as string).includes("hello from docker"), + ); + expect(helloEvent).toBeDefined(); + + const exitedEvent = events.find( + (e) => + e.type === "lifecycle" && (e.payload as any).event === "exited", + ); + expect(exitedEvent).toBeDefined(); + + const result = events.find((e) => e.type === "result") as any; + expect(result).toBeDefined(); + expect(result.payload.exitCode).toBe(0); + + await rt.waitForExit(); + }, 60_000); + + it("verifies Nix store is accessible inside container", async () => { + const rt = spawnExecJson(sessionId, ["ls", "/nix/store"], { + env: dockerEnv, + }); + + const events = await rt.readAllEvents(); + + const result = events.find((e) => e.type === "result") as any; + expect(result).toBeDefined(); + // ls /nix/store should succeed since we mount it + // Note: inside the bwrap sandbox, /nix/store is part of the rootfs + // via --pivot-root, not the Docker mount. The Docker mount makes it + // available to bwrap for --pivot-root to use. + // The actual test is that bwrap can access the rootfs path. + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }, 30_000); + + it("NIXOSANDBOX_NO_DOCKER=1 blocks Docker and exits with error", () => { + const binaryPath = process.env.NIXOSANDBOX_BINARY; + if (!binaryPath) throw new Error("NIXOSANDBOX_BINARY not set"); + + // With Docker disabled on non-Linux, exec should fail + try { + execFileSync( + binaryPath, + ["exec", sessionId, "--", "echo", "should-fail"], + { + env: { ...process.env, NIXOSANDBOX_NO_DOCKER: "1" }, + encoding: "utf-8", + stdio: "pipe", + }, + ); + // If we're on Linux with bwrap, this might succeed — that's OK + } catch (err: any) { + // On macOS without Docker, should fail with non-zero exit + expect(err.status).not.toBe(0); + } + }, 15_000); +}); +``` + +- [ ] **Step 4: Commit** + +```bash +git add tests/protocol/cancel-flow.test.ts tests/protocol/docker-sidecar.test.ts +git commit -m "refactor: adapt protocol tests for exec --json + +- cancel-flow: use spawnExecJson with pre-created session, gate behind + RUN_INTEGRATION_TESTS or RUN_DOCKER_TESTS +- docker-sidecar: test rootfs execution through Docker, update naming + to nixosandbox-sidecar, verify Nix store access" +``` + +--- + +### Task 10: Create integration test infrastructure + +**Files:** +- Create: `tests/integration/package.json` +- Create: `tests/integration/tsconfig.json` +- Create: `tests/integration/vitest.config.ts` +- Create: `tests/integration/globalSetup.ts` +- Create: `tests/integration/helpers.ts` + +- [ ] **Step 1: Create package.json** + +Create `tests/integration/package.json`: + +```json +{ + "name": "@nixosandbox/integration-tests", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +Create `tests/integration/tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["*.ts"], + "exclude": ["node_modules", "dist"] +} +``` + +- [ ] **Step 3: Create vitest.config.ts** + +Create `tests/integration/vitest.config.ts`: + +```typescript +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["*.test.ts"], + globalSetup: "./globalSetup.ts", + testTimeout: 120000, // 2 minutes — Nix builds can be slow + }, +}); +``` + +- [ ] **Step 4: Create globalSetup.ts** + +Create `tests/integration/globalSetup.ts`: + +```typescript +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const CRATE_DIR = resolve(import.meta.dirname, "../../crates/nixosandbox"); + +export async function setup() { + console.log("Building nixosandbox (release)..."); + execFileSync("cargo", ["build", "--release"], { + cwd: CRATE_DIR, + stdio: "inherit", + }); + + const binaryPath = resolve(CRATE_DIR, "target/release/nixosandbox"); + if (!existsSync(binaryPath)) { + throw new Error(`Binary not found at ${binaryPath}`); + } + + process.env.NIXOSANDBOX_BINARY = binaryPath; + console.log(`Runtime binary: ${binaryPath}`); +} +``` + +- [ ] **Step 5: Create helpers.ts** + +Create `tests/integration/helpers.ts`: + +```typescript +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; +import { createInterface } from "node:readline"; + +function getBinary(): string { + const bin = process.env.NIXOSANDBOX_BINARY; + if (!bin) throw new Error("NIXOSANDBOX_BINARY not set. Did globalSetup run?"); + return bin; +} + +export interface BuildResult { + stdout: string; + exitCode: number; +} + +/** + * Run `nixosandbox build` with the given args. + */ +export function build(args: string[], env?: NodeJS.ProcessEnv): BuildResult { + try { + const stdout = execFileSync(getBinary(), ["build", ...args], { + encoding: "utf-8", + env: env ?? process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + return { stdout: stdout.trim(), exitCode: 0 }; + } catch (err: any) { + return { stdout: err.stdout?.toString() ?? "", exitCode: err.status ?? 1 }; + } +} + +export interface CreateResult { + sessionId: string; + metadata: Record; +} + +/** + * Run `nixosandbox create` and parse the JSON output. + */ +export function create( + args: string[], + env?: NodeJS.ProcessEnv, +): CreateResult { + const stdout = execFileSync(getBinary(), ["create", "--json", ...args], { + encoding: "utf-8", + env: env ?? process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + const metadata = JSON.parse(stdout.trim()) as Record; + return { sessionId: metadata.sessionId as string, metadata }; +} + +export interface ExecResult { + events: Record[]; + exitCode: number; +} + +/** + * Run `nixosandbox exec --json -- ` and collect all NDJSON events. + */ +export async function execCmd( + sessionId: string, + command: string[], + opts?: { env?: NodeJS.ProcessEnv; extraEnv?: string[] }, +): Promise { + const envArgs = (opts?.extraEnv ?? []).flatMap((e) => ["--env", e]); + const args = ["exec", "--json", ...envArgs, sessionId, "--", ...command]; + + return new Promise((resolve, reject) => { + const child = spawn(getBinary(), args, { + stdio: ["pipe", "pipe", "pipe"], + env: opts?.env ?? process.env, + }); + + const events: Record[] = []; + const rl = createInterface({ input: child.stdout! }); + + rl.on("line", (line) => { + try { + events.push(JSON.parse(line)); + } catch { + // Ignore unparseable lines + } + }); + + child.on("exit", (code) => { + resolve({ events, exitCode: code ?? 1 }); + }); + + child.on("error", (err) => { + reject(err); + }); + }); +} + +export interface ListResult { + sessions: Record[]; +} + +/** + * Run `nixosandbox list --json` and parse the JSON output. + */ +export function list(env?: NodeJS.ProcessEnv): ListResult { + const stdout = execFileSync(getBinary(), ["list", "--json"], { + encoding: "utf-8", + env: env ?? process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + const sessions = JSON.parse(stdout.trim()) as Record[]; + return { sessions }; +} + +/** + * Run `nixosandbox destroy `. + */ +export function destroy( + sessionId: string, + env?: NodeJS.ProcessEnv, +): number { + try { + execFileSync(getBinary(), ["destroy", sessionId], { + env: env ?? process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + return 0; + } catch (err: any) { + return err.status ?? 1; + } +} +``` + +- [ ] **Step 6: Install dependencies** + +```bash +cd tests/integration && npm install +``` + +- [ ] **Step 7: Commit** + +```bash +git add tests/integration/package.json tests/integration/tsconfig.json tests/integration/vitest.config.ts tests/integration/globalSetup.ts tests/integration/helpers.ts tests/integration/package-lock.json +git commit -m "feat: create integration test infrastructure + +New test project at tests/integration/ with: +- CLI wrapper helpers (build, create, execCmd, list, destroy) +- Vitest config with 2-minute timeout for Nix builds +- globalSetup builds the nixosandbox binary" +``` + +--- + +### Task 11: rootfs-pipeline integration tests + +**Files:** +- Create: `tests/integration/rootfs-pipeline.test.ts` + +- [ ] **Step 1: Create rootfs-pipeline.test.ts** + +Create `tests/integration/rootfs-pipeline.test.ts`: + +```typescript +/** + * Linux native integration tests for the rootfs pipeline. + * + * Requires: Nix + bwrap on Linux. + * Gate: RUN_INTEGRATION_TESTS=1 + * + * Run: RUN_INTEGRATION_TESTS=1 npx vitest run rootfs-pipeline.test.ts + */ +import { describe, it, expect, afterAll } from "vitest"; +import { build, create, execCmd, list, destroy } from "./helpers.js"; + +const RUN = process.env.RUN_INTEGRATION_TESTS === "1"; + +describe.skipIf(!RUN)("Rootfs Pipeline (Linux native)", () => { + const sessionsToCleanup: string[] = []; + + afterAll(() => { + for (const id of sessionsToCleanup) { + try { + destroy(id); + } catch { + // Best-effort cleanup + } + } + }); + + it("build strict profile returns a valid Nix store path", () => { + const result = build(["--profile", "strict", "--json"]); + expect(result.exitCode).toBe(0); + + const parsed = JSON.parse(result.stdout); + expect(parsed.rootfsPath).toBeDefined(); + expect(parsed.rootfsPath).toMatch(/^\/nix\/store\//); + }); + + it("create session returns session ID and metadata", () => { + const { sessionId, metadata } = create(["--profile", "strict"]); + sessionsToCleanup.push(sessionId); + + expect(sessionId).toBeDefined(); + expect(sessionId.length).toBe(8); + expect(metadata.profile).toBe("strict"); + expect(metadata.rootfsPath).toMatch(/^\/nix\/store\//); + }); + + it("exec echo prints hello and exits 0", async () => { + const { sessionId } = create(["--profile", "strict"]); + sessionsToCleanup.push(sessionId); + + const result = await execCmd(sessionId, ["echo", "hello"]); + expect(result.exitCode).toBe(0); + + const stdoutEvents = result.events.filter((e) => e.type === "stdout"); + const helloEvent = stdoutEvents.find((e) => + ((e.payload as any).data as string).includes("hello"), + ); + expect(helloEvent).toBeDefined(); + }); + + it("exec verifies rootfs directory structure", async () => { + const { sessionId } = create(["--profile", "strict"]); + sessionsToCleanup.push(sessionId); + + const result = await execCmd(sessionId, ["ls", "/"]); + expect(result.exitCode).toBe(0); + + const stdout = result.events + .filter((e) => e.type === "stdout") + .map((e) => (e.payload as any).data as string) + .join("\n"); + + // Rootfs should have sandbox dirs + expect(stdout).toContain("bin"); + expect(stdout).toContain("etc"); + expect(stdout).toContain("workspace"); + }); + + it("exec verifies sandbox user exists", async () => { + const { sessionId } = create(["--profile", "strict"]); + sessionsToCleanup.push(sessionId); + + const result = await execCmd(sessionId, ["cat", "/etc/passwd"]); + expect(result.exitCode).toBe(0); + + const stdout = result.events + .filter((e) => e.type === "stdout") + .map((e) => (e.payload as any).data as string) + .join("\n"); + + expect(stdout).toContain("sandbox"); + }); + + it("exec json mode produces lifecycle + stdout + result events", async () => { + const { sessionId } = create(["--profile", "strict"]); + sessionsToCleanup.push(sessionId); + + const result = await execCmd(sessionId, ["echo", "test"]); + expect(result.exitCode).toBe(0); + + // Must have: lifecycle(started), stdout(test), lifecycle(exited), result + const started = result.events.find( + (e) => + e.type === "lifecycle" && (e.payload as any).event === "started", + ); + expect(started).toBeDefined(); + + const stdout = result.events.find( + (e) => + e.type === "stdout" && + ((e.payload as any).data as string).includes("test"), + ); + expect(stdout).toBeDefined(); + + const exited = result.events.find( + (e) => + e.type === "lifecycle" && (e.payload as any).event === "exited", + ); + expect(exited).toBeDefined(); + + const resultEvent = result.events.find((e) => e.type === "result") as any; + expect(resultEvent).toBeDefined(); + expect(resultEvent.payload.exitCode).toBe(0); + expect(resultEvent.payload.timedOut).toBe(false); + expect(resultEvent.payload.durationMs).toBeGreaterThan(0); + + // Sequence numbers strictly increasing + const sequenced = result.events.filter( + (e) => (e as any).sequence !== undefined, + ); + for (let i = 1; i < sequenced.length; i++) { + expect((sequenced[i] as any).sequence).toBeGreaterThan( + (sequenced[i - 1] as any).sequence, + ); + } + }); + + it("list sessions shows the created session", () => { + const { sessionId } = create(["--profile", "strict"]); + sessionsToCleanup.push(sessionId); + + const { sessions } = list(); + const found = sessions.find( + (s) => (s as any).sessionId === sessionId, + ); + expect(found).toBeDefined(); + }); + + it("destroy session removes it from list", () => { + const { sessionId } = create(["--profile", "strict"]); + + const exitCode = destroy(sessionId); + expect(exitCode).toBe(0); + + const { sessions } = list(); + const found = sessions.find( + (s) => (s as any).sessionId === sessionId, + ); + expect(found).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add tests/integration/rootfs-pipeline.test.ts +git commit -m "feat: add rootfs-pipeline integration tests + +8 tests covering the full CLI lifecycle: +- build strict profile → Nix store path +- create session → session ID + metadata +- exec echo → stdout output +- exec ls / → rootfs directory structure +- exec cat /etc/passwd → sandbox user +- exec json mode → lifecycle + stdout + result events +- list sessions → session visible +- destroy session → session removed + +Gated: RUN_INTEGRATION_TESTS=1 (requires Nix + bwrap on Linux)" +``` + +--- + +### Task 12: Docker rootfs integration tests + +**Files:** +- Create: `tests/integration/docker-rootfs.test.ts` + +- [ ] **Step 1: Create docker-rootfs.test.ts** + +Create `tests/integration/docker-rootfs.test.ts`: + +```typescript +/** + * macOS Docker integration tests for rootfs execution. + * + * Requires: Nix + Docker Desktop. + * Gate: RUN_DOCKER_TESTS=1 + * + * Run: RUN_DOCKER_TESTS=1 npx vitest run docker-rootfs.test.ts + */ +import { execFileSync } from "node:child_process"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { create, execCmd, destroy } from "./helpers.js"; + +const RUN = process.env.RUN_DOCKER_TESTS === "1"; + +// Docker tests need NIXOSANDBOX_NO_DOCKER unset +const dockerEnv = { + ...process.env, + NIXOSANDBOX_NO_DOCKER: undefined, +} as NodeJS.ProcessEnv; + +describe.skipIf(!RUN)("Docker Rootfs (macOS)", () => { + const sessionsToCleanup: string[] = []; + + beforeAll(() => { + // Clean up any leftover sidecar + try { + execFileSync("docker", ["rm", "-f", "nixosandbox-sidecar"], { + stdio: "ignore", + }); + } catch { + // Didn't exist + } + }); + + afterAll(() => { + for (const id of sessionsToCleanup) { + try { + destroy(id, dockerEnv); + } catch { + // Best-effort + } + } + }); + + it("create + exec through Docker sidecar", async () => { + const { sessionId } = create(["--profile", "strict"], dockerEnv); + sessionsToCleanup.push(sessionId); + + const result = await execCmd(sessionId, ["echo", "hello from docker"], { + env: dockerEnv, + }); + + expect(result.exitCode).toBe(0); + + const stdout = result.events + .filter((e) => e.type === "stdout") + .map((e) => (e.payload as any).data as string) + .join("\n"); + expect(stdout).toContain("hello from docker"); + }, 120_000); + + it("verifies rootfs directory structure through Docker", async () => { + const { sessionId } = create(["--profile", "strict"], dockerEnv); + sessionsToCleanup.push(sessionId); + + const result = await execCmd(sessionId, ["ls", "/"], { env: dockerEnv }); + expect(result.exitCode).toBe(0); + + const stdout = result.events + .filter((e) => e.type === "stdout") + .map((e) => (e.payload as any).data as string) + .join("\n"); + + expect(stdout).toContain("bin"); + expect(stdout).toContain("etc"); + expect(stdout).toContain("workspace"); + }, 60_000); + + it("verifies sandbox user through Docker", async () => { + const { sessionId } = create(["--profile", "strict"], dockerEnv); + sessionsToCleanup.push(sessionId); + + const result = await execCmd(sessionId, ["cat", "/etc/passwd"], { + env: dockerEnv, + }); + expect(result.exitCode).toBe(0); + + const stdout = result.events + .filter((e) => e.type === "stdout") + .map((e) => (e.payload as any).data as string) + .join("\n"); + + expect(stdout).toContain("sandbox"); + }, 60_000); + + it("JSON mode reports full lifecycle events through Docker", async () => { + const { sessionId } = create(["--profile", "strict"], dockerEnv); + sessionsToCleanup.push(sessionId); + + const result = await execCmd(sessionId, ["echo", "lifecycle-test"], { + env: dockerEnv, + }); + expect(result.exitCode).toBe(0); + + const started = result.events.find( + (e) => + e.type === "lifecycle" && (e.payload as any).event === "started", + ); + expect(started).toBeDefined(); + + const exited = result.events.find( + (e) => + e.type === "lifecycle" && (e.payload as any).event === "exited", + ); + expect(exited).toBeDefined(); + + const resultEvent = result.events.find( + (e) => e.type === "result", + ) as any; + expect(resultEvent).toBeDefined(); + expect(resultEvent.payload.exitCode).toBe(0); + }, 60_000); +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add tests/integration/docker-rootfs.test.ts +git commit -m "feat: add Docker rootfs integration tests + +4 tests covering Docker sidecar execution: +- create + exec through Docker +- verify rootfs directory structure +- verify sandbox user +- JSON mode lifecycle events through Docker + +Gated: RUN_DOCKER_TESTS=1 (requires Nix + Docker Desktop)" +``` + +--- + +## Test Gating Summary + +| Env Var | Suite | Location | Requires | +|---------|-------|----------|----------| +| `RUN_INTEGRATION_TESTS=1` | rootfs-pipeline | `tests/integration/` | Nix, bwrap, Linux | +| `RUN_DOCKER_TESTS=1` | docker-rootfs | `tests/integration/` | Nix, Docker | +| `RUN_INTEGRATION_TESTS=1` or `RUN_DOCKER_TESTS=1` | cancel-flow | `tests/protocol/` | Nix + bwrap or Docker | +| `RUN_DOCKER_TESTS=1` | docker-sidecar | `tests/protocol/` | Nix, Docker | +| (none) | crash-synthesis | `tests/protocol/` | Just Node.js | + +## Run Commands + +```bash +# Rust unit tests (always) +cd crates/nixosandbox && cargo test --test-threads=1 + +# Protocol tests (just binary, no Nix) +cd tests/protocol && npx vitest run + +# Linux integration tests (Nix + bwrap) +cd tests/integration && RUN_INTEGRATION_TESTS=1 npx vitest run rootfs-pipeline.test.ts + +# Docker integration tests (Nix + Docker) +cd tests/integration && RUN_DOCKER_TESTS=1 npx vitest run docker-rootfs.test.ts + +# All Docker protocol tests +cd tests/protocol && RUN_DOCKER_TESTS=1 npx vitest run +``` diff --git a/docs/superpowers/plans/2026-04-08-nix-flake-runtime.md b/docs/superpowers/plans/2026-04-08-nix-flake-runtime.md new file mode 100644 index 0000000..3d2247c --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-nix-flake-runtime.md @@ -0,0 +1,1469 @@ +# Nix Flake Runtime Redesign — Implementation Plan (Part A) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a standalone `nixosandbox` CLI + Nix flake that creates reproducible sandbox environments from Nix derivations and executes commands inside them via bwrap `--pivot-root`. + +**Architecture:** A Nix flake at the repo root exports `mkSandboxRootfs` (builds rootfs derivations from package lists) and a Rust CLI binary (`nixosandbox`) that manages sandbox sessions (create/exec/enter/list/destroy/build). The existing bwrap supervision code from `pi-sandbox-runtime` is migrated into the new CLI crate. bwrap uses `--pivot-root` into the Nix-built rootfs instead of `--ro-bind` of individual host paths. + +**Tech Stack:** Nix flakes, Rust (clap for CLI), bubblewrap, serde_json + +**Design Spec:** `docs/superpowers/specs/2026-04-08-nix-flake-runtime-design.md` + +**Scope:** This is Part A — the standalone tool on Linux with Nix. Part B (Docker sidecar updates, Pi extension simplification) follows in a separate plan. + +--- + +## File Map + +### New Files + +| Path | Responsibility | +|------|----------------| +| `flake.nix` | Flake definition: inputs, mkSandboxRootfs, packages, devShell | +| `nix/mkSandboxRootfs.nix` | Nix function: takes package list, builds rootfs directory tree | +| `nix/profiles/build-install.json` | Built-in profile spec | +| `nix/profiles/offline-review.json` | Built-in profile spec | +| `nix/profiles/strict.json` | Built-in profile spec | +| `nix/profiles/debug-network.json` | Built-in profile spec | +| `nix/packages.json` | Curated package name to nixpkgs attribute mapping | +| `crates/nixosandbox/src/cli.rs` | clap CLI argument parsing and dispatch | +| `crates/nixosandbox/src/session.rs` | Session create/list/destroy + metadata | +| `crates/nixosandbox/src/nix.rs` | Nix build invocation, spec loading, package resolution | +| `crates/nixosandbox/src/spec.rs` | Sandbox spec types + JSON schema validation | + +### Modified Files (after crate rename) + +| Path | Change | +|------|--------| +| `crates/nixosandbox/Cargo.toml` | Rename package, add `clap` dependency | +| `crates/nixosandbox/src/main.rs` | Replace NDJSON-only entry point with clap CLI dispatch | +| `crates/nixosandbox/src/plan_builder.rs` | Add `build_rootfs()` function for pivot-root bwrap argv | +| `tests/protocol/globalSetup.ts` | Update crate path reference | +| `tests/protocol/helpers.ts` | Pass `legacy-ndjson` subcommand to runtime | + +### Deleted Files + +| Path | Reason | +|------|--------| +| `nix/shell.nix` | Replaced by `flake.nix` devShell | +| `docker-compose.yml` | Legacy from old server | + +--- + +### Task 1: Nix flake + mkSandboxRootfs + built-in profiles + +**Files:** +- Create: `flake.nix` +- Create: `nix/mkSandboxRootfs.nix` +- Create: `nix/profiles/build-install.json` +- Create: `nix/profiles/offline-review.json` +- Create: `nix/profiles/strict.json` +- Create: `nix/profiles/debug-network.json` +- Delete: `nix/shell.nix` + +This task builds the Nix side end-to-end: a flake that can build rootfs derivations from profile specs. After this task, `nix build .#sandbox-strict` produces a usable rootfs directory. + +- [ ] **Step 1: Create `nix/mkSandboxRootfs.nix`** + +```nix +# nix/mkSandboxRootfs.nix +# +# Builds a minimal rootfs directory tree from a list of Nix packages. +# The output is suitable for bwrap --pivot-root. +# +# Usage: mkSandboxRootfs { name = "my-env"; packages = [ pkgs.nodejs pkgs.git ]; } +{ pkgs }: + +{ name, packages, env ? {} }: + +let + # Create a merged environment with all requested packages + mergedEnv = pkgs.buildEnv { + name = "sandbox-env-${name}"; + paths = packages; + pathsToLink = [ "/bin" "/lib" "/lib64" "/share" "/etc" "/include" ]; + extraOutputsToInstall = [ "out" ]; + }; +in +pkgs.runCommand "sandbox-${name}" { + passthru = { inherit name env; }; +} '' + mkdir -p $out/{bin,lib,lib64,etc,usr/bin,tmp,dev,proc,workspace,home/sandbox,cache} + + # Symlink all binaries from the merged environment + if [ -d "${mergedEnv}/bin" ]; then + for f in ${mergedEnv}/bin/*; do + ln -sf "$f" "$out/bin/$(basename $f)" + done + fi + + # Symlink libraries + if [ -d "${mergedEnv}/lib" ]; then + for f in ${mergedEnv}/lib/*; do + ln -sf "$f" "$out/lib/$(basename $f)" + done + fi + if [ -d "${mergedEnv}/lib64" ]; then + for f in ${mergedEnv}/lib64/*; do + ln -sf "$f" "$out/lib64/$(basename $f)" + done + fi + + # Symlink share (man pages, etc.) + if [ -d "${mergedEnv}/share" ]; then + ln -sf "${mergedEnv}/share" "$out/share" + fi + + # /usr/bin/env -- needed for #!/usr/bin/env shebangs + ln -sf "${mergedEnv}/bin/env" "$out/usr/bin/env" 2>/dev/null || \ + ln -sf "${pkgs.coreutils}/bin/env" "$out/usr/bin/env" + + # /etc/ssl/certs -- CA certificates + mkdir -p $out/etc/ssl/certs + if [ -e "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ]; then + ln -sf "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" "$out/etc/ssl/certs/ca-certificates.crt" + ln -sf "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" "$out/etc/ssl/certs/ca-bundle.crt" + fi + + # /etc/passwd and /etc/group -- minimal entries for sandbox user + cat > $out/etc/passwd <<'PASSWD' +root:x:0:0:root:/root:/bin/bash +sandbox:x:1000:1000:sandbox:/home/sandbox:/bin/bash +nobody:x:65534:65534:nobody:/nonexistent:/usr/bin/nologin +PASSWD + + cat > $out/etc/group <<'GROUP' +root:x:0: +sandbox:x:1000: +nobody:x:65534: +GROUP + + # /etc/nsswitch.conf + cat > $out/etc/nsswitch.conf <<'NSS' +passwd: files +group: files +hosts: files dns +NSS + + # /etc/hosts -- minimal + cat > $out/etc/hosts <<'HOSTS' +127.0.0.1 localhost +::1 localhost +HOSTS + + # Nix store reference -- keep a file that references the merged env + # so nix-collect-garbage knows this rootfs depends on those packages + echo "${mergedEnv}" > $out/.nix-env-reference +'' +``` + +- [ ] **Step 2: Create the four built-in profile specs** + +Create `nix/profiles/build-install.json`: +```json +{ + "name": "build-install", + "packages": ["nodejs_22", "python312", "rustc", "cargo", "git", "curl", "cacert", "coreutils", "bash", "gnugrep", "gnused", "gawk", "findutils", "gnutar", "gzip", "gnumake", "gcc"], + "env": {}, + "network": "full", + "namespaces": ["pid", "mount", "uts", "ipc"], + "writable": ["/workspace", "/home/sandbox", "/cache", "/tmp"] +} +``` + +Create `nix/profiles/offline-review.json`: +```json +{ + "name": "offline-review", + "packages": ["git", "cacert", "coreutils", "bash", "gnugrep", "gnused", "gawk", "findutils", "jq"], + "env": {}, + "network": "off", + "namespaces": ["pid", "mount", "uts", "ipc", "net"], + "writable": ["/workspace", "/home/sandbox", "/tmp"] +} +``` + +Create `nix/profiles/strict.json`: +```json +{ + "name": "strict", + "packages": ["coreutils", "bash", "cacert"], + "env": {}, + "network": "off", + "namespaces": ["pid", "mount", "uts", "ipc", "net"], + "writable": ["/tmp"] +} +``` + +Create `nix/profiles/debug-network.json`: +```json +{ + "name": "debug-network", + "packages": ["nodejs_22", "python312", "git", "curl", "cacert", "coreutils", "bash", "inetutils", "netcat-gnu", "dig"], + "env": {}, + "network": "full", + "namespaces": ["pid", "mount", "uts", "ipc"], + "writable": ["/workspace", "/home/sandbox", "/cache", "/tmp"] +} +``` + +- [ ] **Step 3: Create `flake.nix`** + +```nix +{ + description = "nixosandbox -- reproducible, isolated sandbox environments"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + }; + + outputs = { self, nixpkgs }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + mkSandboxRootfs = import ./nix/mkSandboxRootfs.nix { inherit pkgs; }; + + # Helper: load a profile JSON and resolve package names to nixpkgs attrs + loadProfile = path: + let + spec = builtins.fromJSON (builtins.readFile path); + resolvedPkgs = map (name: + if builtins.hasAttr name pkgs + then builtins.getAttr name pkgs + else throw "nixosandbox: unknown package '${name}' in profile ${spec.name}" + ) spec.packages; + in + mkSandboxRootfs { + name = spec.name; + packages = resolvedPkgs; + env = spec.env or {}; + }; + in + { + # Library function for custom rootfs + lib.mkSandboxRootfs = mkSandboxRootfs; + + packages.${system} = { + # Pre-built rootfs for each profile + sandbox-build-install = loadProfile ./nix/profiles/build-install.json; + sandbox-offline-review = loadProfile ./nix/profiles/offline-review.json; + sandbox-strict = loadProfile ./nix/profiles/strict.json; + sandbox-debug-network = loadProfile ./nix/profiles/debug-network.json; + + # Default package is the CLI (wired in Task 10) + # nixosandbox = ...; + }; + + devShells.${system}.default = pkgs.mkShell { + name = "nixosandbox-dev"; + buildInputs = with pkgs; [ + rustc + cargo + pkg-config + bubblewrap + jq + ]; + }; + }; +} +``` + +- [ ] **Step 4: Delete `nix/shell.nix`** + +```bash +rm nix/shell.nix +``` + +- [ ] **Step 5: Build the strict profile to verify the flake works** + +Run: `nix build .#sandbox-strict --no-link --print-out-paths` + +Expected: A Nix store path like `/nix/store/...-sandbox-strict`. No errors. + +- [ ] **Step 6: Verify the rootfs has expected contents** + +Run: +```bash +ROOTFS=$(nix build .#sandbox-strict --no-link --print-out-paths) +ls $ROOTFS/bin/ | head -20 +ls $ROOTFS/etc/ +cat $ROOTFS/etc/passwd +test -L $ROOTFS/usr/bin/env && echo "env symlink OK" +``` + +Expected: `bin/` contains bash, coreutils binaries. `etc/` has passwd, group, nsswitch.conf, hosts, ssl/. `usr/bin/env` symlink exists. + +- [ ] **Step 7: Build the build-install profile (larger, confirms package resolution)** + +Run: `nix build .#sandbox-build-install --no-link --print-out-paths` + +Expected: Store path. Takes longer (more packages) but should succeed. + +- [ ] **Step 8: Commit** + +```bash +git add flake.nix nix/mkSandboxRootfs.nix nix/profiles/ +git rm nix/shell.nix +git commit -m "feat: add Nix flake with mkSandboxRootfs and built-in profiles" +``` + +--- + +### Task 2: Curated package mapping + +**Files:** +- Create: `nix/packages.json` + +The curated mapping allows natural-language-style package names to resolve to exact nixpkgs attributes. This file is consumed by the Rust CLI for spec validation and by the future NL skill. + +- [ ] **Step 1: Create `nix/packages.json`** + +```json +{ + "node": { "attr": "nodejs_22", "aliases": ["nodejs", "node.js", "node22"], "extra": [] }, + "python": { "attr": "python312", "aliases": ["python3", "py", "python3.12"], "extra": ["python312Packages.pip"] }, + "rust": { "attr": "rustc", "aliases": ["rustlang"], "extra": ["cargo", "rustfmt", "clippy"] }, + "go": { "attr": "go", "aliases": ["golang", "go-lang"], "extra": [] }, + "git": { "attr": "git", "aliases": [], "extra": [] }, + "curl": { "attr": "curl", "aliases": ["libcurl"], "extra": [] }, + "wget": { "attr": "wget", "aliases": [], "extra": [] }, + "jq": { "attr": "jq", "aliases": [], "extra": [] }, + "ripgrep": { "attr": "ripgrep", "aliases": ["rg"], "extra": [] }, + "fd": { "attr": "fd", "aliases": ["fd-find"], "extra": [] }, + "tree": { "attr": "tree", "aliases": [], "extra": [] }, + "tmux": { "attr": "tmux", "aliases": [], "extra": [] }, + "vim": { "attr": "vim", "aliases": ["vi"], "extra": [] }, + "neovim": { "attr": "neovim", "aliases": ["nvim"], "extra": [] }, + "postgres": { "attr": "postgresql_16", "aliases": ["postgresql", "pg", "psql"], "extra": [] }, + "redis": { "attr": "redis", "aliases": [], "extra": [] }, + "sqlite": { "attr": "sqlite", "aliases": ["sqlite3"], "extra": [] }, + "make": { "attr": "gnumake", "aliases": ["gmake"], "extra": [] }, + "cmake": { "attr": "cmake", "aliases": [], "extra": [] }, + "gcc": { "attr": "gcc", "aliases": ["gnu-cc"], "extra": [] }, + "clang": { "attr": "clang", "aliases": ["llvm-clang"], "extra": ["llvmPackages.llvm"] }, + "ruby": { "attr": "ruby", "aliases": ["ruby3"], "extra": [] }, + "php": { "attr": "php", "aliases": ["php83"], "extra": [] }, + "java": { "attr": "jdk", "aliases": ["jdk", "openjdk"], "extra": [] }, + "maven": { "attr": "maven", "aliases": ["mvn"], "extra": [] }, + "terraform": { "attr": "terraform", "aliases": ["tf"], "extra": [] }, + "kubectl": { "attr": "kubectl", "aliases": ["kube"], "extra": [] }, + "aws": { "attr": "awscli2", "aliases": ["aws-cli", "awscli"], "extra": [] }, + "ssh": { "attr": "openssh", "aliases": ["openssh"], "extra": [] }, + "openssl": { "attr": "openssl", "aliases": ["libssl"], "extra": [] }, + "htop": { "attr": "htop", "aliases": [], "extra": [] }, + "less": { "attr": "less", "aliases": [], "extra": [] }, + "unzip": { "attr": "unzip", "aliases": [], "extra": [] }, + "zip": { "attr": "zip", "aliases": [], "extra": [] }, + "tar": { "attr": "gnutar", "aliases": ["gtar"], "extra": [] }, + "gzip": { "attr": "gzip", "aliases": ["gz"], "extra": [] }, + "bash": { "attr": "bash", "aliases": [], "extra": [] }, + "zsh": { "attr": "zsh", "aliases": [], "extra": [] }, + "fish": { "attr": "fish", "aliases": [], "extra": [] }, + "coreutils": { "attr": "coreutils", "aliases": [], "extra": [] }, + "findutils": { "attr": "findutils", "aliases": ["find"], "extra": [] }, + "grep": { "attr": "gnugrep", "aliases": ["gnugrep"], "extra": [] }, + "sed": { "attr": "gnused", "aliases": ["gnused"], "extra": [] }, + "awk": { "attr": "gawk", "aliases": ["gawk"], "extra": [] }, + "cacert": { "attr": "cacert", "aliases": ["ca-certificates", "ca-certs"], "extra": [] }, + "netcat": { "attr": "netcat-gnu", "aliases": ["nc", "ncat"], "extra": [] }, + "dig": { "attr": "dig", "aliases": ["bind-tools", "nslookup"], "extra": [] }, + "inetutils": { "attr": "inetutils", "aliases": ["hostname", "ping"], "extra": [] }, + "imagemagick": { "attr": "imagemagick", "aliases": ["convert", "magick"], "extra": [] }, + "ffmpeg": { "attr": "ffmpeg", "aliases": ["ffprobe"], "extra": [] }, + "pandoc": { "attr": "pandoc", "aliases": [], "extra": [] }, + "latex": { "attr": "texliveFull", "aliases": ["texlive", "pdflatex"], "extra": [] } +} +``` + +- [ ] **Step 2: Verify it is valid JSON** + +Run: `cat nix/packages.json | jq . > /dev/null && echo "Valid JSON"` + +Expected: `Valid JSON` + +- [ ] **Step 3: Commit** + +```bash +git add nix/packages.json +git commit -m "feat: add curated package mapping (nix/packages.json)" +``` + +--- + +### Task 3: Rename crate + add clap CLI skeleton + +**Files:** +- Rename: `crates/pi-sandbox-runtime/` to `crates/nixosandbox/` +- Modify: `crates/nixosandbox/Cargo.toml` +- Create: `crates/nixosandbox/src/cli.rs` +- Modify: `crates/nixosandbox/src/main.rs` + +This task renames the crate, adds clap, and creates a CLI skeleton that dispatches to subcommands. The existing NDJSON entry point is preserved as a hidden `legacy-ndjson` subcommand for backward compatibility. + +- [ ] **Step 1: Rename the crate directory** + +```bash +mv crates/pi-sandbox-runtime crates/nixosandbox +``` + +- [ ] **Step 2: Update `Cargo.toml`** + +Replace the contents of `crates/nixosandbox/Cargo.toml`: + +```toml +[package] +name = "nixosandbox" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "nixosandbox" +path = "src/main.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4", features = ["derive"] } +uuid = { version = "1", features = ["v4"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" +``` + +- [ ] **Step 3: Create `crates/nixosandbox/src/cli.rs`** + +```rust +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "nixosandbox", about = "Reproducible, isolated sandbox environments")] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Create a new sandbox session + Create { + /// Use a built-in profile + #[arg(long)] + profile: Option, + + /// Use a custom spec file + #[arg(long)] + spec: Option, + + /// Host directory to mount as /workspace + #[arg(long)] + workspace: Option, + + /// Human-readable session name + #[arg(long)] + name: Option, + + /// Output session info as JSON + #[arg(long)] + json: bool, + }, + + /// Execute a command inside a sandbox + Exec { + /// Session ID + session_id: String, + + /// Stream NDJSON events + #[arg(long)] + json: bool, + + /// Kill after timeout (seconds) + #[arg(long)] + timeout: Option, + + /// Additional environment variable (KEY=VALUE) + #[arg(long = "env", value_name = "KEY=VALUE")] + extra_env: Vec, + + /// Command to execute (after --) + #[arg(last = true)] + command: Vec, + }, + + /// Enter a sandbox interactively + Enter { + /// Session ID + session_id: String, + }, + + /// List active sandbox sessions + List { + /// Output as JSON array + #[arg(long)] + json: bool, + }, + + /// Destroy a sandbox session + Destroy { + /// Session ID + session_id: String, + }, + + /// Build a rootfs without creating a session + Build { + /// Use a built-in profile + #[arg(long)] + profile: Option, + + /// Use a custom spec file + #[arg(long)] + spec: Option, + + /// Output rootfs path as JSON + #[arg(long)] + json: bool, + }, + + /// Run in legacy NDJSON subprocess mode (for backward compatibility) + #[command(hide = true)] + LegacyNdjson, +} +``` + +- [ ] **Step 4: Update `main.rs` with CLI dispatch and legacy NDJSON preservation** + +Replace the contents of `crates/nixosandbox/src/main.rs`. The file should declare all modules (adding `cli`), parse CLI args, and dispatch to stubs for new commands. The existing NDJSON logic moves into `legacy_ndjson_main()`. Full file content: + +```rust +mod bubblewrap; +mod cli; +mod contract; +mod docker; +mod observer; +mod plan_builder; +mod supervisor; +mod timestamps; +mod validator; + +use clap::Parser; +use cli::{Cli, Commands}; + +fn main() { + let cli = Cli::parse(); + + match cli.command { + Commands::Create { .. } => { + eprintln!("nixosandbox: create not yet implemented"); + std::process::exit(1); + } + Commands::Exec { .. } => { + eprintln!("nixosandbox: exec not yet implemented"); + std::process::exit(1); + } + Commands::Enter { .. } => { + eprintln!("nixosandbox: enter not yet implemented"); + std::process::exit(1); + } + Commands::List { .. } => { + eprintln!("nixosandbox: list not yet implemented"); + std::process::exit(1); + } + Commands::Destroy { .. } => { + eprintln!("nixosandbox: destroy not yet implemented"); + std::process::exit(1); + } + Commands::Build { .. } => { + eprintln!("nixosandbox: build not yet implemented"); + std::process::exit(1); + } + Commands::LegacyNdjson => { + legacy_ndjson_main(); + } + } +} + +/// The original NDJSON subprocess entry point (preserved for Pi backward compat). +fn legacy_ndjson_main() { + use std::io::{self, BufRead}; + use std::sync::mpsc; + use contract::{ + emit, InboundMessage, ReconciliationHints, ResultEnvelope, ResultPayload, + ValidationEnvelope, ValidationError, ValidationPayload, + }; + + let stdin = io::stdin(); + let mut first_line = String::new(); + if stdin.lock().read_line(&mut first_line).is_err() { + eprintln!("nixosandbox: failed to read from stdin"); + std::process::exit(1); + } + let first_line = first_line.trim(); + let message: InboundMessage = match serde_json::from_str(first_line) { + Ok(m) => m, + Err(e) => { + emit(&ValidationEnvelope::new(ValidationPayload { + ok: false, + errors: vec![ValidationError { + code: "PARSE_ERROR".to_string(), + message: format!("Failed to parse inbound message: {e}"), + field: None, + }], + warnings: vec![], + effective_state: None, + })); + std::process::exit(0); + } + }; + let plan = match message { + InboundMessage::Plan { payload } => payload, + InboundMessage::Cancel { payload } => { + eprintln!("nixosandbox: received Cancel before Plan: reason={:?}", payload.reason); + std::process::exit(0); + } + }; + let bwrap = bubblewrap::detect(); + let validation = validator::validate(&plan, &bwrap); + emit(&ValidationEnvelope::new(validation.clone())); + if !validation.ok { + std::process::exit(0); + } + let effective_state = validation.effective_state.expect("effectiveState must be Some when ok=true"); + let (cancel_tx, cancel_rx) = mpsc::channel::<()>(); + std::thread::spawn(move || { + let stdin = io::stdin(); + for line in stdin.lock().lines() { + let Ok(text) = line else { break }; + let text = text.trim().to_string(); + if text.is_empty() { continue; } + match serde_json::from_str::(&text) { + Ok(InboundMessage::Cancel { .. }) => { let _ = cancel_tx.send(()); break; } + _ => {} + } + } + }); + let result = supervisor::supervise(&plan, &effective_state, cancel_rx, &bwrap); + emit(&ResultEnvelope::new(ResultPayload { + exit_code: result.exit_code, + signal: result.signal, + timed_out: result.timed_out, + duration_ms: result.duration_ms, + effective_network: result.effective_network, + observed_connections: result.observed_connections, + would_have_blocked: result.would_have_blocked, + resource_peaks: None, + reconciliation_hints: ReconciliationHints { + terminal_state: result.terminal_state, + workspace_modified: result.workspace_modified, + cleanup_succeeded: true, + }, + })); + std::process::exit(0); +} +``` + +- [ ] **Step 5: Build the crate** + +Run: `cd crates/nixosandbox && cargo build` + +Expected: Compiles successfully with new `nixosandbox` binary. + +- [ ] **Step 6: Verify CLI help works** + +Run: `./crates/nixosandbox/target/debug/nixosandbox --help` + +Expected: Shows subcommands: create, exec, enter, list, destroy, build. + +- [ ] **Step 7: Verify legacy NDJSON mode still works** + +Run: +```bash +echo '{"type":"plan","payload":{"version":1,"sessionId":"test","executionId":"test","requestedProfile":"build-install","manifest":{"mounts":[],"env":{},"cwd":"/tmp"},"policy":{"namespaces":[],"network":{"mode":"full"},"allowedWritableTargets":["/tmp"],"strictWritePolicy":false},"command":["echo","hello"]}}' | PI_SANDBOX_NO_DOCKER=1 ./crates/nixosandbox/target/debug/nixosandbox legacy-ndjson 2>/dev/null | head -1 | jq .type +``` + +Expected: `"validation"` + +- [ ] **Step 8: Run existing Rust tests** + +Run: `cd crates/nixosandbox && cargo test` + +Expected: All 29 existing tests pass. + +- [ ] **Step 9: Commit** + +```bash +git add -A crates/nixosandbox/ +git rm -r --cached crates/pi-sandbox-runtime/ 2>/dev/null || true +git commit -m "feat: rename crate to nixosandbox, add clap CLI skeleton with subcommands" +``` + +--- + +### Task 4: Sandbox spec types + validation + +**Files:** +- Create: `crates/nixosandbox/src/spec.rs` +- Modify: `crates/nixosandbox/src/main.rs` (add `mod spec;`) + +- [ ] **Step 1: Create `crates/nixosandbox/src/spec.rs`** + +```rust +use std::collections::HashMap; +use std::path::Path; +use serde::{Deserialize, Serialize}; + +/// A sandbox environment specification. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxSpec { + pub name: String, + pub packages: Vec, + #[serde(default)] + pub env: HashMap, + #[serde(default = "default_network")] + pub network: String, + #[serde(default = "default_namespaces")] + pub namespaces: Vec, + #[serde(default = "default_writable")] + pub writable: Vec, +} + +fn default_network() -> String { "full".to_string() } + +fn default_namespaces() -> Vec { + vec!["pid".to_string(), "mount".to_string(), "uts".to_string(), "ipc".to_string()] +} + +fn default_writable() -> Vec { + vec!["/workspace".to_string(), "/home/sandbox".to_string(), "/cache".to_string(), "/tmp".to_string()] +} + +/// Load a spec from a JSON file path. +pub fn load_spec(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("failed to read spec file '{}': {}", path, e))?; + serde_json::from_str(&content) + .map_err(|e| format!("failed to parse spec file '{}': {}", path, e)) +} + +/// Load a built-in profile by name. +pub fn load_profile(name: &str, flake_root: &str) -> Result { + let path = format!("{}/nix/profiles/{}.json", flake_root, name); + if !Path::new(&path).exists() { + return Err(format!( + "unknown profile '{}'. Available: build-install, offline-review, strict, debug-network", + name + )); + } + load_spec(&path) +} + +/// Validate a spec for basic correctness. +pub fn validate_spec(spec: &SandboxSpec) -> Result<(), Vec> { + let mut errors = Vec::new(); + if spec.name.is_empty() { + errors.push("spec.name must not be empty".to_string()); + } + if spec.packages.is_empty() { + errors.push("spec.packages must not be empty".to_string()); + } + match spec.network.as_str() { + "off" | "full" => {} + other => errors.push(format!("spec.network must be 'off' or 'full', got '{}'", other)), + } + for ns in &spec.namespaces { + match ns.as_str() { + "pid" | "mount" | "uts" | "ipc" | "net" | "user" | "cgroup" => {} + other => errors.push(format!("unknown namespace '{}' in spec.namespaces", other)), + } + } + if errors.is_empty() { Ok(()) } else { Err(errors) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_minimal_spec() { + let json = r#"{"name":"test","packages":["bash"]}"#; + let spec: SandboxSpec = serde_json::from_str(json).unwrap(); + assert_eq!(spec.name, "test"); + assert_eq!(spec.packages, vec!["bash"]); + assert_eq!(spec.network, "full"); + assert_eq!(spec.namespaces, vec!["pid", "mount", "uts", "ipc"]); + } + + #[test] + fn deserialize_full_spec() { + let json = r#"{"name":"web","packages":["nodejs_22","git"],"env":{"NODE_ENV":"dev"},"network":"off","namespaces":["pid","net"],"writable":["/tmp"]}"#; + let spec: SandboxSpec = serde_json::from_str(json).unwrap(); + assert_eq!(spec.network, "off"); + assert_eq!(spec.env.get("NODE_ENV").unwrap(), "dev"); + } + + #[test] + fn validate_valid_spec() { + let spec = SandboxSpec { + name: "test".to_string(), packages: vec!["bash".to_string()], + env: HashMap::new(), network: "full".to_string(), + namespaces: vec!["pid".to_string()], writable: vec!["/tmp".to_string()], + }; + assert!(validate_spec(&spec).is_ok()); + } + + #[test] + fn validate_empty_name_fails() { + let spec = SandboxSpec { + name: "".to_string(), packages: vec!["bash".to_string()], + env: HashMap::new(), network: "full".to_string(), + namespaces: vec![], writable: vec![], + }; + assert!(validate_spec(&spec).unwrap_err().iter().any(|e| e.contains("name"))); + } + + #[test] + fn validate_bad_network_fails() { + let spec = SandboxSpec { + name: "test".to_string(), packages: vec!["bash".to_string()], + env: HashMap::new(), network: "allowlist".to_string(), + namespaces: vec![], writable: vec![], + }; + assert!(validate_spec(&spec).unwrap_err().iter().any(|e| e.contains("network"))); + } +} +``` + +- [ ] **Step 2: Add `mod spec;` to main.rs** + +Add `mod spec;` in the module declarations (alphabetical order, after `plan_builder`). + +- [ ] **Step 3: Run tests** + +Run: `cd crates/nixosandbox && cargo test spec` + +Expected: All 5 spec tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/nixosandbox/src/spec.rs crates/nixosandbox/src/main.rs +git commit -m "feat: add sandbox spec types with validation and tests" +``` + +--- + +### Task 5: Session management + +**Files:** +- Create: `crates/nixosandbox/src/session.rs` +- Modify: `crates/nixosandbox/src/main.rs` (add `mod session;`) + +- [ ] **Step 1: Create `crates/nixosandbox/src/session.rs`** + +```rust +use std::fs; +use std::path::{Path, PathBuf}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionMetadata { + pub session_id: String, + pub name: String, + pub profile: String, + pub rootfs_path: String, + pub workspace: String, + pub created_at: String, + pub last_exec_at: Option, + pub pid: Option, +} + +pub struct SessionDirs { + pub root: PathBuf, + pub workspace: PathBuf, + pub home: PathBuf, + pub cache: PathBuf, + pub logs: PathBuf, + pub metadata_path: PathBuf, +} + +pub fn sessions_base_dir() -> PathBuf { + let data_dir = std::env::var("NIXOSANDBOX_DATA_DIR") + .unwrap_or_else(|_| { + let home = std::env::var("HOME").expect("HOME not set"); + format!("{}/.local/share/nixosandbox", home) + }); + PathBuf::from(data_dir).join("sessions") +} + +fn generate_session_id() -> String { + uuid::Uuid::new_v4().to_string()[..8].to_string() +} + +pub fn create_session( + name: &str, profile: &str, rootfs_path: &str, workspace: Option<&str>, +) -> Result { + let session_id = generate_session_id(); + let base = sessions_base_dir(); + let session_dir = base.join(&session_id); + fs::create_dir_all(&session_dir).map_err(|e| format!("failed to create session dir: {e}"))?; + let home_dir = session_dir.join("home"); + let cache_dir = session_dir.join("cache"); + let logs_dir = session_dir.join("logs"); + fs::create_dir_all(&home_dir).map_err(|e| format!("failed to create home dir: {e}"))?; + fs::create_dir_all(&cache_dir).map_err(|e| format!("failed to create cache dir: {e}"))?; + fs::create_dir_all(&logs_dir).map_err(|e| format!("failed to create logs dir: {e}"))?; + + let workspace_dir = session_dir.join("workspace"); + let workspace_path = if let Some(ws) = workspace { + let ws_path = Path::new(ws); + if !ws_path.exists() { + return Err(format!("workspace path does not exist: {ws}")); + } + #[cfg(unix)] + std::os::unix::fs::symlink(ws_path, &workspace_dir) + .map_err(|e| format!("failed to symlink workspace: {e}"))?; + ws.to_string() + } else { + fs::create_dir_all(&workspace_dir).map_err(|e| format!("failed to create workspace: {e}"))?; + workspace_dir.to_string_lossy().to_string() + }; + + let metadata = SessionMetadata { + session_id: session_id.clone(), + name: name.to_string(), + profile: profile.to_string(), + rootfs_path: rootfs_path.to_string(), + workspace: workspace_path, + created_at: crate::timestamps::now_iso8601(), + last_exec_at: None, + pid: None, + }; + let metadata_path = session_dir.join("metadata.json"); + let json = serde_json::to_string_pretty(&metadata).map_err(|e| format!("serialize: {e}"))?; + fs::write(&metadata_path, json).map_err(|e| format!("write metadata: {e}"))?; + Ok(metadata) +} + +pub fn list_sessions() -> Result, String> { + let base = sessions_base_dir(); + if !base.exists() { return Ok(vec![]); } + let mut sessions = Vec::new(); + let entries = fs::read_dir(&base).map_err(|e| format!("read sessions dir: {e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("read dir entry: {e}"))?; + let metadata_path = entry.path().join("metadata.json"); + if metadata_path.exists() { + let content = fs::read_to_string(&metadata_path).map_err(|e| format!("read metadata: {e}"))?; + if let Ok(meta) = serde_json::from_str::(&content) { + sessions.push(meta); + } + } + } + sessions.sort_by(|a, b| a.created_at.cmp(&b.created_at)); + Ok(sessions) +} + +pub fn load_session(session_id: &str) -> Result { + let path = sessions_base_dir().join(session_id).join("metadata.json"); + if !path.exists() { return Err(format!("session '{}' not found", session_id)); } + let content = fs::read_to_string(&path).map_err(|e| format!("read metadata: {e}"))?; + serde_json::from_str(&content).map_err(|e| format!("parse metadata: {e}")) +} + +pub fn session_dirs(session_id: &str) -> SessionDirs { + let root = sessions_base_dir().join(session_id); + SessionDirs { + workspace: root.join("workspace"), home: root.join("home"), + cache: root.join("cache"), logs: root.join("logs"), + metadata_path: root.join("metadata.json"), root, + } +} + +pub fn touch_last_exec(session_id: &str) -> Result<(), String> { + let mut meta = load_session(session_id)?; + meta.last_exec_at = Some(crate::timestamps::now_iso8601()); + let dirs = session_dirs(session_id); + let json = serde_json::to_string_pretty(&meta).map_err(|e| format!("serialize: {e}"))?; + fs::write(&dirs.metadata_path, json).map_err(|e| format!("write metadata: {e}")) +} + +pub fn destroy_session(session_id: &str) -> Result<(), String> { + let dirs = session_dirs(session_id); + if !dirs.root.exists() { return Err(format!("session '{}' not found", session_id)); } + fs::remove_dir_all(&dirs.root).map_err(|e| format!("remove session dir: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn with_temp_data_dir(f: F) { + let dir = std::env::temp_dir().join(format!("nixosandbox-test-{}", uuid::Uuid::new_v4())); + std::env::set_var("NIXOSANDBOX_DATA_DIR", &dir); + f(); + let _ = fs::remove_dir_all(&dir); + std::env::remove_var("NIXOSANDBOX_DATA_DIR"); + } + + #[test] + fn create_and_list_sessions() { + with_temp_data_dir(|| { + let meta = create_session("test-session", "strict", "/nix/store/fake", None).unwrap(); + assert_eq!(meta.name, "test-session"); + let sessions = list_sessions().unwrap(); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].session_id, meta.session_id); + }); + } + + #[test] + fn load_session_by_id() { + with_temp_data_dir(|| { + let meta = create_session("load-test", "strict", "/nix/store/fake", None).unwrap(); + let loaded = load_session(&meta.session_id).unwrap(); + assert_eq!(loaded.name, "load-test"); + }); + } + + #[test] + fn destroy_session_removes_dir() { + with_temp_data_dir(|| { + let meta = create_session("rm-test", "strict", "/nix/store/fake", None).unwrap(); + let dirs = session_dirs(&meta.session_id); + assert!(dirs.root.exists()); + destroy_session(&meta.session_id).unwrap(); + assert!(!dirs.root.exists()); + }); + } + + #[test] + fn destroy_nonexistent_errors() { + with_temp_data_dir(|| { + assert!(destroy_session("nonexistent").is_err()); + }); + } + + #[test] + fn create_with_external_workspace() { + with_temp_data_dir(|| { + let ws = std::env::temp_dir().join(format!("ws-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&ws).unwrap(); + let meta = create_session("ws-test", "strict", "/nix/store/fake", Some(ws.to_str().unwrap())).unwrap(); + let dirs = session_dirs(&meta.session_id); + assert!(dirs.workspace.is_symlink()); + destroy_session(&meta.session_id).unwrap(); + assert!(ws.exists()); // external workspace preserved + let _ = fs::remove_dir_all(&ws); + }); + } + + #[test] + fn metadata_roundtrip() { + let meta = SessionMetadata { + session_id: "abc".to_string(), name: "test".to_string(), + profile: "strict".to_string(), rootfs_path: "/nix/store/fake".to_string(), + workspace: "/tmp/ws".to_string(), created_at: "2026-04-08T12:00:00Z".to_string(), + last_exec_at: None, pid: None, + }; + let json = serde_json::to_string(&meta).unwrap(); + let de: SessionMetadata = serde_json::from_str(&json).unwrap(); + assert_eq!(de.session_id, "abc"); + } +} +``` + +- [ ] **Step 2: Add `mod session;` to main.rs** (alphabetical, after `plan_builder`) + +- [ ] **Step 3: Run tests** + +Run: `cd crates/nixosandbox && cargo test session` + +Expected: All 6 session tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/nixosandbox/src/session.rs crates/nixosandbox/src/main.rs +git commit -m "feat: add session management (create/list/load/destroy) with tests" +``` + +--- + +### Task 6: Nix build invocation from Rust + +**Files:** +- Create: `crates/nixosandbox/src/nix.rs` +- Modify: `crates/nixosandbox/src/main.rs` (add `mod nix;`) + +- [ ] **Step 1: Create `crates/nixosandbox/src/nix.rs`** + +```rust +use std::process::{Command, Stdio}; +use std::path::Path; + +use crate::spec::SandboxSpec; + +/// Find the flake root by looking for flake.nix. +pub fn find_flake_root() -> Result { + if let Ok(root) = std::env::var("NIXOSANDBOX_FLAKE_ROOT") { + if Path::new(&root).join("flake.nix").exists() { + return Ok(root); + } + } + if let Ok(exe) = std::env::current_exe() { + let mut dir = exe.parent().map(|p| p.to_path_buf()); + while let Some(d) = dir { + if d.join("flake.nix").exists() { + return Ok(d.to_string_lossy().to_string()); + } + dir = d.parent().map(|p| p.to_path_buf()); + } + } + if Path::new("flake.nix").exists() { + return Ok(std::env::current_dir().map_err(|e| format!("cwd: {e}"))?.to_string_lossy().to_string()); + } + Err("Could not find flake.nix. Set NIXOSANDBOX_FLAKE_ROOT or run from repo root.".to_string()) +} + +/// Build a rootfs for a built-in profile. Returns the Nix store path. +pub fn build_profile(profile_name: &str) -> Result { + let flake_root = find_flake_root()?; + nix_build(&format!("{}#sandbox-{}", flake_root, profile_name)) +} + +/// Build a rootfs from a custom spec. Returns the Nix store path. +pub fn build_spec(spec: &SandboxSpec) -> Result { + let flake_root = find_flake_root()?; + let packages_nix = spec.packages.iter().map(|p| format!("pkgs.{}", p)).collect::>().join(" "); + let env_nix = spec.env.iter().map(|(k, v)| format!("\"{}\" = \"{}\";", k, v)).collect::>().join(" "); + let expr = format!( + r#"let pkgs = import (builtins.getFlake "{}").inputs.nixpkgs {{}}; mkSandboxRootfs = import {}/nix/mkSandboxRootfs.nix {{ inherit pkgs; }}; in mkSandboxRootfs {{ name = "{}"; packages = [ {} ]; env = {{ {} }}; }}"#, + flake_root, flake_root, spec.name, packages_nix, env_nix + ); + nix_build_expr(&expr) +} + +fn nix_build(flake_attr: &str) -> Result { + let output = Command::new("nix") + .args(["build", flake_attr, "--no-link", "--print-out-paths"]) + .stdout(Stdio::piped()).stderr(Stdio::piped()) + .output().map_err(|e| format!("nix build: {e}"))?; + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { Err("nix build produced no output".into()) } else { Ok(path) } + } else { + Err(format!("nix build failed: {}", String::from_utf8_lossy(&output.stderr))) + } +} + +fn nix_build_expr(expr: &str) -> Result { + let output = Command::new("nix") + .args(["build", "--impure", "--expr", expr, "--no-link", "--print-out-paths"]) + .stdout(Stdio::piped()).stderr(Stdio::piped()) + .output().map_err(|e| format!("nix build --expr: {e}"))?; + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { Err("nix build --expr produced no output".into()) } else { Ok(path) } + } else { + Err(format!("nix build --expr failed: {}", String::from_utf8_lossy(&output.stderr))) + } +} + +/// Check if a rootfs path looks valid. +pub fn validate_rootfs(rootfs_path: &str) -> Result<(), String> { + let root = Path::new(rootfs_path); + if !root.exists() { return Err(format!("rootfs not found: {rootfs_path}")); } + if !root.join("bin").exists() { return Err(format!("rootfs missing /bin: {rootfs_path}")); } + if !root.join("etc").exists() { return Err(format!("rootfs missing /etc: {rootfs_path}")); } + Ok(()) +} +``` + +- [ ] **Step 2: Add `mod nix;` to main.rs** (alphabetical, after `docker`) + +- [ ] **Step 3: Build to verify compilation** + +Run: `cd crates/nixosandbox && cargo build` + +Expected: Compiles successfully. + +- [ ] **Step 4: Commit** + +```bash +git add crates/nixosandbox/src/nix.rs crates/nixosandbox/src/main.rs +git commit -m "feat: add Nix build invocation (build_profile, build_spec, validate_rootfs)" +``` + +--- + +### Task 7: build_rootfs() in plan_builder -- pivot-root bwrap argv + +**Files:** +- Modify: `crates/nixosandbox/src/plan_builder.rs` + +- [ ] **Step 1: Add `SessionDirs` struct and `build_rootfs` function with tests** + +Add before the `#[cfg(test)]` module in `crates/nixosandbox/src/plan_builder.rs`: + +```rust +/// Session directory paths for rootfs-mode execution. +pub struct RootfsSessionDirs { + pub workspace: String, + pub home: String, + pub cache: String, +} + +/// Build bwrap argument vector for pivot-root execution into a Nix rootfs. +pub fn build_rootfs( + rootfs_path: &str, + session_dirs: &RootfsSessionDirs, + command: &[String], + env: &std::collections::HashMap, + network: &str, + namespaces: &[String], +) -> Vec { + let mut argv: Vec = Vec::new(); + argv.extend(["--pivot-root".to_string(), rootfs_path.to_string(), "/oldroot".to_string()]); + argv.extend(["--tmpfs".to_string(), "/oldroot".to_string()]); + argv.extend(["--bind".to_string(), session_dirs.workspace.clone(), "/workspace".to_string()]); + argv.extend(["--bind".to_string(), session_dirs.home.clone(), "/home/sandbox".to_string()]); + argv.extend(["--bind".to_string(), session_dirs.cache.clone(), "/cache".to_string()]); + argv.extend(["--tmpfs".to_string(), "/tmp".to_string()]); + argv.extend(["--dev".to_string(), "/dev".to_string()]); + argv.extend(["--proc".to_string(), "/proc".to_string()]); + for ns in namespaces { + match ns.as_str() { + "pid" => argv.push("--unshare-pid".to_string()), + "mount" => {} // implicit with pivot-root + "uts" => argv.push("--unshare-uts".to_string()), + "ipc" => argv.push("--unshare-ipc".to_string()), + "net" => argv.push("--unshare-net".to_string()), + "user" => argv.push("--unshare-user".to_string()), + "cgroup" => argv.push("--unshare-cgroup-try".to_string()), + _ => {} + } + } + argv.push("--clearenv".to_string()); + argv.extend(["--setenv".to_string(), "HOME".to_string(), "/home/sandbox".to_string()]); + argv.extend(["--setenv".to_string(), "PATH".to_string(), "/bin:/usr/bin".to_string()]); + argv.extend(["--setenv".to_string(), "TERM".to_string(), "xterm-256color".to_string()]); + for (key, value) in env { + argv.extend(["--setenv".to_string(), key.clone(), value.clone()]); + } + argv.extend(["--chdir".to_string(), "/workspace".to_string()]); + argv.push("--".to_string()); + argv.extend(command.iter().cloned()); + argv +} +``` + +Add to the `#[cfg(test)]` module: + +```rust + #[test] + fn build_rootfs_produces_pivot_root_argv() { + let dirs = RootfsSessionDirs { + workspace: "/tmp/ws".to_string(), + home: "/tmp/home".to_string(), + cache: "/tmp/cache".to_string(), + }; + let cmd = vec!["echo".to_string(), "hello".to_string()]; + let env = std::collections::HashMap::new(); + let argv = build_rootfs("/nix/store/fake", &dirs, &cmd, &env, "full", &["pid".to_string()]); + assert!(argv.contains(&"--pivot-root".to_string())); + assert!(argv.contains(&"/nix/store/fake".to_string())); + assert!(argv.contains(&"--bind".to_string())); + assert!(argv.contains(&"--tmpfs".to_string())); + assert!(argv.contains(&"--dev".to_string())); + assert!(argv.contains(&"--proc".to_string())); + assert!(argv.contains(&"--clearenv".to_string())); + let sep = argv.iter().position(|a| a == "--").unwrap(); + assert_eq!(argv[sep + 1], "echo"); + assert_eq!(argv[sep + 2], "hello"); + } + + #[test] + fn build_rootfs_network_off_adds_unshare_net() { + let dirs = RootfsSessionDirs { + workspace: "/tmp/ws".to_string(), home: "/tmp/home".to_string(), cache: "/tmp/cache".to_string(), + }; + let cmd = vec!["echo".to_string()]; + let env = std::collections::HashMap::new(); + let argv = build_rootfs("/nix/store/fake", &dirs, &cmd, &env, "off", &["pid".to_string(), "net".to_string()]); + assert!(argv.contains(&"--unshare-net".to_string())); + } +``` + +- [ ] **Step 2: Run tests** + +Run: `cd crates/nixosandbox && cargo test build_rootfs` + +Expected: Both tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add crates/nixosandbox/src/plan_builder.rs +git commit -m "feat: add build_rootfs() for pivot-root bwrap execution" +``` + +--- + +### Task 8: Wire CLI subcommands to implementations + +**Files:** +- Modify: `crates/nixosandbox/src/main.rs` + +This task replaces stub implementations with real logic for all subcommands. This is the largest single task. After this, the CLI is fully functional. + +- [ ] **Step 1: Replace main.rs CLI dispatch with full implementations** + +Replace the `main()` function and add helper functions. Keep the module declarations and `legacy_ndjson_main()` unchanged. The full new `main()` and helpers are in the plan's Task 8 code block above (from the first Write attempt). Due to the length, refer to the spec for exact behavior of each command: + +- `cmd_create`: loads spec/profile, validates, calls `nix::build_profile` or `nix::build_spec`, calls `session::create_session`, prints session ID +- `cmd_exec`: loads session, loads profile for network/namespace config, builds rootfs argv, detects bwrap, spawns with stdio inherit (default) or NDJSON (--json) +- `cmd_list`: calls `session::list_sessions`, prints table or JSON +- `cmd_destroy`: calls `session::destroy_session` +- `cmd_build`: calls `nix::build_profile` or `nix::build_spec`, prints rootfs path +- `Enter` dispatches to `cmd_exec` with `/bin/bash` + +Key: The `cmd_exec` function must handle two modes: +1. **Default mode**: `cmd.stdin/stdout/stderr(Stdio::inherit())` for interactive use +2. **JSON mode**: pipe stdout/stderr, stream NDJSON events using `contract::emit` + +- [ ] **Step 2: Build and verify compilation** + +Run: `cd crates/nixosandbox && cargo build` + +Expected: Compiles successfully. + +- [ ] **Step 3: Run all Rust tests** + +Run: `cd crates/nixosandbox && cargo test` + +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/nixosandbox/src/main.rs +git commit -m "feat: wire all CLI subcommands (create, exec, enter, list, destroy, build)" +``` + +--- + +### Task 9: Update protocol tests for renamed crate + +**Files:** +- Delete: `docker-compose.yml` +- Modify: `tests/protocol/globalSetup.ts` +- Modify: `tests/protocol/helpers.ts` + +- [ ] **Step 1: Delete `docker-compose.yml`** + +```bash +rm docker-compose.yml +``` + +- [ ] **Step 2: Update `tests/protocol/globalSetup.ts`** + +Change the `CRATE_DIR` path: + +```typescript +const CRATE_DIR = resolve(import.meta.dirname, "../../crates/nixosandbox"); +``` + +- [ ] **Step 3: Update `tests/protocol/helpers.ts`** + +Update `spawnRuntime` to pass `legacy-ndjson` subcommand: + +```typescript + const child = spawn(binaryPath, ["legacy-ndjson"], { + stdio: ["pipe", "pipe", "pipe"], + env: options?.env ?? process.env, + }); +``` + +- [ ] **Step 4: Run protocol tests** + +Run: `cd tests/protocol && npx vitest run` + +Expected: All existing protocol tests pass via `legacy-ndjson` subcommand. + +- [ ] **Step 5: Commit** + +```bash +git rm docker-compose.yml +git add tests/protocol/globalSetup.ts tests/protocol/helpers.ts +git commit -m "chore: delete legacy docker-compose, update tests for renamed crate" +``` + +--- + +### Task 10: Wire nixosandbox binary into flake.nix + +**Files:** +- Modify: `flake.nix` + +- [ ] **Step 1: Update `flake.nix` to build the Rust binary** + +Replace the `# nixosandbox = ...;` placeholder with: + +```nix + nixosandbox = pkgs.rustPlatform.buildRustPackage { + pname = "nixosandbox"; + version = "0.1.0"; + src = ./crates/nixosandbox; + cargoLock.lockFile = ./crates/nixosandbox/Cargo.lock; + }; + + default = self.packages.${system}.nixosandbox; +``` + +- [ ] **Step 2: Build the binary via flake** + +Run: `nix build .#nixosandbox --no-link --print-out-paths` + +Expected: A Nix store path with the binary at `/bin/nixosandbox`. + +- [ ] **Step 3: Verify via nix run** + +Run: `nix run . -- --help` + +Expected: Shows nixosandbox CLI help. + +- [ ] **Step 4: Commit** + +```bash +git add flake.nix +git commit -m "feat: wire nixosandbox binary into flake.nix as default package" +``` + +--- + +## Phase Gate Checklist + +After all tasks are complete, verify: + +- [ ] `nix build .#sandbox-strict` produces a minimal rootfs with bash, coreutils +- [ ] `nix build .#sandbox-build-install` produces a rootfs with node, python, git, rust +- [ ] `nixosandbox create --profile strict` creates a session with ID +- [ ] `nixosandbox exec -- echo hello` prints "hello", exit 0 +- [ ] `nixosandbox exec -- ls /` shows sandbox rootfs, not host +- [ ] `nixosandbox exec --json -- echo test` produces NDJSON event stream +- [ ] `nixosandbox list` shows the session +- [ ] `nixosandbox destroy ` cleans up +- [ ] `nixosandbox build --profile strict` outputs a Nix store path +- [ ] `nix run . -- --help` shows CLI help +- [ ] All Rust unit tests pass +- [ ] All protocol tests pass via `legacy-ndjson` subcommand + +--- + +## What Is Next (Part B -- separate plan) + +- Docker sidecar updated with `/nix/store` mount for macOS +- Pi extension simplified to thin CLI adapter +- macOS integration tests +- End-to-end integration tests on Linux with Nix + bwrap diff --git a/docs/superpowers/plans/2026-04-09-ci-catalog-fixes.md b/docs/superpowers/plans/2026-04-09-ci-catalog-fixes.md new file mode 100644 index 0000000..ab12da3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-ci-catalog-fixes.md @@ -0,0 +1,221 @@ +# CI Additions & Catalog Defensive Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add openclaw and hermes-agent smoke tests plus a catalog count assertion to CI, and fix two latent fragility points in the catalog evaluation pipeline. + +**Architecture:** Task 1 extends `.github/workflows/ci.yml` only (matrix rows + a new step in nix-build). Task 2 hardens the Nix expression in `nix.rs` with a `filterAttrs` derivation guard and adds a `builtins.trace` warning to `catalog.nix` for the empty-attrset case. + +**Tech Stack:** GitHub Actions YAML, Rust (string formatting), Nix (builtins) + +--- + +## File Map + +| File | Task | Change | +|------|------|--------| +| `.github/workflows/ci.yml` | 1 | Add 2 matrix rows; add catalog-count step to nix-build job | +| `crates/nixosandbox/src/nix.rs` | 2 | Add `filterDrvs` to `query_catalog` Nix expression | +| `nix/catalog.nix` | 2 | Add `builtins.trace` guard for empty `llm-agents-pkgs` | + +--- + +### Task 1: CI additions — new agent tests + catalog count assertion + +**Files:** +- Modify: `.github/workflows/ci.yml` + +- [ ] **Step 1: Add openclaw and hermes-agent to the agent smoke test matrix** + + In `.github/workflows/ci.yml`, locate the `matrix.include` block inside `agent-smoke-tests` + (currently ends at the `pi` entry around line 150). Add two new rows immediately after `pi`: + + ```yaml + - agent: openclaw + binary: openclaw + check: "--help" + - agent: hermes-agent + binary: hermes + check: "--version" + ``` + + The block after adding should look like: + + ```yaml + strategy: + fail-fast: false + matrix: + include: + - agent: claude-code + binary: claude + check: "--version" + - agent: codex + binary: codex + check: "--help" + - agent: opencode + binary: opencode + check: "--version" + - agent: amp + binary: amp + check: "--help" + - agent: droid + binary: droid + check: "--version" + - agent: pi + binary: pi + check: "--help" + - agent: openclaw + binary: openclaw + check: "--help" + - agent: hermes-agent + binary: hermes + check: "--version" + ``` + +- [ ] **Step 2: Add catalog count + presence assertion in the nix-build job** + + In `.github/workflows/ci.yml`, locate the `nix-build` job's "Test catalog subcommand" step + (around line 96). Add a new step **immediately after** it: + + ```yaml + - name: Verify catalog agent count and new packages + run: | + agent_count=$(NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox catalog --json | \ + python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d['agents']))") + echo "Catalog agent count: $agent_count" + [ "$agent_count" -gt 25 ] || { echo "ERROR: expected >25 agents, got $agent_count"; exit 1; } + NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox catalog --json | \ + python3 -c " + import sys, json + d = json.load(sys.stdin) + for name in ['openclaw', 'hermes-agent', 'jules']: + assert name in d['agents'], f'{name} missing from catalog' + print(f'All expected agents present in {len(d[\"agents\"])} total') + " + ``` + +- [ ] **Step 3: Verify YAML is valid locally** + + ```bash + python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))" && echo "YAML valid" + ``` + + Expected: `YAML valid` with no errors. If not valid, fix the indentation error. + +- [ ] **Step 4: Commit** + + ```bash + git add .github/workflows/ci.yml + git commit -m "ci: add openclaw and hermes-agent smoke tests, verify catalog agent count" + ``` + +--- + +### Task 2: Defensive catalog fixes + +**Files:** +- Modify: `crates/nixosandbox/src/nix.rs` (lines ~135–137, the `query_catalog` Nix expression) +- Modify: `nix/catalog.nix` (the `agents =` line) + +#### Part A — Filter non-derivation attrs in `query_catalog` + +- [ ] **Step 1: Read the current `query_catalog` expression** + + ```bash + sed -n '130,145p' crates/nixosandbox/src/nix.rs + ``` + + Expected: shows the `let expr = format!(r#"..."#, flake_root)` block with `extractMeta`. + +- [ ] **Step 2: Update the Nix expression in `query_catalog`** + + In `crates/nixosandbox/src/nix.rs`, find the `query_catalog` function (line ~130). + Replace the `let expr = format!(...)` block with: + + ```rust + let expr = format!( + r#"let flake = builtins.getFlake "{}"; catalog = flake.catalog; filterDrvs = attrs: builtins.filterAttrs (_: pkg: (pkg.type or "") == "derivation") attrs; extractMeta = attrs: builtins.mapAttrs (name: pkg: {{ description = pkg.meta.description or ""; }}) (filterDrvs attrs); in {{ agents = extractMeta catalog.agents; tools = extractMeta catalog.tools; }}"#, + flake_root + ); + ``` + + The only change from the original is the addition of: + `filterDrvs = attrs: builtins.filterAttrs (_: pkg: (pkg.type or "") == "derivation") attrs;` + and wrapping `extractMeta`'s `attrs` argument with `(filterDrvs attrs)`. + +- [ ] **Step 3: Verify the Rust still compiles** + + ```bash + cd crates/nixosandbox && cargo build 2>&1 | tail -5 + ``` + + Expected: `Finished` line with no errors. + +- [ ] **Step 4: Run the Rust test suite to confirm no regression** + + ```bash + cd crates/nixosandbox && cargo test 2>&1 | tail -10 + ``` + + Expected: all tests pass (`test result: ok. N passed`). + +- [ ] **Step 5: Verify `query_catalog` still works end-to-end** + + ```bash + cd /path/to/repo # run from repo root, not crates/nixosandbox + NIXOSANDBOX_FLAKE_ROOT=$PWD cargo run --manifest-path crates/nixosandbox/Cargo.toml -- catalog --json | \ + python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d['agents']), 'agents,', len(d['tools']), 'tools')" + ``` + + Expected: prints something like `88 agents, 24 tools` (no error, no reduction in count — since current llm-agents.nix exposes only derivations anyway, the filter is a no-op in practice). + +#### Part B — Trace warning for empty `llm-agents-pkgs` + +- [ ] **Step 6: Read the current `catalog.nix`** + + ```bash + cat nix/catalog.nix + ``` + + Expected: shows `agents = builtins.removeAttrs llm-agents-pkgs [ "default" ];` (set in prior task). + +- [ ] **Step 7: Add empty-attrset guard with `builtins.trace`** + + In `nix/catalog.nix`, replace the `agents = ...` line: + + **Before:** + ```nix + # All packages from numtide/llm-agents.nix. + # 'default' is a meta-alias present in every flake packages output; strip it. + agents = builtins.removeAttrs llm-agents-pkgs [ "default" ]; + ``` + + **After:** + ```nix + # All packages from numtide/llm-agents.nix. + # 'default' is a meta-alias present in every flake packages output; strip it. + # If llm-agents-pkgs is empty (e.g. flake input missing x86_64-linux support), + # emit a trace warning rather than silently returning an empty catalog. + agents = + let filtered = builtins.removeAttrs llm-agents-pkgs [ "default" ]; + in if filtered == {} + then builtins.trace + "nixosandbox WARNING: llm-agents-pkgs is empty — catalog will have no agents. Check that the llm-agents.nix flake input exposes x86_64-linux packages." + {} + else filtered; + ``` + +- [ ] **Step 8: Verify catalog still evaluates correctly (non-empty path)** + + ```bash + nix eval --accept-flake-config .#catalog.agents --apply 'x: builtins.length (builtins.attrNames x)' + ``` + + Expected: prints the agent count (88 or similar). No warnings should appear because `llm-agents-pkgs` is non-empty. + +- [ ] **Step 9: Commit both fixes together** + + ```bash + git add crates/nixosandbox/src/nix.rs nix/catalog.nix + git commit -m "fix: guard query_catalog against non-derivation attrs, warn on empty llm-agents-pkgs" + ``` diff --git a/docs/superpowers/plans/2026-04-09-llm-agents-nix-integration.md b/docs/superpowers/plans/2026-04-09-llm-agents-nix-integration.md new file mode 100644 index 0000000..7e78417 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-llm-agents-nix-integration.md @@ -0,0 +1,1374 @@ +# llm-agents.nix Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Integrate `numtide/llm-agents.nix` as a flake input to provide a unified package catalog (80+ AI agents + nixpkgs tools) and enable agent-driven sandbox composition via `--with` CLI flag. + +**Architecture:** Add `llm-agents.nix` as a flake input with its own binary cache. A new `nix/catalog.nix` merges agent packages and nixpkgs tools into a queryable catalog. A new `nix/mkAgentSandbox.nix` resolves package names from the catalog and delegates to the existing `mkSandboxRootfs`. The Rust CLI gains a `--with` flag on `create` and a new `catalog` subcommand. The Pi extension adds a `sandbox_catalog` tool and updates `sandbox_run` with a `with` parameter. + +**Tech Stack:** Nix flakes, Rust (clap, serde_json), TypeScript (Node.js child_process) + +--- + +## File Structure + +### New files + +| File | Responsibility | +|------|---------------| +| `nix/catalog.nix` | Build unified `{ agents, tools }` attrset from llm-agents.nix + nixpkgs | +| `nix/mkAgentSandbox.nix` | Resolve package names from catalog, delegate to mkSandboxRootfs | + +### Modified files + +| File | Change | +|------|--------| +| `flake.nix` | Add `llm-agents` input, `nixConfig`, expose `catalog` and `lib.mkAgentSandbox` | +| `crates/nixosandbox/src/cli.rs` | Add `--with` on Create, add `Catalog` variant to Commands | +| `crates/nixosandbox/src/main.rs` | Wire `--with` into `cmd_create`, add `cmd_catalog`, update `resolve_spec` | +| `crates/nixosandbox/src/nix.rs` | Add `build_with_catalog()` and `query_catalog()` | +| `packages/pi-sandbox-extension/src/cli-client.ts` | Add `catalogPackages()`, update `CreateOptions` with `withPackages` | +| `packages/pi-sandbox-extension/src/extension.ts` | Add `sandbox_catalog` tool, update `sandbox_run` with `with` param | +| `packages/pi-sandbox-extension/src/index.ts` | Re-export `catalogPackages` and `CatalogResponse` | + +### Unchanged files + +- `nix/mkSandboxRootfs.nix` -- untouched foundation +- `nix/profiles/*.json` -- backward compatible +- `crates/nixosandbox/src/session.rs`, `plan_builder.rs`, `bubblewrap.rs`, `docker.rs` +- `packages/pi-sandbox-extension/src/contract.ts`, `crash-synthesis.ts`, `browser.ts` + +--- + +### Task 1: Add llm-agents.nix flake input and nixConfig + +**Files:** +- Modify: `flake.nix` + +- [ ] **Step 1: Add llm-agents input and nixConfig to flake.nix** + +Open `flake.nix` and make these changes: + +1. Add `nixConfig` block before `inputs`: + +```nix +{ + description = "nixosandbox -- reproducible, isolated sandbox environments"; + + nixConfig = { + extra-substituters = [ "https://cache.numtide.com" ]; + extra-trusted-public-keys = [ "niks3.numtide.com-1:DTx8wZduET09hRmMtKdQDxNNthLQETkc/yaX7M4qK0g=" ]; + }; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + llm-agents.url = "github:numtide/llm-agents.nix"; + }; +``` + +2. Update the `outputs` function signature to include `llm-agents`: + +```nix + outputs = { self, nixpkgs, llm-agents }: +``` + +No other changes to flake.nix yet -- the catalog and mkAgentSandbox outputs come in later tasks. + +- [ ] **Step 2: Lock the new input** + +Run: +```bash +NIX_SSL_CERT_FILE=/etc/ssl/cert.pem nix flake lock --accept-flake-config +``` + +Expected: `flake.lock` updates with a new entry for `llm-agents` and its transitive inputs (`blueprint`, `bun2nix`, `treefmt-nix`, `flake-parts`, `systems`). No errors. + +- [ ] **Step 3: Verify the flake evaluates** + +Run: +```bash +NIX_SSL_CERT_FILE=/etc/ssl/cert.pem nix flake show --accept-flake-config 2>&1 | head -30 +``` + +Expected: Shows `packages`, `devShells`, `lib` outputs without errors. The llm-agents input should be locked but not yet used in outputs. + +- [ ] **Step 4: Commit** + +```bash +git add flake.nix flake.lock +git commit -m "feat: add llm-agents.nix flake input with numtide binary cache" +``` + +--- + +### Task 2: Create nix/catalog.nix + +**Files:** +- Create: `nix/catalog.nix` + +- [ ] **Step 1: Create the catalog module** + +Create `nix/catalog.nix` with the full agent and tool listings: + +```nix +# nix/catalog.nix +# +# Unified package catalog merging AI agents from llm-agents.nix +# and standard development tools from nixpkgs. +# +# Usage: import ./catalog.nix { pkgs = ...; llm-agents-pkgs = ...; } +{ pkgs, llm-agents-pkgs }: + +let + # Helper: only inherit a package if it exists in the source attrset. + # llm-agents.nix package availability varies by platform. + pickExisting = src: names: + builtins.listToAttrs ( + builtins.filter (x: x != null) ( + map (name: + if src ? ${name} + then { inherit name; value = src.${name}; } + else null + ) names + ) + ); +in +{ + agents = pickExisting llm-agents-pkgs [ + # AI Coding Agents + "amp" + "claude-code" + "codex" + "copilot-cli" + "crush" + "cursor-agent" + "droid" + "forge" + "gemini-cli" + "goose-cli" + "iflow-cli" + "kilocode-cli" + "mistral-vibe" + "nanocoder" + "opencode" + "pi" + "qoder-cli" + "qwen-code" + # Claude Code Ecosystem + "claudebox" + "catnip" + "claude-code-router" + # ACP Ecosystem + "claude-code-acp" + "codex-acp" + # Utilities + "sidecar" + "sandbox-runtime" + ]; + + tools = { + # Languages & runtimes + inherit (pkgs) python312 nodejs_22 rustc cargo go; + # Version control + inherit (pkgs) git; + # Core utilities + inherit (pkgs) coreutils bash findutils gnugrep gnused gawk; + # Build tools + inherit (pkgs) gnumake gcc gnutar gzip; + # Network + inherit (pkgs) curl cacert; + # Search & text + inherit (pkgs) ripgrep fd jq less; + # Shells + inherit (pkgs) zsh; + # Nix itself + inherit (pkgs) nix; + }; +} +``` + +- [ ] **Step 2: Verify the catalog evaluates** + +Run: +```bash +NIX_SSL_CERT_FILE=/etc/ssl/cert.pem nix eval --accept-flake-config --impure --expr ' + let + flake = builtins.getFlake (toString ./.); + pkgs = flake.inputs.nixpkgs.legacyPackages.x86_64-linux; + agents-pkgs = flake.inputs.llm-agents.packages.x86_64-linux; + catalog = import ./nix/catalog.nix { inherit pkgs; llm-agents-pkgs = agents-pkgs; }; + in builtins.attrNames catalog.agents +' 2>&1 | head -5 +``` + +Expected: A list of agent names like `[ "amp" "catnip" "claude-code" ... ]`. Some may be missing if not available for x86_64-linux -- that's fine, `pickExisting` handles it. + +- [ ] **Step 3: Commit** + +```bash +git add nix/catalog.nix +git commit -m "feat: create unified package catalog from llm-agents.nix + nixpkgs" +``` + +--- + +### Task 3: Create nix/mkAgentSandbox.nix + +**Files:** +- Create: `nix/mkAgentSandbox.nix` + +- [ ] **Step 1: Create the mkAgentSandbox function** + +Create `nix/mkAgentSandbox.nix`: + +```nix +# nix/mkAgentSandbox.nix +# +# Catalog-aware sandbox composition layer. +# Resolves package names from the unified catalog and delegates to mkSandboxRootfs. +# +# Usage: +# mkAgentSandbox = import ./mkAgentSandbox.nix { inherit catalog mkSandboxRootfs; }; +# mkAgentSandbox { name = "review"; packages = [ "claude-code" "git" "ripgrep" ]; } +{ catalog, mkSandboxRootfs }: + +{ name +, packages ? [] +, extraPackages ? [] +, env ? {} +}: + +let + resolvePackage = pname: + if catalog.agents ? ${pname} then catalog.agents.${pname} + else if catalog.tools ? ${pname} then catalog.tools.${pname} + else throw "nixosandbox: unknown package '${pname}' -- not found in agents or tools catalog. Run 'nixosandbox catalog' to see available packages."; + + resolvedPackages = map resolvePackage packages; + allPackages = resolvedPackages ++ extraPackages; +in + mkSandboxRootfs { + inherit name env; + packages = allPackages; + } +``` + +- [ ] **Step 2: Verify mkAgentSandbox resolves a known tool** + +Run: +```bash +NIX_SSL_CERT_FILE=/etc/ssl/cert.pem nix eval --accept-flake-config --impure --expr ' + let + flake = builtins.getFlake (toString ./.); + pkgs = flake.inputs.nixpkgs.legacyPackages.x86_64-linux; + agents-pkgs = flake.inputs.llm-agents.packages.x86_64-linux; + catalog = import ./nix/catalog.nix { inherit pkgs; llm-agents-pkgs = agents-pkgs; }; + mkSandboxRootfs = import ./nix/mkSandboxRootfs.nix { inherit pkgs; }; + mkAgentSandbox = import ./nix/mkAgentSandbox.nix { inherit catalog mkSandboxRootfs; }; + result = mkAgentSandbox { name = "test"; packages = [ "bash" "git" ]; }; + in result.name +' +``` + +Expected: `"sandbox-test"` (the name prefix from mkSandboxRootfs). + +- [ ] **Step 3: Verify unknown package throws** + +Run: +```bash +NIX_SSL_CERT_FILE=/etc/ssl/cert.pem nix eval --accept-flake-config --impure --expr ' + let + flake = builtins.getFlake (toString ./.); + pkgs = flake.inputs.nixpkgs.legacyPackages.x86_64-linux; + agents-pkgs = flake.inputs.llm-agents.packages.x86_64-linux; + catalog = import ./nix/catalog.nix { inherit pkgs; llm-agents-pkgs = agents-pkgs; }; + mkSandboxRootfs = import ./nix/mkSandboxRootfs.nix { inherit pkgs; }; + mkAgentSandbox = import ./nix/mkAgentSandbox.nix { inherit catalog mkSandboxRootfs; }; + result = mkAgentSandbox { name = "fail"; packages = [ "nonexistent-pkg" ]; }; + in result.name +' 2>&1 +``` + +Expected: Error containing `"unknown package 'nonexistent-pkg'"`. + +- [ ] **Step 4: Commit** + +```bash +git add nix/mkAgentSandbox.nix +git commit -m "feat: create mkAgentSandbox -- catalog-aware composition layer" +``` + +--- + +### Task 4: Wire catalog and mkAgentSandbox into flake.nix outputs + +**Files:** +- Modify: `flake.nix` + +- [ ] **Step 1: Add catalog, mkAgentSandbox, and lib exports to flake.nix** + +In `flake.nix`, update the `let` block to add catalog and mkAgentSandbox, then add them to outputs. The full updated file: + +```nix +{ + description = "nixosandbox -- reproducible, isolated sandbox environments"; + + nixConfig = { + extra-substituters = [ "https://cache.numtide.com" ]; + extra-trusted-public-keys = [ "niks3.numtide.com-1:DTx8wZduET09hRmMtKdQDxNNthLQETkc/yaX7M4qK0g=" ]; + }; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + llm-agents.url = "github:numtide/llm-agents.nix"; + }; + + outputs = { self, nixpkgs, llm-agents }: + let + # Sandbox rootfs is always Linux + linuxSystem = "x86_64-linux"; + linuxPkgs = nixpkgs.legacyPackages.${linuxSystem}; + mkSandboxRootfs = import ./nix/mkSandboxRootfs.nix { pkgs = linuxPkgs; }; + + # Unified catalog: agents from llm-agents.nix + tools from nixpkgs + linuxCatalog = import ./nix/catalog.nix { + pkgs = linuxPkgs; + llm-agents-pkgs = llm-agents.packages.${linuxSystem} or {}; + }; + + # Catalog-aware sandbox builder + mkAgentSandbox = import ./nix/mkAgentSandbox.nix { + catalog = linuxCatalog; + inherit mkSandboxRootfs; + }; + + # Helper: load a profile JSON and resolve package names to nixpkgs attrs + loadProfile = path: + let + spec = builtins.fromJSON (builtins.readFile path); + resolvedPkgs = map (name: + if builtins.hasAttr name linuxPkgs + then builtins.getAttr name linuxPkgs + else throw "nixosandbox: unknown package '${name}' in profile ${spec.name}" + ) spec.packages; + in + mkSandboxRootfs { + name = spec.name; + packages = resolvedPkgs; + env = spec.env or {}; + }; + + # Rootfs derivations (always x86_64-linux, buildable from any host) + sandboxPackages = { + sandbox-build-install = loadProfile ./nix/profiles/build-install.json; + sandbox-offline-review = loadProfile ./nix/profiles/offline-review.json; + sandbox-strict = loadProfile ./nix/profiles/strict.json; + sandbox-debug-network = loadProfile ./nix/profiles/debug-network.json; + }; + + # All systems that can build/use nixosandbox + supportedSystems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ]; + + forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems f; + in + { + # Library functions for custom rootfs and catalog composition + lib = { + inherit mkSandboxRootfs; + inherit mkAgentSandbox; + }; + + # Catalog: queryable package listing (always x86_64-linux for rootfs) + catalog = linuxCatalog; + + packages = forAllSystems (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + sandboxPackages // { + nixosandbox = pkgs.rustPlatform.buildRustPackage { + pname = "nixosandbox"; + version = "0.1.0"; + src = ./crates/nixosandbox; + cargoLock.lockFile = ./crates/nixosandbox/Cargo.lock; + }; + + default = self.packages.${system}.nixosandbox; + } + ); + + devShells = forAllSystems (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in { + default = pkgs.mkShell { + name = "nixosandbox-dev"; + buildInputs = with pkgs; [ + rustc + cargo + pkg-config + jq + ] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ + bubblewrap + ]; + }; + } + ); + }; +} +``` + +- [ ] **Step 2: Verify flake outputs include catalog and lib** + +Run: +```bash +NIX_SSL_CERT_FILE=/etc/ssl/cert.pem nix flake show --accept-flake-config 2>&1 | head -20 +``` + +Expected: Output includes `catalog` and `lib` entries alongside existing `packages` and `devShells`. + +- [ ] **Step 3: Verify catalog is queryable via nix eval** + +Run: +```bash +NIX_SSL_CERT_FILE=/etc/ssl/cert.pem nix eval --accept-flake-config .#catalog.agents --apply 'x: builtins.attrNames x' 2>&1 | head -3 +``` + +Expected: A list of agent names. + +Run: +```bash +NIX_SSL_CERT_FILE=/etc/ssl/cert.pem nix eval --accept-flake-config .#catalog.tools --apply 'x: builtins.attrNames x' 2>&1 | head -3 +``` + +Expected: A list of tool names like `[ "bash" "cacert" "cargo" ... ]`. + +- [ ] **Step 4: Verify existing profiles still build** + +Run: +```bash +NIX_SSL_CERT_FILE=/etc/ssl/cert.pem nix eval --accept-flake-config .#packages.x86_64-linux.sandbox-strict.name +``` + +Expected: `"sandbox-strict"` -- confirms existing profile path is unbroken. + +- [ ] **Step 5: Commit** + +```bash +git add flake.nix +git commit -m "feat: wire catalog and mkAgentSandbox into flake outputs" +``` + +--- + +### Task 5: Add `--with` flag and `Catalog` subcommand to CLI + +**Files:** +- Modify: `crates/nixosandbox/src/cli.rs` + +- [ ] **Step 1: Add `--with` to Create variant and new Catalog variant** + +Replace the full contents of `crates/nixosandbox/src/cli.rs`: + +```rust +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "nixosandbox", about = "Reproducible, isolated sandbox environments")] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Create a new sandbox session + Create { + /// Use a built-in profile + #[arg(long)] + profile: Option, + + /// Use a custom spec file + #[arg(long)] + spec: Option, + + /// Compose from catalog packages (comma-separated, e.g. claude-code,git,python312) + #[arg(long, value_delimiter = ',')] + with: Option>, + + /// Network mode for --with sandboxes + #[arg(long, default_value = "off")] + network: String, + + /// Host directory to mount as /workspace + #[arg(long)] + workspace: Option, + + /// Human-readable session name + #[arg(long)] + name: Option, + + /// Agent runtime identifier (e.g. 'claude:opus-4-6') + #[arg(long)] + agent: Option, + + /// Purpose of this sandbox session + #[arg(long)] + description: Option, + + /// Output session info as JSON + #[arg(long)] + json: bool, + }, + + /// Execute a command inside a sandbox + Exec { + /// Session ID + session_id: String, + + /// Stream NDJSON events + #[arg(long)] + json: bool, + + /// Kill after timeout (seconds) + #[arg(long)] + timeout: Option, + + /// Additional environment variable (KEY=VALUE) + #[arg(long = "env", value_name = "KEY=VALUE")] + extra_env: Vec, + + /// Command to execute (after --) + #[arg(last = true)] + command: Vec, + }, + + /// Enter a sandbox interactively + Enter { + /// Session ID + session_id: String, + }, + + /// List active sandbox sessions + List { + /// Output as JSON array + #[arg(long)] + json: bool, + }, + + /// Destroy a sandbox session + Destroy { + /// Session ID + session_id: String, + }, + + /// Show detailed session status (battlecard) + Status { + /// Session ID + session_id: String, + + /// Output as JSON + #[arg(long)] + json: bool, + }, + + /// Build a rootfs without creating a session + Build { + /// Use a built-in profile + #[arg(long)] + profile: Option, + + /// Use a custom spec file + #[arg(long)] + spec: Option, + + /// Output rootfs path as JSON + #[arg(long)] + json: bool, + }, + + /// List available packages from the catalog + Catalog { + /// Output as JSON + #[arg(long)] + json: bool, + + /// Filter by name substring + #[arg(long)] + filter: Option, + }, +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: +```bash +cd /Users/hashwarlock/Projects/nixosandbox/crates/nixosandbox && cargo check 2>&1 +``` + +Expected: Compilation errors in `main.rs` because the new `Create` fields (`with`, `network`) and `Catalog` variant are not yet handled. This is expected -- we wire them in the next tasks. + +- [ ] **Step 3: Commit** + +```bash +git add crates/nixosandbox/src/cli.rs +git commit -m "feat: add --with flag on create and catalog subcommand to CLI" +``` + +--- + +### Task 6: Add `build_with_catalog()` and `query_catalog()` to nix.rs + +**Files:** +- Modify: `crates/nixosandbox/src/nix.rs` + +- [ ] **Step 1: Add the two new functions to nix.rs** + +Append these functions to the end of `crates/nixosandbox/src/nix.rs` (after the existing `validate_rootfs` function): + +```rust +/// Build a rootfs from catalog package names using mkAgentSandbox. +/// Returns the Nix store path of the resulting rootfs. +pub fn build_with_catalog(names: &[String], network: &str) -> Result { + let flake_root = find_flake_root()?; + let packages_nix = names + .iter() + .map(|n| format!("\"{}\"", n)) + .collect::>() + .join(" "); + + // Generate a deterministic name from the sorted package list + let mut sorted_names = names.to_vec(); + sorted_names.sort(); + let hash_input = sorted_names.join(","); + let mut h: u64 = 0; + for b in hash_input.bytes() { + h = h.wrapping_mul(31).wrapping_add(b as u64); + } + let name_hash = format!("{:08x}", h); + + let expr = format!( + r#"let flake = builtins.getFlake "{}"; in flake.lib.mkAgentSandbox {{ name = "custom-{}"; packages = [ {} ]; }}"#, + flake_root, name_hash, packages_nix + ); + nix_build_expr(&expr) +} + +/// Query the flake catalog and return JSON with agent/tool names and descriptions. +pub fn query_catalog() -> Result { + let flake_root = find_flake_root()?; + + // Evaluate a Nix expression that extracts names and meta.description + // from both catalog.agents and catalog.tools. + let expr = format!( + r#"let flake = builtins.getFlake "{}"; catalog = flake.catalog; extractMeta = attrs: builtins.mapAttrs (name: pkg: {{ description = pkg.meta.description or ""; }}) attrs; in {{ agents = extractMeta catalog.agents; tools = extractMeta catalog.tools; }}"#, + flake_root + ); + + let output = std::process::Command::new("nix") + .args(["eval", "--impure", "--expr", &expr, "--json"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|e| format!("nix eval: {e}"))?; + + if output.status.success() { + let json = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if json.is_empty() { + Err("nix eval produced no output".into()) + } else { + Ok(json) + } + } else { + Err(format!( + "nix eval failed: {}", + String::from_utf8_lossy(&output.stderr) + )) + } +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: +```bash +cd /Users/hashwarlock/Projects/nixosandbox/crates/nixosandbox && cargo check 2>&1 +``` + +Expected: Still has errors from `main.rs` not handling new CLI variants, but `nix.rs` itself should compile cleanly (no errors from this file). + +- [ ] **Step 3: Commit** + +```bash +git add crates/nixosandbox/src/nix.rs +git commit -m "feat: add build_with_catalog() and query_catalog() to nix.rs" +``` + +--- + +### Task 7: Wire everything together in main.rs + +**Files:** +- Modify: `crates/nixosandbox/src/main.rs` + +- [ ] **Step 1: Update the match arm in main() to handle new fields** + +In `main()`, replace the `Commands::Create` match arm: + +```rust + Commands::Create { profile, spec: spec_file, with, network, workspace, name, agent, description, json } => { + cmd_create(profile, spec_file, with, network, workspace, name, agent, description, json); + } +``` + +Add the `Catalog` match arm after the `Build` arm: + +```rust + Commands::Catalog { json, filter } => { + cmd_catalog(json, filter); + } +``` + +- [ ] **Step 2: Replace cmd_create with version that handles --with** + +Replace the existing `cmd_create` function: + +```rust +fn cmd_create( + profile: Option, + spec_file: Option, + with: Option>, + network: String, + workspace: Option, + name: Option, + agent: Option, + description: Option, + json: bool, +) { + // Validate mutual exclusivity: --with vs --profile vs --spec + let source_count = [with.is_some(), profile.is_some(), spec_file.is_some()] + .iter() + .filter(|&&b| b) + .count(); + if source_count > 1 { + eprintln!("error: specify only one of --profile, --spec, or --with"); + std::process::exit(1); + } + if source_count == 0 { + eprintln!("error: specify --profile, --spec, or --with"); + std::process::exit(1); + } + + let (rootfs_path, profile_name) = if let Some(ref packages) = with { + // Catalog-based composition + if packages.is_empty() { + eprintln!("error: --with requires at least one package name"); + std::process::exit(1); + } + match network.as_str() { + "off" | "full" => {} + other => { + eprintln!("error: --network must be 'off' or 'full', got '{other}'"); + std::process::exit(1); + } + } + let rootfs = nix::build_with_catalog(packages, &network).unwrap_or_else(|e| { + eprintln!("nix build failed: {e}"); + std::process::exit(1); + }); + nix::validate_rootfs(&rootfs).unwrap_or_else(|e| { + eprintln!("rootfs validation failed: {e}"); + std::process::exit(1); + }); + (rootfs, format!("custom:{}", packages.join(","))) + } else { + // Profile or spec-based + let sandbox_spec = resolve_spec(profile.clone(), spec_file); + let rootfs = build_rootfs_for_spec(&sandbox_spec, &profile); + nix::validate_rootfs(&rootfs).unwrap_or_else(|e| { + eprintln!("rootfs validation failed: {e}"); + std::process::exit(1); + }); + (rootfs, sandbox_spec.name.clone()) + }; + + let session_name = name.unwrap_or_else(|| profile_name.clone()); + let meta = session::create_session( + &session_name, + &profile_name, + &rootfs_path, + workspace.as_deref(), + agent.as_deref(), + description.as_deref(), + ).unwrap_or_else(|e| { + eprintln!("session creation failed: {e}"); + std::process::exit(1); + }); + + if json { + println!("{}", serde_json::to_string_pretty(&meta).unwrap()); + } else { + println!("{}", meta.session_id); + } +} +``` + +- [ ] **Step 3: Add cmd_catalog function** + +Add this function at the end of `main.rs` (after `cmd_status`): + +```rust +fn cmd_catalog(json: bool, filter: Option) { + let catalog_json = nix::query_catalog().unwrap_or_else(|e| { + eprintln!("error: {e}"); + std::process::exit(1); + }); + + if json && filter.is_none() { + println!("{}", catalog_json); + return; + } + + // Parse for display or filtering + let catalog: serde_json::Value = serde_json::from_str(&catalog_json).unwrap_or_else(|e| { + eprintln!("error: failed to parse catalog: {e}"); + std::process::exit(1); + }); + + let filter_lower = filter.as_ref().map(|f| f.to_lowercase()); + + if json { + // Filtered JSON output + let mut filtered = serde_json::json!({ "agents": {}, "tools": {} }); + for section in ["agents", "tools"] { + if let Some(entries) = catalog.get(section).and_then(|v| v.as_object()) { + let filt = filter_lower.as_ref().unwrap(); + let matched: serde_json::Map = entries + .iter() + .filter(|(k, v)| { + k.to_lowercase().contains(filt) + || v.get("description") + .and_then(|d| d.as_str()) + .map(|d| d.to_lowercase().contains(filt)) + .unwrap_or(false) + }) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + filtered[section] = serde_json::Value::Object(matched); + } + } + println!("{}", serde_json::to_string_pretty(&filtered).unwrap()); + return; + } + + // Human-readable output + for (section, label) in [("agents", "Agents (from llm-agents.nix)"), ("tools", "Tools (from nixpkgs)")] { + if let Some(entries) = catalog.get(section).and_then(|v| v.as_object()) { + println!("{}:", label); + let mut names: Vec<&String> = entries.keys().collect(); + names.sort(); + for name in names { + let desc = entries[name] + .get("description") + .and_then(|d| d.as_str()) + .unwrap_or(""); + if let Some(ref filt) = filter_lower { + if !name.to_lowercase().contains(filt) && !desc.to_lowercase().contains(filt) { + continue; + } + } + println!(" {:<20} {}", name, desc); + } + println!(); + } + } +} +``` + +- [ ] **Step 4: Build and verify compilation** + +Run: +```bash +cd /Users/hashwarlock/Projects/nixosandbox/crates/nixosandbox && cargo build 2>&1 +``` + +Expected: Clean compilation with at most the existing `BwrapAvailability::Available` platform-conditional warning. + +- [ ] **Step 5: Run existing tests** + +Run: +```bash +cd /Users/hashwarlock/Projects/nixosandbox/crates/nixosandbox && cargo test 2>&1 +``` + +Expected: All 19 existing tests pass. + +- [ ] **Step 6: Verify --help shows new options** + +Run: +```bash +cd /Users/hashwarlock/Projects/nixosandbox/crates/nixosandbox && cargo run -- create --help 2>&1 +``` + +Expected: Help output includes `--with`, `--network`, and all existing flags. + +Run: +```bash +cd /Users/hashwarlock/Projects/nixosandbox/crates/nixosandbox && cargo run -- catalog --help 2>&1 +``` + +Expected: Help output shows `--json` and `--filter` options. + +- [ ] **Step 7: Verify mutual exclusivity error** + +Run: +```bash +cd /Users/hashwarlock/Projects/nixosandbox/crates/nixosandbox && cargo run -- create --profile strict --with bash 2>&1 +``` + +Expected: `error: specify only one of --profile, --spec, or --with` + +- [ ] **Step 8: Commit** + +```bash +git add crates/nixosandbox/src/main.rs +git commit -m "feat: wire --with catalog creation and catalog subcommand into main" +``` + +--- + +### Task 8: Update Pi extension cli-client.ts + +**Files:** +- Modify: `packages/pi-sandbox-extension/src/cli-client.ts` + +- [ ] **Step 1: Add CatalogEntry, CatalogResponse types and catalogPackages function** + +Add these types after the existing `CreateOptions` interface (around line 44): + +```typescript +export interface CatalogEntry { + description: string; +} + +export interface CatalogResponse { + agents: Record; + tools: Record; +} +``` + +- [ ] **Step 2: Add withPackages and network to CreateOptions** + +Update the `CreateOptions` interface to include the new fields: + +```typescript +export interface CreateOptions { + profile?: string; + workspace?: string; + name?: string; + agent?: string; + description?: string; + withPackages?: string[]; + network?: string; +} +``` + +- [ ] **Step 3: Update createSession to handle withPackages** + +Replace the `createSession` function: + +```typescript +export function createSession(binary: string, opts: CreateOptions): SessionMetadata { + const args = ["create", "--json"]; + if (opts.withPackages && opts.withPackages.length > 0) { + args.push("--with", opts.withPackages.join(",")); + if (opts.network) { + args.push("--network", opts.network); + } + } else if (opts.profile) { + args.push("--profile", opts.profile); + } + if (opts.workspace) { args.push("--workspace", opts.workspace); } + if (opts.name) { args.push("--name", opts.name); } + if (opts.agent) { args.push("--agent", opts.agent); } + if (opts.description) { args.push("--description", opts.description); } + + const stdout = execFileSync(binary, args, { encoding: "utf-8" }); + return JSON.parse(stdout.trim()) as SessionMetadata; +} +``` + +- [ ] **Step 4: Add catalogPackages function** + +Add this function after `destroySession`: + +```typescript +export function catalogPackages(binary: string, filter?: string): CatalogResponse { + const args = ["catalog", "--json"]; + if (filter) { args.push("--filter", filter); } + const stdout = execFileSync(binary, args, { encoding: "utf-8" }); + return JSON.parse(stdout.trim()) as CatalogResponse; +} +``` + +- [ ] **Step 5: Verify TypeScript compiles** + +Run: +```bash +cd /Users/hashwarlock/Projects/nixosandbox/packages/pi-sandbox-extension && npx tsc --noEmit 2>&1 +``` + +Expected: No errors. + +- [ ] **Step 6: Commit** + +```bash +git add packages/pi-sandbox-extension/src/cli-client.ts +git commit -m "feat: add catalogPackages() and withPackages to cli-client" +``` + +--- + +### Task 9: Add sandbox_catalog tool and update sandbox_run in extension.ts + +**Files:** +- Modify: `packages/pi-sandbox-extension/src/extension.ts` + +- [ ] **Step 1: Add catalogPackages to imports** + +Change the import from `./cli-client.js` to include `catalogPackages`: + +```typescript +import { + createSession, + statusSession, + listSessions, + execCommand, + catalogPackages, +} from "./cli-client.js"; +``` + +- [ ] **Step 2: Update sandbox_run tool description and parameters** + +Replace the `sandboxRun` tool definition inside `createSandboxTools`: + +```typescript + const sandboxRun: ToolDefinition = { + name: "sandbox_run", + description: + "Run a command inside an isolated sandbox. " + + "Use 'with' to compose from catalog packages (call sandbox_catalog first to see available), " + + "or 'profile' for a built-in profile. Returns combined stdout/stderr and execution metadata.", + parameters: Type.Object({ + command: Type.Array(Type.String(), { + description: "Command and arguments to execute, e.g. [\"bash\", \"-c\", \"echo hello\"]", + minItems: 1, + }), + sessionId: Type.Optional( + Type.String({ description: "Reuse an existing session. Omit to create a new one." }), + ), + with: Type.Optional( + Type.Array(Type.String(), { + description: "Package names from the catalog (agents + tools). Mutually exclusive with profile.", + }), + ), + profile: Type.Optional( + Type.String({ description: "Built-in profile name. Defaults to build-install. Mutually exclusive with 'with'." }), + ), + network: Type.Optional( + Type.String({ description: "Network mode: 'off' for review/analysis, 'full' for build/install. Default: 'off'. Only used with 'with'." }), + ), + agent: Type.Optional( + Type.String({ description: "Agent runtime identifier, e.g. 'claude:opus-4-6'" }), + ), + description: Type.Optional( + Type.String({ description: "Purpose of this sandbox session" }), + ), + timeoutMs: Type.Optional( + Type.Number({ description: "Execution timeout in milliseconds." }), + ), + }), + async execute(args: unknown): Promise { + const { + command, + sessionId: maybeSessionId, + with: withPackages, + profile = withPackages ? undefined : "build-install", + network, + agent, + description, + timeoutMs, + } = args as { + command: string[]; + sessionId?: string; + with?: string[]; + profile?: string; + network?: string; + agent?: string; + description?: string; + timeoutMs?: number; + }; + + let sid = maybeSessionId; + if (!sid) { + const meta = createSession(binaryPath, { + withPackages, + profile, + network, + agent, + description, + }); + sid = meta.sessionId; + } + + const result = await execCommand(binaryPath, sid, command, { timeoutMs }); + return formatExecResult(result); + }, + }; +``` + +- [ ] **Step 3: Add sandbox_catalog tool** + +Add this tool definition after `sandboxSessionInfo` and before the `return` statement: + +```typescript + // ------------------------------------------------------------------------- + // Tool: sandbox_catalog + // ------------------------------------------------------------------------- + const sandboxCatalog: ToolDefinition = { + name: "sandbox_catalog", + description: + "List available packages for sandbox composition. " + + "Returns agents (AI coding tools like claude-code, pi, codex) and tools (utilities like python312, git, ripgrep). " + + "Call this before sandbox_run with 'with' to see what packages are available.", + parameters: Type.Object({ + filter: Type.Optional( + Type.String({ description: "Filter results by name or description substring." }), + ), + }), + async execute(args: unknown): Promise { + const { filter } = args as { filter?: string }; + const catalog = catalogPackages(binaryPath, filter); + + const lines: string[] = []; + + const agentNames = Object.keys(catalog.agents).sort(); + if (agentNames.length > 0) { + lines.push("Agents (AI coding tools):"); + for (const name of agentNames) { + lines.push(` ${name} ${catalog.agents[name].description}`); + } + lines.push(""); + } + + const toolNames = Object.keys(catalog.tools).sort(); + if (toolNames.length > 0) { + lines.push("Tools (utilities):"); + for (const name of toolNames) { + lines.push(` ${name} ${catalog.tools[name].description}`); + } + } + + return lines.join("\n"); + }, + }; +``` + +- [ ] **Step 4: Update the return array to include sandboxCatalog** + +Change the return statement to: + +```typescript + return [ + sandboxRun, + sandboxReadFile, + sandboxWriteFile, + sandboxListFiles, + sandboxSessionInfo, + sandboxCatalog, + sandboxBrowser, + ]; +``` + +- [ ] **Step 5: Verify TypeScript compiles** + +Run: +```bash +cd /Users/hashwarlock/Projects/nixosandbox/packages/pi-sandbox-extension && npx tsc --noEmit 2>&1 +``` + +Expected: No errors. + +- [ ] **Step 6: Commit** + +```bash +git add packages/pi-sandbox-extension/src/extension.ts +git commit -m "feat: add sandbox_catalog tool and 'with' param to sandbox_run" +``` + +--- + +### Task 10: Update Pi extension index.ts re-exports + +**Files:** +- Modify: `packages/pi-sandbox-extension/src/index.ts` + +- [ ] **Step 1: Add CatalogEntry, CatalogResponse, and catalogPackages to re-exports** + +Update the cli-client type re-exports: + +```typescript +export type { + SessionMetadata, + StatusResponse, + ExecResult, + CreateOptions, + CatalogEntry, + CatalogResponse, +} from "./cli-client.js"; +export { + createSession, + statusSession, + listSessions, + destroySession, + execCommand, + catalogPackages, +} from "./cli-client.js"; +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: +```bash +cd /Users/hashwarlock/Projects/nixosandbox/packages/pi-sandbox-extension && npx tsc --noEmit 2>&1 +``` + +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add packages/pi-sandbox-extension/src/index.ts +git commit -m "feat: re-export catalogPackages and CatalogResponse from index" +``` + +--- + +### Task 11: Rust unit tests + +**Files:** +- Modify: `crates/nixosandbox/src/spec.rs` + +- [ ] **Step 1: Add validation test for empty packages** + +In `crates/nixosandbox/src/spec.rs`, inside the `#[cfg(test)] mod tests` block, add: + +```rust + #[test] + fn validate_empty_packages_fails() { + let spec = SandboxSpec { + name: "test".to_string(), packages: vec![], + env: HashMap::new(), network: "full".to_string(), + namespaces: vec![], writable: vec![], + }; + assert!(validate_spec(&spec).unwrap_err().iter().any(|e| e.contains("packages"))); + } +``` + +- [ ] **Step 2: Run all Rust tests** + +Run: +```bash +cd /Users/hashwarlock/Projects/nixosandbox/crates/nixosandbox && cargo test 2>&1 +``` + +Expected: All tests pass including the new one (20 total). + +- [ ] **Step 3: Commit** + +```bash +git add crates/nixosandbox/src/spec.rs +git commit -m "test: add validation test for empty packages" +``` + +--- + +### Task 12: End-to-end smoke test + +**Files:** None (manual verification) + +This task verifies the full integration works end-to-end. It requires network access to fetch packages. + +- [ ] **Step 1: Build the CLI** + +Run: +```bash +cd /Users/hashwarlock/Projects/nixosandbox/crates/nixosandbox && cargo build --release 2>&1 +``` + +Expected: Clean build. + +- [ ] **Step 2: Test catalog subcommand** + +Run: +```bash +NIXOSANDBOX_FLAKE_ROOT=/Users/hashwarlock/Projects/nixosandbox \ + NIX_SSL_CERT_FILE=/etc/ssl/cert.pem \ + ./target/release/nixosandbox catalog 2>&1 | head -20 +``` + +Expected: Human-readable catalog listing with "Agents (from llm-agents.nix):" and "Tools (from nixpkgs):" sections. + +- [ ] **Step 3: Test catalog --json** + +Run: +```bash +NIXOSANDBOX_FLAKE_ROOT=/Users/hashwarlock/Projects/nixosandbox \ + NIX_SSL_CERT_FILE=/etc/ssl/cert.pem \ + ./target/release/nixosandbox catalog --json 2>&1 | python3 -m json.tool | head -20 +``` + +Expected: Valid JSON with `agents` and `tools` keys. + +- [ ] **Step 4: Test catalog --filter** + +Run: +```bash +NIXOSANDBOX_FLAKE_ROOT=/Users/hashwarlock/Projects/nixosandbox \ + NIX_SSL_CERT_FILE=/etc/ssl/cert.pem \ + ./target/release/nixosandbox catalog --filter claude 2>&1 +``` + +Expected: Only claude-related entries shown. + +- [ ] **Step 5: Test --with creates a session (requires Nix package downloads)** + +Run: +```bash +NIXOSANDBOX_FLAKE_ROOT=/Users/hashwarlock/Projects/nixosandbox \ + NIX_SSL_CERT_FILE=/etc/ssl/cert.pem \ + ./target/release/nixosandbox create --with bash,coreutils --network off --name test-catalog --json 2>&1 +``` + +Expected: JSON session metadata with `profile` containing `"custom:bash,coreutils"`. + +Note: This step requires Nix to download packages. If VPN causes issues, disconnect ProtonVPN first or skip this step. + +- [ ] **Step 6: Clean up test session (if Step 5 succeeded)** + +Use the session ID from Step 5: + +```bash +NIXOSANDBOX_FLAKE_ROOT=/Users/hashwarlock/Projects/nixosandbox \ + ./target/release/nixosandbox destroy 2>&1 +``` + +- [ ] **Step 7: Verify all Rust tests still pass** + +Run: +```bash +cd /Users/hashwarlock/Projects/nixosandbox/crates/nixosandbox && cargo test 2>&1 +``` + +Expected: All tests pass. diff --git a/docs/superpowers/plans/2026-04-09-nix-flake-part-c.md b/docs/superpowers/plans/2026-04-09-nix-flake-part-c.md new file mode 100644 index 0000000..9f4d354 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-nix-flake-part-c.md @@ -0,0 +1,1952 @@ +# Nix Flake Runtime Part C Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Simplify the Pi extension into a thin CLI adapter, add agent runtime metadata and battlecard-style session info to the CLI, and clean up all dead code across Rust and TypeScript. + +**Architecture:** The Pi extension stops managing sessions, profiles, and runtime bases directly. Instead, it shells out to `nixosandbox create/exec/status/list/destroy`. The CLI gains `--agent` and `--description` flags on `create` and a new `status` subcommand with a battlecard view. Dead code from the legacy NDJSON protocol is deleted from both Rust and TypeScript. + +**Tech Stack:** Rust (clap, serde_json), TypeScript, Vitest + +--- + +## File Structure + +### Modified +| File | Responsibility | +|------|---------------| +| `crates/nixosandbox/src/cli.rs` | Add `--agent`, `--description` to Create; add Status subcommand | +| `crates/nixosandbox/src/session.rs` | Add `agent`, `description` fields to SessionMetadata | +| `crates/nixosandbox/src/main.rs` | Wire new flags, add `cmd_status` with battlecard output | +| `crates/nixosandbox/src/contract.rs` | Gut dead types — keep only what observer.rs and docker.rs need | +| `crates/nixosandbox/src/observer.rs` | Clean up unused imports | +| `crates/nixosandbox/src/plan_builder.rs` | Delete legacy `build()` and `build_with_allowlist()` | +| `crates/nixosandbox/src/docker.rs` | Delete `rewrite_plan()` and its test (only used by dead supervisor) | +| `packages/pi-sandbox-extension/src/contract.ts` | Delete inbound types, keep outbound | +| `packages/pi-sandbox-extension/src/extension.ts` | Rewrite as thin CLI adapter | +| `packages/pi-sandbox-extension/src/index.ts` | Simplify entry point | +| `packages/pi-sandbox-extension/package.json` | Remove unused dependencies | + +### Deleted +| File | Reason | +|------|--------| +| `crates/nixosandbox/src/supervisor.rs` | Legacy NDJSON supervisor — entirely dead | +| `crates/nixosandbox/src/validator.rs` | Legacy plan validator — entirely dead | +| `packages/pi-sandbox-extension/src/session-manager.ts` | CLI owns sessions | +| `packages/pi-sandbox-extension/src/runtime-base.ts` | Nix flake profiles replace this | +| `packages/pi-sandbox-extension/src/profiles.ts` | CLI handles --profile | +| `packages/pi-sandbox-extension/src/reconciler.ts` | Single-shot CLI, nothing to reconcile | +| `packages/pi-sandbox-extension/src/runtime-client.ts` | Replaced by cli-client.ts | + +### Created +| File | Responsibility | +|------|---------------| +| `packages/pi-sandbox-extension/src/cli-client.ts` | Thin wrappers for nixosandbox CLI invocations | + +### Untouched +| File | Reason | +|------|--------| +| `flake.nix`, `nix/` | Nix flake and profiles | +| `docker/nixosandbox-sidecar.Dockerfile` | Docker sidecar | +| `packages/pi-sandbox-extension/src/browser.ts` | Independent of CLI adapter | +| `packages/pi-sandbox-extension/src/crash-synthesis.ts` | TS-only, kept | +| `tests/integration/` | Part B integration tests | + +--- + +### Task 1: Add agent and description fields to SessionMetadata + +**Files:** +- Modify: `crates/nixosandbox/src/session.rs:5-16,69-78,199-209` + +- [ ] **Step 1: Add the two new fields to SessionMetadata** + +In `crates/nixosandbox/src/session.rs`, add `agent` and `description` after `pid`: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionMetadata { + pub session_id: String, + pub name: String, + pub profile: String, + pub rootfs_path: String, + pub workspace: String, + pub created_at: String, + pub last_exec_at: Option, + pub pid: Option, + #[serde(default)] + pub agent: Option, + #[serde(default)] + pub description: Option, +} +``` + +The `#[serde(default)]` ensures existing metadata.json files without these fields deserialize cleanly as `None`. + +- [ ] **Step 2: Update create_session to accept the new fields** + +Replace the `create_session` function signature and the metadata construction: + +```rust +pub fn create_session( + name: &str, profile: &str, rootfs_path: &str, workspace: Option<&str>, + agent: Option<&str>, description: Option<&str>, +) -> Result { +``` + +And update the `SessionMetadata` construction inside the function: + +```rust + let metadata = SessionMetadata { + session_id: session_id.clone(), + name: name.to_string(), + profile: profile.to_string(), + rootfs_path: rootfs_path.to_string(), + workspace: workspace_path, + created_at: crate::timestamps::now_iso8601(), + last_exec_at: None, + pid: None, + agent: agent.map(|s| s.to_string()), + description: description.map(|s| s.to_string()), + }; +``` + +- [ ] **Step 3: Update the metadata_roundtrip test** + +Replace the `metadata_roundtrip` test: + +```rust + #[test] + fn metadata_roundtrip() { + let meta = SessionMetadata { + session_id: "abc".to_string(), name: "test".to_string(), + profile: "strict".to_string(), rootfs_path: "/nix/store/fake".to_string(), + workspace: "/tmp/ws".to_string(), created_at: "2026-04-08T12:00:00Z".to_string(), + last_exec_at: None, pid: None, + agent: Some("claude:opus-4-6".to_string()), + description: Some("test session".to_string()), + }; + let json = serde_json::to_string(&meta).unwrap(); + let de: SessionMetadata = serde_json::from_str(&json).unwrap(); + assert_eq!(de.session_id, "abc"); + assert_eq!(de.agent.as_deref(), Some("claude:opus-4-6")); + assert_eq!(de.description.as_deref(), Some("test session")); + } +``` + +- [ ] **Step 4: Add a test for backward-compatible deserialization** + +Add a new test after `metadata_roundtrip`: + +```rust + #[test] + fn metadata_deserializes_without_new_fields() { + let json = r#"{ + "sessionId": "abc", + "name": "test", + "profile": "strict", + "rootfsPath": "/nix/store/fake", + "workspace": "/tmp/ws", + "createdAt": "2026-04-08T12:00:00Z", + "lastExecAt": null, + "pid": null + }"#; + let de: SessionMetadata = serde_json::from_str(json).unwrap(); + assert_eq!(de.session_id, "abc"); + assert!(de.agent.is_none()); + assert!(de.description.is_none()); + } +``` + +- [ ] **Step 5: Fix all call sites of create_session** + +In `crates/nixosandbox/src/session.rs` tests, update all `create_session` calls to pass the new args: + +Replace all test calls like: +```rust +create_session("test-session", "strict", "/nix/store/fake", None) +``` + +with: +```rust +create_session("test-session", "strict", "/nix/store/fake", None, None, None) +``` + +Do this for all 5 test calls: `create_and_list_sessions`, `load_session_by_id`, `destroy_session_removes_dir`, `destroy_nonexistent_errors` (no call), and `create_with_external_workspace`. + +- [ ] **Step 6: Run tests** + +Run: `cd crates/nixosandbox && cargo test -- --test-threads=1 2>&1` +Expected: All tests pass (44 tests — 42 existing + 2 new). + +- [ ] **Step 7: Commit** + +```bash +git add crates/nixosandbox/src/session.rs +git commit -m "feat: add agent and description fields to SessionMetadata + +New optional fields stored in metadata.json: +- agent: agent runtime identifier (e.g. 'claude:opus-4-6') +- description: purpose of this sandbox session + +Backward-compatible: existing sessions without these fields +deserialize cleanly via #[serde(default)]." +``` + +--- + +### Task 2: Wire --agent and --description flags into CLI + +**Files:** +- Modify: `crates/nixosandbox/src/cli.rs:12-33` +- Modify: `crates/nixosandbox/src/main.rs:21,89-114` + +- [ ] **Step 1: Add the two new flags to the Create command** + +In `crates/nixosandbox/src/cli.rs`, add after the `name` field in the Create variant: + +```rust + /// Create a new sandbox session + Create { + /// Use a built-in profile + #[arg(long)] + profile: Option, + + /// Use a custom spec file + #[arg(long)] + spec: Option, + + /// Host directory to mount as /workspace + #[arg(long)] + workspace: Option, + + /// Human-readable session name + #[arg(long)] + name: Option, + + /// Agent runtime identifier (e.g. 'claude:opus-4-6') + #[arg(long)] + agent: Option, + + /// Purpose of this sandbox session + #[arg(long)] + description: Option, + + /// Output session info as JSON + #[arg(long)] + json: bool, + }, +``` + +- [ ] **Step 2: Update the main() match arm for Create** + +In `crates/nixosandbox/src/main.rs`, update the Create match arm: + +```rust + Commands::Create { profile, spec: spec_file, workspace, name, agent, description, json } => { + cmd_create(profile, spec_file, workspace, name, agent, description, json); + } +``` + +- [ ] **Step 3: Update cmd_create to pass agent and description** + +Update the `cmd_create` function signature and call: + +```rust +fn cmd_create(profile: Option, spec_file: Option, workspace: Option, name: Option, agent: Option, description: Option, json: bool) { + let sandbox_spec = resolve_spec(profile.clone(), spec_file); + let rootfs_path = build_rootfs_for_spec(&sandbox_spec, &profile); + + nix::validate_rootfs(&rootfs_path).unwrap_or_else(|e| { + eprintln!("rootfs validation failed: {e}"); + std::process::exit(1); + }); + + let session_name = name.unwrap_or_else(|| sandbox_spec.name.clone()); + let meta = session::create_session( + &session_name, + &sandbox_spec.name, + &rootfs_path, + workspace.as_deref(), + agent.as_deref(), + description.as_deref(), + ).unwrap_or_else(|e| { + eprintln!("session creation failed: {e}"); + std::process::exit(1); + }); + + if json { + println!("{}", serde_json::to_string_pretty(&meta).unwrap()); + } else { + println!("{}", meta.session_id); + } +} +``` + +- [ ] **Step 4: Run cargo check** + +Run: `cd crates/nixosandbox && cargo check 2>&1` +Expected: Compiles without errors. + +- [ ] **Step 5: Commit** + +```bash +git add crates/nixosandbox/src/cli.rs crates/nixosandbox/src/main.rs +git commit -m "feat: wire --agent and --description flags into create + +Both flags are optional strings passed through to session::create_session +and stored in metadata.json." +``` + +--- + +### Task 3: Add status subcommand with battlecard output + +**Files:** +- Modify: `crates/nixosandbox/src/cli.rs:64-68` +- Modify: `crates/nixosandbox/src/main.rs:17-39,377-417` + +- [ ] **Step 1: Add Status variant to the Commands enum** + +In `crates/nixosandbox/src/cli.rs`, add after Destroy: + +```rust + /// Show detailed session status (battlecard) + Status { + /// Session ID + session_id: String, + + /// Output as JSON + #[arg(long)] + json: bool, + }, +``` + +- [ ] **Step 2: Add match arm in main()** + +In `crates/nixosandbox/src/main.rs`, add in the match block: + +```rust + Commands::Status { session_id, json } => { + cmd_status(&session_id, json); + } +``` + +- [ ] **Step 3: Implement cmd_status** + +Add after `cmd_build` in `crates/nixosandbox/src/main.rs`: + +```rust +fn cmd_status(session_id: &str, json: bool) { + let meta = session::load_session(session_id).unwrap_or_else(|e| { + eprintln!("error: {e}"); + std::process::exit(1); + }); + + // Derive isolation backend + let isolation = match bubblewrap::detect() { + bubblewrap::BwrapAvailability::Available { .. } => "native", + bubblewrap::BwrapAvailability::DockerAvailable { .. } => "docker", + bubblewrap::BwrapAvailability::Unavailable { .. } => "unavailable", + }; + + // Derive network mode from profile spec + let network = { + let flake_root = nix::find_flake_root().ok(); + if let Some(ref root) = flake_root { + spec::load_profile(&meta.profile, root) + .map(|s| s.network.clone()) + .unwrap_or_else(|_| "unknown".to_string()) + } else { + "unknown".to_string() + } + }; + + if json { + let status = serde_json::json!({ + "sessionId": meta.session_id, + "name": meta.name, + "profile": meta.profile, + "rootfsPath": meta.rootfs_path, + "workspace": meta.workspace, + "createdAt": meta.created_at, + "lastExecAt": meta.last_exec_at, + "agent": meta.agent, + "description": meta.description, + "isolation": isolation, + "network": network, + }); + println!("{}", serde_json::to_string_pretty(&status).unwrap()); + } else { + let truncate = |s: &str, max: usize| -> String { + if s.len() > max { format!("{}...", &s[..max-3]) } else { s.to_string() } + }; + + let desc = meta.description.as_deref().unwrap_or("-"); + let agent = meta.agent.as_deref().unwrap_or("-"); + let last_exec = meta.last_exec_at.as_deref().unwrap_or("-"); + let rootfs_display = truncate(&meta.rootfs_path, 36); + let workspace_display = truncate(&meta.workspace, 36); + + let w = 48; + println!("╭{}╮", "─".repeat(w)); + println!("│ {:&1` +Expected: Compiles without errors. + +- [ ] **Step 5: Commit** + +```bash +git add crates/nixosandbox/src/cli.rs crates/nixosandbox/src/main.rs +git commit -m "feat: add status subcommand with battlecard output + +nixosandbox status shows a box-drawn session card with: +name, description, agent, profile, timestamps, rootfs, workspace, +network mode, and isolation backend. + +--json flag returns structured JSON with the same fields." +``` + +--- + +### Task 4: Delete supervisor.rs and validator.rs + +**Files:** +- Delete: `crates/nixosandbox/src/supervisor.rs` +- Delete: `crates/nixosandbox/src/validator.rs` +- Modify: `crates/nixosandbox/src/main.rs:1-12` + +- [ ] **Step 1: Delete the two files** + +```bash +rm crates/nixosandbox/src/supervisor.rs crates/nixosandbox/src/validator.rs +``` + +- [ ] **Step 2: Remove mod declarations from main.rs** + +In `crates/nixosandbox/src/main.rs`, delete these two lines: + +```rust +mod supervisor; +``` + +and: + +```rust +mod validator; +``` + +- [ ] **Step 3: Run cargo check** + +Run: `cd crates/nixosandbox && cargo check 2>&1` +Expected: Compiles. Some dead code warnings remain (for contract.rs types still referenced by other dead code). + +- [ ] **Step 4: Commit** + +```bash +git add crates/nixosandbox/src/supervisor.rs crates/nixosandbox/src/validator.rs crates/nixosandbox/src/main.rs +git commit -m "chore: delete supervisor.rs and validator.rs + +Both modules were entirely dead code after the legacy NDJSON +protocol was removed in Part B. supervisor.rs handled process +supervision for the legacy protocol. validator.rs validated +PlanPayload messages." +``` + +--- + +### Task 5: Clean up contract.rs — delete dead outbound types + +**Files:** +- Modify: `crates/nixosandbox/src/contract.rs` + +After deleting supervisor.rs and validator.rs, the only remaining consumers of contract.rs types are: +- `docker.rs` — uses `PlanPayload`, `Manifest`, `Mount`, `NetworkConfig`, `Policy` (for `rewrite_plan` and its test) +- `observer.rs` — uses `emit`, `BlockedConnection`, `NetworkEnvelope`, `ObservedConnection` +- `plan_builder.rs` — uses `EffectiveNetwork`, `EffectiveState`, `PlanPayload`, `ResolvedAllowlistEntry`, `Manifest`, `Mount`, `NetworkConfig`, `Policy` + +However, `docker::rewrite_plan` is only called from the now-deleted `supervisor.rs`. And `plan_builder::build()` and `build_with_allowlist()` are also only called from supervisor.rs. So we can cascade the cleanup. But that's Tasks 6 and 7. For now, delete the types that have zero remaining references. + +- [ ] **Step 1: Delete all outbound envelope types and emit()** + +In `crates/nixosandbox/src/contract.rs`, delete everything from the outbound section comment through the end of the file. Keep only the inbound types section (PlanPayload and sub-types) since docker.rs and plan_builder.rs still reference them. + +Replace the entire content of `crates/nixosandbox/src/contract.rs` with: + +```rust +use std::collections::HashMap; +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Plan types (used by docker.rs::rewrite_plan and plan_builder) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlanPayload { + pub version: u32, + pub session_id: String, + pub execution_id: String, + pub requested_profile: String, + pub runtime_base_name: Option, + pub manifest: Manifest, + pub policy: Policy, + pub command: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Manifest { + pub mounts: Vec, + pub env: HashMap, + pub cwd: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Mount { + #[serde(rename = "type")] + pub mount_type: String, + pub source: Option, + pub target: String, + pub writable: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Policy { + pub namespaces: Vec, + pub network: NetworkConfig, + pub resource_limits: Option, + pub allowed_writable_targets: Vec, + pub strict_write_policy: bool, + pub env_allowlist: Option>, + pub deny_commands: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkConfig { + pub mode: String, + pub allowlist: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourceLimits { + pub max_cpu_seconds: Option, + pub max_memory_bytes: Option, + pub max_pids: Option, + pub max_output_bytes: Option, +} + +// --------------------------------------------------------------------------- +// Network observation types (used by observer.rs) +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ObservedConnection { + pub direction: String, + pub host: String, + pub port: u16, + pub protocol: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BlockedConnection { + pub direction: String, + pub host: String, + pub port: u16, + pub protocol: Option, +} +``` + +- [ ] **Step 2: Run cargo check** + +Run: `cd crates/nixosandbox && cargo check 2>&1` + +This will fail because `observer.rs` still references `emit`, `NetworkEnvelope`. We'll fix that in the next step. + +- [ ] **Step 3: Fix observer.rs — remove references to deleted types** + +The `observer.rs` `poll_loop` function calls `emit(&NetworkEnvelope::new(...))`. Since we deleted those types, we need to update observer.rs. The observer can emit NDJSON directly like main.rs does. Replace the import line: + +```rust +use crate::contract::{emit, BlockedConnection, NetworkEnvelope, ObservedConnection}; +``` + +with: + +```rust +use crate::contract::{BlockedConnection, ObservedConnection}; +``` + +And in the `poll_loop` function (Linux-only), replace the `emit` call: + +```rust + let s = seq.fetch_add(1, Ordering::SeqCst); + emit(&NetworkEnvelope::new( + s, + "outbound".to_string(), + conn.host.clone(), + conn.port, + Some("tcp".to_string()), + )); +``` + +with: + +```rust + let s = seq.fetch_add(1, Ordering::SeqCst); + let event = serde_json::json!({ + "type": "network", + "sequence": s, + "ts": crate::timestamps::now_iso8601(), + "payload": { + "direction": "outbound", + "host": &conn.host, + "port": conn.port, + "protocol": "tcp" + } + }); + println!("{}", event); +``` + +Also add the unused import cleanup — remove `HashSet` if still unused (it's used in `poll_loop`), `thread::self` (unused), and `Duration` (used in `poll_loop`). The `#[cfg(target_os = "linux")]` blocks use `HashSet`, `thread`, and `Duration`, so on non-Linux they appear unused. Add conditional imports: + +Replace the top-level imports: + +```rust +use std::collections::HashSet; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use crate::contract::{emit, BlockedConnection, NetworkEnvelope, ObservedConnection}; +``` + +with: + +```rust +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use crate::contract::{BlockedConnection, ObservedConnection}; + +#[cfg(target_os = "linux")] +use std::sync::atomic::AtomicU64; +#[cfg(target_os = "linux")] +use std::thread::JoinHandle; +``` + +And update the `NetworkObserver` struct to use conditional types: + +```rust +pub struct NetworkObserver { + #[cfg(target_os = "linux")] + handle: Option>>, + #[cfg(not(target_os = "linux"))] + handle: Option<()>, + stop_flag: Arc, +} +``` + +Actually, this refactoring is getting complex. Let's keep it simpler — just conditionally compile the whole observer as a no-op on non-Linux and keep the imports cleaner. Replace the entire file: + +Replace the full content of `crates/nixosandbox/src/observer.rs` with: + +```rust +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use crate::contract::{BlockedConnection, ObservedConnection}; + +/// Background network observer that polls /proc/net/tcp for outbound connections. +/// +/// On Linux: polls at ~500ms intervals, deduplicates, emits network events. +/// On non-Linux: no-op (returns empty results immediately). +pub struct NetworkObserver { + #[cfg(target_os = "linux")] + handle: Option>>, + stop_flag: Arc, +} + +impl NetworkObserver { + /// Start the observer. On Linux, spawns a polling thread. + /// On non-Linux, returns a no-op observer. + #[cfg(target_os = "linux")] + pub fn start(seq: Arc) -> Self { + let stop_flag = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&stop_flag); + let handle = std::thread::spawn(move || poll_loop(flag, seq)); + NetworkObserver { + handle: Some(handle), + stop_flag, + } + } + + #[cfg(not(target_os = "linux"))] + pub fn start(_seq: Arc) -> Self { + NetworkObserver { + stop_flag: Arc::new(AtomicBool::new(false)), + } + } + + /// Stop the observer and return all observed connections. + pub fn stop(self) -> Vec { + self.stop_flag.store(true, Ordering::Relaxed); + #[cfg(target_os = "linux")] + { + match self.handle { + Some(h) => h.join().unwrap_or_default(), + None => vec![], + } + } + #[cfg(not(target_os = "linux"))] + { + vec![] + } + } +} + +/// The polling loop (Linux only). +#[cfg(target_os = "linux")] +fn poll_loop( + stop_flag: Arc, + seq: Arc, +) -> Vec { + use std::collections::HashSet; + use std::io::{BufRead, BufReader}; + use std::sync::atomic::Ordering as Ord; + use std::time::Duration; + + let mut seen: HashSet<(String, u16)> = HashSet::new(); + let mut results: Vec = Vec::new(); + + loop { + if stop_flag.load(Ordering::Relaxed) { + break; + } + + if let Ok(connections) = parse_proc_net_tcp("/proc/net/tcp") { + for conn in connections { + if seen.insert((conn.host.clone(), conn.port)) { + let s = seq.fetch_add(1, Ord::SeqCst); + let event = serde_json::json!({ + "type": "network", + "sequence": s, + "ts": crate::timestamps::now_iso8601(), + "payload": { + "direction": "outbound", + "host": &conn.host, + "port": conn.port, + "protocol": "tcp" + } + }); + println!("{}", event); + results.push(conn); + } + } + } + + std::thread::sleep(Duration::from_millis(500)); + } + + results +} + +/// Parse /proc/net/tcp and return outbound established connections. +#[cfg(target_os = "linux")] +fn parse_proc_net_tcp(path: &str) -> std::io::Result> { + use std::io::{BufRead, BufReader}; + use std::fs::File; + + let file = File::open(path)?; + let reader = BufReader::new(file); + let mut connections = Vec::new(); + + for (i, line) in reader.lines().enumerate() { + let line = line?; + if i == 0 { continue; } + + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() < 4 { continue; } + + let state = fields[3]; + if state != "01" { continue; } + + let rem_addr = fields[2]; + let parts: Vec<&str> = rem_addr.split(':').collect(); + if parts.len() != 2 { continue; } + + let ip_hex = parts[0]; + let port_hex = parts[1]; + + let ip_u32 = match u32::from_str_radix(ip_hex, 16) { + Ok(v) => v, + Err(_) => continue, + }; + + let a = (ip_u32 & 0xFF) as u8; + let b = ((ip_u32 >> 8) & 0xFF) as u8; + let c = ((ip_u32 >> 16) & 0xFF) as u8; + let d = ((ip_u32 >> 24) & 0xFF) as u8; + + if a == 127 || (a == 0 && b == 0 && c == 0 && d == 0) { continue; } + + let host = format!("{a}.{b}.{c}.{d}"); + let port = match u16::from_str_radix(port_hex, 16) { + Ok(v) => v, + Err(_) => continue, + }; + + connections.push(ObservedConnection { + direction: "outbound".to_string(), + host, + port, + protocol: Some("tcp".to_string()), + }); + } + + Ok(connections) +} + +/// Compute which observed connections would have been blocked under the given allowlist. +pub fn compute_would_have_blocked( + observed: &[ObservedConnection], + allowlist: &Option>, +) -> Vec { + let Some(list) = allowlist else { + return vec![]; + }; + + observed + .iter() + .filter(|conn| { + let entry = format!("{}:{}", conn.host, conn.port); + !list.iter().any(|allowed| allowed == &entry) + }) + .map(|conn| BlockedConnection { + direction: conn.direction.clone(), + host: conn.host.clone(), + port: conn.port, + protocol: conn.protocol.clone(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compute_would_have_blocked_with_no_allowlist() { + let observed = vec![ObservedConnection { + direction: "outbound".to_string(), + host: "1.2.3.4".to_string(), + port: 443, + protocol: Some("tcp".to_string()), + }]; + let blocked = compute_would_have_blocked(&observed, &None); + assert!(blocked.is_empty()); + } + + #[test] + fn compute_would_have_blocked_with_matching_allowlist() { + let observed = vec![ObservedConnection { + direction: "outbound".to_string(), + host: "1.2.3.4".to_string(), + port: 443, + protocol: Some("tcp".to_string()), + }]; + let allowlist = Some(vec!["1.2.3.4:443".to_string()]); + let blocked = compute_would_have_blocked(&observed, &allowlist); + assert!(blocked.is_empty()); + } + + #[test] + fn compute_would_have_blocked_with_non_matching_allowlist() { + let observed = vec![ObservedConnection { + direction: "outbound".to_string(), + host: "1.2.3.4".to_string(), + port: 443, + protocol: Some("tcp".to_string()), + }]; + let allowlist = Some(vec!["5.6.7.8:443".to_string()]); + let blocked = compute_would_have_blocked(&observed, &allowlist); + assert_eq!(blocked.len(), 1); + assert_eq!(blocked[0].host, "1.2.3.4"); + assert_eq!(blocked[0].port, 443); + } + + #[test] + fn network_observer_noop_on_stop() { + let seq = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let observer = NetworkObserver::start(seq); + let connections = observer.stop(); + let _ = connections; + } +} +``` + +- [ ] **Step 4: Run cargo check and test** + +Run: `cd crates/nixosandbox && cargo check 2>&1` +Run: `cd crates/nixosandbox && cargo test -- --test-threads=1 2>&1` +Expected: Compiles and all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/nixosandbox/src/contract.rs crates/nixosandbox/src/observer.rs +git commit -m "chore: delete dead outbound types from contract.rs + +Remove OutboundMessage enum, all envelope types (Stdout, Stderr, +Lifecycle, Network, Warning, Result), ValidationPayload and related +types, EffectiveState, emit() function, and PROTOCOL_VERSION constant. + +Keep only PlanPayload sub-types (used by docker.rs, plan_builder.rs) +and network observation types (used by observer.rs). + +Rewrite observer.rs to emit NDJSON directly instead of using the +deleted emit()/NetworkEnvelope types." +``` + +--- + +### Task 6: Delete legacy plan_builder functions and docker::rewrite_plan + +**Files:** +- Modify: `crates/nixosandbox/src/plan_builder.rs` +- Modify: `crates/nixosandbox/src/docker.rs` + +- [ ] **Step 1: Delete build() and build_with_allowlist() from plan_builder.rs** + +In `crates/nixosandbox/src/plan_builder.rs`, delete: +1. The `use crate::contract::{EffectiveNetwork, EffectiveState, PlanPayload, ResolvedAllowlistEntry};` import — replace with just what `build_rootfs` and its tests need +2. The `build()` function (lines 16-92) +3. The `generate_iptables_wrapper()` function (lines 94-110) +4. The `build_with_allowlist()` function (lines 118-end of that function) +5. All tests that test the deleted functions + +First, read the full file to identify `build_rootfs` and its dependencies. The `build_rootfs` function does NOT use any contract.rs types — it takes raw string arguments. So we can remove the contract import entirely. + +Replace the import at line 1: + +```rust +use crate::contract::{EffectiveNetwork, EffectiveState, PlanPayload, ResolvedAllowlistEntry}; +``` + +with nothing (delete the line entirely). The `build_rootfs` function and `RootfsSessionDirs` struct don't reference contract types. + +Then delete the `build()` function body, `generate_iptables_wrapper()`, and `build_with_allowlist()`. Keep only `RootfsSessionDirs`, `build_rootfs()`, and the tests for `build_rootfs`. + +Delete all tests that reference `PlanPayload`, `EffectiveState`, `EffectiveNetwork`, `Manifest`, `Mount`, `NetworkConfig`, `Policy`, `ResolvedAllowlistEntry` — these are the tests for the deleted `build()` and `build_with_allowlist()` functions. + +Keep these tests (they test `build_rootfs`): +- `build_rootfs_produces_pivot_root_argv` +- `build_rootfs_network_off_adds_unshare_net` + +And keep the test helper `use crate::contract::{Manifest, Mount, NetworkConfig, Policy};` only if those tests use it. Since `build_rootfs` tests don't use contract types, remove that import too. + +- [ ] **Step 2: Delete rewrite_plan() and its test from docker.rs** + +In `crates/nixosandbox/src/docker.rs`: + +Delete the `use crate::contract::PlanPayload;` import at line 3. + +Delete the `rewrite_plan()` function (lines 227-247). + +Delete the `rewrite_plan_rewrites_mount_sources_and_cwd` test and the `use crate::contract::{Manifest, Mount, NetworkConfig, Policy};` import inside the test module. + +Keep `rewrite_path()` and its tests (still used by main.rs for session path rewriting). + +- [ ] **Step 3: Run cargo check and test** + +Run: `cd crates/nixosandbox && cargo check 2>&1` +Run: `cd crates/nixosandbox && cargo test -- --test-threads=1 2>&1` +Expected: Compiles and tests pass. Fewer tests now (deleted legacy test functions). + +- [ ] **Step 4: Commit** + +```bash +git add crates/nixosandbox/src/plan_builder.rs crates/nixosandbox/src/docker.rs +git commit -m "chore: delete legacy build(), build_with_allowlist(), rewrite_plan() + +These functions were only called from the now-deleted supervisor.rs. +build_rootfs() remains as the sole bwrap argv builder. +rewrite_path() remains for session directory path rewriting." +``` + +--- + +### Task 7: Final Rust dead code pass — delete contract.rs if fully dead + +**Files:** +- Modify: `crates/nixosandbox/src/contract.rs` +- Modify: `crates/nixosandbox/src/main.rs` + +- [ ] **Step 1: Check if contract.rs has any remaining consumers** + +After Tasks 4-6, check: does any file still import from contract.rs? + +Run: `grep -r "use crate::contract" crates/nixosandbox/src/` + +If only `observer.rs` uses `ObservedConnection` and `BlockedConnection`, consider whether observer is itself dead. The observer is not called from main.rs's exec path. Check: + +Run: `grep -r "observer::" crates/nixosandbox/src/main.rs` + +If no references, observer.rs is also dead. Delete it and contract.rs entirely. + +If observer is still referenced, keep contract.rs with just the observation types. + +- [ ] **Step 2: Delete dead modules based on findings** + +If both contract.rs and observer.rs are dead: + +```bash +rm crates/nixosandbox/src/contract.rs crates/nixosandbox/src/observer.rs +``` + +And remove from main.rs: +```rust +mod contract; +mod observer; +``` + +If only contract.rs plan types are dead (observer still used), delete just the plan types from contract.rs, keeping observation types. + +- [ ] **Step 3: Remove the logs field from SessionDirs if unused** + +Check: `grep -r "\.logs" crates/nixosandbox/src/` + +If only `session.rs` references it (the struct definition and directory creation), and no one reads it, remove it: +- Delete `pub logs: PathBuf,` from the struct +- Delete `logs: root.join("logs"),` from `session_dirs()` +- Delete `fs::create_dir_all(&logs_dir)` and `let logs_dir` from `create_session()` + +- [ ] **Step 4: Run cargo check and test** + +Run: `cd crates/nixosandbox && cargo check 2>&1` +Run: `cd crates/nixosandbox && cargo test -- --test-threads=1 2>&1` +Expected: Compiles cleanly with minimal or no warnings. All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add -A crates/nixosandbox/src/ +git commit -m "chore: final Rust dead code cleanup + +Remove remaining unreferenced modules and types. +Clean up unused struct fields." +``` + +--- + +### Task 8: Delete 5 extension modules + +**Files:** +- Delete: `packages/pi-sandbox-extension/src/session-manager.ts` +- Delete: `packages/pi-sandbox-extension/src/runtime-base.ts` +- Delete: `packages/pi-sandbox-extension/src/profiles.ts` +- Delete: `packages/pi-sandbox-extension/src/reconciler.ts` +- Delete: `packages/pi-sandbox-extension/src/runtime-client.ts` + +- [ ] **Step 1: Delete the 5 files** + +```bash +rm packages/pi-sandbox-extension/src/session-manager.ts \ + packages/pi-sandbox-extension/src/runtime-base.ts \ + packages/pi-sandbox-extension/src/profiles.ts \ + packages/pi-sandbox-extension/src/reconciler.ts \ + packages/pi-sandbox-extension/src/runtime-client.ts +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/pi-sandbox-extension/src/session-manager.ts \ + packages/pi-sandbox-extension/src/runtime-base.ts \ + packages/pi-sandbox-extension/src/profiles.ts \ + packages/pi-sandbox-extension/src/reconciler.ts \ + packages/pi-sandbox-extension/src/runtime-client.ts +git commit -m "chore: delete 5 extension modules replaced by CLI + +Removed: +- session-manager.ts (CLI owns sessions) +- runtime-base.ts (Nix flake profiles replace host-derived bundles) +- profiles.ts (CLI handles --profile) +- reconciler.ts (single-shot CLI, nothing to reconcile) +- runtime-client.ts (replaced by cli-client.ts)" +``` + +--- + +### Task 9: Clean up contract.ts — delete inbound types + +**Files:** +- Modify: `packages/pi-sandbox-extension/src/contract.ts` + +- [ ] **Step 1: Delete inbound types from contract.ts** + +Delete the following from `packages/pi-sandbox-extension/src/contract.ts`: + +1. The `PlanPayloadSchema` and `PlanPayload` type (lines 101-111) +2. The `PlanMessage` interface (lines 113-116) +3. The `CancelPayloadSchema` and `CancelPayload` type (lines 118-121) +4. The `CancelMessage` interface (lines 123-126) +5. The `InboundMessage` type (line 128) +6. The `ManifestSchema` and `Manifest` type (lines 79-84) +7. The `PolicySchema` and `Policy` type (lines 86-95) +8. The `MountSchema` and `Mount` type (lines 46-56) +9. The `NetworkConfigSchema` and `NetworkConfig` type (lines 65-69) +10. The `ResourceLimitsSchema` and `ResourceLimits` type (lines 71-77) +11. The `NetworkModeSchema` and `NetworkMode` type (lines 58-63) + +Keep: +- `PROTOCOL_VERSION` +- Error/warning code types +- All outbound types (EffectiveNetwork, EffectiveState, ValidationPayload, StreamEvent types, ResultPayload, etc.) + +- [ ] **Step 2: Check if crash-synthesis.ts still compiles** + +The crash-synthesis.ts imports `EffectiveNetwork`, `PlanPayload`, `ResultPayload`, `ValidationPayload`. Since we deleted `PlanPayload`, we need to update crash-synthesis.ts. + +The `synthesizeCrashResult` function takes a `PlanPayload` to extract `plan.policy.network.mode` for the fallback effective network. Since the extension no longer constructs plans, update the function to take the network mode directly: + +Replace the entire content of `packages/pi-sandbox-extension/src/crash-synthesis.ts` with: + +```typescript +/** + * Crash Synthesis + * + * When the Rust runtime exits without emitting a "result" message, + * the TS client synthesizes one to ensure the extension always has + * a complete execution result. + */ + +import type { + EffectiveNetwork, + ResultPayload, + ValidationPayload, +} from "./contract.js"; + +/** + * Synthesize a crash result when the CLI process exits without emitting a result. + * + * @param lastValidation - Last validation received (if any) + * @param requestedNetworkMode - The network mode that was requested (e.g. "off", "full") + * @param exitCode - Process exit code + * @param signal - Signal that killed the process (if any) + * @param durationMs - Execution duration in milliseconds + */ +export function synthesizeCrashResult( + lastValidation: ValidationPayload | null, + requestedNetworkMode: string, + exitCode: number | null, + signal: string | null, + durationMs: number, +): ResultPayload { + let effectiveNetwork: EffectiveNetwork; + let workspaceModified: boolean; + + if (lastValidation?.effectiveState) { + effectiveNetwork = lastValidation.effectiveState.network; + workspaceModified = true; + } else { + effectiveNetwork = { + requested: requestedNetworkMode as any, + actual: "full", + enforcement: "none", + degraded: true, + }; + workspaceModified = false; + } + + return { + exitCode: exitCode ?? -1, + signal, + timedOut: false, + durationMs, + effectiveNetwork, + observedConnections: [], + wouldHaveBlocked: [], + reconciliationHints: { + terminalState: "supervisor_crash", + workspaceModified, + cleanupSucceeded: false, + }, + }; +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/pi-sandbox-extension/src/contract.ts packages/pi-sandbox-extension/src/crash-synthesis.ts +git commit -m "chore: delete inbound types from contract.ts + +Remove PlanPayload, CancelPayload, ManifestSchema, PolicySchema, +and all inbound message types. Keep outbound types for NDJSON parsing. + +Update crash-synthesis.ts to take requestedNetworkMode string +instead of full PlanPayload." +``` + +--- + +### Task 10: Create cli-client.ts + +**Files:** +- Create: `packages/pi-sandbox-extension/src/cli-client.ts` + +- [ ] **Step 1: Create the CLI client module** + +Create `packages/pi-sandbox-extension/src/cli-client.ts`: + +```typescript +/** + * CLI Client + * + * Thin wrappers for shelling out to the nixosandbox CLI binary. + * Replaces session-manager.ts + runtime-client.ts with direct CLI delegation. + */ + +import { execFileSync, spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import type { StreamEvent, ResultPayload } from "./contract.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface SessionMetadata { + sessionId: string; + name: string; + profile: string; + rootfsPath: string; + workspace: string; + createdAt: string; + lastExecAt: string | null; + agent: string | null; + description: string | null; +} + +export interface StatusResponse extends SessionMetadata { + isolation: string; + network: string; +} + +export interface ExecResult { + events: Array>; + exitCode: number; +} + +export interface CreateOptions { + profile?: string; + workspace?: string; + name?: string; + agent?: string; + description?: string; +} + +// --------------------------------------------------------------------------- +// Functions +// --------------------------------------------------------------------------- + +export function createSession(binary: string, opts: CreateOptions): SessionMetadata { + const args = ["create", "--json"]; + if (opts.profile) { args.push("--profile", opts.profile); } + if (opts.workspace) { args.push("--workspace", opts.workspace); } + if (opts.name) { args.push("--name", opts.name); } + if (opts.agent) { args.push("--agent", opts.agent); } + if (opts.description) { args.push("--description", opts.description); } + + const stdout = execFileSync(binary, args, { encoding: "utf-8" }); + return JSON.parse(stdout.trim()) as SessionMetadata; +} + +export function statusSession(binary: string, sessionId: string): StatusResponse { + const stdout = execFileSync(binary, ["status", sessionId, "--json"], { + encoding: "utf-8", + }); + return JSON.parse(stdout.trim()) as StatusResponse; +} + +export function listSessions(binary: string): SessionMetadata[] { + const stdout = execFileSync(binary, ["list", "--json"], { + encoding: "utf-8", + }); + return JSON.parse(stdout.trim()) as SessionMetadata[]; +} + +export function destroySession(binary: string, sessionId: string): void { + execFileSync(binary, ["destroy", sessionId], { stdio: "pipe" }); +} + +export async function execCommand( + binary: string, + sessionId: string, + command: string[], + opts?: { env?: NodeJS.ProcessEnv; timeoutMs?: number }, +): Promise { + const args = ["exec", "--json", sessionId, "--", ...command]; + + return new Promise((resolve, reject) => { + const child = spawn(binary, args, { + stdio: ["pipe", "pipe", "pipe"], + env: opts?.env ?? process.env, + }); + + const events: ExecResult["events"] = []; + const rl = createInterface({ input: child.stdout! }); + + rl.on("line", (line) => { + try { + events.push(JSON.parse(line)); + } catch { + // Ignore unparseable lines + } + }); + + let timer: ReturnType | undefined; + if (opts?.timeoutMs) { + timer = setTimeout(() => { + child.kill("SIGTERM"); + }, opts.timeoutMs); + } + + child.on("exit", (code) => { + if (timer) clearTimeout(timer); + resolve({ events, exitCode: code ?? 1 }); + }); + + child.on("error", (err) => { + if (timer) clearTimeout(timer); + reject(err); + }); + }); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/pi-sandbox-extension/src/cli-client.ts +git commit -m "feat: create cli-client.ts — thin CLI wrappers + +Replaces session-manager.ts + runtime-client.ts with direct +nixosandbox CLI delegation: createSession, statusSession, +listSessions, destroySession, execCommand." +``` + +--- + +### Task 11: Rewrite extension.ts as thin CLI adapter + +**Files:** +- Modify: `packages/pi-sandbox-extension/src/extension.ts` + +- [ ] **Step 1: Replace the entire extension.ts** + +Replace the full content of `packages/pi-sandbox-extension/src/extension.ts` with: + +```typescript +/** + * Extension Tools + * + * Thin CLI adapter — all sandbox operations delegate to the nixosandbox binary. + */ + +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { normalize, resolve as resolvePath } from "node:path"; +import { Type } from "@sinclair/typebox"; +import type { TSchema } from "@sinclair/typebox"; +import { + createSession, + statusSession, + listSessions, + execCommand, +} from "./cli-client.js"; +import type { BrowserManager } from "./browser.js"; + +// --------------------------------------------------------------------------- +// Minimal ToolDefinition interface (avoids importing from Pi directly) +// --------------------------------------------------------------------------- + +export interface ToolDefinition { + name: string; + description: string; + parameters: TSchema; + execute(args: unknown): Promise; +} + +// --------------------------------------------------------------------------- +// Path safety +// --------------------------------------------------------------------------- + +function safePath(workspaceRoot: string, callerPath: string): string { + const resolved = resolvePath(workspaceRoot, normalize(callerPath)); + if (!resolved.startsWith(workspaceRoot + "/") && resolved !== workspaceRoot) { + throw new Error( + `Path traversal detected: "${callerPath}" resolves outside workspace`, + ); + } + return resolved; +} + +// --------------------------------------------------------------------------- +// Result formatter +// --------------------------------------------------------------------------- + +function formatExecResult(result: Awaited>): string { + const stdoutLines: string[] = []; + const stderrLines: string[] = []; + let exitCode: number | null = null; + let durationMs = 0; + + for (const event of result.events) { + if (event.type === "stdout") { + stdoutLines.push((event as any).payload.data); + } else if (event.type === "stderr") { + stderrLines.push((event as any).payload.data); + } else if (event.type === "result") { + const p = (event as any).payload; + exitCode = p.exitCode; + durationMs = p.durationMs; + } + } + + const lines: string[] = [ + `exit_code: ${exitCode ?? result.exitCode}`, + `duration_ms: ${durationMs}`, + ]; + + if (stdoutLines.length > 0) { + lines.push("--- stdout ---"); + lines.push(...stdoutLines); + } + + if (stderrLines.length > 0) { + lines.push("--- stderr ---"); + lines.push(...stderrLines); + } + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Battlecard formatter +// --------------------------------------------------------------------------- + +function formatBattlecard(status: Record): string { + const lines: string[] = []; + const fields = [ + ["Session", status.sessionId], + ["Name", status.name], + ["Description", status.description ?? "-"], + ["Agent", status.agent ?? "-"], + ["Profile", status.profile], + ["Created", status.createdAt], + ["Last Exec", status.lastExecAt ?? "-"], + ["Network", status.network ?? "-"], + ["Isolation", status.isolation ?? "-"], + ["Workspace", status.workspace], + ]; + + for (const [label, value] of fields) { + lines.push(`${label}: ${value}`); + } + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createSandboxTools( + binaryPath: string, + browserManager: BrowserManager, +): ToolDefinition[] { + // ------------------------------------------------------------------------- + // Tool: sandbox_run + // ------------------------------------------------------------------------- + const sandboxRun: ToolDefinition = { + name: "sandbox_run", + description: + "Run a command inside an isolated sandbox. Returns combined stdout/stderr and execution metadata.", + parameters: Type.Object({ + command: Type.Array(Type.String(), { + description: "Command and arguments to execute, e.g. [\"bash\", \"-c\", \"echo hello\"]", + minItems: 1, + }), + sessionId: Type.Optional( + Type.String({ description: "Reuse an existing session. Omit to create a new one." }), + ), + profile: Type.Optional( + Type.String({ description: "Execution profile name. Defaults to build-install." }), + ), + agent: Type.Optional( + Type.String({ description: "Agent runtime identifier, e.g. 'claude:opus-4-6'" }), + ), + description: Type.Optional( + Type.String({ description: "Purpose of this sandbox session" }), + ), + timeoutMs: Type.Optional( + Type.Number({ description: "Execution timeout in milliseconds." }), + ), + }), + async execute(args: unknown): Promise { + const { + command, + sessionId: maybeSessionId, + profile = "build-install", + agent, + description, + timeoutMs, + } = args as { + command: string[]; + sessionId?: string; + profile?: string; + agent?: string; + description?: string; + timeoutMs?: number; + }; + + let sid = maybeSessionId; + if (!sid) { + const meta = createSession(binaryPath, { profile, agent, description }); + sid = meta.sessionId; + } + + const result = await execCommand(binaryPath, sid, command, { timeoutMs }); + return formatExecResult(result); + }, + }; + + // ------------------------------------------------------------------------- + // Tool: sandbox_read_file + // ------------------------------------------------------------------------- + const sandboxReadFile: ToolDefinition = { + name: "sandbox_read_file", + description: "Read a file from the sandbox workspace.", + parameters: Type.Object({ + sessionId: Type.String({ description: "Session ID whose workspace to read from." }), + path: Type.String({ description: "Path relative to the workspace root." }), + }), + async execute(args: unknown): Promise { + const { sessionId, path: callerPath } = args as { + sessionId: string; + path: string; + }; + + const status = statusSession(binaryPath, sessionId); + const absPath = safePath(status.workspace, callerPath); + return readFileSync(absPath, "utf8"); + }, + }; + + // ------------------------------------------------------------------------- + // Tool: sandbox_write_file + // ------------------------------------------------------------------------- + const sandboxWriteFile: ToolDefinition = { + name: "sandbox_write_file", + description: "Write a file into the sandbox workspace.", + parameters: Type.Object({ + sessionId: Type.String({ description: "Session ID whose workspace to write into." }), + path: Type.String({ description: "Path relative to the workspace root." }), + content: Type.String({ description: "File content to write." }), + }), + async execute(args: unknown): Promise { + const { sessionId, path: callerPath, content } = args as { + sessionId: string; + path: string; + content: string; + }; + + const status = statusSession(binaryPath, sessionId); + const absPath = safePath(status.workspace, callerPath); + + const parentDir = absPath.substring(0, absPath.lastIndexOf("/")); + if (parentDir && parentDir !== status.workspace) { + mkdirSync(parentDir, { recursive: true }); + } + + writeFileSync(absPath, content, "utf8"); + return `Written ${content.length} bytes to ${callerPath}`; + }, + }; + + // ------------------------------------------------------------------------- + // Tool: sandbox_list_files + // ------------------------------------------------------------------------- + const sandboxListFiles: ToolDefinition = { + name: "sandbox_list_files", + description: "List files and directories in the sandbox workspace.", + parameters: Type.Object({ + sessionId: Type.String({ description: "Session ID whose workspace to list." }), + path: Type.Optional( + Type.String({ description: "Sub-path relative to the workspace root. Defaults to root." }), + ), + }), + async execute(args: unknown): Promise { + const { sessionId, path: callerPath = "." } = args as { + sessionId: string; + path?: string; + }; + + const status = statusSession(binaryPath, sessionId); + const absPath = safePath(status.workspace, callerPath); + + const entries = readdirSync(absPath, { withFileTypes: true }); + if (entries.length === 0) return "(empty directory)"; + + return entries + .map((e) => (e.isDirectory() ? `${e.name}/` : e.name)) + .sort() + .join("\n"); + }, + }; + + // ------------------------------------------------------------------------- + // Tool: sandbox_session_info + // ------------------------------------------------------------------------- + const sandboxSessionInfo: ToolDefinition = { + name: "sandbox_session_info", + description: + "Show sandbox session battlecard or list all sessions.", + parameters: Type.Object({ + sessionId: Type.Optional( + Type.String({ description: "Session ID for detailed battlecard. Omit to list all." }), + ), + }), + async execute(args: unknown): Promise { + const { sessionId } = args as { sessionId?: string }; + + if (sessionId) { + const status = statusSession(binaryPath, sessionId); + return formatBattlecard(status as unknown as Record); + } + + const sessions = listSessions(binaryPath); + if (sessions.length === 0) return "No sessions found."; + + return sessions + .map( + (s) => + `${s.sessionId} profile=${s.profile} agent=${s.agent ?? "-"} created=${s.createdAt}`, + ) + .join("\n"); + }, + }; + + // ------------------------------------------------------------------------- + // Tool: sandbox_browser + // ------------------------------------------------------------------------- + const sandboxBrowser: ToolDefinition = { + name: "sandbox_browser", + description: + "Interact with a web browser within a sandbox session. Supports goto, screenshot, evaluate, click, type, and close actions.", + parameters: Type.Object({ + sessionId: Type.String({ description: "Session ID to operate within." }), + action: Type.Union( + [ + Type.Literal("goto"), + Type.Literal("screenshot"), + Type.Literal("evaluate"), + Type.Literal("click"), + Type.Literal("type"), + Type.Literal("close"), + ], + { description: "Browser action to perform." }, + ), + url: Type.Optional(Type.String({ description: "URL to navigate to (goto action)." })), + selector: Type.Optional(Type.String({ description: "CSS selector (click/type actions)." })), + text: Type.Optional(Type.String({ description: "Text to type (type action)." })), + script: Type.Optional(Type.String({ description: "JavaScript to evaluate." })), + }), + async execute(args: unknown): Promise { + const { sessionId, action, url, selector, text, script } = args as { + sessionId: string; + action: string; + url?: string; + selector?: string; + text?: string; + script?: string; + }; + + return browserManager.execute(sessionId, action, { + url, + selector, + text, + script, + }); + }, + }; + + return [ + sandboxRun, + sandboxReadFile, + sandboxWriteFile, + sandboxListFiles, + sandboxSessionInfo, + sandboxBrowser, + ]; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/pi-sandbox-extension/src/extension.ts +git commit -m "feat: rewrite extension.ts as thin CLI adapter + +All tools now delegate to the nixosandbox binary via cli-client.ts: +- sandbox_run: create session + exec command via CLI +- sandbox_read/write/list_files: get workspace path from status +- sandbox_session_info: battlecard view from status/list +- sandbox_browser: unchanged (delegates to BrowserManager) + +New parameters: agent and description on sandbox_run. +Removed: SessionManager, RuntimeBase, profile resolution, +RuntimeClient, reconciler — all handled by CLI." +``` + +--- + +### Task 12: Simplify index.ts and package.json + +**Files:** +- Modify: `packages/pi-sandbox-extension/src/index.ts` +- Modify: `packages/pi-sandbox-extension/package.json` + +- [ ] **Step 1: Replace index.ts** + +Replace the entire content of `packages/pi-sandbox-extension/src/index.ts` with: + +```typescript +/** + * Pi Sandbox Extension — entry point + * + * Default export: `sandboxExtension(pi)` registers all tools and lifecycle + * event handlers against the Pi host. + * + * All public types are also re-exported for consumers. + */ + +import { createSandboxTools } from "./extension.js"; +import { BrowserManager } from "./browser.js"; + +// --------------------------------------------------------------------------- +// Extension entry point +// --------------------------------------------------------------------------- + +export default function sandboxExtension( + pi: { + registerTool(tool: { + name: string; + description: string; + parameters: unknown; + execute(args: unknown): Promise; + }): void; + on(event: string, handler: (...args: unknown[]) => void | Promise): void; + }, + opts: { + binaryPath?: string; + } = {}, +): void { + const binaryPath = opts.binaryPath ?? "nixosandbox"; + const browserManager = new BrowserManager(); + + // Register tools + const tools = createSandboxTools(binaryPath, browserManager); + for (const tool of tools) { + pi.registerTool(tool); + } + + // Lifecycle: on session_shutdown → shut down browser + pi.on("session_shutdown", () => { + browserManager.shutdown().catch(() => {}); + }); +} + +// --------------------------------------------------------------------------- +// Public type re-exports +// --------------------------------------------------------------------------- + +export * from "./contract.js"; +export { synthesizeCrashResult } from "./crash-synthesis.js"; +export type { ToolDefinition } from "./extension.js"; +export { createSandboxTools } from "./extension.js"; +export type { + SessionMetadata, + StatusResponse, + ExecResult, + CreateOptions, +} from "./cli-client.js"; +export { + createSession, + statusSession, + listSessions, + destroySession, + execCommand, +} from "./cli-client.js"; +``` + +- [ ] **Step 2: Update package.json — rename and clean up** + +Replace the content of `packages/pi-sandbox-extension/package.json`: + +```json +{ + "name": "@nixosandbox/extension", + "version": "0.2.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@sinclair/typebox": "^0.34.0", + "playwright-core": "^1.50.0" + }, + "devDependencies": { + "typescript": "^5.7.0" + } +} +``` + +Changes: renamed from `@pi-sandbox/extension` to `@nixosandbox/extension`, bumped to 0.2.0, removed vitest (tests that depended on deleted modules — crash-synthesis test remains in `tests/protocol/`), removed `test` and `test:watch` scripts. + +- [ ] **Step 3: Commit** + +```bash +git add packages/pi-sandbox-extension/src/index.ts packages/pi-sandbox-extension/package.json +git commit -m "refactor: simplify index.ts and rename extension package + +- Remove SessionManager, RuntimeBase, Reconciler wiring from entry point +- Default binary path now 'nixosandbox' (was 'pi-sandbox-supervisor') +- Remove session_start reconciliation (no reconciler) +- Rename package to @nixosandbox/extension v0.2.0 +- Remove vitest dependency (tests live in tests/protocol/)" +``` + +--- + +### Task 13: Update protocol tests for extension changes + +**Files:** +- Modify: `tests/protocol/crash-synthesis.test.ts` + +- [ ] **Step 1: Update crash-synthesis test for new function signature** + +The crash-synthesis.test.ts imports `synthesizeCrashResult` and `PlanPayload` from the extension. Since `PlanPayload` is deleted, update the test. + +Read the current test to understand what needs changing. The test constructs a `PlanPayload` and passes it to `synthesizeCrashResult`. Update to pass `requestedNetworkMode` string instead. + +Replace all calls like: +```typescript +synthesizeCrashResult(validation, plan, exitCode, signal, durationMs) +``` +with: +```typescript +synthesizeCrashResult(validation, "off", exitCode, signal, durationMs) +``` + +where `"off"` was `plan.policy.network.mode`. + +Remove the `PlanPayload` import and the plan construction. + +- [ ] **Step 2: Run the crash-synthesis test** + +Run: `cd tests/protocol && npx vitest run crash-synthesis.test.ts 2>&1` +Expected: All crash-synthesis tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add tests/protocol/crash-synthesis.test.ts +git commit -m "test: update crash-synthesis test for simplified API + +Replace PlanPayload argument with requestedNetworkMode string +to match the updated synthesizeCrashResult signature." +``` + +--- + +### Task 14: Final TypeScript cleanup and build verification + +**Files:** +- Modify: `packages/pi-sandbox-extension/tsconfig.json` (if needed) + +- [ ] **Step 1: Run TypeScript typecheck** + +Run: `cd packages/pi-sandbox-extension && npx tsc --noEmit 2>&1` + +Fix any type errors that arise from the deleted modules or changed signatures. + +- [ ] **Step 2: Run all Rust tests** + +Run: `cd crates/nixosandbox && cargo test -- --test-threads=1 2>&1` +Expected: All tests pass. + +- [ ] **Step 3: Run crash-synthesis protocol test** + +Run: `cd tests/protocol && npx vitest run crash-synthesis.test.ts 2>&1` +Expected: Passes. + +- [ ] **Step 4: Commit any remaining fixes** + +```bash +git add -A +git commit -m "chore: final Part C cleanup — typecheck and test fixes" +``` + +--- + +## Test Gating Summary + +| Suite | Location | Requires | Changed in Part C? | +|-------|----------|----------|--------------------| +| Rust unit tests | `crates/nixosandbox/` | Just Rust | Yes — new metadata tests, fewer legacy tests | +| crash-synthesis | `tests/protocol/` | Just Node.js | Yes — updated for new API | +| rootfs-pipeline | `tests/integration/` | Nix, bwrap, Linux | No | +| docker-rootfs | `tests/integration/` | Nix, Docker | No | + +## Run Commands + +```bash +# Rust unit tests (always) +cd crates/nixosandbox && cargo test -- --test-threads=1 + +# Protocol tests (just binary, no Nix) +cd tests/protocol && npx vitest run crash-synthesis.test.ts + +# TypeScript typecheck +cd packages/pi-sandbox-extension && npx tsc --noEmit +``` diff --git a/docs/superpowers/plans/2026-04-09-numtide-catalog-dynamic.md b/docs/superpowers/plans/2026-04-09-numtide-catalog-dynamic.md new file mode 100644 index 0000000..de93d9d --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-numtide-catalog-dynamic.md @@ -0,0 +1,144 @@ +# Dynamic numtide Catalog Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the hardcoded 25-name whitelist in `nix/catalog.nix` with a dynamic passthrough that exposes every package from `numtide/llm-agents.nix` automatically. + +**Architecture:** Remove the `pickExisting` helper and the explicit name list. Assign `agents = builtins.removeAttrs llm-agents-pkgs [ "default" ]` — Nix evaluates this lazily so all 65+ upstream packages become resolvable by name with zero ongoing maintenance. + +**Tech Stack:** Nix (flakes), nixosandbox CLI (`nix eval` for catalog query) + +--- + +### Task 1: Replace the whitelist in catalog.nix + +**Files:** +- Modify: `nix/catalog.nix` + +- [ ] **Step 1: Read the current file to confirm starting state** + +```bash +cat nix/catalog.nix +``` + +Expected: file contains `pickExisting` helper + list of ~25 agent names. + +- [ ] **Step 2: Rewrite catalog.nix** + +Replace the entire file contents with: + +```nix +# nix/catalog.nix +# +# Unified package catalog merging AI agents from llm-agents.nix +# and standard development tools from nixpkgs. +# +# Usage: import ./catalog.nix { pkgs = ...; llm-agents-pkgs = ...; } +{ pkgs, llm-agents-pkgs }: +{ + # All packages from numtide/llm-agents.nix. + # 'default' is a meta-alias present in every flake packages output; strip it. + agents = builtins.removeAttrs llm-agents-pkgs [ "default" ]; + + tools = { + # Languages & runtimes + inherit (pkgs) python312 nodejs_22 rustc cargo go; + # Version control + inherit (pkgs) git; + # Core utilities + inherit (pkgs) coreutils bash findutils gnugrep gnused gawk; + # Build tools + inherit (pkgs) gnumake gcc gnutar gzip; + # Network + inherit (pkgs) curl cacert; + # Search & text + inherit (pkgs) ripgrep fd jq less; + # Shells + inherit (pkgs) zsh; + # Nix itself + inherit (pkgs) nix; + }; +} +``` + +- [ ] **Step 3: Verify the file evaluates without error** + +```bash +nix eval --accept-flake-config .#catalog.agents --apply 'x: builtins.length (builtins.attrNames x)' +``` + +Expected: a number greater than 25 (should be 60+). If this errors, check `flake.nix` still passes `llm-agents-pkgs = llm-agents.packages.${linuxSystem} or {}` — it should, since flake.nix is unchanged. + +- [ ] **Step 4: Spot-check a previously missing package resolves** + +```bash +nix eval --accept-flake-config '.#catalog.agents.jules.meta.description' +``` + +Expected: a string like `"Jules, the asynchronous coding agent from Google, in the terminal"` (not an error). + +- [ ] **Step 5: Spot-check that the old packages still resolve** + +```bash +nix eval --accept-flake-config '.#catalog.agents.claude-code.meta.description' +nix eval --accept-flake-config '.#catalog.agents.codex.meta.description' +``` + +Expected: both return description strings without error. + +- [ ] **Step 6: Verify `default` is absent** + +```bash +nix eval --accept-flake-config '.#catalog.agents' --apply 'x: x ? "default"' +``` + +Expected: `false` + +- [ ] **Step 7: Commit** + +```bash +git add nix/catalog.nix +git commit -m "feat: expose all llm-agents.nix packages dynamically in catalog" +``` + +--- + +### Task 2: Verify nixosandbox catalog CLI output + +**Files:** (read-only verification, no changes) + +- [ ] **Step 1: Build the nixosandbox CLI if not already built** + +```bash +nix build --accept-flake-config .#nixosandbox +``` + +Expected: `./result/bin/nixosandbox` exists. + +- [ ] **Step 2: Run catalog command and check count** + +```bash +NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox catalog | grep -c ' ' +``` + +Expected: line count significantly higher than 25 (the old whitelist size). + +- [ ] **Step 3: Confirm a new package appears in catalog output** + +```bash +NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox catalog | grep jules +``` + +Expected: ` jules Jules, the asynchronous coding agent from Google, in the terminal` + +- [ ] **Step 4: Confirm JSON mode works** + +```bash +NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox catalog --json | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d['agents']), 'agents')" +``` + +Expected: `60+ agents` (exact number depends on current llm-agents.nix upstream). + +- [ ] **Step 5: Commit verification note (optional)** + +No code change in this task — skip commit if nothing changed. diff --git a/docs/superpowers/specs/2026-04-03-pi-sandbox-phases-8-10-design.md b/docs/superpowers/specs/2026-04-03-pi-sandbox-phases-8-10-design.md new file mode 100644 index 0000000..d2d84ee --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-pi-sandbox-phases-8-10-design.md @@ -0,0 +1,499 @@ +# Pi Sandbox Phases 8-10 Design Spec: Make the Runtime Real + +**Date:** 2026-04-03 +**Status:** Approved +**Prerequisite:** Phases 0-7 complete (tag `v1-protocol-passing`) +**Branch:** `pi-sandbox-refactor` + +## Overview + +Replace the stub execution and observation in the Pi Sandbox runtime with real Bubblewrap isolation and network observation. Validate with real-world build flows. + +**What this spec covers:** +- Phase 8: Bubblewrap integration (real isolation on Linux, graceful macOS fallback) +- Phase 9: Integration tests with real build workflows (npm, Python, Rust) +- Phase 10: Network observation via `/proc/net/tcp` polling + +**What this spec does NOT cover:** +- Legacy server deprecation (Phase 11) +- Browser, real allowlist enforcement, Nix runtime bases (Phase 12) + +--- + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Platform strategy | Linux bwrap + macOS fallback | Truthful reporting via effectiveState; dev on macOS, isolate on Linux | +| Bwrap argv construction | Dedicated `plan_builder.rs` | Testable pure function; bwrap flag logic separate from process lifecycle | +| Bwrap binary discovery | `PI_SANDBOX_BWRAP_PATH` env var, fallback `which bwrap` | Supports NixOS store paths; simple case stays simple | +| Network observation | Poll `/proc/net/tcp` at ~500ms | Pure Rust, no external deps, Linux-only (macOS returns empty) | +| Integration test strategy | Fixture repos + optional network smoke tests | Deterministic CI gate; optional real-network validation | + +--- + +## Section 1: Bubblewrap Integration (Phase 8) + +### Architecture + +The supervisor currently runs commands directly: + +```rust +Command::new(&plan.command[0]).args(&plan.command[1..]) +``` + +Phase 8 replaces this with: + +```rust +Command::new(bwrap_path).args(plan_builder::build(plan, effective_state)) +``` + +On macOS or when bwrap is unavailable, the supervisor keeps direct execution and emits degraded warnings. + +### New Files + +#### `crates/pi-sandbox-runtime/src/bubblewrap.rs` + +Bwrap binary discovery and platform detection. + +Responsibilities: +- Check if running on Linux (`cfg(target_os = "linux")`) +- Resolve bwrap path: `PI_SANDBOX_BWRAP_PATH` env var first, then `which bwrap` on PATH +- Validate the resolved binary exists and is executable +- Expose `BwrapAvailability` enum: `Available { path: PathBuf }` or `Unavailable { reason: String }` +- Public function: `detect() -> BwrapAvailability` + +On non-Linux platforms, `detect()` always returns `Unavailable { reason: "not Linux" }`. + +#### `crates/pi-sandbox-runtime/src/plan_builder.rs` + +Translates `PlanPayload` + `EffectiveState` into bwrap argv (`Vec`). + +Construction order: +1. **Mounts:** For each mount in `manifest.mounts`: + - `type = "directory"`, `writable = false` → `--ro-bind ` + - `type = "directory"`, `writable = true` → `--bind ` + - `type = "file"`, `writable = false` → `--ro-bind ` + - `type = "file"`, `writable = true` → `--bind ` + - `type = "tmpfs"` → `--tmpfs ` +2. **Devices:** Hardcoded minimal set (not configurable in v1): + - `--dev-bind /dev/null /dev/null` + - `--dev-bind /dev/zero /dev/zero` + - `--dev-bind /dev/urandom /dev/urandom` + - `--dev-bind /dev/random /dev/random` +3. **Proc filesystem:** `--proc /proc` +4. **Namespaces:** For each namespace in `effective_state.namespaces_applied`: + - `pid` → `--unshare-pid` + - `ipc` → `--unshare-ipc` + - `uts` → `--unshare-uts` + - `net` → `--unshare-net` (only when `network.actual = "off"`) + - `cgroup-try` → `--unshare-cgroup-try` + - `user` → omitted (bwrap implicitly creates a user namespace when any other namespace is unshared; do not pass `--unshare-user` explicitly) +5. **Environment:** `--clearenv` then `--setenv KEY VALUE` for each entry in `manifest.env` +6. **Working directory:** `--chdir ` +7. **Command:** Appended last: `-- ...` + +Public function: `build(plan: &PlanPayload, effective_state: &EffectiveState) -> Vec` + +This is a pure function with no side effects. Testable in isolation. + +### Modified Files + +#### `crates/pi-sandbox-runtime/src/contract.rs` + +Extend `EffectiveState` to include fields that were in the v1 spec but not yet implemented: + +```rust +pub struct EffectiveState { + pub network: EffectiveNetwork, + pub namespaces_applied: Vec, + pub env_applied: Vec, +} +``` + +These fields are populated by the validator and reported in the validation message. Existing protocol tests that check `effectiveState` must be updated to expect these new fields (both will be present in every validation response where `effectiveState` is non-null). + +#### `crates/pi-sandbox-runtime/src/validator.rs` + +Changes: +- Resolve which namespaces can actually be applied based on platform and bwrap availability +- Populate `namespaces_applied` in `EffectiveState` (only namespaces that will actually be created) +- Populate `env_applied` in `EffectiveState` (keys from `manifest.env`, filtered by `env_allowlist` if set) +- Emit `NAMESPACE_DEGRADED` warning for each requested namespace that cannot be applied +- Accept bwrap availability as input (passed from main.rs) + +#### `crates/pi-sandbox-runtime/src/supervisor.rs` + +Changes: +- Accept bwrap availability as input +- When bwrap is available: call `plan_builder::build()` to get argv, spawn `Command::new(bwrap_path).args(argv)` +- When bwrap is unavailable: keep current direct execution (`Command::new(&plan.command[0])`) +- All streaming, cancel, and result logic remains identical regardless of execution mode +- The only branching point is the `Command` construction + +#### `crates/pi-sandbox-runtime/src/main.rs` + +Changes: +- Call `bubblewrap::detect()` at startup +- Pass bwrap availability to `validator::validate()` and `supervisor::supervise()` + +### Platform Detection Flow + +``` +main.rs startup: + bwrap = bubblewrap::detect() + +validator::validate(plan, bwrap): + if bwrap is Available: + namespaces_applied = plan.policy.namespaces (all requested) + else: + namespaces_applied = [] (none applied) + emit NAMESPACE_DEGRADED warning per requested namespace + +supervisor::supervise(plan, effective_state, cancel_rx, bwrap): + if bwrap is Available: + argv = plan_builder::build(plan, effective_state) + child = Command::new(bwrap.path).args(argv) + else: + child = Command::new(plan.command[0]).args(plan.command[1..]) + // everything else identical +``` + +### Rust Unit Tests + +`plan_builder.rs` tests (pure Rust, no bwrap needed): +- Given a plan with read-only directory mounts → argv contains `--ro-bind` +- Given a plan with writable mounts → argv contains `--bind` +- Given a plan with tmpfs mount → argv contains `--tmpfs` +- Given a plan with network mode off → argv contains `--unshare-net` +- Given a plan with network mode full → no `--unshare-net` +- Given a plan with env entries → argv contains `--clearenv --setenv K V` +- Given a plan with cwd → argv contains `--chdir` +- Device mounts are always present +- Command is always last after `--` + +`bubblewrap.rs` tests: +- `PI_SANDBOX_BWRAP_PATH` set → uses that path +- On non-Linux → returns Unavailable + +### Protocol Test Updates + +Existing protocol tests run on macOS (no bwrap). They continue to pass because: +- Supervisor falls back to direct execution +- Validation reports degraded namespaces (tests may need minor assertions updated) +- All NDJSON contract behavior is identical + +New test: `tests/protocol/bwrap-integration.test.ts` +- Skipped on non-Linux +- Sends a plan, asserts `namespacesApplied` is non-empty +- Asserts bwrap actually ran (check lifecycle events, validate isolation) + +--- + +## Section 2: Real Build Flows (Phase 9) + +### Purpose + +Validate that the sandbox can run real-world build workflows, not just `echo hello`. + +### Directory Structure + +``` +tests/integration/ + fixtures/ + tiny-npm/ + package.json + tiny-python/ + setup.py + mypackage/ + __init__.py + tiny-rust/ + Cargo.toml + src/ + main.rs + helpers.ts + globalSetup.ts + vitest.config.ts + build-npm.test.ts + build-python.test.ts + build-rust.test.ts + network-smoke.test.ts +``` + +### Fixture Repos + +#### `tiny-npm/` + +```json +{ + "name": "tiny-npm-fixture", + "version": "1.0.0", + "private": true, + "dependencies": {} +} +``` + +An empty npm project. `npm install` creates `node_modules/` and `package-lock.json` with zero network needed. This validates that Node.js tooling works inside the sandbox (correct PATH, writable workspace, etc.). + +#### `tiny-python/` + +```python +# setup.py +from setuptools import setup +setup( + name="tiny-python-fixture", + version="1.0.0", + packages=["mypackage"], +) +``` + +```python +# mypackage/__init__.py +"""Tiny fixture package.""" +``` + +`pip install -e .` with no external deps. Validates Python tooling works inside the sandbox. + +#### `tiny-rust/` + +```toml +# Cargo.toml +[package] +name = "tiny-rust-fixture" +version = "0.1.0" +edition = "2021" +``` + +```rust +// src/main.rs +fn main() { + println!("built"); +} +``` + +`cargo build` with no external deps. Validates Rust toolchain access inside the sandbox. + +### Integration Test Helpers + +`tests/integration/helpers.ts`: +- Reuses `spawnRuntime()` and `makePlan()` from protocol test helpers (import or copy) +- Adds `copyFixture(name: string) -> { tempDir: string, cleanup: () => void }` — copies fixture into a temp directory that simulates a session workspace +- Adds `makeIntegrationPlan(fixture, command, profile?)` — builds a plan with the fixture's temp dir as workspace, correct mounts, and the specified profile + +`tests/integration/globalSetup.ts`: +- Builds Rust binary (same as protocol tests) +- Sets `RUNTIME_BINARY_PATH` + +### CI Gate Tests + +#### `build-npm.test.ts` + +1. Copy `tiny-npm` fixture to temp workspace +2. Build plan: command = `["npm", "install"]`, profile = `build-install`, workspace mounted writable +3. Send plan via NDJSON protocol +4. Assert: `validation.ok = true` +5. Assert: `result.exitCode = 0` +6. Assert: `result.reconciliationHints.terminalState = "clean_exit"` +7. Assert: `node_modules/` directory exists in temp workspace (or `package-lock.json` was created) +8. Cleanup temp dir + +#### `build-python.test.ts` + +1. Copy `tiny-python` fixture to temp workspace +2. Build plan: command = `["pip", "install", "-e", "."]`, profile = `build-install` +3. Send plan, assert exit code 0, clean_exit + +#### `build-rust.test.ts` + +1. Copy `tiny-rust` fixture to temp workspace +2. Build plan: command = `["cargo", "build"]`, profile = `build-install` +3. Send plan, assert exit code 0, clean_exit +4. Assert: `target/` directory exists in temp workspace + +### Optional Network Smoke Tests + +`network-smoke.test.ts` — skipped unless `RUN_NETWORK_TESTS=1`: + +1. Create a temp workspace with a `package.json` that has one real dependency (e.g., `is-odd@3.0.1`) +2. Build plan: command = `["npm", "install"]`, profile = `build-install`, network mode = `full` +3. Send plan, assert exit code 0 +4. Assert: `node_modules/is-odd/` exists +5. If Phase 10 is complete: assert `observedConnections` is non-empty + +### What Changes in Existing Code + +Nothing. Phase 9 is purely tests. The production code from Phases 0-8 is validated, not modified. + +--- + +## Section 3: Network Observation (Phase 10) + +### Architecture + +A background observer thread runs during child process execution, polling `/proc/net/tcp` for outbound TCP connections. + +``` +supervisor::supervise() + ├─ spawn child + ├─ start observer thread (Linux only) + ├─ stream stdout/stderr (existing) + ├─ poll cancel (existing) + ├─ child exits + ├─ stop observer → Vec + └─ build result with observed connections +``` + +### Modified File: `crates/pi-sandbox-runtime/src/observer.rs` + +Replace the stub with a real implementation. + +#### `NetworkObserver` struct + +```rust +pub struct NetworkObserver { + handle: Option>>, + stop_flag: Arc, +} +``` + +#### Public API + +- `NetworkObserver::start() -> NetworkObserver` — spawns a polling thread (Linux only). On non-Linux, returns a no-op observer that produces empty results. +- `observer.stop() -> Vec` — sets the stop flag, joins the thread, returns deduplicated connections. +- `observer.emit_events(seq: &AtomicU64)` — during polling, emits `network` streamed events for newly discovered connections. + +#### `/proc/net/tcp` Parser + +Parses `/proc/net/tcp` (and optionally `/proc/net/tcp6` for IPv6). + +Each line format: +``` + sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 0100007F:0035 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 12345 ... +``` + +Parsing: +1. Skip header line (starts with whitespace + "sl") +2. Split by whitespace, extract field index 2 (`rem_address`) +3. Split `rem_address` on `:` → hex IP and hex port +4. Convert hex IP to `u32`, then to dotted decimal (little-endian on x86) +5. Convert hex port to `u16` +6. Extract field index 3 (`st` = state), filter to `01` (ESTABLISHED) +7. Filter out loopback (127.0.0.0/8) and unspecified (0.0.0.0) + +#### Deduplication + +The observer maintains a `HashSet<(String, u16)>` of (host, port) pairs seen so far. A connection is only emitted as a `network` event and added to the result list on first sight. + +#### Polling Loop + +```rust +loop { + if stop_flag.load(Ordering::Relaxed) { break; } + let connections = parse_proc_net_tcp(); + for conn in connections { + if seen.insert((conn.host.clone(), conn.port)) { + // New connection — emit network event + let s = seq.fetch_add(1, Ordering::SeqCst); + emit(&NetworkEnvelope::new(s, "outbound", conn.host, conn.port, Some("tcp"))); + results.push(conn); + } + } + thread::sleep(Duration::from_millis(500)); +} +``` + +#### Platform Behavior + +- **Linux:** Real polling. Streamed `network` events. Populated `observedConnections` and `wouldHaveBlocked` in result. +- **macOS/other:** `NetworkObserver::start()` returns a no-op observer. `stop()` returns empty vec. No `network` events emitted. `observedConnections` and `wouldHaveBlocked` are empty. This is truthful. + +No warning is emitted for missing network observation — this is a best-effort diagnostic feature, not a security claim. + +### Modified File: `crates/pi-sandbox-runtime/src/supervisor.rs` + +Changes: +- After spawning child, call `NetworkObserver::start()` +- Pass shared `Arc` sequence counter to observer (same one used by stdout/stderr threads) +- After child exits: call `observer.stop()` to get final connections +- Pass connections to result builder (replaces the current `observe_connections()` call) + +### Existing Code That Lights Up + +`compute_would_have_blocked()` in `observer.rs` already works correctly. Once `observe_connections()` returns real data, `wouldHaveBlocked` is automatically populated for allowlist scenarios. No changes needed. + +### New Protocol Test + +`tests/protocol/network-observation.test.ts`: +- Skip on non-Linux (`process.platform !== 'linux'`) +- Run a command that makes an outbound TCP connection (e.g., `curl -s http://example.com` or `python3 -c "import urllib.request; urllib.request.urlopen('http://example.com')"`) +- Assert: at least one `network` streamed event was received +- Assert: `result.observedConnections` is non-empty +- Assert: observed connections contain expected host + +### Integration With Phase 9 Network Smoke Tests + +Once Phase 10 lands, the optional `network-smoke.test.ts` from Phase 9 can add assertions: +- After `npm install` with real network: `observedConnections` contains registry.npmjs.org +- With allowlist mode: `wouldHaveBlocked` is computed correctly + +--- + +## File Map Summary + +### New Files + +| File | Phase | Purpose | +|------|-------|---------| +| `crates/pi-sandbox-runtime/src/bubblewrap.rs` | 8 | Bwrap binary discovery and platform detection | +| `crates/pi-sandbox-runtime/src/plan_builder.rs` | 8 | Manifest+policy → bwrap argv construction | +| `tests/integration/fixtures/tiny-npm/package.json` | 9 | NPM build fixture | +| `tests/integration/fixtures/tiny-python/setup.py` | 9 | Python build fixture | +| `tests/integration/fixtures/tiny-python/mypackage/__init__.py` | 9 | Python fixture package | +| `tests/integration/fixtures/tiny-rust/Cargo.toml` | 9 | Rust build fixture | +| `tests/integration/fixtures/tiny-rust/src/main.rs` | 9 | Rust fixture source | +| `tests/integration/helpers.ts` | 9 | Integration test utilities | +| `tests/integration/globalSetup.ts` | 9 | Build Rust binary for integration tests | +| `tests/integration/vitest.config.ts` | 9 | Vitest config for integration tests | +| `tests/integration/build-npm.test.ts` | 9 | NPM build integration test | +| `tests/integration/build-python.test.ts` | 9 | Python build integration test | +| `tests/integration/build-rust.test.ts` | 9 | Rust build integration test | +| `tests/integration/network-smoke.test.ts` | 9 | Optional network smoke test | +| `tests/protocol/bwrap-integration.test.ts` | 8 | Bwrap-specific protocol test (Linux only) | +| `tests/protocol/network-observation.test.ts` | 10 | Network observation protocol test (Linux only) | + +### Modified Files + +| File | Phase | Changes | +|------|-------|---------| +| `crates/pi-sandbox-runtime/src/contract.rs` | 8 | Add `namespaces_applied`, `env_applied` to EffectiveState | +| `crates/pi-sandbox-runtime/src/validator.rs` | 8 | Namespace resolution, env resolution, NAMESPACE_DEGRADED warnings | +| `crates/pi-sandbox-runtime/src/supervisor.rs` | 8, 10 | Bwrap dispatch, observer integration | +| `crates/pi-sandbox-runtime/src/main.rs` | 8 | Bwrap detection at startup, pass to validator/supervisor | +| `crates/pi-sandbox-runtime/src/observer.rs` | 10 | Replace stub with /proc/net/tcp polling | +| `crates/pi-sandbox-runtime/Cargo.toml` | 8 | No new deps expected (uses std only) | + +### Unchanged Files + +All TypeScript files in `packages/pi-sandbox-extension/` remain unchanged. The TS runtime client already handles all NDJSON message types including `network` events and `namespacesApplied`. No TS changes needed. + +--- + +## Migration Phase Gates + +| Phase | Gate Criteria | +|-------|--------------| +| 8 | `plan_builder.rs` unit tests pass. On Linux: bwrap protocol test passes with real isolation. On macOS: existing protocol tests pass with degraded warnings. | +| 9 | All 3 CI gate integration tests pass (npm, Python, Rust builds complete in sandbox). | +| 10 | On Linux: network observation test passes (connections detected during execution). `wouldHaveBlocked` correctly computed for allowlist scenarios. | + +--- + +## Engineering Rules (carried from v1) + +- Do not fake namespace application. Report what was actually applied. +- Do not claim allowlist enforcement if only observing. +- Do not bind host `/dev` wholesale. Use hardcoded minimal device set. +- Do not make network observation a security claim. It is diagnostic. +- Platform fallback must be truthful: macOS reports empty namespaces and empty observations. +- `plan_builder.rs` is a pure function. No side effects, no I/O. diff --git a/docs/superpowers/specs/2026-04-03-pi-sandbox-v1-design.md b/docs/superpowers/specs/2026-04-03-pi-sandbox-v1-design.md new file mode 100644 index 0000000..6eb3168 --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-pi-sandbox-v1-design.md @@ -0,0 +1,732 @@ +# Pi Sandbox v1 Design Spec + +**Date:** 2026-04-03 +**Status:** Approved +**Approach:** Two-Package Flat (Approach B) + +## Overview + +Refactor nixosandbox from a Rust/Axum REST sandbox server into a focused local execution runtime that integrates directly with the Pi coding agent. + +**Architecture:** +- Pi extension (TypeScript) for tool UX, approvals, session state, manifests, profiles, reconciliation, and orchestration. +- Rust runtime subprocess for policy interpretation, Bubblewrap argument construction, validation, execution, event streaming, network observation, and cleanup. + +**Transport:** NDJSON over stdin/stdout. +**Process model:** Single-shot subprocess per execution. + +## Out of Scope for v1 + +- Skills, factory, TEE +- Browser as a required core capability +- Long-lived worker/daemon mode +- Real allowlist network enforcement +- Nix-composed runtime bases + +--- + +## Section 1: Repository Structure & Build Infrastructure + +### Directory Layout + +```text +nixosandbox/ + packages/ + pi-sandbox-extension/ # TS package (Pi extension + runtime client) + package.json + tsconfig.json + vitest.config.ts + src/ + index.ts # Extension entry (ExtensionFactory) + contract.ts # NDJSON message types (TypeBox schemas) + extension.ts # Pi tool registrations + runtime-client.ts # Subprocess spawn, NDJSON I/O, cancel + crash-synthesis.ts # Synthesize result when Rust exits uncleanly + session-manager.ts # Session directories, mount manifests + runtime-base.ts # HostDerivedBase bundle resolution + profiles.ts # Profile registry + reconciler.ts # Scan/recover sessions on startup + + crates/ + pi-sandbox-runtime/ # Rust crate (subprocess binary) + Cargo.toml + src/ + main.rs # stdin->plan, validate, execute, result->stdout + contract.rs # Serde structs mirroring contract.ts + plan_builder.rs # Bubblewrap argv construction + validator.rs # Policy validation, writable target checks + supervisor.rs # Process supervision, signal handling + observer.rs # Network observation, connection logging + bubblewrap.rs # Bubblewrap binary invocation + timestamps.rs # Monotonic timestamp utilities + + tests/ + protocol/ # 6 canonical protocol tests (TS, spawns Rust binary) + helpers.ts + globalSetup.ts + version-mismatch.test.ts + validation-failure.test.ts + successful-run.test.ts + cancel-flow.test.ts + crash-synthesis.test.ts + degraded-allowlist.test.ts + integration/ # End-to-end with real commands (later phases) + + sandbox-rs/ # Legacy server (untouched during migration) + docs/ + rfc/ + architecture/ + nix/ + shell.nix + docker-compose.yml +``` + +### Build Tooling + +- **TS package:** vitest for testing, typescript for type checking, TypeBox for parameter schemas. Dev dependency on `@mariozechner/pi-coding-agent` for extension types. +- **Rust crate:** Standard `cargo build --release`. Standalone binary crate (no workspace with sandbox-rs). Dependencies: serde, serde_json, nix (namespace detection), chrono (timestamps). No axum, no tokio for the stub phase. +- **Protocol tests:** TS tests that build the Rust binary via vitest globalSetup, then spawn it as a subprocess per test case. +- **Legacy sandbox-rs/:** Left completely untouched. No shared dependencies. + +### Key Decisions + +1. No Cargo workspace. New crate is independent from sandbox-rs/. +2. Rust runtime is synchronous for the stub phase. No async runtime needed. +3. Protocol tests are the integration gate. Nothing merges until all 6 pass. + +--- + +## Section 2: NDJSON Protocol Contract + +### Envelope + +Every message has a top-level envelope: + +```json +{ + "type": "", + "v": 1, + "sequence": 0, + "payload": {} +} +``` + +Fields: +- `type` — message discriminator (always present) +- `v` — protocol version (on `validation` and `result` only) +- `sequence` — strictly increasing counter (streamed events only) +- `payload` — message body + +### TS to Rust Messages + +#### plan (exactly once) + +```json +{ + "type": "plan", + "payload": { + "version": 1, + "sessionId": "", + "executionId": "", + "requestedProfile": "build-install", + "runtimeBaseName": "host-derived", + "manifest": { + "mounts": [ + { + "type": "directory|file|tmpfs", + "source": "/host/path", + "target": "/sandbox/path", + "writable": false + } + ], + "env": { "HOME": "/home/sandbox" }, + "cwd": "/workspace" + }, + "policy": { + "namespaces": ["user", "pid", "ipc", "uts", "net", "cgroup-try"], + "network": { + "mode": "off|full|allowlist", + "allowlist": ["registry.npmjs.org:443"] + }, + "resourceLimits": { + "maxCpuSeconds": 300, + "maxMemoryBytes": 1073741824, + "maxPids": 256, + "maxOutputBytes": 10485760 + }, + "allowedWritableTargets": ["/workspace", "/tmp"], + "strictWritePolicy": false, + "envAllowlist": ["HOME", "PATH"], + "denyCommands": ["rm"] + }, + "command": ["npm", "install"] + } +} +``` + +#### cancel (optional, at most once) + +```json +{ + "type": "cancel", + "payload": { + "reason": "User cancelled" + } +} +``` + +### Rust to TS Messages + +#### validation (exactly once, before execution) + +```json +{ + "type": "validation", + "v": 1, + "payload": { + "ok": true, + "errors": [ + { "code": "VERSION_MISMATCH", "message": "...", "field": "version" } + ], + "warnings": [ + { "code": "ALLOWLIST_NOT_ENFORCED", "message": "..." } + ], + "effectiveState": { + "network": { + "requested": "allowlist", + "actual": "full", + "enforcement": "observed", + "degraded": true + }, + "namespacesApplied": ["user", "pid"], + "envApplied": ["HOME", "PATH"] + } + } +} +``` + +#### Streamed Events (zero or more, only after validation.ok = true) + +All have `sequence` (strictly increasing) and `ts` (ISO 8601). + +```json +{ "type": "stdout", "sequence": 1, "ts": "...", "payload": { "data": "..." } } +{ "type": "stderr", "sequence": 2, "ts": "...", "payload": { "data": "..." } } +{ "type": "lifecycle", "sequence": 3, "ts": "...", "payload": { "event": "started|cancel_requested|killing|exited" } } +{ "type": "network", "sequence": 4, "ts": "...", "payload": { "direction": "outbound", "host": "...", "port": 443, "protocol": "tcp" } } +{ "type": "warning", "sequence": 5, "ts": "...", "payload": { "code": "...", "message": "..." } } +``` + +#### result (exactly once, final message) + +```json +{ + "type": "result", + "v": 1, + "payload": { + "exitCode": 0, + "signal": null, + "timedOut": false, + "durationMs": 12847, + "effectiveNetwork": { + "requested": "full", + "actual": "full", + "enforcement": "none", + "degraded": false + }, + "observedConnections": [ + { "host": "registry.npmjs.org", "port": 443, "timestamp": "..." } + ], + "wouldHaveBlocked": [], + "resourcePeaks": { + "memoryBytes": 52428800, + "cpuSeconds": 2.3 + }, + "reconciliationHints": { + "terminalState": "clean_exit", + "workspaceModified": true, + "cleanupSucceeded": true + } + } +} +``` + +### Validation Error Codes + +| Code | Meaning | +|------|---------| +| `VERSION_MISMATCH` | Plan version not supported | +| `RW_TARGET_NOT_ALLOWED` | Writable mount outside allowedWritableTargets | +| `COMMAND_DENIED` | Command in denyCommands list | +| `INVALID_MOUNT` | Mount spec is malformed | +| `MISSING_REQUIRED_FIELD` | Required plan field missing | + +### Warning Codes + +| Code | Meaning | +|------|---------| +| `ALLOWLIST_NOT_ENFORCED` | Allowlist requested but degraded to full+observed | +| `NAMESPACE_DEGRADED` | Requested namespace could not be applied | +| `RESOURCE_LIMIT_IGNORED` | Resource limit requested but not enforced | + +### Truthfulness Invariants + +1. `effectiveState.network.actual` reflects what was actually applied, never what was requested. +2. If `requested = allowlist` and `actual = full`, then `degraded = true` and `enforcement = observed`. +3. `namespacesApplied` only lists namespaces that were successfully created. +4. `wouldHaveBlocked` is only meaningful when `degraded = true`. + +### Terminal States + +| State | Meaning | +|-------|---------| +| `clean_exit` | Process exited normally | +| `killed_on_cancel` | Terminated due to cancel message | +| `killed_on_timeout` | Terminated due to timeout | +| `supervisor_crash` | Rust runtime crashed (synthesized by TS) | +| `partial_cleanup` | Cleanup attempted but may be incomplete | + +--- + +## Section 3: Runtime Client & Crash Synthesis + +### runtime-client.ts + +The runtime client manages the Rust subprocess lifecycle. + +#### Interface + +```typescript +interface RuntimeClientOptions { + binaryPath: string; + timeout?: number; + onEvent?: (event: StreamEvent) => void; +} + +interface ExecutionHandle { + validation: Promise; + result: Promise; + cancel(reason?: string): void; +} +``` + +#### Lifecycle + +1. `client.execute(plan)` spawns child process. +2. Writes plan as single NDJSON line to stdin. +3. stdin stays open for potential cancel message. +4. Reads stdout line by line: + - First line: validation message, resolves `handle.validation`. + - If `validation.ok = false`: Rust exits, no more messages. + - Subsequent lines: streamed events, dispatched to `onEvent`. + - Last line: result message, resolves `handle.result`. +5. On `handle.cancel(reason)`: writes cancel NDJSON line to stdin. +6. On abnormal exit: crash synthesis takes over. + +#### Key Details + +- stdin is NOT closed after writing plan (cancel may follow). +- Client tracks state: `spawned -> plan_sent -> validation_received -> streaming -> result_received | crashed`. +- Timeout enforced on TS side: SIGTERM, brief wait, then SIGKILL. +- Stderr captured separately for diagnostics, not part of NDJSON protocol. + +### crash-synthesis.ts + +When Rust exits without emitting a result message, TS synthesizes one. + +#### Case 1: Validation was received + +Preserve the last-known effective state from validation: +- `effectiveNetwork` = validation's effectiveState.network +- `terminalState` = "supervisor_crash" +- `workspaceModified` = true (assume worst case: execution started) +- `cleanupSucceeded` = false + +#### Case 2: No validation received + +Use conservative fallback: +- `effectiveNetwork.actual` = "full" (assume worst case) +- `effectiveNetwork.enforcement` = "none" +- `effectiveNetwork.degraded` = true +- `terminalState` = "supervisor_crash" +- `workspaceModified` = false (execution likely never started) +- `cleanupSucceeded` = false + +The difference in `workspaceModified` reflects whether execution likely occurred. + +--- + +## Section 4: Session Manager, Profiles, Runtime Bases & Reconciler + +### Session Directory Layout + +```text +~/.local/share/pi-sandbox/sessions// + workspace/ # project files, bind-mounted rw into sandbox + artifacts/ # build outputs, logs + logs/ # execution NDJSON transcripts + tmp/ # sandbox tmpdir, cleaned between runs + home/ # sandbox $HOME, persists across runs in a session + cache/ # package manager caches (npm, pip, cargo) +``` + +### Session Record + +Persisted as `session.json` in the session directory: + +```typescript +interface SessionRecord { + sessionId: string; + state: "active" | "idle" | "recovered" | "tombstoned"; + createdAt: string; + lastActiveAt: string; + runtimeBaseName: string; + runtimeBaseFingerprint: string; + policyHash: string; + activeExecution: { + executionId: string; + pid: number; + startedAt: string; + profileName: string; + } | null; + lastHeartbeat: string | null; +} +``` + +### Session Manager Responsibilities + +- Create and manage session directories. +- Generate MountManifest from session + profile + runtime base. +- Track execution start/finish in session record. +- Clean tmp directories between runs. +- Tombstone old sessions. +- **Never** construct Bubblewrap arguments (that is Rust's job). +- **Never** expose arbitrary host paths to the model. + +### Mount Manifest Generation + +Combines session directories (writable) + runtime base paths (read-only) + profile config: + +```typescript +interface MountManifest { + mounts: Mount[]; + env: Record; + cwd: string; +} +``` + +### Profile Registry (profiles.ts) + +Profiles are named policy presets. Hardcoded map for v1. + +```typescript +interface Profile { + name: string; + description: string; + network: { mode: "off" | "full" | "allowlist" }; + bundles: string[]; + resourceLimits?: ResourceLimits; + allowedWritableTargets: string[]; + strictWritePolicy: boolean; + namespaces: string[]; + envAllowlist: string[]; + denyCommands: string[]; +} +``` + +#### v1 Profiles + +| Profile | Network | Bundles | Writable Targets | +|---------|---------|---------|------------------| +| `offline-review` | off | core, git | /workspace, /tmp | +| `strict` | off | core | /workspace, /tmp | +| `build-install` | full | core, git, node, python, rust | /workspace, /home, /cache, /tmp | +| `debug-network` | full | core, git, node, python | /workspace, /home, /cache, /tmp | + +Default profile: `build-install`. + +### Runtime Bases (runtime-base.ts) + +v1 uses only `HostDerivedBase`. Assembles read-only mounts from host filesystem based on named bundles. + +```typescript +interface RuntimeBase { + name: string; + fingerprint: string; + resolveBundleMounts(bundles: string[]): Mount[]; +} +``` + +Bundle registry maps bundle names to host paths: +- `core`: /usr/bin, /usr/lib, /lib, /lib64, /etc/resolv.conf, /etc/hosts +- `certs`: /etc/ssl/certs/ca-certificates.crt +- `git`, `node`, `python`, `rust`: Dynamically resolved from `which` at session creation time. + +Fingerprint = hash of all resolved paths + their mtimes. + +No "mirror the host" profile. Every path is explicitly listed. + +### Reconciler (reconciler.ts) + +Runs once during Pi extension `session_start` event. + +#### Flow + +1. Scan all session.json files in the sessions directory. +2. For each session with `state = "active"`: + a. Check if `activeExecution.pid` is still running. + b. If running: SIGTERM, wait, SIGKILL if needed. + c. Mark session as "recovered". + d. Clean tmp/. + e. Log recovery action. +3. Sessions with `state = "recovered"` older than 7 days: mark as "tombstoned". +4. Return list of recovered sessions for user notification. + +#### What the reconciler does NOT do + +- Does not delete workspaces (preserved by default). +- Does not re-run failed executions (agent's decision). +- Does not interpret what happened (just kills orphans and marks state). + +--- + +## Section 5: Pi Extension Tools & Wiring + +### Extension Entry Point (index.ts) + +Exports an `ExtensionFactory` that Pi loads. On `session_start`: +- Initializes session manager. +- Runs reconciler, notifies user of recovered sessions. +- Registers all sandbox tools. + +On `session_shutdown`: +- Marks active sessions as idle. +- Cleans tmp directories. + +### Tool Definitions (extension.ts) + +Five tools registered via `pi.registerTool()` with TypeBox parameter schemas. + +#### sandbox_run + +Primary tool. Executes a command inside a sandboxed environment. + +Parameters: +- `command: string[]` — Command and arguments. +- `profile?: string` — Policy profile (default: build-install). +- `sessionId?: string` — Existing session ID (omit to create new). +- `timeout?: number` — Timeout in ms (default: 300000). + +Execution flow: +1. Resolve or create session. +2. Resolve profile. +3. Build mount manifest. +4. Construct plan message. +5. Spawn Rust runtime via runtime client. +6. Stream events back via onUpdate callback. +7. On result or crash: update session record, return formatted output. + +Return format to LLM: +``` +[sandbox:build-install] $ npm install +--- stdout --- +added 347 packages in 12.4s +--- stderr --- +npm warn deprecated inflight@1.0.6 +--- result --- +exit_code: 0 +duration: 12847ms +network: full (observed, 23 connections logged) +terminal_state: clean_exit +``` + +Policy warnings prepended when present. + +#### sandbox_read_file + +Parameters: `path` (relative to workspace root), `sessionId`, `encoding?`. + +Path safety: Joins relative path to workspace root, verifies resolved absolute path starts with workspace root. Rejects traversal attacks. + +#### sandbox_write_file + +Parameters: `path` (relative to workspace root), `content`, `sessionId`. + +Same path safety as read. Creates parent directories if needed. Tracks file in extension state as modified. + +#### sandbox_list_files + +Parameters: `path?` (relative, default: root), `sessionId`, `recursive?`. + +Standard directory listing with path traversal protection. + +#### sandbox_session_info + +Parameters: `sessionId?`. + +If provided: returns session record, profile, workspace contents summary. +If omitted: returns list of all sessions with state and last activity. + +### Extension State Persistence + +Uses `pi.appendEntry()` to persist in Pi session file: + +```typescript +interface SandboxExtensionState { + sandboxSessionId: string; + profile: string; + workspaceRoot: string; + readFiles: string[]; + modifiedFiles: string[]; + lastArtifacts: string[]; + recoveryStatus: "clean" | "recovered" | null; +} +``` + +Persisted after sandbox_run, sandbox_read_file, sandbox_write_file, and reconciler runs. + +Not persisted: manifests, runtime plans, full execution logs (those go in session logs/). + +### Event Streaming + +During sandbox_run, streamed events map to onUpdate calls: +- stdout/stderr: accumulated, periodically flushed as text. +- lifecycle "started": notification message. +- warnings: formatted with code and message. +- network events: logged but not streamed to LLM (too noisy). + +### Cancel Integration + +Pi's abort signal (Ctrl+C / ctx.abort()) triggers `handle.cancel()`, which sends the cancel NDJSON message. Rust gracefully terminates and emits final result with `killed_on_cancel`. + +--- + +## Section 6: Rust Runtime & Protocol Tests + +### Rust Runtime Stub (Phase 4) + +Implements full NDJSON protocol but executes commands directly without Bubblewrap. + +#### main.rs Flow + +1. Read one line from stdin, parse as Plan. +2. Validate the plan. +3. Write validation to stdout. +4. If validation failed: exit(0). +5. Emit lifecycle "started". +6. Spawn command as child process. +7. Stream stdout/stderr events. +8. Poll stdin for cancel (non-blocking, separate thread). +9. On child exit: emit lifecycle "exited". +10. Write result to stdout. +11. exit(0). + +Synchronous core. Uses `std::process::Command` and `std::io::BufRead`. Only concurrency: a thread polling stdin for cancel. + +#### contract.rs + +Serde structs with `#[serde(tag = "type")]` for envelope discriminant and `#[serde(rename_all = "camelCase")]` for JSON field names. Mirrors contract.ts exactly. + +#### validator.rs + +Validates plan fields: +- Version check (VERSION_MISMATCH on version != 1, early return with no effectiveState). +- Writable target check (RW_TARGET_NOT_ALLOWED for mounts outside allowedWritableTargets). +- Denied command check (COMMAND_DENIED). +- Builds effective state: resolves actual network state, namespaces applied, env applied. +- Emits ALLOWLIST_NOT_ENFORCED warning when allowlist mode degrades. + +Note: on VERSION_MISMATCH, effectiveState is null (Rust cannot interpret the plan). On other validation failures, effectiveState is still populated (the plan was parseable, just invalid). + +#### supervisor.rs + +Stub phase: runs command directly via `std::process::Command`. + +Phase 8: replaces `Command::new(&plan.command[0])` with `Command::new("bwrap").args(bubblewrap_argv)`. All streaming, cancel, and result logic stays identical. + +Cancel handling: separate thread with `Receiver<()>`. On cancel received, emits "cancel_requested" lifecycle, kills process tree, waits for exit. + +#### observer.rs + +Stub phase: returns empty connection lists. + +Phase 10: will use /proc/net/tcp or ss to log outbound connections. + +`compute_would_have_blocked()` works even in stub phase: filters observed connections against allowlist. + +### Protocol Tests + +All written in TypeScript using vitest. Spawn compiled Rust binary as subprocess. + +#### Test Infrastructure + +- `helpers.ts`: `spawnRuntime()` returns TestRuntime with typed NDJSON I/O. `makePlan()` returns valid default plan with override support. +- `globalSetup.ts`: Runs `cargo build --release`, sets `RUNTIME_BINARY_PATH` env var. + +#### Test 1: Version Mismatch + +Send plan with version 99. Assert: validation.ok = false, VERSION_MISMATCH error, Rust exits cleanly. + +#### Test 2: Validation Failure + +Send plan with writable mount /evil not in allowedWritableTargets. Assert: validation.ok = false, RW_TARGET_NOT_ALLOWED error, no execution. + +#### Test 3: Successful Run + +Run `echo hello`. Assert: validation.ok = true, "started" lifecycle event, stdout contains "hello", result.exitCode = 0, terminalState = clean_exit, sequence numbers strictly increase. + +#### Test 4: Cancel Flow + +Run `sleep 3600`, cancel after "started" event. Assert: "cancel_requested" lifecycle event, terminalState = killed_on_cancel. + +#### Test 5: Crash Synthesis (TS-only) + +Directly calls synthesizeCrashResult() with both cases: +- With validation: preserves effective network, workspaceModified = true. +- Without validation: conservative fallback, workspaceModified = false. + +#### Test 6: Degraded Allowlist + +Send plan with network.mode = "allowlist". Assert: ALLOWLIST_NOT_ENFORCED warning, effectiveState.network.actual = "full", degraded = true, enforcement = "observed". + +--- + +## Migration Phases (from Handoff Document) + +| Phase | Description | Gate | +|-------|-------------|------| +| 0 | Freeze and branch | Tag current state | +| 1 | Bootstrap packages | Directory layout created | +| 2 | Commit frozen contracts | contract.ts + contract.rs committed | +| 3 | Build TS runtime client | Subprocess spawn + NDJSON I/O works | +| 4 | Build Rust stub runtime | Parses plan, validates, streams, emits result | +| 5 | Protocol tests | All 6 tests pass | +| 6 | Session manager + profiles | Session dirs, manifests, profiles, reconciler skeleton | +| 7 | Pi extension v1 | 5 tools wired to session manager + runtime client | +| 8 | Bubblewrap integration | Real isolation replaces stub execution | +| 9 | Real build/install flows | Node/Python repos verified | +| 10 | Network observation | Observe-only reporting, wouldHaveBlocked | +| 11 | Deprecate legacy server | Remove skills/factory/TEE, then server | +| 12 | Phase 2 capabilities | Browser, real allowlist, Nix runtime bases | + +## Engineering Rules + +- Do not refactor the old server in place. +- Do not keep HTTP as the primary interface. +- Do not allow arbitrary host path access from the model. +- Do not let RuntimePlan cross the TS/Rust boundary. +- Do not fake allowlist enforcement. +- Do not start with Nix runtime bases. +- Do not bring browser into the v1 critical path. + +## Definition of Done for v1 + +- Pi can launch sandboxed executions locally. +- Build/install works in representative repos. +- All 6 protocol tests pass. +- Session manager and reconciler work across restart/crash. +- Writable targets are enforced. +- Requested vs effective policy is surfaced honestly. +- Unrestricted network use is visible. +- Legacy skills/factory/TEE removed from main runtime path. +- Browser is clearly deferred, not half-implemented. diff --git a/docs/superpowers/specs/2026-04-06-pi-sandbox-phases-11-12-design.md b/docs/superpowers/specs/2026-04-06-pi-sandbox-phases-11-12-design.md new file mode 100644 index 0000000..9ba0e5c --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-pi-sandbox-phases-11-12-design.md @@ -0,0 +1,315 @@ +# Pi Sandbox Phases 11-12 Design Spec + +**Date:** 2026-04-06 +**Branch:** `pi-sandbox-refactor` +**Prerequisite:** Phases 0-10 complete (tag `v1-phases-8-10-complete`) + +--- + +## Overview + +Phase 11 removes the legacy `sandbox-rs/` Axum REST server. Phase 12 adds two Phase 2 capabilities: session-based browser automation (Playwright, TS-side) and real allowlist network enforcement (iptables inside bwrap network namespace, Rust-side). Nix runtime bases are deferred to a future phase. + +--- + +## Key Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Legacy server removal | Hard delete `sandbox-rs/` | `v0-legacy-server` tag preserves history; dead code adds confusion | +| Browser automation | TS-side Playwright | Browser doesn't benefit from namespace isolation; Pi already has TS tool infra | +| Browser tool shape | Single `sandbox_browser` tool with sub-commands | Session-scoped persistent page state; reduces tool registration noise | +| Allowlist enforcement | iptables inside bwrap network namespace | Kernel-level enforcement for all protocols; standard Linux approach | +| DNS resolution | Pre-resolve at plan time in validator | Bounded problem; avoids DNS inside sandbox | +| Nix runtime bases | Deferred | High complexity, low immediate value; `RuntimeBase` interface already supports future swap | +| Testing pattern | Same as Phases 8-10 | Linux-only enforcement tests, macOS degradation tests, optional env-gated smoke tests | + +--- + +## Phase 11: Legacy Server Deprecation + +### What Gets Deleted + +The entire `sandbox-rs/` directory (~1500 lines): + +| Path | Description | +|---|---| +| `sandbox-rs/src/handlers/shell.rs` | Shell execution handler | +| `sandbox-rs/src/handlers/code.rs` | Code execution handler | +| `sandbox-rs/src/handlers/file.rs` | File operations handler | +| `sandbox-rs/src/handlers/browser.rs` | Browser route handler | +| `sandbox-rs/src/handlers/skills.rs` | Skills CRUD handler | +| `sandbox-rs/src/handlers/factory.rs` | Factory/session dialogue handler | +| `sandbox-rs/src/handlers/tee.rs` | TEE operations handler | +| `sandbox-rs/src/handlers/health.rs` | Health check handler | +| `sandbox-rs/src/handlers/mod.rs` | Handler module index | +| `sandbox-rs/src/browser/` | BrowserService (chromiumoxide) | +| `sandbox-rs/src/skills/` | Skill registry and factory | +| `sandbox-rs/src/tee/` | TEE client | +| `sandbox-rs/src/main.rs` | Axum router and startup | +| `sandbox-rs/src/state.rs` | Shared app state | +| `sandbox-rs/src/config.rs` | Server configuration | +| `sandbox-rs/src/error.rs` | Error types | +| `sandbox-rs/Cargo.toml` | Heavy dependency tree (axum, tokio, chromiumoxide, etc.) | + +### What Changes + +- Root `Cargo.toml`: remove `sandbox-rs` from workspace members. +- Root `Cargo.lock`: regenerated without sandbox-rs dependencies. + +### What Stays Untouched + +- `crates/pi-sandbox-runtime/` -- the new Rust runtime +- `packages/pi-sandbox-extension/` -- the new TS extension +- `tests/` -- all protocol and integration tests + +### Verification Gate + +After deletion: +1. `cargo build -p pi-sandbox-runtime` succeeds +2. All protocol tests pass (`npm test` in `tests/protocol/`) +3. All integration tests pass (`npm test` in `tests/integration/`) + +--- + +## Phase 12a: Session-Based Browser Tool + +### Architecture + +Browser automation lives entirely in the TS extension. No NDJSON protocol changes. The browser runs on the host, outside bwrap -- browser automation does not benefit from namespace isolation. + +``` +Pi agent + -> sandbox_browser({ sessionId, action: "goto", url: "..." }) + -> Pi extension (BrowserManager) + -> Playwright browser context (persistent per session) +``` + +### New Files + +| File | Responsibility | +|---|---| +| `packages/pi-sandbox-extension/src/browser.ts` | BrowserManager class: lifecycle, session-scoped page management | + +### Modified Files + +| File | Change | +|---|---| +| `packages/pi-sandbox-extension/src/extension.ts` | Register `sandbox_browser` tool, wire BrowserManager | +| `packages/pi-sandbox-extension/src/session-manager.ts` | Session cleanup calls `BrowserManager.closePage(sessionId)` | +| `packages/pi-sandbox-extension/package.json` | Add `playwright-core` dependency | + +### BrowserManager Design + +```typescript +class BrowserManager { + // Lazy-initialized Chromium instance (shared across sessions) + private browser: Browser | null = null; + + // One persistent page per sandbox session + private pages: Map = new Map(); + + async getOrCreatePage(sessionId: string): Promise; + async closePage(sessionId: string): Promise; + async shutdown(): Promise; +} +``` + +- **Lazy launch:** Browser starts on first `sandbox_browser` call, not at extension init. +- **Session-scoped pages:** Each sandbox session gets one persistent page. Navigation, clicks, and evaluations operate on the same page -- like a real browsing session. +- **Cleanup:** `closePage()` is called when a session is torn down or reconciled after crash. `shutdown()` closes the browser entirely. + +### Tool Interface + +Single tool: `sandbox_browser` + +Parameters: +- `sessionId: string` -- sandbox session to operate within +- `action: "goto" | "screenshot" | "evaluate" | "click" | "type" | "close"` +- `url?: string` -- for `goto` +- `selector?: string` -- for `click` and `type` +- `text?: string` -- for `type` +- `script?: string` -- for `evaluate` + +Return values by action: +- `goto` -- page title + truncated text content +- `screenshot` -- base64 PNG string +- `evaluate` -- JSON-serialized result +- `click` -- confirmation string +- `type` -- confirmation string +- `close` -- confirmation string + +### Dependency + +`playwright-core` (not `playwright`). Uses `playwright-core` to avoid bundling browser binaries. Expects Chromium to be available on the host via `PLAYWRIGHT_CHROMIUM_PATH` env var or system-installed Chrome/Chromium. + +### Testing + +- Unit tests for BrowserManager lifecycle (launch, getOrCreatePage, closePage, shutdown) +- Integration test: `sandbox_browser` goto + screenshot on a local HTML fixture +- Test that session cleanup closes browser pages + +--- + +## Phase 12b: Real Allowlist Network Enforcement + +### Architecture + +When bwrap is available and creates a network namespace (`--unshare-net`), the runtime injects iptables rules to enforce the allowlist at the kernel level. On platforms without bwrap, behavior remains degraded (unchanged from v1). + +``` +Plan arrives with policy.network.mode = "allowlist" + -> Validator resolves hostnames to IPs (DNS pre-resolution) + -> plan_builder generates iptables wrapper script + -> bwrap --unshare-net runs wrapper script + -> wrapper: iptables -P OUTPUT DROP + -> wrapper: iptables -A OUTPUT -d -j ACCEPT (per host) + -> wrapper: iptables -A OUTPUT -o lo -j ACCEPT + -> wrapper: iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT + -> wrapper: exec + -> Observer cross-checks: wouldHaveBlocked should be empty +``` + +### DNS Pre-Resolution + +The validator resolves each hostname in `policy.network.allowlist` to IP addresses before execution begins. + +Rules: +- Resolution uses system DNS (std::net in Rust) +- Each hostname may resolve to multiple IPs; all are allowed +- If a hostname fails to resolve: emit `DNS_RESOLUTION_PARTIAL` warning, skip that host +- If ALL hostnames fail to resolve: degrade to `actual=full, enforcement=observed`, emit `ALLOWLIST_DNS_FAILED` warning +- Resolved IPs are stored in `EffectiveState.resolved_allowlist: Vec` + +```rust +pub struct ResolvedAllowlistEntry { + pub hostname: String, + pub ips: Vec, + pub resolved: bool, +} +``` + +### Effective Network States + +| Requested | Bwrap? | Net NS? | DNS OK? | Actual | Enforcement | Degraded | +|---|---|---|---|---|---|---| +| `allowlist` | Yes | Yes | Yes | `allowlist` | `enforced` | `false` | +| `allowlist` | Yes | Yes | All fail | `full` | `observed` | `true` | +| `allowlist` | Yes | No | Any | `full` | `observed` | `true` | +| `allowlist` | No | N/A | Any | `full` | `observed` | `true` | +| `off` | Yes | Yes | N/A | `off` | `enforced` | `false` | +| `off` | No | N/A | N/A | `off` | `best_effort` | `true` | +| `full` | Any | Any | N/A | `full` | `observed` | `false` | + +### Modified Files + +| File | Change | +|---|---| +| `crates/pi-sandbox-runtime/src/contract.rs` | Add `ResolvedAllowlistEntry`, add `resolved_allowlist` to `EffectiveState` | +| `crates/pi-sandbox-runtime/src/validator.rs` | DNS resolution logic, new effective states for enforced allowlist | +| `crates/pi-sandbox-runtime/src/plan_builder.rs` | Generate iptables wrapper script when allowlist is enforced | +| `crates/pi-sandbox-runtime/src/supervisor.rs` | Pass resolved allowlist through; detect enforcement leaks via observer cross-check | + +### Wrapper Script Generation + +When `effective_state.network.actual == "allowlist"` and bwrap is available, `plan_builder.rs` generates a wrapper shell script: + +```bash +#!/bin/sh +set -e +iptables -P OUTPUT DROP +iptables -A OUTPUT -o lo -j ACCEPT +iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT +iptables -A OUTPUT -d 93.184.216.34 -j ACCEPT +iptables -A OUTPUT -d 2606:2800:220:1:... -j ACCEPT +# ... one rule per resolved IP +exec "$@" +``` + +The plan builder writes this to a temp file inside the sandbox and adjusts the bwrap command to run the wrapper with the user command as arguments. + +**Prerequisite mount:** The wrapper script requires `iptables` to be available inside the sandbox. `plan_builder.rs` must add a read-only bind mount for the iptables binary (resolved via `which iptables` on the host) when generating an allowlist-enforced plan. If iptables is not found on the host, the validator degrades allowlist to `full/observed` and emits an `IPTABLES_NOT_FOUND` warning. + +### Observer Cross-Check + +The existing `/proc/net/tcp` observer continues running during allowlist-enforced executions. After execution: +- `wouldHaveBlocked` is computed as before +- If enforcement was `enforced` and `wouldHaveBlocked` is non-empty, emit an `ENFORCEMENT_LEAK` warning in the result -- this indicates an iptables race condition or misconfiguration +- This is a safety net, not the enforcement mechanism + +### New Warning Codes + +| Code | When | +|---|---| +| `DNS_RESOLUTION_PARTIAL` | Some allowlist hostnames failed to resolve | +| `ALLOWLIST_DNS_FAILED` | All allowlist hostnames failed to resolve; degraded to full | +| `ENFORCEMENT_LEAK` | Observer saw a connection that should have been blocked by iptables | +| `IPTABLES_NOT_FOUND` | iptables binary not found on host; allowlist degraded to full/observed | + +### Testing + +Protocol tests (same pattern as Phases 8-10): +- **Linux test:** allowlist with bwrap -> enforcement=enforced, attempt blocked connection -> connection fails +- **macOS test:** existing degraded-allowlist test already covers this (enforcement=observed, degraded=true) +- **Optional smoke test:** `RUN_ALLOWLIST_TESTS=1` env var gates a test that verifies real iptables enforcement with external host + +--- + +## File Map (All Changes) + +### Phase 11 -- Delete + +| Path | Action | +|---|---| +| `sandbox-rs/` (entire directory) | Delete | +| Root `Cargo.toml` | Remove sandbox-rs from workspace members | + +### Phase 12a -- New/Modified (Browser) + +| Path | Action | +|---|---| +| `packages/pi-sandbox-extension/src/browser.ts` | Create | +| `packages/pi-sandbox-extension/src/extension.ts` | Modify (add sandbox_browser tool) | +| `packages/pi-sandbox-extension/src/session-manager.ts` | Modify (browser cleanup on session teardown) | +| `packages/pi-sandbox-extension/package.json` | Modify (add playwright-core) | +| `tests/extension/browser.test.ts` | Create | + +### Phase 12b -- Modified (Allowlist Enforcement) + +| Path | Action | +|---|---| +| `crates/pi-sandbox-runtime/src/contract.rs` | Modify (ResolvedAllowlistEntry, resolved_allowlist) | +| `crates/pi-sandbox-runtime/src/validator.rs` | Modify (DNS resolution, enforced allowlist states) | +| `crates/pi-sandbox-runtime/src/plan_builder.rs` | Modify (iptables wrapper script generation) | +| `crates/pi-sandbox-runtime/src/supervisor.rs` | Modify (enforcement leak detection) | +| `tests/protocol/allowlist-enforced.test.ts` | Create | + +--- + +## What Is NOT in This Spec + +- **Nix runtime bases** -- Deferred. `RuntimeBase` interface already supports future `NixComposedBase`. +- **TEE support** -- Removed in Phase 11, not replaced. +- **Skills/Factory** -- Removed in Phase 11, not replaced. Pi's native tool system replaces these. +- **Resource limits enforcement** -- `ResourceLimits` exists in the contract but enforcement (cgroups) is deferred. +- **Browser inside bwrap** -- Explicitly not done. Browser runs on host. + +--- + +## Phase Gates + +| Gate | Criteria | +|---|---| +| Phase 11 complete | `sandbox-rs/` deleted, `cargo build` succeeds, all existing tests pass | +| Phase 12a complete | `sandbox_browser` tool works with goto/screenshot/evaluate/click/type/close, browser tests pass | +| Phase 12b complete | Allowlist enforcement works on Linux with iptables, DNS resolution in validator, observer cross-check, enforcement tests pass on Linux, degradation tests pass on macOS | + +--- + +## Engineering Rules (Carried from Spec A) + +1. **Truthfulness** -- Never claim enforcement not actually applied. +2. **Platform honesty** -- macOS reports degraded; Linux reports enforced only when kernel mechanisms are active. +3. **No protocol changes for browser** -- Browser is entirely TS-side. +4. **Test gating** -- Linux-only tests skip on macOS. Optional smoke tests behind env vars. +5. **YAGNI** -- No Nix bases, no cgroups, no browser-in-bwrap. diff --git a/docs/superpowers/specs/2026-04-08-docker-fallback-macos-design.md b/docs/superpowers/specs/2026-04-08-docker-fallback-macos-design.md new file mode 100644 index 0000000..9cab190 --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-docker-fallback-macos-design.md @@ -0,0 +1,308 @@ +# Docker Fallback for macOS — Design Spec + +**Date:** 2026-04-08 +**Branch:** `pi-sandbox-refactor` +**Prerequisite:** Phases 0-12 complete (tag `v1-phases-11-12-complete`) + +--- + +## Overview + +On macOS, bwrap is unavailable because it depends on Linux kernel namespaces (`unshare(2)`). Today, the runtime degrades to running commands directly on the host with no isolation. This spec adds a Docker-based fallback: when macOS is detected and Docker Desktop is available, the runtime starts a lightweight Linux sidecar container and runs bwrap inside it, giving macOS users real kernel-level isolation. + +--- + +## Key Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Container lifecycle | Hybrid sidecar — long-running container, bwrap isolates per-execution | Container is just a Linux kernel host; bwrap provides per-execution isolation. Fast startup (~50ms per exec). | +| Image strategy | Minimal debian-slim base + Rust binary mounted in | No cross-compile toolchain needed on host. Image is small (~150-200MB). Binary updates are instant. | +| Host↔container IPC | `docker exec -i` into sidecar, NDJSON over stdio | Same protocol as native bwrap. RuntimeClient barely changes. | +| Workspace mounting | Broad mount of pi-sandbox sessions directory | Sessions dir is sandbox-managed. Avoids per-session container restarts. | +| Base image contents | Debian-slim + bwrap + iptables + python3 + node + git + curl | Matches build-install profile expectations. Covers common sandboxed commands. | +| Missing Docker | Silent degradation with `DOCKER_NOT_AVAILABLE` warning + `PI_SANDBOX_NO_DOCKER=1` opt-out | Protocol already supports degradation warnings. Opt-out for CI or users who prefer no Docker. | + +--- + +## Detection Chain + +The detection order in `bubblewrap.rs` becomes: + +1. `PI_SANDBOX_NO_DOCKER=1` set? → skip Docker, go to step 4 +2. Linux? → check for bwrap binary (existing logic) → `Available` or `Unavailable` +3. macOS? → check `docker info` succeeds + - Yes → start/find sidecar container → `DockerAvailable { container_id, host_sessions_dir, container_sessions_dir }` + - No → `Unavailable { "Docker not found" }` with `DOCKER_NOT_AVAILABLE` warning +4. Other platform → `Unavailable` + +### BwrapAvailability Enum + +```rust +pub enum BwrapAvailability { + Available { path: PathBuf }, + DockerAvailable { + container_id: String, + host_sessions_dir: String, + container_sessions_dir: String, + }, + Unavailable { reason: String }, +} +``` + +`DockerAvailable` carries the path mapping so the supervisor can rewrite host paths to container paths without additional lookups. + +--- + +## Sidecar Container Lifecycle + +### Container Name + +`pi-sandbox-sidecar` — well-known name for detection and lifecycle management. + +### Startup (lazy, on first detection) + +``` +detect() on macOS: + 1. docker ps --filter name=pi-sandbox-sidecar --format '{{.ID}}' + → running? return DockerAvailable + 2. docker ps -a --filter name=pi-sandbox-sidecar --format '{{.ID}}' + → exists but stopped? docker start pi-sandbox-sidecar → return DockerAvailable + 3. doesn't exist? + a. Ensure image exists (docker images pi-sandbox-base:latest) + → missing? docker build -t pi-sandbox-base:latest -f docker/pi-sandbox-sidecar.Dockerfile . + b. Ensure Linux runtime binary exists + → missing? docker run --rm -v :/src -v :/out pi-sandbox-base:latest + sh -c "apt-get update && apt-get install -y cargo && cd /src && cargo build --release && cp target/release/pi-sandbox-runtime /out/" + c. docker run -d --name pi-sandbox-sidecar \ + --cap-add SYS_ADMIN --cap-add NET_ADMIN \ + -v :/pi-sandbox \ + -v :/usr/local/bin/pi-sandbox-runtime:ro \ + pi-sandbox-base:latest \ + sleep infinity + → return DockerAvailable +``` + +### Shutdown + +- **Extension shutdown** (`session_shutdown` event): `docker stop pi-sandbox-sidecar` with 10s grace period. Container is NOT removed — next session reuses it. +- **Manual cleanup**: `docker rm -f pi-sandbox-sidecar` to reclaim resources. + +### Crash Recovery + +If `docker exec` fails because the container died: +1. Detect failure (non-zero exit + specific error pattern) +2. Restart sidecar: `docker start pi-sandbox-sidecar` or recreate if container was removed +3. Retry the execution once +4. If retry fails, degrade to `Unavailable` and run naked with `DOCKER_SIDECAR_FAILED` warning + +### Security: No `--privileged` + +The sidecar uses `--cap-add SYS_ADMIN --cap-add NET_ADMIN` instead of `--privileged`. These are the minimum capabilities needed for: +- `SYS_ADMIN`: bwrap's `unshare(2)` calls (mount, pid, uts, ipc namespaces) +- `NET_ADMIN`: `--unshare-net` and iptables rule injection + +--- + +## Supervisor Execution Path + +The supervisor gains a third branch: + +```rust +match bwrap { + BwrapAvailability::Available { path } => { + // Linux: existing bwrap path (unchanged) + } + BwrapAvailability::DockerAvailable { container_id, host_sessions_dir, container_sessions_dir } => { + // macOS+Docker: rewrite paths, docker exec bwrap + let rewritten_plan = rewrite_paths(&plan, &host_sessions_dir, &container_sessions_dir); + let argv = plan_builder::build_with_allowlist(&rewritten_plan, effective_state, iptables_path); + let mut cmd = Command::new("docker"); + cmd.args(["exec", "-i", &container_id, "bwrap"]); + cmd.args(&argv); + cmd + } + BwrapAvailability::Unavailable { .. } => { + // No isolation: existing naked execution (unchanged) + } +} +``` + +### Key properties + +- **`plan_builder.rs` is unchanged.** It produces bwrap argv. The supervisor just prefixes it with `docker exec`. +- **`-i` flag** keeps stdin open for cancel messages over NDJSON. +- **No `-t` flag** — no TTY. We're piping structured data. +- **Observer works.** The Rust runtime (supervisor) runs on the macOS host. On macOS, the observer is a no-op (`#[cfg(not(target_os = "linux"))]`). This is acceptable because bwrap+iptables inside the container provides kernel-level enforcement — the observer is a safety net, not the enforcement mechanism. +- **iptables wrapper script** is written to the host temp dir, then bind-mounted into the container via the existing sessions dir mount (write it to `/tmp/`). + +--- + +## Path Rewriting + +Session manager creates paths like: +``` +/Users/hashwarlock/.local/share/pi-sandbox/sessions//workspace +``` + +Sidecar mounts as: +``` +-v /Users/hashwarlock/.local/share/pi-sandbox:/pi-sandbox +``` + +Inside the container: +``` +/pi-sandbox/sessions//workspace +``` + +### Rewrite function + +```rust +fn rewrite_host_to_container( + host_path: &str, + host_sessions_dir: &str, + container_sessions_dir: &str, +) -> String { + host_path.replacen(host_sessions_dir, container_sessions_dir, 1) +} +``` + +### Applied to + +- `manifest.mounts[].source` — for directory/file bind mounts +- `manifest.cwd` — working directory +- iptables wrapper script path + +### plan_builder stays untouched + +The supervisor clones the plan, rewrites paths in the clone, passes the rewritten plan to plan_builder. The original plan is preserved for the result envelope. + +--- + +## Docker Image + +### Dockerfile: `docker/pi-sandbox-sidecar.Dockerfile` + +```dockerfile +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bubblewrap \ + iptables \ + python3 \ + nodejs \ + git \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* +``` + +### Image build strategy + +The sidecar detection code checks if `pi-sandbox-base:latest` exists locally. If not, it builds from the Dockerfile. This is a one-time ~30 second cost. + +### Rust binary build strategy + +The Rust binary must be a Linux binary (not macOS). Two-stage process: +1. Check for cached Linux binary at `/target/docker-linux/pi-sandbox-runtime` +2. If missing, build inside Docker: mount the crate source into a container, run `cargo build --release`, copy the binary out to the cache path +3. Sidecar mounts the cached binary at `/usr/local/bin/pi-sandbox-runtime:ro` + +Binary rebuilds happen when the user explicitly rebuilds or when the cached binary is missing. The binary update is instant for the sidecar — next `docker exec` picks it up. + +--- + +## Effective States + +The validator treats `DockerAvailable` identically to `Available`. Bwrap is genuinely available inside the container. + +| Requested | Docker? | Actual | Enforcement | Degraded | +|-----------|---------|--------|-------------|----------| +| `off` | Yes | `off` | `enforced` | `false` | +| `full` | Yes | `full` | `observed` | `false` | +| `allowlist` | Yes + iptables | `allowlist` | `enforced` | `false` | +| Any | No Docker | (same as today's macOS behavior) | `best_effort`/`observed` | `true` | + +### New field: `isolationBackend` + +Added to `EffectiveState` for observability: + +```rust +pub isolation_backend: String, // "native" | "docker" | "none" +``` + +- `"native"` — Linux with bwrap directly +- `"docker"` — macOS with Docker sidecar + bwrap +- `"none"` — no isolation (naked execution) + +Purely informational. Not a policy input. + +### New warning codes + +| Code | When | +|------|------| +| `DOCKER_NOT_AVAILABLE` | macOS, Docker not found, degrading to naked execution | +| `DOCKER_SIDECAR_RESTARTED` | Sidecar was dead, successfully restarted before execution | + +--- + +## File Map + +### New Files + +| Path | Responsibility | +|------|----------------| +| `docker/pi-sandbox-sidecar.Dockerfile` | Debian-slim image with bwrap + iptables + common tools | +| `crates/pi-sandbox-runtime/src/docker.rs` | Docker detection, sidecar lifecycle, image build, binary build | + +### Modified Files + +| Path | Change | +|------|--------| +| `crates/pi-sandbox-runtime/src/bubblewrap.rs` | Add `DockerAvailable` variant, update `detect()` to try Docker on macOS | +| `crates/pi-sandbox-runtime/src/supervisor.rs` | Add `DockerAvailable` execution branch with path rewriting, sidecar crash recovery | +| `crates/pi-sandbox-runtime/src/validator.rs` | Treat `DockerAvailable` same as `Available` | +| `crates/pi-sandbox-runtime/src/contract.rs` | Add `isolation_backend` to `EffectiveState`, add warning codes | +| `crates/pi-sandbox-runtime/src/main.rs` | Add `mod docker;` | +| `packages/pi-sandbox-extension/src/contract.ts` | Add `isolationBackend` to EffectiveState schema, add warning codes | + +### New Test Files + +| Path | Responsibility | +|------|----------------| +| `tests/protocol/docker-sidecar.test.ts` | Docker sidecar lifecycle and execution tests (env-gated) | + +--- + +## Testing + +- **Path rewriting unit tests** — pure function, runs on any platform +- **Docker sidecar lifecycle tests** — gated behind `RUN_DOCKER_TESTS=1`. Tests start, detect, stop, restart-on-crash. +- **Docker execution integration test** — gated behind `RUN_DOCKER_TESTS=1`. Runs echo through Docker+bwrap, verifies `enforcement: "enforced"`, `isolationBackend: "docker"`. +- **Existing tests unchanged** — Linux native bwrap tests unaffected. macOS degradation tests without Docker still pass as before. + +--- + +## What Is NOT in This Spec + +- **Cross-compilation toolchain on macOS** — Uses Docker to build the Linux binary instead +- **Custom image registry / image publishing** — Image is built locally from Dockerfile +- **Container resource limits** — cgroups inside Docker deferred (same as native cgroups) +- **Docker Compose integration** — Sidecar is managed programmatically by `docker.rs` +- **Observer inside Docker** — Observer remains a macOS no-op. Enforcement is via bwrap+iptables inside the container. +- **Nix-based image** — Debian-slim keeps it simple. Nix image is an option for a future enhancement. + +--- + +## Phase Gate + +| Criteria | +|----------| +| Dockerfile builds successfully | +| Linux Rust binary builds inside Docker | +| Sidecar starts, stops, and recovers from crash | +| `sandbox_run` on macOS with Docker produces `enforcement: "enforced"` and `isolationBackend: "docker"` | +| `sandbox_run` on macOS without Docker still degrades gracefully (same as today + `DOCKER_NOT_AVAILABLE` warning) | +| `PI_SANDBOX_NO_DOCKER=1` skips Docker detection | +| All existing Linux and macOS tests continue to pass | +| Path rewriting unit tests pass on any platform | diff --git a/docs/superpowers/specs/2026-04-08-nix-flake-part-b-design.md b/docs/superpowers/specs/2026-04-08-nix-flake-part-b-design.md new file mode 100644 index 0000000..72cb6b1 --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-nix-flake-part-b-design.md @@ -0,0 +1,265 @@ +# Nix Flake Runtime — Part B Design Spec + +## Overview + +Part B of the nixosandbox Nix flake runtime redesign. Part A (complete) built the standalone CLI, Nix flake with mkSandboxRootfs, session management, and pivot-root bwrap execution. Part B delivers Docker sidecar support for macOS, legacy cleanup, and integration tests. + +## Goals + +1. **Docker sidecar with `/nix/store` mount** — macOS users get real bwrap sandboxing via Docker with Nix rootfs, no packages pre-installed in the container. +2. **Legacy cleanup** — Delete `legacy-ndjson` subcommand and all legacy NDJSON protocol inbound types. Enhance `exec --json` to emit full event stream. +3. **Integration tests** — Two independently-gated suites: Linux native (Nix + bwrap) and macOS Docker (Nix + Docker sidecar). Port valuable legacy tests, write new rootfs pipeline tests. + +## Out of Scope + +- Pi extension simplification (Part C) +- Network allowlist enforcement +- Browser tool +- Nix search fallback for package resolution + +--- + +## 1. Docker Sidecar Changes + +### 1.1 Dockerfile + +Strip `docker/pi-sandbox-sidecar.Dockerfile` to bare minimum. Rename to `docker/nixosandbox-sidecar.Dockerfile`: + +```dockerfile +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + bubblewrap iptables \ + && rm -rf /var/lib/apt/lists/* +``` + +All runtime packages come from the Nix rootfs via `--pivot-root`. The Dockerfile only provides bwrap (the sandbox primitive) and iptables (for future network enforcement). + +### 1.2 Container Creation + +`create_sidecar()` in `docker.rs` adds the Nix store volume mount: + +``` +docker run -d --name nixosandbox-sidecar \ + --cap-add SYS_ADMIN --cap-add NET_ADMIN \ + --security-opt seccomp=unconfined \ + -v /nix/store:/nix/store:ro \ + -v :/nixosandbox/sessions:rw \ + sleep infinity +``` + +Changes from current: +- Add `-v /nix/store:/nix/store:ro` — makes host Nix closures available inside container +- Container sessions dir changes from `/pi-sandbox` to `/nixosandbox/sessions` (matches new naming) +- Sidecar container name changes from `pi-sandbox-sidecar` to `nixosandbox-sidecar` +- Image name changes from `pi-sandbox-base:latest` to `nixosandbox-sidecar:latest` + +### 1.3 Path Rewriting Strategy + +Nix store paths are absolute and identical on host and container (`/nix/store/abc123-sandbox-strict`), so **rootfs paths need no rewriting**. + +Only session directory paths need translation: +- Host: `~/.local/share/nixosandbox/sessions//workspace` +- Container: `/nixosandbox/sessions//workspace` + +The existing `rewrite_path()` function in `docker.rs` handles this. Update it to use the new container sessions path. + +### 1.4 cmd_exec Docker Execution + +Replace the warning in `cmd_exec`'s Docker branch with real execution: + +1. Rewrite session directory paths (workspace, home, cache) from host to container paths using `docker.rs::rewrite_path()` +2. Build bwrap argv with `plan_builder::build_rootfs()` using the original Nix store rootfs path (no rewriting needed) +3. Construct command: `docker exec -i bwrap ` +4. Spawn and handle output (inherit stdio for interactive, pipe for --json) + +--- + +## 2. Legacy Cleanup + +### 2.1 Delete + +| Item | Location | +|------|----------| +| `LegacyNdjson` variant | `crates/nixosandbox/src/cli.rs` | +| `legacy_ndjson_main()` | `crates/nixosandbox/src/main.rs` | +| `InboundMessage` enum (Plan/Cancel types) | `crates/nixosandbox/src/contract.rs` | +| `ValidationEnvelope`, `ValidationPayload` | `crates/nixosandbox/src/contract.rs` (inbound-only types) | +| Legacy protocol tests | `tests/protocol/version-mismatch.test.ts`, `validation-failure.test.ts`, `degraded-allowlist.test.ts`, `network-observation.test.ts`, `allowlist-enforced.test.ts` | + +### 2.2 Keep + +| Item | Reason | +|------|--------| +| `ResultPayload`, `ResultEnvelope` types | Used by `exec --json` output | +| Streamed event types (stdout, stderr, lifecycle) | Used by `exec --json` output | +| `emit()` function | Used by `exec --json` to write NDJSON | +| `contract.ts` in Pi extension | Untouched (Part C) | +| `supervisor.rs` | Still used by `exec --json` for process supervision | +| `validator.rs` | Still used for plan validation in create/exec | +| `observer.rs` | Still used for network observation | + +### 2.3 Enhance exec --json + +The current `cmd_exec` JSON mode emits basic stdout events and a result. Enhance to emit the full event stream: + +- `lifecycle` event with `stage: "started"` when bwrap spawns +- `stdout` events (already present) +- `stderr` events (add — pipe stderr and stream as separate events) +- `lifecycle` event with `stage: "exited"` before result +- `result` with full payload: exitCode, signal, timedOut, durationMs + +Events use the existing `contract.rs` types and `emit()` function. Sequence numbers are strictly increasing across all event types. + +### 2.4 Supervisor Reuse + +The existing `supervisor::supervise()` function handles process spawning, NDJSON event streaming, cancel handling, and timeout logic. For `cmd_exec --json`, extract the bwrap spawning and NDJSON streaming into a shared function that both the new CLI path and any future callers can use. The supervisor builds a `Command`, spawns it, pipes stdout/stderr, emits events via `emit()`, and returns a `SuperviseResult`. The key change: instead of receiving a `PlanPayload` (legacy protocol type), the supervisor accepts a pre-built `Command` and configuration struct. + +### 2.5 Dead Code Cleanup + +After removing legacy inbound types, scan for unreferenced code in: +- `contract.rs` — Remove unused inbound message types, keep outbound types +- Any functions only called from `legacy_ndjson_main()` + +--- + +## 3. Integration Tests + +### 3.1 Directory Structure + +``` +tests/ + integration/ + vitest.config.ts + globalSetup.ts — cargo build --release, set NIXOSANDBOX_BINARY + helpers.ts — CLI wrapper functions + rootfs-pipeline.test.ts — Linux native (RUN_INTEGRATION_TESTS=1) + docker-rootfs.test.ts — Docker (RUN_DOCKER_TESTS=1) + protocol/ + vitest.config.ts — updated + globalSetup.ts — updated: cargo build, set binary path + helpers.ts — adapted: spawn exec --json, parse NDJSON + cancel-flow.test.ts — adapted from legacy + crash-synthesis.test.ts — adapted from legacy + docker-sidecar.test.ts — adapted for rootfs execution +``` + +### 3.2 Integration Test Helpers + +`tests/integration/helpers.ts` provides CLI wrapper functions: + +- `build(args)` — Spawns `nixosandbox build` with args, returns stdout and exit code +- `create(args)` — Spawns `nixosandbox create` with args, parses session ID or JSON +- `execCmd(sessionId, command, opts)` — Spawns `nixosandbox exec`, handles --json and --env +- `list(opts)` — Spawns `nixosandbox list`, parses table or JSON +- `destroy(sessionId)` — Spawns `nixosandbox destroy`, returns exit code + +Each function uses `execFile` (not shell execution) to spawn the binary safely. + +### 3.3 rootfs-pipeline.test.ts + +Gated: `RUN_INTEGRATION_TESTS=1` (requires Nix + bwrap on Linux). + +Tests: +1. **build strict profile** — `nixosandbox build --profile strict --json` returns a valid Nix store path +2. **create session** — `nixosandbox create --profile strict --json` returns session ID and metadata +3. **exec echo** — `nixosandbox exec -- echo hello` prints "hello", exits 0 +4. **exec verify rootfs** — `nixosandbox exec -- ls /` shows sandbox dirs (bin, etc, workspace), not host dirs +5. **exec verify sandbox user** — `nixosandbox exec -- cat /etc/passwd` contains "sandbox" user +6. **exec json mode** — `nixosandbox exec --json -- echo test` produces NDJSON with lifecycle, stdout, result events +7. **list sessions** — `nixosandbox list --json` shows the session +8. **destroy session** — `nixosandbox destroy ` succeeds, session no longer in list + +### 3.4 docker-rootfs.test.ts + +Gated: `RUN_DOCKER_TESTS=1` (requires Nix + Docker). + +Tests: +1. **create + exec through Docker** — Same as rootfs-pipeline tests 2-5 but on macOS with Docker sidecar +2. **verify Nix store accessible** — `nixosandbox exec -- ls /nix/store` succeeds (store is mounted) +3. **verify isolation backend** — JSON mode reports `isolationBackend: "docker"` in events + +### 3.5 Adapted Protocol Tests + +`tests/protocol/cancel-flow.test.ts`: +- Spawn `nixosandbox exec --json -- sleep 60` +- Send SIGTERM to the process +- Verify lifecycle events and result with terminal state + +`tests/protocol/crash-synthesis.test.ts`: +- Spawn `nixosandbox exec --json -- ` +- Kill the nixosandbox process (SIGKILL) mid-execution +- From the test's perspective, verify the process died without a result event +- (Crash synthesis responsibility moves to the consumer — if no result before exit, the consumer synthesizes) + +`tests/protocol/docker-sidecar.test.ts`: +- Rewrite to test rootfs execution through Docker +- Verify isolation backend, network enforcement, Nix store access + +### 3.6 Test Gating + +| Env Var | Suite | Requires | +|---------|-------|----------| +| `RUN_INTEGRATION_TESTS=1` | rootfs-pipeline | Nix, bwrap, Linux | +| `RUN_DOCKER_TESTS=1` | docker-rootfs, docker-sidecar | Nix, Docker | +| (neither) | cancel-flow, crash-synthesis | Just the binary | + +--- + +## 4. Naming Alignment + +Part A left some `pi-sandbox` naming artifacts. Part B cleans up: + +| Old | New | Location | +|-----|-----|----------| +| `pi-sandbox-sidecar` | `nixosandbox-sidecar` | Container name in docker.rs | +| `pi-sandbox-base:latest` | `nixosandbox-sidecar:latest` | Image name in docker.rs | +| `/pi-sandbox` | `/nixosandbox/sessions` | Container sessions dir in docker.rs | +| `PI_SANDBOX_NO_DOCKER` | `NIXOSANDBOX_NO_DOCKER` | Env var in bubblewrap.rs | +| `PI_SANDBOX_BWRAP_PATH` | `NIXOSANDBOX_BWRAP_PATH` | Env var in bubblewrap.rs | +| `pi-sandbox-sidecar.Dockerfile` | `nixosandbox-sidecar.Dockerfile` | Dockerfile name | +| `RUNTIME_BINARY_PATH` | `NIXOSANDBOX_BINARY` | Test env var | + +--- + +## 5. Files Changed + +### Modified +- `crates/nixosandbox/src/docker.rs` — Nix store mount, new naming, updated container paths +- `crates/nixosandbox/src/bubblewrap.rs` — Rename env vars +- `crates/nixosandbox/src/main.rs` — Delete legacy, wire Docker exec, enhance --json +- `crates/nixosandbox/src/cli.rs` — Remove LegacyNdjson +- `crates/nixosandbox/src/contract.rs` — Remove inbound types, keep outbound + +### Deleted +- `docker/pi-sandbox-sidecar.Dockerfile` (replaced by renamed version) +- `tests/protocol/version-mismatch.test.ts` +- `tests/protocol/validation-failure.test.ts` +- `tests/protocol/degraded-allowlist.test.ts` +- `tests/protocol/network-observation.test.ts` +- `tests/protocol/allowlist-enforced.test.ts` + +### Created +- `docker/nixosandbox-sidecar.Dockerfile` +- `tests/integration/vitest.config.ts` +- `tests/integration/globalSetup.ts` +- `tests/integration/helpers.ts` +- `tests/integration/rootfs-pipeline.test.ts` +- `tests/integration/docker-rootfs.test.ts` + +### Adapted +- `tests/protocol/globalSetup.ts` +- `tests/protocol/helpers.ts` +- `tests/protocol/cancel-flow.test.ts` +- `tests/protocol/crash-synthesis.test.ts` +- `tests/protocol/docker-sidecar.test.ts` + +### Untouched +- `flake.nix`, `nix/` — Nix flake and profiles +- `packages/pi-sandbox-extension/` — Deferred to Part C +- `crates/nixosandbox/src/spec.rs` — Sandbox spec types +- `crates/nixosandbox/src/session.rs` — Session management +- `crates/nixosandbox/src/nix.rs` — Nix build invocation +- `crates/nixosandbox/src/plan_builder.rs` — bwrap argv construction +- `crates/nixosandbox/src/supervisor.rs` — Process supervision +- `crates/nixosandbox/src/observer.rs` — Network observation +- `crates/nixosandbox/src/timestamps.rs` — Timestamp helper diff --git a/docs/superpowers/specs/2026-04-08-nix-flake-runtime-design.md b/docs/superpowers/specs/2026-04-08-nix-flake-runtime-design.md new file mode 100644 index 0000000..be58956 --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-nix-flake-runtime-design.md @@ -0,0 +1,568 @@ +# Nix Flake Runtime Redesign — Design Spec + +**Date:** 2026-04-08 +**Branch:** `pi-sandbox-refactor` +**Prerequisite:** Docker fallback for macOS complete + +--- + +## Overview + +The nixosandbox project was intended to be a NixOS sandbox flake for easy agent integration. The current refactor (Phases 0–12 + Docker fallback) built solid infrastructure — bwrap supervision, NDJSON protocol, session management, Docker sidecar — but moved away from Nix toward host-derived binaries and Debian Docker images. + +This spec puts Nix back at the center. Sandbox environments become Nix derivations — complete rootfs closures that bwrap pivots into. A standalone Rust CLI (`nixosandbox`) owns the full sandbox lifecycle. Agent frameworks (Pi, Claude Code, custom) are thin consumers of the CLI. + +--- + +## Key Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Product shape | Standalone CLI + Nix flake | Agent-runtime-agnostic. Any framework can shell out to `nixosandbox`. | +| Rootfs strategy | `mkSandboxRootfs` builds minimal rootfs from package list | Not a full NixOS system — no systemd, no init. Fast to build, small on disk. | +| LLM integration | LLM generates JSON spec, not Nix code | Constrained schema is reliable; raw Nix is not. Verification via `nix build`. | +| Package resolution | Curated mapping (~200 entries) with `nix search` fallback (future) | Predictable for common tools; extensible for the long tail. | +| Execution model | `bwrap --pivot-root` into Nix rootfs | Complete filesystem isolation. Host is invisible from inside the sandbox. | +| CLI interface | Subcommands: create, exec, enter, list, destroy, build | Full lifecycle management. `--json` flag for programmatic consumers. | +| NDJSON protocol | Preserved as `--json` mode | Backward compatible with existing Pi integration; reuses event types. | +| Existing Rust code | Migrated into `nixosandbox` CLI binary | supervisor, plan_builder, validator, observer reused — not rewritten. | +| Docker fallback | Updated — mounts `/nix/store` into sidecar | Fastest option: host Nix store mounted read-only. No rebuild inside Docker. | +| Pi extension | Becomes thin CLI adapter | Keeps tool registration, approvals, UX. Delegates sessions/execution to CLI. | + +--- + +## Architecture + +```text +User / Agent Framework / Orchestrator Skill + │ + │ shell out or --json + ▼ +┌──────────────────────────────────────────────┐ +│ nixosandbox CLI (Rust binary) │ +│ │ +│ create ──► nix build mkSandboxRootfs │ +│ exec ──► bwrap --pivot-root + supervise │ +│ enter ──► exec -- $SHELL (interactive) │ +│ list ──► read session metadata │ +│ destroy ──► kill + cleanup session dirs │ +│ build ──► nix build only (no session) │ +│ │ +│ Internals (migrated from pi-sandbox-runtime)│ +│ ├─ plan_builder.rs (gains build_rootfs) │ +│ ├─ supervisor.rs (unchanged) │ +│ ├─ validator.rs (gains rootfs validation) │ +│ ├─ observer.rs (unchanged) │ +│ ├─ docker.rs (updated mount paths) │ +│ └─ bubblewrap.rs (unchanged) │ +└──────────────┬───────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ bwrap --pivot-root /nix/store/...-rootfs │ +│ │ +│ /bin/node ─┐ │ +│ /bin/python3 ├─ from Nix closure (ro) │ +│ /bin/git ─┘ │ +│ /workspace ──── session dir (rw) │ +│ /home/sandbox ─── session dir (rw) │ +│ /cache ──── session dir (rw) │ +│ /tmp ──── tmpfs │ +│ /dev ──── devtmpfs │ +│ /proc ──── procfs │ +│ │ +│ Host filesystem: invisible │ +└──────────────────────────────────────────────┘ +``` + +### On macOS (Docker fallback) + +```text +nixosandbox CLI (macOS native) + │ + │ docker exec -i pi-sandbox-sidecar bwrap --pivot-root ... + ▼ +┌──────────────────────────────────────────────┐ +│ Docker sidecar (pi-sandbox-sidecar) │ +│ -v /nix/store:/nix/store:ro │ +│ -v :/nixosandbox:rw │ +│ --cap-add SYS_ADMIN --cap-add NET_ADMIN │ +│ --security-opt seccomp=unconfined │ +│ │ +│ bwrap --pivot-root /nix/store/...-rootfs │ +│ (same as Linux, runs inside Docker) │ +└──────────────────────────────────────────────┘ +``` + +The host's `/nix/store` is mounted read-only into the sidecar. No need to build anything inside Docker. This is the fastest path — instant if the Nix closure is already built. + +--- + +## Flake Structure + +### `flake.nix` outputs + +```nix +{ + outputs = { self, nixpkgs, ... }: { + # Library function for building custom rootfs + lib.mkSandboxRootfs = { name, packages, env ? {}, ... }: ...; + + # Pre-built rootfs for each built-in profile + packages.x86_64-linux = { + nixosandbox = ...; # The CLI binary + sandbox-build-install = ...; # Rootfs derivation + sandbox-offline-review = ...; + sandbox-strict = ...; + sandbox-debug-network = ...; + }; + + # Development shell for working on nixosandbox itself + devShells.x86_64-linux.default = ...; + }; +} +``` + +### `mkSandboxRootfs` behavior + +Input: a sandbox spec (name, packages, env, etc.) + +Output: a derivation producing a directory tree: + +```text +/nix/store/-sandbox-/ + bin/ → symlinks to package bins (via buildEnv) + lib/ → shared libraries + etc/ + ssl/certs/ → CA certificates + passwd → sandbox user entry + group → sandbox group entry + nsswitch.conf + usr/ + bin/env → for #!/usr/bin/env shebangs +``` + +Built using `pkgs.buildEnv` + `pkgs.runCommand` to assemble the tree. Fast to build (seconds if packages are cached). Small on disk (symlinks into Nix store). + +Mountpoints (`/tmp`, `/dev`, `/proc`, `/workspace`, `/home`) are NOT in the derivation — bwrap creates them at runtime. + +--- + +## Sandbox Spec Format + +The intermediate representation between natural language and Nix. Validated against a JSON schema. + +```json +{ + "name": "web-dev", + "packages": ["nodejs_22", "postgresql_16", "git", "curl", "jq", "python312"], + "env": { + "NODE_ENV": "development" + }, + "network": "full", + "namespaces": ["pid", "mount", "uts", "ipc"], + "writable": ["/workspace", "/home/sandbox", "/cache", "/tmp"] +} +``` + +### Fields + +| Field | Type | Required | Description | +|---|---|---|---| +| `name` | string | yes | Unique name for the environment | +| `packages` | string[] | yes | Exact nixpkgs attribute names | +| `env` | object | no | Environment variables set inside sandbox | +| `network` | `"off"` \| `"full"` | no (default: `"full"`) | Network isolation mode | +| `namespaces` | string[] | no (default: `["pid","mount","uts","ipc"]`) | Linux namespaces to unshare | +| `writable` | string[] | no (default: `["/workspace","/home/sandbox","/cache","/tmp"]`) | Paths writable inside sandbox | + +### Built-in profiles + +Shipped as spec files in `nix/profiles/`: + +**`build-install.json`** (default): +```json +{ + "name": "build-install", + "packages": ["nodejs_22", "python312", "rustc", "cargo", "git", "curl", "cacert", "coreutils", "bash", "gnugrep", "gnused", "gawk", "findutils", "gnutar", "gzip", "gnumake", "gcc"], + "env": {}, + "network": "full", + "namespaces": ["pid", "mount", "uts", "ipc"], + "writable": ["/workspace", "/home/sandbox", "/cache", "/tmp"] +} +``` + +**`offline-review.json`**: +```json +{ + "name": "offline-review", + "packages": ["git", "cacert", "coreutils", "bash", "gnugrep", "gnused", "gawk", "findutils", "jq"], + "env": {}, + "network": "off", + "namespaces": ["pid", "mount", "uts", "ipc", "net"], + "writable": ["/workspace", "/home/sandbox", "/tmp"] +} +``` + +**`strict.json`**: +```json +{ + "name": "strict", + "packages": ["coreutils", "bash", "cacert"], + "env": {}, + "network": "off", + "namespaces": ["pid", "mount", "uts", "ipc", "net"], + "writable": ["/tmp"] +} +``` + +**`debug-network.json`**: +```json +{ + "name": "debug-network", + "packages": ["nodejs_22", "python312", "git", "curl", "cacert", "coreutils", "bash", "inetutils", "netcat", "dig"], + "env": {}, + "network": "full", + "namespaces": ["pid", "mount", "uts", "ipc"], + "writable": ["/workspace", "/home/sandbox", "/cache", "/tmp"] +} +``` + +--- + +## Package Resolution + +### Curated Mapping (`nix/packages.json`) + +Maps common tool names and aliases to exact nixpkgs attributes: + +```json +{ + "node": { + "attr": "nodejs_22", + "aliases": ["nodejs", "node.js", "node22", "node-22"], + "extra": [] + }, + "python": { + "attr": "python312", + "aliases": ["python3", "py", "python3.12"], + "extra": ["python312Packages.pip"] + }, + "rust": { + "attr": "rustc", + "aliases": ["rustlang"], + "extra": ["cargo", "rustfmt", "clippy"] + }, + "go": { + "attr": "go", + "aliases": ["golang"], + "extra": [] + }, + "postgres": { + "attr": "postgresql_16", + "aliases": ["postgresql", "pg", "psql"], + "extra": [] + }, + "git": { + "attr": "git", + "aliases": [], + "extra": [] + } +} +``` + +~200 entries covering common development tools, languages, databases, and utilities. + +### Resolution chain (v1) + +1. Look up in curated mapping (exact match or alias match) +2. If match found, expand to `attr` + `extra` packages +3. If no match, treat as a literal nixpkgs attribute name (user knows what they want) +4. Validate all resolved attributes exist via `nix eval` + +### Resolution chain (future, out of scope for v1) + +Steps 1–3 above, plus: +4. If literal attribute doesn't exist, fall back to `nix search nixpkgs#` +5. If still unresolved, ask user for clarification + +--- + +## CLI Interface + +### `nixosandbox create` + +``` +nixosandbox create [OPTIONS] + +Options: + --profile Use a built-in profile (build-install, offline-review, strict, debug-network) + --spec Use a custom spec file + --workspace Host directory to mount as /workspace (default: current directory) + --name Human-readable session name (default: auto-generated) + --json Output session info as JSON + +Creates a sandbox session. Builds the rootfs if not cached. +Prints the session ID (or JSON with --json). +``` + +### `nixosandbox exec` + +``` +nixosandbox exec [OPTIONS] -- + +Options: + --json Stream NDJSON events (lifecycle, stdout, stderr, result) + --timeout Kill after timeout (default: none) + --env Additional environment variable (repeatable) + +Executes a command inside the sandbox. Returns the command's exit code. +With --json, streams the same NDJSON event types as the current pi-sandbox-runtime. +``` + +### `nixosandbox enter` + +``` +nixosandbox enter + +Shorthand for: nixosandbox exec -- /bin/bash +Opens an interactive shell inside the sandbox. +``` + +### `nixosandbox list` + +``` +nixosandbox list [OPTIONS] + +Options: + --json Output as JSON array + +Lists active sandbox sessions with ID, name, profile, workspace, and created timestamp. +``` + +### `nixosandbox destroy` + +``` +nixosandbox destroy + +Kills any running processes in the sandbox, removes session directories. +Does NOT remove the Nix rootfs (it's in /nix/store, managed by nix-collect-garbage). +``` + +### `nixosandbox build` + +``` +nixosandbox build [OPTIONS] + +Options: + --profile Build rootfs for a built-in profile + --spec Build rootfs from a custom spec file + --json Output rootfs path as JSON + +Builds the rootfs derivation without creating a session. +Useful for CI, caching, or pre-warming. +``` + +--- + +## bwrap Execution with Nix Rootfs + +### New `build_rootfs()` in plan_builder.rs + +The existing `build()` and `build_with_allowlist()` functions stay for backward compatibility. A new `build_rootfs()` function produces bwrap argv for the pivot-root model: + +```rust +pub fn build_rootfs( + rootfs_path: &str, + session_dirs: &SessionDirs, + effective_state: &EffectiveState, +) -> Vec { + // --pivot-root /oldroot + // --tmpfs /oldroot (hide host) + // --bind /workspace + // --bind /home/sandbox + // --bind /cache + // --tmpfs /tmp + // --dev /dev + // --proc /proc + // --unshare-pid --unshare-ipc --unshare-uts + // --unshare-net (if network == "off") + // --clearenv + // --setenv HOME /home/sandbox + // --setenv PATH /bin + // --chdir /workspace +} +``` + +### SessionDirs struct + +```rust +pub struct SessionDirs { + pub workspace: String, // host path → /workspace + pub home: String, // host path → /home/sandbox + pub cache: String, // host path → /cache + pub logs: String, // host path (not mounted, for CLI logs) + pub metadata: String, // host path (session metadata file) +} +``` + +### Docker path (macOS) + +On macOS with Docker: +1. Sidecar is started with `-v /nix/store:/nix/store:ro -v :/nixosandbox:rw` +2. `build_rootfs()` produces the same argv +3. Supervisor prefixes with `docker exec -i bwrap` +4. Path rewriting: session dirs are rewritten from host to container paths (existing logic) +5. Nix store paths need NO rewriting — `/nix/store` is the same path on host and in container + +--- + +## Session Management + +### Session directory layout + +```text +~/.local/share/nixosandbox/sessions// + metadata.json + workspace/ # writable (or symlink to --workspace path) + home/ # writable /home/sandbox + cache/ # writable /cache (npm, pip, cargo caches) + logs/ # CLI execution logs +``` + +### metadata.json + +```json +{ + "sessionId": "a1b2c3d4", + "name": "my-project", + "profile": "build-install", + "rootfsPath": "/nix/store/abc123-sandbox-build-install", + "workspace": "/home/user/projects/myapp", + "createdAt": "2026-04-08T12:00:00Z", + "lastExecAt": "2026-04-08T12:05:00Z", + "pid": null +} +``` + +### Lifecycle + +- **create**: Build rootfs (if not cached), create session dirs, write metadata. If `--workspace` points to an existing directory, symlink `sessions//workspace` to it. If omitted, create an empty `workspace/` directory inside the session. +- **exec**: Read metadata, build bwrap argv, supervise process, update lastExecAt +- **destroy**: Kill pid if running, remove session directory. Does NOT remove the `--workspace` directory if it was an external symlink. Rootfs stays in Nix store. +- **Garbage collection**: `nix-collect-garbage` removes unused rootfs derivations. The CLI does not manage Nix store directly. + +--- + +## Migration Path + +### Renames and moves + +| Current | Becomes | +|---|---| +| `crates/pi-sandbox-runtime/` | `crates/nixosandbox/` | +| `pi-sandbox-runtime` binary | `nixosandbox` binary | +| `nix/shell.nix` | Deleted (replaced by `flake.nix` devShell) | +| `docker-compose.yml` | Deleted (legacy) | + +### New files + +| Path | Responsibility | +|---|---| +| `flake.nix` | Flake definition: mkSandboxRootfs, packages, devShell | +| `flake.lock` | Pinned nixpkgs | +| `nix/mkSandboxRootfs.nix` | Rootfs builder function | +| `nix/packages.json` | Curated package mapping | +| `nix/profiles/build-install.json` | Built-in profile spec | +| `nix/profiles/offline-review.json` | Built-in profile spec | +| `nix/profiles/strict.json` | Built-in profile spec | +| `nix/profiles/debug-network.json` | Built-in profile spec | +| `crates/nixosandbox/src/cli.rs` | CLI argument parsing (clap) | +| `crates/nixosandbox/src/session.rs` | Session create/list/destroy | +| `crates/nixosandbox/src/nix.rs` | Nix build invocation, spec validation | + +### Modified files + +| Path | Change | +|---|---| +| `crates/nixosandbox/src/plan_builder.rs` | Add `build_rootfs()` function | +| `crates/nixosandbox/src/main.rs` | Replace NDJSON-only entry with clap CLI | +| `crates/nixosandbox/src/validator.rs` | Add rootfs validation path | +| `crates/nixosandbox/src/docker.rs` | Update sidecar to mount `/nix/store`, update path rewriting | +| `crates/nixosandbox/src/supervisor.rs` | Accept rootfs-mode argv (minimal change) | +| `crates/nixosandbox/Cargo.toml` | Add `clap` dependency | +| `packages/pi-sandbox-extension/src/runtime-base.ts` | Simplify to call `nixosandbox build` | +| `packages/pi-sandbox-extension/src/session-manager.ts` | Delegate to `nixosandbox create`/`exec` | +| `packages/pi-sandbox-extension/src/profiles.ts` | Read from spec files or delegate to CLI | + +### Deleted files + +| Path | Reason | +|---|---| +| `nix/shell.nix` | Replaced by flake.nix devShell | +| `docker-compose.yml` | Legacy from old server | + +--- + +## Testing + +### Unit tests (Rust, any platform) + +- `build_rootfs()` produces correct bwrap argv for a given rootfs path + session dirs +- Session metadata serialization/deserialization +- Spec validation (valid spec passes, invalid spec rejected) +- Package resolution from curated mapping + +### Integration tests (Linux with Nix) + +- `nix build .#sandbox-build-install` produces a rootfs with expected binaries +- `nixosandbox create --profile build-install` creates session dirs + metadata +- `nixosandbox exec -- echo hello` produces stdout "hello", exit code 0 +- `nixosandbox exec -- which node` returns `/bin/node` (Nix closure, not host) +- `nixosandbox exec -- ls /` shows sandbox rootfs, not host +- `nixosandbox destroy ` cleans up session dirs +- `nixosandbox list` shows/hides sessions correctly +- `nixosandbox exec --json -- echo test` produces valid NDJSON stream + +### Docker tests (macOS with Docker + Nix, gated behind env var) + +- Sidecar starts with `/nix/store` mount +- `nixosandbox exec` works through Docker sidecar +- Nix store paths are accessible inside sidecar without rebuilding + +### Backward compatibility + +- `nixosandbox exec --json` output matches existing NDJSON protocol event types +- Pi extension can create and exec sandboxes via CLI + +--- + +## What Is NOT in This Spec + +- **Natural language spec generation** — Future skill that wraps the CLI. CLI only accepts spec files and profiles in v1. +- **`nix search` fallback** — v1 uses curated mapping only. Users specify exact attrs for unmapped packages. +- **Background services** — No PostgreSQL/Redis running as daemons inside sandboxes. Packages are installed but not started. +- **Resource limits via cgroups** — Deferred. bwrap namespace isolation only. +- **Sandbox snapshots** — No checkpoint/restore. +- **Remote execution** — No SSH to remote NixOS hosts. +- **Multi-architecture** — x86_64-linux only in v1. aarch64-linux added later. +- **NixOS module profiles** — Profiles are simple package lists, not NixOS configurations. +- **Orchestrator skill** — The skill that manages multiple sandboxes for subagents is built on top of this CLI, not part of it. + +--- + +## Phase Gate + +| Criteria | +|----------| +| `nix build .#sandbox-build-install` produces a rootfs with node, python, git, rust, curl | +| `nixosandbox create --profile build-install --workspace /tmp/test` creates a session | +| `nixosandbox exec -- node -e "console.log('hello')"` prints "hello", exit 0 | +| `nixosandbox exec -- which git` returns `/bin/git` (Nix, not host) | +| `nixosandbox exec --json -- echo test` produces valid NDJSON event stream | +| `nixosandbox list` shows the session | +| `nixosandbox destroy ` cleans up | +| On macOS with Docker: same commands work via sidecar with `/nix/store` mount | +| Host filesystem is invisible from inside the sandbox (`ls /` shows rootfs, not host) | +| Pi extension can create and exec sandboxes via `nixosandbox` CLI | +| All existing Rust unit tests pass (supervisor, plan_builder, validator, observer) | diff --git a/docs/superpowers/specs/2026-04-09-llm-agents-nix-integration-design.md b/docs/superpowers/specs/2026-04-09-llm-agents-nix-integration-design.md new file mode 100644 index 0000000..5a5d39f --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-llm-agents-nix-integration-design.md @@ -0,0 +1,354 @@ +# llm-agents.nix Integration Design + +## Goal + +Integrate `numtide/llm-agents.nix` as a flake input to provide a unified package catalog of 80+ AI agent tools alongside nixpkgs utilities. This enables agent runtimes to compose custom sandboxes by name — turning natural language task descriptions into concrete, isolated execution environments. + +## Architecture + +nixosandbox gains a **catalog-driven composition layer**. The Nix flake merges two package sources into a queryable catalog. The Rust CLI exposes this catalog and a new `--with` flag. Agent-facing tool schemas guide LLMs to pick the right packages for any task. + +``` +Agent (LLM) reasons about task + → calls sandbox_catalog to see available packages + → calls sandbox_create --with claude-code,python312,git --network off + → nixosandbox resolves names from catalog + → mkAgentSandbox → mkSandboxRootfs → rootfs in /nix/store + → session ready for exec +``` + +## Key Decisions + +- **Approach 1 (Catalog-Driven Composition)** over profiles-only or dynamic Nix expressions +- **Rootfs over OCI** — near-instant from cached Nix store paths, no registry/daemon overhead +- **Agent is the intelligence** — no NLP in the CLI; the LLM reasons about what to install from good tool descriptions and a queryable catalog +- **Two nixpkgs sources, no follows** — our base tools from nixos-25.11 (stable), agent packages from llm-agents.nix's nixpkgs-unstable (pre-built via numtide binary cache) +- **Nix library + CLI + tool schemas** — composable at all three layers + +--- + +## Part 1: Flake Integration & Package Catalog + +### Flake inputs + +```nix +inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + llm-agents.url = "github:numtide/llm-agents.nix"; + # No follows — they keep their own nixpkgs-unstable + binary cache +}; +``` + +Add `nixConfig` to include numtide's binary cache: + +```nix +nixConfig = { + extra-substituters = [ "https://cache.numtide.com" ]; + extra-trusted-public-keys = [ "niks3.numtide.com-1:DTx8wZduET09hRmMtKdQDxNNthLQETkc/yaX7M4qK0g=" ]; +}; +``` + +### Package catalog (`nix/catalog.nix`) + +Builds a unified catalog from both sources: + +```nix +{ pkgs, llm-agents-pkgs }: +{ + agents = { + inherit (llm-agents-pkgs) + claude-code pi codex amp goose-cli gemini-cli + opencode copilot-cli droid forge cursor-agent + /* all available agent packages */; + }; + + tools = { + inherit (pkgs) + python312 nodejs_22 rustc cargo git curl + coreutils bash gnugrep gnused jq ripgrep fd + /* standard dev tools */; + }; +} +``` + +Two namespaces (`agents` and `tools`) prevent accidental conflicts between sources and make it clear to LLMs what's an agent runtime vs a utility. + +### Flake output + +```nix +catalog = forAllSystems (system: import ./nix/catalog.nix { + pkgs = nixpkgs.legacyPackages.${system}; + llm-agents-pkgs = llm-agents.packages.${system}; +}); +``` + +### nixpkgs version strategy + +| Layer | Source | Rationale | +|-------|--------|-----------| +| Base tools (coreutils, bash, git, etc.) | nixos-25.11 (ours) | Stable, predictable | +| Agent packages (claude-code, pi, codex, etc.) | nixpkgs-unstable (theirs, via binary cache) | Pre-built, self-contained | +| nixosandbox Rust CLI | nixos-25.11 (ours) | Our code, our stability | + +Agent packages are mostly binary downloads, npm bundles, or Go static binaries — they don't share libraries with base tools. The rule: don't mix same-domain packages from both sources in one rootfs (e.g., don't request their `python` alongside our `python312`). + +--- + +## Part 2: `mkAgentSandbox` Nix Function + +New file `nix/mkAgentSandbox.nix` — a catalog-aware composition layer over `mkSandboxRootfs`: + +```nix +{ pkgs, catalog, mkSandboxRootfs }: + +{ name +, packages ? [] # names resolved against agents-first, then tools +, extraPackages ? [] # raw Nix derivations for advanced users +, env ? {} +, network ? "off" +, namespaces ? ["pid" "mount" "uts" "ipc"] +, writable ? ["/workspace" "/home/sandbox" "/tmp"] +}: + +let + resolvePackage = pname: + if catalog.agents ? ${pname} then catalog.agents.${pname} + else if catalog.tools ? ${pname} then catalog.tools.${pname} + else throw "unknown package '${pname}' — not in agents or tools catalog"; + + resolvedPackages = map resolvePackage packages; + allPackages = resolvedPackages ++ extraPackages; +in + mkSandboxRootfs { + inherit name env; + packages = allPackages; + } +``` + +### Composition paths + +Existing JSON profiles are unchanged — they go through `loadProfile` directly: + +``` +Profiles path: profiles/*.json → loadProfile → mkSandboxRootfs → rootfs +Catalog path: package names → mkAgentSandbox → mkSandboxRootfs → rootfs +``` + +### Flake library output + +```nix +lib = { + inherit mkSandboxRootfs; # existing — raw derivation lists + inherit mkAgentSandbox; # new — catalog-aware, name-based +}; +``` + +--- + +## Part 3: CLI Changes + +### New `--with` flag on `create` + +``` +nixosandbox create --name "code-review" \ + --with claude-code,git,ripgrep \ + --network off \ + --agent "claude:opus-4-6" \ + --json +``` + +Accepts a comma-separated list of names from the unified catalog (the Pi extension tool schema uses a native array; the CLI parses commas). Mutually exclusive with `--profile` and `--spec`: + +| Flag | Source | Use case | +|------|--------|----------| +| `--profile strict` | Built-in JSON profile | Known, curated environments | +| `--spec ./my-spec.json` | Custom JSON file | User-defined specs | +| `--with claude-code,git` | Catalog resolution | Dynamic, agent-driven composition | + +### Resolution in `nix.rs` + +New function `build_with_catalog()` generates a Nix expression that calls `mkAgentSandbox`: + +```rust +pub fn build_with_catalog( + flake_root: &str, + names: &[String], + network: &str, + env: &HashMap, +) -> Result { + // Generates and evaluates: + // let flake = builtins.getFlake ""; + // mkAgentSandbox = flake.lib.mkAgentSandbox; + // in mkAgentSandbox { + // name = "custom-"; + // packages = [ "claude-code" "git" "ripgrep" ]; + // network = "off"; + // env = { ... }; + // } +} +``` + +### New `catalog` subcommand + +``` +$ nixosandbox catalog +Agents (from llm-agents.nix): + claude-code Anthropic's Claude Code CLI + pi Terminal-based coding agent + codex OpenAI Codex CLI + amp Sourcegraph coding agent + ... + +Tools (from nixpkgs): + python312 Python 3.12 interpreter + nodejs_22 Node.js 22 LTS + git Distributed version control + ... + +$ nixosandbox catalog --json +{ + "agents": { "claude-code": { "description": "..." }, ... }, + "tools": { "python312": { "description": "..." }, ... } +} +``` + +The JSON output is what agents consume to reason about available packages. + +### Implementation in `nix.rs` + +New function `query_catalog()` evaluates the catalog flake output: + +```rust +pub fn query_catalog(flake_root: &str) -> Result { + // nix eval '.#catalog.' --json + // Returns structured catalog with names and descriptions +} +``` + +--- + +## Part 4: Agent Tool Schema + +### Updated `sandbox_create` tool + +```typescript +{ + name: "sandbox_create", + description: `Create an isolated sandbox environment for executing tasks. + First call 'sandbox_catalog' to see available agents and tools, + then compose a sandbox by picking what you need for the task. + + Agents are AI coding tools (claude-code, pi, codex, etc.). + Tools are utilities (python312, git, nodejs_22, ripgrep, etc.). + + Choose 'network: off' for review/analysis tasks. + Choose 'network: full' for build/install tasks that need downloads.`, + parameters: { + name: { type: "string", description: "Human-readable session name" }, + with: { + type: "array", items: { type: "string" }, + description: "Package names from the catalog (agents + tools)" + }, + profile: { type: "string", description: "OR use a built-in profile instead of --with" }, + network: { type: "string", enum: ["off", "full"], default: "off" }, + workspace: { type: "string", description: "Host directory to mount as /workspace" }, + agent: { type: "string", description: "Agent runtime identifier" }, + description: { type: "string", description: "What this sandbox is for" }, + } +} +``` + +### New `sandbox_catalog` tool + +```typescript +{ + name: "sandbox_catalog", + description: `List all available packages for sandbox_create --with. + Returns agents (AI coding tools) and tools (utilities) with descriptions. + Call this before sandbox_create to see what's available.`, + parameters: { + filter: { type: "string", description: "Optional search term to filter results" }, + json: { type: "boolean", default: true } + } +} +``` + +### Natural language flow + +The agent IS the intelligence. No NLP in the CLI. The flow: + +1. Agent receives task description from user +2. Agent calls `sandbox_catalog` to see available packages +3. Agent reasons about what packages the task needs +4. Agent calls `sandbox_create` with concrete `--with` names +5. Agent calls `sandbox_exec` to do the work + +--- + +## Part 5: What Changes Where + +### New files + +| File | Purpose | +|------|---------| +| `nix/catalog.nix` | Unified package catalog from both sources | +| `nix/mkAgentSandbox.nix` | Catalog-aware composition layer | + +### Modified files + +| File | Change | +|------|--------| +| `flake.nix` | Add `llm-agents` input, `nixConfig`, expose `catalog` and `mkAgentSandbox` | +| `crates/nixosandbox/src/cli.rs` | Add `--with` flag on `create`, add `Catalog` subcommand | +| `crates/nixosandbox/src/main.rs` | Wire `cmd_catalog`, wire `--with` into create flow | +| `crates/nixosandbox/src/nix.rs` | Add `build_with_catalog()`, add `query_catalog()` | +| `packages/pi-sandbox-extension/src/extension.ts` | Add `sandbox_catalog` tool, update `sandbox_create` with `with` param | +| `packages/pi-sandbox-extension/src/cli-client.ts` | Add `catalogPackages()`, update `createSession()` for `--with` | + +### Unchanged files + +- `nix/mkSandboxRootfs.nix` — untouched foundation +- `nix/profiles/*.json` — backward compatible +- `crates/nixosandbox/src/session.rs` — no session model changes +- `crates/nixosandbox/src/plan_builder.rs` — bwrap argv unchanged +- `crates/nixosandbox/src/bubblewrap.rs`, `docker.rs` — isolation layer unchanged +- `packages/pi-sandbox-extension/src/contract.ts`, `crash-synthesis.ts` — protocol unchanged + +### Testing strategy + +1. **Nix eval test** — `nix eval .#catalog.x86_64-linux` succeeds, contains expected names +2. **Catalog CLI test** — `nixosandbox catalog --json` returns valid JSON with both namespaces +3. **`--with` integration test** — `nixosandbox create --with git,bash --network off --json` produces a valid session +4. **Error cases** — `--with nonexistent` errors clearly; `--with` + `--profile` together rejected +5. **Existing tests** — all current profile-based tests pass unchanged + +### Out of scope + +- OCI image output +- Overriding llm-agents.nix's nixpkgs (`follows`) +- Cross-namespace conflict detection (Nix's `buildEnv` collision errors are sufficient for v1) +- MCP server wrapper (can add later over the CLI) +- Auto-updating llm-agents.nix input (manual `nix flake update llm-agents`) + +--- + +## Comparison: nixosandbox vs agent-images + +For reference, here is how this design compares to `nothingnesses/agent-images`: + +| Aspect | nixosandbox (this design) | agent-images | +|--------|--------------------------|--------------| +| Isolation | bubblewrap + Docker fallback | OCI containers (Podman/Docker) | +| Image format | Nix store rootfs (local, instant) | OCI layered image (distributable) | +| Composition | `mkAgentSandbox` with name-based catalog | `mkAgentImage` with Nix attrsets | +| Agent catalog | llm-agents.nix (80+ agents) + nixpkgs tools | llm-agents.nix (30 agents) | +| Runtime | Rust CLI with session lifecycle | None (delegates to agent-box / podman) | +| Session state | Built-in (create/exec/status/destroy) | Stateless | +| Network control | off/full per profile, namespace-level | Container networking | +| macOS | Docker sidecar fallback | Linux-only | +| Natural language | Agent tool schemas + queryable catalog | Not supported | +| Distribution | Local Nix store only | OCI registries | + +Our advantages: lightweight isolation, session management, macOS support, structured event streaming, agent-driven composition via catalog + tool schemas. + +Their advantages: OCI distribution, Nix-inside-container, nix-ld for foreign binaries, simpler mental model for manual use. diff --git a/docs/superpowers/specs/2026-04-09-nix-flake-part-c-design.md b/docs/superpowers/specs/2026-04-09-nix-flake-part-c-design.md new file mode 100644 index 0000000..d115f9c --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-nix-flake-part-c-design.md @@ -0,0 +1,278 @@ +# Nix Flake Runtime — Part C Design Spec + +## Overview + +Part C of the nixosandbox Nix flake runtime redesign. Part A (complete) built the standalone CLI + Nix flake. Part B (complete) delivered Docker sidecar support, legacy cleanup, and integration tests. Part C simplifies the Pi extension into a thin CLI adapter with agent runtime metadata and a battlecard-style session info view, then performs a dead code cleanup pass. + +## Goals + +1. **Thin CLI adapter** — Rewrite the Pi extension to delegate all session management, profile resolution, and execution to the `nixosandbox` CLI. Delete modules that duplicate CLI functionality. +2. **Agent runtime metadata** — Add `--agent` and `--description` flags to `nixosandbox create` so each session records which agent platform/model is using it and why. +3. **Battlecard session info** — Enrich `sandbox_session_info` tool and `nixosandbox list/status` to return dense, operator-grade session snapshots (identity, environment, isolation backend, agent runtime, description). +4. **Dead code cleanup** — Final pass to remove unreferenced code in both the Rust crate and the TS extension, refactor any redundancy detected across the codebase. + +## Out of Scope + +- Execution history tracking (future) +- Network allowlist enforcement +- Browser tool changes (kept as-is) +- Nix search fallback for package resolution + +--- + +## 1. CLI Changes + +### 1.1 Agent Runtime Format + +Agent runtime strings follow the format `:`: + +``` +claude:opus-4-6 +codex:gpt-4.1 +pi:sonnet-4-6+tools +copilot:gpt-4.1 +amp:sonnet-4-6 +droid:gemini-2.5-pro +``` + +The platform portion is a short identifier for the agent system. The model_and_config portion is freeform — it can include model name, version, and configuration flags separated by `+`. + +### 1.2 New CLI Flags on `create` + +``` +nixosandbox create --profile strict \ + --agent "claude:opus-4-6" \ + --description "Debugging auth middleware for PR #42" \ + --json +``` + +Both `--agent` and `--description` are optional strings stored in `metadata.json`. + +### 1.3 SessionMetadata Changes + +Add two fields to the `SessionMetadata` struct in `session.rs`: + +```rust +pub struct SessionMetadata { + pub session_id: String, + pub name: String, + pub profile: String, + pub rootfs_path: String, + pub workspace: String, + pub created_at: String, + pub last_exec_at: Option, + pub pid: Option, + // New fields + pub agent: Option, // e.g. "claude:opus-4-6" + pub description: Option, // e.g. "Debugging auth middleware" +} +``` + +These are `Option` — omitted when not provided. Existing sessions without these fields deserialize cleanly (serde defaults to `None`). + +### 1.4 New `status` Subcommand + +Add `nixosandbox status ` that outputs a battlecard-style view. + +**Plain text output:** + +``` +╭──────────────────────────────────────────────╮ +│ Session: a1b2c3d4 │ +├──────────────────────────────────────────────┤ +│ Name: auth-debug │ +│ Description: Debugging auth middleware │ +│ Agent: claude:opus-4-6 │ +│ Profile: strict │ +│ Created: 2026-04-09T14:30:00Z │ +│ Last Exec: 2026-04-09T14:35:12Z │ +│ Rootfs: /nix/store/abc123...-strict │ +│ Workspace: ~/.local/share/.../workspace │ +│ Network: off │ +│ Isolation: native (bwrap) │ +╰──────────────────────────────────────────────╯ +``` + +**JSON output (`--json`):** + +Returns the full `SessionMetadata` struct as JSON (same as `create --json` but for an existing session). The isolation backend is determined at exec time, not stored — the status command reports the *current* backend by running `bubblewrap::detect()`. + +```json +{ + "sessionId": "a1b2c3d4", + "name": "auth-debug", + "profile": "strict", + "rootfsPath": "/nix/store/abc123-sandbox-strict", + "workspace": "/Users/me/.local/share/nixosandbox/sessions/a1b2c3d4/workspace", + "createdAt": "2026-04-09T14:30:00Z", + "lastExecAt": "2026-04-09T14:35:12Z", + "agent": "claude:opus-4-6", + "description": "Debugging auth middleware", + "isolation": "native", + "network": "off" +} +``` + +The `isolation` and `network` fields are computed at status time — not stored in metadata.json but derived for the battlecard: +- `isolation`: one of `"native"`, `"docker"`, or `"unavailable"` — from `bubblewrap::detect()` +- `network`: one of `"off"`, `"full"`, or `"allowlist"` — from loading the session's profile spec + +--- + +## 2. Extension Simplification + +### 2.1 Delete + +| Module | LOC | Reason | +|--------|-----|--------| +| `session-manager.ts` | 335 | CLI owns sessions via `nixosandbox create/list/destroy` | +| `runtime-base.ts` | 179 | Nix flake profiles replace host-derived bundles | +| `profiles.ts` | 121 | CLI handles `--profile` flag | +| `reconciler.ts` | 132 | No daemon mode — single-shot CLI, nothing to reconcile | +| `runtime-client.ts` | 271 | Replaced by direct CLI spawning | + +### 2.2 Keep (Modified) + +| Module | Changes | +|--------|---------| +| `extension.ts` | Rewrite all tools to shell out to `nixosandbox` CLI | +| `contract.ts` | Keep outbound types (StreamEvent, ResultPayload) for NDJSON parsing. Delete inbound types (PlanPayload, CancelPayload, PlanMessage, CancelMessage, InboundMessage) and related schemas (ManifestSchema, PolicySchema, PlanPayloadSchema, CancelPayloadSchema) | +| `crash-synthesis.ts` | Keep — TS-only crash result generation, no runtime dependency | +| `browser.ts` | Keep as-is — independent of CLI adapter changes | +| `index.ts` | Simplify — remove SessionManager/RuntimeBase/Reconciler wiring | + +### 2.3 Rewritten extension.ts + +The extension becomes a thin adapter that shells out to the CLI: + +**sandbox_run:** +```typescript +async execute(args) { + const { command, sessionId, profile, description, agent } = args; + + // Create session if none provided + let sid = sessionId; + if (!sid) { + const createArgs = ["create", "--profile", profile ?? "build-install", "--json"]; + if (agent) createArgs.push("--agent", agent); + if (description) createArgs.push("--description", description); + const meta = JSON.parse(execFileSync(binaryPath, createArgs, { encoding: "utf-8" })); + sid = meta.sessionId; + } + + // Execute command + const result = await spawnExecJson(binaryPath, sid, command); + return formatRunResult(result); +} +``` + +**sandbox_read_file / sandbox_write_file / sandbox_list_files:** +These tools still operate on the workspace directory directly (host filesystem access). They need the session's workspace path, which they get from `nixosandbox status --json`. + +**sandbox_session_info:** +Calls `nixosandbox status --json` for single session or `nixosandbox list --json` for all sessions. Returns the battlecard view. + +### 2.4 New sandbox_run Parameters + +Add `agent` and `description` to the `sandbox_run` tool schema so the calling agent can self-identify: + +```typescript +parameters: Type.Object({ + command: Type.Array(Type.String()), + sessionId: Type.Optional(Type.String()), + profile: Type.Optional(Type.String()), + agent: Type.Optional(Type.String({ description: "Agent runtime identifier, e.g. 'claude:opus-4-6'" })), + description: Type.Optional(Type.String({ description: "Purpose of this sandbox session" })), + timeoutMs: Type.Optional(Type.Number()), +}) +``` + +### 2.5 Helper Module + +Create a small `cli-client.ts` module that wraps CLI invocations: + +```typescript +// Thin helpers for shelling out to nixosandbox CLI +export function createSession(binary, opts): SessionMetadata { ... } +export function statusSession(binary, sessionId): StatusResponse { ... } +export function listSessions(binary): SessionMetadata[] { ... } +export function destroySession(binary, sessionId): void { ... } +export async function execCommand(binary, sessionId, command, opts): ExecResult { ... } +``` + +This replaces both `runtime-client.ts` (subprocess NDJSON protocol) and `session-manager.ts` (directory management) with a single module that delegates to the CLI. + +--- + +## 3. Dead Code Cleanup + +### 3.1 Rust Crate + +After Parts A+B, several modules have dead code warnings: + +| Module | Dead Code | Action | +|--------|-----------|--------| +| `contract.rs` | `OutboundMessage` enum, all envelope types, `emit()`, `PROTOCOL_VERSION` | Delete — exec --json uses inline `serde_json::json!()` | +| `supervisor.rs` | `SupervisionResult`, `build_docker_command`, `supervise` | Delete — legacy supervisor for NDJSON protocol | +| `validator.rs` | `validate`, `resolve_hostname`, `detect_iptables` | Delete — validation was for legacy protocol | +| `observer.rs` | Unused imports, `NetworkObserver` partially dead | Clean up unused imports; keep core observation logic if referenced | +| `plan_builder.rs` | `build()` and `build_with_allowlist()` (legacy functions) | Delete — only `build_rootfs()` is used now | +| `session.rs` | `logs` field in `SessionDirs` | Remove if unused | + +### 3.2 TypeScript Extension + +After deleting the 5 modules: + +| Item | Action | +|------|--------| +| Inbound types in `contract.ts` | Delete PlanPayload, CancelPayload, ManifestSchema, PolicySchema, etc. | +| Re-exports in `index.ts` | Remove exports for deleted modules | +| `@sinclair/typebox` dependency | May become unused if contract.ts types are simplified | +| `package.json` scripts | Clean up any obsolete scripts | + +### 3.3 Cross-Codebase Redundancy + +Scan for: +- Duplicated path constants between docker.rs and session.rs +- Duplicated spec loading logic +- Any shared types that could be consolidated + +--- + +## 4. Files Changed + +### Rust Crate — Modified +- `crates/nixosandbox/src/cli.rs` — Add `--agent`, `--description` to Create; add Status subcommand +- `crates/nixosandbox/src/session.rs` — Add `agent`, `description` fields to SessionMetadata +- `crates/nixosandbox/src/main.rs` — Wire new flags, add `cmd_status`, pass agent/description to create_session + +### Rust Crate — Modified (Cleanup) +- `crates/nixosandbox/src/contract.rs` — Delete all dead types +- `crates/nixosandbox/src/supervisor.rs` — Delete entirely (or gut dead functions) +- `crates/nixosandbox/src/validator.rs` — Delete entirely (or gut dead functions) +- `crates/nixosandbox/src/observer.rs` — Clean up unused imports +- `crates/nixosandbox/src/plan_builder.rs` — Delete legacy `build()` and `build_with_allowlist()` + +### Extension — Deleted +- `packages/pi-sandbox-extension/src/session-manager.ts` +- `packages/pi-sandbox-extension/src/runtime-base.ts` +- `packages/pi-sandbox-extension/src/profiles.ts` +- `packages/pi-sandbox-extension/src/reconciler.ts` +- `packages/pi-sandbox-extension/src/runtime-client.ts` + +### Extension — Modified +- `packages/pi-sandbox-extension/src/extension.ts` — Rewrite as thin CLI adapter +- `packages/pi-sandbox-extension/src/contract.ts` — Delete inbound types, keep outbound +- `packages/pi-sandbox-extension/src/index.ts` — Simplify entry point and re-exports +- `packages/pi-sandbox-extension/package.json` — Remove unused dependencies + +### Extension — Created +- `packages/pi-sandbox-extension/src/cli-client.ts` — CLI wrapper helpers + +### Untouched +- `flake.nix`, `nix/` — Nix flake and profiles +- `docker/nixosandbox-sidecar.Dockerfile` +- `packages/pi-sandbox-extension/src/browser.ts` +- `packages/pi-sandbox-extension/src/crash-synthesis.ts` +- `tests/integration/` — Integration tests from Part B +- `tests/protocol/crash-synthesis.test.ts` — TS-only test diff --git a/docs/superpowers/specs/2026-04-09-numtide-catalog-dynamic-design.md b/docs/superpowers/specs/2026-04-09-numtide-catalog-dynamic-design.md new file mode 100644 index 0000000..77d8bb5 --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-numtide-catalog-dynamic-design.md @@ -0,0 +1,42 @@ +# Design: Dynamic numtide/llm-agents.nix Catalog + +**Date:** 2026-04-09 +**Status:** Approved +**Scope:** `nix/catalog.nix` only + +## Problem + +The catalog uses a hardcoded `pickExisting` whitelist of ~25 agent names from +`numtide/llm-agents.nix`. The upstream flake currently exposes 65+ packages +across 8 categories. Any new package added by numtide is invisible to +`nixosandbox catalog` and cannot be used with `--with` until someone manually +adds its name to the whitelist. + +## Solution + +Replace the whitelist with a dynamic passthrough: assign `agents` directly from +`llm-agents-pkgs`, stripping only the `default` meta-attribute that every Nix +flake packages output includes. + +## Change + +**File:** `nix/catalog.nix` + +Remove: the `pickExisting` helper function and the 25-name whitelist. +Add: `agents = builtins.removeAttrs llm-agents-pkgs [ "default" ];` + +No other files are modified. The `tools` section (nixpkgs packages) is unchanged. + +## Impact + +| Surface | Before | After | +|---------|--------|-------| +| `nixosandbox catalog` agent count | ~25 | 65+ | +| `nixosandbox create --with ` | only whitelisted names | any llm-agents.nix package | +| Maintenance on numtide upstream update | manual name addition required | zero — auto-picks up new packages on `nix flake update` | + +## Out of scope + +- Category-structured display in `nixosandbox catalog` (future enhancement) +- Changes to `tools` (nixpkgs packages) +- Changes to Rust CLI, flake.nix, mkAgentSandbox.nix, or profiles diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..f0d022d --- /dev/null +++ b/flake.lock @@ -0,0 +1,198 @@ +{ + "nodes": { + "blueprint": { + "inputs": { + "nixpkgs": [ + "llm-agents", + "nixpkgs" + ], + "systems": [ + "llm-agents", + "systems" + ] + }, + "locked": { + "lastModified": 1771437256, + "narHash": "sha256-bLqwib+rtyBRRVBWhMuBXPCL/OThfokA+j6+uH7jDGU=", + "owner": "numtide", + "repo": "blueprint", + "rev": "06ee7190dc2620ea98af9eb225aa9627b68b0e33", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "blueprint", + "type": "github" + } + }, + "bun2nix": { + "inputs": { + "flake-parts": [ + "llm-agents", + "flake-parts" + ], + "import-tree": "import-tree", + "nixpkgs": [ + "llm-agents", + "nixpkgs" + ], + "systems": [ + "llm-agents", + "systems" + ], + "treefmt-nix": [ + "llm-agents", + "treefmt-nix" + ] + }, + "locked": { + "lastModified": 1770895533, + "narHash": "sha256-v3QaK9ugy9bN9RXDnjw0i2OifKmz2NnKM82agtqm/UY=", + "owner": "nix-community", + "repo": "bun2nix", + "rev": "c843f477b15f51151f8c6bcc886954699440a6e1", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "bun2nix", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "llm-agents", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1775087534, + "narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "import-tree": { + "locked": { + "lastModified": 1763762820, + "narHash": "sha256-ZvYKbFib3AEwiNMLsejb/CWs/OL/srFQ8AogkebEPF0=", + "owner": "vic", + "repo": "import-tree", + "rev": "3c23749d8013ec6daa1d7255057590e9ca726646", + "type": "github" + }, + "original": { + "owner": "vic", + "repo": "import-tree", + "type": "github" + } + }, + "llm-agents": { + "inputs": { + "blueprint": "blueprint", + "bun2nix": "bun2nix", + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs", + "systems": "systems", + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1775746837, + "narHash": "sha256-WrZCxhx82aBBQb6CMJwXimgOIcv9Eytl1zFsjfxiWSc=", + "owner": "numtide", + "repo": "llm-agents.nix", + "rev": "b91b0e1583091847cf4f8a8fcaad92d66227abfb", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "llm-agents.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1775701739, + "narHash": "sha256-2FWWY1rr/+pGUJK1npcVcsWNEblzmKs6VxD3VEvwJSs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0f7663154ff2fec150f9dbf5f81ec2785dc1e0db", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1775595990, + "narHash": "sha256-OEf7YqhF9IjJFYZJyuhAypgU+VsRB5lD4DuiMws5Ltc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4e92bbcdb030f3b4782be4751dc08e6b6cb6ccf2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "llm-agents": "llm-agents", + "nixpkgs": "nixpkgs_2" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "llm-agents", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1775636079, + "narHash": "sha256-pc20NRoMdiar8oPQceQT47UUZMBTiMdUuWrYu2obUP0=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "790751ff7fd3801feeaf96d7dc416a8d581265ba", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..7774c46 --- /dev/null +++ b/flake.nix @@ -0,0 +1,106 @@ +{ + description = "nixosandbox -- reproducible, isolated sandbox environments"; + + nixConfig = { + extra-substituters = [ "https://cache.numtide.com" ]; + extra-trusted-public-keys = [ "niks3.numtide.com-1:DTx8wZduET09hRmMtKdQDxNNthLQETkc/yaX7M4qK0g=" ]; + }; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + llm-agents.url = "github:numtide/llm-agents.nix"; + }; + + outputs = { self, nixpkgs, llm-agents }: + let + # Sandbox rootfs is always Linux + linuxSystem = "x86_64-linux"; + linuxPkgs = nixpkgs.legacyPackages.${linuxSystem}; + mkSandboxRootfs = import ./nix/mkSandboxRootfs.nix { pkgs = linuxPkgs; }; + + # Unified catalog: agents from llm-agents.nix + tools from nixpkgs + linuxCatalog = import ./nix/catalog.nix { + pkgs = linuxPkgs; + llm-agents-pkgs = llm-agents.packages.${linuxSystem} or {}; + }; + + # Catalog-aware sandbox builder + mkAgentSandbox = import ./nix/mkAgentSandbox.nix { + catalog = linuxCatalog; + inherit mkSandboxRootfs; + }; + + # Helper: load a profile JSON and resolve package names to nixpkgs attrs + loadProfile = path: + let + spec = builtins.fromJSON (builtins.readFile path); + resolvedPkgs = map (name: + if builtins.hasAttr name linuxPkgs + then builtins.getAttr name linuxPkgs + else throw "nixosandbox: unknown package '${name}' in profile ${spec.name}" + ) spec.packages; + in + mkSandboxRootfs { + name = spec.name; + packages = resolvedPkgs; + env = spec.env or {}; + }; + + # Rootfs derivations (always x86_64-linux, buildable from any host) + sandboxPackages = { + sandbox-build-install = loadProfile ./nix/profiles/build-install.json; + sandbox-offline-review = loadProfile ./nix/profiles/offline-review.json; + sandbox-strict = loadProfile ./nix/profiles/strict.json; + sandbox-debug-network = loadProfile ./nix/profiles/debug-network.json; + }; + + # All systems that can build/use nixosandbox + supportedSystems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ]; + + forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems f; + in + { + # Library functions for custom rootfs and catalog composition + lib = { + inherit mkSandboxRootfs; + inherit mkAgentSandbox; + }; + + # Catalog: queryable package listing (always x86_64-linux for rootfs) + catalog = linuxCatalog; + + packages = forAllSystems (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + sandboxPackages // { + nixosandbox = pkgs.rustPlatform.buildRustPackage { + pname = "nixosandbox"; + version = "0.1.0"; + src = ./crates/nixosandbox; + cargoLock.lockFile = ./crates/nixosandbox/Cargo.lock; + }; + + default = self.packages.${system}.nixosandbox; + } + ); + + devShells = forAllSystems (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in { + default = pkgs.mkShell { + name = "nixosandbox-dev"; + buildInputs = with pkgs; [ + rustc + cargo + pkg-config + jq + ] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ + bubblewrap + ]; + }; + } + ); + }; +} diff --git a/nix/catalog.nix b/nix/catalog.nix new file mode 100644 index 0000000..ce9646c --- /dev/null +++ b/nix/catalog.nix @@ -0,0 +1,39 @@ +# nix/catalog.nix +# +# Unified package catalog merging AI agents from llm-agents.nix +# and standard development tools from nixpkgs. +# +# Usage: import ./catalog.nix { pkgs = ...; llm-agents-pkgs = ...; } +{ pkgs, llm-agents-pkgs }: +{ + # All packages from numtide/llm-agents.nix. + # 'default' is a meta-alias present in every flake packages output; strip it. + # If llm-agents-pkgs is empty (e.g. flake input missing x86_64-linux support), + # emit a trace warning rather than silently returning an empty catalog. + agents = + let filtered = builtins.removeAttrs llm-agents-pkgs [ "default" ]; + in if filtered == {} + then builtins.trace + "nixosandbox WARNING: llm-agents-pkgs is empty — catalog will have no agents. Check that the llm-agents.nix flake input exposes x86_64-linux packages." + {} + else filtered; + + tools = { + # Languages & runtimes + inherit (pkgs) python312 nodejs_22 rustc cargo go; + # Version control + inherit (pkgs) git; + # Core utilities + inherit (pkgs) coreutils bash findutils gnugrep gnused gawk; + # Build tools + inherit (pkgs) gnumake gcc gnutar gzip; + # Network + inherit (pkgs) curl cacert; + # Search & text + inherit (pkgs) ripgrep fd jq less; + # Shells + inherit (pkgs) zsh; + # Nix itself + inherit (pkgs) nix; + }; +} diff --git a/nix/mkAgentSandbox.nix b/nix/mkAgentSandbox.nix new file mode 100644 index 0000000..f4f4b0e --- /dev/null +++ b/nix/mkAgentSandbox.nix @@ -0,0 +1,29 @@ +# nix/mkAgentSandbox.nix +# +# Catalog-aware sandbox composition layer. +# Resolves package names from the unified catalog and delegates to mkSandboxRootfs. +# +# Usage: +# mkAgentSandbox = import ./mkAgentSandbox.nix { inherit catalog mkSandboxRootfs; }; +# mkAgentSandbox { name = "review"; packages = [ "claude-code" "git" "ripgrep" ]; } +{ catalog, mkSandboxRootfs }: + +{ name +, packages ? [] +, extraPackages ? [] +, env ? {} +}: + +let + resolvePackage = pname: + if catalog.agents ? ${pname} then catalog.agents.${pname} + else if catalog.tools ? ${pname} then catalog.tools.${pname} + else throw "nixosandbox: unknown package '${pname}' -- not found in agents or tools catalog. Run 'nixosandbox catalog' to see available packages."; + + resolvedPackages = map resolvePackage packages; + allPackages = resolvedPackages ++ extraPackages; +in + mkSandboxRootfs { + inherit name env; + packages = allPackages; + } diff --git a/nix/mkSandboxRootfs.nix b/nix/mkSandboxRootfs.nix new file mode 100644 index 0000000..3473b56 --- /dev/null +++ b/nix/mkSandboxRootfs.nix @@ -0,0 +1,89 @@ +# nix/mkSandboxRootfs.nix +# +# Builds a minimal rootfs directory tree from a list of Nix packages. +# The output is suitable for bwrap --ro-bind /. +# +# Usage: mkSandboxRootfs { name = "my-env"; packages = [ pkgs.nodejs pkgs.git ]; } +{ pkgs }: + +{ name, packages, env ? {} }: + +let + # Create a merged environment with all requested packages + mergedEnv = pkgs.buildEnv { + name = "sandbox-env-${name}"; + paths = packages; + pathsToLink = [ "/bin" "/lib" "/lib64" "/share" "/etc" "/include" ]; + extraOutputsToInstall = [ "out" ]; + }; +in +pkgs.runCommand "sandbox-${name}" { + passthru = { inherit name env; }; +} '' + mkdir -p $out/{bin,lib,lib64,etc,usr/bin,tmp,dev,proc,workspace,home/sandbox,cache,nix/store} + + # Symlink all binaries from the merged environment + if [ -d "${mergedEnv}/bin" ]; then + for f in ${mergedEnv}/bin/*; do + ln -sf "$f" "$out/bin/$(basename $f)" + done + fi + + # Symlink libraries + if [ -d "${mergedEnv}/lib" ]; then + for f in ${mergedEnv}/lib/*; do + ln -sf "$f" "$out/lib/$(basename $f)" + done + fi + if [ -d "${mergedEnv}/lib64" ]; then + for f in ${mergedEnv}/lib64/*; do + ln -sf "$f" "$out/lib64/$(basename $f)" + done + fi + + # Symlink share (man pages, etc.) + if [ -d "${mergedEnv}/share" ]; then + ln -sf "${mergedEnv}/share" "$out/share" + fi + + # /usr/bin/env -- needed for #!/usr/bin/env shebangs + ln -sf "${mergedEnv}/bin/env" "$out/usr/bin/env" 2>/dev/null || \ + ln -sf "${pkgs.coreutils}/bin/env" "$out/usr/bin/env" + + # /etc/ssl/certs -- CA certificates + mkdir -p $out/etc/ssl/certs + if [ -e "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ]; then + ln -sf "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" "$out/etc/ssl/certs/ca-certificates.crt" + ln -sf "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" "$out/etc/ssl/certs/ca-bundle.crt" + fi + + # /etc/passwd and /etc/group -- minimal entries for sandbox user + cat > $out/etc/passwd <<'PASSWD' +root:x:0:0:root:/root:/bin/bash +sandbox:x:1000:1000:sandbox:/home/sandbox:/bin/bash +nobody:x:65534:65534:nobody:/nonexistent:/usr/bin/nologin +PASSWD + + cat > $out/etc/group <<'GROUP' +root:x:0: +sandbox:x:1000: +nobody:x:65534: +GROUP + + # /etc/nsswitch.conf + cat > $out/etc/nsswitch.conf <<'NSS' +passwd: files +group: files +hosts: files dns +NSS + + # /etc/hosts -- minimal + cat > $out/etc/hosts <<'HOSTS' +127.0.0.1 localhost +::1 localhost +HOSTS + + # Nix store reference -- keep a file that references the merged env + # so nix-collect-garbage knows this rootfs depends on those packages + echo "${mergedEnv}" > $out/.nix-env-reference +'' diff --git a/nix/packages.json b/nix/packages.json new file mode 100644 index 0000000..d93b986 --- /dev/null +++ b/nix/packages.json @@ -0,0 +1,54 @@ +{ + "node": { "attr": "nodejs_22", "aliases": ["nodejs", "node.js", "node22"], "extra": [] }, + "python": { "attr": "python312", "aliases": ["python3", "py", "python3.12"], "extra": ["python312Packages.pip"] }, + "rust": { "attr": "rustc", "aliases": ["rustlang"], "extra": ["cargo", "rustfmt", "clippy"] }, + "go": { "attr": "go", "aliases": ["golang", "go-lang"], "extra": [] }, + "git": { "attr": "git", "aliases": [], "extra": [] }, + "curl": { "attr": "curl", "aliases": ["libcurl"], "extra": [] }, + "wget": { "attr": "wget", "aliases": [], "extra": [] }, + "jq": { "attr": "jq", "aliases": [], "extra": [] }, + "ripgrep": { "attr": "ripgrep", "aliases": ["rg"], "extra": [] }, + "fd": { "attr": "fd", "aliases": ["fd-find"], "extra": [] }, + "tree": { "attr": "tree", "aliases": [], "extra": [] }, + "tmux": { "attr": "tmux", "aliases": [], "extra": [] }, + "vim": { "attr": "vim", "aliases": ["vi"], "extra": [] }, + "neovim": { "attr": "neovim", "aliases": ["nvim"], "extra": [] }, + "postgres": { "attr": "postgresql_16", "aliases": ["postgresql", "pg", "psql"], "extra": [] }, + "redis": { "attr": "redis", "aliases": [], "extra": [] }, + "sqlite": { "attr": "sqlite", "aliases": ["sqlite3"], "extra": [] }, + "make": { "attr": "gnumake", "aliases": ["gmake"], "extra": [] }, + "cmake": { "attr": "cmake", "aliases": [], "extra": [] }, + "gcc": { "attr": "gcc", "aliases": ["gnu-cc"], "extra": [] }, + "clang": { "attr": "clang", "aliases": ["llvm-clang"], "extra": ["llvmPackages.llvm"] }, + "ruby": { "attr": "ruby", "aliases": ["ruby3"], "extra": [] }, + "php": { "attr": "php", "aliases": ["php83"], "extra": [] }, + "java": { "attr": "jdk", "aliases": ["jdk", "openjdk"], "extra": [] }, + "maven": { "attr": "maven", "aliases": ["mvn"], "extra": [] }, + "terraform": { "attr": "terraform", "aliases": ["tf"], "extra": [] }, + "kubectl": { "attr": "kubectl", "aliases": ["kube"], "extra": [] }, + "aws": { "attr": "awscli2", "aliases": ["aws-cli", "awscli"], "extra": [] }, + "ssh": { "attr": "openssh", "aliases": ["openssh"], "extra": [] }, + "openssl": { "attr": "openssl", "aliases": ["libssl"], "extra": [] }, + "htop": { "attr": "htop", "aliases": [], "extra": [] }, + "less": { "attr": "less", "aliases": [], "extra": [] }, + "unzip": { "attr": "unzip", "aliases": [], "extra": [] }, + "zip": { "attr": "zip", "aliases": [], "extra": [] }, + "tar": { "attr": "gnutar", "aliases": ["gtar"], "extra": [] }, + "gzip": { "attr": "gzip", "aliases": ["gz"], "extra": [] }, + "bash": { "attr": "bash", "aliases": [], "extra": [] }, + "zsh": { "attr": "zsh", "aliases": [], "extra": [] }, + "fish": { "attr": "fish", "aliases": [], "extra": [] }, + "coreutils": { "attr": "coreutils", "aliases": [], "extra": [] }, + "findutils": { "attr": "findutils", "aliases": ["find"], "extra": [] }, + "grep": { "attr": "gnugrep", "aliases": ["gnugrep"], "extra": [] }, + "sed": { "attr": "gnused", "aliases": ["gnused"], "extra": [] }, + "awk": { "attr": "gawk", "aliases": ["gawk"], "extra": [] }, + "cacert": { "attr": "cacert", "aliases": ["ca-certificates", "ca-certs"], "extra": [] }, + "netcat": { "attr": "netcat-gnu", "aliases": ["nc", "ncat"], "extra": [] }, + "dig": { "attr": "dig", "aliases": ["bind-tools", "nslookup"], "extra": [] }, + "inetutils": { "attr": "inetutils", "aliases": ["hostname", "ping"], "extra": [] }, + "imagemagick": { "attr": "imagemagick", "aliases": ["convert", "magick"], "extra": [] }, + "ffmpeg": { "attr": "ffmpeg", "aliases": ["ffprobe"], "extra": [] }, + "pandoc": { "attr": "pandoc", "aliases": [], "extra": [] }, + "latex": { "attr": "texliveFull", "aliases": ["texlive", "pdflatex"], "extra": [] } +} diff --git a/nix/profiles/build-install.json b/nix/profiles/build-install.json new file mode 100644 index 0000000..f95c328 --- /dev/null +++ b/nix/profiles/build-install.json @@ -0,0 +1,8 @@ +{ + "name": "build-install", + "packages": ["nodejs_22", "python312", "rustc", "cargo", "git", "curl", "cacert", "coreutils", "bash", "gnugrep", "gnused", "gawk", "findutils", "gnutar", "gzip", "gnumake", "gcc"], + "env": {}, + "network": "full", + "namespaces": ["pid", "mount", "uts", "ipc"], + "writable": ["/workspace", "/home/sandbox", "/cache", "/tmp"] +} diff --git a/nix/profiles/debug-network.json b/nix/profiles/debug-network.json new file mode 100644 index 0000000..8640ede --- /dev/null +++ b/nix/profiles/debug-network.json @@ -0,0 +1,8 @@ +{ + "name": "debug-network", + "packages": ["nodejs_22", "python312", "git", "curl", "cacert", "coreutils", "bash", "inetutils", "netcat-gnu", "dig"], + "env": {}, + "network": "full", + "namespaces": ["pid", "mount", "uts", "ipc"], + "writable": ["/workspace", "/home/sandbox", "/cache", "/tmp"] +} diff --git a/nix/profiles/offline-review.json b/nix/profiles/offline-review.json new file mode 100644 index 0000000..564c100 --- /dev/null +++ b/nix/profiles/offline-review.json @@ -0,0 +1,8 @@ +{ + "name": "offline-review", + "packages": ["git", "cacert", "coreutils", "bash", "gnugrep", "gnused", "gawk", "findutils", "jq"], + "env": {}, + "network": "off", + "namespaces": ["pid", "mount", "uts", "ipc", "net"], + "writable": ["/workspace", "/home/sandbox", "/tmp"] +} diff --git a/nix/profiles/strict.json b/nix/profiles/strict.json new file mode 100644 index 0000000..4c392b9 --- /dev/null +++ b/nix/profiles/strict.json @@ -0,0 +1,8 @@ +{ + "name": "strict", + "packages": ["coreutils", "bash", "cacert"], + "env": {}, + "network": "off", + "namespaces": ["pid", "mount", "uts", "ipc", "net"], + "writable": ["/tmp"] +} diff --git a/nix/shell.nix b/nix/shell.nix deleted file mode 100644 index 05258bf..0000000 --- a/nix/shell.nix +++ /dev/null @@ -1,203 +0,0 @@ -{ pkgs ? import {} }: - -pkgs.mkShell { - name = "nixos-sandbox"; - - buildInputs = with pkgs; [ - # Rust toolchain (for building sandbox-api) - rustc - cargo - pkg-config - openssl - - # Node.js - nodejs_22 - - # Browsers - chromium - firefox - - # Browser dependencies (for chromiumoxide/Playwright) - glib - nss - nspr - dbus - atk - at-spi2-atk - cups - libdrm - expat - libxkbcommon - at-spi2-core - xorg.libX11 - xorg.libXcomposite - xorg.libXdamage - xorg.libXext - xorg.libXfixes - xorg.libXrandr - xorg.libxcb - mesa - pango - cairo - alsa-lib - gdk-pixbuf - gtk3 - libGL - - # Display & VNC - xorg.xorgserver # Provides Xvfb binary - xvfb-run - x11vnc - novnc - tigervnc - xdotool - scrot - imagemagick - ffmpeg-full # Screen recording - - # Network utilities - inetutils # Provides hostname command - - # Fonts for browser rendering - dejavu_fonts - liberation_ttf - noto-fonts - noto-fonts-color-emoji - font-awesome - fontconfig - - # D-Bus for browser communication - dbus - - # Development tools - git - curl - wget - jq - ripgrep - fd - tree - tmux - - # Languages for code execution - python312 - nodejs_22 - go - gcc - gnumake - - # Shell utilities - bash - zsh - coreutils - procps - util-linux - - # File utilities - file - unzip - zip - gnutar - gzip - ]; - - shellHook = '' - export HOME=/home/sandbox - export WORKSPACE=$HOME/workspace - export SKILLS_DIR=$HOME/skills - export PATH=$WORKSPACE/node_modules/.bin:$PATH - - # Setup OpenSSL for Rust builds - export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig:$PKG_CONFIG_PATH" - export OPENSSL_DIR="${pkgs.openssl.dev}" - export OPENSSL_LIB_DIR="${pkgs.openssl.out}/lib" - export OPENSSL_INCLUDE_DIR="${pkgs.openssl.dev}/include" - - # Set up library paths for browsers - export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath [ - pkgs.glib - pkgs.nss - pkgs.nspr - pkgs.dbus - pkgs.atk - pkgs.at-spi2-atk - pkgs.cups - pkgs.libdrm - pkgs.expat - pkgs.libxkbcommon - pkgs.at-spi2-core - pkgs.xorg.libX11 - pkgs.xorg.libXcomposite - pkgs.xorg.libXdamage - pkgs.xorg.libXext - pkgs.xorg.libXfixes - pkgs.xorg.libXrandr - pkgs.xorg.libxcb - pkgs.mesa - pkgs.pango - pkgs.cairo - pkgs.alsa-lib - pkgs.gdk-pixbuf - pkgs.gtk3 - pkgs.libGL - pkgs.openssl - ]}:$LD_LIBRARY_PATH - - # Create directories - mkdir -p $HOME $WORKSPACE $SKILLS_DIR /tmp/.X11-unix /tmp/dbus - - # Setup fontconfig for browser rendering - export FONTCONFIG_PATH=${pkgs.fontconfig.out}/etc/fonts - export FONTCONFIG_FILE=${pkgs.fontconfig.out}/etc/fonts/fonts.conf - mkdir -p $HOME/.cache/fontconfig - fc-cache -f 2>/dev/null || true - - # Start D-Bus session for browser communication - if [ -z "$DBUS_SESSION_BUS_ADDRESS" ]; then - export DBUS_SESSION_BUS_ADDRESS="unix:path=/tmp/dbus/session_bus_socket" - if ! pgrep -x "dbus-daemon" > /dev/null; then - dbus-daemon --session --address="$DBUS_SESSION_BUS_ADDRESS" --nofork --nopidfile & - sleep 0.5 - fi - fi - - # Start Xvfb virtual display - if ! pgrep -x "Xvfb" > /dev/null; then - echo "Starting Xvfb..." - Xvfb :99 -screen 0 1920x1080x24 -ac & - fi - export DISPLAY=:99 - - # Wait for X11 socket to be ready - echo "Waiting for X11 display..." - for i in $(seq 1 30); do - if [ -e /tmp/.X11-unix/X99 ]; then - echo "X11 display ready" - break - fi - sleep 0.5 - done - if [ ! -e /tmp/.X11-unix/X99 ]; then - echo "WARNING: X11 display not ready after 15s" - fi - - # Start x11vnc - if ! pgrep -x "x11vnc" > /dev/null; then - x11vnc -display :99 -forever -shared -rfbport 5900 -bg -nopw - fi - - # Start noVNC - if ! pgrep -f "novnc" > /dev/null; then - ${pkgs.novnc}/bin/novnc --listen 6080 --vnc localhost:5900 & - fi - - # Set browser executable for chromiumoxide - export BROWSER_EXECUTABLE=${pkgs.chromium}/bin/chromium - echo "Using system Chromium: $BROWSER_EXECUTABLE" - - echo "🚀 NixOS Sandbox Ready" - echo " API: http://localhost:8080" - echo " VNC: vnc://localhost:5900" - echo " noVNC: http://localhost:6080" - ''; -} diff --git a/packages/pi-sandbox-extension/package.json b/packages/pi-sandbox-extension/package.json new file mode 100644 index 0000000..6d28212 --- /dev/null +++ b/packages/pi-sandbox-extension/package.json @@ -0,0 +1,20 @@ +{ + "name": "@nixosandbox/extension", + "version": "0.2.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@sinclair/typebox": "^0.34.0", + "playwright-core": "^1.50.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/pi-sandbox-extension/src/browser.ts b/packages/pi-sandbox-extension/src/browser.ts new file mode 100644 index 0000000..7ae2fb0 --- /dev/null +++ b/packages/pi-sandbox-extension/src/browser.ts @@ -0,0 +1,160 @@ +/** + * Browser Manager + * + * Manages a shared Playwright browser instance with session-scoped pages. + * Lazy-initialized on first use. Each sandbox session gets one persistent + * page that maintains state across navigation/click/type calls. + */ + +import type { Browser, BrowserContext, Page } from "playwright-core"; +import { chromium } from "playwright-core"; + +export class BrowserManager { + private browser: Browser | null = null; + private context: BrowserContext | null = null; + private pages: Map = new Map(); + private launchPromise: Promise | null = null; + + /** + * Launch the browser if not already running. + * Uses PLAYWRIGHT_CHROMIUM_PATH env var or system chromium. + * Guards against concurrent callers racing to launch two browsers. + */ + private ensureBrowser(): Promise { + if (!this.launchPromise) { + this.launchPromise = this._doLaunch(); + } + return this.launchPromise; + } + + private async _doLaunch(): Promise { + const executablePath = process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined; + this.browser = await chromium.launch({ + headless: true, + executablePath, + }); + this.context = await this.browser.newContext(); + return this.context; + } + + /** + * Get or create a page for the given session. + */ + async getOrCreatePage(sessionId: string): Promise { + const existing = this.pages.get(sessionId); + if (existing && !existing.isClosed()) return existing; + + const ctx = await this.ensureBrowser(); + const page = await ctx.newPage(); + this.pages.set(sessionId, page); + return page; + } + + /** + * Close the page for a specific session (e.g., on session teardown). + */ + async closePage(sessionId: string): Promise { + const page = this.pages.get(sessionId); + if (page && !page.isClosed()) { + await page.close(); + } + this.pages.delete(sessionId); + } + + /** + * Execute a browser action for a session. + */ + async execute( + sessionId: string, + action: string, + params: { + url?: string; + selector?: string; + text?: string; + script?: string; + }, + ): Promise { + if (action === "close") { + await this.closePage(sessionId); + return "Browser page closed."; + } + + const page = await this.getOrCreatePage(sessionId); + + switch (action) { + case "goto": { + if (!params.url) throw new Error("url is required for goto action"); + const response = await page.goto(params.url, { + waitUntil: "domcontentloaded", + }); + const title = await page.title(); + const textContent = await page.evaluate(() => { + const body = document.body; + return body ? body.innerText.slice(0, 4000) : ""; + }); + const status = response?.status() ?? 0; + return [ + `url: ${page.url()}`, + `status: ${status}`, + `title: ${title}`, + "--- content ---", + textContent, + ].join("\n"); + } + + case "screenshot": { + const buffer = await page.screenshot({ type: "png" }); + return buffer.toString("base64"); + } + + case "evaluate": { + if (!params.script) + throw new Error("script is required for evaluate action"); + const result = await page.evaluate(params.script); + return JSON.stringify(result); + } + + case "click": { + if (!params.selector) + throw new Error("selector is required for click action"); + await page.click(params.selector); + return `Clicked: ${params.selector}`; + } + + case "type": { + if (!params.selector) + throw new Error("selector is required for type action"); + if (!params.text) + throw new Error("text is required for type action"); + await page.fill(params.selector, params.text); + return `Typed into: ${params.selector}`; + } + + default: + throw new Error( + `Unknown browser action: "${action}". Valid: goto, screenshot, evaluate, click, type, close`, + ); + } + } + + /** + * Shut down the browser entirely. Called on extension teardown. + */ + async shutdown(): Promise { + for (const [, page] of this.pages) { + if (!page.isClosed()) { + await page.close(); + } + } + this.pages.clear(); + if (this.context) { + await this.context.close(); + this.context = null; + } + if (this.browser) { + await this.browser.close(); + this.browser = null; + } + this.launchPromise = null; + } +} diff --git a/packages/pi-sandbox-extension/src/cli-client.ts b/packages/pi-sandbox-extension/src/cli-client.ts new file mode 100644 index 0000000..2b43a26 --- /dev/null +++ b/packages/pi-sandbox-extension/src/cli-client.ts @@ -0,0 +1,147 @@ +/** + * CLI Client + * + * Thin wrappers for shelling out to the nixosandbox CLI binary. + * Replaces session-manager.ts + runtime-client.ts with direct CLI delegation. + */ + +import { execFileSync, spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import type { StreamEvent, ResultPayload } from "./contract.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface SessionMetadata { + sessionId: string; + name: string; + profile: string; + rootfsPath: string; + workspace: string; + createdAt: string; + lastExecAt: string | null; + agent: string | null; + description: string | null; +} + +export interface StatusResponse extends SessionMetadata { + isolation: string; + network: string; +} + +export interface ExecResult { + events: Array>; + exitCode: number; +} + +export interface CreateOptions { + profile?: string; + workspace?: string; + name?: string; + agent?: string; + description?: string; + withPackages?: string[]; + network?: string; +} + +export interface CatalogEntry { + description: string; +} + +export interface CatalogResponse { + agents: Record; + tools: Record; +} + +// --------------------------------------------------------------------------- +// Functions +// --------------------------------------------------------------------------- + +export function createSession(binary: string, opts: CreateOptions): SessionMetadata { + const args = ["create", "--json"]; + if (opts.withPackages && opts.withPackages.length > 0) { + args.push("--with", opts.withPackages.join(",")); + if (opts.network) { + args.push("--network", opts.network); + } + } else if (opts.profile) { + args.push("--profile", opts.profile); + } + if (opts.workspace) { args.push("--workspace", opts.workspace); } + if (opts.name) { args.push("--name", opts.name); } + if (opts.agent) { args.push("--agent", opts.agent); } + if (opts.description) { args.push("--description", opts.description); } + + const stdout = execFileSync(binary, args, { encoding: "utf-8" }); + return JSON.parse(stdout.trim()) as SessionMetadata; +} + +export function statusSession(binary: string, sessionId: string): StatusResponse { + const stdout = execFileSync(binary, ["status", sessionId, "--json"], { + encoding: "utf-8", + }); + return JSON.parse(stdout.trim()) as StatusResponse; +} + +export function listSessions(binary: string): SessionMetadata[] { + const stdout = execFileSync(binary, ["list", "--json"], { + encoding: "utf-8", + }); + return JSON.parse(stdout.trim()) as SessionMetadata[]; +} + +export function destroySession(binary: string, sessionId: string): void { + execFileSync(binary, ["destroy", sessionId], { stdio: "pipe" }); +} + +export function catalogPackages(binary: string, filter?: string): CatalogResponse { + const args = ["catalog", "--json"]; + if (filter) { args.push("--filter", filter); } + const stdout = execFileSync(binary, args, { encoding: "utf-8" }); + return JSON.parse(stdout.trim()) as CatalogResponse; +} + +export async function execCommand( + binary: string, + sessionId: string, + command: string[], + opts?: { env?: NodeJS.ProcessEnv; timeoutMs?: number }, +): Promise { + const args = ["exec", "--json", sessionId, "--", ...command]; + + return new Promise((resolve, reject) => { + const child = spawn(binary, args, { + stdio: ["pipe", "pipe", "pipe"], + env: opts?.env ?? process.env, + }); + + const events: ExecResult["events"] = []; + const rl = createInterface({ input: child.stdout! }); + + rl.on("line", (line) => { + try { + events.push(JSON.parse(line)); + } catch { + // Ignore unparseable lines + } + }); + + let timer: ReturnType | undefined; + if (opts?.timeoutMs) { + timer = setTimeout(() => { + child.kill("SIGTERM"); + }, opts.timeoutMs); + } + + child.on("exit", (code) => { + if (timer) clearTimeout(timer); + resolve({ events, exitCode: code ?? 1 }); + }); + + child.on("error", (err) => { + if (timer) clearTimeout(timer); + reject(err); + }); + }); +} diff --git a/packages/pi-sandbox-extension/src/contract.ts b/packages/pi-sandbox-extension/src/contract.ts new file mode 100644 index 0000000..65f1e4f --- /dev/null +++ b/packages/pi-sandbox-extension/src/contract.ts @@ -0,0 +1,203 @@ +/** + * Pi Sandbox NDJSON Protocol Contract + * + * FROZEN — changes require a protocol version bump. + * Protocol version: 1 + * + * This file defines the TypeBox schemas and TypeScript interfaces for + * outbound messages received from `nixosandbox exec --json` NDJSON output. + */ + +import { Type, type Static } from "@sinclair/typebox"; + +// --------------------------------------------------------------------------- +// Protocol version +// --------------------------------------------------------------------------- + +export const PROTOCOL_VERSION = 1 as const; + +// --------------------------------------------------------------------------- +// Error and warning codes +// --------------------------------------------------------------------------- + +export type ErrorCode = + | "VERSION_MISMATCH" + | "RW_TARGET_NOT_ALLOWED" + | "COMMAND_DENIED" + | "INVALID_MOUNT" + | "MISSING_REQUIRED_FIELD"; + +export type WarningCode = + | "ALLOWLIST_NOT_ENFORCED" + | "NAMESPACE_DEGRADED" + | "RESOURCE_LIMIT_IGNORED" + | "DNS_RESOLUTION_PARTIAL" + | "ALLOWLIST_DNS_FAILED" + | "ENFORCEMENT_LEAK" + | "IPTABLES_NOT_FOUND" + | "DOCKER_NOT_AVAILABLE" + | "DOCKER_SIDECAR_RESTARTED"; + +// --------------------------------------------------------------------------- +// Rust -> TS messages (Outbound from the supervisor) +// --------------------------------------------------------------------------- + +export const EffectiveNetworkSchema = Type.Object({ + requested: Type.Union([ + Type.Literal("off"), + Type.Literal("full"), + Type.Literal("allowlist"), + ]), + actual: Type.Union([ + Type.Literal("off"), + Type.Literal("full"), + Type.Literal("allowlist"), + ]), + enforcement: Type.Union([ + Type.Literal("enforced"), + Type.Literal("observed"), + Type.Literal("none"), + Type.Literal("best_effort"), + ]), + degraded: Type.Boolean(), +}); +export type EffectiveNetwork = Static; + +export const EffectiveStateSchema = Type.Object({ + network: EffectiveNetworkSchema, + namespacesApplied: Type.Array(Type.String()), + envApplied: Type.Array(Type.String()), + resolvedAllowlist: Type.Array( + Type.Object({ + hostname: Type.String(), + ips: Type.Array(Type.String()), + resolved: Type.Boolean(), + }) + ), + isolationBackend: Type.Union([ + Type.Literal("native"), + Type.Literal("docker"), + Type.Literal("none"), + ]), +}); +export type EffectiveState = Static; + +export interface ValidationError { + code: ErrorCode; + message: string; + field?: string; +} + +export interface ValidationWarning { + code: WarningCode; + message: string; +} + +export interface ValidationPayload { + ok: boolean; + errors: ValidationError[]; + warnings: ValidationWarning[]; + effectiveState: EffectiveState | null; +} + +export interface ValidationMessage { + type: "validation"; + v: number; + payload: ValidationPayload; +} + +// --------------------------------------------------------------------------- +// Streamed events +// --------------------------------------------------------------------------- + +export interface StdoutEvent { + type: "stdout"; + sequence: number; + ts: number; + payload: { data: string }; +} + +export interface StderrEvent { + type: "stderr"; + sequence: number; + ts: number; + payload: { data: string }; +} + +export interface LifecycleEvent { + type: "lifecycle"; + sequence: number; + ts: number; + payload: { + event: "started" | "cancel_requested" | "killing" | "exited"; + }; +} + +export interface NetworkEvent { + type: "network"; + sequence: number; + ts: number; + payload: { [key: string]: unknown }; +} + +export interface WarningEvent { + type: "warning"; + sequence: number; + ts: number; + payload: { code: WarningCode; message: string }; +} + +export type StreamEvent = + | StdoutEvent + | StderrEvent + | LifecycleEvent + | NetworkEvent + | WarningEvent; + +// --------------------------------------------------------------------------- +// Result message +// --------------------------------------------------------------------------- + +export type TerminalState = + | "clean_exit" + | "killed_on_cancel" + | "killed_on_timeout" + | "supervisor_crash" + | "partial_cleanup"; + +export interface ObservedConnection { + host: string; + port: number; + timestamp: number; +} + +export interface BlockedConnection { + host: string; + port: number; +} + +export interface ReconciliationHints { + terminalState: TerminalState; + workspaceModified: boolean; + cleanupSucceeded: boolean; +} + +export interface ResultPayload { + exitCode: number | null; + signal: string | null; + timedOut: boolean; + durationMs: number; + effectiveNetwork: EffectiveNetwork; + observedConnections: ObservedConnection[]; + wouldHaveBlocked: BlockedConnection[]; + resourcePeaks?: { [key: string]: number }; + reconciliationHints: ReconciliationHints; +} + +export interface ResultMessage { + type: "result"; + v: number; + payload: ResultPayload; +} + +export type OutboundMessage = ValidationMessage | StreamEvent | ResultMessage; diff --git a/packages/pi-sandbox-extension/src/crash-synthesis.ts b/packages/pi-sandbox-extension/src/crash-synthesis.ts new file mode 100644 index 0000000..da70755 --- /dev/null +++ b/packages/pi-sandbox-extension/src/crash-synthesis.ts @@ -0,0 +1,61 @@ +/** + * Crash Synthesis + * + * When the Rust runtime exits without emitting a "result" message, + * the TS client synthesizes one to ensure the extension always has + * a complete execution result. + */ + +import type { + EffectiveNetwork, + ResultPayload, + ValidationPayload, +} from "./contract.js"; + +/** + * Synthesize a crash result when the CLI process exits without emitting a result. + * + * @param lastValidation - Last validation received (if any) + * @param requestedNetworkMode - The network mode that was requested (e.g. "off", "full") + * @param exitCode - Process exit code + * @param signal - Signal that killed the process (if any) + * @param durationMs - Execution duration in milliseconds + */ +export function synthesizeCrashResult( + lastValidation: ValidationPayload | null, + requestedNetworkMode: string, + exitCode: number | null, + signal: string | null, + durationMs: number, +): ResultPayload { + let effectiveNetwork: EffectiveNetwork; + let workspaceModified: boolean; + + if (lastValidation?.effectiveState) { + effectiveNetwork = lastValidation.effectiveState.network; + workspaceModified = true; + } else { + effectiveNetwork = { + requested: requestedNetworkMode as any, + actual: "full", + enforcement: "none", + degraded: true, + }; + workspaceModified = false; + } + + return { + exitCode: exitCode ?? -1, + signal, + timedOut: false, + durationMs, + effectiveNetwork, + observedConnections: [], + wouldHaveBlocked: [], + reconciliationHints: { + terminalState: "supervisor_crash", + workspaceModified, + cleanupSucceeded: false, + }, + }; +} diff --git a/packages/pi-sandbox-extension/src/extension.ts b/packages/pi-sandbox-extension/src/extension.ts new file mode 100644 index 0000000..943ce67 --- /dev/null +++ b/packages/pi-sandbox-extension/src/extension.ts @@ -0,0 +1,404 @@ +/** + * Extension Tools + * + * Thin CLI adapter — all sandbox operations delegate to the nixosandbox binary. + */ + +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { normalize, resolve as resolvePath } from "node:path"; +import { Type } from "@sinclair/typebox"; +import type { TSchema } from "@sinclair/typebox"; +import { + createSession, + statusSession, + listSessions, + execCommand, + catalogPackages, +} from "./cli-client.js"; +import type { BrowserManager } from "./browser.js"; + +// --------------------------------------------------------------------------- +// Minimal ToolDefinition interface (avoids importing from Pi directly) +// --------------------------------------------------------------------------- + +export interface ToolDefinition { + name: string; + description: string; + parameters: TSchema; + execute(args: unknown): Promise; +} + +// --------------------------------------------------------------------------- +// Path safety +// --------------------------------------------------------------------------- + +function safePath(workspaceRoot: string, callerPath: string): string { + const resolved = resolvePath(workspaceRoot, normalize(callerPath)); + if (!resolved.startsWith(workspaceRoot + "/") && resolved !== workspaceRoot) { + throw new Error( + `Path traversal detected: "${callerPath}" resolves outside workspace`, + ); + } + return resolved; +} + +// --------------------------------------------------------------------------- +// Result formatter +// --------------------------------------------------------------------------- + +function formatExecResult(result: Awaited>): string { + const stdoutLines: string[] = []; + const stderrLines: string[] = []; + let exitCode: number | null = null; + let durationMs = 0; + + for (const event of result.events) { + if (event.type === "stdout") { + stdoutLines.push((event as any).payload.data); + } else if (event.type === "stderr") { + stderrLines.push((event as any).payload.data); + } else if (event.type === "result") { + const p = (event as any).payload; + exitCode = p.exitCode; + durationMs = p.durationMs; + } + } + + const lines: string[] = [ + `exit_code: ${exitCode ?? result.exitCode}`, + `duration_ms: ${durationMs}`, + ]; + + if (stdoutLines.length > 0) { + lines.push("--- stdout ---"); + lines.push(...stdoutLines); + } + + if (stderrLines.length > 0) { + lines.push("--- stderr ---"); + lines.push(...stderrLines); + } + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Battlecard formatter +// --------------------------------------------------------------------------- + +function formatBattlecard(status: Record): string { + const lines: string[] = []; + const fields = [ + ["Session", status.sessionId], + ["Name", status.name], + ["Description", status.description ?? "-"], + ["Agent", status.agent ?? "-"], + ["Profile", status.profile], + ["Created", status.createdAt], + ["Last Exec", status.lastExecAt ?? "-"], + ["Network", status.network ?? "-"], + ["Isolation", status.isolation ?? "-"], + ["Workspace", status.workspace], + ]; + + for (const [label, value] of fields) { + lines.push(`${label}: ${value}`); + } + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createSandboxTools( + binaryPath: string, + browserManager: BrowserManager, +): ToolDefinition[] { + // ------------------------------------------------------------------------- + // Tool: sandbox_run + // ------------------------------------------------------------------------- + const sandboxRun: ToolDefinition = { + name: "sandbox_run", + description: + "Run a command inside an isolated sandbox. " + + "Use 'with' to compose from catalog packages (call sandbox_catalog first to see available), " + + "or 'profile' for a built-in profile. Returns combined stdout/stderr and execution metadata.", + parameters: Type.Object({ + command: Type.Array(Type.String(), { + description: "Command and arguments to execute, e.g. [\"bash\", \"-c\", \"echo hello\"]", + minItems: 1, + }), + sessionId: Type.Optional( + Type.String({ description: "Reuse an existing session. Omit to create a new one." }), + ), + with: Type.Optional( + Type.Array(Type.String(), { + description: "Package names from the catalog (agents + tools). Mutually exclusive with profile.", + }), + ), + profile: Type.Optional( + Type.String({ description: "Built-in profile name. Defaults to build-install. Mutually exclusive with 'with'." }), + ), + network: Type.Optional( + Type.String({ description: "Network mode: 'off' for review/analysis, 'full' for build/install. Default: 'off'. Only used with 'with'." }), + ), + agent: Type.Optional( + Type.String({ description: "Agent runtime identifier, e.g. 'claude:opus-4-6'" }), + ), + description: Type.Optional( + Type.String({ description: "Purpose of this sandbox session" }), + ), + timeoutMs: Type.Optional( + Type.Number({ description: "Execution timeout in milliseconds." }), + ), + }), + async execute(args: unknown): Promise { + const { + command, + sessionId: maybeSessionId, + with: withPackages, + profile = withPackages ? undefined : "build-install", + network, + agent, + description, + timeoutMs, + } = (args ?? {}) as { + command: string[]; + sessionId?: string; + with?: string[]; + profile?: string; + network?: string; + agent?: string; + description?: string; + timeoutMs?: number; + }; + + let sid = maybeSessionId; + if (!sid) { + const meta = createSession(binaryPath, { + withPackages, + profile, + network, + agent, + description, + }); + sid = meta.sessionId; + } + + const result = await execCommand(binaryPath, sid, command, { timeoutMs }); + return formatExecResult(result); + }, + }; + + // ------------------------------------------------------------------------- + // Tool: sandbox_read_file + // ------------------------------------------------------------------------- + const sandboxReadFile: ToolDefinition = { + name: "sandbox_read_file", + description: "Read a file from the sandbox workspace.", + parameters: Type.Object({ + sessionId: Type.String({ description: "Session ID whose workspace to read from." }), + path: Type.String({ description: "Path relative to the workspace root." }), + }), + async execute(args: unknown): Promise { + const { sessionId, path: callerPath } = args as { + sessionId: string; + path: string; + }; + + const status = statusSession(binaryPath, sessionId); + const absPath = safePath(status.workspace, callerPath); + return readFileSync(absPath, "utf8"); + }, + }; + + // ------------------------------------------------------------------------- + // Tool: sandbox_write_file + // ------------------------------------------------------------------------- + const sandboxWriteFile: ToolDefinition = { + name: "sandbox_write_file", + description: "Write a file into the sandbox workspace.", + parameters: Type.Object({ + sessionId: Type.String({ description: "Session ID whose workspace to write into." }), + path: Type.String({ description: "Path relative to the workspace root." }), + content: Type.String({ description: "File content to write." }), + }), + async execute(args: unknown): Promise { + const { sessionId, path: callerPath, content } = args as { + sessionId: string; + path: string; + content: string; + }; + + const status = statusSession(binaryPath, sessionId); + const absPath = safePath(status.workspace, callerPath); + + const parentDir = absPath.substring(0, absPath.lastIndexOf("/")); + if (parentDir && parentDir !== status.workspace) { + mkdirSync(parentDir, { recursive: true }); + } + + writeFileSync(absPath, content, "utf8"); + return `Written ${content.length} bytes to ${callerPath}`; + }, + }; + + // ------------------------------------------------------------------------- + // Tool: sandbox_list_files + // ------------------------------------------------------------------------- + const sandboxListFiles: ToolDefinition = { + name: "sandbox_list_files", + description: "List files and directories in the sandbox workspace.", + parameters: Type.Object({ + sessionId: Type.String({ description: "Session ID whose workspace to list." }), + path: Type.Optional( + Type.String({ description: "Sub-path relative to the workspace root. Defaults to root." }), + ), + }), + async execute(args: unknown): Promise { + const { sessionId, path: callerPath = "." } = args as { + sessionId: string; + path?: string; + }; + + const status = statusSession(binaryPath, sessionId); + const absPath = safePath(status.workspace, callerPath); + + const entries = readdirSync(absPath, { withFileTypes: true }); + if (entries.length === 0) return "(empty directory)"; + + return entries + .map((e) => (e.isDirectory() ? `${e.name}/` : e.name)) + .sort() + .join("\n"); + }, + }; + + // ------------------------------------------------------------------------- + // Tool: sandbox_session_info + // ------------------------------------------------------------------------- + const sandboxSessionInfo: ToolDefinition = { + name: "sandbox_session_info", + description: + "Show sandbox session battlecard or list all sessions.", + parameters: Type.Object({ + sessionId: Type.Optional( + Type.String({ description: "Session ID for detailed battlecard. Omit to list all." }), + ), + }), + async execute(args: unknown): Promise { + const { sessionId } = args as { sessionId?: string }; + + if (sessionId) { + const status = statusSession(binaryPath, sessionId); + return formatBattlecard(status as unknown as Record); + } + + const sessions = listSessions(binaryPath); + if (sessions.length === 0) return "No sessions found."; + + return sessions + .map( + (s) => + `${s.sessionId} profile=${s.profile} agent=${s.agent ?? "-"} created=${s.createdAt}`, + ) + .join("\n"); + }, + }; + + // ------------------------------------------------------------------------- + // Tool: sandbox_catalog + // ------------------------------------------------------------------------- + const sandboxCatalog: ToolDefinition = { + name: "sandbox_catalog", + description: + "List available packages for sandbox composition. " + + "Returns agents (AI coding tools like claude-code, pi, codex) and tools (utilities like python312, git, ripgrep). " + + "Call this before sandbox_run with 'with' to see what packages are available.", + parameters: Type.Object({ + filter: Type.Optional( + Type.String({ description: "Filter results by name or description substring." }), + ), + }), + async execute(args: unknown): Promise { + const { filter } = (args ?? {}) as { filter?: string }; + const catalog = catalogPackages(binaryPath, filter); + + const lines: string[] = []; + + const agentNames = Object.keys(catalog.agents).sort(); + if (agentNames.length > 0) { + lines.push("Agents (AI coding tools):"); + for (const name of agentNames) { + lines.push(` ${name} ${catalog.agents[name].description}`); + } + lines.push(""); + } + + const toolNames = Object.keys(catalog.tools).sort(); + if (toolNames.length > 0) { + lines.push("Tools (utilities):"); + for (const name of toolNames) { + lines.push(` ${name} ${catalog.tools[name].description}`); + } + } + + return lines.join("\n"); + }, + }; + + // ------------------------------------------------------------------------- + // Tool: sandbox_browser + // ------------------------------------------------------------------------- + const sandboxBrowser: ToolDefinition = { + name: "sandbox_browser", + description: + "Interact with a web browser within a sandbox session. Supports goto, screenshot, evaluate, click, type, and close actions.", + parameters: Type.Object({ + sessionId: Type.String({ description: "Session ID to operate within." }), + action: Type.Union( + [ + Type.Literal("goto"), + Type.Literal("screenshot"), + Type.Literal("evaluate"), + Type.Literal("click"), + Type.Literal("type"), + Type.Literal("close"), + ], + { description: "Browser action to perform." }, + ), + url: Type.Optional(Type.String({ description: "URL to navigate to (goto action)." })), + selector: Type.Optional(Type.String({ description: "CSS selector (click/type actions)." })), + text: Type.Optional(Type.String({ description: "Text to type (type action)." })), + script: Type.Optional(Type.String({ description: "JavaScript to evaluate." })), + }), + async execute(args: unknown): Promise { + const { sessionId, action, url, selector, text, script } = args as { + sessionId: string; + action: string; + url?: string; + selector?: string; + text?: string; + script?: string; + }; + + return browserManager.execute(sessionId, action, { + url, + selector, + text, + script, + }); + }, + }; + + return [ + sandboxRun, + sandboxReadFile, + sandboxWriteFile, + sandboxListFiles, + sandboxSessionInfo, + sandboxCatalog, + sandboxBrowser, + ]; +} diff --git a/packages/pi-sandbox-extension/src/index.ts b/packages/pi-sandbox-extension/src/index.ts new file mode 100644 index 0000000..aa4a472 --- /dev/null +++ b/packages/pi-sandbox-extension/src/index.ts @@ -0,0 +1,74 @@ +/** + * Pi Sandbox Extension — entry point + * + * Default export: `sandboxExtension(pi)` registers all tools and lifecycle + * event handlers against the Pi host. + * + * All public types are also re-exported for consumers. + */ + +import { createSandboxTools } from "./extension.js"; +import { BrowserManager } from "./browser.js"; + +// --------------------------------------------------------------------------- +// Extension entry point +// --------------------------------------------------------------------------- + +export default function sandboxExtension( + pi: { + registerTool(tool: { + name: string; + description: string; + parameters: unknown; + execute(args: unknown): Promise; + }): void; + on(event: string, handler: (...args: unknown[]) => void | Promise): void; + }, + opts: { + binaryPath?: string; + } = {}, +): void { + const binaryPath = opts.binaryPath ?? "nixosandbox"; + const browserManager = new BrowserManager(); + + // Register tools + // Pi requires `required` array in JSON Schema; TypeBox omits it for all-optional schemas + const tools = createSandboxTools(binaryPath, browserManager); + for (const tool of tools) { + const params = tool.parameters as Record; + if (params.type === "object" && !params.required) { + params.required = []; + } + pi.registerTool(tool); + } + + // Lifecycle: on session_shutdown → shut down browser + pi.on("session_shutdown", () => { + browserManager.shutdown().catch(() => {}); + }); +} + +// --------------------------------------------------------------------------- +// Public type re-exports +// --------------------------------------------------------------------------- + +export * from "./contract.js"; +export { synthesizeCrashResult } from "./crash-synthesis.js"; +export type { ToolDefinition } from "./extension.js"; +export { createSandboxTools } from "./extension.js"; +export type { + SessionMetadata, + StatusResponse, + ExecResult, + CreateOptions, + CatalogEntry, + CatalogResponse, +} from "./cli-client.js"; +export { + createSession, + statusSession, + listSessions, + destroySession, + execCommand, + catalogPackages, +} from "./cli-client.js"; diff --git a/packages/pi-sandbox-extension/tsconfig.json b/packages/pi-sandbox-extension/tsconfig.json new file mode 100644 index 0000000..2e9d993 --- /dev/null +++ b/packages/pi-sandbox-extension/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/pi-sandbox-extension/vitest.config.ts b/packages/pi-sandbox-extension/vitest.config.ts new file mode 100644 index 0000000..ae847ff --- /dev/null +++ b/packages/pi-sandbox-extension/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/sandbox-rs/.gitignore b/sandbox-rs/.gitignore deleted file mode 100644 index b83d222..0000000 --- a/sandbox-rs/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/target/ diff --git a/sandbox-rs/Cargo.lock b/sandbox-rs/Cargo.lock deleted file mode 100644 index 66e0066..0000000 --- a/sandbox-rs/Cargo.lock +++ /dev/null @@ -1,5048 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "alloy" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f609fb6392508278b276906d6247ea44f5777e448db95444fa39e89b7aee896a" -dependencies = [ - "alloy-core", - "alloy-signer", - "alloy-signer-local", -] - -[[package]] -name = "alloy-consensus" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3dcd2b4e208ce5477de90ccdcbd4bde2c8fb06af49a443974e92bb8f2c5e93f" -dependencies = [ - "alloy-eips", - "alloy-primitives", - "alloy-rlp", - "alloy-serde", - "alloy-trie", - "alloy-tx-macros", - "auto_impl", - "borsh", - "c-kzg", - "derive_more", - "either", - "k256", - "once_cell", - "rand 0.8.5", - "secp256k1", - "serde", - "serde_json", - "serde_with", - "thiserror 2.0.17", -] - -[[package]] -name = "alloy-consensus-any" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee5655f234985f5ab1e31bef7e02ed11f0a899468cf3300e061e1b96e9e11de0" -dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-primitives", - "alloy-rlp", - "alloy-serde", - "serde", -] - -[[package]] -name = "alloy-core" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d4087016b0896051dd3d03e0bedda2f4d4d1689af8addc8450288c63a9e5f68" -dependencies = [ - "alloy-primitives", -] - -[[package]] -name = "alloy-eip2124" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "741bdd7499908b3aa0b159bba11e71c8cddd009a2c2eb7a06e825f1ec87900a5" -dependencies = [ - "alloy-primitives", - "alloy-rlp", - "crc", - "serde", - "thiserror 2.0.17", -] - -[[package]] -name = "alloy-eip2930" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9441120fa82df73e8959ae0e4ab8ade03de2aaae61be313fbf5746277847ce25" -dependencies = [ - "alloy-primitives", - "alloy-rlp", - "borsh", - "serde", -] - -[[package]] -name = "alloy-eip7702" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2919c5a56a1007492da313e7a3b6d45ef5edc5d33416fdec63c0d7a2702a0d20" -dependencies = [ - "alloy-primitives", - "alloy-rlp", - "borsh", - "serde", - "thiserror 2.0.17", -] - -[[package]] -name = "alloy-eips" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6847d641141b92a1557094aa6c236cbe49c06fb24144d4a21fe6acb970c15888" -dependencies = [ - "alloy-eip2124", - "alloy-eip2930", - "alloy-eip7702", - "alloy-primitives", - "alloy-rlp", - "alloy-serde", - "auto_impl", - "borsh", - "c-kzg", - "derive_more", - "either", - "serde", - "serde_with", - "sha2", - "thiserror 2.0.17", -] - -[[package]] -name = "alloy-json-abi" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84e3cf01219c966f95a460c95f1d4c30e12f6c18150c21a30b768af2a2a29142" -dependencies = [ - "alloy-primitives", - "alloy-sol-type-parser", - "serde", - "serde_json", -] - -[[package]] -name = "alloy-json-rpc" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ab3330e491053e9608b2a315f147357bb8acb9377a988c1203f2e8e2b296c9" -dependencies = [ - "alloy-primitives", - "alloy-sol-types", - "http", - "serde", - "serde_json", - "thiserror 2.0.17", - "tracing", -] - -[[package]] -name = "alloy-network" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1e22ff194b1e34b4defd1e257e3fe4dce0eee37451c7757a1510d6b23e7379a" -dependencies = [ - "alloy-consensus", - "alloy-consensus-any", - "alloy-eips", - "alloy-json-rpc", - "alloy-network-primitives", - "alloy-primitives", - "alloy-rpc-types-any", - "alloy-rpc-types-eth", - "alloy-serde", - "alloy-signer", - "alloy-sol-types", - "async-trait", - "auto_impl", - "derive_more", - "futures-utils-wasm", - "serde", - "serde_json", - "thiserror 2.0.17", -] - -[[package]] -name = "alloy-network-primitives" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8a6cbb9f431bdad294eebb5af9b293d6979e633bfe5468d1e87c1421a858265" -dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-primitives", - "alloy-serde", - "serde", -] - -[[package]] -name = "alloy-primitives" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6a0fb18dd5fb43ec5f0f6a20be1ce0287c79825827de5744afaa6c957737c33" -dependencies = [ - "alloy-rlp", - "bytes", - "cfg-if", - "const-hex", - "derive_more", - "foldhash", - "hashbrown 0.16.1", - "indexmap 2.12.1", - "itoa", - "k256", - "keccak-asm", - "paste", - "proptest", - "rand 0.9.2", - "rapidhash", - "ruint", - "rustc-hash", - "serde", - "sha3", - "tiny-keccak", -] - -[[package]] -name = "alloy-rlp" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f70d83b765fdc080dbcd4f4db70d8d23fe4761f2f02ebfa9146b833900634b4" -dependencies = [ - "alloy-rlp-derive", - "arrayvec", - "bytes", -] - -[[package]] -name = "alloy-rlp-derive" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64b728d511962dda67c1bc7ea7c03736ec275ed2cf4c35d9585298ac9ccf3b73" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "alloy-rpc-types-any" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df0b34551ca2eab8ec83b56cb709ee5da991737282180d354a659b907f00dc" -dependencies = [ - "alloy-consensus-any", - "alloy-rpc-types-eth", - "alloy-serde", -] - -[[package]] -name = "alloy-rpc-types-eth" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f9f130511b8632686dfe6f9909b38d7ae4c68de3ce17d28991400646a39b25" -dependencies = [ - "alloy-consensus", - "alloy-consensus-any", - "alloy-eips", - "alloy-network-primitives", - "alloy-primitives", - "alloy-rlp", - "alloy-serde", - "alloy-sol-types", - "itertools 0.14.0", - "serde", - "serde_json", - "serde_with", - "thiserror 2.0.17", -] - -[[package]] -name = "alloy-serde" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "067b718d2e6ac1bb889341fcc7a250cfa49bcd3ba4f23923f1c1eb1f2b10cb7c" -dependencies = [ - "alloy-primitives", - "serde", - "serde_json", -] - -[[package]] -name = "alloy-signer" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acff6b251740ef473932386d3b71657d3825daebf2217fb41a7ef676229225d4" -dependencies = [ - "alloy-primitives", - "async-trait", - "auto_impl", - "either", - "elliptic-curve", - "k256", - "thiserror 2.0.17", -] - -[[package]] -name = "alloy-signer-local" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9129ef31975d987114c27c9930ee817cf3952355834d47f2fdf4596404507e8" -dependencies = [ - "alloy-consensus", - "alloy-network", - "alloy-primitives", - "alloy-signer", - "async-trait", - "k256", - "rand 0.8.5", - "thiserror 2.0.17", -] - -[[package]] -name = "alloy-sol-macro" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09eb18ce0df92b4277291bbaa0ed70545d78b02948df756bbd3d6214bf39a218" -dependencies = [ - "alloy-sol-macro-expander", - "alloy-sol-macro-input", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "alloy-sol-macro-expander" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95d9fa2daf21f59aa546d549943f10b5cce1ae59986774019fbedae834ffe01b" -dependencies = [ - "alloy-sol-macro-input", - "const-hex", - "heck 0.5.0", - "indexmap 2.12.1", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn 2.0.111", - "syn-solidity", - "tiny-keccak", -] - -[[package]] -name = "alloy-sol-macro-input" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9396007fe69c26ee118a19f4dee1f5d1d6be186ea75b3881adf16d87f8444686" -dependencies = [ - "const-hex", - "dunce", - "heck 0.5.0", - "macro-string", - "proc-macro2", - "quote", - "syn 2.0.111", - "syn-solidity", -] - -[[package]] -name = "alloy-sol-type-parser" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af67a0b0dcebe14244fc92002cd8d96ecbf65db4639d479f5fcd5805755a4c27" -dependencies = [ - "serde", - "winnow", -] - -[[package]] -name = "alloy-sol-types" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09aeea64f09a7483bdcd4193634c7e5cf9fd7775ee767585270cd8ce2d69dc95" -dependencies = [ - "alloy-json-abi", - "alloy-primitives", - "alloy-sol-macro", - "serde", -] - -[[package]] -name = "alloy-trie" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b77b56af09ead281337d06b1d036c88e2dc8a2e45da512a532476dbee94912b" -dependencies = [ - "alloy-primitives", - "alloy-rlp", - "arrayvec", - "derive_more", - "nybbles", - "serde", - "smallvec", - "tracing", -] - -[[package]] -name = "alloy-tx-macros" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04950a13cc4209d8e9b78f306e87782466bad8538c94324702d061ff03e211c9" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - -[[package]] -name = "ark-ff" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b3235cc41ee7a12aaaf2c575a2ad7b46713a8a50bda2fc3b003a04845c05dd6" -dependencies = [ - "ark-ff-asm 0.3.0", - "ark-ff-macros 0.3.0", - "ark-serialize 0.3.0", - "ark-std 0.3.0", - "derivative", - "num-bigint", - "num-traits", - "paste", - "rustc_version 0.3.3", - "zeroize", -] - -[[package]] -name = "ark-ff" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" -dependencies = [ - "ark-ff-asm 0.4.2", - "ark-ff-macros 0.4.2", - "ark-serialize 0.4.2", - "ark-std 0.4.0", - "derivative", - "digest 0.10.7", - "itertools 0.10.5", - "num-bigint", - "num-traits", - "paste", - "rustc_version 0.4.1", - "zeroize", -] - -[[package]] -name = "ark-ff" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" -dependencies = [ - "ark-ff-asm 0.5.0", - "ark-ff-macros 0.5.0", - "ark-serialize 0.5.0", - "ark-std 0.5.0", - "arrayvec", - "digest 0.10.7", - "educe", - "itertools 0.13.0", - "num-bigint", - "num-traits", - "paste", - "zeroize", -] - -[[package]] -name = "ark-ff-asm" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db02d390bf6643fb404d3d22d31aee1c4bc4459600aef9113833d17e786c6e44" -dependencies = [ - "quote", - "syn 1.0.109", -] - -[[package]] -name = "ark-ff-asm" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" -dependencies = [ - "quote", - "syn 1.0.109", -] - -[[package]] -name = "ark-ff-asm" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" -dependencies = [ - "quote", - "syn 2.0.111", -] - -[[package]] -name = "ark-ff-macros" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" -dependencies = [ - "num-bigint", - "num-traits", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "ark-ff-macros" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" -dependencies = [ - "num-bigint", - "num-traits", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "ark-ff-macros" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" -dependencies = [ - "num-bigint", - "num-traits", - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "ark-serialize" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671" -dependencies = [ - "ark-std 0.3.0", - "digest 0.9.0", -] - -[[package]] -name = "ark-serialize" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" -dependencies = [ - "ark-std 0.4.0", - "digest 0.10.7", - "num-bigint", -] - -[[package]] -name = "ark-serialize" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" -dependencies = [ - "ark-std 0.5.0", - "arrayvec", - "digest 0.10.7", - "num-bigint", -] - -[[package]] -name = "ark-std" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" -dependencies = [ - "num-traits", - "rand 0.8.5", -] - -[[package]] -name = "ark-std" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" -dependencies = [ - "num-traits", - "rand 0.8.5", -] - -[[package]] -name = "ark-std" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" -dependencies = [ - "num-traits", - "rand 0.8.5", -] - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -dependencies = [ - "serde", -] - -[[package]] -name = "asn1-rs" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror 1.0.69", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "async-attributes" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" -dependencies = [ - "quote", - "syn 1.0.109", -] - -[[package]] -name = "async-channel" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" -dependencies = [ - "concurrent-queue", - "event-listener 2.5.3", - "futures-core", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-global-executor" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" -dependencies = [ - "async-channel 2.5.0", - "async-executor", - "async-io", - "async-lock", - "blocking", - "futures-lite", - "once_cell", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix 1.1.3", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener 5.4.1", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel 2.5.0", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener 5.4.1", - "futures-lite", - "rustix 1.1.3", -] - -[[package]] -name = "async-signal" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix 1.1.3", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-std" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" -dependencies = [ - "async-attributes", - "async-channel 1.9.0", - "async-global-executor", - "async-io", - "async-lock", - "async-process", - "crossbeam-utils", - "futures-channel", - "futures-core", - "futures-io", - "futures-lite", - "gloo-timers", - "kv-log-macro", - "log", - "memchr", - "once_cell", - "pin-project-lite", - "pin-utils", - "slab", - "wasm-bindgen-futures", -] - -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "async-tungstenite" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5359381fd414fbdb272c48f2111c16cb0bb3447bfacd59311ff3736da9f6664" -dependencies = [ - "async-std", - "futures-io", - "futures-util", - "log", - "pin-project-lite", - "tokio", - "tungstenite", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "auto_impl" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "axum" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" -dependencies = [ - "axum-core", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "multer", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bitcoin-io" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" - -[[package]] -name = "bitcoin_hashes" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" -dependencies = [ - "bitcoin-io", - "hex-conservative", -] - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "bitvec" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel 2.5.0", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - -[[package]] -name = "blst" -version = "0.3.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcdb4c7013139a150f9fc55d123186dbfaba0d912817466282c73ac49e71fb45" -dependencies = [ - "cc", - "glob", - "threadpool", - "zeroize", -] - -[[package]] -name = "bon" -version = "3.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebeb9aaf9329dff6ceb65c689ca3db33dbf15f324909c60e4e5eef5701ce31b1" -dependencies = [ - "bon-macros", - "rustversion", -] - -[[package]] -name = "bon-macros" -version = "3.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645" -dependencies = [ - "darling", - "ident_case", - "prettyplease", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.111", -] - -[[package]] -name = "borsh" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" -dependencies = [ - "borsh-derive", - "cfg_aliases", -] - -[[package]] -name = "borsh-derive" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" -dependencies = [ - "once_cell", - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "bumpalo" -version = "3.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" - -[[package]] -name = "byte-slice-cast" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" -dependencies = [ - "serde", -] - -[[package]] -name = "c-kzg" -version = "2.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e00bf4b112b07b505472dbefd19e37e53307e2bfed5a79e0cc161d58ccd0e687" -dependencies = [ - "blst", - "cc", - "glob", - "hex", - "libc", - "once_cell", - "serde", -] - -[[package]] -name = "cc" -version = "1.2.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chromiumoxide" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8380ce7721cc895fe8a184c49d615fe755b0c9a3d7986355cee847439fff907f" -dependencies = [ - "async-std", - "async-tungstenite", - "base64", - "bytes", - "cfg-if", - "chromiumoxide_cdp", - "chromiumoxide_types", - "dunce", - "fnv", - "futures", - "futures-timer", - "pin-project-lite", - "reqwest", - "serde", - "serde_json", - "thiserror 1.0.69", - "tokio", - "tracing", - "url", - "which", - "winreg 0.52.0", -] - -[[package]] -name = "chromiumoxide_cdp" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cadbfb52fa0aeca43626f6c42ca04184b108b786f8e45198dc41a42aedcf2e50" -dependencies = [ - "chromiumoxide_pdl", - "chromiumoxide_types", - "serde", - "serde_json", -] - -[[package]] -name = "chromiumoxide_pdl" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c197aeb42872c5d4c923e7d8ad46d99a58fd0fec37f6491554ff677a6791d3c9" -dependencies = [ - "chromiumoxide_types", - "either", - "heck 0.4.1", - "once_cell", - "proc-macro2", - "quote", - "regex", - "serde", - "serde_json", -] - -[[package]] -name = "chromiumoxide_types" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "923486888790528d55ac37ec2f7483ed19eb8ccbb44701878e5856d1ceadf5d8" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "chrono" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "const-hex" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" -dependencies = [ - "cfg-if", - "cpufeatures", - "proptest", - "serde_core", -] - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "const_format" -version = "0.2.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" -dependencies = [ - "const_format_proc_macros", -] - -[[package]] -name = "const_format_proc_macros" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" -dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", -] - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "serde", - "strsim", - "syn 2.0.111", -] - -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "dashmap" -version = "6.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "data-encoding" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "der-parser" -version = "9.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom", - "num-bigint", - "num-traits", - "rusticata-macros", -] - -[[package]] -name = "deranged" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" -dependencies = [ - "powerfmt", - "serde_core", -] - -[[package]] -name = "derivative" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version 0.4.1", - "syn 2.0.111", - "unicode-xid", -] - -[[package]] -name = "digest" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" -dependencies = [ - "generic-array", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "dstack-sdk" -version = "0.1.2" -source = "git+https://github.com/Dstack-TEE/dstack#ed57afa75e0545f55bd64f3fbdf94e84b064ba12" -dependencies = [ - "alloy", - "anyhow", - "bon", - "dstack-sdk-types", - "hex", - "http", - "http-client-unix-domain-socket", - "reqwest", - "serde", - "serde_json", - "sha2", - "x509-parser", -] - -[[package]] -name = "dstack-sdk-types" -version = "0.1.2" -source = "git+https://github.com/Dstack-TEE/dstack#ed57afa75e0545f55bd64f3fbdf94e84b064ba12" -dependencies = [ - "anyhow", - "bon", - "hex", - "pkcs8", - "serde", - "serde_json", - "sha2", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "serdect", - "signature", - "spki", -] - -[[package]] -name = "educe" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" -dependencies = [ - "enum-ordinalize", - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -dependencies = [ - "serde", -] - -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest 0.10.7", - "ff", - "generic-array", - "group", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "serdect", - "subtle", - "zeroize", -] - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "enum-as-inner" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "enum-ordinalize" -version = "4.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" -dependencies = [ - "enum-ordinalize-derive", -] - -[[package]] -name = "enum-ordinalize-derive" -version = "4.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "event-listener" -version = "2.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener 5.4.1", - "pin-project-lite", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "fastrlp" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" -dependencies = [ - "arrayvec", - "auto_impl", - "bytes", -] - -[[package]] -name = "fastrlp" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" -dependencies = [ - "arrayvec", - "auto_impl", - "bytes", -] - -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" - -[[package]] -name = "fixed-hash" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" -dependencies = [ - "byteorder", - "rand 0.8.5", - "rustc-hex", - "static_assertions", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-timer" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "futures-utils-wasm" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42012b0f064e01aa58b545fe3727f90f7dd4020f4a3ea735b50344965f5a57e9" - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", - "zeroize", -] - -[[package]] -name = "getrandom" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "foldhash", - "serde", - "serde_core", -] - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hex-conservative" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "hickory-proto" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" -dependencies = [ - "async-trait", - "cfg-if", - "data-encoding", - "enum-as-inner", - "futures-channel", - "futures-io", - "futures-util", - "idna", - "ipnet", - "once_cell", - "rand 0.9.2", - "ring", - "thiserror 2.0.17", - "tinyvec", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "hickory-resolver" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" -dependencies = [ - "cfg-if", - "futures-util", - "hickory-proto", - "ipconfig", - "moka", - "once_cell", - "parking_lot", - "rand 0.9.2", - "resolv-conf", - "smallvec", - "thiserror 2.0.17", - "tokio", - "tracing", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "hostname" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" -dependencies = [ - "cfg-if", - "libc", - "windows-link", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "http-client-unix-domain-socket" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40dfebe8708af3772e3d5121077aaa18045dd9f43fc4b5046c31345cdffb7354" -dependencies = [ - "axum", - "axum-core", - "http-body-util", - "hyper", - "hyper-util", - "serde", - "serde_json", - "tokio", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-util" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2 0.6.1", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "impl-codec" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" -dependencies = [ - "parity-scale-codec", -] - -[[package]] -name = "impl-trait-for-tuples" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "ipconfig" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" -dependencies = [ - "socket2 0.5.10", - "widestring", - "windows-sys 0.48.0", - "winreg 0.50.0", -] - -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" - -[[package]] -name = "js-sys" -version = "0.3.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "k256" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" -dependencies = [ - "cfg-if", - "ecdsa", - "elliptic-curve", - "once_cell", - "serdect", - "sha2", -] - -[[package]] -name = "keccak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" -dependencies = [ - "cpufeatures", -] - -[[package]] -name = "keccak-asm" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "505d1856a39b200489082f90d897c3f07c455563880bc5952e38eabf731c83b6" -dependencies = [ - "digest 0.10.7", - "sha3-asm", -] - -[[package]] -name = "kv-log-macro" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" -dependencies = [ - "log", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.178" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" - -[[package]] -name = "libm" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" - -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -dependencies = [ - "value-bag", -] - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "macro-string" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "moka" -version = "0.12.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3dec6bd31b08944e08b58fd99373893a6c17054d6f3ea5006cc894f4f4eee2a" -dependencies = [ - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "parking_lot", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", -] - -[[package]] -name = "multer" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" -dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http", - "httparse", - "memchr", - "mime", - "spin", - "version_check", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "nybbles" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4b5ecbd0beec843101bffe848217f770e8b8da81d8355b7d6e226f2199b3dc" -dependencies = [ - "alloy-rlp", - "cfg-if", - "proptest", - "ruint", - "serde", - "smallvec", -] - -[[package]] -name = "oid-registry" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" -dependencies = [ - "asn1-rs", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -dependencies = [ - "critical-section", - "portable-atomic", -] - -[[package]] -name = "parity-scale-codec" -version = "3.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" -dependencies = [ - "arrayvec", - "bitvec", - "byte-slice-cast", - "const_format", - "impl-trait-for-tuples", - "parity-scale-codec-derive", - "rustversion", - "serde", -] - -[[package]] -name = "parity-scale-codec-derive" -version = "3.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pest" -version = "2.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbcfd20a6d4eeba40179f05735784ad32bdaef05ce8e8af05f180d45bb3e7e22" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "piper" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.1.3", - "windows-sys 0.61.2", -] - -[[package]] -name = "portable-atomic" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f59e70c4aef1e55797c2e8fd94a4f2a973fc972cfde0e0b05f683667b0cd39dd" - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.111", -] - -[[package]] -name = "primitive-types" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" -dependencies = [ - "fixed-hash", - "impl-codec", - "uint", -] - -[[package]] -name = "proc-macro-crate" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" -dependencies = [ - "toml_edit", -] - -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "proc-macro2" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "proptest" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" -dependencies = [ - "bit-set", - "bit-vec", - "bitflags", - "num-traits", - "rand 0.9.2", - "rand_chacha 0.9.0", - "rand_xorshift", - "regex-syntax", - "rusty-fork", - "tempfile", - "unarray", -] - -[[package]] -name = "quick-error" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" - -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2 0.6.1", - "thiserror 2.0.17", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.2", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.17", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2 0.6.1", - "tracing", - "windows-sys 0.60.2", -] - -[[package]] -name = "quote" -version = "1.0.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", - "serde", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.3", - "serde", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.3", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.16", -] - -[[package]] -name = "rand_core" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom 0.3.4", - "serde", -] - -[[package]] -name = "rand_xorshift" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" -dependencies = [ - "rand_core 0.9.3", -] - -[[package]] -name = "rapidhash" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2988730ee014541157f48ce4dcc603940e00915edc3c7f9a8d78092256bb2493" -dependencies = [ - "rustversion", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "regex" -version = "1.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" - -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "encoding_rs", - "futures-core", - "hickory-resolver", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime", - "once_cell", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots", -] - -[[package]] -name = "resolv-conf" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" - -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.16", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rlp" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" -dependencies = [ - "bytes", - "rustc-hex", -] - -[[package]] -name = "ruint" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68df0380e5c9d20ce49534f292a36a7514ae21350726efe1865bdb1fa91d278" -dependencies = [ - "alloy-rlp", - "ark-ff 0.3.0", - "ark-ff 0.4.2", - "ark-ff 0.5.0", - "bytes", - "fastrlp 0.3.1", - "fastrlp 0.4.0", - "num-bigint", - "num-integer", - "num-traits", - "parity-scale-codec", - "primitive-types", - "proptest", - "rand 0.8.5", - "rand 0.9.2", - "rlp", - "ruint-macro", - "serde_core", - "valuable", - "zeroize", -] - -[[package]] -name = "ruint-macro" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustc-hex" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" - -[[package]] -name = "rustc_version" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" -dependencies = [ - "semver 0.11.0", -] - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver 1.0.27", -] - -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom", -] - -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "rusty-fork" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" -dependencies = [ - "fnv", - "quick-error", - "tempfile", - "wait-timeout", -] - -[[package]] -name = "ryu" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" - -[[package]] -name = "sandbox-api" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-stream", - "axum", - "base64", - "chromiumoxide", - "chrono", - "dashmap", - "dstack-sdk", - "futures", - "hex", - "hostname", - "regex", - "reqwest", - "serde", - "serde_json", - "serde_yaml", - "tempfile", - "thiserror 2.0.17", - "tokio", - "tokio-test", - "tower", - "tower-http", - "tracing", - "tracing-subscriber", - "uuid", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "serdect", - "subtle", - "zeroize", -] - -[[package]] -name = "secp256k1" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" -dependencies = [ - "bitcoin_hashes", - "rand 0.8.5", - "secp256k1-sys", - "serde", -] - -[[package]] -name = "secp256k1-sys" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" -dependencies = [ - "cc", -] - -[[package]] -name = "semver" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" -dependencies = [ - "semver-parser", -] - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - -[[package]] -name = "semver-parser" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" -dependencies = [ - "pest", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "serde_json" -version = "1.0.146" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217ca874ae0207aac254aa02c957ded05585a90892cc8d87f9e5fa49669dadd8" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", - "serde_core", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_with" -version = "3.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" -dependencies = [ - "base64", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.12.1", - "schemars 0.9.0", - "schemars 1.1.0", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.12.1", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[package]] -name = "serdect" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" -dependencies = [ - "base16ct", - "serde", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.10.7", -] - -[[package]] -name = "sha3" -version = "0.10.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" -dependencies = [ - "digest 0.10.7", - "keccak", -] - -[[package]] -name = "sha3-asm" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28efc5e327c837aa837c59eae585fc250715ef939ac32881bcc11677cd02d46" -dependencies = [ - "cc", - "cfg-if", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" -dependencies = [ - "libc", -] - -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", -] - -[[package]] -name = "slab" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn-solidity" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f92d01b5de07eaf324f7fca61cc6bd3d82bbc1de5b6c963e6fe79e86f36580d" -dependencies = [ - "paste", - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - -[[package]] -name = "tempfile" -version = "3.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" -dependencies = [ - "fastrand", - "getrandom 0.3.4", - "once_cell", - "rustix 1.1.3", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" -dependencies = [ - "thiserror-impl 2.0.17", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "threadpool" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" -dependencies = [ - "num_cpus", -] - -[[package]] -name = "time" -version = "0.3.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" - -[[package]] -name = "time-macros" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2 0.6.1", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-test" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" -dependencies = [ - "async-stream", - "bytes", - "futures-core", - "tokio", - "tokio-stream", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.23.10+spec-1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" -dependencies = [ - "indexmap 2.12.1", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.0.6+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" -dependencies = [ - "winnow", -] - -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "iri-string", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "tungstenite" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.8.5", - "sha1", - "thiserror 1.0.69", - "utf-8", -] - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - -[[package]] -name = "uint" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" -dependencies = [ - "byteorder", - "crunchy", - "hex", - "static_assertions", -] - -[[package]] -name = "unarray" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" -dependencies = [ - "getrandom 0.3.4", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "value-bag" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wait-timeout" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" -dependencies = [ - "libc", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" -dependencies = [ - "cfg-if", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.111", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-sys" -version = "0.3.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "which" -version = "6.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" -dependencies = [ - "either", - "home", - "rustix 0.38.44", - "winsafe", -] - -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winnow" -version = "0.7.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" -dependencies = [ - "memchr", -] - -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - -[[package]] -name = "winreg" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - -[[package]] -name = "winsafe" -version = "0.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - -[[package]] -name = "x509-parser" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" -dependencies = [ - "asn1-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom", - "oid-registry", - "rusticata-macros", - "thiserror 1.0.69", - "time", -] - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] diff --git a/sandbox-rs/Cargo.toml b/sandbox-rs/Cargo.toml deleted file mode 100644 index 4271c3e..0000000 --- a/sandbox-rs/Cargo.toml +++ /dev/null @@ -1,42 +0,0 @@ -[package] -name = "sandbox-api" -version = "0.1.0" -edition = "2021" - -[dependencies] -axum = { version = "0.8", features = ["multipart"] } -tokio = { version = "1", features = ["full"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -tower = "0.5" -tower-http = { version = "0.6", features = ["cors", "trace"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -thiserror = "2" -anyhow = "1" -hostname = "0.4" -chrono = { version = "0.4", features = ["serde"] } -async-stream = "0.3" -futures = "0.3" - -# New for Skills -serde_yaml = "0.9" -dashmap = "6" -uuid = { version = "1", features = ["v4"] } -regex = "1" - -# Browser automation -chromiumoxide = { version = "0.7", features = ["tokio-runtime"] } -base64 = "0.22" -# TEE (optional) -dstack-sdk = { git = "https://github.com/Dstack-TEE/dstack", optional = true } -hex = { version = "0.4", optional = true } - -[features] -default = [] -tee = ["dstack-sdk", "hex"] - -[dev-dependencies] -reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } -tokio-test = "0.4" -tempfile = "3" diff --git a/sandbox-rs/Makefile b/sandbox-rs/Makefile deleted file mode 100644 index d2d43ec..0000000 --- a/sandbox-rs/Makefile +++ /dev/null @@ -1,51 +0,0 @@ -.PHONY: build run test check clean dev fmt help - -# Default target -.DEFAULT_GOAL := help - -# Build debug binary -build: - cargo build - -# Build release binary -build-release: - cargo build --release - -# Run the server -run: - cargo run - -# Run all tests -test: - cargo test - -# Run clippy and check formatting -check: - cargo clippy -- -D warnings - cargo fmt -- --check - -# Format code -fmt: - cargo fmt - -# Remove build artifacts -clean: - cargo clean - -# Run with auto-reload (requires cargo-watch) -dev: - @which cargo-watch > /dev/null || (echo "cargo-watch not installed. Install with: cargo install cargo-watch" && exit 1) - cargo watch -x run - -# Show help -help: - @echo "Available targets:" - @echo " build - Build debug binary" - @echo " build-release - Build release binary" - @echo " run - Run the server" - @echo " test - Run all tests" - @echo " check - Run clippy and check formatting" - @echo " fmt - Format code" - @echo " clean - Remove build artifacts" - @echo " dev - Run with auto-reload (requires cargo-watch)" - @echo " help - Show this help message" diff --git a/sandbox-rs/src/browser/mod.rs b/sandbox-rs/src/browser/mod.rs deleted file mode 100644 index 02c9b81..0000000 --- a/sandbox-rs/src/browser/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod types; -pub mod service; - -pub use types::*; -pub use service::*; diff --git a/sandbox-rs/src/browser/service.rs b/sandbox-rs/src/browser/service.rs deleted file mode 100644 index 930a4f6..0000000 --- a/sandbox-rs/src/browser/service.rs +++ /dev/null @@ -1,243 +0,0 @@ -use chromiumoxide::{Browser, BrowserConfig}; -use tokio::sync::OnceCell; -use std::sync::Arc; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; -use futures::StreamExt; - -use crate::browser::types::*; - -#[derive(Debug, Clone)] -pub struct BrowserServiceConfig { - pub headless: bool, - pub executable_path: Option, - pub viewport_width: u32, - pub viewport_height: u32, - #[allow(dead_code)] // Reserved for future timeout support - pub timeout: u64, -} - -impl Default for BrowserServiceConfig { - fn default() -> Self { - Self { - headless: true, - executable_path: None, - viewport_width: 1280, - viewport_height: 720, - timeout: 30, - } - } -} - -#[derive(Clone)] -pub struct BrowserService { - browser: Arc>, - config: BrowserServiceConfig, -} - -impl BrowserService { - pub fn new(config: BrowserServiceConfig) -> Self { - Self { - browser: Arc::new(OnceCell::new()), - config, - } - } - - /// Lazy-init browser on first call - async fn get_browser(&self) -> Result<&Browser, BrowserError> { - self.browser.get_or_try_init(|| async { - let mut builder = BrowserConfig::builder(); - - if self.config.headless { - builder = builder.arg("--headless=new"); - } - - // Container-safe args - builder = builder - .arg("--disable-gpu") - .arg("--disable-dev-shm-usage") - .arg("--disable-setuid-sandbox"); - - // Detect container and disable sandbox - if std::path::Path::new("/.dockerenv").exists() - || std::env::var("CONTAINER").is_ok() { - builder = builder.arg("--no-sandbox"); - } - - builder = builder - .viewport(chromiumoxide::handler::viewport::Viewport { - width: self.config.viewport_width, - height: self.config.viewport_height, - ..Default::default() - }); - - if let Some(ref path) = self.config.executable_path { - builder = builder.chrome_executable(path); - } - - let config = builder.build() - .map_err(|e| BrowserError::LaunchFailed(e.to_string()))?; - - let (browser, mut handler) = Browser::launch(config) - .await - .map_err(|e| BrowserError::LaunchFailed(e.to_string()))?; - - // Spawn handler task (required by chromiumoxide) - tokio::spawn(async move { - while let Some(event) = handler.next().await { - // Handle browser events (required by chromiumoxide) - let _ = event; - } - }); - - Ok(browser) - }).await - } - - pub async fn goto(&self, req: GotoRequest) -> Result { - let browser = self.get_browser().await?; - let page = browser.new_page("about:blank") - .await - .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?; - - page.goto(&req.url) - .await - .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?; - - let title = page.get_title() - .await - .map_err(|e| BrowserError::NavigationFailed(e.to_string()))? - .unwrap_or_default(); - - let url = page.url() - .await - .map_err(|e| BrowserError::NavigationFailed(e.to_string()))? - .map(|u| u.to_string()) - .unwrap_or_else(|| req.url.clone()); - - page.close().await.ok(); - - Ok(GotoResponse { url, title }) - } - - pub async fn screenshot(&self, req: ScreenshotRequest) -> Result { - let browser = self.get_browser().await?; - let page = browser.new_page("about:blank") - .await - .map_err(|e| BrowserError::ScreenshotFailed(e.to_string()))?; - - // Navigate if URL provided - if let Some(ref url) = req.url { - page.goto(url) - .await - .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?; - } - - // Take screenshot - let screenshot_data = if let Some(ref selector) = req.selector { - // Element screenshot - let element = page.find_element(selector) - .await - .map_err(|_| BrowserError::ElementNotFound(selector.clone()))?; - element.screenshot(chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat::Png) - .await - .map_err(|e| BrowserError::ScreenshotFailed(e.to_string()))? - } else { - // Full page screenshot - page.screenshot(chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotParams::default()) - .await - .map_err(|e| BrowserError::ScreenshotFailed(e.to_string()))? - }; - - let data = BASE64.encode(&screenshot_data); - - page.close().await.ok(); - - Ok(ScreenshotResponse { - data, - format: req.format, - width: self.config.viewport_width, - height: self.config.viewport_height, - }) - } - - pub async fn evaluate(&self, req: EvaluateRequest) -> Result { - let browser = self.get_browser().await?; - let page = browser.new_page("about:blank") - .await - .map_err(|e| BrowserError::ScriptError(e.to_string()))?; - - if let Some(ref url) = req.url { - page.goto(url) - .await - .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?; - } - - let eval_result = page.evaluate(req.script) - .await - .map_err(|e| BrowserError::ScriptError(e.to_string()))?; - - let result = eval_result.into_value() - .map_err(|e| BrowserError::ScriptError(e.to_string()))?; - - page.close().await.ok(); - - Ok(EvaluateResponse { result }) - } - - pub async fn click(&self, req: ClickRequest) -> Result<(), BrowserError> { - let browser = self.get_browser().await?; - let page = browser.new_page("about:blank") - .await - .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?; - - if let Some(ref url) = req.url { - page.goto(url) - .await - .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?; - } - - let element = page.find_element(&req.selector) - .await - .map_err(|_| BrowserError::ElementNotFound(req.selector.clone()))?; - - element.click() - .await - .map_err(|e| BrowserError::ScriptError(e.to_string()))?; - - page.close().await.ok(); - - Ok(()) - } - - pub async fn type_text(&self, req: TypeRequest) -> Result<(), BrowserError> { - let browser = self.get_browser().await?; - let page = browser.new_page("about:blank") - .await - .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?; - - if let Some(ref url) = req.url { - page.goto(url) - .await - .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?; - } - - let element = page.find_element(&req.selector) - .await - .map_err(|_| BrowserError::ElementNotFound(req.selector.clone()))?; - - element.type_str(&req.text) - .await - .map_err(|e| BrowserError::ScriptError(e.to_string()))?; - - page.close().await.ok(); - - Ok(()) - } - - pub fn status(&self) -> BrowserStatus { - BrowserStatus { - running: self.browser.get().is_some(), - version: None, // Could query browser for version if needed - } - } -} diff --git a/sandbox-rs/src/browser/types.rs b/sandbox-rs/src/browser/types.rs deleted file mode 100644 index 9da61a8..0000000 --- a/sandbox-rs/src/browser/types.rs +++ /dev/null @@ -1,101 +0,0 @@ -use serde::{Deserialize, Serialize}; - -fn default_timeout() -> u64 { - 30 -} - -fn default_format() -> String { - "png".into() -} - -// POST /browser/goto -#[derive(Debug, Deserialize)] -pub struct GotoRequest { - pub url: String, - #[allow(dead_code)] // Reserved for future wait_until support - #[serde(default)] - pub wait_until: Option, // "load", "domcontentloaded", "networkidle" - #[allow(dead_code)] // Reserved for future timeout support - #[serde(default = "default_timeout")] - pub timeout: u64, -} - -#[derive(Debug, Serialize)] -pub struct GotoResponse { - pub url: String, - pub title: String, -} - -// POST /browser/screenshot -#[derive(Debug, Deserialize)] -pub struct ScreenshotRequest { - pub url: Option, - pub selector: Option, - #[serde(default = "default_format")] - pub format: String, -} - -#[derive(Debug, Serialize)] -pub struct ScreenshotResponse { - pub data: String, // base64 encoded - pub format: String, - pub width: u32, - pub height: u32, -} - -// POST /browser/evaluate -#[derive(Debug, Deserialize)] -pub struct EvaluateRequest { - pub url: Option, - pub script: String, -} - -#[derive(Debug, Serialize)] -pub struct EvaluateResponse { - pub result: serde_json::Value, -} - -// POST /browser/click -#[derive(Debug, Deserialize)] -pub struct ClickRequest { - pub url: Option, - pub selector: String, -} - -// POST /browser/type -#[derive(Debug, Deserialize)] -pub struct TypeRequest { - pub url: Option, - pub selector: String, - pub text: String, -} - -// GET /browser/status -#[derive(Debug, Serialize)] -pub struct BrowserStatus { - pub running: bool, - pub version: Option, -} - -// Error types -#[derive(Debug, thiserror::Error)] -pub enum BrowserError { - #[error("Browser failed to launch: {0}")] - LaunchFailed(String), - - #[error("Navigation failed: {0}")] - NavigationFailed(String), - - #[error("Element not found: {0}")] - ElementNotFound(String), - - #[error("JavaScript error: {0}")] - ScriptError(String), - - #[allow(dead_code)] // Reserved for future timeout support - #[error("Timeout after {0}s")] - Timeout(u64), - - #[error("Screenshot failed: {0}")] - ScreenshotFailed(String), -} diff --git a/sandbox-rs/src/config.rs b/sandbox-rs/src/config.rs deleted file mode 100644 index e478421..0000000 --- a/sandbox-rs/src/config.rs +++ /dev/null @@ -1,56 +0,0 @@ -use std::env; - -#[derive(Debug, Clone)] -pub struct Config { - #[allow(dead_code)] - pub host: String, - pub port: u16, - pub workspace: String, - pub display: String, - pub cdp_port: u16, - pub skills_dir: String, - pub browser_headless: bool, - pub browser_executable: Option, - pub browser_viewport_width: u32, - pub browser_viewport_height: u32, - pub browser_timeout: u64, -} - -impl Config { - pub fn from_env() -> Self { - let workspace = env::var("WORKSPACE") - .unwrap_or_else(|_| "/home/sandbox/workspace".into()); - - Self { - host: env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into()), - port: env::var("PORT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(8080), - workspace: workspace.clone(), - display: env::var("DISPLAY").unwrap_or_else(|_| ":99".into()), - cdp_port: env::var("CDP_PORT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(9222), - skills_dir: env::var("SKILLS_DIR") - .unwrap_or_else(|_| format!("{}/.skills", workspace)), - browser_headless: env::var("BROWSER_HEADLESS") - .map(|v| v != "false" && v != "0") - .unwrap_or(true), - browser_executable: env::var("BROWSER_EXECUTABLE").ok(), - browser_viewport_width: env::var("BROWSER_VIEWPORT_WIDTH") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(1280), - browser_viewport_height: env::var("BROWSER_VIEWPORT_HEIGHT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(720), - browser_timeout: env::var("BROWSER_TIMEOUT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(30), - } - } -} diff --git a/sandbox-rs/src/error.rs b/sandbox-rs/src/error.rs deleted file mode 100644 index 81a3a59..0000000 --- a/sandbox-rs/src/error.rs +++ /dev/null @@ -1,41 +0,0 @@ -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; -use serde_json::json; - -#[derive(Debug, thiserror::Error)] -pub enum AppError { - #[error("Not found: {0}")] - NotFound(String), - - #[error("Bad request: {0}")] - BadRequest(String), - - #[error("Timeout: {0}")] - Timeout(String), - - #[error("Internal error: {0}")] - Internal(String), - - #[error(transparent)] - Io(#[from] std::io::Error), -} - -impl IntoResponse for AppError { - fn into_response(self) -> Response { - let (status, message) = match &self { - AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), - AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), - AppError::Timeout(msg) => (StatusCode::REQUEST_TIMEOUT, msg.clone()), - AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()), - AppError::Io(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), - }; - - let body = Json(json!({ "error": message })); - (status, body).into_response() - } -} - -pub type Result = std::result::Result; diff --git a/sandbox-rs/src/handlers/browser.rs b/sandbox-rs/src/handlers/browser.rs deleted file mode 100644 index ae0fa76..0000000 --- a/sandbox-rs/src/handlers/browser.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::sync::Arc; -use axum::{extract::State, Json}; -use crate::state::AppState; -use crate::error::{AppError, Result}; -use crate::browser::{ - GotoRequest, GotoResponse, - ScreenshotRequest, ScreenshotResponse, - EvaluateRequest, EvaluateResponse, - ClickRequest, TypeRequest, - BrowserStatus, BrowserError, -}; - -// Convert BrowserError to AppError -impl From for AppError { - fn from(e: BrowserError) -> Self { - match e { - BrowserError::ElementNotFound(msg) => AppError::NotFound(msg), - BrowserError::Timeout(secs) => AppError::Timeout(format!("Timeout after {}s", secs)), - BrowserError::LaunchFailed(msg) => AppError::Internal(format!("Browser launch failed: {}", msg)), - BrowserError::NavigationFailed(msg) => AppError::Internal(format!("Navigation failed: {}", msg)), - BrowserError::ScriptError(msg) => AppError::BadRequest(format!("Script error: {}", msg)), - BrowserError::ScreenshotFailed(msg) => AppError::Internal(format!("Screenshot failed: {}", msg)), - } - } -} - -// POST /browser/goto - Navigate to a URL -pub async fn browser_goto( - State(state): State>, - Json(req): Json, -) -> Result> { - let response = state.browser.goto(req).await?; - Ok(Json(response)) -} - -// POST /browser/screenshot - Take a screenshot -pub async fn browser_screenshot( - State(state): State>, - Json(req): Json, -) -> Result> { - let response = state.browser.screenshot(req).await?; - Ok(Json(response)) -} - -// POST /browser/evaluate - Evaluate JavaScript -pub async fn browser_evaluate( - State(state): State>, - Json(req): Json, -) -> Result> { - let response = state.browser.evaluate(req).await?; - Ok(Json(response)) -} - -// POST /browser/click - Click an element -pub async fn browser_click( - State(state): State>, - Json(req): Json, -) -> Result> { - state.browser.click(req).await?; - Ok(Json(serde_json::json!({"success": true}))) -} - -// POST /browser/type - Type text into an element -pub async fn browser_type( - State(state): State>, - Json(req): Json, -) -> Result> { - state.browser.type_text(req).await?; - Ok(Json(serde_json::json!({"success": true}))) -} - -// GET /browser/status - Get browser status -pub async fn browser_status( - State(state): State>, -) -> Json { - Json(state.browser.status()) -} diff --git a/sandbox-rs/src/handlers/code.rs b/sandbox-rs/src/handlers/code.rs deleted file mode 100644 index 0d0979f..0000000 --- a/sandbox-rs/src/handlers/code.rs +++ /dev/null @@ -1,116 +0,0 @@ -use axum::{extract::State, Json}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use std::time::Instant; -use tokio::fs; -use tokio::process::Command; -use tokio::time::{timeout, Duration}; - -use crate::error::{AppError, Result}; -use crate::state::AppState; - -#[derive(Debug, Clone)] -struct LangConfig { - ext: &'static str, - cmd: &'static str, -} - -fn get_lang_config(language: &str) -> Option { - match language.to_lowercase().as_str() { - "python" => Some(LangConfig { - ext: ".py", - cmd: "python3", - }), - "javascript" => Some(LangConfig { - ext: ".js", - cmd: "node", - }), - "typescript" => Some(LangConfig { - ext: ".ts", - cmd: "npx tsx", - }), - "go" => Some(LangConfig { - ext: ".go", - cmd: "go run", - }), - "rust" => Some(LangConfig { - ext: ".rs", - cmd: "rustc -o /tmp/rust_out && /tmp/rust_out", - }), - "bash" => Some(LangConfig { - ext: ".sh", - cmd: "bash", - }), - _ => None, - } -} - -#[derive(Debug, Deserialize)] -pub struct CodeExecRequest { - pub code: String, - pub language: String, - #[serde(default = "default_timeout")] - pub timeout: u64, -} - -fn default_timeout() -> u64 { - 30 -} - -#[derive(Debug, Serialize)] -pub struct CodeExecResponse { - pub output: String, - pub error: String, - pub exit_code: i32, - pub duration_ms: f64, -} - -pub async fn execute_code( - State(state): State>, - Json(req): Json, -) -> Result> { - let config = get_lang_config(&req.language) - .ok_or_else(|| AppError::BadRequest(format!("Unsupported language: {}", req.language)))?; - - let start = Instant::now(); - - // Create temp file - let tmp_path = format!("/tmp/code_{}{}", std::process::id(), config.ext); - fs::write(&tmp_path, &req.code) - .await - .map_err(|e| AppError::Internal(e.to_string()))?; - - // Build command - let full_cmd = if config.cmd.contains("&&") { - // Rust special case: compile and run - config.cmd.replace( - "/tmp/rust_out", - &format!("/tmp/rust_out_{}", std::process::id()), - ) + " " - + &tmp_path - } else { - format!("{} {}", config.cmd, tmp_path) - }; - - let mut cmd = Command::new("sh"); - cmd.arg("-c") - .arg(&full_cmd) - .current_dir(&state.config.workspace); - - let result = timeout(Duration::from_secs(req.timeout), cmd.output()).await; - - // Cleanup temp file - let _ = fs::remove_file(&tmp_path).await; - let _ = fs::remove_file(format!("/tmp/rust_out_{}", std::process::id())).await; - - let output = result - .map_err(|_| AppError::Timeout("Execution timed out".into()))? - .map_err(|e| AppError::Internal(e.to_string()))?; - - Ok(Json(CodeExecResponse { - output: String::from_utf8_lossy(&output.stdout).into_owned(), - error: String::from_utf8_lossy(&output.stderr).into_owned(), - exit_code: output.status.code().unwrap_or(-1), - duration_ms: start.elapsed().as_secs_f64() * 1000.0, - })) -} diff --git a/sandbox-rs/src/handlers/factory.rs b/sandbox-rs/src/handlers/factory.rs deleted file mode 100644 index 9843230..0000000 --- a/sandbox-rs/src/handlers/factory.rs +++ /dev/null @@ -1,186 +0,0 @@ -use axum::{extract::State, Json}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -use crate::error::{AppError, Result}; -use crate::skills::{check_triggers, SkillSummary}; -use crate::state::AppState; - -// POST /factory/start -#[derive(Deserialize)] -pub struct StartFactoryRequest { - pub initial_input: Option, -} - -// POST /factory/continue -#[derive(Deserialize)] -pub struct ContinueFactoryRequest { - pub session_id: String, - pub input: String, -} - -// Response for start/continue -#[derive(Serialize)] -pub struct FactoryResponse { - pub session_id: String, - pub step: String, - pub prompt: String, - pub done: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub skill: Option, -} - -// POST /factory/check -#[derive(Deserialize)] -pub struct CheckTriggerRequest { - pub input: String, -} - -#[derive(Serialize)] -pub struct CheckTriggerResponse { - pub triggers_factory: bool, - pub matched_phrases: Vec, -} - -/// POST /factory/start - Begin dialogue -pub async fn start_factory( - State(state): State>, - Json(req): Json, -) -> Result> { - let session = state.factory.start(req.initial_input); - - Ok(Json(FactoryResponse { - session_id: session.id, - step: format!("{:?}", session.step), - prompt: session.step.get_prompt().to_string(), - done: false, - skill: None, - })) -} - -/// POST /factory/continue - Advance step -pub async fn continue_factory( - State(state): State>, - Json(req): Json, -) -> Result> { - let session = state.factory - .continue_session(&req.session_id, &req.input) - .map_err(|e| AppError::BadRequest(e.to_string()))?; - - let is_done = matches!(session.step, crate::skills::factory::FactoryStep::Done); - - // Build the prompt - for Confirm step, include summary - let prompt = if matches!(session.step, crate::skills::factory::FactoryStep::Confirm) { - format!("{}\n\n{}", session.get_summary(), session.step.get_prompt()) - } else { - session.step.get_prompt().to_string() - }; - - // If done, create a skill summary from the answers - let skill = if is_done { - let goal = session.answers.goal.as_deref().unwrap_or("Untitled Skill"); - let description = session.answers.triggers - .as_ref() - .map(|triggers| format!("Triggers: {}", triggers.join(", "))) - .unwrap_or_else(|| "No triggers defined".to_string()); - - Some(SkillSummary { - name: sanitize_skill_name(goal), - description, - }) - } else { - None - }; - - Ok(Json(FactoryResponse { - session_id: session.id, - step: format!("{:?}", session.step), - prompt, - done: is_done, - skill, - })) -} - -/// POST /factory/check - Check if input triggers factory -pub async fn check_trigger( - Json(req): Json, -) -> Result> { - let matched_phrases = check_triggers(&req.input); - let triggers_factory = !matched_phrases.is_empty(); - - Ok(Json(CheckTriggerResponse { - triggers_factory, - matched_phrases, - })) -} - -/// Convert a goal string into a valid skill name -/// - Convert to lowercase -/// - Replace spaces and special chars with hyphens -/// - Remove consecutive hyphens -/// - Trim hyphens from start/end -fn sanitize_skill_name(goal: &str) -> String { - let mut name = goal - .to_lowercase() - .chars() - .map(|c| { - if c.is_alphanumeric() { - c - } else if c.is_whitespace() || c == '_' { - '-' - } else { - '-' - } - }) - .collect::(); - - // Remove consecutive hyphens - while name.contains("--") { - name = name.replace("--", "-"); - } - - // Trim hyphens from start and end - name = name.trim_matches('-').to_string(); - - // Ensure name is not empty - if name.is_empty() { - name = "custom-skill".to_string(); - } - - name -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_sanitize_skill_name() { - assert_eq!(sanitize_skill_name("Deploy my app"), "deploy-my-app"); - assert_eq!(sanitize_skill_name("Create PDF Reports"), "create-pdf-reports"); - assert_eq!(sanitize_skill_name("Handle API@Requests"), "handle-api-requests"); - assert_eq!(sanitize_skill_name(" lots of spaces "), "lots-of-spaces"); - assert_eq!(sanitize_skill_name("!!!"), "custom-skill"); - assert_eq!(sanitize_skill_name(""), "custom-skill"); - } - - #[test] - fn test_check_trigger() { - let req = CheckTriggerRequest { - input: "Can you teach me how to do this?".to_string(), - }; - let result = tokio_test::block_on(check_trigger(Json(req))).unwrap(); - assert!(result.0.triggers_factory); - assert!(result.0.matched_phrases.contains(&"teach me".to_string())); - } - - #[test] - fn test_check_trigger_no_match() { - let req = CheckTriggerRequest { - input: "Just a regular question".to_string(), - }; - let result = tokio_test::block_on(check_trigger(Json(req))).unwrap(); - assert!(!result.0.triggers_factory); - assert!(result.0.matched_phrases.is_empty()); - } -} diff --git a/sandbox-rs/src/handlers/file.rs b/sandbox-rs/src/handlers/file.rs deleted file mode 100644 index 0b71a79..0000000 --- a/sandbox-rs/src/handlers/file.rs +++ /dev/null @@ -1,326 +0,0 @@ -use axum::{ - extract::{Multipart, Query, State}, - http::{header, StatusCode}, - response::{IntoResponse, Response}, - Json, -}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::fs; -use tokio::io::AsyncReadExt; - -use crate::error::{AppError, Result}; -use crate::state::AppState; - -fn resolve_path(base: &str, path: &str) -> PathBuf { - if path.starts_with('/') { - PathBuf::from(path) - } else { - PathBuf::from(base).join(path) - } -} - -// Read file -#[derive(Debug, Deserialize)] -pub struct FileReadQuery { - pub path: String, - #[serde(default = "default_encoding")] - #[allow(dead_code)] - pub encoding: String, -} - -fn default_encoding() -> String { - "utf-8".into() -} - -#[derive(Debug, Serialize)] -pub struct FileReadResponse { - pub content: String, - pub size: u64, - pub mime_type: String, -} - -pub async fn read_file( - State(state): State>, - Query(query): Query, -) -> Result> { - let full_path = resolve_path(&state.config.workspace, &query.path); - - if !full_path.exists() { - return Err(AppError::NotFound("File not found".into())); - } - - let content = fs::read_to_string(&full_path) - .await - .map_err(|e| AppError::Internal(e.to_string()))?; - - let metadata = fs::metadata(&full_path) - .await - .map_err(|e| AppError::Internal(e.to_string()))?; - - Ok(Json(FileReadResponse { - content, - size: metadata.len(), - mime_type: "text/plain".into(), - })) -} - -// Write file -#[derive(Debug, Deserialize)] -pub struct FileWriteRequest { - pub path: String, - pub content: String, - #[serde(default = "default_mode")] - pub mode: String, -} - -fn default_mode() -> String { - "644".into() -} - -#[derive(Debug, Serialize)] -pub struct FileWriteResponse { - pub path: String, - pub size: u64, -} - -pub async fn write_file( - State(state): State>, - Json(req): Json, -) -> Result> { - let full_path = resolve_path(&state.config.workspace, &req.path); - - // Create parent directories - if let Some(parent) = full_path.parent() { - fs::create_dir_all(parent) - .await - .map_err(|e| AppError::Internal(e.to_string()))?; - } - - fs::write(&full_path, &req.content) - .await - .map_err(|e| AppError::Internal(e.to_string()))?; - - // Set file mode (Unix only) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = u32::from_str_radix(&req.mode, 8).unwrap_or(0o644); - let perms = std::fs::Permissions::from_mode(mode); - fs::set_permissions(&full_path, perms) - .await - .map_err(|e| AppError::Internal(e.to_string()))?; - } - - let size = req.content.len() as u64; - - Ok(Json(FileWriteResponse { - path: full_path.to_string_lossy().into_owned(), - size, - })) -} - -// List directory -#[derive(Debug, Deserialize)] -pub struct FileListQuery { - pub path: String, - #[serde(default)] - pub recursive: bool, -} - -#[derive(Debug, Serialize)] -pub struct FileEntry { - pub name: String, - pub path: String, - #[serde(rename = "type")] - pub file_type: String, - pub size: u64, - pub modified: String, -} - -#[derive(Debug, Serialize)] -pub struct FileListResponse { - pub path: String, - pub entries: Vec, -} - -pub async fn list_files( - State(state): State>, - Query(query): Query, -) -> Result> { - let full_path = resolve_path(&state.config.workspace, &query.path); - - if !full_path.exists() { - return Err(AppError::NotFound("Path not found".into())); - } - - let mut entries = Vec::new(); - - if query.recursive { - collect_entries_recursive(&full_path, &mut entries).await?; - } else { - let mut dir = fs::read_dir(&full_path) - .await - .map_err(|e| AppError::Internal(e.to_string()))?; - - while let Some(entry) = dir - .next_entry() - .await - .map_err(|e| AppError::Internal(e.to_string()))? - { - if let Some(file_entry) = entry_to_file_entry(&entry).await { - entries.push(file_entry); - } - } - } - - Ok(Json(FileListResponse { - path: full_path.to_string_lossy().into_owned(), - entries, - })) -} - -async fn collect_entries_recursive(path: &PathBuf, entries: &mut Vec) -> Result<()> { - let mut dir = fs::read_dir(path) - .await - .map_err(|e| AppError::Internal(e.to_string()))?; - - while let Some(entry) = dir - .next_entry() - .await - .map_err(|e| AppError::Internal(e.to_string()))? - { - if let Some(file_entry) = entry_to_file_entry(&entry).await { - let is_dir = file_entry.file_type == "directory"; - entries.push(file_entry); - - if is_dir { - Box::pin(collect_entries_recursive(&entry.path(), entries)).await?; - } - } - } - - Ok(()) -} - -async fn entry_to_file_entry(entry: &fs::DirEntry) -> Option { - let metadata = entry.metadata().await.ok()?; - let modified = metadata.modified().ok()?; - let datetime: chrono::DateTime = modified.into(); - - Some(FileEntry { - name: entry.file_name().to_string_lossy().into_owned(), - path: entry.path().to_string_lossy().into_owned(), - file_type: if metadata.is_dir() { - "directory" - } else { - "file" - } - .into(), - size: metadata.len(), - modified: datetime.to_rfc3339(), - }) -} - -// Upload file (multipart) -pub async fn upload_file( - State(state): State>, - mut multipart: Multipart, -) -> Result> { - let mut file_data: Option> = None; - let mut file_path: Option = None; - - while let Some(field) = multipart - .next_field() - .await - .map_err(|e| AppError::BadRequest(e.to_string()))? - { - let name = field.name().unwrap_or("").to_string(); - - match name.as_str() { - "file" => { - file_data = Some( - field - .bytes() - .await - .map_err(|e| AppError::Internal(e.to_string()))? - .to_vec(), - ); - } - "path" => { - file_path = Some( - field - .text() - .await - .map_err(|e| AppError::Internal(e.to_string()))?, - ); - } - _ => {} - } - } - - let data = file_data.ok_or_else(|| AppError::BadRequest("Missing file field".into()))?; - let path = file_path.ok_or_else(|| AppError::BadRequest("Missing path field".into()))?; - - let full_path = resolve_path(&state.config.workspace, &path); - - if let Some(parent) = full_path.parent() { - fs::create_dir_all(parent) - .await - .map_err(|e| AppError::Internal(e.to_string()))?; - } - - fs::write(&full_path, &data) - .await - .map_err(|e| AppError::Internal(e.to_string()))?; - - Ok(Json(FileWriteResponse { - path: full_path.to_string_lossy().into_owned(), - size: data.len() as u64, - })) -} - -// Download file -#[derive(Debug, Deserialize)] -pub struct FileDownloadQuery { - pub path: String, -} - -pub async fn download_file( - State(state): State>, - Query(query): Query, -) -> Result { - let full_path = resolve_path(&state.config.workspace, &query.path); - - if !full_path.exists() { - return Err(AppError::NotFound("File not found".into())); - } - - let mut file = fs::File::open(&full_path) - .await - .map_err(|e| AppError::Internal(e.to_string()))?; - - let mut contents = Vec::new(); - file.read_to_end(&mut contents) - .await - .map_err(|e| AppError::Internal(e.to_string()))?; - - let filename = full_path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "download".into()); - - Ok(( - StatusCode::OK, - [ - (header::CONTENT_TYPE, "application/octet-stream"), - ( - header::CONTENT_DISPOSITION, - &format!("attachment; filename=\"{}\"", filename), - ), - ], - contents, - ) - .into_response()) -} diff --git a/sandbox-rs/src/handlers/health.rs b/sandbox-rs/src/handlers/health.rs deleted file mode 100644 index bcbd8cb..0000000 --- a/sandbox-rs/src/handlers/health.rs +++ /dev/null @@ -1,53 +0,0 @@ -use crate::state::AppState; -use axum::{extract::State, Json}; -use serde::Serialize; -use std::sync::Arc; - -#[derive(Serialize)] -pub struct HealthResponse { - pub status: String, - pub uptime: f64, - pub services: Services, -} - -#[derive(Serialize)] -pub struct Services { - pub display: bool, - pub browser: bool, -} - -pub async fn health_check(State(state): State>) -> Json { - let display_exists = std::path::Path::new("/tmp/.X11-unix/X99").exists(); - - Json(HealthResponse { - status: "healthy".into(), - uptime: state.uptime_secs(), - services: Services { - display: display_exists, - browser: false, // Will be updated when browser manager is added - }, - }) -} - -#[derive(Serialize)] -pub struct SandboxInfo { - pub hostname: String, - pub workspace: String, - pub display: String, - pub cdp_url: String, - pub vnc_url: String, -} - -pub async fn sandbox_info(State(state): State>) -> Json { - let hostname = hostname::get() - .map(|h| h.to_string_lossy().into_owned()) - .unwrap_or_else(|_| "unknown".into()); - - Json(SandboxInfo { - hostname, - workspace: state.config.workspace.clone(), - display: state.config.display.clone(), - cdp_url: format!("http://localhost:{}", state.config.cdp_port), - vnc_url: "vnc://localhost:5900".into(), - }) -} diff --git a/sandbox-rs/src/handlers/mod.rs b/sandbox-rs/src/handlers/mod.rs deleted file mode 100644 index 13badbd..0000000 --- a/sandbox-rs/src/handlers/mod.rs +++ /dev/null @@ -1,20 +0,0 @@ -pub mod browser; -pub mod code; -pub mod factory; -pub mod file; -pub mod health; -pub mod shell; -pub mod skills; - -#[cfg(feature = "tee")] -pub mod tee; - -pub use browser::*; -pub use code::*; -pub use factory::*; -pub use file::*; -pub use health::*; -pub use shell::*; -pub use skills::*; - -// Note: TEE handlers are imported explicitly via handlers::tee::{...} in main.rs diff --git a/sandbox-rs/src/handlers/shell.rs b/sandbox-rs/src/handlers/shell.rs deleted file mode 100644 index ddd82ad..0000000 --- a/sandbox-rs/src/handlers/shell.rs +++ /dev/null @@ -1,117 +0,0 @@ -use axum::response::sse::{Event, KeepAlive, Sse}; -use axum::{extract::State, Json}; -use futures::stream::Stream; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::convert::Infallible; -use std::sync::Arc; -use std::time::Instant; -use tokio::io::{AsyncBufReadExt, BufReader}; -use tokio::process::Command; -use tokio::time::{timeout, Duration}; - -use crate::error::{AppError, Result}; -use crate::state::AppState; - -#[derive(Debug, Deserialize)] -pub struct ShellExecRequest { - pub command: String, - pub cwd: Option, - #[serde(default = "default_timeout")] - pub timeout: u64, - pub env: Option>, -} - -fn default_timeout() -> u64 { - 30 -} - -#[derive(Debug, Serialize)] -pub struct ShellExecResponse { - pub stdout: String, - pub stderr: String, - pub exit_code: i32, - pub duration_ms: f64, -} - -pub async fn exec_command( - State(state): State>, - Json(req): Json, -) -> Result> { - let start = Instant::now(); - let cwd = req.cwd.unwrap_or_else(|| state.config.workspace.clone()); - - let mut cmd = Command::new("sh"); - cmd.arg("-c").arg(&req.command).current_dir(&cwd); - - // Merge environment - if let Some(env) = req.env { - for (key, value) in env { - cmd.env(key, value); - } - } - - let output = timeout(Duration::from_secs(req.timeout), cmd.output()) - .await - .map_err(|_| AppError::Timeout("Command timed out".into()))? - .map_err(|e| AppError::Internal(e.to_string()))?; - - Ok(Json(ShellExecResponse { - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - exit_code: output.status.code().unwrap_or(-1), - duration_ms: start.elapsed().as_secs_f64() * 1000.0, - })) -} - -pub async fn stream_command( - State(state): State>, - Json(req): Json, -) -> Sse>> { - let cwd = req.cwd.unwrap_or_else(|| state.config.workspace.clone()); - - let stream = async_stream::stream! { - let mut cmd = Command::new("sh"); - cmd.arg("-c") - .arg(&req.command) - .current_dir(&cwd) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - - // Merge environment - if let Some(env) = &req.env { - for (key, value) in env { - cmd.env(key, value); - } - } - - match cmd.spawn() { - Ok(mut child) => { - let stdout = child.stdout.take(); - let _stderr = child.stderr.take(); - - if let Some(stdout) = stdout { - let mut reader = BufReader::new(stdout).lines(); - while let Ok(Some(line)) = reader.next_line().await { - yield Ok(Event::default().data(line)); - } - } - - match child.wait().await { - Ok(status) => { - let code = status.code().unwrap_or(-1); - yield Ok(Event::default().data(format!("[exit_code:{}]", code))); - } - Err(e) => { - yield Ok(Event::default().data(format!("[error:{}]", e))); - } - } - } - Err(e) => { - yield Ok(Event::default().data(format!("[error:{}]", e))); - } - } - }; - - Sse::new(stream).keep_alive(KeepAlive::default()) -} diff --git a/sandbox-rs/src/handlers/skills.rs b/sandbox-rs/src/handlers/skills.rs deleted file mode 100644 index 185a246..0000000 --- a/sandbox-rs/src/handlers/skills.rs +++ /dev/null @@ -1,212 +0,0 @@ -use axum::{ - extract::{Path, Query, State}, - Json, -}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; -use tokio::process::Command; -use tokio::time::timeout; - -use crate::error::{AppError, Result}; -use crate::skills::{CreateSkillRequest, Skill, SkillSummary, UpdateSkillRequest}; -use crate::state::AppState; - -// GET /skills - List all skills -#[derive(Serialize)] -pub struct ListSkillsResponse { - pub skills: Vec, -} - -pub async fn list_skills(State(state): State>) -> Result> { - let skills = state.skills.list().await?; - Ok(Json(ListSkillsResponse { skills })) -} - -// GET /skills/search - Search skills by query -#[derive(Deserialize)] -pub struct SearchQuery { - pub q: String, -} - -pub async fn search_skills( - State(state): State>, - Query(query): Query, -) -> Result> { - let skills = state.skills.search(&query.q).await?; - Ok(Json(ListSkillsResponse { skills })) -} - -// GET /skills/:name - Get a specific skill -pub async fn get_skill( - State(state): State>, - Path(name): Path, -) -> Result> { - let skill = state.skills.get(&name).await?; - Ok(Json(skill)) -} - -// POST /skills - Create a new skill -#[derive(Deserialize)] -pub struct CreateSkillRequestJson { - pub name: String, - pub description: String, - pub body: String, - #[serde(default)] - pub scripts: HashMap, - #[serde(default)] - pub references: HashMap, - #[serde(default)] - pub assets: HashMap, -} - -pub async fn create_skill( - State(state): State>, - Json(req): Json, -) -> Result> { - let create_req = CreateSkillRequest { - name: req.name, - description: req.description, - body: req.body, - scripts: req.scripts, - references: req.references, - assets: req.assets, - }; - - let skill = state.skills.create(create_req).await?; - Ok(Json(skill)) -} - -// PUT /skills/:name - Update an existing skill -#[derive(Deserialize)] -pub struct UpdateSkillRequestJson { - pub description: Option, - pub body: Option, - pub scripts: Option>, - pub references: Option>, - pub assets: Option>, -} - -pub async fn update_skill( - State(state): State>, - Path(name): Path, - Json(req): Json, -) -> Result> { - let update_req = UpdateSkillRequest { - description: req.description, - body: req.body, - scripts: req.scripts, - references: req.references, - assets: req.assets, - }; - - let skill = state.skills.update(&name, update_req).await?; - Ok(Json(skill)) -} - -// DELETE /skills/:name - Delete a skill -#[derive(Serialize)] -pub struct DeleteSkillResponse { - pub success: bool, - pub message: String, -} - -pub async fn delete_skill( - State(state): State>, - Path(name): Path, -) -> Result> { - state.skills.delete(&name).await?; - Ok(Json(DeleteSkillResponse { - success: true, - message: format!("Skill '{}' deleted successfully", name), - })) -} - -// POST /skills/:name/scripts/:script - Execute a script -#[derive(Deserialize)] -pub struct ExecuteScriptRequest { - #[serde(default)] - pub args: Vec, - #[serde(default)] - pub env: HashMap, -} - -#[derive(Serialize)] -pub struct ExecuteScriptResponse { - pub stdout: String, - pub stderr: String, - pub exit_code: i32, -} - -pub async fn execute_script( - State(state): State>, - Path((skill_name, script_name)): Path<(String, String)>, - Json(req): Json, -) -> Result> { - // Get the skill to verify it exists - let skill = state.skills.get(&skill_name).await?; - - // Verify the script exists - if !skill.scripts.contains(&script_name) { - return Err(AppError::NotFound(format!( - "Script '{}' not found in skill '{}'", - script_name, skill_name - ))); - } - - // Build the script path using the registry's internal path - // The registry knows where skills are stored - let skill_dir = state.skills.skill_dir(&skill_name); - let scripts_dir = skill_dir.join("scripts"); - let script_path = scripts_dir.join(&script_name); - - if !script_path.exists() { - return Err(AppError::NotFound(format!( - "Script file not found: {}", - script_path.display() - ))); - } - - // Determine how to execute the script based on its extension - let script_path_str = script_path.to_string_lossy().to_string(); - let (command, args) = if script_name.ends_with(".sh") { - ("sh", vec![script_path_str.clone()]) - } else if script_name.ends_with(".py") { - ("python3", vec![script_path_str.clone()]) - } else if script_name.ends_with(".js") { - ("node", vec![script_path_str.clone()]) - } else { - // Default: try to execute directly - (script_path_str.as_str(), vec![]) - }; - - // Build the command with user-provided args - let mut cmd = Command::new(command); - cmd.current_dir(&scripts_dir); - - // Add script path and user args - for arg in args { - cmd.arg(arg); - } - for arg in &req.args { - cmd.arg(arg); - } - - // Add environment variables - for (key, value) in &req.env { - cmd.env(key, value); - } - - // Execute the command with timeout - let output = timeout(Duration::from_secs(30), cmd.output()) - .await - .map_err(|_| AppError::Timeout("Script execution timed out".into()))? - .map_err(|e| AppError::Internal(format!("Failed to execute script: {}", e)))?; - - Ok(Json(ExecuteScriptResponse { - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), - exit_code: output.status.code().unwrap_or(-1), - })) -} diff --git a/sandbox-rs/src/handlers/tee.rs b/sandbox-rs/src/handlers/tee.rs deleted file mode 100644 index 17f7a6e..0000000 --- a/sandbox-rs/src/handlers/tee.rs +++ /dev/null @@ -1,138 +0,0 @@ -use axum::{extract::State, Json}; -use dstack_sdk::dstack_client::{ - GetKeyResponse, GetQuoteResponse, InfoResponse, SignResponse, VerifyResponse, -}; -use serde::Deserialize; -use std::sync::Arc; - -use crate::error::{AppError, Result}; -use crate::state::AppState; - -// Request types -#[derive(Deserialize)] -pub struct GenerateQuoteRequest { - pub report_data: String, // hex-encoded -} - -#[derive(Deserialize)] -pub struct DeriveKeyRequest { - pub path: Option, - pub purpose: Option, -} - -#[derive(Deserialize)] -pub struct SignRequest { - pub algorithm: String, // "secp256k1" - pub data: String, // hex-encoded -} - -#[derive(Deserialize)] -pub struct VerifyRequest { - pub algorithm: String, - pub data: String, - pub signature: String, - pub public_key: String, -} - -#[derive(Deserialize)] -pub struct EmitEventRequest { - pub event: String, - pub payload: String, -} - -// Helper function to decode hex strings -fn decode_hex(s: &str) -> Result> { - hex::decode(s).map_err(|e| AppError::BadRequest(format!("Invalid hex string: {}", e))) -} - -// GET /tee/info - CVM instance metadata -pub async fn tee_info(State(state): State>) -> Result> { - let info = state - .tee_service - .info() - .await - .map_err(|e| AppError::Internal(format!("Failed to get TEE info: {}", e)))?; - - Ok(Json(info)) -} - -// POST /tee/quote - TDX attestation quote -pub async fn generate_quote( - State(state): State>, - Json(req): Json, -) -> Result> { - let report_data = decode_hex(&req.report_data)?; - - let quote = state - .tee_service - .get_quote(&report_data) - .await - .map_err(|e| AppError::Internal(format!("Failed to generate quote: {}", e)))?; - - Ok(Json(quote)) -} - -// POST /tee/derive-key - Derive key with path/purpose -pub async fn derive_key( - State(state): State>, - Json(req): Json, -) -> Result> { - let key = state - .tee_service - .derive_key(req.path.as_deref(), req.purpose.as_deref()) - .await - .map_err(|e| AppError::Internal(format!("Failed to derive key: {}", e)))?; - - Ok(Json(key)) -} - -// POST /tee/sign - Sign with derived key -pub async fn sign_data( - State(state): State>, - Json(req): Json, -) -> Result> { - let data = decode_hex(&req.data)?; - - let signature = state - .tee_service - .sign(&req.algorithm, &data) - .await - .map_err(|e| AppError::Internal(format!("Failed to sign data: {}", e)))?; - - Ok(Json(signature)) -} - -// POST /tee/verify - Verify signature -pub async fn verify_signature( - State(state): State>, - Json(req): Json, -) -> Result> { - let data = decode_hex(&req.data)?; - let signature = decode_hex(&req.signature)?; - let public_key = decode_hex(&req.public_key)?; - - let result = state - .tee_service - .verify(&req.algorithm, &data, &signature, &public_key) - .await - .map_err(|e| AppError::Internal(format!("Failed to verify signature: {}", e)))?; - - Ok(Json(result)) -} - -// POST /tee/emit-event - Emit runtime event -pub async fn emit_event( - State(state): State>, - Json(req): Json, -) -> Result> { - state - .tee_service - .emit_event(&req.event, &req.payload) - .await - .map_err(|e| AppError::Internal(format!("Failed to emit event: {}", e)))?; - - Ok(Json(serde_json::json!({ - "success": true, - "message": format!("Event '{}' emitted successfully", req.event) - }))) -} diff --git a/sandbox-rs/src/main.rs b/sandbox-rs/src/main.rs deleted file mode 100644 index ea66985..0000000 --- a/sandbox-rs/src/main.rs +++ /dev/null @@ -1,98 +0,0 @@ -mod browser; -mod config; -mod error; -mod handlers; -mod skills; -mod state; - -#[cfg(feature = "tee")] -mod tee; - -use axum::{ - routing::{get, post}, - Router, -}; -use std::net::SocketAddr; -use tower_http::trace::TraceLayer; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -use config::Config; -use handlers::{ - browser_click, browser_evaluate, browser_goto, browser_screenshot, browser_status, - browser_type, check_trigger, continue_factory, create_skill, delete_skill, download_file, - exec_command, execute_code, execute_script, get_skill, health_check, list_files, list_skills, - read_file, sandbox_info, search_skills, start_factory, stream_command, update_skill, - upload_file, write_file, -}; - -#[cfg(feature = "tee")] -use handlers::tee::{ - derive_key, emit_event, generate_quote, sign_data, tee_info, verify_signature, -}; -use state::AppState; - -#[tokio::main] -async fn main() { - tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "sandbox_api=debug,tower_http=debug".into()), - ) - .with(tracing_subscriber::fmt::layer()) - .init(); - - let config = Config::from_env(); - let addr = SocketAddr::from(([0, 0, 0, 0], config.port)); - let state = AppState::new(config); - - let app = Router::new() - // Health - .route("/health", get(health_check)) - .route("/sandbox/info", get(sandbox_info)) - // Shell - .route("/shell/exec", post(exec_command)) - .route("/shell/stream", post(stream_command)) - // Code - .route("/code/execute", post(execute_code)) - // Files - .route("/file/read", get(read_file)) - .route("/file/write", post(write_file)) - .route("/file/list", get(list_files)) - .route("/file/upload", post(upload_file)) - .route("/file/download", get(download_file)) - // Skills routes - .route("/skills", get(list_skills).post(create_skill)) - .route("/skills/search", get(search_skills)) - .route( - "/skills/{name}", - get(get_skill).put(update_skill).delete(delete_skill), - ) - .route("/skills/{name}/scripts/{script}", post(execute_script)) - // Factory routes - .route("/factory/start", post(start_factory)) - .route("/factory/continue", post(continue_factory)) - .route("/factory/check", post(check_trigger)) - // Browser routes - .route("/browser/goto", post(browser_goto)) - .route("/browser/screenshot", post(browser_screenshot)) - .route("/browser/evaluate", post(browser_evaluate)) - .route("/browser/click", post(browser_click)) - .route("/browser/type", post(browser_type)) - .route("/browser/status", get(browser_status)); - - #[cfg(feature = "tee")] - let app = app - .route("/tee/info", get(tee_info)) - .route("/tee/quote", post(generate_quote)) - .route("/tee/derive-key", post(derive_key)) - .route("/tee/sign", post(sign_data)) - .route("/tee/verify", post(verify_signature)) - .route("/tee/emit-event", post(emit_event)); - - let app = app.with_state(state).layer(TraceLayer::new_for_http()); - - tracing::info!("listening on {}", addr); - - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); -} diff --git a/sandbox-rs/src/skills/factory.rs b/sandbox-rs/src/skills/factory.rs deleted file mode 100644 index b7b989d..0000000 --- a/sandbox-rs/src/skills/factory.rs +++ /dev/null @@ -1,452 +0,0 @@ -use dashmap::DashMap; -use std::time::Instant; - -#[derive(Debug, Clone, PartialEq)] -pub enum FactoryStep { - Goal, - Trigger, - Example, - Complexity, - EdgeCases, - Confirm, - Done, -} - -impl FactoryStep { - /// Get the prompt message for the current step - pub fn get_prompt(&self) -> &'static str { - match self { - FactoryStep::Goal => "What task do you want me to help with? Give me the high-level goal.", - FactoryStep::Trigger => "When should I use this skill? What words or situations should activate it?", - FactoryStep::Example => "Walk me through a real example. What would you give me as input, and what should I produce?", - FactoryStep::Complexity => "Is this a simple skill (text instructions only) or complex (needs scripts, templates)?", - FactoryStep::EdgeCases => "What should I do if something's missing or goes wrong?", - FactoryStep::Confirm => "Does this capture what you want? Say 'yes' to create.", - FactoryStep::Done => "Skill creation complete!", - } - } - - /// Get the next step in the workflow - pub fn next(&self) -> Self { - match self { - FactoryStep::Goal => FactoryStep::Trigger, - FactoryStep::Trigger => FactoryStep::Example, - FactoryStep::Example => FactoryStep::Complexity, - FactoryStep::Complexity => FactoryStep::EdgeCases, - FactoryStep::EdgeCases => FactoryStep::Confirm, - FactoryStep::Confirm => FactoryStep::Done, - FactoryStep::Done => FactoryStep::Done, - } - } -} - -#[derive(Debug, Clone, Default)] -pub struct FactoryAnswers { - pub goal: Option, - pub triggers: Option>, - pub example_input: Option, - pub example_output: Option, - pub complexity: Option, - pub edge_cases: Option, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum Complexity { - Simple, - Complex, -} - -#[derive(Debug, Clone)] -pub struct FactorySession { - pub id: String, - pub step: FactoryStep, - pub answers: FactoryAnswers, - #[allow(dead_code)] // Used by cleanup_expired - pub created_at: Instant, -} - -impl FactorySession { - /// Create a new factory session - pub fn new(id: String, initial_input: Option) -> Self { - let mut answers = FactoryAnswers::default(); - let step = if let Some(input) = initial_input { - answers.goal = Some(input); - FactoryStep::Trigger - } else { - FactoryStep::Goal - }; - - Self { - id, - step, - answers, - created_at: Instant::now(), - } - } - - /// Get a summary of the current session for confirmation - pub fn get_summary(&self) -> String { - let goal = self.answers.goal.as_deref().unwrap_or("(not specified)"); - let triggers = self.answers.triggers.as_ref() - .map(|t| t.join(", ")) - .unwrap_or_else(|| "(not specified)".to_string()); - let example_input = self.answers.example_input.as_deref().unwrap_or("(not specified)"); - let example_output = self.answers.example_output.as_deref().unwrap_or("(not specified)"); - let complexity = match &self.answers.complexity { - Some(Complexity::Simple) => "Simple (text instructions only)", - Some(Complexity::Complex) => "Complex (needs scripts/templates)", - None => "(not specified)", - }; - let edge_cases = self.answers.edge_cases.as_deref().unwrap_or("(not specified)"); - - format!( - "# Skill Summary\n\n\ - **Goal:** {}\n\ - **Triggers:** {}\n\ - **Example Input:** {}\n\ - **Example Output:** {}\n\ - **Complexity:** {}\n\ - **Edge Cases:** {}\n", - goal, triggers, example_input, example_output, complexity, edge_cases - ) - } -} - -#[derive(Clone)] -pub struct FactorySessions { - sessions: DashMap, -} - -impl FactorySessions { - /// Create a new factory sessions manager - pub fn new() -> Self { - Self { - sessions: DashMap::new(), - } - } - - /// Start a new factory session - pub fn start(&self, initial_input: Option) -> FactorySession { - let id = uuid::Uuid::new_v4().to_string(); - let session = FactorySession::new(id.clone(), initial_input); - self.sessions.insert(id, session.clone()); - session - } - - /// Continue an existing session with user input - pub fn continue_session(&self, id: &str, input: &str) -> anyhow::Result { - let mut session = self.sessions - .get_mut(id) - .ok_or_else(|| anyhow::anyhow!("Session not found: {}", id))?; - - // Process input based on current step - match session.step { - FactoryStep::Goal => { - session.answers.goal = Some(input.to_string()); - session.step = session.step.next(); - } - FactoryStep::Trigger => { - // Parse triggers from input (split by commas, newlines, or semicolons) - let triggers: Vec = input - .split(&[',', '\n', ';'][..]) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - session.answers.triggers = Some(triggers); - session.step = session.step.next(); - } - FactoryStep::Example => { - // Parse example input/output from various formats: - // 1. "input: X -> output: Y" or "input: X output: Y" - // 2. "X -> Y" (arrow separator) - // 3. Just "X" (no separator, only input) - - let input_lower = input.to_lowercase(); - - // Try to find "input:" and "output:" markers - if let Some(input_pos) = input_lower.find("input:") { - let after_input = &input[input_pos + 6..]; - - if let Some(output_pos) = input_lower.find("output:") { - // Both markers found - let input_text = if output_pos > input_pos + 6 { - input[input_pos + 6..output_pos].trim() - } else { - after_input.trim() - }; - let output_text = input[output_pos + 7..].trim(); - - session.answers.example_input = Some(input_text.to_string()); - session.answers.example_output = if !output_text.is_empty() { - Some(output_text.to_string()) - } else { - None - }; - } else { - // Only input marker - session.answers.example_input = Some(after_input.trim().to_string()); - session.answers.example_output = None; - } - } else if let Some(arrow_pos) = input.find("->") { - // Try arrow separator - let input_part = input[..arrow_pos].trim(); - let output_part = input[arrow_pos + 2..].trim(); - - session.answers.example_input = Some(input_part.to_string()); - session.answers.example_output = if !output_part.is_empty() { - Some(output_part.to_string()) - } else { - None - }; - } else { - // No separator found, store whole input as example_input - session.answers.example_input = Some(input.to_string()); - session.answers.example_output = None; - } - - session.step = session.step.next(); - } - FactoryStep::Complexity => { - let normalized = input.trim().to_lowercase(); - let complexity = if normalized.contains("simple") || normalized.contains("text") { - Complexity::Simple - } else if normalized.contains("complex") || normalized.contains("script") || normalized.contains("template") { - Complexity::Complex - } else { - // Default to simple if unclear - Complexity::Simple - }; - session.answers.complexity = Some(complexity); - session.step = session.step.next(); - } - FactoryStep::EdgeCases => { - session.answers.edge_cases = Some(input.to_string()); - session.step = session.step.next(); - } - FactoryStep::Confirm => { - let normalized = input.trim().to_lowercase(); - if normalized == "yes" || normalized == "y" || normalized == "confirm" { - session.step = FactoryStep::Done; - } else { - // Reset to Goal step but preserve answers for review/modification - session.step = FactoryStep::Goal; - } - } - FactoryStep::Done => { - // Already done, no changes - } - } - - Ok(session.clone()) - } - - /// Get a session by ID - #[allow(dead_code)] // Used in tests, reserved for future session lookup - pub fn get(&self, id: &str) -> Option { - self.sessions.get(id).map(|s| s.clone()) - } - - /// Remove expired sessions - #[allow(dead_code)] // Reserved for background cleanup task - pub fn cleanup_expired(&self, max_age_secs: u64) { - let now = Instant::now(); - self.sessions.retain(|_, session| { - now.duration_since(session.created_at).as_secs() < max_age_secs - }); - } -} - -impl Default for FactorySessions { - fn default() -> Self { - Self::new() - } -} - -/// Check if input contains trigger phrases for the factory skill -pub fn check_triggers(input: &str) -> Vec { - let normalized = input.to_lowercase(); - let mut triggers = Vec::new(); - - let trigger_phrases = [ - "teach me", - "teach you", - "learn this", - "learn how", - "create a skill", - "remember how to", - "automate this", - ]; - - for phrase in &trigger_phrases { - if normalized.contains(phrase) { - triggers.push(phrase.to_string()); - } - } - - triggers -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_factory_step_prompts() { - assert_eq!( - FactoryStep::Goal.get_prompt(), - "What task do you want me to help with? Give me the high-level goal." - ); - assert_eq!( - FactoryStep::Trigger.get_prompt(), - "When should I use this skill? What words or situations should activate it?" - ); - } - - #[test] - fn test_factory_step_next() { - assert_eq!(FactoryStep::Goal.next(), FactoryStep::Trigger); - assert_eq!(FactoryStep::Trigger.next(), FactoryStep::Example); - assert_eq!(FactoryStep::Example.next(), FactoryStep::Complexity); - assert_eq!(FactoryStep::Complexity.next(), FactoryStep::EdgeCases); - assert_eq!(FactoryStep::EdgeCases.next(), FactoryStep::Confirm); - assert_eq!(FactoryStep::Confirm.next(), FactoryStep::Done); - assert_eq!(FactoryStep::Done.next(), FactoryStep::Done); - } - - #[test] - fn test_check_triggers() { - let triggers = check_triggers("Can you teach me how to do this?"); - assert!(triggers.contains(&"teach me".to_string())); - - let triggers = check_triggers("I want to create a skill for this"); - assert!(triggers.contains(&"create a skill".to_string())); - - let triggers = check_triggers("Please automate this task"); - assert!(triggers.contains(&"automate this".to_string())); - - let triggers = check_triggers("Just a regular message"); - assert!(triggers.is_empty()); - } - - #[test] - fn test_factory_session_creation() { - let session = FactorySession::new("test-id".to_string(), None); - assert_eq!(session.step, FactoryStep::Goal); - assert!(session.answers.goal.is_none()); - - let session = FactorySession::new("test-id".to_string(), Some("Deploy app".to_string())); - assert_eq!(session.step, FactoryStep::Trigger); - assert_eq!(session.answers.goal, Some("Deploy app".to_string())); - } - - #[test] - fn test_factory_sessions_workflow() { - let sessions = FactorySessions::new(); - - // Start new session - let session = sessions.start(Some("Deploy my app".to_string())); - assert_eq!(session.step, FactoryStep::Trigger); - - // Continue with triggers - let session = sessions.continue_session(&session.id, "deploy, deployment").unwrap(); - assert_eq!(session.step, FactoryStep::Example); - assert_eq!(session.answers.triggers, Some(vec!["deploy".to_string(), "deployment".to_string()])); - - // Continue with example - let session = sessions.continue_session(&session.id, "Deploy to production").unwrap(); - assert_eq!(session.step, FactoryStep::Complexity); - - // Continue with complexity - let session = sessions.continue_session(&session.id, "complex with scripts").unwrap(); - assert_eq!(session.step, FactoryStep::EdgeCases); - assert_eq!(session.answers.complexity, Some(Complexity::Complex)); - - // Continue with edge cases - let session = sessions.continue_session(&session.id, "Handle missing credentials").unwrap(); - assert_eq!(session.step, FactoryStep::Confirm); - - // Confirm - let session = sessions.continue_session(&session.id, "yes").unwrap(); - assert_eq!(session.step, FactoryStep::Done); - } - - #[test] - fn test_cleanup_expired() { - let sessions = FactorySessions::new(); - let session = sessions.start(None); - - // Should not cleanup fresh session - sessions.cleanup_expired(3600); - assert!(sessions.get(&session.id).is_some()); - - // Should cleanup very old sessions (0 seconds = everything is expired) - sessions.cleanup_expired(0); - assert!(sessions.get(&session.id).is_none()); - } - - #[test] - fn test_example_parsing_with_arrow() { - let sessions = FactorySessions::new(); - let session = sessions.start(Some("Test goal".to_string())); - - // Continue to Example step - let session = sessions.continue_session(&session.id, "trigger1").unwrap(); - assert_eq!(session.step, FactoryStep::Example); - - // Test arrow separator - let session = sessions.continue_session(&session.id, "User says 'help me' -> I respond with helpful info").unwrap(); - assert_eq!(session.answers.example_input, Some("User says 'help me'".to_string())); - assert_eq!(session.answers.example_output, Some("I respond with helpful info".to_string())); - } - - #[test] - fn test_example_parsing_with_markers() { - let sessions = FactorySessions::new(); - let session = sessions.start(Some("Test goal".to_string())); - - // Continue to Example step - let session = sessions.continue_session(&session.id, "trigger1").unwrap(); - - // Test input/output markers - let session = sessions.continue_session(&session.id, "input: Deploy app output: Success message").unwrap(); - assert_eq!(session.answers.example_input, Some("Deploy app".to_string())); - assert_eq!(session.answers.example_output, Some("Success message".to_string())); - } - - #[test] - fn test_example_parsing_no_separator() { - let sessions = FactorySessions::new(); - let session = sessions.start(Some("Test goal".to_string())); - - // Continue to Example step - let session = sessions.continue_session(&session.id, "trigger1").unwrap(); - - // Test no separator - let session = sessions.continue_session(&session.id, "Just an example input").unwrap(); - assert_eq!(session.answers.example_input, Some("Just an example input".to_string())); - assert_eq!(session.answers.example_output, None); - } - - #[test] - fn test_rejection_preserves_answers() { - let sessions = FactorySessions::new(); - let session = sessions.start(Some("Deploy app".to_string())); - - // Fill in all steps - let session = sessions.continue_session(&session.id, "deploy").unwrap(); - let session = sessions.continue_session(&session.id, "input -> output").unwrap(); - let session = sessions.continue_session(&session.id, "simple").unwrap(); - let session = sessions.continue_session(&session.id, "Handle errors").unwrap(); - assert_eq!(session.step, FactoryStep::Confirm); - - // Reject and verify answers are preserved - let session = sessions.continue_session(&session.id, "no").unwrap(); - assert_eq!(session.step, FactoryStep::Goal); - assert_eq!(session.answers.goal, Some("Deploy app".to_string())); - assert_eq!(session.answers.triggers, Some(vec!["deploy".to_string()])); - assert_eq!(session.answers.example_input, Some("input".to_string())); - assert_eq!(session.answers.example_output, Some("output".to_string())); - assert_eq!(session.answers.complexity, Some(Complexity::Simple)); - assert_eq!(session.answers.edge_cases, Some("Handle errors".to_string())); - } -} diff --git a/sandbox-rs/src/skills/mod.rs b/sandbox-rs/src/skills/mod.rs deleted file mode 100644 index 6723bbd..0000000 --- a/sandbox-rs/src/skills/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -pub mod types; -pub mod registry; -pub mod factory; - -pub use types::{Skill, SkillSummary}; -pub use registry::{SkillRegistry, CreateSkillRequest, UpdateSkillRequest}; -pub use factory::{ - FactorySessions, check_triggers -}; diff --git a/sandbox-rs/src/skills/registry.rs b/sandbox-rs/src/skills/registry.rs deleted file mode 100644 index 6f4e5fc..0000000 --- a/sandbox-rs/src/skills/registry.rs +++ /dev/null @@ -1,504 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use tokio::fs; -use crate::error::{AppError, Result}; -use super::types::{Skill, SkillMeta, SkillSummary, validate_skill_name, validate_description}; - -/// Request to create a new skill -#[derive(Debug, Clone)] -pub struct CreateSkillRequest { - pub name: String, - pub description: String, - pub body: String, - pub scripts: HashMap, // filename -> content - pub references: HashMap, - pub assets: HashMap, -} - -/// Request to update an existing skill -#[derive(Debug, Clone, Default)] -pub struct UpdateSkillRequest { - pub description: Option, - pub body: Option, - pub scripts: Option>, - pub references: Option>, - pub assets: Option>, -} - -/// Registry for managing skills in the filesystem -#[derive(Clone)] -pub struct SkillRegistry { - skills_dir: PathBuf, -} - -/// Validate that a filename doesn't contain path traversal sequences -fn validate_filename(filename: &str) -> Result<()> { - if filename.is_empty() { - return Err(AppError::BadRequest("Filename cannot be empty".into())); - } - if filename.contains('/') || filename.contains('\\') || filename.contains("..") { - return Err(AppError::BadRequest("Invalid filename: path traversal not allowed".into())); - } - Ok(()) -} - -impl SkillRegistry { - /// Create a new skill registry - pub fn new(skills_dir: PathBuf) -> Self { - Self { skills_dir } - } - - /// Ensure the skills directory exists - async fn ensure_skills_dir(&self) -> Result<()> { - fs::create_dir_all(&self.skills_dir).await?; - Ok(()) - } - - /// Get the path to a skill directory - fn skill_path(&self, name: &str) -> PathBuf { - self.skills_dir.join(name) - } - - /// Get the path to a skill directory (public accessor) - pub fn skill_dir(&self, name: &str) -> PathBuf { - self.skill_path(name) - } - - /// Get the path to a skill's SKILL.md file - fn skill_md_path(&self, name: &str) -> PathBuf { - self.skill_path(name).join("SKILL.md") - } - - /// List all skills - pub async fn list(&self) -> Result> { - self.ensure_skills_dir().await?; - - let mut entries = fs::read_dir(&self.skills_dir).await?; - let mut summaries = Vec::new(); - - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if !path.is_dir() { - continue; - } - - let name = match path.file_name().and_then(|n| n.to_str()) { - Some(name) => name.to_string(), - None => continue, - }; - - // Try to read the skill to get its description - match self.get(&name).await { - Ok(skill) => { - summaries.push(SkillSummary { - name: skill.meta.name, - description: skill.meta.description, - }); - } - Err(_) => { - // Skip invalid skills - continue; - } - } - } - - summaries.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(summaries) - } - - /// Get a skill by name - pub async fn get(&self, name: &str) -> Result { - validate_skill_name(name).map_err(|e| AppError::BadRequest(e))?; - - let skill_md_path = self.skill_md_path(name); - if !skill_md_path.exists() { - return Err(AppError::NotFound(format!("Skill '{}' not found", name))); - } - - let content = fs::read_to_string(&skill_md_path).await?; - let (meta, body) = self.parse_skill_md(&content)?; - - // List scripts, references, and assets - let skill_dir = self.skill_path(name); - let scripts = self.list_dir_files(&skill_dir.join("scripts")).await?; - let references = self.list_dir_files(&skill_dir.join("references")).await?; - let assets = self.list_dir_files(&skill_dir.join("assets")).await?; - - Ok(Skill { - meta, - body, - scripts, - references, - assets, - }) - } - - /// Create a new skill - pub async fn create(&self, req: CreateSkillRequest) -> Result { - validate_skill_name(&req.name).map_err(|e| AppError::BadRequest(e))?; - validate_description(&req.description).map_err(|e| AppError::BadRequest(e))?; - - let skill_dir = self.skill_path(&req.name); - if skill_dir.exists() { - return Err(AppError::BadRequest(format!("Skill '{}' already exists", req.name))); - } - - // Create skill directory structure - fs::create_dir_all(&skill_dir).await?; - fs::create_dir_all(skill_dir.join("scripts")).await?; - fs::create_dir_all(skill_dir.join("references")).await?; - fs::create_dir_all(skill_dir.join("assets")).await?; - - // Create metadata - let meta = SkillMeta { - name: req.name.clone(), - description: req.description.clone(), - license: None, - compatibility: None, - metadata: None, - }; - - // Write SKILL.md - let skill_md = self.format_skill_md(&meta, &req.body); - fs::write(self.skill_md_path(&req.name), skill_md).await?; - - // Write scripts - for (filename, content) in &req.scripts { - validate_filename(filename)?; - let script_path = skill_dir.join("scripts").join(filename); - fs::write(script_path, content).await?; - } - - // Write references - for (filename, content) in &req.references { - validate_filename(filename)?; - let ref_path = skill_dir.join("references").join(filename); - fs::write(ref_path, content).await?; - } - - // Write assets - for (filename, content) in &req.assets { - validate_filename(filename)?; - let asset_path = skill_dir.join("assets").join(filename); - fs::write(asset_path, content).await?; - } - - self.get(&req.name).await - } - - /// Update an existing skill - pub async fn update(&self, name: &str, req: UpdateSkillRequest) -> Result { - validate_skill_name(name).map_err(|e| AppError::BadRequest(e))?; - - // Get existing skill - let mut skill = self.get(name).await?; - let skill_dir = self.skill_path(name); - - // Update metadata if description changed - if let Some(description) = &req.description { - validate_description(description).map_err(|e| AppError::BadRequest(e))?; - skill.meta.description = description.clone(); - } - - // Update body if provided - if let Some(body) = &req.body { - skill.body = body.clone(); - } - - // Write updated SKILL.md - let skill_md = self.format_skill_md(&skill.meta, &skill.body); - fs::write(self.skill_md_path(name), skill_md).await?; - - // Update scripts if provided - if let Some(scripts) = &req.scripts { - let scripts_dir = skill_dir.join("scripts"); - // Remove old scripts - if scripts_dir.exists() { - fs::remove_dir_all(&scripts_dir).await?; - } - fs::create_dir_all(&scripts_dir).await?; - // Write new scripts - for (filename, content) in scripts { - validate_filename(filename)?; - let script_path = scripts_dir.join(filename); - fs::write(script_path, content).await?; - } - } - - // Update references if provided - if let Some(references) = &req.references { - let references_dir = skill_dir.join("references"); - // Remove old references - if references_dir.exists() { - fs::remove_dir_all(&references_dir).await?; - } - fs::create_dir_all(&references_dir).await?; - // Write new references - for (filename, content) in references { - validate_filename(filename)?; - let ref_path = references_dir.join(filename); - fs::write(ref_path, content).await?; - } - } - - // Update assets if provided - if let Some(assets) = &req.assets { - let assets_dir = skill_dir.join("assets"); - // Remove old assets - if assets_dir.exists() { - fs::remove_dir_all(&assets_dir).await?; - } - fs::create_dir_all(&assets_dir).await?; - // Write new assets - for (filename, content) in assets { - validate_filename(filename)?; - let asset_path = assets_dir.join(filename); - fs::write(asset_path, content).await?; - } - } - - self.get(name).await - } - - /// Delete a skill - pub async fn delete(&self, name: &str) -> Result<()> { - validate_skill_name(name).map_err(|e| AppError::BadRequest(e))?; - - let skill_dir = self.skill_path(name); - if !skill_dir.exists() { - return Err(AppError::NotFound(format!("Skill '{}' not found", name))); - } - - fs::remove_dir_all(&skill_dir).await?; - Ok(()) - } - - /// Search for skills by query (searches name and description) - pub async fn search(&self, query: &str) -> Result> { - let all_skills = self.list().await?; - let query_lower = query.to_lowercase(); - - let results: Vec = all_skills - .into_iter() - .filter(|skill| { - skill.name.to_lowercase().contains(&query_lower) - || skill.description.to_lowercase().contains(&query_lower) - }) - .collect(); - - Ok(results) - } - - /// Parse SKILL.md into metadata and body - fn parse_skill_md(&self, content: &str) -> Result<(SkillMeta, String)> { - // Split on --- to extract frontmatter - let parts: Vec<&str> = content.splitn(3, "---").collect(); - - if parts.len() < 3 { - return Err(AppError::BadRequest( - "Invalid SKILL.md format: missing frontmatter delimiters".to_string(), - )); - } - - // Parse YAML frontmatter (parts[1]) - let frontmatter = parts[1].trim(); - let meta: SkillMeta = serde_yaml::from_str(frontmatter) - .map_err(|e| AppError::BadRequest(format!("Failed to parse frontmatter: {}", e)))?; - - // Body is everything after the second --- - let body = parts[2].trim().to_string(); - - Ok((meta, body)) - } - - /// Format SKILL.md from metadata and body - fn format_skill_md(&self, meta: &SkillMeta, body: &str) -> String { - let frontmatter = serde_yaml::to_string(meta).unwrap_or_default(); - format!("---\n{}---\n\n{}", frontmatter, body) - } - - /// List files in a directory - async fn list_dir_files(&self, dir: &PathBuf) -> Result> { - if !dir.exists() { - return Ok(Vec::new()); - } - - let mut entries = fs::read_dir(dir).await?; - let mut files = Vec::new(); - - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if path.is_file() { - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - files.push(name.to_string()); - } - } - } - - files.sort(); - Ok(files) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - async fn create_test_registry() -> (SkillRegistry, TempDir) { - let temp_dir = TempDir::new().unwrap(); - let registry = SkillRegistry::new(temp_dir.path().to_path_buf()); - (registry, temp_dir) - } - - #[tokio::test] - async fn test_create_and_get_skill() { - let (registry, _temp) = create_test_registry().await; - - let req = CreateSkillRequest { - name: "test-skill".to_string(), - description: "A test skill".to_string(), - body: "This is the skill body".to_string(), - scripts: HashMap::new(), - references: HashMap::new(), - assets: HashMap::new(), - }; - - let created = registry.create(req).await.unwrap(); - assert_eq!(created.meta.name, "test-skill"); - assert_eq!(created.meta.description, "A test skill"); - assert_eq!(created.body, "This is the skill body"); - - let retrieved = registry.get("test-skill").await.unwrap(); - assert_eq!(retrieved.meta.name, "test-skill"); - } - - #[tokio::test] - async fn test_list_skills() { - let (registry, _temp) = create_test_registry().await; - - let req1 = CreateSkillRequest { - name: "skill-one".to_string(), - description: "First skill".to_string(), - body: "Body 1".to_string(), - scripts: HashMap::new(), - references: HashMap::new(), - assets: HashMap::new(), - }; - - let req2 = CreateSkillRequest { - name: "skill-two".to_string(), - description: "Second skill".to_string(), - body: "Body 2".to_string(), - scripts: HashMap::new(), - references: HashMap::new(), - assets: HashMap::new(), - }; - - registry.create(req1).await.unwrap(); - registry.create(req2).await.unwrap(); - - let skills = registry.list().await.unwrap(); - assert_eq!(skills.len(), 2); - assert_eq!(skills[0].name, "skill-one"); - assert_eq!(skills[1].name, "skill-two"); - } - - #[tokio::test] - async fn test_update_skill() { - let (registry, _temp) = create_test_registry().await; - - let req = CreateSkillRequest { - name: "update-test".to_string(), - description: "Original description".to_string(), - body: "Original body".to_string(), - scripts: HashMap::new(), - references: HashMap::new(), - assets: HashMap::new(), - }; - - registry.create(req).await.unwrap(); - - let update_req = UpdateSkillRequest { - description: Some("Updated description".to_string()), - body: Some("Updated body".to_string()), - ..Default::default() - }; - - let updated = registry.update("update-test", update_req).await.unwrap(); - assert_eq!(updated.meta.description, "Updated description"); - assert_eq!(updated.body, "Updated body"); - } - - #[tokio::test] - async fn test_delete_skill() { - let (registry, _temp) = create_test_registry().await; - - let req = CreateSkillRequest { - name: "delete-me".to_string(), - description: "To be deleted".to_string(), - body: "Body".to_string(), - scripts: HashMap::new(), - references: HashMap::new(), - assets: HashMap::new(), - }; - - registry.create(req).await.unwrap(); - assert!(registry.get("delete-me").await.is_ok()); - - registry.delete("delete-me").await.unwrap(); - assert!(registry.get("delete-me").await.is_err()); - } - - #[tokio::test] - async fn test_search_skills() { - let (registry, _temp) = create_test_registry().await; - - let req1 = CreateSkillRequest { - name: "rust-skill".to_string(), - description: "A skill for Rust programming".to_string(), - body: "Body".to_string(), - scripts: HashMap::new(), - references: HashMap::new(), - assets: HashMap::new(), - }; - - let req2 = CreateSkillRequest { - name: "python-skill".to_string(), - description: "A skill for Python programming".to_string(), - body: "Body".to_string(), - scripts: HashMap::new(), - references: HashMap::new(), - assets: HashMap::new(), - }; - - registry.create(req1).await.unwrap(); - registry.create(req2).await.unwrap(); - - let results = registry.search("rust").await.unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].name, "rust-skill"); - - let results = registry.search("programming").await.unwrap(); - assert_eq!(results.len(), 2); - } - - #[tokio::test] - async fn test_parse_skill_md() { - let (registry, _temp) = create_test_registry().await; - - let content = r#"--- -name: test-skill -description: A test skill ---- - -This is the body content -with multiple lines -"#; - - let (meta, body) = registry.parse_skill_md(content).unwrap(); - assert_eq!(meta.name, "test-skill"); - assert_eq!(meta.description, "A test skill"); - assert!(body.contains("This is the body content")); - } -} diff --git a/sandbox-rs/src/skills/types.rs b/sandbox-rs/src/skills/types.rs deleted file mode 100644 index 7b56903..0000000 --- a/sandbox-rs/src/skills/types.rs +++ /dev/null @@ -1,143 +0,0 @@ -use serde::{Deserialize, Serialize}; -use regex::Regex; -use std::sync::OnceLock; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SkillMeta { - pub name: String, - pub description: String, - #[serde(default)] - pub license: Option, - #[serde(default)] - pub compatibility: Option, - #[serde(default)] - pub metadata: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct Skill { - #[serde(flatten)] - pub meta: SkillMeta, - pub body: String, - pub scripts: Vec, - pub references: Vec, - pub assets: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SkillSummary { - pub name: String, - pub description: String, -} - -// Regex for skill name validation -// Must be lowercase alphanumeric + hyphens, no consecutive hyphens, no start/end with hyphen -static SKILL_NAME_REGEX: OnceLock = OnceLock::new(); - -fn get_skill_name_regex() -> &'static Regex { - SKILL_NAME_REGEX.get_or_init(|| { - Regex::new(r"^[a-z0-9]+(-[a-z0-9]+)*$").unwrap() - }) -} - -/// Validates a skill name according to agentskills.io spec: -/// - Length: 1-64 characters -/// - Characters: lowercase alphanumeric + hyphens -/// - No consecutive hyphens -/// - Cannot start or end with a hyphen -pub fn validate_skill_name(name: &str) -> Result<(), String> { - let len = name.len(); - - if len == 0 { - return Err("Skill name cannot be empty".to_string()); - } - - if len > 64 { - return Err(format!("Skill name too long: {} characters (max 64)", len)); - } - - let regex = get_skill_name_regex(); - if !regex.is_match(name) { - return Err( - "Skill name must be lowercase alphanumeric with hyphens, \ - no consecutive hyphens, and cannot start/end with hyphen" - .to_string(), - ); - } - - Ok(()) -} - -/// Validates a skill description: -/// - Length: 1-1024 characters -pub fn validate_description(desc: &str) -> Result<(), String> { - let len = desc.len(); - - if len == 0 { - return Err("Description cannot be empty".to_string()); - } - - if len > 1024 { - return Err(format!("Description too long: {} characters (max 1024)", len)); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_validate_skill_name_valid() { - assert!(validate_skill_name("my-skill").is_ok()); - assert!(validate_skill_name("skill123").is_ok()); - assert!(validate_skill_name("a").is_ok()); - assert!(validate_skill_name("my-cool-skill-2").is_ok()); - } - - #[test] - fn test_validate_skill_name_invalid() { - // Empty - assert!(validate_skill_name("").is_err()); - - // Too long - let long_name = "a".repeat(65); - assert!(validate_skill_name(&long_name).is_err()); - - // Uppercase - assert!(validate_skill_name("My-Skill").is_err()); - - // Consecutive hyphens - assert!(validate_skill_name("my--skill").is_err()); - - // Start with hyphen - assert!(validate_skill_name("-myskill").is_err()); - - // End with hyphen - assert!(validate_skill_name("myskill-").is_err()); - - // Invalid characters - assert!(validate_skill_name("my_skill").is_err()); - assert!(validate_skill_name("my.skill").is_err()); - assert!(validate_skill_name("my skill").is_err()); - } - - #[test] - fn test_validate_description_valid() { - assert!(validate_description("A valid description").is_ok()); - assert!(validate_description("a").is_ok()); - let long_desc = "a".repeat(1024); - assert!(validate_description(&long_desc).is_ok()); - } - - #[test] - fn test_validate_description_invalid() { - // Empty - assert!(validate_description("").is_err()); - - // Too long - let too_long = "a".repeat(1025); - assert!(validate_description(&too_long).is_err()); - } -} diff --git a/sandbox-rs/src/state.rs b/sandbox-rs/src/state.rs deleted file mode 100644 index 4f58ab5..0000000 --- a/sandbox-rs/src/state.rs +++ /dev/null @@ -1,52 +0,0 @@ -use crate::config::Config; -use crate::skills::{SkillRegistry, FactorySessions}; -use crate::browser::{BrowserService, BrowserServiceConfig}; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Instant; - -#[cfg(feature = "tee")] -use crate::tee::TeeService; - -#[derive(Clone)] -pub struct AppState { - pub config: Config, - pub start_time: Instant, - pub skills: SkillRegistry, - pub factory: FactorySessions, - pub browser: BrowserService, - #[cfg(feature = "tee")] - pub tee_service: TeeService, -} - -impl AppState { - pub fn new(config: Config) -> Arc { - let skills = SkillRegistry::new(PathBuf::from(&config.skills_dir)); - let factory = FactorySessions::new(); - - let browser_config = BrowserServiceConfig { - headless: config.browser_headless, - executable_path: config.browser_executable.clone(), - viewport_width: config.browser_viewport_width, - viewport_height: config.browser_viewport_height, - timeout: config.browser_timeout, - }; - - #[cfg(feature = "tee")] - let tee_service = TeeService::new(None); - - Arc::new(Self { - config, - start_time: Instant::now(), - skills, - factory, - browser: BrowserService::new(browser_config), - #[cfg(feature = "tee")] - tee_service, - }) - } - - pub fn uptime_secs(&self) -> f64 { - self.start_time.elapsed().as_secs_f64() - } -} diff --git a/sandbox-rs/src/tee/client.rs b/sandbox-rs/src/tee/client.rs deleted file mode 100644 index 76f816e..0000000 --- a/sandbox-rs/src/tee/client.rs +++ /dev/null @@ -1,56 +0,0 @@ -use dstack_sdk::dstack_client::{ - DstackClient, GetKeyResponse, GetQuoteResponse, InfoResponse, SignResponse, VerifyResponse, -}; -use std::sync::Arc; - -#[derive(Clone)] -pub struct TeeService { - client: Arc, -} - -impl TeeService { - pub fn new(endpoint: Option<&str>) -> Self { - Self { - client: Arc::new(DstackClient::new(endpoint)), - } - } - - pub async fn info(&self) -> anyhow::Result { - self.client.info().await - } - - pub async fn get_quote(&self, report_data: &[u8]) -> anyhow::Result { - // DstackClient.get_quote() requires Vec as it consumes the data for hex encoding - self.client.get_quote(report_data.to_vec()).await - } - - pub async fn derive_key(&self, path: Option<&str>, purpose: Option<&str>) -> anyhow::Result { - self.client.get_key( - path.map(|s| s.to_string()), - purpose.map(|s| s.to_string()) - ).await - } - - pub async fn sign(&self, algorithm: &str, data: &[u8]) -> anyhow::Result { - // DstackClient.sign() requires Vec as it consumes the data for hex encoding - self.client.sign(algorithm, data.to_vec()).await - } - - pub async fn verify(&self, algorithm: &str, data: &[u8], signature: &[u8], public_key: &[u8]) -> anyhow::Result { - // DstackClient.verify() requires Vec for all byte parameters as it consumes them for hex encoding - self.client.verify( - algorithm, - data.to_vec(), - signature.to_vec(), - public_key.to_vec() - ).await - } - - pub async fn emit_event(&self, event: &str, payload: &str) -> anyhow::Result<()> { - // DstackClient.emit_event() requires Vec payload as it consumes it for hex encoding - self.client.emit_event( - event.to_string(), - payload.as_bytes().to_vec() - ).await - } -} diff --git a/sandbox-rs/src/tee/mod.rs b/sandbox-rs/src/tee/mod.rs deleted file mode 100644 index d3ba2b5..0000000 --- a/sandbox-rs/src/tee/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -#[cfg(feature = "tee")] -pub mod client; - -#[cfg(feature = "tee")] -pub use client::TeeService; diff --git a/sandbox-rs/tests/browser_test.rs b/sandbox-rs/tests/browser_test.rs deleted file mode 100644 index 3aa131d..0000000 --- a/sandbox-rs/tests/browser_test.rs +++ /dev/null @@ -1,279 +0,0 @@ -use reqwest::Client; -use serde_json::{json, Value}; -use std::time::Duration; -use tokio::time::sleep; - -async fn wait_for_server(base_url: &str) { - let client = Client::new(); - for _ in 0..50 { - if client - .get(format!("{}/health", base_url)) - .send() - .await - .is_ok() - { - return; - } - sleep(Duration::from_millis(100)).await; - } - panic!("Server did not start in time"); -} - -#[tokio::test] -#[ignore] // Requires running server with Chromium -async fn test_browser_status_before_use() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .get(format!("{}/browser/status", base_url)) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert!(body["running"].is_boolean()); -} - -#[tokio::test] -#[ignore] // Requires running server with Chromium -async fn test_browser_goto() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .post(format!("{}/browser/goto", base_url)) - .json(&json!({ - "url": "https://example.com" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert!(body["url"].is_string()); - assert!(body["title"].is_string()); - // example.com should have "Example Domain" in title - let title = body["title"].as_str().unwrap(); - assert!(title.contains("Example"), "Expected 'Example' in title, got: {}", title); -} - -#[tokio::test] -#[ignore] // Requires running server with Chromium -async fn test_browser_screenshot() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .post(format!("{}/browser/screenshot", base_url)) - .json(&json!({ - "url": "https://example.com" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert!(body["data"].is_string()); - assert_eq!(body["format"], "png"); - assert!(body["width"].is_number()); - assert!(body["height"].is_number()); - - // Verify it's valid base64 - let data = body["data"].as_str().unwrap(); - assert!(!data.is_empty(), "Screenshot data should not be empty"); -} - -#[tokio::test] -#[ignore] // Requires running server with Chromium -async fn test_browser_evaluate() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .post(format!("{}/browser/evaluate", base_url)) - .json(&json!({ - "url": "https://example.com", - "script": "document.title" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert!(body["result"].is_string()); - let result = body["result"].as_str().unwrap(); - assert!(result.contains("Example"), "Expected 'Example' in result, got: {}", result); -} - -#[tokio::test] -#[ignore] // Requires running server with Chromium -async fn test_browser_evaluate_math() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .post(format!("{}/browser/evaluate", base_url)) - .json(&json!({ - "url": "https://example.com", - "script": "2 + 2" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["result"], 4); -} - -#[tokio::test] -#[ignore] // Requires running server with Chromium -async fn test_browser_click_nonexistent() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .post(format!("{}/browser/click", base_url)) - .json(&json!({ - "url": "https://example.com", - "selector": "#nonexistent-element-12345" - })) - .send() - .await - .expect("Failed to send request"); - - // Should return 404 for element not found - assert_eq!(resp.status(), 404); -} - -#[tokio::test] -#[ignore] // Requires running server with Chromium -async fn test_browser_click_existing() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - // example.com has an link we can click - let resp = client - .post(format!("{}/browser/click", base_url)) - .json(&json!({ - "url": "https://example.com", - "selector": "a" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); -} - -#[tokio::test] -#[ignore] // Requires running server with Chromium -async fn test_browser_type_nonexistent() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .post(format!("{}/browser/type", base_url)) - .json(&json!({ - "url": "https://example.com", - "selector": "#nonexistent-input-12345", - "text": "Hello World" - })) - .send() - .await - .expect("Failed to send request"); - - // Should return 404 for element not found - assert_eq!(resp.status(), 404); -} - -#[tokio::test] -#[ignore] // Requires running server with Chromium -async fn test_browser_status_after_use() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // First trigger browser launch with a goto - client - .post(format!("{}/browser/goto", base_url)) - .json(&json!({ - "url": "https://example.com" - })) - .send() - .await - .expect("Failed to send request"); - - // Now check status - should be running - let resp = client - .get(format!("{}/browser/status", base_url)) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["running"], true); - // Version should be available after browser is running - assert!(body["version"].is_string() || body["version"].is_null()); -} - -#[tokio::test] -#[ignore] // Requires running server with Chromium -async fn test_browser_goto_invalid_url() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .post(format!("{}/browser/goto", base_url)) - .json(&json!({ - "url": "not-a-valid-url" - })) - .send() - .await - .expect("Failed to send request"); - - // Should fail with navigation error (500) - assert!(resp.status().is_client_error() || resp.status().is_server_error()); -} diff --git a/sandbox-rs/tests/code_test.rs b/sandbox-rs/tests/code_test.rs deleted file mode 100644 index 91ac3e1..0000000 --- a/sandbox-rs/tests/code_test.rs +++ /dev/null @@ -1,90 +0,0 @@ -use reqwest::Client; -use serde_json::{json, Value}; -use std::time::Duration; -use tokio::time::sleep; - -async fn wait_for_server(base_url: &str) { - let client = Client::new(); - for _ in 0..50 { - if client - .get(format!("{}/health", base_url)) - .send() - .await - .is_ok() - { - return; - } - sleep(Duration::from_millis(100)).await; - } - panic!("Server did not start in time"); -} - -#[tokio::test] -async fn test_code_python() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .post(format!("{}/code/execute", base_url)) - .json(&json!({ - "code": "print('hello from python')", - "language": "python" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["output"].as_str().unwrap().trim(), "hello from python"); - assert_eq!(body["exit_code"], 0); -} - -#[tokio::test] -async fn test_code_bash() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .post(format!("{}/code/execute", base_url)) - .json(&json!({ - "code": "echo 'hello from bash'", - "language": "bash" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["output"].as_str().unwrap().trim(), "hello from bash"); -} - -#[tokio::test] -async fn test_code_unsupported_language() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .post(format!("{}/code/execute", base_url)) - .json(&json!({ - "code": "print('hello')", - "language": "cobol" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 400); -} diff --git a/sandbox-rs/tests/factory_test.rs b/sandbox-rs/tests/factory_test.rs deleted file mode 100644 index e71fe1c..0000000 --- a/sandbox-rs/tests/factory_test.rs +++ /dev/null @@ -1,374 +0,0 @@ -use reqwest::Client; -use serde_json::{json, Value}; -use std::time::Duration; -use tempfile::TempDir; -use tokio::time::sleep; - -async fn wait_for_server(base_url: &str) { - let client = Client::new(); - for _ in 0..50 { - if client - .get(format!("{}/health", base_url)) - .send() - .await - .is_ok() - { - return; - } - sleep(Duration::from_millis(100)).await; - } - panic!("Server did not start in time"); -} - -fn setup_test_env() -> TempDir { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - std::env::set_var("SKILLS_DIR", temp_dir.path().to_str().unwrap()); - temp_dir -} - -#[tokio::test] -async fn test_factory_full_flow() { - let _temp = setup_test_env(); - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // Step 1: Start factory session with initial goal - let start_resp = client - .post(format!("{}/factory/start", base_url)) - .json(&json!({ - "initial_input": "Deploy my application to production" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(start_resp.status(), 200); - - let start_body: Value = start_resp.json().await.expect("Failed to parse JSON"); - let session_id = start_body["session_id"].as_str().unwrap().to_string(); - assert_eq!(start_body["step"], "Trigger"); - assert_eq!(start_body["done"], false); - assert!(start_body["prompt"].as_str().unwrap().contains("When should I use this skill")); - - // Step 2: Provide triggers - let continue_resp = client - .post(format!("{}/factory/continue", base_url)) - .json(&json!({ - "session_id": session_id, - "input": "deploy, deployment, ship to production" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(continue_resp.status(), 200); - - let body: Value = continue_resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["step"], "Example"); - assert_eq!(body["done"], false); - assert!(body["prompt"].as_str().unwrap().contains("Walk me through a real example")); - - // Step 3: Provide example - let continue_resp = client - .post(format!("{}/factory/continue", base_url)) - .json(&json!({ - "session_id": session_id, - "input": "input: Deploy v2.0 to production -> output: Application deployed successfully" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(continue_resp.status(), 200); - - let body: Value = continue_resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["step"], "Complexity"); - assert_eq!(body["done"], false); - assert!(body["prompt"].as_str().unwrap().contains("simple skill")); - - // Step 4: Provide complexity - let continue_resp = client - .post(format!("{}/factory/continue", base_url)) - .json(&json!({ - "session_id": session_id, - "input": "complex - needs scripts for deployment" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(continue_resp.status(), 200); - - let body: Value = continue_resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["step"], "EdgeCases"); - assert_eq!(body["done"], false); - assert!(body["prompt"].as_str().unwrap().contains("missing or goes wrong")); - - // Step 5: Provide edge cases - let continue_resp = client - .post(format!("{}/factory/continue", base_url)) - .json(&json!({ - "session_id": session_id, - "input": "Handle connection errors and rollback on failure" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(continue_resp.status(), 200); - - let body: Value = continue_resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["step"], "Confirm"); - assert_eq!(body["done"], false); - assert!(body["prompt"].as_str().unwrap().contains("Does this capture what you want")); - // Should include summary in the prompt - assert!(body["prompt"].as_str().unwrap().contains("Skill Summary")); - - // Step 6: Confirm - let continue_resp = client - .post(format!("{}/factory/continue", base_url)) - .json(&json!({ - "session_id": session_id, - "input": "yes" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(continue_resp.status(), 200); - - let body: Value = continue_resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["step"], "Done"); - assert_eq!(body["done"], true); - assert!(body["skill"].is_object()); - - let skill = &body["skill"]; - assert!(skill["name"].as_str().unwrap().contains("deploy")); - assert!(skill["description"].as_str().is_some()); -} - -#[tokio::test] -async fn test_factory_session_not_found() { - let _temp = setup_test_env(); - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // Try to continue with a non-existent session ID - let resp = client - .post(format!("{}/factory/continue", base_url)) - .json(&json!({ - "session_id": "non-existent-session-id", - "input": "some input" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 400); -} - -#[tokio::test] -async fn test_check_trigger_phrases() { - let _temp = setup_test_env(); - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // Test input that should trigger - let resp = client - .post(format!("{}/factory/check", base_url)) - .json(&json!({ - "input": "Can you teach me how to do this task?" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["triggers_factory"], true); - assert!(body["matched_phrases"].is_array()); - let phrases = body["matched_phrases"].as_array().unwrap(); - assert!(phrases.len() > 0); - assert!(phrases.iter().any(|p| p.as_str().unwrap() == "teach me")); - - // Test "create a skill" trigger - let resp = client - .post(format!("{}/factory/check", base_url)) - .json(&json!({ - "input": "I want to create a skill for this" - })) - .send() - .await - .expect("Failed to send request"); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["triggers_factory"], true); - let phrases = body["matched_phrases"].as_array().unwrap(); - assert!(phrases.iter().any(|p| p.as_str().unwrap() == "create a skill")); - - // Test "automate this" trigger - let resp = client - .post(format!("{}/factory/check", base_url)) - .json(&json!({ - "input": "Please help me automate this process" - })) - .send() - .await - .expect("Failed to send request"); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["triggers_factory"], true); - let phrases = body["matched_phrases"].as_array().unwrap(); - assert!(phrases.iter().any(|p| p.as_str().unwrap() == "automate this")); - - // Test input that should NOT trigger - let resp = client - .post(format!("{}/factory/check", base_url)) - .json(&json!({ - "input": "What's the weather today?" - })) - .send() - .await - .expect("Failed to send request"); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["triggers_factory"], false); - let phrases = body["matched_phrases"].as_array().unwrap(); - assert_eq!(phrases.len(), 0); -} - -#[tokio::test] -async fn test_factory_start_without_initial_input() { - let _temp = setup_test_env(); - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // Start factory session without initial input - let start_resp = client - .post(format!("{}/factory/start", base_url)) - .json(&json!({})) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(start_resp.status(), 200); - - let start_body: Value = start_resp.json().await.expect("Failed to parse JSON"); - let session_id = start_body["session_id"].as_str().unwrap().to_string(); - assert_eq!(start_body["step"], "Goal"); - assert_eq!(start_body["done"], false); - assert!(start_body["prompt"].as_str().unwrap().contains("What task do you want me to help with")); - - // Now provide the goal - let continue_resp = client - .post(format!("{}/factory/continue", base_url)) - .json(&json!({ - "session_id": session_id, - "input": "Create PDF reports" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(continue_resp.status(), 200); - - let body: Value = continue_resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["step"], "Trigger"); -} - -#[tokio::test] -async fn test_factory_rejection_and_restart() { - let _temp = setup_test_env(); - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // Start and go through steps - let start_resp = client - .post(format!("{}/factory/start", base_url)) - .json(&json!({ - "initial_input": "Test goal" - })) - .send() - .await - .expect("Failed to send request"); - - let start_body: Value = start_resp.json().await.expect("Failed to parse JSON"); - let session_id = start_body["session_id"].as_str().unwrap().to_string(); - - // Go through all steps quickly - client - .post(format!("{}/factory/continue", base_url)) - .json(&json!({ - "session_id": session_id, - "input": "trigger1, trigger2" - })) - .send() - .await - .unwrap(); - - client - .post(format!("{}/factory/continue", base_url)) - .json(&json!({ - "session_id": session_id, - "input": "example input" - })) - .send() - .await - .unwrap(); - - client - .post(format!("{}/factory/continue", base_url)) - .json(&json!({ - "session_id": session_id, - "input": "simple" - })) - .send() - .await - .unwrap(); - - client - .post(format!("{}/factory/continue", base_url)) - .json(&json!({ - "session_id": session_id, - "input": "edge cases" - })) - .send() - .await - .unwrap(); - - // Reject at confirmation - let reject_resp = client - .post(format!("{}/factory/continue", base_url)) - .json(&json!({ - "session_id": session_id, - "input": "no" - })) - .send() - .await - .expect("Failed to send request"); - - let body: Value = reject_resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["step"], "Goal"); - assert_eq!(body["done"], false); -} diff --git a/sandbox-rs/tests/file_test.rs b/sandbox-rs/tests/file_test.rs deleted file mode 100644 index aa37e6f..0000000 --- a/sandbox-rs/tests/file_test.rs +++ /dev/null @@ -1,131 +0,0 @@ -use reqwest::Client; -use serde_json::{json, Value}; -use std::time::Duration; -use tokio::time::sleep; - -async fn wait_for_server(base_url: &str) { - let client = Client::new(); - for _ in 0..50 { - if client - .get(format!("{}/health", base_url)) - .send() - .await - .is_ok() - { - return; - } - sleep(Duration::from_millis(100)).await; - } - panic!("Server did not start in time"); -} - -#[tokio::test] -async fn test_file_write_and_read() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // Write file - let write_resp = client - .post(format!("{}/file/write", base_url)) - .json(&json!({ - "path": "/tmp/test_file.txt", - "content": "hello world" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(write_resp.status(), 200); - - // Read file back - let read_resp = client - .get(format!("{}/file/read?path=/tmp/test_file.txt", base_url)) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(read_resp.status(), 200); - - let body: Value = read_resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["content"], "hello world"); -} - -#[tokio::test] -async fn test_file_list() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // Ensure /tmp exists and list it - let resp = client - .get(format!("{}/file/list?path=/tmp", base_url)) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert!(body["entries"].is_array()); -} - -#[tokio::test] -async fn test_file_not_found() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .get(format!("{}/file/read?path=/nonexistent/file.txt", base_url)) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 404); -} - -#[tokio::test] -async fn test_file_download() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // First write a file - client - .post(format!("{}/file/write", base_url)) - .json(&json!({ - "path": "/tmp/download_test.txt", - "content": "download content" - })) - .send() - .await - .expect("Failed to write file"); - - // Download it - let resp = client - .get(format!( - "{}/file/download?path=/tmp/download_test.txt", - base_url - )) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - assert!(resp.headers().get("content-disposition").is_some()); - - let content = resp.text().await.expect("Failed to get body"); - assert_eq!(content, "download content"); -} diff --git a/sandbox-rs/tests/health_test.rs b/sandbox-rs/tests/health_test.rs deleted file mode 100644 index 08d4b15..0000000 --- a/sandbox-rs/tests/health_test.rs +++ /dev/null @@ -1,62 +0,0 @@ -use reqwest::Client; -use serde_json::Value; -use std::time::Duration; -use tokio::time::sleep; - -async fn wait_for_server(base_url: &str) { - let client = Client::new(); - for _ in 0..50 { - if client - .get(format!("{}/health", base_url)) - .send() - .await - .is_ok() - { - return; - } - sleep(Duration::from_millis(100)).await; - } - panic!("Server did not start in time"); -} - -#[tokio::test] -async fn test_health_check() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .get(format!("{}/health", base_url)) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["status"], "healthy"); - assert!(body["uptime"].as_f64().unwrap() >= 0.0); -} - -#[tokio::test] -async fn test_sandbox_info() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .get(format!("{}/sandbox/info", base_url)) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert!(body["workspace"].as_str().is_some()); - assert!(body["display"].as_str().is_some()); -} diff --git a/sandbox-rs/tests/shell_test.rs b/sandbox-rs/tests/shell_test.rs deleted file mode 100644 index 3c35fc1..0000000 --- a/sandbox-rs/tests/shell_test.rs +++ /dev/null @@ -1,114 +0,0 @@ -use reqwest::Client; -use serde_json::{json, Value}; -use std::time::Duration; -use tokio::time::sleep; - -async fn wait_for_server(base_url: &str) { - let client = Client::new(); - for _ in 0..50 { - if client - .get(format!("{}/health", base_url)) - .send() - .await - .is_ok() - { - return; - } - sleep(Duration::from_millis(100)).await; - } - panic!("Server did not start in time"); -} - -#[tokio::test] -async fn test_shell_exec_simple() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .post(format!("{}/shell/exec", base_url)) - .json(&json!({ - "command": "echo hello" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["stdout"].as_str().unwrap().trim(), "hello"); - assert_eq!(body["exit_code"], 0); -} - -#[tokio::test] -async fn test_shell_exec_with_env() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .post(format!("{}/shell/exec", base_url)) - .json(&json!({ - "command": "echo $MY_VAR", - "env": { "MY_VAR": "test_value" } - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["stdout"].as_str().unwrap().trim(), "test_value"); -} - -#[tokio::test] -async fn test_shell_exec_stderr() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .post(format!("{}/shell/exec", base_url)) - .json(&json!({ - "command": "echo error >&2" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["stderr"].as_str().unwrap().trim(), "error"); -} - -#[tokio::test] -async fn test_shell_exec_nonzero_exit() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .post(format!("{}/shell/exec", base_url)) - .json(&json!({ - "command": "exit 42" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["exit_code"], 42); -} diff --git a/sandbox-rs/tests/skills_test.rs b/sandbox-rs/tests/skills_test.rs deleted file mode 100644 index da34b26..0000000 --- a/sandbox-rs/tests/skills_test.rs +++ /dev/null @@ -1,349 +0,0 @@ -use reqwest::Client; -use serde_json::{json, Value}; -use std::time::Duration; -use tempfile::TempDir; -use tokio::time::sleep; -use uuid::Uuid; - -async fn wait_for_server(base_url: &str) { - let client = Client::new(); - for _ in 0..50 { - if client - .get(format!("{}/health", base_url)) - .send() - .await - .is_ok() - { - return; - } - sleep(Duration::from_millis(100)).await; - } - panic!("Server did not start in time"); -} - -fn setup_test_env() -> TempDir { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - std::env::set_var("SKILLS_DIR", temp_dir.path().to_str().unwrap()); - temp_dir -} - -#[tokio::test] -async fn test_list_skills_empty() { - let _temp = setup_test_env(); - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .get(format!("{}/skills", base_url)) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert!(body["skills"].is_array()); - // Note: may not be empty if server has pre-existing skills -} - -#[tokio::test] -async fn test_create_and_get_skill() { - let _temp = setup_test_env(); - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let skill_name = format!("test-skill-{}", Uuid::new_v4()); - - // Create a skill - let create_resp = client - .post(format!("{}/skills", base_url)) - .json(&json!({ - "name": skill_name, - "description": "A test skill for integration testing", - "body": "This is the skill body with instructions." - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(create_resp.status(), 200); - - let created: Value = create_resp.json().await.expect("Failed to parse JSON"); - assert_eq!(created["name"], skill_name); - assert_eq!(created["description"], "A test skill for integration testing"); - - // Get the skill - let get_resp = client - .get(format!("{}/skills/{}", base_url, skill_name)) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(get_resp.status(), 200); - - let retrieved: Value = get_resp.json().await.expect("Failed to parse JSON"); - assert_eq!(retrieved["name"], skill_name); - assert_eq!(retrieved["description"], "A test skill for integration testing"); - assert_eq!(retrieved["body"], "This is the skill body with instructions."); -} - -#[tokio::test] -async fn test_create_skill_invalid_name() { - let _temp = setup_test_env(); - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // Try to create a skill with invalid name (uppercase) - let resp = client - .post(format!("{}/skills", base_url)) - .json(&json!({ - "name": "Invalid-Name", - "description": "This should fail", - "body": "Body" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 400); - - // Try with spaces - let resp = client - .post(format!("{}/skills", base_url)) - .json(&json!({ - "name": "invalid name", - "description": "This should fail", - "body": "Body" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 400); - - // Try with consecutive hyphens - let resp = client - .post(format!("{}/skills", base_url)) - .json(&json!({ - "name": "invalid--name", - "description": "This should fail", - "body": "Body" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 400); -} - -#[tokio::test] -async fn test_update_skill() { - let _temp = setup_test_env(); - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let skill_name = format!("update-test-{}", Uuid::new_v4()); - - // Create a skill first - client - .post(format!("{}/skills", base_url)) - .json(&json!({ - "name": skill_name, - "description": "Original description", - "body": "Original body" - })) - .send() - .await - .expect("Failed to create skill"); - - // Update the skill - let update_resp = client - .put(format!("{}/skills/{}", base_url, skill_name)) - .json(&json!({ - "description": "Updated description", - "body": "Updated body" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(update_resp.status(), 200); - - let updated: Value = update_resp.json().await.expect("Failed to parse JSON"); - assert_eq!(updated["description"], "Updated description"); - assert_eq!(updated["body"], "Updated body"); - - // Verify the update persisted - let get_resp = client - .get(format!("{}/skills/{}", base_url, skill_name)) - .send() - .await - .expect("Failed to send request"); - - let retrieved: Value = get_resp.json().await.expect("Failed to parse JSON"); - assert_eq!(retrieved["description"], "Updated description"); - assert_eq!(retrieved["body"], "Updated body"); -} - -#[tokio::test] -async fn test_delete_skill() { - let _temp = setup_test_env(); - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let skill_name = format!("delete-test-{}", Uuid::new_v4()); - - // Create a skill first - client - .post(format!("{}/skills", base_url)) - .json(&json!({ - "name": skill_name, - "description": "To be deleted", - "body": "Body" - })) - .send() - .await - .expect("Failed to create skill"); - - // Verify it exists - let get_resp = client - .get(format!("{}/skills/{}", base_url, skill_name)) - .send() - .await - .expect("Failed to send request"); - assert_eq!(get_resp.status(), 200); - - // Delete the skill - let delete_resp = client - .delete(format!("{}/skills/{}", base_url, skill_name)) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(delete_resp.status(), 200); - - let body: Value = delete_resp.json().await.expect("Failed to parse JSON"); - assert_eq!(body["success"], true); - - // Verify it's gone - let get_resp = client - .get(format!("{}/skills/{}", base_url, skill_name)) - .send() - .await - .expect("Failed to send request"); - assert_eq!(get_resp.status(), 404); -} - -#[tokio::test] -async fn test_search_skills() { - let _temp = setup_test_env(); - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // Use unique identifiers for skill names - let uuid_suffix = Uuid::new_v4(); - let rust_skill = format!("rust-programming-{}", uuid_suffix); - let python_skill = format!("python-automation-{}", uuid_suffix); - let web_skill = format!("web-development-{}", uuid_suffix); - - // Use a unique search term that won't match other tests - let unique_marker = format!("uniquetest{}", uuid_suffix); - - // Create multiple skills with unique marker in description - client - .post(format!("{}/skills", base_url)) - .json(&json!({ - "name": rust_skill, - "description": format!("A skill for Rust development {}", unique_marker), - "body": "Instructions for Rust" - })) - .send() - .await - .expect("Failed to create skill"); - - client - .post(format!("{}/skills", base_url)) - .json(&json!({ - "name": python_skill, - "description": format!("A skill for Python automation {}", unique_marker), - "body": "Instructions for Python" - })) - .send() - .await - .expect("Failed to create skill"); - - client - .post(format!("{}/skills", base_url)) - .json(&json!({ - "name": web_skill, - "description": format!("A skill for web development {}", unique_marker), - "body": "Instructions for web dev" - })) - .send() - .await - .expect("Failed to create skill"); - - // Search for "Rust development" - should match only the rust skill - let search_resp = client - .get(format!( - "{}/skills/search?q=Rust development {}", - base_url, unique_marker - )) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(search_resp.status(), 200); - - let results: Value = search_resp.json().await.expect("Failed to parse JSON"); - assert!(results["skills"].is_array()); - let skills = results["skills"].as_array().unwrap(); - assert_eq!(skills.len(), 1); - assert_eq!(skills[0]["name"], rust_skill); - - // Search for "Python automation" - should match only the python skill - let search_resp = client - .get(format!( - "{}/skills/search?q=Python automation {}", - base_url, unique_marker - )) - .send() - .await - .expect("Failed to send request"); - - let results: Value = search_resp.json().await.expect("Failed to parse JSON"); - let skills = results["skills"].as_array().unwrap(); - assert_eq!(skills.len(), 1); - assert_eq!(skills[0]["name"], python_skill); - - // Search for unique marker (should match all our test skills) - let search_resp = client - .get(format!("{}/skills/search?q={}", base_url, unique_marker)) - .send() - .await - .expect("Failed to send request"); - - let results: Value = search_resp.json().await.expect("Failed to parse JSON"); - let skills = results["skills"].as_array().unwrap(); - assert_eq!(skills.len(), 3); -} diff --git a/sandbox-rs/tests/tee_test.rs b/sandbox-rs/tests/tee_test.rs deleted file mode 100644 index c41a2e5..0000000 --- a/sandbox-rs/tests/tee_test.rs +++ /dev/null @@ -1,395 +0,0 @@ -// TEE integration tests -// -// These tests are feature-gated and require a running dstack socket to function. -// They test the TEE endpoints that provide cryptographic operations and attestation. -// -// To run these tests: -// cargo test --features tee -- --ignored -// -// Note: Tests are ignored by default because they require: -// 1. The 'tee' feature flag to be enabled -// 2. A real dstack socket to be available (typically only in TEE environments) -// 3. Proper TEE hardware/virtualization support - -#[cfg(feature = "tee")] -mod tee_tests { - use reqwest::Client; - use serde_json::{json, Value}; - use std::time::Duration; - use tokio::time::sleep; - - async fn wait_for_server(base_url: &str) { - let client = Client::new(); - for _ in 0..50 { - if client - .get(format!("{}/health", base_url)) - .send() - .await - .is_ok() - { - return; - } - sleep(Duration::from_millis(100)).await; - } - panic!("Server did not start in time"); - } - - #[tokio::test] - #[ignore] // Requires dstack socket - async fn test_tee_info() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - let resp = client - .get(format!("{}/tee/info", base_url)) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - - // InfoResponse should contain CVM instance metadata - // Verify it has expected fields (structure depends on dstack_sdk::InfoResponse) - assert!(body.is_object(), "Response should be an object"); - - // The exact fields depend on dstack SDK's InfoResponse structure - // Common fields might include: instance_id, attestation_type, etc. - } - - #[tokio::test] - #[ignore] // Requires dstack socket - async fn test_generate_quote() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // Generate a quote with report data (64 bytes of zeros as hex) - let report_data = "0".repeat(128); // 64 bytes in hex - - let resp = client - .post(format!("{}/tee/quote", base_url)) - .json(&json!({ - "report_data": report_data - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - - // GetQuoteResponse should contain attestation quote - assert!(body.is_object(), "Response should be an object"); - - // The quote response should have a quote field - // Structure depends on dstack_sdk::GetQuoteResponse - } - - #[tokio::test] - #[ignore] // Requires dstack socket - async fn test_generate_quote_invalid_hex() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // Try to generate quote with invalid hex data - let resp = client - .post(format!("{}/tee/quote", base_url)) - .json(&json!({ - "report_data": "invalid-hex-data" - })) - .send() - .await - .expect("Failed to send request"); - - // Should return 400 Bad Request for invalid hex - assert_eq!(resp.status(), 400); - } - - #[tokio::test] - #[ignore] // Requires dstack socket - async fn test_derive_key() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // Derive a key with path and purpose - let resp = client - .post(format!("{}/tee/derive-key", base_url)) - .json(&json!({ - "path": "m/44'/0'/0'/0/0", - "purpose": "signing" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - - // GetKeyResponse should contain derived key information - assert!(body.is_object(), "Response should be an object"); - - // The response should contain key data - // Structure depends on dstack_sdk::GetKeyResponse - } - - #[tokio::test] - #[ignore] // Requires dstack socket - async fn test_derive_key_minimal() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // Derive a key without path or purpose (both optional) - let resp = client - .post(format!("{}/tee/derive-key", base_url)) - .json(&json!({})) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - assert!(body.is_object(), "Response should be an object"); - } - - #[tokio::test] - #[ignore] // Requires dstack socket - async fn test_sign_data() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // First derive a key to sign with - let _derive_resp = client - .post(format!("{}/tee/derive-key", base_url)) - .json(&json!({ - "path": "m/44'/0'/0'/0/0", - "purpose": "signing" - })) - .send() - .await - .expect("Failed to derive key"); - - // Sign some data with secp256k1 - let data_to_sign = "48656c6c6f20576f726c64"; // "Hello World" in hex - - let resp = client - .post(format!("{}/tee/sign", base_url)) - .json(&json!({ - "algorithm": "secp256k1", - "data": data_to_sign - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - - // SignResponse should contain signature data - assert!(body.is_object(), "Response should be an object"); - - // The response should contain signature - // Structure depends on dstack_sdk::SignResponse - } - - #[tokio::test] - #[ignore] // Requires dstack socket - async fn test_sign_data_invalid_hex() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // Try to sign with invalid hex data - let resp = client - .post(format!("{}/tee/sign", base_url)) - .json(&json!({ - "algorithm": "secp256k1", - "data": "not-valid-hex" - })) - .send() - .await - .expect("Failed to send request"); - - // Should return 400 Bad Request for invalid hex - assert_eq!(resp.status(), 400); - } - - #[tokio::test] - #[ignore] // Requires dstack socket - async fn test_verify_signature() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // This test would ideally: - // 1. Derive a key - // 2. Sign data - // 3. Verify the signature - // For now, we just test the endpoint accepts the right format - - let resp = client - .post(format!("{}/tee/verify", base_url)) - .json(&json!({ - "algorithm": "secp256k1", - "data": "48656c6c6f20576f726c64", // "Hello World" in hex - "signature": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "public_key": "0000000000000000000000000000000000000000000000000000000000000000" - })) - .send() - .await - .expect("Failed to send request"); - - // May return 200 with valid=false or 500 if verification fails - // The important thing is it processes the request - assert!( - resp.status() == 200 || resp.status() == 500, - "Expected 200 or 500, got {}", - resp.status() - ); - } - - #[tokio::test] - #[ignore] // Requires dstack socket - async fn test_verify_signature_invalid_hex() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // Try to verify with invalid hex data - let resp = client - .post(format!("{}/tee/verify", base_url)) - .json(&json!({ - "algorithm": "secp256k1", - "data": "invalid-hex", - "signature": "also-invalid", - "public_key": "not-hex-either" - })) - .send() - .await - .expect("Failed to send request"); - - // Should return 400 Bad Request for invalid hex - assert_eq!(resp.status(), 400); - } - - #[tokio::test] - #[ignore] // Requires dstack socket - async fn test_emit_event() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // Emit a runtime event - let resp = client - .post(format!("{}/tee/emit-event", base_url)) - .json(&json!({ - "event": "test_event", - "payload": "{\"test\": \"data\"}" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(resp.status(), 200); - - let body: Value = resp.json().await.expect("Failed to parse JSON"); - - // Should return success response - assert_eq!(body["success"], true); - assert!(body["message"].is_string()); - - let message = body["message"].as_str().unwrap(); - assert!(message.contains("test_event")); - assert!(message.contains("emitted successfully")); - } - - #[tokio::test] - #[ignore] // Requires dstack socket - async fn test_sign_and_verify_roundtrip() { - let base_url = - std::env::var("TEST_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".into()); - - wait_for_server(&base_url).await; - - let client = Client::new(); - - // 1. Derive a key - let key_resp = client - .post(format!("{}/tee/derive-key", base_url)) - .json(&json!({ - "path": "m/44'/0'/0'/0/0", - "purpose": "signing" - })) - .send() - .await - .expect("Failed to derive key"); - - assert_eq!(key_resp.status(), 200); - let key_body: Value = key_resp.json().await.expect("Failed to parse key response"); - - // 2. Sign data - let data_to_sign = "48656c6c6f20576f726c64"; // "Hello World" in hex - - let sign_resp = client - .post(format!("{}/tee/sign", base_url)) - .json(&json!({ - "algorithm": "secp256k1", - "data": data_to_sign - })) - .send() - .await - .expect("Failed to sign"); - - assert_eq!(sign_resp.status(), 200); - let sign_body: Value = sign_resp.json().await.expect("Failed to parse sign response"); - - // 3. Verify signature - // Note: This assumes the response structures have specific fields - // If the actual dstack SDK response differs, this test may need adjustment - // when run against a real dstack socket - - // The actual verification would use signature and public_key from responses - // For now, we've validated the sign endpoint works - assert!(sign_body.is_object()); - assert!(key_body.is_object()); - } -} diff --git a/tests/extension/browser.test.ts b/tests/extension/browser.test.ts new file mode 100644 index 0000000..57dc6b5 --- /dev/null +++ b/tests/extension/browser.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { chromium } from "playwright-core"; + +// Import the BrowserManager from the extension source +import { BrowserManager } from "../../packages/pi-sandbox-extension/src/browser.js"; + +// Skip all tests if no browser is available +const hasBrowser = await (async () => { + try { + const b = await chromium.launch({ headless: true }); + await b.close(); + return true; + } catch { + return false; + } +})(); + +describe.skipIf(!hasBrowser)("BrowserManager", () => { + const manager = new BrowserManager(); + + afterAll(async () => { + await manager.shutdown(); + }); + + it("getOrCreatePage returns a page for a session", async () => { + const page = await manager.getOrCreatePage("session-1"); + expect(page).toBeDefined(); + expect(page.isClosed()).toBe(false); + }); + + it("getOrCreatePage returns the SAME page for the same session", async () => { + const page1 = await manager.getOrCreatePage("session-2"); + const page2 = await manager.getOrCreatePage("session-2"); + expect(page1).toBe(page2); + }); + + it("closePage closes the page and removes it from the map", async () => { + const page = await manager.getOrCreatePage("session-close"); + expect(page.isClosed()).toBe(false); + await manager.closePage("session-close"); + expect(page.isClosed()).toBe(true); + // A new call should create a fresh page + const page2 = await manager.getOrCreatePage("session-close"); + expect(page2).not.toBe(page); + }); + + it("execute goto navigates and returns content", async () => { + const result = await manager.execute("session-goto", "goto", { + url: "data:text/html,TestHello World", + }); + expect(result).toContain("title: Test"); + expect(result).toContain("Hello World"); + }); + + it("execute screenshot returns base64 PNG", async () => { + await manager.execute("session-ss", "goto", { + url: "data:text/html,Screenshot Test", + }); + const result = await manager.execute("session-ss", "screenshot", {}); + // PNG base64 starts with iVBOR + expect(result.startsWith("iVBOR")).toBe(true); + }); + + it("execute evaluate runs JavaScript and returns result", async () => { + await manager.execute("session-eval", "goto", { + url: "data:text/html,", + }); + const result = await manager.execute("session-eval", "evaluate", { + script: "1 + 2", + }); + expect(result).toBe("3"); + }); + + it("execute click clicks an element", async () => { + await manager.execute("session-click", "goto", { + url: 'data:text/html,', + }); + await manager.execute("session-click", "click", { selector: "#btn" }); + const title = await manager.execute("session-click", "evaluate", { + script: "document.title", + }); + expect(title).toBe('"clicked"'); + }); + + it("execute type fills an input", async () => { + await manager.execute("session-type", "goto", { + url: 'data:text/html,', + }); + await manager.execute("session-type", "type", { + selector: "#inp", + text: "hello", + }); + const value = await manager.execute("session-type", "evaluate", { + script: 'document.querySelector("#inp").value', + }); + expect(value).toBe('"hello"'); + }); + + it("execute close closes the page", async () => { + await manager.getOrCreatePage("session-close2"); + const result = await manager.execute("session-close2", "close", {}); + expect(result).toBe("Browser page closed."); + }); + + it("execute throws on unknown action", async () => { + await expect( + manager.execute("session-err", "invalid" as any, {}), + ).rejects.toThrow("Unknown browser action"); + }); + + it("shutdown closes all pages and the browser", async () => { + await manager.getOrCreatePage("session-shutdown-1"); + await manager.getOrCreatePage("session-shutdown-2"); + await manager.shutdown(); + // After shutdown, a new call should re-launch + const page = await manager.getOrCreatePage("session-after-shutdown"); + expect(page.isClosed()).toBe(false); + await manager.shutdown(); + }); +}); diff --git a/tests/extension/package.json b/tests/extension/package.json new file mode 100644 index 0000000..886cc3c --- /dev/null +++ b/tests/extension/package.json @@ -0,0 +1,15 @@ +{ + "name": "@pi-sandbox/extension-tests", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0", + "playwright-core": "^1.50.0" + } +} diff --git a/tests/extension/tsconfig.json b/tests/extension/tsconfig.json new file mode 100644 index 0000000..a5a6232 --- /dev/null +++ b/tests/extension/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "esModuleInterop": true, + "strict": true, + "outDir": "dist", + "rootDir": ".", + "skipLibCheck": true + }, + "include": ["*.ts"] +} diff --git a/tests/extension/vitest.config.ts b/tests/extension/vitest.config.ts new file mode 100644 index 0000000..2c01d68 --- /dev/null +++ b/tests/extension/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + testTimeout: 30_000, + }, +}); diff --git a/tests/integration/build-npm.test.ts b/tests/integration/build-npm.test.ts new file mode 100644 index 0000000..6064cb3 --- /dev/null +++ b/tests/integration/build-npm.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, afterEach } from "vitest"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { + spawnRuntime, + copyFixture, + makeIntegrationPlan, + type FixtureWorkspace, +} from "./helpers.js"; + +describe("Integration: npm install", () => { + let fixture: FixtureWorkspace | null = null; + + afterEach(() => { + fixture?.cleanup(); + fixture = null; + }); + + it("runs npm install on tiny-npm fixture and exits cleanly", async () => { + fixture = copyFixture("tiny-npm"); + const rt = spawnRuntime(); + + rt.send( + makeIntegrationPlan({ + workspaceDir: fixture.workspaceDir, + command: ["npm", "install"], + }), + ); + + const events = await rt.readAllEvents(); + + const validation = events[0]; + expect(validation.type).toBe("validation"); + expect((validation.payload as any).ok).toBe(true); + + const result = events[events.length - 1]; + expect(result.type).toBe("result"); + const resultPayload = result.payload as any; + expect(resultPayload.exitCode).toBe(0); + expect(resultPayload.reconciliationHints.terminalState).toBe("clean_exit"); + + expect( + existsSync(join(fixture.workspaceDir, "package-lock.json")), + ).toBe(true); + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }); +}); diff --git a/tests/integration/build-python.test.ts b/tests/integration/build-python.test.ts new file mode 100644 index 0000000..523193b --- /dev/null +++ b/tests/integration/build-python.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, afterEach } from "vitest"; +import { execFileSync } from "node:child_process"; +import { + spawnRuntime, + copyFixture, + makeIntegrationPlan, + type FixtureWorkspace, +} from "./helpers.js"; + +function hasPython(): boolean { + try { + execFileSync("python3", ["--version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +describe("Integration: pip install", () => { + let fixture: FixtureWorkspace | null = null; + + afterEach(() => { + fixture?.cleanup(); + fixture = null; + }); + + it.skipIf(!hasPython())( + "runs pip install -e . on tiny-python fixture and exits cleanly", + async () => { + fixture = copyFixture("tiny-python"); + const rt = spawnRuntime(); + + rt.send( + makeIntegrationPlan({ + workspaceDir: fixture.workspaceDir, + command: ["pip", "install", "-e", ".", "--break-system-packages"], + }), + ); + + const events = await rt.readAllEvents(); + + const validation = events[0]; + expect(validation.type).toBe("validation"); + expect((validation.payload as any).ok).toBe(true); + + const result = events[events.length - 1]; + expect(result.type).toBe("result"); + const resultPayload = result.payload as any; + expect(resultPayload.exitCode).toBe(0); + expect(resultPayload.reconciliationHints.terminalState).toBe( + "clean_exit", + ); + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }, + ); +}); diff --git a/tests/integration/build-rust.test.ts b/tests/integration/build-rust.test.ts new file mode 100644 index 0000000..4e2a288 --- /dev/null +++ b/tests/integration/build-rust.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, afterEach } from "vitest"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { + spawnRuntime, + copyFixture, + makeIntegrationPlan, + type FixtureWorkspace, +} from "./helpers.js"; + +describe("Integration: cargo build", () => { + let fixture: FixtureWorkspace | null = null; + + afterEach(() => { + fixture?.cleanup(); + fixture = null; + }); + + it("runs cargo build on tiny-rust fixture and exits cleanly", async () => { + fixture = copyFixture("tiny-rust"); + const rt = spawnRuntime(); + + rt.send( + makeIntegrationPlan({ + workspaceDir: fixture.workspaceDir, + command: ["cargo", "build"], + }), + ); + + const events = await rt.readAllEvents(); + + const validation = events[0]; + expect(validation.type).toBe("validation"); + expect((validation.payload as any).ok).toBe(true); + + const result = events[events.length - 1]; + expect(result.type).toBe("result"); + const resultPayload = result.payload as any; + expect(resultPayload.exitCode).toBe(0); + expect(resultPayload.reconciliationHints.terminalState).toBe("clean_exit"); + + expect(existsSync(join(fixture.workspaceDir, "target"))).toBe(true); + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }); +}); diff --git a/tests/integration/docker-rootfs.test.ts b/tests/integration/docker-rootfs.test.ts new file mode 100644 index 0000000..de6f688 --- /dev/null +++ b/tests/integration/docker-rootfs.test.ts @@ -0,0 +1,123 @@ +/** + * macOS Docker integration tests for rootfs execution. + * + * Requires: Nix + Docker Desktop. + * Gate: RUN_DOCKER_TESTS=1 + * + * Run: RUN_DOCKER_TESTS=1 npx vitest run docker-rootfs.test.ts + */ +import { execFileSync } from "node:child_process"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { create, execCmd, destroy } from "./helpers.js"; + +const RUN = process.env.RUN_DOCKER_TESTS === "1"; + +// Docker tests need NIXOSANDBOX_NO_DOCKER unset +const dockerEnv = { + ...process.env, + NIXOSANDBOX_NO_DOCKER: undefined, +} as NodeJS.ProcessEnv; + +describe.skipIf(!RUN)("Docker Rootfs (macOS)", () => { + const sessionsToCleanup: string[] = []; + + beforeAll(() => { + // Clean up any leftover sidecar + try { + execFileSync("docker", ["rm", "-f", "nixosandbox-sidecar"], { + stdio: "ignore", + }); + } catch { + // Didn't exist + } + }); + + afterAll(() => { + for (const id of sessionsToCleanup) { + try { + destroy(id, dockerEnv); + } catch { + // Best-effort + } + } + }); + + it("create + exec through Docker sidecar", async () => { + const { sessionId } = create(["--profile", "strict"], dockerEnv); + sessionsToCleanup.push(sessionId); + + const result = await execCmd(sessionId, ["echo", "hello from docker"], { + env: dockerEnv, + }); + + expect(result.exitCode).toBe(0); + + const stdout = result.events + .filter((e) => e.type === "stdout") + .map((e) => (e.payload as any).data as string) + .join("\n"); + expect(stdout).toContain("hello from docker"); + }, 120_000); + + it("verifies rootfs directory structure through Docker", async () => { + const { sessionId } = create(["--profile", "strict"], dockerEnv); + sessionsToCleanup.push(sessionId); + + const result = await execCmd(sessionId, ["ls", "/"], { env: dockerEnv }); + expect(result.exitCode).toBe(0); + + const stdout = result.events + .filter((e) => e.type === "stdout") + .map((e) => (e.payload as any).data as string) + .join("\n"); + + expect(stdout).toContain("bin"); + expect(stdout).toContain("etc"); + expect(stdout).toContain("workspace"); + }, 60_000); + + it("verifies sandbox user through Docker", async () => { + const { sessionId } = create(["--profile", "strict"], dockerEnv); + sessionsToCleanup.push(sessionId); + + const result = await execCmd(sessionId, ["cat", "/etc/passwd"], { + env: dockerEnv, + }); + expect(result.exitCode).toBe(0); + + const stdout = result.events + .filter((e) => e.type === "stdout") + .map((e) => (e.payload as any).data as string) + .join("\n"); + + expect(stdout).toContain("sandbox"); + }, 60_000); + + it("JSON mode reports full lifecycle events through Docker", async () => { + const { sessionId } = create(["--profile", "strict"], dockerEnv); + sessionsToCleanup.push(sessionId); + + const result = await execCmd(sessionId, ["echo", "lifecycle-test"], { + env: dockerEnv, + }); + expect(result.exitCode).toBe(0); + + const started = result.events.find( + (e) => + e.type === "lifecycle" && (e.payload as any).event === "started", + ); + expect(started).toBeDefined(); + + const exited = result.events.find( + (e) => + e.type === "lifecycle" && (e.payload as any).event === "exited", + ); + expect(exited).toBeDefined(); + + const resultEvent = result.events.find( + (e) => e.type === "result", + ) as any; + expect(resultEvent).toBeDefined(); + expect(resultEvent.payload.exitCode).toBe(0); + }, 60_000); +}); diff --git a/tests/integration/fixtures/tiny-npm/package.json b/tests/integration/fixtures/tiny-npm/package.json new file mode 100644 index 0000000..633ebc5 --- /dev/null +++ b/tests/integration/fixtures/tiny-npm/package.json @@ -0,0 +1,6 @@ +{ + "name": "tiny-npm-fixture", + "version": "1.0.0", + "private": true, + "dependencies": {} +} diff --git a/tests/integration/fixtures/tiny-python/mypackage/__init__.py b/tests/integration/fixtures/tiny-python/mypackage/__init__.py new file mode 100644 index 0000000..9e29165 --- /dev/null +++ b/tests/integration/fixtures/tiny-python/mypackage/__init__.py @@ -0,0 +1 @@ +"""Tiny fixture package.""" diff --git a/tests/integration/fixtures/tiny-python/setup.py b/tests/integration/fixtures/tiny-python/setup.py new file mode 100644 index 0000000..1a9492a --- /dev/null +++ b/tests/integration/fixtures/tiny-python/setup.py @@ -0,0 +1,7 @@ +from setuptools import setup + +setup( + name="tiny-python-fixture", + version="1.0.0", + packages=["mypackage"], +) diff --git a/tests/integration/fixtures/tiny-rust/Cargo.toml b/tests/integration/fixtures/tiny-rust/Cargo.toml new file mode 100644 index 0000000..ae14d8f --- /dev/null +++ b/tests/integration/fixtures/tiny-rust/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "tiny-rust-fixture" +version = "0.1.0" +edition = "2021" diff --git a/tests/integration/fixtures/tiny-rust/src/main.rs b/tests/integration/fixtures/tiny-rust/src/main.rs new file mode 100644 index 0000000..497419b --- /dev/null +++ b/tests/integration/fixtures/tiny-rust/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("built"); +} diff --git a/tests/integration/globalSetup.ts b/tests/integration/globalSetup.ts new file mode 100644 index 0000000..7e1983e --- /dev/null +++ b/tests/integration/globalSetup.ts @@ -0,0 +1,21 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const CRATE_DIR = resolve(import.meta.dirname, "../../crates/nixosandbox"); + +export async function setup() { + console.log("Building nixosandbox (release)..."); + execFileSync("cargo", ["build", "--release"], { + cwd: CRATE_DIR, + stdio: "inherit", + }); + + const binaryPath = resolve(CRATE_DIR, "target/release/nixosandbox"); + if (!existsSync(binaryPath)) { + throw new Error(`Binary not found at ${binaryPath}`); + } + + process.env.NIXOSANDBOX_BINARY = binaryPath; + console.log(`Runtime binary: ${binaryPath}`); +} diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts new file mode 100644 index 0000000..f30270c --- /dev/null +++ b/tests/integration/helpers.ts @@ -0,0 +1,128 @@ +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; +import { createInterface } from "node:readline"; + +function getBinary(): string { + const bin = process.env.NIXOSANDBOX_BINARY; + if (!bin) throw new Error("NIXOSANDBOX_BINARY not set. Did globalSetup run?"); + return bin; +} + +export interface BuildResult { + stdout: string; + exitCode: number; +} + +/** + * Run `nixosandbox build` with the given args. + */ +export function build(args: string[], env?: NodeJS.ProcessEnv): BuildResult { + try { + const stdout = execFileSync(getBinary(), ["build", ...args], { + encoding: "utf-8", + env: env ?? process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + return { stdout: stdout.trim(), exitCode: 0 }; + } catch (err: any) { + return { stdout: err.stdout?.toString() ?? "", exitCode: err.status ?? 1 }; + } +} + +export interface CreateResult { + sessionId: string; + metadata: Record; +} + +/** + * Run `nixosandbox create` and parse the JSON output. + */ +export function create( + args: string[], + env?: NodeJS.ProcessEnv, +): CreateResult { + const stdout = execFileSync(getBinary(), ["create", "--json", ...args], { + encoding: "utf-8", + env: env ?? process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + const metadata = JSON.parse(stdout.trim()) as Record; + return { sessionId: metadata.sessionId as string, metadata }; +} + +export interface ExecResult { + events: Record[]; + exitCode: number; +} + +/** + * Run `nixosandbox exec --json -- ` and collect all NDJSON events. + */ +export async function execCmd( + sessionId: string, + command: string[], + opts?: { env?: NodeJS.ProcessEnv; extraEnv?: string[] }, +): Promise { + const envArgs = (opts?.extraEnv ?? []).flatMap((e) => ["--env", e]); + const args = ["exec", "--json", ...envArgs, sessionId, "--", ...command]; + + return new Promise((resolve, reject) => { + const child = spawn(getBinary(), args, { + stdio: ["pipe", "pipe", "pipe"], + env: opts?.env ?? process.env, + }); + + const events: Record[] = []; + const rl = createInterface({ input: child.stdout! }); + + rl.on("line", (line) => { + try { + events.push(JSON.parse(line)); + } catch { + // Ignore unparseable lines + } + }); + + child.on("exit", (code) => { + resolve({ events, exitCode: code ?? 1 }); + }); + + child.on("error", (err) => { + reject(err); + }); + }); +} + +export interface ListResult { + sessions: Record[]; +} + +/** + * Run `nixosandbox list --json` and parse the JSON output. + */ +export function list(env?: NodeJS.ProcessEnv): ListResult { + const stdout = execFileSync(getBinary(), ["list", "--json"], { + encoding: "utf-8", + env: env ?? process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + const sessions = JSON.parse(stdout.trim()) as Record[]; + return { sessions }; +} + +/** + * Run `nixosandbox destroy `. + */ +export function destroy( + sessionId: string, + env?: NodeJS.ProcessEnv, +): number { + try { + execFileSync(getBinary(), ["destroy", sessionId], { + env: env ?? process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + return 0; + } catch (err: any) { + return err.status ?? 1; + } +} diff --git a/tests/integration/network-smoke.test.ts b/tests/integration/network-smoke.test.ts new file mode 100644 index 0000000..3582b50 --- /dev/null +++ b/tests/integration/network-smoke.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, afterEach } from "vitest"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { spawnRuntime, makeIntegrationPlan } from "./helpers.js"; + +const RUN_NETWORK_TESTS = process.env.RUN_NETWORK_TESTS === "1"; + +describe("Integration: Network Smoke Tests", () => { + let tempDir: string | null = null; + + afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it.skipIf(!RUN_NETWORK_TESTS)( + "npm install with real network fetches a dependency", + async () => { + tempDir = mkdtempSync(join(tmpdir(), "pi-sandbox-network-")); + writeFileSync( + join(tempDir, "package.json"), + JSON.stringify({ + name: "network-smoke-test", + version: "1.0.0", + private: true, + dependencies: { + "is-odd": "3.0.1", + }, + }), + ); + + const rt = spawnRuntime(); + + rt.send( + makeIntegrationPlan({ + workspaceDir: tempDir, + command: ["npm", "install"], + networkMode: "full", + }), + ); + + const events = await rt.readAllEvents(); + + const validation = events[0]; + expect(validation.type).toBe("validation"); + expect((validation.payload as any).ok).toBe(true); + + const result = events[events.length - 1]; + expect(result.type).toBe("result"); + const resultPayload = result.payload as any; + expect(resultPayload.exitCode).toBe(0); + expect(resultPayload.reconciliationHints.terminalState).toBe( + "clean_exit", + ); + + expect(existsSync(join(tempDir, "node_modules", "is-odd"))).toBe(true); + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }, + ); +}); diff --git a/tests/integration/package-lock.json b/tests/integration/package-lock.json new file mode 100644 index 0000000..95b4d15 --- /dev/null +++ b/tests/integration/package-lock.json @@ -0,0 +1,1602 @@ +{ + "name": "@nixosandbox/integration-tests", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@nixosandbox/integration-tests", + "version": "0.1.0", + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/tests/integration/package.json b/tests/integration/package.json new file mode 100644 index 0000000..92fb465 --- /dev/null +++ b/tests/integration/package.json @@ -0,0 +1,14 @@ +{ + "name": "@nixosandbox/integration-tests", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/tests/integration/rootfs-pipeline.test.ts b/tests/integration/rootfs-pipeline.test.ts new file mode 100644 index 0000000..aad834c --- /dev/null +++ b/tests/integration/rootfs-pipeline.test.ts @@ -0,0 +1,160 @@ +/** + * Linux native integration tests for the rootfs pipeline. + * + * Requires: Nix + bwrap on Linux. + * Gate: RUN_INTEGRATION_TESTS=1 + * + * Run: RUN_INTEGRATION_TESTS=1 npx vitest run rootfs-pipeline.test.ts + */ +import { describe, it, expect, afterAll } from "vitest"; +import { build, create, execCmd, list, destroy } from "./helpers.js"; + +const RUN = process.env.RUN_INTEGRATION_TESTS === "1"; + +describe.skipIf(!RUN)("Rootfs Pipeline (Linux native)", () => { + const sessionsToCleanup: string[] = []; + + afterAll(() => { + for (const id of sessionsToCleanup) { + try { + destroy(id); + } catch { + // Best-effort cleanup + } + } + }); + + it("build strict profile returns a valid Nix store path", () => { + const result = build(["--profile", "strict", "--json"]); + expect(result.exitCode).toBe(0); + + const parsed = JSON.parse(result.stdout); + expect(parsed.rootfsPath).toBeDefined(); + expect(parsed.rootfsPath).toMatch(/^\/nix\/store\//); + }); + + it("create session returns session ID and metadata", () => { + const { sessionId, metadata } = create(["--profile", "strict"]); + sessionsToCleanup.push(sessionId); + + expect(sessionId).toBeDefined(); + expect(sessionId.length).toBe(8); + expect(metadata.profile).toBe("strict"); + expect(metadata.rootfsPath).toMatch(/^\/nix\/store\//); + }); + + it("exec echo prints hello and exits 0", async () => { + const { sessionId } = create(["--profile", "strict"]); + sessionsToCleanup.push(sessionId); + + const result = await execCmd(sessionId, ["echo", "hello"]); + expect(result.exitCode).toBe(0); + + const stdoutEvents = result.events.filter((e) => e.type === "stdout"); + const helloEvent = stdoutEvents.find((e) => + ((e.payload as any).data as string).includes("hello"), + ); + expect(helloEvent).toBeDefined(); + }); + + it("exec verifies rootfs directory structure", async () => { + const { sessionId } = create(["--profile", "strict"]); + sessionsToCleanup.push(sessionId); + + const result = await execCmd(sessionId, ["ls", "/"]); + expect(result.exitCode).toBe(0); + + const stdout = result.events + .filter((e) => e.type === "stdout") + .map((e) => (e.payload as any).data as string) + .join("\n"); + + // Rootfs should have sandbox dirs + expect(stdout).toContain("bin"); + expect(stdout).toContain("etc"); + expect(stdout).toContain("workspace"); + }); + + it("exec verifies sandbox user exists", async () => { + const { sessionId } = create(["--profile", "strict"]); + sessionsToCleanup.push(sessionId); + + const result = await execCmd(sessionId, ["cat", "/etc/passwd"]); + expect(result.exitCode).toBe(0); + + const stdout = result.events + .filter((e) => e.type === "stdout") + .map((e) => (e.payload as any).data as string) + .join("\n"); + + expect(stdout).toContain("sandbox"); + }); + + it("exec json mode produces lifecycle + stdout + result events", async () => { + const { sessionId } = create(["--profile", "strict"]); + sessionsToCleanup.push(sessionId); + + const result = await execCmd(sessionId, ["echo", "test"]); + expect(result.exitCode).toBe(0); + + // Must have: lifecycle(started), stdout(test), lifecycle(exited), result + const started = result.events.find( + (e) => + e.type === "lifecycle" && (e.payload as any).event === "started", + ); + expect(started).toBeDefined(); + + const stdout = result.events.find( + (e) => + e.type === "stdout" && + ((e.payload as any).data as string).includes("test"), + ); + expect(stdout).toBeDefined(); + + const exited = result.events.find( + (e) => + e.type === "lifecycle" && (e.payload as any).event === "exited", + ); + expect(exited).toBeDefined(); + + const resultEvent = result.events.find((e) => e.type === "result") as any; + expect(resultEvent).toBeDefined(); + expect(resultEvent.payload.exitCode).toBe(0); + expect(resultEvent.payload.timedOut).toBe(false); + expect(resultEvent.payload.durationMs).toBeGreaterThan(0); + + // Sequence numbers strictly increasing + const sequenced = result.events.filter( + (e) => (e as any).sequence !== undefined, + ); + for (let i = 1; i < sequenced.length; i++) { + expect((sequenced[i] as any).sequence).toBeGreaterThan( + (sequenced[i - 1] as any).sequence, + ); + } + }); + + it("list sessions shows the created session", () => { + const { sessionId } = create(["--profile", "strict"]); + sessionsToCleanup.push(sessionId); + + const { sessions } = list(); + const found = sessions.find( + (s) => (s as any).sessionId === sessionId, + ); + expect(found).toBeDefined(); + }); + + it("destroy session removes it from list", () => { + const { sessionId } = create(["--profile", "strict"]); + + const exitCode = destroy(sessionId); + expect(exitCode).toBe(0); + + const { sessions } = list(); + const found = sessions.find( + (s) => (s as any).sessionId === sessionId, + ); + expect(found).toBeUndefined(); + }); +}); diff --git a/tests/integration/tsconfig.json b/tests/integration/tsconfig.json new file mode 100644 index 0000000..3c69cae --- /dev/null +++ b/tests/integration/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/tests/integration/vitest.config.ts b/tests/integration/vitest.config.ts new file mode 100644 index 0000000..93354d6 --- /dev/null +++ b/tests/integration/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["*.test.ts"], + globalSetup: "./globalSetup.ts", + testTimeout: 120000, // 2 minutes — Nix builds can be slow + }, +}); diff --git a/tests/protocol/bwrap-integration.test.ts b/tests/protocol/bwrap-integration.test.ts new file mode 100644 index 0000000..a91c480 --- /dev/null +++ b/tests/protocol/bwrap-integration.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { makePlan, spawnRuntime } from "./helpers.js"; + +describe("Protocol Test 7: Bwrap Integration (Linux only)", () => { + const isLinux = process.platform === "linux"; + + it.skipIf(!isLinux)("runs command via bwrap with namespaces applied", async () => { + const rt = spawnRuntime(); + + rt.send( + makePlan({ + command: ["echo", "bwrap-test"], + manifest: { + mounts: [ + { type: "tmpfs", target: "/tmp", writable: true }, + ], + env: { HOME: "/home/sandbox", PATH: "/usr/bin:/bin" }, + cwd: "/tmp", + }, + policy: { + namespaces: ["user", "pid", "ipc"], + network: { mode: "full" }, + allowedWritableTargets: ["/workspace", "/tmp"], + strictWritePolicy: false, + }, + }), + ); + + const events = await rt.readAllEvents(); + + // Validation must succeed + const validation = events[0]; + expect(validation.type).toBe("validation"); + const validationPayload = validation.payload as any; + expect(validationPayload.ok).toBe(true); + + // namespacesApplied must include the requested namespaces (bwrap available) + expect(validationPayload.effectiveState.namespacesApplied).toContain("user"); + expect(validationPayload.effectiveState.namespacesApplied).toContain("pid"); + expect(validationPayload.effectiveState.namespacesApplied).toContain("ipc"); + + // envApplied must include the env keys + expect(validationPayload.effectiveState.envApplied).toContain("HOME"); + expect(validationPayload.effectiveState.envApplied).toContain("PATH"); + + // Execution must succeed + const result = events[events.length - 1]; + expect(result.type).toBe("result"); + const resultPayload = result.payload as any; + expect(resultPayload.exitCode).toBe(0); + expect(resultPayload.reconciliationHints.terminalState).toBe("clean_exit"); + + // Find stdout with "bwrap-test" + const stdoutEvents = events.filter((e) => e.type === "stdout"); + const bwrapOutput = stdoutEvents.find( + (e) => ((e.payload as any).data as string).includes("bwrap-test"), + ); + expect(bwrapOutput).toBeDefined(); + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }); + + it.skipIf(isLinux)("falls back to direct execution on non-Linux with NAMESPACE_DEGRADED warnings", async () => { + const rt = spawnRuntime(); + + rt.send( + makePlan({ + command: ["echo", "fallback-test"], + manifest: { + mounts: [ + { type: "tmpfs", target: "/tmp", writable: true }, + ], + env: { HOME: "/home/sandbox", PATH: "/usr/bin:/bin" }, + cwd: "/tmp", + }, + policy: { + namespaces: ["user", "pid"], + network: { mode: "full" }, + allowedWritableTargets: ["/workspace", "/tmp"], + strictWritePolicy: false, + }, + }), + ); + + const events = await rt.readAllEvents(); + + const validation = events[0]; + expect(validation.type).toBe("validation"); + const validationPayload = validation.payload as any; + expect(validationPayload.ok).toBe(true); + + // namespacesApplied must be empty (no bwrap) + expect(validationPayload.effectiveState.namespacesApplied).toEqual([]); + + // Must have NAMESPACE_DEGRADED warnings + const nsWarnings = (validationPayload.warnings as any[]).filter( + (w: any) => w.code === "NAMESPACE_DEGRADED", + ); + expect(nsWarnings.length).toBe(2); // one for "user", one for "pid" + + // envApplied must still be populated + expect(validationPayload.effectiveState.envApplied).toContain("HOME"); + expect(validationPayload.effectiveState.envApplied).toContain("PATH"); + + // Execution must still succeed (direct execution fallback) + const result = events[events.length - 1]; + expect(result.type).toBe("result"); + expect((result.payload as any).exitCode).toBe(0); + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }); +}); diff --git a/tests/protocol/cancel-flow.test.ts b/tests/protocol/cancel-flow.test.ts new file mode 100644 index 0000000..f00c690 --- /dev/null +++ b/tests/protocol/cancel-flow.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, beforeAll, afterAll } from "vitest"; +import { execFileSync } from "node:child_process"; +import { spawnExecJson } from "./helpers.js"; + +const RUN_INTEGRATION = process.env.RUN_INTEGRATION_TESTS === "1"; +const RUN_DOCKER = process.env.RUN_DOCKER_TESTS === "1"; + +describe.skipIf(!RUN_INTEGRATION && !RUN_DOCKER)( + "Cancel Flow (exec --json)", + () => { + let sessionId: string; + + beforeAll(() => { + const binaryPath = process.env.NIXOSANDBOX_BINARY; + if (!binaryPath) throw new Error("NIXOSANDBOX_BINARY not set"); + + // Create a session for testing + const env = RUN_DOCKER + ? { ...process.env, NIXOSANDBOX_NO_DOCKER: undefined } as NodeJS.ProcessEnv + : process.env; + const output = execFileSync(binaryPath, [ + "create", "--profile", "strict", "--json", + ], { env, encoding: "utf-8" }); + const meta = JSON.parse(output); + sessionId = meta.sessionId; + }); + + afterAll(() => { + const binaryPath = process.env.NIXOSANDBOX_BINARY; + if (binaryPath && sessionId) { + try { + execFileSync(binaryPath, ["destroy", sessionId], { stdio: "ignore" }); + } catch { + // Cleanup best-effort + } + } + }); + + it("cancels a running process via SIGTERM and observes lifecycle events", async () => { + const env = RUN_DOCKER + ? { ...process.env, NIXOSANDBOX_NO_DOCKER: undefined } as NodeJS.ProcessEnv + : process.env; + const rt = spawnExecJson(sessionId, ["sleep", "3600"], { env }); + + // Read events until we see "started" lifecycle + let startedSeen = false; + const preEvents: Record[] = []; + while (!startedSeen) { + const event = await rt.readline(); + preEvents.push(event); + if ( + event.type === "lifecycle" && + (event.payload as any).event === "started" + ) { + startedSeen = true; + } + } + expect(startedSeen).toBe(true); + + // Send SIGTERM to the nixosandbox process (which kills the bwrap child) + rt.kill("SIGTERM"); + + // Read remaining events — should include result with non-zero exit or signal + const resultPromise = new Promise | null>( + async (resolve) => { + const timer = setTimeout(() => resolve(null), 10000); + try { + while (true) { + const event = await rt.readline(); + if (event.type === "result") { + clearTimeout(timer); + resolve(event); + return; + } + } + } catch { + clearTimeout(timer); + resolve(null); + } + }, + ); + + const resultEvent = await resultPromise; + + if (resultEvent) { + const resultPayload = resultEvent.payload as any; + // Process was killed — either signal or non-zero exit + expect( + resultPayload.exitCode !== 0 || resultPayload.signal !== null, + ).toBe(true); + } else { + // Force-kill if no result received + rt.kill("SIGKILL"); + } + + const exit = await rt.waitForExit(); + expect(exit.signal !== null || exit.code !== null).toBe(true); + }, 30_000); + }, +); diff --git a/tests/protocol/crash-synthesis.test.ts b/tests/protocol/crash-synthesis.test.ts new file mode 100644 index 0000000..f2e9bb0 --- /dev/null +++ b/tests/protocol/crash-synthesis.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { synthesizeCrashResult } from "../../packages/pi-sandbox-extension/src/crash-synthesis.js"; +import type { ValidationPayload } from "../../packages/pi-sandbox-extension/src/contract.js"; + +describe("Protocol Test 5: Crash Synthesis (TS-only)", () => { + it("Case 1: with validation state — preserves effective network, workspaceModified=true", () => { + const lastValidation: ValidationPayload = { + ok: true, + errors: [], + warnings: [], + effectiveState: { + network: { + requested: "full", + actual: "full", + enforcement: "none", + degraded: false, + }, + namespacesApplied: ["user"], + envApplied: ["HOME", "PATH"], + }, + }; + + const result = synthesizeCrashResult(lastValidation, "full", null, null, 500); + + expect(result.reconciliationHints.workspaceModified).toBe(true); + expect(result.reconciliationHints.terminalState).toBe("supervisor_crash"); + expect(result.reconciliationHints.cleanupSucceeded).toBe(false); + + // effectiveNetwork should be preserved from validation state + expect(result.effectiveNetwork).toEqual(lastValidation.effectiveState!.network); + expect(result.effectiveNetwork.requested).toBe("full"); + expect(result.effectiveNetwork.actual).toBe("full"); + expect(result.effectiveNetwork.degraded).toBe(false); + + expect(result.exitCode).toBe(-1); + expect(result.timedOut).toBe(false); + expect(result.durationMs).toBe(500); + }); + + it("Case 2: without validation state — conservative fallback, workspaceModified=false", () => { + const result = synthesizeCrashResult(null, "full", 1, null, 100); + + expect(result.reconciliationHints.workspaceModified).toBe(false); + expect(result.reconciliationHints.terminalState).toBe("supervisor_crash"); + expect(result.reconciliationHints.cleanupSucceeded).toBe(false); + + // Conservative fallback: actual=full, degraded=true, enforcement=none + expect(result.effectiveNetwork.actual).toBe("full"); + expect(result.effectiveNetwork.degraded).toBe(true); + expect(result.effectiveNetwork.enforcement).toBe("none"); + // requested comes from the string parameter + expect(result.effectiveNetwork.requested).toBe("full"); + + expect(result.exitCode).toBe(1); + expect(result.timedOut).toBe(false); + expect(result.durationMs).toBe(100); + }); +}); diff --git a/tests/protocol/docker-sidecar.test.ts b/tests/protocol/docker-sidecar.test.ts new file mode 100644 index 0000000..ce9e321 --- /dev/null +++ b/tests/protocol/docker-sidecar.test.ts @@ -0,0 +1,133 @@ +/** + * Docker sidecar integration tests with rootfs execution. + * + * These tests require Docker Desktop + Nix and are gated behind + * RUN_DOCKER_TESTS=1. + * + * Run: RUN_DOCKER_TESTS=1 npx vitest run tests/protocol/docker-sidecar.test.ts + */ +import { execFileSync } from "node:child_process"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { spawnExecJson } from "./helpers.js"; + +const DOCKER_TESTS = process.env.RUN_DOCKER_TESTS === "1"; + +// Docker tests need NIXOSANDBOX_NO_DOCKER unset +const dockerEnv = { + ...process.env, + NIXOSANDBOX_NO_DOCKER: undefined, +} as NodeJS.ProcessEnv; + +describe.skipIf(!DOCKER_TESTS)("Docker sidecar (rootfs)", () => { + let sessionId: string; + + beforeAll(() => { + const binaryPath = process.env.NIXOSANDBOX_BINARY; + if (!binaryPath) throw new Error("NIXOSANDBOX_BINARY not set"); + + // Clean up any leftover sidecar from previous runs + try { + execFileSync("docker", ["rm", "-f", "nixosandbox-sidecar"], { + stdio: "ignore", + }); + } catch { + // Container didn't exist + } + + // Create a session with Docker enabled + const output = execFileSync( + binaryPath, + ["create", "--profile", "strict", "--json"], + { env: dockerEnv, encoding: "utf-8" }, + ); + const meta = JSON.parse(output); + sessionId = meta.sessionId; + }, 120_000); // 2min for first-time rootfs build + Docker image build + + afterAll(() => { + const binaryPath = process.env.NIXOSANDBOX_BINARY; + if (binaryPath && sessionId) { + try { + execFileSync(binaryPath, ["destroy", sessionId], { stdio: "ignore" }); + } catch { + // Cleanup best-effort + } + } + }); + + it("runs echo through Docker+bwrap with rootfs and gets lifecycle events", async () => { + const rt = spawnExecJson(sessionId, ["echo", "hello from docker"], { + env: dockerEnv, + }); + + const events = await rt.readAllEvents(); + + // Should have lifecycle(started), stdout, lifecycle(exited), result + expect(events.length).toBeGreaterThanOrEqual(3); + + const startedEvent = events.find( + (e) => + e.type === "lifecycle" && (e.payload as any).event === "started", + ); + expect(startedEvent).toBeDefined(); + + const stdoutEvents = events.filter((e) => e.type === "stdout"); + const helloEvent = stdoutEvents.find((e) => + ((e.payload as any).data as string).includes("hello from docker"), + ); + expect(helloEvent).toBeDefined(); + + const exitedEvent = events.find( + (e) => + e.type === "lifecycle" && (e.payload as any).event === "exited", + ); + expect(exitedEvent).toBeDefined(); + + const result = events.find((e) => e.type === "result") as any; + expect(result).toBeDefined(); + expect(result.payload.exitCode).toBe(0); + + await rt.waitForExit(); + }, 60_000); + + it("verifies Nix store is accessible inside container", async () => { + const rt = spawnExecJson(sessionId, ["ls", "/nix/store"], { + env: dockerEnv, + }); + + const events = await rt.readAllEvents(); + + const result = events.find((e) => e.type === "result") as any; + expect(result).toBeDefined(); + // ls /nix/store should succeed since we mount it + // Note: inside the bwrap sandbox, /nix/store is part of the rootfs + // via --pivot-root, not the Docker mount. The Docker mount makes it + // available to bwrap for --pivot-root to use. + // The actual test is that bwrap can access the rootfs path. + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }, 30_000); + + it("NIXOSANDBOX_NO_DOCKER=1 blocks Docker and exits with error", () => { + const binaryPath = process.env.NIXOSANDBOX_BINARY; + if (!binaryPath) throw new Error("NIXOSANDBOX_BINARY not set"); + + // With Docker disabled on non-Linux, exec should fail + try { + execFileSync( + binaryPath, + ["exec", sessionId, "--", "echo", "should-fail"], + { + env: { ...process.env, NIXOSANDBOX_NO_DOCKER: "1" }, + encoding: "utf-8", + stdio: "pipe", + }, + ); + // If we're on Linux with bwrap, this might succeed — that's OK + } catch (err: any) { + // On macOS without Docker, should fail with non-zero exit + expect(err.status).not.toBe(0); + } + }, 15_000); +}); diff --git a/tests/protocol/globalSetup.ts b/tests/protocol/globalSetup.ts new file mode 100644 index 0000000..78ef2eb --- /dev/null +++ b/tests/protocol/globalSetup.ts @@ -0,0 +1,24 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const CRATE_DIR = resolve(import.meta.dirname, "../../crates/nixosandbox"); + +export async function setup() { + console.log("Building nixosandbox..."); + execFileSync("cargo", ["build", "--release"], { + cwd: CRATE_DIR, + stdio: "inherit", + }); + + const binaryPath = resolve(CRATE_DIR, "target/release/nixosandbox"); + if (!existsSync(binaryPath)) { + throw new Error(`Binary not found at ${binaryPath}`); + } + + process.env.NIXOSANDBOX_BINARY = binaryPath; + // Disable Docker sidecar for non-Docker tests. + // Docker-specific tests override this via their own env. + process.env.NIXOSANDBOX_NO_DOCKER = "1"; + console.log(`Runtime binary: ${binaryPath}`); +} diff --git a/tests/protocol/helpers.ts b/tests/protocol/helpers.ts new file mode 100644 index 0000000..ecc915e --- /dev/null +++ b/tests/protocol/helpers.ts @@ -0,0 +1,144 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { createInterface } from "node:readline"; + +export interface TestRuntime { + send(message: Record): void; + readline(): Promise>; + readAllEvents(): Promise[]>; + kill(signal?: NodeJS.Signals): void; + waitForExit(): Promise<{ code: number | null; signal: string | null }>; + stderr: string; + process: ChildProcess; +} + +/** + * Spawn `nixosandbox exec --json -- ` and return + * a TestRuntime that reads NDJSON events from stdout. + */ +export function spawnExecJson( + sessionId: string, + command: string[], + options?: { env?: NodeJS.ProcessEnv; extraArgs?: string[] }, +): TestRuntime { + const binaryPath = process.env.NIXOSANDBOX_BINARY; + if (!binaryPath) { + throw new Error("NIXOSANDBOX_BINARY not set. Did globalSetup run?"); + } + + const args = [ + "exec", + "--json", + ...(options?.extraArgs ?? []), + sessionId, + "--", + ...command, + ]; + + const child = spawn(binaryPath, args, { + stdio: ["pipe", "pipe", "pipe"], + env: options?.env ?? process.env, + }); + + return wrapChildProcess(child); +} + +/** + * Wrap a ChildProcess into a TestRuntime for NDJSON event reading. + */ +function wrapChildProcess(child: ChildProcess): TestRuntime { + const rl = createInterface({ input: child.stdout! }); + const lineQueue: string[] = []; + let lineResolve: ((line: string) => void) | null = null; + let closed = false; + + rl.on("line", (line) => { + if (lineResolve) { + const resolve = lineResolve; + lineResolve = null; + resolve(line); + } else { + lineQueue.push(line); + } + }); + + rl.on("close", () => { + closed = true; + if (lineResolve) { + const resolve = lineResolve; + lineResolve = null; + resolve(""); + } + }); + + let stderrBuf = ""; + child.stderr!.on("data", (chunk: Buffer) => { + stderrBuf += chunk.toString(); + }); + + function nextLine(): Promise { + if (lineQueue.length > 0) { + return Promise.resolve(lineQueue.shift()!); + } + if (closed) { + return Promise.reject(new Error("stdout closed before line received")); + } + return new Promise((resolve) => { + lineResolve = resolve; + }); + } + + const runtime: TestRuntime = { + send(message: Record): void { + child.stdin!.write(JSON.stringify(message) + "\n"); + }, + + async readline(): Promise> { + const line = await nextLine(); + if (!line) throw new Error("Empty line received"); + return JSON.parse(line) as Record; + }, + + async readAllEvents(): Promise[]> { + const events: Record[] = []; + while (true) { + let line: string; + try { + line = await nextLine(); + } catch { + break; + } + if (!line) break; + const parsed = JSON.parse(line) as Record; + events.push(parsed); + if (parsed.type === "result") { + break; + } + } + return events; + }, + + kill(signal: NodeJS.Signals = "SIGTERM"): void { + child.kill(signal); + }, + + waitForExit(): Promise<{ code: number | null; signal: string | null }> { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve({ code: child.exitCode, signal: child.signalCode }); + return; + } + child.on("exit", (code, signal) => { + resolve({ code, signal }); + }); + }); + }, + + get stderr(): string { + return stderrBuf; + }, + + process: child, + }; + + return runtime; +} diff --git a/tests/protocol/package.json b/tests/protocol/package.json new file mode 100644 index 0000000..06c1fa8 --- /dev/null +++ b/tests/protocol/package.json @@ -0,0 +1,17 @@ +{ + "name": "@pi-sandbox/protocol-tests", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/tests/protocol/successful-run.test.ts b/tests/protocol/successful-run.test.ts new file mode 100644 index 0000000..3069014 --- /dev/null +++ b/tests/protocol/successful-run.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { makePlan, spawnRuntime } from "./helpers.js"; + +describe("Protocol Test 3: Successful Run", () => { + it("runs echo hello and produces expected event stream", async () => { + const rt = spawnRuntime(); + + // Use /tmp as cwd (always exists) + rt.send( + makePlan({ + command: ["echo", "hello"], + manifest: { + mounts: [ + { type: "tmpfs", target: "/tmp", writable: true }, + ], + env: { HOME: "/home/sandbox", PATH: "/usr/bin:/bin" }, + cwd: "/tmp", + }, + }), + ); + + const events = await rt.readAllEvents(); + + // Must have at least: validation, lifecycle(started), stdout(hello), lifecycle(exited), result + expect(events.length).toBeGreaterThanOrEqual(4); + + // First event: validation ok=true + const validation = events[0]; + expect(validation.type).toBe("validation"); + expect((validation.payload as any).ok).toBe(true); + + // Find the "started" lifecycle event + const startedEvent = events.find( + (e) => + e.type === "lifecycle" && (e.payload as any).event === "started", + ); + expect(startedEvent).toBeDefined(); + + // Find stdout event containing "hello" + const stdoutEvents = events.filter((e) => e.type === "stdout"); + expect(stdoutEvents.length).toBeGreaterThanOrEqual(1); + const helloEvent = stdoutEvents.find((e) => + ((e.payload as any).data as string).includes("hello"), + ); + expect(helloEvent).toBeDefined(); + + // Find the "exited" lifecycle event + const exitedEvent = events.find( + (e) => + e.type === "lifecycle" && (e.payload as any).event === "exited", + ); + expect(exitedEvent).toBeDefined(); + + // Last event: result + const result = events[events.length - 1]; + expect(result.type).toBe("result"); + const resultPayload = result.payload as any; + expect(resultPayload.exitCode).toBe(0); + expect(resultPayload.reconciliationHints.terminalState).toBe("clean_exit"); + + // Sequence numbers must strictly increase across all sequenced events + const sequencedEvents = events.filter((e) => e.sequence !== undefined); + for (let i = 1; i < sequencedEvents.length; i++) { + expect(sequencedEvents[i].sequence as number).toBeGreaterThan( + sequencedEvents[i - 1].sequence as number, + ); + } + + const exit = await rt.waitForExit(); + expect(exit.code).toBe(0); + }); +}); diff --git a/tests/protocol/tsconfig.json b/tests/protocol/tsconfig.json new file mode 100644 index 0000000..3c69cae --- /dev/null +++ b/tests/protocol/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/tests/protocol/vitest.config.ts b/tests/protocol/vitest.config.ts new file mode 100644 index 0000000..34af864 --- /dev/null +++ b/tests/protocol/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["*.test.ts"], + globalSetup: "./globalSetup.ts", + testTimeout: 30000, + }, +});