mcpField
U
upstash-box-js

upstash-box-js

github.com/upstash云与 DevOps341.3k 次安装1 天前更新
代码仓库

关于 upstash-box-js

upstash-box-js 是用于创建和管理沙箱云容器的 TypeScript SDK,提供 Agent、命令行、文件系统及可选浏览器功能,面向需要隔离运行代码和 Agent 任务的开发者。

主要功能

  1. 01创建和管理沙箱容器
  2. 02运行 Agent、Shell 命令和代码
  3. 03读写容器文件
  4. 04克隆仓库并管理 Git 状态
  5. 05安排命令或 Agent 提示词
  6. 06驱动无头 Chromium 浏览器

适用场景

  1. 01在隔离容器中运行 Agent 进行代码审查
  2. 02对克隆的仓库执行构建或测试
  3. 03使用 Chromium 自动化浏览器任务
  4. 04在容器中安排定期命令

MCP 相关

README 提到,启用浏览器后会自动将 chrome-devtools MCP 接入容器内的编码 Agent。

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/upstash/skills --skill upstash-box-js

安装后在 Claude Code 中输入/upstash-box-js确认已加载。

详细介绍

@upstash/box SDK

沙盒化云容器,内置 AI Agent、shell、文件系统、git、cron 计划任务,以及可选的无头浏览器。

Python SDK(upstash-box)使用 snake_case 命名,与此 API 一一对应——有关下文所有内容的 Python 写法,请参阅 upstash-box-py 技能。

内置文档和源代码

编写或调试 SDK 代码之前,请查看 node_modules/@upstash/box/docs/ 中的内置文档,以及 node_modules/@upstash/box/src/ 中易于阅读的 TypeScript 源代码。若相关文件存在,请阅读并搜索;否则请使用此技能中的参考资料。

安装与设置

npm install @upstash/box
npm install zod   # peer dependency, only needed for responseSchema / browser schemas

设置 UPSTASH_BOX_API_KEY 环境变量,或在构造函数中传入 apiKey。

默认会发送匿名遥测标头。可通过 UPSTASH_DISABLE_TELEMETRY 环境变量,或在配置中设置 enableTelemetry: false 选择退出(在没有 process.env 的运行时中,例如 Cloudflare Workers,这是唯一可用的选项)。

Box 生命周期

import { Box, Agent, ClaudeCode, BoxApiKey } from "@upstash/box"

// Create with agent + git + env vars
const box = await Box.create({
  name: "my-box",
  runtime: "node", // "node" | "python" | "golang" | "ruby" | "rust" (+ "-alpine" variants)
  size: "small",   // "small" (2 CPU/4GB) | "medium" (4/8) | "large" (8/16)
  labels: ["beta", "x-team"], // max 5, ≤20 chars each
  keepAlive: true,            // don't idle-pause the box
  initCommand: "npm install && npm run dev", // keep-alive boxes only
  browser: true,              // provision headless Chromium for box.browser
  agent: {
    harness: Agent.ClaudeCode, // Agent.Codex | Agent.OpenCode | Agent.Cursor | Agent.Custom
    model: ClaudeCode.Sonnet_4_5,
    // apiKey options:
    //   omit          → server decides which key to use
    //   BoxApiKey.UpstashKey  → use Upstash-provided LLM key
    //   BoxApiKey.StoredKey   → use key previously stored via Upstash Console
    //   "sk-..."      → direct API key string
    apiKey: BoxApiKey.UpstashKey,
  },
  git: { // all fields optional
    token: process.env.GITHUB_TOKEN, // alternatively link your GitHub account via Upstash Console
    userName: "Bot",
    userEmail: "[email protected]",
  },
  env: { DATABASE_URL: "..." },
  skills: ["upstash/qstash-js/qstash-js"], // owner/repo/skill-name
  timeout: 600_000, // request timeout in ms
  debug: false,
})

// Reconnect, list, delete, pause/resume
// Box.get / Box.getByName take { apiKey, baseUrl, gitToken, timeout, debug }
const same = await Box.get(box.id, { gitToken: process.env.GITHUB_TOKEN })
const byName = await Box.getByName("my-box")
const all = await Box.list()
const beta = await Box.list({ label: "beta" }) // filter by label
await box.pause()   // throws on keep-alive boxes — they are never idle-paused
await box.resume()
await box.delete()  // irreversible
const { status } = await box.getStatus()

box.id; box.size; box.keepAlive; box.cwd; box.networkPolicy

