From ed8f07763ac04f060951d58ec4dc273997d7e44b Mon Sep 17 00:00:00 2001 From: Adwitiya Date: Wed, 4 Mar 2026 13:54:27 +0000 Subject: [PATCH] Add CI/release workflows, installer, README overhaul, and security fixes - GitHub Actions CI (test on push/PR) and release workflow (static binaries + checksums on tag push via softprops/action-gh-release) - curl|bash installer (scripts/install.sh) for zero-dependency installs - Fix ResolveAgentPath to find forgevm-agent via exec.LookPath - Makefile release-build target for static binaries + checksums - README rewrite: E2B comparison table, LLM integration examples (OpenAI + Claude tool calling), all 48 API endpoints, multi-user pools, extended file ops in SDK examples - Extended file operations (move, chmod, stat, glob, delete) across providers, orchestrator, API routes, and Python/TypeScript SDKs - Multi-user VM pool support with configurable overflow policy - Security: remove API key printing from test scripts, replace hardcoded local paths with dotenv, mask key output to last 4 chars Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 23 + .github/workflows/release.yml | 59 +++ Makefile | 8 +- README.md | 508 ++++++++++++++------- cmd/forgevm-agent/main.go | 118 +++++ cmd/forgevm/cmd_serve.go | 2 + internal/agentproto/protocol.go | 38 ++ internal/api/routes/sandboxes.go | 130 ++++++ internal/api/routes/sandboxes_test.go | 263 +++++++++++ internal/api/server.go | 3 +- internal/config/config.go | 40 +- internal/orchestrator/manager.go | 346 ++++++++++++++- internal/orchestrator/types.go | 18 + internal/orchestrator/vm_pool.go | 142 ++++++ internal/providers/custom.go | 88 ++++ internal/providers/e2b.go | 20 + internal/providers/firecracker.go | 123 +++++ internal/providers/mock.go | 89 ++++ internal/providers/provider.go | 15 + internal/store/migrations.go | 9 + internal/store/sqlite.go | 45 +- internal/store/store.go | 4 + scripts/install.sh | 182 ++++++++ scripts/setup.sh | 6 +- scripts/test_multiuser_pool.py | 617 ++++++++++++++++++++++++++ scripts/test_opensandbox_lifecycle.py | 89 ++++ scripts/test_opensandbox_requesty.py | 375 ++++++++++++++++ sdk/js/src/client.ts | 19 + sdk/js/src/sandbox.ts | 82 ++++ sdk/js/src/types.ts | 15 + sdk/python/forgevm/async_client.py | 9 + sdk/python/forgevm/async_sandbox.py | 40 ++ sdk/python/forgevm/client.py | 9 + sdk/python/forgevm/sandbox.py | 40 ++ sdk/python/tests/test_client.py | 62 +++ sdk/python/tests/test_llm.py | 16 +- sdk/python/tests/test_pdf_gen.py | 16 +- tests/integration/api_test.go | 151 +++++++ 38 files changed, 3620 insertions(+), 199 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 internal/orchestrator/vm_pool.go create mode 100755 scripts/install.sh create mode 100644 scripts/test_multiuser_pool.py create mode 100644 scripts/test_opensandbox_lifecycle.py create mode 100644 scripts/test_opensandbox_requesty.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7c09fb2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7469341 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,59 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... + + build: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build forgevm + run: > + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 + go build -ldflags="-s -w -X main.version=${{ github.ref_name }}" + -o forgevm-linux-amd64 ./cmd/forgevm + + - name: Build forgevm-agent + run: > + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 + go build -ldflags="-s -w" + -o forgevm-agent-linux-amd64 ./cmd/forgevm-agent + + - name: Generate checksums + run: sha256sum forgevm-linux-amd64 forgevm-agent-linux-amd64 > checksums.txt + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + prerelease: ${{ contains(github.ref_name, '-') }} + files: | + forgevm-linux-amd64 + forgevm-agent-linux-amd64 + checksums.txt diff --git a/Makefile b/Makefile index fb844df..fac0b8b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-agent test lint clean serve dev +.PHONY: build build-agent test lint clean serve dev release-build VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev) @@ -37,3 +37,9 @@ clean: # Run go vet lint: go vet ./... + +# Build static release binaries + checksums +release-build: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=$(VERSION)" -o forgevm-linux-amd64 ./cmd/forgevm + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o forgevm-agent-linux-amd64 ./cmd/forgevm-agent + sha256sum forgevm-linux-amd64 forgevm-agent-linux-amd64 > checksums.txt diff --git a/README.md b/README.md index 667ec40..358f613 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

- Your AI agent just got its own computer. + Your AI agent's computer. On YOUR machine.

@@ -12,9 +12,9 @@

