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

15 KiB

Sandbox JavaScript API

All methods are available on the global sandbox object. No constructor needed.

Quick Start

// 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).

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.

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.

// 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:

{
  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.

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.

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.

const node = sandbox.GetNode("tai-abc123")
if (node) {
  console.log(node.status, node.system.hostname)
}

sandbox.Nodes() → NodeInfo[]

List all registered nodes.

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.

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.

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:

box.Exec(["npm", "test"], {
  workdir: "/app",
  env:     { CI: "true" },
  timeout: 60000              // ms
})

Return value:

{
  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)
// 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.

// 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:

{
  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.

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.

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()).

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 for the full method list.

box.Info() → BoxInfo

Get current status information.

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.

box.Start()

box.Stop() → void

Stop a running sandbox.

box.Stop()

box.Remove() → void

Remove the sandbox and its container.

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.

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:

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:

{
  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.Streamfunction(type, data).

// 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).

const ws = host.Workspace("my-session")
ws.ReadFile("config.yml")
ws.WriteFile("output.json", JSON.stringify(data))
ws.ReadDir("results/")

See 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.

{
  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:

// 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

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

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

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

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

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

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"])