// Init command (keep-alive boxes only — throws otherwise)
await box.setInitCommand("npm run dev")
const script = await box.getInitCommand()
await box.deleteInitCommand()

// Bulk delete (static, by ID). An empty or blank id list throws — it is never
// read as "everything". Omit snapshotIds entirely to delete all snapshots.
await Box.delete({ boxIds: ["box_1", "box_2"] })
const { deleted } = await Box.deleteSnapshots({ snapshotIds: ["snap_1"] })
await Box.deleteSnapshots() // delete all
账号级环境变量

注入你创建的每个 Box 中。

await Box.setEnv("API_TOKEN", "secret")
const env = await Box.listEnv()          // values are masked
await Box.setAllEnv({ A: "1", B: "2" })  // full replace — unlisted keys are removed
await Box.deleteEnv("API_TOKEN")

Agent 运行

import { z } from "zod"

// Structured output with Zod schema
const run = await box.agent.run({
  prompt: "Review the code for security issues",
  responseSchema: z.object({
    verdict: z.enum(["approved", "changes_requested"]),
    findings: z.array(z.object({
      severity: z.enum(["high", "medium", "low"]),
      file: z.string(),
      issue: z.string(),
    })),
  }),
  timeout: 120_000,
  maxRetries: 2,
  options: { maxTurns: 20, maxBudgetUsd: 1.0, effort: "high" }, // harness-specific
  onToolUse: (tool) => console.log(tool.name, tool.input),
  onToolResult: (result) => console.log(result.toolCallId, result.output),
})

run.status  // "running" | "completed" | "failed" | "cancelled" | "detached"
run.result  // typed from schema
run.cost    // { inputTokens, outputTokens, cachedInputTokens, computeMs, totalUsd }

// Attach files to a prompt (max 10 files, 10 MB each)
await box.agent.run({ prompt: "Describe this", files: ["./screenshot.png"] })
await box.agent.run({
  prompt: "Describe this",
  files: [{ data: base64, mediaType: "image/png", filename: "shot.png" }],
})

// Streaming — chunk is a discriminated union
const stream = await box.agent.stream({ prompt: "Build a REST API" })
for await (const chunk of stream) {
  if (chunk.type === "text-delta") process.stdout.write(chunk.text)
  if (chunk.type === "reasoning") process.stdout.write(chunk.text)
  if (chunk.type === "tool-call") console.log(chunk.toolName, chunk.input)
  if (chunk.type === "tool-result") console.log(chunk.output)
  if (chunk.type === "finish") console.log(chunk.output, chunk.usage, chunk.sessionId)
  // also: { type: "start", runId } | { type: "stats", cpuNs, memoryPeakBytes } | { type: "unknown" }
}
stream.status // "completed" after iteration finishes
stream.result // final output

// stream() takes the same prompt/files/options/timeout/onToolUse/onToolResult as run().
// It has no responseSchema, maxRetries, or webhook — use run() for those.

// Fire-and-forget with webhook
await box.agent.run({
  prompt: "Run tests",
  webhook: { url: "https://example.com/hook", headers: { Authorization: "Bearer ..." } },
})
Agent 选项(按运行框架区分)

options 会传递给运行框架——接受的键取决于 Box 使用的运行框架。为 Box 指定类型(Box.create<Agent.ClaudeCode>({...}))后,options 会缩小为该运行框架对应的类型。

// Agent.ClaudeCode → ClaudeCodeAgentOptions
{
  maxTurns: 20,
  maxBudgetUsd: 1.0,
  effort: "high",                 // "low" | "medium" | "high" | "max"
  thinking: { type: "adaptive" }, // | { type: "enabled", budgetTokens: 8000 } | { type: "disabled" }
  disallowedTools: ["Bash"],
  agents: { reviewer: { /* custom subagent definition */ } },
  promptSuggestions: false,
  fallbackModel: "anthropic/claude-sonnet-4-5",
  systemPrompt: "You are a release engineer.",
}

// Agent.Codex → CodexAgentOptions
{
  modelReasoningEffort: "high",   // "none" | "minimal" | "low" | "medium" | "high" | "xhigh"
  modelReasoningSummary: "concise", // "auto" | "concise" | "detailed" | "none"
  personality: "pragmatic",       // "friendly" | "pragmatic" | "none"
  webSearch: "live",              // or true / false
}

// Agent.OpenCode → OpenCodeAgentOptions
{
  reasoningEffort: "high",        // "low" | "medium" | "high"
  textVerbosity: "low",           // "low" | "medium" | "high"
  reasoningSummary: "auto",       // "auto" | "concise" | "detailed" | "none"
  thinking: { type: "enabled", budgetTokens: 8000 }, // Anthropic-backed models
}