+ Release Stars - Forks - Issues + CI Go 1.25+ MIT License Linux @@ -22,148 +22,217 @@ --- -## The Problem +## Why ForgeVM -You're building an AI agent. It needs to run code. You have two options: +| | **ForgeVM** | **E2B** | +|--|-------------|---------| +| **Hosting** | Self-hosted (your machine) | Cloud only | +| **Isolation** | KVM microVM (hardware) | Firecracker microVM | +| **Boot time** | ~28ms (snapshot restore) | ~500ms | +| **Pricing** | Free forever | Pay per use | +| **Data privacy** | Data never leaves your machine | Data on their servers | +| **Setup** | One command | Sign up + API key | +| **Multi-user pools** | Built-in | No | +| **Python SDK** | Yes (sync + async) | Yes | +| **TypeScript SDK** | Yes | Yes | -1. **Docker** -- shares the host kernel. One escape and your machine is owned. -2. **Cloud sandboxes (E2B, etc.)** -- sends your data to someone else's servers. Costs money. Adds latency. +**Key advantages:** -Neither is great when your agent is running **untrusted, AI-generated code** on **your own machine**. - -## The Solution - -ForgeVM gives your AI agent its own **real virtual machine** -- hardware-level KVM isolation via Firecracker microVMs. Not containers. Not namespaces. Actual separate kernels. - -``` -Your LLM --> ForgeVM API --> Firecracker microVM (~28ms) - |-- exec("python3 sketch.py") - |-- read/write files - |-- stream stdout in real-time - \-- destroy --> gone. forever. -``` - -One binary. One command. Your hardware. Your data. +- **Your data stays on your machine.** No cloud API calls. No vendor lock-in. No usage billing. +- **~28ms boot** — snapshot/restore, not cold boot. Faster than every cloud alternative. +- **Drop-in E2B replacement** — same API shape, same SDKs, point at localhost instead of `api.e2b.dev`. +- **Real VM isolation** — KVM hardware virtualization, not containers. AI-generated code can't escape. --- -## Prerequisites +## Install -- **Linux** (x86_64) -- bare metal or WSL2 -- **KVM support** -- `ls /dev/kvm` (enable virtualization in BIOS) -- **Go 1.25+** ([install](https://go.dev/dl/)) -- **Docker** ([install](https://docs.docker.com/engine/install/)) +### One command (recommended) ```bash -# Ubuntu/Debian -- install all dependencies -sudo apt-get update && sudo apt-get install -y \ - docker.io qemu-kvm xfsprogs curl git make - -# Add yourself to docker + kvm groups (log out/in after) -sudo usermod -aG docker,kvm $(whoami) - -# Fix KVM permissions (needed on WSL2, resets on reboot) -sudo chmod 666 /dev/kvm +curl -fsSL https://raw.githubusercontent.com/DohaerisAI/forgevm/main/scripts/install.sh | bash ``` ---- - -## Install +Downloads pre-built binaries, installs Firecracker, downloads the kernel. No Go required. -### One-command setup (recommended) +### Build from source ```bash -git clone https://github.com/DohaerisAI/forgevm -cd forgevm +git clone https://github.com/DohaerisAI/forgevm && cd forgevm ./scripts/setup.sh ``` -This single script handles everything: -- Checks Go 1.25+, Docker, KVM -- Downloads and installs Firecracker binary -- Creates XFS reflink volume at `/var/lib/forgevm` (instant COW rootfs copies = ~35ms spawns) -- Downloads prebuilt vmlinux kernel -- Builds `forgevm` server and `forgevm-agent` guest binary +Requires Go 1.25+, Docker, and KVM. Sets up XFS reflink for fastest possible snapshot restores. -Once done, just run `./forgevm serve`. +--- -### Manual build (if you prefer) +## Quick Start ```bash -git clone https://github.com/DohaerisAI/forgevm -cd forgevm -make build-all # builds ./forgevm + ./bin/forgevm-agent -sudo mkdir -p /var/lib/forgevm && sudo chown $(whoami) /var/lib/forgevm -./scripts/setup-kernel.sh # downloads kernel to /var/lib/forgevm/vmlinux.bin -sudo chmod 666 /dev/kvm # KVM access (or: sudo usermod -aG kvm $USER) -``` +# 1. Install +curl -fsSL https://raw.githubusercontent.com/DohaerisAI/forgevm/main/scripts/install.sh | bash -### Docker +# 2. Start the server +forgevm serve -```bash -docker build -t forgevm . -docker run -p 7423:7423 forgevm -``` +# 3. Spawn a sandbox, run code, destroy it +SANDBOX=$(curl -s -X POST localhost:7423/api/v1/sandboxes \ + -H "Content-Type: application/json" \ + -d '{"image":"alpine:latest"}' | jq -r .id) -### Option 3: Download binary (when releases are available) +curl -s -X POST localhost:7423/api/v1/sandboxes/$SANDBOX/exec \ + -H "Content-Type: application/json" \ + -d '{"command":"echo hello from ForgeVM"}' +# {"exit_code":0,"stdout":"hello from ForgeVM\n"} -```bash -curl -fsSL https://github.com/DohaerisAI/forgevm/releases/latest/download/forgevm-linux-amd64 -o forgevm -chmod +x forgevm -sudo mv forgevm /usr/local/bin/ +curl -s -X DELETE localhost:7423/api/v1/sandboxes/$SANDBOX ``` -> **Note:** You still need the guest agent binary for custom Docker images. Build it with `make build-agent`. - --- -## Quick Start +## Give Your LLM a Computer -### 1. Start the server +ForgeVM turns any LLM into a coding agent. Define `execute_code` and `write_file` as tools, and let the model call them — ForgeVM handles sandboxed execution. -```bash -./forgevm serve -# ForgeVM listening on http://localhost:7423 -``` - -The **mock provider** is enabled by default -- runs commands in temp directories on the host. No VMs, no KVM needed. Perfect for development. +### OpenAI function calling -### 2. Using custom Docker images - -ForgeVM can spawn sandboxes from any Docker image. The server pre-builds a rootfs from the image and injects the guest agent into it. +```python +import openai +import requests + +FORGEVM = "http://localhost:7423/api/v1" + +# Spawn a sandbox for this conversation +sb = requests.post(f"{FORGEVM}/sandboxes", json={"image": "python:3.12"}).json() +sandbox_id = sb["id"] + +tools = [ + { + "type": "function", + "function": { + "name": "execute_code", + "description": "Execute a shell command in a sandboxed VM", + "parameters": { + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "write_file", + "description": "Write a file in the sandbox", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "content": {"type": "string"}, + }, + "required": ["path", "content"], + }, + }, + }, +] + + +def handle_tool_call(name, args): + if name == "execute_code": + r = requests.post(f"{FORGEVM}/sandboxes/{sandbox_id}/exec", json=args) + return r.json() + elif name == "write_file": + r = requests.post( + f"{FORGEVM}/sandboxes/{sandbox_id}/files", + json={"path": args["path"], "content": args["content"]}, + ) + return r.json() + + +client = openai.OpenAI() +messages = [{"role": "user", "content": "Write a Python script that prints the first 10 primes, then run it"}] + +while True: + response = client.chat.completions.create(model="gpt-4o", messages=messages, tools=tools) + msg = response.choices[0].message + messages.append(msg) + + if msg.tool_calls: + for tc in msg.tool_calls: + import json + result = handle_tool_call(tc.function.name, json.loads(tc.function.arguments)) + messages.append({"role": "tool", "tool_call_id": tc.id, "content": json.dumps(result)}) + else: + print(msg.content) + break -```bash -# Pre-build a rootfs (optional -- happens automatically on first spawn) -./forgevm build-image python:3.12 +# Clean up +requests.delete(f"{FORGEVM}/sandboxes/{sandbox_id}") ``` -**Important:** The `forgevm-agent` binary must exist at `./bin/forgevm-agent` (relative to where you run the server). If you moved the server binary, set the path in your config: +### Claude tool_use -```yaml -# forgevm.yaml -providers: - mock: - agent_path: "/absolute/path/to/bin/forgevm-agent" +```python +import anthropic +import requests +import json + +FORGEVM = "http://localhost:7423/api/v1" +sb = requests.post(f"{FORGEVM}/sandboxes", json={"image": "python:3.12"}).json() +sandbox_id = sb["id"] + +tools = [ + { + "name": "execute_code", + "description": "Execute a shell command in a sandboxed VM", + "input_schema": { + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + }, + { + "name": "write_file", + "description": "Write a file in the sandbox", + "input_schema": { + "type": "object", + "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, + "required": ["path", "content"], + }, + }, +] + +client = anthropic.Anthropic() +messages = [{"role": "user", "content": "Write a Python script that calculates fibonacci numbers, then run it"}] + +while True: + response = client.messages.create(model="claude-sonnet-4-20250514", max_tokens=4096, tools=tools, messages=messages) + + # Collect assistant response + messages.append({"role": "assistant", "content": response.content}) + + if response.stop_reason == "tool_use": + tool_results = [] + for block in response.content: + if block.type == "tool_use": + if block.name == "execute_code": + r = requests.post(f"{FORGEVM}/sandboxes/{sandbox_id}/exec", json=block.input) + result = r.json() + elif block.name == "write_file": + r = requests.post(f"{FORGEVM}/sandboxes/{sandbox_id}/files", json={"path": block.input["path"], "content": block.input["content"]}) + result = r.json() + tool_results.append({"type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result)}) + messages.append({"role": "user", "content": tool_results}) + else: + for block in response.content: + if hasattr(block, "text"): + print(block.text) + break + +requests.delete(f"{FORGEVM}/sandboxes/{sandbox_id}") ``` -### 3. Test it - -```bash -# Spawn a sandbox -curl -s -X POST localhost:7423/api/v1/sandboxes \ - -H "Content-Type: application/json" \ - -d '{"image":"alpine:latest"}' | jq .id -# "sb-a1b2c3d4" - -# Run a command -curl -s -X POST localhost:7423/api/v1/sandboxes/sb-a1b2c3d4/exec \ - -H "Content-Type: application/json" \ - -d '{"command":"echo hello from ForgeVM"}' -# {"exit_code":0,"stdout":"hello from ForgeVM\n"} - -# Destroy -curl -s -X DELETE localhost:7423/api/v1/sandboxes/sb-a1b2c3d4 -``` +**That's it.** Any LLM that supports tool calling can now write and execute code safely on your machine. --- @@ -178,7 +247,7 @@ from forgevm import Client client = Client("http://localhost:7423") -# Spawn a sandbox and run code +# Spawn and execute sandbox = client.spawn(image="alpine:latest") result = sandbox.exec("echo hello world") print(result.stdout) # "hello world\n" @@ -188,18 +257,31 @@ sandbox.write_file("/app/main.py", 'print("built with ForgeVM")') result = sandbox.exec("python3 /app/main.py") print(result.stdout) # "built with ForgeVM\n" -# Extend TTL if you need more time +# Extended file operations +content = sandbox.read_file("/etc/hostname") +files = sandbox.list_files("/app") +sandbox.move_file("/app/main.py", "/app/app.py") +sandbox.chmod_file("/app/app.py", "755") +info = sandbox.stat_file("/app/app.py") +matches = sandbox.glob_files("/app/*.py") +sandbox.delete_file("/app/app.py") + +# Extend TTL sandbox.extend_ttl("30m") # Stream output in real-time for chunk in sandbox.exec_stream("ping -c 3 localhost"): print(chunk.data, end="") +# Pool status (multi-user mode) +status = client.pool_status() +print(status) + # Clean up sandbox.destroy() ``` -**Context manager** -- auto-destroys on exit: +**Context manager** — auto-destroys on exit: ```python with client.spawn(image="python:3.12") as sb: @@ -237,10 +319,19 @@ const sandbox = await client.spawn({ image: "alpine:latest" }); const result = await sandbox.exec("echo hello world"); console.log(result.stdout); // "hello world\n" -// Files +// File operations await sandbox.writeFile("/app/index.js", 'console.log("hi")'); await sandbox.exec("node /app/index.js"); +// Extended file operations +const content = await sandbox.readFile("/etc/hostname"); +const files = await sandbox.listFiles("/app"); +await sandbox.moveFile("/app/index.js", "/app/app.js"); +await sandbox.chmodFile("/app/app.js", "755"); +const info = await sandbox.statFile("/app/app.js"); +const matches = await sandbox.globFiles("/app/*.js"); +await sandbox.deleteFile("/app/app.js"); + // Extend TTL await sandbox.extendTtl("30m"); @@ -254,6 +345,42 @@ await sandbox.destroy(); --- +## Multi-User Pools + +Run a shared pool of VMs that multiple users (or agents) share. Each user gets an isolated session within a pre-warmed VM. + +```yaml +# forgevm.yaml +pool: + enabled: true + max_vms: 10 + max_users_per_vm: 5 + image: "python:3.12" + memory_mb: 2048 + vcpus: 2 + overflow: "reject" # or "queue" +``` + +```bash +# Check pool status +curl -s localhost:7423/api/v1/pool/status | jq +``` + +```json +{ + "total_vms": 10, + "active_users": 23, + "available_slots": 27, + "vms": [ + {"id": "sb-a1b2c3d4", "users": 3, "max_users": 5, "memory_mb": 2048} + ] +} +``` + +VMs are pre-warmed from snapshots (~28ms). When a user connects, they get assigned to a VM with available capacity. When `overflow` is `"reject"`, new requests fail with 503 if all slots are full. With `"queue"`, they wait for a slot. + +--- + ## CLI ```bash @@ -264,6 +391,7 @@ forgevm exec sb-a1b2c3d4 -- echo hello world # run a command forgevm kill sb-a1b2c3d4 # destroy forgevm build-image python:3.12 # pre-build rootfs from Docker image forgevm tui # interactive dashboard +forgevm version # print version ``` --- @@ -272,31 +400,86 @@ forgevm tui # interactive dashboard Base: `http://localhost:7423/api/v1` -| Method | Endpoint | What it does | +### Sandboxes + +| Method | Endpoint | Description | |--------|----------|-------------| -| `POST` | `/sandboxes` | Spawn a sandbox | -| `GET` | `/sandboxes` | List all | -| `GET` | `/sandboxes/:id` | Get details | -| `DELETE` | `/sandboxes/:id` | Destroy | +| `POST` | `/sandboxes` | Create a sandbox | +| `GET` | `/sandboxes` | List all sandboxes | +| `DELETE` | `/sandboxes` | Prune expired sandboxes | +| `GET` | `/sandboxes/:id` | Get sandbox details | +| `DELETE` | `/sandboxes/:id` | Destroy a sandbox | | `POST` | `/sandboxes/:id/extend` | Extend TTL | -| `POST` | `/sandboxes/:id/exec` | Run a command | -| `GET` | `/sandboxes/:id/exec/ws` | Run via WebSocket | +| `POST` | `/sandboxes/:id/exec` | Execute a command | +| `GET` | `/sandboxes/:id/exec/ws` | Execute via WebSocket | | `POST` | `/sandboxes/:id/files` | Write a file | -| `GET` | `/sandboxes/:id/files?path=` | Read a file | -| `GET` | `/sandboxes/:id/files/list` | List files | +| `GET` | `/sandboxes/:id/files` | Read a file | +| `DELETE` | `/sandboxes/:id/files` | Delete a file | +| `GET` | `/sandboxes/:id/files/list` | List directory | +| `POST` | `/sandboxes/:id/files/move` | Move/rename file | +| `POST` | `/sandboxes/:id/files/chmod` | Change permissions | +| `GET` | `/sandboxes/:id/files/stat` | File stat info | +| `GET` | `/sandboxes/:id/files/glob` | Glob pattern match | | `GET` | `/sandboxes/:id/logs` | Console logs | + +### Templates + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/templates` | Create a template | +| `GET` | `/templates` | List templates | +| `GET` | `/templates/:name` | Get template | +| `PUT` | `/templates/:name` | Update template | +| `DELETE` | `/templates/:name` | Delete template | +| `POST` | `/templates/:name/spawn` | Spawn from template | + +### Providers + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/providers` | List providers | +| `POST` | `/providers/test` | Test provider health | +| `GET` | `/providers/:name` | Provider details | + +### Snapshots + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/snapshots` | List snapshots | + +### Environments + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/environments/specs` | Create environment spec | +| `GET` | `/environments/specs` | List specs | +| `GET` | `/environments/specs/:id` | Get spec | +| `GET` | `/environments/specs/:id/suggestions` | Package suggestions | +| `POST` | `/environments/builds` | Start build | +| `GET` | `/environments/builds` | List builds | +| `GET` | `/environments/builds/:id` | Get build | +| `POST` | `/environments/builds/:id/cancel` | Cancel build | +| `GET` | `/environments/builds/:id/spawn-config` | Spawn config | +| `POST` | `/environments/registry-connections` | Save registry credentials | +| `GET` | `/environments/registry-connections` | List connections | +| `DELETE` | `/environments/registry-connections/:id` | Delete connection | + +### System + +| Method | Endpoint | Description | +|--------|----------|-------------| | `GET` | `/health` | Health check | +| `GET` | `/metrics` | Runtime metrics | | `GET` | `/events` | SSE event stream | -| `POST` | `/templates` | Create template | -| `POST` | `/templates/:name/spawn` | Spawn from template | +| `GET` | `/pool/status` | VM pool status | --- ## Providers -ForgeVM uses a **provider interface**. Swap backends without changing your code. +ForgeVM uses a **provider interface** — swap backends without changing your code. -### Mock (default -- zero setup) +### Mock (default — zero setup) Runs commands with `os/exec` in temp directories on the host. No VMs. Ships enabled by default for development and testing. Supports custom Docker images via `build-image`. @@ -304,32 +487,21 @@ Runs commands with `os/exec` in temp directories on the host. No VMs. Ships enab Real Firecracker microVMs with KVM hardware isolation. Each sandbox gets its own kernel, rootfs, and network namespace. -**Prerequisites:** -- Linux with KVM support (`ls /dev/kvm`) -- [Firecracker binary](https://github.com/firecracker-microvm/firecracker/releases) in your PATH - ```bash -# 1. Build everything -make build-all +# Full setup (builds from source + XFS reflink) +./scripts/setup.sh -# 2. Setup kernel + default rootfs -./scripts/setup-kernel.sh -sudo ./scripts/build-rootfs.sh alpine:latest +# Or with pre-built binaries +curl -fsSL https://raw.githubusercontent.com/DohaerisAI/forgevm/main/scripts/install.sh | bash +sudo chmod 666 /dev/kvm -# 3. Enable in config +# Enable in config cat > forgevm.yaml < Containers for AI Agents + +AI agents generate and run **untrusted code**. Containers share the host kernel — a single kernel exploit means host compromise. ForgeVM uses Firecracker microVMs: each sandbox gets its own kernel with KVM hardware isolation. Even if AI-generated code exploits a kernel vulnerability, it only affects that sandbox's kernel, not the host. + +- **KVM isolation** — each sandbox = its own kernel, rootfs, network +- **No shared kernel** — guest exploits can't reach the host +- **Ephemeral rootfs** — destroyed on teardown, nothing persists +- **vsock only** — host/guest communicate over virtio-vsock, zero network exposure +- **API key auth** — optional, on all endpoints +- **Auto-expiry** — sandboxes destroyed after TTL See [SECURITY.md](SECURITY.md) for vulnerability reporting. --- +## vs E2B + +| | **ForgeVM** | **E2B** | +|--|-------------|---------| +| Cost | Free forever | $0.000075/s per sandbox | +| Data location | Your machine | Their cloud | +| Latency | ~28ms (local) | ~500ms + network RTT | +| Internet required | No | Yes | +| Max sandboxes | Your hardware limit | Plan-dependent | +| Custom images | Any Docker image | E2B templates only | +| Open source | MIT | Partial | + +--- + ## Architecture ``` @@ -441,7 +646,7 @@ See [SECURITY.md](SECURITY.md) for vulnerability reporting. └──────────────────────────────────────────────────────────────┘ ``` -**Key flow:** SDK → REST API → Orchestrator → Provider → Firecracker → vsock → Guest Agent → execute → response back +**Key flow:** SDK -> REST API -> Orchestrator -> Provider -> Firecracker -> vsock -> Guest Agent -> execute -> response back **Snapshot/restore:** First spawn cold-boots (~1s) and snapshots. Every spawn after restores in ~28ms. @@ -456,6 +661,7 @@ make build-all # build both make test # run all tests make web # build frontend make lint # go vet +make release-build # static release binaries + checksums ``` ## Contributing diff --git a/cmd/forgevm-agent/main.go b/cmd/forgevm-agent/main.go index f16f81e..ec1e8a6 100644 --- a/cmd/forgevm-agent/main.go +++ b/cmd/forgevm-agent/main.go @@ -140,6 +140,16 @@ func handleConn(conn net.Conn) { handleReadFile(w, req) case agentproto.MethodListFiles: handleListFiles(w, req) + case agentproto.MethodDeleteFile: + handleDeleteFile(w, req) + case agentproto.MethodMoveFile: + handleMoveFile(w, req) + case agentproto.MethodChmodFile: + handleChmodFile(w, req) + case agentproto.MethodStatFile: + handleStatFile(w, req) + case agentproto.MethodGlobFiles: + handleGlobFiles(w, req) case agentproto.MethodShutdown: handleShutdown(w, req) default: @@ -358,6 +368,114 @@ func handleListFiles(w io.Writer, req *agentproto.Request) { agentproto.SendResponse(w, &agentproto.Response{ID: req.ID, Result: result}) } +func handleDeleteFile(w io.Writer, req *agentproto.Request) { + var params agentproto.DeleteFileParams + if err := agentproto.UnmarshalParams(req.Params, ¶ms); err != nil { + sendError(w, req.ID, fmt.Sprintf("bad params: %v", err)) + return + } + + var err error + if params.Recursive { + err = os.RemoveAll(params.Path) + } else { + err = os.Remove(params.Path) + } + if err != nil { + sendError(w, req.ID, fmt.Sprintf("delete: %v", err)) + return + } + + result, _ := agentproto.MarshalResult(map[string]bool{"ok": true}) + agentproto.SendResponse(w, &agentproto.Response{ID: req.ID, Result: result}) +} + +func handleMoveFile(w io.Writer, req *agentproto.Request) { + var params agentproto.MoveFileParams + if err := agentproto.UnmarshalParams(req.Params, ¶ms); err != nil { + sendError(w, req.ID, fmt.Sprintf("bad params: %v", err)) + return + } + + if err := os.MkdirAll(filepath.Dir(params.NewPath), 0755); err != nil { + sendError(w, req.ID, fmt.Sprintf("mkdir: %v", err)) + return + } + + if err := os.Rename(params.OldPath, params.NewPath); err != nil { + sendError(w, req.ID, fmt.Sprintf("rename: %v", err)) + return + } + + result, _ := agentproto.MarshalResult(map[string]bool{"ok": true}) + agentproto.SendResponse(w, &agentproto.Response{ID: req.ID, Result: result}) +} + +func handleChmodFile(w io.Writer, req *agentproto.Request) { + var params agentproto.ChmodFileParams + if err := agentproto.UnmarshalParams(req.Params, ¶ms); err != nil { + sendError(w, req.ID, fmt.Sprintf("bad params: %v", err)) + return + } + + mode, err := strconv.ParseUint(params.Mode, 8, 32) + if err != nil { + sendError(w, req.ID, fmt.Sprintf("parse mode: %v", err)) + return + } + + if err := os.Chmod(params.Path, os.FileMode(mode)); err != nil { + sendError(w, req.ID, fmt.Sprintf("chmod: %v", err)) + return + } + + result, _ := agentproto.MarshalResult(map[string]bool{"ok": true}) + agentproto.SendResponse(w, &agentproto.Response{ID: req.ID, Result: result}) +} + +func handleStatFile(w io.Writer, req *agentproto.Request) { + var params agentproto.StatFileParams + if err := agentproto.UnmarshalParams(req.Params, ¶ms); err != nil { + sendError(w, req.ID, fmt.Sprintf("bad params: %v", err)) + return + } + + info, err := os.Stat(params.Path) + if err != nil { + sendError(w, req.ID, fmt.Sprintf("stat: %v", err)) + return + } + + result, _ := agentproto.MarshalResult(&agentproto.FileInfoResult{ + Path: params.Path, + Size: info.Size(), + Mode: fmt.Sprintf("%o", info.Mode()), + IsDir: info.IsDir(), + ModTime: info.ModTime().UTC().Format(time.RFC3339), + }) + agentproto.SendResponse(w, &agentproto.Response{ID: req.ID, Result: result}) +} + +func handleGlobFiles(w io.Writer, req *agentproto.Request) { + var params agentproto.GlobFilesParams + if err := agentproto.UnmarshalParams(req.Params, ¶ms); err != nil { + sendError(w, req.ID, fmt.Sprintf("bad params: %v", err)) + return + } + + matches, err := filepath.Glob(params.Pattern) + if err != nil { + sendError(w, req.ID, fmt.Sprintf("glob: %v", err)) + return + } + if matches == nil { + matches = []string{} + } + + result, _ := agentproto.MarshalResult(&agentproto.GlobFilesResult{Matches: matches}) + agentproto.SendResponse(w, &agentproto.Response{ID: req.ID, Result: result}) +} + func handleShutdown(w io.Writer, req *agentproto.Request) { result, _ := agentproto.MarshalResult(map[string]bool{"ok": true}) agentproto.SendResponse(w, &agentproto.Response{ID: req.ID, Result: result}) diff --git a/cmd/forgevm/cmd_serve.go b/cmd/forgevm/cmd_serve.go index f7def97..ac366cb 100644 --- a/cmd/forgevm/cmd_serve.go +++ b/cmd/forgevm/cmd_serve.go @@ -103,8 +103,10 @@ func runServe() error { DefaultImage: cfg.Defaults.Image, DefaultMemory: cfg.Defaults.MemoryMB, DefaultVCPUs: cfg.Defaults.VCPUs, + Pool: cfg.Pool, }) mgr.Start() + mgr.InitVMPool() defer mgr.Stop() // Template registry diff --git a/internal/agentproto/protocol.go b/internal/agentproto/protocol.go index cad3332..faef4aa 100644 --- a/internal/agentproto/protocol.go +++ b/internal/agentproto/protocol.go @@ -13,6 +13,11 @@ const ( MethodWriteFile = "write_file" MethodReadFile = "read_file" MethodListFiles = "list_files" + MethodDeleteFile = "delete_file" + MethodMoveFile = "move_file" + MethodChmodFile = "chmod_file" + MethodStatFile = "stat_file" + MethodGlobFiles = "glob_files" MethodShutdown = "shutdown" ) @@ -95,3 +100,36 @@ type FileInfoResult struct { IsDir bool `json:"is_dir"` ModTime string `json:"mod_time"` } + +// DeleteFileParams are parameters for delete_file. +type DeleteFileParams struct { + Path string `json:"path"` + Recursive bool `json:"recursive"` +} + +// MoveFileParams are parameters for move_file. +type MoveFileParams struct { + OldPath string `json:"old_path"` + NewPath string `json:"new_path"` +} + +// ChmodFileParams are parameters for chmod_file. +type ChmodFileParams struct { + Path string `json:"path"` + Mode string `json:"mode"` +} + +// StatFileParams are parameters for stat_file. +type StatFileParams struct { + Path string `json:"path"` +} + +// GlobFilesParams are parameters for glob_files. +type GlobFilesParams struct { + Pattern string `json:"pattern"` +} + +// GlobFilesResult is returned by glob_files. +type GlobFilesResult struct { + Matches []string `json:"matches"` +} diff --git a/internal/api/routes/sandboxes.go b/internal/api/routes/sandboxes.go index fe62d1e..cebf3e3 100644 --- a/internal/api/routes/sandboxes.go +++ b/internal/api/routes/sandboxes.go @@ -35,7 +35,12 @@ func (s *SandboxRoutes) Routes() chi.Router { r.Get("/exec/ws", s.ExecWebSocket) r.Post("/files", s.WriteFile) r.Get("/files", s.ReadFile) + r.Delete("/files", s.DeleteFile) r.Get("/files/list", s.ListFiles) + r.Post("/files/move", s.MoveFile) + r.Post("/files/chmod", s.ChmodFile) + r.Get("/files/stat", s.StatFile) + r.Get("/files/glob", s.GlobFiles) r.Get("/logs", s.ConsoleLog) }) return r @@ -61,6 +66,11 @@ func (s *SandboxRoutes) Create(w http.ResponseWriter, r *http.Request) { return } + // Extract owner from X-User-ID header if present. + if userID := r.Header.Get("X-User-ID"); userID != "" { + req.OwnerID = userID + } + sb, err := s.manager.Spawn(r.Context(), req) if err != nil { httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, err.Error()) @@ -361,6 +371,126 @@ func (s *SandboxRoutes) ListFiles(w http.ResponseWriter, r *http.Request) { httputil.WriteJSON(w, http.StatusOK, files) } +// DeleteFile deletes a file from a sandbox. +func (s *SandboxRoutes) DeleteFile(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "sandboxID") + path := r.URL.Query().Get("path") + if path == "" { + httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, "path query parameter required") + return + } + + recursive := r.URL.Query().Get("recursive") == "true" + + if err := s.manager.DeleteFile(r.Context(), id, orchestrator.FileDeleteRequest{ + Path: path, + Recursive: recursive, + }); err != nil { + if strings.Contains(err.Error(), "not found") { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, err.Error()) + return + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, err.Error()) + return + } + httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +// MoveFile moves/renames a file in a sandbox. +func (s *SandboxRoutes) MoveFile(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "sandboxID") + var req orchestrator.FileMoveRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, "invalid request body") + return + } + + if err := s.manager.MoveFile(r.Context(), id, req); err != nil { + if strings.Contains(err.Error(), "not found") { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, err.Error()) + return + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, err.Error()) + return + } + httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "moved"}) +} + +// ChmodFile changes file permissions in a sandbox. +func (s *SandboxRoutes) ChmodFile(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "sandboxID") + var req orchestrator.FileChmodRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, "invalid request body") + return + } + + if err := s.manager.ChmodFile(r.Context(), id, req); err != nil { + if strings.Contains(err.Error(), "not found") { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, err.Error()) + return + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, err.Error()) + return + } + httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "chmod applied"}) +} + +// StatFile returns file info for a single file in a sandbox. +func (s *SandboxRoutes) StatFile(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "sandboxID") + path := r.URL.Query().Get("path") + if path == "" { + httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, "path query parameter required") + return + } + + fi, err := s.manager.StatFile(r.Context(), id, path) + if err != nil { + if strings.Contains(err.Error(), "not found") { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, err.Error()) + return + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, err.Error()) + return + } + httputil.WriteJSON(w, http.StatusOK, fi) +} + +// GlobFiles returns paths matching a glob pattern in a sandbox. +func (s *SandboxRoutes) GlobFiles(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "sandboxID") + pattern := r.URL.Query().Get("pattern") + if pattern == "" { + httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, "pattern query parameter required") + return + } + + matches, err := s.manager.GlobFiles(r.Context(), id, pattern) + if err != nil { + if strings.Contains(err.Error(), "not found") { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, err.Error()) + return + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, err.Error()) + return + } + httputil.WriteJSON(w, http.StatusOK, matches) +} + +// PoolStatusResponse is returned by the pool status endpoint. +type PoolStatusResponse = orchestrator.VMPoolStatus + +// VMPoolStatusHandler returns the VM pool status. +func (s *SandboxRoutes) VMPoolStatus(w http.ResponseWriter, r *http.Request) { + status := s.manager.VMPoolStatus() + if status == nil { + httputil.WriteJSON(w, http.StatusOK, map[string]bool{"enabled": false}) + return + } + httputil.WriteJSON(w, http.StatusOK, status) +} + // Prune destroys all expired sandboxes. // // @Summary Prune sandboxes diff --git a/internal/api/routes/sandboxes_test.go b/internal/api/routes/sandboxes_test.go index 63ad3fc..78eb412 100644 --- a/internal/api/routes/sandboxes_test.go +++ b/internal/api/routes/sandboxes_test.go @@ -243,6 +243,269 @@ func TestExtendSandbox_NotFound(t *testing.T) { } } +func createTestSandbox(t *testing.T, r chi.Router) string { + t.Helper() + body := `{"image":"alpine:latest"}` + req := httptest.NewRequest("POST", "/api/v1/sandboxes", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create sandbox: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var sb orchestrator.Sandbox + json.NewDecoder(w.Body).Decode(&sb) + return sb.ID +} + +func TestDeleteFile(t *testing.T) { + r, _ := setupTestRouter(t) + sbID := createTestSandbox(t, r) + + // Write a file + writeBody := `{"path":"/workspace/todelete.txt","content":"bye"}` + req := httptest.NewRequest("POST", "/api/v1/sandboxes/"+sbID+"/files", bytes.NewBufferString(writeBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("write: expected 200, got %d", w.Code) + } + + // Delete the file + req = httptest.NewRequest("DELETE", "/api/v1/sandboxes/"+sbID+"/files?path=/workspace/todelete.txt", nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("delete: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify file is gone (read should fail) + req = httptest.NewRequest("GET", "/api/v1/sandboxes/"+sbID+"/files?path=/workspace/todelete.txt", nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code == http.StatusOK { + t.Fatal("expected file to be deleted, but read succeeded") + } +} + +func TestDeleteFile_Recursive(t *testing.T) { + r, _ := setupTestRouter(t) + sbID := createTestSandbox(t, r) + + // Write files in a subdirectory + writeBody := `{"path":"/workspace/subdir/nested.txt","content":"nested"}` + req := httptest.NewRequest("POST", "/api/v1/sandboxes/"+sbID+"/files", bytes.NewBufferString(writeBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("write: expected 200, got %d", w.Code) + } + + // Delete recursively + req = httptest.NewRequest("DELETE", "/api/v1/sandboxes/"+sbID+"/files?path=/workspace/subdir&recursive=true", nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("delete recursive: expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestMoveFile(t *testing.T) { + r, _ := setupTestRouter(t) + sbID := createTestSandbox(t, r) + + // Write a file + writeBody := `{"path":"/workspace/original.txt","content":"move me"}` + req := httptest.NewRequest("POST", "/api/v1/sandboxes/"+sbID+"/files", bytes.NewBufferString(writeBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // Move the file + moveBody := `{"old_path":"/workspace/original.txt","new_path":"/workspace/moved.txt"}` + req = httptest.NewRequest("POST", "/api/v1/sandboxes/"+sbID+"/files/move", bytes.NewBufferString(moveBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("move: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Read from new location + req = httptest.NewRequest("GET", "/api/v1/sandboxes/"+sbID+"/files?path=/workspace/moved.txt", nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("read moved: expected 200, got %d", w.Code) + } + if w.Body.String() != "move me" { + t.Fatalf("expected 'move me', got %q", w.Body.String()) + } + + // Old path should be gone + req = httptest.NewRequest("GET", "/api/v1/sandboxes/"+sbID+"/files?path=/workspace/original.txt", nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code == http.StatusOK { + t.Fatal("expected old file to be gone after move") + } +} + +func TestChmodFile(t *testing.T) { + r, _ := setupTestRouter(t) + sbID := createTestSandbox(t, r) + + // Write a file + writeBody := `{"path":"/workspace/script.sh","content":"#!/bin/sh\necho hi"}` + req := httptest.NewRequest("POST", "/api/v1/sandboxes/"+sbID+"/files", bytes.NewBufferString(writeBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // Chmod + chmodBody := `{"path":"/workspace/script.sh","mode":"0755"}` + req = httptest.NewRequest("POST", "/api/v1/sandboxes/"+sbID+"/files/chmod", bytes.NewBufferString(chmodBody)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("chmod: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify via stat + req = httptest.NewRequest("GET", "/api/v1/sandboxes/"+sbID+"/files/stat?path=/workspace/script.sh", nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("stat: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var fi orchestrator.FileInfo + json.NewDecoder(w.Body).Decode(&fi) + // The mock provider uses fmt.Sprintf("%o", mode) which includes the file type bits + if fi.Mode == "" { + t.Fatal("expected non-empty mode from stat") + } +} + +func TestStatFile(t *testing.T) { + r, _ := setupTestRouter(t) + sbID := createTestSandbox(t, r) + + // Write a file + writeBody := `{"path":"/workspace/info.txt","content":"some data"}` + req := httptest.NewRequest("POST", "/api/v1/sandboxes/"+sbID+"/files", bytes.NewBufferString(writeBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // Stat + req = httptest.NewRequest("GET", "/api/v1/sandboxes/"+sbID+"/files/stat?path=/workspace/info.txt", nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("stat: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var fi orchestrator.FileInfo + json.NewDecoder(w.Body).Decode(&fi) + if fi.Path != "/workspace/info.txt" { + t.Fatalf("expected path '/workspace/info.txt', got %q", fi.Path) + } + if fi.Size != 9 { // "some data" = 9 bytes + t.Fatalf("expected size 9, got %d", fi.Size) + } + if fi.IsDir { + t.Fatal("expected is_dir=false") + } +} + +func TestStatFile_MissingPath(t *testing.T) { + r, _ := setupTestRouter(t) + sbID := createTestSandbox(t, r) + + req := httptest.NewRequest("GET", "/api/v1/sandboxes/"+sbID+"/files/stat", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestGlobFiles(t *testing.T) { + r, _ := setupTestRouter(t) + sbID := createTestSandbox(t, r) + + // Write some files + for _, name := range []string{"a.txt", "b.txt", "c.log"} { + writeBody := `{"path":"/workspace/` + name + `","content":"data"}` + req := httptest.NewRequest("POST", "/api/v1/sandboxes/"+sbID+"/files", bytes.NewBufferString(writeBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + } + + // Glob for .txt files + req := httptest.NewRequest("GET", "/api/v1/sandboxes/"+sbID+"/files/glob?pattern=/workspace/*.txt", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("glob: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var matches []string + json.NewDecoder(w.Body).Decode(&matches) + if len(matches) != 2 { + t.Fatalf("expected 2 .txt matches, got %d: %v", len(matches), matches) + } +} + +func TestGlobFiles_MissingPattern(t *testing.T) { + r, _ := setupTestRouter(t) + sbID := createTestSandbox(t, r) + + req := httptest.NewRequest("GET", "/api/v1/sandboxes/"+sbID+"/files/glob", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestDeleteFile_MissingPath(t *testing.T) { + r, _ := setupTestRouter(t) + sbID := createTestSandbox(t, r) + + req := httptest.NewRequest("DELETE", "/api/v1/sandboxes/"+sbID+"/files", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestCreateSandbox_WithOwnerID(t *testing.T) { + r, _ := setupTestRouter(t) + + body := `{"image":"alpine:latest"}` + req := httptest.NewRequest("POST", "/api/v1/sandboxes", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-ID", "alice") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var sb orchestrator.Sandbox + json.NewDecoder(w.Body).Decode(&sb) + if sb.OwnerID != "alice" { + t.Fatalf("expected owner_id 'alice', got %q", sb.OwnerID) + } +} + func TestWriteAndReadFile(t *testing.T) { r, _ := setupTestRouter(t) diff --git a/internal/api/server.go b/internal/api/server.go index 001aadf..01c626e 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -65,7 +65,7 @@ func NewServer(cfg ServerConfig, registry *providers.Registry, manager *orchestr return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key, X-Request-ID") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key, X-Request-ID, X-User-ID") if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return @@ -88,6 +88,7 @@ func NewServer(cfg ServerConfig, registry *providers.Registry, manager *orchestr r.Mount("/templates", templateRoutes.Routes()) r.Mount("/snapshots", snapshotRoutes.Routes()) r.Mount("/environments", environmentRoutes.Routes()) + r.Get("/pool/status", sandboxRoutes.VMPoolStatus) r.Mount("/", systemRoutes.Routes()) }) }) diff --git a/internal/config/config.go b/internal/config/config.go index 10961b6..784ce13 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "os/exec" "path/filepath" "strings" @@ -16,6 +17,17 @@ type Config struct { Auth AuthConfig `mapstructure:"auth"` Database DatabaseConfig `mapstructure:"database"` Logging LoggingConfig `mapstructure:"logging"` + Pool PoolConfig `mapstructure:"pool"` +} + +type PoolConfig struct { + Enabled bool `mapstructure:"enabled"` + MaxVMs int `mapstructure:"max_vms"` + MaxUsersPerVM int `mapstructure:"max_users_per_vm"` + Image string `mapstructure:"image"` + MemoryMB int `mapstructure:"memory_mb"` + VCPUs int `mapstructure:"vcpus"` + Overflow string `mapstructure:"overflow"` // "reject" or "queue" } type ServerConfig struct { @@ -122,12 +134,21 @@ func setDefaults(v *viper.Viper) { v.SetDefault("logging.level", "info") v.SetDefault("logging.format", "json") + + v.SetDefault("pool.enabled", false) + v.SetDefault("pool.max_vms", 10) + v.SetDefault("pool.max_users_per_vm", 5) + v.SetDefault("pool.image", "alpine:latest") + v.SetDefault("pool.memory_mb", 2048) + v.SetDefault("pool.vcpus", 2) + v.SetDefault("pool.overflow", "reject") } // ResolveAgentPath resolves the agent_path to an absolute path. // If the path is relative, it tries (in order): // 1. Relative to the executable's directory -// 2. Relative to CWD (fallback) +// 2. Relative to CWD +// 3. PATH lookup via exec.LookPath (for /usr/local/bin installs) func (c *Config) ResolveAgentPath() string { p := c.Providers.Firecracker.AgentPath if filepath.IsAbs(p) { @@ -140,12 +161,19 @@ func (c *Config) ResolveAgentPath() string { return candidate } } - // Fallback to CWD-relative - abs, err := filepath.Abs(p) - if err != nil { - return p + // Try CWD-relative + if abs, err := filepath.Abs(p); err == nil { + if _, err := os.Stat(abs); err == nil { + return abs + } + } + // Fallback: look up the binary name in PATH + base := filepath.Base(p) + if found, err := exec.LookPath(base); err == nil { + return found } - return abs + // Last resort: return as-is + return p } func Load() (*Config, error) { diff --git a/internal/orchestrator/manager.go b/internal/orchestrator/manager.go index c308a0f..80856f0 100644 --- a/internal/orchestrator/manager.go +++ b/internal/orchestrator/manager.go @@ -2,13 +2,16 @@ package orchestrator import ( "context" + "crypto/rand" "encoding/json" "fmt" "io" + "path/filepath" "strings" "sync" "time" + "github.com/DohaerisAI/forgevm/internal/config" "github.com/DohaerisAI/forgevm/internal/providers" "github.com/DohaerisAI/forgevm/internal/store" "github.com/rs/zerolog" @@ -28,6 +31,9 @@ type Manager struct { defaultMemory int defaultVCPUs int + vmPoolMgr *VMPoolManager + poolConfig config.PoolConfig + ctx context.Context cancel context.CancelFunc } @@ -37,6 +43,7 @@ type ManagerConfig struct { DefaultImage string DefaultMemory int DefaultVCPUs int + Pool config.PoolConfig } func NewManager(registry *providers.Registry, st store.Store, events *EventBus, logger zerolog.Logger, cfg ManagerConfig) *Manager { @@ -51,6 +58,7 @@ func NewManager(registry *providers.Registry, st store.Store, events *EventBus, defaultImage: cfg.DefaultImage, defaultMemory: cfg.DefaultMemory, defaultVCPUs: cfg.DefaultVCPUs, + poolConfig: cfg.Pool, ctx: ctx, cancel: cancel, } @@ -135,12 +143,19 @@ func (m *Manager) Spawn(ctx context.Context, req SpawnRequest) (*Sandbox, error) } now := time.Now() + + // Pool mode: acquire a VM slot instead of spawning a new VM. + if m.vmPoolMgr != nil { + return m.spawnPooled(ctx, prov, req, image, memMB, vcpus, ttl, now) + } + sb := &Sandbox{ State: StateCreating, Provider: prov.Name(), Image: image, MemoryMB: memMB, VCPUs: vcpus, + OwnerID: req.OwnerID, CreatedAt: now, ExpiresAt: now.Add(ttl), Metadata: req.Metadata, @@ -169,6 +184,8 @@ func (m *Manager) Spawn(ctx context.Context, req SpawnRequest) (*Sandbox, error) MemoryMB: sb.MemoryMB, VCPUs: sb.VCPUs, Metadata: string(metaJSON), + OwnerID: sb.OwnerID, + VMID: sb.VMID, CreatedAt: sb.CreatedAt, ExpiresAt: sb.ExpiresAt, UpdatedAt: now, @@ -195,24 +212,101 @@ func (m *Manager) Spawn(ctx context.Context, req SpawnRequest) (*Sandbox, error) return sb, nil } +// spawnPooled creates a logical sandbox on a shared pool VM. +func (m *Manager) spawnPooled(ctx context.Context, prov providers.Provider, req SpawnRequest, image string, memMB, vcpus int, ttl time.Duration, now time.Time) (*Sandbox, error) { + // Generate a logical sandbox ID (not tied to a VM). + b := make([]byte, 4) + rand.Read(b) + sandboxID := fmt.Sprintf("sb-%08x", b) + + // Acquire a VM slot (may spawn a new VM if needed). + vmID, err := m.vmPoolMgr.Acquire(ctx, sandboxID) + if err != nil { + return nil, fmt.Errorf("pool acquire: %w", err) + } + + sb := &Sandbox{ + ID: sandboxID, + State: StateRunning, + Provider: prov.Name(), + Image: image, + MemoryMB: memMB, + VCPUs: vcpus, + OwnerID: req.OwnerID, + VMID: vmID, + CreatedAt: now, + ExpiresAt: now.Add(ttl), + Metadata: req.Metadata, + } + + // Create the sandbox's isolated workspace on the VM. + workspaceDir := "/workspace/" + sandboxID + _, err = prov.Exec(ctx, vmID, providers.ExecOptions{ + Command: "mkdir -p " + workspaceDir, + }) + if err != nil { + m.vmPoolMgr.Release(vmID, sandboxID) + return nil, fmt.Errorf("creating workspace: %w", err) + } + + // Persist + metaJSON, _ := json.Marshal(sb.Metadata) + if err := m.store.CreateSandbox(ctx, &store.SandboxRecord{ + ID: sb.ID, + State: string(sb.State), + Provider: sb.Provider, + Image: sb.Image, + MemoryMB: sb.MemoryMB, + VCPUs: sb.VCPUs, + Metadata: string(metaJSON), + OwnerID: sb.OwnerID, + VMID: sb.VMID, + CreatedAt: sb.CreatedAt, + ExpiresAt: sb.ExpiresAt, + UpdatedAt: now, + }); err != nil { + m.vmPoolMgr.Release(vmID, sandboxID) + return nil, fmt.Errorf("persisting sandbox: %w", err) + } + + m.mu.Lock() + m.sandboxes[sandboxID] = sb + m.mu.Unlock() + + m.events.Publish(Event{Type: EventSandboxCreated, SandboxID: sandboxID}) + m.events.Publish(Event{Type: EventSandboxRunning, SandboxID: sandboxID}) + + m.logger.Info(). + Str("sandbox", sandboxID). + Str("vm_id", vmID). + Str("owner", req.OwnerID). + Msg("pooled sandbox spawned") + return sb, nil +} + func (m *Manager) Exec(ctx context.Context, sandboxID string, req ExecRequest) (*ExecResult, error) { sb, prov, err := m.getSandboxAndProvider(sandboxID) if err != nil { return nil, err } - _ = sb m.events.Publish(Event{ Type: EventExecStarted, SandboxID: sandboxID, }) + // In pool mode, default workdir to the sandbox's workspace. + workDir := req.WorkDir + if workDir == "" && sb.VMID != "" { + workDir = "/workspace/" + sandboxID + } + start := time.Now() - result, err := prov.Exec(ctx, sandboxID, providers.ExecOptions{ + result, err := prov.Exec(ctx, m.resolveVMID(sb), providers.ExecOptions{ Command: req.Command, Args: req.Args, Env: req.Env, - WorkDir: req.WorkDir, + WorkDir: workDir, }) if err != nil { return nil, fmt.Errorf("exec: %w", err) @@ -246,21 +340,26 @@ func (m *Manager) Exec(ctx context.Context, sandboxID string, req ExecRequest) ( } func (m *Manager) ExecStream(ctx context.Context, sandboxID string, req ExecRequest) (<-chan providers.StreamChunk, error) { - _, prov, err := m.getSandboxAndProvider(sandboxID) + sb, prov, err := m.getSandboxAndProvider(sandboxID) if err != nil { return nil, err } - return prov.ExecStream(ctx, sandboxID, providers.ExecOptions{ + workDir := req.WorkDir + if workDir == "" && sb.VMID != "" { + workDir = "/workspace/" + sandboxID + } + + return prov.ExecStream(ctx, m.resolveVMID(sb), providers.ExecOptions{ Command: req.Command, Args: req.Args, Env: req.Env, - WorkDir: req.WorkDir, + WorkDir: workDir, }) } func (m *Manager) WriteFile(ctx context.Context, sandboxID string, req FileWriteRequest) error { - _, prov, err := m.getSandboxAndProvider(sandboxID) + sb, prov, err := m.getSandboxAndProvider(sandboxID) if err != nil { return err } @@ -270,7 +369,8 @@ func (m *Manager) WriteFile(ctx context.Context, sandboxID string, req FileWrite mode = "0644" } - if err := prov.WriteFile(ctx, sandboxID, req.Path, strings.NewReader(req.Content), mode); err != nil { + path := m.scopedPath(sb, req.Path) + if err := prov.WriteFile(ctx, m.resolveVMID(sb), path, strings.NewReader(req.Content), mode); err != nil { return fmt.Errorf("writing file: %w", err) } @@ -282,12 +382,12 @@ func (m *Manager) WriteFile(ctx context.Context, sandboxID string, req FileWrite } func (m *Manager) ReadFile(ctx context.Context, sandboxID string, path string) ([]byte, error) { - _, prov, err := m.getSandboxAndProvider(sandboxID) + sb, prov, err := m.getSandboxAndProvider(sandboxID) if err != nil { return nil, err } - rc, err := prov.ReadFile(ctx, sandboxID, path) + rc, err := prov.ReadFile(ctx, m.resolveVMID(sb), m.scopedPath(sb, path)) if err != nil { return nil, fmt.Errorf("reading file: %w", err) } @@ -306,12 +406,12 @@ func (m *Manager) ReadFile(ctx context.Context, sandboxID string, path string) ( } func (m *Manager) ListFiles(ctx context.Context, sandboxID string, path string) ([]FileInfo, error) { - _, prov, err := m.getSandboxAndProvider(sandboxID) + sb, prov, err := m.getSandboxAndProvider(sandboxID) if err != nil { return nil, err } - pFiles, err := prov.ListFiles(ctx, sandboxID, path) + pFiles, err := prov.ListFiles(ctx, m.resolveVMID(sb), m.scopedPath(sb, path)) if err != nil { return nil, err } @@ -329,6 +429,176 @@ func (m *Manager) ListFiles(ctx context.Context, sandboxID string, path string) return files, nil } +func (m *Manager) DeleteFile(ctx context.Context, sandboxID string, req FileDeleteRequest) error { + sb, prov, err := m.getSandboxAndProvider(sandboxID) + if err != nil { + return err + } + + path := m.scopedPath(sb, req.Path) + return prov.DeleteFile(ctx, m.resolveVMID(sb), path, req.Recursive) +} + +func (m *Manager) MoveFile(ctx context.Context, sandboxID string, req FileMoveRequest) error { + sb, prov, err := m.getSandboxAndProvider(sandboxID) + if err != nil { + return err + } + + oldPath := m.scopedPath(sb, req.OldPath) + newPath := m.scopedPath(sb, req.NewPath) + return prov.MoveFile(ctx, m.resolveVMID(sb), oldPath, newPath) +} + +func (m *Manager) ChmodFile(ctx context.Context, sandboxID string, req FileChmodRequest) error { + sb, prov, err := m.getSandboxAndProvider(sandboxID) + if err != nil { + return err + } + + path := m.scopedPath(sb, req.Path) + return prov.ChmodFile(ctx, m.resolveVMID(sb), path, req.Mode) +} + +func (m *Manager) StatFile(ctx context.Context, sandboxID string, path string) (*FileInfo, error) { + sb, prov, err := m.getSandboxAndProvider(sandboxID) + if err != nil { + return nil, err + } + + scopedPath := m.scopedPath(sb, path) + fi, err := prov.StatFile(ctx, m.resolveVMID(sb), scopedPath) + if err != nil { + return nil, err + } + return &FileInfo{ + Path: fi.Path, + Size: fi.Size, + Mode: fi.Mode, + IsDir: fi.IsDir, + ModTime: fi.ModTime, + }, nil +} + +func (m *Manager) GlobFiles(ctx context.Context, sandboxID string, pattern string) ([]string, error) { + sb, prov, err := m.getSandboxAndProvider(sandboxID) + if err != nil { + return nil, err + } + + scopedPattern := m.scopedPath(sb, pattern) + return prov.GlobFiles(ctx, m.resolveVMID(sb), scopedPattern) +} + +// scopedPath prefixes a path with the sandbox workspace when running in pool mode. +func (m *Manager) scopedPath(sb *Sandbox, path string) string { + if sb.VMID == "" { + return path // dedicated VM, no scoping + } + base := "/workspace/" + sb.ID + cleaned := filepath.Clean(filepath.Join(base, path)) + if !strings.HasPrefix(cleaned, base+"/") && cleaned != base { + return base + } + return cleaned +} + +// resolveVMID returns the VM sandbox ID for provider calls. +// In pool mode, operations go to the VM (sb.VMID); in 1:1 mode, to the sandbox itself. +func (m *Manager) resolveVMID(sb *Sandbox) string { + if sb.VMID != "" { + return sb.VMID + } + return sb.ID +} + +// VMPoolStatus returns the current VM pool status. Returns nil if pool is disabled. +func (m *Manager) VMPoolStatus() *VMPoolStatus { + if m.vmPoolMgr == nil { + return nil + } + status := m.vmPoolMgr.Status() + return &status +} + +// InitVMPool initializes the VM pool manager if pool mode is enabled. +func (m *Manager) InitVMPool() { + if !m.poolConfig.Enabled { + return + } + // Use spawnDirect (bypasses pool check) to avoid infinite recursion. + m.vmPoolMgr = NewVMPoolManager(m.poolConfig, m.spawnDirect, m.logger) + m.logger.Info().Int("max_vms", m.poolConfig.MaxVMs).Int("max_users_per_vm", m.poolConfig.MaxUsersPerVM).Msg("VM pool mode enabled") +} + +// spawnDirect spawns a VM directly via the provider, bypassing pool logic. +// Used by the pool manager to create pool VMs without recursion. +func (m *Manager) spawnDirect(ctx context.Context, req SpawnRequest) (*Sandbox, error) { + providerName := req.Provider + prov, err := m.registry.Get(providerName) + if err != nil { + return nil, fmt.Errorf("getting provider: %w", err) + } + + image := req.Image + if image == "" { + image = m.defaultImage + } + memMB := req.MemoryMB + if memMB == 0 { + memMB = m.defaultMemory + } + vcpus := req.VCPUs + if vcpus == 0 { + vcpus = m.defaultVCPUs + } + + now := time.Now() + id, err := prov.Spawn(ctx, providers.SpawnOptions{ + Image: image, + MemoryMB: memMB, + VCPUs: vcpus, + }) + if err != nil { + return nil, fmt.Errorf("spawning VM: %w", err) + } + + sb := &Sandbox{ + ID: id, + State: StateRunning, + Provider: prov.Name(), + Image: image, + MemoryMB: memMB, + VCPUs: vcpus, + CreatedAt: now, + ExpiresAt: now.Add(m.defaultTTL), + } + + metaJSON, _ := json.Marshal(sb.Metadata) + if err := m.store.CreateSandbox(ctx, &store.SandboxRecord{ + ID: sb.ID, + State: string(sb.State), + Provider: sb.Provider, + Image: sb.Image, + MemoryMB: sb.MemoryMB, + VCPUs: sb.VCPUs, + Metadata: string(metaJSON), + CreatedAt: sb.CreatedAt, + ExpiresAt: sb.ExpiresAt, + UpdatedAt: now, + }); err != nil { + prov.Destroy(ctx, id) + return nil, fmt.Errorf("persisting pool VM: %w", err) + } + + m.mu.Lock() + m.sandboxes[id] = sb + m.mu.Unlock() + + m.logger.Info().Str("vm_id", id).Str("image", image).Msg("pool VM spawned") + return sb, nil +} + func (m *Manager) ConsoleLog(ctx context.Context, sandboxID string, lines int) ([]string, error) { _, prov, err := m.getSandboxAndProvider(sandboxID) if err != nil { @@ -383,6 +653,15 @@ func (m *Manager) List(ctx context.Context) ([]*Sandbox, error) { } func (m *Manager) Destroy(ctx context.Context, id string) error { + // Check if this is a pooled sandbox. + m.mu.RLock() + sb := m.sandboxes[id] + m.mu.RUnlock() + + if sb != nil && sb.VMID != "" && m.vmPoolMgr != nil { + return m.destroyPooled(ctx, id, sb) + } + prov, err := m.getProvider(id) if err != nil { // If we can't find the provider, just update the store @@ -415,6 +694,47 @@ func (m *Manager) Destroy(ctx context.Context, id string) error { return nil } +// destroyPooled cleans up a pooled sandbox's workspace and releases the VM slot. +func (m *Manager) destroyPooled(ctx context.Context, id string, sb *Sandbox) error { + vmID := sb.VMID + + // Clean up workspace on the VM. + prov, err := m.registry.Get(sb.Provider) + if err == nil { + workspaceDir := "/workspace/" + id + _ = prov.DeleteFile(ctx, vmID, workspaceDir, true) + } + + // Release the VM slot; if VM is now empty, destroy it. + shouldDestroy := m.vmPoolMgr.Release(vmID, id) + if shouldDestroy { + if prov != nil { + if err := prov.Destroy(ctx, vmID); err != nil { + m.logger.Debug().Err(err).Str("vm_id", vmID).Msg("pool VM destroy failed") + } + } + // Clean up the VM's sandbox record too. + m.store.DeleteSandbox(ctx, vmID) + m.mu.Lock() + delete(m.sandboxes, vmID) + m.mu.Unlock() + m.logger.Info().Str("vm_id", vmID).Msg("empty pool VM destroyed") + } + + // Clean up the logical sandbox record. + m.store.UpdateSandboxState(ctx, id, string(StateDestroyed)) + m.mu.Lock() + if cached, ok := m.sandboxes[id]; ok { + cached.State = StateDestroyed + } + delete(m.sandboxes, id) + m.mu.Unlock() + + m.events.Publish(Event{Type: EventSandboxDestroyed, SandboxID: id}) + m.logger.Info().Str("sandbox", id).Str("vm_id", vmID).Msg("pooled sandbox destroyed") + return nil +} + func (m *Manager) ExtendTTL(ctx context.Context, id string, extra time.Duration) (*Sandbox, error) { sb, err := m.Get(ctx, id) if err != nil { @@ -509,6 +829,8 @@ func recordToSandbox(r *store.SandboxRecord) *Sandbox { Image: r.Image, MemoryMB: r.MemoryMB, VCPUs: r.VCPUs, + OwnerID: r.OwnerID, + VMID: r.VMID, CreatedAt: r.CreatedAt, ExpiresAt: r.ExpiresAt, Metadata: metadata, diff --git a/internal/orchestrator/types.go b/internal/orchestrator/types.go index 1a0d9f1..fad6d04 100644 --- a/internal/orchestrator/types.go +++ b/internal/orchestrator/types.go @@ -19,6 +19,8 @@ type Sandbox struct { Image string `json:"image"` MemoryMB int `json:"memory_mb"` VCPUs int `json:"vcpus"` + OwnerID string `json:"owner_id,omitempty"` + VMID string `json:"vm_id,omitempty"` CreatedAt time.Time `json:"created_at"` ExpiresAt time.Time `json:"expires_at"` Metadata map[string]string `json:"metadata,omitempty"` @@ -31,6 +33,7 @@ type SpawnRequest struct { VCPUs int `json:"vcpus,omitempty"` TTL string `json:"ttl,omitempty"` Template string `json:"template,omitempty"` + OwnerID string `json:"owner_id,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` } @@ -64,6 +67,21 @@ type FileInfo struct { ModTime string `json:"mod_time"` } +type FileDeleteRequest struct { + Path string `json:"path"` + Recursive bool `json:"recursive"` +} + +type FileMoveRequest struct { + OldPath string `json:"old_path"` + NewPath string `json:"new_path"` +} + +type FileChmodRequest struct { + Path string `json:"path"` + Mode string `json:"mode"` +} + type SandboxInfo struct { ID string `json:"id"` State SandboxState `json:"state"` diff --git a/internal/orchestrator/vm_pool.go b/internal/orchestrator/vm_pool.go new file mode 100644 index 0000000..2d3d8e2 --- /dev/null +++ b/internal/orchestrator/vm_pool.go @@ -0,0 +1,142 @@ +package orchestrator + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/DohaerisAI/forgevm/internal/config" + "github.com/rs/zerolog" +) + +// ErrVMPoolFull is returned when the VM pool is at capacity and overflow is "reject". +var ErrVMPoolFull = errors.New("vm pool at capacity") + +// VMSlot tracks a single VM in the pool. +type VMSlot struct { + VMID string + Provider string + Image string + UserCount int + MaxUsers int + CreatedAt time.Time +} + +// VMPoolStatus is returned by the pool status endpoint. +type VMPoolStatus struct { + Enabled bool `json:"enabled"` + VMs int `json:"vms"` + MaxVMs int `json:"max_vms"` + TotalUsers int `json:"total_users"` + MaxUsersPerVM int `json:"max_users_per_vm"` +} + +// VMPoolManager handles multi-user VM pool scheduling. +type VMPoolManager struct { + mu sync.RWMutex + vms map[string]*VMSlot // vmID → slot + config config.PoolConfig + spawn func(ctx context.Context, req SpawnRequest) (*Sandbox, error) + logger zerolog.Logger +} + +// NewVMPoolManager creates a VM pool manager. The spawnFn is called to create new VMs. +func NewVMPoolManager(cfg config.PoolConfig, spawnFn func(ctx context.Context, req SpawnRequest) (*Sandbox, error), logger zerolog.Logger) *VMPoolManager { + return &VMPoolManager{ + vms: make(map[string]*VMSlot), + config: cfg, + spawn: spawnFn, + logger: logger.With().Str("component", "vm_pool").Logger(), + } +} + +// Acquire finds or creates a VM with capacity and returns the vmID. +func (p *VMPoolManager) Acquire(ctx context.Context, sandboxID string) (string, error) { + p.mu.Lock() + + // Find an existing VM with capacity. + for _, slot := range p.vms { + if slot.UserCount < slot.MaxUsers { + slot.UserCount++ + vmID := slot.VMID + p.mu.Unlock() + p.logger.Info().Str("vm_id", vmID).Str("sandbox_id", sandboxID).Int("users", slot.UserCount).Msg("assigned sandbox to existing VM") + return vmID, nil + } + } + + // No capacity — can we create a new VM? + if len(p.vms) >= p.config.MaxVMs { + p.mu.Unlock() + return "", ErrVMPoolFull + } + + p.mu.Unlock() + + // Spawn a new pool VM (outside lock to avoid deadlock). + sb, err := p.spawn(ctx, SpawnRequest{ + Image: p.config.Image, + MemoryMB: p.config.MemoryMB, + VCPUs: p.config.VCPUs, + }) + if err != nil { + return "", fmt.Errorf("spawn pool VM: %w", err) + } + + slot := &VMSlot{ + VMID: sb.ID, + Provider: sb.Provider, + Image: p.config.Image, + UserCount: 1, + MaxUsers: p.config.MaxUsersPerVM, + CreatedAt: time.Now(), + } + + p.mu.Lock() + p.vms[sb.ID] = slot + p.mu.Unlock() + + p.logger.Info().Str("vm_id", sb.ID).Str("sandbox_id", sandboxID).Msg("created new pool VM") + return sb.ID, nil +} + +// Release decrements the user count for a VM. Returns true if the VM is now empty and should be destroyed. +func (p *VMPoolManager) Release(vmID, sandboxID string) bool { + p.mu.Lock() + defer p.mu.Unlock() + + slot, ok := p.vms[vmID] + if !ok { + return false + } + + slot.UserCount-- + p.logger.Info().Str("vm_id", vmID).Str("sandbox_id", sandboxID).Int("remaining", slot.UserCount).Msg("released sandbox from pool VM") + + if slot.UserCount <= 0 { + delete(p.vms, vmID) + return true // caller should destroy the VM + } + return false +} + +// Status returns the current pool status. +func (p *VMPoolManager) Status() VMPoolStatus { + p.mu.RLock() + defer p.mu.RUnlock() + + totalUsers := 0 + for _, slot := range p.vms { + totalUsers += slot.UserCount + } + + return VMPoolStatus{ + Enabled: true, + VMs: len(p.vms), + MaxVMs: p.config.MaxVMs, + TotalUsers: totalUsers, + MaxUsersPerVM: p.config.MaxUsersPerVM, + } +} diff --git a/internal/providers/custom.go b/internal/providers/custom.go index fa430e5..1b451a7 100644 --- a/internal/providers/custom.go +++ b/internal/providers/custom.go @@ -315,6 +315,94 @@ func (p *CustomProvider) ListFiles(ctx context.Context, sandboxID string, path s return files, nil } +func (p *CustomProvider) DeleteFile(ctx context.Context, sandboxID string, path string, recursive bool) error { + body := map[string]interface{}{ + "sandbox_id": sandboxID, + "path": path, + "recursive": recursive, + } + data, code, err := p.doRequest(ctx, "DELETE", "/files", body) + if err != nil { + return fmt.Errorf("custom delete: %w", err) + } + if code >= 400 { + return fmt.Errorf("custom delete failed (HTTP %d): %s", code, string(data)) + } + return nil +} + +func (p *CustomProvider) MoveFile(ctx context.Context, sandboxID string, oldPath, newPath string) error { + body := map[string]interface{}{ + "sandbox_id": sandboxID, + "old_path": oldPath, + "new_path": newPath, + } + data, code, err := p.doRequest(ctx, "POST", "/files/move", body) + if err != nil { + return fmt.Errorf("custom move: %w", err) + } + if code >= 400 { + return fmt.Errorf("custom move failed (HTTP %d): %s", code, string(data)) + } + return nil +} + +func (p *CustomProvider) ChmodFile(ctx context.Context, sandboxID string, path string, mode string) error { + body := map[string]interface{}{ + "sandbox_id": sandboxID, + "path": path, + "mode": mode, + } + data, code, err := p.doRequest(ctx, "POST", "/files/chmod", body) + if err != nil { + return fmt.Errorf("custom chmod: %w", err) + } + if code >= 400 { + return fmt.Errorf("custom chmod failed (HTTP %d): %s", code, string(data)) + } + return nil +} + +func (p *CustomProvider) StatFile(ctx context.Context, sandboxID string, path string) (*FileInfo, error) { + params := url.Values{} + params.Set("sandbox_id", sandboxID) + params.Set("path", path) + + data, code, err := p.doRequest(ctx, "GET", "/files/stat?"+params.Encode(), nil) + if err != nil { + return nil, fmt.Errorf("custom stat: %w", err) + } + if code >= 400 { + return nil, fmt.Errorf("custom stat failed (HTTP %d): %s", code, string(data)) + } + + var fi FileInfo + if err := json.Unmarshal(data, &fi); err != nil { + return nil, fmt.Errorf("custom stat: invalid response: %w", err) + } + return &fi, nil +} + +func (p *CustomProvider) GlobFiles(ctx context.Context, sandboxID string, pattern string) ([]string, error) { + params := url.Values{} + params.Set("sandbox_id", sandboxID) + params.Set("pattern", pattern) + + data, code, err := p.doRequest(ctx, "GET", "/files/glob?"+params.Encode(), nil) + if err != nil { + return nil, fmt.Errorf("custom glob: %w", err) + } + if code >= 400 { + return nil, fmt.Errorf("custom glob failed (HTTP %d): %s", code, string(data)) + } + + var matches []string + if err := json.Unmarshal(data, &matches); err != nil { + return nil, fmt.Errorf("custom glob: invalid response: %w", err) + } + return matches, nil +} + // Status returns the current status of a sandbox. // GET /status/:id func (p *CustomProvider) Status(ctx context.Context, sandboxID string) (*SandboxStatus, error) { diff --git a/internal/providers/e2b.go b/internal/providers/e2b.go index 2cd0a17..66d1a0d 100644 --- a/internal/providers/e2b.go +++ b/internal/providers/e2b.go @@ -188,6 +188,26 @@ func (p *E2BProvider) ListFiles(ctx context.Context, sandboxID string, path stri return result, nil } +func (p *E2BProvider) DeleteFile(ctx context.Context, sandboxID string, path string, recursive bool) error { + return fmt.Errorf("e2b: delete_file not supported") +} + +func (p *E2BProvider) MoveFile(ctx context.Context, sandboxID string, oldPath, newPath string) error { + return fmt.Errorf("e2b: move_file not supported") +} + +func (p *E2BProvider) ChmodFile(ctx context.Context, sandboxID string, path string, mode string) error { + return fmt.Errorf("e2b: chmod_file not supported") +} + +func (p *E2BProvider) StatFile(ctx context.Context, sandboxID string, path string) (*FileInfo, error) { + return nil, fmt.Errorf("e2b: stat_file not supported") +} + +func (p *E2BProvider) GlobFiles(ctx context.Context, sandboxID string, pattern string) ([]string, error) { + return nil, fmt.Errorf("e2b: glob_files not supported") +} + func (p *E2BProvider) Status(ctx context.Context, sandboxID string) (*SandboxStatus, error) { data, code, err := p.doRequest(ctx, "GET", fmt.Sprintf("/sandboxes/%s", sandboxID), nil) if err != nil { diff --git a/internal/providers/firecracker.go b/internal/providers/firecracker.go index ef070ae..724f5d4 100644 --- a/internal/providers/firecracker.go +++ b/internal/providers/firecracker.go @@ -783,6 +783,129 @@ func (p *FirecrackerProvider) ListFiles(ctx context.Context, sandboxID string, p return files, nil } +func (p *FirecrackerProvider) DeleteFile(ctx context.Context, sandboxID string, path string, recursive bool) error { + vm, err := p.getVM(sandboxID) + if err != nil { + return err + } + + params, _ := agentproto.MarshalParams(&agentproto.DeleteFileParams{Path: path, Recursive: recursive}) + resp, err := vm.sendRequest(&agentproto.Request{ + ID: generateRequestID(), + Method: agentproto.MethodDeleteFile, + Params: params, + }) + if err != nil { + return fmt.Errorf("agent delete_file: %w", err) + } + if resp.Error != "" { + return fmt.Errorf("agent delete_file: %s", resp.Error) + } + return nil +} + +func (p *FirecrackerProvider) MoveFile(ctx context.Context, sandboxID string, oldPath, newPath string) error { + vm, err := p.getVM(sandboxID) + if err != nil { + return err + } + + params, _ := agentproto.MarshalParams(&agentproto.MoveFileParams{OldPath: oldPath, NewPath: newPath}) + resp, err := vm.sendRequest(&agentproto.Request{ + ID: generateRequestID(), + Method: agentproto.MethodMoveFile, + Params: params, + }) + if err != nil { + return fmt.Errorf("agent move_file: %w", err) + } + if resp.Error != "" { + return fmt.Errorf("agent move_file: %s", resp.Error) + } + return nil +} + +func (p *FirecrackerProvider) ChmodFile(ctx context.Context, sandboxID string, path string, mode string) error { + vm, err := p.getVM(sandboxID) + if err != nil { + return err + } + + params, _ := agentproto.MarshalParams(&agentproto.ChmodFileParams{Path: path, Mode: mode}) + resp, err := vm.sendRequest(&agentproto.Request{ + ID: generateRequestID(), + Method: agentproto.MethodChmodFile, + Params: params, + }) + if err != nil { + return fmt.Errorf("agent chmod_file: %w", err) + } + if resp.Error != "" { + return fmt.Errorf("agent chmod_file: %s", resp.Error) + } + return nil +} + +func (p *FirecrackerProvider) StatFile(ctx context.Context, sandboxID string, path string) (*FileInfo, error) { + vm, err := p.getVM(sandboxID) + if err != nil { + return nil, err + } + + params, _ := agentproto.MarshalParams(&agentproto.StatFileParams{Path: path}) + resp, err := vm.sendRequest(&agentproto.Request{ + ID: generateRequestID(), + Method: agentproto.MethodStatFile, + Params: params, + }) + if err != nil { + return nil, fmt.Errorf("agent stat_file: %w", err) + } + if resp.Error != "" { + return nil, fmt.Errorf("agent stat_file: %s", resp.Error) + } + + var result agentproto.FileInfoResult + if err := agentproto.UnmarshalResult(resp.Result, &result); err != nil { + return nil, fmt.Errorf("unmarshal stat_file result: %w", err) + } + + return &FileInfo{ + Path: result.Path, + Size: result.Size, + Mode: result.Mode, + IsDir: result.IsDir, + ModTime: result.ModTime, + }, nil +} + +func (p *FirecrackerProvider) GlobFiles(ctx context.Context, sandboxID string, pattern string) ([]string, error) { + vm, err := p.getVM(sandboxID) + if err != nil { + return nil, err + } + + params, _ := agentproto.MarshalParams(&agentproto.GlobFilesParams{Pattern: pattern}) + resp, err := vm.sendRequest(&agentproto.Request{ + ID: generateRequestID(), + Method: agentproto.MethodGlobFiles, + Params: params, + }) + if err != nil { + return nil, fmt.Errorf("agent glob_files: %w", err) + } + if resp.Error != "" { + return nil, fmt.Errorf("agent glob_files: %s", resp.Error) + } + + var result agentproto.GlobFilesResult + if err := agentproto.UnmarshalResult(resp.Result, &result); err != nil { + return nil, fmt.Errorf("unmarshal glob_files result: %w", err) + } + + return result.Matches, nil +} + func (p *FirecrackerProvider) Status(ctx context.Context, sandboxID string) (*SandboxStatus, error) { vm, err := p.getVM(sandboxID) if err != nil { diff --git a/internal/providers/mock.go b/internal/providers/mock.go index 436b0ca..6b42600 100644 --- a/internal/providers/mock.go +++ b/internal/providers/mock.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "sync" ) @@ -260,6 +261,94 @@ func (m *MockProvider) ListFiles(ctx context.Context, sandboxID string, path str return files, nil } +func (m *MockProvider) DeleteFile(ctx context.Context, sandboxID string, path string, recursive bool) error { + sb, err := m.getSandbox(sandboxID) + if err != nil { + return err + } + + fullPath := filepath.Join(sb.root, filepath.Clean(path)) + if recursive { + return os.RemoveAll(fullPath) + } + return os.Remove(fullPath) +} + +func (m *MockProvider) MoveFile(ctx context.Context, sandboxID string, oldPath, newPath string) error { + sb, err := m.getSandbox(sandboxID) + if err != nil { + return err + } + + fullOld := filepath.Join(sb.root, filepath.Clean(oldPath)) + fullNew := filepath.Join(sb.root, filepath.Clean(newPath)) + + if err := os.MkdirAll(filepath.Dir(fullNew), 0755); err != nil { + return fmt.Errorf("creating dirs: %w", err) + } + + return os.Rename(fullOld, fullNew) +} + +func (m *MockProvider) ChmodFile(ctx context.Context, sandboxID string, path string, mode string) error { + sb, err := m.getSandbox(sandboxID) + if err != nil { + return err + } + + fullPath := filepath.Join(sb.root, filepath.Clean(path)) + + parsed, err := strconv.ParseUint(mode, 8, 32) + if err != nil { + return fmt.Errorf("parse mode: %w", err) + } + + return os.Chmod(fullPath, os.FileMode(parsed)) +} + +func (m *MockProvider) StatFile(ctx context.Context, sandboxID string, path string) (*FileInfo, error) { + sb, err := m.getSandbox(sandboxID) + if err != nil { + return nil, err + } + + fullPath := filepath.Join(sb.root, filepath.Clean(path)) + info, err := os.Stat(fullPath) + if err != nil { + return nil, fmt.Errorf("stat: %w", err) + } + + return &FileInfo{ + Path: path, + Size: info.Size(), + Mode: fmt.Sprintf("%o", info.Mode()), + IsDir: info.IsDir(), + ModTime: info.ModTime().UTC().Format("2006-01-02T15:04:05Z"), + }, nil +} + +func (m *MockProvider) GlobFiles(ctx context.Context, sandboxID string, pattern string) ([]string, error) { + sb, err := m.getSandbox(sandboxID) + if err != nil { + return nil, err + } + + fullPattern := filepath.Join(sb.root, filepath.Clean(pattern)) + matches, err := filepath.Glob(fullPattern) + if err != nil { + return nil, fmt.Errorf("glob: %w", err) + } + + // Strip sandbox root prefix to return sandbox-relative paths + result := make([]string, len(matches)) + for i, match := range matches { + rel, _ := filepath.Rel(sb.root, match) + result[i] = "/" + rel + } + + return result, nil +} + func (m *MockProvider) Status(ctx context.Context, sandboxID string) (*SandboxStatus, error) { m.mu.RLock() defer m.mu.RUnlock() diff --git a/internal/providers/provider.go b/internal/providers/provider.go index 3267b3e..dd6aef6 100644 --- a/internal/providers/provider.go +++ b/internal/providers/provider.go @@ -68,6 +68,21 @@ type Provider interface { // ListFiles lists files at the given path in the sandbox. ListFiles(ctx context.Context, sandboxID string, path string) ([]FileInfo, error) + // DeleteFile deletes a file or directory in the sandbox. + DeleteFile(ctx context.Context, sandboxID string, path string, recursive bool) error + + // MoveFile moves/renames a file in the sandbox. + MoveFile(ctx context.Context, sandboxID string, oldPath, newPath string) error + + // ChmodFile changes file permissions in the sandbox. + ChmodFile(ctx context.Context, sandboxID string, path string, mode string) error + + // StatFile returns info about a single file in the sandbox. + StatFile(ctx context.Context, sandboxID string, path string) (*FileInfo, error) + + // GlobFiles returns paths matching a glob pattern in the sandbox. + GlobFiles(ctx context.Context, sandboxID string, pattern string) ([]string, error) + // Status returns the current status of a sandbox. Status(ctx context.Context, sandboxID string) (*SandboxStatus, error) diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 6f4f951..031ef50 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -134,6 +134,15 @@ CREATE TABLE IF NOT EXISTS registry_connections ( CREATE INDEX IF NOT EXISTS idx_registry_connections_owner ON registry_connections(owner_id); CREATE INDEX IF NOT EXISTS idx_registry_connections_provider ON registry_connections(provider); +`, + }, + { + version: 4, + sql: ` +ALTER TABLE sandboxes ADD COLUMN owner_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE sandboxes ADD COLUMN vm_id TEXT NOT NULL DEFAULT ''; +CREATE INDEX IF NOT EXISTS idx_sandboxes_owner ON sandboxes(owner_id); +CREATE INDEX IF NOT EXISTS idx_sandboxes_vm ON sandboxes(vm_id); `, }, } diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index a225c93..b1f2ae0 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -77,9 +77,10 @@ func (s *SQLiteStore) migrate() error { func (s *SQLiteStore) CreateSandbox(ctx context.Context, sb *SandboxRecord) error { _, err := s.db.ExecContext(ctx, ` - INSERT INTO sandboxes (id, state, provider, image, memory_mb, vcpus, metadata, created_at, expires_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + INSERT INTO sandboxes (id, state, provider, image, memory_mb, vcpus, metadata, owner_id, vm_id, created_at, expires_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, sb.ID, sb.State, sb.Provider, sb.Image, sb.MemoryMB, sb.VCPUs, sb.Metadata, + sb.OwnerID, sb.VMID, sb.CreatedAt.UTC(), sb.ExpiresAt.UTC(), sb.UpdatedAt.UTC(), ) return err @@ -88,10 +89,10 @@ func (s *SQLiteStore) CreateSandbox(ctx context.Context, sb *SandboxRecord) erro func (s *SQLiteStore) GetSandbox(ctx context.Context, id string) (*SandboxRecord, error) { sb := &SandboxRecord{} err := s.db.QueryRowContext(ctx, ` - SELECT id, state, provider, image, memory_mb, vcpus, metadata, created_at, expires_at, updated_at + SELECT id, state, provider, image, memory_mb, vcpus, metadata, owner_id, vm_id, created_at, expires_at, updated_at FROM sandboxes WHERE id = ?`, id, ).Scan(&sb.ID, &sb.State, &sb.Provider, &sb.Image, &sb.MemoryMB, &sb.VCPUs, - &sb.Metadata, &sb.CreatedAt, &sb.ExpiresAt, &sb.UpdatedAt) + &sb.Metadata, &sb.OwnerID, &sb.VMID, &sb.CreatedAt, &sb.ExpiresAt, &sb.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("sandbox %q not found", id) } @@ -100,7 +101,7 @@ func (s *SQLiteStore) GetSandbox(ctx context.Context, id string) (*SandboxRecord func (s *SQLiteStore) ListSandboxes(ctx context.Context) ([]*SandboxRecord, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT id, state, provider, image, memory_mb, vcpus, metadata, created_at, expires_at, updated_at + SELECT id, state, provider, image, memory_mb, vcpus, metadata, owner_id, vm_id, created_at, expires_at, updated_at FROM sandboxes WHERE state != 'destroyed' ORDER BY created_at DESC`) if err != nil { return nil, err @@ -111,7 +112,7 @@ func (s *SQLiteStore) ListSandboxes(ctx context.Context) ([]*SandboxRecord, erro for rows.Next() { sb := &SandboxRecord{} if err := rows.Scan(&sb.ID, &sb.State, &sb.Provider, &sb.Image, &sb.MemoryMB, &sb.VCPUs, - &sb.Metadata, &sb.CreatedAt, &sb.ExpiresAt, &sb.UpdatedAt); err != nil { + &sb.Metadata, &sb.OwnerID, &sb.VMID, &sb.CreatedAt, &sb.ExpiresAt, &sb.UpdatedAt); err != nil { return nil, err } sandboxes = append(sandboxes, sb) @@ -155,7 +156,7 @@ func (s *SQLiteStore) DeleteSandbox(ctx context.Context, id string) error { func (s *SQLiteStore) ListExpiredSandboxes(ctx context.Context, before time.Time) ([]*SandboxRecord, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT id, state, provider, image, memory_mb, vcpus, metadata, created_at, expires_at, updated_at + SELECT id, state, provider, image, memory_mb, vcpus, metadata, owner_id, vm_id, created_at, expires_at, updated_at FROM sandboxes WHERE state NOT IN ('destroyed') AND expires_at < ?`, before.UTC(), ) @@ -168,7 +169,7 @@ func (s *SQLiteStore) ListExpiredSandboxes(ctx context.Context, before time.Time for rows.Next() { sb := &SandboxRecord{} if err := rows.Scan(&sb.ID, &sb.State, &sb.Provider, &sb.Image, &sb.MemoryMB, &sb.VCPUs, - &sb.Metadata, &sb.CreatedAt, &sb.ExpiresAt, &sb.UpdatedAt); err != nil { + &sb.Metadata, &sb.OwnerID, &sb.VMID, &sb.CreatedAt, &sb.ExpiresAt, &sb.UpdatedAt); err != nil { return nil, err } sandboxes = append(sandboxes, sb) @@ -176,6 +177,34 @@ func (s *SQLiteStore) ListExpiredSandboxes(ctx context.Context, before time.Time return sandboxes, rows.Err() } +func (s *SQLiteStore) ListSandboxesByOwner(ctx context.Context, ownerID string) ([]*SandboxRecord, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, state, provider, image, memory_mb, vcpus, metadata, owner_id, vm_id, created_at, expires_at, updated_at + FROM sandboxes WHERE state != 'destroyed' AND owner_id = ? ORDER BY created_at DESC`, ownerID) + if err != nil { + return nil, err + } + defer rows.Close() + + var sandboxes []*SandboxRecord + for rows.Next() { + sb := &SandboxRecord{} + if err := rows.Scan(&sb.ID, &sb.State, &sb.Provider, &sb.Image, &sb.MemoryMB, &sb.VCPUs, + &sb.Metadata, &sb.OwnerID, &sb.VMID, &sb.CreatedAt, &sb.ExpiresAt, &sb.UpdatedAt); err != nil { + return nil, err + } + sandboxes = append(sandboxes, sb) + } + return sandboxes, rows.Err() +} + +func (s *SQLiteStore) CountSandboxesByVM(ctx context.Context, vmID string) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM sandboxes WHERE state != 'destroyed' AND vm_id = ?`, vmID).Scan(&count) + return count, err +} + // --- Exec Logs --- func (s *SQLiteStore) CreateExecLog(ctx context.Context, log *ExecLogRecord) error { diff --git a/internal/store/store.go b/internal/store/store.go index 452b3bb..d11bf37 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -13,6 +13,8 @@ type SandboxRecord struct { MemoryMB int VCPUs int Metadata string // JSON + OwnerID string + VMID string CreatedAt time.Time ExpiresAt time.Time UpdatedAt time.Time @@ -112,6 +114,8 @@ type Store interface { UpdateSandboxExpiresAt(ctx context.Context, id string, expiresAt time.Time) error DeleteSandbox(ctx context.Context, id string) error ListExpiredSandboxes(ctx context.Context, before time.Time) ([]*SandboxRecord, error) + ListSandboxesByOwner(ctx context.Context, ownerID string) ([]*SandboxRecord, error) + CountSandboxesByVM(ctx context.Context, vmID string) (int, error) // Exec logs CreateExecLog(ctx context.Context, log *ExecLogRecord) error diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..ec4a073 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,182 @@ +#!/bin/bash +# ForgeVM Installer — download pre-built binaries and get running in one command +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/DohaerisAI/forgevm/main/scripts/install.sh | bash +# +# Environment variables: +# FORGEVM_VERSION — specific version to install (default: latest) +# FORGEVM_INSTALL_DIR — where to put binaries (default: /usr/local/bin) +# +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BOLD='\033[1m' +NC='\033[0m' + +info() { echo -e "${GREEN}[+]${NC} $1"; } +warn() { echo -e "${YELLOW}[!]${NC} $1"; } +fail() { echo -e "${RED}[x]${NC} $1"; exit 1; } + +INSTALL_DIR="${FORGEVM_INSTALL_DIR:-/usr/local/bin}" +DATA_DIR="/var/lib/forgevm" +KERNEL_URL="https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/x86_64/kernels/vmlinux.bin" +REPO="DohaerisAI/forgevm" + +echo "" +echo " ╔═══════════════════════════════════╗" +echo " ║ ForgeVM Installer ║" +echo " ╚═══════════════════════════════════╝" +echo "" + +# ── Check OS + arch ────────────────────────────────────── +if [[ "$(uname -s)" != "Linux" ]]; then + fail "ForgeVM requires Linux. You're on $(uname -s)." +fi + +ARCH=$(uname -m) +if [[ "$ARCH" != "x86_64" ]]; then + fail "ForgeVM requires x86_64. You're on $ARCH." +fi + +# ── Detect version ─────────────────────────────────────── +if [[ -n "${FORGEVM_VERSION:-}" ]]; then + VERSION="$FORGEVM_VERSION" + info "Using specified version: $VERSION" +else + info "Detecting latest version..." + VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | cut -d'"' -f4) + if [[ -z "$VERSION" ]]; then + fail "Could not detect latest version. Set FORGEVM_VERSION manually." + fi + info "Latest version: $VERSION" +fi + +RELEASE_URL="https://github.com/${REPO}/releases/download/${VERSION}" + +# ── Download binaries ──────────────────────────────────── +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +info "Downloading forgevm ${VERSION}..." +curl -fSL -o "$TMPDIR/forgevm" "${RELEASE_URL}/forgevm-linux-amd64" +curl -fSL -o "$TMPDIR/forgevm-agent" "${RELEASE_URL}/forgevm-agent-linux-amd64" +curl -fSL -o "$TMPDIR/checksums.txt" "${RELEASE_URL}/checksums.txt" + +# ── Verify checksums ──────────────────────────────────── +info "Verifying checksums..." +cd "$TMPDIR" +# checksums.txt has names like forgevm-linux-amd64, rename for verification +sha256sum -c checksums.txt --ignore-missing 2>/dev/null || { + # Try manual verification with renamed files + EXPECTED_VM=$(grep "forgevm-linux-amd64" checksums.txt | awk '{print $1}') + EXPECTED_AGENT=$(grep "forgevm-agent-linux-amd64" checksums.txt | awk '{print $1}') + ACTUAL_VM=$(sha256sum forgevm | awk '{print $1}') + ACTUAL_AGENT=$(sha256sum forgevm-agent | awk '{print $1}') + + if [[ "$EXPECTED_VM" != "$ACTUAL_VM" ]]; then + fail "Checksum mismatch for forgevm binary!" + fi + if [[ "$EXPECTED_AGENT" != "$ACTUAL_AGENT" ]]; then + fail "Checksum mismatch for forgevm-agent binary!" + fi +} +info "Checksums verified" +cd - > /dev/null + +# ── Install binaries ───────────────────────────────────── +info "Installing to ${INSTALL_DIR}..." +chmod +x "$TMPDIR/forgevm" "$TMPDIR/forgevm-agent" + +if [[ -w "$INSTALL_DIR" ]]; then + cp "$TMPDIR/forgevm" "$INSTALL_DIR/forgevm" + cp "$TMPDIR/forgevm-agent" "$INSTALL_DIR/forgevm-agent" +else + sudo cp "$TMPDIR/forgevm" "$INSTALL_DIR/forgevm" + sudo cp "$TMPDIR/forgevm-agent" "$INSTALL_DIR/forgevm-agent" +fi +info "Installed forgevm and forgevm-agent to ${INSTALL_DIR}" + +# ── Install Firecracker if missing ─────────────────────── +if command -v firecracker &>/dev/null; then + FC_VER=$(firecracker --version 2>&1 | head -1) + info "Firecracker already installed: $FC_VER" +else + warn "Firecracker not found. Installing latest release..." + FC_VERSION=$(curl -fsSL https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest | grep '"tag_name"' | cut -d'"' -f4) + if [[ -z "$FC_VERSION" ]]; then + warn "Could not fetch Firecracker version. Install manually: https://github.com/firecracker-microvm/firecracker/releases" + else + FC_ARCH="x86_64" + FC_URL="https://github.com/firecracker-microvm/firecracker/releases/download/${FC_VERSION}/firecracker-${FC_VERSION}-${FC_ARCH}.tgz" + FC_TMP=$(mktemp -d) + curl -fSL -o "$FC_TMP/firecracker.tgz" "$FC_URL" + tar -xzf "$FC_TMP/firecracker.tgz" -C "$FC_TMP" + FC_BIN=$(find "$FC_TMP" -name "firecracker-${FC_VERSION}-${FC_ARCH}" -type f | head -1) + if [[ -n "$FC_BIN" ]]; then + sudo cp "$FC_BIN" /usr/local/bin/firecracker + sudo chmod +x /usr/local/bin/firecracker + info "Firecracker ${FC_VERSION} installed" + else + warn "Could not find firecracker binary in release archive" + fi + rm -rf "$FC_TMP" + fi +fi + +# ── Data directory + kernel ────────────────────────────── +if [[ ! -d "$DATA_DIR" ]]; then + info "Creating ${DATA_DIR}..." + sudo mkdir -p "$DATA_DIR" + sudo chown "$(whoami)" "$DATA_DIR" +fi + +if [[ -f "$DATA_DIR/vmlinux.bin" ]]; then + info "Kernel already exists at $DATA_DIR/vmlinux.bin" +else + info "Downloading Firecracker kernel..." + curl -fSL -o "$DATA_DIR/vmlinux.bin" "$KERNEL_URL" || sudo curl -fSL -o "$DATA_DIR/vmlinux.bin" "$KERNEL_URL" + chmod 644 "$DATA_DIR/vmlinux.bin" 2>/dev/null || sudo chmod 644 "$DATA_DIR/vmlinux.bin" + info "Kernel downloaded" +fi + +# ── Check KVM (warn only) ─────────────────────────────── +if [[ -e /dev/kvm ]]; then + if [[ -r /dev/kvm ]] && [[ -w /dev/kvm ]]; then + info "KVM is accessible" + else + warn "KVM exists but no permission. Fix with: sudo chmod 666 /dev/kvm" + warn "ForgeVM still works in mock mode without KVM." + fi +else + warn "KVM not available. ForgeVM will work in mock mode (no hardware VM isolation)." + warn "For microVM support, enable virtualization in BIOS." +fi + +# ── Check Docker (advisory) ───────────────────────────── +if command -v docker &>/dev/null; then + info "Docker found (needed for custom environment images)" +else + warn "Docker not found. Optional — needed only for custom Docker images." + warn "Install: https://docs.docker.com/engine/install/" +fi + +# ── Done ───────────────────────────────────────────────── +echo "" +echo -e "${GREEN}══════════════════════════════════════${NC}" +echo -e "${GREEN} ForgeVM ${VERSION} installed!${NC}" +echo -e "${GREEN}══════════════════════════════════════${NC}" +echo "" +echo " Start the server:" +echo -e " ${BOLD}forgevm serve${NC}" +echo "" +echo " Test it:" +echo " curl -s -X POST localhost:7423/api/v1/sandboxes \\" +echo " -H 'Content-Type: application/json' \\" +echo " -d '{\"image\":\"alpine:latest\"}' | jq .id" +echo "" +echo " For XFS reflink support (faster snapshots), run:" +echo " ./scripts/setup.sh from the source repo" +echo "" diff --git a/scripts/setup.sh b/scripts/setup.sh index a161d14..3b4ee42 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -1,6 +1,10 @@ #!/bin/bash -# ForgeVM — one-command setup +# ForgeVM — full development setup (builds from source, configures XFS reflink) # Usage: ./scripts/setup.sh +# +# For pre-built binary installs (no Go required), use instead: +# curl -fsSL https://raw.githubusercontent.com/DohaerisAI/forgevm/main/scripts/install.sh | bash +# set -euo pipefail RED='\033[0;31m' diff --git a/scripts/test_multiuser_pool.py b/scripts/test_multiuser_pool.py new file mode 100644 index 0000000..f0fb2aa --- /dev/null +++ b/scripts/test_multiuser_pool.py @@ -0,0 +1,617 @@ +""" +ForgeVM Multi-User Pool Test with Requesty LLM Integration +Simulates multiple users sharing a ForgeVM instance, each with isolated sandboxes. +Each user gets their own sandbox, runs LLM-driven code execution via Requesty, +and verifies workspace isolation between users. + +Requires: + - ForgeVM server running on http://localhost:7423 + - REQUESTY_API_KEY in env or in acg_yoda/backend/.env + - pip: forgevm, openai, anthropic, python-dotenv +""" +import asyncio +import json +import os +import sys +import time +from dotenv import load_dotenv +from openai import AsyncOpenAI +from anthropic import AsyncAnthropic + +# ForgeVM SDK (sync client for pool status, async sandbox operations via httpx) +from forgevm import Client + +import httpx + +# Load .env from current directory or parent +load_dotenv() + +REQUESTY_API_KEY = os.getenv("REQUESTY_API_KEY") +REQUESTY_BASE_URL = "https://router.requesty.ai/v1" +REQUESTY_ANTHROPIC_BASE_URL = "https://router.requesty.ai" + +FORGEVM_URL = os.getenv("FORGEVM_URL", "http://localhost:7423") +SANDBOX_IMAGE = os.getenv("FORGEVM_IMAGE", "forgevm/python-sandbox:py3.12") + +# Models +OPENAI_MODEL = "openai/gpt-5.2-chat" +ANTHROPIC_MODEL = "anthropic/claude-haiku-4-5" + +# Users +USERS = ["alice", "bob", "carol"] + +# Tool definitions +TOOL_DESC = ( + "Execute Python code in a secure sandbox. " + "Has Python with standard library. Use print() for output. " + "Save output files to the current directory (use relative paths like 'output.csv', not absolute paths)." +) + +COMPUTE_TOOL_OPENAI = { + "type": "function", + "function": { + "name": "compute", + "description": TOOL_DESC, + "parameters": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Python code to execute. Use print() for output.", + } + }, + "required": ["code"], + }, + }, +} + +COMPUTE_TOOL_ANTHROPIC = { + "name": "compute", + "description": TOOL_DESC, + "input_schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Python code to execute. Use print() for output.", + } + }, + "required": ["code"], + }, +} + + +# ── Helpers ── + +def create_user_client(user_id: str) -> Client: + """Create a ForgeVM sync client for a specific user.""" + return Client(base_url=FORGEVM_URL, user_id=user_id) + + +async def run_in_sandbox(http: httpx.AsyncClient, sandbox_id: str, code: str) -> dict: + """Execute code in a ForgeVM sandbox, return result dict.""" + # Write code to a file (path gets scoped in pool mode) + resp = await http.post( + f"/api/v1/sandboxes/{sandbox_id}/files", + json={"path": "/run.py", "content": code}, + ) + resp.raise_for_status() + + # Execute using relative path (workdir is set to sandbox workspace in pool mode) + resp = await http.post( + f"/api/v1/sandboxes/{sandbox_id}/exec", + json={"command": "python3 run.py"}, + ) + resp.raise_for_status() + data = resp.json() + return { + "exit_code": data.get("exit_code", 0), + "stdout": data.get("stdout", ""), + "stderr": data.get("stderr", ""), + } + + +async def openai_tool_loop(oai_client, model, messages, tools, http, sandbox_id, max_rounds=3): + """Run OpenAI tool-use loop with ForgeVM sandbox execution.""" + for _ in range(max_rounds): + response = await oai_client.chat.completions.create( + model=model, messages=messages, tools=tools, max_tokens=1024, + ) + msg = response.choices[0].message + + if not msg.tool_calls: + return msg.content or "(no content)" + + messages.append(msg.model_dump()) + for tc in msg.tool_calls: + if tc.function.name == "compute": + code = json.loads(tc.function.arguments)["code"] + result = await run_in_sandbox(http, sandbox_id, code) + output = result["stdout"] or result["stderr"] or "(no output)" + else: + output = f"Unknown tool: {tc.function.name}" + messages.append({"role": "tool", "tool_call_id": tc.id, "content": output}) + + return "(max rounds reached)" + + +async def anthropic_tool_loop(ant_client, model, system, messages, tools, http, sandbox_id, max_rounds=3): + """Run Anthropic tool-use loop with ForgeVM sandbox execution.""" + for _ in range(max_rounds): + response = await ant_client.messages.create( + model=model, max_tokens=1024, system=system, messages=messages, tools=tools, + ) + + text_parts = [] + tool_blocks = [] + for block in response.content: + if hasattr(block, "text"): + text_parts.append(block.text) + if block.type == "tool_use" and block.name == "compute": + tool_blocks.append(block) + + if not tool_blocks: + return "".join(text_parts) or "(no content)" + + messages.append({"role": "assistant", "content": response.content}) + tool_results = [] + for tb in tool_blocks: + code = tb.input["code"] + result = await run_in_sandbox(http, sandbox_id, code) + output = result["stdout"] or result["stderr"] or "(no output)" + tool_results.append({"type": "tool_result", "tool_use_id": tb.id, "content": output}) + messages.append({"role": "user", "content": tool_results}) + + return "(max rounds reached)" + + +# ═══════════════════════════════════════════════ +# TEST 1: Multi-user sandbox spawn + pool status +# ═══════════════════════════════════════════════ +async def test_multiuser_spawn(http: httpx.AsyncClient): + print("\n" + "=" * 70) + print("TEST 1: Multi-User Sandbox Spawn + Pool Status") + print("=" * 70) + + sandboxes = {} + for user in USERS: + resp = await http.post( + "/api/v1/sandboxes", + json={"image": SANDBOX_IMAGE}, + headers={"X-User-ID": user}, + ) + assert resp.status_code == 201, f"Spawn failed for {user}: {resp.status_code} {resp.text}" + sb = resp.json() + sandboxes[user] = sb["id"] + print(f" {user}: sandbox {sb['id']} (state={sb['state']})") + + # Check pool status + resp = await http.get("/api/v1/pool/status") + assert resp.status_code == 200 + pool = resp.json() + print(f" Pool status: {json.dumps(pool, indent=2)}") + + # List all sandboxes — should see all 3 + resp = await http.get("/api/v1/sandboxes") + all_sbs = resp.json() + print(f" Total active sandboxes: {len(all_sbs)}") + assert len(all_sbs) >= len(USERS), f"Expected at least {len(USERS)} sandboxes" + + print(" PASSED") + return sandboxes + + +# ═══════════════════════════════════════════════ +# TEST 2: File isolation between users +# ═══════════════════════════════════════════════ +async def test_file_isolation(http: httpx.AsyncClient, sandboxes: dict): + print("\n" + "=" * 70) + print("TEST 2: File Isolation Between Users") + print("=" * 70) + + # Each user writes a secret file + for user, sb_id in sandboxes.items(): + secret = f"secret-data-for-{user}-{time.time()}" + resp = await http.post( + f"/api/v1/sandboxes/{sb_id}/files", + json={"path": f"/workspace/{user}_secret.txt", "content": secret}, + ) + assert resp.status_code == 200, f"Write failed for {user}: {resp.text}" + print(f" {user}: wrote /workspace/{user}_secret.txt") + + # Each user can read their own file + for user, sb_id in sandboxes.items(): + resp = await http.get( + f"/api/v1/sandboxes/{sb_id}/files", + params={"path": f"/workspace/{user}_secret.txt"}, + ) + assert resp.status_code == 200, f"Read own file failed for {user}" + content = resp.text + assert user in content, f"Wrong content for {user}: {content}" + print(f" {user}: can read own file OK") + + # Verify user can't access another user's sandbox + user_list = list(sandboxes.keys()) + for i, user in enumerate(user_list): + other_user = user_list[(i + 1) % len(user_list)] + other_sb = sandboxes[other_user] + # Try to read other user's file from their sandbox + resp = await http.get( + f"/api/v1/sandboxes/{other_sb}/files", + params={"path": f"/workspace/{user}_secret.txt"}, + ) + # This should fail (404 or 500) because user's file doesn't exist in other's sandbox + if resp.status_code != 200: + print(f" {user}: correctly cannot read {other_user}'s sandbox files (HTTP {resp.status_code})") + else: + # In non-pool mode, sandboxes are separate VMs, so the file just won't exist + assert user not in resp.text, f"ISOLATION BREACH: {user} found in {other_user}'s sandbox!" + print(f" {user}: file not found in {other_user}'s sandbox (separate VM)") + + # Use extended file ops: stat + glob + for user, sb_id in sandboxes.items(): + # Stat + resp = await http.get( + f"/api/v1/sandboxes/{sb_id}/files/stat", + params={"path": f"/workspace/{user}_secret.txt"}, + ) + assert resp.status_code == 200 + stat = resp.json() + print(f" {user}: stat -> size={stat['size']}, is_dir={stat['is_dir']}") + + # Glob + resp = await http.get( + f"/api/v1/sandboxes/{sb_id}/files/glob", + params={"pattern": "/workspace/*.txt"}, + ) + assert resp.status_code == 200 + matches = resp.json() + print(f" {user}: glob /workspace/*.txt -> {matches}") + + print(" PASSED") + + +# ═══════════════════════════════════════════════ +# TEST 3: Concurrent LLM tasks (each user gets LLM + sandbox) +# ═══════════════════════════════════════════════ +async def test_concurrent_llm_tasks(http: httpx.AsyncClient, sandboxes: dict): + print("\n" + "=" * 70) + print("TEST 3: Concurrent LLM Code Execution Per User") + print("=" * 70) + + if not REQUESTY_API_KEY: + print(" SKIPPED (no REQUESTY_API_KEY)") + return + + oai_client = AsyncOpenAI(api_key=REQUESTY_API_KEY, base_url=REQUESTY_BASE_URL) + ant_client = AsyncAnthropic(api_key=REQUESTY_API_KEY, base_url=REQUESTY_ANTHROPIC_BASE_URL) + + # Each user gets a different task + tasks = { + "alice": { + "prompt": "Calculate the factorial of 12 using the compute tool. Use print() to show the result.", + "expected": "479001600", + "provider": "openai", + }, + "bob": { + "prompt": "What is 2^32? Use the compute tool to calculate it. Use print().", + "expected": "4294967296", + "provider": "anthropic", + "check": lambda ans: "4294967296" in ans.replace(",", ""), + }, + "carol": { + "prompt": "Calculate the sum of squares from 1 to 20 using the compute tool. Use print().", + "expected": "2870", + "provider": "openai", + }, + } + + async def run_user_task(user: str): + task = tasks[user] + sb_id = sandboxes[user] + t0 = time.time() + + if task["provider"] == "openai": + messages = [ + {"role": "system", "content": "You have a `compute` tool to execute Python code. Always use it. Use print() to show results."}, + {"role": "user", "content": task["prompt"]}, + ] + answer = await openai_tool_loop(oai_client, OPENAI_MODEL, messages, [COMPUTE_TOOL_OPENAI], http, sb_id) + else: + system = "You have a `compute` tool. Always use it for calculations. Use print()." + messages = [{"role": "user", "content": task["prompt"]}] + answer = await anthropic_tool_loop(ant_client, ANTHROPIC_MODEL, system, messages, [COMPUTE_TOOL_ANTHROPIC], http, sb_id) + + elapsed = time.time() - t0 + return user, answer, task, elapsed + + # Run all user tasks concurrently + print(" Running 3 users' LLM tasks concurrently...") + results = await asyncio.gather( + run_user_task("alice"), + run_user_task("bob"), + run_user_task("carol"), + ) + + all_passed = True + for user, answer, task, elapsed in results: + checker = task.get("check") + if checker: + ok = checker(answer) + else: + ok = task["expected"] in answer + status = "OK" if ok else "FAIL" + print(f" {user} [{task['provider']}] ({elapsed:.1f}s): {status}") + print(f" Answer: {answer[:200]}") + if not ok: + print(f" Expected '{task['expected']}' in answer") + all_passed = False + + assert all_passed, "Some user tasks failed" + print(" PASSED") + + +# ═══════════════════════════════════════════════ +# TEST 4: Extended file ops across users (move, chmod, delete) +# ═══════════════════════════════════════════════ +async def test_extended_file_ops_multiuser(http: httpx.AsyncClient, sandboxes: dict): + print("\n" + "=" * 70) + print("TEST 4: Extended File Ops Across Multiple Users") + print("=" * 70) + + for user, sb_id in sandboxes.items(): + print(f"\n --- {user} (sandbox {sb_id}) ---") + + # Write + resp = await http.post( + f"/api/v1/sandboxes/{sb_id}/files", + json={"path": f"/workspace/{user}_data.txt", "content": f"Hello from {user}!"}, + ) + assert resp.status_code == 200 + print(f" write /workspace/{user}_data.txt") + + # Move/rename + resp = await http.post( + f"/api/v1/sandboxes/{sb_id}/files/move", + json={"old_path": f"/workspace/{user}_data.txt", "new_path": f"/workspace/{user}_renamed.txt"}, + ) + assert resp.status_code == 200, f"Move failed: {resp.text}" + print(f" move -> /workspace/{user}_renamed.txt") + + # Read from new location + resp = await http.get( + f"/api/v1/sandboxes/{sb_id}/files", + params={"path": f"/workspace/{user}_renamed.txt"}, + ) + assert resp.status_code == 200 + assert user in resp.text + print(f" read -> '{resp.text}'") + + # Chmod + resp = await http.post( + f"/api/v1/sandboxes/{sb_id}/files/chmod", + json={"path": f"/workspace/{user}_renamed.txt", "mode": "0755"}, + ) + assert resp.status_code == 200 + print(f" chmod 0755") + + # Stat + resp = await http.get( + f"/api/v1/sandboxes/{sb_id}/files/stat", + params={"path": f"/workspace/{user}_renamed.txt"}, + ) + assert resp.status_code == 200 + stat = resp.json() + print(f" stat -> size={stat['size']}, is_dir={stat['is_dir']}") + + # Write more files for glob + for ext in [".log", ".tmp"]: + resp = await http.post( + f"/api/v1/sandboxes/{sb_id}/files", + json={"path": f"/workspace/{user}{ext}", "content": "data"}, + ) + + # Glob + resp = await http.get( + f"/api/v1/sandboxes/{sb_id}/files/glob", + params={"pattern": "/workspace/*.log"}, + ) + assert resp.status_code == 200 + matches = resp.json() + print(f" glob *.log -> {matches}") + + # Delete + resp = await http.delete( + f"/api/v1/sandboxes/{sb_id}/files", + params={"path": f"/workspace/{user}_renamed.txt"}, + ) + assert resp.status_code == 200 + print(f" delete /workspace/{user}_renamed.txt") + + # Verify deleted + resp = await http.get( + f"/api/v1/sandboxes/{sb_id}/files", + params={"path": f"/workspace/{user}_renamed.txt"}, + ) + assert resp.status_code != 200, "File should be deleted" + print(f" verified deleted") + + print("\n PASSED") + + +# ═══════════════════════════════════════════════ +# TEST 5: LLM file generation + cross-user verification +# ═══════════════════════════════════════════════ +async def test_llm_file_generation(http: httpx.AsyncClient, sandboxes: dict): + print("\n" + "=" * 70) + print("TEST 5: LLM File Generation Per User") + print("=" * 70) + + if not REQUESTY_API_KEY: + print(" SKIPPED (no REQUESTY_API_KEY)") + return + + ant_client = AsyncAnthropic(api_key=REQUESTY_API_KEY, base_url=REQUESTY_ANTHROPIC_BASE_URL) + system = "You have a `compute` tool. Use it to run Python code. Use print() to confirm. Save files to /workspace/." + + # Alice generates a CSV, Bob generates JSON, Carol generates a script + user_tasks = { + "alice": { + "prompt": "Generate a CSV with 3 rows of data (name,age,city). Save to people.csv (relative path) using the csv module. Print 'done'.", + "file": "/people.csv", + "check": lambda c: "name" in c.lower() and len(c.strip().split("\n")) >= 4, + }, + "bob": { + "prompt": "Generate a JSON file with a list of 3 colors (name + hex code). Save to colors.json (relative path). Print 'done'.", + "file": "/colors.json", + "check": lambda c: json.loads(c) is not None, + }, + "carol": { + "prompt": "Write a Python script that prints 'Hello from Carol' to hello.py (relative path). Print 'done'.", + "file": "/hello.py", + "check": lambda c: "carol" in c.lower() or "Carol" in c, + }, + } + + for user in ["alice", "bob", "carol"]: + task = user_tasks[user] + sb_id = sandboxes[user] + messages = [{"role": "user", "content": task["prompt"]}] + + print(f" {user}: {task['prompt'][:60]}...") + answer = await anthropic_tool_loop( + ant_client, ANTHROPIC_MODEL, system, messages, + [COMPUTE_TOOL_ANTHROPIC], http, sb_id, max_rounds=5, + ) + print(f" {user}: LLM says: {answer[:100]}") + + # Read the generated file + resp = await http.get( + f"/api/v1/sandboxes/{sb_id}/files", + params={"path": task["file"]}, + ) + assert resp.status_code == 200, f"File not found for {user}: {task['file']}" + content = resp.text + print(f" {user}: file content ({len(content)} bytes): {content[:100]}...") + + assert task["check"](content), f"Validation failed for {user}" + print(f" {user}: VERIFIED") + + # Stat the file + resp = await http.get( + f"/api/v1/sandboxes/{sb_id}/files/stat", + params={"path": task["file"]}, + ) + stat = resp.json() + print(f" {user}: stat -> size={stat['size']}") + + print(" PASSED") + + +# ═══════════════════════════════════════════════ +# RUNNER +# ═══════════════════════════════════════════════ +async def run_all(): + print("\n" + "=" * 70) + print("FORGEVM MULTI-USER POOL TEST") + print("=" * 70) + print(f" Server: {FORGEVM_URL}") + print(f" Image: {SANDBOX_IMAGE}") + print(f" Users: {', '.join(USERS)}") + if REQUESTY_API_KEY: + print(f" API Key: {'*' * 8}...{REQUESTY_API_KEY[-4:]}" if REQUESTY_API_KEY else " API Key: (not set)") + print(f" OpenAI: {OPENAI_MODEL}") + print(f" Anthropic: {ANTHROPIC_MODEL}") + else: + print(" LLM: DISABLED (no REQUESTY_API_KEY, LLM tests will be skipped)") + + async with httpx.AsyncClient(base_url=FORGEVM_URL, timeout=60.0) as http: + # Health check + resp = await http.get("/api/v1/health") + assert resp.status_code == 200, f"Server not healthy: {resp.status_code}" + print(f" Health: {resp.json()['status']}") + + tests = [ + ("Test 1: Multi-User Spawn + Pool Status", test_multiuser_spawn), + ("Test 2: File Isolation Between Users", None), + ("Test 3: Concurrent LLM Tasks", None), + ("Test 4: Extended File Ops Multi-User", None), + ("Test 5: LLM File Generation Per User", None), + ] + + results = [] + sandboxes = None + + try: + # Test 1: Spawn sandboxes + try: + sandboxes = await test_multiuser_spawn(http) + results.append(("Test 1: Multi-User Spawn + Pool Status", True, None)) + except Exception as e: + results.append(("Test 1: Multi-User Spawn + Pool Status", False, str(e))) + import traceback; traceback.print_exc() + print("\nCannot continue without sandboxes.") + return + + # Test 2: File isolation + try: + await test_file_isolation(http, sandboxes) + results.append(("Test 2: File Isolation Between Users", True, None)) + except Exception as e: + results.append(("Test 2: File Isolation Between Users", False, str(e))) + import traceback; traceback.print_exc() + + # Test 3: Concurrent LLM + try: + await test_concurrent_llm_tasks(http, sandboxes) + results.append(("Test 3: Concurrent LLM Tasks", True, None)) + except Exception as e: + results.append(("Test 3: Concurrent LLM Tasks", False, str(e))) + import traceback; traceback.print_exc() + + # Test 4: Extended file ops + try: + await test_extended_file_ops_multiuser(http, sandboxes) + results.append(("Test 4: Extended File Ops Multi-User", True, None)) + except Exception as e: + results.append(("Test 4: Extended File Ops Multi-User", False, str(e))) + import traceback; traceback.print_exc() + + # Test 5: LLM file generation + try: + await test_llm_file_generation(http, sandboxes) + results.append(("Test 5: LLM File Generation Per User", True, None)) + except Exception as e: + results.append(("Test 5: LLM File Generation Per User", False, str(e))) + import traceback; traceback.print_exc() + + finally: + # Cleanup all sandboxes + if sandboxes: + print("\n Cleaning up sandboxes...") + for user, sb_id in sandboxes.items(): + try: + resp = await http.delete(f"/api/v1/sandboxes/{sb_id}") + print(f" {user}: destroyed {sb_id} ({resp.status_code})") + except Exception: + pass + + # Summary + print("\n" + "=" * 70) + print("SUMMARY") + print("=" * 70) + for name, passed, err in results: + status = "PASS" if passed else ("SKIP" if "SKIP" in (err or "") else "FAIL") + print(f" {status}: {name}" + (f" -- {err}" if err else "")) + + passed_count = sum(1 for _, p, _ in results if p) + total = len(results) + if passed_count == total: + print(f"\nAll {total} tests PASSED") + else: + print(f"\n{passed_count}/{total} passed, {total - passed_count} FAILED/SKIPPED") + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(run_all()) diff --git a/scripts/test_opensandbox_lifecycle.py b/scripts/test_opensandbox_lifecycle.py new file mode 100644 index 0000000..708c79d --- /dev/null +++ b/scripts/test_opensandbox_lifecycle.py @@ -0,0 +1,89 @@ +""" +OpenSandbox SDK Lifecycle Test +Tests: create, exec, write, read, file info, code interpret, TTL renew, delete. +Requires: OpenSandbox server running on http://127.0.0.1:8080 +Install SDK: cd OpenSandbox/sdks/sandbox/python && uv sync +""" +import asyncio +from datetime import datetime, timedelta, timezone + +import httpx +from opensandbox import Sandbox +from opensandbox.config import ConnectionConfig + +OPENSANDBOX_URL = "127.0.0.1:8080" +SANDBOX_IMAGE = "opensandbox/code-interpreter:v1.0.1" + + +def get_stdout(r): + return "\n".join(m.text for m in r.logs.stdout) + + +async def main(): + config = ConnectionConfig( + domain=OPENSANDBOX_URL, + protocol="http", + request_timeout=timedelta(seconds=60), + ) + + print("=== 1. Create sandbox ===") + sandbox = await Sandbox.create( + SANDBOX_IMAGE, + connection_config=config, + timeout=timedelta(minutes=5), + ready_timeout=timedelta(seconds=60), + ) + print(f"ID: {sandbox.id}") + + print("\n=== 2. Shell command ===") + r = await sandbox.commands.run("echo Hello from OpenSandbox && python3 --version") + print(get_stdout(r)) + + print("\n=== 3. Write + execute Python ===") + code = ( + "import csv, os\n" + "os.makedirs('/output', exist_ok=True)\n" + "with open('/output/data.csv', 'w') as f:\n" + " w = csv.writer(f)\n" + " w.writerow(['name', 'value'])\n" + " for i in range(10):\n" + " w.writerow([f'item_{i}', i * 10])\n" + "print('CSV generated with 10 rows')\n" + ) + await sandbox.files.write_file("/tmp/gen.py", code) + r = await sandbox.commands.run("python3 /tmp/gen.py") + print(get_stdout(r)) + + print("\n=== 4. Read generated CSV ===") + content = await sandbox.files.read_file("/output/data.csv") + print(content) + + print("\n=== 5. File info ===") + info = await sandbox.files.get_file_info(["/output/data.csv"]) + for path, meta in info.items(): + print(f"{path}: size={meta.size}, modified={meta.modified_at}") + + print("\n=== 6. Code interpreter (via commands.run) ===") + r = await sandbox.commands.run('python3 -c "print(sum(range(101)))"') + print(f"Sum 0..100 = {get_stdout(r)}") + + print("\n=== 7. Renew expiration (TTL extend via API) ===") + new_expiry = (datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat() + async with httpx.AsyncClient() as client: + resp = await client.post( + f"http://{OPENSANDBOX_URL}/sandboxes/{sandbox.id}/renew-expiration", + json={"expiresAt": new_expiry}, + ) + print(f"Renew API: {resp.status_code} {resp.json()}") + + print("\n=== 8. Cleanup ===") + await sandbox.close() + async with httpx.AsyncClient() as client: + resp = await client.delete(f"http://{OPENSANDBOX_URL}/sandboxes/{sandbox.id}") + print(f"Delete API: {resp.status_code}") + + print("\nALL TESTS PASSED!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/test_opensandbox_requesty.py b/scripts/test_opensandbox_requesty.py new file mode 100644 index 0000000..69ecad7 --- /dev/null +++ b/scripts/test_opensandbox_requesty.py @@ -0,0 +1,375 @@ +""" +OpenSandbox + Requesty LLM Integration Tests +Full loop: LLM generates code via tool_use -> execute in OpenSandbox -> return result. +Tests the same pattern YODA uses with the compute tool. + +Requires: + - OpenSandbox server running on http://127.0.0.1:8080 + - REQUESTY_API_KEY in env or in acg_yoda/backend/.env + - pip: opensandbox, openai, anthropic, python-dotenv, httpx +""" +import asyncio +import json +import os +import sys +from datetime import timedelta +from dotenv import load_dotenv +from openai import AsyncOpenAI +from anthropic import AsyncAnthropic +from opensandbox import Sandbox +from opensandbox.config import ConnectionConfig + +# Load .env from current directory or parent +load_dotenv() + +REQUESTY_API_KEY = os.getenv("REQUESTY_API_KEY") +REQUESTY_BASE_URL = "https://router.requesty.ai/v1" +REQUESTY_ANTHROPIC_BASE_URL = "https://router.requesty.ai" + +OPENSANDBOX_URL = "127.0.0.1:8080" +SANDBOX_IMAGE = "opensandbox/code-interpreter:v1.0.1" + +OPENSANDBOX_CONFIG = ConnectionConfig( + domain=OPENSANDBOX_URL, + protocol="http", + request_timeout=timedelta(seconds=60), +) + +# Models +OPENAI_MODEL = "openai/gpt-5.2-chat" +ANTHROPIC_MODEL = "anthropic/claude-haiku-4-5" + +# ── Tool definitions ── +# NOTE: code-interpreter image has stdlib only (no pandas/numpy). +TOOL_DESC = ( + "Execute Python code in a secure sandbox. " + "Has Python 3.12 with standard library (csv, json, math, os, etc). " + "No pandas/numpy — use stdlib csv module. " + "Save output files to /output/." +) + +COMPUTE_TOOL_OPENAI = { + "type": "function", + "function": { + "name": "compute", + "description": TOOL_DESC, + "parameters": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Python code to execute as a script via python3. Use print() for output.", + } + }, + "required": ["code"], + }, + }, +} + +COMPUTE_TOOL_ANTHROPIC = { + "name": "compute", + "description": TOOL_DESC, + "input_schema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Python code to execute as a script via python3. Use print() for output.", + } + }, + "required": ["code"], + }, +} + + +def get_stdout(r): + return "\n".join(m.text for m in r.logs.stdout) + + +def get_stderr(r): + return "\n".join(m.text for m in r.logs.stderr) + + +async def run_in_sandbox(sandbox: Sandbox, code: str) -> dict: + """Execute code in OpenSandbox, return result dict matching YODA's format.""" + await sandbox.files.write_file("/tmp/run.py", code) + r = await sandbox.commands.run("python3 /tmp/run.py") + + stdout = get_stdout(r) + stderr = get_stderr(r) + exit_code = r.exit_code if hasattr(r, "exit_code") else 0 + + return {"exit_code": exit_code, "stdout": stdout, "stderr": stderr} + + +async def openai_tool_loop(client, model, messages, tools, sandbox, max_rounds=3): + """Run OpenAI tool-use loop: LLM -> tool call -> execute -> feed back -> repeat.""" + for _ in range(max_rounds): + response = await client.chat.completions.create( + model=model, messages=messages, tools=tools, max_tokens=1024, + ) + msg = response.choices[0].message + + if not msg.tool_calls: + return msg.content or "(no content)" + + messages.append(msg.model_dump()) + for tc in msg.tool_calls: + if tc.function.name == "compute": + code = json.loads(tc.function.arguments)["code"] + result = await run_in_sandbox(sandbox, code) + output = result["stdout"] or result["stderr"] or "(no output)" + else: + output = f"Unknown tool: {tc.function.name}" + messages.append({"role": "tool", "tool_call_id": tc.id, "content": output}) + + return "(max rounds reached)" + + +async def anthropic_tool_loop(client, model, system, messages, tools, sandbox, max_rounds=3): + """Run Anthropic tool-use loop: LLM -> tool call -> execute -> feed back -> repeat.""" + for _ in range(max_rounds): + response = await client.messages.create( + model=model, max_tokens=1024, system=system, messages=messages, tools=tools, + ) + + text_parts = [] + tool_blocks = [] + for block in response.content: + if hasattr(block, "text"): + text_parts.append(block.text) + if block.type == "tool_use" and block.name == "compute": + tool_blocks.append(block) + + if not tool_blocks: + return "".join(text_parts) or "(no content)" + + messages.append({"role": "assistant", "content": response.content}) + tool_results = [] + for tb in tool_blocks: + code = tb.input["code"] + result = await run_in_sandbox(sandbox, code) + output = result["stdout"] or result["stderr"] or "(no output)" + tool_results.append({"type": "tool_result", "tool_use_id": tb.id, "content": output}) + messages.append({"role": "user", "content": tool_results}) + + return "(max rounds reached)" + + +# ═══════════════════════════════════════════════ +# TEST 1: OpenAI + tool_use -> OpenSandbox +# ═══════════════════════════════════════════════ +async def test_openai_tool_use(sandbox: Sandbox): + print("\n" + "=" * 70) + print(f"TEST 1: OpenAI {OPENAI_MODEL} + tool_use -> OpenSandbox") + print("=" * 70) + + client = AsyncOpenAI(api_key=REQUESTY_API_KEY, base_url=REQUESTY_BASE_URL) + messages = [ + {"role": "system", "content": "You have a `compute` tool to execute Python code. Always use it for any calculation. Use print() to show results."}, + {"role": "user", "content": "What is the sum of all prime numbers below 100? Use the compute tool."}, + ] + print(" Prompt: What is the sum of all prime numbers below 100?") + + final = await openai_tool_loop(client, OPENAI_MODEL, messages, [COMPUTE_TOOL_OPENAI], sandbox) + print(f" LLM final answer: {final[:300]}") + + assert "1060" in final, f"Expected 1060 in answer, got: {final}" + print(" PASSED") + + +# ═══════════════════════════════════════════════ +# TEST 2: Anthropic + tool_use -> OpenSandbox +# ═══════════════════════════════════════════════ +async def test_anthropic_tool_use(sandbox: Sandbox): + print("\n" + "=" * 70) + print(f"TEST 2: Anthropic {ANTHROPIC_MODEL} + tool_use -> OpenSandbox") + print("=" * 70) + + client = AsyncAnthropic(api_key=REQUESTY_API_KEY, base_url=REQUESTY_ANTHROPIC_BASE_URL) + system = "You have a `compute` tool. Always use it for calculations. Use print() to show results." + messages = [ + {"role": "user", "content": "Use the compute tool to calculate: what's the 20th Fibonacci number?"} + ] + print(" Prompt: What's the 20th Fibonacci number?") + + final = await anthropic_tool_loop(client, ANTHROPIC_MODEL, system, messages, [COMPUTE_TOOL_ANTHROPIC], sandbox) + print(f" LLM final answer: {final[:300]}") + + assert "6765" in final, f"Expected 6765 in answer, got: {final}" + print(" PASSED") + + +# ═══════════════════════════════════════════════ +# TEST 3: File generation (CSV) via Anthropic -> sandbox +# ═══════════════════════════════════════════════ +async def test_file_generation(sandbox: Sandbox): + print("\n" + "=" * 70) + print("TEST 3: File Generation (Anthropic -> compute -> /output/)") + print("=" * 70) + + client = AsyncAnthropic(api_key=REQUESTY_API_KEY, base_url=REQUESTY_ANTHROPIC_BASE_URL) + await sandbox.commands.run("rm -f /output/employees.csv") + + system = "You have a `compute` tool. Use it to run Python code. Use the csv module (stdlib). Save output files to /output/. Use print() to confirm." + messages = [ + {"role": "user", "content": "Generate a CSV file with 5 rows of sample employee data (name, department, salary). Save it to /output/employees.csv. Use the csv module, not pandas."}, + ] + print(" Prompt: Generate employee CSV to /output/employees.csv") + + final = await anthropic_tool_loop(client, ANTHROPIC_MODEL, system, messages, [COMPUTE_TOOL_ANTHROPIC], sandbox) + print(f" LLM response: {final[:200]}") + + content = await sandbox.files.read_file("/output/employees.csv") + print(f" Generated CSV:\n {content.strip().replace(chr(10), chr(10) + ' ')}") + + assert "name" in content.lower() or "Name" in content, "CSV should have name column" + lines = [l for l in content.strip().split("\n") if l.strip()] + assert len(lines) >= 6, f"Expected header + 5 rows, got {len(lines)} lines" + print(f" File verified: {len(lines) - 1} data rows") + print(" PASSED") + + +# ═══════════════════════════════════════════════ +# TEST 4: Data upload + analysis (YODA sandbox data routing) +# ═══════════════════════════════════════════════ +async def test_data_upload_and_analysis(sandbox: Sandbox): + print("\n" + "=" * 70) + print(f"TEST 4: Data Upload + Analysis ({OPENAI_MODEL})") + print("=" * 70) + + csv_data = ( + "product,region,sales,units\n" + "Widget A,North,15000,150\n" + "Widget A,South,12000,120\n" + "Widget B,North,8000,80\n" + "Widget B,South,22000,220\n" + "Widget C,North,18000,180\n" + "Widget C,South,9000,90\n" + ) + await sandbox.commands.run("mkdir -p /data") + await sandbox.files.write_file("/data/sales.csv", csv_data) + print(" Uploaded sales.csv to /data/sales.csv") + + client = AsyncOpenAI(api_key=REQUESTY_API_KEY, base_url=REQUESTY_BASE_URL) + messages = [ + {"role": "system", "content": "You have a `compute` tool for running Python. Data files are in /data/. Use the csv module (stdlib, no pandas). Use print() to show results."}, + {"role": "user", "content": ( + "I uploaded sales.csv to /data/sales.csv.\n" + "Columns: product, region, sales, units\n" + "6 rows of sales data.\n\n" + "Which product has the highest total sales across all regions? Use the compute tool with the csv module." + )}, + ] + print(" Prompt: Which product has highest total sales?") + + final = await openai_tool_loop(client, OPENAI_MODEL, messages, [COMPUTE_TOOL_OPENAI], sandbox) + print(f" LLM answer: {final[:400]}") + + assert "Widget B" in final or "widget b" in final.lower() or "30000" in final or "30,000" in final, \ + f"Expected Widget B (30000 total), got: {final}" + print(" PASSED") + + +# ═══════════════════════════════════════════════ +# TEST 5: Multi-round agentic (Anthropic + file gen + verify) +# ═══════════════════════════════════════════════ +async def test_multi_round(sandbox: Sandbox): + print("\n" + "=" * 70) + print("TEST 5: Multi-round Agentic (Anthropic -> compute -> file)") + print("=" * 70) + + await sandbox.commands.run("rm -f /output/fibonacci.json") + + client = AsyncAnthropic(api_key=REQUESTY_API_KEY, base_url=REQUESTY_ANTHROPIC_BASE_URL) + system = "You have a `compute` tool. Use it for ALL calculations. Save files to /output/. Use print() to show results." + messages = [ + {"role": "user", "content": ( + "Calculate the first 15 Fibonacci numbers and save them as a JSON list to /output/fibonacci.json. " + "Also print them. Use the compute tool." + )}, + ] + print(" Prompt: Calculate Fibonacci + save as JSON") + + final = await anthropic_tool_loop(client, ANTHROPIC_MODEL, system, messages, [COMPUTE_TOOL_ANTHROPIC], sandbox) + print(f" LLM response: {final[:200]}") + + content = await sandbox.files.read_file("/output/fibonacci.json") + fib_data = json.loads(content) + print(f" Generated JSON: {fib_data}") + + assert isinstance(fib_data, list), f"Expected list, got {type(fib_data)}" + assert len(fib_data) >= 15, f"Expected 15+ items, got {len(fib_data)}" + assert 610 in fib_data or 377 in fib_data, f"Expected 610 or 377 in Fibonacci, got: {fib_data}" + print(" PASSED") + + +# ═══════════════════════════════════════════════ +# RUNNER +# ═══════════════════════════════════════════════ +async def run_all(): + print("\n" + "=" * 70) + print("OPENSANDBOX + REQUESTY LLM INTEGRATION TESTS") + print("=" * 70) + + if not REQUESTY_API_KEY: + print("ERROR: REQUESTY_API_KEY not found. Set it in env or in acg_yoda/backend/.env") + sys.exit(1) + print(f" API Key: {'*' * 8}...{REQUESTY_API_KEY[-4:]}" if REQUESTY_API_KEY else " API Key: (not set)") + print(f" OpenAI model: {OPENAI_MODEL}") + print(f" Anthropic model: {ANTHROPIC_MODEL}") + + print("\n Creating sandbox...") + sandbox = await Sandbox.create( + SANDBOX_IMAGE, + connection_config=OPENSANDBOX_CONFIG, + timeout=timedelta(minutes=10), + ready_timeout=timedelta(seconds=60), + ) + print(f" Sandbox ID: {sandbox.id}") + await sandbox.commands.run("mkdir -p /output /data") + + tests = [ + (f"Test 1: OpenAI {OPENAI_MODEL} tool_use", test_openai_tool_use), + (f"Test 2: Anthropic {ANTHROPIC_MODEL} tool_use", test_anthropic_tool_use), + ("Test 3: File generation (Anthropic)", test_file_generation), + (f"Test 4: Data upload + analysis ({OPENAI_MODEL})", test_data_upload_and_analysis), + ("Test 5: Multi-round agentic (Anthropic)", test_multi_round), + ] + + results = [] + for name, fn in tests: + try: + await fn(sandbox) + results.append((name, True, None)) + except Exception as e: + results.append((name, False, str(e))) + import traceback + traceback.print_exc() + + # Cleanup + print("\n Cleaning up sandbox...") + await sandbox.close() + import httpx + async with httpx.AsyncClient() as hc: + await hc.delete(f"http://{OPENSANDBOX_URL}/sandboxes/{sandbox.id}") + + # Summary + print("\n" + "=" * 70) + print("SUMMARY") + print("=" * 70) + for name, passed, err in results: + status = "PASS" if passed else "FAIL" + print(f" {status}: {name}" + (f" — {err}" if err else "")) + + passed_count = sum(1 for _, p, _ in results if p) + total = len(results) + if passed_count == total: + print(f"\nAll {total} tests PASSED") + else: + print(f"\n{passed_count}/{total} passed, {total - passed_count} FAILED") + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(run_all()) diff --git a/sdk/js/src/client.ts b/sdk/js/src/client.ts index 26fea3e..ac926a0 100644 --- a/sdk/js/src/client.ts +++ b/sdk/js/src/client.ts @@ -16,6 +16,7 @@ import type { ProviderInfo, SandboxInfo, SpawnOptions, + VMPoolStatus, } from "./types.js"; /** @@ -129,6 +130,9 @@ export class Client { if (opts.apiKey) { this._headers["X-API-Key"] = opts.apiKey; } + if (opts.userId) { + this._headers["X-User-ID"] = opts.userId; + } this.templates = new TemplateManager( this._baseUrl, @@ -300,6 +304,21 @@ export class Client { return (await response.json()) as ProviderInfo[]; } + /** + * Get the current VM pool status. + * + * @returns Pool status information, or `{ enabled: false }` if pool mode + * is not active. + */ + async poolStatus(): Promise { + const response = await this._fetch("/api/v1/pool/status", { + method: "GET", + }); + + await handleResponse(response); + return (await response.json()) as VMPoolStatus; + } + // ----------------------------------------------------------------------- // Convenience patterns // ----------------------------------------------------------------------- diff --git a/sdk/js/src/sandbox.ts b/sdk/js/src/sandbox.ts index 87ec198..66b80e4 100644 --- a/sdk/js/src/sandbox.ts +++ b/sdk/js/src/sandbox.ts @@ -273,6 +273,88 @@ export class Sandbox { return (await response.json()) as FileInfo[]; } + /** + * Delete a file or directory inside the sandbox. + * + * @param path - Absolute path inside the sandbox. + * @param recursive - If true, recursively delete directories. + */ + async deleteFile(path: string, recursive?: boolean): Promise { + const params = new URLSearchParams({ path }); + if (recursive) params.set("recursive", "true"); + const response = await this._fetch( + `/api/v1/sandboxes/${this.id}/files?${params.toString()}`, + { method: "DELETE" }, + ); + await handleResponse(response, this.id); + } + + /** + * Move/rename a file inside the sandbox. + * + * @param oldPath - Source path. + * @param newPath - Destination path. + */ + async moveFile(oldPath: string, newPath: string): Promise { + const response = await this._fetch( + `/api/v1/sandboxes/${this.id}/files/move`, + { + method: "POST", + body: JSON.stringify({ old_path: oldPath, new_path: newPath }), + }, + ); + await handleResponse(response, this.id); + } + + /** + * Change file permissions inside the sandbox. + * + * @param path - Absolute path inside the sandbox. + * @param mode - Octal permission string (e.g. `"0755"`). + */ + async chmodFile(path: string, mode: string): Promise { + const response = await this._fetch( + `/api/v1/sandboxes/${this.id}/files/chmod`, + { + method: "POST", + body: JSON.stringify({ path, mode }), + }, + ); + await handleResponse(response, this.id); + } + + /** + * Get file info for a single file inside the sandbox. + * + * @param path - Absolute path inside the sandbox. + * @returns File info object. + */ + async statFile(path: string): Promise { + const params = new URLSearchParams({ path }); + const response = await this._fetch( + `/api/v1/sandboxes/${this.id}/files/stat?${params.toString()}`, + { method: "GET" }, + ); + await handleResponse(response, this.id); + return (await response.json()) as FileInfo; + } + + /** + * Return paths matching a glob pattern inside the sandbox. + * + * @param pattern - Glob pattern (e.g. `"/tmp/*.txt"`). + * @returns Array of matching paths. + */ + async globFiles(pattern: string): Promise { + const params = new URLSearchParams({ pattern }); + const response = await this._fetch( + `/api/v1/sandboxes/${this.id}/files/glob?${params.toString()}`, + { method: "GET" }, + ); + await handleResponse(response, this.id); + return (await response.json()) as string[]; + } + // ----------------------------------------------------------------------- // TTL management // ----------------------------------------------------------------------- diff --git a/sdk/js/src/types.ts b/sdk/js/src/types.ts index d6dc076..995ce3f 100644 --- a/sdk/js/src/types.ts +++ b/sdk/js/src/types.ts @@ -263,6 +263,10 @@ export interface ForgevmClientOptions { * API key for authenticated access. Sent as `X-API-Key` header. */ apiKey?: string; + /** + * User ID for multi-tenant pool mode. Sent as `X-User-ID` header. + */ + userId?: string; /** * Request timeout in milliseconds. * @defaultValue `30000` @@ -270,6 +274,17 @@ export interface ForgevmClientOptions { timeout?: number; } +/** + * VM pool status information. + */ +export interface VMPoolStatus { + enabled: boolean; + vms: number; + max_vms: number; + total_users: number; + max_users_per_vm: number; +} + // --------------------------------------------------------------------------- // API error envelope // --------------------------------------------------------------------------- diff --git a/sdk/python/forgevm/async_client.py b/sdk/python/forgevm/async_client.py index 7a177de..0092fbc 100644 --- a/sdk/python/forgevm/async_client.py +++ b/sdk/python/forgevm/async_client.py @@ -24,11 +24,14 @@ def __init__( self, base_url: str = "http://localhost:7423", api_key: str | None = None, + user_id: str | None = None, timeout: float = 30.0, ): headers = {} if api_key: headers["X-API-Key"] = api_key + if user_id: + headers["X-User-ID"] = user_id self._http = httpx.AsyncClient( base_url=base_url, @@ -113,6 +116,12 @@ async def prune(self) -> int: handle_response(resp) return resp.json().get("pruned", 0) + async def pool_status(self) -> dict: + """Get VM pool status.""" + resp = await self._http.get("/api/v1/pool/status") + handle_response(resp) + return resp.json() + async def health(self) -> dict: """Check server health.""" resp = await self._http.get("/api/v1/health") diff --git a/sdk/python/forgevm/async_sandbox.py b/sdk/python/forgevm/async_sandbox.py index 83098d2..a396b09 100644 --- a/sdk/python/forgevm/async_sandbox.py +++ b/sdk/python/forgevm/async_sandbox.py @@ -107,6 +107,46 @@ async def list_files(self, path: str = "/") -> list[dict]: handle_response(resp) return resp.json() + async def delete_file(self, path: str, recursive: bool = False) -> None: + """Delete a file or directory from the sandbox.""" + params = {"path": path} + if recursive: + params["recursive"] = "true" + resp = await self._http.delete(f"/api/v1/sandboxes/{self.id}/files", params=params) + handle_response(resp) + + async def move_file(self, old_path: str, new_path: str) -> None: + """Move/rename a file in the sandbox.""" + resp = await self._http.post( + f"/api/v1/sandboxes/{self.id}/files/move", + json={"old_path": old_path, "new_path": new_path}, + ) + handle_response(resp) + + async def chmod_file(self, path: str, mode: str) -> None: + """Change file permissions in the sandbox.""" + resp = await self._http.post( + f"/api/v1/sandboxes/{self.id}/files/chmod", + json={"path": path, "mode": mode}, + ) + handle_response(resp) + + async def stat_file(self, path: str) -> dict: + """Get file info for a single file in the sandbox.""" + resp = await self._http.get( + f"/api/v1/sandboxes/{self.id}/files/stat", params={"path": path} + ) + handle_response(resp) + return resp.json() + + async def glob_files(self, pattern: str) -> list[str]: + """Return paths matching a glob pattern in the sandbox.""" + resp = await self._http.get( + f"/api/v1/sandboxes/{self.id}/files/glob", params={"pattern": pattern} + ) + handle_response(resp) + return resp.json() + async def destroy(self) -> None: """Destroy this sandbox.""" resp = await self._http.delete(f"/api/v1/sandboxes/{self.id}") diff --git a/sdk/python/forgevm/client.py b/sdk/python/forgevm/client.py index ffce87d..6285bc0 100644 --- a/sdk/python/forgevm/client.py +++ b/sdk/python/forgevm/client.py @@ -24,11 +24,14 @@ def __init__( self, base_url: str = "http://localhost:7423", api_key: str | None = None, + user_id: str | None = None, timeout: float = 30.0, ): headers = {} if api_key: headers["X-API-Key"] = api_key + if user_id: + headers["X-User-ID"] = user_id self._http = httpx.Client( base_url=base_url, @@ -99,6 +102,12 @@ def prune(self) -> int: handle_response(resp) return resp.json().get("pruned", 0) + def pool_status(self) -> dict: + """Get VM pool status.""" + resp = self._http.get("/api/v1/pool/status") + handle_response(resp) + return resp.json() + def health(self) -> dict: """Check server health.""" resp = self._http.get("/api/v1/health") diff --git a/sdk/python/forgevm/sandbox.py b/sdk/python/forgevm/sandbox.py index cf325aa..0840941 100644 --- a/sdk/python/forgevm/sandbox.py +++ b/sdk/python/forgevm/sandbox.py @@ -100,6 +100,46 @@ def list_files(self, path: str = "/") -> list[dict]: handle_response(resp) return resp.json() + def delete_file(self, path: str, recursive: bool = False) -> None: + """Delete a file or directory from the sandbox.""" + params = {"path": path} + if recursive: + params["recursive"] = "true" + resp = self._http.delete(f"/api/v1/sandboxes/{self.id}/files", params=params) + handle_response(resp) + + def move_file(self, old_path: str, new_path: str) -> None: + """Move/rename a file in the sandbox.""" + resp = self._http.post( + f"/api/v1/sandboxes/{self.id}/files/move", + json={"old_path": old_path, "new_path": new_path}, + ) + handle_response(resp) + + def chmod_file(self, path: str, mode: str) -> None: + """Change file permissions in the sandbox.""" + resp = self._http.post( + f"/api/v1/sandboxes/{self.id}/files/chmod", + json={"path": path, "mode": mode}, + ) + handle_response(resp) + + def stat_file(self, path: str) -> dict: + """Get file info for a single file in the sandbox.""" + resp = self._http.get( + f"/api/v1/sandboxes/{self.id}/files/stat", params={"path": path} + ) + handle_response(resp) + return resp.json() + + def glob_files(self, pattern: str) -> list[str]: + """Return paths matching a glob pattern in the sandbox.""" + resp = self._http.get( + f"/api/v1/sandboxes/{self.id}/files/glob", params={"pattern": pattern} + ) + handle_response(resp) + return resp.json() + def extend_ttl(self, ttl: str = "30m") -> None: """Extend this sandbox's TTL by the given duration.""" resp = self._http.post(f"/api/v1/sandboxes/{self.id}/extend", json={"ttl": ttl}) diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index b544580..4bd766a 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -83,6 +83,68 @@ def test_list_files(self, sandbox): assert any("b.txt" in n for n in names) +class TestExtendedFileOps: + def test_delete_file(self, sandbox): + sandbox.write_file("/workspace/to_delete.txt", "bye") + content = sandbox.read_file("/workspace/to_delete.txt") + assert content == "bye" + sandbox.delete_file("/workspace/to_delete.txt") + # File should no longer exist + with pytest.raises(Exception): + sandbox.read_file("/workspace/to_delete.txt") + + def test_delete_file_recursive(self, sandbox): + sandbox.write_file("/workspace/subdir/nested.txt", "nested") + sandbox.delete_file("/workspace/subdir", recursive=True) + with pytest.raises(Exception): + sandbox.read_file("/workspace/subdir/nested.txt") + + def test_move_file(self, sandbox): + sandbox.write_file("/workspace/original.txt", "move me") + sandbox.move_file("/workspace/original.txt", "/workspace/moved.txt") + content = sandbox.read_file("/workspace/moved.txt") + assert content == "move me" + with pytest.raises(Exception): + sandbox.read_file("/workspace/original.txt") + + def test_chmod_file(self, sandbox): + sandbox.write_file("/workspace/script.sh", "#!/bin/sh\necho hi") + sandbox.chmod_file("/workspace/script.sh", "0755") + info = sandbox.stat_file("/workspace/script.sh") + assert info is not None + + def test_stat_file(self, sandbox): + sandbox.write_file("/workspace/stat_test.txt", "hello world") + info = sandbox.stat_file("/workspace/stat_test.txt") + assert info["size"] == 11 + assert info["is_dir"] is False + + def test_glob_files(self, sandbox): + sandbox.write_file("/workspace/a.log", "log a") + sandbox.write_file("/workspace/b.log", "log b") + sandbox.write_file("/workspace/c.txt", "text c") + matches = sandbox.glob_files("/workspace/*.log") + assert len(matches) == 2 + assert any("a.log" in m for m in matches) + assert any("b.log" in m for m in matches) + + +class TestPoolStatus: + def test_pool_status(self, client): + status = client.pool_status() + assert "enabled" in status + + +class TestUserID: + def test_spawn_with_user_id(self): + with Client(base_url=SERVER_URL, user_id="testuser") as c: + sb = c.spawn(image="alpine:latest") + try: + assert sb.id.startswith("sb-") + finally: + sb.destroy() + + class TestContextManager: def test_sandbox_context(self, client): with client.spawn(image="alpine:latest") as sb: diff --git a/sdk/python/tests/test_llm.py b/sdk/python/tests/test_llm.py index 3dc8621..c2d7b00 100644 --- a/sdk/python/tests/test_llm.py +++ b/sdk/python/tests/test_llm.py @@ -25,16 +25,12 @@ import sys import time import asyncio -from pathlib import Path - -# Load .env from ACG_YODA -env_path = Path("/home/adwitiya24/corporate/acg_yoda/backend/.env") -if env_path.exists(): - for line in env_path.read_text().splitlines(): - line = line.strip() - if line and not line.startswith("#") and "=" in line: - key, _, value = line.partition("=") - os.environ.setdefault(key.strip(), value.strip()) +# Load .env if dotenv is available +try: + from dotenv import load_dotenv + load_dotenv() +except ImportError: + pass from forgevm import Client diff --git a/sdk/python/tests/test_pdf_gen.py b/sdk/python/tests/test_pdf_gen.py index 9e81f95..3999faf 100644 --- a/sdk/python/tests/test_pdf_gen.py +++ b/sdk/python/tests/test_pdf_gen.py @@ -25,14 +25,12 @@ from pathlib import Path from datetime import datetime -# Load .env from ACG_YODA -env_path = Path("/home/adwitiya24/corporate/acg_yoda/backend/.env") -if env_path.exists(): - for line in env_path.read_text().splitlines(): - line = line.strip() - if line and not line.startswith("#") and "=" in line: - key, _, value = line.partition("=") - os.environ.setdefault(key.strip(), value.strip()) +# Load .env if dotenv is available +try: + from dotenv import load_dotenv + load_dotenv() +except ImportError: + pass from forgevm import Client @@ -42,7 +40,7 @@ MODEL = os.environ.get("LLM_MODEL", "anthropic/claude-haiku-4-5") FORGEVM_URL = os.environ.get("FORGEVM_URL", "http://localhost:7423") SANDBOX_IMAGE = os.environ.get("SANDBOX_IMAGE", "ghcr.io/darkxace01/ai-filegen-sandbox:py3.12-v1") -OUTPUT_DIR = Path(os.environ.get("LLM_OUTPUT_DIR", "/home/adwitiya24/sandbox_files")) +OUTPUT_DIR = Path(os.environ.get("LLM_OUTPUT_DIR", "./sandbox_files")) BINARY_EXTENSIONS = {".pdf", ".docx", ".xlsx", ".png", ".jpg", ".jpeg", ".gif", ".zip"} IS_ANTHROPIC = MODEL.startswith("anthropic/") diff --git a/tests/integration/api_test.go b/tests/integration/api_test.go index 16834f4..b896850 100644 --- a/tests/integration/api_test.go +++ b/tests/integration/api_test.go @@ -204,6 +204,157 @@ func TestExecNonZeroExit(t *testing.T) { } } +func TestExtendedFileOps(t *testing.T) { + ts := setupTestServer(t) + + // Spawn sandbox + resp := doJSON(t, ts, "POST", "/api/v1/sandboxes", map[string]string{"image": "alpine"}) + var sb orchestrator.Sandbox + decodeJSON(t, resp, &sb) + id := sb.ID + + // Write a file + resp = doJSON(t, ts, "POST", "/api/v1/sandboxes/"+id+"/files", map[string]string{ + "path": "/workspace/hello.txt", "content": "hello world", + }) + if resp.StatusCode != 200 { + t.Fatalf("write: expected 200, got %d", resp.StatusCode) + } + resp.Body.Close() + + // Stat the file + req, _ := http.NewRequest("GET", ts.URL+"/api/v1/sandboxes/"+id+"/files/stat?path=/workspace/hello.txt", nil) + resp, _ = http.DefaultClient.Do(req) + if resp.StatusCode != 200 { + t.Fatalf("stat: expected 200, got %d", resp.StatusCode) + } + var fi orchestrator.FileInfo + decodeJSON(t, resp, &fi) + if fi.Size != 11 { + t.Fatalf("stat size: expected 11, got %d", fi.Size) + } + if fi.IsDir { + t.Fatal("stat: expected file, not dir") + } + + // Move the file + resp = doJSON(t, ts, "POST", "/api/v1/sandboxes/"+id+"/files/move", map[string]string{ + "old_path": "/workspace/hello.txt", "new_path": "/workspace/greeting.txt", + }) + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("move: expected 200, got %d: %s", resp.StatusCode, string(body)) + } + resp.Body.Close() + + // Read from new location + req, _ = http.NewRequest("GET", ts.URL+"/api/v1/sandboxes/"+id+"/files?path=/workspace/greeting.txt", nil) + resp, _ = http.DefaultClient.Do(req) + if resp.StatusCode != 200 { + t.Fatalf("read moved: expected 200, got %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if string(body) != "hello world" { + t.Fatalf("read moved: expected 'hello world', got %q", string(body)) + } + + // Chmod + resp = doJSON(t, ts, "POST", "/api/v1/sandboxes/"+id+"/files/chmod", map[string]string{ + "path": "/workspace/greeting.txt", "mode": "0755", + }) + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("chmod: expected 200, got %d: %s", resp.StatusCode, string(body)) + } + resp.Body.Close() + + // Write more files for glob test + for _, name := range []string{"a.log", "b.log", "c.txt"} { + resp = doJSON(t, ts, "POST", "/api/v1/sandboxes/"+id+"/files", map[string]string{ + "path": "/workspace/" + name, "content": "data", + }) + resp.Body.Close() + } + + // Glob for .log files + req, _ = http.NewRequest("GET", ts.URL+"/api/v1/sandboxes/"+id+"/files/glob?pattern=/workspace/*.log", nil) + resp, _ = http.DefaultClient.Do(req) + if resp.StatusCode != 200 { + t.Fatalf("glob: expected 200, got %d", resp.StatusCode) + } + var matches []string + decodeJSON(t, resp, &matches) + if len(matches) != 2 { + t.Fatalf("glob: expected 2 matches, got %d: %v", len(matches), matches) + } + + // Delete a file + req, _ = http.NewRequest("DELETE", ts.URL+"/api/v1/sandboxes/"+id+"/files?path=/workspace/greeting.txt", nil) + resp, _ = http.DefaultClient.Do(req) + if resp.StatusCode != 200 { + t.Fatalf("delete: expected 200, got %d", resp.StatusCode) + } + resp.Body.Close() + + // Verify deleted + req, _ = http.NewRequest("GET", ts.URL+"/api/v1/sandboxes/"+id+"/files?path=/workspace/greeting.txt", nil) + resp, _ = http.DefaultClient.Do(req) + if resp.StatusCode == 200 { + t.Fatal("expected file to be deleted") + } + resp.Body.Close() + + // Cleanup + resp = doJSON(t, ts, "DELETE", "/api/v1/sandboxes/"+id, nil) + resp.Body.Close() +} + +func TestPoolStatusEndpoint(t *testing.T) { + ts := setupTestServer(t) + + req, _ := http.NewRequest("GET", ts.URL+"/api/v1/pool/status", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("pool status: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("pool status: expected 200, got %d", resp.StatusCode) + } + + var status map[string]interface{} + decodeJSON(t, resp, &status) + // Pool is not enabled in test config, so we expect enabled=false + if status["enabled"] != false { + t.Fatalf("expected pool disabled, got %v", status["enabled"]) + } +} + +func TestOwnerIDHeader(t *testing.T) { + ts := setupTestServer(t) + + body, _ := json.Marshal(map[string]string{"image": "alpine:latest"}) + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/sandboxes", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-ID", "bob") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("spawn with owner: %v", err) + } + if resp.StatusCode != 201 { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("spawn: expected 201, got %d: %s", resp.StatusCode, string(body)) + } + var sb orchestrator.Sandbox + decodeJSON(t, resp, &sb) + if sb.OwnerID != "bob" { + t.Fatalf("expected owner_id 'bob', got %q", sb.OwnerID) + } + + // Cleanup + doJSON(t, ts, "DELETE", "/api/v1/sandboxes/"+sb.ID, nil) +} + func TestSandboxNotFound(t *testing.T) { ts := setupTestServer(t)