yao/sandbox/v2/jsapi/API.md
Max df420978f0 feat(workspace): implement JSAPI with archive support and full test coverage
- tai/volume: add WithRemotePath sync option, 8 archive/compression methods
  (Zip/Unzip/Gzip/Gunzip/Tar/Untar/Tgz/Untgz) with local+remote impls
- workspace/jsapi: implement 24-method WorkspaceFS (file I/O, Copy with
  local:///tmp:// URI dispatch, archive ops) and 4 static methods
- workspace/manager: add Rename, MkdirAll, Volume() accessors via singleton
- Tests: comprehensive Go tests for tai/volume, workspace, workspace/jsapi
  covering both local and remote modes via SANDBOX_TEST_REMOTE_ADDR
- CI: update tai Docker image to latest in pr-test.yml
- sandbox/v2: add JSAPI docs and host/node module stubs

Made-with: Cursor
2026-03-08 14:17:39 +08:00

588 lines
15 KiB
Markdown

# Sandbox JavaScript API
All methods are available on the global `sandbox` object. No constructor needed.
## Quick Start
```javascript
// Create a sandbox container
const box = sandbox.Create({ image: "node:20", owner: "user-123" })
// Execute a command
const result = box.Exec(["node", "-e", "console.log('hello')"])
console.log(result.stdout) // "hello\n"
// Clean up
box.Remove()
```
---
## Static Methods
### sandbox.Create(options) → Box
Create a new sandbox container. If `options.id` is set and a sandbox with that ID already exists, returns the existing one (GetOrCreate semantics).
```javascript
const box = sandbox.Create({
image: "node:20", // required — container image
owner: "user-123", // required — owner identifier
pool: "gpu", // optional — pool name (default: first pool)
id: "my-sandbox", // optional — if set, uses GetOrCreate
workdir: "/app", // optional — working directory
user: "1000:1000", // optional — UID:GID
env: { NODE_ENV: "dev" },// optional — environment variables
memory: 536870912, // optional — memory limit in bytes (512MB)
cpus: 1.5, // optional — CPU limit
vnc: true, // optional — enable VNC desktop
ports: [ // optional — port mappings
{ container_port: 3000, host_port: 3000, host_ip: "", protocol: "tcp" }
],
policy: "session", // optional — "oneshot"|"session"|"longrunning"|"persistent"
idle_timeout: 600000, // optional — idle timeout in ms (10min)
stop_timeout: 30000, // optional — stop timeout in ms
workspace_id: "ws-abc", // optional — bind a workspace
mount_mode: "rw", // optional — "rw"|"ro"
mount_path: "/workspace", // optional — mount path in container
labels: { team: "backend" } // optional — custom labels
})
```
### sandbox.Get(id) → Box | null
Get an existing sandbox by ID. Returns `null` if not found.
```javascript
const box = sandbox.Get("my-sandbox")
if (box) {
console.log(box.id, box.owner, box.pool)
}
```
### sandbox.List(filter?) → BoxInfo[]
List all sandboxes, optionally filtered.
```javascript
// All sandboxes
const all = sandbox.List()
// Filter by owner
const mine = sandbox.List({ owner: "user-123" })
// Filter by pool and labels
const gpu = sandbox.List({ pool: "gpu", labels: { team: "ml" } })
```
Each element in the returned array:
```javascript
{
id: "sb-xxx",
container_id: "abc123...",
pool: "default",
owner: "user-123",
status: "running", // "running"|"stopped"|"creating"|...
image: "node:20",
vnc: false,
policy: "session",
labels: { team: "backend" },
created_at: "2026-03-07T10:00:00Z",
last_active: "2026-03-07T10:05:00Z",
process_count: 2
}
```
### sandbox.Delete(id) → void
Remove a sandbox and its container.
```javascript
sandbox.Delete("my-sandbox")
```
### sandbox.Host(pool?) → Host
Get a Host object for executing commands directly on the Tai host machine (no container). Only available when the pool's Tai server has `host_exec` capability.
```javascript
const host = sandbox.Host() // default pool
const gpu = sandbox.Host("gpu") // specific pool
```
### sandbox.GetNode(taiID) → NodeInfo | null
Get information about a registered node by its Tai ID.
```javascript
const node = sandbox.GetNode("tai-abc123")
if (node) {
console.log(node.status, node.system.hostname)
}
```
### sandbox.Nodes() → NodeInfo[]
List all registered nodes.
```javascript
const nodes = sandbox.Nodes()
nodes.forEach(function(n) {
console.log(n.tai_id, n.status, n.pool, n.system.os)
})
```
### sandbox.NodesByTeam(teamID) → NodeInfo[]
List nodes belonging to a specific team.
```javascript
const nodes = sandbox.NodesByTeam("team-001")
```
---
## Box Object
Returned by `sandbox.Create()` and `sandbox.Get()`. Holds a sandbox ID internally; all operations delegate to the backend.
### Properties (read-only)
| Property | Type | Description |
|----------|------|-------------|
| `box.id` | string | Sandbox ID |
| `box.owner` | string | Owner identifier |
| `box.pool` | string | Pool name |
### box.Exec(cmd, options?) → ExecResult
Execute a command in the container and wait for it to finish.
```javascript
const result = box.Exec(["ls", "-la", "/app"])
console.log(result.exit_code) // 0
console.log(result.stdout) // file listing
console.log(result.stderr) // empty string
```
Options:
```javascript
box.Exec(["npm", "test"], {
workdir: "/app",
env: { CI: "true" },
timeout: 60000 // ms
})
```
Return value:
```javascript
{
exit_code: 0, // process exit code
stdout: "...", // captured stdout (string)
stderr: "..." // captured stderr (string)
}
```
### box.Stream(cmd, callback) / box.Stream(cmd, options, callback)
Execute a command with streaming output via callback. The call blocks until the process exits.
Callback signature: `function(type, data)`
- `type = "stdout"` → `data` is a string chunk from stdout
- `type = "stderr"` → `data` is a string chunk from stderr
- `type = "exit"` → `data` is the exit code (number)
```javascript
// Basic
box.Stream(["npm", "run", "dev"], function(type, data) {
if (type === "stdout") console.log(data)
if (type === "stderr") console.log("[ERR]", data)
if (type === "exit") console.log("exited:", data)
})
// With options
box.Stream(["npm", "test"], {
workdir: "/app",
env: { CI: "true" },
timeout: 60000
}, function(type, data) {
console.log(type, data)
})
```
### box.Attach(port, options?) → string
Get a WebSocket or SSE endpoint URL for a service running inside the container. Use this for persistent connections (WS/SSE). For plain HTTP requests, use `box.Proxy()` instead.
```javascript
// Get WebSocket URL
const wsURL = box.Attach(3000, { protocol: "ws", path: "/ws" })
// "ws://host:8099/container-id:3000/ws"
// Get SSE URL
const sseURL = box.Attach(8080, { protocol: "sse", path: "/events" })
// "http://host:8099/container-id:8080/events"
```
Options:
```javascript
{
protocol: "ws" | "sse", // default "ws"; affects URL scheme (ws:// vs http://)
path: "/ws" // optional URL path suffix
}
```
### box.VNC() → string
Get the VNC WebSocket URL for a VNC-enabled sandbox.
```javascript
const url = box.VNC()
// "ws://host:16080/vnc/sb-xxx"
```
### box.Proxy(port, path?) → string
Get an HTTP proxy URL for a port inside the container. Use this for plain HTTP requests. For WebSocket/SSE connections, use `box.Attach()` instead.
```javascript
const url = box.Proxy(3000)
// "http://host:8099/proxy/sb-xxx/3000/"
const url = box.Proxy(8080, "/api/v1")
// "http://host:8099/proxy/sb-xxx/8080/api/v1"
```
### box.Workspace() → WorkspaceFS
Access the workspace filesystem bound to this sandbox. The WorkspaceFS object is implemented in the `workspace/jsapi` package; this method returns it directly by calling `workspace.NewFSObject(v8ctx, box.WorkspaceID())`.
```javascript
const ws = box.Workspace()
const content = ws.ReadFile("src/main.go")
ws.WriteFile("src/main.go", "package main\n...")
ws.MkdirAll("src/utils")
const entries = ws.ReadDir("src/")
```
See [WorkspaceFS Object](#workspacefs-object) for the full method list.
### box.Info() → BoxInfo
Get current status information.
```javascript
const info = box.Info()
console.log(info.status, info.process_count, info.last_active)
```
Returns the same structure as elements in `sandbox.List()`.
### box.Start() → void
Start a stopped sandbox.
```javascript
box.Start()
```
### box.Stop() → void
Stop a running sandbox.
```javascript
box.Stop()
```
### box.Remove() → void
Remove the sandbox and its container.
```javascript
box.Remove()
```
---
## Host Object
Returned by `sandbox.Host()`. Executes commands directly on the Tai host machine without a container. Requires the pool to have `host_exec` capability.
### Properties (read-only)
| Property | Type | Description |
|----------|------|-------------|
| `host.pool` | string | Pool name |
### host.Exec(cmd, args, options?) → HostExecResult
Execute a command on the host and wait for it to finish.
```javascript
const result = host.Exec("ls", ["-la", "/workspace"])
console.log(result.exit_code) // 0
console.log(result.stdout) // file listing
console.log(result.duration_ms) // execution time
```
Options:
```javascript
host.Exec("python3", ["train.py"], {
workdir: "/workspace/ml",
env: { CUDA_VISIBLE_DEVICES: "0" },
stdin: "input data",
timeout: 300000, // ms
max_output: 10485760 // bytes (10MB)
})
```
Return value:
```javascript
{
exit_code: 0,
stdout: "...", // UTF-8 string
stderr: "...", // UTF-8 string
duration_ms: 1234, // execution time in ms
error: "", // error message (empty on success)
truncated: false // true if output was truncated by max_output
}
```
### host.Stream(cmd, args, callback) / host.Stream(cmd, args, options, callback)
Execute a command on the host with streaming output via callback. The call blocks until the process exits.
Callback signature: same as `box.Stream` — `function(type, data)`.
```javascript
// Basic
host.Stream("tail", ["-f", "/var/log/app.log"], function(type, data) {
if (type === "stdout") console.log(data)
})
// With options
host.Stream("python3", ["train.py"], {
workdir: "/workspace/ml",
timeout: 3600000
}, function(type, data) {
if (type === "stderr") console.log("[WARN]", data)
if (type === "exit") console.log("done, code:", data)
})
```
### host.Workspace(sessionID) → WorkspaceFS
Access a workspace on the host by session ID. Same as `box.Workspace()`, the WorkspaceFS object is implemented in the `workspace/jsapi` package; this method calls `workspace.NewFSObject(v8ctx, sessionID)`.
```javascript
const ws = host.Workspace("my-session")
ws.ReadFile("config.yml")
ws.WriteFile("output.json", JSON.stringify(data))
ws.ReadDir("results/")
```
See [WorkspaceFS Object](#workspacefs-object) for the full method list.
---
## NodeInfo Object
Returned by `sandbox.GetNode()`, `sandbox.Nodes()`, `sandbox.NodesByTeam()`. Read-only view of a registered Tai node.
```javascript
{
tai_id: "tai-abc123",
machine_id: "m-xyz",
version: "1.2.0",
mode: "direct", // "direct" | "tunnel"
addr: "192.168.1.100",
status: "online", // "online" | "offline" | "connecting"
pool: "gpu",
connected_at: "2026-03-07T08:00:00Z",
last_ping: "2026-03-07T10:05:00Z",
ports: {
grpc: 19100,
http: 8099,
vnc: 16080,
docker: 12375,
k8s: 16443
},
capabilities: {
docker: true,
k8s: false,
host_exec: true
},
system: {
os: "linux",
arch: "amd64",
hostname: "gpu-server-01",
num_cpu: 16,
total_mem: 68719476736 // bytes (64GB)
}
}
```
---
## WorkspaceFS Object
Returned by `box.Workspace()`, `host.Workspace()`, `workspace.Get()`, and `workspace.Create()`.
### Properties (read-only)
| Property | Type | Description |
|----------|------|-------------|
| `ws.id` | string | Workspace ID |
| `ws.name` | string | Workspace name |
| `ws.node` | string | Node name |
### Methods
| Method | Returns | Description |
|--------|---------|-------------|
| `ws.ReadFile(path)` | `string` | Read file content as UTF-8 string |
| `ws.WriteFile(path, data, perm?)` | `void` | Write string data to file. `perm` defaults to `0644` |
| `ws.ReadDir(path?)` | `DirEntry[]` | List directory contents. Defaults to root |
| `ws.Stat(path)` | `FileInfo` | Get file/directory metadata |
| `ws.MkdirAll(path, perm?)` | `void` | Create directory tree. `perm` defaults to `0755` |
| `ws.Remove(path)` | `void` | Remove a file |
| `ws.RemoveAll(path)` | `void` | Remove a file or directory recursively |
| `ws.Rename(from, to)` | `void` | Rename/move a file or directory |
Return types:
```javascript
// DirEntry
{ name: "main.go", is_dir: false, size: 1234 }
// FileInfo
{ name: "main.go", size: 1234, is_dir: false, mod_time: "2026-03-07T10:00:00Z" }
```
---
## Examples
### Run a build and check output
```javascript
const box = sandbox.Create({
image: "golang:1.23",
owner: "ci-bot",
workspace_id: "ws-project-abc"
})
const build = box.Exec(["go", "build", "./..."], {
workdir: "/workspace",
timeout: 120000
})
if (build.exit_code !== 0) {
console.log("Build failed:", build.stderr)
box.Remove()
throw new Error("build failed")
}
const test = box.Exec(["go", "test", "./..."], {
workdir: "/workspace",
env: { CGO_ENABLED: "0" }
})
console.log("Tests:", test.exit_code === 0 ? "PASS" : "FAIL")
box.Remove()
```
### Stream a long-running process
```javascript
const box = sandbox.Create({
image: "node:20",
owner: "user-123",
policy: "session"
})
box.Exec(["npm", "install"], { workdir: "/app" })
box.Stream(["npm", "run", "dev"], { workdir: "/app" }, function(type, data) {
if (type === "stdout") console.log(data)
if (type === "stderr") console.log("[ERR]", data)
if (type === "exit") console.log("dev server exited:", data)
})
```
### Host execution for GPU workloads
```javascript
const host = sandbox.Host("gpu")
const result = host.Exec("nvidia-smi", [])
console.log(result.stdout)
host.Exec("python3", ["train.py", "--epochs=10"], {
workdir: "/workspace/ml",
env: { CUDA_VISIBLE_DEVICES: "0,1" },
timeout: 3600000
})
```
### Query cluster nodes
```javascript
const nodes = sandbox.Nodes()
// Find online GPU nodes
const gpuNodes = nodes.filter(function(n) {
return n.status === "online" && n.pool === "gpu"
})
console.log("Available GPU nodes:", gpuNodes.length)
gpuNodes.forEach(function(n) {
console.log(
n.tai_id,
n.system.hostname,
n.system.num_cpu + " CPUs",
Math.round(n.system.total_mem / 1073741824) + "GB RAM"
)
})
```
### Workspace file operations
```javascript
const ws = workspace.Create({
name: "my-project",
owner: "user-123",
node: "default"
})
ws.MkdirAll("src/utils")
ws.WriteFile("src/main.go", 'package main\n\nfunc main() {\n\tprintln("hello")\n}\n')
ws.WriteFile("go.mod", "module myproject\n\ngo 1.23\n")
const entries = ws.ReadDir("src/")
entries.forEach(function(e) {
console.log(e.name, e.is_dir ? "(dir)" : e.size + " bytes")
})
const content = ws.ReadFile("src/main.go")
console.log(content)
```
### Permission check pattern
```javascript
const auth = Authorized()
if (!auth) throw new Error("not authenticated")
const box = sandbox.Get(id)
if (!box) throw new Error("sandbox not found")
if (box.owner !== auth.user_id) throw new Error("permission denied")
box.Exec(["ls", "-la"])
```