// Agent.Cursor → free-form Record<string, unknown>

Codex 的键会自动转换为后端使用的 snake_case——请始终使用 camelCase 编写。

运行框架与模型

必须指定 harness(provider / runner 是已弃用的别名)。模型枚举:ClaudeCode、OpenAICodex、OpenCodeModel、CursorModel、OpenRouterModel、VercelModel,也可以使用任意以提供商名称为前缀的普通字符串。

import { ClaudeCode, OpenAICodex, OpenCodeModel, CursorModel, OpenRouterModel, VercelModel } from "@upstash/box"

ClaudeCode.Fable_5_1     // "anthropic/claude-fable-5-1"
ClaudeCode.Opus_5        // "anthropic/claude-opus-5"
ClaudeCode.Sonnet_5      // "anthropic/claude-sonnet-5"
OpenAICodex.GPT_6_Astra  // "openai/gpt-6-astra"
OpenAICodex.GPT_5_6      // "openai/gpt-5.6"
OpenCodeModel.Claude_Opus_5   // "opencode/claude-opus-5"
CursorModel.Composer_2_5 // "cursor/composer-2.5"
OpenRouterModel.Claude_Opus_5 // "openrouter/anthropic/claude-opus-5"
VercelModel.GPT_5_5      // "vercel/openai/gpt-5.5"

// Read / change the box's harness + model at runtime
const { harness, model } = box.modelConfig
await box.configureModel("anthropic/claude-opus-4-8")

// Which harness a bare model string implies (prefix-based)
import { inferDefaultProvider } from "@upstash/box"
inferDefaultProvider("openai/gpt-5.6")   // Agent.Codex
inferDefaultProvider("cursor/default")   // Agent.Cursor
自定义运行框架

在 Box 内运行自己的 Agent 二进制文件,而非使用托管运行框架。

import { Box, Agent, runCustomHarness } from "@upstash/box"

const box = await Box.create({
  agent: {
    harness: Agent.Custom,
    model: "my-agent",                                  // label forwarded to the process
    // command: name on PATH, or an absolute path under /workspace/home or /home/boxuser
    customHarness: { command: "node", args: ["/workspace/home/agent.js"], protocol: "box-sse-v1" },
  },
})
await box.configureCustomHarness({ command: "node", args: ["/workspace/home/agent2.js"] })

// Inside the box, agent.js emits box-sse-v1 events. The backend appends
// `-p <prompt> --model <model> --stream` (+ `--session <id>` when resuming).
await runCustomHarness(async ({ prompt, model, sessionId, stream, args }, emit) => {
  emit.text("working...")
  emit.reasoning("thinking out loud")            // -> `thinking` event
  emit.tool({ toolCallId: "1", name: "Bash", input: { command: "ls" } })
  emit.toolResult({ toolCallId: "1", output: "file.txt" })
  emit.emit("custom-event", { any: "payload" })  // raw escape hatch
  // emit.error(new Error("boom")) to fail the run
  return {
    output: "done",
    inputTokens: 10,
    outputTokens: 5,
    cachedInputTokens: 0,
    totalCostUsd: 0.01,
    sessionId,
  } // returning a plain string is shorthand for { output }
})

运行字段

每次 run(Agent、命令或代码)都会返回一个 Run<T>:

const run = await box.exec.command("npm test")
run.id        // run ID
run.status    // "completed" | "failed" | ...
run.result    // stdout on success, stderr on failure (or typed T with responseSchema)
run.stdout    // raw stdout (command/code runs)
run.stderr    // raw stderr (command/code runs)
run.exitCode  // number | null (null for agent runs)
run.cost      // { inputTokens, outputTokens, cachedInputTokens, computeMs, totalUsd }

await run.cancel()          // the pending run()/stream() rejects with the abort, and is not retried
const logs = await run.logs() // [{ timestamp, level, message }]

// Box-level history
const entries = await box.logs({ limit: 100, offset: 0 }) // [{ timestamp, level, source, message }]
const runs = await box.listRuns()              // backend run records, newest first

Shell 执行

// Run commands
const run = await box.exec.command("echo hello && ls -la")

// Run code snippets — lang: "js" | "ts" | "python"
const run2 = await box.exec.code({ code: "console.log(1+1)", lang: "js", timeout: 10_000 })

// Streaming shell / code
const stream = await box.exec.stream("npm run build")
const stream2 = await box.exec.streamCode({ code: "print('hi')", lang: "python" })
for await (const chunk of stream) {
  // chunk: { type: "output", data } | { type: "exit", exitCode, cpuNs }
}
实时会话

