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 @@
+
-
-
+
@@ -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)