exec.session() 会通过 WebSocket 连接到仍在运行的进程——支持 stdin、流式 stdout/stderr、PTY 调整大小和信号。仅限 Node:身份验证通过握手标头进行,浏览器无法设置该标头(ws 已作为 SDK 依赖项提供,无需安装)。Box 和 EphemeralBox 均可使用。

const session = await box.exec.session({
  cmd: "sort",              // run via `bash -lc`; `argv: ["sort"]` runs the program with
                            // no shell and takes precedence over cmd
  cwd: "/workspace/home",   // defaults to the box's tracked cwd
  env: ["LOG_LEVEL=debug"], // KEY=VALUE entries overlaid on the box environment
  onStdout: (bytes) => process.stdout.write(bytes), // Uint8Array
  onStderr: (bytes) => process.stderr.write(bytes), // separate stream unless tty
})

session.pid                        // in-box PID, always non-zero
session.execId                     // server-side exec id
session.write("banana\napple\n")
session.endStdin()                 // EOF — a command that reads to EOF now exits by itself
const exitCode = await session.wait()  // -1 if torn down while still running
session.close()                    // hang up; also kills the process

// Interactive programs / TUIs — tty allocates a real PTY, merging stderr into stdout
const shell = await box.exec.session({ argv: ["bash", "-i"], tty: true, rows: 40, cols: 120 })
shell.resize(50, 160)
shell.kill("INT")     // allowlist: TERM KILL INT HUP TSTP QUIT USR1 USR2 (default TERM)
shell.terminate(5000) // server-side SIGTERM, then SIGKILL after the grace (first call wins)

会话负责管理进程:调用 close()、连接断开或进程退出,都会终止命令;会话也无法重新连接。若要运行任务直至完成,请使用 wait()。

文件系统

await box.files.write({ path: "/workspace/home/app.js", content: "console.log('hi')" })
const content = await box.files.read("/workspace/home/app.js")
const entries = await box.files.list("/workspace/home") // [{ name, path, size, is_dir, mod_time }]

// Binary files — use encoding: "base64" for read and write
await box.files.write({ path: "/workspace/home/image.png", content: base64String, encoding: "base64" })
const b64 = await box.files.read("/workspace/home/image.png", { encoding: "base64" })

// Bounded byte-range read — the *presence* of `length` selects the range, so
// { length: 0 } reads zero bytes rather than the whole file. Server caps it at 8 MiB.
const head = await box.files.read("/workspace/home/big.log", { length: 64 * 1024 })
const slice = await box.files.read("/workspace/home/big.log", { offset: 1024, length: 512 })

// Metadata — defaults to lstat, so a symlink reports type "symlink"
const stat = await box.files.stat("/workspace/home/app.js")
// stat: { type: "file" | "directory" | "symlink" | "other", size, mod_time, inode, version }
// `version` is an opaque freshness token (inode + mtime + size) for optimistic-concurrency
// guards — compare it for equality, never parse it.
const target = await box.files.stat("/workspace/home/link", { follow: true }) // dereference

// Directories, moves, deletes
await box.files.mkdir("build/cache", { parents: true }) // parents mirrors `mkdir -p`
await box.files.rename("draft.md", "docs/final.md")     // move/rename
await box.files.remove("build/cache", { recursive: true }) // recursive required for a directory

// Upload local files
await box.files.upload([{ path: "./local/file.txt", destination: "/workspace/home/file.txt" }])

// Download — `folder` is a path INSIDE the box; files land in ./<basename>
await box.files.download({ folder: "src" }) // → ./src
await box.files.download()                  // whole cwd → ./workspace

cd / 工作目录

SDK 会在客户端跟踪 cwd。所有操作(exec、文件、git、Agent)都相对于该目录执行。

box.cwd // current working directory (starts at /workspace/home)
await box.cd("my-repo")     // relative to current cwd
await box.cd("/workspace/home/other") // absolute path

Git

克隆内容会存放在 Box 隔离的容器内,不会出现在调用方机器上。克隆的代码在运行之前都只是数据——请将不受信任的仓库视为不受信任的输入,并在运行其构建或测试之前配合限制严格的 networkPolicy(见下文)。

除 clone 外,所有 git 调用都会在 Box 的当前目录中执行,因此请先 cd 进入克隆目录。在工作区根目录中没有仓库,status 会返回空结果,表示工作区干净。

await box.git.clone({ repo: "github.com/org/repo", branch: "main" })
await box.git.clone({ repo: "github.com/org/repo", depth: 1 }) // shallow clone
await box.git.clone({ repo: "github.com/org/repo", folder: "my-app" }) // destination
await box.cd("repo") // the clone lands in a directory named after the repo

const status = await box.git.status()
const diff = await box.git.diff()
const { sha } = await box.git.commit({
  message: "fix: resolve bug",
  authorName: "Jane Doe",      // optional per-commit override
  authorEmail: "[email protected]",
})
await box.git.push({ branch: "feature/fix" })

await box.git.checkout({ branch: "release/v2" })
const pr = await box.git.createPR({ title: "Fix bug", body: "...", base: "main" })
// pr: { url, number, title, base, warning? }
const issue = await box.git.createIssue({ title: "Flaky test in CI", body: "..." })
// issue: { url, number, title, warning? }

// Attach images or videos to a PR or issue. Paths are relative to the box's
// working directory; alt text for an image is written as `shot.png#alt text`
// (a video takes none). Reference an attachment from the body as
// ![alt](./shot.png) and GitHub rewrites it to the uploaded asset.
await box.git.createPR({
  title: "Fix the tower layout",
  body: "![before](./before.png)\n![after](./after.png)",
  attach: ["before.png#before", "after.png#after"],
})
// `warning` is set when `gh` exited non-zero but still returned a URL: the item
// exists while an attachment failed to upload, or the PR was already open.

// Update the box-wide git identity
const cfg = await box.git.updateConfig({ userName: "Bot", userEmail: "[email protected]" })
// cfg: { git_user_name, git_user_email }

// Arbitrary git commands. Check exit_code: 128 means the cwd is not a repository.
const { output, exit_code } = await box.git.exec({ args: ["log", "--oneline", "-5"] })

计划任务

在 Box 上运行 cron 任务——shell 命令或 Agent 提示词。Box 和 EphemeralBox 均可使用。Cron 使用 UTC 时间。

const execSchedule = await box.schedule.exec({
  cron: "* * * * *",
  command: ["bash", "-c", "date >> /workspace/home/cron.log"],
  folder: "/workspace/home",            // optional cwd override
  webhookUrl: "https://example.com/hook",
  webhookHeaders: { Authorization: "Bearer ..." },
})

const agentSchedule = await box.schedule.agent({
  cron: "0 9 * * *",
  prompt: "Run the test suite and fix any failures",
  folder: "/workspace/home/repo",       // optional cwd override
  model: "anthropic/claude-sonnet-5",   // optional override
  options: { maxBudgetUsd: 1.0, effort: "high" },
  timeout: 300_000,
  webhookUrl: "https://example.com/hook",
  webhookHeaders: { Authorization: "Bearer ..." },
})

const schedules = await box.schedule.list()
const one = await box.schedule.get(agentSchedule.id)

// Partial update — omitted fields keep their value, "" / [] / {} clear a field,
// `options: null` clears agent options. The schedule's type cannot change.
// Updatable: cron, command, prompt, folder, model, options, timeout, webhookUrl, webhookHeaders
await box.schedule.update(agentSchedule.id, { cron: "0 18 * * *", webhookUrl: "" })

await box.schedule.pause(agentSchedule.id)
await box.schedule.resume(agentSchedule.id)
await box.schedule.delete(agentSchedule.id)

快照

// Snapshot — checkpoint workspace state
const snap = await box.snapshot({ name: "after-setup" })
// snap: { id, name, box_id, size_bytes, status, created_at }

// fromSnapshot takes a BoxConfig: name, labels, size, keepAlive, initCommand, runtime,
// agent, git, env, attachHeaders, networkPolicy. It does NOT send `browser`, `skills`,
// or `mcpServers` (the Python SDK does) — add skills with box.skills.add() afterwards,
// and use Box.create({ browser: true }) when you need Chromium.
const restored = await Box.fromSnapshot(snap.id, {
  size: "medium",
  keepAlive: true,
  // git identity is forwarded, not just the token
  git: { token: process.env.GITHUB_TOKEN, userName: "Bot", userEmail: "[email protected]" },
  env: { DATABASE_URL: "..." },
})
const snaps = await box.listSnapshots()
await box.deleteSnapshot(snap.id)

浏览器

创建 Box 时设置 browser: true,即可驱动无头 Chromium。标签页管理通过 box.browser 完成;每个页面操作都通过 Tab 句柄执行。extract / observe / act(instruction) 由 AI 驱动并按使用量计费;act(action) 会重放已解析的操作,不会调用 LLM,也不会消耗令牌。

import { z } from "zod"

const box = await Box.create({
  browser: true,
  agent: { harness: Agent.ClaudeCode, model: ClaudeCode.Sonnet_4_5 },
})

// Tabs
const tab = await box.browser.tab.create("https://example.com", { waitUntil: "load", timeout: 30_000 })
const tabs = await box.browser.listTabs()
const again = box.browser.getTab(tab.id) // no network call
tab.id; tab.url; tab.title                // handle metadata, no network call

// Page operations
const content = await tab.goto("https://news.ycombinator.com") // { title, url, text, links }
const current = await tab.content()
const png = await tab.screenshot()                                  // Uint8Array
const b64 = await tab.screenshot({ type: "base64", fullPage: true })

// AI operations (metered) — extract/observe/act take an optional { model } override,
// defaulting to the box's model (or anthropic/claude-sonnet-4-5 when it has none)
const data = await tab.extract(
  "Top story title and points",
  z.object({ title: z.string(), points: z.number() }),
  { model: "anthropic/claude-sonnet-4-5" },
)
// observe → actionable elements, each carrying a replayable method + arguments
const { elements } = await tab.observe("What can I click?", { model: "openai/gpt-5.6" })
// elements: [{ description, selector?, url?, method?, arguments? }]

const acted = await tab.act("Click the first headline")
// acted: { success, message, actionDescription, actions, cacheStatus?, inputTokens, outputTokens }

// Replay a pre-resolved action — no LLM call, no tokens, no model provider key.
// Takes a BrowserAction (= BrowserObserveElement | BrowserActAction); `model` is
// ignored in this form, and an action without a `selector` throws.
await tab.act(elements[0])
await tab.act(acted.actions[0])

// Live view + raw CDP
const liveUrl = await tab.liveViewUrl()      // view-only screencast page/iframe
const cdpUrl = await box.browser.cdpUrl()    // wss://…?token=… — no extra auth wiring
await tab.close()

// Drive the same browser from Playwright / Puppeteer / Stagehand
import { chromium } from "playwright-core"
const remote = await chromium.connectOverCDP(cdpUrl)
const context = remote.contexts()[0] ?? (await remote.newContext())
const page = context.pages()[0] ?? (await context.newPage())
await page.goto("https://example.com")
// Stagehand: new Stagehand({ env: "LOCAL", localBrowserLaunchOptions: { cdpUrl } })

// Session recordings (HLS playback URL + MP4 download, chapter markers).
// One active recording per box; captures all tabs and follows the foreground.
// Auto-stops after maxDurationSeconds or ~3 minutes of no on-screen activity.
const handle = await box.browser.recordings.start({ maxDurationSeconds: 600 }) // default & max 600
const recording = await handle.stop()
// or stop whatever is recording on the box, without a handle:
// const recording = await box.browser.recordings.stop()
// recording: { id, boxId, status, startedAt, endedAt, durationMs, sizeBytes, mp4SizeBytes,
//              segmentCount, markers, stoppedReason, maxDurationSeconds, expiresAt, playlistUrl }
// markers: { type: "tab_switch", atMs, endMs?, label?, tabId? }
// expiresAt is epoch ms (videos retained 14 days); playlistUrl is API-served — fetch it
// with an `X-Box-Api-Key: <apiKey>` header (hls.js / Safari / ffplay).
const all = await box.browser.recordings.list()
const one = await box.browser.recordings.get(recording.id)

// Download the video to a local file — returns the path written.
// Defaults to ./box-recording-<id>.mp4 (.ts for recordings captured before MP4 support).
const file = await box.browser.recordings.download(recording.id)
await box.browser.recordings.download(recording.id, { path: "./out/demo.mp4" })
多步骤浏览器目标

tab.run() — 自主多步骤浏览器 Agent — 已在 0.7.0 中移除, BrowserRunOptions / BrowserRunResult / BrowserRunStep 类型也一并移除 (Stagehand v4 移除了底层 Agent 原语)。现在,支持 DOM 感知的浏览器仅 提供 observe、act 和 extract。以下是三种替代方案:

1. 自行驱动循环 — 使用 observe 一次性解析步骤,然后用 act(action) 重放,这样模型就不会进入热路径;extract 用于检查是否停止。

const { elements } = await tab.observe("the product links in the listing")
const actions = elements.filter((e) => e.selector)

for (const action of actions.slice(0, 5)) {
  await tab.goto(START) // deterministic reset, no browser-AI tokens
  await tab.act(action) // replay the resolved click: no LLM, no tokens
  const item = await tab.extract("title and price", z.object({ title: z.string() }))
}

2. 将目标交给内置 Agent — browser: true 会自动将 chrome-devtools MCP (Chromium 已在 127.0.0.1:9222 上预热)接入 box 的编码 Agent,因此 box.agent.run({ prompt }) 会自行驱动浏览器并持续迭代,直到完成。无需先调用 tab.create()。此方式会按编码 Agent 的模型 Token 计费,而非按浏览器 AI 用量 计费,并且需要 Agent 执行框架和密钥。

3. 通过 CDP 连接,在流程完全确定时,使用 Playwright / Puppeteer 和 box.browser.cdpUrl()。

EphemeralBox

轻量、短期的 box(最长 3 天)。支持 exec、files、schedule、cd、网络策略和快照。不支持 Agent、git、技能、labels 命名空间、浏览器或公共 URL。

import { EphemeralBox } from "@upstash/box"

const ebox = await EphemeralBox.create({
  name: "scratch-box",
  runtime: "python",
  size: "small",
  ttl: 3600,  // seconds, max 259200 (3 days), default 259200
  env: { API_KEY: "..." },
  labels: ["scratch"], // settable at create time; filter via Box.list({ label })
  networkPolicy: { mode: "deny-all" },
  attachHeaders: { "api.stripe.com": { Authorization: "Bearer sk_live_..." } },
})

ebox.networkPolicy

ebox.expiresAt // unix timestamp when auto-deleted
await ebox.exec.command("python -c 'print(1+1)'")
await ebox.exec.code({ code: "print('hi')", lang: "python" })
await ebox.exec.session({ argv: ["bash", "-i"], tty: true }) // whole exec namespace, session included
await ebox.files.write({ path: "/workspace/home/data.json", content: "{}" })
await ebox.files.stat("/workspace/home/data.json")           // whole files namespace, stat/mkdir/rename/remove included
await ebox.schedule.exec({ cron: "* * * * *", command: ["bash", "-c", "date"] })
await ebox.cd("subdir")
const snap = await ebox.snapshot({ name: "checkpoint" })
await ebox.listSnapshots()
await ebox.deleteSnapshot(snap.id)
const { status } = await ebox.getStatus()
await ebox.delete()

// Restore from snapshot
const ebox2 = await EphemeralBox.fromSnapshot(snap.id, { ttl: 7200 })

// Statics: EphemeralBox.delete({ boxIds }) and EphemeralBox.deleteSnapshots() are the
// Box ones. EphemeralBox.getByName() is Box.get — it returns a full `Box`, not an
// `EphemeralBox` (quirk mirrored in the Python SDK).

公共 URL

将 box 端口公开为公共 URL,并可选启用身份验证。

const publicURL = await box.getPublicURL(3000)
// publicURL: { url: "https://{id}-3000.preview.box.upstash.com", port }

const authed = await box.getPublicURL(3000, { bearerToken: true })
// authed: { url, port, token }

const basic = await box.getPublicURL(3000, { basicAuth: true })
// basic: { url, port, username, password }

const { publicURLs } = await box.listPublicURLs()
await box.deletePublicURL(3000)

技能

从 Context7 注册表安装 Agent 技能。格式:owner/repo/skill-name。

已安装的技能会成为 box 的 Agent 指令,因此应像对待依赖项一样,只固定使用你信任的所有者提供的技能。技能会在创建 box 时从注册表解析,而非从任意 URL 获取,并且只会在 box 的容器内运行。

const box = await Box.create({ skills: ["upstash/qstash-js/qstash-js"] })

await box.skills.add("upstash/workflow-js/workflow-js")
const enabled = await box.skills.list()
await box.skills.remove("upstash/workflow-js/workflow-js")

标签

const labels = await box.labels.add("prod")     // returns the updated set
await box.labels.remove("beta")
const current = await box.labels.list()
const prodBoxes = await Box.list({ label: "prod" })

网络策略与出站标头

const box = await Box.create({
  // mode: "allow-all" (default) | "deny-all" | "custom"
  // custom takes any of allowedDomains / allowedCidrs / deniedCidrs
  networkPolicy: {
    mode: "custom",
    allowedDomains: ["api.example.com"],
    allowedCidrs: ["203.0.113.0/24"],
    deniedCidrs: ["10.0.0.0/8"],
  },

  // Inject secret headers into matching outbound HTTPS requests (write-only, never read back)
  attachHeaders: {
    "api.stripe.com": { Authorization: "Bearer sk_live_..." },
    "*.example.com": { "X-Custom-Token": "secret123" },
  },
})

box.networkPolicy
await box.updateNetworkPolicy({ mode: "deny-all" })

MCP 服务

将 MCP 服务接入 box Agent。接入的服务会提供 Agent 可调用的工具,因此请使用由你控制或信任的服务;如果 Agent 还会处理不可信输入,请保持 networkPolicy 的限制性。

const box = await Box.create({
  agent: { harness: Agent.ClaudeCode, model: ClaudeCode.Sonnet_4_5 },
  mcpServers: [
    { name: "fs", package: "@modelcontextprotocol/server-filesystem", args: [] },
    { name: "custom", url: "<your-mcp-server-url>", headers: { Authorization: "..." } },
  ],
})

错误与 SSH

import { BoxError } from "@upstash/box"

try {
  await box.agent.run({ prompt: "..." })
} catch (e) {
  if (e instanceof BoxError) console.error(e.message, e.statusCode)
}

直接通过 Shell 连接到 box(Box API 密钥即 SSH 密码):

ssh <box-id>@us-east-1.box.upstash.com

注意事项

  • 默认工作目录是 /workspace/home,不是 /home 或 /
  • box.cd() 用于客户端跟踪路径——它会验证路径是否存在,但不会更改 box 的 shell 当前工作目录。所有 SDK 方法都会自动使用它。
  • 必须设置 agent.harness;provider / runner 仍可使用,但已弃用
  • 没有 box.fork()——它已从 SDK 中移除。请创建 box 的快照,并改用 Box.fromSnapshot()。
  • EphemeralBox 不支持 agent、git、skills、browser 或公共 URL——这些功能请使用完整的 Box(它支持 schedule 和快照)
  • 对于 Agent 运行,run.exitCode 为 null;只有执行命令时才可用
  • 成功时,run.result 是标准输出;失败时则是标准错误——如果命令以退出码 0 结束但只写入标准错误,结果会是 "";请读取 run.stderr
  • maxRetries 仅针对暂时性故障——如果运行因 run.cancel() 或自身的 timeout 而中止,会立即失败,不会再启动第二次仍会计费的运行。超时会抛出 BoxError("Run timed out") / "Stream timed out",并将中止作为 cause 携带
  • Box.delete({ boxIds: [] }) 和 Box.deleteSnapshots({ snapshotIds: [] }) 会抛出异常——空列表绝不会被视为“删除全部”。如需删除全部快照,请不带 ID 调用 Box.deleteSnapshots()
  • files.download({ folder }) 接收 box 内部的路径;输出会保存到本地的 ./<basename>
  • 只有在提供 length 时,files.read() 才会进行切片——仅提供 { offset } 会读取整个文件,而 { length: 0 } 则不会读取任何内容
  • files.stat() 默认使用 lstat:符号链接会报告为 type: "symlink",除非传入 { follow: true }
  • 对目录调用 files.remove() 需要设置 { recursive: true };创建嵌套路径时,files.mkdir() 需要设置 { parents: true }
  • exec.session() 仅支持 Node(WebSocket 握手会携带身份验证标头),且句柄拥有该进程——调用 close() 或断开连接都会终止命令,且无法重新连接会话
  • exec.session({ tty: true }) 会将标准错误合并到标准输出,因此 PTY 会话不会触发 onStderr
  • box.browser 要求创建 box 时设置 browser: true
  • 没有 tab.run()——自主浏览器 Agent 已在 0.7.0 中移除。请自行循环调用 observe + act(action) + extract,将目标交给 box 内的 Agent,或通过 cdpUrl() 使用 Playwright 操控浏览器
  • tab.act(action)(重放 observe() 的结果)不消耗 token,也不需要模型提供方密钥;只有使用字符串调用 act(instruction) 才会计量
  • 除非创建 box 时设置了 keepAlive: true,否则调用 getInitCommand / setInitCommand / deleteInitCommand 会抛出异常
  • box.delete() 不可撤销——如果需要保留状态,请先创建快照
  • 对私有仓库和 PR 执行 Git 操作时,需要在 BoxConfig 中设置 git.token
  • Box.fromSnapshot() 会创建一个新 box,不会修改原 box,也不会从传入的配置转发 browser、skills 或 mcpServers
  • EphemeralBox 没有 updateNetworkPolicy——请在创建时设置 networkPolicy
  • responseSchema 和浏览器的 schema 需要安装 zod(对等依赖,v3 或 v4)
  • 所有 timeout 值的单位都是毫秒

评论

支持 Markdown